LoginPage.tsx 36 KB

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