diff --git a/app/modules/i18n/locales/en.po b/app/modules/i18n/locales/en.po
index b2ad48673..840857e10 100644
--- a/app/modules/i18n/locales/en.po
+++ b/app/modules/i18n/locales/en.po
@@ -13,12 +13,12 @@ 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."
#. 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.}}"
@@ -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}"
@@ -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."
@@ -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 Service0> and <1>Privacy Policy1>, and to receive periodic emails with updates."
msgstr "By continuing, you agree to Datum's <0>Terms of Service0> and <1>Privacy Policy1>, 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"
@@ -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,11 +221,23 @@ 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/password.tsx:114
+#: 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:128
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"
@@ -349,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"
@@ -366,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?"
@@ -378,7 +390,7 @@ msgstr "Get started"
msgid "If you prefer a demo, just <0>reach out0>."
msgstr "If you prefer a demo, just <0>reach out0>."
-#: app/routes/passkeys.tsx:270
+#: app/routes/passkeys.tsx:287
msgid "Inactive"
msgstr "Inactive"
@@ -402,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"
@@ -429,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."
@@ -445,19 +457,19 @@ 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"
-#: 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/passkeys.tsx:249
#: app/routes/reauth.tsx:280
#: app/routes/signed-in.tsx:49
#: app/routes/sso/index.tsx:138
@@ -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
@@ -487,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"
@@ -507,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."
@@ -555,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."
@@ -577,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?"
@@ -663,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"
@@ -701,15 +713,15 @@ 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"
-#: 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."
-#: 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?"
@@ -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}0>."
msgstr "Signing in as <0>{0}0>."
@@ -950,7 +962,7 @@ msgstr "We've sent a password reset link to <0>{0}0>"
msgid "We've sent a verification link to <0>{0}0>"
msgstr "We've sent a verification link to <0>{0}0>"
-#: app/routes/login/index.tsx:380
+#: app/routes/login/index.tsx:389
msgid "Welcome"
msgstr "Welcome"
@@ -978,7 +990,7 @@ msgstr "You can link multiple accounts to your Datum account."
msgid "You can now sign in using <0>{loginName}0>."
msgstr "You can now sign in using <0>{loginName}0>."
-#: 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."
@@ -1006,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."
@@ -1038,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/resources/login/login-view.ts b/app/resources/login/login-view.ts
index c97cd8e7c..ebd406e5b 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 e6a2b60dd..182e6e7bb 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 ? (
}
onClick={() => setShowEmailField(true)}>
- Email
+ {identifierButtonLabel}
) : (
@@ -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/app/routes/login/password.tsx b/app/routes/login/password.tsx
index 5e2155815..0d880bcf7 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 838382275..c94e1b973 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
}
/>
-
-
+ Sign out other sessions
@@ -308,7 +325,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/resources/login/login-view.cy.ts b/cypress/component/resources/login/login-view.cy.ts
index 8b6237b63..473e7166a 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 a41bb772c..f7641a9ad 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
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 000000000..d6e91e36d
--- /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/e2e/core-signin.cy.ts b/cypress/e2e/core-signin.cy.ts
index 5d4cd2a94..41e8e9486 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 1dc1ed374..5a98e223d 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 d2e7b185d..d078ffaaf 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 198066524..0747de254 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');
diff --git a/cypress/support/node/harness.ts b/cypress/support/node/harness.ts
index b31f38caf..28abc7213 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 f7923eadf..761ab92e8 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;