mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 15:35:39 +02:00
LoginPage rendered the credentials form for an already-authenticated session, so a direct visit to /login (browsers autocomplete the origin to it) looked like "Remember Me" never worked despite a live token. Read user/loading from the auth context and redirect to / once the auth check settles, gated on the credentials step so the 2FA and OIDC-callback branches keep their own navigation.
940 lines
40 KiB
TypeScript
940 lines
40 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useAuth } from '../contexts/AuthContext';
|
|
import { useToast } from '../contexts/ToastContext';
|
|
import { useTheme } from '../contexts/ThemeContext';
|
|
import { X, Mail, Shield, Smartphone, Key } from 'lucide-react';
|
|
import { api, type LoginResponse, type OIDCProvider, type TokenPersistence } from '../api/client';
|
|
import { Card, CardHeader, CardContent } from '../components/Card';
|
|
import { Button } from '../components/Button';
|
|
|
|
type LoginStep = 'credentials' | '2fa' | 'reset-password';
|
|
|
|
// sessionStorage survives the OIDC provider round-trip; React state does not.
|
|
// Read + remove in one try so all branches in the OIDC useEffect see the same
|
|
// value and a subsequent page load does not replay the flag.
|
|
const REMEMBER_ME_KEY = 'auth_remember_me';
|
|
const POST_LOGIN_REDIRECT_KEY = 'auth_post_login_redirect';
|
|
|
|
function toPersistence(remember: boolean): TokenPersistence {
|
|
return remember ? 'persistent' : 'session';
|
|
}
|
|
|
|
function consumeSavedRememberMe(): boolean {
|
|
try {
|
|
const saved = sessionStorage.getItem(REMEMBER_ME_KEY) === '1';
|
|
sessionStorage.removeItem(REMEMBER_ME_KEY);
|
|
return saved;
|
|
} catch (err) {
|
|
console.warn('consumeSavedRememberMe: sessionStorage unavailable, Remember Me preference lost across OIDC redirect', err);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Only accept same-origin internal paths. Rejects protocol-relative (`//evil.com`),
|
|
// absolute URLs, and the login page itself (would loop). Anything else falls
|
|
// back to `/` so a tampered sessionStorage entry can't open-redirect.
|
|
function sanitizeRedirectTarget(target: string | null | undefined): string | null {
|
|
if (!target) return null;
|
|
if (!target.startsWith('/')) return null;
|
|
if (target.startsWith('//')) return null;
|
|
if (target.startsWith('/login')) return null;
|
|
return target;
|
|
}
|
|
|
|
function stashPostLoginRedirect(target: string): void {
|
|
const safe = sanitizeRedirectTarget(target);
|
|
if (!safe) return;
|
|
try {
|
|
sessionStorage.setItem(POST_LOGIN_REDIRECT_KEY, safe);
|
|
} catch (err) {
|
|
console.warn('stashPostLoginRedirect: sessionStorage unavailable, post-login target will be lost across OIDC redirect', err);
|
|
}
|
|
}
|
|
|
|
function consumePostLoginRedirect(): string | null {
|
|
try {
|
|
const saved = sessionStorage.getItem(POST_LOGIN_REDIRECT_KEY);
|
|
sessionStorage.removeItem(POST_LOGIN_REDIRECT_KEY);
|
|
return sanitizeRedirectTarget(saved);
|
|
} catch (err) {
|
|
console.warn('consumePostLoginRedirect: sessionStorage unavailable', err);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Single OIDC-provider login button. Extracted from the `.map()` body
|
|
* because hooks can't be used inside a loop callback — the `iconFailed`
|
|
* state is per-provider and must live in its own component instance.
|
|
*
|
|
* On `<img>` load failure (provider deleted between page load and image
|
|
* fetch, network blip, etc.) we flip to the Shield fallback rather than
|
|
* showing the browser's broken-image glyph to anonymous users (#1333 review).
|
|
*/
|
|
function OIDCProviderButton({
|
|
provider,
|
|
onClick,
|
|
disabled,
|
|
}: {
|
|
provider: OIDCProvider;
|
|
onClick: () => void;
|
|
disabled: boolean;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const [iconFailed, setIconFailed] = useState(false);
|
|
const showIcon = provider.has_icon && !iconFailed;
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
disabled={disabled}
|
|
className="w-full flex items-center justify-center gap-3 py-3 px-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary hover:border-bambu-green/50 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
|
|
>
|
|
{showIcon ? (
|
|
<img
|
|
src={api.oidcProviderIconUrl(provider.id)}
|
|
alt=""
|
|
className="w-5 h-5 object-contain"
|
|
onError={() => setIconFailed(true)}
|
|
/>
|
|
) : (
|
|
<Shield className="w-5 h-5 text-bambu-green" />
|
|
)}
|
|
{t('login.twoFA.signInWith', { provider: provider.name })}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export function LoginPage() {
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const [searchParams] = useSearchParams();
|
|
const { t } = useTranslation();
|
|
const { login, loginWithToken, user, loading } = useAuth();
|
|
const { showToast } = useToast();
|
|
const { mode } = useTheme();
|
|
|
|
// Resolve the post-login destination, preferring router state (set by
|
|
// ProtectedRoute when it redirects an unauthed visit) over the sessionStorage
|
|
// stash (used to survive the OIDC provider round-trip, which kills React
|
|
// state). Falls back to `/` and rejects unsafe targets via sanitize.
|
|
function resolvePostLoginRedirect(): string {
|
|
const fromState = (location.state as { from?: { pathname?: string; search?: string } } | null)?.from;
|
|
if (fromState?.pathname) {
|
|
const target = `${fromState.pathname}${fromState.search ?? ''}`;
|
|
const safe = sanitizeRedirectTarget(target);
|
|
if (safe) return safe;
|
|
}
|
|
return consumePostLoginRedirect() ?? '/';
|
|
}
|
|
|
|
// Credentials step state
|
|
const [username, setUsername] = useState('');
|
|
const [password, setPassword] = useState('');
|
|
const [showForgotPassword, setShowForgotPassword] = useState(false);
|
|
const [forgotEmail, setForgotEmail] = useState('');
|
|
|
|
// 2FA step state
|
|
const [step, setStep] = useState<LoginStep>('credentials');
|
|
const [preAuthToken, setPreAuthToken] = useState('');
|
|
const [twoFAMethods, setTwoFAMethods] = useState<string[]>([]);
|
|
const [twoFAMethod, setTwoFAMethod] = useState<'totp' | 'email' | 'backup'>('totp');
|
|
const [twoFACode, setTwoFACode] = useState('');
|
|
const [emailOTPSent, setEmailOTPSent] = useState(false);
|
|
const twoFAInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const [rememberMe, setRememberMe] = useState(false);
|
|
|
|
// H-6: Password reset step state
|
|
const [resetToken, setResetToken] = useState('');
|
|
const [newPassword, setNewPassword] = useState('');
|
|
const [confirmPassword, setConfirmPassword] = useState('');
|
|
|
|
// Check if advanced auth is enabled
|
|
const { data: advancedAuthStatus } = useQuery({
|
|
queryKey: ['advancedAuthStatus'],
|
|
queryFn: () => api.getAdvancedAuthStatus(),
|
|
});
|
|
|
|
// Fetch enabled OIDC providers for login buttons
|
|
const { data: oidcProviders } = useQuery({
|
|
queryKey: ['oidcProviders'],
|
|
queryFn: () => api.getOIDCProviders(),
|
|
});
|
|
|
|
// #1589: autologin redirect with fallback. When the backend reports an
|
|
// `autologin_provider_id`, redirect unauthenticated visitors directly to
|
|
// that provider's authorize URL on mount — unless the URL carries
|
|
// `?fallback=local` (the documented recovery path that pairs with the
|
|
// server-side BAMBUDDY_LOCAL_LOGIN env-var bypass). The authorize-URL
|
|
// fetch is raced against a 5-second timeout; on timeout or fetch error
|
|
// we skip the redirect and render the normal page, surfacing a banner
|
|
// so the user understands why autologin didn't kick in.
|
|
const [autologinFailed, setAutologinFailed] = useState(false);
|
|
|
|
// #1889: redirect already-authenticated visitors away from /login. Without
|
|
// this, a valid session that lands directly on /login (e.g. the browser
|
|
// address bar autocompletes the origin to its most-visited path) renders the
|
|
// credentials form even though the token is live and every request succeeds —
|
|
// making Bambuddy look like it "never stays logged in". Gate on the
|
|
// credentials step so we don't interrupt the 2FA / OIDC-callback branches,
|
|
// which navigate themselves after loginWithToken. Send to '/' rather than
|
|
// resolvePostLoginRedirect() to avoid consuming the OIDC redirect stash: an
|
|
// already-authed direct visit has no pending redirect to honour.
|
|
useEffect(() => {
|
|
if (!loading && user && step === 'credentials') {
|
|
navigate('/', { replace: true });
|
|
}
|
|
}, [loading, user, step, navigate]);
|
|
|
|
const autologinAttemptedRef = useRef(false);
|
|
useEffect(() => {
|
|
if (autologinAttemptedRef.current) return;
|
|
const fallbackQuery = searchParams.get('fallback');
|
|
if (fallbackQuery === 'local') return;
|
|
if (!advancedAuthStatus || !advancedAuthStatus.autologin_provider_id) return;
|
|
// Don't redirect mid-OIDC-exchange (we're already coming back from the IdP).
|
|
const hash = window.location.hash;
|
|
if (hash.startsWith('#oidc_token=') || searchParams.get('oidc_error')) return;
|
|
autologinAttemptedRef.current = true;
|
|
|
|
const providerId = advancedAuthStatus.autologin_provider_id;
|
|
const timeoutPromise = new Promise<never>((_resolve, reject) =>
|
|
setTimeout(() => reject(new Error('autologin timeout')), 5000),
|
|
);
|
|
Promise.race([api.getOIDCAuthorizeUrl(providerId), timeoutPromise])
|
|
.then((result) => {
|
|
window.location.href = (result as { auth_url: string }).auth_url;
|
|
})
|
|
.catch(() => {
|
|
setAutologinFailed(true);
|
|
});
|
|
}, [advancedAuthStatus, searchParams]);
|
|
|
|
const localLoginEnabled = advancedAuthStatus?.local_login_enabled !== false;
|
|
const showAutologinBanner = autologinFailed && advancedAuthStatus?.autologin_provider_id != null;
|
|
|
|
// M-B: Detect #reset_token=... in the URL fragment and switch to the reset step.
|
|
// Fragments are never sent to the server so the token never appears in access-logs
|
|
// or Referer headers — mirrors the H-4 treatment of the OIDC token.
|
|
useEffect(() => {
|
|
const hash = window.location.hash;
|
|
const token = hash.startsWith('#reset_token=') ? hash.slice('#reset_token='.length) : null;
|
|
if (token) {
|
|
setResetToken(token);
|
|
setStep('reset-password');
|
|
// Clear the fragment from the URL so it can't be bookmarked or re-triggered.
|
|
navigate('/login', { replace: true });
|
|
}
|
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// Handle OIDC callback: if #oidc_token=... is present in the fragment, exchange it.
|
|
// H-4: Read from the URL fragment (#) — fragments are never sent to the server
|
|
// so the exchange token stays out of access logs and Referer headers.
|
|
useEffect(() => {
|
|
const hash = window.location.hash;
|
|
const oidcToken = hash.startsWith('#oidc_token=') ? hash.slice('#oidc_token='.length) : null;
|
|
const oidcError = searchParams.get('oidc_error');
|
|
|
|
if (!oidcToken && !oidcError) return;
|
|
|
|
const savedRememberMe = consumeSavedRememberMe();
|
|
|
|
if (oidcError) {
|
|
// L-3: Whitelist known OIDC error codes so provider-controlled text is never
|
|
// shown verbatim. Any unknown code falls back to a generic message.
|
|
const KNOWN_OIDC_ERRORS: Record<string, string> = {
|
|
oidc_provider_error: t('login.oidcErrors.providerError'),
|
|
missing_parameters: t('login.oidcErrors.missingParameters'),
|
|
invalid_state: t('login.oidcErrors.invalidState'),
|
|
state_expired: t('login.oidcErrors.stateExpired'),
|
|
provider_not_found: t('login.oidcErrors.providerNotFound'),
|
|
discovery_failed: t('login.oidcErrors.discoveryFailed'),
|
|
invalid_discovery_document: t('login.oidcErrors.invalidDiscovery'),
|
|
token_exchange_network_error: t('login.oidcErrors.networkError'),
|
|
token_exchange_bad_response: t('login.oidcErrors.badResponse'),
|
|
no_id_token: t('login.oidcErrors.noIdToken'),
|
|
token_validation_failed: t('login.oidcErrors.validationFailed'),
|
|
nonce_mismatch: t('login.oidcErrors.nonceMismatch'),
|
|
missing_sub_claim: t('login.oidcErrors.missingSubClaim'),
|
|
no_linked_account: t('login.oidcErrors.noLinkedAccount'),
|
|
account_inactive: t('login.oidcErrors.accountInactive'),
|
|
user_resolution_failed: t('login.oidcErrors.userResolutionFailed'),
|
|
internal_error: t('login.oidcErrors.internalError'),
|
|
};
|
|
// Dynamic codes like "token_exchange_<provider_code>" → generic message
|
|
const errorMsg = KNOWN_OIDC_ERRORS[oidcError]
|
|
?? (oidcError.startsWith('token_exchange_') ? t('login.oidcErrors.tokenExchangeFailed') : t('login.oidcLoginFailed'));
|
|
showToast(errorMsg, 'error');
|
|
navigate('/login', { replace: true });
|
|
return;
|
|
}
|
|
|
|
if (oidcToken) {
|
|
api.exchangeOIDCToken(oidcToken).then((resp: LoginResponse) => {
|
|
if (resp.requires_2fa && resp.pre_auth_token) {
|
|
// OIDC user has 2FA enabled — redirect to 2FA step
|
|
setRememberMe(savedRememberMe);
|
|
setPreAuthToken(resp.pre_auth_token);
|
|
const methods = resp.two_fa_methods ?? [];
|
|
setTwoFAMethods(methods);
|
|
if (methods.includes('totp')) setTwoFAMethod('totp');
|
|
else if (methods.includes('email')) setTwoFAMethod('email');
|
|
else setTwoFAMethod('backup');
|
|
setStep('2fa');
|
|
// Remove oidc_token from URL so page refresh doesn't re-trigger exchange
|
|
navigate('/login', { replace: true });
|
|
} else if (resp.access_token && resp.user) {
|
|
loginWithToken(resp.access_token, resp.user, toPersistence(savedRememberMe));
|
|
showToast(t('login.loginSuccess'));
|
|
navigate(resolvePostLoginRedirect(), { replace: true });
|
|
} else {
|
|
showToast(t('login.oidcLoginFailed'), 'error');
|
|
navigate('/login', { replace: true });
|
|
}
|
|
}).catch((err: unknown) => {
|
|
console.error('OIDC token exchange failed', err);
|
|
showToast(t('login.oidcLoginFailed'), 'error');
|
|
navigate('/login', { replace: true });
|
|
});
|
|
}
|
|
}, [searchParams]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// --- Step 1: Credentials login ---
|
|
const loginMutation = useMutation({
|
|
mutationFn: () => login(username, password, toPersistence(rememberMe)),
|
|
onSuccess: (resp: LoginResponse) => {
|
|
if (resp.requires_2fa && resp.pre_auth_token) {
|
|
// 2FA required — switch to verification step
|
|
setPreAuthToken(resp.pre_auth_token);
|
|
const methods = resp.two_fa_methods ?? [];
|
|
setTwoFAMethods(methods);
|
|
// Pick a sensible default method
|
|
if (methods.includes('totp')) setTwoFAMethod('totp');
|
|
else if (methods.includes('email')) setTwoFAMethod('email');
|
|
else setTwoFAMethod('backup');
|
|
setStep('2fa');
|
|
} else if (resp.access_token && resp.user) {
|
|
showToast(t('login.loginSuccess'));
|
|
navigate(resolvePostLoginRedirect(), { replace: true });
|
|
}
|
|
},
|
|
onError: (error: Error) => {
|
|
showToast(error.message || t('login.loginFailed'), 'error');
|
|
},
|
|
});
|
|
|
|
const forgotPasswordMutation = useMutation({
|
|
mutationFn: (email: string) => api.forgotPassword({ email }),
|
|
onSuccess: (data) => {
|
|
showToast(data.message, 'success');
|
|
setShowForgotPassword(false);
|
|
setForgotEmail('');
|
|
},
|
|
onError: (error: Error) => {
|
|
showToast(error.message, 'error');
|
|
},
|
|
});
|
|
|
|
// H-6: Mutation to set a new password using the reset token from the email link
|
|
const resetPasswordMutation = useMutation({
|
|
mutationFn: () => api.forgotPasswordConfirm(resetToken, newPassword),
|
|
onSuccess: (data) => {
|
|
showToast(data.message, 'success');
|
|
setStep('credentials');
|
|
setResetToken('');
|
|
setNewPassword('');
|
|
setConfirmPassword('');
|
|
},
|
|
onError: (error: Error) => {
|
|
showToast(error.message || t('login.resetPassword.resetFailed'), 'error');
|
|
},
|
|
});
|
|
|
|
// --- Step 2: 2FA verification ---
|
|
const sendEmailOTPMutation = useMutation({
|
|
mutationFn: () => api.sendEmailOTP(preAuthToken),
|
|
onSuccess: (data: { message: string; pre_auth_token?: string }) => {
|
|
setEmailOTPSent(true);
|
|
// Backend issues a fresh pre-auth token after consuming the original one
|
|
if (data.pre_auth_token) setPreAuthToken(data.pre_auth_token);
|
|
showToast(data.message, 'success');
|
|
},
|
|
onError: (error: Error) => {
|
|
showToast(error.message || t('login.twoFA.sendCodeFailed'), 'error');
|
|
},
|
|
});
|
|
|
|
const verify2FAMutation = useMutation({
|
|
mutationFn: () =>
|
|
api.verify2FA({ pre_auth_token: preAuthToken, code: twoFACode, method: twoFAMethod }),
|
|
onSuccess: (resp: LoginResponse) => {
|
|
if (resp.access_token && resp.user) {
|
|
loginWithToken(resp.access_token, resp.user, toPersistence(rememberMe));
|
|
showToast(t('login.loginSuccess'));
|
|
navigate(resolvePostLoginRedirect(), { replace: true });
|
|
} else {
|
|
console.error('2FA verify: unexpected response shape', resp);
|
|
showToast(t('login.loginFailed'), 'error');
|
|
}
|
|
},
|
|
onError: (error: Error) => {
|
|
showToast(error.message || t('login.twoFA.invalidCode'), 'error');
|
|
setTwoFACode('');
|
|
},
|
|
});
|
|
|
|
// OIDC login
|
|
const oidcLoginMutation = useMutation({
|
|
mutationFn: (providerId: number) => api.getOIDCAuthorizeUrl(providerId),
|
|
onSuccess: (data) => {
|
|
if (rememberMe) {
|
|
try {
|
|
sessionStorage.setItem(REMEMBER_ME_KEY, '1');
|
|
} catch (err) {
|
|
console.warn('setItem auth_remember_me failed, Remember Me will not carry through OIDC redirect', err);
|
|
}
|
|
}
|
|
// Stash the post-login destination from router state so it survives the
|
|
// provider round-trip (window.location.href kills React state). If the
|
|
// user landed on /login directly, fromState is absent and we don't stash.
|
|
const fromState = (location.state as { from?: { pathname?: string; search?: string } } | null)?.from;
|
|
if (fromState?.pathname) {
|
|
stashPostLoginRedirect(`${fromState.pathname}${fromState.search ?? ''}`);
|
|
}
|
|
window.location.href = data.auth_url;
|
|
},
|
|
onError: (error: Error) => {
|
|
showToast(error.message || t('login.oidcLoginFailed'), 'error');
|
|
},
|
|
});
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!username || !password) {
|
|
showToast(t('login.enterCredentials'), 'error');
|
|
return;
|
|
}
|
|
loginMutation.mutate();
|
|
};
|
|
|
|
const handle2FASubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!twoFACode.trim()) {
|
|
showToast(t('login.twoFA.enterCode'), 'error');
|
|
return;
|
|
}
|
|
verify2FAMutation.mutate();
|
|
};
|
|
|
|
const handleForgotPassword = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!forgotEmail) {
|
|
showToast(t('login.enterEmail'), 'error');
|
|
return;
|
|
}
|
|
forgotPasswordMutation.mutate(forgotEmail);
|
|
};
|
|
|
|
const handleMethodChange = (method: 'totp' | 'email' | 'backup') => {
|
|
setTwoFAMethod(method);
|
|
setTwoFACode('');
|
|
setEmailOTPSent(false);
|
|
// Re-focus the code input after method switch (autoFocus only fires on mount)
|
|
setTimeout(() => twoFAInputRef.current?.focus(), 0);
|
|
};
|
|
|
|
// ---- Render: password-reset step (H-6) ----
|
|
if (step === 'reset-password') {
|
|
const handleResetSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (newPassword !== confirmPassword) {
|
|
showToast(t('login.resetPassword.passwordsDoNotMatch'), 'error');
|
|
return;
|
|
}
|
|
if (newPassword.length < 8) {
|
|
showToast(t('login.resetPassword.passwordTooShort'), 'error');
|
|
return;
|
|
}
|
|
resetPasswordMutation.mutate();
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center bg-bambu-dark p-4">
|
|
<div className="max-w-md w-full space-y-8 p-8 bg-gradient-to-br from-bambu-card to-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary shadow-lg">
|
|
<div className="text-center">
|
|
<div className="flex items-center justify-center mb-4">
|
|
<div className="w-14 h-14 rounded-full bg-bambu-green/20 flex items-center justify-center">
|
|
<Key className="w-7 h-7 text-bambu-green" />
|
|
</div>
|
|
</div>
|
|
<h2 className="text-2xl font-bold text-white">{t('login.resetPassword.title')}</h2>
|
|
<p className="mt-2 text-sm text-bambu-gray">{t('login.resetPassword.subtitle')}</p>
|
|
</div>
|
|
|
|
<form onSubmit={handleResetSubmit} className="space-y-4">
|
|
<div>
|
|
<label htmlFor="new-password" className="block text-sm font-medium text-white mb-2">
|
|
{t('login.resetPassword.newPassword')}
|
|
</label>
|
|
<input
|
|
id="new-password"
|
|
type="password"
|
|
required
|
|
value={newPassword}
|
|
onChange={(e) => setNewPassword(e.target.value)}
|
|
className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
|
|
placeholder={t('login.resetPassword.newPasswordPlaceholder')}
|
|
autoFocus
|
|
autoComplete="new-password"
|
|
minLength={8}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label htmlFor="confirm-password" className="block text-sm font-medium text-white mb-2">
|
|
{t('login.resetPassword.confirmPassword')}
|
|
</label>
|
|
<input
|
|
id="confirm-password"
|
|
type="password"
|
|
required
|
|
value={confirmPassword}
|
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
|
|
placeholder={t('login.resetPassword.confirmPasswordPlaceholder')}
|
|
autoComplete="new-password"
|
|
/>
|
|
</div>
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={resetPasswordMutation.isPending || !newPassword || !confirmPassword}
|
|
className="w-full flex justify-center py-3 px-4 bg-bambu-green hover:bg-bambu-green-light text-white font-medium rounded-lg shadow-lg shadow-bambu-green/20 hover:shadow-bambu-green/30 focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:ring-offset-2 focus:ring-offset-bambu-dark-secondary transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{resetPasswordMutation.isPending ? t('login.resetPassword.saving') : t('login.resetPassword.submit')}
|
|
</button>
|
|
</form>
|
|
|
|
<div className="text-center">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setStep('credentials');
|
|
setResetToken('');
|
|
setNewPassword('');
|
|
setConfirmPassword('');
|
|
}}
|
|
className="text-sm text-bambu-gray hover:text-bambu-green transition-colors"
|
|
>
|
|
{t('login.resetPassword.backToLogin')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---- Render: 2FA step ----
|
|
if (step === '2fa') {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center bg-bambu-dark p-4">
|
|
<div className="max-w-md w-full space-y-8 p-8 bg-gradient-to-br from-bambu-card to-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary shadow-lg">
|
|
<div className="text-center">
|
|
<div className="flex items-center justify-center mb-4">
|
|
<div className="w-14 h-14 rounded-full bg-bambu-green/20 flex items-center justify-center">
|
|
<Shield className="w-7 h-7 text-bambu-green" />
|
|
</div>
|
|
</div>
|
|
<h2 className="text-2xl font-bold text-white">{t('login.twoFA.title')}</h2>
|
|
<p className="mt-2 text-sm text-bambu-gray">{t('login.twoFA.subtitle')}</p>
|
|
</div>
|
|
|
|
{/* Method selector — only show if multiple methods available */}
|
|
{twoFAMethods.length > 1 && (
|
|
<div className="flex gap-2">
|
|
{twoFAMethods.includes('totp') && (
|
|
<button
|
|
type="button"
|
|
onClick={() => handleMethodChange('totp')}
|
|
className={`flex-1 flex flex-col items-center gap-1 py-2 px-3 rounded-lg border text-xs font-medium transition-colors ${
|
|
twoFAMethod === 'totp'
|
|
? 'border-bambu-green bg-bambu-green/10 text-bambu-green'
|
|
: 'border-bambu-dark-tertiary text-bambu-gray hover:border-bambu-green/50'
|
|
}`}
|
|
>
|
|
<Smartphone className="w-4 h-4" />
|
|
{t('login.twoFA.methodAuthenticator')}
|
|
</button>
|
|
)}
|
|
{twoFAMethods.includes('email') && (
|
|
<button
|
|
type="button"
|
|
onClick={() => handleMethodChange('email')}
|
|
className={`flex-1 flex flex-col items-center gap-1 py-2 px-3 rounded-lg border text-xs font-medium transition-colors ${
|
|
twoFAMethod === 'email'
|
|
? 'border-bambu-green bg-bambu-green/10 text-bambu-green'
|
|
: 'border-bambu-dark-tertiary text-bambu-gray hover:border-bambu-green/50'
|
|
}`}
|
|
>
|
|
<Mail className="w-4 h-4" />
|
|
{t('login.twoFA.methodEmail')}
|
|
</button>
|
|
)}
|
|
{twoFAMethods.includes('backup') && (
|
|
<button
|
|
type="button"
|
|
onClick={() => handleMethodChange('backup')}
|
|
className={`flex-1 flex flex-col items-center gap-1 py-2 px-3 rounded-lg border text-xs font-medium transition-colors ${
|
|
twoFAMethod === 'backup'
|
|
? 'border-bambu-green bg-bambu-green/10 text-bambu-green'
|
|
: 'border-bambu-dark-tertiary text-bambu-gray hover:border-bambu-green/50'
|
|
}`}
|
|
>
|
|
<Key className="w-4 h-4" />
|
|
{t('login.twoFA.methodBackup')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<form onSubmit={handle2FASubmit} className="space-y-4">
|
|
{/* Method-specific instructions */}
|
|
{twoFAMethod === 'totp' && (
|
|
<p className="text-sm text-bambu-gray">{t('login.twoFA.instructionsTotp')}</p>
|
|
)}
|
|
{twoFAMethod === 'email' && (
|
|
<div className="space-y-3">
|
|
<p className="text-sm text-bambu-gray">
|
|
{emailOTPSent
|
|
? t('login.twoFA.instructionsEmail')
|
|
: t('login.twoFA.instructionsEmailNotSent')}
|
|
</p>
|
|
{!emailOTPSent && (
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
className="w-full"
|
|
onClick={() => sendEmailOTPMutation.mutate()}
|
|
disabled={sendEmailOTPMutation.isPending}
|
|
>
|
|
{sendEmailOTPMutation.isPending
|
|
? t('login.twoFA.sendingCode')
|
|
: t('login.twoFA.sendCodeButton')}
|
|
</Button>
|
|
)}
|
|
{emailOTPSent && (
|
|
<button
|
|
type="button"
|
|
onClick={() => { setEmailOTPSent(false); sendEmailOTPMutation.mutate(); }}
|
|
className="text-xs text-bambu-gray hover:text-bambu-green transition-colors"
|
|
>
|
|
{t('login.twoFA.resendCode')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
{twoFAMethod === 'backup' && (
|
|
<p className="text-sm text-bambu-gray">{t('login.twoFA.instructionsBackup')}</p>
|
|
)}
|
|
|
|
<div>
|
|
<label htmlFor="twofa-code" className="block text-sm font-medium text-white mb-2">
|
|
{twoFAMethod === 'backup'
|
|
? t('login.twoFA.backupCodeLabel')
|
|
: t('login.twoFA.codeLabel')}
|
|
</label>
|
|
<input
|
|
ref={twoFAInputRef}
|
|
id="twofa-code"
|
|
type="text"
|
|
inputMode={twoFAMethod === 'backup' ? 'text' : 'numeric'}
|
|
autoComplete="one-time-code"
|
|
value={twoFACode}
|
|
onChange={(e) => setTwoFACode(e.target.value.trim())}
|
|
disabled={twoFAMethod === 'email' && !emailOTPSent}
|
|
className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray text-center tracking-widest text-xl font-mono focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors disabled:opacity-40"
|
|
placeholder={twoFAMethod === 'backup'
|
|
? t('login.twoFA.backupCodePlaceholder')
|
|
: t('login.twoFA.codePlaceholder')}
|
|
maxLength={twoFAMethod === 'backup' ? 8 : 6}
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={
|
|
verify2FAMutation.isPending ||
|
|
!twoFACode.trim() ||
|
|
(twoFAMethod === 'email' && !emailOTPSent)
|
|
}
|
|
className="w-full flex justify-center py-3 px-4 bg-bambu-green hover:bg-bambu-green-light text-white font-medium rounded-lg shadow-lg shadow-bambu-green/20 hover:shadow-bambu-green/30 focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:ring-offset-2 focus:ring-offset-bambu-dark-secondary transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{verify2FAMutation.isPending
|
|
? t('login.twoFA.verifyingButton')
|
|
: t('login.twoFA.verifyButton')}
|
|
</button>
|
|
</form>
|
|
|
|
<div className="text-center">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setStep('credentials');
|
|
setPreAuthToken('');
|
|
setTwoFACode('');
|
|
setEmailOTPSent(false);
|
|
}}
|
|
className="text-sm text-bambu-gray hover:text-bambu-green transition-colors"
|
|
>
|
|
{t('login.twoFA.backToLogin')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---- Render: credentials step ----
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center bg-bambu-dark p-4">
|
|
<div className="max-w-md w-full space-y-8 p-8 bg-gradient-to-br from-bambu-card to-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary shadow-lg">
|
|
<div className="text-center">
|
|
<div className="flex items-center justify-center mb-6">
|
|
<img
|
|
src={mode === 'dark' ? '/img/bambuddy_logo_dark_transparent.png' : '/img/bambuddy_logo_light.png'}
|
|
alt="Bambuddy"
|
|
className="h-16"
|
|
/>
|
|
</div>
|
|
<h2 className="text-3xl font-bold text-white">
|
|
{t('login.title')}
|
|
</h2>
|
|
<p className="mt-2 text-sm text-bambu-gray">
|
|
{t('login.subtitle')}
|
|
</p>
|
|
</div>
|
|
|
|
{showAutologinBanner && (
|
|
<div className="mt-6 rounded-lg border border-amber-300 dark:border-amber-500/40 bg-amber-50 dark:bg-amber-500/10 px-4 py-3 text-sm text-amber-800 dark:text-amber-200">
|
|
{t('login.autologinFailed')}
|
|
</div>
|
|
)}
|
|
|
|
{!localLoginEnabled && (
|
|
<div className="mt-6 rounded-lg border border-bambu-dark-tertiary bg-bambu-dark/40 px-4 py-3 text-sm text-bambu-gray">
|
|
{t('login.localDisabledNotice')}
|
|
</div>
|
|
)}
|
|
|
|
{localLoginEnabled && (
|
|
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label htmlFor="username" className="block text-sm font-medium text-white mb-2">
|
|
{advancedAuthStatus?.advanced_auth_enabled
|
|
? t('login.usernameOrEmail')
|
|
: t('login.username')}
|
|
</label>
|
|
<input
|
|
id="username"
|
|
type="text"
|
|
required
|
|
value={username}
|
|
onChange={(e) => setUsername(e.target.value)}
|
|
className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
|
|
placeholder={advancedAuthStatus?.advanced_auth_enabled
|
|
? t('login.usernameOrEmailPlaceholder')
|
|
: t('login.usernamePlaceholder')}
|
|
autoComplete="username"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label htmlFor="password" className="block text-sm font-medium text-white mb-2">
|
|
{t('login.password') || 'Password'}
|
|
</label>
|
|
<input
|
|
id="password"
|
|
type="password"
|
|
required
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
|
|
placeholder={t('login.passwordPlaceholder')}
|
|
autoComplete="current-password"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
id="remember-me"
|
|
type="checkbox"
|
|
checked={rememberMe}
|
|
onChange={(e) => setRememberMe(e.target.checked)}
|
|
className="h-4 w-4 rounded border-bambu-dark-tertiary bg-bambu-dark-secondary text-bambu-green focus:ring-bambu-green/50 cursor-pointer"
|
|
/>
|
|
<label htmlFor="remember-me" className="text-sm text-bambu-gray cursor-pointer">
|
|
{t('login.rememberMe')}
|
|
</label>
|
|
</div>
|
|
|
|
<div>
|
|
<button
|
|
type="submit"
|
|
disabled={loginMutation.isPending}
|
|
className="w-full flex justify-center py-3 px-4 bg-bambu-green hover:bg-bambu-green-light text-white font-medium rounded-lg shadow-lg shadow-bambu-green/20 hover:shadow-bambu-green/30 focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:ring-offset-2 focus:ring-offset-bambu-dark-secondary transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-bambu-green"
|
|
>
|
|
{loginMutation.isPending ? t('login.signingIn') : t('login.signIn')}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="text-center">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowForgotPassword(true)}
|
|
className="text-sm text-bambu-gray hover:text-bambu-green transition-colors"
|
|
>
|
|
{t('login.forgotPassword')}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
|
|
{/* OIDC provider buttons */}
|
|
{oidcProviders && oidcProviders.length > 0 && (
|
|
<div className="space-y-3">
|
|
<div className="relative">
|
|
<div className="absolute inset-0 flex items-center">
|
|
<div className="w-full border-t border-bambu-dark-tertiary" />
|
|
</div>
|
|
<div className="relative flex justify-center text-sm">
|
|
<span className="px-2 bg-bambu-dark-secondary text-bambu-gray">{t('login.twoFA.orContinueWith')}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
{oidcProviders.map((provider) => (
|
|
<OIDCProviderButton
|
|
key={provider.id}
|
|
provider={provider}
|
|
onClick={() => oidcLoginMutation.mutate(provider.id)}
|
|
disabled={oidcLoginMutation.isPending}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Forgot Password Modal */}
|
|
{showForgotPassword && (
|
|
<div
|
|
className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
|
|
onClick={() => setShowForgotPassword(false)}
|
|
>
|
|
<Card
|
|
className="w-full max-w-md"
|
|
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
|
>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Mail className="w-5 h-5 text-bambu-green" />
|
|
<h2 className="text-lg font-semibold text-white">{t('login.forgotPasswordTitle')}</h2>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
setShowForgotPassword(false);
|
|
setForgotEmail('');
|
|
}}
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{advancedAuthStatus?.advanced_auth_enabled ? (
|
|
<form onSubmit={handleForgotPassword} className="space-y-4">
|
|
<p className="text-bambu-gray text-sm">
|
|
{t('login.forgotPasswordEmailMessage')}
|
|
</p>
|
|
|
|
<div>
|
|
<label htmlFor="forgot-email" className="block text-sm font-medium text-white mb-2">
|
|
{t('login.emailAddress')}
|
|
</label>
|
|
<input
|
|
id="forgot-email"
|
|
type="email"
|
|
required
|
|
value={forgotEmail}
|
|
onChange={(e) => setForgotEmail(e.target.value)}
|
|
className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
|
|
placeholder={t('login.emailPlaceholder')}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
className="flex-1"
|
|
onClick={() => {
|
|
setShowForgotPassword(false);
|
|
setForgotEmail('');
|
|
}}
|
|
>
|
|
{t('login.cancel')}
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
className="flex-1"
|
|
disabled={forgotPasswordMutation.isPending}
|
|
>
|
|
{forgotPasswordMutation.isPending
|
|
? t('login.sending')
|
|
: t('login.sendResetEmail')}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
) : (
|
|
<div className="space-y-4">
|
|
<p className="text-bambu-gray">
|
|
{t('login.forgotPasswordMessage')}
|
|
</p>
|
|
|
|
<div className="bg-bambu-dark rounded-lg p-4 space-y-2">
|
|
<p className="text-sm text-white font-medium">{t('login.howToReset')}</p>
|
|
<ol className="text-sm text-bambu-gray space-y-1 list-decimal list-inside">
|
|
<li>{t('login.resetStep1')}</li>
|
|
<li>{t('login.resetStep2')}</li>
|
|
<li>{t('login.resetStep3')}</li>
|
|
<li>{t('login.resetStep4')}</li>
|
|
</ol>
|
|
</div>
|
|
|
|
<Button
|
|
variant="secondary"
|
|
className="w-full"
|
|
onClick={() => setShowForgotPassword(false)}
|
|
>
|
|
{t('login.gotIt')}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|