Add custom login UI replacing Zitadel built-in login pages

Replace Zitadel's built-in login v1 with a fully custom SvelteKit-based
login experience using Zitadel Session API v2. Keeps the existing OIDC
authorization code flow (Auth.js handles token exchange) while providing
branded login, signup, password reset, and TOTP pages.

- Enable Login V2 in docker-compose, assign IAM_LOGIN_CLIENT role in setup script
- Add server-only Zitadel API client ($lib/server/zitadel.ts) with session,
  user, and auth-request management functions
- Create reusable auth UI components (AuthCard, FormField, FormError, LoadingButton)
- Rewrite login page with email/password form and TOTP second factor support
- Add signup page with auto-login after registration
- Add password reset flow (request + verify pages)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Mikkel Georgsen 2026-02-08 13:54:01 +01:00
parent ed0578cd07
commit 28a827efa1
16 changed files with 1119 additions and 80 deletions

View file

@ -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

View file

@ -0,0 +1,47 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
title: string;
subtitle?: string;
children: Snippet;
footer?: Snippet;
}
let { title, subtitle, children, footer }: Props = $props();
</script>
<div class="min-h-screen bg-gradient-to-br from-slate-900 via-blue-950 to-slate-900 flex items-center justify-center px-4 py-12 relative overflow-hidden">
<!-- Ambient glow effects for depth -->
<div class="pointer-events-none absolute inset-0">
<div class="absolute top-[-20%] left-[-10%] w-[50%] h-[50%] rounded-full bg-blue-500/[0.04] blur-3xl"></div>
<div class="absolute bottom-[-20%] right-[-10%] w-[40%] h-[40%] rounded-full bg-indigo-500/[0.03] blur-3xl"></div>
</div>
<div class="w-full max-w-md relative z-10">
<!-- Logo -->
<div class="text-center mb-8">
<a href="/" class="inline-flex items-center gap-2 mb-6 group">
<div class="w-10 h-10 bg-blue-500 rounded-lg flex items-center justify-center font-bold text-white text-sm tracking-wider shadow-lg shadow-blue-500/20 transition-shadow duration-300 group-hover:shadow-blue-500/40">
PVM
</div>
</a>
<h1 class="text-2xl font-bold text-white tracking-tight">{title}</h1>
{#if subtitle}
<p class="text-slate-400 mt-2 text-sm">{subtitle}</p>
{/if}
</div>
<!-- Card -->
<div class="bg-white/5 border border-white/10 rounded-2xl p-8 backdrop-blur-sm shadow-2xl shadow-black/20">
{@render children()}
</div>
<!-- Footer -->
{#if footer}
<div class="mt-6 text-center text-sm text-slate-500">
{@render footer()}
</div>
{/if}
</div>
</div>

View file

@ -0,0 +1,16 @@
<script lang="ts">
interface Props {
message?: string;
}
let { message }: Props = $props();
</script>
{#if message}
<div class="flex items-start gap-3 px-4 py-3 bg-red-500/10 border border-red-500/30 rounded-lg">
<svg class="w-5 h-5 text-red-400 shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
<p class="text-sm text-red-400">{message}</p>
</div>
{/if}

View file

@ -0,0 +1,42 @@
<script lang="ts">
interface Props {
name: string;
type?: string;
label: string;
placeholder?: string;
error?: string;
required?: boolean;
autocomplete?: string;
value?: string;
}
let {
name,
type = 'text',
label,
placeholder = '',
error,
required = false,
autocomplete,
value = ''
}: Props = $props();
</script>
<div class="space-y-1.5">
<label for={name} class="block text-sm font-medium text-slate-300">
{label}
</label>
<input
id={name}
{name}
{type}
{placeholder}
{required}
{autocomplete}
{value}
class="w-full px-4 py-2.5 bg-white/5 border rounded-lg text-white placeholder-slate-500 outline-none transition-all duration-200 focus:ring-2 focus:ring-blue-500/40 {error ? 'border-red-500/60 focus:border-red-500' : 'border-white/10 focus:border-blue-500'}"
/>
{#if error}
<p class="text-sm text-red-400 mt-1">{error}</p>
{/if}
</div>

View file

@ -0,0 +1,28 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
loading?: boolean;
disabled?: boolean;
type?: 'submit' | 'button' | 'reset';
children: Snippet;
}
let { loading = false, disabled = false, type = 'submit', children }: Props = $props();
let isDisabled = $derived(loading || disabled);
</script>
<button
{type}
disabled={isDisabled}
class="w-full flex items-center justify-center gap-2 px-4 py-3 bg-blue-600 text-white rounded-lg font-medium transition-all duration-200 {isDisabled ? 'opacity-50 cursor-not-allowed' : 'hover:bg-blue-500 hover:shadow-lg hover:shadow-blue-600/25 active:scale-[0.98]'}"
>
{#if loading}
<svg class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{/if}
{@render children()}
</button>

View file

@ -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<T>(path: string, options?: RequestInit): Promise<T> {
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<ZitadelSession> {
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<ZitadelSession> {
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<ZitadelSession> {
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<FinalizeResult> {
// 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<ZitadelUser | null> {
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<void> {
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<void> {
await zitadelFetch(`/v2/users/${userId}/password`, {
method: 'POST',
body: JSON.stringify({
newPassword: { password: newPassword },
verificationCode: code
})
});
}

View file

@ -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;
}
}
};

View file

@ -1,83 +1,125 @@
<script lang="ts">
import { signIn } from '@auth/sveltekit/client';
import { enhance } from '$app/forms';
import { page } from '$app/state';
import AuthCard from '$lib/components/AuthCard.svelte';
import FormField from '$lib/components/FormField.svelte';
import FormError from '$lib/components/FormError.svelte';
import LoadingButton from '$lib/components/LoadingButton.svelte';
let { data, form } = $props();
let loading = $state(false);
let passwordResetSuccess = $derived(page.url.searchParams.has('passwordReset'));
let isTotp = $derived(form?.step === 'totp');
</script>
<div class="min-h-screen bg-gradient-to-br from-slate-900 via-blue-950 to-slate-900 flex items-center justify-center px-4">
<div class="w-full max-w-md">
<div class="text-center mb-8">
<a href="/" class="inline-flex items-center gap-2 mb-6">
<div class="w-10 h-10 bg-blue-500 rounded-lg flex items-center justify-center font-bold text-white">
PVM
</div>
</a>
<h1 class="text-2xl font-bold text-white">Sign in to PVM</h1>
<p class="text-slate-400 mt-2">Poker Venue Manager</p>
<AuthCard
title={isTotp ? 'Two-factor authentication' : 'Sign in to PVM'}
subtitle={isTotp ? 'Enter the code from your authenticator app' : 'Poker Venue Manager'}
>
{#if passwordResetSuccess && !isTotp}
<div class="flex items-start gap-3 px-4 py-3 bg-emerald-500/10 border border-emerald-500/30 rounded-lg mb-6">
<svg class="w-5 h-5 text-emerald-400 shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
<p class="text-sm text-emerald-400">Your password has been reset. Please sign in with your new password.</p>
</div>
{/if}
<div class="bg-white/5 border border-white/10 rounded-2xl p-8 backdrop-blur-sm">
<!-- OIDC Sign In -->
<button
onclick={() => signIn('zitadel', { callbackUrl: '/dashboard' })}
class="w-full flex items-center justify-center gap-3 px-4 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition-colors cursor-pointer"
>
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
Sign in with Zitadel
</button>
<FormError message={form?.error} />
<div class="relative my-6">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-white/10"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-3 bg-transparent text-slate-500">Social providers via Zitadel</span>
</div>
{#if isTotp}
<!-- TOTP verification step -->
<form
method="POST"
action="?/verifyTotp"
use:enhance={() => {
loading = true;
return async ({ update }) => {
loading = false;
await update();
};
}}
class="space-y-5 mt-4"
>
<input type="hidden" name="authRequest" value={form?.authRequest ?? data.authRequest} />
<input type="hidden" name="sessionId" value={form?.sessionId} />
<input type="hidden" name="sessionToken" value={form?.sessionToken} />
<FormField
name="code"
type="text"
label="Verification code"
placeholder="000000"
autocomplete="one-time-code"
required
/>
<LoadingButton {loading}>
Verify
</LoadingButton>
</form>
{:else}
<!-- Login step -->
<form
method="POST"
action="?/login"
use:enhance={() => {
loading = true;
return async ({ update }) => {
loading = false;
await update();
};
}}
class="space-y-5 {form?.error || passwordResetSuccess ? 'mt-4' : ''}"
>
<input type="hidden" name="authRequest" value={data.authRequest} />
<FormField
name="loginName"
type="email"
label="Email"
placeholder="you@example.com"
autocomplete="email"
value={form?.loginName ?? ''}
required
/>
<FormField
name="password"
type="password"
label="Password"
placeholder="Your password"
autocomplete="current-password"
required
/>
<div class="flex justify-end">
<a
href="/reset-password?authRequest={data.authRequest}"
class="text-sm text-slate-400 hover:text-blue-400 transition-colors"
>
Forgot password?
</a>
</div>
<div class="space-y-3">
<button
onclick={() => signIn('zitadel', { callbackUrl: '/dashboard' })}
class="w-full flex items-center justify-center gap-3 px-4 py-3 bg-white/5 hover:bg-white/10 border border-white/10 text-white rounded-lg font-medium transition-colors cursor-pointer"
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4"/>
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
</svg>
Sign in with Google
</button>
<button
onclick={() => signIn('zitadel', { callbackUrl: '/dashboard' })}
class="w-full flex items-center justify-center gap-3 px-4 py-3 bg-white/5 hover:bg-white/10 border border-white/10 text-white rounded-lg font-medium transition-colors cursor-pointer"
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M17.05 20.28c-.98.95-2.05.8-3.08.35-1.09-.46-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.35C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"/>
</svg>
Sign in with Apple
</button>
<button
onclick={() => signIn('zitadel', { callbackUrl: '/dashboard' })}
class="w-full flex items-center justify-center gap-3 px-4 py-3 bg-white/5 hover:bg-white/10 border border-white/10 text-white rounded-lg font-medium transition-colors cursor-pointer"
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="#1877F2">
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
</svg>
Sign in with Facebook
</button>
</div>
<p class="text-center text-sm text-slate-500 mt-6">
Social providers are configured through Zitadel.
All sign-in methods redirect to your identity provider.
</p>
</div>
<LoadingButton {loading}>
Sign in
</LoadingButton>
</form>
<p class="text-center text-sm text-slate-500 mt-6">
<a href="/" class="hover:text-slate-300 transition-colors">&larr; Back to home</a>
Don't have an account?
<a
href="/signup?authRequest={data.authRequest}"
class="text-blue-400 hover:text-blue-300 transition-colors"
>
Create account
</a>
</p>
</div>
</div>
{/if}
{#snippet footer()}
<a href="/" class="hover:text-slate-300 transition-colors">&larr; Back to home</a>
{/snippet}
</AuthCard>

View file

@ -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 };
}
};

View file

@ -0,0 +1,84 @@
<script lang="ts">
import { enhance } from '$app/forms';
import AuthCard from '$lib/components/AuthCard.svelte';
import FormField from '$lib/components/FormField.svelte';
import FormError from '$lib/components/FormError.svelte';
import LoadingButton from '$lib/components/LoadingButton.svelte';
let { data, form } = $props();
let loading = $state(false);
let success = $derived(form?.success === true);
let authRequest = $derived(form?.authRequest ?? data.authRequest);
let loginHref = $derived(authRequest ? `/login?authRequest=${authRequest}` : '/login');
</script>
<AuthCard
title="Reset your password"
subtitle="Enter your email and we'll send you a reset link"
>
{#if success}
<div class="flex items-start gap-3 px-4 py-3 bg-emerald-500/10 border border-emerald-500/30 rounded-lg">
<svg class="w-5 h-5 text-emerald-400 shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75" />
</svg>
<div>
<p class="text-sm text-emerald-400 font-medium">Check your email</p>
<p class="text-sm text-slate-400 mt-1">If an account exists with that email, we've sent a password reset link.</p>
</div>
</div>
<div class="mt-6">
<a
href={loginHref}
class="w-full flex items-center justify-center px-4 py-3 bg-white/5 border border-white/10 text-white rounded-lg font-medium transition-all duration-200 hover:bg-white/10"
>
Back to sign in
</a>
</div>
{:else}
<FormError message={form?.error} />
<form
method="POST"
action="?/requestReset"
use:enhance={() => {
loading = true;
return async ({ update }) => {
loading = false;
await update();
};
}}
class="space-y-5 {form?.error ? 'mt-4' : ''}"
>
<input type="hidden" name="authRequest" value={data.authRequest} />
<FormField
name="email"
type="email"
label="Email"
placeholder="you@example.com"
autocomplete="email"
required
/>
<LoadingButton {loading}>
Send reset link
</LoadingButton>
</form>
<p class="text-center text-sm text-slate-500 mt-6">
Remember your password?
<a
href={loginHref}
class="text-blue-400 hover:text-blue-300 transition-colors"
>
Sign in
</a>
</p>
{/if}
{#snippet footer()}
<a href="/" class="hover:text-slate-300 transition-colors">&larr; Back to home</a>
{/snippet}
</AuthCard>

View file

@ -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;
}
}
};

View file

@ -0,0 +1,60 @@
<script lang="ts">
import { enhance } from '$app/forms';
import AuthCard from '$lib/components/AuthCard.svelte';
import FormField from '$lib/components/FormField.svelte';
import FormError from '$lib/components/FormError.svelte';
import LoadingButton from '$lib/components/LoadingButton.svelte';
let { data, form } = $props();
let loading = $state(false);
</script>
<AuthCard
title="Set new password"
subtitle="Choose a new password for your account"
>
<FormError message={form?.error} />
<form
method="POST"
action="?/resetPassword"
use:enhance={() => {
loading = true;
return async ({ update }) => {
loading = false;
await update();
};
}}
class="space-y-5 {form?.error ? 'mt-4' : ''}"
>
<input type="hidden" name="userId" value={form?.userId ?? data.userId} />
<input type="hidden" name="code" value={form?.code ?? data.code} />
<FormField
name="password"
type="password"
label="New password"
placeholder="At least 8 characters"
autocomplete="new-password"
required
/>
<FormField
name="confirmPassword"
type="password"
label="Confirm password"
placeholder="Re-enter your password"
autocomplete="new-password"
required
/>
<LoadingButton {loading}>
Reset password
</LoadingButton>
</form>
{#snippet footer()}
<a href="/login" class="hover:text-slate-300 transition-colors">&larr; Back to sign in</a>
{/snippet}
</AuthCard>

View file

@ -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;
}
}
};

View file

@ -0,0 +1,96 @@
<script lang="ts">
import { enhance } from '$app/forms';
import AuthCard from '$lib/components/AuthCard.svelte';
import FormField from '$lib/components/FormField.svelte';
import FormError from '$lib/components/FormError.svelte';
import LoadingButton from '$lib/components/LoadingButton.svelte';
let { data, form } = $props();
let loading = $state(false);
</script>
<AuthCard title="Create your account" subtitle="Poker Venue Manager">
<FormError message={form?.error} />
<form
method="POST"
action="?/register"
use:enhance={() => {
loading = true;
return async ({ update }) => {
loading = false;
await update();
};
}}
class="space-y-5 {form?.error ? 'mt-4' : ''}"
>
<input type="hidden" name="authRequest" value={data.authRequest} />
<div class="grid grid-cols-2 gap-4">
<FormField
name="firstName"
label="First name"
placeholder="John"
autocomplete="given-name"
value={form?.firstName ?? ''}
required
/>
<FormField
name="lastName"
label="Last name"
placeholder="Doe"
autocomplete="family-name"
value={form?.lastName ?? ''}
required
/>
</div>
<FormField
name="email"
type="email"
label="Email"
placeholder="you@example.com"
autocomplete="email"
value={form?.email ?? ''}
required
/>
<FormField
name="password"
type="password"
label="Password"
placeholder="At least 8 characters"
autocomplete="new-password"
required
/>
<FormField
name="confirmPassword"
type="password"
label="Confirm password"
placeholder="Repeat your password"
autocomplete="new-password"
required
/>
<LoadingButton {loading}>
Create account
</LoadingButton>
</form>
<p class="text-center text-sm text-slate-500 mt-6">
Already have an account?
<a
href="/login?authRequest={data.authRequest}"
class="text-blue-400 hover:text-blue-300 transition-colors"
>
Sign in
</a>
</p>
{#snippet footer()}
<a href="/" class="hover:text-slate-300 transition-colors">&larr; Back to home</a>
{/snippet}
</AuthCard>

View file

@ -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

View file

@ -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 ""
}