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:
parent
e25afdcb3a
commit
c972926d31
47 changed files with 3324 additions and 0 deletions
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal 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
52
Cargo.toml
Normal 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"] }
|
||||
16
apps/dashboard/.env.example
Normal file
16
apps/dashboard/.env.example
Normal 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
23
apps/dashboard/.gitignore
vendored
Normal 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
1
apps/dashboard/.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
engine-strict=true
|
||||
42
apps/dashboard/README.md
Normal file
42
apps/dashboard/README.md
Normal 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.
|
||||
30
apps/dashboard/package.json
Normal file
30
apps/dashboard/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
38
apps/dashboard/src/app.css
Normal file
38
apps/dashboard/src/app.css
Normal 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
27
apps/dashboard/src/app.d.ts
vendored
Normal 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 {};
|
||||
11
apps/dashboard/src/app.html
Normal file
11
apps/dashboard/src/app.html
Normal 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>
|
||||
45
apps/dashboard/src/auth.ts
Normal file
45
apps/dashboard/src/auth.ts
Normal 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
|
||||
});
|
||||
1
apps/dashboard/src/hooks.server.ts
Normal file
1
apps/dashboard/src/hooks.server.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { handle } from './auth';
|
||||
62
apps/dashboard/src/lib/api.ts
Normal file
62
apps/dashboard/src/lib/api.ts
Normal 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);
|
||||
}
|
||||
1
apps/dashboard/src/lib/assets/favicon.svg
Normal file
1
apps/dashboard/src/lib/assets/favicon.svg
Normal 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 |
1
apps/dashboard/src/lib/index.ts
Normal file
1
apps/dashboard/src/lib/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
// place files you want to import through the `$lib` alias in this folder.
|
||||
6
apps/dashboard/src/routes/+layout.server.ts
Normal file
6
apps/dashboard/src/routes/+layout.server.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = async (event) => {
|
||||
const session = await event.locals.auth();
|
||||
return { session };
|
||||
};
|
||||
13
apps/dashboard/src/routes/+layout.svelte
Normal file
13
apps/dashboard/src/routes/+layout.svelte
Normal 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()}
|
||||
90
apps/dashboard/src/routes/+page.svelte
Normal file
90
apps/dashboard/src/routes/+page.svelte
Normal 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>
|
||||
83
apps/dashboard/src/routes/auth/signin/+page.svelte
Normal file
83
apps/dashboard/src/routes/auth/signin/+page.svelte
Normal 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">← Back to home</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
10
apps/dashboard/src/routes/dashboard/+layout.server.ts
Normal file
10
apps/dashboard/src/routes/dashboard/+layout.server.ts
Normal 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 };
|
||||
};
|
||||
151
apps/dashboard/src/routes/dashboard/+layout.svelte
Normal file
151
apps/dashboard/src/routes/dashboard/+layout.svelte
Normal 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>
|
||||
116
apps/dashboard/src/routes/dashboard/+page.svelte
Normal file
116
apps/dashboard/src/routes/dashboard/+page.svelte
Normal 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>
|
||||
103
apps/dashboard/src/routes/dashboard/account/+page.svelte
Normal file
103
apps/dashboard/src/routes/dashboard/account/+page.svelte
Normal 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>
|
||||
3
apps/dashboard/static/robots.txt
Normal file
3
apps/dashboard/static/robots.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
13
apps/dashboard/svelte.config.js
Normal file
13
apps/dashboard/svelte.config.js
Normal 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;
|
||||
20
apps/dashboard/tsconfig.json
Normal file
20
apps/dashboard/tsconfig.json
Normal 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
|
||||
}
|
||||
7
apps/dashboard/vite.config.ts
Normal file
7
apps/dashboard/vite.config.ts
Normal 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
26
crates/pvm-api/Cargo.toml
Normal 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
113
crates/pvm-api/src/main.rs
Normal 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");
|
||||
}
|
||||
59
crates/pvm-api/src/routes.rs
Normal file
59
crates/pvm-api/src/routes.rs
Normal 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)
|
||||
}
|
||||
17
crates/pvm-auth/Cargo.toml
Normal file
17
crates/pvm-auth/Cargo.toml
Normal 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
|
||||
24
crates/pvm-auth/src/claims.rs
Normal file
24
crates/pvm-auth/src/claims.rs
Normal 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
105
crates/pvm-auth/src/jwks.rs
Normal 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),
|
||||
}
|
||||
3
crates/pvm-auth/src/lib.rs
Normal file
3
crates/pvm-auth/src/lib.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
pub mod claims;
|
||||
pub mod jwks;
|
||||
pub mod middleware;
|
||||
121
crates/pvm-auth/src/middleware.rs
Normal file
121
crates/pvm-auth/src/middleware.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
13
crates/pvm-core/Cargo.toml
Normal file
13
crates/pvm-core/Cargo.toml
Normal 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
|
||||
41
crates/pvm-core/src/error.rs
Normal file
41
crates/pvm-core/src/error.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
1
crates/pvm-core/src/lib.rs
Normal file
1
crates/pvm-core/src/lib.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub mod error;
|
||||
10
crates/pvm-types/Cargo.toml
Normal file
10
crates/pvm-types/Cargo.toml
Normal 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
|
||||
1
crates/pvm-types/src/lib.rs
Normal file
1
crates/pvm-types/src/lib.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub mod user;
|
||||
15
crates/pvm-types/src/user.rs
Normal file
15
crates/pvm-types/src/user.rs
Normal 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
7
docker/.env.example
Normal 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
75
docker/README.md
Normal 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
|
||||
```
|
||||
73
docker/docker-compose.dev.yml
Normal file
73
docker/docker-compose.dev.yml
Normal 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
10
package.json
Normal 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
1612
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
5
pnpm-workspace.yaml
Normal file
5
pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
packages:
|
||||
- "apps/*"
|
||||
- "packages/*"
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
Loading…
Add table
Reference in a new issue