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:
1 parent
d2bf52fea1
commit
0481b71192
7 files changed
+165
-16
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 />;
|
||||
}
|
||||
Reference in new issue
Block a user