create-astram-app 0.1.3 → 0.3.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 (88) hide show
  1. package/dist/add-frontend.js +107 -0
  2. package/dist/add-frontend.js.map +1 -0
  3. package/dist/index.js +122 -25
  4. package/dist/index.js.map +1 -1
  5. package/package.json +3 -2
  6. package/template/frontend/FRONTEND.md +92 -0
  7. package/template/frontend/_env.example +6 -0
  8. package/template/frontend/_gitignore +9 -0
  9. package/template/frontend/eslint.config.js +71 -0
  10. package/template/frontend/index.html +13 -0
  11. package/template/frontend/package.json +52 -0
  12. package/template/frontend/public/astram-logo-full.png +0 -0
  13. package/template/frontend/public/astram-logo-white.png +0 -0
  14. package/template/frontend/public/favicon.png +0 -0
  15. package/template/frontend/src/App.jsx +191 -0
  16. package/template/frontend/src/auth/AuthContext.jsx +343 -0
  17. package/template/frontend/src/components/ConfirmDialog.jsx +92 -0
  18. package/template/frontend/src/components/EntityFormModal.jsx +154 -0
  19. package/template/frontend/src/components/EntityList.jsx +480 -0
  20. package/template/frontend/src/components/PageHeader.jsx +54 -0
  21. package/template/frontend/src/components/access/CollapsibleGroup.jsx +34 -0
  22. package/template/frontend/src/components/ui/alert-dialog.tsx +175 -0
  23. package/template/frontend/src/components/ui/alert.tsx +83 -0
  24. package/template/frontend/src/components/ui/avatar.tsx +51 -0
  25. package/template/frontend/src/components/ui/badge.tsx +56 -0
  26. package/template/frontend/src/components/ui/button.tsx +67 -0
  27. package/template/frontend/src/components/ui/card.tsx +92 -0
  28. package/template/frontend/src/components/ui/checkbox.tsx +30 -0
  29. package/template/frontend/src/components/ui/date-field.tsx +47 -0
  30. package/template/frontend/src/components/ui/dialog.tsx +214 -0
  31. package/template/frontend/src/components/ui/dropdown-menu.tsx +255 -0
  32. package/template/frontend/src/components/ui/input-otp.tsx +81 -0
  33. package/template/frontend/src/components/ui/input.tsx +21 -0
  34. package/template/frontend/src/components/ui/label.tsx +46 -0
  35. package/template/frontend/src/components/ui/searchable-select.tsx +459 -0
  36. package/template/frontend/src/components/ui/select.tsx +199 -0
  37. package/template/frontend/src/components/ui/separator.tsx +26 -0
  38. package/template/frontend/src/components/ui/sheet.tsx +139 -0
  39. package/template/frontend/src/components/ui/skeleton.tsx +13 -0
  40. package/template/frontend/src/components/ui/sonner.tsx +23 -0
  41. package/template/frontend/src/components/ui/table.tsx +181 -0
  42. package/template/frontend/src/components/ui/tooltip.tsx +59 -0
  43. package/template/frontend/src/config/branding.js +80 -0
  44. package/template/frontend/src/hooks/useMobile.tsx +21 -0
  45. package/template/frontend/src/index.css +265 -0
  46. package/template/frontend/src/lib/utils.js +7 -0
  47. package/template/frontend/src/main.jsx +27 -0
  48. package/template/frontend/src/pages/Dashboard.jsx +127 -0
  49. package/template/frontend/src/pages/ForgotPassword.jsx +104 -0
  50. package/template/frontend/src/pages/Login.jsx +393 -0
  51. package/template/frontend/src/pages/ResetPassword.jsx +159 -0
  52. package/template/frontend/src/pages/access/AccessForm.jsx +416 -0
  53. package/template/frontend/src/pages/access/AccessPage.jsx +282 -0
  54. package/template/frontend/src/pages/access/RoleActionsMenu.jsx +42 -0
  55. package/template/frontend/src/pages/account/AccountLayout.jsx +229 -0
  56. package/template/frontend/src/pages/account/ChangePasswordPage.jsx +235 -0
  57. package/template/frontend/src/pages/account/ProfilePage.jsx +122 -0
  58. package/template/frontend/src/pages/account/ReenrollPage.jsx +98 -0
  59. package/template/frontend/src/pages/account/TwoFactorPage.jsx +833 -0
  60. package/template/frontend/src/pages/audit/AuditPage.jsx +268 -0
  61. package/template/frontend/src/pages/companies/CompaniesPage.jsx +232 -0
  62. package/template/frontend/src/pages/companies/CompanyForm.jsx +127 -0
  63. package/template/frontend/src/pages/cross-tenant/GrantForm.jsx +337 -0
  64. package/template/frontend/src/pages/cross-tenant/GrantsPage.jsx +237 -0
  65. package/template/frontend/src/pages/cross-tenant/InboxPage.jsx +179 -0
  66. package/template/frontend/src/pages/roles/RoleForm.jsx +99 -0
  67. package/template/frontend/src/pages/roles/RolesPage.jsx +207 -0
  68. package/template/frontend/src/pages/users/AssignmentsEditor.jsx +193 -0
  69. package/template/frontend/src/pages/users/MembershipCard.jsx +303 -0
  70. package/template/frontend/src/pages/users/PostingRow.jsx +264 -0
  71. package/template/frontend/src/pages/users/UserForm.jsx +167 -0
  72. package/template/frontend/src/pages/users/UsersPage.jsx +232 -0
  73. package/template/frontend/src/platform/httpClient.js +125 -0
  74. package/template/frontend/src/platform/returnPathStore.js +43 -0
  75. package/template/frontend/src/platform/sessionStore.js +29 -0
  76. package/template/frontend/src/platform/shieldTransport.js +172 -0
  77. package/template/frontend/src/platform/shieldTransport.test.mjs +245 -0
  78. package/template/frontend/src/shell/AppShell.jsx +237 -0
  79. package/template/frontend/src/shell/Sidebar.jsx +257 -0
  80. package/template/frontend/src/shell/nav.js +103 -0
  81. package/template/frontend/src/theme/ThemeProvider.jsx +14 -0
  82. package/template/frontend/src/theme/ThemeToggle.jsx +44 -0
  83. package/template/frontend/tsconfig.json +22 -0
  84. package/template/frontend/vite.config.js +30 -0
  85. package/template/server/_env.example +27 -15
  86. package/template/server/package.json +7 -4
  87. package/template/server/src/app.module.ts +28 -2
  88. package/template/server/src/health/demo.controller.ts +23 -12
@@ -0,0 +1,393 @@
1
+ /**
2
+ * Login — astram3 sign-in (shadcn/ui). Calls the framework auth API via
3
+ * useAuth().login() (@astram/frontend-core logic, unchanged). On success → the return path or
4
+ * /dashboard. An account with MFA enrolled runs a second step: login returns a
5
+ * challengeToken and we prompt for the 6-digit TOTP code, then complete via
6
+ * useAuth().completeMfa() to establish the session exactly as a plain login would.
7
+ */
8
+ import { useRef, useState } from 'react';
9
+ import { Link, useLocation, useNavigate } from 'react-router-dom';
10
+ import { AlertCircle, ArrowLeft, Eye, EyeOff, Loader2, LogIn, ShieldCheck } from 'lucide-react';
11
+ import { useAuth } from '@/auth/AuthContext';
12
+ import { loadReturnPath, clearReturnPath } from '@/platform/returnPathStore';
13
+ import { Button } from '@/components/ui/button';
14
+ import { Input } from '@/components/ui/input';
15
+ import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp';
16
+ import { Checkbox } from '@/components/ui/checkbox';
17
+ import { Card, CardContent } from '@/components/ui/card';
18
+ import ThemeToggle from '@/theme/ThemeToggle';
19
+ import { branding } from '@/config/branding';
20
+
21
+ export default function Login() {
22
+ const { login, completeMfa } = useAuth();
23
+ const navigate = useNavigate();
24
+ const location = useLocation();
25
+ // Where to land after a successful login: the router's in-memory `from` (set when
26
+ // RequireAuth bounced the user this same page-session) if present, else the PERSISTED
27
+ // return path (survives a reload / explicit logout / browser restart — the OCA
28
+ // "return you where you left off" feature), else the dashboard.
29
+ const from = location.state?.from ?? loadReturnPath() ?? '/dashboard';
30
+ // Consume the persisted return path once we've captured it, so a later fresh login
31
+ // (deliberately hitting /login) doesn't resurrect a stale destination.
32
+ const goAfterLogin = (path) => {
33
+ clearReturnPath();
34
+ navigate(path, { replace: true });
35
+ };
36
+
37
+ // Two-step: 'credentials' (username + password) → 'mfa' (6-digit TOTP code). The
38
+ // challengeToken from the login step + the identity hint the user typed are carried
39
+ // into the MFA step (the hint is threaded to completeMfa so the same interim label
40
+ // is kept when the session is established).
41
+ const [step, setStep] = useState('credentials');
42
+ const [challengeToken, setChallengeToken] = useState('');
43
+
44
+ const [identityHint, setIdentityHint] = useState('');
45
+ const [password, setPassword] = useState('');
46
+ const [remember, setRemember] = useState(false);
47
+ const [showPw, setShowPw] = useState(false);
48
+ const [code, setCode] = useState('');
49
+ const [error, setError] = useState('');
50
+ const [busy, setBusy] = useState(false);
51
+
52
+ // MFA second-factor kind: 'totp' (the boxed 6-digit authenticator code, default) or
53
+ // 'recovery_code' (a saved xxxxx-xxxxx code, for a lost device). The toggle swaps the
54
+ // input and the `factor` sent to completeMfa; either shares the single `code` field.
55
+ const [mfaFactor, setMfaFactor] = useState('totp');
56
+
57
+ // The boxed-OTP input element (input-otp forwards its ref to the real <input>). After a
58
+ // wrong code we clear the boxes and refocus this so the user can retype immediately —
59
+ // otherwise the async verify (which disabled + blurred the input) leaves it unfocused,
60
+ // and backspace/typing does nothing until the user clicks a box first.
61
+ const otpRef = useRef(null);
62
+
63
+ const submit = async () => {
64
+ setError('');
65
+ if (busy) return;
66
+ if (!identityHint.trim() || !password) {
67
+ setError('Enter your username and password.');
68
+ return;
69
+ }
70
+ setBusy(true);
71
+ const r = await login(identityHint.trim(), password);
72
+ setBusy(false);
73
+ if (r.ok === true) goAfterLogin(from);
74
+ else if (r.ok === 'mfa') {
75
+ // MFA enrolled: hold the challenge and move to the code step. The password is
76
+ // no longer needed (the challengeToken carries the pending login server-side).
77
+ setChallengeToken(r.challengeToken);
78
+ setPassword('');
79
+ setCode('');
80
+ setMfaFactor('totp');
81
+ setStep('mfa');
82
+ } else setError(r.error);
83
+ };
84
+
85
+ const verify = async () => {
86
+ setError('');
87
+ if (busy) return;
88
+ const entered = code.trim();
89
+ // A TOTP code is exactly 6 digits; a recovery code is a free-form xxxxx-xxxxx token.
90
+ if (mfaFactor === 'totp' ? entered.length !== 6 : entered.length === 0) {
91
+ setError(mfaFactor === 'totp' ? 'Enter the 6-digit code.' : 'Enter a recovery code.');
92
+ return;
93
+ }
94
+ setBusy(true);
95
+ // Bug fix: `factor` is now the 3rd positional arg (was mistakenly the identityHint);
96
+ // identityHint is 4th. This lets us send factor:'recovery_code' for a lost device.
97
+ const r = await completeMfa(challengeToken, entered, mfaFactor, identityHint.trim());
98
+ setBusy(false);
99
+ if (r.ok === true) {
100
+ // A recovery-code login lands the session in the forced re-enroll flow — the
101
+ // router (mustReenroll) pins it to /account/reenroll, so send there and drop any
102
+ // saved return path (the user must re-secure first). Otherwise return where they
103
+ // left off. Both consume the persisted return path via goAfterLogin.
104
+ if (r.mfaRecoveryUsed === true) goAfterLogin('/account/reenroll');
105
+ else goAfterLogin(from);
106
+ return;
107
+ }
108
+ // Generic message (anti-enumeration): a wrong code and an expired/spent challenge
109
+ // are indistinguishable. Stay on the code step so the user can retry a fresh code;
110
+ // "Start over" below returns to credentials for the expired/single-use case.
111
+ setError(
112
+ mfaFactor === 'totp' ? 'Incorrect or expired code.' : 'Incorrect or expired recovery code.',
113
+ );
114
+ // Reset for the retry: clear the boxes and refocus the OTP input. The input was
115
+ // disabled (→ blurred) during the async verify; without this refocus, backspace/typing
116
+ // is dead until the user clicks a box. Deferred so the refocus runs after the
117
+ // busy:false re-render re-enables the input. TOTP only — the recovery-code text input
118
+ // keeps its value (a long code shouldn't be wiped on a transient failure).
119
+ if (mfaFactor === 'totp') {
120
+ setCode('');
121
+ setTimeout(() => otpRef.current?.focus(), 0);
122
+ }
123
+ };
124
+
125
+ // Swap between the authenticator (boxed OTP) and the recovery-code (text) inputs. The
126
+ // shared `code` field is cleared so a half-typed value doesn't carry across factors.
127
+ const useRecoveryCode = () => {
128
+ setMfaFactor('recovery_code');
129
+ setCode('');
130
+ setError('');
131
+ };
132
+ const useAuthenticatorCode = () => {
133
+ setMfaFactor('totp');
134
+ setCode('');
135
+ setError('');
136
+ };
137
+
138
+ // Return to the credentials step, clearing the (single-use) challenge + code. Used
139
+ // by both the "Back" link and the expired-challenge "start over" path.
140
+ const backToCredentials = () => {
141
+ setStep('credentials');
142
+ setChallengeToken('');
143
+ setCode('');
144
+ setMfaFactor('totp');
145
+ setError('');
146
+ };
147
+
148
+ const onSubmit = (e) => {
149
+ e.preventDefault();
150
+ if (step === 'mfa') void verify();
151
+ else void submit();
152
+ };
153
+
154
+ // Belt-and-suspenders: submit on Enter from any field, in case a browser/
155
+ // password-manager quirk swallows the implicit form submit.
156
+ const onFieldKeyDown = (e) => {
157
+ if (e.key === 'Enter') {
158
+ e.preventDefault();
159
+ if (step === 'mfa') void verify();
160
+ else void submit();
161
+ }
162
+ };
163
+
164
+ return (
165
+ <div className="grid-bg relative flex min-h-screen items-center justify-center bg-background p-6">
166
+ <div className="absolute right-4 top-4">
167
+ <ThemeToggle compact />
168
+ </div>
169
+
170
+ <Card className="w-full max-w-md shadow-xl">
171
+ <CardContent className="p-8">
172
+ {/* Brand logo — color in light, white in dark (from branding config). */}
173
+ <div className="mb-6 flex justify-center">
174
+ <img src={branding.logo.light} alt={branding.appName} className="h-9 w-auto dark:hidden" />
175
+ <img
176
+ src={branding.logo.dark}
177
+ alt={branding.appName}
178
+ className="hidden h-9 w-auto dark:block"
179
+ />
180
+ </div>
181
+
182
+ <div className="mb-7 text-center">
183
+ {step === 'mfa' ? (
184
+ <>
185
+ <h1 className="text-2xl font-bold tracking-tight">Two-factor authentication</h1>
186
+ <p className="mt-1 text-sm text-muted-foreground">
187
+ {mfaFactor === 'totp'
188
+ ? 'Enter the 6-digit code from your authenticator app'
189
+ : 'Enter one of your saved recovery codes'}
190
+ </p>
191
+ </>
192
+ ) : (
193
+ <>
194
+ <h1 className="text-2xl font-bold tracking-tight">{branding.tagline}</h1>
195
+ <p className="mt-1 text-sm text-muted-foreground">{branding.subTagline}</p>
196
+ </>
197
+ )}
198
+ </div>
199
+
200
+ {error && (
201
+ <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">
202
+ <AlertCircle className="mt-0.5 size-4 shrink-0" />
203
+ <span>{error}</span>
204
+ </div>
205
+ )}
206
+
207
+ {step === 'mfa' ? (
208
+ <form onSubmit={onSubmit} className="space-y-4" noValidate>
209
+ {mfaFactor === 'totp' ? (
210
+ <div className="space-y-1.5">
211
+ <label
212
+ htmlFor="mfaCode"
213
+ className="block text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"
214
+ >
215
+ Authentication code
216
+ </label>
217
+ {/* Boxed segmented OTP: one box per digit, auto-focus box 1, auto-advance,
218
+ backspace-to-previous, paste fills all 6, active-box focus ring. On the
219
+ 6th digit we auto-submit (belt-and-suspenders with the Verify button). */}
220
+ <div className="flex justify-center py-1">
221
+ <InputOTP
222
+ ref={otpRef}
223
+ id="mfaCode"
224
+ autoFocus
225
+ maxLength={6}
226
+ inputMode="numeric"
227
+ pattern="[0-9]*"
228
+ autoComplete="one-time-code"
229
+ value={code}
230
+ onChange={(v) => setCode(v)}
231
+ onComplete={() => {
232
+ if (!busy) void verify();
233
+ }}
234
+ disabled={busy}
235
+ >
236
+ <InputOTPGroup>
237
+ <InputOTPSlot index={0} />
238
+ <InputOTPSlot index={1} />
239
+ <InputOTPSlot index={2} />
240
+ <InputOTPSlot index={3} />
241
+ <InputOTPSlot index={4} />
242
+ <InputOTPSlot index={5} />
243
+ </InputOTPGroup>
244
+ </InputOTP>
245
+ </div>
246
+ </div>
247
+ ) : (
248
+ <div className="space-y-1.5">
249
+ <label
250
+ htmlFor="recoveryCode"
251
+ className="block text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"
252
+ >
253
+ Recovery code
254
+ </label>
255
+ <Input
256
+ id="recoveryCode"
257
+ autoFocus
258
+ autoComplete="one-time-code"
259
+ value={code}
260
+ onChange={(e) => setCode(e.target.value)}
261
+ onKeyDown={onFieldKeyDown}
262
+ placeholder="xxxxx-xxxxx"
263
+ className="h-11 bg-secondary/60 text-center font-mono text-base tracking-widest"
264
+ />
265
+ </div>
266
+ )}
267
+
268
+ <Button
269
+ type="submit"
270
+ disabled={busy}
271
+ className="h-11 w-full bg-primary font-mono text-xs font-semibold uppercase tracking-widest text-primary-foreground hover:bg-primary/90"
272
+ >
273
+ {busy ? (
274
+ <Loader2 className="size-4 animate-spin" />
275
+ ) : (
276
+ <>
277
+ <ShieldCheck className="size-4" />
278
+ Verify
279
+ </>
280
+ )}
281
+ </Button>
282
+
283
+ {/* Recovery-code toggle — swap the boxed OTP for a recovery-code input (a
284
+ lost-device escape) and back. Sets factor to 'recovery_code' / 'totp'. */}
285
+ <button
286
+ type="button"
287
+ onClick={mfaFactor === 'totp' ? useRecoveryCode : useAuthenticatorCode}
288
+ disabled={busy}
289
+ className="mx-auto flex text-sm font-medium text-primary hover:underline disabled:opacity-50"
290
+ >
291
+ {mfaFactor === 'totp'
292
+ ? 'Use a recovery code instead'
293
+ : 'Use authenticator code instead'}
294
+ </button>
295
+
296
+ {/* Back / start over: an expired or spent (single-use, TTL'd) challenge
297
+ means the user must re-enter credentials to get a fresh one. */}
298
+ <button
299
+ type="button"
300
+ onClick={backToCredentials}
301
+ disabled={busy}
302
+ className="mx-auto flex items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground disabled:opacity-50"
303
+ >
304
+ <ArrowLeft className="size-4" />
305
+ Back to sign in
306
+ </button>
307
+ </form>
308
+ ) : (
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">
337
+ <Input
338
+ id="password"
339
+ type={showPw ? 'text' : 'password'}
340
+ autoComplete="current-password"
341
+ value={password}
342
+ onChange={(e) => setPassword(e.target.value)}
343
+ onKeyDown={onFieldKeyDown}
344
+ placeholder="••••••••"
345
+ className="h-11 bg-secondary/60 pr-10"
346
+ />
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'}
352
+ >
353
+ {showPw ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
354
+ </button>
355
+ </div>
356
+ </div>
357
+
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>
368
+
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>
384
+ )}
385
+
386
+ <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>
388
+ </p>
389
+ </CardContent>
390
+ </Card>
391
+ </div>
392
+ );
393
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * ResetPassword — the page the emailed reset link opens (`/reset-password?token=…`).
3
+ * Reads the raw token from the URL query, collects a new password + confirmation, and
4
+ * submits to the framework's `POST /auth/reset-password` via the shared
5
+ * `authService.resetPassword` (through AuthContext). On success → toast + redirect to
6
+ * /login (the reset revokes ALL sessions server-side, so the user re-signs-in with the
7
+ * new password). On 400 → an "invalid or expired link" message with a way back to
8
+ * request a fresh link.
9
+ */
10
+ import { useState } from 'react';
11
+ import { Link, useNavigate, useSearchParams } from 'react-router-dom';
12
+ import { toast } from 'sonner';
13
+ import { AlertCircle, ArrowLeft, KeyRound, Loader2 } from 'lucide-react';
14
+ import { useAuth } from '@/auth/AuthContext';
15
+ import { Button } from '@/components/ui/button';
16
+ import { Input } from '@/components/ui/input';
17
+ import { Label } from '@/components/ui/label';
18
+ import { Card, CardContent } from '@/components/ui/card';
19
+ import ThemeToggle from '@/theme/ThemeToggle';
20
+ import { branding } from '@/config/branding';
21
+
22
+ const MIN_PASSWORD_LEN = 8;
23
+
24
+ export default function ResetPassword() {
25
+ const { auth } = useAuth();
26
+ const navigate = useNavigate();
27
+ const [params] = useSearchParams();
28
+ const token = params.get('token') ?? '';
29
+
30
+ const [next, setNext] = useState('');
31
+ const [confirm, setConfirm] = useState('');
32
+ const [error, setError] = useState('');
33
+ // When true, the link itself is invalid/expired (400) → offer a fresh request.
34
+ const [linkDead, setLinkDead] = useState(false);
35
+ const [busy, setBusy] = useState(false);
36
+
37
+ const onSubmit = async (e) => {
38
+ e.preventDefault();
39
+ setError('');
40
+ if (busy) return;
41
+
42
+ if (!token) {
43
+ setLinkDead(true);
44
+ setError('This reset link is missing its token.');
45
+ return;
46
+ }
47
+ // Client-side pre-checks (the server is authoritative; these are just UX).
48
+ if (next.length < MIN_PASSWORD_LEN) {
49
+ setError(`New password must be at least ${MIN_PASSWORD_LEN} characters.`);
50
+ return;
51
+ }
52
+ if (next !== confirm) {
53
+ setError('New password and confirmation do not match.');
54
+ return;
55
+ }
56
+
57
+ setBusy(true);
58
+ const r = await auth.resetPassword(token, next);
59
+ setBusy(false);
60
+
61
+ if (r.ok) {
62
+ toast.success('Your password has been reset. Please sign in with your new password.');
63
+ navigate('/login', { replace: true });
64
+ return;
65
+ }
66
+ // 400 → the link is invalid/expired; surface a path to request a new one.
67
+ if (r.status === 400) setLinkDead(true);
68
+ setError(r.error);
69
+ };
70
+
71
+ return (
72
+ <div className="grid-bg relative flex min-h-screen items-center justify-center bg-background p-6">
73
+ <div className="absolute right-4 top-4">
74
+ <ThemeToggle compact />
75
+ </div>
76
+
77
+ <Card className="w-full max-w-md shadow-xl">
78
+ <CardContent className="p-8">
79
+ <div className="mb-7 flex flex-col items-center gap-4">
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" />
82
+ <img
83
+ src={branding.logo.dark}
84
+ alt={branding.appName}
85
+ className="hidden h-9 w-auto dark:block"
86
+ />
87
+ <div className="text-center">
88
+ <h1 className="text-xl font-bold tracking-tight">Choose a new password</h1>
89
+ <p className="mt-1 text-sm text-muted-foreground">
90
+ Enter a new password for your account.
91
+ </p>
92
+ </div>
93
+ </div>
94
+
95
+ {error && (
96
+ <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">
97
+ <AlertCircle className="mt-0.5 size-4 shrink-0" />
98
+ <span>{error}</span>
99
+ </div>
100
+ )}
101
+
102
+ {linkDead ? (
103
+ <div className="text-center">
104
+ <Link
105
+ to="/forgot-password"
106
+ className="inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
107
+ >
108
+ Request a new reset link
109
+ </Link>
110
+ </div>
111
+ ) : (
112
+ <form onSubmit={onSubmit} className="space-y-4" noValidate>
113
+ <div className="space-y-1.5">
114
+ <Label htmlFor="rp-new">New password</Label>
115
+ <Input
116
+ id="rp-new"
117
+ type="password"
118
+ autoComplete="new-password"
119
+ autoFocus
120
+ value={next}
121
+ onChange={(e) => setNext(e.target.value)}
122
+ placeholder="••••••••"
123
+ />
124
+ </div>
125
+ <div className="space-y-1.5">
126
+ <Label htmlFor="rp-confirm">Confirm new password</Label>
127
+ <Input
128
+ id="rp-confirm"
129
+ type="password"
130
+ autoComplete="new-password"
131
+ value={confirm}
132
+ onChange={(e) => setConfirm(e.target.value)}
133
+ placeholder="••••••••"
134
+ />
135
+ </div>
136
+ <Button type="submit" disabled={busy} className="w-full">
137
+ {busy ? (
138
+ <Loader2 className="size-4 animate-spin" />
139
+ ) : (
140
+ <>
141
+ <KeyRound className="size-4" />
142
+ Reset password
143
+ </>
144
+ )}
145
+ </Button>
146
+ </form>
147
+ )}
148
+
149
+ <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">
151
+ <ArrowLeft className="size-4" />
152
+ Back to sign in
153
+ </Link>
154
+ </div>
155
+ </CardContent>
156
+ </Card>
157
+ </div>
158
+ );
159
+ }