Skip to content

Commit a063f07

Browse files
authored
Merge pull request #231 from Resgrid/develop
RU-T48 Adding SSO login support
2 parents cf1956a + ff80618 commit a063f07

File tree

27 files changed

+1305
-86
lines changed

27 files changed

+1305
-86
lines changed

__mocks__/expo-auth-session.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Mock for expo-auth-session
2+
const mockExchangeCodeAsync = jest.fn();
3+
const mockMakeRedirectUri = jest.fn(() => 'resgridunit://auth/callback');
4+
const mockUseAutoDiscovery = jest.fn(() => ({
5+
authorizationEndpoint: 'https://idp.example.com/authorize',
6+
tokenEndpoint: 'https://idp.example.com/token',
7+
}));
8+
const mockUseAuthRequest = jest.fn(() => [
9+
{ codeVerifier: 'test-verifier' },
10+
null,
11+
jest.fn(),
12+
]);
13+
14+
module.exports = {
15+
makeRedirectUri: mockMakeRedirectUri,
16+
useAutoDiscovery: mockUseAutoDiscovery,
17+
useAuthRequest: mockUseAuthRequest,
18+
exchangeCodeAsync: mockExchangeCodeAsync,
19+
ResponseType: { Code: 'code' },
20+
__esModule: true,
21+
};

__mocks__/expo-web-browser.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// Mock for expo-web-browser
2+
const maybeCompleteAuthSession = jest.fn(() => ({ type: 'success' }));
3+
const openBrowserAsync = jest.fn(() => Promise.resolve({ type: 'dismiss' }));
4+
const openAuthSessionAsync = jest.fn(() => Promise.resolve({ type: 'dismiss' }));
5+
const dismissBrowser = jest.fn();
6+
7+
module.exports = {
8+
maybeCompleteAuthSession,
9+
openBrowserAsync,
10+
openAuthSessionAsync,
11+
dismissBrowser,
12+
__esModule: true,
13+
};

app.config.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
5050
ITSAppUsesNonExemptEncryption: false,
5151
UIViewControllerBasedStatusBarAppearance: false,
5252
NSBluetoothAlwaysUsageDescription: 'Allow Resgrid Unit to connect to bluetooth devices for PTT.',
53+
// Allow the app to open its own custom-scheme deep links (needed for SSO callbacks)
54+
LSApplicationQueriesSchemes: ['resgridunit'],
5355
},
5456
entitlements: {
5557
...((Env.APP_ENV === 'production' || Env.APP_ENV === 'internal') && {
@@ -71,6 +73,15 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
7173
softwareKeyboardLayoutMode: 'pan',
7274
package: Env.PACKAGE,
7375
googleServicesFile: 'google-services.json',
76+
// Register the ResgridUnit:// deep-link scheme so OIDC / SAML callbacks are routed back here
77+
intentFilters: [
78+
{
79+
action: 'VIEW',
80+
autoVerify: false,
81+
data: [{ scheme: 'resgridunit' }],
82+
category: ['BROWSABLE', 'DEFAULT'],
83+
},
84+
],
7485
permissions: [
7586
'android.permission.WAKE_LOCK',
7687
'android.permission.RECORD_AUDIO',
@@ -107,6 +118,8 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
107118
'expo-localization',
108119
'expo-router',
109120
['react-native-edge-to-edge'],
121+
'expo-web-browser',
122+
'expo-secure-store',
110123
[
111124
'@rnmapbox/maps',
112125
{

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,9 +116,11 @@
116116
"expo-application": "~6.1.5",
117117
"expo-asset": "~11.1.7",
118118
"expo-audio": "~0.4.9",
119+
"expo-auth-session": "~6.2.1",
119120
"expo-av": "~15.1.7",
120121
"expo-build-properties": "~0.14.8",
121122
"expo-constants": "~17.1.8",
123+
"expo-crypto": "~14.1.5",
122124
"expo-dev-client": "~5.2.4",
123125
"expo-device": "~7.1.4",
124126
"expo-document-picker": "~13.1.6",
@@ -134,11 +136,13 @@
134136
"expo-navigation-bar": "~4.2.8",
135137
"expo-router": "~5.1.11",
136138
"expo-screen-orientation": "~8.1.7",
139+
"expo-secure-store": "~14.2.4",
137140
"expo-sharing": "~13.1.5",
138141
"expo-splash-screen": "~0.30.10",
139142
"expo-status-bar": "~2.2.3",
140143
"expo-system-ui": "~5.0.11",
141144
"expo-task-manager": "~13.1.6",
145+
"expo-web-browser": "~14.2.0",
142146
"geojson": "~0.5.0",
143147
"i18next": "~23.14.0",
144148
"livekit-client": "^2.15.7",

src/app/login/__tests__/index.test.tsx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,37 @@ jest.mock('expo-router', () => ({
1313
}),
1414
}));
1515

16+
// Mock expo-linking (used for SAML deep-link handling)
17+
jest.mock('expo-linking', () => ({
18+
addEventListener: jest.fn(() => ({ remove: jest.fn() })),
19+
parse: jest.fn(() => ({ queryParams: {} })),
20+
}));
21+
22+
// Mock SSO discovery service
23+
jest.mock('@/services/sso-discovery', () => ({
24+
fetchSsoConfigForUser: jest.fn(() => Promise.resolve({ config: null, userExists: false })),
25+
}));
26+
27+
// Mock OIDC hook
28+
jest.mock('@/hooks/use-oidc-login', () => ({
29+
useOidcLogin: jest.fn(() => ({
30+
request: null,
31+
response: null,
32+
promptAsync: jest.fn(),
33+
exchangeForResgridToken: jest.fn(),
34+
discovery: null,
35+
})),
36+
}));
37+
38+
// Mock SAML hook
39+
jest.mock('@/hooks/use-saml-login', () => ({
40+
useSamlLogin: jest.fn(() => ({
41+
startSamlLogin: jest.fn(),
42+
handleDeepLink: jest.fn(),
43+
isSamlCallback: jest.fn(() => false),
44+
})),
45+
}));
46+
1647
// Mock UI components
1748
jest.mock('@/components/ui', () => {
1849
const React = require('react');
@@ -131,6 +162,7 @@ describe('Login', () => {
131162
// Set default mock return values
132163
mockUseAuth.mockReturnValue({
133164
login: jest.fn(),
165+
ssoLogin: jest.fn(),
134166
status: 'idle',
135167
error: null,
136168
isAuthenticated: false,
@@ -182,6 +214,7 @@ describe('Login', () => {
182214
it('shows error modal when status is error', () => {
183215
mockUseAuth.mockReturnValue({
184216
login: jest.fn(),
217+
ssoLogin: jest.fn(),
185218
status: 'error',
186219
error: 'Invalid credentials',
187220
isAuthenticated: false,
@@ -196,6 +229,7 @@ describe('Login', () => {
196229
it('redirects to app when authenticated', async () => {
197230
mockUseAuth.mockReturnValue({
198231
login: jest.fn(),
232+
ssoLogin: jest.fn(),
199233
status: 'signedIn',
200234
error: null,
201235
isAuthenticated: true,
@@ -226,6 +260,7 @@ describe('Login', () => {
226260
const mockLogin = jest.fn();
227261
mockUseAuth.mockReturnValue({
228262
login: mockLogin,
263+
ssoLogin: jest.fn(),
229264
status: 'idle',
230265
error: null,
231266
isAuthenticated: false,

src/app/login/index.tsx

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { LoginForm } from './login-form';
1717
export default function Login() {
1818
const [isErrorModalVisible, setIsErrorModalVisible] = useState(false);
1919
const [showServerUrl, setShowServerUrl] = useState(false);
20+
2021
const { t } = useTranslation();
2122
const { trackEvent } = useAnalytics();
2223
const router = useRouter();
@@ -32,35 +33,34 @@ export default function Login() {
3233

3334
useEffect(() => {
3435
if (status === 'signedIn' && isAuthenticated) {
35-
logger.info({
36-
message: 'Login successful, redirecting to home',
37-
});
36+
logger.info({ message: 'Login successful, redirecting to home' });
3837
router.push('/(app)');
3938
}
4039
}, [status, isAuthenticated, router]);
4140

4241
useEffect(() => {
4342
if (status === 'error') {
44-
logger.error({
45-
message: 'Login failed',
46-
context: { error },
47-
});
43+
logger.error({ message: 'Login failed', context: { error } });
4844
setIsErrorModalVisible(true);
4945
}
5046
}, [status, error]);
5147

48+
// ── Local login ───────────────────────────────────────────────────────────
5249
const onSubmit: LoginFormProps['onSubmit'] = async (data) => {
53-
logger.info({
54-
message: 'Starting Login (button press)',
55-
context: { username: data.username },
56-
});
50+
logger.info({ message: 'Starting Login (button press)' });
5751
await login({ username: data.username, password: data.password });
5852
};
5953

6054
return (
6155
<>
6256
<FocusAwareStatusBar />
63-
<LoginForm onSubmit={onSubmit} isLoading={status === 'loading'} error={error ?? undefined} onServerUrlPress={() => setShowServerUrl(true)} />
57+
<LoginForm
58+
onSubmit={onSubmit}
59+
isLoading={status === 'loading'}
60+
error={error ?? undefined}
61+
onServerUrlPress={() => setShowServerUrl(true)}
62+
onSsoPress={() => router.push('/login/sso')}
63+
/>
6464

6565
<Modal
6666
isOpen={isErrorModalVisible}

src/app/login/login-form.tsx

Lines changed: 39 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { zodResolver } from '@hookform/resolvers/zod';
2-
import { AlertTriangle, EyeIcon, EyeOffIcon } from 'lucide-react-native';
2+
import { AlertTriangle, EyeIcon, EyeOffIcon, LogIn, ShieldCheck } from 'lucide-react-native';
33
import { useColorScheme } from 'nativewind';
44
import React, { useState } from 'react';
55
import type { SubmitHandler } from 'react-hook-form';
@@ -28,7 +28,7 @@ const createLoginFormSchema = () =>
2828
.string({
2929
required_error: 'Password is required',
3030
})
31-
.min(6, 'Password must be at least 6 characters'),
31+
.min(1, 'Password is required'),
3232
});
3333

3434
const loginFormSchema = createLoginFormSchema();
@@ -40,14 +40,17 @@ export type LoginFormProps = {
4040
isLoading?: boolean;
4141
error?: string;
4242
onServerUrlPress?: () => void;
43+
/** Called when the user taps "Sign In with SSO" to navigate to the SSO login page */
44+
onSsoPress?: () => void;
4345
};
4446

45-
export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = undefined, onServerUrlPress }: LoginFormProps) => {
47+
export const LoginForm = ({ onSubmit = () => { }, isLoading = false, error = undefined, onServerUrlPress, onSsoPress }: LoginFormProps) => {
4648
const { colorScheme } = useColorScheme();
4749
const { t } = useTranslation();
4850
const {
4951
control,
5052
handleSubmit,
53+
getValues,
5154
formState: { errors },
5255
} = useForm<FormType>({
5356
resolver: zodResolver(loginFormSchema),
@@ -60,9 +63,7 @@ export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = unde
6063
const [showPassword, setShowPassword] = useState(false);
6164

6265
const handleState = () => {
63-
setShowPassword((showState) => {
64-
return !showState;
65-
});
66+
setShowPassword((showState) => !showState);
6667
};
6768
const handleKeyPress = () => {
6869
Keyboard.dismiss();
@@ -74,12 +75,11 @@ export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = unde
7475
<View className="flex-1 justify-center p-4">
7576
<View className="items-center justify-center">
7677
<Image style={{ width: '96%' }} source={colorScheme === 'dark' ? require('@assets/images/Resgrid_JustText_White.png') : require('@assets/images/Resgrid_JustText.png')} resizeMode="contain" />
77-
<Text className="pb-6 text-center text-4xl font-bold">Sign In</Text>
78-
79-
<Text className="mb-6 max-w-xl text-center text-gray-500">
80-
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.
81-
</Text>
78+
<Text className="pb-6 text-center text-4xl font-bold">{t('login.title')}</Text>
79+
<Text className="mb-6 max-w-xl text-center text-gray-500">{t('login.subtitle')}</Text>
8280
</View>
81+
82+
{/* Username */}
8383
<FormControl isInvalid={!!errors?.username || !validated.usernameValid} className="w-full">
8484
<FormControlLabel>
8585
<FormControlLabelText>{t('login.username')}</FormControlLabelText>
@@ -91,10 +91,10 @@ export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = unde
9191
rules={{
9292
validate: async (value) => {
9393
try {
94-
await loginFormSchema.parseAsync({ username: value });
94+
await loginFormSchema.parseAsync({ username: value, password: 'placeholder' });
9595
return true;
96-
} catch (error: any) {
97-
return error.message;
96+
} catch (err: any) {
97+
return err.message;
9898
}
9999
},
100100
}}
@@ -106,7 +106,7 @@ export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = unde
106106
onChangeText={onChange}
107107
onBlur={onBlur}
108108
onSubmitEditing={handleKeyPress}
109-
returnKeyType="done"
109+
returnKeyType="next"
110110
autoCapitalize="none"
111111
autoComplete="off"
112112
/>
@@ -115,10 +115,11 @@ export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = unde
115115
/>
116116
<FormControlError>
117117
<FormControlErrorIcon as={AlertTriangle} className="text-red-500" />
118-
<FormControlErrorText className="text-red-500">{errors?.username?.message || (!validated.usernameValid && 'Username not found')}</FormControlErrorText>
118+
<FormControlErrorText className="text-red-500">{errors?.username?.message}</FormControlErrorText>
119119
</FormControlError>
120120
</FormControl>
121-
{/* Label Message */}
121+
122+
{/* Password form */}
122123
<FormControl isInvalid={!!errors.password || !validated.passwordValid} className="w-full">
123124
<FormControlLabel>
124125
<FormControlLabelText>{t('login.password')}</FormControlLabelText>
@@ -130,10 +131,10 @@ export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = unde
130131
rules={{
131132
validate: async (value) => {
132133
try {
133-
await loginFormSchema.parseAsync({ password: value });
134+
await loginFormSchema.parseAsync({ username: getValues('username'), password: value });
134135
return true;
135-
} catch (error: any) {
136-
return error.message;
136+
} catch (err: any) {
137+
return err.message;
137138
}
138139
},
139140
}}
@@ -168,16 +169,27 @@ export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = unde
168169
<ButtonText className="ml-2 text-sm font-medium">{t('login.login_button_loading')}</ButtonText>
169170
</Button>
170171
) : (
171-
<Button className="mt-8 w-full" variant="solid" action="primary" onPress={handleSubmit(onSubmit)}>
172-
<ButtonText>Log in</ButtonText>
172+
<Button className="mt-8 w-full" variant="solid" action="primary" onPress={handleSubmit(onSubmit)} accessibilityLabel={t('login.login_button')}>
173+
<ButtonText>{t('login.login_button')}</ButtonText>
173174
</Button>
174175
)}
175176

176-
{onServerUrlPress && (
177-
<Button className="mt-14 w-full" variant="outline" action="secondary" onPress={onServerUrlPress}>
178-
<ButtonText>{t('settings.server_url')}</ButtonText>
179-
</Button>
180-
)}
177+
{error ? <Text className="mt-4 text-center text-sm text-red-500">{error}</Text> : null}
178+
179+
{/* Server URL + Sign In with SSO — side by side small buttons */}
180+
<View className="mt-6 flex-row gap-x-2">
181+
{onServerUrlPress ? (
182+
<Button className="flex-1" variant="outline" action="secondary" size="sm" onPress={onServerUrlPress}>
183+
<ButtonText className="text-xs">{t('settings.server_url')}</ButtonText>
184+
</Button>
185+
) : null}
186+
{onSsoPress ? (
187+
<Button className="flex-1" variant="outline" action="secondary" size="sm" onPress={onSsoPress}>
188+
<ShieldCheck size={14} style={{ marginRight: 4 }} />
189+
<ButtonText className="text-xs">{t('login.sso_button')}</ButtonText>
190+
</Button>
191+
) : null}
192+
</View>
181193
</View>
182194
</KeyboardAvoidingView>
183195
);

0 commit comments

Comments
 (0)