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_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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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