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
+164
-15
No files matched your search
@@ -14,6 +14,7 @@ from .services.admin_service import (
|
|||||||
delete_registration_link,
|
delete_registration_link,
|
||||||
delete_user,
|
delete_user,
|
||||||
get_resendable_user,
|
get_resendable_user,
|
||||||
|
list_orders,
|
||||||
list_registration_links,
|
list_registration_links,
|
||||||
list_users,
|
list_users,
|
||||||
)
|
)
|
||||||
@@ -643,6 +644,12 @@ def admin_delete_user(profile_id: int, admin_id: str = Depends(require_site_admi
|
|||||||
return {"ok": True}
|
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")
|
@app.get("/api/admin/registration-links")
|
||||||
def admin_list_registration_links(_admin_id: str = Depends(require_site_admin)) -> dict[str, Any]:
|
def admin_list_registration_links(_admin_id: str = Depends(require_site_admin)) -> dict[str, Any]:
|
||||||
with get_connection() as conn:
|
with get_connection() as conn:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from uuid import uuid4
|
|||||||
|
|
||||||
from fastapi import HTTPException
|
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
|
from ..db import row_to_submission
|
||||||
|
|
||||||
|
|
||||||
@@ -203,10 +203,17 @@ 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:
|
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)
|
row = get_user_order_tokens(conn, user_id, order_id)
|
||||||
if not row or not row["admin_token"]:
|
if row and row["admin_token"]:
|
||||||
raise HTTPException(status_code=404, detail="Admin order view not found")
|
|
||||||
return row
|
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:
|
def build_submission_payload(row: sqlite3.Row | None) -> dict[str, Any] | None:
|
||||||
return row_to_submission(row) if row else None
|
return row_to_submission(row) if row else 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 user_order_tokens WHERE user_id = ?", (user_id,))
|
||||||
conn.execute("DELETE FROM account_confirmation_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,))
|
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 { apiFetch } from "../api";
|
||||||
import type { AdminUser, RegisterAccountResult, CreatedRegistrationLink, RegistrationLinks } from "../types";
|
import type { AdminOrder, AdminUser, RegisterAccountResult, CreatedRegistrationLink, RegistrationLinks } from "../types";
|
||||||
|
|
||||||
export const adminService = {
|
export const adminService = {
|
||||||
listUsers: () => apiFetch<{ users: AdminUser[] }>("/api/admin/users"),
|
listUsers: () => apiFetch<{ users: AdminUser[] }>("/api/admin/users"),
|
||||||
@@ -11,6 +11,7 @@ export const adminService = {
|
|||||||
resendRegistration: (userId: number) =>
|
resendRegistration: (userId: number) =>
|
||||||
apiFetch<RegisterAccountResult>(`/api/admin/users/${userId}/resend-registration`, { method: "POST" }),
|
apiFetch<RegisterAccountResult>(`/api/admin/users/${userId}/resend-registration`, { method: "POST" }),
|
||||||
deleteUser: (userId: number) => apiFetch<{ ok: boolean }>(`/api/admin/users/${userId}`, { method: "DELETE" }),
|
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"),
|
listRegistrationLinks: () => apiFetch<RegistrationLinks>("/api/admin/registration-links"),
|
||||||
deleteRegistrationLink: (id: number) =>
|
deleteRegistrationLink: (id: number) =>
|
||||||
apiFetch<{ ok: boolean }>(`/api/admin/registration-links/${id}`, { method: "DELETE" }),
|
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 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 React from "react";
|
||||||
import { Card, Empty, Typography } from "antd";
|
import OrderManagement from "../components/admin/OrderManagement";
|
||||||
import UserManagement from "../components/admin/UserManagement";
|
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. */
|
/** 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: "users" | "orders" }) {
|
||||||
if (section === "users") {
|
return section === "users" ? <UserManagement /> : <OrderManagement />;
|
||||||
return <UserManagement />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<Typography.Title level={3}>Order Administration</Typography.Title>
|
|
||||||
<Empty description="Coming soon" />
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user