LoginPage.tsx 34 KB

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