diff --git a/apps/dashboard/.env.example b/apps/dashboard/.env.example index 7e94702..3b2e837 100644 --- a/apps/dashboard/.env.example +++ b/apps/dashboard/.env.example @@ -13,5 +13,8 @@ PUBLIC_API_URL=http://localhost:3001 # Zitadel account management URL (for password/MFA changes) PUBLIC_ZITADEL_ACCOUNT_URL=http://localhost:8080/ui/console +# Zitadel service user PAT (for Session API v2 calls from server-side) +ZITADEL_SERVICE_USER_TOKEN=your-service-user-token + # App URL (for OIDC redirects) ORIGIN=http://localhost:5173 diff --git a/apps/dashboard/src/lib/components/AuthCard.svelte b/apps/dashboard/src/lib/components/AuthCard.svelte new file mode 100644 index 0000000..4258178 --- /dev/null +++ b/apps/dashboard/src/lib/components/AuthCard.svelte @@ -0,0 +1,47 @@ + + +
+ +
+
+
+
+ +
+ +
+ +
+ PVM +
+
+

{title}

+ {#if subtitle} +

{subtitle}

+ {/if} +
+ + +
+ {@render children()} +
+ + + {#if footer} +
+ {@render footer()} +
+ {/if} +
+
diff --git a/apps/dashboard/src/lib/components/FormError.svelte b/apps/dashboard/src/lib/components/FormError.svelte new file mode 100644 index 0000000..5e2cd03 --- /dev/null +++ b/apps/dashboard/src/lib/components/FormError.svelte @@ -0,0 +1,16 @@ + + +{#if message} +
+ + + +

{message}

+
+{/if} diff --git a/apps/dashboard/src/lib/components/FormField.svelte b/apps/dashboard/src/lib/components/FormField.svelte new file mode 100644 index 0000000..2c502d8 --- /dev/null +++ b/apps/dashboard/src/lib/components/FormField.svelte @@ -0,0 +1,42 @@ + + +
+ + + {#if error} +

{error}

+ {/if} +
diff --git a/apps/dashboard/src/lib/components/LoadingButton.svelte b/apps/dashboard/src/lib/components/LoadingButton.svelte new file mode 100644 index 0000000..c1e943d --- /dev/null +++ b/apps/dashboard/src/lib/components/LoadingButton.svelte @@ -0,0 +1,28 @@ + + + diff --git a/apps/dashboard/src/lib/server/zitadel.ts b/apps/dashboard/src/lib/server/zitadel.ts new file mode 100644 index 0000000..f7fa9e8 --- /dev/null +++ b/apps/dashboard/src/lib/server/zitadel.ts @@ -0,0 +1,324 @@ +import { env } from '$env/dynamic/private'; + +// --------------------------------------------------------------------------- +// Error type +// --------------------------------------------------------------------------- + +export class ZitadelError extends Error { + status: number; + + constructor(status: number, message: string) { + super(message); + this.name = 'ZitadelError'; + this.status = status; + } +} + +// --------------------------------------------------------------------------- +// Response types +// --------------------------------------------------------------------------- + +export interface ZitadelSessionFactors { + user?: { + verifiedAt?: string; + id?: string; + loginName?: string; + displayName?: string; + }; + password?: { verifiedAt: string }; + totp?: { verifiedAt: string }; + webAuthN?: { verifiedAt: string; userVerified: boolean }; +} + +export interface ZitadelSession { + sessionId: string; + sessionToken: string; + factors?: ZitadelSessionFactors; +} + +export interface ZitadelUser { + userId: string; + username?: string; + loginNames?: string[]; + human?: { + profile?: { + givenName?: string; + familyName?: string; + displayName?: string; + }; + email?: { + email?: string; + isVerified?: boolean; + }; + }; +} + +export interface FinalizeResult { + callbackUrl: string; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +function baseUrl(): string { + const issuer = env.AUTH_ZITADEL_ISSUER; + if (!issuer) throw new ZitadelError(500, 'AUTH_ZITADEL_ISSUER is not configured'); + return issuer; +} + +function pat(): string { + const token = env.ZITADEL_SERVICE_USER_TOKEN; + if (!token) throw new ZitadelError(500, 'ZITADEL_SERVICE_USER_TOKEN is not configured'); + return token; +} + +async function zitadelFetch(path: string, options?: RequestInit): Promise { + const res = await fetch(`${baseUrl()}${path}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + Authorization: `Bearer ${pat()}`, + ...options?.headers + } + }); + + if (!res.ok) { + let message = res.statusText; + try { + const body = await res.json(); + if (body.message) message = body.message; + } catch { + // ignore parse errors — keep statusText + } + throw new ZitadelError(res.status, message); + } + + // Some endpoints return 200 with empty body + const text = await res.text(); + if (!text) return {} as T; + return JSON.parse(text) as T; +} + +// --------------------------------------------------------------------------- +// Public API functions +// --------------------------------------------------------------------------- + +/** + * Create a new Zitadel session for the given login name. + * Only checks that the user exists — no password verified yet. + */ +export async function createSession(loginName: string): Promise { + const data = await zitadelFetch<{ + sessionId: string; + sessionToken: string; + details?: unknown; + factors?: ZitadelSessionFactors; + }>('/v2/sessions', { + method: 'POST', + body: JSON.stringify({ + checks: { user: { loginName } } + }) + }); + + return { + sessionId: data.sessionId, + sessionToken: data.sessionToken, + factors: data.factors + }; +} + +/** + * Verify a password against an existing session. + * Returns the updated session with a new sessionToken. + */ +export async function verifyPassword( + sessionId: string, + sessionToken: string, + password: string +): Promise { + const data = await zitadelFetch<{ + sessionId?: string; + sessionToken: string; + factors?: ZitadelSessionFactors; + }>(`/v2/sessions/${sessionId}`, { + method: 'PATCH', + body: JSON.stringify({ + sessionToken, + checks: { password: { password } } + }) + }); + + return { + sessionId, + sessionToken: data.sessionToken, + factors: data.factors + }; +} + +/** + * Verify a TOTP code against an existing session. + * Returns the updated session with a new sessionToken. + */ +export async function verifyTotp( + sessionId: string, + sessionToken: string, + code: string +): Promise { + const data = await zitadelFetch<{ + sessionId?: string; + sessionToken: string; + factors?: ZitadelSessionFactors; + }>(`/v2/sessions/${sessionId}`, { + method: 'PATCH', + body: JSON.stringify({ + sessionToken, + checks: { totp: { code } } + }) + }); + + return { + sessionId, + sessionToken: data.sessionToken, + factors: data.factors + }; +} + +/** + * Retrieve an existing session's details. + * Useful for checking which factors have been verified. + */ +export async function getSession( + sessionId: string, + sessionToken: string +): Promise<{ session: ZitadelSession & { factors: ZitadelSessionFactors } }> { + const data = await zitadelFetch<{ + session: { + id: string; + factors?: ZitadelSessionFactors; + }; + }>(`/v2/sessions/${sessionId}?sessionToken=${encodeURIComponent(sessionToken)}`, { + method: 'GET' + }); + + return { + session: { + sessionId, + sessionToken, + factors: data.session.factors ?? {} + } + }; +} + +/** + * Finalize an OIDC auth request by binding it to a verified session. + * Strips the "V2_" prefix that Zitadel Login V2 adds to the authRequestId. + * Returns the callbackUrl for completing the OIDC flow with Auth.js. + */ +export async function finalizeAuthRequest( + authRequestId: string, + sessionId: string, + sessionToken: string +): Promise { + // Strip V2_ prefix if present + const cleanId = authRequestId.startsWith('V2_') ? authRequestId.slice(3) : authRequestId; + + const data = await zitadelFetch<{ callbackUrl: string }>( + `/v2/oidc/auth_requests/${cleanId}`, + { + method: 'POST', + body: JSON.stringify({ + session: { sessionId, sessionToken } + }) + } + ); + + return { callbackUrl: data.callbackUrl }; +} + +/** + * Create a new human user in Zitadel. + */ +export async function createUser(profile: { + firstName: string; + lastName: string; + email: string; + password: string; +}): Promise<{ userId: string }> { + const data = await zitadelFetch<{ userId: string }>('/v2/users/human', { + method: 'POST', + body: JSON.stringify({ + profile: { + givenName: profile.firstName, + familyName: profile.lastName + }, + email: { + email: profile.email, + isVerified: false + }, + password: { + password: profile.password, + changeRequired: false + } + }) + }); + + return { userId: data.userId }; +} + +/** + * Search for a user by their exact login name. + * Returns the user or null if not found. + */ +export async function findUserByLoginName(loginName: string): Promise { + const data = await zitadelFetch<{ result?: ZitadelUser[] }>('/v2/users', { + method: 'POST', + body: JSON.stringify({ + queries: [ + { + loginNameQuery: { + loginName, + method: 'TEXT_QUERY_METHOD_EQUALS' + } + } + ] + }) + }); + + if (!data.result || data.result.length === 0) return null; + return data.result[0]; +} + +/** + * Request a password reset link to be sent via email. + */ +export async function requestPasswordReset(userId: string): Promise { + const origin = env.ORIGIN || 'http://localhost:5173'; + + await zitadelFetch(`/v2/users/${userId}/password_reset`, { + method: 'POST', + body: JSON.stringify({ + sendLink: { + urlTemplate: `${origin}/reset-password/verify?userId={{.UserID}}&code={{.Code}}` + } + }) + }); +} + +/** + * Set a new password using a verification code from a password reset email. + */ +export async function setPassword( + userId: string, + code: string, + newPassword: string +): Promise { + await zitadelFetch(`/v2/users/${userId}/password`, { + method: 'POST', + body: JSON.stringify({ + newPassword: { password: newPassword }, + verificationCode: code + }) + }); +} diff --git a/apps/dashboard/src/routes/login/+page.server.ts b/apps/dashboard/src/routes/login/+page.server.ts index 3ce867b..74fdf56 100644 --- a/apps/dashboard/src/routes/login/+page.server.ts +++ b/apps/dashboard/src/routes/login/+page.server.ts @@ -1,9 +1,113 @@ -import { redirect } from '@sveltejs/kit'; -import type { PageServerLoad } from './$types'; +import { redirect, fail } from '@sveltejs/kit'; +import type { PageServerLoad, Actions } from './$types'; +import { + createSession, + verifyPassword, + verifyTotp, + finalizeAuthRequest, + ZitadelError +} from '$lib/server/zitadel'; export const load: PageServerLoad = async (event) => { const session = await event.locals.auth(); - if (session) { - throw redirect(303, '/dashboard'); + if (session) throw redirect(303, '/dashboard'); + + const authRequest = event.url.searchParams.get('authRequest'); + if (!authRequest) { + // No authRequest — redirect through Auth.js to get one + // This triggers: Auth.js -> Zitadel authorize -> redirect back to /login?authRequest=V2_xxx + throw redirect(303, '/auth/signin/zitadel'); + } + + return { authRequest }; +}; + +export const actions: Actions = { + login: async ({ request }) => { + const data = await request.formData(); + const loginName = data.get('loginName') as string; + const password = data.get('password') as string; + const authRequest = data.get('authRequest') as string; + + if (!loginName || !password || !authRequest) { + return fail(400, { error: 'Email and password are required', loginName, authRequest }); + } + + try { + // Step 1: Create session with user lookup + const sessionResult = await createSession(loginName); + + // Step 2: Verify password + const passwordResult = await verifyPassword( + sessionResult.sessionId, + sessionResult.sessionToken, + password + ); + + // Step 3: Try to finalize — if MFA is required, Zitadel will reject + try { + const { callbackUrl } = await finalizeAuthRequest( + authRequest, + sessionResult.sessionId, + passwordResult.sessionToken + ); + throw redirect(303, callbackUrl); + } catch (e) { + if (e instanceof ZitadelError) { + // Finalization failed — MFA likely required, show TOTP step + return { + step: 'totp' as const, + sessionId: sessionResult.sessionId, + sessionToken: passwordResult.sessionToken, + authRequest + }; + } + throw e; // Re-throw redirects and unexpected errors + } + } catch (e) { + if (e instanceof ZitadelError) { + return fail(400, { error: 'Invalid email or password', loginName, authRequest }); + } + throw e; // Re-throw redirects and unexpected errors + } + }, + + verifyTotp: async ({ request }) => { + const data = await request.formData(); + const code = data.get('code') as string; + const sessionId = data.get('sessionId') as string; + const sessionToken = data.get('sessionToken') as string; + const authRequest = data.get('authRequest') as string; + + if (!code || !sessionId || !sessionToken || !authRequest) { + return fail(400, { + error: 'Verification code is required', + step: 'totp' as const, + sessionId, + sessionToken, + authRequest + }); + } + + try { + const totpResult = await verifyTotp(sessionId, sessionToken, code); + const { callbackUrl } = await finalizeAuthRequest( + authRequest, + sessionId, + totpResult.sessionToken + ); + throw redirect(303, callbackUrl); + } catch (e) { + if (e instanceof ZitadelError) { + return fail(400, { + error: 'Invalid verification code', + step: 'totp' as const, + sessionId, + sessionToken, + authRequest + }); + } + throw e; + } } }; diff --git a/apps/dashboard/src/routes/login/+page.svelte b/apps/dashboard/src/routes/login/+page.svelte index 65d045d..215bb7f 100644 --- a/apps/dashboard/src/routes/login/+page.svelte +++ b/apps/dashboard/src/routes/login/+page.svelte @@ -1,83 +1,125 @@ -
-
-
- -
- PVM -
-
-

Sign in to PVM

-

Poker Venue Manager

+ + {#if passwordResetSuccess && !isTotp} +
+ + + +

Your password has been reset. Please sign in with your new password.

+ {/if} -
- - + -
-
-
-
-
- Social providers via Zitadel -
+ {#if isTotp} + +
{ + loading = true; + return async ({ update }) => { + loading = false; + await update(); + }; + }} + class="space-y-5 mt-4" + > + + + + + + + + Verify + + + {:else} + +
{ + loading = true; + return async ({ update }) => { + loading = false; + await update(); + }; + }} + class="space-y-5 {form?.error || passwordResetSuccess ? 'mt-4' : ''}" + > + + + + + + + -
- - - - - -
- -

- Social providers are configured through Zitadel. - All sign-in methods redirect to your identity provider. -

-
+ + Sign in + +

- ← Back to home + Don't have an account? + + Create account +

-
-
+ {/if} + + {#snippet footer()} + ← Back to home + {/snippet} + diff --git a/apps/dashboard/src/routes/reset-password/+page.server.ts b/apps/dashboard/src/routes/reset-password/+page.server.ts new file mode 100644 index 0000000..b1430dd --- /dev/null +++ b/apps/dashboard/src/routes/reset-password/+page.server.ts @@ -0,0 +1,33 @@ +import { fail } from '@sveltejs/kit'; +import type { PageServerLoad, Actions } from './$types'; +import { findUserByLoginName, requestPasswordReset } from '$lib/server/zitadel'; + +export const load: PageServerLoad = async (event) => { + const authRequest = event.url.searchParams.get('authRequest'); + return { authRequest }; +}; + +export const actions: Actions = { + requestReset: async ({ request }) => { + const data = await request.formData(); + const email = data.get('email') as string; + const authRequest = data.get('authRequest') as string; + + if (!email) { + return fail(400, { error: 'Email is required', authRequest }); + } + + try { + // Find user by email — prevent email enumeration by always showing success + const user = await findUserByLoginName(email); + if (user) { + await requestPasswordReset(user.userId); + } + } catch { + // Silently ignore errors to prevent email enumeration + } + + // Always show success regardless of whether user exists + return { success: true, authRequest }; + } +}; diff --git a/apps/dashboard/src/routes/reset-password/+page.svelte b/apps/dashboard/src/routes/reset-password/+page.svelte new file mode 100644 index 0000000..ea2e76e --- /dev/null +++ b/apps/dashboard/src/routes/reset-password/+page.svelte @@ -0,0 +1,84 @@ + + + + {#if success} +
+ + + +
+

Check your email

+

If an account exists with that email, we've sent a password reset link.

+
+
+ + + {:else} + + +
{ + loading = true; + return async ({ update }) => { + loading = false; + await update(); + }; + }} + class="space-y-5 {form?.error ? 'mt-4' : ''}" + > + + + + + + Send reset link + + + +

+ Remember your password? + + Sign in + +

+ {/if} + + {#snippet footer()} + ← Back to home + {/snippet} +
diff --git a/apps/dashboard/src/routes/reset-password/verify/+page.server.ts b/apps/dashboard/src/routes/reset-password/verify/+page.server.ts new file mode 100644 index 0000000..33ca8ef --- /dev/null +++ b/apps/dashboard/src/routes/reset-password/verify/+page.server.ts @@ -0,0 +1,48 @@ +import { redirect, fail } from '@sveltejs/kit'; +import type { PageServerLoad, Actions } from './$types'; +import { setPassword, ZitadelError } from '$lib/server/zitadel'; + +export const load: PageServerLoad = async (event) => { + const userId = event.url.searchParams.get('userId'); + const code = event.url.searchParams.get('code'); + + if (!userId || !code) { + throw redirect(303, '/reset-password'); + } + + return { userId, code }; +}; + +export const actions: Actions = { + resetPassword: async ({ request }) => { + const data = await request.formData(); + const userId = data.get('userId') as string; + const code = data.get('code') as string; + const password = data.get('password') as string; + const confirmPassword = data.get('confirmPassword') as string; + + if (!password || !confirmPassword) { + return fail(400, { error: 'Password is required', userId, code }); + } + if (password !== confirmPassword) { + return fail(400, { error: 'Passwords do not match', userId, code }); + } + if (password.length < 8) { + return fail(400, { error: 'Password must be at least 8 characters', userId, code }); + } + + try { + await setPassword(userId, code, password); + throw redirect(303, '/login?passwordReset=true'); + } catch (e) { + if (e instanceof ZitadelError) { + return fail(400, { + error: 'Failed to reset password. The link may have expired.', + userId, + code + }); + } + throw e; + } + } +}; diff --git a/apps/dashboard/src/routes/reset-password/verify/+page.svelte b/apps/dashboard/src/routes/reset-password/verify/+page.svelte new file mode 100644 index 0000000..32904dd --- /dev/null +++ b/apps/dashboard/src/routes/reset-password/verify/+page.svelte @@ -0,0 +1,60 @@ + + + + + +
{ + loading = true; + return async ({ update }) => { + loading = false; + await update(); + }; + }} + class="space-y-5 {form?.error ? 'mt-4' : ''}" + > + + + + + + + + + Reset password + + + + {#snippet footer()} + ← Back to sign in + {/snippet} +
diff --git a/apps/dashboard/src/routes/signup/+page.server.ts b/apps/dashboard/src/routes/signup/+page.server.ts new file mode 100644 index 0000000..73e68cc --- /dev/null +++ b/apps/dashboard/src/routes/signup/+page.server.ts @@ -0,0 +1,81 @@ +import { redirect, fail } from '@sveltejs/kit'; +import type { PageServerLoad, Actions } from './$types'; +import { + createUser, + createSession, + verifyPassword, + finalizeAuthRequest, + ZitadelError +} from '$lib/server/zitadel'; + +export const load: PageServerLoad = async (event) => { + const session = await event.locals.auth(); + if (session) throw redirect(303, '/dashboard'); + + const authRequest = event.url.searchParams.get('authRequest'); + if (!authRequest) { + throw redirect(303, '/auth/signin/zitadel'); + } + + return { authRequest }; +}; + +export const actions: Actions = { + register: async ({ request }) => { + const data = await request.formData(); + const firstName = data.get('firstName') as string; + const lastName = data.get('lastName') as string; + const email = data.get('email') as string; + const password = data.get('password') as string; + const confirmPassword = data.get('confirmPassword') as string; + const authRequest = data.get('authRequest') as string; + + // Validate + if (!firstName || !lastName || !email || !password || !authRequest) { + return fail(400, { error: 'All fields are required', firstName, lastName, email, authRequest }); + } + if (password !== confirmPassword) { + return fail(400, { error: 'Passwords do not match', firstName, lastName, email, authRequest }); + } + if (password.length < 8) { + return fail(400, { + error: 'Password must be at least 8 characters', + firstName, + lastName, + email, + authRequest + }); + } + + try { + // Create user in Zitadel + await createUser({ firstName, lastName, email, password }); + + // Auto-login: create session + verify password + const sessionResult = await createSession(email); + const passwordResult = await verifyPassword( + sessionResult.sessionId, + sessionResult.sessionToken, + password + ); + + // Finalize OIDC flow + const { callbackUrl } = await finalizeAuthRequest( + authRequest, + sessionResult.sessionId, + passwordResult.sessionToken + ); + throw redirect(303, callbackUrl); + } catch (e) { + if (e instanceof ZitadelError) { + // Use generic message to avoid leaking internal details (e.g. "user already exists") + const message = + e.status === 409 + ? 'An account with this email may already exist' + : 'Registration failed. Please try again.'; + return fail(400, { error: message, firstName, lastName, email, authRequest }); + } + throw e; + } + } +}; diff --git a/apps/dashboard/src/routes/signup/+page.svelte b/apps/dashboard/src/routes/signup/+page.svelte new file mode 100644 index 0000000..b1182bd --- /dev/null +++ b/apps/dashboard/src/routes/signup/+page.svelte @@ -0,0 +1,96 @@ + + + + + +
{ + loading = true; + return async ({ update }) => { + loading = false; + await update(); + }; + }} + class="space-y-5 {form?.error ? 'mt-4' : ''}" + > + + +
+ + + +
+ + + + + + + + + Create account + + + +

+ Already have an account? + + Sign in + +

+ + {#snippet footer()} + ← Back to home + {/snippet} +
diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 661e246..c1a8eac 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -19,7 +19,8 @@ services: ZITADEL_TLS_MODE: disabled ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME: admin ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD: "${ZITADEL_ADMIN_PASSWORD}" - ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED: "false" + ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED: "true" + ZITADEL_DEFAULTINSTANCE_LOGINV2_BASEURI: "http://localhost:5173" # Machine user for automated setup (PAT written to bind mount) ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_USERNAME: pvm-setup ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_NAME: PVM Setup Service User diff --git a/docker/setup-zitadel.sh b/docker/setup-zitadel.sh index 927218b..d59837a 100755 --- a/docker/setup-zitadel.sh +++ b/docker/setup-zitadel.sh @@ -136,9 +136,31 @@ create_oidc_app() { echo "${CLIENT_ID}|${CLIENT_SECRET}" } +assign_iam_login_client_role() { + log "Finding machine user ID for role assignment..." + RESPONSE=$(curl -sf -X POST "${ZITADEL_URL}/v2/users" \ + -H "Authorization: Bearer ${PAT}" \ + -H "Content-Type: application/json" \ + -d '{"queries":[{"loginNameQuery":{"loginName":"pvm-setup@zitadel.localhost","method":"TEXT_QUERY_METHOD_EQUALS"}}]}') \ + || fail "Failed to search for machine user." + + MACHINE_USER_ID=$(printf '%s' "$RESPONSE" | jq -r '.result[0].userId // empty') + [ -n "$MACHINE_USER_ID" ] || fail "Could not find machine user 'pvm-setup@zitadel.localhost'." + log "Machine user ID: $MACHINE_USER_ID" + + log "Assigning IAM_LOGIN_CLIENT role to machine user..." + curl -sf -X POST "${ZITADEL_URL}/admin/v1/members" \ + -H "Authorization: Bearer ${PAT}" \ + -H "Content-Type: application/json" \ + -d "{\"userId\": \"${MACHINE_USER_ID}\", \"roles\": [\"IAM_LOGIN_CLIENT\"]}" \ + > /dev/null || fail "Failed to assign IAM_LOGIN_CLIENT role." + log "IAM_LOGIN_CLIENT role assigned successfully." +} + write_dashboard_env() { CLIENT_ID="$1" CLIENT_SECRET="$2" + SERVICE_USER_TOKEN="$3" AUTH_SECRET=$(openssl rand -base64 32) @@ -158,6 +180,9 @@ PUBLIC_API_URL=http://localhost:3001 # Zitadel account management URL PUBLIC_ZITADEL_ACCOUNT_URL=http://localhost:8080/ui/console +# Zitadel service user PAT (for Session API v2 calls from server-side) +ZITADEL_SERVICE_USER_TOKEN=${SERVICE_USER_TOKEN} + # App URL (for OIDC redirects) ORIGIN=http://localhost:5173 EOF @@ -174,6 +199,9 @@ main() { [ -n "$PAT" ] || fail "PAT is empty." log "PAT obtained successfully." + # Assign IAM_LOGIN_CLIENT role so PAT can call session/auth-request APIs + assign_iam_login_client_role + # Idempotent: check if project already exists if PROJECT_ID=$(find_project); then log "Project 'PVM' already exists with ID: $PROJECT_ID" @@ -189,20 +217,22 @@ main() { log "Delete apps/dashboard/.env and re-run to regenerate." else log "WARNING: OIDC app exists but no .env file found." - log "You may need to recreate the app (delete it in Zitadel console, then re-run)." + log "Cannot recover client secret — recreate the app or write .env manually." + log "Writing partial .env with service user token (client secret will be empty)..." + write_dashboard_env "$EXISTING_CLIENT_ID" "" "$PAT" fi else RESULT=$(create_oidc_app "$PROJECT_ID") CLIENT_ID=$(echo "$RESULT" | cut -d'|' -f1) CLIENT_SECRET=$(echo "$RESULT" | cut -d'|' -f2) - write_dashboard_env "$CLIENT_ID" "$CLIENT_SECRET" + write_dashboard_env "$CLIENT_ID" "$CLIENT_SECRET" "$PAT" fi log "" log "=== Setup complete ===" log "" log "Zitadel Console: ${ZITADEL_URL}/ui/console" - log "Admin login: admin@zitadel.localhost / Admin1234!" + log "Admin login: admin@zitadel.localhost / (see ZITADEL_ADMIN_PASSWORD in .env)" log "Dashboard: http://localhost:5173" log "" }