From c5d132d6a0ccd486bc25cd08c6459bd936526c80 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sat, 31 Oct 2026 11:51:53 -0700 Subject: [PATCH 1/2] RC-T48 Login Fixes --- src/app/login/__tests__/index.test.tsx | 31 ++++ src/app/login/index.tsx | 20 ++- src/app/login/login-form.tsx | 6 +- .../__tests__/login-server-badge.test.tsx | 54 +++++++ src/components/auth/login-server-badge.tsx | 37 +++++ src/hooks/use-login-server.ts | 22 +++ src/lib/__tests__/login-server.test.ts | 15 ++ src/lib/auth/__tests__/api-logging.test.ts | 17 ++- src/lib/auth/__tests__/login-errors.test.ts | 132 ++++++++++++++++++ src/lib/auth/api.tsx | 16 ++- src/lib/auth/login-errors.ts | 109 +++++++++++++++ src/lib/auth/types.tsx | 5 + src/lib/login-server.ts | 33 +++++ src/stores/auth/__tests__/store-mfa.test.ts | 35 +++++ src/stores/auth/store.tsx | 23 ++- src/translations/ar.json | 66 +++++++++ src/translations/de.json | 66 +++++++++ src/translations/el.json | 66 +++++++++ src/translations/en.json | 66 +++++++++ src/translations/es.json | 66 +++++++++ src/translations/fr.json | 66 +++++++++ src/translations/it.json | 66 +++++++++ src/translations/pl.json | 66 +++++++++ src/translations/sv.json | 66 +++++++++ src/translations/uk.json | 66 +++++++++ 25 files changed, 1198 insertions(+), 17 deletions(-) create mode 100644 src/components/auth/__tests__/login-server-badge.test.tsx create mode 100644 src/components/auth/login-server-badge.tsx create mode 100644 src/hooks/use-login-server.ts create mode 100644 src/lib/__tests__/login-server.test.ts create mode 100644 src/lib/auth/__tests__/login-errors.test.ts create mode 100644 src/lib/auth/login-errors.ts create mode 100644 src/lib/login-server.ts diff --git a/src/app/login/__tests__/index.test.tsx b/src/app/login/__tests__/index.test.tsx index e6879a0..a2c116c 100644 --- a/src/app/login/__tests__/index.test.tsx +++ b/src/app/login/__tests__/index.test.tsx @@ -239,6 +239,37 @@ describe('Login', () => { expect(screen.getByText('Login failed')).toBeTruthy(); }); + describe('a refused password sign-in', () => { + const { default: useAuthStore } = jest.requireActual('@/stores/auth/store') as typeof import('@/stores/auth/store'); + afterEach(() => useAuthStore.setState({ loginFailure: null })); + + it('says why and what to do next, in the modal and on the form, never as the raw code', () => { + useAuthStore.setState({ loginFailure: { code: 'invalid_credentials' } }); + mockUseAuth.mockReturnValue({ login: jest.fn(), ssoLogin: jest.fn(), status: 'error', error: 'invalid_credentials', isAuthenticated: false }); + + render(); + + expect(screen.getByText('login.errors.invalid_credentials.title')).toBeTruthy(); + // Once in the modal and once on the form under the sign-in button. + expect(screen.getAllByText('login.errors.invalid_credentials.message')).toHaveLength(2); + expect(screen.queryByText('invalid_credentials')).toBeNull(); + expect(screen.queryByText('Login failed')).toBeNull(); + }); + + it('tells a temporary lockout from one only an administrator can lift', () => { + mockUseAuth.mockReturnValue({ login: jest.fn(), ssoLogin: jest.fn(), status: 'error', error: 'account_locked_out', isAuthenticated: false }); + + useAuthStore.setState({ loginFailure: { code: 'account_locked_out', lockoutMinutes: 5 } }); + const { unmount } = render(); + expect(screen.getByText('login.errors.account_locked_out.title')).toBeTruthy(); + unmount(); + + useAuthStore.setState({ loginFailure: null }); + render(); + expect(screen.getByText('login.errors.account_locked.title')).toBeTruthy(); + }); + }); + it('redirects to app when authenticated', async () => { mockUseAuth.mockReturnValue({ login: jest.fn(), diff --git a/src/app/login/index.tsx b/src/app/login/index.tsx index ec99851..c179823 100644 --- a/src/app/login/index.tsx +++ b/src/app/login/index.tsx @@ -11,7 +11,9 @@ import { Button, ButtonText } from '@/components/ui/button'; import { Modal, ModalBackdrop, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/ui/modal'; import { Text } from '@/components/ui/text'; import { useAnalytics } from '@/hooks/use-analytics'; +import { useLoginServer } from '@/hooks/use-login-server'; import { useAuth } from '@/lib/auth'; +import { describeLoginFailure, isLoginErrorCode } from '@/lib/auth/login-errors'; import { logger } from '@/lib/logging'; import { isMfaErrorCode, mfaErrorKey } from '@/lib/mfa/messages'; import { useSharedInstallation } from '@/lib/mfa/shared-installation'; @@ -32,7 +34,11 @@ export default function Login() { const router = useRouter(); const { login, status, error, isAuthenticated } = useAuth(); const mfaChallenge = useAuthStore((s) => s.mfaChallenge); + const loginFailure = useAuthStore((s) => s.loginFailure); const installation = useSharedInstallation(); + const { name: serverName } = useLoginServer(); + // A failed password sign-in says why and what to do next. + const loginError = isLoginErrorCode(error) ? describeLoginFailure(t, loginFailure?.code === error ? loginFailure : { code: error }, serverName) : null; // A sign-in that ended (expired, too many attempts, policy changed) or a shared shift that ran out says why in the // member's language. const displayError = error === 'shift_ended' ? t('shared_session.shift_ended') : isMfaErrorCode(error) ? t(mfaErrorKey(error)) : error; @@ -80,7 +86,7 @@ export default function Login() { setShowServerUrl(true)} onSsoPress={() => router.push('/login/sso')} onSharedDevicePress={() => router.push('/login/shared-device' as unknown as Href)} @@ -98,11 +104,17 @@ export default function Login() { - {t('login.errorModal.title')} + {loginError?.title ?? t('login.errorModal.title')} - {t('login.errorModal.message')} - {displayError ? {displayError} : null} + {loginError ? ( + {loginError.message} + ) : ( + <> + {t('login.errorModal.message')} + {displayError ? {displayError} : null} + + )}