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:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user