diff --git a/__mocks__/expo-auth-session.ts b/__mocks__/expo-auth-session.ts new file mode 100644 index 00000000..7e3ed01b --- /dev/null +++ b/__mocks__/expo-auth-session.ts @@ -0,0 +1,21 @@ +// Mock for expo-auth-session +const mockExchangeCodeAsync = jest.fn(); +const mockMakeRedirectUri = jest.fn(() => 'resgridunit://auth/callback'); +const mockUseAutoDiscovery = jest.fn(() => ({ + authorizationEndpoint: 'https://idp.example.com/authorize', + tokenEndpoint: 'https://idp.example.com/token', +})); +const mockUseAuthRequest = jest.fn(() => [ + { codeVerifier: 'test-verifier' }, + null, + jest.fn(), +]); + +module.exports = { + makeRedirectUri: mockMakeRedirectUri, + useAutoDiscovery: mockUseAutoDiscovery, + useAuthRequest: mockUseAuthRequest, + exchangeCodeAsync: mockExchangeCodeAsync, + ResponseType: { Code: 'code' }, + __esModule: true, +}; diff --git a/__mocks__/expo-web-browser.ts b/__mocks__/expo-web-browser.ts new file mode 100644 index 00000000..4a9f145c --- /dev/null +++ b/__mocks__/expo-web-browser.ts @@ -0,0 +1,13 @@ +// Mock for expo-web-browser +const maybeCompleteAuthSession = jest.fn(() => ({ type: 'success' })); +const openBrowserAsync = jest.fn(() => Promise.resolve({ type: 'dismiss' })); +const openAuthSessionAsync = jest.fn(() => Promise.resolve({ type: 'dismiss' })); +const dismissBrowser = jest.fn(); + +module.exports = { + maybeCompleteAuthSession, + openBrowserAsync, + openAuthSessionAsync, + dismissBrowser, + __esModule: true, +}; diff --git a/app.config.ts b/app.config.ts index 39b4ff62..cccf848b 100644 --- a/app.config.ts +++ b/app.config.ts @@ -50,6 +50,8 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ ITSAppUsesNonExemptEncryption: false, UIViewControllerBasedStatusBarAppearance: false, NSBluetoothAlwaysUsageDescription: 'Allow Resgrid Unit to connect to bluetooth devices for PTT.', + // Allow the app to open its own custom-scheme deep links (needed for SSO callbacks) + LSApplicationQueriesSchemes: ['resgridunit'], }, entitlements: { ...((Env.APP_ENV === 'production' || Env.APP_ENV === 'internal') && { @@ -71,6 +73,15 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ softwareKeyboardLayoutMode: 'pan', package: Env.PACKAGE, googleServicesFile: 'google-services.json', + // Register the ResgridUnit:// deep-link scheme so OIDC / SAML callbacks are routed back here + intentFilters: [ + { + action: 'VIEW', + autoVerify: false, + data: [{ scheme: 'resgridunit' }], + category: ['BROWSABLE', 'DEFAULT'], + }, + ], permissions: [ 'android.permission.WAKE_LOCK', 'android.permission.RECORD_AUDIO', @@ -107,6 +118,8 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ 'expo-localization', 'expo-router', ['react-native-edge-to-edge'], + 'expo-web-browser', + 'expo-secure-store', [ '@rnmapbox/maps', { diff --git a/package.json b/package.json index f06eae7d..ff1f45f0 100644 --- a/package.json +++ b/package.json @@ -116,9 +116,11 @@ "expo-application": "~6.1.5", "expo-asset": "~11.1.7", "expo-audio": "~0.4.9", + "expo-auth-session": "~6.2.1", "expo-av": "~15.1.7", "expo-build-properties": "~0.14.8", "expo-constants": "~17.1.8", + "expo-crypto": "~14.1.5", "expo-dev-client": "~5.2.4", "expo-device": "~7.1.4", "expo-document-picker": "~13.1.6", @@ -134,11 +136,13 @@ "expo-navigation-bar": "~4.2.8", "expo-router": "~5.1.11", "expo-screen-orientation": "~8.1.7", + "expo-secure-store": "~14.2.4", "expo-sharing": "~13.1.5", "expo-splash-screen": "~0.30.10", "expo-status-bar": "~2.2.3", "expo-system-ui": "~5.0.11", "expo-task-manager": "~13.1.6", + "expo-web-browser": "~14.2.0", "geojson": "~0.5.0", "i18next": "~23.14.0", "livekit-client": "^2.15.7", diff --git a/src/app/login/__tests__/index.test.tsx b/src/app/login/__tests__/index.test.tsx index 6515f2b2..9eeb0462 100644 --- a/src/app/login/__tests__/index.test.tsx +++ b/src/app/login/__tests__/index.test.tsx @@ -13,6 +13,37 @@ jest.mock('expo-router', () => ({ }), })); +// Mock expo-linking (used for SAML deep-link handling) +jest.mock('expo-linking', () => ({ + addEventListener: jest.fn(() => ({ remove: jest.fn() })), + parse: jest.fn(() => ({ queryParams: {} })), +})); + +// Mock SSO discovery service +jest.mock('@/services/sso-discovery', () => ({ + fetchSsoConfigForUser: jest.fn(() => Promise.resolve({ config: null, userExists: false })), +})); + +// Mock OIDC hook +jest.mock('@/hooks/use-oidc-login', () => ({ + useOidcLogin: jest.fn(() => ({ + request: null, + response: null, + promptAsync: jest.fn(), + exchangeForResgridToken: jest.fn(), + discovery: null, + })), +})); + +// Mock SAML hook +jest.mock('@/hooks/use-saml-login', () => ({ + useSamlLogin: jest.fn(() => ({ + startSamlLogin: jest.fn(), + handleDeepLink: jest.fn(), + isSamlCallback: jest.fn(() => false), + })), +})); + // Mock UI components jest.mock('@/components/ui', () => { const React = require('react'); @@ -131,6 +162,7 @@ describe('Login', () => { // Set default mock return values mockUseAuth.mockReturnValue({ login: jest.fn(), + ssoLogin: jest.fn(), status: 'idle', error: null, isAuthenticated: false, @@ -182,6 +214,7 @@ describe('Login', () => { it('shows error modal when status is error', () => { mockUseAuth.mockReturnValue({ login: jest.fn(), + ssoLogin: jest.fn(), status: 'error', error: 'Invalid credentials', isAuthenticated: false, @@ -196,6 +229,7 @@ describe('Login', () => { it('redirects to app when authenticated', async () => { mockUseAuth.mockReturnValue({ login: jest.fn(), + ssoLogin: jest.fn(), status: 'signedIn', error: null, isAuthenticated: true, @@ -226,6 +260,7 @@ describe('Login', () => { const mockLogin = jest.fn(); mockUseAuth.mockReturnValue({ login: mockLogin, + ssoLogin: jest.fn(), status: 'idle', error: null, isAuthenticated: false, diff --git a/src/app/login/index.tsx b/src/app/login/index.tsx index 63abcab1..b0a79964 100644 --- a/src/app/login/index.tsx +++ b/src/app/login/index.tsx @@ -17,6 +17,7 @@ import { LoginForm } from './login-form'; export default function Login() { const [isErrorModalVisible, setIsErrorModalVisible] = useState(false); const [showServerUrl, setShowServerUrl] = useState(false); + const { t } = useTranslation(); const { trackEvent } = useAnalytics(); const router = useRouter(); @@ -32,35 +33,34 @@ export default function Login() { useEffect(() => { if (status === 'signedIn' && isAuthenticated) { - logger.info({ - message: 'Login successful, redirecting to home', - }); + logger.info({ message: 'Login successful, redirecting to home' }); router.push('/(app)'); } }, [status, isAuthenticated, router]); useEffect(() => { if (status === 'error') { - logger.error({ - message: 'Login failed', - context: { error }, - }); + logger.error({ message: 'Login failed', context: { error } }); setIsErrorModalVisible(true); } }, [status, error]); + // ── Local login ─────────────────────────────────────────────────────────── const onSubmit: LoginFormProps['onSubmit'] = async (data) => { - logger.info({ - message: 'Starting Login (button press)', - context: { username: data.username }, - }); + logger.info({ message: 'Starting Login (button press)', context: { username: data.username } }); await login({ username: data.username, password: data.password }); }; return ( <> - setShowServerUrl(true)} /> + setShowServerUrl(true)} + onSsoPress={() => router.push('/login/sso')} + /> .string({ required_error: 'Password is required', }) - .min(6, 'Password must be at least 6 characters'), + .min(1, 'Password is required'), }); const loginFormSchema = createLoginFormSchema(); @@ -40,14 +40,17 @@ export type LoginFormProps = { isLoading?: boolean; error?: string; onServerUrlPress?: () => void; + /** Called when the user taps "Sign In with SSO" to navigate to the SSO login page */ + onSsoPress?: () => void; }; -export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = undefined, onServerUrlPress }: LoginFormProps) => { +export const LoginForm = ({ onSubmit = () => { }, isLoading = false, error = undefined, onServerUrlPress, onSsoPress }: LoginFormProps) => { const { colorScheme } = useColorScheme(); const { t } = useTranslation(); const { control, handleSubmit, + getValues, formState: { errors }, } = useForm({ resolver: zodResolver(loginFormSchema), @@ -60,9 +63,7 @@ export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = unde const [showPassword, setShowPassword] = useState(false); const handleState = () => { - setShowPassword((showState) => { - return !showState; - }); + setShowPassword((showState) => !showState); }; const handleKeyPress = () => { Keyboard.dismiss(); @@ -74,12 +75,11 @@ export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = unde - Sign In - - - To login in to the Resgrid Unit app, please enter your username and password. Resgrid Unit is an app designed to interface between a Unit (apparatus, team, etc) and the Resgrid system. - + {t('login.title')} + {t('login.subtitle')} + + {/* Username */} {t('login.username')} @@ -91,10 +91,10 @@ export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = unde rules={{ validate: async (value) => { try { - await loginFormSchema.parseAsync({ username: value }); + await loginFormSchema.parseAsync({ username: value, password: 'placeholder' }); return true; - } catch (error: any) { - return error.message; + } catch (err: any) { + return err.message; } }, }} @@ -106,7 +106,7 @@ export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = unde onChangeText={onChange} onBlur={onBlur} onSubmitEditing={handleKeyPress} - returnKeyType="done" + returnKeyType="next" autoCapitalize="none" autoComplete="off" /> @@ -115,10 +115,11 @@ export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = unde /> - {errors?.username?.message || (!validated.usernameValid && 'Username not found')} + {errors?.username?.message} - {/* Label Message */} + + {/* Password form */} {t('login.password')} @@ -130,10 +131,10 @@ export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = unde rules={{ validate: async (value) => { try { - await loginFormSchema.parseAsync({ password: value }); + await loginFormSchema.parseAsync({ username: getValues('username'), password: value }); return true; - } catch (error: any) { - return error.message; + } catch (err: any) { + return err.message; } }, }} @@ -168,16 +169,27 @@ export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = unde {t('login.login_button_loading')} ) : ( - )} - {onServerUrlPress && ( - - )} + {error ? {error} : null} + + {/* Server URL + Sign In with SSO — side by side small buttons */} + + {onServerUrlPress ? ( + + ) : null} + {onSsoPress ? ( + + ) : null} + ); diff --git a/src/app/login/sso.tsx b/src/app/login/sso.tsx new file mode 100644 index 00000000..0b47d0bf --- /dev/null +++ b/src/app/login/sso.tsx @@ -0,0 +1,293 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import * as Linking from 'expo-linking'; +import { useRouter } from 'expo-router'; +import { AlertTriangle, ArrowLeft, ShieldCheck } from 'lucide-react-native'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { ActivityIndicator } from 'react-native'; +import { KeyboardAvoidingView } from 'react-native-keyboard-controller'; +import * as z from 'zod'; + +import { FocusAwareStatusBar, 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, InputSlot } from '@/components/ui/input'; +import { Modal, ModalBackdrop, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/ui/modal'; +import { Text } from '@/components/ui/text'; +import colors from '@/constants/colors'; +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 { fetchSsoConfigForUser } from '@/services/sso-discovery'; + +const ssoFormSchema = z.object({ + username: z.string({ required_error: 'Username is required' }).min(3, 'Username must be at least 3 characters'), + departmentId: z.string().optional(), +}); + +type FormType = z.infer; + +export default function SsoLogin() { + const [ssoConfig, setSsoConfig] = useState(null); + const [isLookingUpSso, setIsLookingUpSso] = useState(false); + const [isSsoLoading, setIsSsoLoading] = useState(false); + const [isErrorModalVisible, setIsErrorModalVisible] = useState(false); + const pendingUsernameRef = useRef(''); + + const { t } = useTranslation(); + const router = useRouter(); + const { ssoLogin, status } = useAuth(); + + const oidc = useOidcLogin({ + authority: ssoConfig?.authority ?? '', + clientId: ssoConfig?.clientId ?? '', + }); + + const { startSamlLogin, handleDeepLink, isSamlCallback } = useSamlLogin(); + + const { + control, + getValues, + formState: { errors }, + } = useForm({ resolver: zodResolver(ssoFormSchema) }); + + useEffect(() => { + if (status === 'signedIn') { + router.push('/(app)'); + } + }, [status, router]); + + useEffect(() => { + if (status === 'error') { + setIsSsoLoading(false); + setIsErrorModalVisible(true); + } + }, [status]); + + // ── OIDC response handler ───────────────────────────────────────────────── + useEffect(() => { + if (oidc.response?.type !== 'success') return; + + setIsSsoLoading(true); + oidc + .exchangeForResgridToken(pendingUsernameRef.current) + .then((result) => { + if (!result) { + setIsSsoLoading(false); + setIsErrorModalVisible(true); + return; + } + ssoLogin({ + provider: 'oidc', + externalToken: result.access_token, + username: pendingUsernameRef.current, + }); + }) + .catch(() => { + setIsSsoLoading(false); + setIsErrorModalVisible(true); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [oidc.response]); + + // ── Deep-link handler for SAML callbacks ───────────────────────────────── + useEffect(() => { + const subscription = Linking.addEventListener('url', async ({ url }: { url: string }) => { + if (!isSamlCallback(url)) return; + setIsSsoLoading(true); + const result = await handleDeepLink(url, pendingUsernameRef.current); + if (!result) { + setIsSsoLoading(false); + setIsErrorModalVisible(true); + return; + } + await ssoLogin({ + provider: 'saml2', + externalToken: result.access_token, + username: pendingUsernameRef.current, + }); + }); + + return () => subscription?.remove(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // ── SSO lookup (called from username or departmentId blur) ─────────────── + const triggerSsoLookup = useCallback(async (username: string, departmentIdStr?: string) => { + if (username.trim().length < 3) return; + pendingUsernameRef.current = username.trim(); + setIsLookingUpSso(true); + setSsoConfig(null); + + const deptId = departmentIdStr ? parseInt(departmentIdStr, 10) : undefined; + const resolvedDeptId = deptId && !isNaN(deptId) && deptId > 0 ? deptId : undefined; + + const { config } = await fetchSsoConfigForUser(username.trim(), resolvedDeptId); + setIsLookingUpSso(false); + + if (config) { + logger.info({ + message: 'SSO config fetched', + context: { ssoEnabled: config.ssoEnabled, providerType: config.providerType, departmentId: resolvedDeptId }, + }); + setSsoConfig(config); + } + }, []); + + const handleUsernameBlur = useCallback((username: string) => triggerSsoLookup(username, getValues('departmentId')), [triggerSsoLookup, getValues]); + + const handleDepartmentIdBlur = useCallback((departmentIdStr: string) => triggerSsoLookup(getValues('username'), departmentIdStr), [triggerSsoLookup, getValues]); + + // ── SSO button press ────────────────────────────────────────────────────── + const handleSsoPress = useCallback(async () => { + if (!ssoConfig) return; + setIsSsoLoading(true); + + if (ssoConfig.providerType === 'oidc') { + await oidc.promptAsync(); + // loading cleared by OIDC response useEffect + } else if (ssoConfig.providerType === 'saml2' && ssoConfig.idpSsoUrl) { + await startSamlLogin(ssoConfig.idpSsoUrl); + setIsSsoLoading(false); + } else { + setIsSsoLoading(false); + } + }, [ssoConfig, oidc, startSamlLogin]); + + const showSsoButton = ssoConfig?.ssoEnabled === true; + + return ( + <> + + + + {/* Back button */} + + + + + + + {t('login.sso_title')} + {t('login.sso_subtitle')} + + + {/* Username */} + + + {t('login.username')} + + ( + + { + onBlur(); + handleUsernameBlur(value); + }} + returnKeyType="done" + autoCapitalize="none" + autoComplete="off" + /> + {isLookingUpSso ? ( + + + + ) : null} + + )} + /> + + + {errors?.username?.message} + + + + {/* Department ID (optional) */} + + + {t('login.sso_department_id')} + + ( + + { + onBlur(); + handleDepartmentIdBlur(value ?? ''); + }} + returnKeyType="done" + keyboardType="number-pad" + autoCapitalize="none" + autoComplete="off" + /> + + )} + /> + + + {errors?.departmentId?.message} + + + + {/* SSO button — appears after lookup resolves */} + {showSsoButton ? ( + + {isSsoLoading ? ( + + ) : ( + + )} + {ssoConfig?.departmentName ? {t('login.sso_department', { name: ssoConfig.departmentName })} : null} + + ) : null} + + {/* Hint shown while waiting for lookup */} + {!isLookingUpSso && !showSsoButton && pendingUsernameRef.current ? {t('login.sso_not_found')} : null} + + + + {/* Error modal */} + setIsErrorModalVisible(false)} size="full" {...({} as any)}> + + + + {t('login.sso_error_title')} + + + {t('login.sso_error_message')} + + + + + + + + ); +} diff --git a/src/components/maps/static-map.tsx b/src/components/maps/static-map.tsx index 38b919d8..c6b48620 100644 --- a/src/components/maps/static-map.tsx +++ b/src/components/maps/static-map.tsx @@ -37,15 +37,7 @@ const StaticMap: React.FC = ({ latitude, longitude, address, zoo return ( - + {/* Marker pin for the location */} diff --git a/src/components/roles/role-assignment-item.tsx b/src/components/roles/role-assignment-item.tsx index 92026a65..cc2dcef8 100644 --- a/src/components/roles/role-assignment-item.tsx +++ b/src/components/roles/role-assignment-item.tsx @@ -96,7 +96,16 @@ export const RoleAssignmentItem: React.FC = ({ role, as - + ); }; 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 00000000..cab1b132 --- /dev/null +++ b/src/hooks/__tests__/use-oidc-login.test.ts @@ -0,0 +1,149 @@ +import { renderHook } from '@testing-library/react-native'; +import axios from 'axios'; +import * as AuthSession from 'expo-auth-session'; +import * as WebBrowser from 'expo-web-browser'; + +import { useOidcLogin } from '../use-oidc-login'; + +jest.mock('expo-auth-session'); +jest.mock('expo-web-browser'); +jest.mock('axios'); +jest.mock('@/lib/storage/app', () => ({ + getBaseApiUrl: jest.fn(() => 'https://api.resgrid.com/api/v4'), +})); +jest.mock('@/lib/logging', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})); + +const mockedAuthSession = AuthSession as jest.Mocked; +const mockedAxios = axios as jest.Mocked; + +describe('useOidcLogin', () => { + const mockPromptAsync = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + + (mockedAuthSession.useAutoDiscovery as jest.Mock).mockReturnValue({ + authorizationEndpoint: 'https://idp.example.com/authorize', + tokenEndpoint: 'https://idp.example.com/token', + }); + + (mockedAuthSession.useAuthRequest as jest.Mock).mockReturnValue([ + { codeVerifier: 'verifier123' }, + null, + mockPromptAsync, + ]); + + (mockedAuthSession.makeRedirectUri as jest.Mock).mockReturnValue( + 'resgridunit://auth/callback', + ); + }); + + it('renders without error', () => { + const { result } = renderHook(() => + useOidcLogin({ authority: 'https://idp.example.com', clientId: 'client123' }), + ); + + expect(result.current.request).toBeDefined(); + expect(result.current.promptAsync).toBe(mockPromptAsync); + expect(result.current.discovery).toBeDefined(); + }); + + it('returns null from exchangeForResgridToken when response is not success', async () => { + (mockedAuthSession.useAuthRequest as jest.Mock).mockReturnValue([ + { codeVerifier: 'verifier123' }, + { type: 'cancel' }, + mockPromptAsync, + ]); + + const { result } = renderHook(() => + useOidcLogin({ authority: 'https://idp.example.com', clientId: 'client123' }), + ); + + const tokenResult = await result.current.exchangeForResgridToken('john.doe'); + expect(tokenResult).toBeNull(); + }); + + it('returns null when id_token is missing from IdP response', async () => { + (mockedAuthSession.useAuthRequest as jest.Mock).mockReturnValue([ + { codeVerifier: 'verifier123' }, + { type: 'success', params: { code: 'auth-code-123' } }, + mockPromptAsync, + ]); + + (mockedAuthSession.exchangeCodeAsync as jest.Mock).mockResolvedValueOnce({ + idToken: undefined, + accessToken: 'some-token', + }); + + const { result } = renderHook(() => + useOidcLogin({ authority: 'https://idp.example.com', clientId: 'client123' }), + ); + + const tokenResult = await result.current.exchangeForResgridToken('john.doe'); + expect(tokenResult).toBeNull(); + }); + + it('exchanges id_token for Resgrid token on success', async () => { + (mockedAuthSession.useAuthRequest as jest.Mock).mockReturnValue([ + { codeVerifier: 'verifier123' }, + { type: 'success', params: { code: 'auth-code-123' } }, + mockPromptAsync, + ]); + + (mockedAuthSession.exchangeCodeAsync as jest.Mock).mockResolvedValueOnce({ + idToken: 'oidc-id-token', + accessToken: 'oidc-access', + }); + + mockedAxios.post = jest.fn().mockResolvedValueOnce({ + data: { + access_token: 'rg-access', + refresh_token: 'rg-refresh', + expires_in: 3600, + token_type: 'Bearer', + }, + }); + + const { result } = renderHook(() => + useOidcLogin({ authority: 'https://idp.example.com', clientId: 'client123' }), + ); + + const tokenResult = await result.current.exchangeForResgridToken('john.doe'); + + expect(tokenResult).toEqual({ + access_token: 'rg-access', + refresh_token: 'rg-refresh', + expires_in: 3600, + token_type: 'Bearer', + }); + + expect(mockedAxios.post).toHaveBeenCalledWith( + 'https://api.resgrid.com/api/v4/connect/external-token', + expect.stringContaining('external_token=oidc-id-token'), + expect.objectContaining({ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }), + ); + }); + + it('returns null when Resgrid API call fails', async () => { + (mockedAuthSession.useAuthRequest as jest.Mock).mockReturnValue([ + { codeVerifier: 'verifier123' }, + { type: 'success', params: { code: 'auth-code-123' } }, + mockPromptAsync, + ]); + + (mockedAuthSession.exchangeCodeAsync as jest.Mock).mockResolvedValueOnce({ + idToken: 'oidc-id-token', + }); + + mockedAxios.post = jest.fn().mockRejectedValueOnce(new Error('API Error')); + + const { result } = renderHook(() => + useOidcLogin({ authority: 'https://idp.example.com', clientId: 'client123' }), + ); + + const tokenResult = await result.current.exchangeForResgridToken('john.doe'); + expect(tokenResult).toBeNull(); + }); +}); 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 00000000..c7521d60 --- /dev/null +++ b/src/hooks/__tests__/use-saml-login.test.ts @@ -0,0 +1,140 @@ +import { renderHook } from '@testing-library/react-native'; +import axios from 'axios'; +import * as Linking from 'expo-linking'; +import * as WebBrowser from 'expo-web-browser'; + +import { useSamlLogin } from '../use-saml-login'; + +jest.mock('expo-web-browser'); +jest.mock('expo-linking', () => ({ + parse: jest.fn(), + addEventListener: jest.fn(() => ({ remove: jest.fn() })), +})); +jest.mock('axios'); +jest.mock('@/lib/storage/app', () => ({ + getBaseApiUrl: jest.fn(() => 'https://api.resgrid.com/api/v4'), +})); +jest.mock('@/lib/logging', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})); + +const mockedWebBrowser = WebBrowser as jest.Mocked; +const mockedLinking = Linking as jest.Mocked; +const mockedAxios = axios as jest.Mocked; + +describe('useSamlLogin', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders without error', () => { + const { result } = renderHook(() => useSamlLogin()); + expect(result.current.startSamlLogin).toBeDefined(); + expect(result.current.handleDeepLink).toBeDefined(); + expect(result.current.isSamlCallback).toBeDefined(); + }); + + it('startSamlLogin opens browser with the given URL', async () => { + (mockedWebBrowser.openBrowserAsync as jest.Mock).mockResolvedValueOnce({ type: 'dismiss' }); + + const { result } = renderHook(() => useSamlLogin()); + await result.current.startSamlLogin('https://idp.example.com/saml/sso'); + + expect(mockedWebBrowser.openBrowserAsync).toHaveBeenCalledWith( + 'https://idp.example.com/saml/sso', + ); + }); + + it('handleDeepLink returns null when saml_response param is missing', async () => { + (mockedLinking.parse as jest.Mock).mockReturnValueOnce({ + scheme: 'resgridunit', + path: 'auth/callback', + queryParams: {}, + }); + + const { result } = renderHook(() => useSamlLogin()); + const tokenResult = await result.current.handleDeepLink( + 'resgridunit://auth/callback', + 'john.doe', + ); + + expect(tokenResult).toBeNull(); + }); + + it('handleDeepLink exchanges SAMLResponse for Resgrid token on success', async () => { + (mockedLinking.parse as jest.Mock).mockReturnValueOnce({ + scheme: 'resgridunit', + path: 'auth/callback', + queryParams: { saml_response: 'base64SamlResponse' }, + }); + + mockedAxios.post = jest.fn().mockResolvedValueOnce({ + data: { + access_token: 'rg-access', + refresh_token: 'rg-refresh', + expires_in: 3600, + token_type: 'Bearer', + }, + }); + + const { result } = renderHook(() => useSamlLogin()); + const tokenResult = await result.current.handleDeepLink( + 'resgridunit://auth/callback?saml_response=base64SamlResponse', + 'john.doe', + ); + + expect(tokenResult).toEqual({ + access_token: 'rg-access', + refresh_token: 'rg-refresh', + expires_in: 3600, + token_type: 'Bearer', + }); + + expect(mockedAxios.post).toHaveBeenCalledWith( + 'https://api.resgrid.com/api/v4/connect/external-token', + expect.stringContaining('provider=saml2'), + expect.any(Object), + ); + }); + + it('handleDeepLink returns null when Resgrid API call fails', async () => { + (mockedLinking.parse as jest.Mock).mockReturnValueOnce({ + scheme: 'resgridunit', + path: 'auth/callback', + queryParams: { saml_response: 'base64SamlResponse' }, + }); + + mockedAxios.post = jest.fn().mockRejectedValueOnce(new Error('API Error')); + + const { result } = renderHook(() => useSamlLogin()); + const tokenResult = await result.current.handleDeepLink( + 'resgridunit://auth/callback?saml_response=base64SamlResponse', + 'john.doe', + ); + + expect(tokenResult).toBeNull(); + }); + + describe('isSamlCallback', () => { + it('returns true for SAML callback URLs', () => { + const { result } = renderHook(() => useSamlLogin()); + expect( + result.current.isSamlCallback( + 'resgridunit://auth/callback?saml_response=abc123', + ), + ).toBe(true); + }); + + it('returns false for OIDC callback URLs without saml_response', () => { + const { result } = renderHook(() => useSamlLogin()); + expect( + result.current.isSamlCallback('resgridunit://auth/callback?code=abc&state=xyz'), + ).toBe(false); + }); + + it('returns false for unrelated URLs', () => { + const { result } = renderHook(() => useSamlLogin()); + expect(result.current.isSamlCallback('https://example.com')).toBe(false); + }); + }); +}); diff --git a/src/hooks/use-oidc-login.ts b/src/hooks/use-oidc-login.ts new file mode 100644 index 00000000..156e6fc8 --- /dev/null +++ b/src/hooks/use-oidc-login.ts @@ -0,0 +1,105 @@ +import axios from 'axios'; +import * as AuthSession from 'expo-auth-session'; +import * as WebBrowser from 'expo-web-browser'; + +import { logger } from '@/lib/logging'; +import { getBaseApiUrl } from '@/lib/storage/app'; + +// Required for iOS / Android to close the browser after redirect +WebBrowser.maybeCompleteAuthSession(); + +export interface UseOidcLoginOptions { + authority: string; + clientId: string; +} + +export interface OidcExchangeResult { + access_token: string; + refresh_token: string; + id_token?: string; + expires_in: number; + token_type: string; + expiration_date?: string; +} + +/** + * Hook that drives the OIDC Authorization-Code + PKCE flow. + * + * Usage: + * const { request, promptAsync, exchangeForResgridToken } = useOidcLogin({ authority, clientId }); + * // 1. call promptAsync() on button press + * // 2. watch response inside a useEffect and call exchangeForResgridToken(username) when type === 'success' + */ +export function useOidcLogin({ authority, clientId }: UseOidcLoginOptions) { + const redirectUri = AuthSession.makeRedirectUri({ + scheme: 'ResgridUnit', + path: 'auth/callback', + }); + + const discovery = AuthSession.useAutoDiscovery(authority); + + const [request, response, promptAsync] = AuthSession.useAuthRequest( + { + clientId, + redirectUri, + scopes: ['openid', 'email', 'profile', 'offline_access'], + usePKCE: true, + responseType: AuthSession.ResponseType.Code, + }, + discovery + ); + + /** + * Exchange the OIDC authorization code for a Resgrid access token. + * Should be called after `response?.type === 'success'`. + */ + async function exchangeForResgridToken(username: string): Promise { + if (response?.type !== 'success' || !request?.codeVerifier || !discovery) { + logger.warn({ + message: 'OIDC exchange called in invalid state', + context: { responseType: response?.type, hasCodeVerifier: !!request?.codeVerifier, hasDiscovery: !!discovery }, + }); + return null; + } + + try { + // Step 1: exchange auth code for id_token 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: 'OIDC exchange: no id_token in IdP response' }); + return null; + } + + // Step 2: exchange id_token for Resgrid access/refresh tokens + const params = new URLSearchParams({ + provider: 'oidc', + external_token: idToken, + username, + scope: 'openid email profile offline_access mobile', + }); + + const resgridResponse = await axios.post(`${getBaseApiUrl()}/connect/external-token`, params.toString(), { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }); + + logger.info({ message: 'OIDC Resgrid token exchange successful' }); + return resgridResponse.data; + } catch (error) { + logger.error({ + message: 'OIDC token exchange failed', + context: { error: error instanceof Error ? error.message : String(error) }, + }); + return null; + } + } + + return { request, response, promptAsync, exchangeForResgridToken, discovery }; +} diff --git a/src/hooks/use-saml-login.ts b/src/hooks/use-saml-login.ts new file mode 100644 index 00000000..ea1cc9c2 --- /dev/null +++ b/src/hooks/use-saml-login.ts @@ -0,0 +1,90 @@ +import axios from 'axios'; +import * as Linking from 'expo-linking'; +import * as WebBrowser from 'expo-web-browser'; + +import { logger } from '@/lib/logging'; +import { getBaseApiUrl } from '@/lib/storage/app'; + +export interface SamlExchangeResult { + access_token: string; + refresh_token: string; + id_token?: string; + expires_in: number; + token_type: string; + expiration_date?: string; +} + +/** + * SAML 2.0 SSO flow: + * 1. Open the IdP-initiated SSO URL in the system browser. + * 2. The IdP POSTs a SAMLResponse to the SP ACS endpoint. + * 3. The backend ACS endpoint redirects to ResgridUnit://auth/callback?saml_response=. + * 4. The app intercepts the deep link and calls handleDeepLink() to exchange + * the SAMLResponse for Resgrid access/refresh tokens. + * + * NOTE: The backend must expose a + * GET/POST /api/v4/connect/saml-mobile-callback endpoint that accepts the + * SAMLResponse and issues a 302 redirect to the app scheme (see plan Step 8). + */ +export function useSamlLogin() { + /** + * Open the IdP SSO URL in the system browser. + * The browser will handle the full SAML redirect chain. + */ + async function startSamlLogin(idpSsoUrl: string): Promise { + try { + await WebBrowser.openBrowserAsync(idpSsoUrl); + } catch (error) { + logger.error({ + message: 'Failed to open SAML SSO browser', + context: { error: error instanceof Error ? error.message : String(error) }, + }); + } + } + + /** + * Handle the deep-link callback that carries the base64-encoded SAMLResponse. + * Returns the Resgrid token pair on success, or null on failure. + * + * @param url The full deep-link URL (e.g. ResgridUnit://auth/callback?saml_response=...) + * @param username The username entered before the SAML flow started (used by the backend) + */ + async function handleDeepLink(url: string, username: string): Promise { + try { + const parsed = Linking.parse(url); + const samlResponse = parsed.queryParams?.saml_response as string | undefined; + + if (!samlResponse) { + logger.warn({ message: 'SAML deep-link missing saml_response param', context: { url } }); + return null; + } + + const params = new URLSearchParams({ + provider: 'saml2', + external_token: samlResponse, + username, + scope: 'openid email profile offline_access mobile', + }); + + const resgridResponse = await axios.post(`${getBaseApiUrl()}/connect/external-token`, params.toString(), { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }); + + logger.info({ message: 'SAML Resgrid token exchange successful' }); + return resgridResponse.data; + } catch (error) { + logger.error({ + message: 'SAML token exchange failed', + context: { error: error instanceof Error ? error.message : String(error) }, + }); + return null; + } + } + + /** + * Check whether a deep-link URL is a SAML callback. + */ + function isSamlCallback(url: string): boolean { + return url.includes('auth/callback') && url.includes('saml_response'); + } + + return { startSamlLogin, handleDeepLink, isSamlCallback }; +} diff --git a/src/lib/auth/__tests__/sso-api.test.ts b/src/lib/auth/__tests__/sso-api.test.ts new file mode 100644 index 00000000..2ac5739a --- /dev/null +++ b/src/lib/auth/__tests__/sso-api.test.ts @@ -0,0 +1,91 @@ +import { ssoExternalTokenRequest } from '../api'; + +// `var` is hoisted above jest.mock so the factory lambda can close over it. +// eslint-disable-next-line no-var +var mockPost: jest.Mock = jest.fn(); + +jest.mock('axios', () => ({ + // Wrap in an arrow so that the binding is resolved at call-time, not factory-time. + create: jest.fn(() => ({ post: (...args: unknown[]) => mockPost(...args) })), + isAxiosError: jest.fn(), +})); + +jest.mock('@env', () => ({ + Env: { IS_MOBILE_APP: 'true' }, +})); +jest.mock('@/lib/storage/app', () => ({ + getBaseApiUrl: jest.fn(() => 'https://api.resgrid.com/api/v4'), +})); +jest.mock('@/lib/logging', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})); + +describe('ssoExternalTokenRequest', () => { + beforeEach(() => { + mockPost.mockReset(); + }); + + it('returns successful response on valid OIDC token', async () => { + mockPost.mockResolvedValueOnce({ + status: 200, + data: { + access_token: 'rg-access', + refresh_token: 'rg-refresh', + id_token: 'rg-id', + expires_in: 3600, + token_type: 'Bearer', + }, + }); + + const result = await ssoExternalTokenRequest({ + provider: 'oidc', + externalToken: 'idp-id-token', + username: 'john.doe', + }); + + expect(result.successful).toBe(true); + expect(result.authResponse?.access_token).toBe('rg-access'); + expect(mockPost).toHaveBeenCalledWith( + '/connect/external-token', + expect.stringContaining('provider=oidc'), + ); + }); + + it('returns successful response on valid SAML token', async () => { + mockPost.mockResolvedValueOnce({ + status: 200, + data: { + access_token: 'rg-access-saml', + refresh_token: 'rg-refresh-saml', + id_token: 'rg-id-saml', + expires_in: 3600, + token_type: 'Bearer', + }, + }); + + const result = await ssoExternalTokenRequest({ + provider: 'saml2', + externalToken: 'base64SamlResponse', + username: 'john.doe', + }); + + expect(result.successful).toBe(true); + expect(mockPost).toHaveBeenCalledWith( + '/connect/external-token', + expect.stringContaining('provider=saml2'), + ); + }); + + it('throws when the API call fails', async () => { + mockPost.mockRejectedValueOnce(new Error('Unauthorized')); + + await expect( + ssoExternalTokenRequest({ + provider: 'oidc', + externalToken: 'bad-token', + username: 'unknown', + }), + ).rejects.toThrow('Unauthorized'); + }); +}); + diff --git a/src/lib/auth/api.tsx b/src/lib/auth/api.tsx index 10d810ae..4cd0a1cf 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, LoginCredentials, LoginResponse, SsoLoginCredentials } 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, + username: credentials.username, + scope: Env.IS_MOBILE_APP === 'true' ? '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: 'SSO external token exchange successful', + context: { provider: credentials.provider, username: credentials.username }, + }); + + return { + successful: true, + message: 'SSO login successful', + authResponse: response.data, + }; + } + + logger.error({ + message: 'SSO external token exchange failed', + context: { status: response.status, username: credentials.username }, + }); + + return { successful: false, message: 'SSO login failed', authResponse: null }; + } catch (error) { + logger.error({ + message: 'SSO external token exchange error', + context: { error, username: credentials.username }, + }); + throw error; + } +}; diff --git a/src/lib/auth/index.tsx b/src/lib/auth/index.tsx index 9c98d087..305bb219 100644 --- a/src/lib/auth/index.tsx +++ b/src/lib/auth/index.tsx @@ -10,12 +10,13 @@ export * from './types'; // Utility hooks and selectors export const useAuth = () => { - const { accessToken, status, error, login, logout, hydrate } = useAuthStore( + const { accessToken, status, error, login, ssoLogin, logout, hydrate } = useAuthStore( useShallow((state) => ({ accessToken: state.accessToken, status: state.status, error: state.error, login: state.login, + ssoLogin: state.ssoLogin, logout: state.logout, hydrate: state.hydrate, })) @@ -25,6 +26,7 @@ export const useAuth = () => { isLoading: status === 'loading', error, login, + ssoLogin, logout, status, hydrate, diff --git a/src/lib/auth/types.tsx b/src/lib/auth/types.tsx index 42e66d14..2b3f2d28 100644 --- a/src/lib/auth/types.tsx +++ b/src/lib/auth/types.tsx @@ -3,6 +3,13 @@ export interface AuthTokens { refreshToken: string; } +export interface SsoLoginCredentials { + /** The external token: id_token (OIDC) or base64 SAMLResponse (SAML 2.0) */ + externalToken: string; + provider: 'oidc' | 'saml2'; + username: string; +} + export interface LoginCredentials { username: string; password: string; @@ -48,6 +55,7 @@ export interface AuthState { userId: string | null; refreshTimeoutId: ReturnType | null; login: (credentials: LoginCredentials) => Promise; + ssoLogin: (credentials: SsoLoginCredentials) => Promise; logout: () => Promise; refreshAccessToken: () => Promise; hydrate: () => void; diff --git a/src/services/__tests__/sso-discovery.test.ts b/src/services/__tests__/sso-discovery.test.ts new file mode 100644 index 00000000..5498d062 --- /dev/null +++ b/src/services/__tests__/sso-discovery.test.ts @@ -0,0 +1,96 @@ +import axios from 'axios'; + +import { fetchSsoConfigForUser } from '../sso-discovery'; + +jest.mock('axios'); +jest.mock('@/lib/storage/app', () => ({ + getBaseApiUrl: jest.fn(() => 'https://api.resgrid.com/api/v4'), +})); + +const mockedAxios = axios as jest.Mocked; + +describe('fetchSsoConfigForUser', () => { + beforeEach(() => { + jest.clearAllMocks(); + (mockedAxios.isAxiosError as unknown as jest.Mock).mockReturnValue(false); + }); + + it('returns config and userExists=true on success', async () => { + const config = { + ssoEnabled: true, + providerType: 'oidc', + authority: 'https://idp.example.com', + clientId: 'client123', + metadataUrl: null, + entityId: null, + idpSsoUrl: null, + allowLocalLogin: true, + requireSso: false, + requireMfa: false, + oidcRedirectUri: 'resgridunit://auth/callback', + oidcScopes: 'openid email profile offline_access', + departmentId: 42, + departmentName: 'Test Department', + }; + + mockedAxios.get = jest.fn().mockResolvedValueOnce({ data: { Data: config } }); + + const result = await fetchSsoConfigForUser('john.doe'); + + expect(result).toEqual({ config, userExists: true }); + expect(mockedAxios.get).toHaveBeenCalledWith( + 'https://api.resgrid.com/api/v4/connect/sso-config-for-user', + { params: { username: 'john.doe' } }, + ); + }); + + it('passes departmentId when provided', async () => { + mockedAxios.get = jest.fn().mockResolvedValueOnce({ data: { Data: { ssoEnabled: false } } }); + + await fetchSsoConfigForUser('john.doe', 99); + + expect(mockedAxios.get).toHaveBeenCalledWith( + 'https://api.resgrid.com/api/v4/connect/sso-config-for-user', + { params: { username: 'john.doe', departmentId: 99 } }, + ); + }); + + it('returns { config: null, userExists: false } when Data is missing', async () => { + mockedAxios.get = jest.fn().mockResolvedValueOnce({ data: {} }); + + const result = await fetchSsoConfigForUser('unknown'); + + expect(result).toEqual({ config: null, userExists: false }); + }); + + it('returns { config: null, userExists: false } on 404 (user not a member)', async () => { + const axiosError = { response: { status: 404 } }; + (mockedAxios.isAxiosError as unknown as jest.Mock).mockReturnValueOnce(true); + mockedAxios.get = jest.fn().mockRejectedValueOnce(axiosError); + + const result = await fetchSsoConfigForUser('john.doe', 5); + + expect(result).toEqual({ config: null, userExists: false }); + }); + + it('returns { config: null, userExists: false } on network error', async () => { + mockedAxios.get = jest.fn().mockRejectedValueOnce(new Error('Network Error')); + + const result = await fetchSsoConfigForUser('john.doe'); + + expect(result).toEqual({ config: null, userExists: false }); + }); + + it('returns { config: null, userExists: false } when ssoEnabled is false', async () => { + mockedAxios.get = jest.fn().mockResolvedValueOnce({ + data: { Data: { ssoEnabled: false, allowLocalLogin: true } }, + }); + + const result = await fetchSsoConfigForUser('localuser'); + + expect(result).toEqual({ + config: { ssoEnabled: false, allowLocalLogin: true }, + userExists: true, + }); + }); +}); diff --git a/src/services/sso-discovery.ts b/src/services/sso-discovery.ts new file mode 100644 index 00000000..535f78bd --- /dev/null +++ b/src/services/sso-discovery.ts @@ -0,0 +1,61 @@ +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; + idpSsoUrl: string | null; + allowLocalLogin: boolean; + requireSso: boolean; + requireMfa: boolean; + oidcRedirectUri: string; + oidcScopes: string; + departmentId: number | null; + departmentName: string | null; +} + +export interface SsoConfigForUserResult { + config: DepartmentSsoConfig | null; + userExists: boolean; +} + +/** + * Fetch the SSO configuration for a given username (and optional departmentId). + * Uses the updated /api/v4/Connect/sso-config-for-user endpoint which does + * username-first discovery: it resolves the user's active/default department + * automatically, or scopes to a specific department when departmentId is provided. + * + * Returns { config: null, userExists: false } when the account does not exist + * (the backend returns allowLocalLogin:true / ssoEnabled:false to avoid + * account enumeration, so we treat a null / empty response as "not found"). + */ +export async function fetchSsoConfigForUser(username: string, departmentId?: number): Promise { + try { + const params: Record = { username }; + if (departmentId !== undefined) { + params.departmentId = departmentId; + } + + const response = await axios.get(`${getBaseApiUrl()}/connect/sso-config-for-user`, { + params, + }); + + const data = response.data?.Data ?? null; + if (!data) { + return { config: null, userExists: false }; + } + + return { config: data as DepartmentSsoConfig, userExists: true }; + } catch (error: unknown) { + if (axios.isAxiosError(error) && error.response?.status === 404) { + // User not a member of the specified department + return { config: null, userExists: false }; + } + return { config: null, userExists: false }; + } +} diff --git a/src/stores/app/livekit-store.ts b/src/stores/app/livekit-store.ts index 45110df1..0fda9bb2 100644 --- a/src/stores/app/livekit-store.ts +++ b/src/stores/app/livekit-store.ts @@ -454,11 +454,7 @@ export const useLiveKitStore = create((set, get) => ({ context: { roomName: roomInfo.Name, timeoutMs: CONNECT_OVERALL_TIMEOUT_MS }, }); set({ isConnecting: false }); - Alert.alert( - 'Voice Connection Timeout', - `The connection to "${roomInfo.Name}" took too long. Please try again.`, - [{ text: 'OK' }] - ); + Alert.alert('Voice Connection Timeout', `The connection to "${roomInfo.Name}" took too long. Please try again.`, [{ text: 'OK' }]); } }, CONNECT_OVERALL_TIMEOUT_MS); @@ -493,11 +489,7 @@ export const useLiveKitStore = create((set, get) => ({ context: { roomName: roomInfo.Name }, }); - const permissionsGranted = await withTimeout( - get().requestPermissions(), - 10_000, - 'requestPermissions' - ); + const permissionsGranted = await withTimeout(get().requestPermissions(), 10_000, 'requestPermissions'); logger.debug({ message: 'connectToRoom: microphone permission result', @@ -530,11 +522,7 @@ export const useLiveKitStore = create((set, get) => ({ if (Platform.OS !== 'web') { try { logger.debug({ message: 'connectToRoom: starting audio session' }); - await withTimeout( - AudioSession.startAudioSession(), - 10_000, - 'AudioSession.startAudioSession' - ); + await withTimeout(AudioSession.startAudioSession(), 10_000, 'AudioSession.startAudioSession'); logger.info({ message: 'Audio session started successfully', }); @@ -587,11 +575,7 @@ export const useLiveKitStore = create((set, get) => ({ hasToken: !!token, }, }); - await withTimeout( - room.connect(voipServerWebsocketSslAddress, token), - 15_000, - 'room.connect' - ); + await withTimeout(room.connect(voipServerWebsocketSslAddress, token), 15_000, 'room.connect'); logger.info({ message: 'LiveKit room connected successfully', context: { roomName: roomInfo.Name }, diff --git a/src/stores/auth/store.tsx b/src/stores/auth/store.tsx index 48074b31..7aac887b 100644 --- a/src/stores/auth/store.tsx +++ b/src/stores/auth/store.tsx @@ -4,8 +4,8 @@ import { createJSONStorage, persist } from 'zustand/middleware'; import { logger } from '@/lib/logging'; -import { loginRequest, refreshTokenRequest } from '../../lib/auth/api'; -import type { AuthResponse, AuthState, LoginCredentials } from '../../lib/auth/types'; +import { loginRequest, refreshTokenRequest, ssoExternalTokenRequest } from '../../lib/auth/api'; +import type { AuthResponse, AuthState, LoginCredentials, SsoLoginCredentials } from '../../lib/auth/types'; import { type ProfileModel } from '../../lib/auth/types'; import { getAuth } from '../../lib/auth/utils'; import { setItem, zustandStorage } from '../../lib/storage'; @@ -86,6 +86,52 @@ const useAuthStore = create()( } }, + ssoLogin: async (credentials: SsoLoginCredentials) => { + try { + set({ status: 'loading' }); + const response = await ssoExternalTokenRequest(credentials); + + if (response.successful && response.authResponse) { + const payload = sanitizeJson(base64.decode(response.authResponse.id_token!.split('.')[1])); + + setItem('authResponse', response.authResponse); + const expiresOn = new Date(Date.now() + response.authResponse.expires_in * 1000).getTime().toString(); + + const profileData = JSON.parse(payload) as ProfileModel; + + set({ + accessToken: response.authResponse.access_token, + refreshToken: response.authResponse.refresh_token, + refreshTokenExpiresOn: expiresOn, + status: 'signedIn', + error: null, + profile: profileData, + userId: profileData.sub, + }); + + const refreshDelayMs = Math.max((response.authResponse.expires_in - 60) * 1000, 60000); + logger.info({ + message: 'SSO login successful, scheduling token refresh', + context: { refreshDelayMs, provider: credentials.provider }, + }); + + const existingTimeoutId = get().refreshTimeoutId; + if (existingTimeoutId !== null) { + clearTimeout(existingTimeoutId); + } + const timeoutId = setTimeout(() => get().refreshAccessToken(), refreshDelayMs); + set({ refreshTimeoutId: timeoutId }); + } else { + set({ status: 'error', error: response.message }); + } + } catch (error) { + set({ + status: 'error', + error: error instanceof Error ? error.message : 'SSO login failed', + }); + } + }, + logout: async () => { // Clear any pending refresh timer to prevent stacked timeouts const existingTimeoutId = get().refreshTimeoutId; diff --git a/src/stores/roles/store.ts b/src/stores/roles/store.ts index c7fcbc99..e912c397 100644 --- a/src/stores/roles/store.ts +++ b/src/stores/roles/store.ts @@ -39,10 +39,7 @@ export const useRolesStore = create((set) => ({ } set({ isLoading: true, error: null }); try { - const [response, personnelResponse] = await Promise.all([ - getAllUnitRolesAndAssignmentsForDepartment(), - getAllPersonnelInfos(''), - ]); + const [response, personnelResponse] = await Promise.all([getAllUnitRolesAndAssignmentsForDepartment(), getAllPersonnelInfos('')]); set({ roles: response.Data, @@ -101,11 +98,7 @@ export const useRolesStore = create((set) => ({ fetchAllForUnit: async (unitId: string) => { set({ isLoading: true, error: null }); try { - const [rolesResponse, unitRoles, personnelResponse] = await Promise.all([ - getAllUnitRolesAndAssignmentsForDepartment(), - getRoleAssignmentsForUnit(unitId), - getAllPersonnelInfos(''), - ]); + const [rolesResponse, unitRoles, personnelResponse] = await Promise.all([getAllUnitRolesAndAssignmentsForDepartment(), getRoleAssignmentsForUnit(unitId), getAllPersonnelInfos('')]); set({ roles: rolesResponse.Data, diff --git a/src/translations/ar.json b/src/translations/ar.json index a6d31e30..66b9a45f 100644 --- a/src/translations/ar.json +++ b/src/translations/ar.json @@ -472,11 +472,24 @@ "login_button_error": "خطأ في تسجيل الدخول", "login_button_loading": "تسجيل الدخول...", "login_button_success": "تم تسجيل الدخول بنجاح", + "or_sign_in_with_password": "أو تسجيل الدخول بكلمة المرور", "password": "كلمة المرور", "password_incorrect": "كانت كلمة المرور غير صحيحة", "password_placeholder": "أدخل كلمة المرور الخاصة بك", + "sso_button": "تسجيل الدخول عبر SSO", + "sso_department": "SSO مقدم من {{name}}", + "sso_department_id": "معرف القسم (اختياري)", + "sso_department_id_placeholder": "أدخل معرف القسم الخاص بك", + "sso_error_message": "تعذر إتمام تسجيل الدخول عبر SSO. يرجى المحاولة مرة أخرى.", + "sso_error_title": "خطأ في SSO", + "sso_not_found": "لم يتم العثور على تكوين SSO لاسم المستخدم هذا", + "sso_signing_in": "جارٍ تسجيل الدخول عبر SSO...", + "sso_subtitle": "أدخل اسم المستخدم الخاص بك للبحث عن تكوين SSO", + "sso_title": "تسجيل الدخول عبر SSO", + "subtitle": "أدخل بيانات اعتمادك للوصول إلى Resgrid Unit", "title": "تسجيل الدخول", "username": "اسم المستخدم", + "username_not_found": "اسم المستخدم غير موجود", "username_placeholder": "أدخل اسم المستخدم الخاص بك" }, "map": { diff --git a/src/translations/en.json b/src/translations/en.json index 01a32a73..4067e456 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -467,16 +467,29 @@ "title": "Login Failed" }, "login": "Login", - "login_button": "Login", + "login_button": "Sign In", "login_button_description": "Login to your account to continue", "login_button_error": "Error logging in", - "login_button_loading": "Logging in...", + "login_button_loading": "Signing in...", "login_button_success": "Logged in successfully", + "or_sign_in_with_password": "or sign in with password", "password": "Password", "password_incorrect": "Password was incorrect", "password_placeholder": "Enter your password", - "title": "Login", + "sso_button": "Sign in with SSO", + "sso_department": "SSO provided by {{name}}", + "sso_department_id": "Department ID (optional)", + "sso_department_id_placeholder": "Enter your department ID", + "sso_error_message": "Could not complete SSO sign-in. Please try again.", + "sso_error_title": "SSO Error", + "sso_not_found": "No SSO configuration found for this username", + "sso_signing_in": "Signing in with SSO...", + "sso_subtitle": "Enter your username to look up your SSO configuration", + "sso_title": "Sign In with SSO", + "subtitle": "Enter your credentials to access Resgrid Unit", + "title": "Sign In", "username": "Username", + "username_not_found": "Username not found", "username_placeholder": "Enter your username" }, "map": { diff --git a/src/translations/es.json b/src/translations/es.json index 0f270231..5bfbc847 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -472,11 +472,24 @@ "login_button_error": "Error al iniciar sesión", "login_button_loading": "Iniciando sesión...", "login_button_success": "Sesión iniciada con éxito", + "or_sign_in_with_password": "o inicia sesión con contraseña", "password": "Contraseña", "password_incorrect": "La contraseña era incorrecta", "password_placeholder": "Introduce tu contraseña", + "sso_button": "Iniciar sesión con SSO", + "sso_department": "SSO proporcionado por {{name}}", + "sso_department_id": "ID de departamento (opcional)", + "sso_department_id_placeholder": "Ingresa tu ID de departamento", + "sso_error_message": "No se pudo completar el inicio de sesión con SSO. Inténtalo de nuevo.", + "sso_error_title": "Error de SSO", + "sso_not_found": "No se encontró configuración de SSO para este nombre de usuario", + "sso_signing_in": "Iniciando sesión con SSO...", + "sso_subtitle": "Ingresa tu nombre de usuario para buscar tu configuración de SSO", + "sso_title": "Iniciar sesión con SSO", + "subtitle": "Introduce tus credenciales para acceder a Resgrid Unit", "title": "Iniciar sesión", "username": "Nombre de usuario", + "username_not_found": "Usuario no encontrado", "username_placeholder": "Introduce tu nombre de usuario" }, "map": { diff --git a/yarn.lock b/yarn.lock index fbcf9540..3e75924a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6489,7 +6489,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== @@ -8861,6 +8861,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" @@ -8890,6 +8902,13 @@ expo-constants@~17.1.8: "@expo/config" "~11.0.13" "@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" @@ -9064,6 +9083,11 @@ expo-screen-orientation@~8.1.7: resolved "https://registry.yarnpkg.com/expo-screen-orientation/-/expo-screen-orientation-8.1.7.tgz#3751b441f2bfcbde798b1508c0ff9f099f4be911" integrity sha512-nYwadYtdU6mMDk0MCHMPPPQtBoeFYJ2FspLRW+J35CMLqzE4nbpwGeiImfXzkvD94fpOCfI4KgLj5vGauC3pfA== +expo-secure-store@~14.2.4: + version "14.2.4" + resolved "https://registry.yarnpkg.com/expo-secure-store/-/expo-secure-store-14.2.4.tgz#673743567a6459fb4b5f9406d57d9a3b16bca69f" + integrity sha512-ePaz4fnTitJJZjAiybaVYGfLWWyaEtepZC+vs9ZBMhQMfG5HUotIcVsDaSo3FnwpHmgwsLVPY2qFeryI6AtULw== + expo-sharing@~13.1.5: version "13.1.5" resolved "https://registry.yarnpkg.com/expo-sharing/-/expo-sharing-13.1.5.tgz#73d86cdcc037b46ddc82be224dfd3d6bceec497c" @@ -9104,6 +9128,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.27: version "53.0.27" resolved "https://registry.yarnpkg.com/expo/-/expo-53.0.27.tgz#d42b14ad23388bd8480c3b84be7558b9a2224c9d"