From 0481b71192044d8d524cc0919f544a4b8b96b767 Mon Sep 17 00:00:00 2001 From: Simon Gruber Date: Thu, 5 Nov 2026 16:26:40 +0100 Subject: [PATCH] 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 --- src/backend/app/main.py | 7 ++ src/backend/app/services/account_service.py | 15 ++- src/backend/app/services/admin_service.py | 18 +++ .../src/components/admin/OrderManagement.tsx | 116 ++++++++++++++++++ src/frontend/src/lib/services/adminService.ts | 3 +- src/frontend/src/lib/types.ts | 9 ++ src/frontend/src/views/SiteAdminView.tsx | 13 +- 7 files changed, 165 insertions(+), 16 deletions(-) create mode 100644 src/frontend/src/components/admin/OrderManagement.tsx diff --git a/src/backend/app/main.py b/src/backend/app/main.py index 571d48f..a2f1322 100644 --- a/src/backend/app/main.py +++ b/src/backend/app/main.py @@ -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: diff --git a/src/backend/app/services/account_service.py b/src/backend/app/services/account_service.py index 6b87cd9..3edfa5d 100644 --- a/src/backend/app/services/account_service.py +++ b/src/backend/app/services/account_service.py @@ -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: diff --git a/src/backend/app/services/admin_service.py b/src/backend/app/services/admin_service.py index f3884d6..c869f2d 100644 --- a/src/backend/app/services/admin_service.py +++ b/src/backend/app/services/admin_service.py @@ -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] diff --git a/src/frontend/src/components/admin/OrderManagement.tsx b/src/frontend/src/components/admin/OrderManagement.tsx new file mode 100644 index 0000000..7fc0b4c --- /dev/null +++ b/src/frontend/src/components/admin/OrderManagement.tsx @@ -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 { + 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) => {closed ? "Closed" : "Open"}, + }, + { title: "Submissions", dataIndex: "submissions", sorter: (a, b) => a.submissions - b.submissions }, + { + title: "", + key: "actions", + align: "right", + render: (_value, order) => ( + + +