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