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}
+
+ {
+ navigator.clipboard.writeText(password);
+ setCopied(true);
+ }}
+ className="inline-flex items-center gap-1.5 rounded-lg border border-border-subtle bg-panel px-2.5 py-2 text-xs text-text-secondary hover:bg-surface-raised"
+ >
+
+ {copied ? "Copied" : "Copy"}
+
+
+ I have saved it
+
+
+
+ );
+}
+
+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 (
+ setOpen(true)}
+ className="inline-flex items-center gap-1.5 rounded-lg bg-brand px-3 py-1.5 text-sm font-medium text-brand-fg transition-colors hover:bg-brand-hover"
+ >
+
+ New manager
+
+ );
+ }
+
+ 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 (
+
+ );
+}
+
+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 (
+
+
+ startTransition(async () => {
+ const { password } = await resetUserPassword({ userId: user.id });
+ setIssued(password);
+ })
+ }
+ className="text-xs text-text-muted underline-offset-2 hover:text-text-secondary hover:underline disabled:opacity-50"
+ >
+ Reset password
+
+
+ startTransition(async () => {
+ await setUserActive({ userId: user.id, active: !user.active });
+ })
+ }
+ className="text-xs text-text-muted underline-offset-2 hover:text-text-secondary hover:underline disabled:opacity-50"
+ >
+ {user.active ? "Deactivate" : "Reactivate"}
+
+
+ );
+}
\ No newline at end of file