LoginPage.tsx 41 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962
  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, user, loading } = 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. // #1889: redirect already-authenticated visitors away from /login. Without
  161. // this, a valid session that lands directly on /login (e.g. the browser
  162. // address bar autocompletes the origin to its most-visited path) renders the
  163. // credentials form even though the token is live and every request succeeds —
  164. // making Bambuddy look like it "never stays logged in". Gate on the
  165. // credentials step so we don't interrupt the 2FA / OIDC-callback branches,
  166. // which navigate themselves after loginWithToken. Send to '/' rather than
  167. // resolvePostLoginRedirect() to avoid consuming the OIDC redirect stash: an
  168. // already-authed direct visit has no pending redirect to honour.
  169. useEffect(() => {
  170. if (!loading && user && step === 'credentials') {
  171. navigate('/', { replace: true });
  172. }
  173. }, [loading, user, step, navigate]);
  174. const autologinAttemptedRef = useRef(false);
  175. useEffect(() => {
  176. if (autologinAttemptedRef.current) return;
  177. const fallbackQuery = searchParams.get('fallback');
  178. if (fallbackQuery === 'local') return;
  179. if (!advancedAuthStatus || !advancedAuthStatus.autologin_provider_id) return;
  180. // Don't redirect mid-OIDC-exchange (we're already coming back from the IdP).
  181. const hash = window.location.hash;
  182. if (hash.startsWith('#oidc_token=') || searchParams.get('oidc_error')) return;
  183. autologinAttemptedRef.current = true;
  184. const providerId = advancedAuthStatus.autologin_provider_id;
  185. const timeoutPromise = new Promise<never>((_resolve, reject) =>
  186. setTimeout(() => reject(new Error('autologin timeout')), 5000),
  187. );
  188. Promise.race([api.getOIDCAuthorizeUrl(providerId), timeoutPromise])
  189. .then((result) => {
  190. const fromState = (location.state as { from?: { pathname?: string; search?: string } } | null)?.from;
  191. if (fromState?.pathname) {
  192. stashPostLoginRedirect(`${fromState.pathname}${fromState.search ?? ''}`);
  193. }
  194. window.location.href = (result as { auth_url: string }).auth_url;
  195. })
  196. .catch(() => {
  197. setAutologinFailed(true);
  198. });
  199. }, [advancedAuthStatus, searchParams, location.state]);
  200. const localLoginEnabled = advancedAuthStatus?.local_login_enabled !== false;
  201. const showAutologinBanner = autologinFailed && advancedAuthStatus?.autologin_provider_id != null;
  202. // M-B: Detect #reset_token=... in the URL fragment and switch to the reset step.
  203. // Fragments are never sent to the server so the token never appears in access-logs
  204. // or Referer headers — mirrors the H-4 treatment of the OIDC token.
  205. useEffect(() => {
  206. const hash = window.location.hash;
  207. const token = hash.startsWith('#reset_token=') ? hash.slice('#reset_token='.length) : null;
  208. if (token) {
  209. setResetToken(token);
  210. setStep('reset-password');
  211. // Clear the fragment from the URL so it can't be bookmarked or re-triggered.
  212. navigate('/login', { replace: true });
  213. }
  214. }, []); // eslint-disable-line react-hooks/exhaustive-deps
  215. // Handle OIDC callback: if #oidc_token=... is present in the fragment, exchange it.
  216. // H-4: Read from the URL fragment (#) — fragments are never sent to the server
  217. // so the exchange token stays out of access logs and Referer headers.
  218. useEffect(() => {
  219. const hash = window.location.hash;
  220. const oidcToken = hash.startsWith('#oidc_token=') ? hash.slice('#oidc_token='.length) : null;
  221. const oidcError = searchParams.get('oidc_error');
  222. if (!oidcToken && !oidcError) return;
  223. const savedRememberMe = consumeSavedRememberMe();
  224. if (oidcError) {
  225. // L-3: Whitelist known OIDC error codes so provider-controlled text is never
  226. // shown verbatim. Any unknown code falls back to a generic message.
  227. const KNOWN_OIDC_ERRORS: Record<string, string> = {
  228. oidc_provider_error: t('login.oidcErrors.providerError'),
  229. missing_parameters: t('login.oidcErrors.missingParameters'),
  230. invalid_state: t('login.oidcErrors.invalidState'),
  231. state_expired: t('login.oidcErrors.stateExpired'),
  232. provider_not_found: t('login.oidcErrors.providerNotFound'),
  233. discovery_failed: t('login.oidcErrors.discoveryFailed'),
  234. invalid_discovery_document: t('login.oidcErrors.invalidDiscovery'),
  235. token_exchange_network_error: t('login.oidcErrors.networkError'),
  236. token_exchange_bad_response: t('login.oidcErrors.badResponse'),
  237. no_id_token: t('login.oidcErrors.noIdToken'),
  238. token_validation_failed: t('login.oidcErrors.validationFailed'),
  239. nonce_mismatch: t('login.oidcErrors.nonceMismatch'),
  240. missing_sub_claim: t('login.oidcErrors.missingSubClaim'),
  241. no_linked_account: t('login.oidcErrors.noLinkedAccount'),
  242. account_inactive: t('login.oidcErrors.accountInactive'),
  243. user_resolution_failed: t('login.oidcErrors.userResolutionFailed'),
  244. internal_error: t('login.oidcErrors.internalError'),
  245. };
  246. // Dynamic codes like "token_exchange_<provider_code>" → generic message
  247. const errorMsg = KNOWN_OIDC_ERRORS[oidcError]
  248. ?? (oidcError.startsWith('token_exchange_') ? t('login.oidcErrors.tokenExchangeFailed') : t('login.oidcLoginFailed'));
  249. showToast(errorMsg, 'error');
  250. navigate('/login', { replace: true });
  251. return;
  252. }
  253. if (oidcToken) {
  254. api.exchangeOIDCToken(oidcToken).then((resp: LoginResponse) => {
  255. if (resp.requires_2fa && resp.pre_auth_token) {
  256. // OIDC user has 2FA enabled — redirect to 2FA step
  257. setRememberMe(savedRememberMe);
  258. setPreAuthToken(resp.pre_auth_token);
  259. const methods = resp.two_fa_methods ?? [];
  260. setTwoFAMethods(methods);
  261. if (methods.includes('totp')) setTwoFAMethod('totp');
  262. else if (methods.includes('email')) setTwoFAMethod('email');
  263. else setTwoFAMethod('backup');
  264. setStep('2fa');
  265. // Remove oidc_token from URL so page refresh doesn't re-trigger exchange
  266. navigate('/login', { replace: true });
  267. } else if (resp.access_token && resp.user) {
  268. loginWithToken(resp.access_token, resp.user, toPersistence(savedRememberMe));
  269. showToast(t('login.loginSuccess'));
  270. navigate(resolvePostLoginRedirect(), { replace: true });
  271. } else {
  272. showToast(t('login.oidcLoginFailed'), 'error');
  273. navigate('/login', { replace: true });
  274. }
  275. }).catch((err: unknown) => {
  276. console.error('OIDC token exchange failed', err);
  277. showToast(t('login.oidcLoginFailed'), 'error');
  278. navigate('/login', { replace: true });
  279. });
  280. }
  281. }, [searchParams]); // eslint-disable-line react-hooks/exhaustive-deps
  282. // --- Step 1: Credentials login ---
  283. const loginMutation = useMutation({
  284. mutationFn: () => login(username, password, toPersistence(rememberMe)),
  285. onSuccess: (resp: LoginResponse) => {
  286. if (resp.requires_2fa && resp.pre_auth_token) {
  287. // 2FA required — switch to verification step
  288. setPreAuthToken(resp.pre_auth_token);
  289. const methods = resp.two_fa_methods ?? [];
  290. setTwoFAMethods(methods);
  291. // Pick a sensible default method
  292. if (methods.includes('totp')) setTwoFAMethod('totp');
  293. else if (methods.includes('email')) setTwoFAMethod('email');
  294. else setTwoFAMethod('backup');
  295. setStep('2fa');
  296. } else if (resp.access_token && resp.user) {
  297. showToast(t('login.loginSuccess'));
  298. navigate(resolvePostLoginRedirect(), { replace: true });
  299. }
  300. },
  301. onError: (error: Error) => {
  302. showToast(error.message || t('login.loginFailed'), 'error');
  303. },
  304. });
  305. const forgotPasswordMutation = useMutation({
  306. mutationFn: (email: string) => api.forgotPassword({ email }),
  307. onSuccess: (data) => {
  308. showToast(data.message, 'success');
  309. setShowForgotPassword(false);
  310. setForgotEmail('');
  311. },
  312. onError: (error: Error) => {
  313. showToast(error.message, 'error');
  314. },
  315. });
  316. // H-6: Mutation to set a new password using the reset token from the email link
  317. const resetPasswordMutation = useMutation({
  318. mutationFn: () => api.forgotPasswordConfirm(resetToken, newPassword),
  319. onSuccess: (data) => {
  320. showToast(data.message, 'success');
  321. setStep('credentials');
  322. setResetToken('');
  323. setNewPassword('');
  324. setConfirmPassword('');
  325. },
  326. onError: (error: Error) => {
  327. showToast(error.message || t('login.resetPassword.resetFailed'), 'error');
  328. },
  329. });
  330. // --- Step 2: 2FA verification ---
  331. const sendEmailOTPMutation = useMutation({
  332. mutationFn: () => api.sendEmailOTP(preAuthToken),
  333. onSuccess: (data: { message: string; pre_auth_token?: string }) => {
  334. setEmailOTPSent(true);
  335. // Backend issues a fresh pre-auth token after consuming the original one
  336. if (data.pre_auth_token) setPreAuthToken(data.pre_auth_token);
  337. showToast(data.message, 'success');
  338. },
  339. onError: (error: Error) => {
  340. showToast(error.message || t('login.twoFA.sendCodeFailed'), 'error');
  341. },
  342. });
  343. const verify2FAMutation = useMutation({
  344. mutationFn: () =>
  345. api.verify2FA({ pre_auth_token: preAuthToken, code: twoFACode, method: twoFAMethod }),
  346. onSuccess: (resp: LoginResponse) => {
  347. if (resp.access_token && resp.user) {
  348. loginWithToken(resp.access_token, resp.user, toPersistence(rememberMe));
  349. showToast(t('login.loginSuccess'));
  350. navigate(resolvePostLoginRedirect(), { replace: true });
  351. } else {
  352. console.error('2FA verify: unexpected response shape', resp);
  353. showToast(t('login.loginFailed'), 'error');
  354. }
  355. },
  356. onError: (error: Error) => {
  357. showToast(error.message || t('login.twoFA.invalidCode'), 'error');
  358. setTwoFACode('');
  359. },
  360. });
  361. // OIDC login
  362. const oidcLoginMutation = useMutation({
  363. mutationFn: (providerId: number) => api.getOIDCAuthorizeUrl(providerId),
  364. onSuccess: (data) => {
  365. try {
  366. if (rememberMe) {
  367. sessionStorage.setItem(REMEMBER_ME_KEY, '1');
  368. } else {
  369. sessionStorage.removeItem(REMEMBER_ME_KEY);
  370. }
  371. } catch (err) {
  372. console.warn('setItem/removeItem auth_remember_me failed, Remember Me will not carry through OIDC redirect', err);
  373. }
  374. // Stash the post-login destination from router state so it survives the
  375. // provider round-trip (window.location.href kills React state). If the
  376. // user landed on /login directly, fromState is absent and we don't stash.
  377. const fromState = (location.state as { from?: { pathname?: string; search?: string } } | null)?.from;
  378. if (fromState?.pathname) {
  379. stashPostLoginRedirect(`${fromState.pathname}${fromState.search ?? ''}`);
  380. }
  381. window.location.href = data.auth_url;
  382. },
  383. onError: (error: Error) => {
  384. showToast(error.message || t('login.oidcLoginFailed'), 'error');
  385. },
  386. });
  387. const handleSubmit = (e: React.FormEvent) => {
  388. e.preventDefault();
  389. if (!username || !password) {
  390. showToast(t('login.enterCredentials'), 'error');
  391. return;
  392. }
  393. loginMutation.mutate();
  394. };
  395. const handle2FASubmit = (e: React.FormEvent) => {
  396. e.preventDefault();
  397. if (!twoFACode.trim()) {
  398. showToast(t('login.twoFA.enterCode'), 'error');
  399. return;
  400. }
  401. verify2FAMutation.mutate();
  402. };
  403. const handleForgotPassword = (e: React.FormEvent) => {
  404. e.preventDefault();
  405. if (!forgotEmail) {
  406. showToast(t('login.enterEmail'), 'error');
  407. return;
  408. }
  409. forgotPasswordMutation.mutate(forgotEmail);
  410. };
  411. const handleMethodChange = (method: 'totp' | 'email' | 'backup') => {
  412. setTwoFAMethod(method);
  413. setTwoFACode('');
  414. setEmailOTPSent(false);
  415. // Re-focus the code input after method switch (autoFocus only fires on mount)
  416. setTimeout(() => twoFAInputRef.current?.focus(), 0);
  417. };
  418. // ---- Render: password-reset step (H-6) ----
  419. if (step === 'reset-password') {
  420. const handleResetSubmit = (e: React.FormEvent) => {
  421. e.preventDefault();
  422. if (newPassword !== confirmPassword) {
  423. showToast(t('login.resetPassword.passwordsDoNotMatch'), 'error');
  424. return;
  425. }
  426. if (newPassword.length < 8) {
  427. showToast(t('login.resetPassword.passwordTooShort'), 'error');
  428. return;
  429. }
  430. resetPasswordMutation.mutate();
  431. };
  432. return (
  433. <div className="min-h-screen flex items-center justify-center bg-bambu-dark p-4">
  434. <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">
  435. <div className="text-center">
  436. <div className="flex items-center justify-center mb-4">
  437. <div className="w-14 h-14 rounded-full bg-bambu-green/20 flex items-center justify-center">
  438. <Key className="w-7 h-7 text-bambu-green" />
  439. </div>
  440. </div>
  441. <h2 className="text-2xl font-bold text-white">{t('login.resetPassword.title')}</h2>
  442. <p className="mt-2 text-sm text-bambu-gray">{t('login.resetPassword.subtitle')}</p>
  443. </div>
  444. <form onSubmit={handleResetSubmit} className="space-y-4">
  445. <div>
  446. <label htmlFor="new-password" className="block text-sm font-medium text-white mb-2">
  447. {t('login.resetPassword.newPassword')}
  448. </label>
  449. <input
  450. id="new-password"
  451. type="password"
  452. required
  453. value={newPassword}
  454. onChange={(e) => setNewPassword(e.target.value)}
  455. 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"
  456. placeholder={t('login.resetPassword.newPasswordPlaceholder')}
  457. autoFocus
  458. autoComplete="new-password"
  459. minLength={8}
  460. />
  461. </div>
  462. <div>
  463. <label htmlFor="confirm-password" className="block text-sm font-medium text-white mb-2">
  464. {t('login.resetPassword.confirmPassword')}
  465. </label>
  466. <input
  467. id="confirm-password"
  468. type="password"
  469. required
  470. value={confirmPassword}
  471. onChange={(e) => setConfirmPassword(e.target.value)}
  472. 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"
  473. placeholder={t('login.resetPassword.confirmPasswordPlaceholder')}
  474. autoComplete="new-password"
  475. />
  476. </div>
  477. <button
  478. type="submit"
  479. disabled={resetPasswordMutation.isPending || !newPassword || !confirmPassword}
  480. 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"
  481. >
  482. {resetPasswordMutation.isPending ? t('login.resetPassword.saving') : t('login.resetPassword.submit')}
  483. </button>
  484. </form>
  485. <div className="text-center">
  486. <button
  487. type="button"
  488. onClick={() => {
  489. setStep('credentials');
  490. setResetToken('');
  491. setNewPassword('');
  492. setConfirmPassword('');
  493. }}
  494. className="text-sm text-bambu-gray hover:text-bambu-green transition-colors"
  495. >
  496. {t('login.resetPassword.backToLogin')}
  497. </button>
  498. </div>
  499. </div>
  500. </div>
  501. );
  502. }
  503. // ---- Render: 2FA step ----
  504. if (step === '2fa') {
  505. return (
  506. <div className="min-h-screen flex items-center justify-center bg-bambu-dark p-4">
  507. <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">
  508. <div className="text-center">
  509. <div className="flex items-center justify-center mb-4">
  510. <div className="w-14 h-14 rounded-full bg-bambu-green/20 flex items-center justify-center">
  511. <Shield className="w-7 h-7 text-bambu-green" />
  512. </div>
  513. </div>
  514. <h2 className="text-2xl font-bold text-white">{t('login.twoFA.title')}</h2>
  515. <p className="mt-2 text-sm text-bambu-gray">{t('login.twoFA.subtitle')}</p>
  516. </div>
  517. {/* Method selector — only show if multiple methods available */}
  518. {twoFAMethods.length > 1 && (
  519. <div className="flex gap-2">
  520. {twoFAMethods.includes('totp') && (
  521. <button
  522. type="button"
  523. onClick={() => handleMethodChange('totp')}
  524. className={`flex-1 flex flex-col items-center gap-1 py-2 px-3 rounded-lg border text-xs font-medium transition-colors ${
  525. twoFAMethod === 'totp'
  526. ? 'border-bambu-green bg-bambu-green/10 text-bambu-green'
  527. : 'border-bambu-dark-tertiary text-bambu-gray hover:border-bambu-green/50'
  528. }`}
  529. >
  530. <Smartphone className="w-4 h-4" />
  531. {t('login.twoFA.methodAuthenticator')}
  532. </button>
  533. )}
  534. {twoFAMethods.includes('email') && (
  535. <button
  536. type="button"
  537. onClick={() => handleMethodChange('email')}
  538. className={`flex-1 flex flex-col items-center gap-1 py-2 px-3 rounded-lg border text-xs font-medium transition-colors ${
  539. twoFAMethod === 'email'
  540. ? 'border-bambu-green bg-bambu-green/10 text-bambu-green'
  541. : 'border-bambu-dark-tertiary text-bambu-gray hover:border-bambu-green/50'
  542. }`}
  543. >
  544. <Mail className="w-4 h-4" />
  545. {t('login.twoFA.methodEmail')}
  546. </button>
  547. )}
  548. {twoFAMethods.includes('backup') && (
  549. <button
  550. type="button"
  551. onClick={() => handleMethodChange('backup')}
  552. className={`flex-1 flex flex-col items-center gap-1 py-2 px-3 rounded-lg border text-xs font-medium transition-colors ${
  553. twoFAMethod === 'backup'
  554. ? 'border-bambu-green bg-bambu-green/10 text-bambu-green'
  555. : 'border-bambu-dark-tertiary text-bambu-gray hover:border-bambu-green/50'
  556. }`}
  557. >
  558. <Key className="w-4 h-4" />
  559. {t('login.twoFA.methodBackup')}
  560. </button>
  561. )}
  562. </div>
  563. )}
  564. <form onSubmit={handle2FASubmit} className="space-y-4">
  565. {/* Method-specific instructions */}
  566. {twoFAMethod === 'totp' && (
  567. <p className="text-sm text-bambu-gray">{t('login.twoFA.instructionsTotp')}</p>
  568. )}
  569. {twoFAMethod === 'email' && (
  570. <div className="space-y-3">
  571. <p className="text-sm text-bambu-gray">
  572. {emailOTPSent
  573. ? t('login.twoFA.instructionsEmail')
  574. : t('login.twoFA.instructionsEmailNotSent')}
  575. </p>
  576. {!emailOTPSent && (
  577. <Button
  578. type="button"
  579. variant="secondary"
  580. className="w-full"
  581. onClick={() => sendEmailOTPMutation.mutate()}
  582. disabled={sendEmailOTPMutation.isPending}
  583. >
  584. {sendEmailOTPMutation.isPending
  585. ? t('login.twoFA.sendingCode')
  586. : t('login.twoFA.sendCodeButton')}
  587. </Button>
  588. )}
  589. {emailOTPSent && (
  590. <button
  591. type="button"
  592. onClick={() => { setEmailOTPSent(false); sendEmailOTPMutation.mutate(); }}
  593. className="text-xs text-bambu-gray hover:text-bambu-green transition-colors"
  594. >
  595. {t('login.twoFA.resendCode')}
  596. </button>
  597. )}
  598. </div>
  599. )}
  600. {twoFAMethod === 'backup' && (
  601. <p className="text-sm text-bambu-gray">{t('login.twoFA.instructionsBackup')}</p>
  602. )}
  603. <div>
  604. <label htmlFor="twofa-code" className="block text-sm font-medium text-white mb-2">
  605. {twoFAMethod === 'backup'
  606. ? t('login.twoFA.backupCodeLabel')
  607. : t('login.twoFA.codeLabel')}
  608. </label>
  609. <input
  610. ref={twoFAInputRef}
  611. id="twofa-code"
  612. type="text"
  613. inputMode={twoFAMethod === 'backup' ? 'text' : 'numeric'}
  614. autoComplete="one-time-code"
  615. value={twoFACode}
  616. onChange={(e) => setTwoFACode(e.target.value.trim())}
  617. disabled={twoFAMethod === 'email' && !emailOTPSent}
  618. 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"
  619. placeholder={twoFAMethod === 'backup'
  620. ? t('login.twoFA.backupCodePlaceholder')
  621. : t('login.twoFA.codePlaceholder')}
  622. maxLength={twoFAMethod === 'backup' ? 8 : 6}
  623. autoFocus
  624. />
  625. </div>
  626. <button
  627. type="submit"
  628. disabled={
  629. verify2FAMutation.isPending ||
  630. !twoFACode.trim() ||
  631. (twoFAMethod === 'email' && !emailOTPSent)
  632. }
  633. 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"
  634. >
  635. {verify2FAMutation.isPending
  636. ? t('login.twoFA.verifyingButton')
  637. : t('login.twoFA.verifyButton')}
  638. </button>
  639. </form>
  640. <div className="text-center">
  641. <button
  642. type="button"
  643. onClick={() => {
  644. setStep('credentials');
  645. setPreAuthToken('');
  646. setTwoFACode('');
  647. setEmailOTPSent(false);
  648. }}
  649. className="text-sm text-bambu-gray hover:text-bambu-green transition-colors"
  650. >
  651. {t('login.twoFA.backToLogin')}
  652. </button>
  653. </div>
  654. </div>
  655. </div>
  656. );
  657. }
  658. // ---- Render: credentials step ----
  659. return (
  660. <div className="min-h-screen flex items-center justify-center bg-bambu-dark p-4">
  661. <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">
  662. <div className="text-center">
  663. <div className="flex items-center justify-center mb-6">
  664. <img
  665. src={mode === 'dark' ? '/img/bambuddy_logo_dark_transparent.png' : '/img/bambuddy_logo_light.png'}
  666. alt="Bambuddy"
  667. className="h-16"
  668. />
  669. </div>
  670. <h2 className="text-3xl font-bold text-white">
  671. {t('login.title')}
  672. </h2>
  673. <p className="mt-2 text-sm text-bambu-gray">
  674. {t('login.subtitle')}
  675. </p>
  676. </div>
  677. {showAutologinBanner && (
  678. <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">
  679. {t('login.autologinFailed')}
  680. </div>
  681. )}
  682. {!localLoginEnabled && (
  683. <div className="mt-6 rounded-lg border border-bambu-dark-tertiary bg-bambu-dark/40 px-4 py-3 text-sm text-bambu-gray">
  684. {t('login.localDisabledNotice')}
  685. </div>
  686. )}
  687. {localLoginEnabled && (
  688. <form className="mt-8 space-y-6" onSubmit={handleSubmit}>
  689. <div className="space-y-4">
  690. <div>
  691. <label htmlFor="username" className="block text-sm font-medium text-white mb-2">
  692. {advancedAuthStatus?.advanced_auth_enabled
  693. ? t('login.usernameOrEmail')
  694. : t('login.username')}
  695. </label>
  696. <input
  697. id="username"
  698. type="text"
  699. required
  700. value={username}
  701. onChange={(e) => setUsername(e.target.value)}
  702. 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"
  703. placeholder={advancedAuthStatus?.advanced_auth_enabled
  704. ? t('login.usernameOrEmailPlaceholder')
  705. : t('login.usernamePlaceholder')}
  706. autoComplete="username"
  707. />
  708. </div>
  709. <div>
  710. <label htmlFor="password" className="block text-sm font-medium text-white mb-2">
  711. {t('login.password') || 'Password'}
  712. </label>
  713. <input
  714. id="password"
  715. type="password"
  716. required
  717. value={password}
  718. onChange={(e) => setPassword(e.target.value)}
  719. 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"
  720. placeholder={t('login.passwordPlaceholder')}
  721. autoComplete="current-password"
  722. />
  723. </div>
  724. </div>
  725. <div className="flex items-center gap-2">
  726. <input
  727. id="remember-me"
  728. type="checkbox"
  729. checked={rememberMe}
  730. onChange={(e) => setRememberMe(e.target.checked)}
  731. className="h-4 w-4 rounded border-bambu-dark-tertiary bg-bambu-dark-secondary text-bambu-green focus:ring-bambu-green/50 cursor-pointer"
  732. />
  733. <label htmlFor="remember-me" className="text-sm text-bambu-gray cursor-pointer">
  734. {t('login.rememberMe')}
  735. </label>
  736. </div>
  737. <div>
  738. <button
  739. type="submit"
  740. disabled={loginMutation.isPending}
  741. 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"
  742. >
  743. {loginMutation.isPending ? t('login.signingIn') : t('login.signIn')}
  744. </button>
  745. </div>
  746. <div className="text-center">
  747. <button
  748. type="button"
  749. onClick={() => setShowForgotPassword(true)}
  750. className="text-sm text-bambu-gray hover:text-bambu-green transition-colors"
  751. >
  752. {t('login.forgotPassword')}
  753. </button>
  754. </div>
  755. </form>
  756. )}
  757. {/* OIDC provider buttons */}
  758. {oidcProviders && oidcProviders.length > 0 && (
  759. <div className="space-y-3">
  760. {localLoginEnabled && (
  761. <div className="relative">
  762. <div className="absolute inset-0 flex items-center">
  763. <div className="w-full border-t border-bambu-dark-tertiary" />
  764. </div>
  765. <div className="relative flex justify-center text-sm">
  766. <span className="px-2 bg-bambu-dark-secondary text-bambu-gray">{t('login.twoFA.orContinueWith')}</span>
  767. </div>
  768. </div>
  769. )}
  770. {!localLoginEnabled && (
  771. <div className="flex items-center gap-2">
  772. <input
  773. id="remember-me"
  774. type="checkbox"
  775. checked={rememberMe}
  776. onChange={(e) => setRememberMe(e.target.checked)}
  777. className="h-4 w-4 rounded border-bambu-dark-tertiary bg-bambu-dark-secondary text-bambu-green focus:ring-bambu-green/50 cursor-pointer"
  778. />
  779. <label htmlFor="remember-me" className="text-sm text-bambu-gray cursor-pointer">
  780. {t('login.rememberMe')}
  781. </label>
  782. </div>
  783. )}
  784. <div className="space-y-2">
  785. {oidcProviders.map((provider) => (
  786. <OIDCProviderButton
  787. key={provider.id}
  788. provider={provider}
  789. onClick={() => oidcLoginMutation.mutate(provider.id)}
  790. disabled={oidcLoginMutation.isPending}
  791. />
  792. ))}
  793. </div>
  794. </div>
  795. )}
  796. </div>
  797. {/* Forgot Password Modal */}
  798. {showForgotPassword && (
  799. <div
  800. className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
  801. onClick={() => setShowForgotPassword(false)}
  802. >
  803. <Card
  804. className="w-full max-w-md"
  805. onClick={(e: React.MouseEvent) => e.stopPropagation()}
  806. >
  807. <CardHeader>
  808. <div className="flex items-center justify-between">
  809. <div className="flex items-center gap-2">
  810. <Mail className="w-5 h-5 text-bambu-green" />
  811. <h2 className="text-lg font-semibold text-white">{t('login.forgotPasswordTitle')}</h2>
  812. </div>
  813. <Button
  814. variant="ghost"
  815. size="sm"
  816. onClick={() => {
  817. setShowForgotPassword(false);
  818. setForgotEmail('');
  819. }}
  820. >
  821. <X className="w-5 h-5" />
  822. </Button>
  823. </div>
  824. </CardHeader>
  825. <CardContent>
  826. {advancedAuthStatus?.advanced_auth_enabled ? (
  827. <form onSubmit={handleForgotPassword} className="space-y-4">
  828. <p className="text-bambu-gray text-sm">
  829. {t('login.forgotPasswordEmailMessage')}
  830. </p>
  831. <div>
  832. <label htmlFor="forgot-email" className="block text-sm font-medium text-white mb-2">
  833. {t('login.emailAddress')}
  834. </label>
  835. <input
  836. id="forgot-email"
  837. type="email"
  838. required
  839. value={forgotEmail}
  840. onChange={(e) => setForgotEmail(e.target.value)}
  841. 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"
  842. placeholder={t('login.emailPlaceholder')}
  843. />
  844. </div>
  845. <div className="flex gap-2">
  846. <Button
  847. type="button"
  848. variant="secondary"
  849. className="flex-1"
  850. onClick={() => {
  851. setShowForgotPassword(false);
  852. setForgotEmail('');
  853. }}
  854. >
  855. {t('login.cancel')}
  856. </Button>
  857. <Button
  858. type="submit"
  859. className="flex-1"
  860. disabled={forgotPasswordMutation.isPending}
  861. >
  862. {forgotPasswordMutation.isPending
  863. ? t('login.sending')
  864. : t('login.sendResetEmail')}
  865. </Button>
  866. </div>
  867. </form>
  868. ) : (
  869. <div className="space-y-4">
  870. <p className="text-bambu-gray">
  871. {t('login.forgotPasswordMessage')}
  872. </p>
  873. <div className="bg-bambu-dark rounded-lg p-4 space-y-2">
  874. <p className="text-sm text-white font-medium">{t('login.howToReset')}</p>
  875. <ol className="text-sm text-bambu-gray space-y-1 list-decimal list-inside">
  876. <li>{t('login.resetStep1')}</li>
  877. <li>{t('login.resetStep2')}</li>
  878. <li>{t('login.resetStep3')}</li>
  879. <li>{t('login.resetStep4')}</li>
  880. </ol>
  881. </div>
  882. <Button
  883. variant="secondary"
  884. className="w-full"
  885. onClick={() => setShowForgotPassword(false)}
  886. >
  887. {t('login.gotIt')}
  888. </Button>
  889. </div>
  890. )}
  891. </CardContent>
  892. </Card>
  893. </div>
  894. )}
  895. </div>
  896. );
  897. }