Files
arcadia-admin/app/routes/users.tsx
jules a286b9cdce aifirst: lift context/agents/tools runtime to lib-aifirst-ui
The mechanism (context surface registry, persona storage + hooks, tool
parser/dispatcher) is now generic and lives in @crema/aifirst-ui/{context,
agents,tools}. This template keeps only the arcadia-shaped configuration:

- agents.ts — owns DEFAULT_AGENTS + legacy/retired migration sets, calls
  configureAgents() at module load, re-exports the runtime
- admin-tools.ts — keeps the 19 arcadia tool definitions, binds the
  runtime via createToolRuntime(TOOLS), re-exports the bound functions
- admin-context.ts — deleted; 18 routes now import directly from
  @crema/aifirst-ui/context

Routes that import from ~/lib/agents and ~/lib/admin-tools are unchanged
(wrapper modules preserve the existing import surface).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 15:18:48 +10:00

1500 lines
43 KiB
TypeScript

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-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<Tab>("users")
const [error, setError] = useState<string | null>(null)
const [info, setInfo] = useState<string | null>(null)
const [users, setUsers] = useState<User[]>([])
const [usersLoading, setUsersLoading] = useState(true)
const [invitations, setInvitations] = useState<Invitation[]>([])
const [invitationsLoading, setInvitationsLoading] = useState(true)
const [roles, setRoles] = useState<Role[]>([])
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 (
<AppShell>
<div className="flex flex-col gap-4">
<header className="flex flex-col gap-2">
<h1 className="text-2xl font-semibold tracking-tight">Users</h1>
<p className="text-sm text-muted-foreground">
Members, pending invitations, and roles for the platform-admin tenant.
</p>
</header>
{error ? (
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
{error}
</AlertBanner>
) : null}
{info ? (
<AlertBanner variant="success" dismissible onDismiss={() => setInfo(null)}>
{info}
</AlertBanner>
) : null}
<Tabs value={tab} onValueChange={(v) => setTab(v as Tab)}>
<TabsList>
<TabsTrigger value="users" data-action="users-tab-users">
Users {users.length ? `(${users.length})` : ""}
</TabsTrigger>
<TabsTrigger value="invitations" data-action="users-tab-invitations">
Invitations {invitations.length ? `(${invitations.length})` : ""}
</TabsTrigger>
<TabsTrigger value="roles" data-action="users-tab-roles">
Roles {roles.length ? `(${roles.length})` : ""}
</TabsTrigger>
</TabsList>
<TabsContent value="users">
<UsersPanel
users={users}
roles={roles}
loading={usersLoading}
onRefresh={refreshUsers}
onError={setError}
onInfo={setInfo}
/>
</TabsContent>
<TabsContent value="invitations">
<InvitationsPanel
invitations={invitations}
roles={roles}
loading={invitationsLoading}
onRefresh={refreshInvitations}
onError={setError}
onInfo={setInfo}
/>
</TabsContent>
<TabsContent value="roles">
<RolesPanel
roles={roles}
loading={rolesLoading}
onRefresh={refreshRoles}
onError={setError}
onInfo={setInfo}
/>
</TabsContent>
</Tabs>
</div>
</AppShell>
)
}
// --- Users tab ----------------------------------------------------------
function UsersPanel({
users,
roles,
loading,
onRefresh,
onError,
onInfo,
}: {
users: User[]
roles: Role[]
loading: boolean
onRefresh: () => Promise<void>
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<User | null>(null)
const [detailUser, setDetailUser] = useState<User | null>(null)
const filtered = useMemo(
() => (statusFilter === "all" ? users : users.filter((u) => u.status === statusFilter)),
[users, statusFilter],
)
const columns = useMemo<Column<User>[]>(
() => [
{
id: "email",
header: "Email",
accessor: "email",
sortable: true,
cell: (u) => (
<span className="flex items-center gap-2">
<span className="font-medium">{u.email}</span>
{u.email_verified ? (
<CheckCircle2 className="size-3.5 text-emerald-500" aria-label="Verified" />
) : null}
</span>
),
},
{
id: "name",
header: "Name",
accessor: "full_name",
sortable: true,
cell: (u) => <span>{u.full_name || "—"}</span>,
},
{
id: "status",
header: "Status",
accessor: "status",
sortable: true,
cell: (u) => <BadgeCell label={u.status} tone={userStatusTone(u.status)} />,
},
{
id: "roles",
header: "Roles",
cell: (u) =>
u.roles.length === 0 ? (
<span className="text-muted-foreground"></span>
) : (
<div className="flex flex-wrap gap-1">
{u.roles.map((r) => (
<Badge key={r.id} variant="secondary" className="font-mono text-xs">
{r.slug}
</Badge>
))}
</div>
),
},
{
id: "last_sign_in",
header: "Last sign-in",
accessor: "last_sign_in_at",
sortable: true,
cell: (u) =>
u.last_sign_in_at ? (
<DateCell value={u.last_sign_in_at} format="short" />
) : (
<span className="text-muted-foreground">never</span>
),
},
{
id: "created",
header: "Created",
accessor: "inserted_at",
sortable: true,
cell: (u) => <DateCell value={u.inserted_at} format="short" />,
},
{
id: "actions",
header: "",
align: "right",
cell: (u) => (
<ActionsCell
items={userRowActions(u, {
arcadia,
refresh: onRefresh,
setEditor,
setPendingDelete,
setDetailUser,
setError: onError,
setInfo: onInfo,
})}
triggerDataAction={`user-${u.id}-actions`}
/>
),
},
],
[arcadia, onError, onInfo, onRefresh],
)
const table = useTable<User>({
data: filtered,
columns,
getRowId: (u) => u.id,
initialPageSize: 25,
initialSearch: search,
})
useEffect(() => {
table.setSearch(search)
}, [search, table])
return (
<Card>
<CardHeader className="flex flex-row flex-wrap items-center gap-3">
<SearchInput
value={search}
onValueChange={setSearch}
placeholder="Search by email or name"
data-action="users-search"
className="max-w-sm flex-1"
/>
<Select value={statusFilter} onValueChange={(v) => setStatusFilter(v as typeof statusFilter)}>
<SelectTrigger className="w-40" data-action="users-status-filter">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="inactive">Inactive</SelectItem>
<SelectItem value="suspended">Suspended</SelectItem>
</SelectContent>
</Select>
<div className="ml-auto flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={onRefresh}
disabled={loading}
data-action="users-refresh"
>
<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />
Refresh
</Button>
<Button size="sm" onClick={() => setEditor({ mode: "create" })} data-action="users-create">
<UserPlus className="size-4" />
New user
</Button>
</div>
</CardHeader>
<CardContent className="relative p-0">
<LoadingOverlay active={loading && users.length === 0} label="Loading users…" />
{table.total === 0 && !loading ? (
<EmptyState
title={search || statusFilter !== "all" ? "No users match those filters." : "No users yet."}
description={
search || statusFilter !== "all"
? "Try a different search or status filter."
: "Invite your first user from the Invitations tab."
}
className="py-12"
/>
) : (
<>
<DataTable
columns={columns}
rows={table.pageRows}
getRowId={(u) => u.id}
sort={table.sort}
onSortToggle={table.toggleSort}
loading={loading && users.length > 0}
stickyHeader
/>
<Pagination
page={table.page}
pageSize={table.pageSize}
total={table.total}
onPageChange={table.setPage}
onPageSizeChange={table.setPageSize}
/>
</>
)}
</CardContent>
<ConfirmDialog
open={pendingDelete !== null}
onOpenChange={(o) => !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)
}
}}
/>
<UserEditorDialog
state={editor}
roles={roles}
onClose={() => setEditor(null)}
onSaved={async () => {
setEditor(null)
await onRefresh()
}}
onError={onError}
/>
<UserDetailSheet
user={detailUser}
roles={roles}
onClose={() => 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,
)
}}
/>
</Card>
)
}
function userStatusTone(status: UserStatus): BadgeTone {
if (status === "active") return "success"
if (status === "suspended") return "danger"
return "default"
}
function userRowActions(
u: User,
ctx: {
arcadia: ReturnType<typeof useArcadiaClient>
refresh: () => Promise<void>
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: <Eye className="size-4" />,
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: <Pause className="size-4" />,
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: <Play className="size-4" />,
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: <Trash2 className="size-4" />,
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<void>
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<UserStatus>("active")
const [password, setPassword] = useState("")
const [selectedRoleIds, setSelectedRoleIds] = useState<Set<string>>(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 (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{isEdit ? "Edit user" : "New user"}</DialogTitle>
<DialogDescription>
{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."}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="user-email">Email</Label>
<Input
id="user-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="user@example.com"
data-action="user-form-email"
disabled={isEdit}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="user-first-name">First name</Label>
<Input
id="user-first-name"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
data-action="user-form-first-name"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="user-last-name">Last name</Label>
<Input
id="user-last-name"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
data-action="user-form-last-name"
/>
</div>
</div>
<div className="flex flex-col gap-1.5">
<Label>Status</Label>
<Select value={status} onValueChange={(v) => setStatus(v as UserStatus)}>
<SelectTrigger data-action="user-form-status">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="inactive">Inactive</SelectItem>
<SelectItem value="suspended">Suspended</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="user-password">
{isEdit ? "Reset password (leave blank to keep current)" : "Password"}
</Label>
<Input
id="user-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={isEdit ? "•••••• (unchanged)" : ""}
data-action="user-form-password"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>Roles</Label>
{roles.length === 0 ? (
<p className="text-xs text-muted-foreground">
No roles defined. Create one in the Roles tab.
</p>
) : (
<div className="flex flex-wrap gap-1.5 rounded-md border p-2">
{roles.map((r) => {
const selected = selectedRoleIds.has(r.id)
return (
<button
key={r.id}
type="button"
data-action={`user-form-role-${r.slug}`}
onClick={() => toggleRole(r.id)}
className={[
"rounded-full border px-2.5 py-1 text-xs font-medium transition-colors",
selected
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:bg-accent",
].join(" ")}
>
{r.name}
</button>
)
})}
</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving} data-action="user-form-cancel">
Cancel
</Button>
<Button
onClick={submit}
disabled={saving || email.trim() === "" || (!isEdit && password.length === 0)}
data-action="user-form-save"
>
{saving ? <RefreshCw className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
{isEdit ? "Save changes" : "Create user"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// --- Invitations tab ----------------------------------------------------
function InvitationsPanel({
invitations,
roles,
loading,
onRefresh,
onError,
onInfo,
}: {
invitations: Invitation[]
roles: Role[]
loading: boolean
onRefresh: () => Promise<void>
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<Invitation | null>(null)
const columns = useMemo<Column<Invitation>[]>(
() => [
{
id: "email",
header: "Email",
accessor: "email",
sortable: true,
cell: (i) => <span className="font-medium">{i.email}</span>,
},
{
id: "role",
header: "Role",
accessor: (i) => i.role.name,
sortable: true,
cell: (i) => (
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">{i.role.slug}</code>
),
},
{
id: "invited_by",
header: "Invited by",
cell: (i) => (
<span className="text-muted-foreground">{i.invited_by?.email ?? "—"}</span>
),
},
{
id: "status",
header: "Status",
cell: (i) => {
const s = invitationStatus(i)
return <BadgeCell label={s} tone={invitationTone(s)} />
},
},
{
id: "expires_at",
header: "Expires",
accessor: "expires_at",
sortable: true,
cell: (i) =>
i.expires_at ? <DateCell value={i.expires_at} format="short" /> : <span></span>,
},
{
id: "actions",
header: "",
align: "right",
cell: (i) => (
<ActionsCell
items={invitationRowActions(i, {
arcadia,
refresh: onRefresh,
setPendingRevoke,
setError: onError,
setInfo: onInfo,
})}
triggerDataAction={`invitation-${i.id}-actions`}
/>
),
},
],
[arcadia, onError, onInfo, onRefresh],
)
const table = useTable<Invitation>({
data: invitations,
columns,
getRowId: (i) => i.id,
initialPageSize: 25,
initialSearch: search,
})
useEffect(() => {
table.setSearch(search)
}, [search, table])
return (
<Card>
<CardHeader className="flex flex-row items-center gap-3">
<SearchInput
value={search}
onValueChange={setSearch}
placeholder="Search by email or role"
data-action="invitations-search"
className="max-w-sm flex-1"
/>
<div className="ml-auto flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={onRefresh}
disabled={loading}
data-action="invitations-refresh"
>
<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />
Refresh
</Button>
<Button
size="sm"
onClick={() => setInviteOpen(true)}
disabled={roles.length === 0}
data-action="invitations-create"
>
<Mail className="size-4" />
Invite user
</Button>
</div>
</CardHeader>
<CardContent className="relative p-0">
<LoadingOverlay active={loading && invitations.length === 0} label="Loading invitations…" />
{table.total === 0 && !loading ? (
<EmptyState
title={search ? "No invitations match." : "No invitations yet."}
description={
search
? "Try a different search."
: roles.length === 0
? "Create a role first, then invite users."
: "Invite your first user."
}
className="py-12"
/>
) : (
<>
<DataTable
columns={columns}
rows={table.pageRows}
getRowId={(i) => i.id}
sort={table.sort}
onSortToggle={table.toggleSort}
loading={loading && invitations.length > 0}
stickyHeader
/>
<Pagination
page={table.page}
pageSize={table.pageSize}
total={table.total}
onPageChange={table.setPage}
onPageSizeChange={table.setPageSize}
/>
</>
)}
</CardContent>
<ConfirmDialog
open={pendingRevoke !== null}
onOpenChange={(o) => !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)
}
}}
/>
<InviteDialog
open={inviteOpen}
roles={roles}
onClose={() => setInviteOpen(false)}
onSent={async () => {
setInviteOpen(false)
onInfo("Invitation sent.")
await onRefresh()
}}
onError={onError}
/>
</Card>
)
}
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<typeof useArcadiaClient>
refresh: () => Promise<void>
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: <Send className="size-4" />,
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: <X className="size-4" />,
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<void>
onError: (msg: string | null) => void
}) {
const arcadia = useArcadiaClient()
const [email, setEmail] = useState("")
const [roleId, setRoleId] = useState<string>("")
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 (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Invite user</DialogTitle>
<DialogDescription>
We'll email a sign-up link. The recipient picks their own password on accept.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="invite-email">Email</Label>
<Input
id="invite-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="new.user@example.com"
data-action="invite-form-email"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>Role</Label>
<Select value={roleId} onValueChange={setRoleId}>
<SelectTrigger data-action="invite-form-role">
<SelectValue />
</SelectTrigger>
<SelectContent>
{roles.map((r) => (
<SelectItem key={r.id} value={r.id}>
{r.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving} data-action="invite-form-cancel">
Cancel
</Button>
<Button
onClick={submit}
disabled={saving || !email || !roleId}
data-action="invite-form-send"
>
{saving ? <RefreshCw className="size-4 animate-spin" /> : <Send className="size-4" />}
Send invite
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// --- Roles tab ----------------------------------------------------------
function RolesPanel({
roles,
loading,
onRefresh,
onError,
onInfo,
}: {
roles: Role[]
loading: boolean
onRefresh: () => Promise<void>
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<Role | null>(null)
const columns = useMemo<Column<Role>[]>(
() => [
{
id: "name",
header: "Name",
accessor: "name",
sortable: true,
cell: (r) => <span className="font-medium">{r.name}</span>,
},
{
id: "slug",
header: "Slug",
accessor: "slug",
sortable: true,
cell: (r) => (
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">{r.slug}</code>
),
},
{
id: "permissions",
header: "Permissions",
cell: (r) =>
r.permissions.length === 0 ? (
<span className="text-muted-foreground"></span>
) : (
<span className="text-muted-foreground">{r.permissions.length}</span>
),
},
{
id: "system",
header: "System",
accessor: "is_system",
sortable: true,
cell: (r) =>
r.is_system ? (
<CheckCircle2 className="size-4 text-emerald-500" />
) : (
<span className="text-muted-foreground"></span>
),
},
{
id: "updated",
header: "Updated",
accessor: "updated_at",
sortable: true,
cell: (r) => <DateCell value={r.updated_at} format="short" />,
},
{
id: "actions",
header: "",
align: "right",
cell: (r) => (
<ActionsCell
items={roleRowActions(r, { setEditor, setPendingDelete })}
triggerDataAction={`role-${r.slug}-actions`}
/>
),
},
],
[],
)
const table = useTable<Role>({
data: roles,
columns,
getRowId: (r) => r.id,
initialPageSize: 25,
initialSearch: search,
})
useEffect(() => {
table.setSearch(search)
}, [search, table])
return (
<Card>
<CardHeader className="flex flex-row items-center gap-3">
<SearchInput
value={search}
onValueChange={setSearch}
placeholder="Search by name or slug"
data-action="roles-search"
className="max-w-sm flex-1"
/>
<div className="ml-auto flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={onRefresh}
disabled={loading}
data-action="roles-refresh"
>
<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />
Refresh
</Button>
<Button size="sm" onClick={() => setEditor({ mode: "create" })} data-action="roles-create">
<Plus className="size-4" />
New role
</Button>
</div>
</CardHeader>
<CardContent className="relative p-0">
<LoadingOverlay active={loading && roles.length === 0} label="Loading roles…" />
{table.total === 0 && !loading ? (
<EmptyState
title={search ? "No roles match." : "No roles yet."}
description={search ? "Try a different search." : "Create your first role."}
className="py-12"
/>
) : (
<>
<DataTable
columns={columns}
rows={table.pageRows}
getRowId={(r) => r.id}
sort={table.sort}
onSortToggle={table.toggleSort}
loading={loading && roles.length > 0}
stickyHeader
/>
<Pagination
page={table.page}
pageSize={table.pageSize}
total={table.total}
onPageChange={table.setPage}
onPageSizeChange={table.setPageSize}
/>
</>
)}
</CardContent>
<ConfirmDialog
open={pendingDelete !== null}
onOpenChange={(o) => !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)
}
}}
/>
<RoleEditorDialog
state={editor}
onClose={() => setEditor(null)}
onSaved={async () => {
setEditor(null)
await onRefresh()
}}
onError={onError}
/>
</Card>
)
}
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: <Trash2 className="size-4" />,
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<void>
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 (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>
{readOnly ? `System role: ${initial?.name}` : isEdit ? "Edit role" : "New role"}
</DialogTitle>
<DialogDescription>
{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)."}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="role-name">Name</Label>
<Input
id="role-name"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={readOnly}
data-action="role-form-name"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="role-slug">Slug</Label>
<Input
id="role-slug"
value={slug}
onChange={(e) => setSlug(e.target.value)}
placeholder="admin, editor, viewer"
disabled={readOnly || isEdit}
data-action="role-form-slug"
/>
</div>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="role-description">Description</Label>
<Input
id="role-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
disabled={readOnly}
data-action="role-form-description"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="role-permissions">Permissions (one per line)</Label>
<Textarea
id="role-permissions"
value={permissionsText}
onChange={(e) => setPermissionsText(e.target.value)}
rows={8}
disabled={readOnly}
placeholder={"users:read\nusers:invite\nstorage:write"}
data-action="role-form-permissions"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} data-action="role-form-cancel">
{readOnly ? "Close" : "Cancel"}
</Button>
{!readOnly ? (
<Button
onClick={submit}
disabled={saving || !name.trim() || !slug.trim()}
data-action="role-form-save"
>
{saving ? <RefreshCw className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
{isEdit ? "Save changes" : "Create role"}
</Button>
) : null}
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// --- helpers ------------------------------------------------------------
function countBy<T>(arr: T[], key: (x: T) => string): Record<string, number> {
return arr.reduce<Record<string, number>>((acc, x) => {
const k = key(x)
acc[k] = (acc[k] ?? 0) + 1
return acc
}, {})
}