From 66107f1b7adefadc735f98d5f90289ef4afcfc19 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sat, 31 Oct 2026 11:53:58 -0700 Subject: [PATCH 1/2] RD-T49 Login Fixes --- src/__tests__/app/login/index.web.test.tsx | 47 ++++++- src/app/login/index.tsx | 20 ++- src/app/login/index.web.tsx | 27 +++- src/app/login/login-form.tsx | 6 +- .../__tests__/login-server-badge.test.tsx | 56 ++++++++ src/components/auth/login-server-badge.tsx | 37 +++++ src/hooks/use-login-server.ts | 22 +++ src/lib/__tests__/login-server.test.ts | 17 +++ src/lib/auth/__tests__/login-errors.test.ts | 132 ++++++++++++++++++ src/lib/auth/api.tsx | 15 +- src/lib/auth/login-errors.ts | 109 +++++++++++++++ src/lib/auth/types.tsx | 5 + src/lib/login-server.ts | 24 ++++ src/stores/auth/store.tsx | 11 +- src/translations/ar.json | 68 ++++++++- src/translations/de.json | 68 ++++++++- src/translations/el.json | 68 ++++++++- src/translations/en.json | 68 ++++++++- src/translations/es.json | 68 ++++++++- src/translations/fr.json | 68 ++++++++- src/translations/it.json | 68 ++++++++- src/translations/pl.json | 68 ++++++++- src/translations/sv.json | 68 ++++++++- src/translations/uk.json | 68 ++++++++- 24 files changed, 1177 insertions(+), 31 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/__tests__/app/login/index.web.test.tsx b/src/__tests__/app/login/index.web.test.tsx index ccadcde0..27b4c900 100644 --- a/src/__tests__/app/login/index.web.test.tsx +++ b/src/__tests__/app/login/index.web.test.tsx @@ -3,6 +3,7 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react-nativ import LoginWeb from '../../../app/login/index.web'; import { useAuth } from '@/lib/auth'; +import useAuthStore from '@/stores/auth/store'; // Mock hooks and dependencies const mockLogin = jest.fn(); @@ -28,8 +29,12 @@ jest.mock('expo-router', () => ({ jest.mock('react-i18next', () => ({ useTranslation: () => ({ - t: (key: string, fallback?: string) => { + t: (key: string, fallbackOrOptions?: string | Record) => { const translations: Record = { + 'login.server.label': 'Server:', + 'login.server.custom_host': 'Custom ({{host}})', + 'login.errors.account_locked_out.title': 'Account temporarily locked', + 'login.errors.account_locked_out.message': 'Wait {{minutes}} min and try again.', 'login.page_subtitle': 'Enter the information below to Sign in to the Resgrid Dispatch application.', 'login.username_placeholder': 'Enter your username', 'login.password_placeholder': 'Enter your password', @@ -46,7 +51,11 @@ jest.mock('react-i18next', () => ({ 'common.save': 'Save', 'sso.sso_button': 'SSO Login', }; - return translations[key] || fallback || key; + if (translations[key]) { + const options = typeof fallbackOrOptions === 'object' ? fallbackOrOptions : {}; + return translations[key].replace(/\{\{(\w+)\}\}/g, (_, name: string) => String(options[name])); + } + return (typeof fallbackOrOptions === 'string' && fallbackOrOptions) || key; }, }), })); @@ -86,12 +95,14 @@ jest.mock('@/lib/env', () => ({ }, })); -jest.mock('@/stores/app/server-url-store', () => ({ - useServerUrlStore: () => ({ +jest.mock('@/stores/app/server-url-store', () => { + const state = { + url: 'https://api.example.com/api/v4', setUrl: jest.fn(), getUrl: jest.fn().mockResolvedValue('https://api.example.com/api/v4'), - }), -})); + }; + return { useServerUrlStore: (selector?: (s: typeof state) => unknown) => (selector ? selector(state) : state) }; +}); jest.mock('nativewind', () => ({ styled: jest.fn((Component: any) => Component), @@ -230,6 +241,30 @@ describe('LoginWeb', () => { }); }); + it('says which server the form signs in to, and opens the server selector from it', () => { + render(); + expect(screen.getByText('Custom (api.example.com)')).toBeTruthy(); + fireEvent.press(screen.getByTestId('login-server-badge')); + expect(screen.getByText('Cancel')).toBeTruthy(); + }); + + it('explains a known sign-in failure instead of the generic message', async () => { + useAuthStore.setState({ loginFailure: { code: 'account_locked_out', lockoutMinutes: 12 } }); + (useAuth as jest.Mock).mockReturnValue({ + login: mockLogin, + status: 'error', + error: 'account_locked_out', + isAuthenticated: false, + }); + render(); + await waitFor(() => { + expect(screen.getByText('Account temporarily locked')).toBeTruthy(); + }); + expect(screen.getByTestId('login-error-message').props.children).toBe('Wait 12 min and try again.'); + expect(screen.queryByText('Login Failed')).toBeNull(); + useAuthStore.setState({ loginFailure: null }); + }); + it('closes error modal when OK is pressed', async () => { (useAuth as jest.Mock).mockReturnValue({ login: mockLogin, diff --git a/src/app/login/index.tsx b/src/app/login/index.tsx index 01aba86b..4a8d3f8b 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'; @@ -31,7 +33,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; @@ -117,7 +123,7 @@ export default function Login() { setShowServerUrl(true)} onSsoPress={() => router.push('/login/sso' as any)} onSharedDevicePress={() => router.push('/login/shared-device' as unknown as Href)} @@ -135,11 +141,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} + + )}