create-astram-app 0.11.1 → 0.12.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 (68) hide show
  1. package/dist/index.js +98 -4
  2. package/dist/index.js.map +1 -1
  3. package/package.json +1 -1
  4. package/template/frontend/_env.example +7 -0
  5. package/template/frontend/package.json +2 -2
  6. package/template/frontend/src/App.jsx +24 -0
  7. package/template/frontend/src/auth/AuthContext.jsx +73 -30
  8. package/template/frontend/src/auth/CaptchaWidget.jsx +108 -0
  9. package/template/frontend/src/auth/captchaProvider.js +23 -0
  10. package/template/frontend/src/auth/captchaProvider.test.mjs +44 -0
  11. package/template/frontend/src/auth/captchaRender.js +125 -0
  12. package/template/frontend/src/auth/captchaRender.test.mjs +182 -0
  13. package/template/frontend/src/auth/passwordVisibility.js +9 -0
  14. package/template/frontend/src/auth/passwordVisibility.test.mjs +12 -0
  15. package/template/frontend/src/auth/reenrollWording.js +29 -0
  16. package/template/frontend/src/auth/reenrollWording.test.mjs +23 -0
  17. package/template/frontend/src/auth/routeGate.js +10 -0
  18. package/template/frontend/src/auth/routeGate.test.mjs +22 -1
  19. package/template/frontend/src/auth/sessionFlow.js +80 -4
  20. package/template/frontend/src/auth/sessionFlow.test.mjs +212 -1
  21. package/template/frontend/src/auth/signInOptions.js +64 -0
  22. package/template/frontend/src/auth/signInOptions.test.mjs +94 -0
  23. package/template/frontend/src/auth/useSignInOptions.js +34 -0
  24. package/template/frontend/src/pages/ForgotPassword.jsx +49 -3
  25. package/template/frontend/src/pages/Login.jsx +54 -4
  26. package/template/frontend/src/pages/ResetPassword.jsx +83 -11
  27. package/template/frontend/src/pages/account/ReenrollPage.jsx +17 -11
  28. package/template/frontend/src/pages/account/TwoFactorPage.jsx +9 -6
  29. package/template/frontend/src/pages/audit/AuditPage.jsx +17 -3
  30. package/template/frontend/src/pages/audit/actorKind.js +17 -0
  31. package/template/frontend/src/pages/audit/actorKind.test.mjs +23 -0
  32. package/template/frontend/src/pages/companies/CompaniesPage.jsx +104 -4
  33. package/template/frontend/src/pages/companies/moveAction.js +61 -0
  34. package/template/frontend/src/pages/companies/moveAction.test.mjs +91 -0
  35. package/template/frontend/src/pages/org-structure/OrgStructurePage.jsx +408 -0
  36. package/template/frontend/src/pages/org-structure/orgStructure.js +140 -0
  37. package/template/frontend/src/pages/org-structure/orgStructure.test.mjs +199 -0
  38. package/template/frontend/src/pages/roles/RoleForm.jsx +32 -14
  39. package/template/frontend/src/pages/roles/RolesPage.jsx +167 -42
  40. package/template/frontend/src/pages/roles/roleRules.js +104 -0
  41. package/template/frontend/src/pages/roles/roleRules.test.mjs +178 -0
  42. package/template/frontend/src/pages/security/PolicyFormDialog.jsx +243 -0
  43. package/template/frontend/src/pages/security/SecuritySettingsPage.jsx +229 -0
  44. package/template/frontend/src/pages/security/securityPolicies.js +246 -0
  45. package/template/frontend/src/pages/security/securityPolicies.test.mjs +429 -0
  46. package/template/frontend/src/pages/users/MembershipCard.jsx +5 -2
  47. package/template/frontend/src/pages/users/PostingRow.jsx +13 -10
  48. package/template/frontend/src/pages/users/SignInActionDialog.jsx +98 -0
  49. package/template/frontend/src/pages/users/UserForm.jsx +65 -3
  50. package/template/frontend/src/pages/users/UsersPage.jsx +91 -3
  51. package/template/frontend/src/pages/users/loginName.js +108 -0
  52. package/template/frontend/src/pages/users/loginName.test.mjs +133 -0
  53. package/template/frontend/src/pages/users/signInAdmin.js +122 -0
  54. package/template/frontend/src/pages/users/signInAdmin.test.mjs +183 -0
  55. package/template/frontend/src/platform/httpClient.js +40 -28
  56. package/template/frontend/src/platform/httpClient.test.mjs +71 -0
  57. package/template/frontend/src/platform/shieldTransport.js +12 -12
  58. package/template/frontend/src/shell/AppShell.jsx +4 -0
  59. package/template/frontend/src/shell/GraceBanner.jsx +28 -0
  60. package/template/frontend/src/shell/graceDeadline.js +50 -0
  61. package/template/frontend/src/shell/graceDeadline.test.mjs +59 -0
  62. package/template/frontend/src/shell/nav.js +30 -1
  63. package/template/frontend/src/viteConfig.test.mjs +63 -0
  64. package/template/frontend/vite/captchaCsp.js +99 -0
  65. package/template/frontend/vite.config.js +14 -3
  66. package/template/server/_env.example +25 -0
  67. package/template/server/package.json +9 -6
  68. package/template/server/src/app.module.ts +17 -6
@@ -4,11 +4,26 @@
4
4
  * /dashboard. An account with MFA enrolled runs a second step: login returns a
5
5
  * challengeToken and we prompt for the 6-digit TOTP code, then complete via
6
6
  * useAuth().completeMfa() to establish the session exactly as a plain login would.
7
+ *
8
+ * Sign-in options (R13.2, R7.6): on mount the page reads `GET /auth/login-options`. The name
9
+ * field's label follows `signInWith` ("Email" until known). With CAPTCHA on, the widget shows in
10
+ * `always` mode or once a failed attempt answers `captchaRequired`; its token goes with the next
11
+ * attempt and the widget is reset after every attempt (single use). A provider mismatch between
12
+ * the server and this build shows a fixed notice and loads no widget (signInOptions.js).
7
13
  */
8
14
  import { useRef, useState } from 'react';
9
15
  import { Link, useLocation, useNavigate } from 'react-router-dom';
10
16
  import { AlertCircle, ArrowLeft, Eye, EyeOff, Loader2, LogIn, ShieldCheck } from 'lucide-react';
11
17
  import { useAuth } from '@/auth/AuthContext';
18
+ import CaptchaWidget from '@/auth/CaptchaWidget';
19
+ import { useSignInOptions } from '@/auth/useSignInOptions';
20
+ import {
21
+ CAPTCHA_ACTIONS,
22
+ MISCONFIGURED_NOTICE,
23
+ captchaOpts,
24
+ captchaVisible,
25
+ signInLabel,
26
+ } from '@/auth/signInOptions';
12
27
  import { loadReturnPath, clearReturnPath } from '@/platform/returnPathStore';
13
28
  import { Button } from '@/components/ui/button';
14
29
  import { Input } from '@/components/ui/input';
@@ -19,7 +34,13 @@ import ThemeToggle from '@/theme/ThemeToggle';
19
34
  import { branding } from '@/config/branding';
20
35
 
21
36
  export default function Login() {
22
- const { login, completeMfa } = useAuth();
37
+ const { login, completeMfa, auth } = useAuth();
38
+ const signIn = useSignInOptions(auth);
39
+ const nameLabel = signInLabel(signIn.signInWith);
40
+ // Sticky for this page: once the server asks for a CAPTCHA, every later attempt sends one.
41
+ const [captchaRequired, setCaptchaRequired] = useState(false);
42
+ const showCaptcha = captchaVisible(signIn.captcha, captchaRequired);
43
+ const captchaRef = useRef(null);
23
44
  const navigate = useNavigate();
24
45
  const location = useLocation();
25
46
  // Where to land after a successful login: the router's in-memory `from` (set when
@@ -64,12 +85,23 @@ export default function Login() {
64
85
  setError('');
65
86
  if (busy) return;
66
87
  if (!identityHint.trim() || !password) {
67
- setError('Enter your username and password.');
88
+ setError(`Enter your ${nameLabel.toLowerCase()} and password.`);
68
89
  return;
69
90
  }
70
91
  setBusy(true);
71
- const r = await login(identityHint.trim(), password);
92
+ let token = '';
93
+ if (showCaptcha) {
94
+ token = (await captchaRef.current?.getToken()) ?? '';
95
+ if (!token) {
96
+ setBusy(false);
97
+ setError('Complete the verification check, then sign in.');
98
+ return;
99
+ }
100
+ }
101
+ const r = await login(identityHint.trim(), password, captchaOpts(token));
72
102
  setBusy(false);
103
+ if (showCaptcha) captchaRef.current?.reset(); // the token is spent either way
104
+ if (r.ok === false && r.captchaRequired) setCaptchaRequired(true);
73
105
  if (r.ok === true) goAfterLogin(from);
74
106
  else if (r.ok === 'mfa') {
75
107
  // MFA enrolled: hold the challenge and move to the code step. The password is
@@ -201,6 +233,16 @@ export default function Login() {
201
233
  )}
202
234
  </div>
203
235
 
236
+ {step === 'credentials' && signIn.captcha.status === 'misconfigured' && (
237
+ <div
238
+ role="alert"
239
+ className="mb-4 flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2.5 text-sm text-destructive"
240
+ >
241
+ <AlertCircle className="mt-0.5 size-4 shrink-0" />
242
+ <span>{MISCONFIGURED_NOTICE}</span>
243
+ </div>
244
+ )}
245
+
204
246
  {error && (
205
247
  <div className="mb-4 flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2.5 text-sm text-destructive">
206
248
  <AlertCircle className="mt-0.5 size-4 shrink-0" />
@@ -316,7 +358,7 @@ export default function Login() {
316
358
  htmlFor="identityHint"
317
359
  className="block text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"
318
360
  >
319
- Email or username
361
+ {nameLabel}
320
362
  </label>
321
363
  <Input
322
364
  id="identityHint"
@@ -359,6 +401,14 @@ export default function Login() {
359
401
  </div>
360
402
  </div>
361
403
 
404
+ {showCaptcha && (
405
+ <CaptchaWidget
406
+ ref={captchaRef}
407
+ setup={signIn.captcha}
408
+ action={CAPTCHA_ACTIONS.login}
409
+ />
410
+ )}
411
+
362
412
  {/* Remember me + Forgot password, inline below the password. */}
363
413
  <div className="flex items-center justify-between pt-0.5">
364
414
  <label
@@ -6,12 +6,25 @@
6
6
  * /login (the reset revokes ALL sessions server-side, so the user re-signs-in with the
7
7
  * new password). On 400 → an "invalid or expired link" message with a way back to
8
8
  * request a fresh link.
9
+ *
10
+ * Both password fields have a show/hide toggle (R13.5, AC-22). CAPTCHA (R7.6): the widget shows
11
+ * in `always` mode, or once an attempt answers `captchaRequired` (that 400 is not a dead link);
12
+ * its token goes with the next attempt and the widget is reset after every attempt.
9
13
  */
10
- import { useState } from 'react';
14
+ import { useRef, useState } from 'react';
11
15
  import { Link, useNavigate, useSearchParams } from 'react-router-dom';
12
16
  import { toast } from 'sonner';
13
- import { AlertCircle, ArrowLeft, KeyRound, Loader2 } from 'lucide-react';
17
+ import { AlertCircle, ArrowLeft, Eye, EyeOff, KeyRound, Loader2 } from 'lucide-react';
14
18
  import { useAuth } from '@/auth/AuthContext';
19
+ import CaptchaWidget from '@/auth/CaptchaWidget';
20
+ import { useSignInOptions } from '@/auth/useSignInOptions';
21
+ import { passwordVisibility } from '@/auth/passwordVisibility';
22
+ import {
23
+ CAPTCHA_ACTIONS,
24
+ MISCONFIGURED_NOTICE,
25
+ captchaOpts,
26
+ captchaVisible,
27
+ } from '@/auth/signInOptions';
15
28
  import { Button } from '@/components/ui/button';
16
29
  import { Input } from '@/components/ui/input';
17
30
  import { Label } from '@/components/ui/label';
@@ -22,6 +35,34 @@ import { resetWording } from '@/auth/resetWording';
22
35
 
23
36
  const MIN_PASSWORD_LEN = 8;
24
37
 
38
+ /** A password input with the show/hide toggle (same style and labels as the login page). */
39
+ function PasswordInput({ id, value, onChange, autoFocus }) {
40
+ const [shown, setShown] = useState(false);
41
+ const { type, toggleLabel } = passwordVisibility(shown);
42
+ return (
43
+ <div className="relative">
44
+ <Input
45
+ id={id}
46
+ type={type}
47
+ autoComplete="new-password"
48
+ autoFocus={autoFocus}
49
+ value={value}
50
+ onChange={onChange}
51
+ placeholder="••••••••"
52
+ className="pr-10"
53
+ />
54
+ <button
55
+ type="button"
56
+ onClick={() => setShown((s) => !s)}
57
+ className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-muted-foreground hover:text-foreground"
58
+ aria-label={toggleLabel}
59
+ >
60
+ {shown ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
61
+ </button>
62
+ </div>
63
+ );
64
+ }
65
+
25
66
  export default function ResetPassword() {
26
67
  const { auth } = useAuth();
27
68
  const navigate = useNavigate();
@@ -36,6 +77,10 @@ export default function ResetPassword() {
36
77
  // When true, the link itself is invalid/expired (400) → offer a fresh request.
37
78
  const [linkDead, setLinkDead] = useState(false);
38
79
  const [busy, setBusy] = useState(false);
80
+ const signIn = useSignInOptions(auth);
81
+ const [captchaRequired, setCaptchaRequired] = useState(false);
82
+ const showCaptcha = captchaVisible(signIn.captcha, captchaRequired);
83
+ const captchaRef = useRef(null);
39
84
 
40
85
  const onSubmit = async (e) => {
41
86
  e.preventDefault();
@@ -58,14 +103,30 @@ export default function ResetPassword() {
58
103
  }
59
104
 
60
105
  setBusy(true);
61
- const r = await auth.resetPassword(token, next);
106
+ let captchaToken = '';
107
+ if (showCaptcha) {
108
+ captchaToken = (await captchaRef.current?.getToken()) ?? '';
109
+ if (!captchaToken) {
110
+ setBusy(false);
111
+ setError('Complete the verification check, then try again.');
112
+ return;
113
+ }
114
+ }
115
+ const r = await auth.resetPassword(token, next, captchaOpts(captchaToken));
62
116
  setBusy(false);
117
+ if (showCaptcha) captchaRef.current?.reset(); // single-use token
63
118
 
64
119
  if (r.ok) {
65
120
  toast.success(words.success);
66
121
  navigate('/login', { replace: true });
67
122
  return;
68
123
  }
124
+ // A 400 asking for a CAPTCHA is not a dead link: show the widget and let the user retry.
125
+ if (r.captchaRequired) {
126
+ setCaptchaRequired(true);
127
+ setError('Complete the verification check, then try again.');
128
+ return;
129
+ }
69
130
  // 400 → the link is invalid/expired; surface a path to request a new one.
70
131
  if (r.status === 400) setLinkDead(true);
71
132
  setError(r.error);
@@ -97,6 +158,16 @@ export default function ResetPassword() {
97
158
  </div>
98
159
  </div>
99
160
 
161
+ {!linkDead && signIn.captcha.status === 'misconfigured' && (
162
+ <div
163
+ role="alert"
164
+ className="mb-4 flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2.5 text-sm text-destructive"
165
+ >
166
+ <AlertCircle className="mt-0.5 size-4 shrink-0" />
167
+ <span>{MISCONFIGURED_NOTICE}</span>
168
+ </div>
169
+ )}
170
+
100
171
  {error && (
101
172
  <div className="mb-4 flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2.5 text-sm text-destructive">
102
173
  <AlertCircle className="mt-0.5 size-4 shrink-0" />
@@ -121,27 +192,28 @@ export default function ResetPassword() {
121
192
  <form onSubmit={onSubmit} className="space-y-4" noValidate>
122
193
  <div className="space-y-1.5">
123
194
  <Label htmlFor="rp-new">New password</Label>
124
- <Input
195
+ <PasswordInput
125
196
  id="rp-new"
126
- type="password"
127
- autoComplete="new-password"
128
197
  autoFocus
129
198
  value={next}
130
199
  onChange={(e) => setNext(e.target.value)}
131
- placeholder="••••••••"
132
200
  />
133
201
  </div>
134
202
  <div className="space-y-1.5">
135
203
  <Label htmlFor="rp-confirm">Confirm new password</Label>
136
- <Input
204
+ <PasswordInput
137
205
  id="rp-confirm"
138
- type="password"
139
- autoComplete="new-password"
140
206
  value={confirm}
141
207
  onChange={(e) => setConfirm(e.target.value)}
142
- placeholder="••••••••"
143
208
  />
144
209
  </div>
210
+ {showCaptcha && (
211
+ <CaptchaWidget
212
+ ref={captchaRef}
213
+ setup={signIn.captcha}
214
+ action={CAPTCHA_ACTIONS.reset}
215
+ />
216
+ )}
145
217
  <Button type="submit" disabled={busy} className="w-full">
146
218
  {busy ? (
147
219
  <Loader2 className="size-4 animate-spin" />
@@ -9,27 +9,35 @@
9
9
  * adds the framing + the "you're done" transition.
10
10
  *
11
11
  * When a device confirm succeeds, the server clears the gate on the SAME token (§4.3) —
12
- * the next request is unrestricted. We clear the local re-enroll flag at that moment
13
- * (so the user is no longer bounced) while keeping them on-screen to save any one-time
12
+ * the next request is unrestricted. The auth facts and profile are then refetched and the
13
+ * local re-enroll flag cleared (useAuth().deviceConfirmed, so the user is no longer bounced and
14
+ * lands on a loaded app) while keeping them on-screen to save any one-time
14
15
  * recovery codes, then offer an explicit "Continue" back into the app.
16
+ *
17
+ * The same screen serves a session restricted by a 2FA policy (R2.3, 403
18
+ * `mfa_enrollment_required`): only the framing differs, per `restrictionReason`
19
+ * (reenrollWording.js).
15
20
  */
16
21
  import { useState } from 'react';
17
22
  import { useNavigate } from 'react-router-dom';
18
23
  import { ArrowRight, LogOut, ShieldAlert, ShieldCheck } from 'lucide-react';
19
24
  import { useAuth } from '@/auth/AuthContext';
25
+ import { reenrollWording } from '@/auth/reenrollWording';
20
26
  import { Button } from '@/components/ui/button';
21
27
  import TwoFactorPage from './TwoFactorPage';
22
28
 
23
29
  export default function ReenrollPage() {
24
- const { clearReenroll, logout } = useAuth();
30
+ const { logout, restrictionReason } = useAuth();
31
+ // Captured at mount: clearing the restriction on success must not swap the wording.
32
+ const [words] = useState(() => reenrollWording(restrictionReason));
25
33
  const navigate = useNavigate();
26
34
  const [secured, setSecured] = useState(false);
27
35
 
28
- // A device confirm succeeded → the server has already lifted the restriction on this
29
- // token; release the UI gate so the user can leave once they've saved their codes.
36
+ // A device confirm succeeded: TwoFactorPage has already refetched the auth facts and the
37
+ // profile and lifted the UI gate (useAuth().deviceConfirmed), so the user can leave once
38
+ // they've saved their codes.
30
39
  const onDeviceConfirmed = () => {
31
40
  setSecured(true);
32
- clearReenroll();
33
41
  };
34
42
 
35
43
  const onSignOut = async () => {
@@ -55,18 +63,16 @@ export default function ReenrollPage() {
55
63
  )}
56
64
  <div className="min-w-0">
57
65
  <p className="text-sm font-semibold text-foreground">
58
- {secured ? 'Your account is secured again' : 'Finish securing your account'}
66
+ {secured ? words.securedTitle : words.title}
59
67
  </p>
60
68
  <p className="mt-0.5 text-sm text-muted-foreground">
61
- {secured
62
- ? 'A new authenticator device is active. Save any recovery codes shown below, then continue.'
63
- : 'You signed in with a recovery code. Add a new authenticator device below to re-secure your account — the rest of the app stays locked until you do.'}
69
+ {secured ? words.securedBody : words.body}
64
70
  </p>
65
71
  </div>
66
72
  </div>
67
73
 
68
74
  {/* The re-enroll work — the exact device-add + regenerate-codes flow from the
69
- account 2FA page, reused as-is. onDeviceConfirmed lifts the gate on success.
75
+ account 2FA page, reused as-is; it lifts the gate on success, then calls onDeviceConfirmed.
70
76
  reenrollMode hides Rename/Remove on existing devices (those routes aren't on
71
77
  the server's re-enroll allowlist → they'd only 403 here). */}
72
78
  <TwoFactorPage onDeviceConfirmed={onDeviceConfirmed} reenrollMode />
@@ -270,15 +270,16 @@ function DeviceRow({ device, onRename, onRemove, disabled, reenrollMode = false
270
270
  /**
271
271
  * @param {object} props
272
272
  * @param {(() => void)} [props.onDeviceConfirmed] Called after a device confirm
273
- * succeeds. Undefined in the normal account context; the forced-re-enroll screen
274
- * passes it to clear the re-enroll gate + leave the screen once a device is added.
273
+ * succeeds, once the auth facts and profile are refetched and the restriction lifted
274
+ * (useAuth().deviceConfirmed). Undefined in the normal account context; the
275
+ * forced-re-enroll screen passes it to switch to its "secured" framing.
275
276
  * @param {boolean} [props.reenrollMode] When true (the forced-re-enroll screen),
276
277
  * hides Rename/Remove on existing devices — those routes aren't on the server's
277
278
  * re-enroll allowlist, so offering them would only 403. Add-device + regenerate
278
279
  * (both allowlisted) stay usable. Normal /account/2fa omits this → actions shown.
279
280
  */
280
281
  export default function TwoFactorPage({ onDeviceConfirmed, reenrollMode = false } = {}) {
281
- const { tokens, account } = useAuth();
282
+ const { tokens, account, deviceConfirmed } = useAuth();
282
283
  const token = tokens?.accessToken;
283
284
 
284
285
  // List state.
@@ -476,9 +477,11 @@ export default function TwoFactorPage({ onDeviceConfirmed, reenrollMode = false
476
477
  setRecoveryCodes(codes);
477
478
  setShowRecovery(true);
478
479
  }
479
- // In the forced-re-enroll flow, a successful device confirm is what re-secures the
480
- // account — the server has cleared the gate, so release the UI. Deferred so any
481
- // one-time recovery-codes panel is shown first (the parent decides when to leave).
480
+ // The server has lifted any restriction and grace deadline on this token: refetch the auth
481
+ // facts and the profile, then lift the local restriction (sessionFlow.js
482
+ // afterDeviceConfirmed), so the nav, gated pages and grace banner are current. Runs after
483
+ // the one-time recovery-codes panel is shown; the parent decides when to leave.
484
+ await deviceConfirmed();
482
485
  onDeviceConfirmed?.();
483
486
  };
484
487
 
@@ -12,8 +12,8 @@
12
12
  * instead (no new reusable component — same primitives EntityList itself imports).
13
13
  *
14
14
  * ⛔ No raw UUIDs: rows carry NONE (the backend already strips ids — `AuditReadService`
15
- * server-side). Only `actorLabel` / `tenantLabel` / `action` / `occurredAt` / `detail` are
16
- * ever rendered.
15
+ * server-side). Only `actorLabel` / `actorKind` (as a badge, `actorKind.js`) / `tenantLabel` /
16
+ * `action` / `occurredAt` / `detail` are ever rendered.
17
17
  *
18
18
  * Tenant column: audit rows always carry a `tenantLabel` (the server auto-scopes a non-
19
19
  * root to their own tenant already), but the column is only USEFUL for a
@@ -37,6 +37,7 @@ import EntityList from '@/components/EntityList';
37
37
  import { Badge } from '@/components/ui/badge';
38
38
  import { Button } from '@/components/ui/button';
39
39
  import { Input } from '@/components/ui/input';
40
+ import { actorKindBadge } from './actorKind';
40
41
 
41
42
  /** Readable absolute date-time — e.g. "13 Jul 2026, 4:05 PM"; '—' when missing/invalid. */
42
43
  function formatDateTime(ms) {
@@ -167,7 +168,20 @@ export default function AuditPage() {
167
168
  {
168
169
  key: 'actorLabel',
169
170
  label: 'Actor',
170
- render: (row) => <span className="text-sm text-foreground">{row.actorLabel || '—'}</span>,
171
+ render: (row) => {
172
+ // KI #140: an operation or a service account is badged so it never reads as a person.
173
+ const badge = actorKindBadge(row.actorKind);
174
+ return (
175
+ <span className="inline-flex items-center gap-1.5 text-sm text-foreground">
176
+ {row.actorLabel || '—'}
177
+ {badge ? (
178
+ <Badge variant="outline" className="font-normal text-xs">
179
+ {badge}
180
+ </Badge>
181
+ ) : null}
182
+ </span>
183
+ );
184
+ },
171
185
  },
172
186
  {
173
187
  key: 'action',
@@ -0,0 +1,17 @@
1
+ // actorKind.js — the Audit page's actor badge (KI #140). Pure logic, tested by actorKind.test.mjs;
2
+ // AuditPage renders from it. The server sends `actorKind` with every row ('user', 'service_account',
3
+ // 'operation', 'unknown'); an older server sends none, which reads as a user.
4
+
5
+ const BADGES = Object.freeze({
6
+ operation: 'System',
7
+ service_account: 'Service account',
8
+ });
9
+
10
+ /**
11
+ * The badge text for a row's `actorKind`, or null for users, unknown actors and missing kinds.
12
+ * @param {string | null | undefined} kind
13
+ * @returns {string | null}
14
+ */
15
+ export function actorKindBadge(kind) {
16
+ return typeof kind === 'string' && Object.hasOwn(BADGES, kind) ? BADGES[kind] : null;
17
+ }
@@ -0,0 +1,23 @@
1
+ // actorKind.test.mjs — KI #140: the Audit page badges actors that are not users (an org bootstrap or
2
+ // audit-prune operation, a service account) so they never read as a person; users get no badge.
3
+ //
4
+ // node --test src/pages/audit/actorKind.test.mjs
5
+ import { test, describe } from 'node:test';
6
+ import assert from 'node:assert/strict';
7
+ import { actorKindBadge } from './actorKind.js';
8
+
9
+ describe('actorKindBadge', () => {
10
+ test('a framework operation is badged "System"', () => {
11
+ assert.equal(actorKindBadge('operation'), 'System');
12
+ });
13
+
14
+ test('a service account is badged "Service account"', () => {
15
+ assert.equal(actorKindBadge('service_account'), 'Service account');
16
+ });
17
+
18
+ test('users, unknown actors and older servers (no actorKind) get no badge', () => {
19
+ for (const kind of ['user', 'unknown', undefined, null, '', 'OPERATION', 'other']) {
20
+ assert.equal(actorKindBadge(kind), null);
21
+ }
22
+ });
23
+ });
@@ -12,17 +12,30 @@
12
12
  *
13
13
  * ⛔ No raw UUIDs: columns render `fields.name` / `fields.status`; the row uuid (`row.id`)
14
14
  * is used only as a key and for routing to the edit page.
15
+ *
16
+ * "Move under…" (G-011) re-parents a company through `identity.moveCompany`; it shows only to
17
+ * callers holding identity:company:update (`moveAction.js`; UX only, the server decides).
15
18
  */
16
- import { useCallback, useEffect, useState } from 'react';
19
+ import { useCallback, useEffect, useMemo, useState } from 'react';
17
20
  import { Outlet, useNavigate, useSearchParams } from 'react-router-dom';
18
- import { Building2 } from 'lucide-react';
21
+ import { Building2, FolderTree } from 'lucide-react';
19
22
  import { toast } from 'sonner';
20
- import { useAuth } from '@/auth/AuthContext';
23
+ import { useAuth, useCan } from '@/auth/AuthContext';
21
24
  import AppShell from '@/shell/AppShell';
22
25
  import PageHeader from '@/components/PageHeader';
23
26
  import EntityList, { EntityListControls, EntityListPager } from '@/components/EntityList';
24
27
  import ConfirmDialog from '@/components/ConfirmDialog';
25
28
  import { Badge } from '@/components/ui/badge';
29
+ import { Button } from '@/components/ui/button';
30
+ import { Label } from '@/components/ui/label';
31
+ import {
32
+ Select,
33
+ SelectContent,
34
+ SelectItem,
35
+ SelectTrigger,
36
+ SelectValue,
37
+ } from '@/components/ui/select';
38
+ import { MOVE_PERMISSION, canMove, moveOutcome, parentOptions, parentUuidOf } from './moveAction';
26
39
 
27
40
  /** Compact absolute date — e.g. "13 Jul 2026"; '—' when missing/invalid. */
28
41
  function formatDate(v) {
@@ -70,6 +83,28 @@ const COLUMNS = [
70
83
  },
71
84
  ];
72
85
 
86
+ /** The row action column: "Move under…" for callers holding the update key (G-011). */
87
+ function moveColumn(holdsUpdate, onMove) {
88
+ return {
89
+ key: 'move',
90
+ label: 'Parent',
91
+ sortable: false,
92
+ render: (row) =>
93
+ canMove(row, holdsUpdate) ? (
94
+ <Button
95
+ type="button"
96
+ variant="ghost"
97
+ size="sm"
98
+ onClick={() => onMove(row)}
99
+ aria-label={`Move ${row.fields?.name || 'this company'} under another company`}
100
+ >
101
+ <FolderTree className="size-4" />
102
+ Move under…
103
+ </Button>
104
+ ) : null,
105
+ };
106
+ }
107
+
73
108
  const DEFAULT_LIMIT = 25;
74
109
 
75
110
  /**
@@ -118,6 +153,12 @@ export default function CompaniesPage() {
118
153
  setSearchParams(p, { replace: true });
119
154
  }, [query, setSearchParams]);
120
155
 
156
+ // Move dialog state (G-011): the company being moved and the chosen parent option.
157
+ const holdsUpdate = useCan(MOVE_PERMISSION);
158
+ const [moveTarget, setMoveTarget] = useState(null);
159
+ const [moveParent, setMoveParent] = useState('');
160
+ const [moveBusy, setMoveBusy] = useState(false);
161
+
121
162
  // Delete confirm state (ConfirmDialog — the API fires ONLY from onConfirm).
122
163
  const [deleteTarget, setDeleteTarget] = useState(null);
123
164
  const [deleteBusy, setDeleteBusy] = useState(false);
@@ -151,6 +192,39 @@ export default function CompaniesPage() {
151
192
  const onPageChange = useCallback((page) => setQuery((q) => ({ ...q, page })), []);
152
193
  const onLimitChange = useCallback((limit) => setQuery((q) => ({ ...q, limit, page: 1 })), []);
153
194
 
195
+ const onMove = useCallback((row) => {
196
+ setMoveTarget(row);
197
+ setMoveParent('');
198
+ }, []);
199
+ const columns = useMemo(
200
+ () => [...COLUMNS, moveColumn(holdsUpdate, onMove)],
201
+ [holdsUpdate, onMove],
202
+ );
203
+ const moveOptions = useMemo(
204
+ () => (moveTarget ? parentOptions(rows, moveTarget.id) : []),
205
+ [rows, moveTarget],
206
+ );
207
+
208
+ const onConfirmMove = async () => {
209
+ if (!moveTarget) return;
210
+ if (!moveParent) {
211
+ toast.error('Choose the company to move it under.');
212
+ return;
213
+ }
214
+ setMoveBusy(true);
215
+ const outcome = moveOutcome(
216
+ await identity.moveCompany(token, moveTarget.id, parentUuidOf(moveParent)),
217
+ );
218
+ setMoveBusy(false);
219
+ if (outcome.kind === 'error') {
220
+ toast.error(outcome.text);
221
+ return;
222
+ }
223
+ setMoveTarget(null);
224
+ toast.success(outcome.text);
225
+ await load();
226
+ };
227
+
154
228
  const onConfirmDelete = async () => {
155
229
  if (!deleteTarget) return;
156
230
  setDeleteBusy(true);
@@ -184,7 +258,7 @@ export default function CompaniesPage() {
184
258
  />
185
259
 
186
260
  <EntityList
187
- columns={COLUMNS}
261
+ columns={columns}
188
262
  rows={rows}
189
263
  search={query.search}
190
264
  sortBy={query.sortBy}
@@ -211,6 +285,32 @@ export default function CompaniesPage() {
211
285
  {/* Add / Edit render here as a routed overlay; onRefresh re-fetches the list. */}
212
286
  <Outlet context={{ onRefresh: load }} />
213
287
 
288
+ <ConfirmDialog
289
+ open={!!moveTarget}
290
+ title={moveTarget ? `Move “${moveTarget.fields?.name || 'this company'}”` : 'Move company'}
291
+ message="Choose the company it moves under. Its own companies move with it."
292
+ confirmLabel="Move"
293
+ busy={moveBusy}
294
+ onConfirm={onConfirmMove}
295
+ onCancel={() => setMoveTarget(null)}
296
+ >
297
+ <div className="grid gap-2">
298
+ <Label htmlFor="move-parent">New parent</Label>
299
+ <Select value={moveParent} onValueChange={setMoveParent}>
300
+ <SelectTrigger id="move-parent" className="w-full">
301
+ <SelectValue placeholder="Select a company" />
302
+ </SelectTrigger>
303
+ <SelectContent>
304
+ {moveOptions.map((o) => (
305
+ <SelectItem key={o.value} value={o.value}>
306
+ {o.label}
307
+ </SelectItem>
308
+ ))}
309
+ </SelectContent>
310
+ </Select>
311
+ </div>
312
+ </ConfirmDialog>
313
+
214
314
  <ConfirmDialog
215
315
  open={!!deleteTarget}
216
316
  title={
@@ -0,0 +1,61 @@
1
+ // moveAction.js — the Companies page's "Move under…" action (G-011). Pure logic, tested by
2
+ // moveAction.test.mjs; CompaniesPage renders from it. Showing or hiding the action is UX only: the
3
+ // server decides (`PUT /identity/companies/:uuid/parent` needs identity:company:update, both
4
+ // companies inside the caller's subtree, and no cycle).
5
+
6
+ /** The permission key the move route is gated on. */
7
+ export const MOVE_PERMISSION = 'identity:company:update';
8
+
9
+ /** The select value standing for "no parent" (Radix Select refuses an empty value). */
10
+ export const TOP_LEVEL = '__top_level__';
11
+
12
+ /** The label of the "no parent" option: only a platform admin (root) may move a company there. */
13
+ export const TOP_LEVEL_LABEL = 'Top level (platform admins only)';
14
+
15
+ /**
16
+ * Show "Move under…" for this row? Only when the caller holds {@link MOVE_PERMISSION}.
17
+ * @param {{ id?: string } | null | undefined} row
18
+ * @param {boolean} holdsUpdate
19
+ */
20
+ export function canMove(row, holdsUpdate) {
21
+ return Boolean(holdsUpdate && row && row.id);
22
+ }
23
+
24
+ /**
25
+ * The parents offered for `movingUuid`: "Top level" first, then every visible company except the
26
+ * one being moved, by name. The frontend cannot tell a root caller (`/auth/me` carries no root
27
+ * flag), so "Top level" is always offered and labelled for platform admins; the server refuses it
28
+ * for anyone else, and refuses a cycle or an out-of-reach parent. Labels are names, never ids.
29
+ * @param {Array<{ id: string, fields?: { name?: string } }>} rows
30
+ * @param {string} movingUuid
31
+ * @returns {Array<{ value: string, label: string }>}
32
+ */
33
+ export function parentOptions(rows, movingUuid) {
34
+ const out = (rows || [])
35
+ .filter((r) => r && r.id && r.id !== movingUuid)
36
+ .map((r) => ({ value: r.id, label: r.fields?.name || '(unnamed)' }))
37
+ .sort((a, b) => a.label.localeCompare(b.label));
38
+ out.unshift({ value: TOP_LEVEL, label: TOP_LEVEL_LABEL });
39
+ return out;
40
+ }
41
+
42
+ /**
43
+ * The `parentUuid` to send for a selected option value (`null` for "Top level").
44
+ * @param {string} value
45
+ * @returns {string | null}
46
+ */
47
+ export function parentUuidOf(value) {
48
+ return value === TOP_LEVEL ? null : value;
49
+ }
50
+
51
+ /**
52
+ * The toast for an `identity.moveCompany` result.
53
+ * @param {{ ok: boolean, error?: string }} result
54
+ * @returns {{ kind: 'success' | 'error', text: string }}
55
+ */
56
+ export function moveOutcome(result) {
57
+ if (!result || !result.ok) {
58
+ return { kind: 'error', text: (result && result.error) || 'Could not move the company.' };
59
+ }
60
+ return { kind: 'success', text: 'Company moved' };
61
+ }