@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.
@@ -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
+ }