Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 41 additions & 6 deletions src/__tests__/app/login/index.web.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -28,8 +29,12 @@ jest.mock('expo-router', () => ({

jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, fallback?: string) => {
t: (key: string, fallbackOrOptions?: string | Record<string, unknown>) => {
const translations: Record<string, string> = {
'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',
Expand All @@ -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;
},
}),
}));
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -230,6 +241,30 @@ describe('LoginWeb', () => {
});
});

it('says which server the form signs in to, and opens the server selector from it', () => {
render(<LoginWeb />);
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(<LoginWeb />);
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,
Expand Down
20 changes: 16 additions & 4 deletions src/app/login/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -117,7 +123,7 @@ export default function Login() {
<LoginForm
onSubmit={onSubmit}
isLoading={status === 'loading'}
error={displayError ?? undefined}
error={loginError?.message ?? displayError ?? undefined}
onServerUrlPress={() => setShowServerUrl(true)}
onSsoPress={() => router.push('/login/sso' as any)}
onSharedDevicePress={() => router.push('/login/shared-device' as unknown as Href)}
Expand All @@ -135,11 +141,17 @@ export default function Login() {
<ModalBackdrop />
<ModalContent className="m-4 w-full max-w-3xl rounded-2xl">
<ModalHeader>
<Text className="text-xl font-semibold">{t('login.errorModal.title')}</Text>
<Text className="text-xl font-semibold">{loginError?.title ?? t('login.errorModal.title')}</Text>
</ModalHeader>
<ModalBody>
<Text>{t('login.errorModal.message')}</Text>
{displayError ? <Text className="mt-2 text-sm text-gray-500">{displayError}</Text> : null}
{loginError ? (
<Text testID="login-error-message">{loginError.message}</Text>
) : (
<>
<Text>{t('login.errorModal.message')}</Text>
{displayError ? <Text className="mt-2 text-sm text-gray-500">{displayError}</Text> : null}
</>
)}
</ModalBody>
<ModalFooter>
<Button
Expand Down
27 changes: 24 additions & 3 deletions src/app/login/index.web.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,12 @@ import * as z from 'zod';

import { LoginMfaSheet } from '@/components/auth/login-mfa-sheet';
import { LoginOtpModal } from '@/components/auth/login-otp-modal';
import { LoginServerBadge } from '@/components/auth/login-server-badge';
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';
Expand Down Expand Up @@ -137,7 +140,11 @@ export default function LoginWeb() {
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;
// Held only to resubmit with the code when an older server answers mfa_required without a login transaction;
// memory-only, dropped on success, dismissal or unmount. Never logged.
const [pendingCredentials, setPendingCredentials] = useState<{ username: string; password: string } | null>(null);
Expand Down Expand Up @@ -428,6 +435,9 @@ export default function LoginWeb() {
<Animated.View entering={FadeInDown.delay(200).duration(500)} style={styles.loginHeader}>
<Text style={StyleSheet.flatten([styles.loginTitle, isDark ? styles.loginTitleDark : styles.loginTitleLight])}>{t('login.welcome_title', 'Welcome Back')}</Text>
<Text style={StyleSheet.flatten([styles.loginSubtitle, isDark ? styles.loginSubtitleDark : styles.loginSubtitleLight])}>{t('login.page_subtitle')}</Text>
<View style={styles.serverBadgeContainer}>
<LoginServerBadge onPress={() => setShowServerUrlModal(true)} />

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Inline arrow function in the LoginServerBadge JSX onPress prop creates a new function on every render, degrading performance. Move the handler definition outside the render method.

Kody rule violation: Avoid using .bind() or arrow functions in JSX props

Prompt for LLM

File src/app/login/index.web.tsx:

Line 439:

Inline arrow function in the `LoginServerBadge` JSX `onPress` prop creates a new function on every render, degrading performance. Move the handler definition outside the render method.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

​

​

</View>
</Animated.View>

{/* Login Form */}
Expand Down Expand Up @@ -552,9 +562,17 @@ export default function LoginWeb() {
<View style={styles.modalIconContainer}>
<AlertCircle size={48} color="#ef4444" />
</View>
<Text style={StyleSheet.flatten([styles.modalTitle, isDark ? styles.modalTitleDark : styles.modalTitleLight])}>{t('login.errorModal.title')}</Text>
<Text style={StyleSheet.flatten([styles.modalMessage, isDark ? styles.modalMessageDark : styles.modalMessageLight])}>{t('login.errorModal.message')}</Text>
{displayError ? <Text style={StyleSheet.flatten([styles.modalMessage, isDark ? styles.modalMessageDark : styles.modalMessageLight])}>{displayError}</Text> : null}
<Text style={StyleSheet.flatten([styles.modalTitle, isDark ? styles.modalTitleDark : styles.modalTitleLight])}>{loginError?.title ?? t('login.errorModal.title')}</Text>
{loginError ? (
<Text style={StyleSheet.flatten([styles.modalMessage, isDark ? styles.modalMessageDark : styles.modalMessageLight])} testID="login-error-message">
{loginError.message}
</Text>
) : (
<>
<Text style={StyleSheet.flatten([styles.modalMessage, isDark ? styles.modalMessageDark : styles.modalMessageLight])}>{t('login.errorModal.message')}</Text>
{displayError ? <Text style={StyleSheet.flatten([styles.modalMessage, isDark ? styles.modalMessageDark : styles.modalMessageLight])}>{displayError}</Text> : null}
</>
)}
<Pressable style={styles.modalButton} onPress={() => setShowErrorModal(false)}>
<Text style={styles.modalButtonText}>{t('login.errorModal.confirmButton')}</Text>
</Pressable>
Expand Down Expand Up @@ -977,6 +995,9 @@ const styles = StyleSheet.create({
loginSubtitleLight: {
color: '#6b7280',
},
serverBadgeContainer: {
marginTop: 12,
},
formContainer: {
gap: 12,
},
Expand Down
6 changes: 5 additions & 1 deletion src/app/login/login-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { Image, Keyboard } from 'react-native';
import { KeyboardAvoidingView } from 'react-native-keyboard-controller';
import * as z from 'zod';

import { LoginServerBadge } from '@/components/auth/login-server-badge';
import { View } from '@/components/ui';
import { Button, ButtonSpinner, ButtonText } from '@/components/ui/button';
import { FormControl, FormControlError, FormControlErrorIcon, FormControlErrorText, FormControlLabel, FormControlLabelText } from '@/components/ui/form-control';
Expand Down Expand Up @@ -82,7 +83,10 @@ export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = unde
<Image style={{ width: '96%' }} source={colorScheme === 'dark' ? require('@assets/images/Resgrid_JustText_White.png') : require('@assets/images/Resgrid_JustText.png')} resizeMode="contain" />
<Text className="pb-2 text-center text-4xl font-bold">{t('login.page_title')}</Text>

<Text className="mb-6 max-w-xl text-center text-gray-500 dark:text-gray-400">{t('login.page_subtitle')}</Text>
<Text className="mb-4 max-w-xl text-center text-gray-500 dark:text-gray-400">{t('login.page_subtitle')}</Text>
</View>
<View className="mb-6">
<LoginServerBadge onPress={onServerUrlPress} />
</View>
<FormControl isInvalid={!!errors?.username || !validated.usernameValid} className="w-full">
<FormControlLabel>
Expand Down
65 changes: 65 additions & 0 deletions src/components/auth/__tests__/login-server-badge.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { act, fireEvent, render, screen } from '@testing-library/react-native';
import React from 'react';

import { useServerUrlStore } from '@/stores/app/server-url-store';

import { LoginServerBadge } from '../login-server-badge';

jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, string>) =>
({
'login.server.label': 'Server:',
'login.server.us_west': 'US West',
'login.server.eu_central': 'EU Central',
})[key] ?? (key === 'login.server.custom_host' ? `Custom (${options?.host})` : key),
}),
}));

jest.mock('nativewind', () => ({ useColorScheme: () => ({ colorScheme: 'light' }) }));
jest.mock('@/api/config', () => ({ getSystemConfig: jest.fn() }));
jest.mock('@/lib/mapbox-token', () => ({ clearMapboxToken: jest.fn() }));
jest.mock('@/lib/storage/app', () => ({
getBaseApiUrl: jest.fn(() => 'https://api.resgrid.com/api/v4'),
setBaseApiUrl: jest.fn(),
}));

describe('LoginServerBadge', () => {
beforeEach(() => {
useServerUrlStore.setState({ url: '' });
});

it('names the stored server before anything changes it', () => {
render(<LoginServerBadge />);
expect(screen.getByText('US West')).toBeTruthy();
});

it('follows a server change', async () => {
render(<LoginServerBadge />);
await act(async () => {
await useServerUrlStore.getState().setUrl('https://api-eu-central.resgrid.com/api/v4');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Unhandled rejection from the asynchronous setUrl operation can cause the tests to fail without an explicit assertion; this occurs at src/components/auth/__tests__/login-server-badge.test.tsx:45-45, src/__tests__/app/login/index.web.test.tsx:260-260, src/components/auth/__tests__/login-server-badge.test.tsx:39-39, and src/components/auth/__tests__/login-server-badge.test.tsx:44-44. Handle the rejection with try/catch or an equivalent error assertion.

Kody rule violation: Handle async operations with proper error handling

try {
  await useServerUrlStore.getState().setUrl('https://api-eu-central.resgrid.com/api/v4');
} catch (error) {
  // Assert or report the failure appropriately.
}
Prompt for LLM

File src/components/auth/__tests__/login-server-badge.test.tsx:

Line 40:

Unhandled rejection from the asynchronous `setUrl` operation can cause the tests to fail without an explicit assertion; this occurs at `src/components/auth/__tests__/login-server-badge.test.tsx:45-45`, `src/__tests__/app/login/index.web.test.tsx:260-260`, `src/components/auth/__tests__/login-server-badge.test.tsx:39-39`, and `src/components/auth/__tests__/login-server-badge.test.tsx:44-44`. Handle the rejection with `try/catch` or an equivalent error assertion.

Suggested Code:

      try {
        await useServerUrlStore.getState().setUrl('https://api-eu-central.resgrid.com/api/v4');
      } catch (error) {
        // Assert or report the failure appropriately.
      }

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

​

​

});
expect(screen.getByText('EU Central')).toBeTruthy();

await act(async () => {
await useServerUrlStore.getState().setUrl('https://rg.example.org/api/v4');
});
expect(screen.getByText('Custom (rg.example.org)')).toBeTruthy();
});

it('opens the server selector when pressed', () => {
const onPress = jest.fn();
render(<LoginServerBadge onPress={onPress} />);
fireEvent.press(screen.getByTestId('login-server-badge'));
expect(onPress).toHaveBeenCalled();
});

it('is a button only when it opens something', () => {
const { rerender } = render(<LoginServerBadge onPress={jest.fn()} />);
expect(screen.getByTestId('login-server-badge').props.accessibilityRole).toBe('button');

rerender(<LoginServerBadge />);
expect(screen.getByTestId('login-server-badge').props.accessibilityRole).toBeUndefined();
expect(screen.queryByRole('button')).toBeNull();
});
});
47 changes: 47 additions & 0 deletions src/components/auth/login-server-badge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Server } from 'lucide-react-native';
import { useColorScheme } from 'nativewind';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Pressable, View } from 'react-native';

import { Text } from '@/components/ui/text';
import { useLoginServer } from '@/hooks/use-login-server';

interface LoginServerBadgeProps {
/** Opens the server selector; without it the badge is a plain label. */
onPress?: () => void;
}

const BADGE_CLASS = 'flex-row items-center self-center rounded-full border border-gray-300 px-3 py-1.5 dark:border-gray-700';

/** Names the server the login form signs in to (US West, EU Central or Custom), so a wrong server is spotted before signing in. */
export const LoginServerBadge = ({ onPress }: LoginServerBadgeProps) => {
const { t } = useTranslation();
const { colorScheme } = useColorScheme();
const { name } = useLoginServer();
const label = t('login.server.signing_in_to', { server: name });

const content = (
<>
<Server size={14} color={colorScheme === 'dark' ? '#9ca3af' : '#6b7280'} />
<Text className="ml-1.5 text-sm text-gray-600 dark:text-gray-300">
{t('login.server.label')} <Text className="text-sm font-semibold text-gray-900 dark:text-white">{name}</Text>
</Text>
</>
);

// Only a badge that does something is a button; a label stays a label for assistive technologies.
if (!onPress) {
return (
<View accessible accessibilityLabel={label} testID="login-server-badge" className={BADGE_CLASS}>
{content}
</View>
);
}

return (
<Pressable onPress={onPress} accessibilityRole="button" accessibilityLabel={label} accessibilityHint={t('login.server.change_hint')} testID="login-server-badge" className={BADGE_CLASS}>
{content}
</Pressable>
);
};
22 changes: 22 additions & 0 deletions src/hooks/use-login-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';

import { describeLoginServer, type LoginServer } from '@/lib/login-server';
import { getBaseApiUrl } from '@/lib/storage/app';
import { useServerUrlStore } from '@/stores/app/server-url-store';

/**
* The server this device signs in to, and its display name ("US West", "EU Central" or "Custom (host)"). Follows a
* server change made from the login screen, since saving one updates the server URL store.
*/
export const useLoginServer = (): LoginServer & { name: string } => {
const { t } = useTranslation();
const storeUrl = useServerUrlStore((state) => state.url);
const apiUrl = storeUrl || getBaseApiUrl();

return useMemo(() => {
const server = describeLoginServer(apiUrl);
const name = server.kind === 'custom' ? t('login.server.custom_host', { host: server.host }) : t(`login.server.${server.kind}`);
return { ...server, name };
}, [apiUrl, t]);
};
17 changes: 17 additions & 0 deletions src/lib/__tests__/login-server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { describeLoginServer } from '../login-server';

jest.mock('@/api/config', () => ({ getSystemConfig: jest.fn() }));

describe('describeLoginServer', () => {
it('names the Resgrid hosted servers with or without the API suffix', () => {
expect(describeLoginServer('https://api.resgrid.com/api/v4')).toEqual({ kind: 'us_west', host: 'api.resgrid.com' });
expect(describeLoginServer('https://API.resgrid.com/')).toEqual({ kind: 'us_west', host: 'api.resgrid.com' });
expect(describeLoginServer('https://api-eu-central.resgrid.com/api/v4')).toEqual({ kind: 'eu_central', host: 'api-eu-central.resgrid.com' });
});

it('labels any other server custom, with its host', () => {
expect(describeLoginServer('https://qaapi.resgrid.dev/api/v4')).toEqual({ kind: 'custom', host: 'qaapi.resgrid.dev' });
expect(describeLoginServer('http://localhost:5000/api/v4')).toEqual({ kind: 'custom', host: 'localhost:5000' });
expect(describeLoginServer('https://resgrid.example.org/rg/api/v4')).toEqual({ kind: 'custom', host: 'resgrid.example.org' });
});
});
Loading
Loading