Skip to content

Commit 15c84c0

Browse files
committed
feat(auth): passwordless signup/login redesign, error surfacing, email-delivery toggle
Rebuild the signup and login flows around passwordless-first auth, surface provider errors consistently, and gate email-link behind an email-delivery flag. Squash-merge of 57 commits from feat/passwordless-signup-redesign. Signup redesign - Identifier screen: IdP buttons + email reveal; first/last name parsed from the email (parseNameFromEmail), editable later in the portal. - Method screen: confirm identity, then choose email-link / passkey / password / IdP, driven by a settings-gated view-model (resolveSignupView). - Email-link signup: passwordless register + verification email; /signup/complete verifies the link and self-authenticates the session, with a skippable passkey nudge afterward. - Passkey enrollment chains into assertion (checkAfter) so the session authenticates. Passkey is gated behind email verification (anti-spam): a passkey proves device possession, not email ownership. - New IdP users route to the /signup/method confirm screen (register-and-link). - otpEmail modeled as a primary factor in the neutral + fake providers. Login redesign - Email-first entry, then a primary-method chooser when 2+ methods exist; email-only users route straight to email-link sign-in (resolveLoginView.showEmailLink). - Passkey nudge after email-link sign-in (next=passkey marker). Email-delivery toggle - AUTH_EMAIL_DELIVERY_ENABLED env flag (default off) for stacks where mail is delivered by the backend rather than Zitadel SMTP. - Gates showEmailLink on signup + login; drops otp_email as a primary factor and rejects email-link intents / guards password-reset when delivery is off. Error surfacing (toast + inline) - resolveAuthError + actionError map ProviderError to a surfaceable code, including precise password-complexity codes; generic message for the rest. - Client message map (useAuthErrorMessage) + useActionErrorToast + Toaster mount. - Rolled out across all auth forms: signup (index/method/password), login (index/password/passkey/security-key, verify email/sms/authenticator), password (reset/new/change), setup (passkey/security-key/authenticator/email/ sms/mfa). Public assets behind the /id gateway - Prefix public asset URLs with the Vite base so they resolve behind /id; serve /id/images and /id/favicons statically in prod; route them to Vite in dev and allow the auth.localtest.me host. Tests - Unit specs for new schemas, view-models, providers, and routes; e2e signup acceptance updated + email-link passwordless journey added.
1 parent d22a568 commit 15c84c0

77 files changed

Lines changed: 3521 additions & 666 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,7 @@ POST_LOGOUT_ALLOWLIST=
8383
# session metadata). Optional in EVERY environment (staging may want fraud detection too).
8484
# Unset = the signup MaxMind tracker is a true no-op (no device.js loaded, no token captured).
8585
# MAXMIND_ACCOUNT_ID=123456
86+
87+
# Set to "true" only where email delivery works (BE/infra email path). Unset/false hides
88+
# the email sign-in link + password-reset (fail-safe; Zitadel SMTP is NOT the signal here).
89+
# AUTH_EMAIL_DELIVERY_ENABLED=false

acceptance/signup-email.acceptance.cy.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Email signup → verification acceptance (real Zitadel + Mailpit).
2-
// Proves the full registration entry/exit: /id/signup (collect email/names) →
3-
// /id/signup/password (set password → register() creates a REAL Zitadel user and
2+
// Proves the full registration entry/exit: /id/signup (collect email; name parsed from
3+
// it) → /id/signup/method (choose "Set a password") → /id/signup/password (set password
4+
// → register() creates a REAL Zitadel user and
45
// Zitadel sends a verification email to Mailpit) → the emailed link lands on OUR
56
// /id/verify with the code pre-filled (the verifyUrlTemplate fix; without it the
67
// link would point at Zitadel's built-in page) → POST /id/verify → /verify/success.
@@ -44,15 +45,18 @@ function pollForCode(attempt: number): Cypress.Chainable<{ code: string; userId:
4445

4546
(RUN ? describe : describe.skip)('signup → email verification (real Zitadel + Mailpit)', () => {
4647
it('registers a real user, receives the Mailpit code, and verifies the email', () => {
47-
// 1. Collect email + names on /signup.
48+
// 1. Collect email on /signup. The name is parsed from the email now (no name
49+
// fields); the email input is behind an "Email" reveal button (mirrors login).
4850
cy.visit('/id/signup');
49-
cy.get('input[name="email"]').type(EMAIL);
50-
cy.get('input[name="firstName"]').type('E2E');
51-
cy.get('input[name="lastName"]').type('Signup');
52-
cy.get('button[type="submit"]').first().click();
51+
cy.contains('button', /^Email$/).click();
52+
cy.get('input[name="email"]:visible').type(EMAIL);
53+
cy.contains('button', /^Continue$/i).click();
5354

54-
// 2. Org allows password → /signup/password. Setting a password registers the
55-
// user in Zitadel, which sends the verification email to Mailpit.
55+
// 2. Method screen — org allows password → choose "Set a password" → /signup/password.
56+
// Setting a password registers the user in Zitadel, which sends the verification
57+
// email to Mailpit.
58+
cy.location('pathname').should('include', '/id/signup/method');
59+
cy.contains('button', /set a password/i).click();
5660
cy.location('pathname').should('include', '/id/signup/password');
5761
cy.get('input[name="password"]').type(PASSWORD, { log: false });
5862
cy.get('input[name="confirm"]').type(PASSWORD, { log: false });
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// Email-link (passwordless) signup acceptance — real Zitadel + Mailpit.
2+
// Proves the new passwordless entry/exit: /id/signup (collect email; name parsed
3+
// from it) → /id/signup/method → "Email me a sign-in link" → register() creates a
4+
// REAL Zitadel user and Zitadel sends the verification email to Mailpit → the emailed
5+
// link lands on OUR /id/signup/complete (built from signupCompleteUrlTemplate) which
6+
// verifies the email, enrolls otpEmail, self-authenticates the session, and redirects
7+
// to the skippable passkey nudge at /id/setup/passkey.
8+
//
9+
// Gated: runs only with `--env ACCEPTANCE=1`. App must run with AUTH_PROVIDER=zitadel
10+
// AND PUBLIC_ORIGIN=http://localhost:3000 against the local stack; the cypress process
11+
// needs NODE_EXTRA_CA_CERTS=./dev-ca.crt AND Mailpit port-forwarded (svc/mailpit 8025)
12+
// so the fetchEmailCode task can read the mail. Mirrors signup-email.acceptance.cy.ts.
13+
//
14+
// NOTE: signup mutates real state — each run creates a brand-new Zitadel user, so the
15+
// email MUST be unique per run.
16+
17+
const RUN = String(Cypress.env('ACCEPTANCE') ?? '') === '1';
18+
19+
// Unique recipient per run — signup creates a real user keyed on this email.
20+
const LINK_EMAIL = `e2e-emaillink-${Date.now()}@example.test`;
21+
22+
// Bounded poll for the sign-in/verification mail (absorbs SMTP jitter; no fixed sleep).
23+
const MAX_POLLS = 12;
24+
const POLL_MS = 1000;
25+
26+
type LinkEmailHit = { id: string; code: string; userId: string; link: string } | null;
27+
28+
function pollForLink(attempt: number): Cypress.Chainable<{ code: string; userId: string }> {
29+
return cy.task<LinkEmailHit>('fetchEmailCode', { to: LINK_EMAIL }).then((hit) => {
30+
if (hit && hit.code && hit.userId) {
31+
return cy.wrap({ code: hit.code, userId: hit.userId });
32+
}
33+
if (attempt >= MAX_POLLS) {
34+
throw new Error(`No signup-complete email for ${LINK_EMAIL} after ${MAX_POLLS} polls`);
35+
}
36+
return cy.wait(POLL_MS).then(() => pollForLink(attempt + 1));
37+
});
38+
}
39+
40+
(RUN ? describe : describe.skip)('email-link signup (real Zitadel + Mailpit)', () => {
41+
it('registers via email link, follows the Mailpit link, and lands authenticated', () => {
42+
// 1. Collect email on /signup (name parsed from it; field behind the "Email" reveal).
43+
cy.visit('/id/signup');
44+
cy.contains('button', /^Email$/).click();
45+
cy.get('input[name="email"]:visible').type(LINK_EMAIL);
46+
cy.contains('button', /^Continue$/i).click();
47+
48+
// 2. Method screen → choose the email sign-in link. This registers the user and
49+
// sends the verification email (link → /id/signup/complete) to Mailpit.
50+
cy.location('pathname').should('include', '/id/signup/method');
51+
cy.contains('button', /email me a sign-in link/i).click();
52+
53+
// Enumeration-safe terminal: generic "check your email" card (no live redirect).
54+
cy.contains(/check your email/i);
55+
56+
// 3. Pull the real code + userId from the Mailpit message (bounded poll).
57+
pollForLink(1).then(({ code, userId }) => {
58+
expect(code, 'code extracted from Mailpit').to.match(/^[A-Z0-9]{4,8}$/);
59+
expect(userId, 'userId extracted from the emailed link').to.match(/^\d+$/);
60+
61+
// 4. Follow the emailed link onto OUR /signup/complete. The loader verifies the
62+
// email, enrolls otpEmail, self-authenticates, and redirects to the skippable
63+
// passkey nudge. (next=passkey is carried by signupCompleteUrlTemplate.)
64+
cy.visit(
65+
`/id/signup/complete?code=${code}&userId=${userId}&next=passkey&loginName=${encodeURIComponent(LINK_EMAIL)}`
66+
);
67+
68+
// 5. Authenticated session → skippable passkey setup (or straight signed-in).
69+
cy.location('pathname', { timeout: 10000 }).should(
70+
'match',
71+
/\/(setup\/passkey|signed-in|authorize)/
72+
);
73+
});
74+
});
75+
});
76+
77+
// Module scope: prevents top-level const collisions across acceptance specs under tsc.
78+
export {};

app/components/misc/dynamic-favicon.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useSystemTheme } from '@/hooks/useSystemTheme';
2+
import { assetUrl } from '@/utils/asset-url';
23
import * as React from 'react';
34

45
// Favicon configuration for responsive theme support
@@ -152,7 +153,7 @@ export const DynamicFaviconLinks = () => {
152153
link.setAttribute('sizes', config.sizes);
153154
}
154155

155-
link.href = `/favicons/${themeFolder}/${config.filename}`;
156+
link.href = assetUrl(`/favicons/${themeFolder}/${config.filename}`);
156157

157158
// Add the link to the document head
158159
document.head.appendChild(link);
@@ -162,7 +163,7 @@ export const DynamicFaviconLinks = () => {
162163
MSTILE_CONFIGS.forEach((config) => {
163164
const meta = document.createElement('meta');
164165
meta.name = config.name;
165-
meta.content = `/favicons/${themeFolder}/${config.filename}`;
166+
meta.content = assetUrl(`/favicons/${themeFolder}/${config.filename}`);
166167
document.head.appendChild(meta);
167168
});
168169
}, [isDarkMode]);
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// @vitest-environment happy-dom
2+
import { useActionErrorToast } from '../use-action-error-toast';
3+
import { toast } from '@datum-cloud/datum-ui/toast';
4+
import { renderHook } from '@testing-library/react';
5+
import { describe, it, expect, vi, beforeEach } from 'vitest';
6+
7+
vi.mock('@datum-cloud/datum-ui/toast', () => ({
8+
toast: { error: vi.fn() },
9+
}));
10+
11+
const toastError = toast.error as ReturnType<typeof vi.fn>;
12+
13+
beforeEach(() => {
14+
toastError.mockClear();
15+
});
16+
17+
describe('useActionErrorToast', () => {
18+
it('calls toast.error once when a message appears', () => {
19+
renderHook(() => useActionErrorToast('Something went wrong.'));
20+
expect(toastError).toHaveBeenCalledOnce();
21+
expect(toastError).toHaveBeenCalledWith('Something went wrong.');
22+
});
23+
24+
it('does not call toast.error again on re-render with the same message', () => {
25+
const { rerender } = renderHook(
26+
({ msg }: { msg: string | undefined }) => useActionErrorToast(msg),
27+
{ initialProps: { msg: 'Duplicate error' } }
28+
);
29+
expect(toastError).toHaveBeenCalledOnce();
30+
rerender({ msg: 'Duplicate error' });
31+
expect(toastError).toHaveBeenCalledOnce();
32+
});
33+
34+
it('does not call toast.error when message is undefined', () => {
35+
renderHook(() => useActionErrorToast(undefined));
36+
expect(toastError).not.toHaveBeenCalled();
37+
});
38+
39+
it('calls toast.error again when message changes', () => {
40+
const { rerender } = renderHook(
41+
({ msg }: { msg: string | undefined }) => useActionErrorToast(msg),
42+
{ initialProps: { msg: 'First error' as string | undefined } }
43+
);
44+
expect(toastError).toHaveBeenCalledOnce();
45+
rerender({ msg: 'Second error' });
46+
expect(toastError).toHaveBeenCalledTimes(2);
47+
expect(toastError).toHaveBeenLastCalledWith('Second error');
48+
});
49+
50+
it('resets so a repeated message toasts again after clearing to undefined', () => {
51+
const { rerender } = renderHook(
52+
({ msg }: { msg: string | undefined }) => useActionErrorToast(msg),
53+
{ initialProps: { msg: 'Error' as string | undefined } }
54+
);
55+
expect(toastError).toHaveBeenCalledOnce();
56+
rerender({ msg: undefined });
57+
rerender({ msg: 'Error' });
58+
expect(toastError).toHaveBeenCalledTimes(2);
59+
});
60+
});
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { toast } from '@datum-cloud/datum-ui/toast';
2+
import { useEffect, useRef } from 'react';
3+
4+
/** Toast a message once when it appears/changes (e.g. derived from useActionData). */
5+
export function useActionErrorToast(message: string | undefined): void {
6+
const seen = useRef<string | undefined>(undefined);
7+
useEffect(() => {
8+
if (message && message !== seen.current) {
9+
toast.error(message);
10+
seen.current = message;
11+
}
12+
if (!message) seen.current = undefined;
13+
}, [message]);
14+
}

app/layouts/split.layout.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { BrandingTheme } from '@/modules/auth/types';
2+
import { assetUrl } from '@/utils/asset-url';
23
import { Avatar, AvatarFallback, AvatarImage } from '@datum-cloud/datum-ui/avatar';
34
import { Button } from '@datum-cloud/datum-ui/button';
45
import { Icon } from '@datum-cloud/datum-ui/icons';
@@ -72,7 +73,10 @@ export default function SplitLayout({
7273
<div className="flex w-full flex-1 items-center justify-center">
7374
<div className="relative flex w-full max-w-[400px] flex-col gap-6">
7475
<div className="absolute -top-36 -left-24 z-0 max-w-[115px]">
75-
<img src="/images/illustration-2.png" className="size-auto w-full object-cover" />
76+
<img
77+
src={assetUrl('/images/illustration-2.png')}
78+
className="size-auto w-full object-cover"
79+
/>
7680
</div>
7781

7882
<div className="stretch leading-6 text-[#67717C]">
@@ -100,19 +104,22 @@ export default function SplitLayout({
100104

101105
<div className="flex flex-col">
102106
<Avatar className="mb-2 size-10 rounded-lg">
103-
<AvatarImage alt="Zac Smith" src="/images/zac-avatar.png" />
107+
<AvatarImage alt="Zac Smith" src={assetUrl('/images/zac-avatar.png')} />
104108
<AvatarFallback>ZS</AvatarFallback>
105109
</Avatar>
106110
<span className="leading-4 text-[#67717C]">Zac Smith</span>
107111
<span className="text-xs text-[#90969C]">Co-founder and CEO</span>
108112
</div>
109113

110-
<img src="/images/zac-sign.png" className="h-[38px] w-24" />
114+
<img src={assetUrl('/images/zac-sign.png')} className="h-[38px] w-24" />
111115
</div>
112116
</div>
113117

114118
<div className="absolute right-0 bottom-0 z-0 max-w-[500px] md:max-w-[800px]">
115-
<img src="/images/illustration-1.png" className="size-auto w-full object-cover" />
119+
<img
120+
src={assetUrl('/images/illustration-1.png')}
121+
className="size-auto w-full object-cover"
122+
/>
116123
</div>
117124
</div>
118125
</div>

app/modules/auth/auth-provider.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,10 @@ export interface SessionChecks {
3636
* with `urlTemplate` (built by flows/otp-email-url-template.ts) to override the emailed
3737
* link so it lands on OUR /id/login/verify/email route instead of the provider's default
3838
* /ui/v2/login/otp/email page. The mapper sets proto OTPEmail.SendCode.url_template from it.
39+
* Pass `{ returnCode: true }` to request a return-code challenge — the OTP code is NOT
40+
* emailed; instead it is returned on the session under Session.challenges.otpEmailCode.
3941
*/
40-
otpEmail?: boolean | { urlTemplate?: string };
42+
otpEmail?: boolean | { urlTemplate?: string } | { returnCode: true };
4143
otpSms?: boolean;
4244
};
4345
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { FakeAuthProvider } from './fake-provider';
3+
import { ProviderError } from '@/modules/auth/types';
4+
5+
describe('FakeAuthProvider otpEmail primary factor', () => {
6+
it('rejects addOtpEmail until the email is verified, then authenticates a session via returnCode', async () => {
7+
const p = new FakeAuthProvider({ users: [] });
8+
const user = await p.register({ email: 'new@x.com', firstName: 'New', lastName: 'User' });
9+
10+
// Before email verification, addOtpEmail must throw FAILED_PRECONDITION
11+
await expect(p.addOtpEmail(user.id)).rejects.toThrow(ProviderError);
12+
await expect(p.addOtpEmail(user.id)).rejects.toMatchObject({ code: 'FAILED_PRECONDITION' });
13+
14+
// After verifyEmail (any code accepted by the fake), addOtpEmail must succeed
15+
await p.verifyEmail(user.id, `email-${user.id}`);
16+
await p.addOtpEmail(user.id);
17+
18+
// Create a session for this user
19+
const session = await p.createSession({}, { userId: user.id });
20+
21+
// Request a returnCode challenge — must come back with otpEmailCode populated
22+
const challenged = await p.updateSession(session.id, session.token, {
23+
challenges: { otpEmail: { returnCode: true } },
24+
});
25+
expect(challenged.challenges?.otpEmailCode).toBeTruthy();
26+
27+
// Submit the returned code — must set factors.otpEmail on the session
28+
const verified = await p.updateSession(session.id, session.token, {
29+
otpEmail: challenged.challenges!.otpEmailCode!,
30+
});
31+
expect(verified.factors.otpEmail).toBeTruthy();
32+
expect(verified.factors.otpEmail?.verifiedAt).toBeTruthy();
33+
});
34+
35+
it('throws INVALID_CREDENTIALS when a wrong otpEmail code is submitted', async () => {
36+
const p = new FakeAuthProvider({ users: [] });
37+
const user = await p.register({ email: 'x@x.com', firstName: 'X', lastName: 'Y' });
38+
await p.verifyEmail(user.id, `email-${user.id}`);
39+
await p.addOtpEmail(user.id);
40+
41+
const session = await p.createSession({}, { userId: user.id });
42+
await p.updateSession(session.id, session.token, {
43+
challenges: { otpEmail: { returnCode: true } },
44+
});
45+
46+
// Wrong code must throw INVALID_CREDENTIALS
47+
await expect(
48+
p.updateSession(session.id, session.token, { otpEmail: 'wrong-code' })
49+
).rejects.toMatchObject({ code: 'INVALID_CREDENTIALS' });
50+
});
51+
52+
it('seeded users with email already verified can addOtpEmail without calling verifyEmail', async () => {
53+
const p = new FakeAuthProvider({
54+
users: [{ id: 'u1', loginName: 'seeded@x.com', displayName: 'Seeded' }],
55+
});
56+
// Seeded users have emailCodes set but emailVerified is not explicitly set to true in the
57+
// constructor — they go through verifyEmail in practice. Test that after verifyEmail it works.
58+
await p.verifyEmail('u1', 'email-u1');
59+
await expect(p.addOtpEmail('u1')).resolves.toBeUndefined();
60+
});
61+
});

app/modules/auth/providers/fake/fake-provider.mfa.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,10 @@ describe('FakeAuthProvider — MFA (P5)', () => {
3030
expect(await p.listAuthMethods('u1')).toContain('u2f');
3131
});
3232

33-
it('addOtpEmail enrolls otp_email', async () => {
33+
it('addOtpEmail enrolls otp_email (requires email verified first)', async () => {
3434
const p = new FakeAuthProvider({ users: [{ id: 'u1', loginName: 'a@acme.test' }] });
35+
// Seeded users have their email code set as `email-<id>` by the constructor
36+
await p.verifyEmail('u1', 'email-u1');
3537
await p.addOtpEmail('u1');
3638
expect(await p.listAuthMethods('u1')).toContain('otp_email');
3739
});
@@ -47,6 +49,8 @@ describe('FakeAuthProvider — MFA (P5)', () => {
4749
users: [{ id: 'u1', loginName: 'a@acme.test' }],
4850
authMethods: { u1: ['password'] },
4951
});
52+
// Seeded users have their email code set as `email-<id>` by the constructor
53+
await p.verifyEmail('u1', 'email-u1');
5054
await p.addOtpEmail('u1');
5155
const methods = await p.listAuthMethods('u1');
5256
expect(methods).toContain('password');

0 commit comments

Comments
 (0)