Add site admin order management page

- GET /api/admin/orders (site admins only): orders with owner email and submission count
- ensure_owner_access also grants confirmed ADMIN_EMAILS users, so admins can manage/delete any order
- Order table with search (title/email), icon actions (inspect, manage, delete), no horizontal scroll

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:26:40 +01:00
1 parent d2bf52fea1
commit 0481b71192
7 files changed
+165 -16

No files matched your search

+7
View File
@@ -14,6 +14,7 @@ from .services.admin_service import (
delete_registration_link,
delete_user,
get_resendable_user,
list_orders,
list_registration_links,
list_users,
)
@@ -643,6 +644,12 @@ def admin_delete_user(profile_id: int, admin_id: str = Depends(require_site_admi
return {"ok": True}
@app.get("/api/admin/orders")
def admin_list_orders(_admin_id: str = Depends(require_site_admin)) -> dict[str, Any]:
with get_connection() as conn:
return {"orders": list_orders(conn)}
@app.get("/api/admin/registration-links")
def admin_list_registration_links(_admin_id: str = Depends(require_site_admin)) -> dict[str, Any]:
with get_connection() as conn:
+11 -4
View File
@@ -6,7 +6,7 @@ from uuid import uuid4
from fastapi import HTTPException
from .common_service import clean_user_email, clean_user_id, ensure_email_not_privileged, now_iso
from .common_service import clean_user_email, clean_user_id, ensure_email_not_privileged, is_site_admin, now_iso
from ..db import row_to_submission
@@ -203,9 +203,16 @@ def resolve_submission_email(conn: sqlite3.Connection, user_id: str, user_email_
def ensure_owner_access(conn: sqlite3.Connection, user_id: str, order_id: str) -> sqlite3.Row:
row = get_user_order_tokens(conn, user_id, order_id)
if not row or not row["admin_token"]:
raise HTTPException(status_code=404, detail="Admin order view not found")
return row
if row and row["admin_token"]:
return row
# Site admins (confirmed ADMIN_EMAILS profile) may manage every order.
if is_site_admin(get_user_profile(conn, user_id)):
order = conn.execute("SELECT admin_token FROM group_orders WHERE id = ?", (order_id,)).fetchone()
if order:
return order
raise HTTPException(status_code=404, detail="Admin order view not found")
def build_submission_payload(row: sqlite3.Row | None) -> dict[str, Any] | None:
+18
View File
@@ -163,3 +163,21 @@ def delete_user(conn: sqlite3.Connection, profile_id: int, admin_user_id: str) -
conn.execute("DELETE FROM user_order_tokens WHERE user_id = ?", (user_id,))
conn.execute("DELETE FROM account_confirmation_tokens WHERE user_id = ?", (user_id,))
conn.execute("DELETE FROM user_profiles WHERE user_id = ?", (user_id,))
def list_orders(conn: sqlite3.Connection) -> list[dict[str, Any]]:
"""All orders with owner email and submission count."""
rows = conn.execute(
"""
SELECT
go.id, go.title, go.closed, go.created_at,
(SELECT up.email FROM user_order_tokens uot
JOIN user_profiles up ON up.user_id = uot.user_id
WHERE uot.group_order_id = go.id AND uot.admin_token = go.admin_token
ORDER BY uot.created_at LIMIT 1) AS owner_email,
(SELECT COUNT(*) FROM submissions s WHERE s.group_order_id = go.id) AS submissions
FROM group_orders go
ORDER BY go.created_at DESC
"""
).fetchall()
return [{**dict(row), "closed": bool(row["closed"])} for row in rows]
@@ -0,0 +1,116 @@
import React, { useMemo, useState } from "react";
import { DeleteOutlined, EyeOutlined, ReloadOutlined, SearchOutlined, SettingOutlined } from "@ant-design/icons";
import { Button, Card, Input, Popconfirm, Space, Table, Tag, Tooltip, Typography, message } from "antd";
import type { ColumnsType } from "antd/es/table";
import { apiService } from "../../lib/services";
import { navigateTo } from "../../lib/routing";
import { useApiRequest } from "../../hooks/useApiRequest";
import AsyncContent from "../utils/AsyncContent";
import type { AdminOrder } from "../../lib/types";
const loadOrders = () => apiService.admin.listOrders();
function createColumns(onChanged: () => void): ColumnsType<AdminOrder> {
const remove = async (order: AdminOrder) => {
try {
await apiService.orders.delete(order.id);
message.success(`Deleted "${order.title}".`);
onChanged();
} catch (error: any) {
message.error(error?.message || "Could not delete the order.");
}
};
return [
{
title: "Created",
dataIndex: "created_at",
defaultSortOrder: "descend",
sorter: (a, b) => a.created_at.localeCompare(b.created_at),
render: (iso: string) => new Date(iso).toLocaleString(),
},
{ title: "Title", dataIndex: "title", ellipsis: true, sorter: (a, b) => a.title.localeCompare(b.title) },
{
title: "User",
dataIndex: "owner_email",
ellipsis: true,
sorter: (a, b) => (a.owner_email ?? "").localeCompare(b.owner_email ?? ""),
render: (email: string | null) => email ?? "–",
},
{
title: "Status",
dataIndex: "closed",
filters: [
{ text: "Open", value: false },
{ text: "Closed", value: true },
],
onFilter: (value, order) => order.closed === value,
render: (closed: boolean) => <Tag color={closed ? "default" : "green"}>{closed ? "Closed" : "Open"}</Tag>,
},
{ title: "Submissions", dataIndex: "submissions", sorter: (a, b) => a.submissions - b.submissions },
{
title: "",
key: "actions",
align: "right",
render: (_value, order) => (
<Space>
<Tooltip title="Inspect">
<Button size="small" icon={<EyeOutlined />} aria-label="Inspect order" onClick={() => navigateTo(`/order/${order.id}`)} />
</Tooltip>
<Tooltip title="Manage">
<Button size="small" icon={<SettingOutlined />} aria-label="Manage order" onClick={() => navigateTo(`/order/${order.id}/admin`)} />
</Tooltip>
<Popconfirm
title={`Delete "${order.title}"?`}
description="Removes the order, its submissions and files."
okText="Delete"
okButtonProps={{ danger: true }}
onConfirm={() => remove(order)}
>
<Button danger size="small" icon={<DeleteOutlined />} aria-label="Delete order" />
</Popconfirm>
</Space>
),
},
];
}
export default function OrderManagement() {
const orders = useApiRequest(loadOrders);
const columns = useMemo(() => createColumns(orders.run), [orders.run]);
const [search, setSearch] = useState("");
const all = orders.data?.orders ?? [];
const query = search.trim().toLowerCase();
const rows = query
? all.filter((o) => o.title.toLowerCase().includes(query) || (o.owner_email ?? "").toLowerCase().includes(query))
: all;
return (
<Card
title="Order Management"
extra={<Button icon={<ReloadOutlined />} onClick={orders.run} aria-label="Reload" />}
>
<Input
allowClear
prefix={<SearchOutlined />}
placeholder="Search by title or user email"
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ marginBottom: 16, maxWidth: 400 }}
/>
<AsyncContent loading={orders.loading && !orders.data} error={orders.error} onRetry={orders.run}>
<Table
rowKey="id"
size="small"
columns={columns}
dataSource={rows}
pagination={{ pageSize: 20, hideOnSinglePage: true }}
tableLayout="fixed"
/>
<Typography.Text type="secondary" style={{ display: "block", marginTop: 8 }}>
{rows.length === all.length ? `${all.length} orders` : `${rows.length} of ${all.length} orders selected`}
</Typography.Text>
</AsyncContent>
</Card>
);
}
@@ -1,5 +1,5 @@
import { apiFetch } from "../api";
import type { AdminUser, RegisterAccountResult, CreatedRegistrationLink, RegistrationLinks } from "../types";
import type { AdminOrder, AdminUser, RegisterAccountResult, CreatedRegistrationLink, RegistrationLinks } from "../types";
export const adminService = {
listUsers: () => apiFetch<{ users: AdminUser[] }>("/api/admin/users"),
@@ -11,6 +11,7 @@ export const adminService = {
resendRegistration: (userId: number) =>
apiFetch<RegisterAccountResult>(`/api/admin/users/${userId}/resend-registration`, { method: "POST" }),
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"),
deleteRegistrationLink: (id: number) =>
apiFetch<{ ok: boolean }>(`/api/admin/registration-links/${id}`, { method: "DELETE" }),
+9
View File
@@ -389,3 +389,12 @@ export type RegistrationLinks = {
};
export type CreatedRegistrationLink = { email: string; link: string };
export type AdminOrder = {
id: string;
title: string;
owner_email: string | null;
closed: boolean;
created_at: string;
submissions: number;
};
+2 -11
View File
@@ -1,17 +1,8 @@
import React from "react";
import { Card, Empty, Typography } from "antd";
import OrderManagement from "../components/admin/OrderManagement";
import UserManagement from "../components/admin/UserManagement";
/** 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" }) {
if (section === "users") {
return <UserManagement />;
}
return (
<Card>
<Typography.Title level={3}>Order Administration</Typography.Title>
<Empty description="Coming soon" />
</Card>
);
return section === "users" ? <UserManagement /> : <OrderManagement />;
}