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/register.spec.ts b/playwright/e2e/register.spec.ts index b8ccad89b9..dc2b2c2ffc 100644 --- a/playwright/e2e/register.spec.ts +++ b/playwright/e2e/register.spec.ts @@ -133,7 +133,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..777711e1b1 100644 --- a/playwright/e2e/sso-login.spec.ts +++ b/playwright/e2e/sso-login.spec.ts @@ -1,64 +1,65 @@ 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() - - 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/src/pages/login.vue b/src/pages/login.vue index c438d474ad..16146ea499 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' @@ -25,9 +24,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) @@ -35,18 +35,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'), @@ -91,21 +108,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)]', @@ -144,7 +146,7 @@ function clearCaptchaInitTimeout() { } function scheduleCaptchaInitTimeout() { - if (!captchaKey.value || statusAuth.value !== 'credentials') { + if (!captchaKey.value || !isLoginStep.value) { clearCaptchaInitTimeout() return } @@ -174,7 +176,7 @@ watch(turnstileToken, (token) => { captchaStatus.value = 'ready' clearCaptchaInitTimeout() } - else if (statusAuth.value === 'credentials') { + else if (isLoginStep.value) { captchaStatus.value = 'loading' scheduleCaptchaInitTimeout() } @@ -187,7 +189,7 @@ watch(statusAuth, (status) => { return } - if (status === 'credentials') { + if (status === 'login') { captchaStatus.value = turnstileToken.value ? 'ready' : 'loading' scheduleCaptchaInitTimeout() } @@ -198,6 +200,8 @@ watch(statusAuth, (status) => { onBeforeUnmount(() => { clearCaptchaInitTimeout() + if (domainCheckTimer) + clearTimeout(domainCheckTimer) }) async function nextLogin() { @@ -246,6 +250,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() @@ -356,39 +363,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 { @@ -451,19 +543,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', @@ -500,9 +579,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() @@ -676,8 +757,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)) } @@ -693,7 +772,10 @@ async function goback() { mfaChallengeId.value = '' mfaLoginFactor.value = null - statusAuth.value = 'email' + mfaCode.value = '' + statusAuth.value = 'login' + await nextTick() + focusLoginEmailInput() } onMounted(checkLogin) @@ -824,105 +906,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') }} + + +
+ +
+ +
+
+
- +
- +