From efb0bd8c0f34cc65c498cc6e4401a558364b5002 Mon Sep 17 00:00:00 2001 From: Yahya Fakhroji Date: Wed, 29 Jul 2026 15:59:58 +0700 Subject: [PATCH 1/3] fix(login): decouple identifier entry from allowPassword MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /login identifier field was gated on settings.allowPassword, but the identifier is a prerequisite for nearly every method — password, passkey (usernameless is unsupported upstream, zitadel/zitadel#8899), and email-link. Only IdP buttons bypass it. The flag name `showPasswordForm` encoded the wrong mental model and the markup inherited it. Worst case was a silent dead end: on a passkey-only org (allowPassword=false, passkeysType=allowed, no IdP) a visitor with no resolvable loginName got an EMPTY card. The form was hidden, and signInUnavailable was suppressed by showPasskeyPrompt — claiming a passkey path that cannot start without an identifier. No sign-in path, and no error explaining why. - login-view: showPasswordForm → showIdentifierForm, gated on allowPassword || passkeysType === 'allowed' || showEmailLink. - login-view: new showContinue (allowPassword || passkey allowed). "Continue" hands off to decideAfterIdentifier, which returns NO_SUPPORTED_METHOD when neither exists — so on an email-link-only org the button is hidden rather than left to dead-end. Dropping it also makes email-link the first submit button, so Enter in the field still does the right thing. - login-view: signInUnavailable drops from three terms to two — !showIdentifierForm && !showIdpButtons. Passkey no longer clears the state on its own. This reverses the 2026-07-06 assumption that email-link is not a sign-in path; its action mints the OTP session directly, without any enrolled method, so it is one. - login/index: the reveal button hardcoded "Email" while the field label adapts via identifierLabel — a username-only org showed an "Email" button that opened a "Username" field. Now derived from the same resolveIdentifierField result. - Tests: login-view gains an exhaustive gate/unavailable table incl. the empty-card regression; the route spec gains passkey-only and email-link-only render cases. The mount helper now threads settings through hydrationData as well as the loader — with hydrationData present the loaders do not run, so patching only the loader was a no-op. Spec: docs/superpowers/specs/2026-07-29-login-identifier-gate-design.md --- app/modules/i18n/locales/en.po | 38 ++++++++----- app/resources/login/login-view.ts | 36 +++++++++--- app/routes/login/index.tsx | 56 ++++++++++++++----- .../resources/login/login-view.cy.ts | 55 +++++++++++++++++- cypress/component/routes/login/index.cy.tsx | 52 +++++++++++++++-- 5 files changed, 192 insertions(+), 45 deletions(-) diff --git a/app/modules/i18n/locales/en.po b/app/modules/i18n/locales/en.po index b2ad486737..8a50f6ae16 100644 --- a/app/modules/i18n/locales/en.po +++ b/app/modules/i18n/locales/en.po @@ -78,7 +78,7 @@ msgstr "Additional verification is required to continue." msgid "Already have an account?" msgstr "Already have an account?" -#: app/routes/login/index.tsx:387 +#: app/routes/login/index.tsx:396 msgid "An account with this email already exists — sign in to continue." msgstr "An account with this email already exists — sign in to continue." @@ -180,7 +180,7 @@ msgstr "Choose how to sign in" msgid "Choose how you want to verify your identity." msgstr "Choose how you want to verify your identity." -#: app/routes/login/index.tsx:383 +#: app/routes/login/index.tsx:392 msgid "Choose your login method" msgstr "Choose your login method" @@ -211,7 +211,7 @@ msgid "Connected accounts" msgstr "Connected accounts" #: app/routes/device/index.tsx:73 -#: app/routes/login/index.tsx:513 +#: app/routes/login/index.tsx:527 #: app/routes/signup/index.tsx:291 msgid "Continue" msgstr "Continue" @@ -221,10 +221,22 @@ msgstr "Continue" msgid "Continue with {0}" msgstr "Continue with {0}" -#: app/routes/login/index.tsx:428 +#: app/routes/login/index.tsx:364 +msgid "Continue with email" +msgstr "Continue with email" + +#: app/routes/login/index.tsx:437 msgid "Continue with passkey" msgstr "Continue with passkey" +#: app/routes/login/index.tsx:366 +msgid "Continue with phone" +msgstr "Continue with phone" + +#: app/routes/login/index.tsx:367 +msgid "Continue with username" +msgstr "Continue with username" + #: app/routes/login/password.tsx:114 msgid "Could not verify password" msgstr "Could not verify password" @@ -245,7 +257,7 @@ msgstr "Couldn't verify" msgid "Create a new account" msgstr "Create a new account" -#: app/routes/login/index.tsx:545 +#: app/routes/login/index.tsx:573 #: app/routes/signup/password.tsx:237 msgid "Create account" msgstr "Create account" @@ -273,7 +285,6 @@ msgid "Device denied" msgstr "Device denied" #: app/routes/login/index.tsx:350 -#: app/routes/login/index.tsx:482 #: app/routes/signup/index.tsx:250 #: app/routes/signup/index.tsx:275 msgid "Email" @@ -287,7 +298,8 @@ msgstr "Email code" msgid "Email me a code" msgstr "Email me a code" -#: app/routes/login/index.tsx:522 +#: app/routes/login/index.tsx:538 +#: app/routes/login/index.tsx:548 #: app/routes/login/method.tsx:238 #: app/routes/signup/method.tsx:339 msgid "Email me a sign-in link" @@ -449,13 +461,13 @@ msgstr "No signed-in accounts." msgid "Not now" msgstr "Not now" -#: app/routes/login/index.tsx:543 +#: app/routes/login/index.tsx:571 msgid "Not registered?" msgstr "Not registered?" #: app/components/identity-badge/identity-badge.tsx:30 #: app/routes/device/authorize.tsx:122 -#: app/routes/login/index.tsx:416 +#: app/routes/login/index.tsx:425 #: app/routes/login/method.tsx:193 #: app/routes/passkeys.tsx:232 #: app/routes/reauth.tsx:280 @@ -476,7 +488,7 @@ msgstr "or" msgid "Or import this URI in your authenticator app" msgstr "Or import this URI in your authenticator app" -#: app/routes/login/index.tsx:462 +#: app/routes/login/index.tsx:471 #: app/routes/login/method.tsx:221 #: app/routes/reauth.tsx:310 #: app/routes/setup/mfa.tsx:46 @@ -705,7 +717,7 @@ msgstr "Sign out of" msgid "Sign out other sessions" msgstr "Sign out other sessions" -#: app/routes/login/index.tsx:532 +#: app/routes/login/index.tsx:560 msgid "Sign-in is currently unavailable for this account. Please contact your administrator." msgstr "Sign-in is currently unavailable for this account. Please contact your administrator." @@ -719,7 +731,7 @@ msgid "Signing in as" msgstr "Signing in as" #. placeholder {0}: passkeyInline.loginName -#: app/routes/login/index.tsx:409 +#: app/routes/login/index.tsx:418 msgid "Signing in as <0>{0}." msgstr "Signing in as <0>{0}." @@ -950,7 +962,7 @@ msgstr "We've sent a password reset link to <0>{0}" msgid "We've sent a verification link to <0>{0}" msgstr "We've sent a verification link to <0>{0}" -#: app/routes/login/index.tsx:380 +#: app/routes/login/index.tsx:389 msgid "Welcome" msgstr "Welcome" diff --git a/app/resources/login/login-view.ts b/app/resources/login/login-view.ts index c97cd8e7cf..ebd406e5b9 100644 --- a/app/resources/login/login-view.ts +++ b/app/resources/login/login-view.ts @@ -1,7 +1,8 @@ import type { IdProvider, LoginSettings } from '@/modules/auth/types'; export interface LoginView { - showPasswordForm: boolean; + showIdentifierForm: boolean; + showContinue: boolean; showIdpButtons: boolean; showRegisterLink: boolean; showPasskeyPrompt: boolean; @@ -12,12 +13,21 @@ export interface LoginView { /** * Derives what the /login identifier screen should render from the org's login * settings + active IdPs. Pure (no I/O) so it is exhaustively unit-tested; the - * route maps these booleans straight to JSX presence. Mirrors the spec audit: - * allowPassword → render the identifier/password entry form + * route maps these booleans straight to JSX presence. + * + * The identifier field is NOT password-specific: password, passkey, and email-link + * all need a resolved user first (usernameless passkey is unsupported upstream — + * zitadel/zitadel#8899), so gating it on allowPassword alone left passkey-only orgs + * with an unreachable sign-in. Hence two flags: + * showIdentifierForm → any identifier-requiring method is possible + * showContinue → a method exists BEHIND the identifier submit; without it + * decideAfterIdentifier would return NO_SUPPORTED_METHOD, so + * an email-link-only org shows the field without "Continue". + * * allowExternalIdp+ids → render IdP buttons * allowRegister → render "Create account" link - * passkeysType==='allowed' → surface a "Sign in with a passkey" prompt (P2) - * none of the above → render a "sign-in unavailable" state + * passkeysType==='allowed' → also surfaces the known-user passkey shortcut + * neither identifier nor IdP → render a "sign-in unavailable" state */ export function resolveLoginView( settings: Pick< @@ -31,19 +41,27 @@ export function resolveLoginView( idps: IdProvider[], emailDeliveryEnabled: boolean ): LoginView { - const showPasswordForm = settings.allowPassword; const showIdpButtons = settings.allowExternalIdp && idps.length > 0; const showRegisterLink = settings.allowRegister; const showPasskeyPrompt = settings.passkeysType === 'allowed'; const showEmailLink = settings.disableLoginWithEmail !== true && emailDeliveryEnabled; + // "Continue" hands off to decideAfterIdentifier — only offer it when that can resolve + // to a real method for this org. + const showContinue = settings.allowPassword || showPasskeyPrompt; + // Email-link signs in without any enrolled method (its action mints the OTP session + // directly), so it justifies the field on its own — but not the Continue button. + const showIdentifierForm = showContinue || showEmailLink; return { - showPasswordForm, + showIdentifierForm, + showContinue, showIdpButtons, showRegisterLink, showPasskeyPrompt, showEmailLink, - // Passkey is a real sign-in path, so it also clears the "unavailable" state. - signInUnavailable: !showPasswordForm && !showIdpButtons && !showPasskeyPrompt, + // You can sign in iff you can enter an identifier or click an IdP. Passkey no longer + // clears this on its own: without an identifier the ceremony cannot start, and the + // old formula suppressed the message on the strength of an unreachable path. + signInUnavailable: !showIdentifierForm && !showIdpButtons, }; } diff --git a/app/routes/login/index.tsx b/app/routes/login/index.tsx index e6a2b60dd7..182e6e7bb0 100644 --- a/app/routes/login/index.tsx +++ b/app/routes/login/index.tsx @@ -356,6 +356,15 @@ export default function Login() { : field.allowPhone ? '+1 555 000 0000' : 'username'; + // Derived separately from identifierLabel: the field label spells out every accepted type + // ("Email, phone, or username"), too long for a button. Email wins the both-allowed case — + // it is the dominant identifier and the field states the full set once opened. Separate `t` + // literals so Lingui extracts each string. + const identifierButtonLabel = field.allowEmail + ? t`Continue with email` + : field.allowPhone + ? t`Continue with phone` + : t`Continue with username`; const identifierClientSchema = makeLoginIdentifierClientSchema({ rejectPhone: field.rejectPhone, }); @@ -464,9 +473,9 @@ export default function Login() { ) : null} - {view.showPasswordForm && view.showIdpButtons ? : null} + {view.showIdentifierForm && view.showIdpButtons ? : null} - {view.showPasswordForm ? ( + {view.showIdentifierForm ? ( <> {!showEmailField ? ( ) : ( @@ -509,18 +518,37 @@ export default function Login() { className="h-9" /> - - Continue - + {/* Continue hands off to decideAfterIdentifier. With neither password nor + passkey that resolves to NO_SUPPORTED_METHOD, so it is hidden rather than + left to dead-end. Dropping it also makes the email-link button the first + submit button, so Enter in the field triggers email-link. */} + {view.showContinue ? ( + + Continue + + ) : null} {view.showEmailLink ? ( - + view.showContinue ? ( + + ) : ( + // Sole action — render as the primary button, not a secondary link. The + // intent rides on a hidden field rather than the button's name/value: + // SubmitButton does not forward those, and with no Continue button this + // form has exactly one submission meaning anyway. + <> + + + Email me a sign-in link + + + ) ) : null} )} diff --git a/cypress/component/resources/login/login-view.cy.ts b/cypress/component/resources/login/login-view.cy.ts index 8b6237b639..473e7166a8 100644 --- a/cypress/component/resources/login/login-view.cy.ts +++ b/cypress/component/resources/login/login-view.cy.ts @@ -24,12 +24,61 @@ const settings = ( disableLoginWithEmail: o.disableLoginWithEmail ?? false, }); +const IDP = [{ id: 'i', name: 'G', type: 'oidc' } as never]; + describe('resolveLoginView', () => { - it('flags sign-in unavailable when neither password, IdP, nor passkey is offered', () => { - expect(resolveLoginView(settings({}), [], true).signInUnavailable).to.equal(true); + // The identifier field is a prerequisite for password, passkey (usernameless is + // unsupported upstream — zitadel/zitadel#8899) AND email-link, so it must not be + // gated on allowPassword alone. + it('shows the identifier form for password, passkey, or email-link independently', () => { + expect( + resolveLoginView(settings({ allowPassword: true }), [], false).showIdentifierForm + ).to.equal(true); + expect( + resolveLoginView(settings({ passkeysAllowed: true }), [], false).showIdentifierForm + ).to.equal(true); + // email-link alone: delivery on, org has not disabled email login. + expect(resolveLoginView(settings({}), [], true).showIdentifierForm).to.equal(true); + // nothing at all → no form. + expect(resolveLoginView(settings({}), [], false).showIdentifierForm).to.equal(false); + }); + + // REGRESSION: a passkey-only org with no loginName used to render an EMPTY card — the + // identifier form was hidden (allowPassword false) while signInUnavailable was + // suppressed by showPasskeyPrompt, so there was no sign-in path AND no error. + it('gives a passkey-only org a reachable identifier form, not an empty card', () => { + const view = resolveLoginView(settings({ passkeysAllowed: true }), [], false); + expect(view.showIdentifierForm).to.equal(true); + expect(view.showContinue).to.equal(true); + expect(view.signInUnavailable).to.equal(false); + }); + + // "Continue" routes through decideAfterIdentifier; with neither password nor passkey + // that resolves to NO_SUPPORTED_METHOD, so the button must not render. + it('hides Continue when only email-link is available, but keeps the form', () => { + const view = resolveLoginView(settings({}), [], true); + expect(view.showContinue).to.equal(false); + expect(view.showEmailLink).to.equal(true); + expect(view.showIdentifierForm).to.equal(true); + expect(view.signInUnavailable).to.equal(false); + }); + + it('flags sign-in unavailable only when neither an identifier nor an IdP path exists', () => { + // No password, no passkey, no IdP, email delivery OFF → genuinely unavailable. + expect(resolveLoginView(settings({}), [], false).signInUnavailable).to.equal(true); expect( - resolveLoginView(settings({ allowPassword: true }), [], true).signInUnavailable + resolveLoginView(settings({ allowPassword: true }), [], false).signInUnavailable ).to.equal(false); + // An IdP alone clears it even with no identifier path. + expect( + resolveLoginView(settings({ allowExternalIdp: true }), IDP, false).signInUnavailable + ).to.equal(false); + // Email delivery on is itself a path (reverses the 2026-07-06 assumption). + expect(resolveLoginView(settings({}), [], true).signInUnavailable).to.equal(false); + // …but not when the org disabled email login. + expect( + resolveLoginView(settings({ disableLoginWithEmail: true }), [], true).signInUnavailable + ).to.equal(true); }); }); diff --git a/cypress/component/routes/login/index.cy.tsx b/cypress/component/routes/login/index.cy.tsx index a41bb772c8..f7641a9add 100644 --- a/cypress/component/routes/login/index.cy.tsx +++ b/cypress/component/routes/login/index.cy.tsx @@ -14,7 +14,7 @@ import { createMemoryRouter, RouterProvider } from 'react-router'; const LOGIN_CONTEXT = { loginName: '', requestId: undefined, organization: undefined }; -// Settings shaped so the ordinary identifier form (the "Email" button) renders by +// Settings shaped so the ordinary identifier form (the "Continue with email" button) renders by // default — the baseline the inline ceremony state must replace/restore around. const INDEX_LOADER_DATA = { csrfToken: 'tok-0', @@ -55,8 +55,18 @@ function mountLogin(opts?: { // Overrides the /login/passkey stub's action — used to simulate a ceremony failure // (Finding 1 coverage). Defaults to capturing the POST into capturedPosts. passkeyAction?: (args: { request: Request }) => unknown | Promise; + // Org-policy overrides — used to cover configurations where password is disabled. + settings?: Partial<(typeof INDEX_LOADER_DATA)['settings']>; + emailDeliveryEnabled?: boolean; }) { const loginContext = { ...LOGIN_CONTEXT, loginName: opts?.loginName ?? LOGIN_CONTEXT.loginName }; + // Must feed BOTH the loader and the router's hydrationData below — with hydrationData + // present the loaders do not run on initial render, so patching only the loader is a no-op. + const indexData = { + ...INDEX_LOADER_DATA, + settings: { ...INDEX_LOADER_DATA.settings, ...opts?.settings }, + emailDeliveryEnabled: opts?.emailDeliveryEnabled ?? INDEX_LOADER_DATA.emailDeliveryEnabled, + }; const router = createMemoryRouter( [ { @@ -68,7 +78,7 @@ function mountLogin(opts?: { id: 'index', index: true, element: , - loader: async () => INDEX_LOADER_DATA, + loader: async () => indexData, action: opts?.indexAction ?? (async () => null), }, // Stub /login/passkey — same route the shared ceremony hook lazily loads @@ -102,7 +112,7 @@ function mountLogin(opts?: { { initialEntries: ['/login'], hydrationData: { - loaderData: { login: loginContext, index: INDEX_LOADER_DATA }, + loaderData: { login: loginContext, index: indexData }, ...(opts?.actionData !== undefined ? { actionData: { index: opts.actionData } } : {}), }, } @@ -134,7 +144,37 @@ describe('/login — sole-passkey inline ceremony', () => { expect(capturedPosts[0].loginName).to.equal('solo@acme.test'); }); // Identifier form is gone while the ceremony state shows. - cy.contains('button', 'Email').should('not.exist'); + cy.contains('button', 'Continue with email').should('not.exist'); + }); + + // REGRESSION: with allowPassword=false the identifier form used to be hidden entirely, + // while signInUnavailable stayed false (suppressed by showPasskeyPrompt) — so a fresh + // visitor at a passkey-only org got an EMPTY card: no sign-in path and no error. + // Passkey needs a known user (zitadel/zitadel#8899), so the identifier IS the entry point. + it('a passkey-only org still offers the identifier form, not an empty card', () => { + mountLogin({ + settings: { allowPassword: false, passkeysType: 'allowed' }, + emailDeliveryEnabled: false, + }); + cy.contains('button', 'Continue with email').should('be.visible'); + cy.contains('Sign-in is currently unavailable').should('not.exist'); + }); + + // When email-link is the ONLY path, "Continue" would hand off to decideAfterIdentifier + // and resolve to NO_SUPPORTED_METHOD — so it must not render. The email-link submit + // takes over as the form's primary (and only) action. + it('an email-link-only org shows the form without a dead-end Continue button', () => { + mountLogin({ + settings: { allowPassword: false, passkeysType: 'not_allowed' }, + emailDeliveryEnabled: true, + }); + cy.contains('button', 'Continue with email').should('be.visible').click(); + cy.contains('button', 'Email me a sign-in link').should('be.visible'); + // Exactly one submit action — no "Continue" alongside it. + cy.get('form button[type="submit"]').should('have.length', 1); + cy.contains('form button', 'Continue').should('not.exist'); + // The intent rides on a hidden field, so implicit submission still means email-link. + cy.get('form input[name="intent"]').should('have.value', 'email-link'); }); it('inline state offers the manual button and Not you? returns to the identifier form', () => { @@ -149,7 +189,7 @@ describe('/login — sole-passkey inline ceremony', () => { }); cy.contains('button', 'Continue with passkey').should('be.visible'); cy.contains('Not you?').click(); - cy.contains('button', 'Email').should('be.visible'); + cy.contains('button', 'Continue with email').should('be.visible'); }); // Finding 1: the gated Passkey SHORTCUT drives the same `ceremony` as the inline @@ -198,7 +238,7 @@ describe('/login — sole-passkey inline ceremony', () => { cy.wrap(null).should(() => expect(capturedPosts).to.have.length(1)); cy.contains('Not you?').click(); - cy.contains('button', 'Email').should('be.visible').click(); + cy.contains('button', 'Continue with email').should('be.visible').click(); cy.contains('button', 'Continue').click(); // The fresh challenge (different identity, different publicKeyCredentialRequestOptions From ddf34416c5e339faefbb819a910bb5172e886b20 Mon Sep 17 00:00:00 2001 From: Yahya Fakhroji Date: Thu, 30 Jul 2026 21:54:56 +0700 Subject: [PATCH 2/3] fix(login): refuse /login/password when org policy disables password MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit settings.allowPassword=false (Zitadel LoginPolicy.userLogin=false — production's configuration today) kept password out of the post-identifier routing, but nothing stopped a direct visit or a crafted POST. LoginPolicy governs Zitadel's own hosted UI; auth-ui drives the headless Session API, whose password check does not consult it — so /login/password still rendered a working form and still authenticated. - password loader: redirect back to /login when !allowPassword, carrying requestId and organization through redirectToLogin. - password action: return 403 PASSWORD_NOT_ALLOWED before verifyLoginPassword. The loader guard alone is not enough — a POST never runs the loader. - test harness: register loginPasswordLoader and add allowPassword to mockLoginSettings so the policy can be driven from a scenario. - passkeys: restyle the Back button (theme="link" type="quaternary"). Prerequisite for enabling passkeys in production, where password is policy-disabled but the route was still reachable. --- app/modules/i18n/locales/en.po | 20 ++++---- app/routes/login/password.tsx | 14 ++++++ app/routes/passkeys.tsx | 2 +- .../routes/login/password-policy-guard.cy.ts | 50 +++++++++++++++++++ cypress/support/node/harness.ts | 18 ++++++- cypress/support/node/scenario.ts | 4 ++ 6 files changed, 96 insertions(+), 12 deletions(-) create mode 100644 cypress/component/routes/login/password-policy-guard.cy.ts diff --git a/app/modules/i18n/locales/en.po b/app/modules/i18n/locales/en.po index 8a50f6ae16..0b0a5eaa32 100644 --- a/app/modules/i18n/locales/en.po +++ b/app/modules/i18n/locales/en.po @@ -18,7 +18,7 @@ msgid "\"{0}\" will no longer work for signing in. This cannot be undone." msgstr "\"{0}\" will no longer work for signing in. This cannot be undone." #. placeholder {0}: attempts.count -#: app/routes/login/password.tsx:166 +#: app/routes/login/password.tsx:180 msgid "{0, plural, one {# attempt remaining.} other {# attempts remaining.}}" msgstr "{0, plural, one {# attempt remaining.} other {# attempts remaining.}}" @@ -237,7 +237,7 @@ msgstr "Continue with phone" msgid "Continue with username" msgstr "Continue with username" -#: app/routes/login/password.tsx:114 +#: app/routes/login/password.tsx:128 msgid "Could not verify password" msgstr "Could not verify password" @@ -361,8 +361,8 @@ msgstr "Enter your authenticator code" msgid "Enter your email code" msgstr "Enter your email code" -#: app/routes/login/password.tsx:134 -#: app/routes/login/password.tsx:151 +#: app/routes/login/password.tsx:148 +#: app/routes/login/password.tsx:165 msgid "Enter your password" msgstr "Enter your password" @@ -378,7 +378,7 @@ msgstr "Finish creating your account" msgid "For your security, verify one of your sign-in methods to continue." msgstr "For your security, verify one of your sign-in methods to continue." -#: app/routes/login/password.tsx:197 +#: app/routes/login/password.tsx:211 msgid "Forgot password?" msgstr "Forgot password?" @@ -567,7 +567,7 @@ msgstr "Phone" msgid "Phone sign-in isn't available — use your email or username." msgstr "Phone sign-in isn't available — use your email or username." -#: app/routes/login/password.tsx:185 +#: app/routes/login/password.tsx:199 #: app/utils/errors/auth-error-messages.tsx:27 msgid "Please check your input and try again." msgstr "Please check your input and try again." @@ -675,13 +675,13 @@ msgstr "Set up security key" msgid "Set up SMS one-time code" msgstr "Set up SMS one-time code" -#: app/routes/login/password.tsx:190 +#: app/routes/login/password.tsx:204 #: app/routes/signup/index.tsx:308 #: app/routes/sso/ldap.tsx:85 msgid "Sign in" msgstr "Sign in" -#: app/routes/login/password.tsx:179 +#: app/routes/login/password.tsx:193 #: app/routes/logout/success.tsx:27 #: app/utils/errors/auth-error-recovery.tsx:47 msgid "Sign in again" @@ -1018,7 +1018,7 @@ msgstr "You'll be asked to sign in before authorizing." msgid "You've been signed out" msgstr "You've been signed out" -#: app/routes/login/password.tsx:161 +#: app/routes/login/password.tsx:175 msgid "Your account is temporarily locked after too many attempts." msgstr "Your account is temporarily locked after too many attempts." @@ -1050,7 +1050,7 @@ msgstr "Your previous attempt may have already created a passkey on this device. msgid "Your session has ended and you've been securely signed out of Datum. You can safely close this tab, or sign back in any time to pick up where you left off." msgstr "Your session has ended and you've been securely signed out of Datum. You can safely close this tab, or sign back in any time to pick up where you left off." -#: app/routes/login/password.tsx:177 +#: app/routes/login/password.tsx:191 #: app/utils/errors/auth-error-messages.tsx:56 msgid "Your session has expired." msgstr "Your session has expired." diff --git a/app/routes/login/password.tsx b/app/routes/login/password.tsx index 5e2155815a..0d880bcf73 100644 --- a/app/routes/login/password.tsx +++ b/app/routes/login/password.tsx @@ -35,9 +35,15 @@ export const meta: MetaFunction = () => [{ title: 'Enter your password' }]; export async function loader({ request }: LoaderFunctionArgs) { const url = new URL(request.url); const organization = url.searchParams.get('organization') ?? undefined; + const requestId = url.searchParams.get('requestId') ?? undefined; const provider = providerForRequest(request); // Org-first: an explicit org wins, else the default org (old app's `organization ?? getDefaultOrg()`). const settings = await provider.getLoginSettings(await resolveOrg(provider, organization)); + // Org policy may forbid password entirely (Zitadel LoginPolicy.userLogin=false — production's + // configuration). decideAfterIdentifier already filters password out of the post-identifier + // routing, so nothing NAVIGATES here; but LoginPolicy governs Zitadel's own hosted UI, not + // auth-ui's headless Session API calls — without this a direct visit renders a working form. + if (!settings.allowPassword) return redirect(redirectToLogin(requestId, organization)); const { csrfToken, headers } = await loaderCsrf(request); return data({ csrfToken, hidePasswordReset: settings.hidePasswordReset === true }, { headers }); } @@ -50,6 +56,14 @@ export async function action({ request }: ActionFunctionArgs) { if (!parsed.success) return data({ error: 'INVALID_INPUT' as const }, { status: 400 }); const { password, loginName, requestId, organization } = parsed.data; + // Same policy guard as the loader, repeated because a crafted POST never runs the loader. + // Not belt-and-braces: Zitadel's Session API password check does NOT consult LoginPolicy, + // so without this a direct POST would authenticate against a policy that forbids passwords. + const settings = await provider.getLoginSettings(await resolveOrg(provider, organization)); + if (!settings.allowPassword) { + return data({ error: 'PASSWORD_NOT_ALLOWED' as const }, { status: 403 }); + } + const list = await readSessions(request); const result = await verifyLoginPassword(provider, list, { password, diff --git a/app/routes/passkeys.tsx b/app/routes/passkeys.tsx index 838382275b..9b9f2df341 100644 --- a/app/routes/passkeys.tsx +++ b/app/routes/passkeys.tsx @@ -308,7 +308,7 @@ export default function Passkeys() { {returnTo && /^https?:\/\//.test(returnTo) ? ( // Validated external entry point (portal round-trip) — offer the way back. - + Back ) : null} diff --git a/cypress/component/routes/login/password-policy-guard.cy.ts b/cypress/component/routes/login/password-policy-guard.cy.ts new file mode 100644 index 0000000000..d6e91e36d9 --- /dev/null +++ b/cypress/component/routes/login/password-policy-guard.cy.ts @@ -0,0 +1,50 @@ +// cypress/component/routes/login/password-policy-guard.cy.ts +// +// Org policy can disable password sign-in (Zitadel LoginPolicy.userLogin=false → +// settings.allowPassword=false; that is production's configuration today). +// decideAfterIdentifier already filters password out of the post-identifier routing, but +// auth-ui drives Zitadel through the headless Session API — LoginPolicy governs Zitadel's +// own hosted UI, not us. So /login/password must refuse on its own, or a direct navigation +// (loader) or crafted POST (action) still reaches a working password check. +import { callService } from '../../../support/node/call-service'; + +describe('/login/password — allowPassword policy guard', () => { + it('loader bounces a direct visit back to /login when password is disabled', () => { + callService({ + fn: 'loginPasswordLoader', + provider: 'singleton', + mockLoginSettings: { allowPassword: false }, + request: { url: 'http://localhost/id/login/password' }, + }).then((v) => { + expect(v.response?.isResponse).to.equal(true); + expect(v.response?.status).to.equal(302); + expect(v.response?.location).to.contain('/login'); + }); + }); + + it('action refuses a crafted POST when password is disabled', () => { + callService({ + fn: 'loginPasswordAction', + provider: 'singleton', + mockLoginSettings: { allowPassword: false }, + request: { + url: 'http://localhost/id/login/password', + form: { loginName: 'user@acme.test', password: 'hunter2hunter2' }, + csrf: true, + }, + }).then((v) => { + expect(v.response?.dataStatus).to.equal(403); + expect(v.response?.dataBody).to.have.property('error', 'PASSWORD_NOT_ALLOWED'); + }); + }); + + it('loader renders normally when password is allowed', () => { + callService({ + fn: 'loginPasswordLoader', + provider: 'singleton', + request: { url: 'http://localhost/id/login/password' }, + }).then((v) => { + expect(v.response?.status).to.not.equal(302); + }); + }); +}); diff --git a/cypress/support/node/harness.ts b/cypress/support/node/harness.ts index b31f38cafd..28abc72130 100644 --- a/cypress/support/node/harness.ts +++ b/cypress/support/node/harness.ts @@ -109,7 +109,10 @@ import { loader as deviceIndexLoader } from '@/routes/device/index'; import { loader as loginLoader, action as loginAction } from '@/routes/login/index'; import { loader as loginMethodLoader, action as loginMethodAction } from '@/routes/login/method'; import { action as loginMfaAction } from '@/routes/login/mfa'; -import { action as loginPasswordAction } from '@/routes/login/password'; +import { + action as loginPasswordAction, + loader as loginPasswordLoader, +} from '@/routes/login/password'; import { action as securityKeyAction } from '@/routes/login/security-key'; import { loader as loginVerifyEmailLoader } from '@/routes/login/verify/email'; import { loader as logoutLoader } from '@/routes/logout/index'; @@ -2182,6 +2185,19 @@ export async function runScenario(s: Scenario): Promise { break; } + case 'loginPasswordLoader': { + const { request } = await buildHandlerRequest( + s.request ?? { url: 'http://localhost/id/login/password' } + ); + const result = await loginPasswordLoader({ + request, + params: {}, + context: {} as never, + } as never); + response = await serializeResponse(result); + break; + } + case 'loginPasswordAction': { const { request } = await buildHandlerRequest( s.request ?? { url: 'http://localhost/id/login/password', csrf: true } diff --git a/cypress/support/node/scenario.ts b/cypress/support/node/scenario.ts index f7923eadfa..761ab92e88 100644 --- a/cypress/support/node/scenario.ts +++ b/cypress/support/node/scenario.ts @@ -230,6 +230,7 @@ export type ServiceFn = | 'loginLoader' | 'loginAction' | 'loginPasswordAction' + | 'loginPasswordLoader' | 'securityKeyAction' | 'loginVerifyEmailLoader' | 'loginMethodLoader' @@ -308,6 +309,9 @@ export interface Scenario { disableLoginWithEmail?: boolean; disableLoginWithPhone?: boolean; allowRegister?: boolean; + /** Password disabled by org policy (Zitadel LoginPolicy.userLogin=false) — production's + * configuration. Drives the /login/password loader+action policy guard. */ + allowPassword?: boolean; }; /** deleteSession throws — exercises completeOidcLogout's best-effort tolerance. */ failDeleteSession?: boolean; From c8c279426596f8e53705b6c465aa81704d05ddd2 Mon Sep 17 00:00:00 2001 From: Yahya Fakhroji Date: Thu, 30 Jul 2026 22:08:22 +0700 Subject: [PATCH 3/3] fix(passkeys): pending state on the destructive dialog buttons; update e2e labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit E2E: four /login specs still asserted the old hardcoded "Email" button and broke on the relabel to "Continue with email" (core-signin, verify-otp, passkeys-manage, login-hydrated-submit). The earlier verification ran `cypress run --component`, which never loads cypress/e2e — hence CI caught it and the local run did not. The /signup acceptance specs keep asserting "Email"; that button is unchanged. Passkeys dialogs: both destructive submits were bare RRForm buttons with no pending state, so clicking "Remove passkey" looked inert until the dialog abruptly swapped to the sign-out-others offer. Both now show a spinner and disable their cancel: - Remove: scoped to the row via navigation.formData.passkeyId — useNavigation is global and every row renders its own dialog, so an unscoped check would spin all of them at once. - Sign out other sessions: same treatment, matched on intent. --- app/modules/i18n/locales/en.po | 40 ++++++++++++------------- app/routes/passkeys.tsx | 23 ++++++++++++-- cypress/e2e/core-signin.cy.ts | 6 ++-- cypress/e2e/login-hydrated-submit.cy.ts | 2 +- cypress/e2e/passkeys-manage.cy.ts | 2 +- cypress/e2e/verify-otp.cy.ts | 2 +- 6 files changed, 46 insertions(+), 29 deletions(-) diff --git a/app/modules/i18n/locales/en.po b/app/modules/i18n/locales/en.po index 0b0a5eaa32..840857e102 100644 --- a/app/modules/i18n/locales/en.po +++ b/app/modules/i18n/locales/en.po @@ -13,7 +13,7 @@ msgstr "" "Plural-Forms: \n" #. placeholder {0}: row.name -#: app/routes/passkeys.tsx:138 +#: app/routes/passkeys.tsx:146 msgid "\"{0}\" will no longer work for signing in. This cannot be undone." msgstr "\"{0}\" will no longer work for signing in. This cannot be undone." @@ -53,7 +53,7 @@ msgstr "Add an extra layer of security to your account by setting up a second fa msgid "Add another account" msgstr "Add another account" -#: app/routes/passkeys.tsx:306 +#: app/routes/passkeys.tsx:323 msgid "Add passkey" msgstr "Add passkey" @@ -66,7 +66,7 @@ msgid "Add your phone number as a second factor. We will send a one-time code vi msgstr "Add your phone number as a second factor. We will send a one-time code via SMS each time you sign in." #. placeholder {0}: i18n.date(new Date(row.createdAt), { dateStyle: 'medium' }) -#: app/routes/passkeys.tsx:277 +#: app/routes/passkeys.tsx:294 msgid "Added {0}" msgstr "Added {0}" @@ -131,7 +131,7 @@ msgid "Available accounts to link" msgstr "Available accounts to link" #: app/components/back-link/back-link.tsx:24 -#: app/routes/passkeys.tsx:312 +#: app/routes/passkeys.tsx:329 #: app/routes/sso/link.tsx:68 #: app/routes/verify/success.tsx:36 msgid "Back" @@ -145,7 +145,7 @@ msgstr "Back to sign in" msgid "By continuing, you agree to Datum's <0>Terms of Service and <1>Privacy Policy, and to receive periodic emails with updates." msgstr "By continuing, you agree to Datum's <0>Terms of Service and <1>Privacy Policy, and to receive periodic emails with updates." -#: app/routes/passkeys.tsx:143 +#: app/routes/passkeys.tsx:156 #: app/routes/sso/index.tsx:107 msgid "Cancel" msgstr "Cancel" @@ -390,7 +390,7 @@ msgstr "Get started" msgid "If you prefer a demo, just <0>reach out." msgstr "If you prefer a demo, just <0>reach out." -#: app/routes/passkeys.tsx:270 +#: app/routes/passkeys.tsx:287 msgid "Inactive" msgstr "Inactive" @@ -414,7 +414,7 @@ msgstr "Link your account" msgid "Linked accounts" msgstr "Linked accounts" -#: app/routes/passkeys.tsx:231 +#: app/routes/passkeys.tsx:248 #: app/routes/reauth.tsx:279 #: app/routes/sso/index.tsx:137 msgid "Logged in as" @@ -441,7 +441,7 @@ msgstr "New password" msgid "No account was found and sign-up is not available." msgstr "No account was found and sign-up is not available." -#: app/routes/passkeys.tsx:251 +#: app/routes/passkeys.tsx:268 msgid "No passkeys yet." msgstr "No passkeys yet." @@ -457,7 +457,7 @@ msgstr "No sign-in method is available for this account." msgid "No signed-in accounts." msgstr "No signed-in accounts." -#: app/routes/passkeys.tsx:189 +#: app/routes/passkeys.tsx:206 msgid "Not now" msgstr "Not now" @@ -469,7 +469,7 @@ msgstr "Not registered?" #: app/routes/device/authorize.tsx:122 #: app/routes/login/index.tsx:425 #: app/routes/login/method.tsx:193 -#: app/routes/passkeys.tsx:232 +#: app/routes/passkeys.tsx:249 #: app/routes/reauth.tsx:280 #: app/routes/signed-in.tsx:49 #: app/routes/sso/index.tsx:138 @@ -499,7 +499,7 @@ msgstr "Passkey" msgid "Passkey name" msgstr "Passkey name" -#: app/routes/passkeys.tsx:176 +#: app/routes/passkeys.tsx:192 msgid "Passkey removed" msgstr "Passkey removed" @@ -519,11 +519,11 @@ msgstr "Passkey sign-in couldn't be completed for security reasons. Please conta msgid "Passkey sign-in was cancelled, or no passkey for this account is available on this device. Make sure you're using a device where you set up your passkey, then try again." msgstr "Passkey sign-in was cancelled, or no passkey for this account is available on this device. Make sure you're using a device where you set up your passkey, then try again." -#: app/routes/passkeys.tsx:224 +#: app/routes/passkeys.tsx:241 msgid "Passkeys" msgstr "Passkeys" -#: app/routes/passkeys.tsx:227 +#: app/routes/passkeys.tsx:244 msgid "Passkeys let you sign in with your fingerprint, face, or device PIN." msgstr "Passkeys let you sign in with your fingerprint, face, or device PIN." @@ -589,16 +589,16 @@ msgid "Registration is currently unavailable. Please contact your administrator. msgstr "Registration is currently unavailable. Please contact your administrator." #. placeholder {0}: row.name -#: app/routes/passkeys.tsx:129 -#: app/routes/passkeys.tsx:130 +#: app/routes/passkeys.tsx:137 +#: app/routes/passkeys.tsx:138 msgid "Remove {0}" msgstr "Remove {0}" -#: app/routes/passkeys.tsx:150 +#: app/routes/passkeys.tsx:163 msgid "Remove passkey" msgstr "Remove passkey" -#: app/routes/passkeys.tsx:136 +#: app/routes/passkeys.tsx:144 msgid "Remove this passkey?" msgstr "Remove this passkey?" @@ -713,7 +713,7 @@ msgstr "Sign out" msgid "Sign out of" msgstr "Sign out of" -#: app/routes/passkeys.tsx:195 +#: app/routes/passkeys.tsx:212 msgid "Sign out other sessions" msgstr "Sign out other sessions" @@ -721,7 +721,7 @@ msgstr "Sign out other sessions" msgid "Sign-in is currently unavailable for this account. Please contact your administrator." msgstr "Sign-in is currently unavailable for this account. Please contact your administrator." -#: app/routes/passkeys.tsx:178 +#: app/routes/passkeys.tsx:194 msgid "Signed-in sessions on other devices can still be active. Sign out your other sessions?" msgstr "Signed-in sessions on other devices can still be active. Sign out your other sessions?" @@ -990,7 +990,7 @@ msgstr "You can link multiple accounts to your Datum account." msgid "You can now sign in using <0>{loginName}." msgstr "You can now sign in using <0>{loginName}." -#: app/routes/passkeys.tsx:242 +#: app/routes/passkeys.tsx:259 msgid "You can't remove your only sign-in method. Add another method first." msgstr "You can't remove your only sign-in method. Add another method first." diff --git a/app/routes/passkeys.tsx b/app/routes/passkeys.tsx index 9b9f2df341..c94e1b9732 100644 --- a/app/routes/passkeys.tsx +++ b/app/routes/passkeys.tsx @@ -34,6 +34,7 @@ import { redirect, useActionData, useLoaderData, + useNavigation, type ActionFunctionArgs, type LoaderFunctionArgs, type MetaFunction, @@ -111,7 +112,14 @@ export async function action({ request }: ActionFunctionArgs) { function RemoveConfirmDialog({ row, csrfToken }: { row: PasskeyRow; csrfToken: string }) { const [open, setOpen] = useState(false); const actionData = useActionData(); + const navigation = useNavigation(); const { t } = useLingui(); + // Scoped to THIS row: useNavigation is global, and every passkey row renders its own + // dialog — matching on passkeyId keeps one row's removal from spinning all of them. + const removing = + navigation.state !== 'idle' && + navigation.formData?.get('intent') === 'remove' && + navigation.formData?.get('passkeyId') === row.id; // Close when any action result lands: on success the row unmounts anyway, but on a // refusal (LAST_METHOD) the inline error must not hide behind the modal overlay. useEffect(() => { @@ -139,14 +147,19 @@ function RemoveConfirmDialog({ row, csrfToken }: { row: PasskeyRow; csrfToken: s } /> - - @@ -169,6 +182,9 @@ function SignOutOthersDialog({ onOpenChange: (open: boolean) => void; csrfToken: string; }) { + const navigation = useNavigation(); + const signingOut = + navigation.state !== 'idle' && navigation.formData?.get('intent') === 'signout-others'; return ( @@ -185,13 +201,14 @@ function SignOutOthersDialog({ type="secondary" theme="outline" htmlType="button" + disabled={signingOut} onClick={() => onOpenChange(false)}> Not now - diff --git a/cypress/e2e/core-signin.cy.ts b/cypress/e2e/core-signin.cy.ts index 5d4cd2a94c..41e8e9486f 100644 --- a/cypress/e2e/core-signin.cy.ts +++ b/cypress/e2e/core-signin.cy.ts @@ -7,7 +7,7 @@ describe('core sign-in (fake provider)', () => { // visit hydrated. We wait for the SSR'd "Email" button so the cold reload has fully settled. before(() => { cy.visit('/id/login'); - cy.contains('button', 'Email'); + cy.contains('button', 'Continue with email'); }); it('identifier → password → signed-in', () => { @@ -20,7 +20,7 @@ describe('core sign-in (fake provider)', () => { checkA11y(); // /login renders // The email input is behind an "Email" reveal button (IdP-first UX); click it first. - cy.contains('button', 'Email').click(); + cy.contains('button', 'Continue with email').click(); cy.get('input[name="loginName"]').type('alice@acme.test'); cy.get('input[name="loginName"]:visible').closest('form').submit(); @@ -42,7 +42,7 @@ describe('core sign-in (fake provider)', () => { }, }); cy.settleHydration(); - cy.contains('button', 'Email').click(); + cy.contains('button', 'Continue with email').click(); cy.get('input[name="loginName"]').type('alice@acme.test'); cy.get('input[name="loginName"]:visible').closest('form').submit(); cy.get('input[name="password"]').type('wrong-password'); diff --git a/cypress/e2e/login-hydrated-submit.cy.ts b/cypress/e2e/login-hydrated-submit.cy.ts index 1dc1ed374b..5a98e223d8 100644 --- a/cypress/e2e/login-hydrated-submit.cy.ts +++ b/cypress/e2e/login-hydrated-submit.cy.ts @@ -46,7 +46,7 @@ describe('login form submits when hydrated (RHF-adapter regression)', () => { // entry.client.tsx + routes/login/index.tsx). Click it first to mount the loginName input — // mirrors core-signin.cy.ts. (Pre-IdP-first this field was visible on load; the reveal is the // current behavior.) The hydration regression this spec guards is unaffected by the reveal. - cy.contains('button', 'Email').click(); + cy.contains('button', 'Continue with email').click(); // Type into the (now React-controlled) identifier field and click the real // Continue button — the exact interaction the RHF adapter used to swallow. diff --git a/cypress/e2e/passkeys-manage.cy.ts b/cypress/e2e/passkeys-manage.cy.ts index d2e7b185d3..d078ffaaf0 100644 --- a/cypress/e2e/passkeys-manage.cy.ts +++ b/cypress/e2e/passkeys-manage.cy.ts @@ -30,7 +30,7 @@ describe('/id/passkeys — list / remove / last-method guard / sign-out offer / // passkey-use.cy.ts fails standalone on main the same way) so ceremony clicks land. before(() => { cy.visit('/id/login'); - cy.contains('button', 'Email'); + cy.contains('button', 'Continue with email'); loginAndGetSession('passkey-user@acme.test'); cy.visit('/id/login/passkey?loginName=passkey-user%40acme.test', { onBeforeLoad: (win) => { diff --git a/cypress/e2e/verify-otp.cy.ts b/cypress/e2e/verify-otp.cy.ts index 1980665245..0747de254c 100644 --- a/cypress/e2e/verify-otp.cy.ts +++ b/cypress/e2e/verify-otp.cy.ts @@ -18,7 +18,7 @@ function loginAndGetSession(loginName: string) { cy.settleHydration(); // The email input is behind an "Email" reveal button (IdP-first UX); click it first // (mirrors core-signin.cy.ts's identifier flow). - cy.contains('button', 'Email').click(); + cy.contains('button', 'Continue with email').click(); cy.get('input[name="loginName"]').type(loginName); cy.get('input[name="loginName"]:visible').closest('form').submit(); cy.location('pathname').should('eq', '/id/login/password');