LoginPage.tsx 40 KB

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