Add admin page navigation, user search and registration link resend
Build and Push Lunchtime Images (Kaniko) / build-and-push (push) Successful in 1m57s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Simon GruberandClaude Sonnet 5.5 committed 2026-11-05 16:40:50 +01:00
1 parent 5c1f4a77ef
commit cb4e610ea1
6 files changed
+103 -27

No files matched your search

+10
View File
@@ -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:
+17
View File
@@ -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(
@@ -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: <TeamOutlined /> },
{ path: "/admin/orders", label: "Order Administration", icon: <ShoppingOutlined /> },
];
@@ -77,27 +78,6 @@ export default function AccountSettingsPopoverContent({
return (
<Space direction="vertical" size={0} style={{ width: 300 }}>
{isAdmin && (
<>
<Typography.Text type="secondary" strong style={sectionHeaderStyle}>
Administration
</Typography.Text>
{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" }} />
</>
)}
<Typography.Text type="secondary" strong style={sectionHeaderStyle}>
Account settings
</Typography.Text>
@@ -154,6 +134,18 @@ export default function AccountSettingsPopoverContent({
<Divider style={{ margin: "8px 0" }} />
{isAdmin && (
<Button
type="text"
block
icon={<SettingOutlined />}
style={menuEntryStyle}
onClick={() => onNavigate(ADMIN_LINKS[0].path)}
>
Administration
</Button>
)}
<Popconfirm
title="Log out?"
description="This clears your local user token on this device."
@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useState } from "react";
import { CopyOutlined, CrownOutlined, DeleteOutlined, MailOutlined, ReloadOutlined, UserAddOutlined } from "@ant-design/icons";
import { CopyOutlined, CrownOutlined, DeleteOutlined, MailOutlined, ReloadOutlined, SearchOutlined, UserAddOutlined } from "@ant-design/icons";
import { Alert, Button, Card, Form, Input, Popconfirm, Popover, Space, Table, Tabs, Tag, Tooltip, Typography, message } from "antd";
import type { ColumnsType, TableProps } from "antd/es/table";
import { apiService } from "../../lib/services";
@@ -220,6 +220,27 @@ function UsersCard({ users, onReload }: { users: AdminUser[]; onReload: () => 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 (
<Popconfirm title={`Send this registration link to ${row.email}?`} okText="Send" onConfirm={resend} disabled={!usable}>
<Tooltip title={usable ? undefined : `Link is already ${row.status}`}>
<Button size="small" icon={<MailOutlined />} disabled={!usable}>
Resend
</Button>
</Tooltip>
</Popconfirm>
);
}
function DeleteLinkButton({ id, onDeleted }: { id: number; onDeleted: () => void }) {
const remove = async () => {
try {
@@ -294,6 +315,7 @@ function RegistrationLinksCard({
Copy link
</Button>
)}
<ResendLinkButton row={row} />
<DeleteLinkButton id={row.id} onDeleted={onChanged} />
</Space>
),
@@ -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 (
<Card
title="User Management"
@@ -374,6 +405,14 @@ export default function UserManagement() {
</Space>
}
>
<Input
allowClear
prefix={<SearchOutlined />}
placeholder="Search by email"
value={search}
onChange={(event) => setSearch(event.target.value)}
style={{ marginBottom: 8, maxWidth: 400 }}
/>
<Tabs
activeKey={activeTab}
onChange={selectTab}
@@ -383,7 +422,7 @@ export default function UserManagement() {
label: "Users",
children: (
<AsyncContent loading={users.loading && !users.data} error={users.error} onRetry={users.run}>
<UsersCard users={users.data?.users ?? []} onReload={reloadAll} />
<UsersCard users={visibleUsers} onReload={reloadAll} />
</AsyncContent>
),
},
@@ -393,8 +432,8 @@ export default function UserManagement() {
children: (
<AsyncContent loading={links.loading && !links.data} error={links.error} onRetry={links.run}>
<RegistrationLinksCard
active={links.data?.active ?? []}
previous={links.data?.previous ?? []}
active={visibleActive}
previous={visiblePrevious}
onChanged={reloadAll}
created={created}
onDismissCreated={() => setCreated(null)}
@@ -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<RegistrationLinks>("/api/admin/registration-links"),
resendRegistrationLink: (id: number) =>
apiFetch<RegisterAccountResult>(`/api/admin/registration-links/${id}/resend`, { method: "POST" }),
deleteRegistrationLink: (id: number) =>
apiFetch<{ ok: boolean }>(`/api/admin/registration-links/${id}`, { method: "DELETE" }),
};
+17 -1
View File
@@ -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" ? <UserManagement /> : <OrderManagement />;
return (
<div style={{ display: "flex", flexWrap: "wrap", gap: 16, alignItems: "flex-start" }}>
<Menu
mode="inline"
selectedKeys={[`/admin/${section}`]}
items={ADMIN_LINKS.map(({ path, label, icon }) => ({ key: path, label, icon }))}
onClick={({ key }) => navigateTo(key)}
style={{ flex: "0 0 220px", borderRadius: 12 }}
/>
<div style={{ flex: "1 1 400px", minWidth: 0 }}>
{section === "users" ? <UserManagement /> : <OrderManagement />}
</div>
</div>
);
}