-
-
-
- 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}
+
+
+ {:else}
+
+
+
+ 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}
+
+
+
+
+
+ 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 @@
+
+
+
+
+
+
+
+ {#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 @@
+
+
+
+
+
+
+
+
+ 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 ""
}