import { useCallback, useEffect, useMemo, useState } from "react" import { Link } from "react-router" import { CheckCircle2, Eye, Mail, Pause, Play, Plus, RefreshCw, Send, Trash2, UserPlus, X, } from "lucide-react" import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-client" import { ActionsCell, BadgeCell, DataTable, DateCell, Pagination, useTable, type ActionItem, type BadgeTone, type Column, } from "@crema/table-ui" import { SearchInput } from "@crema/search-ui" import { AlertBanner, ConfirmDialog, EmptyState, LoadingOverlay } from "@crema/feedback-ui" import { AppShell } from "~/components/layout/app-shell" import { Button } from "~/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "~/components/ui/card" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "~/components/ui/dialog" import { Input } from "~/components/ui/input" import { Label } from "~/components/ui/label" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "~/components/ui/select" import { Textarea } from "~/components/ui/textarea" import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs" import { Badge } from "~/components/ui/badge" import { createInvitation, invitationStatus, listInvitations, resendInvitation, revokeInvitation, type Invitation, type InvitationStatus, } from "~/lib/arcadia/invitations" import { createRole, deleteRole, listRoles, updateRole, type Role, type RoleInput, } from "~/lib/arcadia/roles" import { createUser, deleteUser, listUsers, setUserStatus, updateUser, type User, type UserInput, type UserStatus, } from "~/lib/arcadia/users" import { pageTitle } from "~/lib/page-meta" import { useSession } from "~/lib/session" import { useRegisterContext } from "@crema/aifirst-ui/context" import { UserDetailSheet } from "~/components/users/user-detail-sheet" export const meta = () => pageTitle("Users") type Tab = "users" | "invitations" | "roles" export default function UsersRoute() { const session = useSession() const arcadia = useArcadiaClient() const [tab, setTab] = useState("users") const [error, setError] = useState(null) const [info, setInfo] = useState(null) const [users, setUsers] = useState([]) const [usersLoading, setUsersLoading] = useState(true) const [invitations, setInvitations] = useState([]) const [invitationsLoading, setInvitationsLoading] = useState(true) const [roles, setRoles] = useState([]) const [rolesLoading, setRolesLoading] = useState(true) const refreshUsers = useCallback(async () => { setUsersLoading(true) try { setUsers(await listUsers(arcadia)) } catch (err) { setError(err instanceof ArcadiaError ? err.message : "Failed to load users.") } finally { setUsersLoading(false) } }, [arcadia]) const refreshInvitations = useCallback(async () => { setInvitationsLoading(true) try { setInvitations(await listInvitations(arcadia)) } catch (err) { setError(err instanceof ArcadiaError ? err.message : "Failed to load invitations.") } finally { setInvitationsLoading(false) } }, [arcadia]) const refreshRoles = useCallback(async () => { setRolesLoading(true) try { setRoles(await listRoles(arcadia)) } catch (err) { setError(err instanceof ArcadiaError ? err.message : "Failed to load roles.") } finally { setRolesLoading(false) } }, [arcadia]) useEffect(() => { if (!session) return refreshUsers() refreshInvitations() refreshRoles() }, [session, refreshUsers, refreshInvitations, refreshRoles]) const summary = useMemo( () => ({ users: { total: users.length, byStatus: countBy(users, (u) => u.status), verified: users.filter((u) => u.email_verified).length, }, invitations: { total: invitations.length, byStatus: countBy(invitations, invitationStatus), }, roles: { total: roles.length, system: roles.filter((r) => r.is_system).length, }, }), [users, invitations, roles], ) useRegisterContext("users", summary) return (

Users

Members, pending invitations, and roles for the platform-admin tenant.

{error ? ( setError(null)}> {error} ) : null} {info ? ( setInfo(null)}> {info} ) : null} setTab(v as Tab)}> Users {users.length ? `(${users.length})` : ""} Invitations {invitations.length ? `(${invitations.length})` : ""} Roles {roles.length ? `(${roles.length})` : ""}
) } // --- Users tab ---------------------------------------------------------- function UsersPanel({ users, roles, loading, onRefresh, onError, onInfo, }: { users: User[] roles: Role[] loading: boolean onRefresh: () => Promise onError: (msg: string | null) => void onInfo: (msg: string | null) => void }) { const arcadia = useArcadiaClient() const [search, setSearch] = useState("") const [statusFilter, setStatusFilter] = useState<"all" | UserStatus>("all") const [editor, setEditor] = useState<{ mode: "create" } | { mode: "edit"; user: User } | null>(null) const [pendingDelete, setPendingDelete] = useState(null) const [detailUser, setDetailUser] = useState(null) const filtered = useMemo( () => (statusFilter === "all" ? users : users.filter((u) => u.status === statusFilter)), [users, statusFilter], ) const columns = useMemo[]>( () => [ { id: "email", header: "Email", accessor: "email", sortable: true, cell: (u) => ( {u.email} {u.email_verified ? ( ) : null} ), }, { id: "name", header: "Name", accessor: "full_name", sortable: true, cell: (u) => {u.full_name || "—"}, }, { id: "status", header: "Status", accessor: "status", sortable: true, cell: (u) => , }, { id: "roles", header: "Roles", cell: (u) => u.roles.length === 0 ? ( ) : (
{u.roles.map((r) => ( {r.slug} ))}
), }, { id: "last_sign_in", header: "Last sign-in", accessor: "last_sign_in_at", sortable: true, cell: (u) => u.last_sign_in_at ? ( ) : ( never ), }, { id: "created", header: "Created", accessor: "inserted_at", sortable: true, cell: (u) => , }, { id: "actions", header: "", align: "right", cell: (u) => ( ), }, ], [arcadia, onError, onInfo, onRefresh], ) const table = useTable({ data: filtered, columns, getRowId: (u) => u.id, initialPageSize: 25, initialSearch: search, }) useEffect(() => { table.setSearch(search) }, [search, table]) return (
{table.total === 0 && !loading ? ( ) : ( <> u.id} sort={table.sort} onSortToggle={table.toggleSort} loading={loading && users.length > 0} stickyHeader /> )} !o && setPendingDelete(null)} title="Delete user?" description={ pendingDelete ? `${pendingDelete.email} will be permanently removed. Their objects and audit history remain.` : "" } confirmLabel="Delete" variant="danger" onConfirm={async () => { if (!pendingDelete) return try { await deleteUser(arcadia, pendingDelete.id) setPendingDelete(null) await onRefresh() } catch (err) { onError(err instanceof ArcadiaError ? err.message : "Delete failed.") setPendingDelete(null) } }} /> setEditor(null)} onSaved={async () => { setEditor(null) await onRefresh() }} onError={onError} /> setDetailUser(null)} onChanged={async () => { await onRefresh() // Re-sync the open detail with the freshly fetched user list. setDetailUser((cur) => cur ? users.find((u) => u.id === cur.id) ?? cur : cur, ) }} />
) } function userStatusTone(status: UserStatus): BadgeTone { if (status === "active") return "success" if (status === "suspended") return "danger" return "default" } function userRowActions( u: User, ctx: { arcadia: ReturnType refresh: () => Promise setEditor: (s: { mode: "edit"; user: User } | null) => void setPendingDelete: (u: User | null) => void setDetailUser: (u: User | null) => void setError: (msg: string | null) => void setInfo: (msg: string | null) => void }, ): ActionItem[] { const { arcadia, refresh, setEditor, setPendingDelete, setDetailUser, setError, setInfo } = ctx const items: ActionItem[] = [] items.push({ id: "view", label: "View", icon: , dataAction: `user-${u.id}-view`, onSelect: () => setDetailUser(u), }) items.push({ id: "edit", label: "Edit", dataAction: `user-${u.id}-edit`, onSelect: () => setEditor({ mode: "edit", user: u }), }) if (u.status === "active") { items.push({ id: "suspend", label: "Suspend", icon: , dataAction: `user-${u.id}-suspend`, onSelect: async () => { try { await setUserStatus(arcadia, u.id, "suspended") setInfo(`${u.email} suspended.`) await refresh() } catch (err) { setError(err instanceof ArcadiaError ? err.message : "Suspend failed.") } }, }) } else { items.push({ id: "activate", label: "Activate", icon: , dataAction: `user-${u.id}-activate`, onSelect: async () => { try { await setUserStatus(arcadia, u.id, "active") setInfo(`${u.email} activated.`) await refresh() } catch (err) { setError(err instanceof ArcadiaError ? err.message : "Activate failed.") } }, }) } items.push({ id: "delete", label: "Delete", icon: , destructive: true, dataAction: `user-${u.id}-delete`, onSelect: () => setPendingDelete(u), }) return items } function UserEditorDialog({ state, roles, onClose, onSaved, onError, }: { state: { mode: "create" } | { mode: "edit"; user: User } | null roles: Role[] onClose: () => void onSaved: () => Promise onError: (msg: string | null) => void }) { const arcadia = useArcadiaClient() const open = state !== null const isEdit = state?.mode === "edit" const initial = isEdit ? state.user : null const [email, setEmail] = useState("") const [firstName, setFirstName] = useState("") const [lastName, setLastName] = useState("") const [status, setStatus] = useState("active") const [password, setPassword] = useState("") const [selectedRoleIds, setSelectedRoleIds] = useState>(new Set()) const [saving, setSaving] = useState(false) useEffect(() => { if (!open) return if (initial) { setEmail(initial.email) setFirstName(initial.first_name ?? "") setLastName(initial.last_name ?? "") setStatus(initial.status) setPassword("") setSelectedRoleIds(new Set(initial.roles.map((r) => r.id))) } else { setEmail("") setFirstName("") setLastName("") setStatus("active") setPassword("") setSelectedRoleIds(new Set()) } }, [open, initial]) const toggleRole = (id: string) => { setSelectedRoleIds((prev) => { const next = new Set(prev) if (next.has(id)) next.delete(id) else next.add(id) return next }) } const submit = async () => { onError(null) setSaving(true) try { const input: UserInput = { email, first_name: firstName || null, last_name: lastName || null, status, role_ids: Array.from(selectedRoleIds), } if (!isEdit && password) input.password = password else if (isEdit && password) input.password = password if (isEdit && initial) { await updateUser(arcadia, initial.id, input) } else { await createUser(arcadia, input) } await onSaved() } catch (err) { onError(err instanceof ArcadiaError ? err.message : err instanceof Error ? err.message : "Save failed.") } finally { setSaving(false) } } return ( !o && onClose()}> {isEdit ? "Edit user" : "New user"} {isEdit ? "Update the user's profile, status, and role assignments." : "Create a user directly. To send a sign-up email, use the Invitations tab instead."}
setEmail(e.target.value)} placeholder="user@example.com" data-action="user-form-email" disabled={isEdit} />
setFirstName(e.target.value)} data-action="user-form-first-name" />
setLastName(e.target.value)} data-action="user-form-last-name" />
setPassword(e.target.value)} placeholder={isEdit ? "•••••• (unchanged)" : ""} data-action="user-form-password" />
{roles.length === 0 ? (

No roles defined. Create one in the Roles tab.

) : (
{roles.map((r) => { const selected = selectedRoleIds.has(r.id) return ( ) })}
)}
) } // --- Invitations tab ---------------------------------------------------- function InvitationsPanel({ invitations, roles, loading, onRefresh, onError, onInfo, }: { invitations: Invitation[] roles: Role[] loading: boolean onRefresh: () => Promise onError: (msg: string | null) => void onInfo: (msg: string | null) => void }) { const arcadia = useArcadiaClient() const [search, setSearch] = useState("") const [inviteOpen, setInviteOpen] = useState(false) const [pendingRevoke, setPendingRevoke] = useState(null) const columns = useMemo[]>( () => [ { id: "email", header: "Email", accessor: "email", sortable: true, cell: (i) => {i.email}, }, { id: "role", header: "Role", accessor: (i) => i.role.name, sortable: true, cell: (i) => ( {i.role.slug} ), }, { id: "invited_by", header: "Invited by", cell: (i) => ( {i.invited_by?.email ?? "—"} ), }, { id: "status", header: "Status", cell: (i) => { const s = invitationStatus(i) return }, }, { id: "expires_at", header: "Expires", accessor: "expires_at", sortable: true, cell: (i) => i.expires_at ? : , }, { id: "actions", header: "", align: "right", cell: (i) => ( ), }, ], [arcadia, onError, onInfo, onRefresh], ) const table = useTable({ data: invitations, columns, getRowId: (i) => i.id, initialPageSize: 25, initialSearch: search, }) useEffect(() => { table.setSearch(search) }, [search, table]) return (
{table.total === 0 && !loading ? ( ) : ( <> i.id} sort={table.sort} onSortToggle={table.toggleSort} loading={loading && invitations.length > 0} stickyHeader /> )} !o && setPendingRevoke(null)} title="Revoke invitation?" description={ pendingRevoke ? `${pendingRevoke.email} will no longer be able to accept this invitation.` : "" } confirmLabel="Revoke" variant="danger" onConfirm={async () => { if (!pendingRevoke) return try { await revokeInvitation(arcadia, pendingRevoke.id) setPendingRevoke(null) onInfo("Invitation revoked.") await onRefresh() } catch (err) { onError(err instanceof ArcadiaError ? err.message : "Revoke failed.") setPendingRevoke(null) } }} /> setInviteOpen(false)} onSent={async () => { setInviteOpen(false) onInfo("Invitation sent.") await onRefresh() }} onError={onError} />
) } function invitationTone(status: InvitationStatus): BadgeTone { if (status === "pending") return "warning" if (status === "accepted") return "success" if (status === "expired") return "default" return "danger" } function invitationRowActions( inv: Invitation, ctx: { arcadia: ReturnType refresh: () => Promise setPendingRevoke: (i: Invitation | null) => void setError: (msg: string | null) => void setInfo: (msg: string | null) => void }, ): ActionItem[] { const { arcadia, refresh, setPendingRevoke, setError, setInfo } = ctx const status = invitationStatus(inv) const items: ActionItem[] = [] if (status === "pending" || status === "expired") { items.push({ id: "resend", label: "Resend", icon: , dataAction: `invitation-${inv.id}-resend`, onSelect: async () => { try { await resendInvitation(arcadia, inv.id) setInfo(`Resent invitation to ${inv.email}.`) await refresh() } catch (err) { setError(err instanceof ArcadiaError ? err.message : "Resend failed.") } }, }) } if (status === "pending") { items.push({ id: "revoke", label: "Revoke", icon: , destructive: true, dataAction: `invitation-${inv.id}-revoke`, onSelect: () => setPendingRevoke(inv), }) } return items } function InviteDialog({ open, roles, onClose, onSent, onError, }: { open: boolean roles: Role[] onClose: () => void onSent: () => Promise onError: (msg: string | null) => void }) { const arcadia = useArcadiaClient() const [email, setEmail] = useState("") const [roleId, setRoleId] = useState("") const [saving, setSaving] = useState(false) useEffect(() => { if (!open) { setEmail("") setRoleId(roles[0]?.id ?? "") } else { setRoleId((prev) => prev || roles[0]?.id || "") } }, [open, roles]) const submit = async () => { onError(null) setSaving(true) try { await createInvitation(arcadia, { email, role_id: roleId }) await onSent() } catch (err) { onError(err instanceof ArcadiaError ? err.message : err instanceof Error ? err.message : "Invite failed.") } finally { setSaving(false) } } return ( !o && onClose()}> Invite user We'll email a sign-up link. The recipient picks their own password on accept.
setEmail(e.target.value)} placeholder="new.user@example.com" data-action="invite-form-email" />
) } // --- Roles tab ---------------------------------------------------------- function RolesPanel({ roles, loading, onRefresh, onError, onInfo, }: { roles: Role[] loading: boolean onRefresh: () => Promise onError: (msg: string | null) => void onInfo: (msg: string | null) => void }) { const arcadia = useArcadiaClient() const [search, setSearch] = useState("") const [editor, setEditor] = useState<{ mode: "create" } | { mode: "edit"; role: Role } | null>(null) const [pendingDelete, setPendingDelete] = useState(null) const columns = useMemo[]>( () => [ { id: "name", header: "Name", accessor: "name", sortable: true, cell: (r) => {r.name}, }, { id: "slug", header: "Slug", accessor: "slug", sortable: true, cell: (r) => ( {r.slug} ), }, { id: "permissions", header: "Permissions", cell: (r) => r.permissions.length === 0 ? ( ) : ( {r.permissions.length} ), }, { id: "system", header: "System", accessor: "is_system", sortable: true, cell: (r) => r.is_system ? ( ) : ( ), }, { id: "updated", header: "Updated", accessor: "updated_at", sortable: true, cell: (r) => , }, { id: "actions", header: "", align: "right", cell: (r) => ( ), }, ], [], ) const table = useTable({ data: roles, columns, getRowId: (r) => r.id, initialPageSize: 25, initialSearch: search, }) useEffect(() => { table.setSearch(search) }, [search, table]) return (
{table.total === 0 && !loading ? ( ) : ( <> r.id} sort={table.sort} onSortToggle={table.toggleSort} loading={loading && roles.length > 0} stickyHeader /> )} !o && setPendingDelete(null)} title="Delete role?" description={ pendingDelete ? `Users currently assigned to ${pendingDelete.name} will lose its permissions.` : "" } confirmLabel="Delete" variant="danger" onConfirm={async () => { if (!pendingDelete) return try { await deleteRole(arcadia, pendingDelete.id) setPendingDelete(null) onInfo("Role deleted.") await onRefresh() } catch (err) { onError(err instanceof ArcadiaError ? err.message : "Delete failed.") setPendingDelete(null) } }} /> setEditor(null)} onSaved={async () => { setEditor(null) await onRefresh() }} onError={onError} />
) } function roleRowActions( r: Role, ctx: { setEditor: (s: { mode: "edit"; role: Role } | null) => void setPendingDelete: (r: Role | null) => void }, ): ActionItem[] { const items: ActionItem[] = [] items.push({ id: "edit", label: r.is_system ? "View" : "Edit", dataAction: `role-${r.slug}-edit`, onSelect: () => ctx.setEditor({ mode: "edit", role: r }), }) if (!r.is_system) { items.push({ id: "delete", label: "Delete", icon: , destructive: true, dataAction: `role-${r.slug}-delete`, onSelect: () => ctx.setPendingDelete(r), }) } return items } function RoleEditorDialog({ state, onClose, onSaved, onError, }: { state: { mode: "create" } | { mode: "edit"; role: Role } | null onClose: () => void onSaved: () => Promise onError: (msg: string | null) => void }) { const arcadia = useArcadiaClient() const open = state !== null const isEdit = state?.mode === "edit" const initial = isEdit ? state.role : null const readOnly = !!initial?.is_system const [name, setName] = useState("") const [slug, setSlug] = useState("") const [description, setDescription] = useState("") const [permissionsText, setPermissionsText] = useState("") const [saving, setSaving] = useState(false) useEffect(() => { if (!open) return if (initial) { setName(initial.name) setSlug(initial.slug) setDescription(initial.description ?? "") setPermissionsText(initial.permissions.join("\n")) } else { setName("") setSlug("") setDescription("") setPermissionsText("") } }, [open, initial]) const submit = async () => { onError(null) setSaving(true) try { const permissions = permissionsText .split(/\r?\n/) .map((s) => s.trim()) .filter(Boolean) const input: RoleInput = { name, slug, description: description || null, permissions } if (isEdit && initial) await updateRole(arcadia, initial.id, input) else await createRole(arcadia, input) await onSaved() } catch (err) { onError(err instanceof ArcadiaError ? err.message : err instanceof Error ? err.message : "Save failed.") } finally { setSaving(false) } } return ( !o && onClose()}> {readOnly ? `System role: ${initial?.name}` : isEdit ? "Edit role" : "New role"} {readOnly ? "System roles are read-only. Their permissions are managed by the platform." : "Permissions are free-text strings; add one per line (e.g. users:invite, storage:read)."}
setName(e.target.value)} disabled={readOnly} data-action="role-form-name" />
setSlug(e.target.value)} placeholder="admin, editor, viewer" disabled={readOnly || isEdit} data-action="role-form-slug" />
setDescription(e.target.value)} disabled={readOnly} data-action="role-form-description" />