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:
parent
ed0578cd07
commit
28a827efa1
16 changed files with 1119 additions and 80 deletions
|
|
@ -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
|
||||
|
|
|
|||
47
apps/dashboard/src/lib/components/AuthCard.svelte
Normal file
47
apps/dashboard/src/lib/components/AuthCard.svelte
Normal 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>
|
||||
16
apps/dashboard/src/lib/components/FormError.svelte
Normal file
16
apps/dashboard/src/lib/components/FormError.svelte
Normal 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}
|
||||
42
apps/dashboard/src/lib/components/FormField.svelte
Normal file
42
apps/dashboard/src/lib/components/FormField.svelte
Normal 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>
|
||||
28
apps/dashboard/src/lib/components/LoadingButton.svelte
Normal file
28
apps/dashboard/src/lib/components/LoadingButton.svelte
Normal 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>
|
||||
324
apps/dashboard/src/lib/server/zitadel.ts
Normal file
324
apps/dashboard/src/lib/server/zitadel.ts
Normal 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
|
||||
})
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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">← 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">← Back to home</a>
|
||||
{/snippet}
|
||||
</AuthCard>
|
||||
|
|
|
|||
33
apps/dashboard/src/routes/reset-password/+page.server.ts
Normal file
33
apps/dashboard/src/routes/reset-password/+page.server.ts
Normal 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 };
|
||||
}
|
||||
};
|
||||
84
apps/dashboard/src/routes/reset-password/+page.svelte
Normal file
84
apps/dashboard/src/routes/reset-password/+page.svelte
Normal 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">← Back to home</a>
|
||||
{/snippet}
|
||||
</AuthCard>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
60
apps/dashboard/src/routes/reset-password/verify/+page.svelte
Normal file
60
apps/dashboard/src/routes/reset-password/verify/+page.svelte
Normal 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">← Back to sign in</a>
|
||||
{/snippet}
|
||||
</AuthCard>
|
||||
81
apps/dashboard/src/routes/signup/+page.server.ts
Normal file
81
apps/dashboard/src/routes/signup/+page.server.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
};
|
||||
96
apps/dashboard/src/routes/signup/+page.svelte
Normal file
96
apps/dashboard/src/routes/signup/+page.svelte
Normal 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">← Back to home</a>
|
||||
{/snippet}
|
||||
</AuthCard>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue