diff --git a/public/locales/en/translations.json b/public/locales/en/translations.json index 5caee65b..d8b17718 100644 --- a/public/locales/en/translations.json +++ b/public/locales/en/translations.json @@ -119,7 +119,11 @@ "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." - } + }, + "Continue": "Continue", + "Sign in with SSO": "Sign in with SSO", + "SSO login failed": "Single Sign-On failed, try again", + "SSO user not enabled": "You signed in on the identity provider, but this user is not enabled to use the CTI. Contact your administrator." }, "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 6cd3bb1b..7e5bf0d9 100644 --- a/public/locales/it/translations.json +++ b/public/locales/it/translations.json @@ -119,7 +119,11 @@ "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." - } + }, + "Continue": "Continua", + "Sign in with SSO": "Accedi con SSO", + "SSO login failed": "Accesso Single Sign-On non riuscito, riprova", + "SSO user not enabled": "Ti sei autenticato sull'identity provider, ma questo utente non è abilitato all'uso del CTI. Contatta l'amministratore." }, "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 a563097c..93d5c7ff 100644 --- a/src/main/classes/controllers/AccountController.ts +++ b/src/main/classes/controllers/AccountController.ts @@ -1,4 +1,4 @@ -import { Account, AuthAppData, AvailableDevices, ConfigFile, PhoneIslandPosition } from '@shared/types' +import { Account, AuthAppData, AvailableDevices, ConfigFile, PhoneIslandPosition, isSsoMethod } from '@shared/types' import { Log } from '@shared/utils/logger' import { safeStorage } from 'electron' import { store } from '@/lib/mainStore' @@ -161,6 +161,13 @@ export class AccountController { } } + // SSO accounts have no password: when the token expires the user + // must go through the interactive SSO flow again + if (isSsoMethod(lastLoggedAccount.authenticationMethod)) { + Log.info('auto login failed: SSO account token expired, user interaction needed') + return false + } + // Token is expired or doesn't exist, do a new login const tempLoggedAccount = await this.NethVoiceAPI.Authentication.login(lastLoggedAccount.host, lastLoggedAccount.username, password) diff --git a/src/main/lib/ipcEvents.ts b/src/main/lib/ipcEvents.ts index fc608d14..bbb238ca 100644 --- a/src/main/lib/ipcEvents.ts +++ b/src/main/lib/ipcEvents.ts @@ -4,7 +4,7 @@ import { PhoneIslandController } from '@/classes/controllers/PhoneIslandControll import { CommandBarController } from '@/classes/controllers/CommandBarController' import { IPC_EVENTS } from '@shared/constants' import { Account, OnDraggingWindow, PAGES } from '@shared/types' -import { BrowserWindow, app, ipcMain, screen, shell, desktopCapturer, globalShortcut, clipboard, Notification } from 'electron' +import { BrowserWindow, app, ipcMain, net, screen, shell, desktopCapturer, globalShortcut, clipboard, Notification } from 'electron' import { Log } from '@shared/utils/logger' import { NethLinkController } from '@/classes/controllers/NethLinkController' import { AppController } from '@/classes/controllers/AppController' @@ -457,6 +457,82 @@ export function registerIpcEvents() { e.reply(IPC_EVENTS.SET_NETHVOICE_CONFIG, account) }) + ipcMain.on(IPC_EVENTS.GET_HOST_CONFIG, async (e, host: string) => { + // read the authentication capabilities of the host before asking credentials + const { parseHostConfig } = useLogin() + try { + const config: string = await NetworkController.instance.get(`https://${host}/config/config.production.js`) + e.reply(IPC_EVENTS.SET_HOST_CONFIG, { hostConfig: parseHostConfig(config) }) + } catch (error: any) { + e.reply(IPC_EVENTS.SET_HOST_CONFIG, { error: error?.message || 'unreachable host' }) + } + }) + + ipcMain.on(IPC_EVENTS.SSO_LOGIN, (event, payload: { host: string, url: string }) => { + // Single Sign-On: run the SAML dance in a dedicated browser window, then + // mint the JWT on the forwardAuth-guarded endpoint using the window session + // cookies. The persistent partition keeps the IdP session across logins. + const { host, url } = payload + const win = new BrowserWindow({ + width: 520, + height: 660, + autoHideMenuBar: true, + webPreferences: { partition: 'persist:sso', nodeIntegration: false, contextIsolation: true, sandbox: true } + }) + let settled = false + const settle = (token?: string, error?: string) => { + if (settled) return + settled = true + event.reply(IPC_EVENTS.SSO_LOGIN_RESULT, { token, error }) + if (!win.isDestroyed()) win.destroy() + } + const mint = () => { + const request = net.request({ + url: `https://${host}/api/sso-login`, + method: 'POST', + session: win.webContents.session, + useSessionCookies: true + }) + request.setHeader('Content-Type', 'application/json') + request.on('response', (response) => { + let body = '' + response.on('data', (chunk) => (body += chunk)) + response.on('end', () => { + try { + const token = JSON.parse(body).token + if (response.statusCode === 200 && token) settle(token) + // authenticated on the IdP but the mint was rejected: a 401/403 + // means the user is not enabled on this CTI + else if (response.statusCode === 401 || response.statusCode === 403) + settle(undefined, 'SSO_USER_NOT_ENABLED') + else settle(undefined, `SSO login failed with status ${response.statusCode}`) + } catch { + settle(undefined, 'SSO login failed: invalid response') + } + }) + }) + request.on('error', (error) => settle(undefined, error.message)) + request.end('{}') + } + // the SSO flow ends with a redirect to https:///?ssologin=1: the + // session cookie is already set, mint the token instead of loading the app + const checkUrl = (e: Electron.Event, newUrl: string) => { + try { + const u = new URL(newUrl) + if (u.hostname === host && u.searchParams.has('ssologin')) { + e.preventDefault() + mint() + } + } catch (err) { + Log.warning('SSO login: unparsable navigation url', newUrl) + } + } + win.webContents.on('will-redirect', checkUrl) + win.webContents.on('will-navigate', checkUrl) + win.on('closed', () => settle(undefined, 'SSO window closed')) + win.loadURL(url) + }) + ipcMain.on(IPC_EVENTS.EMIT_QUEUE_UPDATE, (_, queue) => { try { NethLinkController.instance.window.emit(IPC_EVENTS.EMIT_QUEUE_UPDATE, queue) diff --git a/src/main/main.ts b/src/main/main.ts index b0ccaf87..d2bbfbd1 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -76,7 +76,7 @@ function startup() { ipcMain.on(IPC_EVENTS.LOGIN, async (e, props?: { account?: Account, password?: string, showNethlink: boolean, }) => { const { password, showNethlink, account } = props || { showNethlink: true } - if (LoginController.instance && LoginController.instance.window.isOpen() && password && account) { + if (LoginController.instance && LoginController.instance.window.isOpen() && password !== undefined && account) { Log.info("LOGIN SUCCESS") await LoginController.instance.quit() await AccountController.instance.saveLoggedAccount(account, password) diff --git a/src/renderer/public/locales/en/translations.json b/src/renderer/public/locales/en/translations.json index fbc96a82..16883fba 100644 --- a/src/renderer/public/locales/en/translations.json +++ b/src/renderer/public/locales/en/translations.json @@ -112,6 +112,9 @@ "Delete account": "Are you sure you want to delete {{username}}?", "Back": "Back", "User not authorized for NethLink": "User not authorized for NethLink", + "Sign in with SSO": "Sign in with SSO", + "SSO login failed": "Single Sign-On failed, try again", + "SSO user not enabled": "You signed in on the identity provider, but this user is not enabled to use the CTI. Contact your administrator.", "Generic error": "Generic error", "2FA": { "OTP code": "OTP code", diff --git a/src/renderer/public/locales/it/translations.json b/src/renderer/public/locales/it/translations.json index da90b8f8..468aa8de 100644 --- a/src/renderer/public/locales/it/translations.json +++ b/src/renderer/public/locales/it/translations.json @@ -112,6 +112,9 @@ "Delete account": "Sei sicuro di voler eliminare {{username}}?", "Back": "Indietro", "User not authorized for NethLink": "Utente non autorizzato per NethLink", + "Sign in with SSO": "Accedi con SSO", + "SSO login failed": "Single Sign-On non riuscito, riprova", + "SSO user not enabled": "Ti sei autenticato sull'identity provider, ma questo utente non è abilitato all'uso del CTI. Contatta l'amministratore.", "Generic error": "Errore generico", "2FA": { "OTP code": "Codice OTP", diff --git a/src/renderer/src/components/pageComponents/login/LoginForm.tsx b/src/renderer/src/components/pageComponents/login/LoginForm.tsx index a4408e4a..29829f4c 100644 --- a/src/renderer/src/components/pageComponents/login/LoginForm.tsx +++ b/src/renderer/src/components/pageComponents/login/LoginForm.tsx @@ -11,7 +11,7 @@ import { import { t } from 'i18next' import { useEffect, useRef, useState } from 'react' import { Button, TextInput } from '@renderer/components/Nethesis' -import { Account, LoginData } from '@shared/types' +import { Account, HostConfig, LoginData, isSsoMethod } from '@shared/types' import { DisplayedAccountLogin } from './DisplayedAccountLogin' import { OTPInput, OTPInputRef } from './OTPInput' import { useLoginPageData, useSharedState } from '@renderer/store' @@ -43,30 +43,30 @@ export const LoginForm = ({ onError, handleRefreshConnection }) => { const [showTwoFactor, setShowTwoFactor] = useLoginPageData('showTwoFactor') const [tempAccount, setTempAccount] = useState(undefined) const [otpDisabled, setOtpDisabled] = useState(false) + const [loginStep, setLoginStep] = useLoginPageData('loginStep') + const [hostConfig, setHostConfig] = useLoginPageData('hostConfig') const passwordRef = useRef() const otpInputRef = useRef() as React.MutableRefObject + const hostConfigHandlerRef = useRef<(res: { hostConfig?: HostConfig, error?: string }) => void>() + const ssoResultHandlerRef = useRef<(res: { token?: string, error?: string }) => void>() const schema: z.ZodType = z.object({ host: z .string() .trim() .min(1, `${t('Common.This field is required')}`), - username: z - .string() - .trim() - .min(1, `${t('Common.This field is required')}`), - password: z - .string() - .trim() - .min(1, `${t('Common.This field is required')}`), + username: z.string().trim().optional(), + password: z.string().trim().optional(), }) const { register, handleSubmit, setValue, + getValues, reset, setFocus, + setError: setFieldError, formState: { errors }, } = useForm({ defaultValues: {}, @@ -77,6 +77,15 @@ export const LoginForm = ({ onError, handleRefreshConnection }) => { onError(errors, error) }, [Object.keys(errors).length, error]) + useEffect(() => { + window.electron.receive(IPC_EVENTS.SET_HOST_CONFIG, (res) => + hostConfigHandlerRef.current?.(res), + ) + window.electron.receive(IPC_EVENTS.SSO_LOGIN_RESULT, (res) => + ssoResultHandlerRef.current?.(res), + ) + }, []) + useEffect(() => { setIsLoading(false) setTempAccount(undefined) @@ -86,17 +95,26 @@ export const LoginForm = ({ onError, handleRefreshConnection }) => { if (selectedAccount === NEW_ACCOUNT) { setShowTwoFactor(false) // Reset 2FA when switching to new account reset() + setLoginStep('host') + setHostConfig(undefined) focus('host') } else { setShowTwoFactor(false) // Reset 2FA when switching to existing account reset() setValue('host', selectedAccount.host) setValue('username', selectedAccount.username) - focus('password') + if (isSsoMethod(selectedAccount.authenticationMethod)) { + // the SSO entry point is read from the host at every login + fetchHostConfig(selectedAccount.host) + } else { + focus('password') + } } } else { setShowTwoFactor(false) // Reset 2FA when going back to account list setError(undefined) + setLoginStep('host') + setHostConfig(undefined) focus('host') } } @@ -115,6 +133,126 @@ export const LoginForm = ({ onError, handleRefreshConnection }) => { } }, [showTwoFactor]) + function cleanHost(rawHost: string): string | undefined { + const hostReg = + /^(?:(https?:\/\/)?([^:/$]{1,})(?::(\d{1,}))?(?:($|\/(?:[^?#]{0,}))?((?:\?(?:[^#]{1,}))?)?(?:(#(?:.*)?)?|$)))$/g + return hostReg.exec(rawHost.trim())?.[2] + } + + // shared login completion: enrich the account with the host configuration, + // then hand it over to the main process together with the password (empty + // for SSO accounts) + function completeLogin(loggedAccount: Account, password: string) { + window.electron.receive( + IPC_EVENTS.SET_NETHVOICE_CONFIG, + (account: Account) => { + passwordRef.current = password + Log.info('LOGIN received account server configuration', account) + const previousLoggedAccount = + auth?.availableAccounts[getAccountUID(account)] + account.theme = previousLoggedAccount + ? previousLoggedAccount.theme + : 'system' + Log.info('LOGIN send login event to the backend', account) + window.electron.send(IPC_EVENTS.LOGIN, { + password: passwordRef.current, + account, + }) + }, + ) + Log.info('LOGIN get account server configuration') + window.electron.send(IPC_EVENTS.GET_NETHVOICE_CONFIG, loggedAccount) + } + + function fetchHostConfig(host: string) { + setHostConfig(undefined) + hostConfigHandlerRef.current = (res) => { + if (res.hostConfig) { + setHostConfig(res.hostConfig) + } else { + Log.warning('LOGIN unable to read the host configuration', res.error) + setError(() => new Error(t('Login.Network connection is lost')!)) + } + } + window.electron.send(IPC_EVENTS.GET_HOST_CONFIG, host) + } + + // step 1: read the host authentication capabilities, then show the proper + // credentials step (username/password or SSO button) + function handleHostNext(data: LoginData) { + const host = cleanHost(data.host) + if (!host) { + setError(() => new Error(t('Login.Wrong host or username or password')!)) + return + } + setIsLoading(true) + setError(() => undefined) + hostConfigHandlerRef.current = (res) => { + setIsLoading(false) + if (res.hostConfig) { + setValue('host', host) + setHostConfig(res.hostConfig) + setLoginStep('credentials') + if (res.hostConfig.authenticationMethod === 'password') { + focus('username') + } + } else { + Log.warning('LOGIN unable to read the host configuration', res.error) + setError(() => new Error(t('Login.Network connection is lost')!)) + } + } + window.electron.send(IPC_EVENTS.GET_HOST_CONFIG, host) + } + + // Single Sign-On: the main process runs the SSO flow in a dedicated + // window and returns the minted JWT + function handleSsoLogin() { + if (isLoading || !hostConfig?.ssoLoginUrl) return + const host = + selectedAccount && selectedAccount !== NEW_ACCOUNT + ? selectedAccount.host + : getValues('host') + setIsLoading(true) + setError(() => undefined) + ssoResultHandlerRef.current = async (res) => { + if (!res.token) { + setIsLoading(false) + if (res.error !== 'SSO window closed') { + Log.warning('LOGIN SSO failed', res.error) + const msg = + res.error === 'SSO_USER_NOT_ENABLED' + ? t('Login.SSO user not enabled') + : t('Login.SSO login failed') + setError(() => new Error(msg!)) + } + return + } + try { + Log.info('LOGIN SSO token received, completing login') + const loggedAccount = await NethVoiceAPI.Authentication.ssoLogin( + host, + res.token, + hostConfig?.authenticationMethod, + ) + completeLogin(loggedAccount, '') + setError(() => undefined) + } catch (error: any) { + setIsLoading(false) + if (error.message === 'User not authorized for NethLink') { + setError( + () => new Error(t('Login.User not authorized for NethLink')!), + ) + } else { + setError(() => new Error(t('Login.SSO login failed')!)) + } + } + } + window.electron.send(IPC_EVENTS.SSO_LOGIN, { + host, + url: hostConfig.ssoLoginUrl, + }) + } + async function handleLogin(data: LoginData) { if (!isLoading) { let e: Error | undefined = undefined @@ -128,8 +266,8 @@ export const LoginForm = ({ onError, handleRefreshConnection }) => { Log.info('LOGIN try login with credential') const loggedAccount = await NethVoiceAPI.Authentication.login( res[2], - data.username, - data.password, + data.username!, + data.password!, ) Log.info('LOGIN successfully logged in with credential') @@ -144,25 +282,7 @@ export const LoginForm = ({ onError, handleRefreshConnection }) => { } // Complete login flow - window.electron.receive( - IPC_EVENTS.SET_NETHVOICE_CONFIG, - (account: Account) => { - passwordRef.current = data.password - Log.info('LOGIN received account server configuration', account) - const previousLoggedAccount = - auth?.availableAccounts[getAccountUID(account)] - account.theme = previousLoggedAccount - ? previousLoggedAccount.theme - : 'system' - Log.info('LOGIN send login event to the backend', account) - window.electron.send(IPC_EVENTS.LOGIN, { - password: passwordRef.current, - account, - }) - }, - ) - Log.info('LOGIN get account server configuration') - window.electron.send(IPC_EVENTS.GET_NETHVOICE_CONFIG, loggedAccount) + completeLogin(loggedAccount, data.password!) setFormValues({ host: '', @@ -223,27 +343,7 @@ export const LoginForm = ({ onError, handleRefreshConnection }) => { 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) + completeLogin(verifiedAccount, passwordRef.current || '') setTempAccount(undefined) setError(() => undefined) @@ -277,13 +377,33 @@ export const LoginForm = ({ onError, handleRefreshConnection }) => { } const onSubmitForm: SubmitHandler = (data) => { - handleLogin(data) + const isSavedAccount = !!(selectedAccount && selectedAccount !== NEW_ACCOUNT) + if (!isSavedAccount && loginStep === 'host') { + handleHostNext(data) + return + } + let valid = true + if (!isSavedAccount && !data.username?.trim()) { + setFieldError('username', { + message: `${t('Common.This field is required')}`, + }) + valid = false + } + if (!data.password?.trim()) { + setFieldError('password', { + message: `${t('Common.This field is required')}`, + }) + valid = false + } + if (valid) { + handleLogin(data) + } } function setFormValues(data: LoginData) { setValue('host', data.host) - setValue('username', data.username) - setValue('password', data.password) + setValue('username', data.username || '') + setValue('password', data.password || '') } const focus = (selector: keyof LoginData) => { @@ -292,6 +412,15 @@ export const LoginForm = ({ onError, handleRefreshConnection }) => { }, 100) } + const isSavedAccount = !!(selectedAccount && selectedAccount !== NEW_ACCOUNT) + const authMethod = isSavedAccount + ? (selectedAccount as Account).authenticationMethod || 'password' + : hostConfig?.authenticationMethod || 'password' + const showSsoButton = + isSsoMethod(authMethod) && (isSavedAccount || loginStep === 'credentials') + const ssoButtonLabel = + hostConfig?.ssoButtonLabel || (t('Login.Sign in with SSO') as string) + const RenderConnectionError = ({ handleRefreshConnection }) => { return (
@@ -417,14 +546,33 @@ export const LoginForm = ({ onError, handleRefreshConnection }) => { {connection ? (
- {!(selectedAccount && selectedAccount !== NEW_ACCOUNT) && ( - <> + {!isSavedAccount && loginStep === 'host' && ( + { + if (e.key === 'Enter') { + e.preventDefault() + submitButtonRef.current?.focus() + handleSubmit(onSubmitForm)(e) + } + }} + /> + )} + {!isSavedAccount && + loginStep === 'credentials' && + authMethod === 'password' && ( value?.toLowerCase() || '', + })} type='text' - label={t('Login.Host') as string} - helper={errors.host?.message || undefined} - error={!!errors.host?.message} + label={t('Login.Username') as string} + helper={errors.username?.message || undefined} + error={!!errors.username?.message} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault() @@ -433,14 +581,18 @@ export const LoginForm = ({ onError, handleRefreshConnection }) => { } }} /> + )} + {authMethod === 'password' && + (isSavedAccount || loginStep === 'credentials') && ( value?.toLowerCase() || '', - })} - type='text' - label={t('Login.Username') as string} - helper={errors.username?.message || undefined} - error={!!errors.username?.message} + {...register('password')} + label={t('Login.Password') as string} + type={pwdVisible ? 'text' : 'password'} + icon={pwdVisible ? EyeIcon : EyeSlashIcon} + onIconClick={() => setPwdVisible(!pwdVisible)} + trailingIcon={true} + helper={errors.password?.message || undefined} + error={!!errors.password?.message} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault() @@ -449,28 +601,41 @@ export const LoginForm = ({ onError, handleRefreshConnection }) => { } }} /> - + )} + {showSsoButton ? ( +
+ + {(hostConfig?.ssoIdpName || hostConfig?.ssoIdpLogo) && ( +
+ {hostConfig?.ssoIdpLogo && ( + + )} + {hostConfig?.ssoIdpName && ( + + {hostConfig.ssoIdpName} + + )} +
+ )} +
+ ) : ( + )} - 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/pages/LoginPage.tsx b/src/renderer/src/pages/LoginPage.tsx index 3930a61f..8c90eb2c 100644 --- a/src/renderer/src/pages/LoginPage.tsx +++ b/src/renderer/src/pages/LoginPage.tsx @@ -1,4 +1,4 @@ -import { Account, LoginData } from '@shared/types' +import { Account, LoginData, isSsoMethod } from '@shared/types' import classNames from 'classnames' import { MutableRefObject, useEffect, useRef, useState } from 'react' import spinner from '../assets/loginPageSpinner.svg' @@ -27,6 +27,8 @@ export interface LoginPageProps { enum LoginSizes { BASE = 550, + HOST_STEP = 400, + SSO_STEP = 400, ACCOUNT_FORM = 488, TWO_FACTOR_AUTH = 420, BACK_BUTTON = 60, @@ -55,6 +57,8 @@ export function LoginPage({ useLoginPageData('selectedAccount') const [windowHeight, setWindowHeight] = useLoginPageData('windowHeight') const [showTwoFactor, setShowTwoFactor] = useLoginPageData('showTwoFactor') + const [loginStep, setLoginStep] = useLoginPageData('loginStep') + const [hostConfig, setHostConfig] = useLoginPageData('hostConfig') const [connection] = useSharedState('connection') const [errorsData, setErrorsData] = useState() const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) @@ -64,7 +68,7 @@ export function LoginPage({ useEffect(() => { calculateHeight() - }, [selectedAccount, auth, errorsData, connection, showTwoFactor]) + }, [selectedAccount, auth, errorsData, connection, showTwoFactor, loginStep, hostConfig]) useEffect(() => { if (windowHeight) { @@ -77,9 +81,16 @@ export function LoginPage({ // If we're in OTP verification, go back to login form setShowTwoFactor(false) // Keep selectedAccount, stay in the login form + } else if (selectedAccount === NEW_ACCOUNT && loginStep === 'credentials') { + // Back from the credentials/SSO step to the host step + setLoginStep('host') + setHostConfig(undefined) + setErrorsData({ formErrors: {}, generalError: undefined }) } else { // If we're in normal login form, go back to account selection setSelectedAccount(undefined) + setLoginStep('host') + setHostConfig(undefined) setErrorsData({ formErrors: {}, generalError: undefined }) } } @@ -126,9 +137,18 @@ export function LoginPage({ // Login form is shown else if (selectedAccount) { if (selectedAccount === NEW_ACCOUNT) { - loginWindowHeight = LoginSizes.BASE + if (loginStep === 'host') { + loginWindowHeight = LoginSizes.HOST_STEP + } else if (hostConfig && isSsoMethod(hostConfig.authenticationMethod)) { + loginWindowHeight = LoginSizes.SSO_STEP + if (hostConfig.ssoIdpName || hostConfig.ssoIdpLogo) { + loginWindowHeight += 40 + } + } else { + loginWindowHeight = LoginSizes.BASE + } if (!connection) loginWindowHeight = LoginSizes.CONNECTION_FAILURE_BASE - if (!auth?.isFirstStart) { + if (!auth?.isFirstStart || loginStep === 'credentials') { loginWindowHeight += LoginSizes.BACK_BUTTON - 24 } } else { @@ -186,8 +206,10 @@ export function LoginPage({
{auth && ( <> - {Object.keys(auth.availableAccounts).length > 0 && - selectedAccount && ( + {((Object.keys(auth.availableAccounts).length > 0 && + selectedAccount) || + (selectedAccount === NEW_ACCOUNT && + loginStep === 'credentials')) && (