diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx new file mode 100644 index 0000000..86003f2 --- /dev/null +++ b/src/app/admin/users/page.tsx @@ -0,0 +1,50 @@ +import { AppShell } from "@/components/shell/app-shell"; +import { TopBar } from "@/components/shell/top-bar"; +import { AdminTable, AdminCell } from "@/components/admin/admin-table"; +import { NewUserForm, UserRowActions } from "@/components/admin/user-form"; +import { Pill } from "@/components/ui/pill"; +import { loadClientOptions, loadUsers } from "@/lib/data/admin"; + +export default async function AdminUsersPage() { + const [users, clients] = await Promise.all([loadUsers(), loadClientOptions()]); + const clientsById = new Map(clients.map((c) => [c.id, c])); + + return ( + + + +
+ + + + {users.map((user) => ( + + + {user.name || "—"} + {user.active === false && ( + + deactivated + + )} + + {user.email} + + {clientsById.get(user.client)?.name ?? ( + — + )} + + + + {user.role === "owner" ? "Owner" : "Client manager"} + + + + + + + ))} + +
+
+ ); +} \ No newline at end of file diff --git a/src/components/admin/user-form.tsx b/src/components/admin/user-form.tsx new file mode 100644 index 0000000..674cc83 --- /dev/null +++ b/src/components/admin/user-form.tsx @@ -0,0 +1,234 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { Plus, Copy } from "lucide-react"; + +import { + createClientManager, + resetUserPassword, + setUserActive, +} from "@/app/actions/users"; +import type { ClientRecord, UserRecord } from "@/lib/pb/types"; + +/** + * Shows a generated password exactly once. + * + * It is not stored in readable form and cannot be retrieved again. If the + * owner navigates away before copying it, the answer is to issue a new one — + * so this panel is deliberately loud and does not auto-dismiss. + */ +function OneTimePassword({ + email, + password, + onDone, +}: { + email: string; + password: string; + onDone: () => void; +}) { + const [copied, setCopied] = useState(false); + + return ( +
+

+ One-time password for {email} +

+

+ This is shown once and cannot be recovered. Send it to them now, and + have them change it after signing in. +

+
+ + {password} + + + +
+
+ ); +} + +export function NewUserForm({ clients }: { clients: ClientRecord[] }) { + const [open, setOpen] = useState(false); + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [clientId, setClientId] = useState(clients[0]?.id ?? ""); + const [issued, setIssued] = useState<{ email: string; password: string } | null>(null); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + + if (issued) { + return ( + setIssued(null)} + /> + ); + } + + if (clients.length === 0) { + return

Create a client first.

; + } + + function submit(event: React.FormEvent) { + event.preventDefault(); + setError(null); + + startTransition(async () => { + try { + const result = await createClientManager({ name, email, clientId }); + setName(""); + setEmail(""); + setOpen(false); + setIssued(result); + } catch (e) { + setError(e instanceof Error ? e.message : "Could not create that account"); + } + }); + } + + if (!open) { + return ( + + ); + } + + const field = + "w-full rounded-lg border border-border-subtle bg-panel px-3 py-2 text-sm text-text-primary outline-none focus:border-brand"; + + return ( +
+
+ + + +
+ + {error && ( +

+ {error} +

+ )} + +
+ + +
+
+ ); +} + +export function UserRowActions({ user }: { user: UserRecord }) { + const [pending, startTransition] = useTransition(); + const [issued, setIssued] = useState(null); + + if (user.role === "owner") { + return —; + } + + if (issued) { + return ( + + {issued} + + ); + } + + return ( + + + + + ); +} \ No newline at end of file