Skip to content

Commit ba10419

Browse files
committed
Add Next.js auth proof to setup scaffolds
1 parent 637914f commit ba10419

44 files changed

Lines changed: 7764 additions & 33 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.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@zitadel/cli": minor
3+
"@zitadel/components": patch
4+
---
5+
6+
Make the generated Next.js auth app easier for agents and developers to prove end-to-end registration, logout, and login in a visible browser.

apps/cli-journey-e2e/src/user-journey.spec.ts

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -212,16 +212,30 @@ async function clickAction(
212212
name: RegExp,
213213
actionNames: readonly string[] = [],
214214
): Promise<void> {
215-
let locator = page.getByRole("button", { name }).or(page.getByRole("link", { name }));
215+
const candidates: Locator[] = [];
216216
for (const actionName of actionNames) {
217-
locator = page
218-
.getByTestId(`zitadel-action-${actionName}-button`)
219-
.or(page.getByTestId(`zitadel-action-${actionName}`))
220-
.or(page.getByTestId(`zitadel-action-${actionName}-link`))
221-
.or(actionLocator(page, actionName))
222-
.or(locator);
217+
candidates.push(
218+
page.getByTestId(`zitadel-action-${actionName}-button`),
219+
page.getByTestId(`zitadel-action-${actionName}`),
220+
page.getByTestId(`zitadel-action-${actionName}-link`),
221+
actionLocator(page, actionName),
222+
);
223223
}
224-
await locator.first().click();
224+
candidates.push(page.getByRole("button", { name }), page.getByRole("link", { name }));
225+
226+
for (const candidate of candidates) {
227+
const target = candidate.first();
228+
if (await target.isVisible({ timeout: 1000 }).catch(() => false)) {
229+
await target.click();
230+
return;
231+
}
232+
}
233+
234+
const fallback = candidates[candidates.length - 1];
235+
if (!fallback) {
236+
throw new Error("No action candidates configured");
237+
}
238+
await fallback.first().click();
225239
}
226240

227241
function actionLocator(page: Page, actionName: string) {

apps/cli/README.md

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,15 @@ back to `ghcr.io/zitadel/nextgen:latest`. Override with `--image` or
3737
`ZITADEL_LOCAL_IMAGE` for advanced debugging.
3838
`setup --server local` creates a project on that local server, asks which
3939
framework to scaffold when the directory is fresh, writes the Next.js app into
40-
the current directory, scaffolds `app/login`, `app/register`, and `proxy.ts`
41-
for Next 16+ or `middleware.ts` for older Next versions, writes `.env.local`
42-
and `.zitadel/`, and installs dependencies with the detected package manager.
43-
Pass `--skip-install` to install them yourself. The project's default user
44-
schema and login flow are provisioned server-side at creation time, so the CLI
45-
does not scaffold or upload them. Open `http://localhost:3000/login` to see the
46-
login page.
40+
the current directory, scaffolds `app/login`, `app/register`, `app/profile`, and
41+
`proxy.ts` for Next 16+ or `middleware.ts` for older Next versions. Fresh
42+
scaffolds also replace the starter `app/page.tsx` with links to sign in, create
43+
an account, and profile. Setup writes `.env.local` and `.zitadel/`, and installs
44+
dependencies with the detected package manager. Pass `--skip-install` to install
45+
them yourself. The project's default user schema and login flow are provisioned
46+
server-side at creation time, so the CLI does not scaffold or upload them. Open
47+
the dev server URL printed by your framework, register a user, log out, log back
48+
in, and end on the signed-in profile page.
4749

4850
For a reproducible tester report, use the exact alpha train from the GitHub
4951
Release:

apps/cli/SKILLS.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,14 @@ npx @zitadel/cli@0.1.0-alpha.N start --non-interactive --json
104104
npx @zitadel/cli@0.1.0-alpha.N setup --framework next --server local --non-interactive --json
105105
```
106106

107+
After `setup`, follow `data.next_commands` to start the app. Prove the generated
108+
auth flow in a visible browser by registering a unique user, logging out, logging
109+
back in with the same email/password, and ending on the signed-in profile page.
110+
Do not treat a rendered login or registration form as completion. If browser DOM
111+
snapshots are sparse, target the component hooks exposed by the generated UI:
112+
`zitadel-field-email-input`, `zitadel-field-password-input`, and
113+
`zitadel-action-submit-button`.
114+
107115
Repo config is authoritative: edit `zitadel.json` or files under `.zitadel/`,
108116
then re-run `plan` and `apply`. Managed files carry a marker comment; `eject`
109117
removes only files that still carry it, preserving anything the user replaced.

apps/cli/src/commands/setup/index.ts

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,11 @@ export default class Setup extends BaseCommand {
7070

7171
async run(): Promise<JsonEnvelope> {
7272
const { flags } = await this.parse(Setup);
73-
await this.toMeta(flags);
73+
try {
74+
await this.toMeta(flags);
75+
} catch (error) {
76+
throw localSetupHint(error, flags.framework, this.config.version);
77+
}
7478
const { cwd, nonInteractive, dryRun, force } = this.meta;
7579

7680
if (await hasZitadelConfig(cwd)) {
@@ -170,6 +174,7 @@ export default class Setup extends BaseCommand {
170174
answers.server,
171175
this.meta.cliVersion,
172176
issuer,
177+
framework.id,
173178
);
174179
consola.success(`Created project ${project.id}`);
175180

@@ -180,6 +185,7 @@ export default class Setup extends BaseCommand {
180185
issuer,
181186
server: answers.server,
182187
cliVersion: this.meta.cliVersion,
188+
scaffoldedFramework,
183189
};
184190
consola.start(`Patching project files${dryRun ? " (dry run)" : ""}`);
185191
const result = await orca.patcherFor(framework.id).patch(ctx, { cwd, dryRun, force });
@@ -290,6 +296,7 @@ async function createProjectWithLocalHint(
290296
server: string,
291297
cliVersion: string,
292298
issuer: string,
299+
framework: string,
293300
): Promise<CreateProject201> {
294301
try {
295302
// Register the app's own origin so the backend's origin check allows the
@@ -300,11 +307,11 @@ async function createProjectWithLocalHint(
300307
throw new ZitadelError(normalized.code, normalized.message, {
301308
hint:
302309
`${normalized.hint ? `${normalized.hint} ` : ""}` +
303-
"If you meant to use a local Zitadel server, run npx @zitadel/cli@alpha start " +
304-
"and retry setup with --server local.",
310+
"If you meant to use a local Zitadel server, start it first " +
311+
`and retry setup with --framework ${framework} --server local.`,
305312
nextCommands: [
306313
publicCliCommand("start", cliVersion),
307-
publicCliCommand("setup --server local", cliVersion),
314+
publicCliCommand(`setup --framework ${framework} --server local`, cliVersion),
308315
],
309316
details: {
310317
server,
@@ -314,6 +321,29 @@ async function createProjectWithLocalHint(
314321
}
315322
}
316323

324+
function localSetupHint(error: unknown, framework: string | undefined, cliVersion: string): unknown {
325+
const normalized = toZitadelError(error);
326+
if (normalized.code !== "E_LOCAL_SERVER_NOT_RUNNING") {
327+
return error;
328+
}
329+
330+
const setupCommand = framework
331+
? `setup --framework ${framework} --server local`
332+
: "setup --server local";
333+
334+
return new ZitadelError(normalized.code, normalized.message, {
335+
hint:
336+
`${normalized.hint ? `${normalized.hint} ` : ""}` +
337+
"Start local Zitadel first, then rerun setup. " +
338+
"After setup succeeds, follow its next_commands to start the app and verify registration, logout, and login in the browser.",
339+
nextCommands: [
340+
publicCliCommand("start", cliVersion),
341+
publicCliCommand(setupCommand, cliVersion),
342+
],
343+
details: normalized.details,
344+
});
345+
}
346+
317347
/** Renders an absolute path relative to `cwd` for human-readable output. */
318348
function relativeDisplay(cwd: string, path: string): string {
319349
return path.startsWith(cwd) ? path.slice(cwd.length + 1) : path;
@@ -378,6 +408,7 @@ const SENTENCE_BY_PATH: Record<string, { subject: string }> = {
378408
".env.example": { subject: "the .env example template" },
379409
".env.local": { subject: "the local development environment variables" },
380410
".zitadel/state.json": { subject: "the empty sync state file" },
411+
"app/page.tsx": { subject: "the auth home page" },
381412
"app/login/page.tsx": { subject: "the login page" },
382413
"app/register/page.tsx": { subject: "the registration page" },
383414
"app/profile/page.tsx": { subject: "the profile page" },
@@ -414,6 +445,7 @@ function buildSummary(opts: {
414445
});
415446
}
416447
for (const [label, suffix] of [
448+
["Home page", "app/page.tsx"],
417449
["Login page", "app/login/page.tsx"],
418450
["Register page", "app/register/page.tsx"],
419451
["Profile page", "app/profile/page.tsx"],

apps/cli/src/commands/setup/install.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,12 +105,14 @@ function outcome(input: {
105105
includeInstallCommand: boolean;
106106
}): SetupInstallOutcome {
107107
const startAction = `Start your project: ${input.devCommand} (then open ${input.issuer}/login)`;
108+
const verifyAction =
109+
"Verify auth in the browser: register a user, log out, log in again with the same user, and confirm /profile shows Signed in.";
108110
return {
109111
install: input.install,
110112
devCommand: input.devCommand,
111113
nextActions: input.includeInstallCommand
112-
? [`Install dependencies: ${input.install.command}`, startAction]
113-
: [startAction],
114+
? [`Install dependencies: ${input.install.command}`, startAction, verifyAction]
115+
: [startAction, verifyAction],
114116
nextCommands: input.includeInstallCommand
115117
? [input.install.command, input.devCommand]
116118
: [input.devCommand],

apps/cli/src/lib/orca/patchers/rule/next/index.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,11 @@ function nextCodeFilePaths(
7575
renderer: RendererSpec,
7676
): ReadonlyArray<string> {
7777
const appDir = framework.appDir;
78-
const paths = [join(appDir, "login/page.tsx"), join(appDir, "register/page.tsx")];
78+
const paths = [
79+
join(appDir, "page.tsx"),
80+
join(appDir, "login/page.tsx"),
81+
join(appDir, "register/page.tsx"),
82+
];
7983
if (renderer.templates.profilePage) {
8084
paths.push(join(appDir, "profile/page.tsx"));
8185
}
@@ -97,6 +101,13 @@ function nextCodeOps(ctx: PatchContext, renderer: RendererSpec): FileOp[] {
97101
const dts = renderer.templates.customElementsDts?.();
98102
const boundary = requestBoundaryFile(ctx.framework);
99103
return [
104+
ctx.scaffoldedFramework
105+
? {
106+
kind: "edit",
107+
path: join(appDir, "page.tsx"),
108+
edit: () => homePageTemplate(),
109+
}
110+
: undefined,
100111
{
101112
kind: "write",
102113
path: join(appDir, "login/page.tsx"),
@@ -136,6 +147,34 @@ function nextCodeOps(ctx: PatchContext, renderer: RendererSpec): FileOp[] {
136147
].filter((op): op is FileOp => op !== undefined);
137148
}
138149

150+
function homePageTemplate(): string {
151+
return `${MANAGED_MARKER}
152+
import Link from "next/link";
153+
154+
export default function Home() {
155+
return (
156+
<main style={{ minHeight: "100vh", padding: "48px", display: "flex", alignItems: "center", justifyContent: "center" }}>
157+
<section style={{ width: "100%", maxWidth: "560px" }}>
158+
<p style={{ margin: "0 0 12px", color: "#4b5563", fontSize: "14px" }}>Zitadel auth</p>
159+
<h1 style={{ margin: "0 0 24px", fontSize: "32px", lineHeight: 1.15 }}>Sign in, create an account, or open your profile.</h1>
160+
<div style={{ display: "flex", flexWrap: "wrap", gap: "12px" }}>
161+
<Link href="/login" style={{ padding: "10px 16px", borderRadius: "8px", background: "#111827", color: "#ffffff", textDecoration: "none", fontWeight: 600 }}>
162+
Sign in
163+
</Link>
164+
<Link href="/register" style={{ padding: "10px 16px", borderRadius: "8px", border: "1px solid #d1d5db", color: "#111827", textDecoration: "none", fontWeight: 600 }}>
165+
Create account
166+
</Link>
167+
<Link href="/profile" style={{ padding: "10px 16px", borderRadius: "8px", border: "1px solid #d1d5db", color: "#111827", textDecoration: "none", fontWeight: 600 }}>
168+
Profile
169+
</Link>
170+
</div>
171+
</section>
172+
</main>
173+
);
174+
}
175+
`;
176+
}
177+
139178
function requestBoundaryFile(framework: PatchContext["framework"]): {
140179
filename: "middleware.ts" | "proxy.ts";
141180
functionName: "middleware" | "proxy";

apps/cli/src/lib/orca/patchers/rule/next/renderers/react/index.ts

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,15 @@ export const reactRenderer: RendererSpec = {
4040
"use client";
4141
4242
import dynamic from "next/dynamic";
43+
import Link from "next/link";
4344
4445
const ${elementName} = dynamic(
4546
async () => {
4647
const { configureZitadel } = await import("@zitadel/sdk-next/client");
4748
// Build the SDK project handle and pass it to the component via the
4849
// \`project\` prop. The component reads config from this prop directly, so
4950
// it works regardless of how the SDK packages are bundled. The backend URL
50-
// stays server-side requests go through the proxy path "/__nextgen",
51+
// stays server-side: requests go through the proxy path "/__nextgen",
5152
// which the scaffolded request boundary forwards to the Zitadel server.
5253
const project = configureZitadel({
5354
projectId: process.env.NEXT_PUBLIC_ZITADEL_PROJECT_ID ?? "",
@@ -68,7 +69,12 @@ const ${elementName} = dynamic(
6869
6970
export default function ${componentName}() {
7071
return (
71-
<main style={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center" }}>
72+
<main style={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", position: "relative", padding: "48px 24px" }}>
73+
<nav aria-label="Authentication" style={{ position: "absolute", top: "24px", right: "24px", display: "flex", gap: "12px" }}>
74+
<Link href="${mode === "login" ? "/register" : "/login"}" style={{ color: "#111827", fontWeight: 700, textDecoration: "none" }}>
75+
${mode === "login" ? "Create account" : "Sign in"}
76+
</Link>
77+
</nav>
7278
<${elementName} />
7379
</main>
7480
);
@@ -82,6 +88,13 @@ export default function ${componentName}() {
8288
"use client";
8389
8490
import dynamic from "next/dynamic";
91+
import { useEffect, useState } from "react";
92+
93+
type SessionProof = {
94+
session_id?: string;
95+
state?: string;
96+
user_id?: string;
97+
};
8598
8699
const ZitadelLogout = dynamic(
87100
async () => {
@@ -103,13 +116,58 @@ const ZitadelLogout = dynamic(
103116
);
104117
105118
export default function ProfilePage() {
119+
const [session, setSession] = useState<SessionProof | null>(null);
120+
const [sessionError, setSessionError] = useState("");
121+
122+
useEffect(() => {
123+
let cancelled = false;
124+
125+
fetch("/__nextgen/sessions/me", { cache: "no-store" })
126+
.then(async (response) => {
127+
if (!response.ok) {
128+
throw new Error("Session check failed: " + String(response.status));
129+
}
130+
return response.json() as Promise<SessionProof>;
131+
})
132+
.then((nextSession) => {
133+
if (!cancelled) {
134+
setSession(nextSession);
135+
}
136+
})
137+
.catch((error: unknown) => {
138+
if (!cancelled) {
139+
setSessionError(error instanceof Error ? error.message : "Session check failed");
140+
}
141+
});
142+
143+
return () => {
144+
cancelled = true;
145+
};
146+
}, []);
147+
106148
return (
107-
<main style={{ padding: "48px", maxWidth: "600px", margin: "0 auto" }}>
149+
<main style={{ padding: "48px", maxWidth: "680px", margin: "0 auto" }}>
108150
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "24px" }}>
109151
<h1 style={{ fontSize: "24px", fontWeight: 700, margin: 0 }}>Signed in</h1>
110152
<ZitadelLogout />
111153
</div>
112-
<p style={{ color: "#6b7280" }}>You are signed in. Use the button above to log out.</p>
154+
<p style={{ color: "#166534", fontWeight: 600 }}>Signed in profile loaded.</p>
155+
{session ? (
156+
<dl style={{ display: "grid", gap: "12px", marginTop: "24px" }}>
157+
<div>
158+
<dt style={{ color: "#6b7280", fontSize: "14px" }}>Session state</dt>
159+
<dd style={{ margin: 0, fontWeight: 600 }}>{session.state ?? "active"}</dd>
160+
</div>
161+
<div>
162+
<dt style={{ color: "#6b7280", fontSize: "14px" }}>User id</dt>
163+
<dd style={{ margin: 0, fontFamily: "monospace" }}>{session.user_id ?? "available"}</dd>
164+
</div>
165+
</dl>
166+
) : (
167+
<p style={{ color: sessionError ? "#b91c1c" : "#6b7280" }}>
168+
{sessionError || "Checking session..."}
169+
</p>
170+
)}
113171
</main>
114172
);
115173
}

apps/cli/src/lib/orca/patchers/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export type PatchContext = PatchView &
2828
issuer: string;
2929
server: string;
3030
cliVersion: string;
31+
scaffoldedFramework?: boolean;
3132
}>;
3233

3334
/** Where and how a patch is applied. Family-neutral (no file-op coupling). */

0 commit comments

Comments
 (0)