Disambiguates the Phoenix/auth client lib from lib-arcadia-agents-client. Dir lib-arcadia-client → lib-arcadia-core-client; alias updated in tsconfig paths, vite config, app.css @source, imports, CI and docs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1500 lines
43 KiB
TypeScript
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-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<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
|
|
}, {})
|
|
}
|