Add site admin user management page

- Admin API (/api/admin/users, registration links) guarded by require_site_admin:
  confirmed profile email must be in ADMIN_EMAILS, checked server-side
- Block unverified switches to an admin email (PUT /me/profile, X-User-Email)
- Users table with registration date, activity counters, resend and delete
- Create, view (active/previous) and delete registration links
- user_profiles.created_at column with backfill

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Simon GruberandClaude Sonnet 5.5 committed 2026-11-05 15:04:45 +01:00
1 parent e3f2f44765
commit 7f7e529b16
10 files changed
+675 -14

No files matched your search

+20
View File
@@ -86,6 +86,26 @@ def init_db() -> None:
if "handed_out" not in submission_columns:
conn.execute("ALTER TABLE submissions ADD COLUMN handed_out INTEGER NOT NULL DEFAULT 0")
profile_columns = {row["name"] for row in conn.execute("PRAGMA table_info(user_profiles)")}
if "created_at" not in profile_columns:
conn.execute("ALTER TABLE user_profiles ADD COLUMN created_at TEXT")
# Backfill: earliest known trace of the user (registration link, order link) or the last profile update.
conn.execute(
"""
UPDATE user_profiles
SET created_at = (
SELECT MIN(ts) FROM (
SELECT created_at AS ts FROM account_confirmation_tokens WHERE user_id = user_profiles.user_id
UNION ALL
SELECT created_at AS ts FROM user_order_tokens WHERE user_id = user_profiles.user_id
UNION ALL
SELECT user_profiles.updated_at AS ts
)
)
WHERE created_at IS NULL
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_account_confirmation_tokens_user_id
+74 -3
View File
@@ -9,6 +9,14 @@ from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from .db import get_connection, init_db, row_to_my_order, row_to_submission
from .services.admin_service import (
create_registration_link,
delete_registration_link,
delete_user,
get_resendable_user,
list_registration_links,
list_users,
)
from .services.account_service import (
choose_new_user_id,
consume_confirmation_token,
@@ -24,8 +32,8 @@ from .services.account_service import (
upsert_user_order_tokens,
upsert_user_profile_email,
)
from .services.common_service import clean_image_url, clean_user_email, clean_user_id, is_admin_email, now_iso
from .services.config_service import load_config
from .services.common_service import clean_image_url, clean_user_email, clean_user_id, ensure_email_not_privileged, is_site_admin, now_iso
from .services.config_service import get_email_settings, load_config
from .services.email_service import (
send_email_change_new_email_confirmation,
send_migration_email,
@@ -215,6 +223,10 @@ class AccountLookupResponse(BaseModel):
exists: bool
class AdminCreateUserRequest(BaseModel):
email: str = Field(min_length=3, max_length=320)
@app.on_event("startup")
def startup() -> None:
init_db()
@@ -227,6 +239,15 @@ def get_existing_user_id(user_id: str = Header(alias="X-User-Id")) -> str:
return clean_id
def require_site_admin(user_id: str = Depends(get_existing_user_id)) -> str:
"""Authorize from server-side state only: the profile's confirmed email must be in ADMIN_EMAILS."""
with get_connection() as conn:
profile = get_user_profile(conn, user_id)
if not is_site_admin(profile):
raise HTTPException(status_code=403, detail="Admin access required")
return user_id
@app.get("/api/config")
def get_config() -> dict[str, Any]:
return load_config()
@@ -261,7 +282,7 @@ def get_my_profile(user_id: str = Depends(get_existing_user_id)) -> dict[str, An
return {
"email": email,
"is_admin": is_admin_email(email),
"is_admin": is_site_admin(profile),
"email_confirmed": bool(profile and profile["email_confirmed"]),
"pending_email": str(profile["pending_email"]).strip().lower() if profile and profile["pending_email"] else None,
"pending_email_old_confirmed": bool(profile and profile["pending_email_old_confirmed"]),
@@ -281,6 +302,7 @@ def update_my_profile(
raise HTTPException(status_code=422, detail="Email is required")
with get_connection() as conn:
ensure_email_not_privileged(email, get_user_profile_email(conn, clean_id))
upsert_user_profile_email(conn, clean_id, email)
conn.execute(
"UPDATE user_profiles SET email_confirmed = 1, pending_email = NULL, pending_email_old_confirmed = 0, pending_email_new_confirmed = 0, updated_at = ? WHERE user_id = ?",
@@ -585,6 +607,55 @@ def confirm_account_action(token: str) -> dict[str, Any]:
raise HTTPException(status_code=400, detail="Unsupported confirmation action")
@app.get("/api/admin/users")
def admin_list_users(admin_id: str = Depends(require_site_admin)) -> dict[str, Any]:
with get_connection() as conn:
return {"users": list_users(conn, admin_id)}
@app.post("/api/admin/users")
def admin_register_user(
payload: AdminCreateUserRequest,
_admin_id: str = Depends(require_site_admin),
) -> dict[str, Any]:
email = clean_user_email(payload.email)
if not email:
raise HTTPException(status_code=422, detail="Email is required")
with get_connection() as conn:
return create_registration_link(conn, email, get_email_settings()["confirmation_link_base"])
@app.post("/api/admin/users/{profile_id}/resend-registration")
def admin_resend_registration(profile_id: int, _admin_id: str = Depends(require_site_admin)) -> dict[str, Any]:
with get_connection() as conn:
user_id, email = get_resendable_user(conn, profile_id)
token = create_confirmation_token(conn, user_id=user_id, action="register_confirm", email=email)
send_registration_email(user_id, email, token)
return {"status": "sent", "message": f"Registration email sent to {email}"}
@app.delete("/api/admin/users/{profile_id}")
def admin_delete_user(profile_id: int, admin_id: str = Depends(require_site_admin)) -> dict[str, bool]:
with get_connection() as conn:
delete_user(conn, profile_id, admin_id)
return {"ok": True}
@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:
return list_registration_links(conn, get_email_settings()["confirmation_link_base"])
@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:
delete_registration_link(conn, link_id)
return {"ok": True}
@app.post("/api/orders")
def create_order(
payload: OrderCreateRequest,
+5 -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, now_iso
from .common_service import clean_user_email, clean_user_id, ensure_email_not_privileged, now_iso
from ..db import row_to_submission
@@ -44,14 +44,14 @@ def get_user_order_tokens(conn: sqlite3.Connection, user_id: str, order_id: str)
def upsert_user_profile_email(conn: sqlite3.Connection, user_id: str, email: str) -> None:
conn.execute(
"""
INSERT INTO user_profiles (user_id, email, updated_at)
VALUES (?, ?, ?)
INSERT INTO user_profiles (user_id, email, created_at, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(user_id)
DO UPDATE SET
email = excluded.email,
updated_at = excluded.updated_at
""",
(user_id, email, now_iso()),
(user_id, email, now_iso(), now_iso()),
)
@@ -190,6 +190,7 @@ def migrate_user_id(conn: sqlite3.Connection, old_user_id: str, new_user_id: str
def resolve_submission_email(conn: sqlite3.Connection, user_id: str, user_email_header: str | None) -> str:
header_email = clean_user_email(user_email_header)
if header_email:
ensure_email_not_privileged(header_email, get_user_profile_email(conn, user_id))
upsert_user_profile_email(conn, user_id, header_email)
return header_email
+165
View File
@@ -0,0 +1,165 @@
"""Site-admin operations: user overview and registration links (account_confirmation_tokens)."""
import sqlite3
from typing import Any
from fastapi import HTTPException
from .account_service import choose_new_user_id, create_confirmation_token, upsert_user_profile_email
from .common_service import is_admin_email, now_iso
from .email_service import build_confirmation_link
REGISTER_ACTION = "register_confirm"
REGISTRATION_LINK_VALIDITY_HOURS = 24 * 7
def list_users(conn: sqlite3.Connection, admin_user_id: str) -> list[dict[str, Any]]:
"""All user profiles with activity counters. The secret user_id is intentionally never returned."""
rows = conn.execute(
"""
SELECT
up.rowid AS id,
up.user_id,
up.created_at,
up.email,
up.email_confirmed,
up.pending_email,
up.pending_user_id,
up.updated_at,
(SELECT COUNT(*) FROM user_order_tokens uot
WHERE uot.user_id = up.user_id AND uot.admin_token IS NOT NULL) AS orders_owned,
(SELECT COUNT(*) FROM user_order_tokens uot
WHERE uot.user_id = up.user_id AND uot.submission_token IS NOT NULL) AS submissions,
(SELECT MAX(uot.updated_at) FROM user_order_tokens uot
WHERE uot.user_id = up.user_id) AS last_order_activity
FROM user_profiles up
ORDER BY lower(up.email)
"""
).fetchall()
return [
{
"id": row["id"],
"email": row["email"],
"registered_at": row["created_at"] or row["updated_at"],
"is_self": row["user_id"] == admin_user_id,
"email_confirmed": bool(row["email_confirmed"]),
"is_admin": bool(row["email_confirmed"]) and is_admin_email(row["email"]),
"pending_email": row["pending_email"],
"pending_user_id_change": bool(row["pending_user_id"]),
"orders_owned": row["orders_owned"],
"submissions": row["submissions"],
"last_activity": max(filter(None, [row["updated_at"], row["last_order_activity"]])),
}
for row in rows
]
def create_registration_link(conn: sqlite3.Connection, email: str, link_base: str) -> dict[str, Any]:
"""Create (or re-issue for a still unused pending profile) a registration link for the email."""
matches = conn.execute(
"""
SELECT
up.user_id,
up.email_confirmed,
(SELECT COUNT(*) FROM user_order_tokens uot WHERE uot.user_id = up.user_id) AS order_links
FROM user_profiles up
WHERE lower(trim(up.email)) = ?
""",
(email,),
).fetchall()
if any(row["email_confirmed"] or row["order_links"] for row in matches):
raise HTTPException(status_code=409, detail="A user with this email already exists")
user_id = str(matches[0]["user_id"]) if matches else choose_new_user_id(conn, None)
if not matches:
upsert_user_profile_email(conn, user_id, email)
token = create_confirmation_token(
conn,
user_id=user_id,
action=REGISTER_ACTION,
email=email,
expires_in_hours=REGISTRATION_LINK_VALIDITY_HOURS,
)
# Built last: a misconfigured link base raises and the connection context rolls everything back.
return {"email": email, "link": build_confirmation_link(link_base, token)}
def list_registration_links(conn: sqlite3.Connection, link_base: str) -> dict[str, list[dict[str, Any]]]:
"""Active links include the usable link; previous (used/expired) ones only metadata."""
rows = conn.execute(
"""
SELECT rowid AS id, token, email, created_at, expires_at, consumed_at
FROM account_confirmation_tokens
WHERE action = ?
ORDER BY created_at DESC
""",
(REGISTER_ACTION,),
).fetchall()
now = now_iso()
active: list[dict[str, Any]] = []
previous: list[dict[str, Any]] = []
for row in rows:
entry = {
"id": row["id"],
"email": row["email"],
"created_at": row["created_at"],
"expires_at": row["expires_at"],
"consumed_at": row["consumed_at"],
}
if not row["consumed_at"] and row["expires_at"] > now:
active.append({**entry, "link": build_confirmation_link(link_base, row["token"])})
else:
previous.append({**entry, "status": "used" if row["consumed_at"] else "expired"})
return {"active": active, "previous": previous}
def delete_registration_link(conn: sqlite3.Connection, link_id: int) -> None:
cursor = conn.execute(
"DELETE FROM account_confirmation_tokens WHERE rowid = ? AND action = ?",
(link_id, REGISTER_ACTION),
)
if cursor.rowcount == 0:
raise HTTPException(status_code=404, detail="Registration link not found")
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(
"SELECT user_id, email, email_confirmed FROM user_profiles WHERE rowid = ?",
(profile_id,),
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="User not found")
if row["email_confirmed"]:
raise HTTPException(status_code=409, detail="User has already confirmed their email")
return str(row["user_id"]), str(row["email"])
def delete_user(conn: sqlite3.Connection, profile_id: int, admin_user_id: str) -> None:
"""Delete a profile with its order links and confirmation tokens.
Refused for the calling admin and for users owning orders (their orders would be orphaned).
Submissions stay in their orders; the user just loses access to them.
"""
row = conn.execute("SELECT user_id FROM user_profiles WHERE rowid = ?", (profile_id,)).fetchone()
if not row:
raise HTTPException(status_code=404, detail="User not found")
user_id = row["user_id"]
if user_id == admin_user_id:
raise HTTPException(status_code=409, detail="You cannot delete your own account")
owned = conn.execute(
"SELECT COUNT(*) FROM user_order_tokens WHERE user_id = ? AND admin_token IS NOT NULL",
(user_id,),
).fetchone()[0]
if owned:
raise HTTPException(status_code=409, detail="User still owns orders; delete or hand over those orders first")
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,))
@@ -1,4 +1,6 @@
import os
from collections.abc import Mapping
from typing import Any
from datetime import datetime, timezone
from urllib.parse import urlparse
@@ -47,6 +49,20 @@ def is_admin_email(email: str | None) -> bool:
return email.strip().lower() in admin_emails
def is_site_admin(profile: Mapping[str, Any] | None) -> bool:
"""Admin = confirmed email that is listed in ADMIN_EMAILS.
The confirmation requirement matters: an unconfirmed email is only a client claim.
"""
return bool(profile and profile["email_confirmed"] and is_admin_email(profile["email"]))
def ensure_email_not_privileged(new_email: str, current_email: str | None) -> None:
"""Reject unverified switches to an admin email (the email change flow verifies ownership by mail)."""
if is_admin_email(new_email) and new_email != current_email:
raise HTTPException(status_code=403, detail="This email can only be set via the email change confirmation flow")
def clean_image_url(value: str | None) -> str | None:
clean_url = clean_optional(value)
if not clean_url:
@@ -0,0 +1,335 @@
import React, { useMemo, useState } from "react";
import { CopyOutlined, CrownOutlined, DeleteOutlined, MailOutlined, ReloadOutlined, UserAddOutlined } from "@ant-design/icons";
import { Alert, Button, Card, Form, Input, Popconfirm, Space, Table, Tag, Tooltip, Typography, message } from "antd";
import type { ColumnsType } from "antd/es/table";
import { apiService } from "../../lib/services";
import { useApiRequest } from "../../hooks/useApiRequest";
import AsyncContent from "../utils/AsyncContent";
import type {
ActiveRegistrationLink,
AdminUser,
CreatedRegistrationLink,
PreviousRegistrationLink,
} from "../../lib/types";
const loadUsers = () => apiService.admin.listUsers();
const loadLinks = () => apiService.admin.listRegistrationLinks();
function formatDateTime(iso: string | null) {
return iso ? new Date(iso).toLocaleString() : "–";
}
async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text);
message.success("Link copied to clipboard.");
} catch (_error) {
message.error("Could not copy the link. Select and copy it manually.");
}
}
function RegisterUserCard({ onCreated }: { onCreated: () => void }) {
const [form] = Form.useForm<{ email: string }>();
const [submitting, setSubmitting] = useState(false);
const [created, setCreated] = useState<CreatedRegistrationLink | null>(null);
const submit = async ({ email }: { email: string }) => {
setSubmitting(true);
try {
setCreated(await apiService.admin.registerUser(email.trim()));
form.resetFields();
onCreated();
} catch (error: any) {
setCreated(null);
message.error(error?.message || "Could not register the user.");
} finally {
setSubmitting(false);
}
};
return (
<Card title="Register new user">
<Form form={form} layout="inline" onFinish={submit}>
<Form.Item
name="email"
rules={[{ required: true, type: "email", message: "Enter a valid email address" }]}
style={{ flex: "1 1 280px" }}
>
<Input placeholder="user@example.com" autoComplete="off" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" icon={<UserAddOutlined />} loading={submitting}>
Create registration link
</Button>
</Form.Item>
</Form>
{created && (
<Alert
style={{ marginTop: 16 }}
type="success"
showIcon
closable
onClose={() => setCreated(null)}
message={`Registration link for ${created.email}`}
description={
<Space direction="vertical" style={{ width: "100%" }}>
<Typography.Text code copyable={{ text: created.link }} style={{ wordBreak: "break-all" }}>
{created.link}
</Typography.Text>
<Typography.Text type="secondary">
Anyone with this link can sign in as the new user. Hand it over only to them.
</Typography.Text>
</Space>
}
/>
)}
</Card>
);
}
function createUserColumns(onChanged: () => void): ColumnsType<AdminUser> {
return [
{
title: "Email",
dataIndex: "email",
sorter: (a, b) => a.email.localeCompare(b.email),
render: (email: string, user) => (
<Space size={6} wrap>
{email}
{user.is_admin && (
<Tooltip title="Site admin">
<CrownOutlined />
</Tooltip>
)}
</Space>
),
},
{
title: "Status",
dataIndex: "email_confirmed",
filters: [
{ text: "Confirmed", value: true },
{ text: "Pending", value: false },
],
onFilter: (value, user) => user.email_confirmed === value,
render: (confirmed: boolean, user) => (
<Space size={4} wrap>
<Tag color={confirmed ? "green" : "orange"}>{confirmed ? "Confirmed" : "Pending"}</Tag>
{user.pending_email && <Tag>Email change → {user.pending_email}</Tag>}
{user.pending_user_id_change && <Tag>User ID change</Tag>}
</Space>
),
},
{
title: "Registered",
dataIndex: "registered_at",
sorter: (a, b) => a.registered_at.localeCompare(b.registered_at),
render: formatDateTime,
},
{ title: "Orders created", dataIndex: "orders_owned", sorter: (a, b) => a.orders_owned - b.orders_owned },
{ title: "Submissions", dataIndex: "submissions", sorter: (a, b) => a.submissions - b.submissions },
{
title: "Last activity",
dataIndex: "last_activity",
defaultSortOrder: "descend",
sorter: (a, b) => a.last_activity.localeCompare(b.last_activity),
render: formatDateTime,
},
{
title: "",
key: "actions",
align: "right",
render: (_value, user) => <UserActions user={user} onChanged={onChanged} />,
},
];
}
function UserActions({ user, onChanged }: { user: AdminUser; onChanged: () => void }) {
const resend = async () => {
try {
const result = await apiService.admin.resendRegistration(user.id);
message.success(result.message);
onChanged();
} catch (error: any) {
message.error(error?.message || "Could not send the registration email.");
}
};
const remove = async () => {
try {
await apiService.admin.deleteUser(user.id);
message.success(`Deleted ${user.email}.`);
onChanged();
} catch (error: any) {
message.error(error?.message || "Could not delete the user.");
}
};
return (
<Space>
{!user.email_confirmed && (
<Popconfirm title={`Send a new registration email to ${user.email}?`} okText="Send" onConfirm={resend}>
<Button size="small" icon={<MailOutlined />}>
Resend
</Button>
</Popconfirm>
)}
<Popconfirm
title={`Delete ${user.email}?`}
description="Removes the account and its order links. Submissions stay in their orders."
okText="Delete"
okButtonProps={{ danger: true }}
onConfirm={remove}
disabled={user.is_self}
>
<Tooltip title={user.is_self ? "You cannot delete your own account" : undefined}>
<Button danger size="small" icon={<DeleteOutlined />} disabled={user.is_self} aria-label="Delete user" />
</Tooltip>
</Popconfirm>
</Space>
);
}
function UsersCard({ users, onReload }: { users: AdminUser[]; onReload: () => void }) {
const columns = useMemo(() => createUserColumns(onReload), [onReload]);
return (
<Card
title={`Users (${users.length})`}
extra={<Button icon={<ReloadOutlined />} onClick={onReload} aria-label="Reload users" />}
>
<Table
rowKey="id"
size="small"
columns={columns}
dataSource={users}
pagination={{ pageSize: 20, hideOnSinglePage: true }}
scroll={{ x: "max-content" }}
/>
</Card>
);
}
function DeleteLinkButton({ id, onDeleted }: { id: number; onDeleted: () => void }) {
const remove = async () => {
try {
await apiService.admin.deleteRegistrationLink(id);
onDeleted();
} catch (error: any) {
message.error(error?.message || "Could not delete the link.");
}
};
return (
<Popconfirm title="Delete this registration link?" okText="Delete" okButtonProps={{ danger: true }} onConfirm={remove}>
<Button danger size="small" icon={<DeleteOutlined />} aria-label="Delete registration link" />
</Popconfirm>
);
}
function RegistrationLinksCard({
active,
previous,
onChanged,
}: {
active: ActiveRegistrationLink[];
previous: PreviousRegistrationLink[];
onChanged: () => void;
}) {
const activeColumns: ColumnsType<ActiveRegistrationLink> = [
{ title: "Email", dataIndex: "email" },
{ title: "Created", dataIndex: "created_at", render: formatDateTime },
{ title: "Expires", dataIndex: "expires_at", render: formatDateTime },
{
title: "",
key: "actions",
align: "right",
render: (_value, row) => (
<Space>
<Button size="small" icon={<CopyOutlined />} onClick={() => copyToClipboard(row.link)}>
Copy link
</Button>
<DeleteLinkButton id={row.id} onDeleted={onChanged} />
</Space>
),
},
];
const previousColumns: ColumnsType<PreviousRegistrationLink> = [
{ title: "Email", dataIndex: "email" },
{ title: "Created", dataIndex: "created_at", render: formatDateTime },
{
title: "Result",
dataIndex: "status",
render: (status: PreviousRegistrationLink["status"], row) =>
status === "used" ? (
<Tag color="green">Used {formatDateTime(row.consumed_at)}</Tag>
) : (
<Tag>Expired {formatDateTime(row.expires_at)}</Tag>
),
},
{
title: "",
key: "actions",
align: "right",
render: (_value, row) => <DeleteLinkButton id={row.id} onDeleted={onChanged} />,
},
];
return (
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
<Card title={`Active registration links (${active.length})`}>
<Table
rowKey="id"
size="small"
columns={activeColumns}
dataSource={active}
pagination={false}
locale={{ emptyText: "No active registration links" }}
scroll={{ x: "max-content" }}
/>
</Card>
<Card title={`Previous registration links (${previous.length})`}>
<Table
rowKey="id"
size="small"
columns={previousColumns}
dataSource={previous}
pagination={{ pageSize: 10, hideOnSinglePage: true }}
locale={{ emptyText: "No previous registration links" }}
scroll={{ x: "max-content" }}
/>
</Card>
</Space>
);
}
export default function UserManagement() {
const users = useApiRequest(loadUsers);
const links = useApiRequest(loadLinks);
const reloadAll = () => {
users.run();
links.run();
};
return (
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
<Typography.Title level={3} style={{ margin: 0 }}>
User Management
</Typography.Title>
<RegisterUserCard onCreated={reloadAll} />
<AsyncContent loading={users.loading && !users.data} error={users.error} onRetry={users.run}>
<UsersCard users={users.data?.users ?? []} onReload={reloadAll} />
</AsyncContent>
<AsyncContent loading={links.loading && !links.data} error={links.error} onRetry={links.run}>
<RegistrationLinksCard
active={links.data?.active ?? []}
previous={links.data?.previous ?? []}
onChanged={reloadAll}
/>
</AsyncContent>
</Space>
);
}
@@ -0,0 +1,17 @@
import { apiFetch } from "../api";
import type { AdminUser, RegisterAccountResult, CreatedRegistrationLink, RegistrationLinks } from "../types";
export const adminService = {
listUsers: () => apiFetch<{ users: AdminUser[] }>("/api/admin/users"),
registerUser: (email: string) =>
apiFetch<CreatedRegistrationLink>("/api/admin/users", {
method: "POST",
body: JSON.stringify({ email }),
}),
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" }),
listRegistrationLinks: () => apiFetch<RegistrationLinks>("/api/admin/registration-links"),
deleteRegistrationLink: (id: number) =>
apiFetch<{ ok: boolean }>(`/api/admin/registration-links/${id}`, { method: "DELETE" }),
};
+2
View File
@@ -1,4 +1,5 @@
import { accountService } from "./accountService";
import { adminService } from "./adminService";
import { configService } from "./configService";
import { ordersService } from "./ordersService";
import { profileService } from "./profileService";
@@ -6,6 +7,7 @@ import { submissionsService } from "./submissionsService";
export const apiService = {
account: accountService,
admin: adminService,
config: configService,
orders: ordersService,
profile: profileService,
+34
View File
@@ -355,3 +355,37 @@ export type OrderNotifications = {
/** Messages the admin can send manually from the notification center. */
custom: CustomNotification[];
};
/* ==================== Site Administration ==================== */
export type AdminUser = {
id: number;
email: string;
registered_at: string;
is_self: boolean;
email_confirmed: boolean;
is_admin: boolean;
pending_email: string | null;
pending_user_id_change: boolean;
orders_owned: number;
submissions: number;
last_activity: string;
};
export type RegistrationLink = {
id: number;
email: string;
created_at: string;
expires_at: string;
consumed_at: string | null;
};
export type ActiveRegistrationLink = RegistrationLink & { link: string };
export type PreviousRegistrationLink = RegistrationLink & { status: "used" | "expired" };
export type RegistrationLinks = {
active: ActiveRegistrationLink[];
previous: PreviousRegistrationLink[];
};
export type CreatedRegistrationLink = { email: string; link: string };
+7 -7
View File
@@ -1,16 +1,16 @@
import React from "react";
import { Card, Empty, Typography } from "antd";
import UserManagement from "../components/admin/UserManagement";
const TITLES = {
users: "User Management",
orders: "Order Administration",
};
/** 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 />;
}
/** Placeholder for the site-wide admin pages. */
export default function SiteAdminView({ section }: { section: keyof typeof TITLES }) {
return (
<Card>
<Typography.Title level={3}>{TITLES[section]}</Typography.Title>
<Typography.Title level={3}>Order Administration</Typography.Title>
<Empty description="Coming soon" />
</Card>
);