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"