Admin UX overhaul: P0 fixes, error-UX foundations, nav cleanup, tenant detail page
From the 2026-07-14 UI/UX audit (17/40). Four phases: P1 — Settings route crashed on Agents→Edit (Input/Textarea used but never imported). Added imports + a shared route-level error boundary (components/route-error.tsx) re-exported from all shell routes, so one crashing panel degrades to an explained card with the nav intact instead of replacing the whole app with a stack trace. Corrected the stale CLAUDE.md claim that `npm run typecheck` crashes — it works, and would have caught the missing import. P2 — Error/empty/feedback foundations. Fixed useSession identity churn that fired ~3x duplicate fetches per screen and self-inflicted 429s (referentially stable snapshot). New lib/errors.ts (describeError → plain-language + the fix) and components/data-state.tsx (DataState renders exactly one of error/loading/empty/content, so a failed load never shows as "empty"; DialogError for in-dialog failures; 429 auto-retry). Rolled across all 15 list routes; every mutation now toasts. Surfaced+fixed two silent-failure bugs (sso + buckets-CORS swallowed load errors; the latter could wipe rules on save). P3 — Nav IA + trust cleanup. Default-expanded rail; regrouped into 7 coherent sections; collapsed the Apps/Plan/Entitlements stub triplication into one honest /billing page; deleted fabricated seeded notifications and the dead Help menu item; fixed the 403 copy (referenced a tenant switcher that doesn't exist); removed orphan /assistant + /library routes; gated dev-seed login hints behind DEV; renamed /activity → /audit-log with a redirect. P4 — Tenant detail page (routes/tenants.$id.tsx + components/tenant-detail/*), closing the provision→configure gap. 8 tabs (Overview, Plan & quotas, Branding, Localization, Email & SMS, Feature flags, IP rules, Inbound webhooks), each verified saving to the real backend. Row name links to detail. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { Link } from "react-router"
|
||||
import {
|
||||
CheckCircle2,
|
||||
Eye,
|
||||
@@ -14,7 +13,8 @@ import {
|
||||
X,
|
||||
} from "lucide-react"
|
||||
|
||||
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-client"
|
||||
import { useArcadiaClient } from "@crema/arcadia-core-client"
|
||||
import { useToast } from "@crema/notification-ui"
|
||||
import {
|
||||
ActionsCell,
|
||||
BadgeCell,
|
||||
@@ -27,17 +27,13 @@ import {
|
||||
type Column,
|
||||
} from "@crema/table-ui"
|
||||
import { SearchInput } from "@crema/search-ui"
|
||||
import { AlertBanner, ConfirmDialog, EmptyState, LoadingOverlay } from "@crema/feedback-ui"
|
||||
import { ConfirmDialog, EmptyState } from "@crema/feedback-ui"
|
||||
|
||||
import { AppShell } from "~/components/layout/app-shell"
|
||||
import { DataState, DialogError } from "~/components/data-state"
|
||||
import { errorMessage } from "~/lib/errors"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card"
|
||||
import { Card, CardContent, CardHeader } from "~/components/ui/card"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -99,44 +95,53 @@ export default function UsersRoute() {
|
||||
const arcadia = useArcadiaClient()
|
||||
|
||||
const [tab, setTab] = useState<Tab>("users")
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [info, setInfo] = useState<string | null>(null)
|
||||
|
||||
// Three independent lists, three independent load errors. A broken /roles
|
||||
// call must not make the users table claim there are no users.
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [usersLoading, setUsersLoading] = useState(true)
|
||||
const [usersError, setUsersError] = useState<unknown>(null)
|
||||
|
||||
const [invitations, setInvitations] = useState<Invitation[]>([])
|
||||
const [invitationsLoading, setInvitationsLoading] = useState(true)
|
||||
const [invitationsError, setInvitationsError] = useState<unknown>(null)
|
||||
|
||||
const [roles, setRoles] = useState<Role[]>([])
|
||||
const [rolesLoading, setRolesLoading] = useState(true)
|
||||
const [rolesError, setRolesError] = useState<unknown>(null)
|
||||
|
||||
const refreshUsers = useCallback(async () => {
|
||||
setUsersError(null)
|
||||
setUsersLoading(true)
|
||||
try {
|
||||
setUsers(await listUsers(arcadia))
|
||||
} catch (err) {
|
||||
setError(err instanceof ArcadiaError ? err.message : "Failed to load users.")
|
||||
// Raw throw: describeError() reads the status off it.
|
||||
setUsersError(err)
|
||||
} finally {
|
||||
setUsersLoading(false)
|
||||
}
|
||||
}, [arcadia])
|
||||
|
||||
const refreshInvitations = useCallback(async () => {
|
||||
setInvitationsError(null)
|
||||
setInvitationsLoading(true)
|
||||
try {
|
||||
setInvitations(await listInvitations(arcadia))
|
||||
} catch (err) {
|
||||
setError(err instanceof ArcadiaError ? err.message : "Failed to load invitations.")
|
||||
setInvitationsError(err)
|
||||
} finally {
|
||||
setInvitationsLoading(false)
|
||||
}
|
||||
}, [arcadia])
|
||||
|
||||
const refreshRoles = useCallback(async () => {
|
||||
setRolesError(null)
|
||||
setRolesLoading(true)
|
||||
try {
|
||||
setRoles(await listRoles(arcadia))
|
||||
} catch (err) {
|
||||
setError(err instanceof ArcadiaError ? err.message : "Failed to load roles.")
|
||||
setRolesError(err)
|
||||
} finally {
|
||||
setRolesLoading(false)
|
||||
}
|
||||
@@ -179,17 +184,6 @@ export default function UsersRoute() {
|
||||
</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">
|
||||
@@ -208,9 +202,8 @@ export default function UsersRoute() {
|
||||
users={users}
|
||||
roles={roles}
|
||||
loading={usersLoading}
|
||||
error={usersError}
|
||||
onRefresh={refreshUsers}
|
||||
onError={setError}
|
||||
onInfo={setInfo}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="invitations">
|
||||
@@ -218,18 +211,16 @@ export default function UsersRoute() {
|
||||
invitations={invitations}
|
||||
roles={roles}
|
||||
loading={invitationsLoading}
|
||||
error={invitationsError}
|
||||
onRefresh={refreshInvitations}
|
||||
onError={setError}
|
||||
onInfo={setInfo}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="roles">
|
||||
<RolesPanel
|
||||
roles={roles}
|
||||
loading={rolesLoading}
|
||||
error={rolesError}
|
||||
onRefresh={refreshRoles}
|
||||
onError={setError}
|
||||
onInfo={setInfo}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
@@ -244,18 +235,17 @@ function UsersPanel({
|
||||
users,
|
||||
roles,
|
||||
loading,
|
||||
error,
|
||||
onRefresh,
|
||||
onError,
|
||||
onInfo,
|
||||
}: {
|
||||
users: User[]
|
||||
roles: Role[]
|
||||
loading: boolean
|
||||
error: unknown
|
||||
onRefresh: () => Promise<void>
|
||||
onError: (msg: string | null) => void
|
||||
onInfo: (msg: string | null) => void
|
||||
}) {
|
||||
const arcadia = useArcadiaClient()
|
||||
const toast = useToast()
|
||||
const [search, setSearch] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | UserStatus>("all")
|
||||
const [editor, setEditor] = useState<{ mode: "create" } | { mode: "edit"; user: User } | null>(null)
|
||||
@@ -344,15 +334,14 @@ function UsersPanel({
|
||||
setEditor,
|
||||
setPendingDelete,
|
||||
setDetailUser,
|
||||
setError: onError,
|
||||
setInfo: onInfo,
|
||||
toast,
|
||||
})}
|
||||
triggerDataAction={`user-${u.id}-actions`}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
[arcadia, onError, onInfo, onRefresh],
|
||||
[arcadia, onRefresh, toast],
|
||||
)
|
||||
|
||||
const table = useTable<User>({
|
||||
@@ -406,37 +395,43 @@ function UsersPanel({
|
||||
</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"
|
||||
<DataState
|
||||
loading={loading}
|
||||
error={error}
|
||||
isEmpty={table.total === 0}
|
||||
onRetry={onRefresh}
|
||||
loadingLabel="Loading users…"
|
||||
empty={
|
||||
<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
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Pagination
|
||||
page={table.page}
|
||||
pageSize={table.pageSize}
|
||||
total={table.total}
|
||||
onPageChange={table.setPage}
|
||||
onPageSizeChange={table.setPageSize}
|
||||
/>
|
||||
</DataState>
|
||||
</CardContent>
|
||||
|
||||
<ConfirmDialog
|
||||
@@ -445,20 +440,22 @@ function UsersPanel({
|
||||
title="Delete user?"
|
||||
description={
|
||||
pendingDelete
|
||||
? `${pendingDelete.email} will be permanently removed. Their objects and audit history remain.`
|
||||
? `${pendingDelete.email} will be permanently removed and can no longer sign in. Their objects and audit history are kept. Suspend instead if this is temporary.`
|
||||
: ""
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
variant="danger"
|
||||
onConfirm={async () => {
|
||||
if (!pendingDelete) return
|
||||
const email = pendingDelete.email
|
||||
try {
|
||||
await deleteUser(arcadia, pendingDelete.id)
|
||||
setPendingDelete(null)
|
||||
await onRefresh()
|
||||
toast.success(`Deleted ${email}`)
|
||||
} catch (err) {
|
||||
onError(err instanceof ArcadiaError ? err.message : "Delete failed.")
|
||||
setPendingDelete(null)
|
||||
toast.error(errorMessage(err, `delete ${email}`))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -467,11 +464,11 @@ function UsersPanel({
|
||||
state={editor}
|
||||
roles={roles}
|
||||
onClose={() => setEditor(null)}
|
||||
onSaved={async () => {
|
||||
onSaved={async (message) => {
|
||||
setEditor(null)
|
||||
await onRefresh()
|
||||
toast.success(message)
|
||||
}}
|
||||
onError={onError}
|
||||
/>
|
||||
|
||||
<UserDetailSheet
|
||||
@@ -504,11 +501,10 @@ function userRowActions(
|
||||
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
|
||||
toast: ReturnType<typeof useToast>
|
||||
},
|
||||
): ActionItem[] {
|
||||
const { arcadia, refresh, setEditor, setPendingDelete, setDetailUser, setError, setInfo } = ctx
|
||||
const { arcadia, refresh, setEditor, setPendingDelete, setDetailUser, toast } = ctx
|
||||
const items: ActionItem[] = []
|
||||
|
||||
items.push({
|
||||
@@ -535,10 +531,10 @@ function userRowActions(
|
||||
onSelect: async () => {
|
||||
try {
|
||||
await setUserStatus(arcadia, u.id, "suspended")
|
||||
setInfo(`${u.email} suspended.`)
|
||||
await refresh()
|
||||
toast.success(`Suspended ${u.email}`)
|
||||
} catch (err) {
|
||||
setError(err instanceof ArcadiaError ? err.message : "Suspend failed.")
|
||||
toast.error(errorMessage(err, `suspend ${u.email}`))
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -551,10 +547,10 @@ function userRowActions(
|
||||
onSelect: async () => {
|
||||
try {
|
||||
await setUserStatus(arcadia, u.id, "active")
|
||||
setInfo(`${u.email} activated.`)
|
||||
await refresh()
|
||||
toast.success(`Activated ${u.email}`)
|
||||
} catch (err) {
|
||||
setError(err instanceof ArcadiaError ? err.message : "Activate failed.")
|
||||
toast.error(errorMessage(err, `activate ${u.email}`))
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -577,13 +573,11 @@ function UserEditorDialog({
|
||||
roles,
|
||||
onClose,
|
||||
onSaved,
|
||||
onError,
|
||||
}: {
|
||||
state: { mode: "create" } | { mode: "edit"; user: User } | null
|
||||
roles: Role[]
|
||||
onClose: () => void
|
||||
onSaved: () => Promise<void>
|
||||
onError: (msg: string | null) => void
|
||||
onSaved: (message: string) => Promise<void>
|
||||
}) {
|
||||
const arcadia = useArcadiaClient()
|
||||
const open = state !== null
|
||||
@@ -597,9 +591,14 @@ function UserEditorDialog({
|
||||
const [password, setPassword] = useState("")
|
||||
const [selectedRoleIds, setSelectedRoleIds] = useState<Set<string>>(new Set())
|
||||
const [saving, setSaving] = useState(false)
|
||||
// The failure belongs where the operator is looking — inside this dialog,
|
||||
// with the form still filled in — not behind the modal scrim.
|
||||
const [error, setError] = useState<unknown>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setError(null)
|
||||
setSaving(false)
|
||||
if (initial) {
|
||||
setEmail(initial.email)
|
||||
setFirstName(initial.first_name ?? "")
|
||||
@@ -627,7 +626,7 @@ function UserEditorDialog({
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
onError(null)
|
||||
setError(null)
|
||||
setSaving(true)
|
||||
try {
|
||||
const input: UserInput = {
|
||||
@@ -637,18 +636,18 @@ function UserEditorDialog({
|
||||
status,
|
||||
role_ids: Array.from(selectedRoleIds),
|
||||
}
|
||||
if (!isEdit && password) input.password = password
|
||||
else if (isEdit && password) input.password = password
|
||||
if (password) input.password = password
|
||||
|
||||
if (isEdit && initial) {
|
||||
await updateUser(arcadia, initial.id, input)
|
||||
await onSaved(`Saved ${initial.email}`)
|
||||
} else {
|
||||
await createUser(arcadia, input)
|
||||
await onSaved(`Created ${email.trim()}`)
|
||||
}
|
||||
await onSaved()
|
||||
} catch (err) {
|
||||
onError(err instanceof ArcadiaError ? err.message : err instanceof Error ? err.message : "Save failed.")
|
||||
} finally {
|
||||
// Keep the dialog open and the form intact so it can be fixed and resubmitted.
|
||||
setError(err)
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
@@ -759,6 +758,10 @@ function UserEditorDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<DialogError error={error} context={isEdit ? "save the user" : "create the user"} />
|
||||
) : null}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={saving} data-action="user-form-cancel">
|
||||
Cancel
|
||||
@@ -783,18 +786,17 @@ function InvitationsPanel({
|
||||
invitations,
|
||||
roles,
|
||||
loading,
|
||||
error,
|
||||
onRefresh,
|
||||
onError,
|
||||
onInfo,
|
||||
}: {
|
||||
invitations: Invitation[]
|
||||
roles: Role[]
|
||||
loading: boolean
|
||||
error: unknown
|
||||
onRefresh: () => Promise<void>
|
||||
onError: (msg: string | null) => void
|
||||
onInfo: (msg: string | null) => void
|
||||
}) {
|
||||
const arcadia = useArcadiaClient()
|
||||
const toast = useToast()
|
||||
const [search, setSearch] = useState("")
|
||||
const [inviteOpen, setInviteOpen] = useState(false)
|
||||
const [pendingRevoke, setPendingRevoke] = useState<Invitation | null>(null)
|
||||
@@ -850,15 +852,14 @@ function InvitationsPanel({
|
||||
arcadia,
|
||||
refresh: onRefresh,
|
||||
setPendingRevoke,
|
||||
setError: onError,
|
||||
setInfo: onInfo,
|
||||
toast,
|
||||
})}
|
||||
triggerDataAction={`invitation-${i.id}-actions`}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
[arcadia, onError, onInfo, onRefresh],
|
||||
[arcadia, onRefresh, toast],
|
||||
)
|
||||
|
||||
const table = useTable<Invitation>({
|
||||
@@ -906,39 +907,43 @@ function InvitationsPanel({
|
||||
</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"
|
||||
<DataState
|
||||
loading={loading}
|
||||
error={error}
|
||||
isEmpty={table.total === 0}
|
||||
onRetry={onRefresh}
|
||||
loadingLabel="Loading invitations…"
|
||||
empty={
|
||||
<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
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Pagination
|
||||
page={table.page}
|
||||
pageSize={table.pageSize}
|
||||
total={table.total}
|
||||
onPageChange={table.setPage}
|
||||
onPageSizeChange={table.setPageSize}
|
||||
/>
|
||||
</DataState>
|
||||
</CardContent>
|
||||
|
||||
<ConfirmDialog
|
||||
@@ -947,21 +952,22 @@ function InvitationsPanel({
|
||||
title="Revoke invitation?"
|
||||
description={
|
||||
pendingRevoke
|
||||
? `${pendingRevoke.email} will no longer be able to accept this invitation.`
|
||||
? `The link sent to ${pendingRevoke.email} stops working immediately, and they can't accept it. You can invite them again later.`
|
||||
: ""
|
||||
}
|
||||
confirmLabel="Revoke"
|
||||
variant="danger"
|
||||
onConfirm={async () => {
|
||||
if (!pendingRevoke) return
|
||||
const email = pendingRevoke.email
|
||||
try {
|
||||
await revokeInvitation(arcadia, pendingRevoke.id)
|
||||
setPendingRevoke(null)
|
||||
onInfo("Invitation revoked.")
|
||||
await onRefresh()
|
||||
toast.success(`Revoked the invitation to ${email}`)
|
||||
} catch (err) {
|
||||
onError(err instanceof ArcadiaError ? err.message : "Revoke failed.")
|
||||
setPendingRevoke(null)
|
||||
toast.error(errorMessage(err, `revoke the invitation to ${email}`))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -970,12 +976,13 @@ function InvitationsPanel({
|
||||
open={inviteOpen}
|
||||
roles={roles}
|
||||
onClose={() => setInviteOpen(false)}
|
||||
onSent={async () => {
|
||||
onSent={async (email) => {
|
||||
setInviteOpen(false)
|
||||
onInfo("Invitation sent.")
|
||||
await onRefresh()
|
||||
toast.success(`Invitation sent to ${email}`, {
|
||||
description: "They'll pick their own password when they accept.",
|
||||
})
|
||||
}}
|
||||
onError={onError}
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
@@ -994,11 +1001,10 @@ function invitationRowActions(
|
||||
arcadia: ReturnType<typeof useArcadiaClient>
|
||||
refresh: () => Promise<void>
|
||||
setPendingRevoke: (i: Invitation | null) => void
|
||||
setError: (msg: string | null) => void
|
||||
setInfo: (msg: string | null) => void
|
||||
toast: ReturnType<typeof useToast>
|
||||
},
|
||||
): ActionItem[] {
|
||||
const { arcadia, refresh, setPendingRevoke, setError, setInfo } = ctx
|
||||
const { arcadia, refresh, setPendingRevoke, toast } = ctx
|
||||
const status = invitationStatus(inv)
|
||||
const items: ActionItem[] = []
|
||||
|
||||
@@ -1011,10 +1017,10 @@ function invitationRowActions(
|
||||
onSelect: async () => {
|
||||
try {
|
||||
await resendInvitation(arcadia, inv.id)
|
||||
setInfo(`Resent invitation to ${inv.email}.`)
|
||||
await refresh()
|
||||
toast.success(`Resent the invitation to ${inv.email}`)
|
||||
} catch (err) {
|
||||
setError(err instanceof ArcadiaError ? err.message : "Resend failed.")
|
||||
toast.error(errorMessage(err, `resend the invitation to ${inv.email}`))
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -1039,37 +1045,37 @@ function InviteDialog({
|
||||
roles,
|
||||
onClose,
|
||||
onSent,
|
||||
onError,
|
||||
}: {
|
||||
open: boolean
|
||||
roles: Role[]
|
||||
onClose: () => void
|
||||
onSent: () => Promise<void>
|
||||
onError: (msg: string | null) => void
|
||||
onSent: (email: string) => Promise<void>
|
||||
}) {
|
||||
const arcadia = useArcadiaClient()
|
||||
const [email, setEmail] = useState("")
|
||||
const [roleId, setRoleId] = useState<string>("")
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<unknown>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setEmail("")
|
||||
setRoleId(roles[0]?.id ?? "")
|
||||
setError(null)
|
||||
setSaving(false)
|
||||
} else {
|
||||
setRoleId((prev) => prev || roles[0]?.id || "")
|
||||
}
|
||||
}, [open, roles])
|
||||
|
||||
const submit = async () => {
|
||||
onError(null)
|
||||
setError(null)
|
||||
setSaving(true)
|
||||
try {
|
||||
await createInvitation(arcadia, { email, role_id: roleId })
|
||||
await onSent()
|
||||
await onSent(email.trim())
|
||||
} catch (err) {
|
||||
onError(err instanceof ArcadiaError ? err.message : err instanceof Error ? err.message : "Invite failed.")
|
||||
} finally {
|
||||
setError(err)
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
@@ -1113,6 +1119,8 @@ function InviteDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <DialogError error={error} context="send the invitation" /> : null}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={saving} data-action="invite-form-cancel">
|
||||
Cancel
|
||||
@@ -1136,17 +1144,16 @@ function InviteDialog({
|
||||
function RolesPanel({
|
||||
roles,
|
||||
loading,
|
||||
error,
|
||||
onRefresh,
|
||||
onError,
|
||||
onInfo,
|
||||
}: {
|
||||
roles: Role[]
|
||||
loading: boolean
|
||||
error: unknown
|
||||
onRefresh: () => Promise<void>
|
||||
onError: (msg: string | null) => void
|
||||
onInfo: (msg: string | null) => void
|
||||
}) {
|
||||
const arcadia = useArcadiaClient()
|
||||
const toast = useToast()
|
||||
const [search, setSearch] = useState("")
|
||||
const [editor, setEditor] = useState<{ mode: "create" } | { mode: "edit"; role: Role } | null>(null)
|
||||
const [pendingDelete, setPendingDelete] = useState<Role | null>(null)
|
||||
@@ -1253,33 +1260,37 @@ function RolesPanel({
|
||||
</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"
|
||||
<DataState
|
||||
loading={loading}
|
||||
error={error}
|
||||
isEmpty={table.total === 0}
|
||||
onRetry={onRefresh}
|
||||
loadingLabel="Loading roles…"
|
||||
empty={
|
||||
<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
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Pagination
|
||||
page={table.page}
|
||||
pageSize={table.pageSize}
|
||||
total={table.total}
|
||||
onPageChange={table.setPage}
|
||||
onPageSizeChange={table.setPageSize}
|
||||
/>
|
||||
</DataState>
|
||||
</CardContent>
|
||||
|
||||
<ConfirmDialog
|
||||
@@ -1288,21 +1299,22 @@ function RolesPanel({
|
||||
title="Delete role?"
|
||||
description={
|
||||
pendingDelete
|
||||
? `Users currently assigned to ${pendingDelete.name} will lose its permissions.`
|
||||
? `${pendingDelete.name} is removed from every user who has it, and they immediately lose its ${pendingDelete.permissions.length} permission${pendingDelete.permissions.length === 1 ? "" : "s"}. This can't be undone.`
|
||||
: ""
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
variant="danger"
|
||||
onConfirm={async () => {
|
||||
if (!pendingDelete) return
|
||||
const name = pendingDelete.name
|
||||
try {
|
||||
await deleteRole(arcadia, pendingDelete.id)
|
||||
setPendingDelete(null)
|
||||
onInfo("Role deleted.")
|
||||
await onRefresh()
|
||||
toast.success(`Deleted ${name}`)
|
||||
} catch (err) {
|
||||
onError(err instanceof ArcadiaError ? err.message : "Delete failed.")
|
||||
setPendingDelete(null)
|
||||
toast.error(errorMessage(err, `delete ${name}`))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -1310,11 +1322,11 @@ function RolesPanel({
|
||||
<RoleEditorDialog
|
||||
state={editor}
|
||||
onClose={() => setEditor(null)}
|
||||
onSaved={async () => {
|
||||
onSaved={async (message) => {
|
||||
setEditor(null)
|
||||
await onRefresh()
|
||||
toast.success(message)
|
||||
}}
|
||||
onError={onError}
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
@@ -1351,12 +1363,10 @@ function RoleEditorDialog({
|
||||
state,
|
||||
onClose,
|
||||
onSaved,
|
||||
onError,
|
||||
}: {
|
||||
state: { mode: "create" } | { mode: "edit"; role: Role } | null
|
||||
onClose: () => void
|
||||
onSaved: () => Promise<void>
|
||||
onError: (msg: string | null) => void
|
||||
onSaved: (message: string) => Promise<void>
|
||||
}) {
|
||||
const arcadia = useArcadiaClient()
|
||||
const open = state !== null
|
||||
@@ -1369,9 +1379,12 @@ function RoleEditorDialog({
|
||||
const [description, setDescription] = useState("")
|
||||
const [permissionsText, setPermissionsText] = useState("")
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<unknown>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setError(null)
|
||||
setSaving(false)
|
||||
if (initial) {
|
||||
setName(initial.name)
|
||||
setSlug(initial.slug)
|
||||
@@ -1386,7 +1399,7 @@ function RoleEditorDialog({
|
||||
}, [open, initial])
|
||||
|
||||
const submit = async () => {
|
||||
onError(null)
|
||||
setError(null)
|
||||
setSaving(true)
|
||||
try {
|
||||
const permissions = permissionsText
|
||||
@@ -1394,12 +1407,15 @@ function RoleEditorDialog({
|
||||
.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()
|
||||
if (isEdit && initial) {
|
||||
await updateRole(arcadia, initial.id, input)
|
||||
await onSaved(`Saved ${name.trim()}`)
|
||||
} else {
|
||||
await createRole(arcadia, input)
|
||||
await onSaved(`Created ${name.trim()}`)
|
||||
}
|
||||
} catch (err) {
|
||||
onError(err instanceof ArcadiaError ? err.message : err instanceof Error ? err.message : "Save failed.")
|
||||
} finally {
|
||||
setError(err)
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
@@ -1468,6 +1484,10 @@ function RoleEditorDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<DialogError error={error} context={isEdit ? "save the role" : "create the role"} />
|
||||
) : null}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} data-action="role-form-cancel">
|
||||
{readOnly ? "Close" : "Cancel"}
|
||||
@@ -1497,3 +1517,5 @@ function countBy<T>(arr: T[], key: (x: T) => string): Record<string, number> {
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"
|
||||
|
||||
Reference in New Issue
Block a user