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:
1 parent
1cff743015
commit
ebf4ebdd2e
8 files changed
+173
-5
No files matched your search
+38
-1
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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", {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user