From ebf4ebdd2e4b94e08510dfc5859158cb58b3264c Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 6 Nov 2026 13:27:19 +0100 Subject: [PATCH] Add admin General page with email settings and test mail; reuse unconfirmed profile on register retry Co-Authored-By: Claude Sonnet 5.5 --- src/backend/app/main.py | 39 ++++++++++- src/backend/tests/test_register.py | 41 ++++++++++++ .../account/AccountSettingsPopoverContent.tsx | 1 + .../src/components/admin/EmailManagement.tsx | 67 +++++++++++++++++++ src/frontend/src/lib/routing.ts | 6 +- src/frontend/src/lib/services/adminService.ts | 8 ++- src/frontend/src/lib/types.ts | 11 +++ src/frontend/src/views/SiteAdminView.tsx | 5 +- 8 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 src/backend/tests/test_register.py create mode 100644 src/frontend/src/components/admin/EmailManagement.tsx diff --git a/src/backend/app/main.py b/src/backend/app/main.py index 4577e53..3e16a26 100644 --- a/src/backend/app/main.py +++ b/src/backend/app/main.py @@ -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: diff --git a/src/backend/tests/test_register.py b/src/backend/tests/test_register.py new file mode 100644 index 0000000..3804e1a --- /dev/null +++ b/src/backend/tests/test_register.py @@ -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() diff --git a/src/frontend/src/components/account/AccountSettingsPopoverContent.tsx b/src/frontend/src/components/account/AccountSettingsPopoverContent.tsx index 01adf4e..df738c7 100644 --- a/src/frontend/src/components/account/AccountSettingsPopoverContent.tsx +++ b/src/frontend/src/components/account/AccountSettingsPopoverContent.tsx @@ -22,6 +22,7 @@ import { } from "antd"; export const ADMIN_LINKS = [ + { path: "/admin/general", label: "General", icon: }, { path: "/admin/users", label: "User Management", icon: }, { path: "/admin/orders", label: "Order Administration", icon: }, ]; diff --git a/src/frontend/src/components/admin/EmailManagement.tsx b/src/frontend/src/components/admin/EmailManagement.tsx new file mode 100644 index 0000000..1c2a9c6 --- /dev/null +++ b/src/frontend/src/components/admin/EmailManagement.tsx @@ -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 ( + + + {settings && ( + + + + {settings.enabled ? "Enabled" : "Disabled"} + + + {settings.smtp_host || "–"}:{settings.smtp_port} + + {settings.smtp_username || "–"} + {settings.from_address || "–"} + + {settings.use_ssl ? "SSL" : settings.use_tls ? "STARTTLS" : "None"} + + {settings.confirmation_link_base || "–"} + + + Settings are part of the server configuration and cannot be changed here. + +
+ + + + +
+
+ )} +
+
+ ); +} diff --git a/src/frontend/src/lib/routing.ts b/src/frontend/src/lib/routing.ts index 1d1dcac..22c61fe 100644 --- a/src/frontend/src/lib/routing.ts +++ b/src/frontend/src/lib/routing.ts @@ -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" }; } diff --git a/src/frontend/src/lib/services/adminService.ts b/src/frontend/src/lib/services/adminService.ts index 8ba4699..5fb8f41 100644 --- a/src/frontend/src/lib/services/adminService.ts +++ b/src/frontend/src/lib/services/adminService.ts @@ -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("/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("/api/admin/users", { diff --git a/src/frontend/src/lib/types.ts b/src/frontend/src/lib/types.ts index a499287..e178a2e 100644 --- a/src/frontend/src/lib/types.ts +++ b/src/frontend/src/lib/types.ts @@ -469,3 +469,14 @@ export type SiteStats = { items: ItemCount[]; storage: Record; }; + +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; +}; diff --git a/src/frontend/src/views/SiteAdminView.tsx b/src/frontend/src/views/SiteAdminView.tsx index b3c0c05..02ed98c 100644 --- a/src/frontend/src/views/SiteAdminView.tsx +++ b/src/frontend/src/views/SiteAdminView.tsx @@ -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 (
- {section === "users" ? : } + {section === "general" ? : section === "users" ? : }
);