diff --git a/app.config.ts b/app.config.ts
index 148d795..806ba79 100644
--- a/app.config.ts
+++ b/app.config.ts
@@ -26,7 +26,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
name: Env.NAME,
description: `${Env.NAME} Resgrid Responder`,
owner: Env.EXPO_ACCOUNT_OWNER,
- scheme: Env.SCHEME,
+ scheme: [Env.SCHEME, 'resgrid'],
slug: 'resgrid-responder',
version: Env.VERSION.toString(),
orientation: 'default',
@@ -55,6 +55,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
UIViewControllerBasedStatusBarAppearance: false,
NSBluetoothAlwaysUsageDescription: 'Allow Resgrid Responder to connect to bluetooth devices for PTT.',
NSMicrophoneUsageDescription: 'Allow Resgrid Responder to access the microphone for voice communication and push-to-talk functionality during emergency response.',
+ LSApplicationQueriesSchemes: ['resgrid'],
},
entitlements: {
...((Env.APP_ENV === 'production' || Env.APP_ENV === 'internal') && {
@@ -76,6 +77,13 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
softwareKeyboardLayoutMode: 'pan',
package: Env.PACKAGE,
googleServicesFile: 'google-services.json',
+ intentFilters: [
+ {
+ action: 'VIEW',
+ data: [{ scheme: 'resgrid' }],
+ category: ['BROWSABLE', 'DEFAULT'],
+ },
+ ],
permissions: [
'android.permission.WAKE_LOCK',
'android.permission.RECORD_AUDIO',
@@ -273,6 +281,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
],
'react-native-ble-manager',
'expo-secure-store',
+ 'expo-web-browser',
'@livekit/react-native-expo-plugin',
'@config-plugins/react-native-webrtc',
'@config-plugins/react-native-callkeep',
diff --git a/package.json b/package.json
index a35ae8d..cf83226 100644
--- a/package.json
+++ b/package.json
@@ -101,9 +101,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.7",
+ "expo-crypto": "~14.1.5",
"expo-dev-client": "~5.2.4",
"expo-device": "~7.1.4",
"expo-document-picker": "~13.1.6",
@@ -126,6 +128,7 @@
"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",
diff --git a/src/app/(app)/calendar.tsx b/src/app/(app)/calendar.tsx
index 19a973d..b63b27d 100644
--- a/src/app/(app)/calendar.tsx
+++ b/src/app/(app)/calendar.tsx
@@ -19,7 +19,7 @@ import { RefreshControl } from '@/components/ui/refresh-control';
import { Text } from '@/components/ui/text';
import { VStack } from '@/components/ui/vstack';
import { useAnalytics } from '@/hooks/use-analytics';
-import { isSameDate } from '@/lib/utils';
+import { isDateInRange } from '@/lib/utils';
import { type CalendarItemResultData } from '@/models/v4/calendar/calendarItemResultData';
import { useCalendarStore } from '@/stores/calendar/store';
@@ -110,8 +110,11 @@ export default function CalendarScreen() {
if (!selectedDate) return [];
return selectedMonthItems.filter((item) => {
- // Use Start field for consistent date comparison with .NET backend timezone-aware dates
- return isSameDate(item.Start, selectedDate);
+ // Always use range-based filtering so that:
+ // 1. timed multi-day events (regardless of IsMultiDay flag) show on every covered day
+ // 2. all-day events use the exclusive-end convention
+ // 3. single-day events still work (start === end collapses to an exact match)
+ return isDateInRange(selectedDate, item.Start, item.End, item.IsAllDay);
});
};
diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx
index b8122ea..b411b25 100644
--- a/src/app/_layout.tsx
+++ b/src/app/_layout.tsx
@@ -10,9 +10,11 @@ import { registerGlobals } from '@livekit/react-native';
import { createNavigationContainerRef, DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
import * as Sentry from '@sentry/react-native';
import { isRunningInExpoGo } from 'expo';
+import * as Linking from 'expo-linking';
import * as Notifications from 'expo-notifications';
import { Stack, useNavigationContainerRef } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
+import * as WebBrowser from 'expo-web-browser';
import React, { useEffect } from 'react';
import { LogBox, useColorScheme } from 'react-native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
@@ -24,6 +26,7 @@ import { LiveKitBottomSheet } from '@/components/livekit';
import { PushNotificationModal } from '@/components/push-notification/push-notification-modal';
import { ToastContainer } from '@/components/toast/toast-container';
import { GluestackUIProvider } from '@/components/ui/gluestack-ui-provider';
+import { handleSamlCallbackUrl } from '@/hooks/use-saml-login';
import { hydrateAuth, useAuth } from '@/lib/auth';
import { loadKeepAliveState } from '@/lib/hooks/use-keep-alive';
import { loadSelectedTheme } from '@/lib/hooks/use-selected-theme';
@@ -33,6 +36,9 @@ import { loadBackgroundGeolocationState } from '@/lib/storage/background-geoloca
import { uuidv4 } from '@/lib/utils';
import { appInitializationService } from '@/services/app-initialization.service';
+// Ensure OIDC / OAuth in-app browser sessions complete properly on iOS
+WebBrowser.maybeCompleteAuthSession();
+
// Prevent the splash screen from auto-hiding before asset loading is complete.
//SplashScreen.preventAutoHideAsync();
@@ -106,6 +112,20 @@ function RootLayout() {
hydrateAuth();
+ // Handle SAML deep-link callbacks (cold start)
+ Linking.getInitialURL().then((url) => {
+ if (url && url.includes('saml_response')) {
+ handleSamlCallbackUrl(url).catch((err: unknown) => logger.error({ message: 'SAML cold-start deep-link handler failed', context: { err } }));
+ }
+ });
+
+ // Handle SAML deep-link callbacks (warm start)
+ const samlSubscription = Linking.addEventListener('url', ({ url }) => {
+ if (url.includes('saml_response')) {
+ handleSamlCallbackUrl(url).catch((err: unknown) => logger.error({ message: 'SAML warm-start deep-link handler failed', context: { err } }));
+ }
+ });
+
// Clear the badge count on app startup
Notifications.setBadgeCountAsync(0)
.then(() => {
@@ -162,6 +182,10 @@ function RootLayout() {
context: { error },
});
});
+
+ return () => {
+ samlSubscription.remove();
+ };
}, [ref]);
return (
@@ -171,6 +195,7 @@ function RootLayout() {
+
);
diff --git a/src/app/login/__tests__/login-form.test.tsx b/src/app/login/__tests__/login-form.test.tsx
index 29e6a13..7dc414b 100644
--- a/src/app/login/__tests__/login-form.test.tsx
+++ b/src/app/login/__tests__/login-form.test.tsx
@@ -215,7 +215,7 @@ describe('LoginForm Server URL Integration', () => {
// Check that the form renders properly - there should be multiple text elements
expect(screen.getAllByTestId('text').length).toBeGreaterThan(0);
- expect(screen.getAllByTestId('button')).toHaveLength(2);
+ expect(screen.getAllByTestId('button')).toHaveLength(3);
});
it('should track analytics and show bottom sheet when server URL button is pressed', () => {
@@ -245,7 +245,7 @@ describe('LoginForm Server URL Integration', () => {
// Check that the loading button is rendered with spinner
expect(screen.getByTestId('button-spinner')).toBeTruthy();
- expect(screen.getAllByTestId('button')).toHaveLength(2);
+ expect(screen.getAllByTestId('button')).toHaveLength(3);
});
it('should render with different prop combinations', () => {
@@ -257,7 +257,7 @@ describe('LoginForm Server URL Integration', () => {
);
// Check basic rendering
- expect(screen.getAllByTestId('button')).toHaveLength(2);
+ expect(screen.getAllByTestId('button')).toHaveLength(3);
// Test with error
rerender(
@@ -268,6 +268,6 @@ describe('LoginForm Server URL Integration', () => {
);
// Should still render the basic structure
- expect(screen.getAllByTestId('button')).toHaveLength(2);
+ expect(screen.getAllByTestId('button')).toHaveLength(3);
});
});
diff --git a/src/app/login/index.tsx b/src/app/login/index.tsx
index b67aff4..7dca89b 100644
--- a/src/app/login/index.tsx
+++ b/src/app/login/index.tsx
@@ -32,32 +32,22 @@ export default function Login() {
}, [trackEvent])
);
+ // Handle successful authenticated state → navigate to app
useEffect(() => {
if (status === 'signedIn' && isAuthenticated) {
- logger.info({
- message: 'Login successful, redirecting to home',
- });
-
- // Track successful login
- trackEvent('login_success', {
- timestamp: new Date().toISOString(),
- });
-
+ logger.info({ message: 'Login successful, redirecting to home' });
+ trackEvent('login_success', { timestamp: new Date().toISOString() });
router.push('/(app)');
}
}, [status, isAuthenticated, router, trackEvent]);
+ // Show error modal on login failure
useEffect(() => {
if (status === 'error') {
- logger.error({
- message: 'Login failed',
- context: { error },
- });
+ logger.error({ message: 'Login failed', context: { error } });
- // Safe analytics: classify and truncate error before tracking
try {
const timestamp = new Date().toISOString();
- // Treat error as string and classify based on content
const rawMessage = error ?? '';
let errorCode = 'unknown_error';
if (rawMessage.includes('TypeError')) {
@@ -67,16 +57,13 @@ export default function Login() {
} else if (rawMessage.toLowerCase().includes('auth')) {
errorCode = 'auth_error';
}
- // Truncate message to 100 chars
- const message = rawMessage.slice(0, 100);
trackEvent('login_failed', {
timestamp,
errorCode,
category: 'login_error',
- message,
+ message: rawMessage.slice(0, 100),
});
} catch {
- // Swallow analytics errors, log non-sensitive warning
logger.warn({ message: 'Failed to track login_failed event' });
}
@@ -84,14 +71,13 @@ export default function Login() {
}
}, [status, error, trackEvent]);
- const onSubmit: LoginFormProps['onSubmit'] = async (data) => {
+ const onLocalLoginSubmit: LoginFormProps['onSubmit'] = async (data) => {
const usernameHash = data.username ? CryptoJS.HmacSHA256(data.username, Env.LOGGING_KEY || '').toString() : null;
logger.info({
message: 'Starting Login (button press)',
context: { hasUsername: Boolean(data.username), usernameHash },
});
- // Track login attempt
try {
trackEvent('login_attempted', {
timestamp: new Date().toISOString(),
@@ -112,15 +98,11 @@ export default function Login() {
return (
<>
-
- {
- setIsErrorModalVisible(false);
- }}
- size="full"
- >
+ router.push('/login/sso')} {...(error ? { error } : {})} />
+
+ {/* Error modal */}
+ setIsErrorModalVisible(false)} size="full">
diff --git a/src/app/login/login-form.tsx b/src/app/login/login-form.tsx
index 3ce66de..c522a53 100644
--- a/src/app/login/login-form.tsx
+++ b/src/app/login/login-form.tsx
@@ -37,9 +37,10 @@ export type LoginFormProps = {
onSubmit?: SubmitHandler;
isLoading?: boolean;
error?: string;
+ onSsoPress?: () => void;
};
-export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = undefined }: LoginFormProps) => {
+export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = undefined, onSsoPress }: LoginFormProps) => {
const { colorScheme } = useColorScheme();
const { t } = useTranslation();
const { trackEvent } = useAnalytics();
@@ -158,10 +159,15 @@ export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = unde
)}
- {/* Server URL Change Button */}
-
+ {/* Server URL and SSO Buttons */}
+
+
+
+
diff --git a/src/app/login/sso-section.tsx b/src/app/login/sso-section.tsx
new file mode 100644
index 0000000..3c3a680
--- /dev/null
+++ b/src/app/login/sso-section.tsx
@@ -0,0 +1,228 @@
+import { AlertTriangle, ChevronLeft, LogIn } from 'lucide-react-native';
+import { useColorScheme } from 'nativewind';
+import React, { useCallback, useState } from 'react';
+import { Controller, useForm } from 'react-hook-form';
+import { useTranslation } from 'react-i18next';
+import { Image } from 'react-native';
+import { ScrollView } from 'react-native';
+import { KeyboardAvoidingView } from 'react-native-keyboard-controller';
+
+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';
+import { Input, InputField } from '@/components/ui/input';
+import { Text } from '@/components/ui/text';
+import colors from '@/constants/colors';
+import type { DepartmentSsoConfig } from '@/services/sso-discovery';
+
+export interface SsoDepartmentFormProps {
+ onSsoConfigResolved: (username: string, config: DepartmentSsoConfig) => void;
+ onLookupUser: (username: string, departmentId?: number) => Promise;
+ isLoading?: boolean;
+}
+
+interface DepartmentFormFields {
+ username: string;
+ departmentId: string;
+}
+
+/**
+ * Phase 1 of the SSO flow — collects the username and optional department ID,
+ * then resolves the SSO config via the username-first discovery endpoint.
+ */
+export const SsoDepartmentForm: React.FC = ({ onSsoConfigResolved, onLookupUser, isLoading = false }) => {
+ const { colorScheme } = useColorScheme();
+ const { t } = useTranslation();
+ const [lookupError, setLookupError] = useState(null);
+ const [localLoading, setLocalLoading] = useState(false);
+
+ const {
+ control,
+ handleSubmit,
+ formState: { errors },
+ } = useForm({ defaultValues: { username: '', departmentId: '' } });
+
+ const onSubmit = useCallback(
+ async (data: DepartmentFormFields) => {
+ setLookupError(null);
+ setLocalLoading(true);
+ try {
+ const trimmedUsername = data.username.trim();
+ const deptIdRaw = data.departmentId.trim();
+ const departmentId = deptIdRaw !== '' ? parseInt(deptIdRaw, 10) : undefined;
+ const config = await onLookupUser(trimmedUsername, departmentId);
+ if (!config) {
+ setLookupError(t('login.sso.user_not_found'));
+ } else if (!config.ssoEnabled) {
+ setLookupError(t('login.sso.sso_not_enabled'));
+ } else {
+ onSsoConfigResolved(trimmedUsername, config);
+ }
+ } catch {
+ setLookupError(t('login.sso.lookup_network_error'));
+ } finally {
+ setLocalLoading(false);
+ }
+ },
+ [onLookupUser, onSsoConfigResolved, t]
+ );
+
+ const busy = isLoading || localLoading;
+
+ return (
+
+
+
+
+
+ {t('login.title')}
+ {t('login.sso.user_description')}
+
+
+ {/* Username field */}
+
+
+ {t('login.sso.username_label')}
+
+ (
+
+
+
+ )}
+ />
+ {errors.username ? (
+
+
+ {errors.username.message}
+
+ ) : null}
+
+
+ {/* Optional department ID field */}
+
+
+ {t('login.sso.department_id_label')}
+
+ {
+ if (v.trim() === '') return true;
+ return /^\d+$/.test(v.trim()) || t('login.sso.department_id_invalid');
+ },
+ }}
+ render={({ field: { onChange, onBlur, value } }) => (
+
+
+
+ )}
+ />
+ {errors.departmentId ? (
+
+
+ {errors.departmentId.message}
+
+ ) : null}
+
+
+ {/* General lookup error */}
+ {lookupError ? (
+
+
+ {lookupError}
+
+ ) : null}
+
+ {busy ? (
+
+ ) : (
+
+ )}
+
+
+
+ );
+};
+
+// ---------------------------------------------------------------------------
+
+export interface SsoLoginButtonsProps {
+ departmentCode: string;
+ ssoConfig: DepartmentSsoConfig;
+ onOidcPress: () => void;
+ onSamlPress: () => void;
+ onChangeDepartment: () => void;
+ oidcRequestReady?: boolean;
+ isLoading?: boolean;
+}
+
+/**
+ * Renders the SSO provider buttons (OIDC / SAML) for Phase 2 of the login flow.
+ * Consumed by the login index so it can be placed above or below the local login form.
+ */
+export const SsoLoginButtons: React.FC = ({ departmentCode, ssoConfig, onOidcPress, onSamlPress, onChangeDepartment, oidcRequestReady = false, isLoading = false }) => {
+ const { t } = useTranslation();
+
+ const showOidc = ssoConfig.ssoEnabled && ssoConfig.providerType === 'oidc';
+ const showSaml = ssoConfig.ssoEnabled && ssoConfig.providerType === 'saml2';
+ const showDivider = ssoConfig.ssoEnabled && ssoConfig.allowLocalLogin;
+
+ if (!showOidc && !showSaml) return null;
+
+ return (
+
+ {departmentCode}
+
+ {showOidc ? (
+
+ ) : null}
+
+ {showSaml ? (
+
+ ) : null}
+
+ {showDivider ? {t('login.sso.or_sign_in_with_password')} : null}
+
+
+
+ );
+};
diff --git a/src/app/login/sso.tsx b/src/app/login/sso.tsx
new file mode 100644
index 0000000..a2c3e15
--- /dev/null
+++ b/src/app/login/sso.tsx
@@ -0,0 +1,157 @@
+import { Stack, useRouter } from 'expo-router';
+import React, { useCallback, useEffect, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { View } from 'react-native';
+
+import { SsoDepartmentForm, SsoLoginButtons } from '@/app/login/sso-section';
+import { FocusAwareStatusBar } from '@/components/ui';
+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 { useOidcLogin } from '@/hooks/use-oidc-login';
+import { useSamlLogin } from '@/hooks/use-saml-login';
+import { useAuth } from '@/lib/auth';
+import { logger } from '@/lib/logging';
+import type { DepartmentSsoConfig } from '@/services/sso-discovery';
+import { fetchUserSsoConfig } from '@/services/sso-discovery';
+
+type SsoPhase = 'department' | 'login';
+
+export default function SsoLogin() {
+ const { t } = useTranslation();
+ const router = useRouter();
+ const { status, error, isAuthenticated } = useAuth();
+ const { trackEvent } = useAnalytics();
+
+ const [ssoPhase, setSsoPhase] = useState('department');
+ const [username, setUsername] = useState('');
+ const [ssoConfig, setSsoConfig] = useState(null);
+ const [isErrorModalVisible, setIsErrorModalVisible] = useState(false);
+
+ // OIDC hook — called unconditionally; empty strings until config resolved
+ const oidc = useOidcLogin({
+ authority: ssoConfig?.authority ?? '',
+ clientId: ssoConfig?.clientId ?? '',
+ departmentCode: username,
+ });
+
+ // SAML hook
+ const saml = useSamlLogin({
+ idpSsoUrl: ssoConfig?.metadataUrl ?? ssoConfig?.authority ?? '',
+ departmentCode: username,
+ });
+
+ // Redirect to app on successful auth
+ useEffect(() => {
+ if (status === 'signedIn' && isAuthenticated) {
+ logger.info({ message: 'SSO login successful, redirecting to home' });
+ trackEvent('sso_login_success', { timestamp: new Date().toISOString() });
+ router.replace('/(app)');
+ }
+ }, [status, isAuthenticated, router, trackEvent]);
+
+ // Show error modal on auth failure
+ useEffect(() => {
+ if (status === 'error') {
+ logger.error({ message: 'SSO login failed', context: { error } });
+ trackEvent('sso_login_failed', {
+ timestamp: new Date().toISOString(),
+ message: (error ?? '').slice(0, 100),
+ });
+ setIsErrorModalVisible(true);
+ }
+ }, [status, error, trackEvent]);
+
+ // Watch OIDC response — exchange code for Resgrid token when authorisation completes
+ useEffect(() => {
+ if (oidc.response?.type === 'success') {
+ oidc.exchangeCodeForResgridToken().then((ok) => {
+ if (!ok) {
+ logger.error({ message: 'OIDC code exchange returned false' });
+ setIsErrorModalVisible(true);
+ }
+ });
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [oidc.response]);
+
+ // User / department lookup
+ const handleLookupUser = useCallback(async (user: string, departmentId?: number): Promise => fetchUserSsoConfig(user, departmentId), []);
+
+ const handleSsoConfigResolved = useCallback(
+ (user: string, config: DepartmentSsoConfig) => {
+ setUsername(user);
+ setSsoConfig(config);
+ setSsoPhase('login');
+ trackEvent('sso_user_resolved', {
+ timestamp: new Date().toISOString(),
+ hasSso: config.ssoEnabled,
+ providerType: config.providerType ?? 'none',
+ });
+ },
+ [trackEvent]
+ );
+
+ const handleChangeDepartment = useCallback(() => {
+ setSsoConfig(null);
+ setUsername('');
+ setSsoPhase('department');
+ }, []);
+
+ const ssoEnabled = ssoConfig?.ssoEnabled ?? false;
+
+ return (
+ <>
+
+
+
+ {ssoPhase === 'department' ? (
+
+ ) : ssoConfig !== null && ssoEnabled ? (
+ oidc.promptAsync()}
+ onSamlPress={() => saml.startSamlLogin()}
+ onChangeDepartment={handleChangeDepartment}
+ oidcRequestReady={!!oidc.request}
+ isLoading={status === 'loading'}
+ />
+ ) : (
+
+ {t('login.sso.sso_not_enabled')}
+
+
+
+ )}
+
+ {/* Error modal */}
+ setIsErrorModalVisible(false)} size="full">
+
+
+
+ {t('login.errorModal.title')}
+
+
+ {t('login.errorModal.message')}
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/components/calendar/__tests__/calendar-card.test.tsx b/src/components/calendar/__tests__/calendar-card.test.tsx
index 53b9a74..ff56e42 100644
--- a/src/components/calendar/__tests__/calendar-card.test.tsx
+++ b/src/components/calendar/__tests__/calendar-card.test.tsx
@@ -148,6 +148,7 @@ describe('CalendarCard', () => {
RecurrenceException: '',
ItemType: 1,
IsAllDay: false,
+ IsMultiDay: false,
Location: 'Test Location',
SignupType: 1,
Reminder: 0,
diff --git a/src/components/calendar/__tests__/calendar-item-details-sheet-analytics.test.tsx b/src/components/calendar/__tests__/calendar-item-details-sheet-analytics.test.tsx
index 14373f5..ba5fd66 100644
--- a/src/components/calendar/__tests__/calendar-item-details-sheet-analytics.test.tsx
+++ b/src/components/calendar/__tests__/calendar-item-details-sheet-analytics.test.tsx
@@ -179,6 +179,7 @@ describe('CalendarItemDetailsSheet Analytics', () => {
RecurrenceException: '',
ItemType: 1,
IsAllDay: false,
+ IsMultiDay: false,
Location: 'Test Location',
SignupType: 1,
Reminder: 0,
diff --git a/src/components/calendar/__tests__/calendar-item-details-sheet-minimal.test.tsx b/src/components/calendar/__tests__/calendar-item-details-sheet-minimal.test.tsx
index 2328cab..4277c21 100644
--- a/src/components/calendar/__tests__/calendar-item-details-sheet-minimal.test.tsx
+++ b/src/components/calendar/__tests__/calendar-item-details-sheet-minimal.test.tsx
@@ -115,6 +115,7 @@ describe('CalendarItemDetailsSheet - Analytics Only', () => {
RecurrenceException: '',
ItemType: 1,
IsAllDay: false,
+ IsMultiDay: false,
Location: 'Test Location',
SignupType: 1,
Reminder: 0,
diff --git a/src/components/calendar/__tests__/calendar-item-details-sheet.security.test.tsx b/src/components/calendar/__tests__/calendar-item-details-sheet.security.test.tsx
index 4bc6416..0fd48f8 100644
--- a/src/components/calendar/__tests__/calendar-item-details-sheet.security.test.tsx
+++ b/src/components/calendar/__tests__/calendar-item-details-sheet.security.test.tsx
@@ -145,6 +145,7 @@ const mockItem: CalendarItemResultData = {
ItemType: 1,
Location: 'Test Location',
IsAllDay: false,
+ IsMultiDay: false,
SignupType: 0,
Reminder: 0,
LockEditing: false,
diff --git a/src/components/calendar/__tests__/calendar-item-details-sheet.test.tsx b/src/components/calendar/__tests__/calendar-item-details-sheet.test.tsx
index 486a455..fd18ec6 100644
--- a/src/components/calendar/__tests__/calendar-item-details-sheet.test.tsx
+++ b/src/components/calendar/__tests__/calendar-item-details-sheet.test.tsx
@@ -167,6 +167,7 @@ describe('CalendarItemDetailsSheet', () => {
RecurrenceException: '',
ItemType: 1,
IsAllDay: false,
+ IsMultiDay: false,
Location: 'Test Location',
SignupType: 1,
Reminder: 0,
@@ -696,6 +697,7 @@ describe('CalendarItemDetailsSheet', () => {
RecurrenceException: '',
ItemType: 1,
IsAllDay: false,
+ IsMultiDay: false,
Location: '',
SignupType: 0,
Reminder: 0,
diff --git a/src/components/calendar/__tests__/compact-calendar-item.test.tsx b/src/components/calendar/__tests__/compact-calendar-item.test.tsx
index 146c58b..f7ef7e3 100644
--- a/src/components/calendar/__tests__/compact-calendar-item.test.tsx
+++ b/src/components/calendar/__tests__/compact-calendar-item.test.tsx
@@ -109,6 +109,7 @@ describe('CompactCalendarItem', () => {
RecurrenceException: '',
ItemType: 1,
IsAllDay: false,
+ IsMultiDay: false,
Location: 'Test Location',
SignupType: 1,
Reminder: 0,
diff --git a/src/components/calendar/__tests__/component-comparison.test.tsx b/src/components/calendar/__tests__/component-comparison.test.tsx
index 263f889..cb0d1a0 100644
--- a/src/components/calendar/__tests__/component-comparison.test.tsx
+++ b/src/components/calendar/__tests__/component-comparison.test.tsx
@@ -117,6 +117,7 @@ describe('Calendar Component Comparison', () => {
RecurrenceException: '',
ItemType: 1,
IsAllDay: false,
+ IsMultiDay: false,
Location: 'Test Location Address, City, State 12345',
SignupType: 1,
Reminder: 0,
diff --git a/src/components/calendar/calendar-card.tsx b/src/components/calendar/calendar-card.tsx
index 1bedaca..d5a2d6d 100644
--- a/src/components/calendar/calendar-card.tsx
+++ b/src/components/calendar/calendar-card.tsx
@@ -13,6 +13,7 @@ import { HStack } from '@/components/ui/hstack';
import { Pressable } from '@/components/ui/pressable';
import { Text } from '@/components/ui/text';
import { VStack } from '@/components/ui/vstack';
+import { extractDatePart, resolveAllDayEndDate } from '@/lib/utils';
import { type CalendarItemResultData } from '@/models/v4/calendar/calendarItemResultData';
import { defaultWebViewProps, generateWebViewHtml } from '@/utils/webview-html';
@@ -33,8 +34,18 @@ export const CalendarCard: React.FC = ({ item, onPress, testI
});
};
- const formatDate = (dateString: string) => {
- return new Date(dateString).toLocaleDateString([], {
+ // For all-day events the API stores midnight UTC as the date portion.
+ // Extract the date string directly to avoid timezone-induced day shifts.
+ const parseDateForDisplay = (dateString: string, isAllDay: boolean): Date => {
+ if (isAllDay) {
+ const parts = extractDatePart(dateString).split('-');
+ return new Date(parseInt(parts[0] ?? '0', 10), parseInt(parts[1] ?? '0', 10) - 1, parseInt(parts[2] ?? '0', 10));
+ }
+ return new Date(dateString);
+ };
+
+ const formatDate = (dateString: string, isAllDay: boolean = false) => {
+ return parseDateForDisplay(dateString, isAllDay).toLocaleDateString([], {
weekday: 'short',
month: 'short',
day: 'numeric',
@@ -43,6 +54,14 @@ export const CalendarCard: React.FC = ({ item, onPress, testI
const getEventDuration = () => {
if (item.IsAllDay) {
+ if (item.IsMultiDay) {
+ // Resolve the last inclusive end date, honoring both UTC-exclusive and local-inclusive conventions
+ const lastDayStr = resolveAllDayEndDate(item.End);
+ const parts = lastDayStr.split('-');
+ const lastDay = new Date(parseInt(parts[0] ?? '0', 10), parseInt(parts[1] ?? '0', 10) - 1, parseInt(parts[2] ?? '0', 10));
+ const endStr = lastDay.toLocaleDateString([], { month: 'short', day: 'numeric' });
+ return `${t('calendar.allDay')} · ${endStr}`;
+ }
return t('calendar.allDay');
}
const start = formatTime(item.Start);
@@ -76,9 +95,34 @@ export const CalendarCard: React.FC = ({ item, onPress, testI
{/* Date and Time */}
- {formatDate(item.Start)}
-
- {getEventDuration()}
+ {item.IsMultiDay ? (
+
+ {formatDate(item.Start, item.IsAllDay)}
+ {' – '}
+ {(() => {
+ if (item.IsAllDay) {
+ const lastDayStr = resolveAllDayEndDate(item.End);
+ const parts = lastDayStr.split('-');
+ const lastDay = new Date(parseInt(parts[0] ?? '0', 10), parseInt(parts[1] ?? '0', 10) - 1, parseInt(parts[2] ?? '0', 10));
+ return lastDay.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' });
+ }
+ return formatDate(item.End, false);
+ })()}
+
+ ) : (
+ {formatDate(item.Start, item.IsAllDay)}
+ )}
+ {!item.IsAllDay ? (
+ <>
+
+ {getEventDuration()}
+ >
+ ) : (
+ <>
+
+ {t('calendar.allDay')}
+ >
+ )}
{/* Location */}
diff --git a/src/components/calendar/compact-calendar-item.tsx b/src/components/calendar/compact-calendar-item.tsx
index ef27770..b0ff334 100644
--- a/src/components/calendar/compact-calendar-item.tsx
+++ b/src/components/calendar/compact-calendar-item.tsx
@@ -9,6 +9,7 @@ import { HStack } from '@/components/ui/hstack';
import { Pressable } from '@/components/ui/pressable';
import { Text } from '@/components/ui/text';
import { VStack } from '@/components/ui/vstack';
+import { extractDatePart, resolveAllDayEndDate } from '@/lib/utils';
import { type CalendarItemResultData } from '@/models/v4/calendar/calendarItemResultData';
interface CompactCalendarItemProps {
@@ -31,7 +32,13 @@ export const CompactCalendarItem: React.FC = ({ item,
});
};
- const formatDate = (dateString: string) => {
+ const formatDate = (dateString: string, isAllDay: boolean = false) => {
+ // For all-day events extract the date portion directly to avoid timezone-induced day shifts
+ if (isAllDay) {
+ const parts = extractDatePart(dateString).split('-');
+ const d = new Date(parseInt(parts[0] ?? '0', 10), parseInt(parts[1] ?? '0', 10) - 1, parseInt(parts[2] ?? '0', 10));
+ return d.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' });
+ }
const date = new Date(dateString);
if (isNaN(date.getTime())) {
return '';
@@ -68,7 +75,23 @@ export const CompactCalendarItem: React.FC = ({ item,
{/* Date and time on same line as title for mobile efficiency */}
- {formatDate(item.Start)}
+ {item.IsMultiDay ? (
+
+ {formatDate(item.Start, item.IsAllDay)}
+ {' – '}
+ {(() => {
+ if (item.IsAllDay) {
+ const lastDayStr = resolveAllDayEndDate(item.End);
+ const parts = lastDayStr.split('-');
+ const lastDay = new Date(parseInt(parts[0] ?? '0', 10), parseInt(parts[1] ?? '0', 10) - 1, parseInt(parts[2] ?? '0', 10));
+ return lastDay.toLocaleDateString([], { month: 'short', day: 'numeric' });
+ }
+ return formatDate(item.End, false);
+ })()}
+
+ ) : (
+ {formatDate(item.Start, item.IsAllDay)}
+ )}
{getEventDuration()}
diff --git a/src/components/calendar/enhanced-calendar-view.tsx b/src/components/calendar/enhanced-calendar-view.tsx
index b03285d..60ad860 100644
--- a/src/components/calendar/enhanced-calendar-view.tsx
+++ b/src/components/calendar/enhanced-calendar-view.tsx
@@ -9,7 +9,7 @@ import { Heading } from '@/components/ui/heading';
import { HStack } from '@/components/ui/hstack';
import { Text } from '@/components/ui/text';
import { VStack } from '@/components/ui/vstack';
-import { formatLocalDateString, getTodayLocalString, isSameDate } from '@/lib/utils';
+import { extractDatePart, formatLocalDateString, getTodayLocalString, isDateInRange, resolveAllDayEndDate } from '@/lib/utils';
import { type CalendarItemResultData } from '@/models/v4/calendar/calendarItemResultData';
import { useCalendarStore } from '@/stores/calendar/store';
@@ -34,11 +34,19 @@ export const EnhancedCalendarView: React.FC = ({ onDa
// Mark dates that have events
selectedMonthItems.forEach((item: CalendarItemResultData) => {
- // Parse full ISO string and format as local YYYY-MM-DD to avoid timezone drift
- const startDateObj = new Date(item.Start);
- const endDateObj = new Date(item.End);
- const startDate = formatLocalDateString(startDateObj);
- const endDate = formatLocalDateString(endDateObj);
+ // For all-day events extract the date directly from the ISO string to avoid
+ // UTC-to-local shifts (e.g. "2026-03-04T00:00:00Z" → "2026-03-04").
+ // For timed events convert through local Date as before.
+ const startDate = item.IsAllDay ? extractDatePart(item.Start) : formatLocalDateString(new Date(item.Start));
+
+ // For all-day events resolve the last inclusive date honoring both UTC-exclusive
+ // (Z suffix) and local-inclusive (no Z) end conventions from the backend.
+ let endDate: string;
+ if (item.IsAllDay) {
+ endDate = resolveAllDayEndDate(item.End);
+ } else {
+ endDate = formatLocalDateString(new Date(item.End));
+ }
// Mark start date
if (!marked[startDate]) {
@@ -48,39 +56,32 @@ export const EnhancedCalendarView: React.FC = ({ onDa
};
}
- // Add a dot for this event (different colors based on event type)
- marked[startDate].dots.push({
+ const eventDot = {
key: item.CalendarItemId,
- color: item.TypeColor || '#3B82F6', // Use event type color or default blue
- });
-
- // If it's a multi-day event, mark the range
- if (startDate !== endDate) {
- // Use local Date constructors to avoid timezone issues
- const start = new Date(startDateObj.getFullYear(), startDateObj.getMonth(), startDateObj.getDate());
- const end = new Date(endDateObj.getFullYear(), endDateObj.getMonth(), endDateObj.getDate());
- const current = new Date(start);
-
- while (current <= end) {
+ color: item.TypeColor || '#3B82F6',
+ };
+
+ if (startDate === endDate) {
+ // Single-day event: just add a dot to that day
+ marked[startDate].dots.push(eventDot);
+ } else {
+ // Multi-day event: iterate every day in the range and add a dot to each
+ const startParts = startDate.split('-');
+ const endParts = endDate.split('-');
+ const rangeStart = new Date(parseInt(startParts[0] ?? '0', 10), parseInt(startParts[1] ?? '0', 10) - 1, parseInt(startParts[2] ?? '0', 10));
+ const rangeEnd = new Date(parseInt(endParts[0] ?? '0', 10), parseInt(endParts[1] ?? '0', 10) - 1, parseInt(endParts[2] ?? '0', 10));
+ const current = new Date(rangeStart);
+
+ while (current <= rangeEnd) {
const dateStr = formatLocalDateString(current);
if (!marked[dateStr]) {
- marked[dateStr] = {
- marked: true,
- dots: [],
- };
+ marked[dateStr] = { marked: true, dots: [] };
}
-
- // Add period marking for multi-day events
- if (dateStr === startDate) {
- marked[dateStr].startingDay = true;
- marked[dateStr].color = item.TypeColor || '#3B82F6';
- } else if (dateStr === endDate) {
- marked[dateStr].endingDay = true;
- marked[dateStr].color = item.TypeColor || '#3B82F6';
- } else {
- marked[dateStr].color = item.TypeColor || '#3B82F6';
+ // Ensure dots array exists (may have been initialised without it)
+ if (!marked[dateStr].dots) {
+ marked[dateStr].dots = [];
}
-
+ marked[dateStr].dots.push(eventDot);
current.setDate(current.getDate() + 1);
}
}
@@ -231,7 +232,7 @@ export const EnhancedCalendarView: React.FC = ({ onDa
{(() => {
const eventsForDay = selectedMonthItems.filter((item) => {
// Use isSameDate for timezone-safe date comparison with .NET backend timezone-aware dates
- return selectedDate ? isSameDate(item.Start, selectedDate) : false;
+ return selectedDate ? isDateInRange(selectedDate, item.Start, item.End, item.IsAllDay) : false;
});
if (eventsForDay.length > 0) {
diff --git a/src/hooks/__tests__/use-oidc-login.test.ts b/src/hooks/__tests__/use-oidc-login.test.ts
new file mode 100644
index 0000000..9e879df
--- /dev/null
+++ b/src/hooks/__tests__/use-oidc-login.test.ts
@@ -0,0 +1,139 @@
+import { renderHook, act } from '@testing-library/react-native';
+import * as AuthSession from 'expo-auth-session';
+import * as WebBrowser from 'expo-web-browser';
+
+// Mock expo-auth-session
+jest.mock('expo-auth-session', () => ({
+ makeRedirectUri: jest.fn().mockReturnValue('resgrid://auth/callback'),
+ useAutoDiscovery: jest.fn().mockReturnValue(null),
+ useAuthRequest: jest.fn().mockReturnValue([null, null, jest.fn()]),
+ exchangeCodeAsync: jest.fn(),
+ ResponseType: { Code: 'code' },
+}));
+
+jest.mock('expo-web-browser', () => ({
+ maybeCompleteAuthSession: jest.fn(),
+}));
+
+jest.mock('@/lib/logging', () => ({
+ logger: {
+ error: jest.fn(),
+ info: jest.fn(),
+ debug: jest.fn(),
+ warn: jest.fn(),
+ },
+}));
+
+// Define loginWithSso mock inside the factory so it's not subject to TDZ
+jest.mock('@/stores/auth/store', () => {
+ const loginWithSso = jest.fn();
+ const storeMock = jest.fn().mockReturnValue({ loginWithSso });
+ (storeMock as any).__loginWithSso = loginWithSso;
+ return { __esModule: true, default: storeMock };
+});
+
+import useAuthStore from '@/stores/auth/store';
+import { useOidcLogin } from '../use-oidc-login';
+
+// Access mockLoginWithSso via the attached property
+const mockLoginWithSso: jest.Mock = (useAuthStore as any).__loginWithSso;
+
+const mockedUseAuthRequest = AuthSession.useAuthRequest as jest.Mock;
+const mockedUseAutoDiscovery = AuthSession.useAutoDiscovery as jest.Mock;
+const mockedExchangeCodeAsync = AuthSession.exchangeCodeAsync as jest.Mock;
+
+describe('useOidcLogin', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ // Restore default mock return values after clearAllMocks
+ mockedUseAuthRequest.mockReturnValue([null, null, jest.fn()]);
+ mockedUseAutoDiscovery.mockReturnValue(null);
+ (useAuthStore as unknown as jest.Mock).mockReturnValue({ loginWithSso: mockLoginWithSso });
+ });
+
+ it('returns request=null and response=null when authority is empty', () => {
+ const { result } = renderHook(() =>
+ useOidcLogin({ authority: '', clientId: 'test', departmentCode: 'DEPT' }),
+ );
+
+ expect(result.current.request).toBeNull();
+ expect(result.current.response).toBeNull();
+ });
+
+ it('exchangeCodeForResgridToken returns false when response type is not success', async () => {
+ mockedUseAuthRequest.mockReturnValue([
+ { codeVerifier: 'verifier123' },
+ { type: 'cancel' },
+ jest.fn(),
+ ]);
+ mockedUseAutoDiscovery.mockReturnValue({ authorizationEndpoint: 'https://idp/auth' });
+
+ const { result } = renderHook(() =>
+ useOidcLogin({
+ authority: 'https://idp.example.com',
+ clientId: 'client123',
+ departmentCode: 'DEPT',
+ }),
+ );
+
+ const ok = await result.current.exchangeCodeForResgridToken();
+ expect(ok).toBe(false);
+ expect(mockLoginWithSso).not.toHaveBeenCalled();
+ });
+
+ it('exchangeCodeForResgridToken returns false when no idToken in IdP response', async () => {
+ mockedUseAuthRequest.mockReturnValue([
+ { codeVerifier: 'verifier123' },
+ { type: 'success', params: { code: 'auth-code-abc' } },
+ jest.fn(),
+ ]);
+ const mockDiscovery = { authorizationEndpoint: 'https://idp/auth', tokenEndpoint: 'https://idp/token' };
+ mockedUseAutoDiscovery.mockReturnValue(mockDiscovery);
+ mockedExchangeCodeAsync.mockResolvedValue({ idToken: null, accessToken: 'at' });
+
+ const { result } = renderHook(() =>
+ useOidcLogin({
+ authority: 'https://idp.example.com',
+ clientId: 'client123',
+ departmentCode: 'DEPT',
+ }),
+ );
+
+ const ok = await result.current.exchangeCodeForResgridToken();
+ expect(ok).toBe(false);
+ });
+
+ it('exchangeCodeForResgridToken calls loginWithSso on success', async () => {
+ const mockRequest = { codeVerifier: 'verifier123' };
+ const mockResponse = { type: 'success', params: { code: 'auth-code-abc' } };
+ const mockDiscovery = {
+ authorizationEndpoint: 'https://idp/auth',
+ tokenEndpoint: 'https://idp/token',
+ };
+
+ mockedUseAuthRequest.mockReturnValue([mockRequest, mockResponse, jest.fn()]);
+ mockedUseAutoDiscovery.mockReturnValue(mockDiscovery);
+ mockedExchangeCodeAsync.mockResolvedValue({ idToken: 'id.token.here' });
+ mockLoginWithSso.mockResolvedValue(undefined);
+
+ const { result } = renderHook(() =>
+ useOidcLogin({
+ authority: 'https://idp.example.com',
+ clientId: 'client123',
+ departmentCode: 'DEPT001',
+ }),
+ );
+
+ let ok: boolean;
+ await act(async () => {
+ ok = await result.current.exchangeCodeForResgridToken();
+ });
+
+ expect(mockLoginWithSso).toHaveBeenCalledWith({
+ provider: 'oidc',
+ externalToken: 'id.token.here',
+ departmentCode: 'DEPT001',
+ });
+ expect(ok!).toBe(true);
+ });
+});
diff --git a/src/hooks/__tests__/use-saml-login.test.ts b/src/hooks/__tests__/use-saml-login.test.ts
new file mode 100644
index 0000000..9981c39
--- /dev/null
+++ b/src/hooks/__tests__/use-saml-login.test.ts
@@ -0,0 +1,158 @@
+import * as Linking from 'expo-linking';
+import * as WebBrowser from 'expo-web-browser';
+
+import { handleSamlCallbackUrl, PENDING_SAML_DEPT_CODE_KEY } from '../use-saml-login';
+
+// Mock expo modules
+jest.mock('expo-linking', () => ({
+ parse: jest.fn(),
+}));
+
+jest.mock('expo-web-browser', () => ({
+ openBrowserAsync: jest.fn(),
+}));
+
+// Mock storage
+const mockSetItem = jest.fn();
+const mockGetItem = jest.fn();
+const mockRemoveItem = jest.fn();
+jest.mock('@/lib/storage', () => ({
+ setItem: (...args: any[]) => mockSetItem(...args),
+ getItem: (...args: any[]) => mockGetItem(...args),
+ removeItem: (...args: any[]) => mockRemoveItem(...args),
+}));
+
+// Mock logger
+jest.mock('@/lib/logging', () => ({
+ logger: {
+ error: jest.fn(),
+ info: jest.fn(),
+ debug: jest.fn(),
+ warn: jest.fn(),
+ },
+}));
+
+// Mock auth store — define loginWithSso inside the mock factory
+const mockLoginWithSso = jest.fn();
+jest.mock('@/stores/auth/store', () => {
+ const storeMock = jest.fn();
+ (storeMock as any).getState = jest.fn().mockReturnValue({ loginWithSso: mockLoginWithSso });
+ return {
+ __esModule: true,
+ default: storeMock,
+ };
+});
+
+import useAuthStore from '@/stores/auth/store';
+
+const mockedParse = Linking.parse as jest.Mock;
+
+describe('handleSamlCallbackUrl', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ // Re-bind loginWithSso on getState to pick up per-test mockResolvedValue settings
+ (useAuthStore as any).getState.mockReturnValue({ loginWithSso: mockLoginWithSso });
+ });
+
+ it('returns false when the URL has no saml_response param', async () => {
+ mockedParse.mockReturnValue({ queryParams: {} });
+
+ const result = await handleSamlCallbackUrl('resgrid://auth/callback');
+ expect(result).toBe(false);
+ expect(mockLoginWithSso).not.toHaveBeenCalled();
+ });
+
+ it('returns false when no pending department code is stored', async () => {
+ mockedParse.mockReturnValue({ queryParams: { saml_response: 'base64saml' } });
+ mockGetItem.mockReturnValue(null);
+
+ const result = await handleSamlCallbackUrl('resgrid://auth/callback?saml_response=base64saml');
+ expect(result).toBe(false);
+ expect(mockLoginWithSso).not.toHaveBeenCalled();
+ });
+
+ it('calls loginWithSso and clears stored dept code on success', async () => {
+ mockedParse.mockReturnValue({ queryParams: { saml_response: 'base64saml=' } });
+ mockGetItem.mockReturnValue('DEPT001');
+ mockLoginWithSso.mockResolvedValue(undefined);
+
+ const result = await handleSamlCallbackUrl(
+ 'resgrid://auth/callback?saml_response=base64saml=',
+ );
+
+ expect(mockLoginWithSso).toHaveBeenCalledWith({
+ provider: 'saml2',
+ externalToken: 'base64saml=',
+ departmentCode: 'DEPT001',
+ });
+ expect(mockRemoveItem).toHaveBeenCalledWith(PENDING_SAML_DEPT_CODE_KEY);
+ expect(result).toBe(true);
+ });
+
+ it('returns false and does not clear storage when loginWithSso throws', async () => {
+ mockedParse.mockReturnValue({ queryParams: { saml_response: 'base64saml' } });
+ mockGetItem.mockReturnValue('DEPT001');
+ mockLoginWithSso.mockRejectedValue(new Error('Token exchange failed'));
+
+ const result = await handleSamlCallbackUrl(
+ 'resgrid://auth/callback?saml_response=base64saml',
+ );
+
+ expect(result).toBe(false);
+ expect(mockRemoveItem).not.toHaveBeenCalled();
+ });
+});
+
+describe('handleSamlCallbackUrl', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('returns false when the URL has no saml_response param', async () => {
+ mockedParse.mockReturnValue({ queryParams: {} });
+
+ const result = await handleSamlCallbackUrl('resgrid://auth/callback');
+ expect(result).toBe(false);
+ expect(mockLoginWithSso).not.toHaveBeenCalled();
+ });
+
+ it('returns false when no pending department code is stored', async () => {
+ mockedParse.mockReturnValue({ queryParams: { saml_response: 'base64saml' } });
+ mockGetItem.mockReturnValue(null);
+
+ const result = await handleSamlCallbackUrl('resgrid://auth/callback?saml_response=base64saml');
+ expect(result).toBe(false);
+ expect(mockLoginWithSso).not.toHaveBeenCalled();
+ });
+
+ it('calls loginWithSso and clears stored dept code on success', async () => {
+ mockedParse.mockReturnValue({ queryParams: { saml_response: 'base64saml=' } });
+ mockGetItem.mockReturnValue('DEPT001');
+ mockLoginWithSso.mockResolvedValue(undefined);
+
+ const result = await handleSamlCallbackUrl(
+ 'resgrid://auth/callback?saml_response=base64saml=',
+ );
+
+ expect(mockLoginWithSso).toHaveBeenCalledWith({
+ provider: 'saml2',
+ externalToken: 'base64saml=',
+ departmentCode: 'DEPT001',
+ });
+ expect(mockRemoveItem).toHaveBeenCalledWith(PENDING_SAML_DEPT_CODE_KEY);
+ expect(result).toBe(true);
+ });
+
+ it('returns false and does not clear storage when loginWithSso throws', async () => {
+ mockedParse.mockReturnValue({ queryParams: { saml_response: 'base64saml' } });
+ mockGetItem.mockReturnValue('DEPT001');
+ mockLoginWithSso.mockRejectedValue(new Error('Token exchange failed'));
+
+ const result = await handleSamlCallbackUrl(
+ 'resgrid://auth/callback?saml_response=base64saml',
+ );
+
+ expect(result).toBe(false);
+ expect(mockRemoveItem).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/hooks/use-oidc-login.ts b/src/hooks/use-oidc-login.ts
new file mode 100644
index 0000000..d5e6bea
--- /dev/null
+++ b/src/hooks/use-oidc-login.ts
@@ -0,0 +1,103 @@
+import * as AuthSession from 'expo-auth-session';
+import * as WebBrowser from 'expo-web-browser';
+import { useCallback } from 'react';
+
+import { logger } from '@/lib/logging';
+import useAuthStore from '@/stores/auth/store';
+
+// Required for iOS / Android to close the in-app browser after redirect
+WebBrowser.maybeCompleteAuthSession();
+
+export interface UseOidcLoginOptions {
+ authority: string;
+ clientId: string;
+ departmentCode: string;
+}
+
+export interface UseOidcLoginResult {
+ request: AuthSession.AuthRequest | null;
+ response: AuthSession.AuthSessionResult | null;
+ promptAsync: (options?: AuthSession.AuthRequestPromptOptions) => Promise;
+ exchangeCodeForResgridToken: () => Promise;
+}
+
+/**
+ * Hook that drives the OIDC Authorization-Code + PKCE login flow.
+ *
+ * Usage:
+ * const { request, promptAsync, exchangeCodeForResgridToken } = useOidcLogin({ authority, clientId, departmentCode });
+ * // Call promptAsync() to open the system browser.
+ * // Watch `response` and call exchangeCodeForResgridToken() when response.type === 'success'.
+ */
+export function useOidcLogin({ authority, clientId, departmentCode }: UseOidcLoginOptions): UseOidcLoginResult {
+ const redirectUri = AuthSession.makeRedirectUri({ scheme: 'resgrid', path: 'auth/callback' });
+
+ // Auto-discover OIDC endpoints from the authority's /.well-known/openid-configuration
+ // discovery will be null until the authority URL is non-empty and valid
+ const discovery = AuthSession.useAutoDiscovery(authority || '');
+
+ const [request, response, promptAsync] = AuthSession.useAuthRequest(
+ {
+ clientId: clientId || '__placeholder__',
+ redirectUri,
+ scopes: ['openid', 'email', 'profile', 'offline_access'],
+ usePKCE: true,
+ responseType: AuthSession.ResponseType.Code,
+ },
+ // Pass null discovery when authority is not yet set to prevent premature requests
+ authority ? discovery : null
+ );
+
+ const { loginWithSso } = useAuthStore();
+
+ const exchangeCodeForResgridToken = useCallback(async (): Promise => {
+ if (response?.type !== 'success' || !request?.codeVerifier || !discovery) {
+ logger.warn({
+ message: 'OIDC exchange preconditions not met',
+ context: { responseType: response?.type, hasCodeVerifier: !!request?.codeVerifier, hasDiscovery: !!discovery },
+ });
+ return false;
+ }
+
+ try {
+ // Step 1: Exchange the authorization code for tokens at the IdP
+ const tokenResponse = await AuthSession.exchangeCodeAsync(
+ {
+ clientId,
+ redirectUri,
+ code: response.params.code,
+ extraParams: { code_verifier: request.codeVerifier },
+ },
+ discovery
+ );
+
+ const idToken = tokenResponse.idToken;
+ if (!idToken) {
+ logger.error({ message: 'No id_token in OIDC token response' });
+ return false;
+ }
+
+ // Step 2: Exchange the IdP id_token for a Resgrid access token
+ await loginWithSso({
+ provider: 'oidc',
+ externalToken: idToken,
+ departmentCode,
+ });
+
+ return true;
+ } catch (error) {
+ logger.error({
+ message: 'OIDC code exchange failed',
+ context: { error: error instanceof Error ? error.message : String(error) },
+ });
+ return false;
+ }
+ }, [response, request, discovery, clientId, redirectUri, departmentCode, loginWithSso]);
+
+ return {
+ request,
+ response,
+ promptAsync,
+ exchangeCodeForResgridToken,
+ };
+}
diff --git a/src/hooks/use-saml-login.ts b/src/hooks/use-saml-login.ts
new file mode 100644
index 0000000..f905fa5
--- /dev/null
+++ b/src/hooks/use-saml-login.ts
@@ -0,0 +1,121 @@
+import * as Linking from 'expo-linking';
+import * as WebBrowser from 'expo-web-browser';
+import { useCallback } from 'react';
+
+import { logger } from '@/lib/logging';
+import { getItem, removeItem, setItem } from '@/lib/storage';
+import useAuthStore from '@/stores/auth/store';
+
+/** MMKV key used to persist the active SAML department code across cold starts */
+export const PENDING_SAML_DEPT_CODE_KEY = 'pending_saml_dept_code';
+
+export interface UseSamlLoginOptions {
+ idpSsoUrl: string;
+ departmentCode: string;
+}
+
+export interface UseSamlLoginResult {
+ startSamlLogin: () => Promise;
+ handleDeepLink: (url: string) => Promise;
+}
+
+/**
+ * Hook that drives the SAML 2.0 IdP-initiated login flow.
+ *
+ * Flow:
+ * 1. Call startSamlLogin() to open the IdP SSO URL in a browser.
+ * 2. The IdP POSTs a SAMLResponse to the SP ACS URL.
+ * 3. The SP ACS URL redirects to resgrid://auth/callback?saml_response=.
+ * 4. The deep-link is intercepted by the app and handleDeepLink() is called.
+ * 5. handleDeepLink() exchanges the SAMLResponse for a Resgrid token.
+ *
+ * NOTE: The backend SP ACS endpoint must redirect to
+ * resgrid://auth/callback?saml_response=
+ * See the implementation plan Step 8 for backend configuration.
+ */
+export function useSamlLogin({ idpSsoUrl, departmentCode }: UseSamlLoginOptions): UseSamlLoginResult {
+ const { loginWithSso } = useAuthStore();
+
+ const startSamlLogin = useCallback(async (): Promise => {
+ if (!idpSsoUrl) {
+ logger.warn({ message: 'SAML: idpSsoUrl is empty, cannot start login' });
+ return;
+ }
+
+ // Persist department code so the cold-start deep-link handler can retrieve it
+ await setItem(PENDING_SAML_DEPT_CODE_KEY, departmentCode);
+
+ logger.info({ message: 'SAML: opening IdP SSO URL', context: { idpSsoUrl } });
+ await WebBrowser.openBrowserAsync(idpSsoUrl);
+ }, [idpSsoUrl, departmentCode]);
+
+ const handleDeepLink = useCallback(
+ async (url: string): Promise => {
+ const parsed = Linking.parse(url);
+ const samlResponse = parsed.queryParams?.saml_response as string | undefined;
+
+ if (!samlResponse) {
+ logger.debug({ message: 'SAML: deep-link does not contain saml_response', context: { url } });
+ return false;
+ }
+
+ logger.info({ message: 'SAML: received saml_response via deep-link, exchanging for Resgrid token' });
+
+ try {
+ await loginWithSso({
+ provider: 'saml2',
+ externalToken: samlResponse,
+ departmentCode,
+ });
+ await removeItem(PENDING_SAML_DEPT_CODE_KEY);
+ return true;
+ } catch (error) {
+ await removeItem(PENDING_SAML_DEPT_CODE_KEY);
+ logger.error({
+ message: 'SAML: token exchange failed',
+ context: { error: error instanceof Error ? error.message : String(error) },
+ });
+ return false;
+ }
+ },
+ [departmentCode, loginWithSso]
+ );
+
+ return { startSamlLogin, handleDeepLink };
+}
+
+/**
+ * Standalone SAML deep-link handler for use outside of React components
+ * (e.g., in the app _layout.tsx for cold-start callbacks).
+ * Reads the stored department code from MMKV and calls loginWithSso directly.
+ */
+export async function handleSamlCallbackUrl(url: string): Promise {
+ const parsed = Linking.parse(url);
+ const samlResponse = parsed.queryParams?.saml_response as string | undefined;
+
+ if (!samlResponse) return false;
+
+ const departmentCode = getItem(PENDING_SAML_DEPT_CODE_KEY);
+ if (!departmentCode) {
+ logger.warn({ message: 'SAML cold-start: no pending department code found in storage' });
+ return false;
+ }
+
+ logger.info({ message: 'SAML cold-start: handling saml_response deep-link' });
+
+ try {
+ await useAuthStore.getState().loginWithSso({
+ provider: 'saml2',
+ externalToken: samlResponse,
+ departmentCode,
+ });
+ await removeItem(PENDING_SAML_DEPT_CODE_KEY);
+ return true;
+ } catch (error) {
+ logger.error({
+ message: 'SAML cold-start: token exchange failed',
+ context: { error: error instanceof Error ? error.message : String(error) },
+ });
+ return false;
+ }
+}
diff --git a/src/lib/auth/api.tsx b/src/lib/auth/api.tsx
index 4324e6b..b69c58f 100644
--- a/src/lib/auth/api.tsx
+++ b/src/lib/auth/api.tsx
@@ -5,7 +5,7 @@ import queryString from 'query-string';
import { logger } from '@/lib/logging';
import { getBaseApiUrl } from '../storage/app';
-import type { AuthResponse, LoginCredentials, LoginResponse } from './types';
+import type { AuthResponse, ExternalTokenCredentials, LoginCredentials, LoginResponse } from './types';
const authApi = axios.create({
baseURL: getBaseApiUrl(),
@@ -80,3 +80,46 @@ export const refreshTokenRequest = async (refreshToken: string): Promise => {
+ try {
+ const data = queryString.stringify({
+ provider: credentials.provider,
+ external_token: credentials.externalToken,
+ department_code: credentials.departmentCode,
+ scope: Env.IS_MOBILE_APP ? 'openid email profile offline_access mobile' : 'openid email profile offline_access',
+ });
+
+ const response = await authApi.post('/connect/external-token', data);
+
+ if (response.status === 200) {
+ logger.info({
+ message: 'External token exchange successful',
+ context: { provider: credentials.provider, departmentCode: credentials.departmentCode },
+ });
+
+ return {
+ successful: true,
+ message: 'Login successful',
+ authResponse: response.data,
+ };
+ }
+
+ logger.error({
+ message: 'External token exchange failed',
+ context: { response, provider: credentials.provider },
+ });
+
+ return {
+ successful: false,
+ message: 'SSO login failed',
+ authResponse: null,
+ };
+ } catch (error) {
+ logger.error({
+ message: 'External token exchange error',
+ context: { error, provider: credentials.provider },
+ });
+ throw error;
+ }
+};
diff --git a/src/lib/auth/index.tsx b/src/lib/auth/index.tsx
index 28f8b5b..d3c0f5b 100644
--- a/src/lib/auth/index.tsx
+++ b/src/lib/auth/index.tsx
@@ -14,6 +14,7 @@ export const useAuth = () => {
isLoading: store.status === 'loading',
error: store.error,
login: store.login,
+ loginWithSso: store.loginWithSso,
logout: store.logout,
status: store.status,
hydrate: store.hydrate,
diff --git a/src/lib/auth/types.tsx b/src/lib/auth/types.tsx
index 4f7d87a..654e439 100644
--- a/src/lib/auth/types.tsx
+++ b/src/lib/auth/types.tsx
@@ -3,6 +3,14 @@ export interface AuthTokens {
refreshToken: string;
}
+export type SsoProvider = 'oidc' | 'saml2';
+
+export interface ExternalTokenCredentials {
+ provider: SsoProvider;
+ externalToken: string;
+ departmentCode: string;
+}
+
export interface LoginCredentials {
username: string;
password: string;
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
index 40c2779..3080f8f 100644
--- a/src/lib/utils.ts
+++ b/src/lib/utils.ts
@@ -318,6 +318,68 @@ export function isToday(date: string | Date): boolean {
return isSameDate(date, new Date());
}
+/**
+ * Extracts the date portion (YYYY-MM-DD) from an ISO string or Date object WITHOUT
+ * timezone conversion. For all-day events stored as midnight UTC ("2026-03-04T00:00:00Z"),
+ * this returns the intended calendar date ("2026-03-04") regardless of the device timezone.
+ */
+export function extractDatePart(date: string | Date): string {
+ if (date instanceof Date) {
+ return formatLocalDateString(date);
+ }
+ // For ISO strings, extract date portion directly to avoid timezone conversion
+ if (typeof date === 'string' && date.length >= 10) {
+ const datePart = date.slice(0, 10);
+ if (/^\d{4}-\d{2}-\d{2}$/.test(datePart)) {
+ return datePart;
+ }
+ }
+ return formatLocalDateString(new Date(date));
+}
+
+/**
+ * Resolves the last inclusive date (YYYY-MM-DD) for an all-day event's End field.
+ *
+ * The .NET backend has two conventions depending on the endpoint/timezone mode:
+ * - UTC (Z suffix): End = midnight of the day AFTER the last day (exclusive).
+ * e.g. event ends Mar 5 → End = "2026-03-06T00:00:00Z" → subtract 1.
+ * - Local (no Z): End = midnight of the last day itself (inclusive).
+ * e.g. event ends Mar 5 → End = "2026-03-05T00:00:00" → use as-is.
+ */
+export function resolveAllDayEndDate(eventEnd: string): string {
+ const endDatePart = eventEnd.slice(0, 10);
+ // Detect UTC or explicit offset (e.g. +05:30) → exclusive next-day convention
+ const hasTimezone = eventEnd.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(eventEnd);
+ if (hasTimezone) {
+ const parts = endDatePart.split('-');
+ const year = parseInt(parts[0] ?? '0', 10);
+ const month = parseInt(parts[1] ?? '0', 10);
+ const day = parseInt(parts[2] ?? '0', 10);
+ return formatLocalDateString(new Date(year, month - 1, day - 1));
+ }
+ // No timezone indicator → local midnight, date portion IS the last included day
+ return endDatePart;
+}
+
+/**
+ * Checks if a given date (YYYY-MM-DD string) falls within a calendar event's date range.
+ * For all-day events the End date convention is detected automatically — see resolveAllDayEndDate.
+ * For timed events End is treated as inclusive (date-portion comparison).
+ */
+export function isDateInRange(date: string, eventStart: string, eventEnd: string, isAllDay: boolean): boolean {
+ const dateStr = date.slice(0, 10);
+ const startStr = eventStart.slice(0, 10);
+
+ if (isAllDay) {
+ const endStr = resolveAllDayEndDate(eventEnd);
+ return dateStr >= startStr && dateStr <= endStr;
+ }
+
+ // For timed events compare the date portions inclusively
+ const endStr = eventEnd.slice(0, 10);
+ return dateStr >= startStr && dateStr <= endStr;
+}
+
export function formatDateString(date: Date): string {
const day = date.getDate(); // yields date
const month = date.getMonth() + 1; // yields month (add one as '.getMonth()' is zero indexed)
diff --git a/src/models/v4/calendar/calendarItemResultData.ts b/src/models/v4/calendar/calendarItemResultData.ts
index 64e05c6..a0e9fb2 100644
--- a/src/models/v4/calendar/calendarItemResultData.ts
+++ b/src/models/v4/calendar/calendarItemResultData.ts
@@ -13,6 +13,7 @@ export class CalendarItemResultData {
public RecurrenceException: string = '';
public ItemType: number = 0;
public IsAllDay: boolean = false;
+ public IsMultiDay?: boolean;
public Location: string = '';
public SignupType: number = 0;
public Reminder: number = 0;
@@ -38,3 +39,46 @@ export class CalendarItemResultAttendeeData {
public Timestamp: string = '';
public Note: string = '';
}
+
+/**
+ * Subtracts one calendar day from a YYYY-MM-DD string.
+ * Used to convert exclusive all-day end dates to inclusive ones.
+ */
+function dateMinusOneDay(dateStr: string): string {
+ const d = new Date(dateStr + 'T00:00:00');
+ d.setDate(d.getDate() - 1);
+ return d.toISOString().slice(0, 10);
+}
+
+/**
+ * Returns true when End falls on a later calendar day than Start.
+ * Handles ISO 8601 strings by comparing the date portion directly,
+ * which is timezone-agnostic and works for both UTC-suffixed and
+ * offset-bearing strings.
+ *
+ * When isAllDay is true the end date is treated as exclusive (i.e. the
+ * calendar convention where the end is set to the day *after* the last
+ * day), so one day is subtracted before comparing.
+ */
+export function computeIsMultiDay(start: string, end: string, isAllDay?: boolean): boolean {
+ if (!start || !end) return false;
+ const startDate = start.slice(0, 10);
+ let endDate = end.slice(0, 10);
+ if (isAllDay) {
+ endDate = dateMinusOneDay(endDate);
+ }
+ return endDate > startDate;
+}
+
+/**
+ * Ensures IsMultiDay is always set on an item returned from the API.
+ * When the API omits the field (undefined/null) it is computed from
+ * the Start and End date strings. An explicit API-provided value
+ * (true or false) is left unchanged.
+ */
+export function mapCalendarItemResultData(raw: CalendarItemResultData): CalendarItemResultData {
+ if (raw.IsMultiDay !== undefined && raw.IsMultiDay !== null) {
+ return raw;
+ }
+ return { ...raw, IsMultiDay: computeIsMultiDay(raw.Start, raw.End, raw.IsAllDay) };
+}
diff --git a/src/services/__tests__/sso-discovery.test.ts b/src/services/__tests__/sso-discovery.test.ts
new file mode 100644
index 0000000..ce602b4
--- /dev/null
+++ b/src/services/__tests__/sso-discovery.test.ts
@@ -0,0 +1,152 @@
+import axios from 'axios';
+
+import { fetchDepartmentSsoConfig, fetchUserSsoConfig } from '../sso-discovery';
+
+jest.mock('axios');
+
+// Mock storage
+jest.mock('@/lib/storage/app', () => ({
+ getBaseApiUrl: jest.fn().mockReturnValue('https://api.resgrid.dev/api/v4'),
+}));
+
+const mockedAxios = axios as jest.Mocked;
+
+describe('ssoDiscovery', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('fetchDepartmentSsoConfig', () => {
+ it('returns the SSO config when the API responds successfully', async () => {
+ const mockConfig = {
+ ssoEnabled: true,
+ providerType: 'oidc',
+ authority: 'https://idp.example.com',
+ clientId: 'client-123',
+ metadataUrl: null,
+ entityId: null,
+ allowLocalLogin: true,
+ requireSso: false,
+ requireMfa: false,
+ oidcRedirectUri: 'resgrid://auth/callback',
+ oidcScopes: 'openid email profile offline_access',
+ };
+
+ mockedAxios.get = jest.fn().mockResolvedValue({
+ data: { Data: mockConfig },
+ });
+
+ const result = await fetchDepartmentSsoConfig('DEPT001');
+
+ expect(mockedAxios.get).toHaveBeenCalledWith(
+ 'https://api.resgrid.dev/api/v4/connect/sso-config',
+ { params: { departmentCode: 'DEPT001' } },
+ );
+ expect(result).toEqual(mockConfig);
+ });
+
+ it('returns null when the API response has no Data field', async () => {
+ mockedAxios.get = jest.fn().mockResolvedValue({ data: {} });
+
+ const result = await fetchDepartmentSsoConfig('DEPT001');
+ expect(result).toBeNull();
+ });
+
+ it('throws when the API call fails', async () => {
+ mockedAxios.get = jest.fn().mockRejectedValue(new Error('Network error'));
+
+ await expect(fetchDepartmentSsoConfig('DEPT001')).rejects.toThrow(
+ 'SSO config lookup failed for department "DEPT001"'
+ );
+ });
+
+ it('handles a SAML providerType', async () => {
+ const mockConfig = {
+ ssoEnabled: true,
+ providerType: 'saml2',
+ authority: null,
+ clientId: null,
+ metadataUrl: 'https://idp.example.com/saml/sso',
+ entityId: 'urn:example:sp',
+ allowLocalLogin: false,
+ requireSso: true,
+ requireMfa: false,
+ oidcRedirectUri: '',
+ oidcScopes: '',
+ };
+
+ mockedAxios.get = jest.fn().mockResolvedValue({ data: { Data: mockConfig } });
+
+ const result = await fetchDepartmentSsoConfig('SAML_DEPT');
+ expect(result?.providerType).toBe('saml2');
+ expect(result?.requireSso).toBe(true);
+ });
+ });
+
+ describe('fetchUserSsoConfig', () => {
+ const mockOidcConfig = {
+ ssoEnabled: true,
+ providerType: 'oidc',
+ authority: 'https://idp.example.com',
+ clientId: 'client-123',
+ metadataUrl: null,
+ entityId: null,
+ allowLocalLogin: true,
+ requireSso: false,
+ requireMfa: false,
+ oidcRedirectUri: 'resgrid://auth/callback',
+ oidcScopes: 'openid email profile offline_access',
+ };
+
+ it('calls the correct endpoint with username only', async () => {
+ mockedAxios.get = jest.fn().mockResolvedValue({ data: { Data: mockOidcConfig } });
+
+ const result = await fetchUserSsoConfig('jdoe@example.com');
+
+ expect(mockedAxios.get).toHaveBeenCalledWith(
+ 'https://api.resgrid.dev/api/v4/connect/sso-config-for-user',
+ { params: { username: 'jdoe@example.com' } },
+ );
+ expect(result).toEqual(mockOidcConfig);
+ });
+
+ it('includes departmentId param when provided', async () => {
+ mockedAxios.get = jest.fn().mockResolvedValue({ data: { Data: mockOidcConfig } });
+
+ await fetchUserSsoConfig('jdoe@example.com', 42);
+
+ expect(mockedAxios.get).toHaveBeenCalledWith(
+ 'https://api.resgrid.dev/api/v4/connect/sso-config-for-user',
+ { params: { username: 'jdoe@example.com', departmentId: 42 } },
+ );
+ });
+
+ it('omits departmentId when value is 0 or undefined', async () => {
+ mockedAxios.get = jest.fn().mockResolvedValue({ data: { Data: mockOidcConfig } });
+
+ await fetchUserSsoConfig('jdoe@example.com', 0);
+
+ expect(mockedAxios.get).toHaveBeenCalledWith(
+ 'https://api.resgrid.dev/api/v4/connect/sso-config-for-user',
+ { params: { username: 'jdoe@example.com' } },
+ );
+ });
+
+ it('throws on network error', async () => {
+ mockedAxios.get = jest.fn().mockRejectedValue(new Error('Network error'));
+
+ await expect(fetchUserSsoConfig('jdoe@example.com')).rejects.toThrow(
+ 'SSO config lookup failed for user "jdoe@example.com"'
+ );
+ });
+
+ it('returns ssoEnabled=false config when user is unknown (no account enumeration)', async () => {
+ const noSsoConfig = { ssoEnabled: false, allowLocalLogin: true, providerType: null };
+ mockedAxios.get = jest.fn().mockResolvedValue({ data: { Data: noSsoConfig } });
+
+ const result = await fetchUserSsoConfig('unknown@example.com');
+ expect(result?.ssoEnabled).toBe(false);
+ expect(result?.allowLocalLogin).toBe(true);
+ });
+ });
+});
diff --git a/src/services/callkeep.service.android.ts b/src/services/callkeep.service.android.ts
index 7f6f73b..5dbfcbc 100644
--- a/src/services/callkeep.service.android.ts
+++ b/src/services/callkeep.service.android.ts
@@ -57,10 +57,7 @@ export class CallKeepService {
alertDescription: 'This application needs to access your phone accounts',
cancelButton: 'Cancel',
okButton: 'OK',
- additionalPermissions: [
- PermissionsAndroid.PERMISSIONS.READ_PHONE_STATE,
- ...(Platform.Version >= 30 ? [PermissionsAndroid.PERMISSIONS.READ_PHONE_NUMBERS] : []),
- ],
+ additionalPermissions: [PermissionsAndroid.PERMISSIONS.READ_PHONE_STATE, ...(Platform.Version >= 30 ? [PermissionsAndroid.PERMISSIONS.READ_PHONE_NUMBERS] : [])],
// Important for VoIP on Android O+
selfManaged: true,
foregroundService: {
@@ -113,9 +110,7 @@ export class CallKeepService {
}
try {
- const hasPermission = await PermissionsAndroid.check(
- PermissionsAndroid.PERMISSIONS.READ_PHONE_NUMBERS
- );
+ const hasPermission = await PermissionsAndroid.check(PermissionsAndroid.PERMISSIONS.READ_PHONE_NUMBERS);
if (hasPermission) {
logger.debug({
@@ -124,15 +119,12 @@ export class CallKeepService {
return true;
}
- const result = await PermissionsAndroid.request(
- PermissionsAndroid.PERMISSIONS.READ_PHONE_NUMBERS,
- {
- title: 'Phone Permission Required',
- message: 'This app needs phone access to manage voice calls with your headset',
- buttonPositive: 'Grant',
- buttonNegative: 'Deny',
- }
- );
+ const result = await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.READ_PHONE_NUMBERS, {
+ title: 'Phone Permission Required',
+ message: 'This app needs phone access to manage voice calls with your headset',
+ buttonPositive: 'Grant',
+ buttonNegative: 'Deny',
+ });
const granted = result === PermissionsAndroid.RESULTS.GRANTED;
logger.info({
diff --git a/src/services/sso-discovery.ts b/src/services/sso-discovery.ts
new file mode 100644
index 0000000..52d45bc
--- /dev/null
+++ b/src/services/sso-discovery.ts
@@ -0,0 +1,49 @@
+import axios from 'axios';
+
+import { getBaseApiUrl } from '@/lib/storage/app';
+
+export interface DepartmentSsoConfig {
+ ssoEnabled: boolean;
+ providerType: 'oidc' | 'saml2' | null;
+ authority: string | null;
+ clientId: string | null;
+ metadataUrl: string | null;
+ entityId: string | null;
+ allowLocalLogin: boolean;
+ requireSso: boolean;
+ requireMfa: boolean;
+ oidcRedirectUri: string;
+ oidcScopes: string;
+}
+
+export async function fetchDepartmentSsoConfig(departmentCode: string): Promise {
+ try {
+ const baseUrl = getBaseApiUrl();
+ const response = await axios.get(`${baseUrl}/connect/sso-config`, {
+ params: { departmentCode },
+ });
+ return (response.data?.Data as DepartmentSsoConfig) ?? null;
+ } catch (err) {
+ throw new Error(`SSO config lookup failed for department "${departmentCode}": ${err instanceof Error ? err.message : String(err)}`);
+ }
+}
+
+/**
+ * Resolves SSO config for a user by username (and optionally a specific department ID).
+ * Calls GET /connect/sso-config-for-user.
+ * Throws on network/server error; returns a config with ssoEnabled=false if the
+ * username doesn't exist (the backend intentionally avoids account-enumeration leaks).
+ */
+export async function fetchUserSsoConfig(username: string, departmentId?: number): Promise {
+ try {
+ const baseUrl = getBaseApiUrl();
+ const params: Record = { username };
+ if (departmentId !== undefined && departmentId > 0) {
+ params.departmentId = departmentId;
+ }
+ const response = await axios.get(`${baseUrl}/connect/sso-config-for-user`, { params });
+ return (response.data?.Data as DepartmentSsoConfig) ?? null;
+ } catch (err) {
+ throw new Error(`SSO config lookup failed for user "${username}": ${err instanceof Error ? err.message : String(err)}`);
+ }
+}
diff --git a/src/stores/auth/store.tsx b/src/stores/auth/store.tsx
index 89dda72..e412134 100644
--- a/src/stores/auth/store.tsx
+++ b/src/stores/auth/store.tsx
@@ -5,8 +5,8 @@ import { createJSONStorage, persist } from 'zustand/middleware';
import { logger } from '@/lib/logging';
-import { loginRequest, refreshTokenRequest } from '../../lib/auth/api';
-import type { AuthResponse, AuthStatus, LoginCredentials } from '../../lib/auth/types';
+import { externalTokenRequest, loginRequest, refreshTokenRequest } from '../../lib/auth/api';
+import type { AuthResponse, AuthStatus, ExternalTokenCredentials, LoginCredentials } from '../../lib/auth/types';
import { type ProfileModel } from '../../lib/auth/types';
import { getAuth } from '../../lib/auth/utils';
import { getItem, removeItem, setItem, zustandStorage } from '../../lib/storage';
@@ -52,6 +52,7 @@ interface AuthState {
// Actions
login: (credentials: LoginCredentials) => Promise;
+ loginWithSso: (credentials: ExternalTokenCredentials) => Promise;
logout: (reason?: string) => Promise;
refreshAccessToken: () => Promise;
hydrate: () => void;
@@ -157,6 +158,82 @@ const useAuthStore = create()(
}
},
+ loginWithSso: async (credentials: ExternalTokenCredentials) => {
+ try {
+ set({ status: 'loading' });
+ const response = await externalTokenRequest(credentials);
+
+ if (response.successful) {
+ const idToken = response.authResponse?.id_token;
+ if (!idToken) {
+ logger.error({
+ message: 'No ID token received during SSO login',
+ context: { provider: credentials.provider },
+ });
+ throw new Error('No ID token received from SSO');
+ }
+
+ const tokenParts = idToken.split('.');
+ if (tokenParts.length < 3 || !tokenParts[1]) {
+ logger.error({
+ message: 'Invalid ID token format during SSO login',
+ context: { provider: credentials.provider },
+ });
+ throw new Error('Invalid ID token format');
+ }
+
+ const payload = sanitizeJson(decodeJwtPayload(tokenParts[1]));
+ const now = Date.now();
+ const authResponseWithTimestamp = {
+ ...response.authResponse!,
+ obtained_at: now,
+ };
+
+ setItem('authResponse', authResponseWithTimestamp);
+
+ const profileData = JSON.parse(payload) as ProfileModel;
+
+ set({
+ accessToken: response.authResponse?.access_token ?? null,
+ refreshToken: response.authResponse?.refresh_token ?? null,
+ accessTokenObtainedAt: now,
+ refreshTokenObtainedAt: now,
+ status: 'signedIn',
+ error: null,
+ profile: profileData,
+ userId: profileData.sub,
+ isFirstTime: false,
+ });
+
+ logger.info({
+ message: 'SSO login successful',
+ context: {
+ provider: credentials.provider,
+ userId: profileData.sub,
+ },
+ });
+ } else {
+ logger.error({
+ message: 'SSO login failed - unsuccessful response',
+ context: { provider: credentials.provider, message: response.message },
+ });
+ set({ status: 'error', error: response.message });
+ }
+ } catch (error) {
+ logger.error({
+ message: 'SSO login failed with exception',
+ context: {
+ provider: credentials.provider,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ },
+ });
+ set({
+ status: 'error',
+ error: error instanceof Error ? error.message : 'SSO login failed',
+ });
+ }
+ },
+
logout: async (reason?: string) => {
const currentState = get();
const wasAuthenticated = currentState.isAuthenticated();
diff --git a/src/stores/calendar/__tests__/store.test.ts b/src/stores/calendar/__tests__/store.test.ts
index 81f626b..1a9c8af 100644
--- a/src/stores/calendar/__tests__/store.test.ts
+++ b/src/stores/calendar/__tests__/store.test.ts
@@ -19,6 +19,8 @@ jest.mock('@/api/calendar/calendar', () => ({
// Mock the utils module
jest.mock('@/lib/utils', () => ({
isSameDate: jest.fn(),
+ getTodayLocalString: jest.fn(),
+ isDateInRange: jest.fn(),
}));
// Mock the logger
@@ -69,6 +71,7 @@ const mockCalendarItem = {
RecurrenceException: '',
ItemType: 1,
IsAllDay: false,
+ IsMultiDay: false,
Location: 'Test Location',
SignupType: 1,
Reminder: 0,
@@ -132,6 +135,13 @@ describe('Calendar Store', () => {
d1.getMonth() === d2.getMonth() &&
d1.getDate() === d2.getDate();
});
+ mockedUtils.getTodayLocalString.mockReturnValue('2024-01-15');
+ mockedUtils.isDateInRange.mockImplementation((date: string, eventStart: string, _eventEnd: string) => {
+ const dateStr = date.slice(0, 10);
+ const startStr = eventStart.slice(0, 10);
+ const endStr = _eventEnd.slice(0, 10);
+ return dateStr >= startStr && dateStr <= endStr;
+ });
// Reset store state
useCalendarStore.setState({
@@ -203,18 +213,6 @@ describe('Calendar Store', () => {
};
mockedApi.getCalendarItemsForDateRange.mockResolvedValue(mockResponse);
- // Mock isSameDate to return true only for the today item
- mockedUtils.isSameDate.mockImplementation((date1: string | Date, date2: string | Date) => {
- const d1 = new Date(date1);
- const d2 = new Date(date2);
- // Only return true for items on 2024-01-15
- if (d1.getFullYear() === 2024 && d1.getMonth() === 0 && d1.getDate() === 15 &&
- d2.getFullYear() === 2024 && d2.getMonth() === 0 && d2.getDate() === 15) {
- return true;
- }
- return false;
- });
-
const { result } = renderHook(() => useCalendarStore());
await act(async () => {
diff --git a/src/stores/calendar/store.ts b/src/stores/calendar/store.ts
index 515d7bd..e82c0ba 100644
--- a/src/stores/calendar/store.ts
+++ b/src/stores/calendar/store.ts
@@ -3,8 +3,8 @@ import { create } from 'zustand';
import { getCalendarItem, getCalendarItems, getCalendarItemsForDateRange, getCalendarItemTypes, setCalendarAttending } from '@/api/calendar/calendar';
import { logger } from '@/lib/logging';
-import { isSameDate } from '@/lib/utils';
-import { type CalendarItemResultData } from '@/models/v4/calendar/calendarItemResultData';
+import { getTodayLocalString, isDateInRange } from '@/lib/utils';
+import { type CalendarItemResultData, mapCalendarItemResultData } from '@/models/v4/calendar/calendarItemResultData';
import { type GetAllCalendarItemTypesResult } from '@/models/v4/calendar/calendarItemTypeResultData';
import type { ApiResponse } from '@/types/api';
@@ -108,10 +108,12 @@ export const useCalendarStore = create((set, get) => ({
const response = (await getCalendarItemsForDateRange(today.toISOString(), today.toISOString())) as ApiResponse;
- // Filter items to ensure they're really for today (additional client-side validation)
- // Use Start field for date comparison as it contains the timezone-aware date from .NET backend
- const todayItems = response.Data.filter((item: CalendarItemResultData) => {
- return isSameDate(item.Start, new Date());
+ // Filter items to ensure they're really for today.
+ // Always use range-based filtering so timed multi-day events (where IsMultiDay may
+ // not be set by the API) still appear on every day they cover, including day one.
+ const todayStr = getTodayLocalString();
+ const todayItems = response.Data.map(mapCalendarItemResultData).filter((item: CalendarItemResultData) => {
+ return isDateInRange(todayStr, item.Start, item.End, item.IsAllDay);
});
set({
@@ -152,7 +154,7 @@ export const useCalendarStore = create((set, get) => ({
const response = (await getCalendarItemsForDateRange(startDate, endDate)) as ApiResponse;
set({
- upcomingCalendarItems: response.Data,
+ upcomingCalendarItems: response.Data.map(mapCalendarItemResultData),
isUpcomingLoading: false,
updateCalendarItems: false,
});
@@ -178,7 +180,7 @@ export const useCalendarStore = create((set, get) => ({
const response = (await getCalendarItemsForDateRange(startDate, endDate)) as ApiResponse;
set({
- calendarItems: response.Data,
+ calendarItems: response.Data.map(mapCalendarItemResultData),
isLoading: false,
updateCalendarItems: false,
});
@@ -200,7 +202,7 @@ export const useCalendarStore = create((set, get) => ({
try {
const response = (await getCalendarItemsForDateRange(startDate, endDate)) as ApiResponse;
set({
- selectedMonthItems: response.Data,
+ selectedMonthItems: response.Data.map(mapCalendarItemResultData),
isLoading: false,
updateCalendarItems: false,
});
@@ -265,7 +267,7 @@ export const useCalendarStore = create((set, get) => ({
set({ isItemLoading: true, error: null });
try {
const response = (await getCalendarItem(calendarItemId)) as ApiResponse;
- set({ viewCalendarItem: response.Data, isItemLoading: false });
+ set({ viewCalendarItem: mapCalendarItemResultData(response.Data), isItemLoading: false });
logger.info({
message: 'Calendar item fetched successfully',
context: { calendarItemId },
diff --git a/src/translations/ar.json b/src/translations/ar.json
index 87334a0..6897619 100644
--- a/src/translations/ar.json
+++ b/src/translations/ar.json
@@ -573,6 +573,31 @@
"password": "كلمة المرور",
"password_incorrect": "كانت كلمة المرور غير صحيحة",
"password_placeholder": "أدخل كلمة المرور الخاصة بك",
+ "sso": {
+ "back": "رجوع",
+ "change_department": "تغيير المستخدم",
+ "continue_button": "متابعة",
+ "department_code_label": "رمز القسم",
+ "department_code_placeholder": "أدخل رمز القسم",
+ "department_code_required": "رمز القسم مطلوب",
+ "department_description": "أدخل رمز قسمك لتسجيل الدخول",
+ "department_id_invalid": "يجب أن يكون معرف القسم رقمًا",
+ "department_id_label": "معرف القسم (اختياري)",
+ "department_id_placeholder": "أدخل معرف القسم إن عرفته",
+ "department_not_found": "القسم غير موجود. يرجى التحقق من الرمز والمحاولة مرة أخرى.",
+ "login_with_sso_button": "تسجيل الدخول عبر SSO",
+ "looking_up": "جارٍ البحث عن الحساب...",
+ "lookup_network_error": "تعذّر الاتصال بالخادم. يرجى التحقق من اتصالك والمحاولة مرة أخرى.",
+ "or_sign_in_with_password": "— أو سجّل الدخول بكلمة المرور —",
+ "page_title": "تسجيل الدخول عبر SSO",
+ "sign_in_with_sso": "تسجيل الدخول عبر SSO",
+ "sso_not_enabled": "SSO غير مفعّل لهذا الحساب. يرجى استخدام اسم المستخدم وكلمة المرور.",
+ "user_description": "أدخل اسم مستخدم Resgrid أو بريدك الإلكتروني لتسجيل الدخول عبر SSO",
+ "user_not_found": "الحساب غير موجود. يرجى التحقق من اسم المستخدم والمحاولة مرة أخرى.",
+ "username_label": "اسم المستخدم أو البريد الإلكتروني",
+ "username_placeholder": "أدخل اسم المستخدم أو بريدك الإلكتروني",
+ "username_required": "اسم المستخدم مطلوب"
+ },
"title": "تسجيل الدخول",
"username": "اسم المستخدم",
"username_placeholder": "أدخل اسم المستخدم الخاص بك"
diff --git a/src/translations/en.json b/src/translations/en.json
index c3d50ae..761310a 100644
--- a/src/translations/en.json
+++ b/src/translations/en.json
@@ -573,6 +573,31 @@
"password": "Password",
"password_incorrect": "Password was incorrect",
"password_placeholder": "Enter your password",
+ "sso": {
+ "back": "Back",
+ "change_department": "Change username",
+ "continue_button": "Continue",
+ "department_code_label": "Department Code",
+ "department_code_placeholder": "Enter department code",
+ "department_code_required": "Department code is required",
+ "department_description": "Enter your department code to sign in",
+ "department_id_invalid": "Department ID must be a number",
+ "department_id_label": "Department ID (optional)",
+ "department_id_placeholder": "Enter department ID if known",
+ "department_not_found": "Department not found. Please check the code and try again.",
+ "login_with_sso_button": "Login with SSO",
+ "looking_up": "Looking up account...",
+ "lookup_network_error": "Could not reach the server. Please check your connection and try again.",
+ "or_sign_in_with_password": "— or sign in with password —",
+ "page_title": "Login with SSO",
+ "sign_in_with_sso": "Sign in with SSO",
+ "sso_not_enabled": "SSO is not enabled for this account. Please use your username and password.",
+ "user_description": "Enter your Resgrid username or email to sign in with SSO",
+ "user_not_found": "Account not found. Please check your username and try again.",
+ "username_label": "Username or Email",
+ "username_placeholder": "Enter your username or email",
+ "username_required": "Username is required"
+ },
"title": "Login",
"username": "Username",
"username_placeholder": "Enter your username"
diff --git a/src/translations/es.json b/src/translations/es.json
index 8ff6ee0..364a754 100644
--- a/src/translations/es.json
+++ b/src/translations/es.json
@@ -573,6 +573,31 @@
"password": "Contraseña",
"password_incorrect": "La contraseña era incorrecta",
"password_placeholder": "Introduce tu contraseña",
+ "sso": {
+ "back": "Atrás",
+ "change_department": "Cambiar usuario",
+ "continue_button": "Continuar",
+ "department_code_label": "Código de departamento",
+ "department_code_placeholder": "Introduce el código del departamento",
+ "department_code_required": "El código del departamento es obligatorio",
+ "department_description": "Introduce el código de tu departamento para iniciar sesión",
+ "department_id_invalid": "El ID de departamento debe ser un número",
+ "department_id_label": "ID de departamento (opcional)",
+ "department_id_placeholder": "Introduce el ID de departamento si lo conoces",
+ "department_not_found": "Departamento no encontrado. Por favor verifica el código e intenta de nuevo.",
+ "login_with_sso_button": "Iniciar sesión con SSO",
+ "looking_up": "Buscando cuenta...",
+ "lookup_network_error": "No se pudo contactar el servidor. Por favor verifica tu conexión e intenta de nuevo.",
+ "or_sign_in_with_password": "— o inicia sesión con contraseña —",
+ "page_title": "Iniciar sesión con SSO",
+ "sign_in_with_sso": "Iniciar sesión con SSO",
+ "sso_not_enabled": "SSO no está habilitado para esta cuenta. Por favor usa tu usuario y contraseña.",
+ "user_description": "Introduce tu usuario o correo de Resgrid para iniciar sesión con SSO",
+ "user_not_found": "Cuenta no encontrada. Por favor verifica tu usuario e intenta de nuevo.",
+ "username_label": "Usuario o correo electrónico",
+ "username_placeholder": "Introduce tu usuario o correo",
+ "username_required": "El usuario es obligatorio"
+ },
"title": "Iniciar sesión",
"username": "Nombre de usuario",
"username_placeholder": "Introduce tu nombre de usuario"
diff --git a/src/utils/InCallAudio.ts b/src/utils/InCallAudio.ts
index 9cc0db4..4e54d6c 100644
--- a/src/utils/InCallAudio.ts
+++ b/src/utils/InCallAudio.ts
@@ -52,9 +52,7 @@ class InCallAudioService {
if (InCallAudioModule) {
await InCallAudioModule.initializeAudio?.();
// Preload sounds
- const preloadPromises = Object.entries(SOUNDS).map(([name, config]) =>
- InCallAudioModule.loadSound(name, (config as any).android)
- );
+ const preloadPromises = Object.entries(SOUNDS).map(([name, config]) => InCallAudioModule.loadSound(name, (config as any).android));
await Promise.all(preloadPromises);
this.isInitialized = true;
@@ -103,15 +101,12 @@ class InCallAudioService {
} else {
// iOS
const source = SOUNDS[name].ios;
- const { sound } = await Audio.Sound.createAsync(
- source,
- { shouldPlay: true },
- async (status) => {
- if (status.isLoaded && status.didJustFinish) {
- await sound.unloadAsync();
- }
+ const { sound } = await Audio.Sound.createAsync(source, { shouldPlay: true });
+ sound.setOnPlaybackStatusUpdate(async (status) => {
+ if (status.isLoaded && status.didJustFinish) {
+ await sound.unloadAsync();
}
- );
+ });
}
} catch (error) {
logger.warn({ message: 'Failed to play in-call sound', context: { name, error } });
diff --git a/yarn.lock b/yarn.lock
index d8ce4eb..86c37fa 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5414,7 +5414,7 @@ balanced-match@^1.0.0:
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
-base64-js@1.5.1, base64-js@^1.2.3, base64-js@^1.3.1, base64-js@^1.5.1:
+base64-js@1.5.1, base64-js@^1.2.3, base64-js@^1.3.0, base64-js@^1.3.1, base64-js@^1.5.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
@@ -7412,6 +7412,18 @@ expo-audio@~0.4.9:
resolved "https://registry.yarnpkg.com/expo-audio/-/expo-audio-0.4.9.tgz#f15f64652785ecd416ad351bf42666315e1e0b69"
integrity sha512-J4mMYEt2mqRqqwmSsXFylMGlrNWa+MbCzGl1IZBs+smvPAMJ3Ni8fNplzCQ0I9RnRzygKhRwJNpnAVL+n4MuyA==
+expo-auth-session@~6.2.1:
+ version "6.2.1"
+ resolved "https://registry.yarnpkg.com/expo-auth-session/-/expo-auth-session-6.2.1.tgz#27c645575ce98508ed8a0faf2c586b04e1a1ba15"
+ integrity sha512-9KgqrGpW7PoNOhxJ7toofi/Dz5BU2TE4Q+ktJZsmDXLoFcNOcvBokh2+mkhG58Qvd/xJ9Z5sAt/5QoOFaPb9wA==
+ dependencies:
+ expo-application "~6.1.5"
+ expo-constants "~17.1.7"
+ expo-crypto "~14.1.5"
+ expo-linking "~7.1.7"
+ expo-web-browser "~14.2.0"
+ invariant "^2.2.4"
+
expo-av@~15.1.7:
version "15.1.7"
resolved "https://registry.yarnpkg.com/expo-av/-/expo-av-15.1.7.tgz#a8422646eca9250c842e8a44fccccb1a4b070a05"
@@ -7433,6 +7445,13 @@ expo-constants@~17.1.7:
"@expo/config" "~11.0.12"
"@expo/env" "~1.0.7"
+expo-crypto@~14.1.5:
+ version "14.1.5"
+ resolved "https://registry.yarnpkg.com/expo-crypto/-/expo-crypto-14.1.5.tgz#1c29ddd4657d96af6358a9ecdc85a0c344c9ae0c"
+ integrity sha512-ZXJoUMoUeiMNEoSD4itItFFz3cKrit6YJ/BR0hjuwNC+NczbV9rorvhvmeJmrU9O2cFQHhJQQR1fjQnt45Vu4Q==
+ dependencies:
+ base64-js "^1.3.0"
+
expo-dev-client@~5.2.4:
version "5.2.4"
resolved "https://registry.yarnpkg.com/expo-dev-client/-/expo-dev-client-5.2.4.tgz#cdffaea81841b2903cb9585bdd1566dea275a097"
@@ -7665,6 +7684,11 @@ expo-updates-interface@~1.1.0:
resolved "https://registry.yarnpkg.com/expo-updates-interface/-/expo-updates-interface-1.1.0.tgz#62497d4647b381da9fdb68868ed180203ae737ef"
integrity sha512-DeB+fRe0hUDPZhpJ4X4bFMAItatFBUPjw/TVSbJsaf3Exeami+2qbbJhWkcTMoYHOB73nOIcaYcWXYJnCJXO0w==
+expo-web-browser@~14.2.0:
+ version "14.2.0"
+ resolved "https://registry.yarnpkg.com/expo-web-browser/-/expo-web-browser-14.2.0.tgz#d8fb521ae349aebbf5c0ca32448877480124c06c"
+ integrity sha512-6S51d8pVlDRDsgGAp8BPpwnxtyKiMWEFdezNz+5jVIyT+ctReW42uxnjRgtsdn5sXaqzhaX+Tzk/CWaKCyC0hw==
+
expo@~53.0.23:
version "53.0.23"
resolved "https://registry.yarnpkg.com/expo/-/expo-53.0.23.tgz#b6fd102ac74537d86f99e87bd26a254a1b560b9b"