create-astram-app 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +211 -38
  2. package/dist/add-frontend.js.map +1 -1
  3. package/dist/index.js +14 -9
  4. package/dist/index.js.map +1 -1
  5. package/package.json +2 -2
  6. package/template/README.md +51 -20
  7. package/template/frontend/README.md +21 -11
  8. package/template/frontend/index.html +16 -0
  9. package/template/frontend/package.json +1 -1
  10. package/template/frontend/src/App.jsx +82 -20
  11. package/template/frontend/src/auth/AuthContext.jsx +139 -21
  12. package/template/frontend/src/auth/routeGate.js +31 -0
  13. package/template/frontend/src/auth/routeGate.test.mjs +59 -0
  14. package/template/frontend/src/auth/sessionFlow.js +27 -0
  15. package/template/frontend/src/auth/sessionFlow.test.mjs +63 -0
  16. package/template/frontend/src/components/EntityList.jsx +1 -7
  17. package/template/frontend/src/components/PageHeader.jsx +5 -4
  18. package/template/frontend/src/components/ShareDialog.jsx +3 -5
  19. package/template/frontend/src/components/access/CollapsibleGroup.jsx +4 -1
  20. package/template/frontend/src/components/ui/alert-dialog.tsx +27 -50
  21. package/template/frontend/src/components/ui/alert.tsx +17 -23
  22. package/template/frontend/src/components/ui/avatar.tsx +8 -20
  23. package/template/frontend/src/components/ui/badge.tsx +17 -26
  24. package/template/frontend/src/components/ui/button.tsx +22 -24
  25. package/template/frontend/src/components/ui/card.tsx +19 -36
  26. package/template/frontend/src/components/ui/checkbox.tsx +7 -10
  27. package/template/frontend/src/components/ui/date-field.tsx +26 -27
  28. package/template/frontend/src/components/ui/dialog.tsx +24 -40
  29. package/template/frontend/src/components/ui/dropdown-menu.tsx +25 -54
  30. package/template/frontend/src/components/ui/input-otp.tsx +1 -4
  31. package/template/frontend/src/components/ui/label.tsx +10 -12
  32. package/template/frontend/src/components/ui/searchable-select.tsx +1 -3
  33. package/template/frontend/src/components/ui/select.tsx +31 -43
  34. package/template/frontend/src/components/ui/separator.tsx +6 -6
  35. package/template/frontend/src/components/ui/sheet.tsx +29 -38
  36. package/template/frontend/src/components/ui/skeleton.tsx +3 -3
  37. package/template/frontend/src/components/ui/sonner.tsx +7 -7
  38. package/template/frontend/src/components/ui/table.tsx +56 -70
  39. package/template/frontend/src/components/ui/tooltip.tsx +7 -11
  40. package/template/frontend/src/config/branding.js +1 -4
  41. package/template/frontend/src/hooks/useMobile.tsx +4 -6
  42. package/template/frontend/src/index.css +5 -2
  43. package/template/frontend/src/pages/Dashboard.jsx +13 -7
  44. package/template/frontend/src/pages/ForgotPassword.jsx +9 -2
  45. package/template/frontend/src/pages/Login.jsx +85 -70
  46. package/template/frontend/src/pages/ResetPassword.jsx +9 -2
  47. package/template/frontend/src/pages/access/AccessForm.jsx +49 -15
  48. package/template/frontend/src/pages/access/AccessPage.jsx +10 -6
  49. package/template/frontend/src/pages/access/RecordPoliciesPage.jsx +9 -2
  50. package/template/frontend/src/pages/account/AccountLayout.jsx +11 -6
  51. package/template/frontend/src/pages/account/ChangePasswordPage.jsx +2 -2
  52. package/template/frontend/src/pages/account/ReenrollPage.jsx +3 -5
  53. package/template/frontend/src/pages/account/TwoFactorPage.jsx +3 -8
  54. package/template/frontend/src/pages/companies/CompaniesPage.jsx +7 -9
  55. package/template/frontend/src/pages/cross-tenant/GrantForm.jsx +41 -8
  56. package/template/frontend/src/pages/cross-tenant/GrantsPage.jsx +30 -7
  57. package/template/frontend/src/pages/cross-tenant/InboxPage.jsx +12 -3
  58. package/template/frontend/src/pages/roles/RolesPage.jsx +5 -9
  59. package/template/frontend/src/pages/service-accounts/ServiceAccountsPage.jsx +77 -18
  60. package/template/frontend/src/pages/service-accounts/accessModel.js +22 -0
  61. package/template/frontend/src/pages/service-accounts/accessModel.test.mjs +28 -0
  62. package/template/frontend/src/pages/users/MembershipCard.jsx +3 -1
  63. package/template/frontend/src/pages/users/PostingRow.jsx +8 -14
  64. package/template/frontend/src/pages/users/UsersPage.jsx +7 -9
  65. package/template/frontend/src/platform/httpClient.js +5 -0
  66. package/template/frontend/src/platform/sessionStore.js +55 -13
  67. package/template/frontend/src/platform/sessionStore.test.mjs +89 -0
  68. package/template/frontend/src/platform/shieldTransport.js +6 -0
  69. package/template/frontend/src/platform/shieldTransport.test.mjs +84 -5
  70. package/template/frontend/src/shell/AppShell.jsx +10 -12
  71. package/template/frontend/src/shell/Sidebar.jsx +15 -3
  72. package/template/frontend/src/shell/nav.js +1 -3
  73. package/template/frontend/vite.config.js +19 -1
  74. package/template/server/_env.example +22 -1
  75. package/template/server/eslint.config.mjs +194 -0
  76. package/template/server/package.json +11 -11
  77. package/template/server/scripts/dev.mjs +17 -6
  78. package/template/server/scripts/sync-astram-guide.mjs +45 -19
  79. package/template/server/src/app.module.ts +11 -5
  80. package/template/server/src/health/demo.controller.ts +16 -12
  81. package/template/server/src/main.ts +23 -1
  82. package/template/server/eslint.config.js +0 -95
@@ -1,7 +1,7 @@
1
- import * as React from "react";
2
- import * as TooltipPrimitive from "@radix-ui/react-tooltip";
1
+ import * as React from 'react';
2
+ import * as TooltipPrimitive from '@radix-ui/react-tooltip';
3
3
 
4
- import { cn } from "@/lib/utils";
4
+ import { cn } from '@/lib/utils';
5
5
 
6
6
  function TooltipProvider({
7
7
  delayDuration = 0,
@@ -16,9 +16,7 @@ function TooltipProvider({
16
16
  );
17
17
  }
18
18
 
19
- function Tooltip({
20
- ...props
21
- }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
19
+ function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
22
20
  return (
23
21
  <TooltipProvider>
24
22
  <TooltipPrimitive.Root data-slot="tooltip" {...props} />
@@ -26,9 +24,7 @@ function Tooltip({
26
24
  );
27
25
  }
28
26
 
29
- function TooltipTrigger({
30
- ...props
31
- }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
27
+ function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
32
28
  return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
33
29
  }
34
30
 
@@ -44,8 +40,8 @@ function TooltipContent({
44
40
  data-slot="tooltip-content"
45
41
  sideOffset={sideOffset}
46
42
  className={cn(
47
- "bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
48
- className
43
+ 'bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance',
44
+ className,
49
45
  )}
50
46
  {...props}
51
47
  >
@@ -43,10 +43,7 @@ export const branding = {
43
43
  * `VITE_APP_SUB_TAGLINE`); falls back to these static strings when the env is unset.
44
44
  */
45
45
  tagline: envString(import.meta.env.VITE_APP_TAGLINE, 'Welcome back'),
46
- subTagline: envString(
47
- import.meta.env.VITE_APP_SUB_TAGLINE,
48
- 'Sign in to your account',
49
- ),
46
+ subTagline: envString(import.meta.env.VITE_APP_SUB_TAGLINE, 'Sign in to your account'),
50
47
  poweredBy: 'Astram',
51
48
 
52
49
  /**
@@ -1,20 +1,18 @@
1
- import * as React from "react";
1
+ import * as React from 'react';
2
2
 
3
3
  const MOBILE_BREAKPOINT = 768;
4
4
 
5
5
  export function useIsMobile() {
6
- const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
7
- undefined
8
- );
6
+ const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);
9
7
 
10
8
  React.useEffect(() => {
11
9
  const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
12
10
  const onChange = () => {
13
11
  setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
14
12
  };
15
- mql.addEventListener("change", onChange);
13
+ mql.addEventListener('change', onChange);
16
14
  setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
17
- return () => mql.removeEventListener("change", onChange);
15
+ return () => mql.removeEventListener('change', onChange);
18
16
  }, []);
19
17
 
20
18
  return !!isMobile;
@@ -182,8 +182,11 @@
182
182
  @layer components {
183
183
  /* Subtle dot-grid for auth/content backgrounds (astram-tinted). */
184
184
  .grid-bg {
185
- background-image:
186
- radial-gradient(circle at 1px 1px, color-mix(in oklch, var(--primary) 12%, transparent) 1px, transparent 0);
185
+ background-image: radial-gradient(
186
+ circle at 1px 1px,
187
+ color-mix(in oklch, var(--primary) 12%, transparent) 1px,
188
+ transparent 0
189
+ );
187
190
  background-size: 22px 22px;
188
191
  }
189
192
 
@@ -53,7 +53,8 @@ export default function Dashboard() {
53
53
  <div className="mb-4">
54
54
  <h1 className="text-xl font-bold tracking-tight">Dashboard</h1>
55
55
  <p className="mt-0.5 text-sm text-muted-foreground">
56
- You are signed in. Below, the app calls a protected API route as you — the framework enforces it.
56
+ You are signed in. Below, the app calls a protected API route as you — the framework
57
+ enforces it.
57
58
  </p>
58
59
  </div>
59
60
 
@@ -67,10 +68,11 @@ export default function Dashboard() {
67
68
  </CardHeader>
68
69
  <CardContent className="space-y-3">
69
70
  <p className="text-sm text-muted-foreground">
70
- <code className="rounded bg-muted px-1 py-0.5 text-xs">GET /identity/companies</code> is a{' '}
71
- <code className="rounded bg-muted px-1 py-0.5 text-xs">@Classified</code> route — the framework
72
- resolves your bearer token to a principal and runs <code className="rounded bg-muted px-1 py-0.5 text-xs">decide()</code>{' '}
73
- before the handler.
71
+ <code className="rounded bg-muted px-1 py-0.5 text-xs">GET /identity/companies</code>{' '}
72
+ is a <code className="rounded bg-muted px-1 py-0.5 text-xs">@Classified</code> route —
73
+ the framework resolves your bearer token to a principal and runs{' '}
74
+ <code className="rounded bg-muted px-1 py-0.5 text-xs">decide()</code> before the
75
+ handler.
74
76
  </p>
75
77
  <div className="flex flex-wrap gap-2">
76
78
  <Button variant="outline" size="sm" onClick={() => load(true)}>
@@ -104,7 +106,9 @@ export default function Dashboard() {
104
106
  </CardTitle>
105
107
  </CardHeader>
106
108
  <CardContent>
107
- {status === 'loading' && <p className="py-6 text-center text-sm text-muted-foreground">Loading…</p>}
109
+ {status === 'loading' && (
110
+ <p className="py-6 text-center text-sm text-muted-foreground">Loading…</p>
111
+ )}
108
112
  {status !== 'loading' && rows.length === 0 && (
109
113
  <p className="py-6 text-center text-sm text-muted-foreground">
110
114
  No companies visible (or the call was denied).
@@ -115,7 +119,9 @@ export default function Dashboard() {
115
119
  {i > 0 && <Separator />}
116
120
  <div className="flex items-center justify-between py-2.5 text-sm">
117
121
  <span className="font-medium">{r.fields?.name ?? '(unnamed)'}</span>
118
- <span className="font-mono text-xs text-muted-foreground">{r.fields?.status ?? ''}</span>
122
+ <span className="font-mono text-xs text-muted-foreground">
123
+ {r.fields?.status ?? ''}
124
+ </span>
119
125
  </div>
120
126
  </div>
121
127
  ))}
@@ -47,7 +47,11 @@ export default function ForgotPassword() {
47
47
  <CardContent className="p-8">
48
48
  <div className="mb-7 flex flex-col items-center gap-4">
49
49
  {/* Brand logo — color in light, white in dark (from branding config). */}
50
- <img src={branding.logo.light} alt={branding.appName} className="h-9 w-auto dark:hidden" />
50
+ <img
51
+ src={branding.logo.light}
52
+ alt={branding.appName}
53
+ className="h-9 w-auto dark:hidden"
54
+ />
51
55
  <img
52
56
  src={branding.logo.dark}
53
57
  alt={branding.appName}
@@ -92,7 +96,10 @@ export default function ForgotPassword() {
92
96
  )}
93
97
 
94
98
  <div className="mt-6 text-center">
95
- <Link to="/login" className="inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline">
99
+ <Link
100
+ to="/login"
101
+ className="inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
102
+ >
96
103
  <ArrowLeft className="size-4" />
97
104
  Back to sign in
98
105
  </Link>
@@ -171,7 +171,11 @@ export default function Login() {
171
171
  <CardContent className="p-8">
172
172
  {/* Brand logo — color in light, white in dark (from branding config). */}
173
173
  <div className="mb-6 flex justify-center">
174
- <img src={branding.logo.light} alt={branding.appName} className="h-9 w-auto dark:hidden" />
174
+ <img
175
+ src={branding.logo.light}
176
+ alt={branding.appName}
177
+ className="h-9 w-auto dark:hidden"
178
+ />
175
179
  <img
176
180
  src={branding.logo.dark}
177
181
  alt={branding.appName}
@@ -306,85 +310,96 @@ export default function Login() {
306
310
  </button>
307
311
  </form>
308
312
  ) : (
309
- <form onSubmit={onSubmit} className="space-y-4" noValidate>
310
- <div className="space-y-1.5">
311
- <label
312
- htmlFor="identityHint"
313
- className="block text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"
314
- >
315
- Email or username
316
- </label>
317
- <Input
318
- id="identityHint"
319
- autoComplete="username"
320
- autoFocus
321
- value={identityHint}
322
- onChange={(e) => setIdentityHint(e.target.value)}
323
- onKeyDown={onFieldKeyDown}
324
- placeholder="you@example.com"
325
- className="h-11 bg-secondary/60"
326
- />
327
- </div>
328
-
329
- <div className="space-y-1.5">
330
- <label
331
- htmlFor="password"
332
- className="block text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"
333
- >
334
- Password
335
- </label>
336
- <div className="relative">
313
+ <form onSubmit={onSubmit} className="space-y-4" noValidate>
314
+ <div className="space-y-1.5">
315
+ <label
316
+ htmlFor="identityHint"
317
+ className="block text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"
318
+ >
319
+ Email or username
320
+ </label>
337
321
  <Input
338
- id="password"
339
- type={showPw ? 'text' : 'password'}
340
- autoComplete="current-password"
341
- value={password}
342
- onChange={(e) => setPassword(e.target.value)}
322
+ id="identityHint"
323
+ autoComplete="username"
324
+ autoFocus
325
+ value={identityHint}
326
+ onChange={(e) => setIdentityHint(e.target.value)}
343
327
  onKeyDown={onFieldKeyDown}
344
- placeholder="••••••••"
345
- className="h-11 bg-secondary/60 pr-10"
328
+ placeholder="you@example.com"
329
+ className="h-11 bg-secondary/60"
346
330
  />
347
- <button
348
- type="button"
349
- onClick={() => setShowPw((s) => !s)}
350
- className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-muted-foreground hover:text-foreground"
351
- aria-label={showPw ? 'Hide password' : 'Show password'}
331
+ </div>
332
+
333
+ <div className="space-y-1.5">
334
+ <label
335
+ htmlFor="password"
336
+ className="block text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"
352
337
  >
353
- {showPw ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
354
- </button>
338
+ Password
339
+ </label>
340
+ <div className="relative">
341
+ <Input
342
+ id="password"
343
+ type={showPw ? 'text' : 'password'}
344
+ autoComplete="current-password"
345
+ value={password}
346
+ onChange={(e) => setPassword(e.target.value)}
347
+ onKeyDown={onFieldKeyDown}
348
+ placeholder="••••••••"
349
+ className="h-11 bg-secondary/60 pr-10"
350
+ />
351
+ <button
352
+ type="button"
353
+ onClick={() => setShowPw((s) => !s)}
354
+ className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-muted-foreground hover:text-foreground"
355
+ aria-label={showPw ? 'Hide password' : 'Show password'}
356
+ >
357
+ {showPw ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
358
+ </button>
359
+ </div>
355
360
  </div>
356
- </div>
357
361
 
358
- {/* Remember me + Forgot password, inline below the password. */}
359
- <div className="flex items-center justify-between pt-0.5">
360
- <label htmlFor="remember" className="flex items-center gap-2 text-sm text-muted-foreground">
361
- <Checkbox id="remember" checked={remember} onCheckedChange={(v) => setRemember(Boolean(v))} />
362
- Remember me
363
- </label>
364
- <Link to="/forgot-password" className="text-sm font-medium text-primary hover:underline">
365
- Forgot password?
366
- </Link>
367
- </div>
362
+ {/* Remember me + Forgot password, inline below the password. */}
363
+ <div className="flex items-center justify-between pt-0.5">
364
+ <label
365
+ htmlFor="remember"
366
+ className="flex items-center gap-2 text-sm text-muted-foreground"
367
+ >
368
+ <Checkbox
369
+ id="remember"
370
+ checked={remember}
371
+ onCheckedChange={(v) => setRemember(Boolean(v))}
372
+ />
373
+ Remember me
374
+ </label>
375
+ <Link
376
+ to="/forgot-password"
377
+ className="text-sm font-medium text-primary hover:underline"
378
+ >
379
+ Forgot password?
380
+ </Link>
381
+ </div>
368
382
 
369
- <Button
370
- type="submit"
371
- disabled={busy}
372
- className="h-11 w-full bg-primary font-mono text-xs font-semibold uppercase tracking-widest text-primary-foreground hover:bg-primary/90"
373
- >
374
- {busy ? (
375
- <Loader2 className="size-4 animate-spin" />
376
- ) : (
377
- <>
378
- <LogIn className="size-4" />
379
- Log in
380
- </>
381
- )}
382
- </Button>
383
- </form>
383
+ <Button
384
+ type="submit"
385
+ disabled={busy}
386
+ className="h-11 w-full bg-primary font-mono text-xs font-semibold uppercase tracking-widest text-primary-foreground hover:bg-primary/90"
387
+ >
388
+ {busy ? (
389
+ <Loader2 className="size-4 animate-spin" />
390
+ ) : (
391
+ <>
392
+ <LogIn className="size-4" />
393
+ Log in
394
+ </>
395
+ )}
396
+ </Button>
397
+ </form>
384
398
  )}
385
399
 
386
400
  <p className="mt-6 text-center text-[11px] tracking-wide text-muted-foreground">
387
- Powered by <span className="font-semibold text-foreground/70">{branding.poweredBy}</span>
401
+ Powered by{' '}
402
+ <span className="font-semibold text-foreground/70">{branding.poweredBy}</span>
388
403
  </p>
389
404
  </CardContent>
390
405
  </Card>
@@ -78,7 +78,11 @@ export default function ResetPassword() {
78
78
  <CardContent className="p-8">
79
79
  <div className="mb-7 flex flex-col items-center gap-4">
80
80
  {/* Brand logo — color in light, white in dark (from branding config). */}
81
- <img src={branding.logo.light} alt={branding.appName} className="h-9 w-auto dark:hidden" />
81
+ <img
82
+ src={branding.logo.light}
83
+ alt={branding.appName}
84
+ className="h-9 w-auto dark:hidden"
85
+ />
82
86
  <img
83
87
  src={branding.logo.dark}
84
88
  alt={branding.appName}
@@ -147,7 +151,10 @@ export default function ResetPassword() {
147
151
  )}
148
152
 
149
153
  <div className="mt-6 text-center">
150
- <Link to="/login" className="inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline">
154
+ <Link
155
+ to="/login"
156
+ className="inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
157
+ >
151
158
  <ArrowLeft className="size-4" />
152
159
  Back to sign in
153
160
  </Link>
@@ -27,7 +27,10 @@ const ACTIONS = ['read', 'create', 'update', 'delete'];
27
27
 
28
28
  // Object → group mapping. An unmapped typeKey falls into "Other" (fail-safe).
29
29
  const GROUPS = [
30
- { label: 'Organization', types: ['company', 'company_branch', 'company_department', 'department', 'branch'] },
30
+ {
31
+ label: 'Organization',
32
+ types: ['company', 'company_branch', 'company_department', 'department', 'branch'],
33
+ },
31
34
  { label: 'People & Access', types: ['user', 'role', 'company_membership', 'membership_posting'] },
32
35
  ];
33
36
 
@@ -66,7 +69,10 @@ export default function AccessForm() {
66
69
  }, [catalog]);
67
70
  const groups = useMemo(() => {
68
71
  const mapped = new Set(GROUPS.flatMap((g) => g.types));
69
- const out = GROUPS.map((g) => ({ label: g.label, types: g.types.filter((t) => typeKeys.includes(t)) }));
72
+ const out = GROUPS.map((g) => ({
73
+ label: g.label,
74
+ types: g.types.filter((t) => typeKeys.includes(t)),
75
+ }));
70
76
  const other = typeKeys.filter((t) => !mapped.has(t));
71
77
  if (other.length) out.push({ label: 'Other', types: other });
72
78
  return out.filter((g) => g.types.length > 0);
@@ -90,9 +96,18 @@ export default function AccessForm() {
90
96
  // Paginated + searchable role fetcher for the SearchableSelect.
91
97
  const fetchRoles = useCallback(
92
98
  async ({ search, page, limit }) => {
93
- const r = await identity.listRoles(token, { page, limit, search, sortBy: 'name', sortOrder: 'asc' });
99
+ const r = await identity.listRoles(token, {
100
+ page,
101
+ limit,
102
+ search,
103
+ sortBy: 'name',
104
+ sortOrder: 'asc',
105
+ });
94
106
  if (!r.ok) return { options: [], total: 0, page };
95
- const options = (r.rows || []).map((row) => ({ value: row.id, label: row.fields?.name || '(unnamed)' }));
107
+ const options = (r.rows || []).map((row) => ({
108
+ value: row.id,
109
+ label: row.fields?.name || '(unnamed)',
110
+ }));
96
111
  return { options, total: r.total ?? options.length, page: r.page ?? page };
97
112
  },
98
113
  [identity, token],
@@ -107,7 +122,9 @@ export default function AccessForm() {
107
122
  const [catRes, bundlesRes, grantsRes, roleRes] = await Promise.all([
108
123
  identity.listPermissionCatalog(token),
109
124
  identity.listAccessBundles(token),
110
- sourceUuid ? identity.listRolePermissions(token, sourceUuid) : Promise.resolve({ ok: true, grants: [] }),
125
+ sourceUuid
126
+ ? identity.listRolePermissions(token, sourceUuid)
127
+ : Promise.resolve({ ok: true, grants: [] }),
111
128
  // In EDIT mode the role is fixed — resolve its NAME so the (disabled) role select
112
129
  // shows it on the trigger (a uuid is never displayed).
113
130
  mode === 'edit' && sourceUuid
@@ -162,7 +179,9 @@ export default function AccessForm() {
162
179
  };
163
180
 
164
181
  const toggleGroupAll = (group) => {
165
- const groupKeys = group.types.flatMap((t) => ACTIONS.map((a) => cellKey(t, a))).filter(keyExists);
182
+ const groupKeys = group.types
183
+ .flatMap((t) => ACTIONS.map((a) => cellKey(t, a)))
184
+ .filter(keyExists);
166
185
  const allHeld = groupKeys.every((k) => keyHeld(k, working));
167
186
  setWorking((prev) => {
168
187
  const next = new Set(prev);
@@ -245,7 +264,12 @@ export default function AccessForm() {
245
264
  const matchesSearch = (t) =>
246
265
  !objectSearch.trim() || t.toLowerCase().includes(objectSearch.trim().toLowerCase());
247
266
 
248
- const title = mode === 'edit' ? 'Edit permissions' : mode === 'duplicate' ? 'Duplicate access' : 'Assign access';
267
+ const title =
268
+ mode === 'edit'
269
+ ? 'Edit permissions'
270
+ : mode === 'duplicate'
271
+ ? 'Duplicate access'
272
+ : 'Assign access';
249
273
  const description =
250
274
  mode === 'edit'
251
275
  ? 'Adjust the permissions granted to this role.'
@@ -268,8 +292,12 @@ export default function AccessForm() {
268
292
  <div className="grid gap-4 md:grid-cols-[240px_1fr]">
269
293
  {/* Left rail — presets */}
270
294
  <div className="flex flex-col gap-2 md:border-r md:border-border md:pr-4">
271
- <div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Presets</div>
272
- {bundles.length === 0 && <p className="text-xs text-muted-foreground">No presets available.</p>}
295
+ <div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
296
+ Presets
297
+ </div>
298
+ {bundles.length === 0 && (
299
+ <p className="text-xs text-muted-foreground">No presets available.</p>
300
+ )}
273
301
  {bundles.map((b) => {
274
302
  const isApplied = isBundleApplied(b);
275
303
  return (
@@ -284,7 +312,11 @@ export default function AccessForm() {
284
312
  {/* Checkbox-like: presets stack (multiple can be applied); this reflects +
285
313
  toggles whether ALL of the preset's keys are held. Non-interactive
286
314
  itself (the whole card is the button) — pointer-events-none. */}
287
- <Checkbox checked={isApplied} className="pointer-events-none mt-0.5" tabIndex={-1} />
315
+ <Checkbox
316
+ checked={isApplied}
317
+ className="pointer-events-none mt-0.5"
318
+ tabIndex={-1}
319
+ />
288
320
  <div className="min-w-0">
289
321
  <span className="text-sm font-medium text-foreground">{b.label}</span>
290
322
  {b.description && (
@@ -325,7 +357,9 @@ export default function AccessForm() {
325
357
  {groups.map((group) => {
326
358
  const visibleTypes = group.types.filter(matchesSearch);
327
359
  if (visibleTypes.length === 0) return null;
328
- const groupCells = group.types.flatMap((t) => ACTIONS.map((a) => cellKey(t, a))).filter(keyExists);
360
+ const groupCells = group.types
361
+ .flatMap((t) => ACTIONS.map((a) => cellKey(t, a)))
362
+ .filter(keyExists);
329
363
  const heldCount = groupCells.filter((k) => keyHeld(k, working)).length;
330
364
  return (
331
365
  <CollapsibleGroup
@@ -334,7 +368,9 @@ export default function AccessForm() {
334
368
  count={heldCount}
335
369
  total={groupCells.length}
336
370
  open={openGroups[group.label] ?? true}
337
- onToggle={() => setOpenGroups((p) => ({ ...p, [group.label]: !(p[group.label] ?? true) }))}
371
+ onToggle={() =>
372
+ setOpenGroups((p) => ({ ...p, [group.label]: !(p[group.label] ?? true) }))
373
+ }
338
374
  right={
339
375
  <span
340
376
  role="button"
@@ -405,9 +441,7 @@ export default function AccessForm() {
405
441
  {permCount} permission{permCount === 1 ? '' : 's'}
406
442
  </span>{' '}
407
443
  across {objectCount} object{objectCount === 1 ? '' : 's'}
408
- {appliedPresetLabels.length > 0 && (
409
- <span> · {appliedPresetLabels.join(', ')}</span>
410
- )}
444
+ {appliedPresetLabels.length > 0 && <span> · {appliedPresetLabels.join(', ')}</span>}
411
445
  </div>
412
446
  </div>
413
447
  </div>
@@ -32,11 +32,13 @@ function initialOf(name) {
32
32
  function shortDate(iso) {
33
33
  if (!iso) return null;
34
34
  const d = new Date(iso);
35
- return Number.isNaN(d.getTime()) ? null : d.toLocaleDateString(undefined, {
36
- day: 'numeric',
37
- month: 'short',
38
- year: 'numeric',
39
- });
35
+ return Number.isNaN(d.getTime())
36
+ ? null
37
+ : d.toLocaleDateString(undefined, {
38
+ day: 'numeric',
39
+ month: 'short',
40
+ year: 'numeric',
41
+ });
40
42
  }
41
43
 
42
44
  /** Seed the datatable query from the URL so a reload / shared link restores the view. */
@@ -269,7 +271,9 @@ export default function AccessPage() {
269
271
 
270
272
  <ConfirmDialog
271
273
  open={!!revokeTarget}
272
- title={revokeTarget ? `Revoke all access from “${revokeTarget.roleName}”?` : 'Revoke all access?'}
274
+ title={
275
+ revokeTarget ? `Revoke all access from “${revokeTarget.roleName}”?` : 'Revoke all access?'
276
+ }
273
277
  message="This removes every permission granted to the role. It will no longer appear in this list. This cannot be undone."
274
278
  confirmLabel="Revoke all"
275
279
  destructive
@@ -184,13 +184,20 @@ export default function RecordPoliciesPage() {
184
184
  key: 'effect',
185
185
  label: 'Effect',
186
186
  render: (row) => (
187
- <Badge variant={row.policy.effect === 'deny' ? 'destructive' : 'success'} className="capitalize font-normal">
187
+ <Badge
188
+ variant={row.policy.effect === 'deny' ? 'destructive' : 'success'}
189
+ className="capitalize font-normal"
190
+ >
188
191
  {row.policy.effect}
189
192
  </Badge>
190
193
  ),
191
194
  },
192
195
  { key: 'action', label: 'Action', render: (row) => row.policy.action },
193
- { key: 'rule', label: 'Rule', render: (row) => <span className="text-sm">{describe(row.policy)}</span> },
196
+ {
197
+ key: 'rule',
198
+ label: 'Rule',
199
+ render: (row) => <span className="text-sm">{describe(row.policy)}</span>,
200
+ },
194
201
  {
195
202
  key: 'rowActions',
196
203
  label: '',
@@ -24,9 +24,17 @@ import { cn } from '@/lib/utils';
24
24
  function initialsOf(name) {
25
25
  if (!name) return 'U';
26
26
  const clean = name.includes('@') ? name.split('@')[0] : name;
27
- const parts = clean.trim().split(/[\s._-]+/).filter(Boolean);
27
+ const parts = clean
28
+ .trim()
29
+ .split(/[\s._-]+/)
30
+ .filter(Boolean);
28
31
  const letters = parts.length >= 2 ? parts[0][0] + parts[1][0] : clean.slice(0, 2);
29
- return letters.replace(/[^a-zA-Z0-9]/g, '').slice(0, 2).toUpperCase() || 'U';
32
+ return (
33
+ letters
34
+ .replace(/[^a-zA-Z0-9]/g, '')
35
+ .slice(0, 2)
36
+ .toUpperCase() || 'U'
37
+ );
30
38
  }
31
39
 
32
40
  const TABS = [
@@ -73,10 +81,7 @@ function IdentityCard({ banner = false }) {
73
81
  )}
74
82
  >
75
83
  <Avatar
76
- className={cn(
77
- 'shrink-0 rounded-xl ring-1 ring-primary/20',
78
- banner ? 'size-11' : 'size-16',
79
- )}
84
+ className={cn('shrink-0 rounded-xl ring-1 ring-primary/20', banner ? 'size-11' : 'size-16')}
80
85
  >
81
86
  <AvatarFallback
82
87
  className={cn(
@@ -126,8 +126,8 @@ export default function ChangePasswordPage() {
126
126
  Change password
127
127
  </CardTitle>
128
128
  <CardDescription>
129
- Set a new password. For your security, changing it signs out your other sessions —
130
- this one stays active.
129
+ Set a new password. For your security, changing it signs out your other sessions — this
130
+ one stays active.
131
131
  </CardDescription>
132
132
  </CardHeader>
133
133
  <CardContent>
@@ -55,9 +55,7 @@ export default function ReenrollPage() {
55
55
  )}
56
56
  <div className="min-w-0">
57
57
  <p className="text-sm font-semibold text-foreground">
58
- {secured
59
- ? 'Your account is secured again'
60
- : 'Finish securing your account'}
58
+ {secured ? 'Your account is secured again' : 'Finish securing your account'}
61
59
  </p>
62
60
  <p className="mt-0.5 text-sm text-muted-foreground">
63
61
  {secured
@@ -75,8 +73,8 @@ export default function ReenrollPage() {
75
73
 
76
74
  {!secured && (
77
75
  <p className="text-center text-xs text-muted-foreground">
78
- You can rename or remove existing devices from your account settings once your account is
79
- secured.
76
+ You can rename or remove existing devices from your account settings once your account
77
+ is secured.
80
78
  </p>
81
79
  )}
82
80