Scaffold base webapp: Rust/Axum API + SvelteKit dashboard + Docker dev env

Backend (Rust/Axum):
- pvm-api: Axum server with health and user profile endpoints,
  OpenAPI/Swagger UI, CORS, tracing, graceful shutdown
- pvm-auth: JWT validation middleware with JWKS cache for
  offline-capable Zitadel token verification
- pvm-core: Shared error types with IntoResponse impl
- pvm-types: Shared domain types (UserProfile)

Frontend (SvelteKit):
- Dashboard app with Svelte 5 + TypeScript + Tailwind CSS v4
- Zitadel OIDC auth via @auth/sveltekit (PKCE flow)
- Pages: landing, sign-in, dashboard, account settings
- Responsive sidebar layout with dark mode support
- Typed API client for backend communication

Infrastructure:
- Docker Compose dev environment with Zitadel v3, PostgreSQL 16,
  and DragonflyDB
- Environment variable examples and setup documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Mikkel Georgsen 2026-02-08 03:37:07 +01:00
parent e25afdcb3a
commit c972926d31
47 changed files with 3324 additions and 0 deletions

28
.gitignore vendored Normal file
View file

@ -0,0 +1,28 @@
# Rust
target/
Cargo.lock
# Node
node_modules/
.turbo/
dist/
build/
.svelte-kit/
# Environment
.env
.env.local
.env.*.local
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Docker
docker/data/

52
Cargo.toml Normal file
View file

@ -0,0 +1,52 @@
[workspace]
resolver = "2"
members = [
"crates/pvm-api",
"crates/pvm-core",
"crates/pvm-auth",
"crates/pvm-types",
]
[workspace.package]
version = "0.1.0"
edition = "2024"
license = "UNLICENSED"
[workspace.dependencies]
# Async runtime
tokio = { version = "1", features = ["full"] }
# Web framework
axum = { version = "0.8", features = ["ws", "macros"] }
axum-extra = { version = "0.10", features = ["typed-header", "cookie"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "trace", "compression-gzip"] }
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Auth / JWT
jsonwebtoken = "9"
# Database
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "sqlite", "chrono", "uuid"] }
# Logging / Tracing
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
# Error handling
thiserror = "2"
anyhow = "1"
# OpenAPI
utoipa = { version = "5", features = ["axum_extras", "uuid", "chrono"] }
utoipa-axum = "0.2"
utoipa-swagger-ui = { version = "9", features = ["axum"] }
# Misc
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4", "serde"] }
dotenvy = "0.15"
reqwest = { version = "0.12", features = ["json"] }

View file

@ -0,0 +1,16 @@
# Zitadel OIDC Configuration
AUTH_ZITADEL_ISSUER=https://auth.pvm.example.com
AUTH_ZITADEL_CLIENT_ID=your-client-id
AUTH_ZITADEL_CLIENT_SECRET=your-client-secret
# Auth.js secret (generate with: openssl rand -base64 32)
AUTH_SECRET=your-auth-secret
# Backend API URL
PUBLIC_API_URL=http://localhost:3001
# Zitadel account management URL (for password/MFA changes)
PUBLIC_ZITADEL_ACCOUNT_URL=https://auth.pvm.example.com/ui/console
# App URL (for OIDC redirects)
ORIGIN=http://localhost:5173

23
apps/dashboard/.gitignore vendored Normal file
View file

@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
apps/dashboard/.npmrc Normal file
View file

@ -0,0 +1 @@
engine-strict=true

42
apps/dashboard/README.md Normal file
View file

@ -0,0 +1,42 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project
npx sv create my-app
```
To recreate this project with the same configuration:
```sh
# recreate this project
npx sv create --template minimal --types ts --no-install dashboard
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

View file

@ -0,0 +1,30 @@
{
"name": "dashboard",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/adapter-node": "^5.0.0",
"@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.0.0",
"svelte": "^5.49.2",
"svelte-check": "^4.3.6",
"typescript": "^5.9.3",
"vite": "^7.3.1"
},
"dependencies": {
"@auth/core": "^0.38.0",
"@auth/sveltekit": "^1.7.0",
"tailwindcss": "^4.0.0"
}
}

View file

@ -0,0 +1,38 @@
@import "tailwindcss";
@theme {
--color-primary: #1e40af;
--color-primary-light: #3b82f6;
--color-primary-dark: #1e3a8a;
--color-surface: #ffffff;
--color-surface-dark: #0f172a;
--color-surface-card: #f8fafc;
--color-surface-card-dark: #1e293b;
--color-surface-sidebar: #f1f5f9;
--color-surface-sidebar-dark: #0f172a;
--color-border: #e2e8f0;
--color-border-dark: #334155;
--color-text: #0f172a;
--color-text-dark: #f1f5f9;
--color-text-muted: #64748b;
--color-text-muted-dark: #94a3b8;
--color-accent: #10b981;
--color-danger: #ef4444;
}
:root {
color-scheme: light dark;
}
body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@media (prefers-color-scheme: dark) {
body {
background-color: var(--color-surface-dark);
color: var(--color-text-dark);
}
}

27
apps/dashboard/src/app.d.ts vendored Normal file
View file

@ -0,0 +1,27 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
declare module '@auth/sveltekit' {
interface Session {
accessToken?: string;
}
}
declare module '@auth/core/jwt' {
interface JWT {
accessToken?: string;
refreshToken?: string;
expiresAt?: number;
}
}
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

View file

@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -0,0 +1,45 @@
import { SvelteKitAuth } from '@auth/sveltekit';
import type { Provider } from '@auth/core/providers';
import { env } from '$env/dynamic/private';
function zitadelProvider(): Provider {
return {
id: 'zitadel',
name: 'Zitadel',
type: 'oidc',
issuer: env.AUTH_ZITADEL_ISSUER,
clientId: env.AUTH_ZITADEL_CLIENT_ID,
clientSecret: env.AUTH_ZITADEL_CLIENT_SECRET,
authorization: {
params: {
scope: 'openid profile email offline_access'
}
},
checks: ['pkce', 'state'],
client: {
token_endpoint_auth_method: 'client_secret_basic'
}
};
}
export const { handle, signIn, signOut } = SvelteKitAuth({
providers: [zitadelProvider()],
callbacks: {
async jwt({ token, account }) {
if (account) {
token.accessToken = account.access_token;
token.refreshToken = account.refresh_token;
token.expiresAt = account.expires_at;
}
return token;
},
async session({ session, token }) {
session.accessToken = token.accessToken as string;
return session;
}
},
pages: {
signIn: '/auth/signin'
},
trustHost: true
});

View file

@ -0,0 +1 @@
export { handle } from './auth';

View file

@ -0,0 +1,62 @@
const API_BASE = import.meta.env.PUBLIC_API_URL || 'http://localhost:3001';
export class ApiClient {
private token: string;
constructor(token: string) {
this.token = token;
}
private async request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.token}`,
...options?.headers
}
});
if (!res.ok) {
throw new ApiError(res.status, await res.text());
}
return res.json();
}
async get<T>(path: string): Promise<T> {
return this.request<T>(path);
}
async post<T>(path: string, body?: unknown): Promise<T> {
return this.request<T>(path, {
method: 'POST',
body: body ? JSON.stringify(body) : undefined
});
}
async put<T>(path: string, body?: unknown): Promise<T> {
return this.request<T>(path, {
method: 'PUT',
body: body ? JSON.stringify(body) : undefined
});
}
async delete<T>(path: string): Promise<T> {
return this.request<T>(path, { method: 'DELETE' });
}
}
export class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
this.name = 'ApiError';
}
}
export function createApiClient(token: string): ApiClient {
return new ApiClient(token);
}

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

View file

@ -0,0 +1,6 @@
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async (event) => {
const session = await event.locals.auth();
return { session };
};

View file

@ -0,0 +1,13 @@
<script lang="ts">
import '../app.css';
import favicon from '$lib/assets/favicon.svg';
let { children } = $props();
</script>
<svelte:head>
<link rel="icon" href={favicon} />
<title>PVM - Poker Venue Manager</title>
</svelte:head>
{@render children()}

View file

@ -0,0 +1,90 @@
<script lang="ts">
import { page } from '$app/stores';
import { signIn } from '@auth/sveltekit/client';
let session = $derived($page.data.session);
</script>
<div class="min-h-screen bg-gradient-to-br from-slate-900 via-blue-950 to-slate-900 text-white">
<nav class="flex items-center justify-between px-6 py-4 max-w-7xl mx-auto">
<div class="flex items-center gap-2">
<div class="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center font-bold text-sm">
PVM
</div>
<span class="text-xl font-semibold">Poker Venue Manager</span>
</div>
<div>
{#if session}
<a
href="/dashboard"
class="px-5 py-2 bg-blue-600 hover:bg-blue-500 rounded-lg font-medium transition-colors"
>
Dashboard
</a>
{:else}
<button
onclick={() => signIn('zitadel')}
class="px-5 py-2 bg-blue-600 hover:bg-blue-500 rounded-lg font-medium transition-colors cursor-pointer"
>
Sign In
</button>
{/if}
</div>
</nav>
<main class="flex flex-col items-center justify-center px-6 pt-32 pb-20 max-w-4xl mx-auto text-center">
<h1 class="text-5xl sm:text-6xl font-bold tracking-tight mb-6">
Manage your poker venue
<span class="text-blue-400">with ease</span>
</h1>
<p class="text-lg sm:text-xl text-slate-300 max-w-2xl mb-10">
PVM helps poker rooms run smoother — manage tournaments, track players,
handle waitlists, and display live information. All from one dashboard.
</p>
{#if session}
<a
href="/dashboard"
class="px-8 py-3 bg-blue-600 hover:bg-blue-500 rounded-lg text-lg font-medium transition-colors"
>
Go to Dashboard
</a>
{:else}
<button
onclick={() => signIn('zitadel')}
class="px-8 py-3 bg-blue-600 hover:bg-blue-500 rounded-lg text-lg font-medium transition-colors cursor-pointer"
>
Get Started
</button>
{/if}
<div class="grid grid-cols-1 sm:grid-cols-3 gap-6 mt-20 w-full">
<div class="bg-white/5 border border-white/10 rounded-xl p-6 text-left">
<div class="w-10 h-10 bg-blue-500/20 rounded-lg flex items-center justify-center mb-4">
<svg class="w-5 h-5 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<h3 class="text-lg font-semibold mb-2">Player Management</h3>
<p class="text-slate-400 text-sm">Track players, manage waitlists, and keep everyone in the game.</p>
</div>
<div class="bg-white/5 border border-white/10 rounded-xl p-6 text-left">
<div class="w-10 h-10 bg-blue-500/20 rounded-lg flex items-center justify-center mb-4">
<svg class="w-5 h-5 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
</div>
<h3 class="text-lg font-semibold mb-2">Tournaments</h3>
<p class="text-slate-400 text-sm">Create and manage tournaments with automatic blind structures and payouts.</p>
</div>
<div class="bg-white/5 border border-white/10 rounded-xl p-6 text-left">
<div class="w-10 h-10 bg-blue-500/20 rounded-lg flex items-center justify-center mb-4">
<svg class="w-5 h-5 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<h3 class="text-lg font-semibold mb-2">Live Displays</h3>
<p class="text-slate-400 text-sm">Show live tournament info, waitlists, and announcements on venue screens.</p>
</div>
</div>
</main>
</div>

View file

@ -0,0 +1,83 @@
<script lang="ts">
import { signIn } from '@auth/sveltekit/client';
</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>
</div>
<div class="bg-white/5 border border-white/10 rounded-2xl p-8 backdrop-blur-sm">
<!-- OIDC Sign In -->
<button
onclick={() => signIn('zitadel')}
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>
<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>
</div>
<div class="space-y-3">
<button
onclick={() => signIn('zitadel')}
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')}
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')}
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>
<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>
</p>
</div>
</div>

View file

@ -0,0 +1,10 @@
import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async (event) => {
const session = await event.locals.auth();
if (!session) {
throw redirect(303, '/auth/signin');
}
return { session };
};

View file

@ -0,0 +1,151 @@
<script lang="ts">
import { page } from '$app/stores';
import { signOut } from '@auth/sveltekit/client';
let { children } = $props();
let session = $derived($page.data.session);
let sidebarOpen = $state(false);
const navItems = [
{ href: '/dashboard', label: 'Dashboard', icon: 'home' },
{ href: '/dashboard/account', label: 'Account Settings', icon: 'settings' }
];
function isActive(href: string): boolean {
if (href === '/dashboard') {
return $page.url.pathname === '/dashboard';
}
return $page.url.pathname.startsWith(href);
}
</script>
<div class="min-h-screen bg-slate-50 dark:bg-slate-900 flex">
<!-- Mobile sidebar overlay -->
{#if sidebarOpen}
<div
class="fixed inset-0 bg-black/50 z-40 lg:hidden"
onclick={() => (sidebarOpen = false)}
onkeydown={(e) => e.key === 'Escape' && (sidebarOpen = false)}
role="button"
tabindex="-1"
aria-label="Close sidebar"
></div>
{/if}
<!-- Sidebar -->
<aside
class="fixed lg:sticky top-0 left-0 z-50 h-screen w-64 bg-white dark:bg-slate-800 border-r border-slate-200 dark:border-slate-700 flex flex-col transition-transform lg:translate-x-0 {sidebarOpen
? 'translate-x-0'
: '-translate-x-full'}"
>
<div class="flex items-center gap-2 px-5 py-4 border-b border-slate-200 dark:border-slate-700">
<div
class="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center font-bold text-white text-sm"
>
PVM
</div>
<span class="text-lg font-semibold text-slate-900 dark:text-white">PVM Dashboard</span>
</div>
<nav class="flex-1 px-3 py-4 space-y-1">
{#each navItems as item}
<a
href={item.href}
onclick={() => (sidebarOpen = false)}
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {isActive(
item.href
)
? 'bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300'
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700/50 hover:text-slate-900 dark:hover:text-white'}"
>
{#if item.icon === 'home'}
<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="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
{:else if item.icon === 'settings'}
<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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
{/if}
{item.label}
</a>
{/each}
</nav>
<div class="px-3 py-4 border-t border-slate-200 dark:border-slate-700">
<div class="flex items-center gap-3 px-3 py-2">
{#if session?.user?.image}
<img
src={session.user.image}
alt="Avatar"
class="w-8 h-8 rounded-full"
/>
{:else}
<div class="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white text-sm font-medium">
{session?.user?.name?.charAt(0)?.toUpperCase() || '?'}
</div>
{/if}
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-slate-900 dark:text-white truncate">
{session?.user?.name || 'User'}
</p>
<p class="text-xs text-slate-500 dark:text-slate-400 truncate">
{session?.user?.email || ''}
</p>
</div>
</div>
</div>
</aside>
<!-- Main content -->
<div class="flex-1 flex flex-col min-h-screen">
<!-- Top bar -->
<header class="sticky top-0 z-30 bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700 px-4 sm:px-6 py-3">
<div class="flex items-center justify-between">
<button
onclick={() => (sidebarOpen = true)}
class="lg:hidden p-2 -ml-2 text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white cursor-pointer"
aria-label="Open sidebar"
>
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<div class="flex-1"></div>
<div class="flex items-center gap-3">
<span class="text-sm text-slate-600 dark:text-slate-400 hidden sm:block">
{session?.user?.name || 'User'}
</span>
{#if session?.user?.image}
<img
src={session.user.image}
alt="Avatar"
class="w-8 h-8 rounded-full"
/>
{:else}
<div class="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white text-sm font-medium">
{session?.user?.name?.charAt(0)?.toUpperCase() || '?'}
</div>
{/if}
<button
onclick={() => signOut()}
class="text-sm text-slate-500 hover:text-red-600 dark:text-slate-400 dark:hover:text-red-400 transition-colors cursor-pointer"
title="Sign out"
>
<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="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</button>
</div>
</div>
</header>
<!-- Page content -->
<main class="flex-1 p-4 sm:p-6 lg:p-8">
{@render children()}
</main>
</div>
</div>

View file

@ -0,0 +1,116 @@
<script lang="ts">
import { page } from '$app/stores';
let session = $derived($page.data.session);
</script>
<div class="max-w-4xl">
<div class="mb-8">
<h1 class="text-2xl font-bold text-slate-900 dark:text-white">
Welcome back, {session?.user?.name || 'there'}
</h1>
<p class="text-slate-500 dark:text-slate-400 mt-1">
Here's an overview of your poker venue.
</p>
</div>
<!-- User profile card -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6 mb-6">
<div class="flex items-center gap-4">
{#if session?.user?.image}
<img
src={session.user.image}
alt="Profile"
class="w-16 h-16 rounded-full"
/>
{:else}
<div class="w-16 h-16 bg-blue-500 rounded-full flex items-center justify-center text-white text-2xl font-medium">
{session?.user?.name?.charAt(0)?.toUpperCase() || '?'}
</div>
{/if}
<div>
<h2 class="text-lg font-semibold text-slate-900 dark:text-white">
{session?.user?.name || 'Unknown User'}
</h2>
<p class="text-slate-500 dark:text-slate-400">
{session?.user?.email || 'No email'}
</p>
</div>
</div>
</div>
<!-- Quick stats placeholder -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6">
<div class="flex items-center gap-3 mb-2">
<div class="w-10 h-10 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<span class="text-sm font-medium text-slate-500 dark:text-slate-400">Players</span>
</div>
<p class="text-2xl font-bold text-slate-900 dark:text-white">--</p>
<p class="text-xs text-slate-400 dark:text-slate-500 mt-1">Connect backend to see data</p>
</div>
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6">
<div class="flex items-center gap-3 mb-2">
<div class="w-10 h-10 bg-green-100 dark:bg-green-900/30 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-green-600 dark:text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
</div>
<span class="text-sm font-medium text-slate-500 dark:text-slate-400">Active Tables</span>
</div>
<p class="text-2xl font-bold text-slate-900 dark:text-white">--</p>
<p class="text-xs text-slate-400 dark:text-slate-500 mt-1">Connect backend to see data</p>
</div>
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6">
<div class="flex items-center gap-3 mb-2">
<div class="w-10 h-10 bg-purple-100 dark:bg-purple-900/30 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-purple-600 dark:text-purple-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<span class="text-sm font-medium text-slate-500 dark:text-slate-400">Tournaments Today</span>
</div>
<p class="text-2xl font-bold text-slate-900 dark:text-white">--</p>
<p class="text-xs text-slate-400 dark:text-slate-500 mt-1">Connect backend to see data</p>
</div>
</div>
<!-- Getting started -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6">
<h3 class="text-lg font-semibold text-slate-900 dark:text-white mb-4">Getting Started</h3>
<div class="space-y-3">
<div class="flex items-center gap-3 text-sm">
<div class="w-6 h-6 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
<svg class="w-4 h-4 text-green-600 dark:text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<span class="text-slate-600 dark:text-slate-300">Sign in to your account</span>
</div>
<div class="flex items-center gap-3 text-sm">
<div class="w-6 h-6 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center">
<span class="text-xs text-slate-400">2</span>
</div>
<span class="text-slate-400">Set up your venue profile</span>
</div>
<div class="flex items-center gap-3 text-sm">
<div class="w-6 h-6 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center">
<span class="text-xs text-slate-400">3</span>
</div>
<span class="text-slate-400">Configure your first tournament</span>
</div>
<div class="flex items-center gap-3 text-sm">
<div class="w-6 h-6 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center">
<span class="text-xs text-slate-400">4</span>
</div>
<span class="text-slate-400">Connect a display device</span>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,103 @@
<script lang="ts">
import { page } from '$app/stores';
import { signOut } from '@auth/sveltekit/client';
let session = $derived($page.data.session);
const zitadelAccountUrl = import.meta.env.PUBLIC_ZITADEL_ACCOUNT_URL || '#';
</script>
<div class="max-w-2xl">
<div class="mb-8">
<h1 class="text-2xl font-bold text-slate-900 dark:text-white">Account Settings</h1>
<p class="text-slate-500 dark:text-slate-400 mt-1">
Manage your account and security settings.
</p>
</div>
<!-- Profile section -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6 mb-6">
<h2 class="text-lg font-semibold text-slate-900 dark:text-white mb-4">Profile</h2>
<div class="flex items-center gap-4 mb-4">
{#if session?.user?.image}
<img
src={session.user.image}
alt="Profile"
class="w-20 h-20 rounded-full"
/>
{:else}
<div class="w-20 h-20 bg-blue-500 rounded-full flex items-center justify-center text-white text-3xl font-medium">
{session?.user?.name?.charAt(0)?.toUpperCase() || '?'}
</div>
{/if}
<div>
<p class="text-lg font-medium text-slate-900 dark:text-white">
{session?.user?.name || 'Unknown User'}
</p>
<p class="text-slate-500 dark:text-slate-400">
{session?.user?.email || 'No email'}
</p>
</div>
</div>
<p class="text-sm text-slate-500 dark:text-slate-400">
Profile information is managed through your identity provider (Zitadel).
</p>
</div>
<!-- Password section -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6 mb-6">
<h2 class="text-lg font-semibold text-slate-900 dark:text-white mb-2">Password</h2>
<p class="text-sm text-slate-500 dark:text-slate-400 mb-4">
Change your password through Zitadel's account management.
</p>
<a
href={zitadelAccountUrl}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-4 py-2 bg-slate-100 dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600 text-slate-700 dark:text-slate-300 rounded-lg text-sm font-medium transition-colors"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Change Password in Zitadel
</a>
</div>
<!-- MFA section -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6 mb-6">
<h2 class="text-lg font-semibold text-slate-900 dark:text-white mb-2">
Two-Factor Authentication (2FA)
</h2>
<p class="text-sm text-slate-500 dark:text-slate-400 mb-4">
Add an extra layer of security to your account with TOTP-based two-factor authentication.
Managed through Zitadel.
</p>
<a
href={zitadelAccountUrl}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-4 py-2 bg-slate-100 dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600 text-slate-700 dark:text-slate-300 rounded-lg text-sm font-medium transition-colors"
>
<svg class="w-4 h-4" 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>
Manage 2FA in Zitadel
</a>
</div>
<!-- Danger zone -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-red-200 dark:border-red-900/50 p-6">
<h2 class="text-lg font-semibold text-red-600 dark:text-red-400 mb-2">Sign Out</h2>
<p class="text-sm text-slate-500 dark:text-slate-400 mb-4">
Sign out of your PVM account on this device.
</p>
<button
onclick={() => signOut({ callbackUrl: '/' })}
class="inline-flex items-center gap-2 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm font-medium transition-colors cursor-pointer"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
Sign Out
</button>
</div>
</div>

View file

@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:

View file

@ -0,0 +1,13 @@
import adapter from '@sveltejs/adapter-auto';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter()
}
};
export default config;

View file

@ -0,0 +1,20 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}

View file

@ -0,0 +1,7 @@
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()]
});

26
crates/pvm-api/Cargo.toml Normal file
View file

@ -0,0 +1,26 @@
[package]
name = "pvm-api"
version.workspace = true
edition.workspace = true
[dependencies]
pvm-core = { path = "../pvm-core" }
pvm-types = { path = "../pvm-types" }
pvm-auth = { path = "../pvm-auth" }
axum.workspace = true
axum-extra.workspace = true
tower.workspace = true
tower-http.workspace = true
tokio.workspace = true
serde.workspace = true
serde_json.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
utoipa.workspace = true
utoipa-axum.workspace = true
utoipa-swagger-ui.workspace = true
chrono.workspace = true
uuid.workspace = true
dotenvy.workspace = true
thiserror.workspace = true

113
crates/pvm-api/src/main.rs Normal file
View file

@ -0,0 +1,113 @@
use axum::extract::FromRef;
use axum::http::Method;
use pvm_auth::jwks::JwksCache;
use pvm_auth::middleware::AuthState;
use pvm_types::user::UserProfile;
use tokio::net::TcpListener;
use tokio::signal;
use tower_http::cors::{Any, CorsLayer};
use tower_http::trace::TraceLayer;
use tracing::info;
use utoipa::OpenApi;
mod routes;
#[derive(Clone, FromRef)]
struct AppState {
auth: AuthState,
}
#[derive(OpenApi)]
#[openapi(
info(title = "PVM API", version = "0.1.0", description = "Poker Venue Manager API"),
paths(routes::health, routes::user_profile),
components(schemas(routes::HealthResponse, UserProfile)),
)]
struct ApiDoc;
#[tokio::main]
async fn main() {
// Load .env if present
let _ = dotenvy::dotenv();
// Init tracing
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "pvm_api=debug,pvm_auth=debug,tower_http=debug".parse().unwrap()),
)
.init();
// Config from env
let port: u16 = std::env::var("PVM_API_PORT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(3001);
let issuer_url = std::env::var("PVM_ZITADEL_ISSUER_URL")
.unwrap_or_else(|_| "http://localhost:8080".into());
let audience = std::env::var("PVM_ZITADEL_AUDIENCE")
.unwrap_or_else(|_| "pvm-api".into());
// Build auth state
let jwks_cache = JwksCache::new(&issuer_url);
jwks_cache.spawn_refresh_task();
let auth_state = AuthState {
jwks_cache,
audience,
issuer: issuer_url,
};
let state = AppState { auth: auth_state };
// CORS
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE])
.allow_headers(Any);
// Build router
let app = routes::router()
.merge(
utoipa_swagger_ui::SwaggerUi::new("/swagger-ui")
.url("/api-docs/openapi.json", ApiDoc::openapi()),
)
.layer(TraceLayer::new_for_http())
.layer(cors)
.with_state(state);
// Start server
let addr = format!("0.0.0.0:{port}");
info!(%addr, "Starting PVM API server");
let listener = TcpListener::bind(&addr).await.expect("failed to bind");
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await
.expect("server error");
}
async fn shutdown_signal() {
let ctrl_c = async {
signal::ctrl_c().await.expect("failed to install Ctrl+C handler");
};
#[cfg(unix)]
let terminate = async {
signal::unix::signal(signal::unix::SignalKind::terminate())
.expect("failed to install signal handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
info!("Shutdown signal received, starting graceful shutdown");
}

View file

@ -0,0 +1,59 @@
use axum::routing::get;
use axum::{Json, Router};
use chrono::Utc;
use pvm_auth::middleware::AuthUser;
use pvm_types::user::UserProfile;
use serde::Serialize;
use utoipa::ToSchema;
use uuid::Uuid;
use crate::AppState;
pub fn router() -> Router<AppState> {
Router::new()
.route("/api/health", get(health))
.route("/api/user/profile", get(user_profile))
}
#[derive(Serialize, ToSchema)]
pub struct HealthResponse {
pub status: String,
}
#[utoipa::path(
get,
path = "/api/health",
responses(
(status = 200, description = "Service is healthy", body = HealthResponse),
),
tag = "health",
)]
pub async fn health() -> Json<HealthResponse> {
Json(HealthResponse {
status: "ok".into(),
})
}
#[utoipa::path(
get,
path = "/api/user/profile",
responses(
(status = 200, description = "User profile from JWT claims", body = UserProfile),
(status = 401, description = "Missing or invalid authentication"),
),
security(("bearer_auth" = [])),
tag = "user",
)]
pub async fn user_profile(AuthUser(claims): AuthUser) -> Json<UserProfile> {
let profile = UserProfile {
id: Uuid::parse_str(&claims.sub).unwrap_or_else(|_| Uuid::new_v4()),
email: claims.email.unwrap_or_default(),
name: claims.name,
picture: claims.picture,
email_verified: claims.email_verified.unwrap_or(false),
mfa_enabled: false,
created_at: Utc::now(),
};
Json(profile)
}

View file

@ -0,0 +1,17 @@
[package]
name = "pvm-auth"
version.workspace = true
edition.workspace = true
[dependencies]
pvm-types = { path = "../pvm-types" }
axum.workspace = true
jsonwebtoken.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tokio.workspace = true
tracing.workspace = true
chrono.workspace = true
uuid.workspace = true

View file

@ -0,0 +1,24 @@
use serde::{Deserialize, Serialize};
/// JWT claims from Zitadel OIDC tokens
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PvmClaims {
/// Subject (user ID from Zitadel)
pub sub: String,
/// Issuer (Zitadel instance URL)
pub iss: String,
/// Audience
pub aud: Vec<String>,
/// Expiration time (UTC timestamp)
pub exp: i64,
/// Issued at (UTC timestamp)
pub iat: i64,
/// Email address
pub email: Option<String>,
/// Whether the email is verified
pub email_verified: Option<bool>,
/// Display name
pub name: Option<String>,
/// Profile picture URL
pub picture: Option<String>,
}

105
crates/pvm-auth/src/jwks.rs Normal file
View file

@ -0,0 +1,105 @@
use jsonwebtoken::jwk::JwkSet;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
use tracing::{info, warn};
const REFRESH_INTERVAL: Duration = Duration::from_secs(3600); // 1 hour
#[derive(Clone)]
pub struct JwksCache {
inner: Arc<RwLock<CacheInner>>,
jwks_url: String,
client: reqwest::Client,
}
struct CacheInner {
jwks: Option<JwkSet>,
last_refresh: Option<Instant>,
}
impl JwksCache {
pub fn new(issuer_url: &str) -> Self {
let jwks_url = format!(
"{}/.well-known/jwks.json",
issuer_url.trim_end_matches('/')
);
Self {
inner: Arc::new(RwLock::new(CacheInner {
jwks: None,
last_refresh: None,
})),
jwks_url,
client: reqwest::Client::new(),
}
}
pub async fn get_jwks(&self) -> Result<JwkSet, JwksError> {
// Check if we have a cached, non-stale JWKS
{
let cache = self.inner.read().await;
if let (Some(jwks), Some(last_refresh)) = (&cache.jwks, &cache.last_refresh) {
if last_refresh.elapsed() < REFRESH_INTERVAL {
return Ok(jwks.clone());
}
}
}
// Need to refresh
self.refresh().await
}
async fn refresh(&self) -> Result<JwkSet, JwksError> {
info!(url = %self.jwks_url, "Fetching JWKS");
let response = self
.client
.get(&self.jwks_url)
.send()
.await
.map_err(|e| JwksError::Fetch(e.to_string()))?;
if !response.status().is_success() {
return Err(JwksError::Fetch(format!(
"JWKS endpoint returned status {}",
response.status()
)));
}
let jwks: JwkSet = response
.json()
.await
.map_err(|e| JwksError::Parse(e.to_string()))?;
info!(keys = jwks.keys.len(), "JWKS refreshed");
let mut cache = self.inner.write().await;
cache.jwks = Some(jwks.clone());
cache.last_refresh = Some(Instant::now());
Ok(jwks)
}
/// Spawn a background task that periodically refreshes the JWKS cache.
pub fn spawn_refresh_task(&self) {
let cache = self.clone();
tokio::spawn(async move {
loop {
tokio::time::sleep(REFRESH_INTERVAL).await;
if let Err(e) = cache.refresh().await {
warn!(error = %e, "Background JWKS refresh failed");
}
}
});
}
}
#[derive(Debug, thiserror::Error)]
pub enum JwksError {
#[error("Failed to fetch JWKS: {0}")]
Fetch(String),
#[error("Failed to parse JWKS: {0}")]
Parse(String),
}

View file

@ -0,0 +1,3 @@
pub mod claims;
pub mod jwks;
pub mod middleware;

View file

@ -0,0 +1,121 @@
use axum::extract::{FromRef, FromRequestParts};
use axum::http::request::Parts;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use jsonwebtoken::{decode, decode_header, DecodingKey, Validation};
use serde::Serialize;
use tracing::warn;
use crate::claims::PvmClaims;
use crate::jwks::JwksCache;
/// Axum extractor that validates a JWT Bearer token and yields PvmClaims.
///
/// Usage in a handler:
/// ```ignore
/// async fn profile(AuthUser(claims): AuthUser) -> impl IntoResponse { ... }
/// ```
pub struct AuthUser(pub PvmClaims);
/// Shared auth state that must be added to the Axum router state.
#[derive(Clone)]
pub struct AuthState {
pub jwks_cache: JwksCache,
pub audience: String,
pub issuer: String,
}
#[derive(Debug)]
pub enum AuthError {
MissingHeader,
InvalidHeader,
InvalidToken(String),
JwksError(String),
}
#[derive(Serialize)]
struct AuthErrorBody {
error: String,
}
impl IntoResponse for AuthError {
fn into_response(self) -> Response {
let msg = match &self {
AuthError::MissingHeader => "Missing Authorization header",
AuthError::InvalidHeader => "Invalid Authorization header format",
AuthError::InvalidToken(e) => e.as_str(),
AuthError::JwksError(e) => e.as_str(),
};
warn!(error = msg, "Auth rejection");
let body = AuthErrorBody {
error: msg.to_string(),
};
(StatusCode::UNAUTHORIZED, axum::Json(body)).into_response()
}
}
impl<S> FromRequestParts<S> for AuthUser
where
S: Send + Sync,
AuthState: axum::extract::FromRef<S>,
{
type Rejection = AuthError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let auth_state = AuthState::from_ref(state);
// Extract the Bearer token
let auth_header = parts
.headers
.get(axum::http::header::AUTHORIZATION)
.ok_or(AuthError::MissingHeader)?
.to_str()
.map_err(|_| AuthError::InvalidHeader)?;
let token = auth_header
.strip_prefix("Bearer ")
.ok_or(AuthError::InvalidHeader)?;
// Decode the JWT header to get the key ID (kid)
let header =
decode_header(token).map_err(|e| AuthError::InvalidToken(e.to_string()))?;
let kid = header.kid.ok_or_else(|| {
AuthError::InvalidToken("Token header missing 'kid'".into())
})?;
// Fetch JWKS and find the matching key
let jwks: jsonwebtoken::jwk::JwkSet = auth_state
.jwks_cache
.get_jwks()
.await
.map_err(|e: crate::jwks::JwksError| AuthError::JwksError(e.to_string()))?;
let jwk = jwks
.keys
.iter()
.find(|k| k.common.key_id.as_deref() == Some(&kid))
.ok_or_else(|| {
AuthError::InvalidToken(format!("No JWK found for kid '{kid}'"))
})?;
// Build the decoding key from the JWK
let decoding_key = DecodingKey::from_jwk(jwk)
.map_err(|e| AuthError::InvalidToken(e.to_string()))?;
// Validate the token
let mut validation = Validation::new(
header.alg,
);
validation.set_audience(&[&auth_state.audience]);
validation.set_issuer(&[&auth_state.issuer]);
let token_data = decode::<PvmClaims>(token, &decoding_key, &validation)
.map_err(|e| AuthError::InvalidToken(e.to_string()))?;
Ok(AuthUser(token_data.claims))
}
}

View file

@ -0,0 +1,13 @@
[package]
name = "pvm-core"
version.workspace = true
edition.workspace = true
[dependencies]
pvm-types = { path = "../pvm-types" }
axum.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
uuid.workspace = true
chrono.workspace = true

View file

@ -0,0 +1,41 @@
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde::Serialize;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum PvmError {
#[error("Authentication required")]
Unauthorized,
#[error("Insufficient permissions")]
Forbidden,
#[error("Resource not found: {0}")]
NotFound(String),
#[error("Internal error: {0}")]
Internal(String),
}
#[derive(Serialize)]
struct ErrorBody {
error: String,
}
impl IntoResponse for PvmError {
fn into_response(self) -> Response {
let status = match &self {
PvmError::Unauthorized => StatusCode::UNAUTHORIZED,
PvmError::Forbidden => StatusCode::FORBIDDEN,
PvmError::NotFound(_) => StatusCode::NOT_FOUND,
PvmError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
};
let body = ErrorBody {
error: self.to_string(),
};
(status, axum::Json(body)).into_response()
}
}

View file

@ -0,0 +1 @@
pub mod error;

View file

@ -0,0 +1,10 @@
[package]
name = "pvm-types"
version.workspace = true
edition.workspace = true
[dependencies]
serde.workspace = true
chrono.workspace = true
uuid.workspace = true
utoipa.workspace = true

View file

@ -0,0 +1 @@
pub mod user;

View file

@ -0,0 +1,15 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UserProfile {
pub id: Uuid,
pub email: String,
pub name: Option<String>,
pub picture: Option<String>,
pub email_verified: bool,
pub mfa_enabled: bool,
pub created_at: DateTime<Utc>,
}

7
docker/.env.example Normal file
View file

@ -0,0 +1,7 @@
# Zitadel
ZITADEL_MASTERKEY=a-32-character-masterkey-for-dev!
ZITADEL_DB_PASSWORD=zitadel-dev-password
ZITADEL_ADMIN_PASSWORD=Admin1234!
# PVM Application Database
PVM_DB_PASSWORD=pvm-dev-password

75
docker/README.md Normal file
View file

@ -0,0 +1,75 @@
# PVM Docker Dev Environment
Local development stack with Zitadel auth, PostgreSQL, and DragonflyDB.
## Services
| Service | Description | Port |
|---------|-------------|------|
| **zitadel** | Zitadel v3 identity provider (OIDC/OAuth2) | 8080 |
| **zitadel-db** | PostgreSQL 16 for Zitadel (internal, not exposed) | — |
| **pvm-db** | PostgreSQL 16 for PVM application data | 5432 |
| **dragonfly** | DragonflyDB (Redis-compatible cache) | 6379 |
## Quick Start
```bash
# Copy env file and adjust if needed
cp .env.example .env
# Start all services
docker compose -f docker-compose.dev.yml up -d
# Check status
docker compose -f docker-compose.dev.yml ps
# View Zitadel logs (first startup takes ~30-60s)
docker compose -f docker-compose.dev.yml logs -f zitadel
```
## Zitadel Admin Console
Once Zitadel finishes initializing (watch the logs for "server is listening"), open:
- **Console URL:** http://localhost:8080/ui/console
- **Username:** `admin`
- **Password:** value of `ZITADEL_ADMIN_PASSWORD` in your `.env` (default: `Admin1234!`)
## First-Time Zitadel Setup
After the first `docker compose up`, configure Zitadel for PVM:
1. **Log in** to the admin console at http://localhost:8080/ui/console
2. **Create a project** called "PVM"
3. **Create an application** within the project:
- Name: "PVM Web"
- Type: Web
- Auth method: PKCE (recommended for SvelteKit)
- Redirect URIs: `http://localhost:5173/auth/callback/zitadel`
- Post-logout URIs: `http://localhost:5173`
4. **Note the Client ID** — you'll need it for SvelteKit's `AUTH_ZITADEL_ID`
5. (Optional) **Configure social login** providers under Settings > Identity Providers:
- Google, Apple, Facebook — each requires an OAuth app from the respective developer console
## Connecting from the PVM Backend
```
# PostgreSQL (PVM app database)
DATABASE_URL=postgres://pvm:pvm-dev-password@localhost:5432/pvm
# DragonflyDB (Redis-compatible)
REDIS_URL=redis://localhost:6379
# Zitadel issuer (for OIDC/JWT validation)
ZITADEL_URL=http://localhost:8080
```
## Stopping & Cleanup
```bash
# Stop services (data is preserved in volumes)
docker compose -f docker-compose.dev.yml down
# Stop and delete all data (fresh start)
docker compose -f docker-compose.dev.yml down -v
```

View file

@ -0,0 +1,73 @@
services:
zitadel:
image: ghcr.io/zitadel/zitadel:v3-latest
command: start-from-init --masterkey "${ZITADEL_MASTERKEY}" --tlsMode disabled
environment:
ZITADEL_DATABASE_POSTGRES_HOST: zitadel-db
ZITADEL_DATABASE_POSTGRES_PORT: 5432
ZITADEL_DATABASE_POSTGRES_DATABASE: zitadel
ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel
ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: "${ZITADEL_DB_PASSWORD}"
ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable
ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: zitadel
ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: "${ZITADEL_DB_PASSWORD}"
ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable
ZITADEL_EXTERNALDOMAIN: localhost
ZITADEL_EXTERNALPORT: 8080
ZITADEL_EXTERNALSECURE: "false"
ZITADEL_TLS_MODE: disabled
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME: admin
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD: "${ZITADEL_ADMIN_PASSWORD}"
ports:
- "8080:8080"
depends_on:
zitadel-db:
condition: service_healthy
restart: unless-stopped
zitadel-db:
image: postgres:16-alpine
environment:
POSTGRES_DB: zitadel
POSTGRES_USER: zitadel
POSTGRES_PASSWORD: "${ZITADEL_DB_PASSWORD}"
volumes:
- zitadel-pg-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U zitadel -d zitadel"]
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped
pvm-db:
image: postgres:16-alpine
environment:
POSTGRES_DB: pvm
POSTGRES_USER: pvm
POSTGRES_PASSWORD: "${PVM_DB_PASSWORD}"
ports:
- "5432:5432"
volumes:
- pvm-pg-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U pvm -d pvm"]
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped
dragonfly:
image: docker.dragonflydb.io/dragonflydb/dragonfly:latest
ports:
- "6379:6379"
volumes:
- dragonfly-data:/data
ulimits:
memlock: -1
restart: unless-stopped
volumes:
zitadel-pg-data:
pvm-pg-data:
dragonfly-data:

10
package.json Normal file
View file

@ -0,0 +1,10 @@
{
"name": "pvm",
"private": true,
"scripts": {
"dev": "turbo dev",
"build": "turbo build",
"test": "turbo test",
"lint": "turbo lint"
}
}

1612
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

5
pnpm-workspace.yaml Normal file
View file

@ -0,0 +1,5 @@
packages:
- "apps/*"
- "packages/*"
onlyBuiltDependencies:
- esbuild