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:
jules
2026-07-14 13:43:57 +10:00
parent 938143f3f5
commit 7415b40240
51 changed files with 5923 additions and 4575 deletions

View File

@@ -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"