Add admin General page with email settings and test mail; reuse unconfirmed profile on register retry

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
simandClaude Sonnet 5.5 committed 2026-11-06 13:27:19 +01:00
1 parent 1cff743015
commit ebf4ebdd2e
8 files changed
+173 -5

No files matched your search

+38 -1
View File
@@ -40,6 +40,7 @@ from .services.backup_service import start_backup_scheduler
from .services.config_service import get_email_settings, load_config
from .services.email_service import (
send_email_change_new_email_confirmation,
send_email_message,
send_migration_email,
send_registration_email,
send_user_id_change_email,
@@ -227,6 +228,10 @@ class AccountLookupResponse(BaseModel):
exists: bool
class AdminTestEmailRequest(BaseModel):
to_email: str = Field(min_length=3, max_length=320)
class AdminCreateUserRequest(BaseModel):
email: str = Field(min_length=3, max_length=320)
@@ -328,7 +333,16 @@ def register_account(payload: RegisterAccountRequest) -> dict[str, Any]:
raise HTTPException(status_code=422, detail="Email is required")
with get_connection() as conn:
new_user_id = choose_new_user_id(conn, None)
# Reuse an unconfirmed profile so retries (e.g. after a failed send) don't pile up duplicates
existing = conn.execute(
"""
SELECT user_id FROM user_profiles
WHERE lower(trim(email)) = ? AND email_confirmed = 0
ORDER BY updated_at DESC LIMIT 1
""",
(email,),
).fetchone()
new_user_id = str(existing["user_id"]) if existing else choose_new_user_id(conn, None)
upsert_user_profile_email(conn, new_user_id, email)
conn.execute(
"""
@@ -616,6 +630,29 @@ def confirm_account_action(token: str) -> dict[str, Any]:
raise HTTPException(status_code=400, detail="Unsupported confirmation action")
@app.get("/api/admin/email")
def admin_email_settings(_admin_id: str = Depends(require_site_admin)) -> dict[str, Any]:
# SMTP password is never exposed
settings = get_email_settings()
return {k: v for k, v in settings.items() if k != "smtp_password"}
@app.post("/api/admin/email/test")
def admin_send_test_email(
payload: AdminTestEmailRequest,
_admin_id: str = Depends(require_site_admin),
) -> dict[str, str]:
to_email = clean_user_email(payload.to_email)
if not to_email or "@" not in to_email:
raise HTTPException(status_code=422, detail="A valid email address is required")
send_email_message(
to_email=to_email,
subject="Test email",
body="This is a test email from Lunchtime. Your email settings work.",
)
return {"status": "sent", "message": f"Test email sent to {to_email}"}
@app.get("/api/admin/users")
def admin_list_users(admin_id: str = Depends(require_site_admin)) -> dict[str, Any]:
with get_connection() as conn:
+41
View File
@@ -0,0 +1,41 @@
"""Run from src/backend: python -m unittest discover tests"""
import os
import tempfile
import unittest
from unittest import mock
os.environ["DB_PATH"] = tempfile.mkdtemp() # app.db creates its data dir on import
from fastapi import HTTPException
from app import main
from app.db import get_connection, init_db
def profiles(email):
with get_connection() as conn:
return conn.execute("SELECT user_id FROM user_profiles WHERE email = ?", (email,)).fetchall()
class RegisterTest(unittest.TestCase):
def setUp(self):
init_db()
def test_retry_after_failed_send_reuses_unconfirmed_profile(self):
email = "retry@example.com"
fail = HTTPException(status_code=502, detail="Failed to send email")
with mock.patch.object(main, "send_registration_email", side_effect=fail):
for _ in range(2):
with self.assertRaises(HTTPException):
main.register_account(main.RegisterAccountRequest(email=email))
with mock.patch.object(main, "send_registration_email"):
main.register_account(main.RegisterAccountRequest(email=email))
self.assertEqual(len(profiles(email)), 1)
# migrating by email must not hit "multiple accounts"
with mock.patch.object(main, "send_migration_email"):
main.request_account_migration(main.RequestMigrationByEmailRequest(email=email))
if __name__ == "__main__":
unittest.main()
@@ -22,6 +22,7 @@ import {
} from "antd";
export const ADMIN_LINKS = [
{ path: "/admin/general", label: "General", icon: <SettingOutlined /> },
{ path: "/admin/users", label: "User Management", icon: <TeamOutlined /> },
{ path: "/admin/orders", label: "Order Administration", icon: <ShoppingOutlined /> },
];
@@ -0,0 +1,67 @@
import React, { useState } from "react";
import { SendOutlined } from "@ant-design/icons";
import { Button, Card, Descriptions, Form, Input, Space, Tag, Typography, message } from "antd";
import { apiService } from "../../lib/services";
import { useApiRequest } from "../../hooks/useApiRequest";
import AsyncContent from "../utils/AsyncContent";
const loadSettings = () => apiService.admin.getEmailSettings();
/** Read-only view of the SMTP settings (configured in config.yaml, password never sent) plus a test mail. */
export default function EmailManagement() {
const { data: settings, loading, error, run } = useApiRequest(loadSettings);
const [form] = Form.useForm<{ email: string }>();
const [sending, setSending] = useState(false);
const sendTest = async ({ email }: { email: string }) => {
setSending(true);
try {
const result = await apiService.admin.sendTestEmail(email.trim());
message.success(result.message);
} catch (error: any) {
message.error(error?.message || "Could not send the test email.");
} finally {
setSending(false);
}
};
return (
<Card title="Email">
<AsyncContent loading={loading && !settings} error={error} onRetry={run}>
{settings && (
<Space direction="vertical" size="large" style={{ width: "100%" }}>
<Descriptions size="small" column={1} bordered>
<Descriptions.Item label="Delivery">
<Tag color={settings.enabled ? "green" : "red"}>{settings.enabled ? "Enabled" : "Disabled"}</Tag>
</Descriptions.Item>
<Descriptions.Item label="SMTP server">
{settings.smtp_host || "–"}:{settings.smtp_port}
</Descriptions.Item>
<Descriptions.Item label="Username">{settings.smtp_username || "–"}</Descriptions.Item>
<Descriptions.Item label="From address">{settings.from_address || "–"}</Descriptions.Item>
<Descriptions.Item label="Encryption">
{settings.use_ssl ? "SSL" : settings.use_tls ? "STARTTLS" : "None"}
</Descriptions.Item>
<Descriptions.Item label="Confirmation link base">{settings.confirmation_link_base || "–"}</Descriptions.Item>
</Descriptions>
<Typography.Text type="secondary">
Settings are part of the server configuration and cannot be changed here.
</Typography.Text>
<Form form={form} layout="inline" onFinish={sendTest} style={{ rowGap: 8 }}>
<Form.Item
name="email"
rules={[{ required: true, type: "email", message: "Enter a valid email address" }]}
style={{ flex: "1 1 240px" }}
>
<Input placeholder="Send test email to…" />
</Form.Item>
<Button type="primary" htmlType="submit" icon={<SendOutlined />} loading={sending}>
Send test email
</Button>
</Form>
</Space>
)}
</AsyncContent>
</Card>
);
}
+5 -1
View File
@@ -3,7 +3,7 @@ export type AppRoute =
| { type: "create" }
| { type: "order"; orderId: string }
| { type: "admin"; orderId: string }
| { type: "siteAdmin"; section: "users" | "orders" }
| { type: "siteAdmin"; section: "general" | "users" | "orders" }
| { type: "stats"; section: StatsSection };
export type StatsSection = "orderee" | "manager" | "admin";
@@ -18,6 +18,10 @@ export function parseRoute(pathname: string): AppRoute {
return { type: "stats", section: "orderee" };
}
if (pathname === "/admin/general") {
return { type: "siteAdmin", section: "general" };
}
if (pathname === "/admin/users") {
return { type: "siteAdmin", section: "users" };
}
@@ -1,7 +1,13 @@
import { apiFetch } from "../api";
import type { AdminOrder, AdminUser, RegisterAccountResult, CreatedRegistrationLink, RegistrationLinks } from "../types";
import type { AdminEmailSettings, AdminOrder, AdminUser, RegisterAccountResult, CreatedRegistrationLink, RegistrationLinks } from "../types";
export const adminService = {
getEmailSettings: () => apiFetch<AdminEmailSettings>("/api/admin/email"),
sendTestEmail: (toEmail: string) =>
apiFetch<{ status: string; message: string }>("/api/admin/email/test", {
method: "POST",
body: JSON.stringify({ to_email: toEmail }),
}),
listUsers: () => apiFetch<{ users: AdminUser[] }>("/api/admin/users"),
registerUser: (email: string) =>
apiFetch<CreatedRegistrationLink>("/api/admin/users", {
+11
View File
@@ -469,3 +469,14 @@ export type SiteStats = {
items: ItemCount[];
storage: Record<string, StorageUsage>;
};
export type AdminEmailSettings = {
enabled: boolean;
smtp_host: string;
smtp_port: number;
smtp_username: string;
from_address: string;
use_tls: boolean;
use_ssl: boolean;
confirmation_link_base: string;
};
+3 -2
View File
@@ -1,12 +1,13 @@
import React from "react";
import { Menu } from "antd";
import { ADMIN_LINKS } from "../components/account/AccountSettingsPopoverContent";
import EmailManagement from "../components/admin/EmailManagement";
import OrderManagement from "../components/admin/OrderManagement";
import UserManagement from "../components/admin/UserManagement";
import { navigateTo } from "../lib/routing";
/** Site-wide admin pages. Access is enforced by the backend (403 for non-admins); this only handles the UI. */
export default function SiteAdminView({ section }: { section: "users" | "orders" }) {
export default function SiteAdminView({ section }: { section: "general" | "users" | "orders" }) {
return (
<div style={{ display: "flex", flexWrap: "wrap", gap: 16, alignItems: "flex-start" }}>
<Menu
@@ -17,7 +18,7 @@ export default function SiteAdminView({ section }: { section: "users" | "orders"
style={{ flex: "0 0 220px", borderRadius: 12 }}
/>
<div style={{ flex: "1 1 400px", minWidth: 0 }}>
{section === "users" ? <UserManagement /> : <OrderManagement />}
{section === "general" ? <EmailManagement /> : section === "users" ? <UserManagement /> : <OrderManagement />}
</div>
</div>
);