@drawboard/authagonal-login 0.1.1
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 +73 -0
- package/dist/assets/index-Bn-ws4eD.js +11 -0
- package/dist/assets/index-CDKtEFn6.css +1 -0
- package/dist/branding.json +8 -0
- package/dist/favicon.svg +1 -0
- package/dist/icons.svg +24 -0
- package/dist/index.html +14 -0
- package/index.html +13 -0
- package/package.json +53 -0
- package/public/branding.json +8 -0
- package/public/favicon.svg +1 -0
- package/public/icons.svg +24 -0
- package/src/App.tsx +20 -0
- package/src/api.ts +87 -0
- package/src/branding.ts +36 -0
- package/src/components/AuthLayout.tsx +65 -0
- package/src/i18n/de.json +51 -0
- package/src/i18n/en.json +51 -0
- package/src/i18n/es.json +51 -0
- package/src/i18n/fr.json +51 -0
- package/src/i18n/index.ts +37 -0
- package/src/i18n/pt.json +51 -0
- package/src/i18n/vi.json +51 -0
- package/src/i18n/zh-Hans.json +51 -0
- package/src/index.ts +28 -0
- package/src/main.tsx +19 -0
- package/src/pages/ForgotPasswordPage.tsx +100 -0
- package/src/pages/LoginPage.tsx +308 -0
- package/src/pages/ResetPasswordPage.tsx +228 -0
- package/src/styles.css +339 -0
- package/src/types.ts +46 -0
- package/tsconfig.app.json +28 -0
- package/tsconfig.json +7 -0
- package/vite.config.ts +27 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
2
|
+
import { useSearchParams, Link } from 'react-router-dom';
|
|
3
|
+
import { useTranslation } from 'react-i18next';
|
|
4
|
+
import { login, ssoCheck, getProviders, getSession, ApiRequestError } from '../api';
|
|
5
|
+
import { useBranding } from '../branding';
|
|
6
|
+
import type { ExternalProvider } from '../types';
|
|
7
|
+
|
|
8
|
+
const API_URL = import.meta.env.VITE_API_URL || '';
|
|
9
|
+
|
|
10
|
+
function isSafeReturnUrl(url: string): boolean {
|
|
11
|
+
if (!url) return false;
|
|
12
|
+
// Only allow relative paths (starting with /) that don't escape to another host
|
|
13
|
+
try {
|
|
14
|
+
const parsed = new URL(url, window.location.origin);
|
|
15
|
+
return parsed.origin === window.location.origin && url.startsWith('/');
|
|
16
|
+
} catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export default function LoginPage() {
|
|
22
|
+
const { t } = useTranslation();
|
|
23
|
+
const branding = useBranding();
|
|
24
|
+
const [searchParams] = useSearchParams();
|
|
25
|
+
const returnUrl = searchParams.get('returnUrl') || '';
|
|
26
|
+
const loginHint = searchParams.get('login_hint') || '';
|
|
27
|
+
const oidcError = searchParams.get('error_description') || searchParams.get('error') || '';
|
|
28
|
+
|
|
29
|
+
const [email, setEmail] = useState(loginHint);
|
|
30
|
+
const [password, setPassword] = useState('');
|
|
31
|
+
const [error, setError] = useState(oidcError);
|
|
32
|
+
const [loading, setLoading] = useState(false);
|
|
33
|
+
const [ssoInfo, setSsoInfo] = useState<{ redirectUrl: string } | null>(null);
|
|
34
|
+
const [ssoChecked, setSsoChecked] = useState(false);
|
|
35
|
+
const [ssoChecking, setSsoChecking] = useState(false);
|
|
36
|
+
const [providers, setProviders] = useState<ExternalProvider[]>([]);
|
|
37
|
+
const [session, setSession] = useState<{ name: string; email: string } | null>(null);
|
|
38
|
+
|
|
39
|
+
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
40
|
+
const lastCheckedEmailRef = useRef('');
|
|
41
|
+
|
|
42
|
+
const performSsoCheck = useCallback(async (emailToCheck: string) => {
|
|
43
|
+
if (!emailToCheck.includes('@') || emailToCheck === lastCheckedEmailRef.current) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
lastCheckedEmailRef.current = emailToCheck;
|
|
48
|
+
setSsoChecking(true);
|
|
49
|
+
setError('');
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const result = await ssoCheck(emailToCheck);
|
|
53
|
+
if (result.ssoRequired && result.redirectUrl) {
|
|
54
|
+
setSsoInfo({ redirectUrl: result.redirectUrl });
|
|
55
|
+
} else {
|
|
56
|
+
setSsoInfo(null);
|
|
57
|
+
}
|
|
58
|
+
setSsoChecked(true);
|
|
59
|
+
} catch {
|
|
60
|
+
// If SSO check fails, allow normal login
|
|
61
|
+
setSsoInfo(null);
|
|
62
|
+
setSsoChecked(true);
|
|
63
|
+
} finally {
|
|
64
|
+
setSsoChecking(false);
|
|
65
|
+
}
|
|
66
|
+
}, []);
|
|
67
|
+
|
|
68
|
+
// Check for existing session (e.g. after OIDC callback with no returnUrl)
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (returnUrl && isSafeReturnUrl(returnUrl)) return; // OAuth flow — don't check session
|
|
71
|
+
getSession()
|
|
72
|
+
.then((s) => {
|
|
73
|
+
if (s.authenticated) {
|
|
74
|
+
setSession({ name: s.name, email: s.email });
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
.catch(() => {});
|
|
78
|
+
}, [returnUrl]);
|
|
79
|
+
|
|
80
|
+
// Fetch available external providers
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
getProviders()
|
|
83
|
+
.then((res) => setProviders(res.providers ?? []))
|
|
84
|
+
.catch(() => {});
|
|
85
|
+
}, []);
|
|
86
|
+
|
|
87
|
+
// Auto-trigger SSO check if login_hint is provided
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (loginHint && loginHint.includes('@')) {
|
|
90
|
+
performSsoCheck(loginHint);
|
|
91
|
+
}
|
|
92
|
+
}, [loginHint, performSsoCheck]);
|
|
93
|
+
|
|
94
|
+
function handleEmailBlur() {
|
|
95
|
+
if (debounceTimerRef.current) {
|
|
96
|
+
clearTimeout(debounceTimerRef.current);
|
|
97
|
+
}
|
|
98
|
+
debounceTimerRef.current = setTimeout(() => {
|
|
99
|
+
performSsoCheck(email);
|
|
100
|
+
}, 300);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function handleEmailChange(value: string) {
|
|
104
|
+
setEmail(value);
|
|
105
|
+
// Reset SSO state when email changes
|
|
106
|
+
if (value !== lastCheckedEmailRef.current) {
|
|
107
|
+
setSsoChecked(false);
|
|
108
|
+
setSsoInfo(null);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function handleProviderLogin(provider: ExternalProvider) {
|
|
113
|
+
const url = new URL(`${API_URL}${provider.loginUrl}`, window.location.origin);
|
|
114
|
+
if (returnUrl && isSafeReturnUrl(returnUrl)) {
|
|
115
|
+
url.searchParams.set('returnUrl', returnUrl);
|
|
116
|
+
}
|
|
117
|
+
window.location.href = url.toString();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function handleSsoRedirect() {
|
|
121
|
+
if (ssoInfo) {
|
|
122
|
+
const ssoUrl = new URL(`${API_URL}${ssoInfo.redirectUrl}`, window.location.origin);
|
|
123
|
+
if (returnUrl && isSafeReturnUrl(returnUrl)) {
|
|
124
|
+
ssoUrl.searchParams.set('returnUrl', returnUrl);
|
|
125
|
+
}
|
|
126
|
+
window.location.href = ssoUrl.toString();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
131
|
+
e.preventDefault();
|
|
132
|
+
setError('');
|
|
133
|
+
setLoading(true);
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
await login(email, password);
|
|
137
|
+
// On success, redirect to returnUrl (validated) using window.location.href
|
|
138
|
+
if (returnUrl && isSafeReturnUrl(returnUrl)) {
|
|
139
|
+
window.location.href = returnUrl;
|
|
140
|
+
} else {
|
|
141
|
+
window.location.href = '/';
|
|
142
|
+
}
|
|
143
|
+
} catch (err) {
|
|
144
|
+
if (err instanceof ApiRequestError) {
|
|
145
|
+
switch (err.error) {
|
|
146
|
+
case 'invalid_credentials':
|
|
147
|
+
setError(t('errorInvalidCredentials'));
|
|
148
|
+
break;
|
|
149
|
+
case 'locked_out':
|
|
150
|
+
setError(t('errorLockedOut', { seconds: err.retryAfter ?? '?' }));
|
|
151
|
+
break;
|
|
152
|
+
case 'email_not_confirmed':
|
|
153
|
+
setError(t('errorEmailNotConfirmed'));
|
|
154
|
+
break;
|
|
155
|
+
case 'sso_required':
|
|
156
|
+
if (err.redirectUrl) {
|
|
157
|
+
const ssoUrl = new URL(`${API_URL}${err.redirectUrl}`, window.location.origin);
|
|
158
|
+
if (returnUrl && isSafeReturnUrl(returnUrl)) {
|
|
159
|
+
ssoUrl.searchParams.set('returnUrl', returnUrl);
|
|
160
|
+
}
|
|
161
|
+
window.location.href = ssoUrl.toString();
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
setError(t('errorSsoRequired'));
|
|
165
|
+
break;
|
|
166
|
+
case 'email_required':
|
|
167
|
+
setError(t('errorEmailRequired'));
|
|
168
|
+
break;
|
|
169
|
+
case 'password_required':
|
|
170
|
+
setError(t('errorPasswordRequired'));
|
|
171
|
+
break;
|
|
172
|
+
default:
|
|
173
|
+
setError(err.message || t('errorUnexpected'));
|
|
174
|
+
}
|
|
175
|
+
} else {
|
|
176
|
+
setError(t('errorUnexpected'));
|
|
177
|
+
}
|
|
178
|
+
} finally {
|
|
179
|
+
setLoading(false);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const forgotPasswordLink = returnUrl && isSafeReturnUrl(returnUrl)
|
|
184
|
+
? `/forgot-password?returnUrl=${encodeURIComponent(returnUrl)}`
|
|
185
|
+
: '/forgot-password';
|
|
186
|
+
|
|
187
|
+
const showPasswordField = ssoChecked && !ssoInfo;
|
|
188
|
+
|
|
189
|
+
if (session) {
|
|
190
|
+
return (
|
|
191
|
+
<div>
|
|
192
|
+
<h2 className="auth-title">{t('signedInAs', { name: session.name || session.email })}</h2>
|
|
193
|
+
<p style={{ textAlign: 'center', color: '#6b7280' }}>{t('signedInMessage')}</p>
|
|
194
|
+
</div>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return (
|
|
199
|
+
<div>
|
|
200
|
+
<h2 className="auth-title">{t('signIn')}</h2>
|
|
201
|
+
|
|
202
|
+
{providers.length > 0 && (
|
|
203
|
+
<div className="external-providers">
|
|
204
|
+
{providers.map((p) => (
|
|
205
|
+
<button
|
|
206
|
+
key={p.connectionId}
|
|
207
|
+
type="button"
|
|
208
|
+
className={`btn-provider btn-provider-${p.connectionId}`}
|
|
209
|
+
onClick={() => handleProviderLogin(p)}
|
|
210
|
+
>
|
|
211
|
+
{p.connectionId === 'google' && (
|
|
212
|
+
<svg className="provider-icon" viewBox="0 0 24 24" width="20" height="20">
|
|
213
|
+
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4"/>
|
|
214
|
+
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
|
|
215
|
+
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
|
|
216
|
+
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
|
|
217
|
+
</svg>
|
|
218
|
+
)}
|
|
219
|
+
{t('continueWith', { provider: p.name })}
|
|
220
|
+
</button>
|
|
221
|
+
))}
|
|
222
|
+
<div className="divider">
|
|
223
|
+
<span>{t('or')}</span>
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
)}
|
|
227
|
+
|
|
228
|
+
{error && <div className="alert-error">{error}</div>}
|
|
229
|
+
|
|
230
|
+
<form onSubmit={handleSubmit}>
|
|
231
|
+
<div className="form-group">
|
|
232
|
+
<label htmlFor="email">{t('email')}</label>
|
|
233
|
+
<input
|
|
234
|
+
id="email"
|
|
235
|
+
type="email"
|
|
236
|
+
value={email}
|
|
237
|
+
onChange={(e) => handleEmailChange(e.target.value)}
|
|
238
|
+
onBlur={handleEmailBlur}
|
|
239
|
+
placeholder={t('emailPlaceholder')}
|
|
240
|
+
autoComplete="email"
|
|
241
|
+
autoFocus={!loginHint}
|
|
242
|
+
maxLength={256}
|
|
243
|
+
required
|
|
244
|
+
/>
|
|
245
|
+
</div>
|
|
246
|
+
|
|
247
|
+
{ssoChecking && (
|
|
248
|
+
<div className="sso-checking">{t('ssoChecking')}</div>
|
|
249
|
+
)}
|
|
250
|
+
|
|
251
|
+
{ssoInfo && (
|
|
252
|
+
<div className="sso-notice">
|
|
253
|
+
<p>{t('ssoNotice')}</p>
|
|
254
|
+
<button
|
|
255
|
+
type="button"
|
|
256
|
+
className="btn-secondary"
|
|
257
|
+
onClick={handleSsoRedirect}
|
|
258
|
+
>
|
|
259
|
+
{t('continueWithSso')}
|
|
260
|
+
</button>
|
|
261
|
+
</div>
|
|
262
|
+
)}
|
|
263
|
+
|
|
264
|
+
{showPasswordField && (
|
|
265
|
+
<>
|
|
266
|
+
<div className="form-group">
|
|
267
|
+
<label htmlFor="password">{t('password')}</label>
|
|
268
|
+
<input
|
|
269
|
+
id="password"
|
|
270
|
+
type="password"
|
|
271
|
+
value={password}
|
|
272
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
273
|
+
placeholder={t('passwordPlaceholder')}
|
|
274
|
+
autoComplete="current-password"
|
|
275
|
+
autoFocus
|
|
276
|
+
maxLength={256}
|
|
277
|
+
required
|
|
278
|
+
/>
|
|
279
|
+
</div>
|
|
280
|
+
|
|
281
|
+
<button
|
|
282
|
+
type="submit"
|
|
283
|
+
className="btn-primary"
|
|
284
|
+
disabled={loading}
|
|
285
|
+
>
|
|
286
|
+
{loading ? (
|
|
287
|
+
<span className="btn-loading">
|
|
288
|
+
<span className="spinner" />
|
|
289
|
+
{t('signingIn')}
|
|
290
|
+
</span>
|
|
291
|
+
) : (
|
|
292
|
+
t('signIn')
|
|
293
|
+
)}
|
|
294
|
+
</button>
|
|
295
|
+
|
|
296
|
+
{branding.showForgotPassword && (
|
|
297
|
+
<div className="form-footer">
|
|
298
|
+
<Link to={forgotPasswordLink} className="link">
|
|
299
|
+
{t('forgotPassword')}
|
|
300
|
+
</Link>
|
|
301
|
+
</div>
|
|
302
|
+
)}
|
|
303
|
+
</>
|
|
304
|
+
)}
|
|
305
|
+
</form>
|
|
306
|
+
</div>
|
|
307
|
+
);
|
|
308
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { useSearchParams, Link } from 'react-router-dom';
|
|
3
|
+
import { useTranslation } from 'react-i18next';
|
|
4
|
+
import { resetPassword, ApiRequestError } from '../api';
|
|
5
|
+
|
|
6
|
+
interface PasswordRule {
|
|
7
|
+
rule: string;
|
|
8
|
+
value: number | null;
|
|
9
|
+
label: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface PasswordRequirement {
|
|
13
|
+
label: string;
|
|
14
|
+
met: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const API_URL = import.meta.env.VITE_API_URL || '';
|
|
18
|
+
|
|
19
|
+
const defaultRules: PasswordRule[] = [
|
|
20
|
+
{ rule: 'minLength', value: 8, label: 'At least 8 characters' },
|
|
21
|
+
{ rule: 'uppercase', value: null, label: 'Uppercase letter' },
|
|
22
|
+
{ rule: 'lowercase', value: null, label: 'Lowercase letter' },
|
|
23
|
+
{ rule: 'digit', value: null, label: 'Number' },
|
|
24
|
+
{ rule: 'specialChar', value: null, label: 'Special character' },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
function evaluateRequirements(password: string, rules: PasswordRule[]): PasswordRequirement[] {
|
|
28
|
+
return rules.map((r) => {
|
|
29
|
+
let met = false;
|
|
30
|
+
switch (r.rule) {
|
|
31
|
+
case 'minLength': met = password.length >= (r.value ?? 8); break;
|
|
32
|
+
case 'uppercase': met = /[A-Z]/.test(password); break;
|
|
33
|
+
case 'lowercase': met = /[a-z]/.test(password); break;
|
|
34
|
+
case 'digit': met = /[0-9]/.test(password); break;
|
|
35
|
+
case 'specialChar': met = /[^A-Za-z0-9]/.test(password); break;
|
|
36
|
+
default: met = true;
|
|
37
|
+
}
|
|
38
|
+
return { label: r.label, met };
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export default function ResetPasswordPage() {
|
|
43
|
+
const { t } = useTranslation();
|
|
44
|
+
const [searchParams] = useSearchParams();
|
|
45
|
+
const token = searchParams.get('p') || '';
|
|
46
|
+
|
|
47
|
+
const [newPassword, setNewPassword] = useState('');
|
|
48
|
+
const [confirmPassword, setConfirmPassword] = useState('');
|
|
49
|
+
const [loading, setLoading] = useState(false);
|
|
50
|
+
const [error, setError] = useState('');
|
|
51
|
+
const [success, setSuccess] = useState(false);
|
|
52
|
+
const [validationError, setValidationError] = useState('');
|
|
53
|
+
const [rules, setRules] = useState<PasswordRule[]>(defaultRules);
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
fetch(`${API_URL}/api/auth/password-policy`)
|
|
57
|
+
.then((r) => r.ok ? r.json() : null)
|
|
58
|
+
.then((data) => { if (data?.rules) setRules(data.rules); })
|
|
59
|
+
.catch(() => { /* use defaults */ });
|
|
60
|
+
}, []);
|
|
61
|
+
|
|
62
|
+
function getRuleLabel(rule: PasswordRule): string {
|
|
63
|
+
switch (rule.rule) {
|
|
64
|
+
case 'minLength': return t('ruleMinLength', { count: rule.value ?? 8 });
|
|
65
|
+
case 'uppercase': return t('ruleUppercase');
|
|
66
|
+
case 'lowercase': return t('ruleLowercase');
|
|
67
|
+
case 'digit': return t('ruleDigit');
|
|
68
|
+
case 'specialChar': return t('ruleSpecialChar');
|
|
69
|
+
default: return rule.label;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const localizedRules: PasswordRule[] = rules.map(r => ({
|
|
74
|
+
...r,
|
|
75
|
+
label: getRuleLabel(r),
|
|
76
|
+
}));
|
|
77
|
+
|
|
78
|
+
const requirements = evaluateRequirements(newPassword, localizedRules);
|
|
79
|
+
const allRequirementsMet = requirements.every((r) => r.met);
|
|
80
|
+
|
|
81
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
82
|
+
e.preventDefault();
|
|
83
|
+
setError('');
|
|
84
|
+
setValidationError('');
|
|
85
|
+
|
|
86
|
+
if (!allRequirementsMet) {
|
|
87
|
+
setValidationError(t('passwordNotMeetRequirements'));
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (newPassword !== confirmPassword) {
|
|
92
|
+
setValidationError(t('passwordsDoNotMatch'));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
setLoading(true);
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
await resetPassword(token, newPassword);
|
|
100
|
+
setSuccess(true);
|
|
101
|
+
} catch (err) {
|
|
102
|
+
if (err instanceof ApiRequestError) {
|
|
103
|
+
switch (err.error) {
|
|
104
|
+
case 'weak_password':
|
|
105
|
+
setError(err.message || t('passwordWeakError'));
|
|
106
|
+
break;
|
|
107
|
+
case 'invalid_token':
|
|
108
|
+
case 'token_expired':
|
|
109
|
+
setError(t('invalidOrExpiredLink'));
|
|
110
|
+
break;
|
|
111
|
+
case 'password_required':
|
|
112
|
+
setError(t('errorPasswordRequired'));
|
|
113
|
+
break;
|
|
114
|
+
default:
|
|
115
|
+
setError(err.message || t('errorUnexpected'));
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
setError(t('errorUnexpected'));
|
|
119
|
+
}
|
|
120
|
+
} finally {
|
|
121
|
+
setLoading(false);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (success) {
|
|
126
|
+
return (
|
|
127
|
+
<div>
|
|
128
|
+
<h2 className="auth-title">{t('passwordResetSuccess')}</h2>
|
|
129
|
+
<div className="alert-success">
|
|
130
|
+
{t('passwordResetSuccessMessage')}
|
|
131
|
+
</div>
|
|
132
|
+
<div className="form-footer">
|
|
133
|
+
<Link to="/login" className="link">
|
|
134
|
+
{t('signIn')}
|
|
135
|
+
</Link>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (!token) {
|
|
142
|
+
return (
|
|
143
|
+
<div>
|
|
144
|
+
<h2 className="auth-title">{t('invalidLink')}</h2>
|
|
145
|
+
<div className="alert-error">
|
|
146
|
+
{t('invalidOrExpiredLink')}
|
|
147
|
+
</div>
|
|
148
|
+
<div className="form-footer">
|
|
149
|
+
<Link to="/forgot-password" className="link">
|
|
150
|
+
{t('requestNewResetLink')}
|
|
151
|
+
</Link>
|
|
152
|
+
</div>
|
|
153
|
+
</div>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
<div>
|
|
159
|
+
<h2 className="auth-title">{t('setNewPassword')}</h2>
|
|
160
|
+
|
|
161
|
+
{error && <div className="alert-error">{error}</div>}
|
|
162
|
+
{validationError && <div className="alert-error">{validationError}</div>}
|
|
163
|
+
|
|
164
|
+
<form onSubmit={handleSubmit}>
|
|
165
|
+
<div className="form-group">
|
|
166
|
+
<label htmlFor="newPassword">{t('newPassword')}</label>
|
|
167
|
+
<input
|
|
168
|
+
id="newPassword"
|
|
169
|
+
type="password"
|
|
170
|
+
value={newPassword}
|
|
171
|
+
onChange={(e) => setNewPassword(e.target.value)}
|
|
172
|
+
placeholder={t('newPasswordPlaceholder')}
|
|
173
|
+
autoComplete="new-password"
|
|
174
|
+
autoFocus
|
|
175
|
+
maxLength={256}
|
|
176
|
+
required
|
|
177
|
+
/>
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
{newPassword.length > 0 && (
|
|
181
|
+
<ul className="password-requirements">
|
|
182
|
+
{requirements.map((req) => (
|
|
183
|
+
<li key={req.label} className={req.met ? 'met' : 'unmet'}>
|
|
184
|
+
<span className="req-icon">{req.met ? '\u2713' : '\u2717'}</span>
|
|
185
|
+
{req.label}
|
|
186
|
+
</li>
|
|
187
|
+
))}
|
|
188
|
+
</ul>
|
|
189
|
+
)}
|
|
190
|
+
|
|
191
|
+
<div className="form-group">
|
|
192
|
+
<label htmlFor="confirmPassword">{t('confirmPassword')}</label>
|
|
193
|
+
<input
|
|
194
|
+
id="confirmPassword"
|
|
195
|
+
type="password"
|
|
196
|
+
value={confirmPassword}
|
|
197
|
+
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
198
|
+
placeholder={t('confirmPasswordPlaceholder')}
|
|
199
|
+
autoComplete="new-password"
|
|
200
|
+
maxLength={256}
|
|
201
|
+
required
|
|
202
|
+
/>
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
<button
|
|
206
|
+
type="submit"
|
|
207
|
+
className="btn-primary"
|
|
208
|
+
disabled={loading || !allRequirementsMet}
|
|
209
|
+
>
|
|
210
|
+
{loading ? (
|
|
211
|
+
<span className="btn-loading">
|
|
212
|
+
<span className="spinner" />
|
|
213
|
+
{t('resetting')}
|
|
214
|
+
</span>
|
|
215
|
+
) : (
|
|
216
|
+
t('resetPassword')
|
|
217
|
+
)}
|
|
218
|
+
</button>
|
|
219
|
+
|
|
220
|
+
<div className="form-footer">
|
|
221
|
+
<Link to="/login" className="link">
|
|
222
|
+
{t('backToSignIn')}
|
|
223
|
+
</Link>
|
|
224
|
+
</div>
|
|
225
|
+
</form>
|
|
226
|
+
</div>
|
|
227
|
+
);
|
|
228
|
+
}
|