Add admin page navigation, user search and registration link resend
Build and Push Lunchtime Images (Kaniko) / build-and-push (push) Successful in 1m57s
Build and Push Lunchtime Images (Kaniko) / build-and-push (push) Successful in 1m57s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5c1f4a77ef
commit
cb4e610ea1
6 files changed
+103
-27
No files matched your search
@@ -13,6 +13,7 @@ from .services.admin_service import (
|
||||
create_registration_link,
|
||||
delete_registration_link,
|
||||
delete_user,
|
||||
get_resendable_link,
|
||||
get_resendable_user,
|
||||
list_orders,
|
||||
list_registration_links,
|
||||
@@ -656,6 +657,15 @@ def admin_list_registration_links(_admin_id: str = Depends(require_site_admin))
|
||||
return list_registration_links(conn, get_email_settings()["confirmation_link_base"])
|
||||
|
||||
|
||||
@app.post("/api/admin/registration-links/{link_id}/resend")
|
||||
def admin_resend_registration_link(link_id: int, _admin_id: str = Depends(require_site_admin)) -> dict[str, Any]:
|
||||
with get_connection() as conn:
|
||||
user_id, email, token = get_resendable_link(conn, link_id)
|
||||
|
||||
send_registration_email(user_id, email, token)
|
||||
return {"status": "sent", "message": f"Registration email sent to {email}"}
|
||||
|
||||
|
||||
@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:
|
||||
|
||||
@@ -126,6 +126,23 @@ def delete_registration_link(conn: sqlite3.Connection, link_id: int) -> None:
|
||||
raise HTTPException(status_code=404, detail="Registration link not found")
|
||||
|
||||
|
||||
def get_resendable_link(conn: sqlite3.Connection, link_id: int) -> tuple[str, str, str]:
|
||||
"""(user_id, email, token) of a registration link that is still unused and unexpired."""
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT user_id, email, token, expires_at, consumed_at
|
||||
FROM account_confirmation_tokens
|
||||
WHERE rowid = ? AND action = ?
|
||||
""",
|
||||
(link_id, REGISTER_ACTION),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Registration link not found")
|
||||
if row["consumed_at"] or row["expires_at"] <= now_iso():
|
||||
raise HTTPException(status_code=409, detail="Registration link is already used or expired")
|
||||
return str(row["user_id"]), str(row["email"]), str(row["token"])
|
||||
|
||||
|
||||
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(
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useEffect } from "react";
|
||||
import {
|
||||
CheckOutlined,
|
||||
LogoutOutlined,
|
||||
SettingOutlined,
|
||||
ShoppingOutlined,
|
||||
TeamOutlined,
|
||||
} from "@ant-design/icons";
|
||||
@@ -19,7 +20,7 @@ import {
|
||||
message,
|
||||
} from "antd";
|
||||
|
||||
const ADMIN_LINKS = [
|
||||
export const ADMIN_LINKS = [
|
||||
{ path: "/admin/users", label: "User Management", icon: <TeamOutlined /> },
|
||||
{ path: "/admin/orders", label: "Order Administration", icon: <ShoppingOutlined /> },
|
||||
];
|
||||
@@ -77,27 +78,6 @@ export default function AccountSettingsPopoverContent({
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={0} style={{ width: 300 }}>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Typography.Text type="secondary" strong style={sectionHeaderStyle}>
|
||||
Administration
|
||||
</Typography.Text>
|
||||
{ADMIN_LINKS.map((link) => (
|
||||
<Button
|
||||
key={link.path}
|
||||
type="text"
|
||||
block
|
||||
icon={link.icon}
|
||||
style={menuEntryStyle}
|
||||
onClick={() => onNavigate(link.path)}
|
||||
>
|
||||
{link.label}
|
||||
</Button>
|
||||
))}
|
||||
<Divider style={{ margin: "8px 0" }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Typography.Text type="secondary" strong style={sectionHeaderStyle}>
|
||||
Account settings
|
||||
</Typography.Text>
|
||||
@@ -154,6 +134,18 @@ export default function AccountSettingsPopoverContent({
|
||||
|
||||
<Divider style={{ margin: "8px 0" }} />
|
||||
|
||||
{isAdmin && (
|
||||
<Button
|
||||
type="text"
|
||||
block
|
||||
icon={<SettingOutlined />}
|
||||
style={menuEntryStyle}
|
||||
onClick={() => onNavigate(ADMIN_LINKS[0].path)}
|
||||
>
|
||||
Administration
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Popconfirm
|
||||
title="Log out?"
|
||||
description="This clears your local user token on this device."
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { CopyOutlined, CrownOutlined, DeleteOutlined, MailOutlined, ReloadOutlined, UserAddOutlined } from "@ant-design/icons";
|
||||
import { CopyOutlined, CrownOutlined, DeleteOutlined, MailOutlined, ReloadOutlined, SearchOutlined, UserAddOutlined } from "@ant-design/icons";
|
||||
import { Alert, Button, Card, Form, Input, Popconfirm, Popover, Space, Table, Tabs, Tag, Tooltip, Typography, message } from "antd";
|
||||
import type { ColumnsType, TableProps } from "antd/es/table";
|
||||
import { apiService } from "../../lib/services";
|
||||
@@ -220,6 +220,27 @@ function UsersCard({ users, onReload }: { users: AdminUser[]; onReload: () => vo
|
||||
);
|
||||
}
|
||||
|
||||
function ResendLinkButton({ row }: { row: LinkRow }) {
|
||||
const resend = async () => {
|
||||
try {
|
||||
message.success((await apiService.admin.resendRegistrationLink(row.id)).message);
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || "Could not send the registration email.");
|
||||
}
|
||||
};
|
||||
|
||||
const usable = row.status === "active";
|
||||
return (
|
||||
<Popconfirm title={`Send this registration link to ${row.email}?`} okText="Send" onConfirm={resend} disabled={!usable}>
|
||||
<Tooltip title={usable ? undefined : `Link is already ${row.status}`}>
|
||||
<Button size="small" icon={<MailOutlined />} disabled={!usable}>
|
||||
Resend
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteLinkButton({ id, onDeleted }: { id: number; onDeleted: () => void }) {
|
||||
const remove = async () => {
|
||||
try {
|
||||
@@ -294,6 +315,7 @@ function RegistrationLinksCard({
|
||||
Copy link
|
||||
</Button>
|
||||
)}
|
||||
<ResendLinkButton row={row} />
|
||||
<DeleteLinkButton id={row.id} onDeleted={onChanged} />
|
||||
</Space>
|
||||
),
|
||||
@@ -358,6 +380,15 @@ export default function UserManagement() {
|
||||
setStoredValue(TAB_STORAGE_KEY, key);
|
||||
};
|
||||
|
||||
// One search box for both tabs: both list rows keyed by email address.
|
||||
const [search, setSearch] = useState("");
|
||||
const query = search.trim().toLowerCase();
|
||||
const matches = (...texts: (string | null | undefined)[]) =>
|
||||
!query || texts.some((text) => text?.toLowerCase().includes(query));
|
||||
const visibleUsers = (users.data?.users ?? []).filter((u) => matches(u.email, u.pending_email));
|
||||
const visibleActive = (links.data?.active ?? []).filter((l) => matches(l.email));
|
||||
const visiblePrevious = (links.data?.previous ?? []).filter((l) => matches(l.email));
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="User Management"
|
||||
@@ -374,6 +405,14 @@ export default function UserManagement() {
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
allowClear
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="Search by email"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
style={{ marginBottom: 8, maxWidth: 400 }}
|
||||
/>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={selectTab}
|
||||
@@ -383,7 +422,7 @@ export default function UserManagement() {
|
||||
label: "Users",
|
||||
children: (
|
||||
<AsyncContent loading={users.loading && !users.data} error={users.error} onRetry={users.run}>
|
||||
<UsersCard users={users.data?.users ?? []} onReload={reloadAll} />
|
||||
<UsersCard users={visibleUsers} onReload={reloadAll} />
|
||||
</AsyncContent>
|
||||
),
|
||||
},
|
||||
@@ -393,8 +432,8 @@ export default function UserManagement() {
|
||||
children: (
|
||||
<AsyncContent loading={links.loading && !links.data} error={links.error} onRetry={links.run}>
|
||||
<RegistrationLinksCard
|
||||
active={links.data?.active ?? []}
|
||||
previous={links.data?.previous ?? []}
|
||||
active={visibleActive}
|
||||
previous={visiblePrevious}
|
||||
onChanged={reloadAll}
|
||||
created={created}
|
||||
onDismissCreated={() => setCreated(null)}
|
||||
|
||||
@@ -13,6 +13,8 @@ export const adminService = {
|
||||
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"),
|
||||
resendRegistrationLink: (id: number) =>
|
||||
apiFetch<RegisterAccountResult>(`/api/admin/registration-links/${id}/resend`, { method: "POST" }),
|
||||
deleteRegistrationLink: (id: number) =>
|
||||
apiFetch<{ ok: boolean }>(`/api/admin/registration-links/${id}`, { method: "DELETE" }),
|
||||
};
|
||||
@@ -1,8 +1,24 @@
|
||||
import React from "react";
|
||||
import { Menu } from "antd";
|
||||
import { ADMIN_LINKS } from "../components/account/AccountSettingsPopoverContent";
|
||||
import OrderManagement from "../components/admin/OrderManagement";
|
||||
import UserManagement from "../components/admin/UserManagement";
|
||||
import { navigateTo } from "../lib/routing";
|
||||
|
||||
/** 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" }) {
|
||||
return section === "users" ? <UserManagement /> : <OrderManagement />;
|
||||
return (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 16, alignItems: "flex-start" }}>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[`/admin/${section}`]}
|
||||
items={ADMIN_LINKS.map(({ path, label, icon }) => ({ key: path, label, icon }))}
|
||||
onClick={({ key }) => navigateTo(key)}
|
||||
style={{ flex: "0 0 220px", borderRadius: 12 }}
|
||||
/>
|
||||
<div style={{ flex: "1 1 400px", minWidth: 0 }}>
|
||||
{section === "users" ? <UserManagement /> : <OrderManagement />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user