Add ADMIN_EMAILS admin users and rework account menu
- ADMIN_EMAILS env (compose) marks admins; /api/me/profile returns is_admin - Crown badge on the user icon for admins - Account popover as menu: admin links, email editor, logout - Placeholder site admin pages (/admin/users, /admin/orders) - Notification event text: built-in clear resets to default Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d15fe97584
commit
e3f2f44765
10 files changed
+205
-85
No files matched your search
@@ -24,7 +24,7 @@ from .services.account_service import (
|
|||||||
upsert_user_order_tokens,
|
upsert_user_order_tokens,
|
||||||
upsert_user_profile_email,
|
upsert_user_profile_email,
|
||||||
)
|
)
|
||||||
from .services.common_service import clean_image_url, clean_user_email, clean_user_id, now_iso
|
from .services.common_service import clean_image_url, clean_user_email, clean_user_id, is_admin_email, now_iso
|
||||||
from .services.config_service import load_config
|
from .services.config_service import load_config
|
||||||
from .services.email_service import (
|
from .services.email_service import (
|
||||||
send_email_change_new_email_confirmation,
|
send_email_change_new_email_confirmation,
|
||||||
@@ -261,6 +261,7 @@ def get_my_profile(user_id: str = Depends(get_existing_user_id)) -> dict[str, An
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"email": email,
|
"email": email,
|
||||||
|
"is_admin": is_admin_email(email),
|
||||||
"email_confirmed": bool(profile and profile["email_confirmed"]),
|
"email_confirmed": bool(profile and profile["email_confirmed"]),
|
||||||
"pending_email": str(profile["pending_email"]).strip().lower() if profile and profile["pending_email"] else None,
|
"pending_email": str(profile["pending_email"]).strip().lower() if profile and profile["pending_email"] else None,
|
||||||
"pending_email_old_confirmed": bool(profile and profile["pending_email_old_confirmed"]),
|
"pending_email_old_confirmed": bool(profile and profile["pending_email_old_confirmed"]),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import os
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
@@ -38,6 +39,14 @@ def clean_user_email(raw_user_email: str | None) -> str | None:
|
|||||||
return str(validated).lower()
|
return str(validated).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def is_admin_email(email: str | None) -> bool:
|
||||||
|
"""True if the email is listed (comma-separated, case-insensitive) in the ADMIN_EMAILS env variable."""
|
||||||
|
if not email:
|
||||||
|
return False
|
||||||
|
admin_emails = {entry.strip().lower() for entry in os.environ.get("ADMIN_EMAILS", "").split(",")}
|
||||||
|
return email.strip().lower() in admin_emails
|
||||||
|
|
||||||
|
|
||||||
def clean_image_url(value: str | None) -> str | None:
|
def clean_image_url(value: str | None) -> str | None:
|
||||||
clean_url = clean_optional(value)
|
clean_url = clean_optional(value)
|
||||||
if not clean_url:
|
if not clean_url:
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ services:
|
|||||||
- APP_ENV=production
|
- APP_ENV=production
|
||||||
- LOG_LEVEL=info
|
- LOG_LEVEL=info
|
||||||
- DB_PATH=/app/data
|
- DB_PATH=/app/data
|
||||||
|
# Comma-separated emails of admin users (shown with a badge in the UI)
|
||||||
|
- ADMIN_EMAILS=${ADMIN_EMAILS:-}
|
||||||
- SMTP_HOST=mailpit
|
- SMTP_HOST=mailpit
|
||||||
- SMTP_PORT=1025
|
- SMTP_PORT=1025
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
theme as antdTheme,
|
theme as antdTheme,
|
||||||
} from "antd";
|
} from "antd";
|
||||||
import Announcements from "./components/common/Announcements";
|
import Announcements from "./components/common/Announcements";
|
||||||
|
import SiteAdminView from "./views/SiteAdminView";
|
||||||
import TopNav from "./components/common/TopNav";
|
import TopNav from "./components/common/TopNav";
|
||||||
import WelcomeOnboardingModal from "./components/modals/WelcomeOnboardingModal";
|
import WelcomeOnboardingModal from "./components/modals/WelcomeOnboardingModal";
|
||||||
import { navigateTo, parseRoute, subscribeToRouteChange } from "./lib/routing";
|
import { navigateTo, parseRoute, subscribeToRouteChange } from "./lib/routing";
|
||||||
@@ -135,6 +136,10 @@ function RouteOutlet({ isLocked }: { isLocked: boolean }) {
|
|||||||
return <AdminView orderId={route.orderId} />;
|
return <AdminView orderId={route.orderId} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (route.type === "siteAdmin") {
|
||||||
|
return <SiteAdminView section={route.section} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Result
|
<Result
|
||||||
status="404"
|
status="404"
|
||||||
@@ -164,6 +169,7 @@ function AppContent({
|
|||||||
isLocked,
|
isLocked,
|
||||||
userId,
|
userId,
|
||||||
userEmail,
|
userEmail,
|
||||||
|
isAdmin,
|
||||||
onUserEmailChange,
|
onUserEmailChange,
|
||||||
onLogout,
|
onLogout,
|
||||||
}: {
|
}: {
|
||||||
@@ -173,6 +179,7 @@ function AppContent({
|
|||||||
isLocked: boolean;
|
isLocked: boolean;
|
||||||
userId: string;
|
userId: string;
|
||||||
userEmail: string;
|
userEmail: string;
|
||||||
|
isAdmin: boolean;
|
||||||
onUserEmailChange: (nextUserEmail: string) => void | Promise<string>;
|
onUserEmailChange: (nextUserEmail: string) => void | Promise<string>;
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -203,6 +210,7 @@ function AppContent({
|
|||||||
navigateTo("/");
|
navigateTo("/");
|
||||||
}}
|
}}
|
||||||
userEmail={userEmail}
|
userEmail={userEmail}
|
||||||
|
isAdmin={isAdmin}
|
||||||
onUserEmailChange={onUserEmailChange}
|
onUserEmailChange={onUserEmailChange}
|
||||||
onLogout={onLogout}
|
onLogout={onLogout}
|
||||||
/>
|
/>
|
||||||
@@ -255,6 +263,7 @@ export default function App() {
|
|||||||
getInitialThemeMode(),
|
getInitialThemeMode(),
|
||||||
);
|
);
|
||||||
const [userEmail, setUserEmail] = useState("");
|
const [userEmail, setUserEmail] = useState("");
|
||||||
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
|
|
||||||
const [announcements, setAnnouncements] = useState<AppAnnouncement[]>([]);
|
const [announcements, setAnnouncements] = useState<AppAnnouncement[]>([]);
|
||||||
|
|
||||||
@@ -266,15 +275,17 @@ export default function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
|
|
||||||
async function loadUserProfileEmail() {
|
async function loadUserProfileEmail(isRefresh: boolean) {
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
setUserEmail("");
|
setUserEmail("");
|
||||||
|
setIsAdmin(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const profile = await apiService.profile.getMine();
|
const profile = await apiService.profile.getMine();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
setIsAdmin(!!profile?.is_admin);
|
||||||
setUserEmail(
|
setUserEmail(
|
||||||
String(profile?.email || "")
|
String(profile?.email || "")
|
||||||
.trim()
|
.trim()
|
||||||
@@ -282,16 +293,30 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
if (mounted) {
|
// Keep the last known profile when a background refresh fails.
|
||||||
|
if (mounted && !isRefresh) {
|
||||||
setUserEmail("");
|
setUserEmail("");
|
||||||
|
setIsAdmin(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadUserProfileEmail();
|
// Email changes are confirmed via a link (usually in another tab), so
|
||||||
|
// re-read the profile whenever the user returns to this tab.
|
||||||
|
const refreshOnReturn = () => {
|
||||||
|
if (document.visibilityState === "visible") {
|
||||||
|
loadUserProfileEmail(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadUserProfileEmail(false);
|
||||||
|
document.addEventListener("visibilitychange", refreshOnReturn);
|
||||||
|
window.addEventListener("focus", refreshOnReturn);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
mounted = false;
|
mounted = false;
|
||||||
|
document.removeEventListener("visibilitychange", refreshOnReturn);
|
||||||
|
window.removeEventListener("focus", refreshOnReturn);
|
||||||
};
|
};
|
||||||
}, [userId]);
|
}, [userId]);
|
||||||
|
|
||||||
@@ -446,6 +471,7 @@ export default function App() {
|
|||||||
isLocked={isAppLocked}
|
isLocked={isAppLocked}
|
||||||
userId={userId}
|
userId={userId}
|
||||||
userEmail={userEmail}
|
userEmail={userEmail}
|
||||||
|
isAdmin={isAdmin}
|
||||||
onUserEmailChange={handleUserEmailChange}
|
onUserEmailChange={handleUserEmailChange}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,24 +1,42 @@
|
|||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
import { CheckOutlined, LogoutOutlined } from "@ant-design/icons";
|
import {
|
||||||
|
CheckOutlined,
|
||||||
|
LogoutOutlined,
|
||||||
|
ShoppingOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
|
Divider,
|
||||||
Flex,
|
Flex,
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
Popconfirm,
|
Popconfirm,
|
||||||
Space,
|
Space,
|
||||||
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
message,
|
message,
|
||||||
} from "antd";
|
} from "antd";
|
||||||
|
|
||||||
|
const ADMIN_LINKS = [
|
||||||
|
{ path: "/admin/users", label: "User Management", icon: <TeamOutlined /> },
|
||||||
|
{ path: "/admin/orders", label: "Order Administration", icon: <ShoppingOutlined /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
const menuEntryStyle: React.CSSProperties = { textAlign: "left", justifyContent: "flex-start" };
|
||||||
|
|
||||||
export default function AccountSettingsPopoverContent({
|
export default function AccountSettingsPopoverContent({
|
||||||
userEmail,
|
userEmail,
|
||||||
|
isAdmin,
|
||||||
onUserEmailChange,
|
onUserEmailChange,
|
||||||
|
onNavigate,
|
||||||
onLogout,
|
onLogout,
|
||||||
}: {
|
}: {
|
||||||
userEmail: string;
|
userEmail: string;
|
||||||
|
isAdmin: boolean;
|
||||||
onUserEmailChange: (nextUserEmail: string) => void | Promise<string>;
|
onUserEmailChange: (nextUserEmail: string) => void | Promise<string>;
|
||||||
|
onNavigate: (path: string) => void;
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [form] = Form.useForm<{ email: string }>();
|
const [form] = Form.useForm<{ email: string }>();
|
||||||
@@ -47,7 +65,6 @@ export default function AccountSettingsPopoverContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
message.error(error?.message || "Could not save account settings.");
|
message.error(error?.message || "Could not save account settings.");
|
||||||
// Validation errors are shown by Form.Item rules.
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -57,70 +74,97 @@ export default function AccountSettingsPopoverContent({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Space direction="vertical" size={10} style={{ maxWidth: 340 }}>
|
<Space direction="vertical" size={0} style={{ width: 300 }}>
|
||||||
<Typography.Title level={4}>Account Settings</Typography.Title>
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
{ADMIN_LINKS.map((link) => (
|
||||||
|
<Button
|
||||||
|
key={link.path}
|
||||||
|
type="text"
|
||||||
|
block
|
||||||
|
icon={link.icon}
|
||||||
|
style={menuEntryStyle}
|
||||||
|
onClick={() => onNavigate(link.path)}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
<Divider style={{ margin: "8px 0" }} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Form form={form} layout="vertical" onFinish={saveAccountSettings}>
|
<Typography.Text
|
||||||
<Form.Item
|
type="secondary"
|
||||||
label="Email"
|
strong
|
||||||
name="email"
|
style={{ fontSize: 12, textTransform: "uppercase", letterSpacing: 0.5 }}
|
||||||
rules={[
|
>
|
||||||
{ required: true, message: "Email cannot be empty." },
|
Account settings
|
||||||
{ type: "email", message: "Enter a valid email address." },
|
</Typography.Text>
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Input placeholder="alex@example.com" maxLength={320} />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
{watchedEmail.trim().toLowerCase() !== userEmail && (
|
<Form
|
||||||
|
form={form}
|
||||||
|
initialValues={{ email: userEmail }}
|
||||||
|
onFinish={saveAccountSettings}
|
||||||
|
style={{ width: "100%", marginTop: 6 }}
|
||||||
|
>
|
||||||
|
<Flex gap={12} align="flex-start">
|
||||||
|
<Form.Item
|
||||||
|
name="email"
|
||||||
|
style={{ flex: 1, marginBottom: 0 }}
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: "Email cannot be empty." },
|
||||||
|
{ type: "email", message: "Enter a valid email address." },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder={userEmail || "alex@example.com"}
|
||||||
|
maxLength={320}
|
||||||
|
allowClear={hasChanges}
|
||||||
|
onChange={(event) => {
|
||||||
|
// The built-in clear button fires a "click" event; typing fires "change".
|
||||||
|
if (event.type === "click") {
|
||||||
|
discardAccountChanges();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Tooltip title="Save">
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<CheckOutlined />}
|
||||||
|
htmlType="submit"
|
||||||
|
disabled={!hasChanges}
|
||||||
|
aria-label="Save email"
|
||||||
|
// Theme buttons are 40px tall; match the default 32px input.
|
||||||
|
style={{ borderRadius: 10, flexShrink: 0, height: 32, width: 32 }}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{hasChanges && (
|
||||||
<Alert
|
<Alert
|
||||||
type="info"
|
type="info"
|
||||||
showIcon
|
showIcon
|
||||||
message="A confirmation email will be sent to the new address before the change is applied."
|
message="A confirmation email will be sent to the new address before the change is applied."
|
||||||
style={{ marginBottom: 12 }}
|
style={{ marginTop: 8 }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Flex gap={8}>
|
|
||||||
<Button
|
|
||||||
size="large"
|
|
||||||
type="primary"
|
|
||||||
icon={<CheckOutlined />}
|
|
||||||
htmlType="submit"
|
|
||||||
block
|
|
||||||
disabled={!hasChanges}
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="large"
|
|
||||||
block
|
|
||||||
onClick={discardAccountChanges}
|
|
||||||
disabled={!hasChanges}
|
|
||||||
>
|
|
||||||
Discard
|
|
||||||
</Button>
|
|
||||||
</Flex>
|
|
||||||
|
|
||||||
<Popconfirm
|
|
||||||
title="Log out?"
|
|
||||||
description="This clears your local user token on this device."
|
|
||||||
okText="Log out"
|
|
||||||
cancelText="Cancel"
|
|
||||||
okButtonProps={{ danger: true }}
|
|
||||||
onConfirm={onLogout}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
size="large"
|
|
||||||
icon={<LogoutOutlined />}
|
|
||||||
danger
|
|
||||||
block
|
|
||||||
style={{ marginTop: 8 }}
|
|
||||||
>
|
|
||||||
Logout
|
|
||||||
</Button>
|
|
||||||
</Popconfirm>
|
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
|
<Divider style={{ margin: "8px 0" }} />
|
||||||
|
|
||||||
|
<Popconfirm
|
||||||
|
title="Log out?"
|
||||||
|
description="This clears your local user token on this device."
|
||||||
|
okText="Log out"
|
||||||
|
cancelText="Cancel"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
onConfirm={onLogout}
|
||||||
|
>
|
||||||
|
<Button type="text" danger block icon={<LogoutOutlined />} style={menuEntryStyle}>
|
||||||
|
Logout
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
</Space>
|
</Space>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { UserOutlined } from "@ant-design/icons";
|
import { CrownFilled, UserOutlined } from "@ant-design/icons";
|
||||||
import { Button, Card, Flex, Popover, Space, Tooltip, Typography } from "antd";
|
import { Badge, Button, Card, Flex, Popover, Space, Tooltip, Typography } from "antd";
|
||||||
import AccountSettingsPopoverContent from "../account/AccountSettingsPopoverContent";
|
import AccountSettingsPopoverContent from "../account/AccountSettingsPopoverContent";
|
||||||
|
import { navigateTo } from "../../lib/routing";
|
||||||
import ThemeModeToggle from "../utils/ThemeModeToggle";
|
import ThemeModeToggle from "../utils/ThemeModeToggle";
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
@@ -11,6 +12,7 @@ export default function TopNav({
|
|||||||
onThemeChange,
|
onThemeChange,
|
||||||
onHome,
|
onHome,
|
||||||
userEmail,
|
userEmail,
|
||||||
|
isAdmin,
|
||||||
onUserEmailChange,
|
onUserEmailChange,
|
||||||
onLogout,
|
onLogout,
|
||||||
}: {
|
}: {
|
||||||
@@ -18,6 +20,7 @@ export default function TopNav({
|
|||||||
onThemeChange: (mode: "light" | "dark") => void;
|
onThemeChange: (mode: "light" | "dark") => void;
|
||||||
onHome: () => void;
|
onHome: () => void;
|
||||||
userEmail: string;
|
userEmail: string;
|
||||||
|
isAdmin: boolean;
|
||||||
onUserEmailChange: (nextUserEmail: string) => void | Promise<string>;
|
onUserEmailChange: (nextUserEmail: string) => void | Promise<string>;
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -51,7 +54,12 @@ export default function TopNav({
|
|||||||
content={
|
content={
|
||||||
<AccountSettingsPopoverContent
|
<AccountSettingsPopoverContent
|
||||||
userEmail={userEmail}
|
userEmail={userEmail}
|
||||||
|
isAdmin={isAdmin}
|
||||||
onUserEmailChange={onUserEmailChange}
|
onUserEmailChange={onUserEmailChange}
|
||||||
|
onNavigate={(path) => {
|
||||||
|
setIsPopoverOpen(false);
|
||||||
|
navigateTo(path);
|
||||||
|
}}
|
||||||
onLogout={() => {
|
onLogout={() => {
|
||||||
setIsPopoverOpen(false);
|
setIsPopoverOpen(false);
|
||||||
onLogout();
|
onLogout();
|
||||||
@@ -59,13 +67,18 @@ export default function TopNav({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Tooltip title="Account settings">
|
<Tooltip title={isAdmin ? "Account settings (admin)" : "Account settings"}>
|
||||||
<Button
|
<Badge
|
||||||
type="text"
|
count={isAdmin ? <CrownFilled style={{ fontSize: 10, color: "#faad14" }} /> : 0}
|
||||||
shape="circle"
|
offset={[-4, 4]}
|
||||||
icon={<UserOutlined />}
|
>
|
||||||
aria-label="User settings"
|
<Button
|
||||||
/>
|
type="text"
|
||||||
|
shape="circle"
|
||||||
|
icon={<UserOutlined />}
|
||||||
|
aria-label="User settings"
|
||||||
|
/>
|
||||||
|
</Badge>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Popover>
|
</Popover>
|
||||||
</Space>
|
</Space>
|
||||||
|
|||||||
@@ -145,20 +145,18 @@ export default function NotificationsEditor({
|
|||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Space.Compact style={{ width: "100%" }}>
|
<Input.TextArea
|
||||||
<Input.TextArea
|
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||||
autoSize={{ minRows: 1, maxRows: 4 }}
|
disabled={!settings.enabled}
|
||||||
disabled={!settings.enabled}
|
value={settings.text}
|
||||||
value={settings.text}
|
// The built-in clear button resets to the default text.
|
||||||
onChange={(inputEvent) => updateEvent(webhook, event.key, { text: inputEvent.target.value })}
|
allowClear={settings.text !== event.defaultText}
|
||||||
/>
|
onChange={(inputEvent) =>
|
||||||
<Button
|
updateEvent(webhook, event.key, {
|
||||||
disabled={!settings.enabled || settings.text === event.defaultText}
|
text: inputEvent.type === "click" ? event.defaultText : inputEvent.target.value,
|
||||||
onClick={() => updateEvent(webhook, event.key, { text: event.defaultText })}
|
})
|
||||||
>
|
}
|
||||||
Default
|
/>
|
||||||
</Button>
|
|
||||||
</Space.Compact>
|
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -2,9 +2,18 @@ export type AppRoute =
|
|||||||
| { type: "home" }
|
| { type: "home" }
|
||||||
| { type: "create" }
|
| { type: "create" }
|
||||||
| { type: "order"; orderId: string }
|
| { type: "order"; orderId: string }
|
||||||
| { type: "admin"; orderId: string };
|
| { type: "admin"; orderId: string }
|
||||||
|
| { type: "siteAdmin"; section: "users" | "orders" };
|
||||||
|
|
||||||
export function parseRoute(pathname: string): AppRoute {
|
export function parseRoute(pathname: string): AppRoute {
|
||||||
|
if (pathname === "/admin/users") {
|
||||||
|
return { type: "siteAdmin", section: "users" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === "/admin/orders") {
|
||||||
|
return { type: "siteAdmin", section: "orders" };
|
||||||
|
}
|
||||||
|
|
||||||
if (pathname === "/create") {
|
if (pathname === "/create") {
|
||||||
return { type: "create" };
|
return { type: "create" };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export type AccountLookupResult = {
|
|||||||
|
|
||||||
export type UserProfile = {
|
export type UserProfile = {
|
||||||
email: string | null;
|
email: string | null;
|
||||||
|
is_admin?: boolean;
|
||||||
email_confirmed?: boolean;
|
email_confirmed?: boolean;
|
||||||
pending_email?: string | null;
|
pending_email?: string | null;
|
||||||
pending_email_old_confirmed?: boolean;
|
pending_email_old_confirmed?: boolean;
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Card, Empty, Typography } from "antd";
|
||||||
|
|
||||||
|
const TITLES = {
|
||||||
|
users: "User Management",
|
||||||
|
orders: "Order Administration",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Placeholder for the site-wide admin pages. */
|
||||||
|
export default function SiteAdminView({ section }: { section: keyof typeof TITLES }) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<Typography.Title level={3}>{TITLES[section]}</Typography.Title>
|
||||||
|
<Empty description="Coming soon" />
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user