Rework user management into tabs with a single registration links table

- Users and Registration links tabs
- Active, used and expired links in one filterable table
- Register-new-user popover and refresh button in the card header

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:38:44 +01:00
1 parent 485e3aadf6
commit 908756190a
1 file changed
+136 -101
@@ -1,6 +1,6 @@
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 { Alert, Button, Card, Form, Input, Popconfirm, Popover, Space, Table, Tabs, Tag, Tooltip, Typography, message } from "antd";
import type { ColumnsType } from "antd/es/table";
import { apiService } from "../../lib/services";
import { useApiRequest } from "../../hooks/useApiRequest";
@@ -10,6 +10,7 @@ import type {
AdminUser,
CreatedRegistrationLink,
PreviousRegistrationLink,
RegistrationLink,
} from "../../lib/types";
const loadUsers = () => apiService.admin.listUsers();
@@ -28,19 +29,18 @@ async function copyToClipboard(text: string) {
}
}
function RegisterUserCard({ onCreated }: { onCreated: () => void }) {
function RegisterUserButton({ onCreated }: { onCreated: (created: CreatedRegistrationLink) => void }) {
const [form] = Form.useForm<{ email: string }>();
const [open, setOpen] = useState(false);
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()));
onCreated(await apiService.admin.registerUser(email.trim()));
form.resetFields();
onCreated();
setOpen(false);
} catch (error: any) {
setCreated(null);
message.error(error?.message || "Could not register the user.");
} finally {
setSubmitting(false);
@@ -48,42 +48,30 @@ function RegisterUserCard({ onCreated }: { onCreated: () => void }) {
};
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}>
<Popover
trigger="click"
placement="bottomRight"
open={open}
onOpenChange={setOpen}
title="Register new user"
content={
<Form form={form} layout="vertical" onFinish={submit} style={{ width: 280 }}>
<Form.Item
name="email"
rules={[{ required: true, type: "email", message: "Enter a valid email address" }]}
>
<Input placeholder="user@example.com" autoComplete="off" autoFocus />
</Form.Item>
<Button type="primary" htmlType="submit" block 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>
</Form>
}
>
<Button type="primary" icon={<UserAddOutlined />}>
Register new user
</Button>
</Popover>
);
}
@@ -195,7 +183,7 @@ function UsersCard({ users, onReload }: { users: AdminUser[]; onReload: () => vo
return (
<Card
title={`Users (${users.length})`}
title="All users"
extra={<Button icon={<ReloadOutlined />} onClick={onReload} aria-label="Reload users" />}
>
<Table
@@ -227,6 +215,8 @@ function DeleteLinkButton({ id, onDeleted }: { id: number; onDeleted: () => void
);
}
type LinkRow = RegistrationLink & { status: "active" | "used" | "expired"; link?: string };
function RegistrationLinksCard({
active,
previous,
@@ -236,71 +226,99 @@ function RegistrationLinksCard({
previous: PreviousRegistrationLink[];
onChanged: () => void;
}) {
const activeColumns: ColumnsType<ActiveRegistrationLink> = [
const [created, setCreated] = useState<CreatedRegistrationLink | null>(null);
const rows: LinkRow[] = useMemo(
() =>
[...active.map((link) => ({ ...link, status: "active" as const })), ...previous].sort((a, b) =>
b.created_at.localeCompare(a.created_at),
),
[active, previous],
);
const columns: ColumnsType<LinkRow> = [
{ title: "Email", dataIndex: "email" },
{
title: "Status",
dataIndex: "status",
filters: [
{ text: "Active", value: "active" },
{ text: "Used", value: "used" },
{ text: "Expired", value: "expired" },
],
onFilter: (value, row) => row.status === value,
render: (status: LinkRow["status"], row) =>
status === "active" ? (
<Tag color="blue">Active until {formatDateTime(row.expires_at)}</Tag>
) : status === "used" ? (
<Tag color="green">Used {formatDateTime(row.consumed_at)}</Tag>
) : (
<Tag>Expired {formatDateTime(row.expires_at)}</Tag>
),
},
{ 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>
{row.link && (
<Button size="small" icon={<CopyOutlined />} onClick={() => copyToClipboard(row.link as string)}>
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
title="Registration links"
extra={
<Space>
<RegisterUserButton
onCreated={(link) => {
setCreated(link);
onChanged();
}}
/>
<Button icon={<ReloadOutlined />} onClick={onChanged} aria-label="Reload registration links" />
</Space>
}
>
{created && (
<Alert
style={{ marginBottom: 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>
<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>
)}
<Table
rowKey="id"
size="small"
columns={columns}
dataSource={rows}
pagination={{ pageSize: 15, hideOnSinglePage: true }}
locale={{ emptyText: "No registration links" }}
scroll={{ x: "max-content" }}
/>
</Card>
);
}
@@ -319,17 +337,34 @@ export default function UserManagement() {
<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>
<Tabs
items={[
{
key: "users",
label: `Users${users.data ? ` (${users.data.users.length})` : ""}`,
children: (
<AsyncContent loading={users.loading && !users.data} error={users.error} onRetry={users.run}>
<UsersCard users={users.data?.users ?? []} onReload={reloadAll} />
</AsyncContent>
),
},
{
key: "links",
label: "Registration links",
children: (
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
<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>
),
},
]}
/>
</Space>
);
}