diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index addd8113d6..f509e8423a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1007,7 +1007,7 @@ jobs: needs: [changes, lint_typecheck, dead_code] if: needs.changes.outputs.run_capgo == 'true' runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 7 name: Run Playwright tests (shard ${{ matrix.shard }}) permissions: contents: read diff --git a/artifacts/capgo-login-autofill-final-state.webp b/artifacts/capgo-login-autofill-final-state.webp new file mode 100644 index 0000000000..f605ef8bd0 Binary files /dev/null and b/artifacts/capgo-login-autofill-final-state.webp differ diff --git a/artifacts/capgo-login-password-manager-autofill.mp4 b/artifacts/capgo-login-password-manager-autofill.mp4 new file mode 100644 index 0000000000..d611cdf6ac Binary files /dev/null and b/artifacts/capgo-login-password-manager-autofill.mp4 differ diff --git a/artifacts/login-2fa.webp b/artifacts/login-2fa.webp new file mode 100644 index 0000000000..b3f09219c3 Binary files /dev/null and b/artifacts/login-2fa.webp differ diff --git a/artifacts/login-email.webp b/artifacts/login-email.webp new file mode 100644 index 0000000000..fef39742e9 Binary files /dev/null and b/artifacts/login-email.webp differ diff --git a/artifacts/login-password.webp b/artifacts/login-password.webp new file mode 100644 index 0000000000..d75aefef7d Binary files /dev/null and b/artifacts/login-password.webp differ diff --git a/artifacts/login-sso.webp b/artifacts/login-sso.webp new file mode 100644 index 0000000000..a2b7014866 Binary files /dev/null and b/artifacts/login-sso.webp differ diff --git a/playwright/e2e/auth.spec.ts b/playwright/e2e/auth.spec.ts index f66c8deefc..28277d0a54 100644 --- a/playwright/e2e/auth.spec.ts +++ b/playwright/e2e/auth.spec.ts @@ -1,4 +1,3 @@ -import type { Page } from '@playwright/test' import { expect, test } from '../support/commands' test.describe('Authentication', () => { @@ -6,44 +5,34 @@ test.describe('Authentication', () => { await page.goto('/login/') }) - async function continueToPasswordStep(page: Page, email: string) { - await page.fill('[data-test="email"]', email) - await page.click('[data-test="continue"]') - await page.waitForSelector('[data-test="password"]') - } - - test('should show loading state during domain check', async ({ page }) => { - await page.route('**/private/sso/check-domain', async (route) => { - await new Promise(resolve => setTimeout(resolve, 500)) - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ has_sso: false }), - }) - }) - - await page.fill('[data-test="email"]', 'test@capgo.app') - await page.click('[data-test="continue"]') - await expect(page.locator('[data-test="loading"]')).toBeVisible() - await expect(page.locator('[data-test="password"]')).toBeVisible() + test('should keep the password field in the form but hidden until the domain is known', async ({ page }) => { + await expect(page.locator('[data-test="email"]')).toBeVisible() + await expect(page.locator('[data-test="submit"]')).toBeHidden() + await expect(page.locator('[data-test="sso-login"]')).toHaveCount(0) + await expect(page.locator('[data-test="password"]')).toHaveCount(1) + await expect(page.locator('[data-password-ready="false"]')).toHaveCount(1) + await expect(page.locator('input[autocomplete="one-time-code"]')).toHaveCount(1) }) test('should show error for invalid credentials', async ({ page }) => { - await continueToPasswordStep(page, 'wrong@example.com') + await page.fill('[data-test="email"]', 'wrong@example.com') + await expect(page.locator('[data-test="submit"]')).toBeVisible({ timeout: 10000 }) await page.fill('[data-test="password"]', 'wrongpass') await page.click('[data-test="submit"]') await expect(page.locator('[data-test="form-error"]')).toContainText('Invalid login credentials') }) test('should show error for deleted account', async ({ page }) => { - await continueToPasswordStep(page, 'deleted@capgo.app') + await page.fill('[data-test="email"]', 'deleted@capgo.app') + await expect(page.locator('[data-test="submit"]')).toBeVisible({ timeout: 10000 }) await page.fill('[data-test="password"]', 'password') await page.click('[data-test="submit"]') await expect(page.locator('[data-test="form-error"]')).toContainText('Account with this email used to exist, cannot recreate') }) test('should login successfully and redirect', async ({ page }) => { - await continueToPasswordStep(page, 'test@capgo.app') + await page.fill('[data-test="email"]', 'test@capgo.app') + await expect(page.locator('[data-test="submit"]')).toBeVisible({ timeout: 10000 }) await page.fill('[data-test="password"]', 'testtest') await page.click('[data-test="submit"]') await page.waitForURL(/\/(apps|dashboard)(\/|$)/) @@ -51,7 +40,8 @@ test.describe('Authentication', () => { test('should keep email when navigating to forgot password page', async ({ page }) => { const email = 'test@capgo.app' - await continueToPasswordStep(page, email) + await page.fill('[data-test="email"]', email) + await expect(page.locator('[data-test="forgot-password"]')).toBeVisible({ timeout: 10000 }) await page.click('[data-test="forgot-password"]') await expect(page).toHaveURL('/forgot_password') await expect(page.locator('[data-test="email"]')).toHaveValue(email) diff --git a/playwright/e2e/observe-tabs.spec.ts b/playwright/e2e/observe-tabs.spec.ts index 68f80ddca7..9ab2d4645b 100644 --- a/playwright/e2e/observe-tabs.spec.ts +++ b/playwright/e2e/observe-tabs.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from '../support/commands' +import { dismissSupportPrompt } from '../support/dismissSupportPrompt' test.describe('Observe sections', () => { test.beforeEach(async ({ page }) => { @@ -7,6 +8,8 @@ test.describe('Observe sections', () => { test('keeps Observe subtabs reachable on desktop and mobile', async ({ page }) => { await page.goto('/app/com.demo.app/observe/updater') + // Dismiss support prompt so mobile tab clicks are not intercepted. + await dismissSupportPrompt(page) const updaterTab = page.getByRole('button', { name: 'Updater', exact: true }) const logsTab = page.getByRole('button', { name: 'Logs', exact: true }) @@ -47,6 +50,8 @@ test.describe('Observe sections', () => { expect(updaterBox?.x).toBeGreaterThanOrEqual(0) expect((pluginsBox?.x ?? 0) + (pluginsBox?.width ?? 0)).toBeLessThanOrEqual(375) + // Dismiss support prompt so mobile tab clicks are not intercepted. + await dismissSupportPrompt(page) await nativeTab.click() await expect(page).toHaveURL(/\/app\/com\.demo\.app\/observe\/native(?:\?|$)/) await expect(nativeTab).toHaveAttribute('aria-current', 'page') diff --git a/playwright/e2e/register.spec.ts b/playwright/e2e/register.spec.ts index acf4dc47d6..87bad972ba 100644 --- a/playwright/e2e/register.spec.ts +++ b/playwright/e2e/register.spec.ts @@ -164,7 +164,7 @@ test.describe('Registration', () => { await page.click('[data-test="onboarding-logout"]') await page.waitForURL(/\/login\/?$/) - await expectProtectedRouteRedirect(page, '/apps', /\/login/, '[data-test="continue"]') + await expectProtectedRouteRedirect(page, '/apps', /\/login/, '[data-test="email"]') }) test('should show error for existing email', async ({ page }) => { diff --git a/playwright/e2e/sso-login.spec.ts b/playwright/e2e/sso-login.spec.ts index a667294bd4..0a2aa38f6c 100644 --- a/playwright/e2e/sso-login.spec.ts +++ b/playwright/e2e/sso-login.spec.ts @@ -1,64 +1,88 @@ import { expect, test } from '../support/commands' -test.describe('SSO Login Flow (Two-Step)', () => { +test.describe('SSO Login Flow', () => { test.beforeEach(async ({ page }) => { await page.goto('/login/') }) - test('should show email step first (Step 1)', async ({ page }) => { + test('should show email only on first paint', async ({ page }) => { await expect(page.locator('[data-test="email"]')).toBeVisible() - await expect(page.locator('[data-test="continue"]')).toBeVisible() - - await expect(page.locator('[data-test="password"]')).not.toBeVisible() - await expect(page.locator('[data-test="submit"]')).not.toBeVisible() + await expect(page.locator('[data-test="submit"]')).toBeHidden() + await expect(page.locator('[data-test="sso-login"]')).toHaveCount(0) + await expect(page.locator('[data-test="password"]')).toHaveCount(1) + await expect(page.locator('[data-password-ready="false"]')).toHaveCount(1) }) - test('should show password field for non-SSO domain after Continue (Step 2)', async ({ page }) => { - await page.fill('[data-test="email"]', 'test@example.com') - await page.click('[data-test="continue"]') + test('should reveal password for non-SSO domains', async ({ page }) => { + await page.route('**/private/sso/check-domain', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ has_sso: false }), + }) + }) - await expect(page.locator('[data-test="password"]')).toBeVisible({ timeout: 10000 }) + const domainCheck = page.waitForResponse(response => response.url().includes('/private/sso/check-domain')) + await page.fill('[data-test="email"]', 'test@example.com') + await domainCheck + await expect(page.locator('[data-password-ready="true"]')).toHaveCount(1) + await expect(page.locator('[data-test="password"]')).toBeVisible() await expect(page.locator('[data-test="submit"]')).toBeVisible() - - await expect(page.locator('[data-test="continue"]')).not.toBeVisible() + await expect(page.locator('[data-test="sso-login"]')).toHaveCount(0) }) - test('should return to Step 1 when clicking Back from password step', async ({ page }) => { - await page.fill('[data-test="email"]', 'test@example.com') - await page.click('[data-test="continue"]') - - await expect(page.locator('[data-test="password"]')).toBeVisible({ timeout: 10000 }) - - await page.locator('[data-test="back-to-email"]').click() + test('should retry an unsuccessful domain check on submit', async ({ page }) => { + let checks = 0 + await page.route('**/private/sso/check-domain', async (route) => { + checks += 1 + if (checks === 1) { + await route.fulfill({ status: 500, body: 'error' }) + return + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ has_sso: true }), + }) + }) + + await page.fill('[data-test="email"]', 'user@sso.example') + await expect(page.locator('[data-password-ready="true"]')).toHaveCount(1) + await page.fill('[data-test="password"]', 'Password123!') + await page.click('[data-test="submit"]') + await expect.poll(() => checks).toBeGreaterThan(1) + }) - await expect(page.locator('[data-test="email"]')).toBeVisible() - await expect(page.locator('[data-test="continue"]')).toBeVisible() - await expect(page.locator('[data-test="password"]')).not.toBeVisible() + test('should use SSO only when the domain has SSO', async ({ page }) => { + await page.route('**/private/sso/check-domain', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ has_sso: true, enforce_sso: false }), + }) + }) + + await page.fill('[data-test="email"]', 'user@sso.example') + await expect(page.locator('[data-test="sso-login"]')).toBeVisible({ timeout: 10000 }) + await expect(page.locator('[data-password-ready="false"]')).toHaveCount(1) + await expect(page.locator('[data-test="submit"]')).toBeHidden() }) - test('should keep Back visible and tappable on mobile password step', async ({ page }) => { + test('should keep email editable on mobile when the domain has SSO', async ({ page }) => { await page.setViewportSize({ width: 375, height: 667 }) + await page.route('**/private/sso/check-domain', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ has_sso: true }), + }) + }) const longEmail = 'avery.long.email.address.with-many-segments@very-long-example-domain-for-mobile-testing.example.com' await page.fill('[data-test="email"]', longEmail) - await page.click('[data-test="continue"]') - - await expect(page.locator('[data-test="password"]')).toBeVisible({ timeout: 10000 }) - - const backButton = page.locator('[data-test="back-to-email"]') - await expect(backButton).toBeVisible() - - const selectedEmail = page.locator('[data-test="selected-email"]') - await expect(selectedEmail).toHaveText(longEmail) - - const buttonBox = await backButton.boundingBox() - const emailBox = await selectedEmail.boundingBox() - expect(buttonBox?.height).toBeGreaterThanOrEqual(44) - expect(emailBox?.y).toBeGreaterThan((buttonBox?.y ?? 0) + (buttonBox?.height ?? 0)) - expect((emailBox?.x ?? 0) + (emailBox?.width ?? 0)).toBeLessThanOrEqual(375) - expect(emailBox?.height).toBeGreaterThan(32) - - await backButton.click() - await expect(page.locator('[data-test="email"]')).toBeFocused() + await expect(page.locator('[data-test="sso-login"]')).toBeVisible({ timeout: 10000 }) + await expect(page.locator('[data-test="email"]')).toHaveValue(longEmail) + await expect(page.locator('[data-test="email"]')).toBeEditable() }) }) diff --git a/playwright/support/commands.ts b/playwright/support/commands.ts index 56ba60ce0e..27ed5636d1 100644 --- a/playwright/support/commands.ts +++ b/playwright/support/commands.ts @@ -8,8 +8,7 @@ export const test = base.extend({ page.login = async (email: string, password: string, targetUrl = /\/(apps|dashboard)(\/|$)/) => { await page.goto('/login/') await page.fill('[data-test="email"]', email) - await page.click('[data-test="continue"]') - await page.waitForSelector('[data-test="password"]') + await page.locator('[data-test="submit"]').waitFor({ state: 'visible', timeout: 10000 }) await page.fill('[data-test="password"]', password) const submit = page.locator('[data-test="submit"]') for (let attempt = 0; attempt < 3; attempt++) { diff --git a/playwright/visual-diff.config.ts b/playwright/visual-diff.config.ts index 9a1ad8fe5a..1b89759022 100644 --- a/playwright/visual-diff.config.ts +++ b/playwright/visual-diff.config.ts @@ -105,7 +105,9 @@ export const visualDiffRoutes: VisualDiffRoute[] = [ path: '/apikeys', auth: true, prepare: async (page) => { - await page.locator('[data-test="create-key"]').click() + const createKey = page.locator('[data-test="create-key"]') + await createKey.waitFor({ state: 'visible', timeout: 30_000 }) + await createKey.click() const appOnlyScope = page.locator('[data-test="create-key-app-only-scope"]') if (await appOnlyScope.count()) await appOnlyScope.check() diff --git a/scripts/visual-diff.ts b/scripts/visual-diff.ts index 3928bf85df..de780d9a00 100644 --- a/scripts/visual-diff.ts +++ b/scripts/visual-diff.ts @@ -72,7 +72,7 @@ const managedProcesses: ManagedProcess[] = [] const isWindows = process.platform === 'win32' const httpReadyTimeoutMs = 5_000 const visualDiffActionTimeoutMs = 60_000 -const visualDiffRouteTimeoutMs = 120_000 +const visualDiffRouteTimeoutMs = 180_000 function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)) @@ -519,11 +519,14 @@ async function prepareScreenshotData(supabaseUrl: string, supabaseAnon: string) async function login(page: Page) { await page.goto('/login/', { waitUntil: 'domcontentloaded' }) await page.fill('[data-test="email"]', 'test@capgo.app') - await page.click('[data-test="continue"]') - await page.waitForSelector('[data-test="password"]', { timeout: 30_000 }) - await page.fill('[data-test="password"]', 'testtest') + const continueButton = page.locator('[data-test="continue"]') const submit = page.locator('[data-test="submit"]') + if (await continueButton.count()) + await continueButton.click() + await submit.waitFor({ state: 'visible', timeout: 30_000 }) + await page.fill('[data-test="password"]', 'testtest') + if (await submit.isEnabled()) await submit.click() else diff --git a/src/pages/login.vue b/src/pages/login.vue index 409901ad6c..35b845d246 100644 --- a/src/pages/login.vue +++ b/src/pages/login.vue @@ -10,7 +10,6 @@ import { useI18n } from 'vue-i18n' import { useRoute, useRouter } from 'vue-router' import { toast } from 'vue-sonner' import VueTurnstile from 'vue-turnstile' -import IconBack from '~icons/material-symbols/arrow-back-ios-rounded' import iconEmail from '~icons/oui/email?raw' import iconPassword from '~icons/ph/key?raw' import mfaIcon from '~icons/simple-icons/2fas?raw' @@ -26,9 +25,10 @@ const isLoading = ref(false) const isMobile = ref(Capacitor.isNativePlatform()) const turnstileToken = ref('') const captchaKey = ref(import.meta.env.VITE_CAPTCHA_KEY) -const statusAuth: Ref<'email' | 'credentials' | '2fa'> = ref('email') +const statusAuth: Ref<'login' | '2fa'> = ref('login') const mfaLoginFactor: Ref = ref(null) const mfaChallengeId: Ref = ref('') +const mfaCode = ref('') const querySessionAccessToken = ref('') const querySessionRefreshToken = ref('') const hasQuerySession = ref(false) @@ -36,18 +36,35 @@ const router = useRouter() const { t } = useI18n() const captchaComponent = ref | null>(null) -// Two-step login state +// Keep email, password, and OTP in one form so password managers can fill them +// with a single biometric prompt. Password stays hidden until the domain is +// known not to use SSO. SSO domains never show a password field. const emailForLogin = ref('') const hasSso = ref(false) -const enforceSso = ref(false) -const isDomainChecking = ref(false) +const lastCheckedEmail = ref('') +const passwordPathReady = ref(false) const domainCheckTimeoutMs = 5000 +const domainCheckDebounceMs = 350 const isCheckingSavedSession = ref(true) const captchaStatus = ref<'disabled' | 'loading' | 'ready' | 'unavailable'>(captchaKey.value ? 'loading' : 'disabled') let captchaInitTimeout: ReturnType | null = null +let domainCheckTimer: ReturnType | null = null +let domainCheckSeq = 0 const version = import.meta.env.VITE_APP_VERSION -const isEmailStepBusy = computed(() => isDomainChecking.value || isCheckingSavedSession.value) +const isLoginStep = computed(() => statusAuth.value === 'login') +const emailValidation = computed(() => (isLoginStep.value ? 'required:trim|email' : '')) +const passwordValidation = computed(() => (passwordPathReady.value ? 'required:trim' : '')) +const mfaValidation = computed(() => (statusAuth.value === '2fa' ? 'required|mfa_code_validation' : '')) +const autofillPreserveHiddenStyle = { + position: 'absolute', + width: '1px', + height: '1px', + opacity: '0', + overflow: 'hidden', + pointerEvents: 'none', +} as const +const mfaRegex = /^(?:\d{6}|\d{3} \d{3})$/ const shouldBlockForCaptcha = computed(() => !!captchaKey.value && captchaStatus.value === 'loading' && !turnstileToken.value) const loginHeroChips = computed(() => [ t('login-chip-live-updates'), @@ -92,21 +109,6 @@ const authSecondaryButtonClass = [ 'dark:border-slate-600/90 dark:bg-slate-950/85 dark:text-slate-200 dark:hover:bg-slate-800/95', 'disabled:pointer-events-none disabled:opacity-60', ].join(' ') -const authAccountContextClass = [ - 'flex min-w-0 flex-col items-start gap-2 rounded-2xl border border-slate-200/80 bg-slate-50/80 p-2', - 'dark:border-slate-700 dark:bg-slate-900/70', -].join(' ') -const authBackToEmailButtonClass = [ - 'inline-flex min-h-11 shrink-0 items-center justify-center gap-1.5 rounded-xl px-3 text-sm font-semibold text-slate-700', - 'transition duration-200 hover:bg-white hover:text-slate-950', - 'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[var(--color-azure-500)]', - 'dark:text-slate-200 dark:hover:bg-slate-800 dark:hover:text-white', - 'disabled:pointer-events-none disabled:opacity-60', -].join(' ') -const authSelectedEmailClass = [ - 'w-full min-w-0 rounded-xl bg-white/75 px-3 py-2 text-sm font-medium leading-5 text-slate-700 [overflow-wrap:anywhere]', - 'dark:bg-slate-950/55 dark:text-slate-100', -].join(' ') const authInlineLinkClass = [ 'inline-flex min-h-6 items-center justify-center gap-1 border-none bg-transparent p-0 text-[0.95rem] font-semibold text-[rgb(255,114,17)]', 'transition-colors duration-200 hover:text-[rgb(235,94,0)]', @@ -145,7 +147,7 @@ function clearCaptchaInitTimeout() { } function scheduleCaptchaInitTimeout() { - if (!captchaKey.value || statusAuth.value !== 'credentials') { + if (!captchaKey.value || !isLoginStep.value) { clearCaptchaInitTimeout() return } @@ -175,7 +177,7 @@ watch(turnstileToken, (token) => { captchaStatus.value = 'ready' clearCaptchaInitTimeout() } - else if (statusAuth.value === 'credentials') { + else if (isLoginStep.value) { captchaStatus.value = 'loading' scheduleCaptchaInitTimeout() } @@ -188,7 +190,7 @@ watch(statusAuth, (status) => { return } - if (status === 'credentials') { + if (status === 'login') { captchaStatus.value = turnstileToken.value ? 'ready' : 'loading' scheduleCaptchaInitTimeout() } @@ -199,6 +201,8 @@ watch(statusAuth, (status) => { onBeforeUnmount(() => { clearCaptchaInitTimeout() + if (domainCheckTimer) + clearTimeout(domainCheckTimer) }) async function nextLogin() { @@ -247,6 +251,9 @@ async function checkMfa() { mfaChallengeId.value = challenge.id statusAuth.value = '2fa' isLoading.value = false + await nextTick() + if (mfaRegex.test(mfaCode.value)) + await handleMfaSubmit({ code: mfaCode.value }) } else { await nextLogin() @@ -347,7 +354,7 @@ async function checkDomain(email: string): Promise<{ has_sso: boolean, enforce_s }) if (!response.ok) { - return { has_sso: false } + throw new Error(`SSO domain check failed: ${response.status}`) } return await response.json() @@ -357,39 +364,124 @@ async function checkDomain(email: string): Promise<{ has_sso: boolean, enforce_s } } -async function handleEmailContinue(form: { email: string }) { - isDomainChecking.value = true - emailForLogin.value = form.email +function isCompletableEmail(email: string) { + const trimmed = email.trim() + const at = trimmed.indexOf('@') + if (at <= 0 || trimmed.includes(' ')) + return false + const domain = trimmed.slice(at + 1) + const dot = domain.indexOf('.') + return dot > 0 && dot < domain.length - 1 +} + +function isCurrentDomainCheck(seq: number, email: string) { + return seq === domainCheckSeq && email === emailForLogin.value.trim() +} +async function refreshSsoForEmail(email: string) { + const trimmed = email.trim() + const seq = ++domainCheckSeq try { - const result = await checkDomain(form.email) + const result = await checkDomain(trimmed) + if (!isCurrentDomainCheck(seq, trimmed)) + return hasSso.value = result.has_sso - enforceSso.value = result.enforce_sso === true + lastCheckedEmail.value = trimmed + passwordPathReady.value = !result.has_sso } catch (error) { - // Domain check timed out or failed. Fall through to the password step. + if (!isCurrentDomainCheck(seq, trimmed)) + return console.error('SSO domain check failed', error) hasSso.value = false - enforceSso.value = false - toast.error(t('sso-check-failed')) + lastCheckedEmail.value = '' + passwordPathReady.value = true } - finally { - isDomainChecking.value = false - statusAuth.value = 'credentials' +} + +async function ensureSsoChecked(email: string) { + const trimmed = email.trim() + if (lastCheckedEmail.value === trimmed) + return + if (domainCheckTimer) { + clearTimeout(domainCheckTimer) + domainCheckTimer = null } + await refreshSsoForEmail(trimmed) } -async function handlePasswordSubmit(form: { password: string }) { +watch(emailForLogin, (email) => { + ++domainCheckSeq + const trimmed = email.trim() + const domain = trimmed.split('@')[1] || '' + const lastDomain = lastCheckedEmail.value.split('@')[1] || '' + + if (domainCheckTimer) { + clearTimeout(domainCheckTimer) + domainCheckTimer = null + } + + if (!isCompletableEmail(trimmed)) { + hasSso.value = false + passwordPathReady.value = false + lastCheckedEmail.value = '' + return + } + + if (lastCheckedEmail.value === trimmed) { + passwordPathReady.value = !hasSso.value + return + } + + if (domain !== lastDomain) { + hasSso.value = false + passwordPathReady.value = false + lastCheckedEmail.value = '' + } + + domainCheckTimer = setTimeout(() => { + void refreshSsoForEmail(trimmed) + }, domainCheckDebounceMs) +}) + +async function handleLoginSubmit(form: { email: string, password: string, code?: string }) { + if (statusAuth.value === '2fa') { + await handleMfaSubmit({ code: form.code || mfaCode.value }) + return + } + isLoading.value = true - await login({ email: emailForLogin.value, password: form.password }) + const email = form.email.trim() + emailForLogin.value = email + + try { + await ensureSsoChecked(email) + } + catch (error) { + console.error('SSO domain check failed', error) + } + + if (hasSso.value) { + await handleSsoLogin() + return + } + + await login({ email, password: form.password }) } async function handleSsoLogin() { - if (isLoading.value || shouldBlockForCaptcha.value) { + if (shouldBlockForCaptcha.value) { + isLoading.value = false return } isLoading.value = true + await ensureSsoChecked(emailForLogin.value) + if (!hasSso.value) { + isLoading.value = false + return + } + const domain = emailForLogin.value.split('@')[1] try { @@ -452,19 +544,6 @@ async function handleMfaSubmit(form: { code: string }) { } } -async function goBackToEmail() { - if (isLoading.value) { - return - } - - statusAuth.value = 'email' - hasSso.value = false - enforceSso.value = false - - await nextTick() - focusLoginEmailInput() -} - async function goToForgotPassword() { await router.push({ path: '/forgot_password', @@ -501,9 +580,11 @@ async function checkAuthUser() { mfaLoginFactor.value = mfaFactor! mfaChallengeId.value = challenge.id - statusAuth.value = '2fa' isLoading.value = false + await nextTick() + if (mfaRegex.test(mfaCode.value)) + await handleMfaSubmit({ code: mfaCode.value }) } else { await nextLogin() @@ -677,8 +758,6 @@ function declineQuerySession() { hideLoader() } -// eslint-disable-next-line regexp/no-unused-capturing-group -const mfaRegex = /(((\d){6})|((\d){3} (\d){3}))$/ function mfa_code_validation(node: { value: any }) { return Promise.resolve(mfaRegex.test(node.value)) } @@ -694,7 +773,10 @@ async function goback() { mfaChallengeId.value = '' mfaLoginFactor.value = null - statusAuth.value = 'email' + mfaCode.value = '' + statusAuth.value = 'login' + await nextTick() + focusLoginEmailInput() } onMounted(checkLogin) @@ -825,105 +907,36 @@ onMounted(checkLogin) - - -
-
- -
+
+
+ +
+
- -
-
- -
-
- -
- - {{ t('dont-have-an-account') }} - - - {{ t('create-a-free-account') }} - -
-
- -
-
- -
-
- -
- -
- -

- {{ emailForLogin }} +

+ {{ t('sso-detected') }}

-
-

- {{ t('sso-detected') }} -

-
- -
-
-
+ +
-
-
- -
- -
- - -
- -

- {{ emailForLogin }} -

-
- -
- -
-
- {{ t('login-or-separator') }} -
-
-
-
- -
-
- -
- -
-
- -
-
- -
- - {{ t('create-a-free-account') }} - - -
+ +
+
- -
-
-
- -
-
- -
+
+ +
+
+ + +
- -
-
- -
+ + + + {{ t('log-in') }} +
+
-
-
- -
+ +
+ + {{ t('dont-have-an-account') }} + + + {{ t('create-a-free-account') }} + + +
+ +
+ +
+
+
- +
- +