From 011827c88eb7bfd09c34356e9f1ae13ef507841b Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Wed, 11 Feb 2026 09:57:14 -0800 Subject: [PATCH 1/7] RD-T39 Moving tests --- .../__tests__ => __tests__/app}/_layout.auth-guard.test.tsx | 0 src/{app/(app)/__tests__ => __tests__/app}/_layout.test.tsx | 0 .../__tests__ => __tests__/app/call}/[id].security.test.tsx | 2 +- src/{app/call/__tests__ => __tests__/app/call}/[id].test.tsx | 2 +- .../__tests__ => __tests__/app/call/new}/address-search.test.ts | 0 .../app/call/new}/coordinates-search.test.tsx | 0 .../app/call/new}/plus-code-search.test.ts | 0 .../__tests__ => __tests__/app/call/new}/what3words.test.tsx | 0 src/{app/(app)/__tests__ => __tests__/app}/calls.test.tsx | 2 +- .../app}/contacts-pull-to-refresh.integration.test.tsx | 2 +- src/{app/(app)/__tests__ => __tests__/app}/contacts.test.tsx | 2 +- src/{app/(app)/__tests__ => __tests__/app}/index.test.tsx | 2 +- .../(app)/__tests__ => __tests__/app}/initialization.test.tsx | 0 src/{app/login/__tests__ => __tests__/app/login}/index.test.tsx | 2 +- .../login/__tests__ => __tests__/app/login}/index.web.test.tsx | 2 +- .../login/__tests__ => __tests__/app/login}/login-form.test.tsx | 2 +- src/{app/(app)/__tests__ => __tests__/app}/protocols.test.tsx | 2 +- src/{app/__tests__ => __tests__/app/root}/lockscreen.test.tsx | 2 +- src/{app/__tests__ => __tests__/app/root}/maintenance.test.tsx | 2 +- .../__tests__ => __tests__/app}/signalr-lifecycle.test.tsx | 0 20 files changed, 12 insertions(+), 12 deletions(-) rename src/{app/(app)/__tests__ => __tests__/app}/_layout.auth-guard.test.tsx (100%) rename src/{app/(app)/__tests__ => __tests__/app}/_layout.test.tsx (100%) rename src/{app/call/__tests__ => __tests__/app/call}/[id].security.test.tsx (99%) rename src/{app/call/__tests__ => __tests__/app/call}/[id].test.tsx (99%) rename src/{app/call/new/__tests__ => __tests__/app/call/new}/address-search.test.ts (100%) rename src/{app/call/new/__tests__ => __tests__/app/call/new}/coordinates-search.test.tsx (100%) rename src/{app/call/new/__tests__ => __tests__/app/call/new}/plus-code-search.test.ts (100%) rename src/{app/call/new/__tests__ => __tests__/app/call/new}/what3words.test.tsx (100%) rename src/{app/(app)/__tests__ => __tests__/app}/calls.test.tsx (99%) rename src/{app/(app)/__tests__ => __tests__/app}/contacts-pull-to-refresh.integration.test.tsx (99%) rename src/{app/(app)/__tests__ => __tests__/app}/contacts.test.tsx (99%) rename src/{app/(app)/__tests__ => __tests__/app}/index.test.tsx (99%) rename src/{app/(app)/__tests__ => __tests__/app}/initialization.test.tsx (100%) rename src/{app/login/__tests__ => __tests__/app/login}/index.test.tsx (99%) rename src/{app/login/__tests__ => __tests__/app/login}/index.web.test.tsx (99%) rename src/{app/login/__tests__ => __tests__/app/login}/login-form.test.tsx (99%) rename src/{app/(app)/__tests__ => __tests__/app}/protocols.test.tsx (99%) rename src/{app/__tests__ => __tests__/app/root}/lockscreen.test.tsx (99%) rename src/{app/__tests__ => __tests__/app/root}/maintenance.test.tsx (98%) rename src/{app/(app)/__tests__ => __tests__/app}/signalr-lifecycle.test.tsx (100%) diff --git a/src/app/(app)/__tests__/_layout.auth-guard.test.tsx b/src/__tests__/app/_layout.auth-guard.test.tsx similarity index 100% rename from src/app/(app)/__tests__/_layout.auth-guard.test.tsx rename to src/__tests__/app/_layout.auth-guard.test.tsx diff --git a/src/app/(app)/__tests__/_layout.test.tsx b/src/__tests__/app/_layout.test.tsx similarity index 100% rename from src/app/(app)/__tests__/_layout.test.tsx rename to src/__tests__/app/_layout.test.tsx diff --git a/src/app/call/__tests__/[id].security.test.tsx b/src/__tests__/app/call/[id].security.test.tsx similarity index 99% rename from src/app/call/__tests__/[id].security.test.tsx rename to src/__tests__/app/call/[id].security.test.tsx index fef3552..4c71dd6 100644 --- a/src/app/call/__tests__/[id].security.test.tsx +++ b/src/__tests__/app/call/[id].security.test.tsx @@ -304,7 +304,7 @@ jest.mock('react-native-svg', () => ({ Mixin: {}, })); -import CallDetail from '../[id]'; +import CallDetail from '../../app/call/[id]'; describe('CallDetail', () => { const { useCallDetailStore } = require('@/stores/calls/detail-store'); diff --git a/src/app/call/__tests__/[id].test.tsx b/src/__tests__/app/call/[id].test.tsx similarity index 99% rename from src/app/call/__tests__/[id].test.tsx rename to src/__tests__/app/call/[id].test.tsx index 02c8f97..d52103c 100644 --- a/src/app/call/__tests__/[id].test.tsx +++ b/src/__tests__/app/call/[id].test.tsx @@ -11,7 +11,7 @@ import { useLocationStore } from '@/stores/app/location-store'; import { useStatusBottomSheetStore } from '@/stores/status/store'; import { useToastStore } from '@/stores/toast/store'; -import CallDetail from '../[id]'; +import CallDetail from '../../app/call/[id]'; diff --git a/src/app/call/new/__tests__/address-search.test.ts b/src/__tests__/app/call/new/address-search.test.ts similarity index 100% rename from src/app/call/new/__tests__/address-search.test.ts rename to src/__tests__/app/call/new/address-search.test.ts diff --git a/src/app/call/new/__tests__/coordinates-search.test.tsx b/src/__tests__/app/call/new/coordinates-search.test.tsx similarity index 100% rename from src/app/call/new/__tests__/coordinates-search.test.tsx rename to src/__tests__/app/call/new/coordinates-search.test.tsx diff --git a/src/app/call/new/__tests__/plus-code-search.test.ts b/src/__tests__/app/call/new/plus-code-search.test.ts similarity index 100% rename from src/app/call/new/__tests__/plus-code-search.test.ts rename to src/__tests__/app/call/new/plus-code-search.test.ts diff --git a/src/app/call/new/__tests__/what3words.test.tsx b/src/__tests__/app/call/new/what3words.test.tsx similarity index 100% rename from src/app/call/new/__tests__/what3words.test.tsx rename to src/__tests__/app/call/new/what3words.test.tsx diff --git a/src/app/(app)/__tests__/calls.test.tsx b/src/__tests__/app/calls.test.tsx similarity index 99% rename from src/app/(app)/__tests__/calls.test.tsx rename to src/__tests__/app/calls.test.tsx index 21656f3..0d94ff6 100644 --- a/src/app/(app)/__tests__/calls.test.tsx +++ b/src/__tests__/app/calls.test.tsx @@ -205,7 +205,7 @@ jest.mock('@react-navigation/native', () => ({ useIsFocused: jest.fn(() => true), })); -import CallsScreen from '../calls'; +import CallsScreen from '../../app/(app)/calls'; describe('CallsScreen', () => { const { useCallsStore } = require('@/stores/calls/store'); diff --git a/src/app/(app)/__tests__/contacts-pull-to-refresh.integration.test.tsx b/src/__tests__/app/contacts-pull-to-refresh.integration.test.tsx similarity index 99% rename from src/app/(app)/__tests__/contacts-pull-to-refresh.integration.test.tsx rename to src/__tests__/app/contacts-pull-to-refresh.integration.test.tsx index 4bfacec..40c01f1 100644 --- a/src/app/(app)/__tests__/contacts-pull-to-refresh.integration.test.tsx +++ b/src/__tests__/app/contacts-pull-to-refresh.integration.test.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { ContactType } from '@/models/v4/contacts/contactResultData'; -import Contacts from '../contacts'; +import Contacts from '../../app/(app)/contacts'; // Mock dependencies jest.mock('react-i18next', () => ({ diff --git a/src/app/(app)/__tests__/contacts.test.tsx b/src/__tests__/app/contacts.test.tsx similarity index 99% rename from src/app/(app)/__tests__/contacts.test.tsx rename to src/__tests__/app/contacts.test.tsx index c698c5c..99b0af3 100644 --- a/src/app/(app)/__tests__/contacts.test.tsx +++ b/src/__tests__/app/contacts.test.tsx @@ -5,7 +5,7 @@ import { RefreshControl } from 'react-native'; import { ContactType } from '@/models/v4/contacts/contactResultData'; -import Contacts from '../contacts'; +import Contacts from '../../app/(app)/contacts'; // Mock dependencies jest.mock('react-i18next', () => ({ diff --git a/src/app/(app)/__tests__/index.test.tsx b/src/__tests__/app/index.test.tsx similarity index 99% rename from src/app/(app)/__tests__/index.test.tsx rename to src/__tests__/app/index.test.tsx index 5d71c3c..eba6d11 100644 --- a/src/app/(app)/__tests__/index.test.tsx +++ b/src/__tests__/app/index.test.tsx @@ -3,7 +3,7 @@ import { useColorScheme } from 'nativewind'; import React from 'react'; import { SafeAreaProvider } from 'react-native-safe-area-context'; -import Map from '../map'; +import Map from '../../app/(app)/map'; import { useAppLifecycle } from '@/hooks/use-app-lifecycle'; import { useLocationStore } from '@/stores/app/location-store'; diff --git a/src/app/(app)/__tests__/initialization.test.tsx b/src/__tests__/app/initialization.test.tsx similarity index 100% rename from src/app/(app)/__tests__/initialization.test.tsx rename to src/__tests__/app/initialization.test.tsx diff --git a/src/app/login/__tests__/index.test.tsx b/src/__tests__/app/login/index.test.tsx similarity index 99% rename from src/app/login/__tests__/index.test.tsx rename to src/__tests__/app/login/index.test.tsx index d7e2143..ead085f 100644 --- a/src/app/login/__tests__/index.test.tsx +++ b/src/__tests__/app/login/index.test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { render, screen, fireEvent, waitFor } from '@testing-library/react-native'; import { View, Text, TouchableOpacity } from 'react-native'; -import Login from '../index'; +import Login from '../../app/login/index'; const mockPush = jest.fn(); const mockReplace = jest.fn(); diff --git a/src/app/login/__tests__/index.web.test.tsx b/src/__tests__/app/login/index.web.test.tsx similarity index 99% rename from src/app/login/__tests__/index.web.test.tsx rename to src/__tests__/app/login/index.web.test.tsx index f69292b..bc6ee5f 100644 --- a/src/app/login/__tests__/index.web.test.tsx +++ b/src/__tests__/app/login/index.web.test.tsx @@ -100,7 +100,7 @@ jest.mock('../index.web', () => { }); // Import after mocking -import LoginWeb from '../index.web'; +import LoginWeb from '../../app/login/index.web'; // Mock hooks and dependencies const mockLogin = jest.fn(); diff --git a/src/app/login/__tests__/login-form.test.tsx b/src/__tests__/app/login/login-form.test.tsx similarity index 99% rename from src/app/login/__tests__/login-form.test.tsx rename to src/__tests__/app/login/login-form.test.tsx index b7733d3..4c1233a 100644 --- a/src/app/login/__tests__/login-form.test.tsx +++ b/src/__tests__/app/login/login-form.test.tsx @@ -60,7 +60,7 @@ jest.mock('../login-form', () => { }; }); -import { LoginForm } from '../login-form'; +import { LoginForm } from '../../app/login/login-form'; // Mock react-i18next jest.mock('react-i18next', () => ({ diff --git a/src/app/(app)/__tests__/protocols.test.tsx b/src/__tests__/app/protocols.test.tsx similarity index 99% rename from src/app/(app)/__tests__/protocols.test.tsx rename to src/__tests__/app/protocols.test.tsx index a74b76f..e3bd157 100644 --- a/src/app/(app)/__tests__/protocols.test.tsx +++ b/src/__tests__/app/protocols.test.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { CallProtocolsResultData } from '@/models/v4/callProtocols/callProtocolsResultData'; -import Protocols from '../protocols'; +import Protocols from '../../app/(app)/protocols'; // Mock dependencies jest.mock('react-i18next', () => ({ diff --git a/src/app/__tests__/lockscreen.test.tsx b/src/__tests__/app/root/lockscreen.test.tsx similarity index 99% rename from src/app/__tests__/lockscreen.test.tsx rename to src/__tests__/app/root/lockscreen.test.tsx index 70ab67c..1f3ffcb 100644 --- a/src/app/__tests__/lockscreen.test.tsx +++ b/src/__tests__/app/root/lockscreen.test.tsx @@ -3,7 +3,7 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react-nativ import { NavigationContainer } from '@react-navigation/native'; import { useRouter } from 'expo-router'; -import Lockscreen from '../lockscreen'; +import Lockscreen from '../../app/lockscreen'; import { useAuth } from '@/lib/auth'; import useLockscreenStore from '@/stores/lockscreen/store'; diff --git a/src/app/__tests__/maintenance.test.tsx b/src/__tests__/app/root/maintenance.test.tsx similarity index 98% rename from src/app/__tests__/maintenance.test.tsx rename to src/__tests__/app/root/maintenance.test.tsx index e6e98db..c17104f 100644 --- a/src/app/__tests__/maintenance.test.tsx +++ b/src/__tests__/app/root/maintenance.test.tsx @@ -3,7 +3,7 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react-nativ import { NavigationContainer } from '@react-navigation/native'; import { useRouter } from 'expo-router'; -import Maintenance from '../maintenance'; +import Maintenance from '../../app/maintenance'; import { Env } from '@/lib/env'; // Mock dependencies diff --git a/src/app/(app)/__tests__/signalr-lifecycle.test.tsx b/src/__tests__/app/signalr-lifecycle.test.tsx similarity index 100% rename from src/app/(app)/__tests__/signalr-lifecycle.test.tsx rename to src/__tests__/app/signalr-lifecycle.test.tsx From 8d86a19cf6274bfa56efbf973a066812b85cb6fe Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Tue, 10 Mar 2026 19:14:11 -0700 Subject: [PATCH 2/7] RD-T40 Adding SSO support new call udpates. --- __mocks__/expo-auth-session.ts | 26 + __mocks__/expo-crypto.ts | 20 + __mocks__/expo-web-browser.ts | 8 + __mocks__/react-native-mmkv.ts | 13 +- app.config.ts | 10 + electron/main.js | 86 +- electron/preload.js | 49 ++ package.json | 7 +- src/__tests__/app/call/[id].security.test.tsx | 2 +- src/__tests__/app/call/[id].test.tsx | 2 +- src/__tests__/app/login/index.test.tsx | 4 +- src/__tests__/app/login/index.web.test.tsx | 4 +- src/__tests__/app/login/login-form.test.tsx | 4 +- src/__tests__/app/root/lockscreen.test.tsx | 2 +- src/__tests__/app/root/maintenance.test.tsx | 2 +- src/api/calls/callTemplates.ts | 13 + src/api/calls/calls.ts | 16 + src/api/forms/forms.ts | 32 + .../userDefinedFields/userDefinedFields.ts | 32 + src/app/call/[id]/edit.tsx | 30 +- src/app/call/[id]/edit.web.tsx | 28 +- src/app/call/new/index.tsx | 755 ++++++++++++------ src/app/call/new/index.web.tsx | 653 ++++++++++----- src/app/login/index.tsx | 2 +- src/app/login/index.web.tsx | 34 +- src/app/login/login-form.tsx | 20 +- src/app/login/sso.tsx | 419 ++++++++++ src/components/calls/call-form-renderer.tsx | 132 +++ .../calls/call-form-renderer.web.tsx | 118 +++ src/components/calls/call-templates-modal.tsx | 209 +++++ src/components/calls/contact-picker-modal.tsx | 218 +++++ src/components/calls/linked-calls-modal.tsx | 228 ++++++ .../calls/protocol-selector-modal.tsx | 352 ++++++++ src/components/calls/udf-fields-renderer.tsx | 533 +++++++++++++ .../contacts/contact-details-sheet.tsx | 6 + .../personnel-actions-panel.tsx | 16 +- .../dispatch-console/unit-actions-panel.tsx | 16 +- .../__tests__/role-assignment-item.test.tsx | 2 + .../__tests__/roles-bottom-sheet.test.tsx | 1 + .../roles/__tests__/roles-modal.test.tsx | 1 + src/hooks/use-oidc-login.ts | 81 ++ src/hooks/use-saml-login.ts | 58 ++ src/lib/__tests__/platform.test.ts | 143 ++++ src/lib/auth/api.tsx | 41 + src/lib/auth/types.tsx | 17 + src/lib/hooks/use-selected-theme.web.tsx | 17 +- src/lib/platform.ts | 48 ++ src/lib/storage/index.web.tsx | 16 +- src/models/v4/forms/formsResult.ts | 6 + .../v4/personnel/personnelInfoResultData.ts | 3 + .../templates/callQuickTemplateResultData.ts | 11 + .../v4/templates/callQuickTemplatesResult.ts | 6 + src/models/v4/units/unitInfoResultData.ts | 2 + .../userDefinedFields/udfDefinitionResult.ts | 8 + .../udfDefinitionResultData.ts | 11 + .../userDefinedFields/udfFieldResultData.ts | 22 + .../userDefinedFields/udfFieldValueInput.ts | 4 + .../udfFieldValueResultData.ts | 8 + .../userDefinedFields/udfFieldValuesResult.ts | 8 + .../__tests__/electron-notification.test.ts | 221 +++++ src/services/audio.service.web.ts | 9 +- src/services/electron-notification.ts | 266 ++++++ src/services/push-notification.ts | 88 +- src/services/sso-discovery.ts | 31 + src/stores/auth/store.tsx | 49 ++ src/stores/personnel/__tests__/store.test.ts | 3 + src/translations/ar.json | 57 +- src/translations/en.json | 57 +- src/translations/es.json | 57 +- yarn.lock | 26 +- 70 files changed, 4965 insertions(+), 514 deletions(-) create mode 100644 __mocks__/expo-auth-session.ts create mode 100644 __mocks__/expo-crypto.ts create mode 100644 __mocks__/expo-web-browser.ts create mode 100644 src/api/calls/callTemplates.ts create mode 100644 src/api/forms/forms.ts create mode 100644 src/api/userDefinedFields/userDefinedFields.ts create mode 100644 src/app/login/sso.tsx create mode 100644 src/components/calls/call-form-renderer.tsx create mode 100644 src/components/calls/call-form-renderer.web.tsx create mode 100644 src/components/calls/call-templates-modal.tsx create mode 100644 src/components/calls/contact-picker-modal.tsx create mode 100644 src/components/calls/linked-calls-modal.tsx create mode 100644 src/components/calls/protocol-selector-modal.tsx create mode 100644 src/components/calls/udf-fields-renderer.tsx create mode 100644 src/hooks/use-oidc-login.ts create mode 100644 src/hooks/use-saml-login.ts create mode 100644 src/lib/__tests__/platform.test.ts create mode 100644 src/lib/platform.ts create mode 100644 src/models/v4/forms/formsResult.ts create mode 100644 src/models/v4/templates/callQuickTemplateResultData.ts create mode 100644 src/models/v4/templates/callQuickTemplatesResult.ts create mode 100644 src/models/v4/userDefinedFields/udfDefinitionResult.ts create mode 100644 src/models/v4/userDefinedFields/udfDefinitionResultData.ts create mode 100644 src/models/v4/userDefinedFields/udfFieldResultData.ts create mode 100644 src/models/v4/userDefinedFields/udfFieldValueInput.ts create mode 100644 src/models/v4/userDefinedFields/udfFieldValueResultData.ts create mode 100644 src/models/v4/userDefinedFields/udfFieldValuesResult.ts create mode 100644 src/services/__tests__/electron-notification.test.ts create mode 100644 src/services/electron-notification.ts create mode 100644 src/services/sso-discovery.ts diff --git a/__mocks__/expo-auth-session.ts b/__mocks__/expo-auth-session.ts new file mode 100644 index 0000000..e62bbed --- /dev/null +++ b/__mocks__/expo-auth-session.ts @@ -0,0 +1,26 @@ +// Mock for expo-auth-session +export const ResponseType = { + Code: 'code', + Token: 'token', + IdToken: 'id_token', +}; + +export const makeRedirectUri = jest.fn().mockReturnValue('test://auth/callback'); + +export const useAutoDiscovery = jest.fn().mockReturnValue({ + authorizationEndpoint: 'https://example.com/oauth/authorize', + tokenEndpoint: 'https://example.com/oauth/token', +}); + +export const useAuthRequest = jest.fn().mockReturnValue([ + null, // request + null, // response + jest.fn().mockResolvedValue({ type: 'cancel' }), // promptAsync +]); + +export const exchangeCodeAsync = jest.fn().mockResolvedValue({ + idToken: 'mock-id-token', + accessToken: 'mock-access-token', +}); + +export const AuthSessionRedirectUriOptions = {}; diff --git a/__mocks__/expo-crypto.ts b/__mocks__/expo-crypto.ts new file mode 100644 index 0000000..c361d59 --- /dev/null +++ b/__mocks__/expo-crypto.ts @@ -0,0 +1,20 @@ +// Mock for expo-crypto +export const digestStringAsync = jest.fn().mockResolvedValue('mock-hash'); + +export const CryptoDigestAlgorithm = { + SHA256: 'SHA-256', + SHA512: 'SHA-512', + SHA1: 'SHA-1', + MD2: 'MD2', + MD4: 'MD4', + MD5: 'MD5', +}; + +export const CryptoEncoding = { + HEX: 'hex', + BASE64: 'base64', +}; + +export const getRandomBytesAsync = jest.fn().mockResolvedValue(new Uint8Array(32)); + +export const randomUUID = jest.fn().mockReturnValue('mock-uuid-1234'); diff --git a/__mocks__/expo-web-browser.ts b/__mocks__/expo-web-browser.ts new file mode 100644 index 0000000..c46a2ee --- /dev/null +++ b/__mocks__/expo-web-browser.ts @@ -0,0 +1,8 @@ +// Mock for expo-web-browser +export const maybeCompleteAuthSession = jest.fn().mockReturnValue({ type: 'success' }); + +export const openBrowserAsync = jest.fn().mockResolvedValue({ type: 'cancel' }); + +export const openAuthSessionAsync = jest.fn().mockResolvedValue({ type: 'cancel' }); + +export const dismissBrowser = jest.fn(); diff --git a/__mocks__/react-native-mmkv.ts b/__mocks__/react-native-mmkv.ts index 54a7f57..c123c8b 100644 --- a/__mocks__/react-native-mmkv.ts +++ b/__mocks__/react-native-mmkv.ts @@ -10,7 +10,18 @@ class MockMMKV { private prefix: string; constructor(config?: { id?: string; encryptionKey?: string }) { - this.storage = typeof window !== 'undefined' ? window.localStorage : ({} as Storage); + const _fallbackData: Record = {}; + const _fallbackStorage: Storage = { + getItem(key: string) { return _fallbackData[key] ?? null; }, + setItem(key: string, value: string) { _fallbackData[key] = value; }, + removeItem(key: string) { delete _fallbackData[key]; }, + key(index: number) { return Object.keys(_fallbackData)[index] ?? null; }, + get length() { return Object.keys(_fallbackData).length; }, + clear() { Object.keys(_fallbackData).forEach((k) => delete _fallbackData[k]); }, + } as unknown as Storage; + this.storage = typeof window !== 'undefined' && typeof window.localStorage !== 'undefined' + ? window.localStorage + : _fallbackStorage; this.prefix = config?.id || 'mmkv'; } diff --git a/app.config.ts b/app.config.ts index 1a8d7af..555945f 100644 --- a/app.config.ts +++ b/app.config.ts @@ -51,6 +51,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ ITSAppUsesNonExemptEncryption: false, UIViewControllerBasedStatusBarAppearance: false, NSBluetoothAlwaysUsageDescription: 'Allow Resgrid Dispatch to connect to bluetooth devices for PTT.', + LSApplicationQueriesSchemes: [Env.SCHEME, 'https', 'http'], }, entitlements: { ...((Env.APP_ENV === 'production' || Env.APP_ENV === 'internal') && { @@ -72,6 +73,14 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ }, softwareKeyboardLayoutMode: 'pan', package: Env.PACKAGE, + intentFilters: [ + { + action: 'VIEW', + autoVerify: true, + data: [{ scheme: Env.SCHEME }], + category: ['BROWSABLE', 'DEFAULT'], + }, + ], ...(fs.existsSync(path.join(__dirname, 'google-services.json')) && { googleServicesFile: 'google-services.json', }), @@ -268,6 +277,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ '@livekit/react-native-expo-plugin', '@config-plugins/react-native-webrtc', '@config-plugins/react-native-callkeep', + 'expo-web-browser', './customGradle.plugin.js', './customManifest.plugin.js', ['app-icon-badge', appIconBadgeConfig], diff --git a/electron/main.js b/electron/main.js index 27b5fc4..7b08234 100644 --- a/electron/main.js +++ b/electron/main.js @@ -1,4 +1,4 @@ -const { app, BrowserWindow, protocol, net } = require('electron'); +const { app, BrowserWindow, protocol, net, Notification, ipcMain, session } = require('electron'); const path = require('path'); const url = require('url'); @@ -31,9 +31,9 @@ function createWindow() { icon: path.join(__dirname, '../assets/icon.png'), webPreferences: { preload: path.join(__dirname, 'preload.js'), - nodeIntegration: isDev, - contextIsolation: !isDev, - webSecurity: !isDev, + nodeIntegration: false, + contextIsolation: true, + webSecurity: true, }, }); @@ -55,6 +55,43 @@ function createWindow() { } app.whenReady().then(() => { + // ── Content Security Policy ─────────────────────────────────────── + // Set a proper CSP to silence the Electron security warning about + // "unsafe-eval" / missing CSP. In development we allow the local + // dev-server origin; in production only the custom app:// scheme. + session.defaultSession.webRequest.onHeadersReceived((details, callback) => { + let csp; + if (isDev) { + // Dev mode: Metro/webpack needs 'unsafe-eval' for source maps + // and hot-reload, blob: for dynamic chunks, ws: for HMR. + csp = + "default-src 'self' http://localhost:8081;" + + " script-src 'self' http://localhost:8081 'unsafe-inline' 'unsafe-eval' blob:;" + + " style-src 'self' http://localhost:8081 'unsafe-inline';" + + " img-src 'self' http://localhost:8081 data: https: blob:;" + + " font-src 'self' http://localhost:8081 data:;" + + " connect-src 'self' http://localhost:8081 https: wss: ws:;" + + " media-src 'self' http://localhost:8081 data: blob:;" + + " worker-src 'self' blob:;"; + } else { + csp = + "default-src 'self' app:;" + + " script-src 'self' app: 'unsafe-inline';" + + " style-src 'self' app: 'unsafe-inline';" + + " img-src 'self' app: data: https:;" + + " font-src 'self' app: data:;" + + " connect-src 'self' app: https: wss:;" + + " media-src 'self' app: data:;" + + " worker-src 'self' blob:;"; + } + + callback({ + responseHeaders: { + ...details.responseHeaders, + 'Content-Security-Policy': [csp], + }, + }); + }); // Register the custom app:// protocol handler for production builds. // This serves all files from the dist/ directory so that absolute asset // paths in the bundled HTML/JS/CSS resolve correctly. @@ -77,6 +114,47 @@ app.whenReady().then(() => { createWindow(); + // ── Notification IPC handlers ────────────────────────────────────── + // Allow the renderer to request native Electron Notification objects + // which map to macOS Notification Center, Windows Toast & Linux + // libnotify/notify-send automatically. + + ipcMain.handle('notifications:isSupported', () => { + return Notification.isSupported(); + }); + + ipcMain.handle('notifications:show', (_event, payload) => { + if (!Notification.isSupported()) { + console.warn('Native notifications are not supported on this platform'); + return false; + } + + try { + const notification = new Notification({ + title: payload.title || 'Resgrid Dispatch', + body: payload.body || '', + icon: path.join(__dirname, '../assets/icon.png'), + silent: false, + }); + + notification.on('click', () => { + // Focus / restore the main window when the notification is clicked + const windows = BrowserWindow.getAllWindows(); + if (windows.length > 0) { + const win = windows[0]; + if (win.isMinimized()) win.restore(); + win.focus(); + } + }); + + notification.show(); + return true; + } catch (err) { + console.error('Failed to show native notification:', err); + return false; + } + }); + app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow(); diff --git a/electron/preload.js b/electron/preload.js index e3d51b5..7e25c05 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -1,6 +1,55 @@ // preload.js // All of the Node.js APIs are available in the preload process. // It has the same sandbox as a Chrome extension. +const { contextBridge, ipcRenderer } = require('electron'); + +// ── Expose Electron platform flag ────────────────────────────────────── +// The renderer can use `window.__ELECTRON__` to detect if it is running +// inside Electron (as opposed to a regular browser). +window.__ELECTRON__ = true; + +// ── Expose notification bridge ───────────────────────────────────────── +// Provides a safe bridge for the renderer to trigger native OS +// notifications via the main process (macOS, Windows, Linux). +const notificationCallbacks = []; + +contextBridge.exposeInMainWorld('electronNotifications', { + /** + * Show a native OS notification via the Electron main process. + * @param {{ title: string, body: string, eventCode?: string, data?: object }} payload + * @returns {Promise} true if the notification was shown + */ + show: (payload) => ipcRenderer.invoke('notifications:show', payload), + + /** + * Check whether native notifications are supported on this platform. + * @returns {Promise} + */ + isSupported: () => ipcRenderer.invoke('notifications:isSupported'), + + /** + * Register a callback that fires when the main process sends a + * notification payload to the renderer (e.g. from a backend push + * channel handled in the main process). + * @param {(payload: object) => void} callback + */ + onNotification: (callback) => { + notificationCallbacks.push(callback); + }, +}); + +// Forward notifications pushed from main → renderer +ipcRenderer.on('notification:push', (_event, payload) => { + for (const cb of notificationCallbacks) { + try { + cb(payload); + } catch (err) { + console.error('Error in notification callback:', err); + } + } +}); + +// ── Version information (original preload logic) ─────────────────────── window.addEventListener('DOMContentLoaded', () => { const replaceText = (selector, text) => { const element = document.getElementById(selector) diff --git a/package.json b/package.json index 1c24c57..c62ae30 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "install-maestro": "curl -Ls 'https://get.maestro.mobile.dev' | bash", "e2e-test": "maestro test .maestro/ -e APP_ID=com.obytes.development", "web:build": "cross-env APP_ENV=production expo export --platform web", - "electron:dev": "concurrently \"cross-env BROWSER=none yarn web\" \"wait-on http://localhost:8081 && electron .\"", + "electron:dev": "concurrently \"cross-env NODE_OPTIONS=--max-old-space-size=16384 BROWSER=none yarn web\" \"wait-on http://localhost:8081 && electron electron/main.js\"", "electron:pack": "yarn web:build && electron-builder -c.extraMetadata.main=electron/main.js", "docker:build": "docker build -t resgrid-dispatch-web .", "docker:run": "docker run -p 3000:80 --env-file .env.docker resgrid-dispatch-web", @@ -151,9 +151,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", @@ -175,6 +177,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", "he": "^1.2.0", "i18next": "~23.14.0", @@ -298,4 +301,4 @@ "resolutions": { "form-data": "4.0.4" } -} \ No newline at end of file +} diff --git a/src/__tests__/app/call/[id].security.test.tsx b/src/__tests__/app/call/[id].security.test.tsx index 4c71dd6..0d7d1a8 100644 --- a/src/__tests__/app/call/[id].security.test.tsx +++ b/src/__tests__/app/call/[id].security.test.tsx @@ -304,7 +304,7 @@ jest.mock('react-native-svg', () => ({ Mixin: {}, })); -import CallDetail from '../../app/call/[id]'; +import CallDetail from '../../../app/call/[id]'; describe('CallDetail', () => { const { useCallDetailStore } = require('@/stores/calls/detail-store'); diff --git a/src/__tests__/app/call/[id].test.tsx b/src/__tests__/app/call/[id].test.tsx index d52103c..fdfdc49 100644 --- a/src/__tests__/app/call/[id].test.tsx +++ b/src/__tests__/app/call/[id].test.tsx @@ -11,7 +11,7 @@ import { useLocationStore } from '@/stores/app/location-store'; import { useStatusBottomSheetStore } from '@/stores/status/store'; import { useToastStore } from '@/stores/toast/store'; -import CallDetail from '../../app/call/[id]'; +import CallDetail from '../../../app/call/[id]'; diff --git a/src/__tests__/app/login/index.test.tsx b/src/__tests__/app/login/index.test.tsx index ead085f..5d7c01d 100644 --- a/src/__tests__/app/login/index.test.tsx +++ b/src/__tests__/app/login/index.test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { render, screen, fireEvent, waitFor } from '@testing-library/react-native'; import { View, Text, TouchableOpacity } from 'react-native'; -import Login from '../../app/login/index'; +import Login from '../../../app/login/index'; const mockPush = jest.fn(); const mockReplace = jest.fn(); @@ -71,7 +71,7 @@ jest.mock('@/components/settings/server-url-bottom-sheet', () => { }; }); -jest.mock('../login-form', () => { +jest.mock('../../../app/login/login-form', () => { const React = require('react'); const { View, TouchableOpacity, Text } = require('react-native'); diff --git a/src/__tests__/app/login/index.web.test.tsx b/src/__tests__/app/login/index.web.test.tsx index bc6ee5f..c03e64a 100644 --- a/src/__tests__/app/login/index.web.test.tsx +++ b/src/__tests__/app/login/index.web.test.tsx @@ -3,7 +3,7 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react-nativ import { View, Text, TextInput, Pressable } from 'react-native'; // Mock the entire web login module -jest.mock('../index.web', () => { +jest.mock('../../../app/login/index.web', () => { const React = require('react'); const { View, Text, TextInput, Pressable } = require('react-native'); @@ -100,7 +100,7 @@ jest.mock('../index.web', () => { }); // Import after mocking -import LoginWeb from '../../app/login/index.web'; +import LoginWeb from '../../../app/login/index.web'; // Mock hooks and dependencies const mockLogin = jest.fn(); diff --git a/src/__tests__/app/login/login-form.test.tsx b/src/__tests__/app/login/login-form.test.tsx index 4c1233a..9b7cb18 100644 --- a/src/__tests__/app/login/login-form.test.tsx +++ b/src/__tests__/app/login/login-form.test.tsx @@ -3,7 +3,7 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react-nativ import { View, Text, TouchableOpacity, TextInput } from 'react-native'; // Mock the entire login-form module to replace the schema creation -jest.mock('../login-form', () => { +jest.mock('../../../app/login/login-form', () => { const React = require('react'); const { View, Text, TouchableOpacity, TextInput } = require('react-native'); @@ -60,7 +60,7 @@ jest.mock('../login-form', () => { }; }); -import { LoginForm } from '../../app/login/login-form'; +import { LoginForm } from '../../../app/login/login-form'; // Mock react-i18next jest.mock('react-i18next', () => ({ diff --git a/src/__tests__/app/root/lockscreen.test.tsx b/src/__tests__/app/root/lockscreen.test.tsx index 1f3ffcb..903d397 100644 --- a/src/__tests__/app/root/lockscreen.test.tsx +++ b/src/__tests__/app/root/lockscreen.test.tsx @@ -3,7 +3,7 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react-nativ import { NavigationContainer } from '@react-navigation/native'; import { useRouter } from 'expo-router'; -import Lockscreen from '../../app/lockscreen'; +import Lockscreen from '../../../app/lockscreen'; import { useAuth } from '@/lib/auth'; import useLockscreenStore from '@/stores/lockscreen/store'; diff --git a/src/__tests__/app/root/maintenance.test.tsx b/src/__tests__/app/root/maintenance.test.tsx index c17104f..c101589 100644 --- a/src/__tests__/app/root/maintenance.test.tsx +++ b/src/__tests__/app/root/maintenance.test.tsx @@ -3,7 +3,7 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react-nativ import { NavigationContainer } from '@react-navigation/native'; import { useRouter } from 'expo-router'; -import Maintenance from '../../app/maintenance'; +import Maintenance from '../../../app/maintenance'; import { Env } from '@/lib/env'; // Mock dependencies diff --git a/src/api/calls/callTemplates.ts b/src/api/calls/callTemplates.ts new file mode 100644 index 0000000..b8ae23d --- /dev/null +++ b/src/api/calls/callTemplates.ts @@ -0,0 +1,13 @@ +import { type CallQuickTemplatesResult } from '@/models/v4/templates/callQuickTemplatesResult'; + +import { createCachedApiEndpoint } from '../common/cached-client'; + +const getAllCallQuickTemplatesApi = createCachedApiEndpoint('/Templates/GetAllCallQuickTemplates', { + ttl: 60 * 1000 * 60, // Cache for 1 hour + enabled: true, +}); + +export const getAllCallQuickTemplates = async () => { + const response = await getAllCallQuickTemplatesApi.get(); + return response.data; +}; diff --git a/src/api/calls/calls.ts b/src/api/calls/calls.ts index 98210f2..ace9cf1 100644 --- a/src/api/calls/calls.ts +++ b/src/api/calls/calls.ts @@ -50,6 +50,10 @@ export interface CreateCallRequest { dispatchRoles?: string[]; dispatchUnits?: string[]; dispatchEveryone?: boolean; + callFormData?: string; + linkedCallId?: string; + externalId?: string; + referenceId?: string; } export interface UpdateCallRequest { @@ -71,6 +75,10 @@ export interface UpdateCallRequest { dispatchRoles?: string[]; dispatchUnits?: string[]; dispatchEveryone?: boolean; + callFormData?: string; + linkedCallId?: string; + externalId?: string; + referenceId?: string; } export interface CloseCallRequest { @@ -120,6 +128,10 @@ export const createCall = async (callData: CreateCallRequest) => { What3Words: callData.what3words || '', PlusCode: callData.plusCode || '', DispatchList: dispatchList, + CallFormData: callData.callFormData || '', + IncidentId: callData.linkedCallId || '', + ExternalId: callData.externalId || '', + ReferenceId: callData.referenceId || '', }; const response = await createCallApi.post(data); @@ -168,6 +180,10 @@ export const updateCall = async (callData: UpdateCallRequest) => { What3Words: callData.what3words || '', PlusCode: callData.plusCode || '', DispatchList: dispatchList, + CallFormData: callData.callFormData || '', + IncidentId: callData.linkedCallId || '', + ExternalId: callData.externalId || '', + ReferenceId: callData.referenceId || '', }; const response = await updateCallApi.post(data); diff --git a/src/api/forms/forms.ts b/src/api/forms/forms.ts new file mode 100644 index 0000000..9236a01 --- /dev/null +++ b/src/api/forms/forms.ts @@ -0,0 +1,32 @@ +import { type FormResult } from '@/models/v4/forms/formResult'; +import { type FormsResult } from '@/models/v4/forms/formsResult'; + +import { createCachedApiEndpoint } from '../common/cached-client'; +import { createApiEndpoint } from '../common/client'; + +const getNewCallFormApi = createCachedApiEndpoint('/Forms/GetNewCallForm', { + ttl: 60 * 1000 * 60, // Cache for 1 hour + enabled: true, +}); + +const getAllFormsApi = createCachedApiEndpoint('/Forms/GetAllForms', { + ttl: 60 * 1000 * 60, // Cache for 1 hour + enabled: true, +}); + +const getFormByIdApi = createApiEndpoint('/Forms/GetFormById'); + +export const getNewCallForm = async () => { + const response = await getNewCallFormApi.get(); + return response.data; +}; + +export const getAllForms = async () => { + const response = await getAllFormsApi.get(); + return response.data; +}; + +export const getFormById = async (formId: string) => { + const response = await getFormByIdApi.get({ formId }); + return response.data; +}; diff --git a/src/api/userDefinedFields/userDefinedFields.ts b/src/api/userDefinedFields/userDefinedFields.ts new file mode 100644 index 0000000..7c25baf --- /dev/null +++ b/src/api/userDefinedFields/userDefinedFields.ts @@ -0,0 +1,32 @@ +import { type UdfDefinitionResult } from '@/models/v4/userDefinedFields/udfDefinitionResult'; +import { type UdfFieldValueInput } from '@/models/v4/userDefinedFields/udfFieldValueInput'; +import { type UdfFieldValuesResult } from '@/models/v4/userDefinedFields/udfFieldValuesResult'; + +import { api } from '../common/client'; + +// EntityType enum: Call=0, Personnel=1, Unit=2, Contact=3 +export const getUdfDefinition = async (entityType: number) => { + const response = await api.get(`/UserDefinedFields/${entityType}`); + return response.data; +}; + +export const getUdfValues = async (entityType: number, entityId: string) => { + const response = await api.get(`/UserDefinedFields/Values/${entityType}/${encodeURIComponent(entityId)}`); + return response.data; +}; + +export interface SaveUdfValuesRequest { + EntityType: number; + EntityId: string; + Values: UdfFieldValueInput[]; +} + +export const saveUdfValues = async (entityType: number, entityId: string, values: UdfFieldValueInput[]) => { + const data: SaveUdfValuesRequest = { + EntityType: entityType, + EntityId: entityId, + Values: values, + }; + const response = await api.post('/UserDefinedFields/Values', data); + return response.data; +}; diff --git a/src/app/call/[id]/edit.tsx b/src/app/call/[id]/edit.tsx index 2e165f2..ccd15d5 100644 --- a/src/app/call/[id]/edit.tsx +++ b/src/app/call/[id]/edit.tsx @@ -1,15 +1,17 @@ import { zodResolver } from '@hookform/resolvers/zod'; import axios from 'axios'; import { router, Stack, useLocalSearchParams } from 'expo-router'; -import { ChevronDownIcon, PlusIcon, SearchIcon } from 'lucide-react-native'; +import { ChevronDownIcon, ChevronUpIcon, PlusIcon, SearchIcon } from 'lucide-react-native'; import { useColorScheme } from 'nativewind'; import React, { useEffect, useState } from 'react'; import { Controller, useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { ScrollView, View } from 'react-native'; +import { ScrollView, TouchableOpacity, View } from 'react-native'; import * as z from 'zod'; +import { saveUdfValues } from '@/api/userDefinedFields/userDefinedFields'; import { DispatchSelectionModal } from '@/components/calls/dispatch-selection-modal'; +import { UdfFieldsRenderer } from '@/components/calls/udf-fields-renderer'; import { Loading } from '@/components/common/loading'; import FullScreenLocationPicker from '@/components/maps/full-screen-location-picker'; import LocationPicker from '@/components/maps/location-picker'; @@ -24,6 +26,7 @@ import { Text } from '@/components/ui/text'; import { Textarea, TextareaInput } from '@/components/ui/textarea'; import { useToast } from '@/components/ui/toast'; import { useAnalytics } from '@/hooks/use-analytics'; +import { type UdfFieldValueInput } from '@/models/v4/userDefinedFields/udfFieldValueInput'; import { useCoreStore } from '@/stores/app/core-store'; import { useCallDetailStore } from '@/stores/calls/detail-store'; import { useCallsStore } from '@/stores/calls/store'; @@ -84,6 +87,8 @@ export default function EditCall() { const [showLocationPicker, setShowLocationPicker] = useState(false); const [showDispatchModal, setShowDispatchModal] = useState(false); const [showAddressSelection, setShowAddressSelection] = useState(false); + const [udfValues, setUdfValues] = useState([]); + const [isAdditionalFieldsExpanded, setIsAdditionalFieldsExpanded] = useState(false); const [isGeocodingAddress, setIsGeocodingAddress] = useState(false); const [isGeocodingPlusCode, setIsGeocodingPlusCode] = useState(false); const [isGeocodingCoordinates, setIsGeocodingCoordinates] = useState(false); @@ -231,6 +236,14 @@ export default function EditCall() { dispatchEveryone: data.dispatchSelection?.everyone, }); + if (udfValues.length > 0 && callId) { + try { + await saveUdfValues(0, callId, udfValues); + } catch (udfError) { + console.warn('Failed to save UDF values:', udfError); + } + } + // Show success toast toast.show({ placement: 'top', @@ -644,6 +657,19 @@ export default function EditCall() { + {/* Additional Fields (UDF) */} + + setIsAdditionalFieldsExpanded((prev) => !prev)}> + {t('calls.additional_fields', 'Additional Fields')} + {isAdditionalFieldsExpanded ? : } + + {isAdditionalFieldsExpanded ? ( + + + + ) : null} + + {t('calls.dispatch_to')} + + ) : null} - - - - {t('calls.nature')} - - ( - - )} - /> - {errors.nature && ( - - {errors.nature.message} - - )} - + + toggleSection('callName')} className="flex-row items-center justify-between p-4"> + {t('calls.name')} + {sectionsExpanded.callName ? : } + + {sectionsExpanded.callName ? ( + + + ( + + + + )} + /> + {errors.name && ( + + {errors.name.message} + + )} + + + ) : null} - - - - {t('calls.priority')} - - ( - - )} - /> - {errors.priority && ( - - {errors.priority.message} - - )} - + + toggleSection('nature')} className="flex-row items-center justify-between p-4"> + {t('calls.nature')} + {sectionsExpanded.nature ? : } + + {sectionsExpanded.nature ? ( + + + ( + + )} + /> + {errors.nature && ( + + {errors.nature.message} + + )} + + + ) : null} - - - - {t('calls.type')} - - ( - - )} - /> - {errors.type && ( - - {errors.type.message} - - )} - + + toggleSection('priorityType')} className="flex-row items-center justify-between p-4"> + {t('calls.priority_and_type', 'Priority & Type')} + {sectionsExpanded.priorityType ? : } + + {sectionsExpanded.priorityType ? ( + + + + {t('calls.priority')} + + ( + + )} + /> + {errors.priority && ( + + {errors.priority.message} + + )} + + + + {t('calls.type')} + + ( + + )} + /> + {errors.type && ( + + {errors.type.message} + + )} + + + ) : null} - - - - {t('calls.note')} - - ( - - )} - /> - + + toggleSection('note')} className="flex-row items-center justify-between p-4"> + {t('calls.note')} + {sectionsExpanded.note ? : } + + {sectionsExpanded.note ? ( + + + ( + + )} + /> + + + ) : null} - - {t('calls.call_location')} - - {/* Address Field */} - - - {t('calls.address')} - - ( - - - - - - - - - )} - /> - - - {/* GPS Coordinates Field */} - - - {t('calls.coordinates')} - - ( - - - - - - - + + )} + /> + + + {/* GPS Coordinates Field */} + + + {t('calls.coordinates')} + + ( + + + + + + + + + )} + /> + + + {/* what3words Field */} + + + {t('calls.what3words')} + + ( + + + + + + + + + )} + /> + + + {/* Plus Code Field */} + + + {t('calls.plus_code')} + + ( + + + + + + + + + )} + /> + + + {/* Map Preview */} + + {selectedLocation ? ( + + ) : ( + - - )} - /> - - - {/* what3words Field */} - - - {t('calls.what3words')} - - ( - - + )} + + + ) : null} + + + + toggleSection('contact')} className="flex-row items-center justify-between p-4"> + + + {t('calls.contact_information', 'Contact Information')} + + {sectionsExpanded.contact ? : } + + {sectionsExpanded.contact ? ( + + + + + {t('calls.contact_name')} + + ( - + - - - - )} - /> - - - {/* Plus Code Field */} - - - {t('calls.plus_code')} - - ( - - + )} + /> + + + + {t('calls.contact_info')} + + ( - + - - - - )} - /> - - - {/* Map Preview */} - - {selectedLocation ? ( - - ) : ( - - )} - + + ) : null} - - - - {t('calls.contact_name')} - - ( - - - - )} - /> - + {/* Linked Call */} + + toggleSection('linkedCall')} className="flex-row items-center justify-between p-4"> + + + {t('calls.linked_calls.title', 'Linked Call')} + {linkedCall ? ( + + #{linkedCall.number} + + ) : null} + + {sectionsExpanded.linkedCall ? : } + + {sectionsExpanded.linkedCall ? ( + + {linkedCall ? ( + + + #{linkedCall.number} — {linkedCall.name} + + + + ) : null} + + + ) : null} - - - - {t('calls.contact_info')} - - ( - - - - )} - /> - + {/* Additional Fields (UDF) */} + + toggleSection('additionalFields')} className="flex-row items-center justify-between p-4"> + {t('calls.additional_fields', 'Additional Fields')} + {sectionsExpanded.additionalFields ? ( + + ) : ( + + )} + + {sectionsExpanded.additionalFields ? ( + + + + ) : null} - - {t('calls.dispatch_to')} - + {/* Call Form */} + {callForm ? ( + + toggleSection('callForm')} className="flex-row items-center justify-between p-4"> + {callForm.Name || t('calls.form.title', 'Call Form')} + {sectionsExpanded.callForm ? : } + + {sectionsExpanded.callForm ? ( + + + + ) : null} + + ) : null} + + + toggleSection('dispatch')} className="flex-row items-center justify-between p-4"> + {t('calls.dispatch_to')} + {sectionsExpanded.dispatch ? : } + + {sectionsExpanded.dispatch ? ( + + + + ) : null} @@ -908,6 +1145,18 @@ export default function NewCall() { {/* Dispatch selection modal */} setShowDispatchModal(false)} onConfirm={handleDispatchSelection} initialSelection={dispatchSelection} /> + {/* Call Templates modal */} + setShowTemplatesModal(false)} onSelect={handleTemplateSelect} /> + + {/* Contact Picker modal */} + setShowContactPicker(false)} onSelect={handleContactSelect} /> + + {/* Protocol Selector modal */} + setShowProtocolSelector(false)} onConfirm={setSelectedProtocols} initialSelected={selectedProtocols} /> + + {/* Linked Calls modal */} + setShowLinkedCallsModal(false)} onSelect={handleLinkedCallSelect} selectedCallId={linkedCall?.callId} /> + {/* Address selection bottom sheet */} setShowAddressSelection(false)} isLoading={false}> diff --git a/src/app/call/new/index.web.tsx b/src/app/call/new/index.web.tsx index 180d72c..b9ca120 100644 --- a/src/app/call/new/index.web.tsx +++ b/src/app/call/new/index.web.tsx @@ -1,7 +1,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import axios from 'axios'; import { type Href, router, Stack } from 'expo-router'; -import { ChevronDownIcon, MapPinIcon, PlusIcon, SearchIcon, XIcon } from 'lucide-react-native'; +import { BookOpenIcon, ChevronDownIcon, ChevronUpIcon, FileTextIcon, LinkIcon, MapPinIcon, PlusIcon, SearchIcon, UserIcon, XIcon } from 'lucide-react-native'; import { useColorScheme } from 'nativewind'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Controller, useForm } from 'react-hook-form'; @@ -10,7 +10,15 @@ import { Pressable, ScrollView, StyleSheet, useWindowDimensions, View } from 're import * as z from 'zod'; import { createCall } from '@/api/calls/calls'; +import { getNewCallForm } from '@/api/forms/forms'; +import { saveUdfValues } from '@/api/userDefinedFields/userDefinedFields'; +import { CallFormRenderer } from '@/components/calls/call-form-renderer'; +import { CallTemplatesModal, type TemplateSelection } from '@/components/calls/call-templates-modal'; +import { ContactPickerModal } from '@/components/calls/contact-picker-modal'; import { DispatchSelectionModal } from '@/components/calls/dispatch-selection-modal'; +import { LinkedCallsModal } from '@/components/calls/linked-calls-modal'; +import { ProtocolSelectorModal, type SelectedProtocol } from '@/components/calls/protocol-selector-modal'; +import { UdfFieldsRenderer } from '@/components/calls/udf-fields-renderer'; import { Loading } from '@/components/common/loading'; import FullScreenLocationPicker from '@/components/maps/full-screen-location-picker'; import LocationPicker from '@/components/maps/location-picker'; @@ -23,6 +31,10 @@ import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; import { useAnalytics } from '@/hooks/use-analytics'; import { useToast } from '@/hooks/use-toast'; +import { type CallResultData } from '@/models/v4/calls/callResultData'; +import { type ContactResultData } from '@/models/v4/contacts/contactResultData'; +import { type FormResultData } from '@/models/v4/forms/formResultData'; +import { type UdfFieldValueInput } from '@/models/v4/userDefinedFields/udfFieldValueInput'; import { useCoreStore } from '@/stores/app/core-store'; import { useCallsStore } from '@/stores/calls/store'; import { type DispatchSelection } from '@/stores/dispatch/store'; @@ -220,6 +232,15 @@ export default function NewCallWeb() { const [showLocationPicker, setShowLocationPicker] = useState(false); const [showDispatchModal, setShowDispatchModal] = useState(false); const [showAddressSelection, setShowAddressSelection] = useState(false); + const [showTemplatesModal, setShowTemplatesModal] = useState(false); + const [showContactPicker, setShowContactPicker] = useState(false); + const [showProtocolSelector, setShowProtocolSelector] = useState(false); + const [showLinkedCallsModal, setShowLinkedCallsModal] = useState(false); + const [callFormData, setCallFormData] = useState(null); + const [callForm, setCallForm] = useState(null); + const [udfValues, setUdfValues] = useState([]); + const [selectedProtocols, setSelectedProtocols] = useState([]); + const [linkedCall, setLinkedCall] = useState<{ callId: string; number: string; name: string } | null>(null); const [isGeocodingAddress, setIsGeocodingAddress] = useState(false); const [isGeocodingPlusCode, setIsGeocodingPlusCode] = useState(false); const [isGeocodingCoordinates, setIsGeocodingCoordinates] = useState(false); @@ -238,6 +259,21 @@ export default function NewCallWeb() { address?: string; } | null>(null); const [isSubmitting, setIsSubmitting] = useState(false); + const [sectionsExpanded, setSectionsExpanded] = useState({ + templates: false, + callDetails: true, + contact: false, + protocols: false, + linkedCall: false, + callForm: false, + additionalFields: false, + location: true, + dispatch: true, + }); + + const toggleSection = (section: keyof typeof sectionsExpanded) => { + setSectionsExpanded((prev) => ({ ...prev, [section]: !prev[section] })); + }; const nameInputRef = useRef(null); @@ -286,6 +322,14 @@ export default function NewCallWeb() { fetchCallTypes(); }, [fetchCallPriorities, fetchCallTypes]); + useEffect(() => { + getNewCallForm() + .then((result) => { + if (result?.Data?.Data) setCallForm(result.Data); + }) + .catch(() => {}); + }, []); + useEffect(() => { trackEvent('new_call_web_view_rendered', { prioritiesCount: callPriorities.length, @@ -318,7 +362,7 @@ export default function NewCallWeb() { return; } - await createCall({ + const response = await createCall({ name: data.name, nature: data.nature, priority: priority.Id, @@ -334,8 +378,18 @@ export default function NewCallWeb() { dispatchRoles: data.dispatchSelection?.roles, dispatchUnits: data.dispatchSelection?.units, dispatchEveryone: data.dispatchSelection?.everyone, + callFormData: callFormData ?? undefined, + linkedCallId: linkedCall?.callId, }); + if (udfValues.length > 0 && response?.Id) { + try { + await saveUdfValues(0, response.Id, udfValues); + } catch (udfError) { + console.warn('Failed to save UDF values:', udfError); + } + } + toast.success(t('calls.create_success')); router.push('/calls' as Href); } catch (err) { @@ -345,7 +399,7 @@ export default function NewCallWeb() { setIsSubmitting(false); } }, - [selectedLocation, callPriorities, callTypes, toast, t] + [selectedLocation, callPriorities, callTypes, toast, t, callFormData, linkedCall?.callId, udfValues] ); // Keyboard shortcuts @@ -408,6 +462,35 @@ export default function NewCallWeb() { return `${count} ${t('calls.selected')}`; }; + const handleTemplateSelect = useCallback( + (template: TemplateSelection) => { + if (template.name) setValue('name', template.name); + if (template.nature) setValue('nature', template.nature); + if (template.type) setValue('type', template.type); + if (template.priority) { + const matched = callPriorities.find((p) => p.Id === template.priority); + if (matched) setValue('priority', matched.Name); + } + toast.success(t('calls.templates.template_applied', 'Template applied')); + }, + [callPriorities, setValue, toast, t] + ); + + const handleContactSelect = useCallback( + (contact: ContactResultData) => { + const parts = [contact.FirstName, contact.MiddleName, contact.LastName].filter(Boolean); + const name = contact.CompanyName || parts.join(' ') || contact.Name || ''; + const info = contact.Email || String(contact.Phone || contact.Mobile || ''); + setValue('contactName', name); + setValue('contactInfo', info); + }, + [setValue] + ); + + const handleLinkedCallSelect = useCallback((call: CallResultData) => { + setLinkedCall({ callId: call.CallId, number: call.Number, name: call.Name }); + }, []); + const handleAddressSearch = async (address: string) => { if (!address.trim()) { toast.warning(t('calls.address_required')); @@ -606,270 +689,402 @@ export default function NewCallWeb() { {t('calls.new_call_web_hint', 'Fill in the call details below. Press Ctrl+Enter to create.')} + {/* Call Templates */} + + toggleSection('templates')}> + {t('calls.templates.title', 'Call Templates')} + {sectionsExpanded.templates ? : } + + {sectionsExpanded.templates ? ( + setShowTemplatesModal(true)}> + + + {t('calls.templates.select_template', 'Select Template')} + + + ) : null} + + {/* Main Content - Two Column Layout for Wide Screens */} {/* Left Column - Call Details */} - {t('calls.call_details')} - - ( - - )} - /> - - ( - toggleSection('callDetails')}> + {t('calls.call_details')} + {sectionsExpanded.callDetails ? : } + + {sectionsExpanded.callDetails ? ( + + ( + + )} /> - )} - /> - - ( - ( + ({ id: p.Id, name: p.Name, color: p.Color }))} - error={errors.priority?.message} + onBlur={onBlur} + error={errors.nature?.message} + multiline + rows={3} required + testID="nature-input" /> )} /> - - + + + + ( + ({ id: p.Id, name: p.Name, color: p.Color }))} + error={errors.priority?.message} + required + /> + )} + /> + + + ( + ({ id: t.Id, name: t.Name }))} + error={errors.type?.message} + required + /> + )} + /> + + + ( - ({ id: t.Id, name: t.Name }))} - error={errors.type?.message} - required - /> + name="note" + render={({ field: { onChange, onBlur, value } }) => ( + )} /> - - - ( - - )} - /> + ) : null} {/* Contact Information */} - {t('calls.contact_information')} + toggleSection('contact')}> + {t('calls.contact_information')} + {sectionsExpanded.contact ? : } + + {sectionsExpanded.contact ? ( + + setShowContactPicker(true)}> + + + {t('calls.contact_picker.search_placeholder', 'Search contacts...')} + + - - - ( - - )} - /> + + + ( + + )} + /> + + + ( + + )} + /> + + - - ( - - )} - /> + ) : null} + + + {/* Call Form */} + {callForm ? ( + + toggleSection('callForm')}> + {callForm.Name || t('calls.form.title', 'Call Form')} + {sectionsExpanded.callForm ? : } + + {sectionsExpanded.callForm ? ( + + + + ) : null} + + ) : null} + + {/* Additional Fields (UDF) */} + + toggleSection('additionalFields')}> + {t('calls.additional_fields', 'Additional Fields')} + {sectionsExpanded.additionalFields ? : } + + {sectionsExpanded.additionalFields ? ( + + - + ) : null} - {/* Right Column - Location & Dispatch */} + {/* Right Column - Location, Dispatch, Protocols, Linked Call */} {/* Location Card */} - {t('calls.call_location')} - - ( - { - if (e.key === 'Enter') { - e.preventDefault(); - handleAddressSearch(value || ''); - } - }} - rightElement={ - handleAddressSearch(value || '')} - style={StyleSheet.flatten([styles.searchButton, isGeocodingAddress ? styles.searchButtonDisabled : {}])} - disabled={isGeocodingAddress || !value?.trim()} - > - {isGeocodingAddress ? ... : } - - } - /> - )} - /> - - ( - { - if (e.key === 'Enter') { - e.preventDefault(); - handleCoordinatesSearch(value || ''); - } - }} - rightElement={ - handleCoordinatesSearch(value || '')} - style={StyleSheet.flatten([styles.searchButton, isGeocodingCoordinates ? styles.searchButtonDisabled : {}])} - disabled={isGeocodingCoordinates || !value?.trim()} - > - {isGeocodingCoordinates ? ... : } - - } - /> - )} - /> - - - + toggleSection('location')}> + {t('calls.call_location')} + {sectionsExpanded.location ? : } + + {sectionsExpanded.location ? ( + ( { if (e.key === 'Enter') { e.preventDefault(); - handleWhat3WordsSearch(value || ''); + handleAddressSearch(value || ''); } }} rightElement={ handleWhat3WordsSearch(value || '')} - style={StyleSheet.flatten([styles.searchButton, isGeocodingWhat3Words ? styles.searchButtonDisabled : {}])} - disabled={isGeocodingWhat3Words || !value?.trim()} + onPress={() => handleAddressSearch(value || '')} + style={StyleSheet.flatten([styles.searchButton, isGeocodingAddress ? styles.searchButtonDisabled : {}])} + disabled={isGeocodingAddress || !value?.trim()} > - {isGeocodingWhat3Words ? ... : } + {isGeocodingAddress ? ... : } } /> )} /> - - + ( { if (e.key === 'Enter') { e.preventDefault(); - handlePlusCodeSearch(value || ''); + handleCoordinatesSearch(value || ''); } }} rightElement={ handlePlusCodeSearch(value || '')} - style={StyleSheet.flatten([styles.searchButton, isGeocodingPlusCode ? styles.searchButtonDisabled : {}])} - disabled={isGeocodingPlusCode || !value?.trim()} + onPress={() => handleCoordinatesSearch(value || '')} + style={StyleSheet.flatten([styles.searchButton, isGeocodingCoordinates ? styles.searchButtonDisabled : {}])} + disabled={isGeocodingCoordinates || !value?.trim()} > - {isGeocodingPlusCode ? ... : } + {isGeocodingCoordinates ? ... : } } /> )} /> - - - - {/* Map Preview */} - - {selectedLocation ? ( - - - setShowLocationPicker(true)}> - - {t('calls.expand_map')} - + + + + ( + { + if (e.key === 'Enter') { + e.preventDefault(); + handleWhat3WordsSearch(value || ''); + } + }} + rightElement={ + handleWhat3WordsSearch(value || '')} + style={StyleSheet.flatten([styles.searchButton, isGeocodingWhat3Words ? styles.searchButtonDisabled : {}])} + disabled={isGeocodingWhat3Words || !value?.trim()} + > + {isGeocodingWhat3Words ? ... : } + + } + /> + )} + /> + + + ( + { + if (e.key === 'Enter') { + e.preventDefault(); + handlePlusCodeSearch(value || ''); + } + }} + rightElement={ + handlePlusCodeSearch(value || '')} + style={StyleSheet.flatten([styles.searchButton, isGeocodingPlusCode ? styles.searchButtonDisabled : {}])} + disabled={isGeocodingPlusCode || !value?.trim()} + > + {isGeocodingPlusCode ? ... : } + + } + /> + )} + /> + - ) : ( - setShowLocationPicker(true)}> - - {t('calls.select_location')} - - )} - + + {/* Map Preview */} + + {selectedLocation ? ( + + + setShowLocationPicker(true)}> + + {t('calls.expand_map')} + + + ) : ( + setShowLocationPicker(true)}> + + {t('calls.select_location')} + + )} + + + ) : null} {/* Dispatch Card */} - {t('calls.dispatch_to')} - setShowDispatchModal(true)}> - {getDispatchSummary()} - + toggleSection('dispatch')}> + {t('calls.dispatch_to')} + {sectionsExpanded.dispatch ? : } + {sectionsExpanded.dispatch ? ( + setShowDispatchModal(true)}> + {getDispatchSummary()} + + + ) : null} + + + {/* Protocols */} + + toggleSection('protocols')}> + + {t('calls.protocols.title', 'Protocols')} + {selectedProtocols.length > 0 ? ( + + {selectedProtocols.length} + + ) : null} + + {sectionsExpanded.protocols ? : } + + {sectionsExpanded.protocols ? ( + setShowProtocolSelector(true)}> + + + {selectedProtocols.length > 0 ? `${selectedProtocols.length} ${t('calls.protocols.selected_count', 'selected')}` : t('calls.protocols.select', 'Select Protocols')} + + + ) : null} + + + {/* Linked Call */} + + toggleSection('linkedCall')}> + + {t('calls.linked_calls.title', 'Linked Call')} + {linkedCall ? ( + + #{linkedCall.number} + + ) : null} + + {sectionsExpanded.linkedCall ? : } + + {sectionsExpanded.linkedCall ? ( + + {linkedCall ? ( + + + #{linkedCall.number} — {linkedCall.name} + + setLinkedCall(null)}> + + + + ) : null} + setShowLinkedCallsModal(true)}> + + + {linkedCall ? t('calls.linked_calls.change', 'Change linked call') : t('calls.linked_calls.select', 'Link to existing call')} + + + + ) : null} @@ -902,6 +1117,18 @@ export default function NewCallWeb() { {/* Dispatch selection modal */} setShowDispatchModal(false)} onConfirm={handleDispatchSelection} initialSelection={dispatchSelection} /> + {/* Call Templates modal */} + setShowTemplatesModal(false)} onSelect={handleTemplateSelect} /> + + {/* Contact Picker modal */} + setShowContactPicker(false)} onSelect={handleContactSelect} /> + + {/* Protocol Selector modal */} + setShowProtocolSelector(false)} onConfirm={setSelectedProtocols} initialSelected={selectedProtocols} /> + + {/* Linked Calls modal */} + setShowLinkedCallsModal(false)} onSelect={handleLinkedCallSelect} selectedCallId={linkedCall?.callId} /> + {/* Address selection modal */} {showAddressSelection ? ( @@ -1166,6 +1393,34 @@ const styles = StyleSheet.create({ dispatchButtonTextLight: { color: '#374151', }, + linkedCallBadge: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + padding: 10, + borderRadius: 8, + marginBottom: 10, + borderWidth: 1, + }, + linkedCallBadgeDark: { + backgroundColor: '#262626', + borderColor: '#404040', + }, + linkedCallBadgeLight: { + backgroundColor: '#f3f4f6', + borderColor: '#e5e7eb', + }, + linkedCallText: { + fontSize: 13, + flex: 1, + marginRight: 8, + }, + linkedCallTextDark: { + color: '#d1d5db', + }, + linkedCallTextLight: { + color: '#374151', + }, actionButtons: { flexDirection: 'row', justifyContent: 'flex-end', @@ -1308,6 +1563,28 @@ const styles = StyleSheet.create({ addressItemTextLight: { color: '#374151', }, + collapsibleHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingBottom: 0, + }, + collapsibleHeaderLeft: { + flexDirection: 'row', + alignItems: 'center', + }, + countBadge: { + marginLeft: 8, + backgroundColor: '#2563eb', + borderRadius: 10, + paddingHorizontal: 7, + paddingVertical: 2, + }, + countBadgeText: { + color: '#ffffff', + fontSize: 11, + fontWeight: '600', + }, }); // Web-specific styles that use CSS-only properties diff --git a/src/app/login/index.tsx b/src/app/login/index.tsx index 9249d4b..94c8de1 100644 --- a/src/app/login/index.tsx +++ b/src/app/login/index.tsx @@ -78,7 +78,7 @@ export default function Login() { return ( <> - setShowServerUrl(true)} /> + setShowServerUrl(true)} onSsoPress={() => router.push('/login/sso' as any)} /> - {/* Server URL Button */} - StyleSheet.flatten([styles.serverUrlButton, isDark ? styles.serverUrlButtonDark : styles.serverUrlButtonLight, pressed ? styles.serverUrlButtonPressed : {}])} - onPress={() => setShowServerUrlModal(true)} - > - - {t('settings.server_url')} - + {/* Server URL and SSO Buttons */} + + StyleSheet.flatten([styles.serverUrlButton, styles.actionButtonFlex, isDark ? styles.serverUrlButtonDark : styles.serverUrlButtonLight, pressed ? styles.serverUrlButtonPressed : {}])} + onPress={() => setShowServerUrlModal(true)} + > + + {t('settings.server_url')} + + StyleSheet.flatten([styles.serverUrlButton, styles.actionButtonFlex, isDark ? styles.serverUrlButtonDark : styles.serverUrlButtonLight, pressed ? styles.serverUrlButtonPressed : {}])} + onPress={() => router.push('/login/sso' as any)} + > + {t('sso.sso_button')} + + {/* Footer */} @@ -868,6 +876,16 @@ const styles = StyleSheet.create({ }, // Server URL Button + actionButtonRow: { + flexDirection: 'row', + marginTop: 8, + }, + actionButtonFlex: { + flex: 1, + marginTop: 0, + marginLeft: 4, + marginRight: 4, + }, serverUrlButton: { flexDirection: 'row', alignItems: 'center', diff --git a/src/app/login/login-form.tsx b/src/app/login/login-form.tsx index dd0b328..35c3f13 100644 --- a/src/app/login/login-form.tsx +++ b/src/app/login/login-form.tsx @@ -40,9 +40,10 @@ export type LoginFormProps = { isLoading?: boolean; error?: string; onServerUrlPress?: () => void; + 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 { @@ -171,11 +172,18 @@ export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = unde )} - {onServerUrlPress && ( - - )} + + {onServerUrlPress ? ( + + ) : null} + {onSsoPress ? ( + + ) : null} + {/* Footer */} diff --git a/src/app/login/sso.tsx b/src/app/login/sso.tsx new file mode 100644 index 0000000..0151473 --- /dev/null +++ b/src/app/login/sso.tsx @@ -0,0 +1,419 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import * as Linking from 'expo-linking'; +import { useRouter } from 'expo-router'; +import { AlertTriangle, ChevronLeft, ShieldCheck } from 'lucide-react-native'; +import { useColorScheme } from 'nativewind'; +import { useCallback, useEffect, useState } from 'react'; +import type { SubmitHandler } from 'react-hook-form'; +import { Controller, useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { Image, ScrollView } from 'react-native'; +import { KeyboardAvoidingView } from 'react-native-keyboard-controller'; +import * as z from 'zod'; + +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 { useOidcLogin } from '@/hooks/use-oidc-login'; +import { useSamlLogin } from '@/hooks/use-saml-login'; +import type { AuthResponse, SsoConfig } from '@/lib/auth/types'; +import { logger } from '@/lib/logging'; +import { fetchSsoConfigForUser } from '@/services/sso-discovery'; +import useAuthStore from '@/stores/auth/store'; + +// --------------------------------------------------------------------------- +// OidcSignInSection — only mounted when a valid OIDC authority is available +// --------------------------------------------------------------------------- + +interface OidcSignInSectionProps { + authority: string; + clientId: string; + username: string; + departmentId?: number; + isAuthenticating: boolean; + onAuthStart: () => void; + onAuthEnd: () => void; + onTokenReceived: (authResponse: AuthResponse) => void; + onError: (msg: string) => void; +} + +function OidcSignInSection({ authority, clientId, username, departmentId, isAuthenticating, onAuthStart, onAuthEnd, onTokenReceived, onError }: OidcSignInSectionProps) { + const { t } = useTranslation(); + const { request, response, promptAsync, exchangeCodeForResgridToken } = useOidcLogin(authority, clientId, username, departmentId); + + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { + if (response?.type === 'success') { + (async () => { + onAuthStart(); + try { + const authResponse = await exchangeCodeForResgridToken(); + if (!authResponse) { + onError(t('sso.error_token_exchange')); + } else { + onTokenReceived(authResponse); + } + } catch (err) { + logger.error({ message: 'SSO OidcSignInSection: exchange failed', context: { err } }); + onError(t('sso.error_generic')); + } finally { + onAuthEnd(); + } + })(); + } else if (response?.type === 'error') { + onError(t('sso.error_oidc_cancelled')); + } + }, [response]); + + const handlePress = async () => { + onAuthStart(); + try { + await promptAsync(); + } finally { + onAuthEnd(); + } + }; + + if (isAuthenticating) { + return ( + + ); + } + + return ( + + ); +} + +// --------------------------------------------------------------------------- +// SamlSignInSection — only mounted when a valid SAML idpSsoUrl is available +// --------------------------------------------------------------------------- + +interface SamlSignInSectionProps { + idpSsoUrl: string; + username: string; + departmentId?: number; + isAuthenticating: boolean; + onAuthStart: () => void; + onAuthEnd: () => void; + onTokenReceived: (authResponse: AuthResponse) => void; + onError: (msg: string) => void; +} + +function SamlSignInSection({ idpSsoUrl, username, departmentId, isAuthenticating, onAuthStart, onAuthEnd, onTokenReceived, onError }: SamlSignInSectionProps) { + const { t } = useTranslation(); + const { startSamlLogin, handleSamlDeepLink } = useSamlLogin(idpSsoUrl, username, departmentId); + + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { + const subscription = Linking.addEventListener('url', ({ url }) => { + if (url.includes('auth/callback') && url.includes('saml_response')) { + (async () => { + onAuthStart(); + try { + const authResponse = await handleSamlDeepLink(url); + if (!authResponse) { + onError(t('sso.error_token_exchange')); + } else { + onTokenReceived(authResponse); + } + } catch (err) { + logger.error({ message: 'SSO SamlSignInSection: deep link failed', context: { err } }); + onError(t('sso.error_generic')); + } finally { + onAuthEnd(); + } + })(); + } + }); + return () => subscription.remove(); + }, []); + + const handlePress = async () => { + onAuthStart(); + try { + await startSamlLogin(); + } finally { + onAuthEnd(); + } + }; + + if (isAuthenticating) { + return ( + + ); + } + + return ( + + ); +} + +// --------------------------------------------------------------------------- + +const ssoLookupSchema = z.object({ + username: z.string({ required_error: 'Username is required' }).min(3, 'Username must be at least 3 characters'), + departmentId: z + .string() + .optional() + .refine((val) => !val || /^\d+$/.test(val), 'Department ID must be a number'), +}); + +type SsoLookupFormType = z.infer; + +export default function SsoLoginScreen() { + const { t } = useTranslation(); + const { colorScheme } = useColorScheme(); + const router = useRouter(); + + const [phase, setPhase] = useState<'lookup' | 'sso-options'>('lookup'); + const [ssoConfig, setSsoConfig] = useState(null); + const [isLookingUp, setIsLookingUp] = useState(false); + const [isAuthenticating, setIsAuthenticating] = useState(false); + const [lookupError, setLookupError] = useState(null); + const [authError, setAuthError] = useState(null); + const [resolvedUsername, setResolvedUsername] = useState(''); + const [resolvedDepartmentId, setResolvedDepartmentId] = useState(); + + const loginWithSso = useAuthStore((s) => s.loginWithSso); + const status = useAuthStore((s) => s.status); + + const { + control, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(ssoLookupSchema), + }); + + // Navigate to app once signed in + useEffect(() => { + if (status === 'signedIn') { + router.replace('/(app)' as any); + } + }, [status, router]); + + const handleTokenReceived = useCallback( + async (authResponse: AuthResponse) => { + try { + await loginWithSso(authResponse); + } catch (err) { + logger.error({ message: 'SSO: loginWithSso failed', context: { err } }); + setAuthError(t('sso.error_generic')); + } + }, + [loginWithSso, t] + ); + + const onLookup: SubmitHandler = async (data) => { + setLookupError(null); + setIsLookingUp(true); + + const parsedDeptId = data.departmentId ? parseInt(data.departmentId, 10) : undefined; + const config = await fetchSsoConfigForUser(data.username.trim(), parsedDeptId); + + setIsLookingUp(false); + + if (!config) { + setLookupError(t('sso.error_user_not_found')); + return; + } + + if (!config.ssoEnabled) { + setLookupError(t('sso.error_sso_not_enabled')); + return; + } + + setResolvedUsername(data.username.trim()); + setResolvedDepartmentId(parsedDeptId); + setSsoConfig(config); + setPhase('sso-options'); + }; + + if (phase === 'lookup') { + return ( + + + + + + + + {t('sso.page_title')} + + {t('sso.page_subtitle')} + + + + + {t('login.username')} + + ( + + + + )} + /> + + + {errors.username?.message} + + + + + + + {t('sso.department_id_label')} + ({t('sso.optional')}) + + + ( + + + + )} + /> + + + {errors.departmentId?.message} + + + + {lookupError ? ( + + + {lookupError} + + ) : null} + + {isLookingUp ? ( + + ) : ( + + )} + + + + + + ); + } + + // Phase: sso-options + return ( + + + + + + + + {t('sso.sign_in_title')} + + {resolvedUsername} + {ssoConfig?.providerType === 'oidc' ? t('sso.provider_oidc') : t('sso.provider_saml')} + + + {authError ? ( + + + {authError} + + ) : null} + + {ssoConfig?.providerType === 'oidc' && ssoConfig.authority && ssoConfig.clientId ? ( + { + setIsAuthenticating(true); + setAuthError(null); + }} + onAuthEnd={() => setIsAuthenticating(false)} + onTokenReceived={handleTokenReceived} + onError={(msg) => { + setAuthError(msg); + setIsAuthenticating(false); + }} + /> + ) : null} + + {ssoConfig?.providerType === 'saml2' && ssoConfig.idpSsoUrl ? ( + { + setIsAuthenticating(true); + setAuthError(null); + }} + onAuthEnd={() => setIsAuthenticating(false)} + onTokenReceived={handleTokenReceived} + onError={(msg) => { + setAuthError(msg); + setIsAuthenticating(false); + }} + /> + ) : null} + + + + + + ); +} diff --git a/src/components/calls/call-form-renderer.tsx b/src/components/calls/call-form-renderer.tsx new file mode 100644 index 0000000..1cac189 --- /dev/null +++ b/src/components/calls/call-form-renderer.tsx @@ -0,0 +1,132 @@ +import { useColorScheme } from 'nativewind'; +import React, { useRef } from 'react'; +import { ActivityIndicator, StyleSheet, View } from 'react-native'; +import WebView, { type WebViewMessageEvent } from 'react-native-webview'; + +interface CallFormRendererProps { + formSchemaJson: string; + onFormDataChange: (formDataJson: string) => void; + height?: number; +} + +function buildHtml(formSchemaJson: string, isDark: boolean): string { + const bg = isDark ? '#171717' : '#ffffff'; + const text = isDark ? '#f3f4f6' : '#111827'; + const border = isDark ? '#404040' : '#d1d5db'; + const inputBg = isDark ? '#262626' : '#f9fafb'; + + return ` + + + + +Call Form + + + + + +
+ + +`; +} + +export const CallFormRenderer: React.FC = ({ formSchemaJson, onFormDataChange, height = 400 }) => { + const { colorScheme } = useColorScheme(); + const isDark = colorScheme === 'dark'; + const webviewRef = useRef(null); + + const html = buildHtml(formSchemaJson, isDark); + + const handleMessage = (event: WebViewMessageEvent) => { + try { + const data = event.nativeEvent.data; + onFormDataChange(data); + } catch { + // ignore parse errors + } + }; + + return ( + + ( + + + + )} + startInLoadingState + /> + + ); +}; + +const styles = StyleSheet.create({ + container: { + borderRadius: 8, + overflow: 'hidden', + borderWidth: 1, + }, + containerDark: { borderColor: '#404040' }, + containerLight: { borderColor: '#e5e7eb' }, + webview: { flex: 1 }, + loading: { flex: 1, alignItems: 'center', justifyContent: 'center' }, +}); diff --git a/src/components/calls/call-form-renderer.web.tsx b/src/components/calls/call-form-renderer.web.tsx new file mode 100644 index 0000000..1eb0810 --- /dev/null +++ b/src/components/calls/call-form-renderer.web.tsx @@ -0,0 +1,118 @@ +import { useColorScheme } from 'nativewind'; +import React, { useEffect, useRef } from 'react'; +import { StyleSheet, View } from 'react-native'; + +interface CallFormRendererProps { + formSchemaJson: string; + onFormDataChange: (formDataJson: string) => void; + height?: number; +} + +function buildHtml(formSchemaJson: string, isDark: boolean): string { + const bg = isDark ? '#171717' : '#ffffff'; + const text = isDark ? '#f3f4f6' : '#111827'; + const border = isDark ? '#404040' : '#d1d5db'; + const inputBg = isDark ? '#262626' : '#f9fafb'; + + return ` + + + +Call Form + + + + + +
+ + +`; +} + +export const CallFormRenderer: React.FC = ({ formSchemaJson, onFormDataChange, height = 400 }) => { + const { colorScheme } = useColorScheme(); + const isDark = colorScheme === 'dark'; + const iframeRef = useRef(null); + + const html = buildHtml(formSchemaJson, isDark); + + useEffect(() => { + const handler = (event: MessageEvent) => { + if (typeof event.data === 'string') { + onFormDataChange(event.data); + } + }; + window.addEventListener('message', handler); + return () => window.removeEventListener('message', handler); + }, [onFormDataChange]); + + return ( + +