From cb4e610ea130edc96228dfb71cbcb202a6c9be1d Mon Sep 17 00:00:00 2001 From: Simon Gruber Date: Thu, 5 Nov 2026 16:40:50 +0100 Subject: [PATCH] Add admin page navigation, user search and registration link resend Co-Authored-By: Claude Sonnet 5.5 --- src/backend/app/main.py | 10 ++++ src/backend/app/services/admin_service.py | 17 +++++++ .../account/AccountSettingsPopoverContent.tsx | 36 ++++++-------- .../src/components/admin/UserManagement.tsx | 47 +++++++++++++++++-- src/frontend/src/lib/services/adminService.ts | 2 + src/frontend/src/views/SiteAdminView.tsx | 18 ++++++- 6 files changed, 103 insertions(+), 27 deletions(-) diff --git a/src/backend/app/main.py b/src/backend/app/main.py index a2f1322..9d42b1d 100644 --- a/src/backend/app/main.py +++ b/src/backend/app/main.py @@ -13,6 +13,7 @@ from .services.admin_service import ( create_registration_link, delete_registration_link, delete_user, + get_resendable_link, get_resendable_user, list_orders, list_registration_links, @@ -656,6 +657,15 @@ def admin_list_registration_links(_admin_id: str = Depends(require_site_admin)) return list_registration_links(conn, get_email_settings()["confirmation_link_base"]) +@app.post("/api/admin/registration-links/{link_id}/resend") +def admin_resend_registration_link(link_id: int, _admin_id: str = Depends(require_site_admin)) -> dict[str, Any]: + with get_connection() as conn: + user_id, email, token = get_resendable_link(conn, link_id) + + send_registration_email(user_id, email, token) + return {"status": "sent", "message": f"Registration email sent to {email}"} + + @app.delete("/api/admin/registration-links/{link_id}") def admin_delete_registration_link(link_id: int, _admin_id: str = Depends(require_site_admin)) -> dict[str, bool]: with get_connection() as conn: diff --git a/src/backend/app/services/admin_service.py b/src/backend/app/services/admin_service.py index c869f2d..223e9ec 100644 --- a/src/backend/app/services/admin_service.py +++ b/src/backend/app/services/admin_service.py @@ -126,6 +126,23 @@ def delete_registration_link(conn: sqlite3.Connection, link_id: int) -> None: raise HTTPException(status_code=404, detail="Registration link not found") +def get_resendable_link(conn: sqlite3.Connection, link_id: int) -> tuple[str, str, str]: + """(user_id, email, token) of a registration link that is still unused and unexpired.""" + row = conn.execute( + """ + SELECT user_id, email, token, expires_at, consumed_at + FROM account_confirmation_tokens + WHERE rowid = ? AND action = ? + """, + (link_id, REGISTER_ACTION), + ).fetchone() + if not row: + raise HTTPException(status_code=404, detail="Registration link not found") + if row["consumed_at"] or row["expires_at"] <= now_iso(): + raise HTTPException(status_code=409, detail="Registration link is already used or expired") + return str(row["user_id"]), str(row["email"]), str(row["token"]) + + def get_resendable_user(conn: sqlite3.Connection, profile_id: int) -> tuple[str, str]: """(user_id, email) of a still unconfirmed user; only those can be sent a registration link.""" row = conn.execute( diff --git a/src/frontend/src/components/account/AccountSettingsPopoverContent.tsx b/src/frontend/src/components/account/AccountSettingsPopoverContent.tsx index 476fe20..5d657a7 100644 --- a/src/frontend/src/components/account/AccountSettingsPopoverContent.tsx +++ b/src/frontend/src/components/account/AccountSettingsPopoverContent.tsx @@ -2,6 +2,7 @@ import React, { useEffect } from "react"; import { CheckOutlined, LogoutOutlined, + SettingOutlined, ShoppingOutlined, TeamOutlined, } from "@ant-design/icons"; @@ -19,7 +20,7 @@ import { message, } from "antd"; -const ADMIN_LINKS = [ +export const ADMIN_LINKS = [ { path: "/admin/users", label: "User Management", icon: }, { path: "/admin/orders", label: "Order Administration", icon: }, ]; @@ -77,27 +78,6 @@ export default function AccountSettingsPopoverContent({ return ( - {isAdmin && ( - <> - - Administration - - {ADMIN_LINKS.map((link) => ( - - ))} - - - )} - Account settings @@ -154,6 +134,18 @@ export default function AccountSettingsPopoverContent({ + {isAdmin && ( + + )} + vo ); } +function ResendLinkButton({ row }: { row: LinkRow }) { + const resend = async () => { + try { + message.success((await apiService.admin.resendRegistrationLink(row.id)).message); + } catch (error: any) { + message.error(error?.message || "Could not send the registration email."); + } + }; + + const usable = row.status === "active"; + return ( + + + + + + ); +} + function DeleteLinkButton({ id, onDeleted }: { id: number; onDeleted: () => void }) { const remove = async () => { try { @@ -294,6 +315,7 @@ function RegistrationLinksCard({ Copy link )} + ), @@ -358,6 +380,15 @@ export default function UserManagement() { setStoredValue(TAB_STORAGE_KEY, key); }; + // One search box for both tabs: both list rows keyed by email address. + const [search, setSearch] = useState(""); + const query = search.trim().toLowerCase(); + const matches = (...texts: (string | null | undefined)[]) => + !query || texts.some((text) => text?.toLowerCase().includes(query)); + const visibleUsers = (users.data?.users ?? []).filter((u) => matches(u.email, u.pending_email)); + const visibleActive = (links.data?.active ?? []).filter((l) => matches(l.email)); + const visiblePrevious = (links.data?.previous ?? []).filter((l) => matches(l.email)); + return ( } > + } + placeholder="Search by email" + value={search} + onChange={(event) => setSearch(event.target.value)} + style={{ marginBottom: 8, maxWidth: 400 }} + /> - + ), }, @@ -393,8 +432,8 @@ export default function UserManagement() { children: ( setCreated(null)} diff --git a/src/frontend/src/lib/services/adminService.ts b/src/frontend/src/lib/services/adminService.ts index eac8eab..8ba4699 100644 --- a/src/frontend/src/lib/services/adminService.ts +++ b/src/frontend/src/lib/services/adminService.ts @@ -13,6 +13,8 @@ export const adminService = { deleteUser: (userId: number) => apiFetch<{ ok: boolean }>(`/api/admin/users/${userId}`, { method: "DELETE" }), listOrders: () => apiFetch<{ orders: AdminOrder[] }>("/api/admin/orders"), listRegistrationLinks: () => apiFetch("/api/admin/registration-links"), + resendRegistrationLink: (id: number) => + apiFetch(`/api/admin/registration-links/${id}/resend`, { method: "POST" }), deleteRegistrationLink: (id: number) => apiFetch<{ ok: boolean }>(`/api/admin/registration-links/${id}`, { method: "DELETE" }), }; diff --git a/src/frontend/src/views/SiteAdminView.tsx b/src/frontend/src/views/SiteAdminView.tsx index 9b20b35..b3c0c05 100644 --- a/src/frontend/src/views/SiteAdminView.tsx +++ b/src/frontend/src/views/SiteAdminView.tsx @@ -1,8 +1,24 @@ import React from "react"; +import { Menu } from "antd"; +import { ADMIN_LINKS } from "../components/account/AccountSettingsPopoverContent"; 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" }) { - return section === "users" ? : ; + return ( +
+ ({ key: path, label, icon }))} + onClick={({ key }) => navigateTo(key)} + style={{ flex: "0 0 220px", borderRadius: 12 }} + /> +
+ {section === "users" ? : } +
+
+ ); }