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.
- package/dist/add-frontend.js +107 -0
- package/dist/add-frontend.js.map +1 -0
- package/dist/index.js +122 -25
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/template/frontend/FRONTEND.md +92 -0
- package/template/frontend/_env.example +6 -0
- package/template/frontend/_gitignore +9 -0
- package/template/frontend/eslint.config.js +71 -0
- package/template/frontend/index.html +13 -0
- package/template/frontend/package.json +52 -0
- package/template/frontend/public/astram-logo-full.png +0 -0
- package/template/frontend/public/astram-logo-white.png +0 -0
- package/template/frontend/public/favicon.png +0 -0
- package/template/frontend/src/App.jsx +191 -0
- package/template/frontend/src/auth/AuthContext.jsx +343 -0
- package/template/frontend/src/components/ConfirmDialog.jsx +92 -0
- package/template/frontend/src/components/EntityFormModal.jsx +154 -0
- package/template/frontend/src/components/EntityList.jsx +480 -0
- package/template/frontend/src/components/PageHeader.jsx +54 -0
- package/template/frontend/src/components/access/CollapsibleGroup.jsx +34 -0
- package/template/frontend/src/components/ui/alert-dialog.tsx +175 -0
- package/template/frontend/src/components/ui/alert.tsx +83 -0
- package/template/frontend/src/components/ui/avatar.tsx +51 -0
- package/template/frontend/src/components/ui/badge.tsx +56 -0
- package/template/frontend/src/components/ui/button.tsx +67 -0
- package/template/frontend/src/components/ui/card.tsx +92 -0
- package/template/frontend/src/components/ui/checkbox.tsx +30 -0
- package/template/frontend/src/components/ui/date-field.tsx +47 -0
- package/template/frontend/src/components/ui/dialog.tsx +214 -0
- package/template/frontend/src/components/ui/dropdown-menu.tsx +255 -0
- package/template/frontend/src/components/ui/input-otp.tsx +81 -0
- package/template/frontend/src/components/ui/input.tsx +21 -0
- package/template/frontend/src/components/ui/label.tsx +46 -0
- package/template/frontend/src/components/ui/searchable-select.tsx +459 -0
- package/template/frontend/src/components/ui/select.tsx +199 -0
- package/template/frontend/src/components/ui/separator.tsx +26 -0
- package/template/frontend/src/components/ui/sheet.tsx +139 -0
- package/template/frontend/src/components/ui/skeleton.tsx +13 -0
- package/template/frontend/src/components/ui/sonner.tsx +23 -0
- package/template/frontend/src/components/ui/table.tsx +181 -0
- package/template/frontend/src/components/ui/tooltip.tsx +59 -0
- package/template/frontend/src/config/branding.js +80 -0
- package/template/frontend/src/hooks/useMobile.tsx +21 -0
- package/template/frontend/src/index.css +265 -0
- package/template/frontend/src/lib/utils.js +7 -0
- package/template/frontend/src/main.jsx +27 -0
- package/template/frontend/src/pages/Dashboard.jsx +127 -0
- package/template/frontend/src/pages/ForgotPassword.jsx +104 -0
- package/template/frontend/src/pages/Login.jsx +393 -0
- package/template/frontend/src/pages/ResetPassword.jsx +159 -0
- package/template/frontend/src/pages/access/AccessForm.jsx +416 -0
- package/template/frontend/src/pages/access/AccessPage.jsx +282 -0
- package/template/frontend/src/pages/access/RoleActionsMenu.jsx +42 -0
- package/template/frontend/src/pages/account/AccountLayout.jsx +229 -0
- package/template/frontend/src/pages/account/ChangePasswordPage.jsx +235 -0
- package/template/frontend/src/pages/account/ProfilePage.jsx +122 -0
- package/template/frontend/src/pages/account/ReenrollPage.jsx +98 -0
- package/template/frontend/src/pages/account/TwoFactorPage.jsx +833 -0
- package/template/frontend/src/pages/audit/AuditPage.jsx +268 -0
- package/template/frontend/src/pages/companies/CompaniesPage.jsx +232 -0
- package/template/frontend/src/pages/companies/CompanyForm.jsx +127 -0
- package/template/frontend/src/pages/cross-tenant/GrantForm.jsx +337 -0
- package/template/frontend/src/pages/cross-tenant/GrantsPage.jsx +237 -0
- package/template/frontend/src/pages/cross-tenant/InboxPage.jsx +179 -0
- package/template/frontend/src/pages/roles/RoleForm.jsx +99 -0
- package/template/frontend/src/pages/roles/RolesPage.jsx +207 -0
- package/template/frontend/src/pages/users/AssignmentsEditor.jsx +193 -0
- package/template/frontend/src/pages/users/MembershipCard.jsx +303 -0
- package/template/frontend/src/pages/users/PostingRow.jsx +264 -0
- package/template/frontend/src/pages/users/UserForm.jsx +167 -0
- package/template/frontend/src/pages/users/UsersPage.jsx +232 -0
- package/template/frontend/src/platform/httpClient.js +125 -0
- package/template/frontend/src/platform/returnPathStore.js +43 -0
- package/template/frontend/src/platform/sessionStore.js +29 -0
- package/template/frontend/src/platform/shieldTransport.js +172 -0
- package/template/frontend/src/platform/shieldTransport.test.mjs +245 -0
- package/template/frontend/src/shell/AppShell.jsx +237 -0
- package/template/frontend/src/shell/Sidebar.jsx +257 -0
- package/template/frontend/src/shell/nav.js +103 -0
- package/template/frontend/src/theme/ThemeProvider.jsx +14 -0
- package/template/frontend/src/theme/ThemeToggle.jsx +44 -0
- package/template/frontend/tsconfig.json +22 -0
- package/template/frontend/vite.config.js +30 -0
- package/template/server/_env.example +27 -15
- package/template/server/package.json +7 -4
- package/template/server/src/app.module.ts +28 -2
- 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
|
+
}
|