Skip to content

Commit d72d9ed

Browse files
authored
Merge pull request #109 from Resgrid/develop
Develop
2 parents 90c1778 + 11f18e4 commit d72d9ed

39 files changed

Lines changed: 1669 additions & 137 deletions

app.config.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
2626
name: Env.NAME,
2727
description: `${Env.NAME} Resgrid Responder`,
2828
owner: Env.EXPO_ACCOUNT_OWNER,
29-
scheme: Env.SCHEME,
29+
scheme: [Env.SCHEME, 'resgrid'],
3030
slug: 'resgrid-responder',
3131
version: Env.VERSION.toString(),
3232
orientation: 'default',
@@ -55,6 +55,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
5555
UIViewControllerBasedStatusBarAppearance: false,
5656
NSBluetoothAlwaysUsageDescription: 'Allow Resgrid Responder to connect to bluetooth devices for PTT.',
5757
NSMicrophoneUsageDescription: 'Allow Resgrid Responder to access the microphone for voice communication and push-to-talk functionality during emergency response.',
58+
LSApplicationQueriesSchemes: ['resgrid'],
5859
},
5960
entitlements: {
6061
...((Env.APP_ENV === 'production' || Env.APP_ENV === 'internal') && {
@@ -76,6 +77,13 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
7677
softwareKeyboardLayoutMode: 'pan',
7778
package: Env.PACKAGE,
7879
googleServicesFile: 'google-services.json',
80+
intentFilters: [
81+
{
82+
action: 'VIEW',
83+
data: [{ scheme: 'resgrid' }],
84+
category: ['BROWSABLE', 'DEFAULT'],
85+
},
86+
],
7987
permissions: [
8088
'android.permission.WAKE_LOCK',
8189
'android.permission.RECORD_AUDIO',
@@ -273,6 +281,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
273281
],
274282
'react-native-ble-manager',
275283
'expo-secure-store',
284+
'expo-web-browser',
276285
'@livekit/react-native-expo-plugin',
277286
'@config-plugins/react-native-webrtc',
278287
'@config-plugins/react-native-callkeep',

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,9 +101,11 @@
101101
"expo-application": "~6.1.5",
102102
"expo-asset": "~11.1.7",
103103
"expo-audio": "~0.4.9",
104+
"expo-auth-session": "~6.2.1",
104105
"expo-av": "~15.1.7",
105106
"expo-build-properties": "~0.14.8",
106107
"expo-constants": "~17.1.7",
108+
"expo-crypto": "~14.1.5",
107109
"expo-dev-client": "~5.2.4",
108110
"expo-device": "~7.1.4",
109111
"expo-document-picker": "~13.1.6",
@@ -126,6 +128,7 @@
126128
"expo-status-bar": "~2.2.3",
127129
"expo-system-ui": "~5.0.11",
128130
"expo-task-manager": "~13.1.6",
131+
"expo-web-browser": "~14.2.0",
129132
"geojson": "~0.5.0",
130133
"i18next": "~23.14.0",
131134
"livekit-client": "^2.15.7",

src/app/(app)/calendar.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { RefreshControl } from '@/components/ui/refresh-control';
1919
import { Text } from '@/components/ui/text';
2020
import { VStack } from '@/components/ui/vstack';
2121
import { useAnalytics } from '@/hooks/use-analytics';
22-
import { isSameDate } from '@/lib/utils';
22+
import { isDateInRange } from '@/lib/utils';
2323
import { type CalendarItemResultData } from '@/models/v4/calendar/calendarItemResultData';
2424
import { useCalendarStore } from '@/stores/calendar/store';
2525

@@ -110,8 +110,11 @@ export default function CalendarScreen() {
110110
if (!selectedDate) return [];
111111

112112
return selectedMonthItems.filter((item) => {
113-
// Use Start field for consistent date comparison with .NET backend timezone-aware dates
114-
return isSameDate(item.Start, selectedDate);
113+
// Always use range-based filtering so that:
114+
// 1. timed multi-day events (regardless of IsMultiDay flag) show on every covered day
115+
// 2. all-day events use the exclusive-end convention
116+
// 3. single-day events still work (start === end collapses to an exact match)
117+
return isDateInRange(selectedDate, item.Start, item.End, item.IsAllDay);
115118
});
116119
};
117120

src/app/_layout.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@ import { registerGlobals } from '@livekit/react-native';
1010
import { createNavigationContainerRef, DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
1111
import * as Sentry from '@sentry/react-native';
1212
import { isRunningInExpoGo } from 'expo';
13+
import * as Linking from 'expo-linking';
1314
import * as Notifications from 'expo-notifications';
1415
import { Stack, useNavigationContainerRef } from 'expo-router';
1516
import * as SplashScreen from 'expo-splash-screen';
17+
import * as WebBrowser from 'expo-web-browser';
1618
import React, { useEffect } from 'react';
1719
import { LogBox, useColorScheme } from 'react-native';
1820
import { GestureHandlerRootView } from 'react-native-gesture-handler';
@@ -24,6 +26,7 @@ import { LiveKitBottomSheet } from '@/components/livekit';
2426
import { PushNotificationModal } from '@/components/push-notification/push-notification-modal';
2527
import { ToastContainer } from '@/components/toast/toast-container';
2628
import { GluestackUIProvider } from '@/components/ui/gluestack-ui-provider';
29+
import { handleSamlCallbackUrl } from '@/hooks/use-saml-login';
2730
import { hydrateAuth, useAuth } from '@/lib/auth';
2831
import { loadKeepAliveState } from '@/lib/hooks/use-keep-alive';
2932
import { loadSelectedTheme } from '@/lib/hooks/use-selected-theme';
@@ -33,6 +36,9 @@ import { loadBackgroundGeolocationState } from '@/lib/storage/background-geoloca
3336
import { uuidv4 } from '@/lib/utils';
3437
import { appInitializationService } from '@/services/app-initialization.service';
3538

39+
// Ensure OIDC / OAuth in-app browser sessions complete properly on iOS
40+
WebBrowser.maybeCompleteAuthSession();
41+
3642
// Prevent the splash screen from auto-hiding before asset loading is complete.
3743
//SplashScreen.preventAutoHideAsync();
3844

@@ -106,6 +112,20 @@ function RootLayout() {
106112

107113
hydrateAuth();
108114

115+
// Handle SAML deep-link callbacks (cold start)
116+
Linking.getInitialURL().then((url) => {
117+
if (url && url.includes('saml_response')) {
118+
handleSamlCallbackUrl(url).catch((err: unknown) => logger.error({ message: 'SAML cold-start deep-link handler failed', context: { err } }));
119+
}
120+
});
121+
122+
// Handle SAML deep-link callbacks (warm start)
123+
const samlSubscription = Linking.addEventListener('url', ({ url }) => {
124+
if (url.includes('saml_response')) {
125+
handleSamlCallbackUrl(url).catch((err: unknown) => logger.error({ message: 'SAML warm-start deep-link handler failed', context: { err } }));
126+
}
127+
});
128+
109129
// Clear the badge count on app startup
110130
Notifications.setBadgeCountAsync(0)
111131
.then(() => {
@@ -162,6 +182,10 @@ function RootLayout() {
162182
context: { error },
163183
});
164184
});
185+
186+
return () => {
187+
samlSubscription.remove();
188+
};
165189
}, [ref]);
166190

167191
return (
@@ -171,6 +195,7 @@ function RootLayout() {
171195
<Stack.Screen name="call" options={{ headerShown: false }} />
172196
<Stack.Screen name="onboarding" options={{ headerShown: false }} />
173197
<Stack.Screen name="login/index" options={{ headerShown: false }} />
198+
<Stack.Screen name="login/sso" options={{ headerShown: true }} />
174199
</Stack>
175200
</Providers>
176201
);

src/app/login/__tests__/login-form.test.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,7 @@ describe('LoginForm Server URL Integration', () => {
215215

216216
// Check that the form renders properly - there should be multiple text elements
217217
expect(screen.getAllByTestId('text').length).toBeGreaterThan(0);
218-
expect(screen.getAllByTestId('button')).toHaveLength(2);
218+
expect(screen.getAllByTestId('button')).toHaveLength(3);
219219
});
220220

221221
it('should track analytics and show bottom sheet when server URL button is pressed', () => {
@@ -245,7 +245,7 @@ describe('LoginForm Server URL Integration', () => {
245245

246246
// Check that the loading button is rendered with spinner
247247
expect(screen.getByTestId('button-spinner')).toBeTruthy();
248-
expect(screen.getAllByTestId('button')).toHaveLength(2);
248+
expect(screen.getAllByTestId('button')).toHaveLength(3);
249249
});
250250

251251
it('should render with different prop combinations', () => {
@@ -257,7 +257,7 @@ describe('LoginForm Server URL Integration', () => {
257257
);
258258

259259
// Check basic rendering
260-
expect(screen.getAllByTestId('button')).toHaveLength(2);
260+
expect(screen.getAllByTestId('button')).toHaveLength(3);
261261

262262
// Test with error
263263
rerender(
@@ -268,6 +268,6 @@ describe('LoginForm Server URL Integration', () => {
268268
);
269269

270270
// Should still render the basic structure
271-
expect(screen.getAllByTestId('button')).toHaveLength(2);
271+
expect(screen.getAllByTestId('button')).toHaveLength(3);
272272
});
273273
});

src/app/login/index.tsx

Lines changed: 11 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -32,32 +32,22 @@ export default function Login() {
3232
}, [trackEvent])
3333
);
3434

35+
// Handle successful authenticated state → navigate to app
3536
useEffect(() => {
3637
if (status === 'signedIn' && isAuthenticated) {
37-
logger.info({
38-
message: 'Login successful, redirecting to home',
39-
});
40-
41-
// Track successful login
42-
trackEvent('login_success', {
43-
timestamp: new Date().toISOString(),
44-
});
45-
38+
logger.info({ message: 'Login successful, redirecting to home' });
39+
trackEvent('login_success', { timestamp: new Date().toISOString() });
4640
router.push('/(app)');
4741
}
4842
}, [status, isAuthenticated, router, trackEvent]);
4943

44+
// Show error modal on login failure
5045
useEffect(() => {
5146
if (status === 'error') {
52-
logger.error({
53-
message: 'Login failed',
54-
context: { error },
55-
});
47+
logger.error({ message: 'Login failed', context: { error } });
5648

57-
// Safe analytics: classify and truncate error before tracking
5849
try {
5950
const timestamp = new Date().toISOString();
60-
// Treat error as string and classify based on content
6151
const rawMessage = error ?? '';
6252
let errorCode = 'unknown_error';
6353
if (rawMessage.includes('TypeError')) {
@@ -67,31 +57,27 @@ export default function Login() {
6757
} else if (rawMessage.toLowerCase().includes('auth')) {
6858
errorCode = 'auth_error';
6959
}
70-
// Truncate message to 100 chars
71-
const message = rawMessage.slice(0, 100);
7260
trackEvent('login_failed', {
7361
timestamp,
7462
errorCode,
7563
category: 'login_error',
76-
message,
64+
message: rawMessage.slice(0, 100),
7765
});
7866
} catch {
79-
// Swallow analytics errors, log non-sensitive warning
8067
logger.warn({ message: 'Failed to track login_failed event' });
8168
}
8269

8370
setIsErrorModalVisible(true);
8471
}
8572
}, [status, error, trackEvent]);
8673

87-
const onSubmit: LoginFormProps['onSubmit'] = async (data) => {
74+
const onLocalLoginSubmit: LoginFormProps['onSubmit'] = async (data) => {
8875
const usernameHash = data.username ? CryptoJS.HmacSHA256(data.username, Env.LOGGING_KEY || '').toString() : null;
8976
logger.info({
9077
message: 'Starting Login (button press)',
9178
context: { hasUsername: Boolean(data.username), usernameHash },
9279
});
9380

94-
// Track login attempt
9581
try {
9682
trackEvent('login_attempted', {
9783
timestamp: new Date().toISOString(),
@@ -112,15 +98,11 @@ export default function Login() {
11298
return (
11399
<>
114100
<FocusAwareStatusBar />
115-
<LoginForm onSubmit={onSubmit} isLoading={status === 'loading'} {...(error ? { error } : {})} />
116101

117-
<Modal
118-
isOpen={isErrorModalVisible}
119-
onClose={() => {
120-
setIsErrorModalVisible(false);
121-
}}
122-
size="full"
123-
>
102+
<LoginForm onSubmit={onLocalLoginSubmit} isLoading={status === 'loading'} onSsoPress={() => router.push('/login/sso')} {...(error ? { error } : {})} />
103+
104+
{/* Error modal */}
105+
<Modal isOpen={isErrorModalVisible} onClose={() => setIsErrorModalVisible(false)} size="full">
124106
<ModalBackdrop />
125107
<ModalContent className="m-4 w-full max-w-3xl rounded-2xl">
126108
<ModalHeader>

src/app/login/login-form.tsx

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,10 @@ export type LoginFormProps = {
3737
onSubmit?: SubmitHandler<FormType>;
3838
isLoading?: boolean;
3939
error?: string;
40+
onSsoPress?: () => void;
4041
};
4142

42-
export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = undefined }: LoginFormProps) => {
43+
export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = undefined, onSsoPress }: LoginFormProps) => {
4344
const { colorScheme } = useColorScheme();
4445
const { t } = useTranslation();
4546
const { trackEvent } = useAnalytics();
@@ -158,10 +159,15 @@ export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = unde
158159
</Button>
159160
)}
160161

161-
{/* Server URL Change Button */}
162-
<Button className="mt-14 w-full" variant="outline" onPress={handleServerUrlPress}>
163-
<ButtonText className="text-sm">{t('login.change_server_url')}</ButtonText>
164-
</Button>
162+
{/* Server URL and SSO Buttons */}
163+
<View className="mt-14 w-full flex-row gap-2">
164+
<Button className="flex-1" variant="outline" onPress={handleServerUrlPress}>
165+
<ButtonText className="text-xs">{t('login.change_server_url')}</ButtonText>
166+
</Button>
167+
<Button className="flex-1" variant="outline" onPress={onSsoPress}>
168+
<ButtonText className="text-xs">{t('login.sso.login_with_sso_button')}</ButtonText>
169+
</Button>
170+
</View>
165171
</View>
166172
</ScrollView>
167173

0 commit comments

Comments
 (0)