diff --git a/package-lock.json b/package-lock.json index dd0b217c..49b56d06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,7 +28,7 @@ "@nethesis/nethesis-brands-svg-icons": "github:nethesis/Font-Awesome#ns-brands", "@nethesis/nethesis-light-svg-icons": "github:nethesis/Font-Awesome#ns-light", "@nethesis/nethesis-solid-svg-icons": "github:nethesis/Font-Awesome#ns-solid", - "@nethesis/phone-island": "^0.15.11", + "@nethesis/phone-island": "^0.17.4", "@tailwindcss/forms": "^0.5.7", "@types/lodash": "^4.14.202", "@types/node": "^18.19.9", @@ -3177,72 +3177,6 @@ "node": ">= 10.0.0" } }, - "node_modules/@electron/windows-sign": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.1.tgz", - "integrity": "sha512-YfASnrhJ+ve6Q43ZiDwmpBgYgi2u0bYjeAVi2tDfN7YWAKO8X9EEOuPGtqbJpPLM6TfAHimghICjWe2eaJ8BAg==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "peer": true, - "dependencies": { - "cross-dirname": "^0.1.0", - "debug": "^4.3.4", - "fs-extra": "^11.1.1", - "minimist": "^1.2.8", - "postject": "^1.0.0-alpha.6" - }, - "bin": { - "electron-windows-sign": "bin/electron-windows-sign.js" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/@electron/windows-sign/node_modules/fs-extra": { - "version": "11.3.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz", - "integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/@electron/windows-sign/node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/@electron/windows-sign/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/@emotion/is-prop-valid": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.3.1.tgz", @@ -5639,15 +5573,15 @@ } }, "node_modules/@nethesis/phone-island": { - "version": "0.15.11", - "resolved": "https://registry.npmjs.org/@nethesis/phone-island/-/phone-island-0.15.11.tgz", - "integrity": "sha512-+Jso0npfbdOjC9+pPSsZmZYpNepbWRQ7dagRDIpFsi1bVAk0S/wh7+0KbUW5OVeaHbRICjLagl7jLAGP4/Q4MQ==", + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@nethesis/phone-island/-/phone-island-0.17.4.tgz", + "integrity": "sha512-3QRVzxyXEcPQIljI2vnO3VrYE+J5sM9Tz9iInna9ciKfLUGnBJWaj8K1towNhrfLbf/OAGlSwZeQE/Z1OGVGLA==", "dev": true, "license": "GPL-3.0-or-later", "dependencies": { "@fortawesome/free-solid-svg-icons": "^6.2.1", "@fortawesome/react-fontawesome": "^0.2.0", - "@headlessui/react": "^2.2.0", + "@headlessui/react": "^2.2.8", "@nethesis/nethesis-light-svg-icons": "github:nethesis/Font-Awesome#ns-light", "@nethesis/nethesis-solid-svg-icons": "github:nethesis/Font-Awesome#ns-solid", "@rematch/core": "^2.2.0", @@ -6046,9 +5980,9 @@ } }, "node_modules/@nethesis/phone-island/node_modules/type-fest": { - "version": "4.29.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.29.0.tgz", - "integrity": "sha512-RPYt6dKyemXJe7I6oNstcH24myUGSReicxcHTvCLgzm4e0n8y05dGvcGB15/SoPRBmhlMthWQ9pvKyL81ko8nQ==", + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", "dev": true, "license": "(MIT OR CC0-1.0)", "optional": true, @@ -11456,15 +11390,6 @@ "sha.js": "^2.4.8" } }, - "node_modules/cross-dirname": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", - "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/cross-fetch": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", @@ -23740,36 +23665,6 @@ "dev": true, "license": "MIT" }, - "node_modules/postject": { - "version": "1.0.0-alpha.6", - "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", - "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "commander": "^9.4.0" - }, - "bin": { - "postject": "dist/cli.js" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/postject/node_modules/commander": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", - "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": "^12.20.0 || >=14" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", diff --git a/package.json b/package.json index 1c511cf9..05da5c4c 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "@nethesis/nethesis-brands-svg-icons": "github:nethesis/Font-Awesome#ns-brands", "@nethesis/nethesis-light-svg-icons": "github:nethesis/Font-Awesome#ns-light", "@nethesis/nethesis-solid-svg-icons": "github:nethesis/Font-Awesome#ns-solid", - "@nethesis/phone-island": "^0.15.11", + "@nethesis/phone-island": "^0.17.4", "@tailwindcss/forms": "^0.5.7", "@types/lodash": "^4.14.202", "@types/node": "^18.19.9", diff --git a/public/locales/en/translations.json b/public/locales/en/translations.json index 921078c8..ef745fed 100644 --- a/public/locales/en/translations.json +++ b/public/locales/en/translations.json @@ -109,7 +109,16 @@ "Account List title": "Account list", "Account List description": "Choose an account to continue to NethLink.", "Delete account": "Are you sure you want to delete {{username}}?", - "Back": "Back" + "Back": "Back", + "User not authorized for NethLink": "User not authorized for NethLink", + "Generic error": "Generic error", + "2FA": { + "OTP code": "OTP code", + "OTP invalid": "The code you entered is invalid or has expired. Please check your authenticator app and try again.", + "OTP verification failed": "OTP verification failed", + "Two-Factor Authentication": "Two-Factor Authentication", + "Enter the 6-digit code (OTP code) from your authenticator app. If you cannot access the app, you can use one recovery OTP code.": "Enter the 6-digit code (OTP code) from your authenticator app. If you cannot access the app, you can use one recovery OTP code." + } }, "SplashScreen": { "Description": "Welcome to NethLink, a desktop solution for seamless communication. Make and receive calls, save contacts to you phonebook and much more.", diff --git a/public/locales/it/translations.json b/public/locales/it/translations.json index daa6c0f4..866dea3d 100644 --- a/public/locales/it/translations.json +++ b/public/locales/it/translations.json @@ -109,7 +109,16 @@ "Account List title": "Account disponibili", "Account List description": "Scegliete un account per proseguire con NethLink.", "Delete account": "Sei sicuro di voler eliminare {{username}}?", - "Back": "Indietro" + "Back": "Indietro", + "User not authorized for NethLink": "Utente non autorizzato per NethLink", + "Generic error": "Errore generico", + "2FA": { + "OTP code": "Codice OTP", + "OTP invalid": "Il codice inserito non è valido o è scaduto. Controlla la tua app di autenticazione e riprova.", + "OTP verification failed": "Verifica OTP fallita", + "Two-Factor Authentication": "Autenticazione a Due Fattori", + "Enter the 6-digit code (OTP code) from your authenticator app. If you cannot access the app, you can use one recovery OTP code.": "Inserisci il codice a 6 cifre (codice OTP) dalla tua app di autenticazione. Se non puoi accedere all'app, puoi utilizzare un codice OTP di recupero." + } }, "SplashScreen": { "Description": "Benvenuti in NethLink, la soluzione desktop per comunicazioni senza confini. Effettua e ricevi chiamate, salva i contatti nella tua rubrica e molto altro ancora.", diff --git a/src/main/classes/controllers/AccountController.ts b/src/main/classes/controllers/AccountController.ts index 35a2f07c..d82c1f22 100644 --- a/src/main/classes/controllers/AccountController.ts +++ b/src/main/classes/controllers/AccountController.ts @@ -5,7 +5,8 @@ import { store } from '@/lib/mainStore' import { useNethVoiceAPI } from '@shared/useNethVoiceAPI' import { useLogin } from '@shared/useLogin' import { NetworkController } from './NetworkController' -import { delay, getAccountUID } from '@shared/utils/utils' +import { getAccountUID } from '@shared/utils/utils' +import { requires2FA, isJWTExpired } from '@shared/utils/jwt' const defaultConfig: ConfigFile = { lastUser: undefined, @@ -76,7 +77,75 @@ export class AccountController { const decryptString = safeStorage.decryptString(psw) const _accountData = JSON.parse(decryptString) const password = _accountData.password + + // Check if saved token is still valid and doesn't require 2FA + if (lastLoggedAccount.jwtToken) { + if (!isJWTExpired(lastLoggedAccount.jwtToken)) { + // Token is still valid locally, check if it requires 2FA + if (requires2FA(lastLoggedAccount.jwtToken)) { + Log.info('auto login failed: 2FA required, user interaction needed') + return false + } + + // Token looks valid locally, but we need to verify with server + // The token might have been invalidated (e.g., 2FA disabled/enabled) + Log.info('auto login: validating saved token with server...') + + try { + // Make a simple API call to verify the token is still accepted by the server + // Use the /api/user/me endpoint to validate the token + const testUrl = `https://${lastLoggedAccount.host}/api/user/me` + const response = await NetworkController.instance.get(testUrl, { + headers: { + 'Authorization': `Bearer ${lastLoggedAccount.jwtToken}` + } + } as any) + + // If we get here, the token is valid on the server + Log.info('auto login: token validated with server, using saved token') + } catch (error: any) { + // Token was rejected by server (401/403) or network error + Log.info('auto login failed: saved token rejected by server', error?.response?.status || error?.message) + return false + } + + // Update store with the saved account (don't do a new login!) + // IMPORTANT: Preserve auth.lastUser and auth.lastUserCryptPsw so they are saved to disk + // IMPORTANT: Set connection: true to prevent "No internet connection" banner + store.updateStore({ + account: lastLoggedAccount, + theme: lastLoggedAccount.theme, + connection: true, + accountStatus: store.store.accountStatus || 'offline', + isCallsEnabled: store.store.isCallsEnabled || false, + auth: { + ...authAppData, + lastUser: authAppData.lastUser, + lastUserCryptPsw: authAppData.lastUserCryptPsw + } + }, 'autoLogin') + + return true + } else { + Log.info('auto login: saved token expired, need to re-login') + } + } + + // Token is expired or doesn't exist, do a new login const tempLoggedAccount = await this.NethVoiceAPI.Authentication.login(lastLoggedAccount.host, lastLoggedAccount.username, password) + + // Check if 2FA is required - auto-login should fail in this case + if (tempLoggedAccount.jwtToken && requires2FA(tempLoggedAccount.jwtToken)) { + Log.info('auto login failed: 2FA required, user interaction needed') + return false + } + + // Auto-login only works with JWT tokens (no legacy support) + if (!tempLoggedAccount.jwtToken) { + Log.info('auto login failed: no JWT token received') + return false + } + let loggedAccount: Account = { ...lastLoggedAccount, ...tempLoggedAccount, diff --git a/src/main/classes/controllers/PhoneIslandController.ts b/src/main/classes/controllers/PhoneIslandController.ts index 46f9600f..8ad4c4f8 100644 --- a/src/main/classes/controllers/PhoneIslandController.ts +++ b/src/main/classes/controllers/PhoneIslandController.ts @@ -10,6 +10,7 @@ import { Extension, Size } from '@shared/types' export class PhoneIslandController { static instance: PhoneIslandController window: PhoneIslandWindow + private isWarmingUp: boolean = false constructor() { PhoneIslandController.instance = this @@ -30,7 +31,8 @@ export class PhoneIslandController { if (h === 0 && w === 0) { window.hide() } else { - if (!window.isVisible()) { + // Don't show window during warm-up + if (!window.isVisible() && !this.isWarmingUp) { window.show() window.setAlwaysOnTop(true) } @@ -148,6 +150,60 @@ export class PhoneIslandController { } } + muteAudio() { + try { + const window = this.window.getWindow() + if (window && window.webContents) { + window.webContents.setAudioMuted(true) + Log.info('PhoneIsland audio muted') + } + } catch (e) { + Log.warning('error during muting PhoneIsland audio:', e) + } + } + + unmuteAudio() { + try { + const window = this.window.getWindow() + if (window && window.webContents) { + window.webContents.setAudioMuted(false) + Log.info('PhoneIsland audio unmuted') + } + } catch (e) { + Log.warning('error during unmuting PhoneIsland audio:', e) + } + } + + forceHide() { + try { + const window = this.window.getWindow() + if (window) { + this.isWarmingUp = true + window.hide() + Log.info('PhoneIsland window hidden') + } + } catch (e) { + Log.warning('error during force hiding PhoneIsland:', e) + } + } + + forceShow() { + try { + const window = this.window.getWindow() + if (window) { + this.isWarmingUp = false + // Only show if there's actually content (size > 0) + const bounds = window.getBounds() + if (bounds.width > 0 && bounds.height > 0) { + window.show() + window.setAlwaysOnTop(true) + Log.info('PhoneIsland window shown') + } + } + } catch (e) { + Log.warning('error during force showing PhoneIsland:', e) + } + } async safeQuit() { await this.logout() diff --git a/src/main/lib/ipcEvents.ts b/src/main/lib/ipcEvents.ts index 29417770..82a5013f 100644 --- a/src/main/lib/ipcEvents.ts +++ b/src/main/lib/ipcEvents.ts @@ -18,6 +18,9 @@ import os from 'os' const { keyboard, Key } = require("@nut-tree-fork/nut-js"); +// Global flag to ensure audio warm-up runs only once per app session +let hasRunAudioWarmup = false + function onSyncEmitter( channel: IPC_EVENTS, asyncCallback: (...args: any[]) => Promise diff --git a/src/main/main.ts b/src/main/main.ts index 93ba78e5..c2f24f35 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -205,6 +205,20 @@ function attachOnReadyProcess() { //I display the splashscreen when the splashscreen component is correctly loaded. SplashScreenController.instance.window.addOnBuildListener(() => { + // On Windows, check if system is locked before starting + if (process.platform === 'win32') { + const idleState = powerMonitor.getSystemIdleState(1) + if (idleState === 'locked') { + Log.info('Windows is locked, waiting for unlock before starting app...') + // Wait for unlock-screen event before starting + powerMonitor.once('unlock-screen', () => { + Log.info('Windows unlocked, starting app now...') + setTimeout(startApp, 1000) + }) + return + } + } + // Normal flow: start after 1 second setTimeout(startApp, 1000) }) await attachProtocolListeners() diff --git a/src/preload/index.ts b/src/preload/index.ts index af4031cc..9b5f98bf 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -11,6 +11,7 @@ import { preloadBindings } from 'i18next-electron-fs-backend' export interface IElectronAPI { env: NodeJS.ProcessEnv, appVersion: string, + platform: string, // Use `contextBridge` APIs to expose Electron APIs to // renderer only if context isolation is enabled, otherwise @@ -82,6 +83,7 @@ function setEmitter(event) { const api: IElectronAPI = { env: process.env, appVersion: process.env['APP_VERSION'] || '0.0.1', + platform: process.platform, i18nextElectronBackend: preloadBindings(ipcRenderer, process), //SYNC EMITTERS - expect response login: setEmitterSync(IPC_EVENTS.LOGIN), diff --git a/src/renderer/public/locales/en/translations.json b/src/renderer/public/locales/en/translations.json index 921078c8..ef745fed 100644 --- a/src/renderer/public/locales/en/translations.json +++ b/src/renderer/public/locales/en/translations.json @@ -109,7 +109,16 @@ "Account List title": "Account list", "Account List description": "Choose an account to continue to NethLink.", "Delete account": "Are you sure you want to delete {{username}}?", - "Back": "Back" + "Back": "Back", + "User not authorized for NethLink": "User not authorized for NethLink", + "Generic error": "Generic error", + "2FA": { + "OTP code": "OTP code", + "OTP invalid": "The code you entered is invalid or has expired. Please check your authenticator app and try again.", + "OTP verification failed": "OTP verification failed", + "Two-Factor Authentication": "Two-Factor Authentication", + "Enter the 6-digit code (OTP code) from your authenticator app. If you cannot access the app, you can use one recovery OTP code.": "Enter the 6-digit code (OTP code) from your authenticator app. If you cannot access the app, you can use one recovery OTP code." + } }, "SplashScreen": { "Description": "Welcome to NethLink, a desktop solution for seamless communication. Make and receive calls, save contacts to you phonebook and much more.", diff --git a/src/renderer/public/locales/it/translations.json b/src/renderer/public/locales/it/translations.json index daa6c0f4..866dea3d 100644 --- a/src/renderer/public/locales/it/translations.json +++ b/src/renderer/public/locales/it/translations.json @@ -109,7 +109,16 @@ "Account List title": "Account disponibili", "Account List description": "Scegliete un account per proseguire con NethLink.", "Delete account": "Sei sicuro di voler eliminare {{username}}?", - "Back": "Indietro" + "Back": "Indietro", + "User not authorized for NethLink": "Utente non autorizzato per NethLink", + "Generic error": "Errore generico", + "2FA": { + "OTP code": "Codice OTP", + "OTP invalid": "Il codice inserito non è valido o è scaduto. Controlla la tua app di autenticazione e riprova.", + "OTP verification failed": "Verifica OTP fallita", + "Two-Factor Authentication": "Autenticazione a Due Fattori", + "Enter the 6-digit code (OTP code) from your authenticator app. If you cannot access the app, you can use one recovery OTP code.": "Inserisci il codice a 6 cifre (codice OTP) dalla tua app di autenticazione. Se non puoi accedere all'app, puoi utilizzare un codice OTP di recupero." + } }, "SplashScreen": { "Description": "Benvenuti in NethLink, la soluzione desktop per comunicazioni senza confini. Effettua e ricevi chiamate, salva i contatti nella tua rubrica e molto altro ancora.", diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index c287469d..2324b66c 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -44,9 +44,9 @@ const RequestStateComponent = () => { // @ts-ignore (define in dts) window['CONFIG'] = { PRODUCT_NAME: 'NethLink', - COMPANY_NAME: 'Nethesis', + COMPANY_NAME: account.companyName, COMPANY_SUBNAME: 'CTI', - COMPANY_URL: 'https://www.nethesis.it/', + COMPANY_URL: account.companyUrl, API_ENDPOINT: `${account.host}`, API_SCHEME: 'https://', WS_ENDPOINT: `wss://${account.host}/ws`, diff --git a/src/renderer/src/components/Modules/NethVoice/BaseModule/ProfileDialog/SettingsSettings/SettingsDevicesDialog.tsx b/src/renderer/src/components/Modules/NethVoice/BaseModule/ProfileDialog/SettingsSettings/SettingsDevicesDialog.tsx index 99fac417..f603e834 100644 --- a/src/renderer/src/components/Modules/NethVoice/BaseModule/ProfileDialog/SettingsSettings/SettingsDevicesDialog.tsx +++ b/src/renderer/src/components/Modules/NethVoice/BaseModule/ProfileDialog/SettingsSettings/SettingsDevicesDialog.tsx @@ -169,10 +169,6 @@ export function SettingsDeviceDialog() { videoInput: t('TopBar.Camera'), } - const isDeviceUnavailable = - account?.data?.default_device?.type == 'webrtc' || - account?.data?.mainPresence !== 'online' - return ( <> {/* Background color */} @@ -276,23 +272,12 @@ export function SettingsDeviceDialog() { ))} - {/* Inline notification */} - {isDeviceUnavailable && ( - -

{t('Devices.Inline warning message devices')}

-
- )} {/* Action buttons */}
@@ -313,4 +298,4 @@ export function SettingsDeviceDialog() {
) -} +} \ No newline at end of file diff --git a/src/renderer/src/components/Modules/NethVoice/LastCalls/LastCallsBox.tsx b/src/renderer/src/components/Modules/NethVoice/LastCalls/LastCallsBox.tsx index 864f9291..e433786e 100644 --- a/src/renderer/src/components/Modules/NethVoice/LastCalls/LastCallsBox.tsx +++ b/src/renderer/src/components/Modules/NethVoice/LastCalls/LastCallsBox.tsx @@ -18,7 +18,17 @@ export function LastCallsBox({ showContactForm }): JSX.Element { const [operators] = useNethlinkData('operators') const [missedCalls, setMissedCalls] = useNethlinkData('missedCalls') const [preparedCalls, setPreparedCalls] = useState([]) - const title = `${t('LastCalls.Calls', { count: lastCalls?.length })} (${lastCalls?.length || 0})` + + const getFilteredCallsCount = (): number => { + if (!lastCalls) return 0 + return lastCalls.filter((call) => { + const numberToCheck = call.direction === 'in' ? call.src : call.dst + return !numberToCheck?.includes('*43') + }).length + } + + const filteredCount = getFilteredCallsCount() + const title = `${t('LastCalls.Calls', { count: filteredCount })} (${filteredCount})` useEffect(() => { prepareCalls() diff --git a/src/renderer/src/components/Nethesis/dropdown/index.tsx b/src/renderer/src/components/Nethesis/dropdown/index.tsx index 9aaef3de..ee450102 100644 --- a/src/renderer/src/components/Nethesis/dropdown/index.tsx +++ b/src/renderer/src/components/Nethesis/dropdown/index.tsx @@ -24,6 +24,7 @@ export interface DropdownProps extends ComponentProps<'div'> { | 'topVoicemail' | 'bottomVoicemail' | 'oneVoicemail' + | 'bottomTranscription' size?: 'full' } diff --git a/src/renderer/src/components/pageComponents/login/DisplayedAccountLogin.tsx b/src/renderer/src/components/pageComponents/login/DisplayedAccountLogin.tsx index 6f30649d..1fe9b018 100644 --- a/src/renderer/src/components/pageComponents/login/DisplayedAccountLogin.tsx +++ b/src/renderer/src/components/pageComponents/login/DisplayedAccountLogin.tsx @@ -17,41 +17,47 @@ export function DisplayedAccountLogin({ account, imageSrc, handleClick, - handleDeleteClick + handleDeleteClick, }: DisplayedAccountLoginProps) { return (
handleClick?.()} className={classNames( 'w-full flex flex-row gap-7 items-center justify-start bg-transparent h-20 rounded-lg text-titleLight dark:text-titleDark cursor-pointer', - handleClick ? 'hover:bg-hoverLight dark:hover:bg-hoverDark' : '' + handleClick ? 'hover:bg-hoverLight dark:hover:bg-hoverDark' : '', )} > -
- +
+
-

- {account - ? - - {account.data?.name} - - {

{account.data?.endpoints.mainextension[0].id} - {account.host}
} +

+ {account ? ( + + {account.data?.name} + { + + {account.data?.endpoints.mainextension[0].id} - {account.host} + + } - : t('Login.Use Another Account')} + ) : ( + t('Login.Use Another Account') + )}

- { - handleDeleteClick && { - e.preventDefault() - e.stopPropagation() - handleDeleteClick() - }} /> - } + {handleDeleteClick && ( + { + e.preventDefault() + e.stopPropagation() + handleDeleteClick() + }} + /> + )}
) diff --git a/src/renderer/src/components/pageComponents/login/LoginForm.tsx b/src/renderer/src/components/pageComponents/login/LoginForm.tsx index 1146a38e..a4408e4a 100644 --- a/src/renderer/src/components/pageComponents/login/LoginForm.tsx +++ b/src/renderer/src/components/pageComponents/login/LoginForm.tsx @@ -5,20 +5,22 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faEye as EyeIcon, faEyeSlash as EyeSlashIcon, - faXmarkCircle as ErrorIcon, faWarning as AlertIcon, + faCircleNotch, } from '@fortawesome/free-solid-svg-icons' import { t } from 'i18next' import { useEffect, useRef, useState } from 'react' import { Button, TextInput } from '@renderer/components/Nethesis' import { Account, LoginData } from '@shared/types' import { DisplayedAccountLogin } from './DisplayedAccountLogin' +import { OTPInput, OTPInputRef } from './OTPInput' import { useLoginPageData, useSharedState } from '@renderer/store' import { useNethVoiceAPI } from '@shared/useNethVoiceAPI' import { IPC_EVENTS, NEW_ACCOUNT } from '@shared/constants' import { Log } from '@shared/utils/logger' import { getAccountUID } from '@shared/utils/utils' import { InlineNotification } from '@renderer/components/Nethesis/InlineNotification' +import { requires2FA } from '@shared/utils/jwt' export interface LoginFormProps { onError: ( @@ -36,7 +38,13 @@ export const LoginForm = ({ onError, handleRefreshConnection }) => { const [isLoading, setIsLoading] = useLoginPageData('isLoading') const [connection] = useSharedState('connection') const [error, setError] = useState(undefined) + const [otpCode, setOtpCode] = useState('') + const [onOTPError, setOnOTPError] = useState(false) + const [showTwoFactor, setShowTwoFactor] = useLoginPageData('showTwoFactor') + const [tempAccount, setTempAccount] = useState(undefined) + const [otpDisabled, setOtpDisabled] = useState(false) const passwordRef = useRef() + const otpInputRef = useRef() as React.MutableRefObject const schema: z.ZodType = z.object({ host: z @@ -71,24 +79,42 @@ export const LoginForm = ({ onError, handleRefreshConnection }) => { useEffect(() => { setIsLoading(false) + setTempAccount(undefined) + setOnOTPError(false) if (auth?.availableAccounts) { if (selectedAccount) { if (selectedAccount === NEW_ACCOUNT) { + setShowTwoFactor(false) // Reset 2FA when switching to new account reset() focus('host') } else { + setShowTwoFactor(false) // Reset 2FA when switching to existing account reset() setValue('host', selectedAccount.host) setValue('username', selectedAccount.username) focus('password') } } else { + setShowTwoFactor(false) // Reset 2FA when going back to account list setError(undefined) focus('host') } } }, [auth, selectedAccount]) + // Handle 2FA reset when back button is pressed from LoginPage + useEffect(() => { + if (!showTwoFactor && tempAccount) { + // Reset 2FA state when going back from OTP verification + setTempAccount(undefined) + setOnOTPError(false) + setIsLoading(false) + setOtpDisabled(false) + setOtpCode('') + setError(undefined) + } + }, [showTwoFactor]) + async function handleLogin(data: LoginData) { if (!isLoading) { let e: Error | undefined = undefined @@ -106,6 +132,18 @@ export const LoginForm = ({ onError, handleRefreshConnection }) => { data.password, ) Log.info('LOGIN successfully logged in with credential') + + // Check if 2FA is required + if (loggedAccount.jwtToken && requires2FA(loggedAccount.jwtToken)) { + Log.info('LOGIN 2FA required, showing 2FA form') + setTempAccount(loggedAccount) + passwordRef.current = data.password + setShowTwoFactor(true) + setIsLoading(false) + return + } + + // Complete login flow window.electron.receive( IPC_EVENTS.SET_NETHVOICE_CONFIG, (account: Account) => { @@ -133,12 +171,22 @@ export const LoginForm = ({ onError, handleRefreshConnection }) => { }) setError(() => undefined) } catch (error: any) { + console.error('LOGIN error during login', error) setIsLoading(false) - if (error.message === 'Unauthorized') + if (error.message === 'Wrong username or password') { + setError(() => new Error(t('Login.Wrong username or password')!)) + } else if (error.message === 'Network connection lost') { + setError(() => new Error(t('Login.Network connection is lost')!)) + } else if (error.message === 'Unauthorized') { setError( () => new Error(t('Login.Wrong host or username or password')!), ) - else { + } else if (error.message === 'User not authorized for NethLink') { + Log.info('LOGIN user not authorized for NethLink') + setError( + () => new Error(t('Login.User not authorized for NethLink')!), + ) + } else { setError(() => error) } } @@ -152,6 +200,82 @@ export const LoginForm = ({ onError, handleRefreshConnection }) => { } } + async function handle2FAVerification(e?: React.FormEvent) { + if (e) { + e.preventDefault() + } + + if (!tempAccount) { + setOnOTPError(true) + return + } + + setIsLoading(true) + setOnOTPError(false) + + try { + Log.info('LOGIN verifying 2FA code') + // const { NethVoiceAPI: TempAPI } = useNethVoiceAPI(tempAccount) + const verifiedAccount = await NethVoiceAPI.Authentication.verify2FA( + otpCode, + tempAccount, + ) + Log.info('LOGIN 2FA verification successful') + + // Complete login flow + window.electron.receive( + IPC_EVENTS.SET_NETHVOICE_CONFIG, + (account: Account) => { + Log.info( + 'LOGIN received account server configuration after 2FA', + account, + ) + const previousLoggedAccount = + auth?.availableAccounts[getAccountUID(account)] + account.theme = previousLoggedAccount + ? previousLoggedAccount.theme + : 'system' + Log.info('LOGIN send login event to the backend after 2FA', account) + window.electron.send(IPC_EVENTS.LOGIN, { + password: passwordRef.current, + account, + }) + }, + ) + Log.info('LOGIN get account server configuration after 2FA') + window.electron.send(IPC_EVENTS.GET_NETHVOICE_CONFIG, verifiedAccount) + + setTempAccount(undefined) + setError(() => undefined) + } catch (error: any) { + setIsLoading(false) + + if (error.message === 'OTP invalid') { + setOnOTPError(true) + setError(() => new Error(t('Login.2FA.OTP invalid') as string)) + } else if (error.message === 'User not authorized for NethLink') { + setError( + () => + new Error(t('Login.User not authorized for NethLink') as string), + ) + setOtpDisabled(true) + } else { + console.error('LOGIN error during 2FA verification', error) + setError(() => new Error(t('Login.Generic error') as string)) + } + } + } + + function handleBack2FA() { + setShowTwoFactor(false) + setTempAccount(undefined) + setOnOTPError(false) + setIsLoading(false) + setOtpDisabled(false) + setOtpCode('') + setError(undefined) + } + const onSubmitForm: SubmitHandler = (data) => { handleLogin(data) } @@ -198,52 +322,144 @@ export const LoginForm = ({ onError, handleRefreshConnection }) => { return (
-

- {selectedAccount - ? t('Login.Account List title') - : t('Login.New Account title')} -

-

- {selectedAccount - ? t('Login.Account List description') - : t('Login.New Account description')} -

- {error && ( - - {error.message} - - )} - {selectedAccount && selectedAccount !== NEW_ACCOUNT && ( - - )} - {connection ? ( -
-
- {!(selectedAccount && selectedAccount !== NEW_ACCOUNT) && ( - <> - { - if (e.key === 'Enter') { - e.preventDefault() - submitButtonRef.current?.focus() - handleSubmit(onSubmitForm)(e) - } - }} + {showTwoFactor ? ( + <> + {/* OTP input section */} +
+
+

+ {t('Login.2FA.Two-Factor Authentication')} +

+

+ {t( + 'Login.2FA.Enter the 6-digit code (OTP code) from your authenticator app. If you cannot access the app, you can use one recovery OTP code.', + )} +

+
+ {error && ( + + {error.message} + + )} + handle2FAVerification(e)} + className='space-y-6 mt-6' + > +
+

+ {t('Login.2FA.OTP code')} +

+ +
+ +
+ +
+ +
+ + ) : ( + <> +

+ {selectedAccount + ? t('Login.Account List title') + : t('Login.New Account title')} +

+

+ {selectedAccount + ? t('Login.Account List description') + : t('Login.New Account description')} +

+ {error && ( + + {error.message} + + )} + {selectedAccount && selectedAccount !== NEW_ACCOUNT && ( + + )} + {connection ? ( +
+
+ {!(selectedAccount && selectedAccount !== NEW_ACCOUNT) && ( + <> + { + if (e.key === 'Enter') { + e.preventDefault() + submitButtonRef.current?.focus() + handleSubmit(onSubmitForm)(e) + } + }} + /> + value?.toLowerCase() || '', + })} + type='text' + label={t('Login.Username') as string} + helper={errors.username?.message || undefined} + error={!!errors.username?.message} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + submitButtonRef.current?.focus() + handleSubmit(onSubmitForm)(e) + } + }} + /> + + )} setPwdVisible(!pwdVisible)} + trailingIcon={true} + helper={errors.password?.message || undefined} + error={!!errors.password?.message} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault() @@ -252,36 +468,19 @@ export const LoginForm = ({ onError, handleRefreshConnection }) => { } }} /> - - )} - setPwdVisible(!pwdVisible)} - trailingIcon={true} - helper={errors.password?.message || undefined} - error={!!errors.password?.message} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault() - submitButtonRef.current?.focus() - handleSubmit(onSubmitForm)(e) - } - }} - /> - -
-
- ) : ( -
- -
+ +
+ + ) : ( +
+ +
+ )} + )}
) diff --git a/src/renderer/src/components/pageComponents/login/OTPInput.tsx b/src/renderer/src/components/pageComponents/login/OTPInput.tsx new file mode 100644 index 00000000..17d0d192 --- /dev/null +++ b/src/renderer/src/components/pageComponents/login/OTPInput.tsx @@ -0,0 +1,193 @@ +// Copyright (C) 2025 Nethesis S.r.l. +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + useRef, + useEffect, + KeyboardEvent, + ClipboardEvent, + forwardRef, + useImperativeHandle, +} from 'react' + +interface OTPInputProps { + value: string + onChange: (value: string) => void + length?: number + disabled?: boolean + className?: string + error?: boolean +} + +export interface OTPInputRef { + focus: () => void + clear: () => void +} + +export const OTPInput = forwardRef( + ({ value, onChange, length = 6, disabled = false, className = '', error = false }, ref) => { + const inputRefs = useRef<(HTMLInputElement | null)[]>([]) + + // Initialize input refs array + useEffect(() => { + inputRefs.current = inputRefs.current.slice(0, length) + }, [length]) + + // Expose methods to parent component + useImperativeHandle(ref, () => ({ + focus: () => { + const firstEmptyIndex = value.length < length ? value.length : 0 + inputRefs.current[firstEmptyIndex]?.focus() + }, + clear: () => { + onChange('') + inputRefs.current[0]?.focus() + }, + })) + + // Auto-focus first input on mount + useEffect(() => { + if (!disabled) { + inputRefs.current[0]?.focus() + } + }, [disabled]) + + const focusInput = (index: number) => { + if (inputRefs.current[index]) { + inputRefs.current[index]?.focus() + } + } + + const focusNextInput = (index: number) => { + if (index < length - 1) { + focusInput(index + 1) + } + } + + const focusPrevInput = (index: number) => { + if (index > 0) { + focusInput(index - 1) + } + } + + const handleChange = (index: number, inputValue: string) => { + // Remove any non-digit characters + const digit = inputValue.replace(/\D/g, '') + + if (digit.length <= 1) { + const newValue = value.split('') + newValue[index] = digit + const updatedValue = newValue.join('').slice(0, length) + onChange(updatedValue) + + // Auto-focus next input if a digit was entered + if (digit && index < length - 1) { + focusNextInput(index) + } + } + } + + const handleKeyDown = (index: number, event: KeyboardEvent) => { + const { key } = event + + if (key === 'Backspace') { + if (value[index]) { + // Clear current input + const newValue = value.split('') + newValue[index] = '' + onChange(newValue.join('')) + } else if (index > 0) { + // Move to previous input and clear it + const newValue = value.split('') + newValue[index - 1] = '' + onChange(newValue.join('')) + focusPrevInput(index) + } + } else if (key === 'ArrowLeft') { + event.preventDefault() + focusPrevInput(index) + } else if (key === 'ArrowRight') { + event.preventDefault() + focusNextInput(index) + } else if (key === 'Delete') { + event.preventDefault() + const newValue = value.split('') + newValue[index] = '' + onChange(newValue.join('')) + } else if (/^[0-9]$/.test(key)) { + // Handle direct digit input + event.preventDefault() + handleChange(index, key) + } + } + + const handlePaste = (event: ClipboardEvent) => { + event.preventDefault() + const pasteData = event.clipboardData.getData('text') + const digits = pasteData.replace(/\D/g, '').slice(0, length) + onChange(digits) + + // Focus the next empty input or the last input + const nextFocusIndex = Math.min(digits.length, length - 1) + setTimeout(() => focusInput(nextFocusIndex), 0) + } + + const handleFocus = (index: number) => { + // Select all text when focusing + inputRefs.current[index]?.select() + } + + return ( +
+ {Array.from({ length }, (_, index) => ( + (inputRefs.current[index] = el)} + type='text' + inputMode='numeric' + pattern='[0-9]*' + maxLength={1} + value={value[index] || ''} + onChange={(e) => handleChange(index, e.target.value)} + onKeyDown={(e) => handleKeyDown(index, e)} + onPaste={handlePaste} + onFocus={() => handleFocus(index)} + disabled={disabled} + className={` + w-12 h-12 text-center text-lg font-semibold + border-2 rounded-lg + focus:outline-none transition-colors duration-200 + ${ + error + ? 'border-red-500 dark:border-red-400 focus:ring-2 focus:ring-red-500 focus:border-red-500 dark:focus:ring-red-400 dark:focus:border-red-400' + : 'focus:ring-2 focus:ring-primary focus:border-primary dark:focus:ring-primaryDark dark:focus:border-primaryDark' + } + ${ + !error && value[index] + ? 'border-primary dark:border-primaryDark bg-primary/5 dark:bg-primaryDark/5' + : !error && !value[index] + ? 'border-gray-300 dark:border-gray-600' + : '' + } + ${ + error && value[index] + ? 'bg-red-50 dark:bg-red-900/20' + : error && !value[index] + ? 'bg-red-50 dark:bg-red-900/20' + : '' + } + ${ + disabled + ? 'bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500 cursor-not-allowed' + : 'bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 hover:border-gray-400 dark:hover:border-gray-500' + } + `} + aria-label={`Digit ${index + 1}`} + /> + ))} +
+ ) + }, +) + +OTPInput.displayName = 'OTPInput' diff --git a/src/renderer/src/components/pageComponents/login/index.ts b/src/renderer/src/components/pageComponents/login/index.ts index 493ad306..085a2255 100644 --- a/src/renderer/src/components/pageComponents/login/index.ts +++ b/src/renderer/src/components/pageComponents/login/index.ts @@ -2,3 +2,4 @@ export * from './AvailableAccountList' export * from './LoginForm' export * from './AvailableAccountDeleteDialog' export * from './DisplayedAccountLogin' +export * from './OTPInput' diff --git a/src/renderer/src/pages/LoginPage.tsx b/src/renderer/src/pages/LoginPage.tsx index e2e54a50..3930a61f 100644 --- a/src/renderer/src/pages/LoginPage.tsx +++ b/src/renderer/src/pages/LoginPage.tsx @@ -5,28 +5,30 @@ import spinner from '../assets/loginPageSpinner.svg' import darkHeader from '../assets/nethlinkDarkHeader.svg' import lightHeader from '../assets/nethlinkLightHeader.svg' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { - faArrowLeft as ArrowIcon, -} from '@fortawesome/free-solid-svg-icons' +import { faArrowLeft as ArrowIcon } from '@fortawesome/free-solid-svg-icons' import { t } from 'i18next' import { Button } from '@renderer/components/Nethesis' import './LoginPage.css' import { useLoginPageData, useSharedState } from '@renderer/store' -import { AvailableAccountList, LoginForm } from '@renderer/components/pageComponents' +import { + AvailableAccountList, + LoginForm, +} from '@renderer/components/pageComponents' import { IPC_EVENTS, LoginPageSize, NEW_ACCOUNT } from '@shared/constants' import { Log } from '@shared/utils/logger' import { FieldErrors } from 'react-hook-form' import { AvailableAccountDeleteDialog } from '@renderer/components/pageComponents/login/AvailableAccountDeleteDialog' export interface LoginPageProps { - themeMode: string, + themeMode: string handleRefreshConnection: () => void } enum LoginSizes { BASE = 550, ACCOUNT_FORM = 488, + TWO_FACTOR_AUTH = 420, BACK_BUTTON = 60, INPUT_ERROR = 22, LOGIN_FAILURE = 104, @@ -39,25 +41,30 @@ enum LoginSizes { } type ErrorsData = { - formErrors: FieldErrors, - generalError: Error | undefined, + formErrors: FieldErrors + generalError: Error | undefined } -export function LoginPage({ themeMode, handleRefreshConnection }: LoginPageProps) { - - +export function LoginPage({ + themeMode, + handleRefreshConnection, +}: LoginPageProps) { const loginWindowRef = useRef() as MutableRefObject const [auth] = useSharedState('auth') - const [isLoading, setIsLoading] = useLoginPageData('isLoading') - const [selectedAccount, setSelectedAccount] = useLoginPageData('selectedAccount') + const [isLoading] = useLoginPageData('isLoading') + const [selectedAccount, setSelectedAccount] = + useLoginPageData('selectedAccount') const [windowHeight, setWindowHeight] = useLoginPageData('windowHeight') + const [showTwoFactor, setShowTwoFactor] = useLoginPageData('showTwoFactor') const [connection] = useSharedState('connection') const [errorsData, setErrorsData] = useState() const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) - const [deleteDialogAccount, setDeleteDialogAccount] = useState(undefined) + const [deleteDialogAccount, setDeleteDialogAccount] = useState< + Account | undefined + >(undefined) useEffect(() => { calculateHeight() - }, [selectedAccount, auth, errorsData, connection]) + }, [selectedAccount, auth, errorsData, connection, showTwoFactor]) useEffect(() => { if (windowHeight) { @@ -66,15 +73,24 @@ export function LoginPage({ themeMode, handleRefreshConnection }: LoginPageProps }, [windowHeight]) const goBack = () => { - setSelectedAccount(undefined) - setErrorsData({ formErrors: {}, generalError: undefined }) + if (showTwoFactor) { + // If we're in OTP verification, go back to login form + setShowTwoFactor(false) + // Keep selectedAccount, stay in the login form + } else { + // If we're in normal login form, go back to account selection + setSelectedAccount(undefined) + setErrorsData({ formErrors: {}, generalError: undefined }) + } } - - const onFormErrors = (formErrors: FieldErrors, generalError: Error | undefined) => { + const onFormErrors = ( + formErrors: FieldErrors, + generalError: Error | undefined, + ) => { setErrorsData({ formErrors, - generalError + generalError, }) } @@ -96,13 +112,22 @@ export function LoginPage({ themeMode, handleRefreshConnection }: LoginPageProps function calculateHeight() { let loginWindowHeight = 0 const accounts = Object.keys(auth?.availableAccounts || {}) - const errorCount = Object.values(errorsData?.formErrors || {}).filter((v) => v.message).length - //Login form is shown - if (selectedAccount) { + const errorCount = Object.values(errorsData?.formErrors || {}).filter( + (v) => v.message, + ).length + + // If Two Factor Authentication is shown, use specific height + if (showTwoFactor) { + loginWindowHeight = LoginSizes.TWO_FACTOR_AUTH + if (!auth?.isFirstStart) { + loginWindowHeight += LoginSizes.BACK_BUTTON - 24 + } + } + // Login form is shown + else if (selectedAccount) { if (selectedAccount === NEW_ACCOUNT) { loginWindowHeight = LoginSizes.BASE - if (!connection) - loginWindowHeight = LoginSizes.CONNECTION_FAILURE_BASE + if (!connection) loginWindowHeight = LoginSizes.CONNECTION_FAILURE_BASE if (!auth?.isFirstStart) { loginWindowHeight += LoginSizes.BACK_BUTTON - 24 } @@ -115,11 +140,15 @@ export function LoginPage({ themeMode, handleRefreshConnection }: LoginPageProps if (auth?.isFirstStart) { loginWindowHeight = LoginSizes.BASE } else { - //List of account is shown + // Account list is shown switch (accounts.length) { case 0: loginWindowHeight = LoginSizes.BASE - if (auth && !auth.isFirstStart && Object.keys(auth.availableAccounts).length > 0) { + if ( + auth && + !auth.isFirstStart && + Object.keys(auth.availableAccounts).length > 0 + ) { loginWindowHeight += LoginSizes.BACK_BUTTON } break @@ -145,38 +174,51 @@ export function LoginPage({ themeMode, handleRefreshConnection }: LoginPageProps return (
-
- +
+
- { - auth && <> - { - Object.keys(auth.availableAccounts).length > 0 && selectedAccount && ( + {auth && ( + <> + {Object.keys(auth.availableAccounts).length > 0 && + selectedAccount && ( )} - {(auth.isFirstStart || selectedAccount || Object.keys(auth.availableAccounts).length === 0) ? : } + {auth.isFirstStart || + selectedAccount || + showTwoFactor || + Object.keys(auth.availableAccounts).length === 0 ? ( + + ) : ( + + )} - } + )}
{isLoading && ( -
- +
+
)} (false) const urlOpenAttempts = useRef(0) const urlOpenListenerRegistered = useRef(false) + const hasRunWarmup = useRef(false) useEffect(() => { resize(phoneIsalndSizes) @@ -76,9 +77,26 @@ export function PhoneIslandPage() { window.electron.receive(IPC_EVENTS.CHANGE_PREFERRED_DEVICES, (devices: PreferredDevices) => { Log.info('Received CHANGE_PREFERRED_DEVICES in PhoneIslandPage:', devices) - eventDispatch(PHONE_ISLAND_EVENTS['phone-island-audio-input-change'], { deviceId: devices.audioInput }) - eventDispatch(PHONE_ISLAND_EVENTS['phone-island-video-input-change'], { deviceId: devices.videoInput }) - eventDispatch(PHONE_ISLAND_EVENTS['phone-island-audio-output-change'], { deviceId: devices.audioOutput }) + + // Run audio warm-up first, only once after PhoneIsland is fully initialized + // Only on Windows/macOS where the issue occurs + if (!hasRunWarmup.current) { + hasRunWarmup.current = true + Log.info('Requesting audio warm-up from main process...') + eventDispatch(PHONE_ISLAND_EVENTS['phone-island-init-audio']) + + // Dispatch device changes after warm-up completes (after ~5 seconds) + setTimeout(() => { + eventDispatch(PHONE_ISLAND_EVENTS['phone-island-audio-input-change'], { deviceId: devices.audioInput }) + eventDispatch(PHONE_ISLAND_EVENTS['phone-island-video-input-change'], { deviceId: devices.videoInput }) + eventDispatch(PHONE_ISLAND_EVENTS['phone-island-audio-output-change'], { deviceId: devices.audioOutput }) + }, 5000) + } else { + // If warm-up already done or not needed, dispatch immediately + eventDispatch(PHONE_ISLAND_EVENTS['phone-island-audio-input-change'], { deviceId: devices.audioInput }) + eventDispatch(PHONE_ISLAND_EVENTS['phone-island-video-input-change'], { deviceId: devices.videoInput }) + eventDispatch(PHONE_ISLAND_EVENTS['phone-island-audio-output-change'], { deviceId: devices.audioOutput }) + } }) window.electron.receive(IPC_EVENTS.TRANSFER_CALL, (to: string) => { @@ -150,34 +168,35 @@ export function PhoneIslandPage() { const resize = (phoneIsalndSize: PhoneIslandSizes) => { if (!isOnLogout.current) { - const { width, height, top, bottom, left, right } = phoneIsalndSize.sizes + const { width, height, top, bottom, left, right, bottomTranscription } = phoneIsalndSize.sizes const w = Number(width.replace('px', '')) const h = Number(height.replace('px', '')) const r = Number((right ?? '0px').replace('px', '')) + const transcription = Number((bottomTranscription ?? '0px').replace('px', '')) const t = Number((top ?? '0px').replace('px', '')) const l = Number((left ?? '0px').replace('px', '')) const b = Number((bottom ?? '0px').replace('px', '')) const data = { width, height, - bottom: bottom ?? '0px', top: top ?? '0px', right: right ?? '0px', left: left ?? '0px', + transcription: bottomTranscription ?? '0px', } phoneIslandContainer.current?.setAttribute('style', ` width: calc(100vw + ${data.right} + ${data.left}); - height: calc(100vh + ${data.top} + ${data.bottom}); + height: calc(100vh + ${data.top} + ${data.bottom} + ${data.transcription}); `) - innerPIContainer.current?.setAttribute('style', ` margin-left: calc(${data.left} - ${data.right}); - `) //calc(${data.top} - ${data.bottom}) + margin-top: calc(${data.transcription} * -1); + `) window.api.resizePhoneIsland({ w: w + r + l, - h: h + t + b + h: h + t + b + transcription , }) } } diff --git a/src/renderer/src/store.ts b/src/renderer/src/store.ts index 61ad2c7a..d7b87ccb 100644 --- a/src/renderer/src/store.ts +++ b/src/renderer/src/store.ts @@ -157,7 +157,8 @@ export const useNethlinkData = createGlobalStateHook({ export const useLoginPageData = createGlobalStateHook({ isLoading: false, selectedAccount: undefined, - windowHeight: LoginPageSize.h + windowHeight: LoginPageSize.h, + showTwoFactor: false } as LoginPageData).useGlobalState diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 8a606aa3..92b9c548 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -229,4 +229,6 @@ export enum PHONE_ISLAND_EVENTS { // Url param 'phone-island-url-parameter-opened-external' = 'phone-island-url-parameter-opened-external', 'phone-island-already-opened-external-page' = 'phone-island-already-opened-external-page', + // Init audio + 'phone-island-init-audio' = 'phone-island-init-audio', } diff --git a/src/shared/types.ts b/src/shared/types.ts index 51ca5e30..7526e222 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -17,11 +17,14 @@ export type StateType = [(T | undefined), (value: T | undefined) => void] export type Account = { username: string accessToken?: string + jwtToken?: string // New JWT token field lastAccess?: string host: string theme: AvailableThemes phoneIslandPosition?: { x: number; y: number } nethlinkBounds?: Electron.Rectangle + companyName?: string + companyUrl?: string sipPort?: string sipHost?: string voiceEndpoint?: string @@ -31,6 +34,7 @@ export type Account = { data?: AccountData, shortcut?: string preferredDevices?: PreferredDevices + apiBasePath?: string // Store which API path works for this account } export type PreferredDevices = { @@ -410,6 +414,7 @@ export type LoginPageData = { selectedAccount?: Account | typeof NEW_ACCOUNT isLoading: boolean windowHeight?: number + showTwoFactor: boolean } export type AuthAppData = { @@ -490,6 +495,7 @@ export type sizeInformationType = { bottom?: string left?: string right?: string + bottomTranscription?: string } export type PhoneIslandSizes = { diff --git a/src/shared/useLogin.ts b/src/shared/useLogin.ts index 45dfe92f..dc7e5bcc 100644 --- a/src/shared/useLogin.ts +++ b/src/shared/useLogin.ts @@ -6,18 +6,24 @@ export const useLogin = () => { const voiceHost = account.host.split('.') voiceHost.shift() voiceHost.join('.') + let COMPANY_NAME = 'Nethesis' + let COMPANY_URL = 'https://www.nethesis.it/' let SIP_HOST = '127.0.0.1' let SIP_PORT = '5060' let NUMERIC_TIMEZONE = '+0200' let TIMEZONE = 'Europe/Rome' let VOICE_ENDPOINT = `voice.${voiceHost}` + COMPANY_NAME = config.split("COMPANY_NAME: '")[1].split("',")[0].trim() // + COMPANY_URL = config.split("COMPANY_URL: '")[1].split("',")[0].trim() // SIP_HOST = config.split("SIP_HOST: '")[1].split("',")[0].trim() // SIP_PORT = config.split("SIP_PORT: '")[1].split("',")[0].trim() // NUMERIC_TIMEZONE = config.split("NUMERIC_TIMEZONE: '")[1].split("',")[0].trim() // TIMEZONE = config.split(" TIMEZONE: '")[1].split("',")[0].trim() // VOICE_ENDPOINT = config.split(" VOICE_ENDPOINT: '")[1].split("',")[0].trim() // + account.companyName = COMPANY_NAME + account.companyUrl = COMPANY_URL account.sipHost = SIP_HOST account.sipPort = SIP_PORT account.numeric_timezone = NUMERIC_TIMEZONE diff --git a/src/shared/useNethVoiceAPI.ts b/src/shared/useNethVoiceAPI.ts index b2050189..1556ecc6 100644 --- a/src/shared/useNethVoiceAPI.ts +++ b/src/shared/useNethVoiceAPI.ts @@ -17,15 +17,47 @@ import { import { Log } from '@shared/utils/logger' import { useNetwork } from './useNetwork' import { SpeeddialTypes } from './constants' +import { requires2FA } from '@shared/utils/jwt' + +// Base paths for API endpoints (fallback from /api to /webrest) +const PRIMARY_API_BASE_PATH = '/api' +const FALLBACK_API_BASE_PATH = '/webrest' export const useNethVoiceAPI = (loggedAccount: Account | undefined = undefined) => { const { GET, POST } = useNetwork() let isFirstHeartbeat = true let account: Account | undefined = loggedAccount || undefined + // Use account's stored API path preference, or default to primary + let currentApiBasePath = account?.apiBasePath || PRIMARY_API_BASE_PATH - function _joinUrl(url: string) { - const path = `https://${account!.host}${url}` - return path + if (account?.apiBasePath) { + Log.debug(`Using stored API path for ${account.username}: ${account.apiBasePath}`) + } else { + Log.debug(`Using default API path: ${PRIMARY_API_BASE_PATH}`) + } + + function buildApiPath(endpoint: string): string { + const result = (() => { + if (currentApiBasePath === FALLBACK_API_BASE_PATH) { + // Special mapping for webrest endpoints + if (endpoint === '/login') { + return `${FALLBACK_API_BASE_PATH}/authentication/login` + } + if (endpoint === '/authentication/logout') { + return `${FALLBACK_API_BASE_PATH}/authentication/logout` + } + if (endpoint === '/authentication/phone_island_token_login') { + return `${FALLBACK_API_BASE_PATH}/authentication/phone_island_token_login` + } + // For other endpoints, use webrest format + return `${FALLBACK_API_BASE_PATH}${endpoint}` + } + // Primary API path + return `${currentApiBasePath}${endpoint}` + })() + + Log.debug(`buildApiPath(${endpoint}) -> ${result} (currentApiBasePath: ${currentApiBasePath})`) + return result } function _toHash(username: string, password: string, nonce: string) { @@ -33,42 +65,152 @@ export const useNethVoiceAPI = (loggedAccount: Account | undefined = undefined) return token } + function _joinUrl(url: string) { + const path = `https://${account!.host}${url}` + return path + } + function _getHeaders(hasAuth = true) { if (hasAuth && !account) throw new Error('no token') - return { - headers: { - 'Content-Type': 'application/json', - ...(hasAuth && { Authorization: account!.username + ':' + account!.accessToken }) + + const headers: { 'Content-Type': string; Authorization?: string } = { + 'Content-Type': 'application/json', + } + + if (hasAuth) { + if (account!.jwtToken) { + // JWT Bearer token for /api + headers.Authorization = `Bearer ${account!.jwtToken}` + } else if (account!.accessToken) { + // Hash-based token for /webrest + headers.Authorization = `${account!.username}:${account!.accessToken}` + } else { + throw new Error('No authentication token available') } } + + return { headers } } async function _GET(path: string, hasAuth = true): Promise { try { return (await GET(_joinUrl(path), _getHeaders(hasAuth))) } catch (e) { + // Check if we should try fallback path for critical endpoints + if (shouldTryFallback(path, e)) { + return await _GETWithFallback(path, hasAuth) + } console.error(e) throw e } } + async function _GETWithFallback(path: string, hasAuth = true): Promise { + const originalPath = path + + // Switch to fallback path and rebuild the correct path + currentApiBasePath = FALLBACK_API_BASE_PATH + if (account) { + account.apiBasePath = FALLBACK_API_BASE_PATH + } + + // Extract the endpoint from the original path and rebuild with fallback + const endpoint = path.replace(PRIMARY_API_BASE_PATH, '') + const fallbackPath = buildApiPath(endpoint) + + try { + Log.debug(`Trying fallback path: ${fallbackPath}`) + const result = await GET(_joinUrl(fallbackPath), _getHeaders(hasAuth)) + + Log.info('Switched to fallback API path: /webrest') + return result + } catch (fallbackError) { + Log.warning('Fallback also failed:', fallbackError) + console.error(fallbackError) + throw fallbackError + } + } + async function _POST(path: string, data?: object, hasAuth = true): Promise { try { return (await POST(_joinUrl(path), data, _getHeaders(hasAuth))) } catch (e) { - if (!path.includes('login')) + // Check if we should try fallback path for critical endpoints + if (shouldTryFallback(path, e)) { + return await _POSTWithFallback(path, data, hasAuth) + } + + if (!path.includes('login') && !path.includes('2fa/verify-otp')) console.error(e) throw e } } + function shouldTryFallback(path: string, error: any): boolean { + // Only try fallback if we're using primary path + if (currentApiBasePath !== PRIMARY_API_BASE_PATH) { + return false + } + + // Try fallback for connection errors (404, 503, or network failures) + const isConnectionError = error?.response?.status === 404 || error?.response?.status === 503 || !error?.response + + // For auth endpoints, always try fallback on connection errors + const isCriticalAuthEndpoint = path.includes('login') || path.includes('2fa/verify-otp') + if (isCriticalAuthEndpoint && isConnectionError) { + return true + } + + // For other endpoints, try fallback only on 404 (endpoint not found) + // This indicates the API structure is different (middleware vs webrest) + if (error?.response?.status === 404) { + return true + } + + return false + } + + async function _POSTWithFallback(path: string, data?: object, hasAuth = true): Promise { + const originalPath = path + + // Switch to fallback path + currentApiBasePath = FALLBACK_API_BASE_PATH + if (account) { + account.apiBasePath = FALLBACK_API_BASE_PATH + } + Log.info('Switched to fallback API path: /webrest') + + // For login endpoint, we need special handling for webrest authentication + if (originalPath.includes('/login')) { + Log.debug('Login fallback: switching to hash-based authentication') + // The login function will now use webrest logic since currentApiBasePath is changed + // We need to throw the original error to let the login function handle the retry + throw new Error('FALLBACK_TO_WEBREST') + } + + // For other endpoints, try the direct fallback + const endpoint = path.replace(PRIMARY_API_BASE_PATH, '') + const fallbackPath = buildApiPath(endpoint) + + try { + Log.debug(`Trying fallback path: ${fallbackPath}`) + const result = await POST(_joinUrl(fallbackPath), data, _getHeaders(hasAuth)) + return result + } catch (fallbackError) { + Log.warning('Fallback also failed:', fallbackError) + if (!originalPath.includes('login') && !originalPath.includes('2fa/verify-otp')) + console.error(fallbackError) + throw fallbackError + } + } + const AstProxy = { - groups: async () => await _GET('/webrest/astproxy/opgroups'), - extensions: async (): Promise => await _GET('/webrest/astproxy/extensions'), - getQueues: async () => await _GET('/webrest/astproxy/queues'), - getParkings: async () => await _GET('/webrest/astproxy/parkings'), - pickupParking: async (parkInformation: any) => await _POST('/webrest/astproxy/pickup_parking', parkInformation) + groups: async () => await _GET(buildApiPath('/astproxy/opgroups')), + extensions: async (): Promise => await _GET(buildApiPath('/astproxy/extensions')), + getQueues: async () => await _GET(buildApiPath('/astproxy/queues')), + getParkings: async () => await _GET(buildApiPath('/astproxy/parkings')), + pickupParking: async (parkInformation: any) => await _POST(buildApiPath('/astproxy/pickup_parking'), parkInformation) } @@ -83,52 +225,175 @@ export const useNethVoiceAPI = (loggedAccount: Account | undefined = undefined) username, theme: 'system' } - return new Promise((resolve, reject) => { - _POST('/webrest/authentication/login', data, false).catch(async (reason) => { - try { - if (reason.response?.status === 401 && reason.response?.headers['www-authenticate']) { - const digest = reason.response.headers['www-authenticate'] - const nonce = digest.split(' ')[1] - if (nonce) { - const accessToken = _toHash(username, password, nonce) - account = { - ...account, - accessToken, - lastAccess: moment().toISOString() - } as Account - const me = await User.me() - account.data = me - const nethlinkExtension = account.data!.endpoints.extension.find((el) => el.type === 'nethlink') - if (!nethlinkExtension) - reject(new Error("Questo utente non è abilitato all'uso del NethLink")) - else { - resolve(account) - } + + // Try JWT authentication first (for /api) + if (currentApiBasePath === PRIMARY_API_BASE_PATH) { + try { + const response = await _POST(buildApiPath('/login'), data, false) + + if (response.token) { + // JWT authentication successful + account = { + ...account, + jwtToken: response.token, + lastAccess: moment().toISOString(), + apiBasePath: PRIMARY_API_BASE_PATH + } as Account + + // Check if 2FA is required + if (requires2FA(response.token)) { + // Return account with JWT token but mark as requiring 2FA + return account + } else { + // Complete login process + const me = await User.me() + account.data = me + const nethlinkExtension = account.data!.endpoints.extension.find((el) => el.type === 'nethlink') + if (!nethlinkExtension) { + throw new Error('User not authorized for NethLink') } + return account + } + } else { + throw new Error('No token received') + } + } catch (reason: any) { + // Check if this is a fallback trigger + if (reason.message === 'FALLBACK_TO_WEBREST') { + Log.debug('Retrying login with webrest authentication') + // Fallback has set currentApiBasePath to FALLBACK_API_BASE_PATH + // Continue to webrest authentication below + } else { + // Handle other specific error cases + if (reason.response?.status === 401) { + throw new Error('Wrong username or password') + } else if (reason.response?.status === 404) { + throw new Error('Network connection lost') + } else if (reason.message === 'User not authorized for NethLink') { + throw reason } else { - console.error('undefined nonce response') - reject(new Error('Unauthorized')) + console.error('Login error:', reason) + throw new Error('Unauthorized') } - } catch (e) { - reject(e) } + } + } + + // Hash-based authentication for /webrest (either direct or after fallback) + if (currentApiBasePath === FALLBACK_API_BASE_PATH) { + // Hash-based authentication for /webrest + return new Promise((resolve, reject) => { + _POST(buildApiPath('/login'), data, false).catch(async (reason) => { + try { + if (reason.response?.status === 401 && reason.response?.headers['www-authenticate']) { + const digest = reason.response.headers['www-authenticate'] + const nonce = digest.split(' ')[1] + if (nonce) { + const accessToken = _toHash(username, password, nonce) + account = { + ...account, + accessToken, + lastAccess: moment().toISOString(), + apiBasePath: FALLBACK_API_BASE_PATH + } as Account + const me = await User.me() + account.data = me + const nethlinkExtension = account.data!.endpoints.extension.find((el) => el.type === 'nethlink') + if (!nethlinkExtension) { + reject(new Error('User not authorized for NethLink')) + } else { + resolve(account) + } + } else { + console.error('undefined nonce response') + reject(new Error('Unauthorized')) + } + } else { + console.error('Login error:', reason) + reject(new Error('Unauthorized')) + } + } catch (e) { + reject(e) + } + }) }) - }) + } + + // This should never be reached + throw new Error('No authentication method available') }, + + verify2FA: async (otp: string, tempAccount: Account | undefined): Promise => { + account = tempAccount + + if (!account || !account.jwtToken) { + throw new Error('No active login session') + } + + try { + const response = await _POST(buildApiPath('/2fa/verify-otp'), { + otp, + username: account.username + }, true) + + if (response.data.token) { + // Update account with new JWT token + account = { + ...account, + jwtToken: response.data.token, + lastAccess: moment().toISOString() + } as Account + + // Complete login process + const me = await User.me() + account.data = me + const nethlinkExtension = account.data!.endpoints.extension.find((el) => el.type === 'nethlink') + if (!nethlinkExtension) { + // Clean up backend token and clear account state + try { + await Authentication.logout() + } catch (logoutError) { + Log.warning("Error during logout after unauthorized access:", logoutError) + } + account = undefined + throw new Error('User not authorized for NethLink') + } + return account + } else { + throw new Error('No token received after 2FA verification') + } + } catch (reason: any) { + if (reason.response?.status === 400) { + throw new Error('OTP invalid') + } else if (reason.message === 'User not authorized for NethLink') { + throw reason + } else { + console.error('2FA verification error:', reason) + throw new Error('Verification failed') + } + } + }, + logout: async () => { isFirstHeartbeat = false return new Promise(async (resolve) => { try { - await _POST('/webrest/authentication/logout', {}) + await _POST(buildApiPath('/authentication/logout'), {}) } catch (e) { Log.warning("error during logout:", e) } finally { + // Reset to primary API path for next login attempt + currentApiBasePath = PRIMARY_API_BASE_PATH + if (account) { + account.apiBasePath = PRIMARY_API_BASE_PATH + } resolve() } }) }, + phoneIslandTokenLogin: async (): Promise<{ username: string, token: string }> => - await _POST('/webrest/authentication/phone_island_token_login', { subtype: 'nethlink'}), + await _POST(buildApiPath('/authentication/phone_island_token_login'), { subtype: 'nethlink' }), } const CustCard = {} @@ -143,7 +408,7 @@ export const useNethVoiceAPI = (loggedAccount: Account | undefined = undefined) try { if (account) { const res = await _GET( - `/webrest/historycall/interval/user/${account.username}/${from}/${to}?offset=0&limit=15&sort=time%20desc&removeLostCalls=undefined` + buildApiPath(`/historycall/interval/user/${account.username}/${from}/${to}?offset=0&limit=15&sort=time%20desc&removeLostCalls=undefined`) ) return res } else { @@ -166,12 +431,12 @@ export const useNethVoiceAPI = (loggedAccount: Account | undefined = undefined) view: 'all' | 'company' | 'person' = 'all' ) => { const s = await _GET( - `/webrest/phonebook/search/${search.trim()}?offset=${offset}&limit=${pageSize}&view=${view}` + buildApiPath(`/phonebook/search/${search.trim()}?offset=${offset}&limit=${pageSize}&view=${view}`) ) return s }, getSpeeddials: async () => { - return await _GET('/webrest/phonebook/speeddials') + return await _GET(buildApiPath('/phonebook/speeddials')) }, ///SPEEDDIALS createSpeeddial: async (create: NewContactType) => { @@ -186,7 +451,7 @@ export const useNethVoiceAPI = (loggedAccount: Account | undefined = undefined) notes: SpeeddialTypes.BASIC } try { - await _POST(`/webrest/phonebook/create`, newSpeedDial) + await _POST(buildApiPath('/phonebook/create'), newSpeedDial) return newSpeedDial } catch (e) { Log.warning('error during createSpeeddial', e) @@ -205,7 +470,7 @@ export const useNethVoiceAPI = (loggedAccount: Account | undefined = undefined) notes: SpeeddialTypes.FAVOURITES } try { - await _POST(`/webrest/phonebook/create`, newSpeedDial) + await _POST(buildApiPath('/phonebook/create'), newSpeedDial) return newSpeedDial } catch (e) { Log.warning('error during createFavourite', e) @@ -216,7 +481,7 @@ export const useNethVoiceAPI = (loggedAccount: Account | undefined = undefined) const editedSpeedDial = Object.assign({}, updatedContact) editedSpeedDial.id = editedSpeedDial.id?.toString() try { - await _POST(`/webrest/phonebook/modify_cticontact`, editedSpeedDial) + await _POST(buildApiPath('/phonebook/modify_cticontact'), editedSpeedDial) return editedSpeedDial } catch (e) { Log.warning('error during updateSpeeddialBy', e) @@ -229,12 +494,12 @@ export const useNethVoiceAPI = (loggedAccount: Account | undefined = undefined) editedSpeedDial.speeddial_num = edit.speeddial_num editedSpeedDial.name = edit.name editedSpeedDial.id = editedSpeedDial.id?.toString() - await _POST(`/webrest/phonebook/modify_cticontact`, editedSpeedDial) + await _POST(`${currentApiBasePath}/phonebook/modify_cticontact`, editedSpeedDial) return editedSpeedDial } }, deleteSpeeddial: async (obj: { id: string }) => { - await _POST(`/webrest/phonebook/delete_cticontact`, { id: '' + obj.id }) + await _POST(buildApiPath('/phonebook/delete_cticontact'), { id: '' + obj.id }) return obj }, //CONTACTS @@ -254,7 +519,7 @@ export const useNethVoiceAPI = (loggedAccount: Account | undefined = undefined) selectedPrefNum: 'extension', kind: 'person' } - await _POST(`/webrest/phonebook/create`, newContact) + await _POST(`${currentApiBasePath}/phonebook/create`, newContact) return newContact }, updateContact: async (edit: NewContactType, current: ContactType) => { @@ -263,18 +528,18 @@ export const useNethVoiceAPI = (loggedAccount: Account | undefined = undefined) newSpeedDial.speeddial_num = edit.speeddial_num newSpeedDial.name = edit.name newSpeedDial.id = newSpeedDial.id?.toString() - await _POST(`/webrest/phonebook/modify_cticontact`, newSpeedDial) + await _POST(`${currentApiBasePath}/phonebook/modify_cticontact`, newSpeedDial) return current } }, deleteContact: async (obj: { id: string }) => { - await _POST(`/webrest/phonebook/delete_cticontact`, obj) + await _POST(buildApiPath('/phonebook/delete_cticontact'), obj) } } const Profiling = { all: async () => { - return await _GET(`/webrest/profiling/all`) + return await _GET(buildApiPath('/profiling/all')) } } @@ -282,7 +547,7 @@ export const useNethVoiceAPI = (loggedAccount: Account | undefined = undefined) const User = { me: async (): Promise => { - const data: AccountData = await _GET('/webrest/user/me') + const data: AccountData = await _GET(buildApiPath('/user/me')) data.mainextension = data!.endpoints.mainextension[0].id const ext = data.endpoints.extension.find((e) => e.type === 'nethlink') //the !loggedAccount flag allow to reduce the invocation only to the backend module and only at the first login @@ -293,14 +558,14 @@ export const useNethVoiceAPI = (loggedAccount: Account | undefined = undefined) } return data }, - all: async () => await _GET('/webrest/user/all'), - all_avatars: async () => await _GET('/webrest/user/all_avatars'), - all_endpoints: async () => await _GET('/webrest/user/endpoints/all'), - heartbeat: async (extension: string, username: string) => await _POST('/webrest/user/nethlink', { extension, username }), + all: async () => await _GET(buildApiPath('/user/all')), + all_avatars: async () => await _GET(buildApiPath('/user/all_avatars')), + all_endpoints: async () => await _GET(buildApiPath('/user/endpoints/all')), + heartbeat: async (extension: string, username: string) => await _POST(buildApiPath('/user/nethlink'), { extension, username }), default_device: async (deviceIdInformation: Extension, force = false): Promise => { try { if (account?.data?.default_device.type !== 'physical' || force) { - await _POST('/webrest/user/default_device', { id: deviceIdInformation.id }) + await _POST(buildApiPath('/user/default_device'), { id: deviceIdInformation.id }) return true } } catch (e) { @@ -308,7 +573,7 @@ export const useNethVoiceAPI = (loggedAccount: Account | undefined = undefined) } return false; }, - setPresence: async (status: StatusTypes, to?: string) => await _POST('/webrest/user/presence', { status, ...(to ? { to } : {}) }) + setPresence: async (status: StatusTypes, to?: string) => await _POST(buildApiPath('/user/presence'), { status, ...(to ? { to } : {}) }) } const Voicemail = {} diff --git a/src/shared/useNetwork.ts b/src/shared/useNetwork.ts index 235146f5..b5e32488 100644 --- a/src/shared/useNetwork.ts +++ b/src/shared/useNetwork.ts @@ -10,7 +10,7 @@ export const useNetwork = () => { return response.data } catch (e: any) { const err: AxiosError = e - if (!path.includes('login')) + if (!path.includes('login') && !path.includes('2fa/verify-otp')) Log.error('during fetch POST', err.name, err.code, err.message, path, config, data) throw e } diff --git a/src/shared/utils/jwt.ts b/src/shared/utils/jwt.ts new file mode 100644 index 00000000..e5f3ebca --- /dev/null +++ b/src/shared/utils/jwt.ts @@ -0,0 +1,79 @@ +/** + * JWT utilities for token decoding and validation + */ + +export interface JWTPayload { + username: string + '2fa'?: boolean + exp?: number + iat?: number + [key: string]: any +} + +/** + * Base64 decode that works in both browser and Node.js + */ +function base64Decode(str: string): string { + if (typeof window !== 'undefined' && typeof window.atob === 'function') { + // Browser environment + return window.atob(str) + } else { + // Node.js environment + return Buffer.from(str, 'base64').toString('utf-8') + } +} + +/** + * Decode JWT token (client-side only) + * @param token JWT token string + * @returns Decoded payload or null if invalid + */ +export function decodeJWT(token: string): JWTPayload | null { + try { + // Split the token into parts + const parts = token.split('.') + if (parts.length !== 3) { + return null + } + + // Decode the payload (second part) + const payload = parts[1] + // Add padding if needed + const paddedPayload = payload + '='.repeat((4 - payload.length % 4) % 4) + + const decodedPayload = base64Decode(paddedPayload) + return JSON.parse(decodedPayload) as JWTPayload + } catch (error) { + console.error('Error decoding JWT:', error) + return null + } +} + +/** + * Check if JWT token is expired + * @param token JWT token string + * @returns true if expired, false if valid + */ +export function isJWTExpired(token: string): boolean { + const payload = decodeJWT(token) + if (!payload || !payload.exp) { + return true + } + + const now = Math.floor(Date.now() / 1000) + return payload.exp < now +} + +/** + * Check if 2FA is required from JWT token + * @param token JWT token string + * @returns true if 2FA is required (2FA enabled but OTP not yet verified) + */ +export function requires2FA(token: string): boolean { + const payload = decodeJWT(token) + const has2FA = payload?.['2fa'] === true + const otpVerified = payload?.['otp_verified'] === true + + // 2FA is required only if it's enabled AND the OTP hasn't been verified yet + return has2FA && !otpVerified +}