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:
Simon GruberandClaude Sonnet 5.5 committed 2026-11-05 13:39:49 +01:00
1 parent d15fe97584
commit e3f2f44765
10 files changed
+205 -85

No files matched your search

+2 -1
View File
@@ -24,7 +24,7 @@ from .services.account_service import (
upsert_user_order_tokens,
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.email_service import (
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 {
"email": email,
"is_admin": is_admin_email(email),
"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_old_confirmed": bool(profile and profile["pending_email_old_confirmed"]),
@@ -1,3 +1,4 @@
import os
from datetime import datetime, timezone
from urllib.parse import urlparse
@@ -38,6 +39,14 @@ def clean_user_email(raw_user_email: str | None) -> str | None:
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:
clean_url = clean_optional(value)
if not clean_url:
+2
View File
@@ -11,6 +11,8 @@ services:
- APP_ENV=production
- LOG_LEVEL=info
- 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_PORT=1025
restart: unless-stopped
+29 -3
View File
@@ -17,6 +17,7 @@ import {
theme as antdTheme,
} from "antd";
import Announcements from "./components/common/Announcements";
import SiteAdminView from "./views/SiteAdminView";
import TopNav from "./components/common/TopNav";
import WelcomeOnboardingModal from "./components/modals/WelcomeOnboardingModal";
import { navigateTo, parseRoute, subscribeToRouteChange } from "./lib/routing";
@@ -135,6 +136,10 @@ function RouteOutlet({ isLocked }: { isLocked: boolean }) {
return <AdminView orderId={route.orderId} />;
}
if (route.type === "siteAdmin") {
return <SiteAdminView section={route.section} />;
}
return (
<Result
status="404"
@@ -164,6 +169,7 @@ function AppContent({
isLocked,
userId,
userEmail,
isAdmin,
onUserEmailChange,
onLogout,
}: {
@@ -173,6 +179,7 @@ function AppContent({
isLocked: boolean;
userId: string;
userEmail: string;
isAdmin: boolean;
onUserEmailChange: (nextUserEmail: string) => void | Promise<string>;
onLogout: () => void;
}) {
@@ -203,6 +210,7 @@ function AppContent({
navigateTo("/");
}}
userEmail={userEmail}
isAdmin={isAdmin}
onUserEmailChange={onUserEmailChange}
onLogout={onLogout}
/>
@@ -255,6 +263,7 @@ export default function App() {
getInitialThemeMode(),
);
const [userEmail, setUserEmail] = useState("");
const [isAdmin, setIsAdmin] = useState(false);
const [announcements, setAnnouncements] = useState<AppAnnouncement[]>([]);
@@ -266,15 +275,17 @@ export default function App() {
useEffect(() => {
let mounted = true;
async function loadUserProfileEmail() {
async function loadUserProfileEmail(isRefresh: boolean) {
if (!userId) {
setUserEmail("");
setIsAdmin(false);
return;
}
try {
const profile = await apiService.profile.getMine();
if (mounted) {
setIsAdmin(!!profile?.is_admin);
setUserEmail(
String(profile?.email || "")
.trim()
@@ -282,16 +293,30 @@ export default function App() {
);
}
} catch (_error) {
if (mounted) {
// Keep the last known profile when a background refresh fails.
if (mounted && !isRefresh) {
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 () => {
mounted = false;
document.removeEventListener("visibilitychange", refreshOnReturn);
window.removeEventListener("focus", refreshOnReturn);
};
}, [userId]);
@@ -446,6 +471,7 @@ export default function App() {
isLocked={isAppLocked}
userId={userId}
userEmail={userEmail}
isAdmin={isAdmin}
onUserEmailChange={handleUserEmailChange}
onLogout={handleLogout}
/>
@@ -1,24 +1,42 @@
import React, { useEffect } from "react";
import { CheckOutlined, LogoutOutlined } from "@ant-design/icons";
import {
CheckOutlined,
LogoutOutlined,
ShoppingOutlined,
TeamOutlined,
} from "@ant-design/icons";
import {
Alert,
Button,
Divider,
Flex,
Form,
Input,
Popconfirm,
Space,
Tooltip,
Typography,
message,
} 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({
userEmail,
isAdmin,
onUserEmailChange,
onNavigate,
onLogout,
}: {
userEmail: string;
isAdmin: boolean;
onUserEmailChange: (nextUserEmail: string) => void | Promise<string>;
onNavigate: (path: string) => void;
onLogout: () => void;
}) {
const [form] = Form.useForm<{ email: string }>();
@@ -47,7 +65,6 @@ export default function AccountSettingsPopoverContent({
}
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 (
<Space direction="vertical" size={10} style={{ maxWidth: 340 }}>
<Typography.Title level={4}>Account Settings</Typography.Title>
<Space direction="vertical" size={0} style={{ width: 300 }}>
{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}>
<Form.Item
label="Email"
name="email"
rules={[
{ required: true, message: "Email cannot be empty." },
{ type: "email", message: "Enter a valid email address." },
]}
>
<Input placeholder="alex@example.com" maxLength={320} />
</Form.Item>
<Typography.Text
type="secondary"
strong
style={{ fontSize: 12, textTransform: "uppercase", letterSpacing: 0.5 }}
>
Account settings
</Typography.Text>
{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
type="info"
showIcon
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>
<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>
);
}
+22 -9
View File
@@ -1,7 +1,8 @@
import React, { useState } from "react";
import { UserOutlined } from "@ant-design/icons";
import { Button, Card, Flex, Popover, Space, Tooltip, Typography } from "antd";
import { CrownFilled, UserOutlined } from "@ant-design/icons";
import { Badge, Button, Card, Flex, Popover, Space, Tooltip, Typography } from "antd";
import AccountSettingsPopoverContent from "../account/AccountSettingsPopoverContent";
import { navigateTo } from "../../lib/routing";
import ThemeModeToggle from "../utils/ThemeModeToggle";
const { Text } = Typography;
@@ -11,6 +12,7 @@ export default function TopNav({
onThemeChange,
onHome,
userEmail,
isAdmin,
onUserEmailChange,
onLogout,
}: {
@@ -18,6 +20,7 @@ export default function TopNav({
onThemeChange: (mode: "light" | "dark") => void;
onHome: () => void;
userEmail: string;
isAdmin: boolean;
onUserEmailChange: (nextUserEmail: string) => void | Promise<string>;
onLogout: () => void;
}) {
@@ -51,7 +54,12 @@ export default function TopNav({
content={
<AccountSettingsPopoverContent
userEmail={userEmail}
isAdmin={isAdmin}
onUserEmailChange={onUserEmailChange}
onNavigate={(path) => {
setIsPopoverOpen(false);
navigateTo(path);
}}
onLogout={() => {
setIsPopoverOpen(false);
onLogout();
@@ -59,13 +67,18 @@ export default function TopNav({
/>
}
>
<Tooltip title="Account settings">
<Button
type="text"
shape="circle"
icon={<UserOutlined />}
aria-label="User settings"
/>
<Tooltip title={isAdmin ? "Account settings (admin)" : "Account settings"}>
<Badge
count={isAdmin ? <CrownFilled style={{ fontSize: 10, color: "#faad14" }} /> : 0}
offset={[-4, 4]}
>
<Button
type="text"
shape="circle"
icon={<UserOutlined />}
aria-label="User settings"
/>
</Badge>
</Tooltip>
</Popover>
</Space>
@@ -145,20 +145,18 @@ export default function NotificationsEditor({
</Space>
}
>
<Space.Compact style={{ width: "100%" }}>
<Input.TextArea
autoSize={{ minRows: 1, maxRows: 4 }}
disabled={!settings.enabled}
value={settings.text}
onChange={(inputEvent) => updateEvent(webhook, event.key, { text: inputEvent.target.value })}
/>
<Button
disabled={!settings.enabled || settings.text === event.defaultText}
onClick={() => updateEvent(webhook, event.key, { text: event.defaultText })}
>
Default
</Button>
</Space.Compact>
<Input.TextArea
autoSize={{ minRows: 1, maxRows: 4 }}
disabled={!settings.enabled}
value={settings.text}
// The built-in clear button resets to the default text.
allowClear={settings.text !== event.defaultText}
onChange={(inputEvent) =>
updateEvent(webhook, event.key, {
text: inputEvent.type === "click" ? event.defaultText : inputEvent.target.value,
})
}
/>
</Form.Item>
);
})}
+10 -1
View File
@@ -2,9 +2,18 @@ export type AppRoute =
| { type: "home" }
| { type: "create" }
| { type: "order"; orderId: string }
| { type: "admin"; orderId: string };
| { type: "admin"; orderId: string }
| { type: "siteAdmin"; section: "users" | "orders" };
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") {
return { type: "create" };
}
+1
View File
@@ -22,6 +22,7 @@ export type AccountLookupResult = {
export type UserProfile = {
email: string | null;
is_admin?: boolean;
email_confirmed?: boolean;
pending_email?: string | null;
pending_email_old_confirmed?: boolean;
+17
View File
@@ -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>
);
}