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:
1 parent
e3f2f44765
commit
7f7e529b16
10 files changed
+675
-14
No files matched your search
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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" }),
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user