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
+116 -81
@@ -1,6 +1,6 @@
import React, { useMemo, useState } from "react"; import React, { useMemo, useState } from "react";
import { CopyOutlined, CrownOutlined, DeleteOutlined, MailOutlined, ReloadOutlined, UserAddOutlined } from "@ant-design/icons"; 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 type { ColumnsType } from "antd/es/table";
import { apiService } from "../../lib/services"; import { apiService } from "../../lib/services";
import { useApiRequest } from "../../hooks/useApiRequest"; import { useApiRequest } from "../../hooks/useApiRequest";
@@ -10,6 +10,7 @@ import type {
AdminUser, AdminUser,
CreatedRegistrationLink, CreatedRegistrationLink,
PreviousRegistrationLink, PreviousRegistrationLink,
RegistrationLink,
} from "../../lib/types"; } from "../../lib/types";
const loadUsers = () => apiService.admin.listUsers(); 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 [form] = Form.useForm<{ email: string }>();
const [open, setOpen] = useState(false);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [created, setCreated] = useState<CreatedRegistrationLink | null>(null);
const submit = async ({ email }: { email: string }) => { const submit = async ({ email }: { email: string }) => {
setSubmitting(true); setSubmitting(true);
try { try {
setCreated(await apiService.admin.registerUser(email.trim())); onCreated(await apiService.admin.registerUser(email.trim()));
form.resetFields(); form.resetFields();
onCreated(); setOpen(false);
} catch (error: any) { } catch (error: any) {
setCreated(null);
message.error(error?.message || "Could not register the user."); message.error(error?.message || "Could not register the user.");
} finally { } finally {
setSubmitting(false); setSubmitting(false);
@@ -48,42 +48,30 @@ function RegisterUserCard({ onCreated }: { onCreated: () => void }) {
}; };
return ( return (
<Card title="Register new user"> <Popover
<Form form={form} layout="inline" onFinish={submit}> 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 <Form.Item
name="email" name="email"
rules={[{ required: true, type: "email", message: "Enter a valid email address" }]} rules={[{ required: true, type: "email", message: "Enter a valid email address" }]}
style={{ flex: "1 1 280px" }}
> >
<Input placeholder="user@example.com" autoComplete="off" /> <Input placeholder="user@example.com" autoComplete="off" autoFocus />
</Form.Item> </Form.Item>
<Form.Item> <Button type="primary" htmlType="submit" block loading={submitting}>
<Button type="primary" htmlType="submit" icon={<UserAddOutlined />} loading={submitting}>
Create registration link Create registration link
</Button> </Button>
</Form.Item>
</Form> </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>
} }
/> >
)} <Button type="primary" icon={<UserAddOutlined />}>
</Card> Register new user
</Button>
</Popover>
); );
} }
@@ -195,7 +183,7 @@ function UsersCard({ users, onReload }: { users: AdminUser[]; onReload: () => vo
return ( return (
<Card <Card
title={`Users (${users.length})`} title="All users"
extra={<Button icon={<ReloadOutlined />} onClick={onReload} aria-label="Reload users" />} extra={<Button icon={<ReloadOutlined />} onClick={onReload} aria-label="Reload users" />}
> >
<Table <Table
@@ -227,6 +215,8 @@ function DeleteLinkButton({ id, onDeleted }: { id: number; onDeleted: () => void
); );
} }
type LinkRow = RegistrationLink & { status: "active" | "used" | "expired"; link?: string };
function RegistrationLinksCard({ function RegistrationLinksCard({
active, active,
previous, previous,
@@ -236,71 +226,99 @@ function RegistrationLinksCard({
previous: PreviousRegistrationLink[]; previous: PreviousRegistrationLink[];
onChanged: () => void; 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: "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: "Created", dataIndex: "created_at", render: formatDateTime },
{ title: "Expires", dataIndex: "expires_at", render: formatDateTime },
{ {
title: "", title: "",
key: "actions", key: "actions",
align: "right", align: "right",
render: (_value, row) => ( render: (_value, row) => (
<Space> <Space>
<Button size="small" icon={<CopyOutlined />} onClick={() => copyToClipboard(row.link)}> {row.link && (
<Button size="small" icon={<CopyOutlined />} onClick={() => copyToClipboard(row.link as string)}>
Copy link Copy link
</Button> </Button>
)}
<DeleteLinkButton id={row.id} onDeleted={onChanged} /> <DeleteLinkButton id={row.id} onDeleted={onChanged} />
</Space> </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 ( return (
<Space direction="vertical" size="middle" style={{ width: "100%" }}> <Card
<Card title={`Active registration links (${active.length})`}> title="Registration links"
<Table extra={
rowKey="id" <Space>
size="small" <RegisterUserButton
columns={activeColumns} onCreated={(link) => {
dataSource={active} setCreated(link);
pagination={false} onChanged();
locale={{ emptyText: "No active registration links" }} }}
scroll={{ x: "max-content" }}
/> />
</Card> <Button icon={<ReloadOutlined />} onClick={onChanged} aria-label="Reload registration links" />
<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> </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>
}
/>
)}
<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,10 +337,22 @@ export default function UserManagement() {
<Typography.Title level={3} style={{ margin: 0 }}> <Typography.Title level={3} style={{ margin: 0 }}>
User Management User Management
</Typography.Title> </Typography.Title>
<RegisterUserCard onCreated={reloadAll} /> <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}> <AsyncContent loading={users.loading && !users.data} error={users.error} onRetry={users.run}>
<UsersCard users={users.data?.users ?? []} onReload={reloadAll} /> <UsersCard users={users.data?.users ?? []} onReload={reloadAll} />
</AsyncContent> </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}> <AsyncContent loading={links.loading && !links.data} error={links.error} onRetry={links.run}>
<RegistrationLinksCard <RegistrationLinksCard
active={links.data?.active ?? []} active={links.data?.active ?? []}
@@ -331,5 +361,10 @@ export default function UserManagement() {
/> />
</AsyncContent> </AsyncContent>
</Space> </Space>
),
},
]}
/>
</Space>
); );
} }