Skip to content
Closed
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
21 changes: 21 additions & 0 deletions __mocks__/expo-auth-session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Mock for expo-auth-session
const mockExchangeCodeAsync = jest.fn();
const mockMakeRedirectUri = jest.fn(() => 'resgridunit://auth/callback');
const mockUseAutoDiscovery = jest.fn(() => ({
authorizationEndpoint: 'https://idp.example.com/authorize',
tokenEndpoint: 'https://idp.example.com/token',
}));
const mockUseAuthRequest = jest.fn(() => [
{ codeVerifier: 'test-verifier' },
null,
jest.fn(),
]);

module.exports = {
makeRedirectUri: mockMakeRedirectUri,
useAutoDiscovery: mockUseAutoDiscovery,
useAuthRequest: mockUseAuthRequest,
exchangeCodeAsync: mockExchangeCodeAsync,
ResponseType: { Code: 'code' },
__esModule: true,
};
13 changes: 13 additions & 0 deletions __mocks__/expo-web-browser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Mock for expo-web-browser
const maybeCompleteAuthSession = jest.fn(() => ({ type: 'success' }));
const openBrowserAsync = jest.fn(() => Promise.resolve({ type: 'dismiss' }));
const openAuthSessionAsync = jest.fn(() => Promise.resolve({ type: 'dismiss' }));
const dismissBrowser = jest.fn();

module.exports = {
maybeCompleteAuthSession,
openBrowserAsync,
openAuthSessionAsync,
dismissBrowser,
__esModule: true,
};
13 changes: 13 additions & 0 deletions app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
ITSAppUsesNonExemptEncryption: false,
UIViewControllerBasedStatusBarAppearance: false,
NSBluetoothAlwaysUsageDescription: 'Allow Resgrid Unit to connect to bluetooth devices for PTT.',
// Allow the app to open its own custom-scheme deep links (needed for SSO callbacks)
LSApplicationQueriesSchemes: ['resgridunit'],
},
entitlements: {
...((Env.APP_ENV === 'production' || Env.APP_ENV === 'internal') && {
Expand All @@ -71,6 +73,15 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
softwareKeyboardLayoutMode: 'pan',
package: Env.PACKAGE,
googleServicesFile: 'google-services.json',
// Register the ResgridUnit:// deep-link scheme so OIDC / SAML callbacks are routed back here
intentFilters: [
{
action: 'VIEW',
autoVerify: false,
data: [{ scheme: 'resgridunit' }],
category: ['BROWSABLE', 'DEFAULT'],
},
],
permissions: [
'android.permission.WAKE_LOCK',
'android.permission.RECORD_AUDIO',
Expand Down Expand Up @@ -107,6 +118,8 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
'expo-localization',
'expo-router',
['react-native-edge-to-edge'],
'expo-web-browser',
'expo-secure-store',
[
'@rnmapbox/maps',
{
Expand Down
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,11 @@
"expo-application": "~6.1.5",
"expo-asset": "~11.1.7",
"expo-audio": "~0.4.9",
"expo-auth-session": "~6.2.1",
"expo-av": "~15.1.7",
"expo-build-properties": "~0.14.8",
"expo-constants": "~17.1.8",
"expo-crypto": "~14.1.5",
"expo-dev-client": "~5.2.4",
"expo-device": "~7.1.4",
"expo-document-picker": "~13.1.6",
Expand All @@ -134,11 +136,13 @@
"expo-navigation-bar": "~4.2.8",
"expo-router": "~5.1.11",
"expo-screen-orientation": "~8.1.7",
"expo-secure-store": "~14.2.4",
"expo-sharing": "~13.1.5",
"expo-splash-screen": "~0.30.10",
"expo-status-bar": "~2.2.3",
"expo-system-ui": "~5.0.11",
"expo-task-manager": "~13.1.6",
"expo-web-browser": "~14.2.0",
"geojson": "~0.5.0",
"i18next": "~23.14.0",
"livekit-client": "^2.15.7",
Expand Down
35 changes: 35 additions & 0 deletions src/app/login/__tests__/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,37 @@ jest.mock('expo-router', () => ({
}),
}));

// Mock expo-linking (used for SAML deep-link handling)
jest.mock('expo-linking', () => ({
addEventListener: jest.fn(() => ({ remove: jest.fn() })),
parse: jest.fn(() => ({ queryParams: {} })),
}));

// Mock SSO discovery service
jest.mock('@/services/sso-discovery', () => ({
fetchSsoConfigForUser: jest.fn(() => Promise.resolve({ config: null, userExists: false })),
}));

// Mock OIDC hook
jest.mock('@/hooks/use-oidc-login', () => ({
useOidcLogin: jest.fn(() => ({
request: null,
response: null,
promptAsync: jest.fn(),
exchangeForResgridToken: jest.fn(),
discovery: null,
})),
}));

// Mock SAML hook
jest.mock('@/hooks/use-saml-login', () => ({
useSamlLogin: jest.fn(() => ({
startSamlLogin: jest.fn(),
handleDeepLink: jest.fn(),
isSamlCallback: jest.fn(() => false),
})),
}));

// Mock UI components
jest.mock('@/components/ui', () => {
const React = require('react');
Expand Down Expand Up @@ -131,6 +162,7 @@ describe('Login', () => {
// Set default mock return values
mockUseAuth.mockReturnValue({
login: jest.fn(),
ssoLogin: jest.fn(),
status: 'idle',
error: null,
isAuthenticated: false,
Expand Down Expand Up @@ -182,6 +214,7 @@ describe('Login', () => {
it('shows error modal when status is error', () => {
mockUseAuth.mockReturnValue({
login: jest.fn(),
ssoLogin: jest.fn(),
status: 'error',
error: 'Invalid credentials',
isAuthenticated: false,
Expand All @@ -196,6 +229,7 @@ describe('Login', () => {
it('redirects to app when authenticated', async () => {
mockUseAuth.mockReturnValue({
login: jest.fn(),
ssoLogin: jest.fn(),
status: 'signedIn',
error: null,
isAuthenticated: true,
Expand Down Expand Up @@ -226,6 +260,7 @@ describe('Login', () => {
const mockLogin = jest.fn();
mockUseAuth.mockReturnValue({
login: mockLogin,
ssoLogin: jest.fn(),
status: 'idle',
error: null,
isAuthenticated: false,
Expand Down
24 changes: 12 additions & 12 deletions src/app/login/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
export default function Login() {
const [isErrorModalVisible, setIsErrorModalVisible] = useState(false);
const [showServerUrl, setShowServerUrl] = useState(false);

const { t } = useTranslation();
const { trackEvent } = useAnalytics();
const router = useRouter();
Expand All @@ -32,35 +33,34 @@

useEffect(() => {
if (status === 'signedIn' && isAuthenticated) {
logger.info({
message: 'Login successful, redirecting to home',
});
logger.info({ message: 'Login successful, redirecting to home' });
router.push('/(app)');
}
}, [status, isAuthenticated, router]);

useEffect(() => {
if (status === 'error') {
logger.error({
message: 'Login failed',
context: { error },
});
logger.error({ message: 'Login failed', context: { error } });
setIsErrorModalVisible(true);
}
}, [status, error]);

// ── Local login ───────────────────────────────────────────────────────────
const onSubmit: LoginFormProps['onSubmit'] = async (data) => {
logger.info({
message: 'Starting Login (button press)',
context: { username: data.username },
});
logger.info({ message: 'Starting Login (button press)', context: { username: data.username } });
await login({ username: data.username, password: data.password });
};

return (
<>
<FocusAwareStatusBar />
<LoginForm onSubmit={onSubmit} isLoading={status === 'loading'} error={error ?? undefined} onServerUrlPress={() => setShowServerUrl(true)} />
<LoginForm

Check warning on line 57 in src/app/login/index.tsx

View workflow job for this annotation

GitHub Actions / test

Replace `⏎········onSubmit={onSubmit}⏎········isLoading={status·===·'loading'}⏎········error={error·??·undefined}⏎········onServerUrlPress={()·=>·setShowServerUrl(true)}⏎········onSsoPress={()·=>·router.push('/login/sso')}⏎·····` with `·onSubmit={onSubmit}·isLoading={status·===·'loading'}·error={error·??·undefined}·onServerUrlPress={()·=>·setShowServerUrl(true)}·onSsoPress={()·=>·router.push('/login/sso')}`

Check warning on line 57 in src/app/login/index.tsx

View workflow job for this annotation

GitHub Actions / test

Replace `⏎········onSubmit={onSubmit}⏎········isLoading={status·===·'loading'}⏎········error={error·??·undefined}⏎········onServerUrlPress={()·=>·setShowServerUrl(true)}⏎········onSsoPress={()·=>·router.push('/login/sso')}⏎·····` with `·onSubmit={onSubmit}·isLoading={status·===·'loading'}·error={error·??·undefined}·onServerUrlPress={()·=>·setShowServerUrl(true)}·onSsoPress={()·=>·router.push('/login/sso')}`
onSubmit={onSubmit}
isLoading={status === 'loading'}
error={error ?? undefined}
onServerUrlPress={() => setShowServerUrl(true)}
onSsoPress={() => router.push('/login/sso')}
/>

<Modal
isOpen={isErrorModalVisible}
Expand Down
66 changes: 39 additions & 27 deletions src/app/login/login-form.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { AlertTriangle, EyeIcon, EyeOffIcon } from 'lucide-react-native';
import { AlertTriangle, EyeIcon, EyeOffIcon, LogIn, ShieldCheck } from 'lucide-react-native';
import { useColorScheme } from 'nativewind';
import React, { useState } from 'react';
import type { SubmitHandler } from 'react-hook-form';
Expand Down Expand Up @@ -28,7 +28,7 @@
.string({
required_error: 'Password is required',
})
.min(6, 'Password must be at least 6 characters'),
.min(1, 'Password is required'),
});

const loginFormSchema = createLoginFormSchema();
Expand All @@ -40,14 +40,17 @@
isLoading?: boolean;
error?: string;
onServerUrlPress?: () => void;
/** Called when the user taps "Sign In with SSO" to navigate to the SSO login page */
onSsoPress?: () => void;
};

export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = undefined, onServerUrlPress }: LoginFormProps) => {
export const LoginForm = ({ onSubmit = () => { }, isLoading = false, error = undefined, onServerUrlPress, onSsoPress }: LoginFormProps) => {

Check warning on line 47 in src/app/login/login-form.tsx

View workflow job for this annotation

GitHub Actions / test

Delete `·`

Check warning on line 47 in src/app/login/login-form.tsx

View workflow job for this annotation

GitHub Actions / test

Delete `·`
const { colorScheme } = useColorScheme();
const { t } = useTranslation();
const {
control,
handleSubmit,
getValues,
formState: { errors },
} = useForm<FormType>({
resolver: zodResolver(loginFormSchema),
Expand All @@ -60,9 +63,7 @@
const [showPassword, setShowPassword] = useState(false);

const handleState = () => {
setShowPassword((showState) => {
return !showState;
});
setShowPassword((showState) => !showState);
};
const handleKeyPress = () => {
Keyboard.dismiss();
Expand All @@ -74,12 +75,11 @@
<View className="flex-1 justify-center p-4">
<View className="items-center justify-center">
<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-6 text-center text-4xl font-bold">Sign In</Text>

<Text className="mb-6 max-w-xl text-center text-gray-500">
To login in to the Resgrid Unit app, please enter your username and password. Resgrid Unit is an app designed to interface between a Unit (apparatus, team, etc) and the Resgrid system.
</Text>
<Text className="pb-6 text-center text-4xl font-bold">{t('login.title')}</Text>
<Text className="mb-6 max-w-xl text-center text-gray-500">{t('login.subtitle')}</Text>
</View>

{/* Username */}
<FormControl isInvalid={!!errors?.username || !validated.usernameValid} className="w-full">
<FormControlLabel>
<FormControlLabelText>{t('login.username')}</FormControlLabelText>
Expand All @@ -91,10 +91,10 @@
rules={{
validate: async (value) => {
try {
await loginFormSchema.parseAsync({ username: value });
await loginFormSchema.parseAsync({ username: value, password: 'placeholder' });
return true;
} catch (error: any) {
return error.message;
} catch (err: any) {
return err.message;
}
},
}}
Expand All @@ -106,7 +106,7 @@
onChangeText={onChange}
onBlur={onBlur}
onSubmitEditing={handleKeyPress}
returnKeyType="done"
returnKeyType="next"
autoCapitalize="none"
autoComplete="off"
/>
Expand All @@ -115,10 +115,11 @@
/>
<FormControlError>
<FormControlErrorIcon as={AlertTriangle} className="text-red-500" />
<FormControlErrorText className="text-red-500">{errors?.username?.message || (!validated.usernameValid && 'Username not found')}</FormControlErrorText>
<FormControlErrorText className="text-red-500">{errors?.username?.message}</FormControlErrorText>
</FormControlError>
</FormControl>
{/* Label Message */}

{/* Password form */}
<FormControl isInvalid={!!errors.password || !validated.passwordValid} className="w-full">
<FormControlLabel>
<FormControlLabelText>{t('login.password')}</FormControlLabelText>
Expand All @@ -130,10 +131,10 @@
rules={{
validate: async (value) => {
try {
await loginFormSchema.parseAsync({ password: value });
await loginFormSchema.parseAsync({ username: getValues('username'), password: value });
return true;
} catch (error: any) {
return error.message;
} catch (err: any) {
return err.message;
}
},
}}
Expand Down Expand Up @@ -168,16 +169,27 @@
<ButtonText className="ml-2 text-sm font-medium">{t('login.login_button_loading')}</ButtonText>
</Button>
) : (
<Button className="mt-8 w-full" variant="solid" action="primary" onPress={handleSubmit(onSubmit)}>
<ButtonText>Log in</ButtonText>
<Button className="mt-8 w-full" variant="solid" action="primary" onPress={handleSubmit(onSubmit)} accessibilityLabel={t('login.login_button')}>
<ButtonText>{t('login.login_button')}</ButtonText>
</Button>
)}

{onServerUrlPress && (
<Button className="mt-14 w-full" variant="outline" action="secondary" onPress={onServerUrlPress}>
<ButtonText>{t('settings.server_url')}</ButtonText>
</Button>
)}
{error ? <Text className="mt-4 text-center text-sm text-red-500">{error}</Text> : null}

{/* Server URL + Sign In with SSO — side by side small buttons */}
<View className="mt-6 flex-row gap-x-2">
{onServerUrlPress ? (
<Button className="flex-1" variant="outline" action="secondary" size="sm" onPress={onServerUrlPress}>
<ButtonText className="text-xs">{t('settings.server_url')}</ButtonText>
</Button>
) : null}
{onSsoPress ? (
<Button className="flex-1" variant="outline" action="secondary" size="sm" onPress={onSsoPress}>
<ShieldCheck size={14} style={{ marginRight: 4 }} />
<ButtonText className="text-xs">{t('login.sso_button')}</ButtonText>
</Button>
) : null}
</View>
</View>
</KeyboardAvoidingView>
);
Expand Down
Loading
Loading