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.
- package/README.md +211 -38
- package/dist/add-frontend.js.map +1 -1
- package/dist/index.js +14 -9
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/template/README.md +51 -20
- package/template/frontend/README.md +21 -11
- package/template/frontend/index.html +16 -0
- package/template/frontend/package.json +1 -1
- package/template/frontend/src/App.jsx +82 -20
- package/template/frontend/src/auth/AuthContext.jsx +139 -21
- package/template/frontend/src/auth/routeGate.js +31 -0
- package/template/frontend/src/auth/routeGate.test.mjs +59 -0
- package/template/frontend/src/auth/sessionFlow.js +27 -0
- package/template/frontend/src/auth/sessionFlow.test.mjs +63 -0
- package/template/frontend/src/components/EntityList.jsx +1 -7
- package/template/frontend/src/components/PageHeader.jsx +5 -4
- package/template/frontend/src/components/ShareDialog.jsx +3 -5
- package/template/frontend/src/components/access/CollapsibleGroup.jsx +4 -1
- package/template/frontend/src/components/ui/alert-dialog.tsx +27 -50
- package/template/frontend/src/components/ui/alert.tsx +17 -23
- package/template/frontend/src/components/ui/avatar.tsx +8 -20
- package/template/frontend/src/components/ui/badge.tsx +17 -26
- package/template/frontend/src/components/ui/button.tsx +22 -24
- package/template/frontend/src/components/ui/card.tsx +19 -36
- package/template/frontend/src/components/ui/checkbox.tsx +7 -10
- package/template/frontend/src/components/ui/date-field.tsx +26 -27
- package/template/frontend/src/components/ui/dialog.tsx +24 -40
- package/template/frontend/src/components/ui/dropdown-menu.tsx +25 -54
- package/template/frontend/src/components/ui/input-otp.tsx +1 -4
- package/template/frontend/src/components/ui/label.tsx +10 -12
- package/template/frontend/src/components/ui/searchable-select.tsx +1 -3
- package/template/frontend/src/components/ui/select.tsx +31 -43
- package/template/frontend/src/components/ui/separator.tsx +6 -6
- package/template/frontend/src/components/ui/sheet.tsx +29 -38
- package/template/frontend/src/components/ui/skeleton.tsx +3 -3
- package/template/frontend/src/components/ui/sonner.tsx +7 -7
- package/template/frontend/src/components/ui/table.tsx +56 -70
- package/template/frontend/src/components/ui/tooltip.tsx +7 -11
- package/template/frontend/src/config/branding.js +1 -4
- package/template/frontend/src/hooks/useMobile.tsx +4 -6
- package/template/frontend/src/index.css +5 -2
- package/template/frontend/src/pages/Dashboard.jsx +13 -7
- package/template/frontend/src/pages/ForgotPassword.jsx +9 -2
- package/template/frontend/src/pages/Login.jsx +85 -70
- package/template/frontend/src/pages/ResetPassword.jsx +9 -2
- package/template/frontend/src/pages/access/AccessForm.jsx +49 -15
- package/template/frontend/src/pages/access/AccessPage.jsx +10 -6
- package/template/frontend/src/pages/access/RecordPoliciesPage.jsx +9 -2
- package/template/frontend/src/pages/account/AccountLayout.jsx +11 -6
- package/template/frontend/src/pages/account/ChangePasswordPage.jsx +2 -2
- package/template/frontend/src/pages/account/ReenrollPage.jsx +3 -5
- package/template/frontend/src/pages/account/TwoFactorPage.jsx +3 -8
- package/template/frontend/src/pages/companies/CompaniesPage.jsx +7 -9
- package/template/frontend/src/pages/cross-tenant/GrantForm.jsx +41 -8
- package/template/frontend/src/pages/cross-tenant/GrantsPage.jsx +30 -7
- package/template/frontend/src/pages/cross-tenant/InboxPage.jsx +12 -3
- package/template/frontend/src/pages/roles/RolesPage.jsx +5 -9
- package/template/frontend/src/pages/service-accounts/ServiceAccountsPage.jsx +77 -18
- package/template/frontend/src/pages/service-accounts/accessModel.js +22 -0
- package/template/frontend/src/pages/service-accounts/accessModel.test.mjs +28 -0
- package/template/frontend/src/pages/users/MembershipCard.jsx +3 -1
- package/template/frontend/src/pages/users/PostingRow.jsx +8 -14
- package/template/frontend/src/pages/users/UsersPage.jsx +7 -9
- package/template/frontend/src/platform/httpClient.js +5 -0
- package/template/frontend/src/platform/sessionStore.js +55 -13
- package/template/frontend/src/platform/sessionStore.test.mjs +89 -0
- package/template/frontend/src/platform/shieldTransport.js +6 -0
- package/template/frontend/src/platform/shieldTransport.test.mjs +84 -5
- package/template/frontend/src/shell/AppShell.jsx +10 -12
- package/template/frontend/src/shell/Sidebar.jsx +15 -3
- package/template/frontend/src/shell/nav.js +1 -3
- package/template/frontend/vite.config.js +19 -1
- package/template/server/_env.example +22 -1
- package/template/server/eslint.config.mjs +194 -0
- package/template/server/package.json +11 -11
- package/template/server/scripts/dev.mjs +17 -6
- package/template/server/scripts/sync-astram-guide.mjs +45 -19
- package/template/server/src/app.module.ts +11 -5
- package/template/server/src/health/demo.controller.ts +16 -12
- package/template/server/src/main.ts +23 -1
- package/template/server/eslint.config.js +0 -95
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import * as React from
|
|
2
|
-
import * as TooltipPrimitive from
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
|
3
3
|
|
|
4
|
-
import { cn } from
|
|
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
|
-
|
|
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
|
|
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(
|
|
13
|
+
mql.addEventListener('change', onChange);
|
|
16
14
|
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
|
17
|
-
return () => mql.removeEventListener(
|
|
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
|
-
|
|
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
|
|
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>
|
|
71
|
-
<code className="rounded bg-muted px-1 py-0.5 text-xs">@Classified</code> route —
|
|
72
|
-
resolves your bearer token to a principal and runs
|
|
73
|
-
before the
|
|
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' &&
|
|
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">
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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="
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
value={
|
|
342
|
-
onChange={(e) =>
|
|
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
|
|
328
|
+
placeholder="you@example.com"
|
|
329
|
+
className="h-11 bg-secondary/60"
|
|
346
330
|
/>
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
354
|
-
</
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
{
|
|
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) => ({
|
|
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, {
|
|
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) => ({
|
|
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
|
|
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
|
|
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 =
|
|
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">
|
|
272
|
-
|
|
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
|
|
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
|
|
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={() =>
|
|
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())
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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={
|
|
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
|
|
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
|
-
{
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|