LoginPage.tsx 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923
  1. import { useEffect, useRef, useState } from 'react';
  2. import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
  3. import { useMutation, useQuery } from '@tanstack/react-query';
  4. import { useTranslation } from 'react-i18next';
  5. import { useAuth } from '../contexts/AuthContext';
  6. import { useToast } from '../contexts/ToastContext';
  7. import { useTheme } from '../contexts/ThemeContext';
  8. import { X, Mail, Shield, Smartphone, Key } from 'lucide-react';
  9. import { api, type LoginResponse, type OIDCProvider, type TokenPersistence } from '../api/client';
  10. import { Card, CardHeader, CardContent } from '../components/Card';
  11. import { Button } from '../components/Button';
  12. type LoginStep = 'credentials' | '2fa' | 'reset-password';
  13. // sessionStorage survives the OIDC provider round-trip; React state does not.
  14. // Read + remove in one try so all branches in the OIDC useEffect see the same
  15. // value and a subsequent page load does not replay the flag.
  16. const REMEMBER_ME_KEY = 'auth_remember_me';
  17. const POST_LOGIN_REDIRECT_KEY = 'auth_post_login_redirect';
  18. function toPersistence(remember: boolean): TokenPersistence {
  19. return remember ? 'persistent' : 'session';
  20. }
  21. function consumeSavedRememberMe(): boolean {
  22. try {
  23. const saved = sessionStorage.getItem(REMEMBER_ME_KEY) === '1';
  24. sessionStorage.removeItem(REMEMBER_ME_KEY);
  25. return saved;
  26. } catch (err) {
  27. console.warn('consumeSavedRememberMe: sessionStorage unavailable, Remember Me preference lost across OIDC redirect', err);
  28. return false;
  29. }
  30. }
  31. // Only accept same-origin internal paths. Rejects protocol-relative (`//evil.com`),
  32. // absolute URLs, and the login page itself (would loop). Anything else falls
  33. // back to `/` so a tampered sessionStorage entry can't open-redirect.
  34. function sanitizeRedirectTarget(target: string | null | undefined): string | null {
  35. if (!target) return null;
  36. if (!target.startsWith('/')) return null;
  37. if (target.startsWith('//')) return null;
  38. if (target.startsWith('/login')) return null;
  39. return target;
  40. }
  41. function stashPostLoginRedirect(target: string): void {
  42. const safe = sanitizeRedirectTarget(target);
  43. if (!safe) return;
  44. try {
  45. sessionStorage.setItem(POST_LOGIN_REDIRECT_KEY, safe);
  46. } catch (err) {
  47. console.warn('stashPostLoginRedirect: sessionStorage unavailable, post-login target will be lost across OIDC redirect', err);
  48. }
  49. }
  50. function consumePostLoginRedirect(): string | null {
  51. try {
  52. const saved = sessionStorage.getItem(POST_LOGIN_REDIRECT_KEY);
  53. sessionStorage.removeItem(POST_LOGIN_REDIRECT_KEY);
  54. return sanitizeRedirectTarget(saved);
  55. } catch (err) {
  56. console.warn('consumePostLoginRedirect: sessionStorage unavailable', err);
  57. return null;
  58. }
  59. }
  60. /**
  61. * Single OIDC-provider login button. Extracted from the `.map()` body
  62. * because hooks can't be used inside a loop callback — the `iconFailed`
  63. * state is per-provider and must live in its own component instance.
  64. *
  65. * On `<img>` load failure (provider deleted between page load and image
  66. * fetch, network blip, etc.) we flip to the Shield fallback rather than
  67. * showing the browser's broken-image glyph to anonymous users (#1333 review).
  68. */
  69. function OIDCProviderButton({
  70. provider,
  71. onClick,
  72. disabled,
  73. }: {
  74. provider: OIDCProvider;
  75. onClick: () => void;
  76. disabled: boolean;
  77. }) {
  78. const { t } = useTranslation();
  79. const [iconFailed, setIconFailed] = useState(false);
  80. const showIcon = provider.has_icon && !iconFailed;
  81. return (
  82. <button
  83. type="button"
  84. onClick={onClick}
  85. disabled={disabled}
  86. 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"
  87. >
  88. {showIcon ? (
  89. <img
  90. src={api.oidcProviderIconUrl(provider.id)}
  91. alt=""
  92. className="w-5 h-5 object-contain"
  93. onError={() => setIconFailed(true)}
  94. />
  95. ) : (
  96. <Shield className="w-5 h-5 text-bambu-green" />
  97. )}
  98. {t('login.twoFA.signInWith', { provider: provider.name })}
  99. </button>
  100. );
  101. }
  102. export function LoginPage() {
  103. const navigate = useNavigate();
  104. const location = useLocation();
  105. const [searchParams] = useSearchParams();
  106. const { t } = useTranslation();
  107. const { login, loginWithToken } = useAuth();
  108. const { showToast } = useToast();
  109. const { mode } = useTheme();
  110. // Resolve the post-login destination, preferring router state (set by
  111. // ProtectedRoute when it redirects an unauthed visit) over the sessionStorage
  112. // stash (used to survive the OIDC provider round-trip, which kills React
  113. // state). Falls back to `/` and rejects unsafe targets via sanitize.
  114. function resolvePostLoginRedirect(): string {
  115. const fromState = (location.state as { from?: { pathname?: string; search?: string } } | null)?.from;
  116. if (fromState?.pathname) {
  117. const target = `${fromState.pathname}${fromState.search ?? ''}`;
  118. const safe = sanitizeRedirectTarget(target);
  119. if (safe) return safe;
  120. }
  121. return consumePostLoginRedirect() ?? '/';
  122. }
  123. // Credentials step state
  124. const [username, setUsername] = useState('');
  125. const [password, setPassword] = useState('');
  126. const [showForgotPassword, setShowForgotPassword] = useState(false);
  127. const [forgotEmail, setForgotEmail] = useState('');
  128. // 2FA step state
  129. const [step, setStep] = useState<LoginStep>('credentials');
  130. const [preAuthToken, setPreAuthToken] = useState('');
  131. const [twoFAMethods, setTwoFAMethods] = useState<string[]>([]);
  132. const [twoFAMethod, setTwoFAMethod] = useState<'totp' | 'email' | 'backup'>('totp');
  133. const [twoFACode, setTwoFACode] = useState('');
  134. const [emailOTPSent, setEmailOTPSent] = useState(false);
  135. const twoFAInputRef = useRef<HTMLInputElement>(null);
  136. const [rememberMe, setRememberMe] = useState(false);
  137. // H-6: Password reset step state
  138. const [resetToken, setResetToken] = useState('');
  139. const [newPassword, setNewPassword] = useState('');
  140. const [confirmPassword, setConfirmPassword] = useState('');
  141. // Check if advanced auth is enabled
  142. const { data: advancedAuthStatus } = useQuery({
  143. queryKey: ['advancedAuthStatus'],
  144. queryFn: () => api.getAdvancedAuthStatus(),
  145. });
  146. // Fetch enabled OIDC providers for login buttons
  147. const { data: oidcProviders } = useQuery({
  148. queryKey: ['oidcProviders'],
  149. queryFn: () => api.getOIDCProviders(),
  150. });
  151. // #1589: autologin redirect with fallback. When the backend reports an
  152. // `autologin_provider_id`, redirect unauthenticated visitors directly to
  153. // that provider's authorize URL on mount — unless the URL carries
  154. // `?fallback=local` (the documented recovery path that pairs with the
  155. // server-side BAMBUDDY_LOCAL_LOGIN env-var bypass). The authorize-URL
  156. // fetch is raced against a 5-second timeout; on timeout or fetch error
  157. // we skip the redirect and render the normal page, surfacing a banner
  158. // so the user understands why autologin didn't kick in.
  159. const [autologinFailed, setAutologinFailed] = useState(false);
  160. const autologinAttemptedRef = useRef(false);
  161. useEffect(() => {
  162. if (autologinAttemptedRef.current) return;
  163. const fallbackQuery = searchParams.get('fallback');
  164. if (fallbackQuery === 'local') return;
  165. if (!advancedAuthStatus || !advancedAuthStatus.autologin_provider_id) return;
  166. // Don't redirect mid-OIDC-exchange (we're already coming back from the IdP).
  167. const hash = window.location.hash;
  168. if (hash.startsWith('#oidc_token=') || searchParams.get('oidc_error')) return;
  169. autologinAttemptedRef.current = true;
  170. const providerId = advancedAuthStatus.autologin_provider_id;
  171. const timeoutPromise = new Promise<never>((_resolve, reject) =>
  172. setTimeout(() => reject(new Error('autologin timeout')), 5000),
  173. );
  174. Promise.race([api.getOIDCAuthorizeUrl(providerId), timeoutPromise])
  175. .then((result) => {
  176. window.location.href = (result as { auth_url: string }).auth_url;
  177. })
  178. .catch(() => {
  179. setAutologinFailed(true);
  180. });
  181. }, [advancedAuthStatus, searchParams]);
  182. const localLoginEnabled = advancedAuthStatus?.local_login_enabled !== false;
  183. const showAutologinBanner = autologinFailed && advancedAuthStatus?.autologin_provider_id != null;
  184. // M-B: Detect #reset_token=... in the URL fragment and switch to the reset step.
  185. // Fragments are never sent to the server so the token never appears in access-logs
  186. // or Referer headers — mirrors the H-4 treatment of the OIDC token.
  187. useEffect(() => {
  188. const hash = window.location.hash;
  189. const token = hash.startsWith('#reset_token=') ? hash.slice('#reset_token='.length) : null;
  190. if (token) {
  191. setResetToken(token);
  192. setStep('reset-password');
  193. // Clear the fragment from the URL so it can't be bookmarked or re-triggered.
  194. navigate('/login', { replace: true });
  195. }
  196. }, []); // eslint-disable-line react-hooks/exhaustive-deps
  197. // Handle OIDC callback: if #oidc_token=... is present in the fragment, exchange it.
  198. // H-4: Read from the URL fragment (#) — fragments are never sent to the server
  199. // so the exchange token stays out of access logs and Referer headers.
  200. useEffect(() => {
  201. const hash = window.location.hash;
  202. const oidcToken = hash.startsWith('#oidc_token=') ? hash.slice('#oidc_token='.length) : null;
  203. const oidcError = searchParams.get('oidc_error');
  204. if (!oidcToken && !oidcError) return;
  205. const savedRememberMe = consumeSavedRememberMe();
  206. if (oidcError) {
  207. // L-3: Whitelist known OIDC error codes so provider-controlled text is never
  208. // shown verbatim. Any unknown code falls back to a generic message.
  209. const KNOWN_OIDC_ERRORS: Record<string, string> = {
  210. oidc_provider_error: t('login.oidcErrors.providerError'),
  211. missing_parameters: t('login.oidcErrors.missingParameters'),
  212. invalid_state: t('login.oidcErrors.invalidState'),
  213. state_expired: t('login.oidcErrors.stateExpired'),
  214. provider_not_found: t('login.oidcErrors.providerNotFound'),
  215. discovery_failed: t('login.oidcErrors.discoveryFailed'),
  216. invalid_discovery_document: t('login.oidcErrors.invalidDiscovery'),
  217. token_exchange_network_error: t('login.oidcErrors.networkError'),
  218. token_exchange_bad_response: t('login.oidcErrors.badResponse'),
  219. no_id_token: t('login.oidcErrors.noIdToken'),
  220. token_validation_failed: t('login.oidcErrors.validationFailed'),
  221. nonce_mismatch: t('login.oidcErrors.nonceMismatch'),
  222. missing_sub_claim: t('login.oidcErrors.missingSubClaim'),
  223. no_linked_account: t('login.oidcErrors.noLinkedAccount'),
  224. account_inactive: t('login.oidcErrors.accountInactive'),
  225. user_resolution_failed: t('login.oidcErrors.userResolutionFailed'),
  226. internal_error: t('login.oidcErrors.internalError'),
  227. };
  228. // Dynamic codes like "token_exchange_<provider_code>" → generic message
  229. const errorMsg = KNOWN_OIDC_ERRORS[oidcError]
  230. ?? (oidcError.startsWith('token_exchange_') ? t('login.oidcErrors.tokenExchangeFailed') : t('login.oidcLoginFailed'));
  231. showToast(errorMsg, 'error');
  232. navigate('/login', { replace: true });
  233. return;
  234. }
  235. if (oidcToken) {
  236. api.exchangeOIDCToken(oidcToken).then((resp: LoginResponse) => {
  237. if (resp.requires_2fa && resp.pre_auth_token) {
  238. // OIDC user has 2FA enabled — redirect to 2FA step
  239. setRememberMe(savedRememberMe);
  240. setPreAuthToken(resp.pre_auth_token);
  241. const methods = resp.two_fa_methods ?? [];
  242. setTwoFAMethods(methods);
  243. if (methods.includes('totp')) setTwoFAMethod('totp');
  244. else if (methods.includes('email')) setTwoFAMethod('email');
  245. else setTwoFAMethod('backup');
  246. setStep('2fa');
  247. // Remove oidc_token from URL so page refresh doesn't re-trigger exchange
  248. navigate('/login', { replace: true });
  249. } else if (resp.access_token && resp.user) {
  250. loginWithToken(resp.access_token, resp.user, toPersistence(savedRememberMe));
  251. showToast(t('login.loginSuccess'));
  252. navigate(resolvePostLoginRedirect(), { replace: true });
  253. } else {
  254. showToast(t('login.oidcLoginFailed'), 'error');
  255. navigate('/login', { replace: true });
  256. }
  257. }).catch((err: unknown) => {
  258. console.error('OIDC token exchange failed', err);
  259. showToast(t('login.oidcLoginFailed'), 'error');
  260. navigate('/login', { replace: true });
  261. });
  262. }
  263. }, [searchParams]); // eslint-disable-line react-hooks/exhaustive-deps
  264. // --- Step 1: Credentials login ---
  265. const loginMutation = useMutation({
  266. mutationFn: () => login(username, password, toPersistence(rememberMe)),
  267. onSuccess: (resp: LoginResponse) => {
  268. if (resp.requires_2fa && resp.pre_auth_token) {
  269. // 2FA required — switch to verification step
  270. setPreAuthToken(resp.pre_auth_token);
  271. const methods = resp.two_fa_methods ?? [];
  272. setTwoFAMethods(methods);
  273. // Pick a sensible default method
  274. if (methods.includes('totp')) setTwoFAMethod('totp');
  275. else if (methods.includes('email')) setTwoFAMethod('email');
  276. else setTwoFAMethod('backup');
  277. setStep('2fa');
  278. } else if (resp.access_token && resp.user) {
  279. showToast(t('login.loginSuccess'));
  280. navigate(resolvePostLoginRedirect(), { replace: true });
  281. }
  282. },
  283. onError: (error: Error) => {
  284. showToast(error.message || t('login.loginFailed'), 'error');
  285. },
  286. });
  287. const forgotPasswordMutation = useMutation({
  288. mutationFn: (email: string) => api.forgotPassword({ email }),
  289. onSuccess: (data) => {
  290. showToast(data.message, 'success');
  291. setShowForgotPassword(false);
  292. setForgotEmail('');
  293. },
  294. onError: (error: Error) => {
  295. showToast(error.message, 'error');
  296. },
  297. });
  298. // H-6: Mutation to set a new password using the reset token from the email link
  299. const resetPasswordMutation = useMutation({
  300. mutationFn: () => api.forgotPasswordConfirm(resetToken, newPassword),
  301. onSuccess: (data) => {
  302. showToast(data.message, 'success');
  303. setStep('credentials');
  304. setResetToken('');
  305. setNewPassword('');
  306. setConfirmPassword('');
  307. },
  308. onError: (error: Error) => {
  309. showToast(error.message || t('login.resetPassword.resetFailed'), 'error');
  310. },
  311. });
  312. // --- Step 2: 2FA verification ---
  313. const sendEmailOTPMutation = useMutation({
  314. mutationFn: () => api.sendEmailOTP(preAuthToken),
  315. onSuccess: (data: { message: string; pre_auth_token?: string }) => {
  316. setEmailOTPSent(true);
  317. // Backend issues a fresh pre-auth token after consuming the original one
  318. if (data.pre_auth_token) setPreAuthToken(data.pre_auth_token);
  319. showToast(data.message, 'success');
  320. },
  321. onError: (error: Error) => {
  322. showToast(error.message || t('login.twoFA.sendCodeFailed'), 'error');
  323. },
  324. });
  325. const verify2FAMutation = useMutation({
  326. mutationFn: () =>
  327. api.verify2FA({ pre_auth_token: preAuthToken, code: twoFACode, method: twoFAMethod }),
  328. onSuccess: (resp: LoginResponse) => {
  329. if (resp.access_token && resp.user) {
  330. loginWithToken(resp.access_token, resp.user, toPersistence(rememberMe));
  331. showToast(t('login.loginSuccess'));
  332. navigate(resolvePostLoginRedirect(), { replace: true });
  333. } else {
  334. console.error('2FA verify: unexpected response shape', resp);
  335. showToast(t('login.loginFailed'), 'error');
  336. }
  337. },
  338. onError: (error: Error) => {
  339. showToast(error.message || t('login.twoFA.invalidCode'), 'error');
  340. setTwoFACode('');
  341. },
  342. });
  343. // OIDC login
  344. const oidcLoginMutation = useMutation({
  345. mutationFn: (providerId: number) => api.getOIDCAuthorizeUrl(providerId),
  346. onSuccess: (data) => {
  347. if (rememberMe) {
  348. try {
  349. sessionStorage.setItem(REMEMBER_ME_KEY, '1');
  350. } catch (err) {
  351. console.warn('setItem auth_remember_me failed, Remember Me will not carry through OIDC redirect', err);
  352. }
  353. }
  354. // Stash the post-login destination from router state so it survives the
  355. // provider round-trip (window.location.href kills React state). If the
  356. // user landed on /login directly, fromState is absent and we don't stash.
  357. const fromState = (location.state as { from?: { pathname?: string; search?: string } } | null)?.from;
  358. if (fromState?.pathname) {
  359. stashPostLoginRedirect(`${fromState.pathname}${fromState.search ?? ''}`);
  360. }
  361. window.location.href = data.auth_url;
  362. },
  363. onError: (error: Error) => {
  364. showToast(error.message || t('login.oidcLoginFailed'), 'error');
  365. },
  366. });
  367. const handleSubmit = (e: React.FormEvent) => {
  368. e.preventDefault();
  369. if (!username || !password) {
  370. showToast(t('login.enterCredentials'), 'error');
  371. return;
  372. }
  373. loginMutation.mutate();
  374. };
  375. const handle2FASubmit = (e: React.FormEvent) => {
  376. e.preventDefault();
  377. if (!twoFACode.trim()) {
  378. showToast(t('login.twoFA.enterCode'), 'error');
  379. return;
  380. }
  381. verify2FAMutation.mutate();
  382. };
  383. const handleForgotPassword = (e: React.FormEvent) => {
  384. e.preventDefault();
  385. if (!forgotEmail) {
  386. showToast(t('login.enterEmail'), 'error');
  387. return;
  388. }
  389. forgotPasswordMutation.mutate(forgotEmail);
  390. };
  391. const handleMethodChange = (method: 'totp' | 'email' | 'backup') => {
  392. setTwoFAMethod(method);
  393. setTwoFACode('');
  394. setEmailOTPSent(false);
  395. // Re-focus the code input after method switch (autoFocus only fires on mount)
  396. setTimeout(() => twoFAInputRef.current?.focus(), 0);
  397. };
  398. // ---- Render: password-reset step (H-6) ----
  399. if (step === 'reset-password') {
  400. const handleResetSubmit = (e: React.FormEvent) => {
  401. e.preventDefault();
  402. if (newPassword !== confirmPassword) {
  403. showToast(t('login.resetPassword.passwordsDoNotMatch'), 'error');
  404. return;
  405. }
  406. if (newPassword.length < 8) {
  407. showToast(t('login.resetPassword.passwordTooShort'), 'error');
  408. return;
  409. }
  410. resetPasswordMutation.mutate();
  411. };
  412. return (
  413. <div className="min-h-screen flex items-center justify-center bg-bambu-dark p-4">
  414. <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">
  415. <div className="text-center">
  416. <div className="flex items-center justify-center mb-4">
  417. <div className="w-14 h-14 rounded-full bg-bambu-green/20 flex items-center justify-center">
  418. <Key className="w-7 h-7 text-bambu-green" />
  419. </div>
  420. </div>
  421. <h2 className="text-2xl font-bold text-white">{t('login.resetPassword.title')}</h2>
  422. <p className="mt-2 text-sm text-bambu-gray">{t('login.resetPassword.subtitle')}</p>
  423. </div>
  424. <form onSubmit={handleResetSubmit} className="space-y-4">
  425. <div>
  426. <label htmlFor="new-password" className="block text-sm font-medium text-white mb-2">
  427. {t('login.resetPassword.newPassword')}
  428. </label>
  429. <input
  430. id="new-password"
  431. type="password"
  432. required
  433. value={newPassword}
  434. onChange={(e) => setNewPassword(e.target.value)}
  435. 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"
  436. placeholder={t('login.resetPassword.newPasswordPlaceholder')}
  437. autoFocus
  438. autoComplete="new-password"
  439. minLength={8}
  440. />
  441. </div>
  442. <div>
  443. <label htmlFor="confirm-password" className="block text-sm font-medium text-white mb-2">
  444. {t('login.resetPassword.confirmPassword')}
  445. </label>
  446. <input
  447. id="confirm-password"
  448. type="password"
  449. required
  450. value={confirmPassword}
  451. onChange={(e) => setConfirmPassword(e.target.value)}
  452. 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"
  453. placeholder={t('login.resetPassword.confirmPasswordPlaceholder')}
  454. autoComplete="new-password"
  455. />
  456. </div>
  457. <button
  458. type="submit"
  459. disabled={resetPasswordMutation.isPending || !newPassword || !confirmPassword}
  460. 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"
  461. >
  462. {resetPasswordMutation.isPending ? t('login.resetPassword.saving') : t('login.resetPassword.submit')}
  463. </button>
  464. </form>
  465. <div className="text-center">
  466. <button
  467. type="button"
  468. onClick={() => {
  469. setStep('credentials');
  470. setResetToken('');
  471. setNewPassword('');
  472. setConfirmPassword('');
  473. }}
  474. className="text-sm text-bambu-gray hover:text-bambu-green transition-colors"
  475. >
  476. {t('login.resetPassword.backToLogin')}
  477. </button>
  478. </div>
  479. </div>
  480. </div>
  481. );
  482. }
  483. // ---- Render: 2FA step ----
  484. if (step === '2fa') {
  485. return (
  486. <div className="min-h-screen flex items-center justify-center bg-bambu-dark p-4">
  487. <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">
  488. <div className="text-center">
  489. <div className="flex items-center justify-center mb-4">
  490. <div className="w-14 h-14 rounded-full bg-bambu-green/20 flex items-center justify-center">
  491. <Shield className="w-7 h-7 text-bambu-green" />
  492. </div>
  493. </div>
  494. <h2 className="text-2xl font-bold text-white">{t('login.twoFA.title')}</h2>
  495. <p className="mt-2 text-sm text-bambu-gray">{t('login.twoFA.subtitle')}</p>
  496. </div>
  497. {/* Method selector — only show if multiple methods available */}
  498. {twoFAMethods.length > 1 && (
  499. <div className="flex gap-2">
  500. {twoFAMethods.includes('totp') && (
  501. <button
  502. type="button"
  503. onClick={() => handleMethodChange('totp')}
  504. className={`flex-1 flex flex-col items-center gap-1 py-2 px-3 rounded-lg border text-xs font-medium transition-colors ${
  505. twoFAMethod === 'totp'
  506. ? 'border-bambu-green bg-bambu-green/10 text-bambu-green'
  507. : 'border-bambu-dark-tertiary text-bambu-gray hover:border-bambu-green/50'
  508. }`}
  509. >
  510. <Smartphone className="w-4 h-4" />
  511. {t('login.twoFA.methodAuthenticator')}
  512. </button>
  513. )}
  514. {twoFAMethods.includes('email') && (
  515. <button
  516. type="button"
  517. onClick={() => handleMethodChange('email')}
  518. className={`flex-1 flex flex-col items-center gap-1 py-2 px-3 rounded-lg border text-xs font-medium transition-colors ${
  519. twoFAMethod === 'email'
  520. ? 'border-bambu-green bg-bambu-green/10 text-bambu-green'
  521. : 'border-bambu-dark-tertiary text-bambu-gray hover:border-bambu-green/50'
  522. }`}
  523. >
  524. <Mail className="w-4 h-4" />
  525. {t('login.twoFA.methodEmail')}
  526. </button>
  527. )}
  528. {twoFAMethods.includes('backup') && (
  529. <button
  530. type="button"
  531. onClick={() => handleMethodChange('backup')}
  532. className={`flex-1 flex flex-col items-center gap-1 py-2 px-3 rounded-lg border text-xs font-medium transition-colors ${
  533. twoFAMethod === 'backup'
  534. ? 'border-bambu-green bg-bambu-green/10 text-bambu-green'
  535. : 'border-bambu-dark-tertiary text-bambu-gray hover:border-bambu-green/50'
  536. }`}
  537. >
  538. <Key className="w-4 h-4" />
  539. {t('login.twoFA.methodBackup')}
  540. </button>
  541. )}
  542. </div>
  543. )}
  544. <form onSubmit={handle2FASubmit} className="space-y-4">
  545. {/* Method-specific instructions */}
  546. {twoFAMethod === 'totp' && (
  547. <p className="text-sm text-bambu-gray">{t('login.twoFA.instructionsTotp')}</p>
  548. )}
  549. {twoFAMethod === 'email' && (
  550. <div className="space-y-3">
  551. <p className="text-sm text-bambu-gray">
  552. {emailOTPSent
  553. ? t('login.twoFA.instructionsEmail')
  554. : t('login.twoFA.instructionsEmailNotSent')}
  555. </p>
  556. {!emailOTPSent && (
  557. <Button
  558. type="button"
  559. variant="secondary"
  560. className="w-full"
  561. onClick={() => sendEmailOTPMutation.mutate()}
  562. disabled={sendEmailOTPMutation.isPending}
  563. >
  564. {sendEmailOTPMutation.isPending
  565. ? t('login.twoFA.sendingCode')
  566. : t('login.twoFA.sendCodeButton')}
  567. </Button>
  568. )}
  569. {emailOTPSent && (
  570. <button
  571. type="button"
  572. onClick={() => { setEmailOTPSent(false); sendEmailOTPMutation.mutate(); }}
  573. className="text-xs text-bambu-gray hover:text-bambu-green transition-colors"
  574. >
  575. {t('login.twoFA.resendCode')}
  576. </button>
  577. )}
  578. </div>
  579. )}
  580. {twoFAMethod === 'backup' && (
  581. <p className="text-sm text-bambu-gray">{t('login.twoFA.instructionsBackup')}</p>
  582. )}
  583. <div>
  584. <label htmlFor="twofa-code" className="block text-sm font-medium text-white mb-2">
  585. {twoFAMethod === 'backup'
  586. ? t('login.twoFA.backupCodeLabel')
  587. : t('login.twoFA.codeLabel')}
  588. </label>
  589. <input
  590. ref={twoFAInputRef}
  591. id="twofa-code"
  592. type="text"
  593. inputMode={twoFAMethod === 'backup' ? 'text' : 'numeric'}
  594. autoComplete="one-time-code"
  595. value={twoFACode}
  596. onChange={(e) => setTwoFACode(e.target.value.trim())}
  597. disabled={twoFAMethod === 'email' && !emailOTPSent}
  598. 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"
  599. placeholder={twoFAMethod === 'backup'
  600. ? t('login.twoFA.backupCodePlaceholder')
  601. : t('login.twoFA.codePlaceholder')}
  602. maxLength={twoFAMethod === 'backup' ? 8 : 6}
  603. autoFocus
  604. />
  605. </div>
  606. <button
  607. type="submit"
  608. disabled={
  609. verify2FAMutation.isPending ||
  610. !twoFACode.trim() ||
  611. (twoFAMethod === 'email' && !emailOTPSent)
  612. }
  613. 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"
  614. >
  615. {verify2FAMutation.isPending
  616. ? t('login.twoFA.verifyingButton')
  617. : t('login.twoFA.verifyButton')}
  618. </button>
  619. </form>
  620. <div className="text-center">
  621. <button
  622. type="button"
  623. onClick={() => {
  624. setStep('credentials');
  625. setPreAuthToken('');
  626. setTwoFACode('');
  627. setEmailOTPSent(false);
  628. }}
  629. className="text-sm text-bambu-gray hover:text-bambu-green transition-colors"
  630. >
  631. {t('login.twoFA.backToLogin')}
  632. </button>
  633. </div>
  634. </div>
  635. </div>
  636. );
  637. }
  638. // ---- Render: credentials step ----
  639. return (
  640. <div className="min-h-screen flex items-center justify-center bg-bambu-dark p-4">
  641. <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">
  642. <div className="text-center">
  643. <div className="flex items-center justify-center mb-6">
  644. <img
  645. src={mode === 'dark' ? '/img/bambuddy_logo_dark_transparent.png' : '/img/bambuddy_logo_light.png'}
  646. alt="Bambuddy"
  647. className="h-16"
  648. />
  649. </div>
  650. <h2 className="text-3xl font-bold text-white">
  651. {t('login.title')}
  652. </h2>
  653. <p className="mt-2 text-sm text-bambu-gray">
  654. {t('login.subtitle')}
  655. </p>
  656. </div>
  657. {showAutologinBanner && (
  658. <div className="mt-6 rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-sm text-amber-200">
  659. {t('login.autologinFailed')}
  660. </div>
  661. )}
  662. {!localLoginEnabled && (
  663. <div className="mt-6 rounded-lg border border-bambu-dark-tertiary bg-bambu-dark/40 px-4 py-3 text-sm text-bambu-gray">
  664. {t('login.localDisabledNotice')}
  665. </div>
  666. )}
  667. {localLoginEnabled && (
  668. <form className="mt-8 space-y-6" onSubmit={handleSubmit}>
  669. <div className="space-y-4">
  670. <div>
  671. <label htmlFor="username" className="block text-sm font-medium text-white mb-2">
  672. {advancedAuthStatus?.advanced_auth_enabled
  673. ? t('login.usernameOrEmail')
  674. : t('login.username')}
  675. </label>
  676. <input
  677. id="username"
  678. type="text"
  679. required
  680. value={username}
  681. onChange={(e) => setUsername(e.target.value)}
  682. 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"
  683. placeholder={advancedAuthStatus?.advanced_auth_enabled
  684. ? t('login.usernameOrEmailPlaceholder')
  685. : t('login.usernamePlaceholder')}
  686. autoComplete="username"
  687. />
  688. </div>
  689. <div>
  690. <label htmlFor="password" className="block text-sm font-medium text-white mb-2">
  691. {t('login.password') || 'Password'}
  692. </label>
  693. <input
  694. id="password"
  695. type="password"
  696. required
  697. value={password}
  698. onChange={(e) => setPassword(e.target.value)}
  699. 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"
  700. placeholder={t('login.passwordPlaceholder')}
  701. autoComplete="current-password"
  702. />
  703. </div>
  704. </div>
  705. <div className="flex items-center gap-2">
  706. <input
  707. id="remember-me"
  708. type="checkbox"
  709. checked={rememberMe}
  710. onChange={(e) => setRememberMe(e.target.checked)}
  711. className="h-4 w-4 rounded border-bambu-dark-tertiary bg-bambu-dark-secondary text-bambu-green focus:ring-bambu-green/50 cursor-pointer"
  712. />
  713. <label htmlFor="remember-me" className="text-sm text-bambu-gray cursor-pointer">
  714. {t('login.rememberMe')}
  715. </label>
  716. </div>
  717. <div>
  718. <button
  719. type="submit"
  720. disabled={loginMutation.isPending}
  721. 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"
  722. >
  723. {loginMutation.isPending ? t('login.signingIn') : t('login.signIn')}
  724. </button>
  725. </div>
  726. <div className="text-center">
  727. <button
  728. type="button"
  729. onClick={() => setShowForgotPassword(true)}
  730. className="text-sm text-bambu-gray hover:text-bambu-green transition-colors"
  731. >
  732. {t('login.forgotPassword')}
  733. </button>
  734. </div>
  735. </form>
  736. )}
  737. {/* OIDC provider buttons */}
  738. {oidcProviders && oidcProviders.length > 0 && (
  739. <div className="space-y-3">
  740. <div className="relative">
  741. <div className="absolute inset-0 flex items-center">
  742. <div className="w-full border-t border-bambu-dark-tertiary" />
  743. </div>
  744. <div className="relative flex justify-center text-sm">
  745. <span className="px-2 bg-bambu-dark-secondary text-bambu-gray">{t('login.twoFA.orContinueWith')}</span>
  746. </div>
  747. </div>
  748. <div className="space-y-2">
  749. {oidcProviders.map((provider) => (
  750. <OIDCProviderButton
  751. key={provider.id}
  752. provider={provider}
  753. onClick={() => oidcLoginMutation.mutate(provider.id)}
  754. disabled={oidcLoginMutation.isPending}
  755. />
  756. ))}
  757. </div>
  758. </div>
  759. )}
  760. </div>
  761. {/* Forgot Password Modal */}
  762. {showForgotPassword && (
  763. <div
  764. className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
  765. onClick={() => setShowForgotPassword(false)}
  766. >
  767. <Card
  768. className="w-full max-w-md"
  769. onClick={(e: React.MouseEvent) => e.stopPropagation()}
  770. >
  771. <CardHeader>
  772. <div className="flex items-center justify-between">
  773. <div className="flex items-center gap-2">
  774. <Mail className="w-5 h-5 text-bambu-green" />
  775. <h2 className="text-lg font-semibold text-white">{t('login.forgotPasswordTitle')}</h2>
  776. </div>
  777. <Button
  778. variant="ghost"
  779. size="sm"
  780. onClick={() => {
  781. setShowForgotPassword(false);
  782. setForgotEmail('');
  783. }}
  784. >
  785. <X className="w-5 h-5" />
  786. </Button>
  787. </div>
  788. </CardHeader>
  789. <CardContent>
  790. {advancedAuthStatus?.advanced_auth_enabled ? (
  791. <form onSubmit={handleForgotPassword} className="space-y-4">
  792. <p className="text-bambu-gray text-sm">
  793. {t('login.forgotPasswordEmailMessage')}
  794. </p>
  795. <div>
  796. <label htmlFor="forgot-email" className="block text-sm font-medium text-white mb-2">
  797. {t('login.emailAddress')}
  798. </label>
  799. <input
  800. id="forgot-email"
  801. type="email"
  802. required
  803. value={forgotEmail}
  804. onChange={(e) => setForgotEmail(e.target.value)}
  805. 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"
  806. placeholder={t('login.emailPlaceholder')}
  807. />
  808. </div>
  809. <div className="flex gap-2">
  810. <Button
  811. type="button"
  812. variant="secondary"
  813. className="flex-1"
  814. onClick={() => {
  815. setShowForgotPassword(false);
  816. setForgotEmail('');
  817. }}
  818. >
  819. {t('login.cancel')}
  820. </Button>
  821. <Button
  822. type="submit"
  823. className="flex-1"
  824. disabled={forgotPasswordMutation.isPending}
  825. >
  826. {forgotPasswordMutation.isPending
  827. ? t('login.sending')
  828. : t('login.sendResetEmail')}
  829. </Button>
  830. </div>
  831. </form>
  832. ) : (
  833. <div className="space-y-4">
  834. <p className="text-bambu-gray">
  835. {t('login.forgotPasswordMessage')}
  836. </p>
  837. <div className="bg-bambu-dark rounded-lg p-4 space-y-2">
  838. <p className="text-sm text-white font-medium">{t('login.howToReset')}</p>
  839. <ol className="text-sm text-bambu-gray space-y-1 list-decimal list-inside">
  840. <li>{t('login.resetStep1')}</li>
  841. <li>{t('login.resetStep2')}</li>
  842. <li>{t('login.resetStep3')}</li>
  843. <li>{t('login.resetStep4')}</li>
  844. </ol>
  845. </div>
  846. <Button
  847. variant="secondary"
  848. className="w-full"
  849. onClick={() => setShowForgotPassword(false)}
  850. >
  851. {t('login.gotIt')}
  852. </Button>
  853. </div>
  854. )}
  855. </CardContent>
  856. </Card>
  857. </div>
  858. )}
  859. </div>
  860. );
  861. }