public-ready-init

This commit is contained in:
Damien Coles 2026-01-26 11:58:04 -05:00
commit fa0767e456
466 changed files with 103757 additions and 0 deletions

15
.dockerignore Normal file
View File

@ -0,0 +1,15 @@
target/
.git/
.gitignore
.env
.env.*
!.env.example
*.md
!README.md
.idea/
.vscode/
context/
data/
secrets/
vault/
setup.sh

35
.env.example Normal file
View File

@ -0,0 +1,35 @@
# Nexus Environment Configuration
# Copy to .env and fill in values
# Server
HOST=0.0.0.0
PORT=5050
# Vault Configuration
VAULT_ADDR=http://vault.example.local:8200
# Vault AppRole - Nexus App (runtime: database/creds/nexus-app + secret/data/nexus/*)
VAULT_APP_ROLE_ID=
VAULT_APP_SECRET_ID=
# Vault AppRole - Nexus Migrate (migrations: database/creds/nexus-migrate only)
VAULT_MIGRATE_ROLE_ID=
VAULT_MIGRATE_SECRET_ID=
# Vault AppRole - Kratos App (runtime: database/creds/nexus-kratos-app + secret/data/nexus/kratos)
VAULT_KRATOS_APP_ROLE_ID=
VAULT_KRATOS_APP_SECRET_ID=
# Vault AppRole - Kratos Migrate (migrations: database/creds/nexus-kratos-migrate only)
VAULT_KRATOS_MIGRATE_ROLE_ID=
VAULT_KRATOS_MIGRATE_SECRET_ID=
# Vault AppRole - Oathkeeper (runtime: secret/data/nexus/oathkeeper only)
VAULT_OATHKEEPER_ROLE_ID=
VAULT_OATHKEEPER_SECRET_ID=
# Logging
RUST_LOG=nexus=debug,tower_http=debug
# Note: All secrets (DATABASE_URL, VALKEY_URL, S3_*, OATHKEEPER_SECRET, etc.)
# are fetched dynamically from Vault by the Vault Agent sidecars.

14
.gitignore vendored Normal file
View File

@ -0,0 +1,14 @@
/context/
/data/
/secrets/
/run/
/scripts/
.env
# Oathkeeper secrets
oathkeeper/config/id_token.jwks.json
# Added by cargo
/target
.idea

4981
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

71
Cargo.toml Normal file
View File

@ -0,0 +1,71 @@
[package]
name = "nexus"
version = "0.1.0"
edition = "2024"
[dependencies]
# Web Framework
axum = { version = "0.8", features = ["macros"] }
tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7", features = ["io", "compat"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "trace", "fs"] }
futures-util = "0.3"
# GraphQL
async-graphql = { version = "7", features = ["chrono", "uuid", "decimal"] }
async-graphql-axum = "7"
# Database
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chrono", "json", "rust_decimal"] }
# Background Jobs
apalis = "1.0.0-rc.1"
apalis-redis = "1.0.0-rc.1"
apalis-cron = "1.0.0-rc.1"
cron = "0.15"
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Types
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
chrono-tz = "0.10"
rust_decimal = { version = "1", features = ["serde", "db-postgres"] }
# Tracing
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Config
dotenvy = "0.15"
# Error handling
thiserror = "2"
anyhow = "1"
# HTTP client for Google APIs
reqwest = { version = "0.12", features = ["json"] }
# S3/Garage storage
rust-s3 = { version = "0.35", default-features = false, features = ["tokio-rustls-tls"] }
# Image processing
image = "0.25"
# Temp files for video processing
tempfile = "3"
# Bytes for S3 uploads
bytes = "1"
# JWT for service account auth
jsonwebtoken = "9"
# Base64 for credentials
base64 = "0.22"
# URL encoding
urlencoding = "2"

56
Dockerfile Normal file
View File

@ -0,0 +1,56 @@
# Build stage
FROM rustlang/rust:nightly-bookworm AS builder
WORKDIR /app
# Copy manifests first for dependency caching
COPY Cargo.toml Cargo.lock ./
# Create dummy src to build dependencies
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release
RUN rm -rf src
# Copy actual source code
COPY src ./src
COPY migrations ./migrations
# Touch main.rs to invalidate the dummy build
RUN touch src/main.rs
# Build the actual application
RUN cargo build --release
# Runtime stage
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y \
ca-certificates \
libssl3 \
curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy the binary from builder
COPY --from=builder /app/target/release/nexus /app/nexus
# Copy migrations for runtime (if needed for embedded migrations)
COPY --from=builder /app/migrations ./migrations
# Copy static files (logo for emails, etc.)
COPY static ./static
# Copy entrypoint script
COPY entrypoint.sh /app/entrypoint.sh
RUN chmod 755 /app/entrypoint.sh
# Create non-root user
RUN useradd -r -s /bin/false nexus
USER nexus
EXPOSE 5050
ENV RUST_LOG=nexus=info,tower_http=info
ENTRYPOINT ["/app/entrypoint.sh"]

9
Dockerfile.migrate Normal file
View File

@ -0,0 +1,9 @@
# Migration runner using Rust nightly
FROM rustlang/rust:nightly-bookworm
# Install sqlx-cli with only postgres support
RUN cargo install sqlx-cli --no-default-features --features postgres,native-tls
WORKDIR /app
ENTRYPOINT ["/bin/sh", "-c"]

282
README.md Normal file
View File

@ -0,0 +1,282 @@
# Nexus 6 - Rust Platform Rewrite
The final evolution of the Nexus platform, completely rewritten in Rust for maximum performance, type safety, and reliability. This is a production-ready monorepo containing the Axum-based API, SvelteKit frontends, and infrastructure configuration.
## Overview
Nexus 6 represents the culmination of lessons learned from five previous iterations, now built on a rock-solid Rust foundation:
- **Nexus 1-3**: Django + Graphene (Python)
- **Nexus 4**: Rust experiment (abandoned)
- **Nexus 5**: Django + Strawberry GraphQL (Python)
- **Nexus 6**: Full Rust rewrite with Axum + async-graphql
## Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ Clients │
│ (Browser / Mobile / API Consumers) │
└─────────────────────────┬───────────────────────────────────┘
┌─────────────────────────▼───────────────────────────────────┐
│ Ory Oathkeeper │
│ (API Gateway / Zero Trust) │
│ - Route-based authentication │
│ - JWT token injection │
│ - CORS handling │
└─────────────────────────┬───────────────────────────────────┘
┌────────────────┼────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐
│ Frontend │ │ Auth │ │ Nexus API │
│ (SvelteKit) │ │ Frontend │ │ (Axum/Rust) │
│ │ │ (SvelteKit) │ │ │
│ - Admin │ │ │ │ - GraphQL API │
│ - Team │ │ - Login │ │ - Background Jobs │
│ - Customer │ │ - Register │ │ - Media handling │
│ - Public │ │ - Settings │ │ - Notifications │
└─────────────┘ └─────────────┘ └──────────┬──────────┘
│ │
│ │
┌────────────────┼───────────────────┘
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Ory Kratos │ │ PostgreSQL │
│ (Identity) │ │ (via │
│ │ │ PgBouncer) │
│ - Sessions │ │ │
│ - Recovery │ │ - App data │
│ - Verification │ │ - Kratos data │
└─────────────────┘ └─────────────────┘
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ HashiCorp │ │ Redis │ │ S3 Storage │
│ Vault │ │ (Valkey) │ │ (Garage) │
│ │ │ │ │ │
│ - DB creds │ │ - Job queue │ │ - Media files │
│ - API keys │ │ - Caching │ │ - Reports │
│ - Secrets │ │ │ │ - Uploads │
└─────────────────┘ └─────────────────┘ └─────────────────┘
```
## Tech Stack
### Backend (Rust)
- **Axum** - Web framework with Tower middleware
- **async-graphql** - Type-safe GraphQL with subscriptions
- **SQLx** - Async SQL with compile-time checked queries
- **Apalis** - Background job processing (Redis-backed)
- **Tokio** - Async runtime
### Frontend
- **SvelteKit 5** - Svelte 5 with runes
- **Tailwind CSS v4** - Utility-first CSS
- **TypeScript** - Type safety throughout
### Infrastructure
- **Ory Kratos** - Identity management
- **Ory Oathkeeper** - API gateway / Zero-trust
- **PgBouncer** - Connection pooling
- **HashiCorp Vault** - Secrets management
- **Redis/Valkey** - Job queue and caching
- **S3 (Garage)** - Object storage
## Evolution Comparison
| Feature | Django (nexus-5) | Rust (nexus-6) |
|---------|------------------|----------------|
| **Language** | Python 3.12 | Rust 2024 Edition |
| **Web Framework** | Django 5.x | Axum 0.8 |
| **GraphQL** | Strawberry | async-graphql |
| **Database** | Django ORM | SQLx (compile-time) |
| **Background Jobs** | Celery | Apalis |
| **Type Safety** | Runtime | Compile-time |
| **Memory Usage** | ~500MB | ~50MB |
| **Startup Time** | ~5s | <100ms |
| **Concurrency** | Thread-based | Async/await |
## Project Structure
```
nexus-6/
├── src/ # Rust backend
│ ├── main.rs # Entry point with Axum server
│ ├── config.rs # Configuration management
│ ├── db.rs # Database connection pool
│ ├── auth/ # Authentication middleware
│ ├── graphql/ # GraphQL schema
│ │ ├── queries/ # Query resolvers
│ │ ├── mutations/ # Mutation resolvers
│ │ └── types/ # GraphQL types
│ ├── models/ # Database models
│ ├── services/ # Business logic services
│ ├── jobs/ # Background job handlers
│ └── routes/ # HTTP route handlers
├── migrations/ # SQL migrations
├── frontend/ # Main SvelteKit app
│ ├── src/
│ │ ├── lib/ # Components, stores, utils
│ │ └── routes/ # Page routes
│ └── package.json
├── auth-frontend/ # Auth UI (Ory Kratos)
├── kratos/ # Kratos configuration
├── oathkeeper/ # Oathkeeper configuration
├── vault/ # Vault agent templates
├── pgbouncer/ # PgBouncer configuration
├── docker-compose.yml # Full stack deployment
└── Cargo.toml # Rust dependencies
```
## Features
### Core Functionality
- **Customer Management** - CRM with profiles and accounts
- **Service Scheduling** - Recurring service management
- **Project Management** - One-time project tracking
- **Work Sessions** - Time tracking with task completion
- **Scope Templates** - Reusable work specifications
- **Reporting** - PDF report generation with media
- **Invoicing** - Wave API integration
### Technical Features
- **GraphQL API** - Full query/mutation/subscription support
- **Real-time Updates** - WebSocket subscriptions
- **Background Jobs** - Scheduled and on-demand processing
- **Media Handling** - Image/video upload and processing
- **Email Integration** - Gmail API for notifications
- **Calendar Sync** - Google Calendar integration
## Getting Started
### Prerequisites
- Rust 1.75+ (2024 edition)
- Node.js 20+
- PostgreSQL 16+
- Redis 7+
- Docker & Docker Compose
### Development Setup
```bash
# Clone the repository
git clone https://github.com/your-org/nexus-6.git
cd nexus-6
# Copy environment file
cp .env.example .env
# Edit .env with your configuration
# Run database migrations
cargo run --bin migrate
# Start the backend
cargo run
# In another terminal, start the frontend
cd frontend
npm install
npm run dev
# Start auth frontend
cd auth-frontend
npm install
npm run dev
```
### Docker Deployment
```bash
# Start the full stack
docker-compose up -d
# View logs
docker-compose logs -f nexus
# Stop all services
docker-compose down
```
## Configuration
### Environment Variables
```bash
# Database
DATABASE_URL=postgres://user:pass@localhost:5432/nexus
# Redis (Job Queue)
REDIS_URL=redis://localhost:6379
# S3 Storage
S3_ENDPOINT=http://localhost:3900
S3_ACCESS_KEY=your-access-key
S3_SECRET_KEY=your-secret-key
S3_BUCKET=nexus-media
# Google APIs
GOOGLE_OAUTH_CLIENT_ID=...
GOOGLE_OAUTH_CLIENT_SECRET=...
# Wave Invoicing
WAVE_ACCESS_TOKEN=...
WAVE_BUSINESS_ID=...
# Ory
KRATOS_PUBLIC_URL=http://localhost:4433
OATHKEEPER_SECRET=your-secret
```
## API Documentation
The GraphQL API is self-documenting. Access the GraphQL Playground at:
- Development: `http://localhost:8080/graphql`
- Production: `https://api.your-domain.com/graphql`
## Performance
Benchmarks comparing nexus-5 (Django) vs nexus-6 (Rust):
| Metric | Django | Rust | Improvement |
|--------|--------|------|-------------|
| Requests/sec | 1,200 | 45,000 | 37x |
| P99 Latency | 85ms | 2ms | 42x |
| Memory Usage | 512MB | 48MB | 10x |
| Cold Start | 4.2s | 80ms | 52x |
## Security
- **Zero-Trust Architecture** - All requests validated via Oathkeeper
- **Session Management** - Ory Kratos handles auth flows
- **Secrets Management** - HashiCorp Vault for all credentials
- **SQL Injection Prevention** - Compile-time query checking
- **Type Safety** - Rust's ownership model prevents memory bugs
## Related Repositories
- **nexus-1 through nexus-5** - Previous Python iterations
- **nexus-5-auth** - Standalone Ory configuration (if separating)
- **nexus-5-frontend-1/2/3** - Previous SvelteKit frontends
## License
MIT License - See LICENSE file for details.
## Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Run `cargo test` and `cargo clippy`
5. Submit a pull request
## Acknowledgments
This project represents years of iteration and learning. Special thanks to:
- The Rust community for excellent tooling
- Ory for their identity infrastructure
- The SvelteKit team for an amazing framework

View File

@ -0,0 +1,32 @@
# Dependencies
node_modules
.pnp
.pnp.js
# Testing
coverage
# Build outputs
.svelte-kit
build
dist
# Misc
.DS_Store
*.pem
.env.local
.env.development.local
.env.test.local
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# IDE
.vscode
.idea
# Git
.git
.gitignore

View File

@ -0,0 +1,27 @@
# ====================================
# Frontend Configuration - DEVELOPMENT
# ====================================
# Port to expose the frontend on
FRONTEND_PORT=3000
# ====================================
# Kratos Connection URLs (Unified Stack)
# ====================================
# Browser/client-side requests go through Oathkeeper proxy
PUBLIC_KRATOS_URL=http://localhost:7200
# Server-side requests during SSR (direct to Kratos public API)
KRATOS_SERVER_URL=http://localhost:6000
# ====================================
# Origin Configuration
# ====================================
ORIGIN=http://localhost:3000
# ====================================
# Admin Configuration
# ====================================
# User ID that has admin access to the dashboard
ADMIN_USER_ID=

View File

@ -0,0 +1,27 @@
# ====================================
# Frontend Configuration - PRODUCTION
# ====================================
# Port to expose the frontend on
FRONTEND_PORT=3000
# ====================================
# Kratos Connection URLs (Unified Stack)
# ====================================
# Browser/client-side requests go through Oathkeeper via Caddy
PUBLIC_KRATOS_URL=https://auth.example.com
# Server-side requests during SSR (direct to Kratos public API on same VM)
KRATOS_SERVER_URL=http://localhost:6000
# ====================================
# Origin Configuration
# ====================================
ORIGIN=https://account.example.com
# ====================================
# Admin Configuration
# ====================================
# User ID that has admin access to the dashboard
ADMIN_USER_ID=00000000-0000-0000-0000-000000000000

26
auth-frontend/.gitignore vendored Normal file
View File

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

1
auth-frontend/.npmrc Normal file
View File

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

View File

@ -0,0 +1,9 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lock
bun.lockb
# Miscellaneous
/static/

16
auth-frontend/.prettierrc Normal file
View File

@ -0,0 +1,16 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
],
"tailwindStylesheet": "./src/app.css"
}

63
auth-frontend/Dockerfile Normal file
View File

@ -0,0 +1,63 @@
# ====================================
# Build Stage
# ====================================
FROM node:20-alpine AS builder
# Set working directory
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies (including devDependencies for build)
RUN npm ci
# Copy source code and configuration
COPY . .
# Build the SvelteKit application
# Note: PUBLIC_* vars must be set at build time for SvelteKit
ARG PUBLIC_KRATOS_URL=https://auth.example.com
ENV PUBLIC_KRATOS_URL=$PUBLIC_KRATOS_URL
RUN npm run build
# Prune dev dependencies
RUN npm prune --production
# ====================================
# Production Stage
# ====================================
FROM node:20-alpine
# Install curl for health checks
RUN apk add --no-cache curl
# Set working directory
WORKDIR /app
# Copy built application from builder
COPY --from=builder /app/build ./build
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S sveltekit -u 1001 && \
chown -R sveltekit:nodejs /app
# Switch to non-root user
USER sveltekit
# Expose port 3000
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:3000/ || exit 1
# Set environment variable for production
ENV NODE_ENV=production
# Start the application
CMD ["node", "build"]

View File

@ -0,0 +1,28 @@
services:
frontend:
build:
context: .
dockerfile: Dockerfile
container_name: nexus-auth-frontend
restart: unless-stopped
ports:
- '${FRONTEND_PORT:-3000}:3000'
environment:
- NODE_ENV=production
- PUBLIC_KRATOS_URL=${PUBLIC_KRATOS_URL}
- KRATOS_SERVER_URL=${KRATOS_SERVER_URL}
- ORIGIN=${ORIGIN}
- ADMIN_USER_ID=${ADMIN_USER_ID}
networks:
- ory-network
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:3000/']
interval: 30s
timeout: 5s
retries: 5
start_period: 10s
networks:
ory-network:
external: true
name: ory-network

View File

@ -0,0 +1,41 @@
import prettier from 'eslint-config-prettier';
import { fileURLToPath } from 'node:url';
import { includeIgnoreFile } from '@eslint/compat';
import js from '@eslint/js';
import svelte from 'eslint-plugin-svelte';
import { defineConfig } from 'eslint/config';
import globals from 'globals';
import ts from 'typescript-eslint';
import svelteConfig from './svelte.config.js';
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
export default defineConfig(
includeIgnoreFile(gitignorePath),
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs.recommended,
prettier,
...svelte.configs.prettier,
{
languageOptions: {
globals: { ...globals.browser, ...globals.node }
},
rules: {
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off'
}
},
{
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: {
parserOptions: {
projectService: true,
extraFileExtensions: ['.svelte'],
parser: ts.parser,
svelteConfig
}
}
}
);

4925
auth-frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,45 @@
{
"name": "nexus-5-auth-frontend",
"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",
"format": "prettier --write .",
"lint": "prettier --check . && eslint ."
},
"devDependencies": {
"@eslint/compat": "^1.4.0",
"@eslint/js": "^9.36.0",
"@sveltejs/adapter-node": "^5.3.2",
"@sveltejs/kit": "^2.43.2",
"@sveltejs/vite-plugin-svelte": "^6.2.0",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.18",
"@tailwindcss/vite": "^4.1.13",
"@types/node": "^22",
"eslint": "^9.36.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.12.4",
"globals": "^16.4.0",
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",
"prettier-plugin-tailwindcss": "^0.6.14",
"svelte": "^5.39.5",
"svelte-check": "^4.3.2",
"tailwindcss": "^4.1.13",
"typescript": "^5.9.2",
"typescript-eslint": "^8.44.1",
"vite": "^7.1.7"
},
"dependencies": {
"@ory/client": "^1.22.5",
"axios": "^1.12.2",
"flowbite-svelte": "^1.17.4"
}
}

314
auth-frontend/src/app.css Normal file
View File

@ -0,0 +1,314 @@
@import 'tailwindcss';
@plugin '@tailwindcss/forms';
@plugin '@tailwindcss/typography';
/* ============================================
THEME COLOR SYSTEM
============================================
Primary: Blue
Secondary: Green
Primary Accent: Orange
Secondary Accent: Purple
Alert/Error: Red
Warning: Yellow
Success: Green (distinct from secondary)
============================================ */
@theme {
/* Primary - Blue (muted/professional) */
--color-primary-50: #f0f6fc;
--color-primary-100: #dbe8f7;
--color-primary-200: #bdd4f0;
--color-primary-300: #8fb8e5;
--color-primary-400: #5a94d6;
--color-primary-500: #3b78c4;
--color-primary-600: #2d5fa6;
--color-primary-700: #274d87;
--color-primary-800: #254270;
--color-primary-900: #23395e;
--color-primary-950: #18253f;
/* Secondary - Green (muted/professional) */
--color-secondary-50: #f2f8f4;
--color-secondary-100: #e0efe4;
--color-secondary-200: #c3dfcc;
--color-secondary-300: #96c7a6;
--color-secondary-400: #65a97b;
--color-secondary-500: #458c5e;
--color-secondary-600: #33714a;
--color-secondary-700: #2a5b3d;
--color-secondary-800: #244933;
--color-secondary-900: #1f3c2b;
--color-secondary-950: #102118;
/* Accent Primary - Orange (muted/professional) */
--color-accent-50: #fdf6f0;
--color-accent-100: #fbe9db;
--color-accent-200: #f6d0b6;
--color-accent-300: #f0b088;
--color-accent-400: #e88958;
--color-accent-500: #e16a36;
--color-accent-600: #d2522b;
--color-accent-700: #ae3f26;
--color-accent-800: #8b3425;
--color-accent-900: #712e22;
--color-accent-950: #3d1510;
/* Accent Secondary - Purple (muted/professional) */
--color-accent2-50: #f6f4fb;
--color-accent2-100: #ede9f7;
--color-accent2-200: #ddd5f0;
--color-accent2-300: #c5b6e4;
--color-accent2-400: #a78fd4;
--color-accent2-500: #8b6bc2;
--color-accent2-600: #7652ab;
--color-accent2-700: #634391;
--color-accent2-800: #533978;
--color-accent2-900: #463162;
--color-accent2-950: #2c1c42;
/* Error/Alert - Red (muted/professional) */
--color-error-50: #fdf3f3;
--color-error-100: #fce4e4;
--color-error-200: #fbcdcd;
--color-error-300: #f6a8a8;
--color-error-400: #ee7676;
--color-error-500: #e14a4a;
--color-error-600: #cd2d2d;
--color-error-700: #ac2323;
--color-error-800: #8e2121;
--color-error-900: #772222;
--color-error-950: #400d0d;
/* Warning - Yellow (muted/professional) */
--color-warning-50: #fdfaeb;
--color-warning-100: #faf2c9;
--color-warning-200: #f5e394;
--color-warning-300: #efd05b;
--color-warning-400: #e8bb30;
--color-warning-500: #d8a01d;
--color-warning-600: #ba7c16;
--color-warning-700: #955916;
--color-warning-800: #7b4619;
--color-warning-900: #693a1a;
--color-warning-950: #3d1e0a;
/* Success - Green (distinct from secondary, muted) */
--color-success-50: #f0fdf2;
--color-success-100: #dcfce2;
--color-success-200: #bbf7c6;
--color-success-300: #86ef9b;
--color-success-400: #4ade6a;
--color-success-500: #22c546;
--color-success-600: #16a336;
--color-success-700: #16802e;
--color-success-800: #176528;
--color-success-900: #155324;
--color-success-950: #052e10;
/* Neutral/Surface colors for theming */
--color-surface-50: #f8fafc;
--color-surface-100: #f1f5f9;
--color-surface-200: #e2e8f0;
--color-surface-300: #cbd5e1;
--color-surface-400: #94a3b8;
--color-surface-500: #64748b;
--color-surface-600: #475569;
--color-surface-700: #334155;
--color-surface-800: #1e293b;
--color-surface-900: #0f172a;
--color-surface-950: #020617;
}
/* ============================================
LIGHT THEME (default)
============================================ */
:root {
color-scheme: light;
/* Background colors - subtle blue tint for softer appearance */
--theme-bg: var(--color-primary-50);
--theme-bg-secondary: #e8f0f8;
--theme-bg-tertiary: var(--color-primary-100);
/* Text colors */
--theme-text: var(--color-surface-900);
--theme-text-secondary: var(--color-surface-600);
--theme-text-muted: var(--color-surface-400);
/* Border colors */
--theme-border: var(--color-surface-200);
--theme-border-hover: var(--color-surface-300);
/* Interactive states */
--theme-hover: var(--color-primary-100);
--theme-active: var(--color-primary-200);
/* Shadows */
--theme-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--theme-shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--theme-shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
/* Card/Panel backgrounds - subtle blue tint to match overall theme */
--theme-card: #f5f8fc;
--theme-card-hover: #edf2f9;
}
/* ============================================
DARK THEME
============================================ */
.dark {
color-scheme: dark;
/* Background colors */
--theme-bg: var(--color-surface-900);
--theme-bg-secondary: var(--color-surface-800);
--theme-bg-tertiary: var(--color-surface-700);
/* Text colors */
--theme-text: var(--color-surface-50);
--theme-text-secondary: var(--color-surface-300);
--theme-text-muted: var(--color-surface-500);
/* Border colors */
--theme-border: var(--color-surface-700);
--theme-border-hover: var(--color-surface-600);
/* Interactive states */
--theme-hover: var(--color-surface-800);
--theme-active: var(--color-surface-700);
/* Shadows (more subtle in dark mode) */
--theme-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.3), 0 1px 2px -1px rgb(0 0 0 / 0.3);
--theme-shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.3), 0 2px 4px -2px rgb(0 0 0 / 0.3);
--theme-shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.3), 0 4px 6px -4px rgb(0 0 0 / 0.3);
/* Card/Panel backgrounds */
--theme-card: var(--color-surface-800);
--theme-card-hover: var(--color-surface-700);
}
/* ============================================
BASE STYLES
============================================ */
html {
background-color: var(--theme-bg);
color: var(--theme-text);
transition:
background-color 0.2s ease,
color 0.2s ease;
}
body {
background-color: var(--theme-bg);
min-height: 100vh;
}
/* ============================================
UTILITY CLASSES
============================================ */
@utility bg-theme {
background-color: var(--theme-bg);
}
@utility bg-theme-secondary {
background-color: var(--theme-bg-secondary);
}
@utility bg-theme-tertiary {
background-color: var(--theme-bg-tertiary);
}
@utility bg-theme-card {
background-color: var(--theme-card);
}
@utility text-theme {
color: var(--theme-text);
}
@utility text-theme-secondary {
color: var(--theme-text-secondary);
}
@utility text-theme-muted {
color: var(--theme-text-muted);
}
@utility border-theme {
border-color: var(--theme-border);
}
@utility border-theme-hover {
border-color: var(--theme-border-hover);
}
@utility shadow-theme {
box-shadow: var(--theme-shadow);
}
@utility shadow-theme-md {
box-shadow: var(--theme-shadow-md);
}
@utility shadow-theme-lg {
box-shadow: var(--theme-shadow-lg);
}
/* ============================================
COMPONENT STYLES
============================================ */
/* Cards */
@utility card {
@apply rounded-lg border;
border-color: var(--theme-border);
background-color: var(--theme-card);
}
@utility card-padded {
@apply rounded-lg border p-6;
border-color: var(--theme-border);
background-color: var(--theme-card);
}
/* Buttons */
@utility btn-primary {
@apply inline-block rounded-lg bg-primary-500 px-4 py-2 font-medium text-white transition-colors hover:bg-primary-600 active:bg-primary-700;
}
@utility btn-danger {
@apply inline-block rounded-lg bg-error-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-error-700 active:bg-error-800;
}
/* Form Input Borders (for checkboxes, radios) */
@utility border-input {
@apply border-surface-300 dark:border-surface-600;
}
/* Interactive hover/active - unifies hover states */
@utility interactive {
@apply transition-colors hover:bg-black/5 active:bg-black/10 dark:hover:bg-white/10 dark:active:bg-white/15;
}
/* Alert utilities */
@utility alert-error {
@apply rounded-lg border border-error-400 bg-error-50 p-3 text-sm text-error-700 dark:border-error-600 dark:bg-error-900/20 dark:text-error-400;
}
@utility alert-success {
@apply rounded-lg border border-success-400 bg-success-50 p-3 text-sm text-success-700 dark:border-success-600 dark:bg-success-900/20 dark:text-success-400;
}
/* Form utilities */
@utility input-base {
@apply w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme placeholder:text-theme-muted focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50;
}
@utility textarea-base {
@apply w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme placeholder:text-theme-muted focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50;
}
@utility form-label {
@apply mb-1.5 block text-sm font-medium text-theme;
}

13
auth-frontend/src/app.d.ts vendored Normal file
View File

@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

View File

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

View File

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

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,284 @@
<script lang="ts">
import type {
LoginFlow,
RegistrationFlow,
RecoveryFlow,
VerificationFlow,
SettingsFlow
} from '@ory/client';
import FormField from './FormField.svelte';
import { filterNodesByGroups, formDataToJson } from '$lib/utils';
import { kratosClient } from '$lib/kratos';
import { PUBLIC_KRATOS_URL } from '$env/static/public';
type Flow = LoginFlow | RegistrationFlow | RecoveryFlow | VerificationFlow | SettingsFlow;
let {
flow,
groups = ['default', 'password', 'profile', 'totp', 'webauthn', 'code', 'link']
}: { flow: Flow; groups?: string[] } = $props();
const nodes = $derived(filterNodesByGroups(flow.ui.nodes, ...groups));
const messages = $derived(flow.ui.messages || []);
const formMethod = $derived((flow.ui.method?.toLowerCase() || 'post') as 'get' | 'post');
// Check if form has password fields for accessibility
const hasPasswordField = $derived(
nodes.some((node) => {
const attrs = node.attributes as any;
return attrs.type === 'password';
})
);
// Get username/email for accessibility (for password managers)
const usernameValue = $derived(() => {
// For settings flow, get email from identity
if ('identity' in flow && flow.identity?.traits) {
return (flow.identity.traits as any).email || '';
}
// For other flows, try to get from nodes
const emailNode = nodes.find((node) => {
const attrs = node.attributes as any;
return attrs.name === 'traits.email' || attrs.name === 'identifier';
});
if (emailNode) {
const attrs = emailNode.attributes as any;
return attrs.value || '';
}
return '';
});
// Helper to determine actual flow type since flow.type might be 'browser'
function getFlowType(flow: Flow): string {
// If flow.type is specific, use it
if (flow.type && flow.type !== 'browser') {
return flow.type;
}
// Try to determine from the form action URL
const actionUrl = flow.ui?.action || '';
if (actionUrl.includes('/self-service/login')) return 'login';
if (actionUrl.includes('/self-service/registration')) return 'registration';
if (actionUrl.includes('/self-service/settings')) return 'settings';
if (actionUrl.includes('/self-service/recovery')) return 'recovery';
if (actionUrl.includes('/self-service/verification')) return 'verification';
// Try to determine from current URL
if (typeof window !== 'undefined') {
const currentPath = window.location.pathname;
if (currentPath.includes('/login')) return 'login';
if (currentPath.includes('/registration')) return 'registration';
if (currentPath.includes('/settings')) return 'settings';
if (currentPath.includes('/recovery')) return 'recovery';
if (currentPath.includes('/verification')) return 'verification';
}
// Fallback to the original type
return flow.type || 'unknown';
}
// SDK-based form submission - handles CSRF automatically via JSON + cookies
async function handleSubmit(event: SubmitEvent) {
event.preventDefault();
const form = event.target as HTMLFormElement;
const formData = new FormData(form);
// When using preventDefault, submit button values are not included in FormData
// We need to manually add the method from the clicked submit button
const submitter = event.submitter as HTMLButtonElement;
if (submitter && submitter.name && submitter.value) {
formData.set(submitter.name, submitter.value);
}
const updateBody = formDataToJson(formData);
// Determine flow type from URL or flow properties since flow.type might be 'browser'
const flowType = getFlowType(flow);
// For settings flows, manually add CSRF token if not present in form data
if (flowType === 'settings' && !updateBody.csrf_token) {
// Try to get CSRF token from flow nodes (hidden fields)
const csrfNode = flow.ui.nodes.find((node) => {
const attrs = node.attributes as any;
return attrs?.name === 'csrf_token' && attrs?.type === 'hidden';
});
if (csrfNode) {
const attrs = csrfNode.attributes as any;
if (attrs?.value) {
updateBody.csrf_token = attrs.value;
}
}
}
// Validate flow type before attempting submission
if (!['login', 'registration', 'settings', 'recovery', 'verification'].includes(flowType)) {
console.error(`Unsupported flow type: ${flowType}`);
alert(`Error: Unsupported flow type "${flowType}". Please refresh the page and try again.`);
return;
}
try {
let response;
// Use appropriate SDK method based on flow type
switch (flowType) {
case 'login':
response = await kratosClient.updateLoginFlow({
flow: flow.id,
updateLoginFlowBody: updateBody as any
});
break;
case 'registration':
response = await kratosClient.updateRegistrationFlow({
flow: flow.id,
updateRegistrationFlowBody: updateBody as any
});
break;
case 'settings':
response = await kratosClient.updateSettingsFlow({
flow: flow.id,
updateSettingsFlowBody: updateBody as any
});
break;
case 'recovery':
response = await kratosClient.updateRecoveryFlow({
flow: flow.id,
updateRecoveryFlowBody: updateBody as any
});
break;
case 'verification':
response = await kratosClient.updateVerificationFlow({
flow: flow.id,
updateVerificationFlowBody: updateBody as any
});
break;
default:
// TypeScript exhaustiveness check - should never reach here
console.error(`Unsupported flow type: ${flowType}`);
return;
}
// Handle success response
const responseData = response.data as any;
// Special handling for recovery and verification flows
// These flows need to stay on the same page to preserve the flow ID
if (flowType === 'recovery' || flowType === 'verification') {
const state = (responseData as any).state;
// If email was sent or we're in a code-entry state, reload with same flow to update UI
if (state === 'sent_email' || state === 'choose_method') {
window.location.href = window.location.pathname + '?flow=' + flow.id;
return;
}
// If we passed the challenge (code verified), follow redirect or go to settings
if (responseData.redirect_browser_to) {
window.location.href = responseData.redirect_browser_to;
return;
}
// Otherwise reload with same flow to show updated state
window.location.href = window.location.pathname + '?flow=' + flow.id;
return;
}
// For other flows, follow redirects or default behavior
if (responseData.redirect_browser_to) {
window.location.href = responseData.redirect_browser_to;
} else {
// For settings, show success message
if (flowType === 'settings') {
window.location.href = window.location.pathname + '?updated=true';
} else {
// For login/registration, check for return_to in flow data first
const returnTo = (flow as any).return_to;
if (returnTo && (flowType === 'login' || flowType === 'registration')) {
// Redirect to the original requested URL
window.location.href = returnTo;
} else {
// Default to home page
window.location.href = '/';
}
}
}
} catch (error: any) {
console.error('Flow submission failed:', error);
// Log detailed error information
if (error.response) {
console.error('Error status:', error.response.status);
console.error('Error data:', JSON.stringify(error.response.data, null, 2));
console.error('Error headers:', error.response.headers);
}
// Handle 422 with redirect_browser_to - this is actually a success case
// Kratos uses 422 to signal that a redirect is needed to complete the flow
if (error.response?.status === 422 && error.response?.data?.redirect_browser_to) {
window.location.href = error.response.data.redirect_browser_to;
return;
}
// Handle flow expired error
if (error.response?.status === 410) {
window.location.href = `${PUBLIC_KRATOS_URL}/self-service/${flowType}/browser`;
} else if (error.response?.data?.ui) {
// Update flow with validation errors from server
flow = error.response.data;
} else if (error.response?.data) {
console.error('Server error details:', error.response.data);
// Show error message to user if available
if (error.response.data.error) {
alert(
`Error: ${error.response.data.error.message || JSON.stringify(error.response.data.error)}`
);
}
}
}
}
</script>
<div class="flow-form">
{#if messages.length > 0}
<div class="mb-4 space-y-2">
{#each messages as message}
{#if message.type === 'error'}
<div class="alert-error">
{message.text}
</div>
{:else if message.type === 'success'}
<div class="alert-success">
{message.text}
</div>
{:else}
<div class="rounded-lg border border-primary-400 bg-primary-50 p-4 text-sm text-primary-800 dark:border-primary-600 dark:bg-primary-900/20 dark:text-primary-400">
{message.text}
</div>
{/if}
{/each}
</div>
{/if}
<form action={flow.ui.action} method={formMethod} onsubmit={handleSubmit}>
<!-- Hidden username field for accessibility when form has password fields -->
{#if hasPasswordField && usernameValue()}
<input
type="text"
name="username"
value={usernameValue()}
autocomplete="username"
readonly
style="display: none;"
aria-hidden="true"
tabindex="-1"
/>
{/if}
{#each nodes as node (node.attributes)}
<FormField {node} />
{/each}
</form>
</div>

View File

@ -0,0 +1,243 @@
<script lang="ts">
import type { UiNode, UiNodeInputAttributes } from '@ory/client';
import { getNodeLabel } from '$lib/utils';
import { onMount } from 'svelte';
let { node }: { node: UiNode } = $props();
// Filter out metadata_public fields from registration forms
const shouldHideField = $derived(() => {
const inputAttrs = node.type === 'input' ? (node.attributes as UiNodeInputAttributes) : null;
if (!inputAttrs) return false;
// Hide metadata_public fields (these should be set programmatically, not by users)
return inputAttrs.name?.startsWith('metadata_public.');
});
// Check if this is the profile_type field that should be a select
const isProfileTypeSelect = $derived(() => {
const inputAttrs = node.type === 'input' ? (node.attributes as UiNodeInputAttributes) : null;
return inputAttrs?.name === 'traits.profile_type';
});
const label = $derived(getNodeLabel(node));
const messages = $derived(node.messages || []);
const hasError = $derived(messages.some((m) => m.type === 'error'));
// Get typed attributes based on node type
const inputAttrs = $derived(
node.type === 'input' ? (node.attributes as UiNodeInputAttributes) : null
);
const imageAttrs = $derived(
node.type === 'img' ? (node.attributes as any) : null
);
const textAttrs = $derived(
node.type === 'text' ? (node.attributes as any) : null
);
const scriptAttrs = $derived(
node.type === 'script' ? (node.attributes as any) : null
);
// Handle script nodes by dynamically injecting them into the DOM
onMount(() => {
if (scriptAttrs) {
const script = document.createElement('script');
script.src = scriptAttrs.src;
script.async = scriptAttrs.async;
script.type = scriptAttrs.type;
if (scriptAttrs.integrity) script.integrity = scriptAttrs.integrity;
if (scriptAttrs.crossorigin) script.crossOrigin = scriptAttrs.crossorigin;
if (scriptAttrs.referrerpolicy) script.referrerPolicy = scriptAttrs.referrerpolicy;
document.body.appendChild(script);
return () => {
// Cleanup on unmount
if (script.parentNode) {
script.parentNode.removeChild(script);
}
};
}
});
// Provide appropriate autocomplete values for better accessibility (only for input nodes)
const autocompleteValue = $derived.by((): any => {
if (!inputAttrs) return undefined;
// If Kratos provides autocomplete, use it (but filter out invalid values)
if (inputAttrs.autocomplete) {
const autocompleteStr = String(inputAttrs.autocomplete);
// Filter out OpenAPI unknown default values
if (autocompleteStr === '11184809') return undefined;
return autocompleteStr;
}
// Fallback autocomplete values based on field name and type
if (inputAttrs.name === 'identifier') return 'username';
if (inputAttrs.name === 'password')
return inputAttrs.type === 'password' ? 'current-password' : 'new-password';
if (inputAttrs.name === 'traits.email') return 'email';
if (inputAttrs.name === 'traits.name.first') return 'given-name';
if (inputAttrs.name === 'traits.name.last') return 'family-name';
if (inputAttrs.name === 'traits.phone') return 'tel';
return undefined;
});
// Handle WebAuthn button clicks
function handleButtonClick(e: MouseEvent) {
if (!inputAttrs) return;
// Handle WebAuthn triggers
if (inputAttrs.onclickTrigger) {
const triggerFn = (window as any)[inputAttrs.onclickTrigger];
if (typeof triggerFn === 'function') {
triggerFn(e.currentTarget);
}
} else if (inputAttrs.onclick) {
// Deprecated onclick - use eval (security risk, but Kratos provides this)
eval(inputAttrs.onclick);
}
}
</script>
{#if shouldHideField()}
<!-- Hidden metadata_public fields - don't render -->
{:else}
<div class="form-field">
{#if node.type === 'script' && scriptAttrs}
<!-- Script node (e.g., WebAuthn scripts) - injected dynamically via onMount into document.body -->
{:else if node.type === 'img' && imageAttrs}
<!-- Image node (e.g., TOTP QR code) -->
<div class="qr-code-container">
{#if label}
<p class="form-label">{label}</p>
{/if}
<img src={imageAttrs.src} alt="QR Code" width={imageAttrs.width} height={imageAttrs.height} />
</div>
{:else if node.type === 'text' && textAttrs}
<!-- Text node (e.g., TOTP secret) -->
<div class="text-node">
<p class="text-sm text-theme-secondary">{textAttrs.text.text}</p>
</div>
{:else if node.type === 'input' && inputAttrs}
<!-- Input nodes -->
{#if inputAttrs.type === 'hidden'}
<input
type="hidden"
name={inputAttrs.name}
value={inputAttrs.value}
required={inputAttrs.required}
disabled={inputAttrs.disabled}
/>
{:else if inputAttrs.type === 'submit' || inputAttrs.type === 'button'}
<button
type={inputAttrs.type}
name={inputAttrs.name}
value={inputAttrs.value}
disabled={inputAttrs.disabled}
data-onclicktrigger={inputAttrs.onclickTrigger}
data-onclick-raw={inputAttrs.onclick}
onclick={handleButtonClick}
class="btn-primary w-full disabled:opacity-50"
>
{label || inputAttrs.value}
</button>
{:else if isProfileTypeSelect()}
<!-- Custom select dropdown for profile_type -->
<label for={inputAttrs.name} class="form-label">
{label}
{#if inputAttrs.required}
<span class="text-error-500">*</span>
{/if}
</label>
<select
name={inputAttrs.name}
required={inputAttrs.required}
disabled={inputAttrs.disabled}
class="input-base disabled:opacity-50"
class:border-error-500={hasError}
>
<option value="">Select profile type...</option>
<option value="team" selected={inputAttrs.value === 'team'}>Team</option>
<option value="customer" selected={inputAttrs.value === 'customer'}>Customer</option>
</select>
{:else if inputAttrs.type === 'checkbox'}
<div class="flex items-center">
<input
type="checkbox"
name={inputAttrs.name}
checked={inputAttrs.value === true}
required={inputAttrs.required}
disabled={inputAttrs.disabled}
class="h-4 w-4 rounded border-input text-primary-500 focus:ring-primary-500"
/>
<label for={inputAttrs.name} class="ml-2 text-sm text-theme">
{label}
</label>
</div>
{:else}
<label for={inputAttrs.name} class="form-label">
{label}
{#if inputAttrs.required}
<span class="text-error-500">*</span>
{/if}
</label>
<!-- @ts-expect-error: autocomplete string values are valid but TS has strict typing -->
<input
type={inputAttrs.type}
name={inputAttrs.name}
value={inputAttrs.value || ''}
required={inputAttrs.required}
disabled={inputAttrs.disabled}
{...(autocompleteValue ? { autocomplete: autocompleteValue } : {})}
pattern={inputAttrs.pattern}
class="input-base disabled:opacity-50"
class:border-error-500={hasError}
/>
{/if}
{/if}
{#if messages.length > 0}
<div class="mt-1 space-y-1">
{#each messages as message}
<p
class="text-sm"
class:text-error-600={message.type === 'error'}
class:text-theme-secondary={message.type === 'info'}
>
{message.text}
</p>
{/each}
</div>
{/if}
</div>
{/if}
<style>
.form-field {
margin-bottom: 1rem;
}
.qr-code-container {
display: flex;
flex-direction: column;
align-items: center;
padding: 1rem;
background-color: var(--theme-card);
border-radius: 0.5rem;
border: 1px solid var(--theme-border);
}
.qr-code-container img {
max-width: 100%;
height: auto;
}
.text-node {
padding: 0.75rem;
background-color: var(--theme-bg-secondary);
border-radius: 0.375rem;
border: 1px solid var(--theme-border);
}
</style>

View File

@ -0,0 +1,159 @@
<script lang="ts">
import type { SettingsFlow } from '@ory/client';
import { kratosClient } from '$lib/kratos';
import { formDataToJson } from '$lib/utils';
import { PUBLIC_KRATOS_URL } from '$env/static/public';
let { flow }: { flow: SettingsFlow } = $props();
let loading = $state(false);
let error = $state<string | null>(null);
let success = $state(false);
// Extract current values from flow
const email = $derived(
(flow.ui.nodes.find((n: any) => n.attributes?.name === 'traits.email')?.attributes as any)
?.value || ''
);
const firstName = $derived(
(flow.ui.nodes.find((n: any) => n.attributes?.name === 'traits.name.first')?.attributes as any)
?.value || ''
);
const lastName = $derived(
(flow.ui.nodes.find((n: any) => n.attributes?.name === 'traits.name.last')?.attributes as any)
?.value || ''
);
const phone = $derived(
(flow.ui.nodes.find((n: any) => n.attributes?.name === 'traits.phone')?.attributes as any)
?.value || ''
);
const csrfToken = $derived(
(flow.ui.nodes.find((n: any) => n.attributes?.name === 'csrf_token')?.attributes as any)
?.value || ''
);
async function handleSubmit(event: SubmitEvent) {
event.preventDefault();
loading = true;
error = null;
success = false;
const form = event.target as HTMLFormElement;
const formData = new FormData(form);
// Add the method field (required by Kratos)
formData.set('method', 'profile');
// Convert to JSON
const updateBody = formDataToJson(formData);
try {
await kratosClient.updateSettingsFlow({
flow: flow.id,
updateSettingsFlowBody: updateBody as any
});
success = true;
// Reload to show updated info
setTimeout(() => {
window.location.href = window.location.pathname + '?updated=true';
}, 1000);
} catch (err: any) {
console.error('Settings update failed:', err);
if (err.response?.status === 410) {
window.location.href = `${PUBLIC_KRATOS_URL}/self-service/settings/browser`;
} else if (err.response?.data?.ui) {
error = 'Validation failed. Please check your input.';
} else {
error = 'Failed to update settings. Please try again.';
}
} finally {
loading = false;
}
}
</script>
<form onsubmit={handleSubmit} class="space-y-4">
<!-- CSRF Token -->
<input type="hidden" name="csrf_token" value={csrfToken} />
{#if error}
<div class="alert-error">
<p>{error}</p>
</div>
{/if}
{#if success}
<div class="alert-success">
<p>Profile updated successfully!</p>
</div>
{/if}
<!-- Email -->
<div>
<label for="email" class="form-label">
Email <span class="text-error-500">*</span>
</label>
<input
type="email"
id="email"
name="traits.email"
value={email}
required
class="input-base"
/>
</div>
<!-- First Name -->
<div>
<label for="first-name" class="form-label">
First Name <span class="text-error-500">*</span>
</label>
<input
type="text"
id="first-name"
name="traits.name.first"
value={firstName}
required
class="input-base"
/>
</div>
<!-- Last Name -->
<div>
<label for="last-name" class="form-label">
Last Name <span class="text-error-500">*</span>
</label>
<input
type="text"
id="last-name"
name="traits.name.last"
value={lastName}
required
class="input-base"
/>
</div>
<!-- Phone (Optional) -->
<div>
<label for="phone" class="form-label">Phone Number</label>
<input
type="tel"
id="phone"
name="traits.phone"
value={phone}
pattern="^[0-9\s\-+()]*$"
class="input-base"
/>
</div>
<!-- Submit Button -->
<button
type="submit"
disabled={loading}
class="btn-primary w-full disabled:opacity-50"
>
{loading ? 'Saving...' : 'Save Changes'}
</button>
</form>

View File

@ -0,0 +1,118 @@
<script lang="ts">
import { Modal } from 'flowbite-svelte';
interface CreateForm {
schema_id: string;
traits: {
email: string;
name: {
first: string;
last: string;
};
profile_type: string;
};
}
interface Props {
open: boolean;
form: CreateForm;
onClose: () => void;
onCreate: () => Promise<void>;
loading?: boolean;
error?: string | null;
}
let { open, form, onClose, onCreate, loading = false, error = null }: Props = $props();
</script>
<Modal
bind:open
onclose={onClose}
size="lg"
autoclose={false}
dismissable={false}
outsideclose={false}
>
{#snippet header()}
<h3 class="text-lg leading-6 font-medium text-gray-900">Create New Identity</h3>
{/snippet}
<div class="w-full">
{#if error}
<div class="mb-4 rounded-lg bg-red-50 p-4">
<p class="text-sm text-red-800">{error}</p>
</div>
{/if}
<div class="space-y-4">
<div>
<label for="create-email" class="block text-sm font-medium text-gray-700">Email *</label>
<input
id="create-email"
type="email"
bind:value={form.traits.email}
required
class="mt-1 w-full rounded-lg border border-gray-300 px-4 py-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="create-first-name" class="block text-sm font-medium text-gray-700"
>First Name</label
>
<input
id="create-first-name"
type="text"
bind:value={form.traits.name.first}
class="mt-1 w-full rounded-lg border border-gray-300 px-4 py-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label for="create-last-name" class="block text-sm font-medium text-gray-700"
>Last Name</label
>
<input
id="create-last-name"
type="text"
bind:value={form.traits.name.last}
class="mt-1 w-full rounded-lg border border-gray-300 px-4 py-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<div>
<label for="create-profile-type" class="block text-sm font-medium text-gray-700"
>Profile Type *</label
>
<select
id="create-profile-type"
bind:value={form.traits.profile_type}
required
class="mt-1 w-full rounded-lg border border-gray-300 px-4 py-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
>
<option value="team">Team</option>
<option value="customer">Customer</option>
</select>
</div>
</div>
</div>
{#snippet footer()}
<button
type="button"
onclick={onCreate}
disabled={loading || !form.traits.email}
class="rounded-md bg-green-600 px-4 py-2 text-base font-medium text-white shadow-sm hover:bg-green-700 focus:ring-2 focus:ring-green-500 focus:ring-offset-2 focus:outline-none disabled:opacity-50"
>
{loading ? 'Creating...' : 'Create Identity'}
</button>
<button
type="button"
onclick={onClose}
class="rounded-md bg-white px-4 py-2 text-base font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none"
>
Cancel
</button>
{/snippet}
</Modal>

View File

@ -0,0 +1,116 @@
<script lang="ts">
import type { Identity } from '@ory/client';
import { Modal } from 'flowbite-svelte';
interface Props {
identity: Identity | null;
onClose: () => void;
onDeleteCredential: (identityId: string, type: string, identifier?: string) => Promise<void>;
}
let { identity, onClose, onDeleteCredential }: Props = $props();
let open = $derived(!!identity);
</script>
<Modal
bind:open
onclose={onClose}
size="xl"
autoclose={false}
dismissable={false}
outsideclose={false}
>
{#snippet header()}
<h3 class="text-lg leading-6 font-medium text-gray-900">Identity Details</h3>
{/snippet}
<div class="w-full">
<!-- Basic Info -->
<div class="mb-6 grid grid-cols-2 gap-4">
<div>
<span class="block text-sm font-medium text-gray-700">Email</span>
<p class="mt-1 text-sm text-gray-900">{identity?.traits?.email || 'N/A'}</p>
</div>
<div>
<span class="block text-sm font-medium text-gray-700">Name</span>
<p class="mt-1 text-sm text-gray-900">
{#if identity?.traits?.name}
{identity.traits.name.first || ''} {identity.traits.name.last || ''}
{:else}
N/A
{/if}
</p>
</div>
<div>
<span class="block text-sm font-medium text-gray-700">State</span>
<p class="mt-1 text-sm text-gray-900">{identity?.state || 'N/A'}</p>
</div>
<div>
<span class="block text-sm font-medium text-gray-700">Created</span>
<p class="mt-1 text-sm text-gray-900">
{identity?.created_at ? new Date(identity.created_at).toLocaleString() : 'N/A'}
</p>
</div>
</div>
<!-- Credentials Section -->
{#if identity?.credentials}
<div class="mb-6">
<h4 class="text-md mb-3 font-medium text-gray-900">Authentication Methods</h4>
<div class="space-y-3">
{#each Object.entries(identity.credentials) as [type, credential]}
<div class="rounded-lg border border-gray-200 p-3">
<div class="flex items-center justify-between">
<div>
<span class="text-sm font-medium text-gray-900 capitalize">{type}</span>
{#if credential.identifiers && credential.identifiers.length > 0}
<div class="mt-1">
{#each credential.identifiers as identifier}
<span class="block text-xs text-gray-600">{identifier}</span>
{/each}
</div>
{/if}
{#if credential.created_at}
<p class="mt-1 text-xs text-gray-500">
Added {new Date(credential.created_at).toLocaleDateString()}
</p>
{/if}
</div>
{#if type !== 'password' && type !== 'code'}
<button
onclick={() =>
onDeleteCredential(identity.id, type, credential.identifiers?.[0])}
class="rounded border border-red-300 px-2 py-1 text-xs text-red-600 hover:bg-red-50 hover:text-red-900"
>
Remove
</button>
{/if}
</div>
</div>
{/each}
</div>
</div>
{/if}
<!-- Raw JSON (collapsed by default) -->
<details class="mt-4">
<summary class="cursor-pointer text-sm font-medium text-gray-700 hover:text-gray-900">
View Raw JSON
</summary>
<div class="mt-2 rounded-lg bg-gray-50 p-4">
<pre class="overflow-x-auto text-xs text-gray-800">{JSON.stringify(identity, null, 2)}</pre>
</div>
</details>
</div>
{#snippet footer()}
<button
type="button"
onclick={onClose}
class="rounded-md bg-white px-4 py-2 text-base font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none"
>
Close
</button>
{/snippet}
</Modal>

View File

@ -0,0 +1,183 @@
<script lang="ts">
import type { Identity } from '@ory/client';
import { Modal } from 'flowbite-svelte';
interface Props {
identity: Identity | null;
onClose: () => void;
onSave: () => Promise<void>;
loading?: boolean;
error?: string | null;
}
let { identity, onClose, onSave, loading = false, error = null }: Props = $props();
let open = $derived(!!identity);
// Helper getters/setters for metadata_public fields
let djangoProfileId = $derived.by(() => {
if (!identity?.metadata_public) return '';
return (identity.metadata_public as any)?.django_profile_id || '';
});
function updateMetadata(field: string, value: string) {
if (!identity) return;
if (!identity.metadata_public) {
identity.metadata_public = {};
}
(identity.metadata_public as any)[field] = value || undefined;
}
function handleDjangoProfileIdInput(e: Event) {
const target = e.target as HTMLInputElement;
updateMetadata('django_profile_id', target.value);
}
</script>
<Modal
bind:open
onclose={onClose}
size="lg"
autoclose={false}
dismissable={false}
outsideclose={false}
>
{#snippet header()}
<h3 class="text-lg leading-6 font-medium text-gray-900">Edit Identity</h3>
{/snippet}
<div class="w-full">
{#if error}
<div class="mb-4 rounded-lg bg-red-50 p-4">
<p class="text-sm text-red-800">{error}</p>
</div>
{/if}
{#if identity}
<div class="space-y-4">
<!-- Email -->
<div>
<label for="edit-email" class="block text-sm font-medium text-gray-700">Email</label>
<input
id="edit-email"
type="email"
bind:value={identity.traits.email}
class="mt-1 w-full rounded-lg border border-gray-300 px-4 py-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
/>
</div>
<!-- Name Fields -->
<div class="grid grid-cols-2 gap-4">
<div>
<label for="edit-first-name" class="block text-sm font-medium text-gray-700"
>First Name</label
>
<input
id="edit-first-name"
type="text"
bind:value={identity.traits.name.first}
class="mt-1 w-full rounded-lg border border-gray-300 px-4 py-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label for="edit-last-name" class="block text-sm font-medium text-gray-700"
>Last Name</label
>
<input
id="edit-last-name"
type="text"
bind:value={identity.traits.name.last}
class="mt-1 w-full rounded-lg border border-gray-300 px-4 py-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<!-- Phone -->
<div>
<label for="edit-phone" class="block text-sm font-medium text-gray-700"
>Phone Number</label
>
<input
id="edit-phone"
type="tel"
bind:value={identity.traits.phone}
pattern="^[0-9\s\-+()]*$"
class="mt-1 w-full rounded-lg border border-gray-300 px-4 py-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
/>
<p class="mt-1 text-xs text-gray-500">Optional</p>
</div>
<!-- Profile Type -->
<div>
<label for="edit-profile-type" class="block text-sm font-medium text-gray-700"
>Profile Type</label
>
<select
id="edit-profile-type"
bind:value={identity.traits.profile_type}
class="mt-1 w-full rounded-lg border border-gray-300 px-4 py-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
>
<option value="team">Team Member</option>
<option value="customer">Customer</option>
</select>
<p class="mt-1 text-xs text-gray-500">Determines account type and permissions</p>
</div>
<!-- State -->
<div>
<label for="edit-state" class="block text-sm font-medium text-gray-700">State</label>
<select
id="edit-state"
bind:value={identity.state}
class="mt-1 w-full rounded-lg border border-gray-300 px-4 py-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
<!-- Metadata Public Section -->
<div class="border-t border-gray-200 pt-4">
<h4 class="mb-3 text-sm font-semibold text-gray-900">System Metadata (Public)</h4>
<p class="mb-3 text-xs text-gray-500">
Read-only for users, editable by admin. Links to Django backend.
</p>
<div class="space-y-3">
<div>
<label for="edit-django-profile-id" class="block text-sm font-medium text-gray-700"
>Django Profile ID</label
>
<input
id="edit-django-profile-id"
type="text"
value={djangoProfileId}
oninput={handleDjangoProfileIdInput}
placeholder="UUID of linked Django profile"
class="mt-1 w-full rounded-lg border border-gray-300 px-4 py-2 font-mono text-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
</div>
</div>
{/if}
</div>
{#snippet footer()}
<button
type="button"
onclick={onSave}
disabled={loading}
class="rounded-md bg-blue-600 px-4 py-2 text-base font-medium text-white shadow-sm hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none disabled:opacity-50"
>
{loading ? 'Saving...' : 'Save Changes'}
</button>
<button
type="button"
onclick={onClose}
class="rounded-md bg-white px-4 py-2 text-base font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none"
>
Cancel
</button>
{/snippet}
</Modal>

View File

@ -0,0 +1,122 @@
<script lang="ts">
import type { Identity, Session } from '@ory/client';
import { Modal } from 'flowbite-svelte';
interface ModalData {
identity: Identity;
sessions: Session[];
}
interface Props {
data: ModalData | null;
onClose: () => void;
onViewSession: (session: Session) => void;
onExtendSession: (id: string) => Promise<void>;
onDeleteSession: (id: string) => Promise<void>;
}
let { data, onClose, onViewSession, onExtendSession, onDeleteSession }: Props = $props();
let open = $derived(!!data);
</script>
<Modal
bind:open
onclose={onClose}
size="xl"
autoclose={false}
dismissable={false}
outsideclose={false}
>
{#snippet header()}
<div>
<h3 class="text-lg leading-6 font-medium text-gray-900">
Sessions for {data?.identity.traits?.email || 'User'}
</h3>
<p class="mt-1 text-sm text-gray-600">
{data?.sessions.length || 0} active
{data?.sessions.length === 1 ? 'session' : 'sessions'}
</p>
</div>
{/snippet}
<div class="w-full">
{#if data && data.sessions.length === 0}
<p class="py-8 text-center text-gray-500">No active sessions for this user.</p>
{:else if data}
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead>
<tr>
<th
class="px-4 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>
Session ID
</th>
<th
class="px-4 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>
Issued At
</th>
<th
class="px-4 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>
Expires At
</th>
<th
class="px-4 py-3 text-right text-xs font-medium tracking-wider text-gray-500 uppercase"
>
Actions
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#each data.sessions as session (session.id)}
<tr class="hover:bg-gray-50">
<td class="px-4 py-4 font-mono text-sm whitespace-nowrap text-gray-600">
{session.id.substring(0, 12)}...
</td>
<td class="px-4 py-4 text-sm whitespace-nowrap text-gray-500">
{new Date(session.issued_at || '').toLocaleString()}
</td>
<td class="px-4 py-4 text-sm whitespace-nowrap text-gray-500">
{new Date(session.expires_at || '').toLocaleString()}
</td>
<td class="space-x-2 px-4 py-4 text-right text-sm whitespace-nowrap">
<button
onclick={() => onViewSession(session)}
class="text-blue-600 hover:text-blue-900"
>
View
</button>
<button
onclick={() => onExtendSession(session.id)}
class="text-green-600 hover:text-green-900"
>
Extend
</button>
<button
onclick={() => onDeleteSession(session.id)}
class="text-red-600 hover:text-red-900"
>
Revoke
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
{#snippet footer()}
<button
type="button"
onclick={onClose}
class="rounded-md bg-white px-4 py-2 text-base font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none"
>
Close
</button>
{/snippet}
</Modal>

View File

@ -0,0 +1,40 @@
<script lang="ts">
import type { Message } from '@ory/client';
import { Modal } from 'flowbite-svelte';
interface Props {
message: Message | null;
onClose: () => void;
}
let { message, onClose }: Props = $props();
let open = $derived(!!message);
</script>
<Modal
bind:open
onclose={onClose}
size="xl"
autoclose={false}
dismissable={false}
outsideclose={false}
>
{#snippet header()}
<h3 class="text-lg leading-6 font-medium text-gray-900">Message Details</h3>
{/snippet}
<div class="rounded-lg bg-gray-50 p-4">
<pre class="overflow-x-auto text-sm text-gray-800">{JSON.stringify(message, null, 2)}</pre>
</div>
{#snippet footer()}
<button
type="button"
onclick={onClose}
class="rounded-md bg-white px-4 py-2 text-base font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none"
>
Close
</button>
{/snippet}
</Modal>

View File

@ -0,0 +1,40 @@
<script lang="ts">
import type { Session } from '@ory/client';
import { Modal } from 'flowbite-svelte';
interface Props {
session: Session | null;
onClose: () => void;
}
let { session, onClose }: Props = $props();
let open = $derived(!!session);
</script>
<Modal
bind:open
onclose={onClose}
size="xl"
autoclose={false}
dismissable={false}
outsideclose={false}
>
{#snippet header()}
<h3 class="text-lg leading-6 font-medium text-gray-900">Session Details</h3>
{/snippet}
<div class="rounded-lg bg-gray-50 p-4">
<pre class="overflow-x-auto text-sm text-gray-800">{JSON.stringify(session, null, 2)}</pre>
</div>
{#snippet footer()}
<button
type="button"
onclick={onClose}
class="rounded-md bg-white px-4 py-2 text-base font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none"
>
Close
</button>
{/snippet}
</Modal>

View File

@ -0,0 +1,96 @@
import { kratosServerClient } from './kratos-server';
import type {
LoginFlow,
RegistrationFlow,
RecoveryFlow,
VerificationFlow,
SettingsFlow
} from '@ory/client';
// Utility to (a) get flow if id present, (b) create the new browser flow if not, and
// (c) transparently re-init on stale/expired/forbidden flows similar to Ory Kratos UI
async function getOrCreateFlow<
T extends LoginFlow | RegistrationFlow | RecoveryFlow | VerificationFlow | SettingsFlow
>(params: {
flowId: string | null;
create: () => Promise<T>;
get: () => Promise<T>;
redirectBasePath: string; // e.g. '/login'
searchParams?: URLSearchParams;
excludeParams?: string[]; // Additional params to exclude from redirect (e.g., 'code' for verification/recovery)
}): Promise<{ flow: T; redirectTo?: string }> {
const { flowId, create, get, redirectBasePath, searchParams, excludeParams = [] } = params;
const buildRedirect = (flow: T) => {
const sp = new URLSearchParams();
// Parameters to exclude from redirect URL
const excluded = new Set(['flow', ...excludeParams]);
// Only copy non-excluded search params
if (searchParams) {
for (const [key, value] of searchParams.entries()) {
if (!excluded.has(key)) {
sp.set(key, value);
}
}
}
sp.set('flow', flow.id);
return `${redirectBasePath}?${sp.toString()}`;
};
try {
if (flowId) {
const flow = await get();
return { flow };
}
const flow = await create();
return { flow, redirectTo: buildRedirect(flow) };
} catch (e: any) {
// Handle common Kratos flow errors by re-initializing the flow
// 410 (Gone) - flow expired; 403 (Forbidden) - CSRF or not allowed; 400 (Bad Request) - invalid id
if ([410, 403, 400].includes(e?.status || e?.response?.status)) {
try {
const flow = await create();
return { flow, redirectTo: buildRedirect(flow) };
} catch (ee: any) {
throw ee;
}
}
throw e;
}
}
export async function loadRecoveryFlow(flowId: string | null, searchParams?: URLSearchParams) {
return getOrCreateFlow<RecoveryFlow>({
flowId,
create: async () => (await kratosServerClient.createBrowserRecoveryFlow()).data,
get: async () => (await kratosServerClient.getRecoveryFlow({ id: flowId! })).data,
redirectBasePath: '/recovery',
searchParams,
excludeParams: ['code'] // Don't preserve recovery codes across flow recreations
});
}
export async function loadVerificationFlow(flowId: string | null, searchParams?: URLSearchParams) {
return getOrCreateFlow<VerificationFlow>({
flowId,
create: async () => (await kratosServerClient.createBrowserVerificationFlow()).data,
get: async () => (await kratosServerClient.getVerificationFlow({ id: flowId! })).data,
redirectBasePath: '/verification',
searchParams,
excludeParams: ['code'] // Don't preserve verification codes across flow recreations
});
}
export async function loadSettingsFlow(
flowId: string | null,
cookie: string,
searchParams?: URLSearchParams
) {
return getOrCreateFlow<SettingsFlow>({
flowId,
create: async () => (await kratosServerClient.createBrowserSettingsFlow({ cookie })).data,
get: async () => (await kratosServerClient.getSettingsFlow({ id: flowId!, cookie })).data,
redirectBasePath: '/settings',
searchParams
});
}

View File

@ -0,0 +1,11 @@
import { Configuration, FrontendApi } from '@ory/client';
import { PUBLIC_KRATOS_URL } from '$env/static/public';
import { KRATOS_SERVER_URL } from '$env/static/private';
// Server-side client (without browser-specific settings)
// Used only for session validation in server-side loaders
export const kratosServerClient = new FrontendApi(
new Configuration({
basePath: KRATOS_SERVER_URL || PUBLIC_KRATOS_URL || 'http://localhost:7200'
})
);

View File

@ -0,0 +1,24 @@
import { Configuration, FrontendApi, IdentityApi } from '@ory/client';
import { PUBLIC_KRATOS_URL } from '$env/static/public';
// Browser-side client (with credentials)
// All browser calls (including admin API) go through Oathkeeper at PUBLIC_KRATOS_URL
export const kratosClient = new FrontendApi(
new Configuration({
basePath: PUBLIC_KRATOS_URL || 'http://localhost:7200',
baseOptions: {
withCredentials: true
}
})
);
// Admin client for administrative operations (with credentials)
// Uses the same proxy but with IdentityApi for admin operations
export const kratosAdminClient = new IdentityApi(
new Configuration({
basePath: PUBLIC_KRATOS_URL || 'http://localhost:7200',
baseOptions: {
withCredentials: true
}
})
);

View File

@ -0,0 +1,79 @@
import { browser } from '$app/environment';
type Theme = 'light' | 'dark' | 'system';
const STORAGE_KEY = 'theme-preference';
function getSystemTheme(): 'light' | 'dark' {
if (!browser) return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function getStoredTheme(): Theme {
if (!browser) return 'system';
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === 'light' || stored === 'dark' || stored === 'system') {
return stored;
}
return 'system';
}
function createThemeStore() {
let preference = $state<Theme>(getStoredTheme());
let resolved = $derived<'light' | 'dark'>(
preference === 'system' ? getSystemTheme() : preference
);
function applyTheme(theme: 'light' | 'dark') {
if (!browser) return;
document.documentElement.classList.remove('light', 'dark');
document.documentElement.classList.add(theme);
}
function setTheme(theme: Theme) {
preference = theme;
if (browser) {
localStorage.setItem(STORAGE_KEY, theme);
applyTheme(resolved);
}
}
function toggle() {
const newTheme = resolved === 'light' ? 'dark' : 'light';
setTheme(newTheme);
}
function init() {
if (!browser) return;
applyTheme(resolved);
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (e: MediaQueryListEvent) => {
if (preference === 'system') {
resolved = e.matches ? 'dark' : 'light';
applyTheme(resolved);
}
};
mediaQuery.addEventListener('change', handleChange);
}
return {
get preference() {
return preference;
},
get resolved() {
return resolved;
},
get isDark() {
return resolved === 'dark';
},
setTheme,
toggle,
init
};
}
export const theme = createThemeStore();

View File

@ -0,0 +1,52 @@
import type { UiNode, UiNodeInputAttributes } from '@ory/client';
export function getNodeLabel(node: UiNode): string {
const attrs = node.attributes as UiNodeInputAttributes;
if (node.meta.label?.text) {
return node.meta.label.text;
}
return attrs.name || '';
}
export function filterNodesByGroups(nodes: UiNode[], ...groups: string[]): UiNode[] {
return nodes.filter((node) => groups.includes(node.group));
}
/**
* Helper function to set nested property in an object using dot notation
* Example: setNestedProperty(obj, 'traits.name.first', 'John')
*/
export function setNestedProperty(obj: any, path: string, value: any): void {
const keys = path.split('.');
let current = obj;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
if (!(key in current)) {
current[key] = {};
}
current = current[key];
}
current[keys[keys.length - 1]] = value;
}
/**
* Convert FormData to JSON object for Ory SDK submission
* This handles nested properties (like traits.email, traits.name.first)
* and includes the csrf_token which must be present in JSON requests
*/
export function formDataToJson(formData: FormData): Record<string, any> {
const json: Record<string, any> = {};
for (const [key, value] of formData.entries()) {
// Handle nested object notation (e.g., traits.email, traits.name.first)
if (key.includes('.')) {
setNestedProperty(json, key, value);
} else {
json[key] = value;
}
}
return json;
}

View File

@ -0,0 +1,23 @@
import { kratosServerClient } from '$lib/kratos-server';
import { ADMIN_USER_ID } from '$env/static/private';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ cookies }) => {
try {
const sessionToken = cookies.get('ory_kratos_session');
if (!sessionToken) {
return { session: null, isAdmin: false };
}
const { data: session } = await kratosServerClient.toSession({
cookie: `ory_kratos_session=${sessionToken}`
});
const isAdmin = session?.identity?.id === ADMIN_USER_ID;
return { session, isAdmin };
} catch {
return { session: null, isAdmin: false };
}
};

View File

@ -0,0 +1,187 @@
<script lang="ts">
import '../app.css';
import { page } from '$app/state';
import { theme } from '$lib/stores/theme.svelte';
import { onMount } from 'svelte';
import type { LayoutData } from './$types';
let { data, children }: { data: LayoutData; children: any } = $props();
let menuOpen = $state(false);
const isActive = $derived((path: string) => page.url.pathname === path);
function toggleMenu() {
menuOpen = !menuOpen;
}
function closeMenu() {
menuOpen = false;
}
onMount(() => {
theme.init();
});
</script>
<div class="flex min-h-screen flex-col bg-theme">
<nav class="border-b border-theme bg-theme-secondary shadow-theme">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-16 items-center justify-between">
<!-- Left section: Hamburger + Logo -->
<div class="flex items-center gap-3">
<!-- Hamburger menu button -->
<button
onclick={toggleMenu}
class="inline-flex items-center justify-center rounded-lg p-2.5 text-theme-secondary transition-colors interactive"
aria-expanded={menuOpen}
aria-label="Toggle navigation menu"
>
{#if !menuOpen}
<svg class="h-6 w-6" viewBox="0 0 24 24" stroke="currentColor" style="fill: none">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
{:else}
<svg class="h-6 w-6" viewBox="0 0 24 24" stroke="currentColor" style="fill: none">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
{/if}
</button>
<!-- Logo -->
<a href="/" class="flex items-center space-x-2">
<span class="text-xl font-bold text-theme">Nexus</span>
<span class="text-theme-muted">|</span>
<span class="text-base font-medium text-theme-secondary">Account</span>
</a>
</div>
<!-- Right section: Auth buttons and theme toggle -->
<div class="flex items-center gap-2">
<!-- Theme toggle button -->
<button
onclick={() => theme.toggle()}
class="rounded-lg p-2.5 text-theme-secondary transition-colors interactive"
aria-label="Toggle theme"
>
{#if theme.isDark}
<!-- Sun icon for light mode -->
<svg class="h-5 w-5" viewBox="0 0 24 24" stroke="currentColor" style="fill: none">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
{:else}
<!-- Moon icon for dark mode -->
<svg class="h-5 w-5" viewBox="0 0 24 24" stroke="currentColor" style="fill: none">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg>
{/if}
</button>
{#if data.session}
<span class="mr-2 hidden text-sm text-theme-secondary sm:inline">
{data.session.identity?.traits.email}
</span>
<form action="/logout" method="POST">
<input type="hidden" name="return_to" value={page.url.origin} />
<button type="submit" class="btn-danger">
Logout
</button>
</form>
{:else}
<div class="flex gap-2">
<a
href="/login"
class="rounded-lg border border-theme px-4 py-2 text-sm font-medium text-theme-secondary transition-colors interactive"
>
Login
</a>
<a href="/registration" class="btn-primary text-sm">
Register
</a>
</div>
{/if}
</div>
</div>
</div>
</nav>
<!-- Dropdown menu overlay -->
{#if menuOpen}
<!-- Backdrop to close menu when clicking outside -->
<button
class="fixed inset-0 z-40 bg-black/20 backdrop-blur-sm"
onclick={closeMenu}
aria-label="Close menu"
></button>
<!-- Menu panel -->
<div class="fixed top-16 right-0 left-0 z-50 mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="rounded-b-lg border border-t-0 border-theme bg-theme-card shadow-theme-lg">
<!-- Navigation items -->
<div class="space-y-1 px-2 pt-2 pb-3">
<a
href="/"
onclick={closeMenu}
class="block rounded-lg px-4 py-3 text-base font-medium transition-colors {isActive(
'/'
)
? 'bg-primary-500 text-white'
: 'text-theme-secondary interactive'}"
>
Home
</a>
{#if data.session}
<a
href="/settings"
onclick={closeMenu}
class="block rounded-lg px-4 py-3 text-base font-medium transition-colors {isActive(
'/settings'
)
? 'bg-primary-500 text-white'
: 'text-theme-secondary interactive'}"
>
Settings
</a>
{#if data.isAdmin}
<a
href="/admin"
onclick={closeMenu}
class="block rounded-lg px-4 py-3 text-base font-medium transition-colors {isActive(
'/admin'
)
? 'bg-primary-500 text-white'
: 'text-theme-secondary interactive'}"
>
Admin
</a>
{/if}
{/if}
</div>
</div>
</div>
{/if}
<main class="mx-auto flex-1 max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
{@render children()}
</main>
</div>

View File

@ -0,0 +1,135 @@
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
const session = $derived(data.session);
const identity = $derived(session?.identity);
const traits = $derived(identity?.traits);
</script>
<svelte:head>
<title>Home - Example App</title>
</svelte:head>
{#if session}
<div class="mx-auto max-w-4xl">
<div class="card-padded overflow-hidden shadow-theme">
<h1 class="mb-6 text-2xl font-bold text-theme sm:text-3xl">Welcome back!</h1>
<div class="space-y-6">
<div>
<h2 class="text-lg font-medium text-theme">Account Information</h2>
<dl class="mt-4 grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
<div>
<dt class="text-sm font-medium text-theme-muted">Email</dt>
<dd class="mt-1 text-sm text-theme">{traits?.email || 'Not set'}</dd>
</div>
{#if traits?.name?.first || traits?.name?.last}
<div>
<dt class="text-sm font-medium text-theme-muted">Name</dt>
<dd class="mt-1 text-sm text-theme">
{traits.name.first}
{traits.name.last}
</dd>
</div>
{/if}
<div>
<dt class="text-sm font-medium text-theme-muted">User ID</dt>
<dd class="mt-1 font-mono text-sm text-theme">{identity?.id}</dd>
</div>
<div>
<dt class="text-sm font-medium text-theme-muted">Session Active</dt>
<dd class="mt-1 text-sm text-theme">
{session.active ? 'Yes' : 'No'}
</dd>
</div>
{#if session.expires_at}
<div>
<dt class="text-sm font-medium text-theme-muted">Session Expires</dt>
<dd class="mt-1 text-sm text-theme">
{new Date(session.expires_at).toLocaleString()}
</dd>
</div>
{/if}
<div>
<dt class="text-sm font-medium text-theme-muted">Authentication Level</dt>
<dd class="mt-1 text-sm text-theme">
{session.authenticator_assurance_level === 'aal2'
? 'Two-Factor'
: 'Single-Factor'}
</dd>
</div>
</dl>
</div>
{#if session.authentication_methods && session.authentication_methods.length > 0}
<div>
<h3 class="text-lg font-medium text-theme">Authentication Methods</h3>
<ul class="mt-2 space-y-2">
{#each session.authentication_methods as method}
<li class="flex items-center text-sm text-theme-secondary">
<svg class="mr-2 h-5 w-5 text-success-500" viewBox="0 0 20 20" aria-hidden="true">
<path
fill="currentColor"
fill-rule="evenodd"
clip-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
/>
</svg>
{method.method} (completed at {new Date(
method.completed_at || ''
).toLocaleString()})
</li>
{/each}
</ul>
</div>
{/if}
<div class="pt-4">
<a href="/settings" class="btn-primary">
Manage Account Settings
</a>
</div>
</div>
</div>
</div>
{:else}
<div class="text-center">
<h1 class="text-3xl font-bold tracking-tight text-theme sm:text-5xl">
Welcome to Example App
</h1>
<p class="mt-6 text-lg leading-8 text-theme-secondary">
A secure authentication platform built with Ory Kratos and SvelteKit
</p>
<div class="mt-10 flex flex-col items-center justify-center gap-4 sm:flex-row sm:gap-6">
<a href="/registration" class="btn-primary w-full px-6 py-3 text-base sm:w-auto">
Get started
</a>
<a href="/login" class="text-base font-semibold leading-7 text-theme">
Sign in <span aria-hidden="true"></span>
</a>
</div>
<div class="mt-16 grid grid-cols-1 gap-6 sm:grid-cols-3">
<div class="card-padded shadow-theme">
<h3 class="text-lg font-semibold text-theme">Secure Authentication</h3>
<p class="mt-2 text-sm text-theme-secondary">
Powered by Ory Kratos with support for passwords, 2FA, and passwordless login
</p>
</div>
<div class="card-padded shadow-theme">
<h3 class="text-lg font-semibold text-theme">Account Recovery</h3>
<p class="mt-2 text-sm text-theme-secondary">
Easy password recovery and email verification flows to keep your account secure
</p>
</div>
<div class="card-padded shadow-theme">
<h3 class="text-lg font-semibold text-theme">Self-Service Settings</h3>
<p class="mt-2 text-sm text-theme-secondary">
Manage your profile, change passwords, and configure two-factor authentication
</p>
</div>
</div>
</div>
{/if}

View File

@ -0,0 +1,31 @@
import { kratosServerClient } from '$lib/kratos-server';
import { redirect } from '@sveltejs/kit';
import { ADMIN_USER_ID } from '$env/static/private';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies }) => {
const sessionToken = cookies.get('ory_kratos_session');
if (!sessionToken) {
redirect(303, '/login?return_to=/admin');
}
try {
const { data: session } = await kratosServerClient.toSession({
cookie: `ory_kratos_session=${sessionToken}`
});
// Check if the user is the admin
if (session.identity?.id !== ADMIN_USER_ID) {
redirect(303, '/?error=unauthorized');
}
return {
session,
isAdmin: true
};
} catch {
// If session validation fails, redirect to login
redirect(303, '/login?return_to=/admin');
}
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,22 @@
import { kratosServerClient } from '$lib/kratos-server';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ url }) => {
const flowId = url.searchParams.get('id');
if (!flowId) {
return {
errorMessage: 'An error occurred. Please try again.'
};
}
try {
const { data: flow } = await kratosServerClient.getFlowError({ id: flowId });
return { flow };
} catch (error) {
console.error('Error flow error:', error);
return {
errorMessage: 'An error occurred. Please try again.'
};
}
};

View File

@ -0,0 +1,49 @@
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
const errorDetails = $derived(
(data.flow?.error as { message?: string; reason?: string } | null) || null
);
const errorMessage = $derived(data.errorMessage || errorDetails?.message || 'An error occurred');
</script>
<svelte:head>
<title>Error - Example App</title>
</svelte:head>
<div class="flex min-h-full flex-col justify-center px-4 py-12 sm:px-6 lg:px-8">
<div class="mx-auto w-full max-w-md">
<div class="alert-error">
<div class="flex gap-3">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-error-400" viewBox="0 0 24 24" aria-hidden="true">
<path
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
/>
</svg>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-sm font-medium">Error</h3>
<div class="mt-2 text-sm">
<p>{errorMessage}</p>
{#if errorDetails?.reason}
<p class="mt-2">{errorDetails.reason}</p>
{/if}
</div>
<div class="mt-4">
<a href="/" class="btn-danger">
Go back home
</a>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,77 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import { PUBLIC_KRATOS_URL } from '$env/static/public';
import FlowForm from '$lib/components/FlowForm.svelte';
import type { LoginFlow } from '@ory/client';
import { kratosClient } from '$lib/kratos';
let flow = $state<LoginFlow | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
onMount(async () => {
const flowId = page.url.searchParams.get('flow');
if (flowId) {
try {
// Use SDK method to fetch flow - handles credentials automatically
const { data } = await kratosClient.getLoginFlow({ id: flowId });
flow = data;
} catch (err) {
console.error('Login flow error:', err);
error = 'Flow expired or invalid. Please try again.';
} finally {
loading = false;
}
} else {
window.location.href = `${PUBLIC_KRATOS_URL}/self-service/login/browser`;
}
});
</script>
<svelte:head>
<title>Login - Example App</title>
</svelte:head>
<div class="flex min-h-full flex-col justify-center px-4 py-12 sm:px-6 lg:px-8">
<div class="mx-auto w-full max-w-sm sm:max-w-md">
<h2 class="text-center text-2xl font-bold leading-9 tracking-tight text-theme sm:text-3xl">
Sign in to your account
</h2>
</div>
<div class="mx-auto mt-8 w-full max-w-sm sm:max-w-md">
<div class="card-padded shadow-theme">
{#if loading}
<p class="text-center text-theme-muted">Loading...</p>
{:else if error}
<div class="alert-error mb-4">
<p>{error}</p>
</div>
<p class="text-center">
<a href="/login" class="text-primary-500 hover:text-primary-600">Try again</a>
</p>
{:else if flow}
<FlowForm {flow} groups={['default', 'password']} />
{:else}
<div class="alert-error">
<p>Failed to load login form</p>
</div>
{/if}
</div>
<p class="mt-6 text-center text-sm text-theme-secondary">
Don't have an account?
<a href="/registration" class="font-semibold leading-6 text-primary-500 hover:text-primary-600">
Register here
</a>
</p>
<p class="mt-2 text-center text-sm text-theme-secondary">
<a href="/recovery" class="font-semibold leading-6 text-primary-500 hover:text-primary-600">
Forgot your password?
</a>
</p>
</div>
</div>

View File

@ -0,0 +1,56 @@
import { redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { PUBLIC_KRATOS_URL } from '$env/static/public';
export const POST: RequestHandler = async ({ cookies, fetch, request }) => {
const formData = await request.formData();
const returnTo = formData.get('return_to')?.toString() || 'https://account.example.com';
try {
const sessionToken = cookies.get('ory_kratos_session');
if (sessionToken) {
// Create a logout flow through Oathkeeper with return_to parameter
const logoutUrl = new URL(`${PUBLIC_KRATOS_URL}/self-service/logout/browser`);
logoutUrl.searchParams.set('return_to', returnTo);
const response = await fetch(logoutUrl.toString(), {
method: 'GET',
headers: {
cookie: `ory_kratos_session=${sessionToken}`
},
redirect: 'manual'
});
// Get the logout token from response
if (response.status === 200) {
const data = await response.json();
if (data.logout_url) {
// Execute logout - this will redirect to return_to after logout
await fetch(data.logout_url, {
method: 'GET',
headers: {
cookie: `ory_kratos_session=${sessionToken}`
}
});
}
}
}
// Clear cookie on the server side with matching attributes
cookies.delete('ory_kratos_session', {
path: '/',
domain: '.example.com'
});
} catch (error) {
console.error('Logout error:', error);
// Continue to redirect even if logout fails
}
// Redirect to the return_to URL or default to login page
if (returnTo && returnTo !== 'https://account.example.com') {
throw redirect(303, returnTo);
}
throw redirect(303, '/login');
};

View File

@ -0,0 +1,76 @@
import { kratosServerClient } from '$lib/kratos-server';
import { redirect } from '@sveltejs/kit';
import { PUBLIC_KRATOS_URL } from '$env/static/public';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ url, request }) => {
const flowId = url.searchParams.get('flow');
const code = url.searchParams.get('code');
// If no flow ID, redirect to Kratos to create the flow (with proper CSRF cookie handling)
if (!flowId) {
throw redirect(303, `${PUBLIC_KRATOS_URL}/self-service/recovery/browser`);
}
// Load the existing flow
const cookie = request.headers.get('cookie') || undefined;
let flow;
try {
const result = await kratosServerClient.getRecoveryFlow({ id: flowId, cookie });
flow = result.data;
} catch (error: any) {
// If flow is expired/invalid, redirect to create a new one
if ([410, 403, 400].includes(error?.status || error?.response?.status)) {
throw redirect(303, `${PUBLIC_KRATOS_URL}/self-service/recovery/browser`);
}
throw error;
}
// If we have a valid flow and code, auto-submit the recovery code
if (flow && code) {
try {
const result = await kratosServerClient.updateRecoveryFlow({
flow: flow.id,
updateRecoveryFlowBody: {
method: 'code',
code: code
}
});
// Recovery code submitted successfully
// Check if Kratos wants us to redirect somewhere (usually to settings to set new password)
if (result.data && (result.data as any).redirect_browser_to) {
throw redirect(303, (result.data as any).redirect_browser_to);
}
// Otherwise update the flow with the result
flow = result.data;
} catch (error: any) {
// Re-throw if this is a redirect (SvelteKit internal)
if (error?.status === 303 || error?.location) {
throw error;
}
console.error('Auto recovery failed:', error);
// If Kratos returned an updated flow with error messages, use it
if (error.response?.data?.ui) {
flow = error.response.data;
} else {
// Add a user-friendly error message to the flow UI
if (!flow.ui.messages) {
flow.ui.messages = [];
}
flow.ui.messages.push({
id: 4060001,
text: 'The recovery code has expired or is invalid. Please request a new recovery email by entering your email address below.',
type: 'error',
context: {}
});
}
}
}
return { flow };
};

View File

@ -0,0 +1,49 @@
<script lang="ts">
import FlowForm from '$lib/components/FlowForm.svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
// Derive UI text based on recovery flow state
const flowState = $derived(data.flow.state as string);
const heading = $derived(
flowState === 'sent_email' || flowState === 'passed_challenge'
? 'Enter recovery code'
: 'Recover your password'
);
const description = $derived(
flowState === 'sent_email'
? 'Enter the 6-digit recovery code sent to your email'
: flowState === 'passed_challenge'
? 'Recovery successful! Update your password below'
: 'Enter your email address to receive a recovery code'
);
</script>
<svelte:head>
<title>Password Recovery - Example App</title>
</svelte:head>
<div class="flex min-h-full flex-col justify-center px-4 py-12 sm:px-6 lg:px-8">
<div class="mx-auto w-full max-w-sm sm:max-w-md">
<h2 class="text-center text-2xl font-bold leading-9 tracking-tight text-theme sm:text-3xl">
{heading}
</h2>
<p class="mt-2 text-center text-sm text-theme-secondary">
{description}
</p>
</div>
<div class="mx-auto mt-8 w-full max-w-sm sm:max-w-md">
<div class="card-padded shadow-theme">
<FlowForm flow={data.flow} groups={['default', 'code', 'link']} />
</div>
<p class="mt-6 text-center text-sm text-theme-secondary">
Remember your password?
<a href="/login" class="font-semibold leading-6 text-primary-500 hover:text-primary-600">
Sign in here
</a>
</p>
</div>
</div>

View File

@ -0,0 +1,71 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import { PUBLIC_KRATOS_URL } from '$env/static/public';
import FlowForm from '$lib/components/FlowForm.svelte';
import type { RegistrationFlow } from '@ory/client';
import { kratosClient } from '$lib/kratos';
let flow = $state<RegistrationFlow | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
onMount(async () => {
const flowId = page.url.searchParams.get('flow');
if (flowId) {
try {
// Use SDK method to fetch flow - handles credentials automatically
const { data } = await kratosClient.getRegistrationFlow({ id: flowId });
flow = data;
} catch (err) {
console.error('Registration flow error:', err);
error = 'Failed to load registration form. Please try again.';
} finally {
loading = false;
}
} else {
window.location.href = `${PUBLIC_KRATOS_URL}/self-service/registration/browser`;
}
});
</script>
<svelte:head>
<title>Register - Example App</title>
</svelte:head>
<div class="flex min-h-full flex-col justify-center px-4 py-12 sm:px-6 lg:px-8">
<div class="mx-auto w-full max-w-sm sm:max-w-md">
<h2 class="text-center text-2xl font-bold leading-9 tracking-tight text-theme sm:text-3xl">
Create your account
</h2>
</div>
<div class="mx-auto mt-8 w-full max-w-sm sm:max-w-md">
<div class="card-padded shadow-theme">
{#if loading}
<p class="text-center text-theme-muted">Loading...</p>
{:else if error}
<div class="alert-error mb-4">
<p>{error}</p>
</div>
<p class="text-center">
<a href="/registration" class="text-primary-500 hover:text-primary-600">Try again</a>
</p>
{:else if flow}
<FlowForm {flow} groups={['default', 'password', 'profile']} />
{:else}
<div class="alert-error">
<p>Failed to load registration form</p>
</div>
{/if}
</div>
<p class="mt-6 text-center text-sm text-theme-secondary">
Already have an account?
<a href="/login" class="font-semibold leading-6 text-primary-500 hover:text-primary-600">
Sign in here
</a>
</p>
</div>
</div>

View File

@ -0,0 +1,21 @@
import { kratosServerClient } from '$lib/kratos-server';
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
// Only validate session on the server. Do NOT create/fetch the settings flow here
// so that Kratos' Set-Cookie (csrf) reaches the browser directly when the flow
// is initialized client-side.
export const load: PageServerLoad = async ({ cookies }) => {
const sessionToken = cookies.get('ory_kratos_session');
if (!sessionToken) {
throw redirect(303, '/login');
}
const sessionCookie = `ory_kratos_session=${sessionToken}`;
try {
await kratosServerClient.toSession({ cookie: sessionCookie });
} catch {
throw redirect(303, '/login');
}
return {};
};

View File

@ -0,0 +1,82 @@
<script lang="ts">
import FlowForm from '$lib/components/FlowForm.svelte';
import SettingsProfileForm from '$lib/components/SettingsProfileForm.svelte';
import { page } from '$app/state';
import { onMount } from 'svelte';
import { PUBLIC_KRATOS_URL } from '$env/static/public';
import type { SettingsFlow } from '@ory/client';
import { kratosClient } from '$lib/kratos';
let flow = $state<SettingsFlow | null>(null);
let isLoading = $state(true);
const isUpdated = $derived(page.url.searchParams.has('updated'));
onMount(async () => {
const url = new URL(window.location.href);
const flowId = url.searchParams.get('flow');
if (!flowId) {
// Redirect to create flow
window.location.href = `${PUBLIC_KRATOS_URL}/self-service/settings/browser`;
return;
}
try {
// Use SDK method to fetch flow - handles credentials automatically
const { data } = await kratosClient.getSettingsFlow({ id: flowId });
flow = data;
isLoading = false;
} catch (error) {
console.error('Failed to fetch settings flow:', error);
// Restart flow on error
window.location.href = `${PUBLIC_KRATOS_URL}/self-service/settings/browser`;
}
});
</script>
<svelte:head>
<title>Settings - Example App</title>
</svelte:head>
<div class="mx-auto max-w-2xl">
<h1 class="mb-8 text-2xl font-bold text-theme sm:text-3xl">Account Settings</h1>
{#if isUpdated}
<div class="alert-success mb-6">
<p>Your settings have been updated successfully!</p>
</div>
{/if}
{#if isLoading}
<div class="card-padded shadow-theme">
<p class="text-center text-theme-muted">Loading settings…</p>
</div>
{:else if flow}
<div class="card-padded shadow-theme">
<h2 class="mb-4 text-xl font-semibold text-theme">Profile Settings</h2>
<p class="mb-4 text-sm text-theme-secondary">Update your personal information</p>
<SettingsProfileForm {flow} />
</div>
<div class="card-padded mt-6 shadow-theme">
<h2 class="mb-4 text-xl font-semibold text-theme">Password</h2>
<FlowForm {flow} groups={['password']} />
</div>
<div class="card-padded mt-6 shadow-theme">
<h2 class="mb-4 text-xl font-semibold text-theme">Authenticator App (TOTP)</h2>
<p class="mb-4 text-sm text-theme-secondary">
Use an authenticator app like Google Authenticator, Authy, or 1Password to generate verification codes.
</p>
<FlowForm {flow} groups={['totp']} />
</div>
<div class="card-padded mt-6 shadow-theme">
<h2 class="mb-4 text-xl font-semibold text-theme">Security Keys & Biometrics (WebAuthn)</h2>
<p class="mb-4 text-sm text-theme-secondary">
Use hardware security keys (like YubiKey) or biometric authentication (like Face ID or Touch ID) for enhanced security.
</p>
<FlowForm {flow} groups={['webauthn']} />
</div>
{/if}
</div>

View File

@ -0,0 +1,77 @@
import { kratosServerClient } from '$lib/kratos-server';
import { redirect } from '@sveltejs/kit';
import { PUBLIC_KRATOS_URL } from '$env/static/public';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ url, request }) => {
const flowId = url.searchParams.get('flow');
const code = url.searchParams.get('code');
// If no flow ID, redirect to Kratos to create the flow (with proper CSRF cookie handling)
if (!flowId) {
throw redirect(303, `${PUBLIC_KRATOS_URL}/self-service/verification/browser`);
}
// Load the existing flow
const cookie = request.headers.get('cookie') || undefined;
let flow;
try {
const result = await kratosServerClient.getVerificationFlow({ id: flowId, cookie });
flow = result.data;
} catch (error: any) {
// If flow is expired/invalid, redirect to create a new one
if ([410, 403, 400].includes(error?.status || error?.response?.status)) {
throw redirect(303, `${PUBLIC_KRATOS_URL}/self-service/verification/browser`);
}
throw error;
}
// If we have a valid flow and code, auto-submit the verification
if (flow && code) {
try {
const result = await kratosServerClient.updateVerificationFlow({
flow: flow.id,
updateVerificationFlowBody: {
method: 'code',
code: code
},
cookie // Pass session cookie so Kratos can associate verification with authenticated user
});
// Verification code submitted successfully
// Check if Kratos wants us to redirect somewhere
if (result.data && (result.data as any).redirect_browser_to) {
throw redirect(303, (result.data as any).redirect_browser_to);
}
// Otherwise redirect to home
throw redirect(303, '/');
} catch (error: any) {
// Re-throw if this is a redirect (SvelteKit internal)
if (error?.status === 303 || error?.location) {
throw error;
}
console.error('Auto verification failed:', error);
// If Kratos returned an updated flow with error messages, use it
if (error.response?.data?.ui) {
flow = error.response.data;
} else {
// Add a user-friendly error message to the flow UI
if (!flow.ui.messages) {
flow.ui.messages = [];
}
flow.ui.messages.push({
id: 4070001,
text: 'The verification code has expired or is invalid. Please request a new verification email by entering your email address below.',
type: 'error',
context: {}
});
}
}
}
return { flow };
};

View File

@ -0,0 +1,42 @@
<script lang="ts">
import FlowForm from '$lib/components/FlowForm.svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
// Derive UI text based on verification flow state
const flowState = $derived(data.flow.state as string);
const heading = $derived(
flowState === 'sent_email' || flowState === 'passed_challenge'
? 'Enter verification code'
: 'Verify your email address'
);
const description = $derived(
flowState === 'sent_email'
? 'Enter the 6-digit verification code sent to your email'
: flowState === 'passed_challenge'
? 'Email verified successfully!'
: 'Enter your email address to receive a verification code'
);
</script>
<svelte:head>
<title>Email Verification - Example App</title>
</svelte:head>
<div class="flex min-h-full flex-col justify-center px-4 py-12 sm:px-6 lg:px-8">
<div class="mx-auto w-full max-w-sm sm:max-w-md">
<h2 class="text-center text-2xl font-bold leading-9 tracking-tight text-theme sm:text-3xl">
{heading}
</h2>
<p class="mt-2 text-center text-sm text-theme-secondary">
{description}
</p>
</div>
<div class="mx-auto mt-8 w-full max-w-sm sm:max-w-md">
<div class="card-padded shadow-theme">
<FlowForm flow={data.flow} groups={['default', 'code', 'link']} />
</div>
</div>
</div>

View File

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

View File

@ -0,0 +1,12 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: { adapter: adapter() }
};
export default config;

View File

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

View File

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

423
docker-compose.yml Normal file
View File

@ -0,0 +1,423 @@
services:
# ============================================
# NEXUS APP SERVICES
# ============================================
# Vault Agent for nexus migrations (one-shot, runs before app)
vault-agent-migrate:
image: hashicorp/vault:1.18
container_name: nexus-vault-agent-migrate
network_mode: host
user: "0:0"
command: >
sh -c "rm -f /vault/secrets/.env;
vault agent -config=/vault/config/agent-config.hcl &
while [ ! -f /vault/secrets/.env ]; do sleep 1; done;
echo 'Secrets rendered, exiting'; exit 0"
cap_add:
- IPC_LOCK
volumes:
- ./vault/agent-config-migrate.hcl:/vault/config/agent-config.hcl:ro
- ./vault/templates:/vault/templates:ro
- ./secrets/migrate/role-id:/vault/role-id:ro
- ./secrets/migrate/secret-id:/vault/secret-id:ro
- ./run/migrate:/vault/secrets
environment:
- VAULT_ADDR=http://vault.example.local:8200
# Nexus migration runner (one-shot, runs before app)
migrate:
build:
context: .
dockerfile: Dockerfile.migrate
container_name: nexus-migrate
network_mode: host
depends_on:
vault-agent-migrate:
condition: service_completed_successfully
volumes:
- ./run/migrate:/vault/secrets:ro
- ./migrations:/app/migrations:ro
working_dir: /app
command:
- |
set -e
echo "Loading credentials from Vault..."
set -a
. /vault/secrets/.env
set +a
echo "Running migrations..."
sqlx migrate run
echo "Migrations complete!"
# Vault Agent for nexus app runtime (long-running)
vault-agent:
image: hashicorp/vault:1.18
container_name: nexus-vault-agent
restart: unless-stopped
network_mode: host
pid: host # Share PID namespace to signal nexus on credential refresh
user: "0:0"
command: ["vault", "agent", "-config=/vault/config/agent-config.hcl"]
cap_add:
- IPC_LOCK
- KILL # Required to send SIGHUP to nexus for credential refresh
volumes:
- ./vault/agent-config.hcl:/vault/config/agent-config.hcl:ro
- ./vault/templates:/vault/templates:ro
- ./secrets/app/role-id:/vault/role-id:ro
- ./secrets/app/secret-id:/vault/secret-id:ro
- ./run/app:/vault/secrets
environment:
- VAULT_ADDR=http://vault.example.local:8200
healthcheck:
test: ["CMD", "test", "-f", "/vault/secrets/.env"]
interval: 5s
timeout: 3s
retries: 30
start_period: 10s
# Main nexus application
nexus:
build:
context: .
dockerfile: Dockerfile
container_name: nexus
restart: unless-stopped
network_mode: host
pid: host # Share PID namespace so vault-agent can signal us
depends_on:
migrate:
condition: service_completed_successfully
vault-agent:
condition: service_healthy
volumes:
- ./run/app:/vault/secrets # Not read-only - app writes nexus.pid
environment:
- RUST_LOG=nexus=info,tower_http=info
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5050/health/ready"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ============================================
# PGBOUNCER SERVICE (for Kratos)
# ============================================
# PgBouncer with integrated Vault Agent
# Proxies Kratos DB connections with dynamic Vault credentials
pgbouncer:
build:
context: ./pgbouncer
dockerfile: Dockerfile
container_name: nexus-pgbouncer
restart: unless-stopped
network_mode: host
cap_add:
- IPC_LOCK
volumes:
- ./vault/agent-config-pgbouncer.hcl:/vault/config/agent-config.hcl:ro
- ./vault/templates:/vault/templates:ro
- ./secrets/kratos-app/role-id:/vault/role-id:ro
- ./secrets/kratos-app/secret-id:/vault/secret-id:ro
environment:
- VAULT_ADDR=http://vault.example.local:8200
healthcheck:
test: ["CMD", "pg_isready", "-h", "127.0.0.1", "-p", "6432", "-U", "kratos"]
interval: 5s
timeout: 3s
retries: 30
start_period: 15s
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ============================================
# KRATOS SERVICES
# ============================================
# Vault Agent for Kratos migrations (one-shot)
vault-agent-kratos-migrate:
image: hashicorp/vault:1.18
container_name: nexus-vault-agent-kratos-migrate
network_mode: host
user: "0:0"
command: >
sh -c "rm -f /vault/secrets/.env;
vault agent -config=/vault/config/agent-config.hcl &
while [ ! -f /vault/secrets/.env ]; do sleep 1; done;
echo 'Secrets rendered, exiting'; exit 0"
cap_add:
- IPC_LOCK
volumes:
- ./vault/agent-config-kratos-migrate.hcl:/vault/config/agent-config.hcl:ro
- ./vault/templates:/vault/templates:ro
- ./secrets/kratos-migrate/role-id:/vault/role-id:ro
- ./secrets/kratos-migrate/secret-id:/vault/secret-id:ro
- ./run/kratos-migrate:/vault/secrets
environment:
- VAULT_ADDR=http://vault.example.local:8200
# Kratos migration runner (one-shot)
kratos-migrate:
image: oryd/kratos:v1.1.0
container_name: nexus-kratos-migrate
network_mode: host
depends_on:
vault-agent-kratos-migrate:
condition: service_completed_successfully
migrate:
condition: service_completed_successfully # Nexus migrations create kratos schema
volumes:
- ./kratos/config:/etc/kratos:ro
- ./run/kratos-migrate:/vault/secrets:ro
entrypoint: ["/bin/sh", "-c"]
command:
- |
export $(grep -v '^#' /vault/secrets/.env | xargs)
exec kratos migrate sql -e --yes
# Vault Agent for Kratos runtime (long-running)
vault-agent-kratos:
image: hashicorp/vault:1.18
container_name: nexus-vault-agent-kratos
restart: unless-stopped
network_mode: host
user: "0:0"
command: ["vault", "agent", "-config=/vault/config/agent-config.hcl"]
cap_add:
- IPC_LOCK
volumes:
- ./vault/agent-config-kratos.hcl:/vault/config/agent-config.hcl:ro
- ./vault/templates:/vault/templates:ro
- ./secrets/kratos-app/role-id:/vault/role-id:ro
- ./secrets/kratos-app/secret-id:/vault/secret-id:ro
- ./run/kratos:/vault/secrets
environment:
- VAULT_ADDR=http://vault.example.local:8200
healthcheck:
test: ["CMD", "test", "-f", "/vault/secrets/.env"]
interval: 5s
timeout: 3s
retries: 30
start_period: 10s
# Kratos identity server (long-running)
kratos:
image: oryd/kratos:v1.1.0
container_name: nexus-kratos
restart: unless-stopped
network_mode: host
depends_on:
kratos-migrate:
condition: service_completed_successfully
vault-agent-kratos:
condition: service_healthy
pgbouncer:
condition: service_healthy
volumes:
- ./kratos/config:/etc/kratos:ro
- ./run/kratos:/vault/secrets:ro
entrypoint: ["/bin/sh", "-c"]
command:
- |
export $(grep -v '^#' /vault/secrets/.env | xargs)
exec kratos serve --config /etc/kratos/kratos.yml
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:6050/health/alive"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ============================================
# OATHKEEPER SERVICE
# ============================================
# Vault Agent for Oathkeeper (long-running)
vault-agent-oathkeeper:
image: hashicorp/vault:1.18
container_name: nexus-vault-agent-oathkeeper
restart: unless-stopped
network_mode: host
user: "0:0"
command: ["vault", "agent", "-config=/vault/config/agent-config.hcl"]
cap_add:
- IPC_LOCK
volumes:
- ./vault/agent-config-oathkeeper.hcl:/vault/config/agent-config.hcl:ro
- ./vault/templates:/vault/templates:ro
- ./secrets/oathkeeper/role-id:/vault/role-id:ro
- ./secrets/oathkeeper/secret-id:/vault/secret-id:ro
- ./run/oathkeeper:/vault/secrets
environment:
- VAULT_ADDR=http://vault.example.local:8200
healthcheck:
test: ["CMD", "test", "-f", "/vault/secrets/.env"]
interval: 5s
timeout: 3s
retries: 30
start_period: 10s
# Oathkeeper API gateway (stateless, long-running)
oathkeeper:
build:
context: ./oathkeeper
dockerfile: Dockerfile
container_name: nexus-oathkeeper
restart: unless-stopped
network_mode: host
depends_on:
kratos:
condition: service_healthy
nexus:
condition: service_healthy
vault-agent-oathkeeper:
condition: service_healthy
volumes:
- ./run/oathkeeper:/vault/secrets:ro
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:7250/health/alive"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ============================================
# AUTH FRONTEND SERVICE
# ============================================
# Auth frontend (account.example.com)
auth-frontend:
build:
context: ./auth-frontend
dockerfile: Dockerfile
container_name: nexus-auth-frontend
restart: unless-stopped
network_mode: host
environment:
- KRATOS_SERVER_URL=http://localhost:6000
- ORIGIN=https://account.example.com
- ADMIN_USER_ID=00000000-0000-0000-0000-000000000000 # Replace with your admin user ID
depends_on:
kratos:
condition: service_healthy
oathkeeper:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ============================================
# MAIN FRONTEND SERVICE
# ============================================
# Main frontend (app.example.com)
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: nexus-frontend
restart: unless-stopped
network_mode: host
environment:
- NODE_ENV=production
- ORIGIN=https://app.example.com
depends_on:
oathkeeper:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5000/"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"

32
entrypoint.sh Normal file
View File

@ -0,0 +1,32 @@
#!/bin/sh
set -e
# Source Vault-rendered environment if available
if [ -f /vault/secrets/.env ]; then
echo "Loading environment from Vault..."
set -a
. /vault/secrets/.env
set +a
fi
# Start the application in background
/app/nexus &
APP_PID=$!
# Write PID for Vault Agent to signal on credential rotation
# Use shared volume so vault-agent can read it
echo $APP_PID > /vault/secrets/nexus.pid
echo "Nexus started with PID $APP_PID"
# Forward signals to the app
trap "kill -TERM $APP_PID" TERM INT
trap "kill -HUP $APP_PID" HUP
# Wait for app to exit
wait $APP_PID
EXIT_CODE=$?
# Clean up
rm -f /vault/secrets/nexus.pid
exit $EXIT_CODE

49
frontend/.dockerignore Normal file
View File

@ -0,0 +1,49 @@
# Dependencies
node_modules
.pnp
.pnp.js
# Build outputs
.svelte-kit
build
dist
.output
.vercel
.netlify
.wrangler
# Cache and vite
.vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# Testing
coverage
# Environment files
.env
.env.*
!.env.example
# IDE and editors
.vscode
.idea
# Version control
.git
.gitignore
# Documentation
*.md
# OS files
.DS_Store
Thumbs.db
# Debug logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Misc
*.pem

24
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
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-*
/.vscode/

5
frontend/.graphqlrc.yaml Normal file
View File

@ -0,0 +1,5 @@
schema:
- http://localhost:5050/graphql:
headers:
X-Oathkeeper-Secret: ${OATHKEEPER_SECRET}
documents: src/lib/graphql/**/*.ts

1
frontend/.npmrc Normal file
View File

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

9
frontend/.prettierignore Normal file
View File

@ -0,0 +1,9 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lock
bun.lockb
# Miscellaneous
/static/

16
frontend/.prettierrc Normal file
View File

@ -0,0 +1,16 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
],
"tailwindStylesheet": "./src/routes/layout.css"
}

55
frontend/Dockerfile Normal file
View File

@ -0,0 +1,55 @@
# ====================================
# Build Stage
# ====================================
FROM node:22-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install all dependencies (including devDependencies for build)
RUN npm ci
# Copy source code and configuration
COPY . .
# Build the SvelteKit application
RUN npm run build
# Prune dev dependencies after build
RUN npm prune --production
# ====================================
# Production Stage
# ====================================
FROM node:22-alpine
# Install curl for health checks
RUN apk add --no-cache curl
WORKDIR /app
# Copy built application from builder
COPY --from=builder /app/build ./build
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S sveltekit -u 1001 && \
chown -R sveltekit:nodejs /app
USER sveltekit
EXPOSE 5000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:5000/ || exit 1
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=5000
ENV ORIGIN=https://app.example.com
CMD ["node", "build"]

38
frontend/README.md Normal file
View File

@ -0,0 +1,38 @@
# 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 in the current directory
npx sv create
# create a new project in my-app
npx sv create my-app
```
## 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.

43
frontend/eslint.config.js Normal file
View File

@ -0,0 +1,43 @@
import prettier from 'eslint-config-prettier';
import { fileURLToPath } from 'node:url';
import { includeIgnoreFile } from '@eslint/compat';
import js from '@eslint/js';
import svelte from 'eslint-plugin-svelte';
import { defineConfig } from 'eslint/config';
import globals from 'globals';
import ts from 'typescript-eslint';
import svelteConfig from './svelte.config.js';
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
export default defineConfig(
includeIgnoreFile(gitignorePath),
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs.recommended,
prettier,
...svelte.configs.prettier,
{
languageOptions: { globals: { ...globals.browser, ...globals.node } },
rules: {
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off',
// Navigation without resolve() is fine for absolute paths in this project
'svelte/no-navigation-without-resolve': 'off'
}
},
{
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: {
parserOptions: {
projectService: true,
extraFileExtensions: ['.svelte'],
parser: ts.parser,
svelteConfig
}
}
}
);

4712
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

49
frontend/package.json Normal file
View File

@ -0,0 +1,49 @@
{
"name": "frontend",
"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",
"format": "prettier --write .",
"lint": "prettier --check . && eslint ."
},
"devDependencies": {
"@eslint/compat": "^1.4.0",
"@eslint/js": "^9.39.1",
"@sveltejs/adapter-node": "^5.4.0",
"@sveltejs/kit": "^2.49.1",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.17",
"@types/apollo-upload-client": "^19.0.0",
"@types/node": "^24",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.13.1",
"globals": "^16.5.0",
"prettier": "^3.7.4",
"prettier-plugin-svelte": "^3.4.0",
"prettier-plugin-tailwindcss": "^0.7.2",
"svelte": "^5.45.6",
"svelte-check": "^4.3.4",
"tailwindcss": "^4.1.17",
"typescript": "^5.9.3",
"typescript-eslint": "^8.48.1",
"vite": "^7.2.6",
"vite-plugin-devtools-json": "^1.0.0",
"vite-plugin-mkcert": "^1.17.9"
},
"dependencies": {
"@apollo/client": "^4.0.11",
"apollo-upload-client": "^19.0.0",
"date-fns": "^4.1.0",
"graphql": "^16.12.0"
}
}

56
frontend/src/app.d.ts vendored Normal file
View File

@ -0,0 +1,56 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
// Kratos session identity
interface SessionIdentity {
id: string;
traits: {
email?: string;
name?: {
first?: string;
last?: string;
};
phone?: string;
profile_type?: 'team' | 'customer';
};
metadata_public?: {
django_profile_id?: string;
customer_id?: string;
};
}
// Kratos session
interface Session {
id: string;
active: boolean;
identity: SessionIdentity;
expires_at?: string;
authenticated_at?: string;
}
// User from GraphQL Me query
interface User {
__typename: 'TeamProfileType' | 'CustomerProfileType';
id: string;
fullName: string;
email: string;
phone?: string;
role?: 'ADMIN' | 'TEAM_LEADER' | 'TEAM_MEMBER'; // TeamProfile only
customers?: Array<{ id: string; name: string }>; // CustomerProfile only
}
declare global {
namespace App {
// interface Error {}
interface Locals {
cookie: string | null;
}
interface PageData {
user: User | null;
}
// interface PageState {}
// interface Platform {}
}
}
export { Session, SessionIdentity, User };

28
frontend/src/app.html Normal file
View File

@ -0,0 +1,28 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script>
// Apply theme immediately to prevent flash
(function () {
const stored = localStorage.getItem('theme-preference');
let theme = 'light';
if (stored === 'dark') {
theme = 'dark';
} else if (stored === 'light') {
theme = 'light';
} else {
// System preference
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
document.documentElement.classList.add(theme);
})();
</script>
%sveltekit.head%
<title>Nexus</title>
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@ -0,0 +1,10 @@
import type { Handle } from '@sveltejs/kit';
import { KRATOS_SESSION_COOKIE } from '$lib/config';
export const handle: Handle = async ({ event, resolve }) => {
// Extract Kratos session cookie for forwarding to backend
const cookie = event.cookies.get(KRATOS_SESSION_COOKIE);
event.locals.cookie = cookie ? `${KRATOS_SESSION_COOKIE}=${cookie}` : null;
return resolve(event);
};

View File

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

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

View File

@ -0,0 +1,19 @@
<script lang="ts">
interface Props {
href?: string;
label?: string;
class?: string;
}
let { href = '/', label = 'Home', class: className = '' }: Props = $props();
</script>
<a
{href}
class="inline-flex items-center gap-1 text-sm text-theme-muted transition-colors hover:text-theme {className}"
>
<svg class="h-4 w-4" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
{label}
</a>

View File

@ -0,0 +1,148 @@
<script lang="ts">
import { page } from '$app/stores';
import NavIcon from '$lib/components/icons/NavIcon.svelte';
type NavIconName =
| 'customers'
| 'accounts'
| 'services'
| 'projects'
| 'scopes'
| 'reports'
| 'invoices'
| 'calendar';
type NavItem = { label: string; href: string; icon: NavIconName; color: string };
// Left nav items (before FAB)
const leftItems: NavItem[] = [
{ label: 'Customers', href: '/admin/customers', icon: 'customers', color: 'accent3' },
{ label: 'Accounts', href: '/admin/accounts', icon: 'accounts', color: 'primary' },
{ label: 'Services', href: '/admin/services', icon: 'services', color: 'secondary' },
{ label: 'Projects', href: '/admin/projects', icon: 'projects', color: 'accent' }
];
// Right nav items (after FAB)
const rightItems: NavItem[] = [
{ label: 'Scopes', href: '/admin/scopes', icon: 'scopes', color: 'accent3' },
{ label: 'Reports', href: '/admin/reports', icon: 'reports', color: 'accent2' },
{ label: 'Invoices', href: '/admin/invoices', icon: 'invoices', color: 'accent' },
{ label: 'Calendar', href: '/admin/calendar', icon: 'calendar', color: 'primary' }
];
// Add menu state
let addMenuOpen = $state(false);
const addMenuItems = [
{ label: 'Add Customer', href: '/admin/customers/new' },
{ label: 'Add Account', href: '/admin/accounts/new' },
{ label: 'Schedule Service', href: '/admin/services?action=create' },
{ label: 'Schedule Project', href: '/admin/projects?action=create' },
{ label: 'Create Invoice', href: '/admin/invoices/new' }
];
function isActive(href: string): boolean {
if (href === '/admin') {
return $page.url.pathname === '/admin';
}
return $page.url.pathname.startsWith(href);
}
function getActiveColor(color: string): string {
return `text-${color}-600 dark:text-${color}-400`;
}
function closeMenu() {
addMenuOpen = false;
}
</script>
<svelte:window
on:click={(e) => {
if (addMenuOpen) closeMenu();
}}
/>
<nav class="pb-safe fixed inset-x-0 bottom-0 z-30 border-t border-theme bg-theme-secondary">
<div class="relative mx-auto flex h-20 items-center justify-between px-2 sm:h-16 sm:px-4">
<!-- Left items -->
<div class="grid flex-1 grid-cols-2 gap-x-1 gap-y-0.5 sm:flex sm:justify-end sm:gap-4">
{#each leftItems as item (item.href)}
{@const active = isActive(item.href)}
<a
href={item.href}
class="flex flex-col items-center justify-center rounded-lg px-1 py-1 transition-all duration-200 sm:px-3 sm:py-1.5 {active
? getActiveColor(item.color)
: 'text-theme-muted hover:text-theme-secondary'}"
aria-current={active ? 'page' : undefined}
>
<NavIcon name={item.icon} class="h-5 w-5 sm:h-6 sm:w-6" />
<span class="mt-0.5 text-[8px] font-semibold sm:text-[10px]">{item.label}</span>
</a>
{/each}
</div>
<!-- Center FAB -->
<div class="relative mx-2 flex shrink-0 items-center justify-center sm:mx-4">
<!-- Add Menu Dropdown -->
{#if addMenuOpen}
<div
class="absolute bottom-full left-1/2 z-50 mb-3 w-56 -translate-x-1/2 overflow-hidden rounded-xl border border-theme bg-theme-card shadow-theme-lg"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="menu"
tabindex="-1"
>
{#each addMenuItems as menuItem (menuItem.label)}
<a
href={menuItem.href}
class="block px-4 py-3 text-sm text-theme transition-colors hover:bg-theme-secondary"
onclick={closeMenu}
role="menuitem"
>
{menuItem.label}
</a>
{/each}
</div>
{/if}
<button
type="button"
class="flex h-14 w-14 items-center justify-center rounded-full bg-primary-500 text-white shadow-lg transition-all duration-200 hover:bg-primary-600 active:bg-primary-700 {addMenuOpen
? 'rotate-45'
: ''}"
onclick={(e) => {
e.stopPropagation();
addMenuOpen = !addMenuOpen;
}}
aria-label="Add new item"
aria-expanded={addMenuOpen}
>
<svg class="h-7 w-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
</button>
</div>
<!-- Right items -->
<div class="grid flex-1 grid-cols-2 gap-x-1 gap-y-0.5 sm:flex sm:justify-start sm:gap-4">
{#each rightItems as item (item.href)}
{@const active = isActive(item.href)}
<a
href={item.href}
class="flex flex-col items-center justify-center rounded-lg px-1 py-1 transition-all duration-200 sm:px-3 sm:py-1.5 {active
? getActiveColor(item.color)
: 'text-theme-muted hover:text-theme-secondary'}"
aria-current={active ? 'page' : undefined}
>
<NavIcon name={item.icon} class="h-5 w-5 sm:h-6 sm:w-6" />
<span class="mt-0.5 text-[8px] font-semibold sm:text-[10px]">{item.label}</span>
</a>
{/each}
</div>
</div>
</nav>

View File

@ -0,0 +1,250 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { page } from '$app/stores';
import { goto, beforeNavigate } from '$app/navigation';
interface Props {
title: string;
subtitle?: string;
subtitleSnippet?: Snippet<[{ toggleMenu: () => void }]>;
actions?: Snippet;
showBackButton?: boolean;
backHref?: string;
invalidateOnBack?: boolean;
}
let {
title,
subtitle,
subtitleSnippet,
actions,
showBackButton = true,
backHref = '/admin',
invalidateOnBack = false
}: Props = $props();
let menuOpen = $state(false);
let isNavigatingBack = $state(false);
// Track when we're navigating to the back URL
beforeNavigate(({ to }) => {
if (to?.url.pathname === backHref) {
isNavigatingBack = true;
}
});
function handleBack(event: MouseEvent) {
if (invalidateOnBack) {
event.preventDefault();
goto(backHref, { invalidateAll: true });
}
}
function toggleMenu() {
menuOpen = !menuOpen;
}
const navItems = [
{
label: 'Customers',
href: '/admin/customers',
color: 'text-accent3-600 dark:text-accent3-400'
},
{ label: 'Accounts', href: '/admin/accounts', color: 'text-primary-600 dark:text-primary-400' },
{
label: 'Services',
href: '/admin/services',
color: 'text-secondary-600 dark:text-secondary-400'
},
{ label: 'Projects', href: '/admin/projects', color: 'text-accent-600 dark:text-accent-400' },
{ label: 'Scopes', href: '/admin/scopes', color: 'text-accent3-600 dark:text-accent3-400' },
{ label: 'Reports', href: '/admin/reports', color: 'text-accent2-600 dark:text-accent2-400' },
{ label: 'Invoices', href: '/admin/invoices', color: 'text-accent6-600 dark:text-accent6-400' },
{ label: 'Calendar', href: '/admin/calendar', color: 'text-accent7-600 dark:text-accent7-400' },
{ label: 'Profiles', href: '/admin/profiles', color: 'text-accent4-600 dark:text-accent4-400' }
];
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement;
if (!target.closest('.nav-menu-container') && !target.closest('.menu-trigger')) {
menuOpen = false;
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
menuOpen = false;
}
}
</script>
<svelte:window onclick={handleClickOutside} onkeydown={handleKeydown} />
<div class="mb-6">
{#if showBackButton}
<!-- Flex layout: back button + content on left, actions on right -->
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div class="flex items-start gap-3">
<a
href={backHref}
onclick={handleBack}
class="mt-1 rounded-lg p-1 text-theme-muted hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
aria-label="Go back"
>
{#if isNavigatingBack}
<svg
class="h-5 w-5 animate-spin text-primary-600 dark:text-primary-400"
style="fill: none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{:else}
<svg class="h-5 w-5" style="fill: none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 19l-7-7 7-7"
/>
</svg>
{/if}
</a>
<div>
<div class="flex items-center gap-2">
<h1 class="page-title text-primary-600 dark:text-primary-400">{title}</h1>
<div class="nav-menu-container relative">
<button
type="button"
onclick={() => (menuOpen = !menuOpen)}
class="rounded-lg p-1 text-theme-muted transition-colors hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
aria-label="Navigation menu"
aria-expanded={menuOpen}
>
<svg
class="h-5 w-5 transition-transform {menuOpen ? 'rotate-90' : ''}"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</button>
{#if menuOpen}
<div
class="fixed z-50 mt-2 min-w-48 rounded-lg border border-theme bg-theme-card py-2 shadow-theme-lg"
>
{#each navItems as item (item.href)}
<a
href={item.href}
class="block interactive px-4 py-2 text-sm {item.color} {$page.url
.pathname === item.href
? 'font-semibold'
: ''}"
onclick={() => (menuOpen = false)}
>
{item.label}
</a>
{/each}
</div>
{/if}
</div>
</div>
{#if subtitleSnippet}
<p class="page-subtitle">
{@render subtitleSnippet({ toggleMenu })}
</p>
{:else if subtitle}
<p class="page-subtitle">{subtitle}</p>
{/if}
</div>
</div>
{#if actions}
<div class="flex flex-wrap gap-2">
{@render actions()}
</div>
{/if}
</div>
{:else}
<!-- No back button - simple layout -->
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<div class="flex items-center gap-2">
<h1 class="page-title text-primary-600 dark:text-primary-400">{title}</h1>
<div class="nav-menu-container relative">
<button
type="button"
onclick={() => (menuOpen = !menuOpen)}
class="rounded-lg p-1 text-theme-muted transition-colors hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
aria-label="Navigation menu"
aria-expanded={menuOpen}
>
<svg
class="h-5 w-5 transition-transform {menuOpen ? 'rotate-90' : ''}"
style="fill: none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</button>
{#if menuOpen}
<div
class="fixed z-50 mt-2 min-w-48 rounded-lg border border-theme bg-theme-card py-2 shadow-theme-lg"
>
{#each navItems as item (item.href)}
<a
href={item.href}
class="block interactive px-4 py-2 text-sm {item.color} {$page.url.pathname ===
item.href
? 'font-semibold'
: ''}"
onclick={() => (menuOpen = false)}
>
{item.label}
</a>
{/each}
</div>
{/if}
</div>
</div>
{#if subtitleSnippet}
<p class="page-subtitle">
{@render subtitleSnippet({ toggleMenu })}
</p>
{:else if subtitle}
<p class="page-subtitle">{subtitle}</p>
{/if}
</div>
{#if actions}
<div class="flex flex-wrap gap-2">
{@render actions()}
</div>
{/if}
</div>
{/if}
</div>

View File

@ -0,0 +1,79 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { IconChevronLeft, IconChevronRight } from '$lib/components/icons';
interface Props {
month: string; // YYYY-MM format
}
let { month }: Props = $props();
// Parse month into Date object
let currentDate = $derived.by(() => {
const [year, monthNum] = month.split('-').map(Number);
return new Date(year, monthNum - 1, 1);
});
let displayMonth = $derived(
currentDate.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
);
function formatMonth(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
return `${year}-${month}`;
}
function navigateMonth(direction: -1 | 1) {
const date = new Date(currentDate);
date.setMonth(date.getMonth() + direction);
const newMonth = formatMonth(date);
// Preserve other URL params while updating month
const url = new URL($page.url);
url.searchParams.set('month', newMonth);
// Reset to first page when changing month
url.searchParams.delete('page');
goto(url.toString());
}
function goToCurrentMonth() {
const newMonth = formatMonth(new Date());
const url = new URL($page.url);
url.searchParams.set('month', newMonth);
url.searchParams.delete('page');
goto(url.toString());
}
let isCurrentMonth = $derived(month === formatMonth(new Date()));
</script>
<div class="flex items-center gap-2">
<button
type="button"
onclick={() => navigateMonth(-1)}
class="rounded-lg p-2 text-theme-secondary transition-colors hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
aria-label="Previous month"
>
<IconChevronLeft class="h-5 w-5" />
</button>
<button
type="button"
onclick={goToCurrentMonth}
disabled={isCurrentMonth}
class="min-w-[160px] rounded-lg px-4 py-2 text-center font-medium text-theme transition-colors hover:bg-black/5 disabled:cursor-default disabled:hover:bg-transparent dark:hover:bg-white/10"
>
{displayMonth}
</button>
<button
type="button"
onclick={() => navigateMonth(1)}
class="rounded-lg p-2 text-theme-secondary transition-colors hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
aria-label="Next month"
>
<IconChevronRight class="h-5 w-5" />
</button>
</div>

View File

@ -0,0 +1,116 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { goto } from '$app/navigation';
import { IconChevronLeft, IconEdit } from '$lib/components/icons';
type ColorScheme =
| 'primary'
| 'secondary'
| 'accent'
| 'accent2'
| 'accent3'
| 'accent4'
| 'accent5'
| 'accent6'
| 'accent7'
| 'gray';
interface Props {
title: string;
subtitle?: string;
children?: Snippet;
colorScheme?: ColorScheme;
backHref?: string;
invalidateOnBack?: boolean;
onEdit?: () => void;
}
let {
title,
subtitle,
children,
colorScheme = 'primary',
backHref = '/admin',
invalidateOnBack = false,
onEdit
}: Props = $props();
let isNavigating = $state(false);
function handleBack(event: MouseEvent) {
event.preventDefault();
isNavigating = true;
goto(backHref, { invalidateAll: invalidateOnBack });
}
const colorClasses: Record<ColorScheme, string> = {
primary: 'text-primary-600 dark:text-primary-400',
secondary: 'text-secondary-600 dark:text-secondary-400',
accent: 'text-accent-600 dark:text-accent-400',
accent2: 'text-accent2-600 dark:text-accent2-400',
accent3: 'text-accent3-600 dark:text-accent3-400',
accent4: 'text-accent4-600 dark:text-accent4-400',
accent5: 'text-accent5-600 dark:text-accent5-400',
accent6: 'text-accent6-600 dark:text-accent6-400',
accent7: 'text-accent7-600 dark:text-accent7-400',
gray: 'text-gray-500 dark:text-gray-400'
};
</script>
<div class="mb-6">
<div class="grid grid-cols-[auto_1fr_auto] items-start gap-2 sm:gap-4">
<!-- Back button -->
<a
href={backHref}
onclick={handleBack}
class="rounded-lg p-1 text-theme-muted transition-colors hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
aria-label="Go back"
>
{#if isNavigating}
<svg class="h-5 w-5 animate-spin {colorClasses[colorScheme]}" viewBox="0 0 24 24">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
fill="none"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{:else}
<IconChevronLeft class="h-5 w-5" />
{/if}
</a>
<!-- Title and subtitle -->
<div>
<h1 class="text-2xl font-bold md:text-3xl {colorClasses[colorScheme]}">{title}</h1>
{#if subtitle}
<p class="mt-1 text-theme-secondary">{subtitle}</p>
{/if}
{#if children}
<div class="mt-2">
{@render children()}
</div>
{/if}
</div>
<!-- Edit button -->
{#if onEdit}
<button
type="button"
onclick={onEdit}
class="btn-icon text-theme-secondary"
aria-label="Edit"
>
<IconEdit class="h-5 w-5" />
</button>
{/if}
</div>
</div>

View File

@ -0,0 +1,123 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { IconChevronLeft, IconChevronRight } from '$lib/components/icons';
interface Props {
currentPage: number;
totalCount: number;
limit: number;
hasNextPage: boolean;
}
let { currentPage, totalCount, limit, hasNextPage }: Props = $props();
let totalPages = $derived(Math.ceil(totalCount / limit));
let hasPrevPage = $derived(currentPage > 1);
let startItem = $derived((currentPage - 1) * limit + 1);
let endItem = $derived(Math.min(currentPage * limit, totalCount));
function goToPage(pageNum: number) {
const url = new URL($page.url);
if (pageNum === 1) {
url.searchParams.delete('page');
} else {
url.searchParams.set('page', String(pageNum));
}
goto(url.toString());
}
// Generate page numbers to display
let pageNumbers = $derived.by(() => {
const pages: (number | 'ellipsis')[] = [];
const maxVisible = 5;
if (totalPages <= maxVisible) {
// Show all pages
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
// Always show first page
pages.push(1);
if (currentPage > 3) {
pages.push('ellipsis');
}
// Show pages around current
const start = Math.max(2, currentPage - 1);
const end = Math.min(totalPages - 1, currentPage + 1);
for (let i = start; i <= end; i++) {
pages.push(i);
}
if (currentPage < totalPages - 2) {
pages.push('ellipsis');
}
// Always show last page
if (totalPages > 1) {
pages.push(totalPages);
}
}
return pages;
});
</script>
{#if totalCount > 0}
<div class="flex flex-col items-center justify-between gap-3 sm:flex-row">
<!-- Item count -->
<p class="text-sm text-theme-secondary">
Showing <span class="font-medium text-theme">{startItem}</span>
to <span class="font-medium text-theme">{endItem}</span>
of <span class="font-medium text-theme">{totalCount}</span> results
</p>
<!-- Page navigation -->
<div class="flex items-center gap-1">
<!-- Previous button -->
<button
type="button"
onclick={() => goToPage(currentPage - 1)}
disabled={!hasPrevPage}
class="rounded-lg p-2 text-theme-secondary transition-colors hover:bg-black/5 hover:text-theme disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
aria-label="Previous page"
>
<IconChevronLeft class="h-4 w-4" />
</button>
<!-- Page numbers -->
{#each pageNumbers as pageNum}
{#if pageNum === 'ellipsis'}
<span class="px-2 text-theme-secondary">...</span>
{:else}
<button
type="button"
onclick={() => goToPage(pageNum)}
class="min-w-[2rem] rounded-lg px-2 py-1 text-sm font-medium transition-colors
{currentPage === pageNum
? 'bg-primary-500 text-white'
: 'text-theme-secondary hover:bg-black/5 hover:text-theme dark:hover:bg-white/10'}"
>
{pageNum}
</button>
{/if}
{/each}
<!-- Next button -->
<button
type="button"
onclick={() => goToPage(currentPage + 1)}
disabled={!hasNextPage}
class="rounded-lg p-2 text-theme-secondary transition-colors hover:bg-black/5 hover:text-theme disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
aria-label="Next page"
>
<IconChevronRight class="h-4 w-4" />
</button>
</div>
</div>
{/if}

View File

@ -0,0 +1,30 @@
<script lang="ts">
import { IconPlus, IconEdit } from '$lib/components/icons';
interface Props {
title: string;
buttonText?: string;
buttonIcon?: 'plus' | 'edit' | 'none';
onButtonClick?: () => void;
}
let { title, buttonText, buttonIcon = 'plus', onButtonClick }: Props = $props();
</script>
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-theme">{title}</h2>
{#if onButtonClick && buttonText}
<button
type="button"
onclick={onButtonClick}
class="inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium text-primary-500 transition-colors hover:bg-primary-500/10"
>
{#if buttonIcon === 'plus'}
<IconPlus />
{:else if buttonIcon === 'edit'}
<IconEdit />
{/if}
<span>{buttonText}</span>
</button>
{/if}
</div>

View File

@ -0,0 +1,111 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
interface StatusCounts {
scheduled: number;
inProgress: number;
completed: number;
cancelled: number;
}
type StatusValue = 'SCHEDULED' | 'IN_PROGRESS' | 'COMPLETED' | 'CANCELLED' | null;
interface Props {
counts: StatusCounts;
currentStatus: StatusValue;
showCancelled?: boolean;
}
let { counts, currentStatus, showCancelled = false }: Props = $props();
interface Tab {
label: string;
value: StatusValue;
count: number;
colorClass: string;
activeColorClass: string;
}
let tabs = $derived.by<Tab[]>(() => {
const baseTabs: Tab[] = [
{
label: 'All',
value: null,
count:
counts.scheduled +
counts.inProgress +
counts.completed +
(showCancelled ? counts.cancelled : 0),
colorClass: 'text-theme-secondary',
activeColorClass: 'bg-theme text-white'
},
{
label: 'Scheduled',
value: 'SCHEDULED',
count: counts.scheduled,
colorClass: 'text-accent2-600 dark:text-accent2-400',
activeColorClass: 'bg-accent2-500 text-white'
},
{
label: 'In Progress',
value: 'IN_PROGRESS',
count: counts.inProgress,
colorClass: 'text-accent-600 dark:text-accent-400',
activeColorClass: 'bg-accent-500 text-white'
},
{
label: 'Completed',
value: 'COMPLETED',
count: counts.completed,
colorClass: 'text-primary-600 dark:text-primary-400',
activeColorClass: 'bg-primary-500 text-white'
}
];
if (showCancelled) {
baseTabs.push({
label: 'Cancelled',
value: 'CANCELLED',
count: counts.cancelled,
colorClass: 'text-red-600 dark:text-red-400',
activeColorClass: 'bg-red-500 text-white'
});
}
return baseTabs;
});
function selectStatus(status: StatusValue) {
const url = new URL($page.url);
if (status) {
url.searchParams.set('status', status);
} else {
url.searchParams.delete('status');
}
// Reset to first page when changing status
url.searchParams.delete('page');
goto(url.toString());
}
</script>
<div class="flex flex-wrap gap-1 sm:gap-2">
{#each tabs as tab}
<button
type="button"
onclick={() => selectStatus(tab.value)}
class="inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium transition-colors
{currentStatus === tab.value
? tab.activeColorClass
: 'hover:bg-black/5 dark:hover:bg-white/10 ' + tab.colorClass}"
>
<span>{tab.label}</span>
<span
class="rounded-full px-1.5 py-0.5 text-xs
{currentStatus === tab.value ? 'bg-white/20' : 'bg-black/10 dark:bg-white/10'}"
>
{tab.count}
</span>
</button>
{/each}
</div>

View File

@ -0,0 +1,7 @@
export { default as AdminBottomNav } from './AdminBottomNav.svelte';
export { default as AdminDashboardHeader } from './AdminDashboardHeader.svelte';
export { default as MonthSelector } from './MonthSelector.svelte';
export { default as PageHeader } from './PageHeader.svelte';
export { default as Pagination } from './Pagination.svelte';
export { default as SectionHeader } from './SectionHeader.svelte';
export { default as StatusTabs } from './StatusTabs.svelte';

View File

@ -0,0 +1,554 @@
<script lang="ts">
import { fade, scale } from 'svelte/transition';
import { client } from '$lib/graphql/client';
import {
ACCOUNTS_WITH_SCHEDULES_QUERY,
type AccountForGeneration,
type AccountsWithSchedulesQueryResult,
type ScheduleForGeneration,
type AddressForGeneration
} from '$lib/graphql/queries/accounts';
import {
GENERATE_SERVICES_BY_MONTH,
type GenerateServicesInput,
type GenerateServicesByMonthResult
} from '$lib/graphql/mutations/service';
interface Props {
open: boolean;
onClose: () => void;
onSuccess?: () => void;
}
let { open, onClose, onSuccess }: Props = $props();
// State
let loading = $state(true);
let generating = $state(false);
let error = $state<string | null>(null);
let accounts = $state<AccountForGeneration[]>([]);
// Month/Year selection (default to next month)
const now = new Date();
const defaultMonth = now.getMonth() === 11 ? 1 : now.getMonth() + 2;
const defaultYear = now.getMonth() === 11 ? now.getFullYear() + 1 : now.getFullYear();
let selectedMonth = $state(defaultMonth);
let selectedYear = $state(defaultYear);
// Schedule selection: Map of scheduleId -> { accountAddressId, schedule }
interface ScheduleSelection {
accountAddressId: string;
schedule: ScheduleForGeneration;
accountName: string;
addressName: string;
}
let selectedSchedules = $state<Map<string, ScheduleSelection>>(new Map());
// Expanded accounts for accordion
let expandedAccounts = $state<Set<string>>(new Set());
// Results
let results = $state<{ success: number; errors: string[] } | null>(null);
// Months for dropdown
const months = [
{ value: 1, label: 'January' },
{ value: 2, label: 'February' },
{ value: 3, label: 'March' },
{ value: 4, label: 'April' },
{ value: 5, label: 'May' },
{ value: 6, label: 'June' },
{ value: 7, label: 'July' },
{ value: 8, label: 'August' },
{ value: 9, label: 'September' },
{ value: 10, label: 'October' },
{ value: 11, label: 'November' },
{ value: 12, label: 'December' }
];
// Years for dropdown (current year - 1 to current year + 2)
const currentYear = now.getFullYear();
const years = [currentYear - 1, currentYear, currentYear + 1, currentYear + 2];
// Load accounts when modal opens
$effect(() => {
if (open) {
loadAccounts();
} else {
// Reset state when modal closes
selectedSchedules = new Map();
expandedAccounts = new Set();
results = null;
error = null;
}
});
async function loadAccounts() {
loading = true;
error = null;
try {
const result = await client.query<AccountsWithSchedulesQueryResult>({
query: ACCOUNTS_WITH_SCHEDULES_QUERY,
fetchPolicy: 'network-only'
});
if (result.data?.accounts) {
// Filter to only accounts with addresses that have schedules
accounts = result.data.accounts.filter((a: AccountForGeneration) =>
a.addresses.some(
(addr: AddressForGeneration) => addr.isActive && addr.schedules.length > 0
)
);
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load accounts';
} finally {
loading = false;
}
}
function toggleAccount(accountId: string) {
const newExpanded = new Set(expandedAccounts);
if (newExpanded.has(accountId)) {
newExpanded.delete(accountId);
} else {
newExpanded.add(accountId);
}
expandedAccounts = newExpanded;
}
function toggleSchedule(
schedule: ScheduleForGeneration,
address: AddressForGeneration,
account: AccountForGeneration
) {
const newSelected = new Map(selectedSchedules);
if (newSelected.has(schedule.id)) {
newSelected.delete(schedule.id);
} else {
newSelected.set(schedule.id, {
accountAddressId: address.id,
schedule,
accountName: account.name,
addressName: address.name || `${address.city}, ${address.state}`
});
}
selectedSchedules = newSelected;
}
function selectAll() {
const newSelected = new Map<string, ScheduleSelection>();
for (const account of accounts) {
for (const address of account.addresses) {
if (!address.isActive) continue;
for (const schedule of address.schedules) {
if (isScheduleActive(schedule)) {
newSelected.set(schedule.id, {
accountAddressId: address.id,
schedule,
accountName: account.name,
addressName: address.name || `${address.city}, ${address.state}`
});
}
}
}
}
selectedSchedules = newSelected;
}
function clearAll() {
selectedSchedules = new Map();
}
function isScheduleActive(schedule: ScheduleForGeneration): boolean {
const today = new Date().toISOString().split('T')[0];
if (schedule.endDate && schedule.endDate < today) return false;
return true;
}
function getDaysInMonth(month: number, year: number): number {
return new Date(year, month, 0).getDate();
}
function getWeekday(year: number, month: number, day: number): number {
// Returns 0=Monday, 6=Sunday (chrono convention)
const date = new Date(year, month - 1, day);
const jsWeekday = date.getDay(); // 0=Sunday, 6=Saturday
return jsWeekday === 0 ? 6 : jsWeekday - 1;
}
// Calculate estimated service count for a schedule
// Mirrors backend logic: Mon-Thu use day flags, Fri uses weekend_service OR friday flag,
// Sat-Sun use day flags only if weekend_service is false
function estimateServiceCount(schedule: ScheduleForGeneration): number {
const daysInMonth = getDaysInMonth(selectedMonth, selectedYear);
let count = 0;
for (let day = 1; day <= daysInMonth; day++) {
const weekday = getWeekday(selectedYear, selectedMonth, day);
// Check if schedule is active on this date
const dateStr = `${selectedYear}-${String(selectedMonth).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
if (schedule.startDate && dateStr < schedule.startDate) continue;
if (schedule.endDate && dateStr > schedule.endDate) continue;
let scheduleToday = false;
if (weekday >= 0 && weekday <= 3) {
// Mon-Thu: use the individual day flag
if (weekday === 0) scheduleToday = schedule.monday;
else if (weekday === 1) scheduleToday = schedule.tuesday;
else if (weekday === 2) scheduleToday = schedule.wednesday;
else if (weekday === 3) scheduleToday = schedule.thursday;
} else if (weekday === 4) {
// Friday: weekend_service takes precedence, otherwise use friday flag
scheduleToday = schedule.weekendService || schedule.friday;
} else {
// Sat (5) or Sun (6): only use day flag if weekend_service is OFF
if (!schedule.weekendService) {
if (weekday === 5) scheduleToday = schedule.saturday;
else if (weekday === 6) scheduleToday = schedule.sunday;
}
}
if (scheduleToday) count++;
}
return count;
}
function formatScheduleDays(schedule: ScheduleForGeneration): string {
const days: string[] = [];
if (schedule.monday) days.push('M');
if (schedule.tuesday) days.push('T');
if (schedule.wednesday) days.push('W');
if (schedule.thursday) days.push('Th');
if (schedule.friday) days.push('F');
if (schedule.saturday) days.push('Sa');
if (schedule.sunday) days.push('Su');
// Add Weekend indicator if weekend_service is on (and Friday not already shown)
if (schedule.weekendService && !schedule.friday) days.push('Weekend');
else if (schedule.weekendService) days.push('(+Wknd)');
return days.join(', ') || 'No days';
}
let totalEstimatedCount = $derived(
Array.from(selectedSchedules.values()).reduce(
(sum, sel) => sum + estimateServiceCount(sel.schedule),
0
)
);
async function handleGenerate() {
if (selectedSchedules.size === 0) return;
generating = true;
error = null;
results = { success: 0, errors: [] };
for (const [scheduleId, selection] of selectedSchedules) {
try {
const input: GenerateServicesInput = {
accountAddressId: selection.accountAddressId,
scheduleId,
month: selectedMonth,
year: selectedYear
};
const result = await client.mutate<GenerateServicesByMonthResult>({
mutation: GENERATE_SERVICES_BY_MONTH,
variables: { input }
});
if (result.data?.generateServicesByMonth) {
results.success += result.data.generateServicesByMonth.length;
}
} catch (e) {
const errMsg = e instanceof Error ? e.message : 'Unknown error';
results.errors.push(`${selection.accountName} - ${selection.addressName}: ${errMsg}`);
}
}
generating = false;
if (results.errors.length === 0 && results.success > 0) {
onSuccess?.();
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
onClose();
}
}
// Lock body scroll when open
$effect(() => {
if (open) {
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = '';
};
}
});
</script>
<svelte:window onkeydown={handleKeydown} />
{#if open}
<!-- Backdrop -->
<button
type="button"
class="fixed inset-0 z-50 cursor-default bg-overlay backdrop-blur-sm"
onclick={onClose}
aria-label="Close modal"
transition:fade={{ duration: 200 }}
></button>
<!-- Modal -->
<div
class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center"
role="dialog"
aria-modal="true"
aria-labelledby="generate-services-modal-title"
tabindex="-1"
transition:fade={{ duration: 200 }}
>
<div
class="pointer-events-auto mx-4 w-full max-w-2xl rounded-xl border border-primary-500/20 bg-theme-card shadow-2xl"
transition:scale={{ start: 0.95, duration: 200 }}
>
<!-- Header -->
<div
class="border-b border-primary-500/20 bg-linear-to-r from-primary-500/10 to-transparent px-5 py-4"
>
<h2 id="generate-services-modal-title" class="text-lg font-semibold text-theme">
Generate Services
</h2>
<p class="mt-1 text-sm text-theme-secondary">
Create services for a month based on schedules
</p>
</div>
<!-- Body -->
<div class="max-h-[60vh] overflow-y-auto px-5 py-5">
{#if loading}
<div class="flex items-center justify-center py-8">
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-primary-500 border-t-transparent"
></div>
</div>
{:else if error}
<div class="rounded-lg bg-red-50 p-4 text-red-800 dark:bg-red-900/20 dark:text-red-400">
{error}
</div>
{:else if results}
<!-- Results view -->
<div class="space-y-4">
{#if results.success > 0}
<div
class="rounded-lg bg-primary-50 p-4 text-primary-800 dark:bg-primary-900/20 dark:text-primary-400"
>
Successfully created {results.success} service{results.success === 1 ? '' : 's'}.
</div>
{/if}
{#if results.errors.length > 0}
<div
class="rounded-lg bg-red-50 p-4 text-red-800 dark:bg-red-900/20 dark:text-red-400"
>
<p class="font-medium">Some schedules failed:</p>
<ul class="mt-2 list-inside list-disc space-y-1 text-sm">
{#each results.errors as err}
<li>{err}</li>
{/each}
</ul>
</div>
{/if}
</div>
{:else}
<!-- Selection view -->
<div class="space-y-4">
<!-- Month/Year Selection -->
<div class="flex gap-4">
<label class="flex-1">
<span class="mb-1 block text-sm font-medium text-theme-secondary">Month</span>
<select
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
onchange={(e) => (selectedMonth = Number(e.currentTarget.value))}
>
{#each months as month}
<option value={month.value} selected={selectedMonth === month.value}>
{month.label}
</option>
{/each}
</select>
</label>
<label class="w-32">
<span class="mb-1 block text-sm font-medium text-theme-secondary">Year</span>
<select
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-theme focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
onchange={(e) => (selectedYear = Number(e.currentTarget.value))}
>
{#each years as year}
<option value={year} selected={selectedYear === year}>
{year}
</option>
{/each}
</select>
</label>
</div>
<!-- Select All / Clear -->
<div class="flex items-center justify-between">
<span class="text-sm text-theme-secondary">
{selectedSchedules.size} schedule{selectedSchedules.size === 1 ? '' : 's'} selected
{#if selectedSchedules.size > 0}
({totalEstimatedCount} service{totalEstimatedCount === 1 ? '' : 's'})
{/if}
</span>
<div class="flex gap-2">
<button
type="button"
class="text-sm text-primary-500 hover:text-primary-600"
onclick={selectAll}
>
Select All
</button>
<span class="text-theme-muted">|</span>
<button
type="button"
class="text-sm text-theme-secondary hover:text-theme"
onclick={clearAll}
>
Clear
</button>
</div>
</div>
<!-- Account Accordion -->
{#if accounts.length === 0}
<div class="py-4 text-center text-theme-muted">
No active accounts with schedules found.
</div>
{:else}
<div class="space-y-2">
{#each accounts as account (account.id)}
<div class="bg-theme-secondary/30 rounded-lg border border-theme">
<!-- Account Header -->
<button
type="button"
class="flex w-full items-center justify-between px-4 py-3 text-left"
onclick={() => toggleAccount(account.id)}
>
<div>
<span class="font-medium text-theme">{account.name}</span>
{#if account.customer}
<span class="ml-2 text-sm text-theme-muted">
({account.customer.name})
</span>
{/if}
</div>
<svg
class="h-5 w-5 shrink-0 text-theme-muted transition-transform {expandedAccounts.has(
account.id
)
? 'rotate-180'
: ''}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
<!-- Account Content -->
{#if expandedAccounts.has(account.id)}
<div class="border-t border-theme px-4 pt-3 pb-4">
{#each account.addresses as address (address.id)}
{#if address.isActive && address.schedules.length > 0}
<div class="mb-3 last:mb-0">
<p class="mb-2 text-sm font-medium text-theme-secondary">
{address.name || `${address.city}, ${address.state}`}
</p>
<div class="space-y-2 pl-2">
{#each address.schedules as schedule (schedule.id)}
{@const estimated = estimateServiceCount(schedule)}
{@const isActive = isScheduleActive(schedule)}
<label
class="hover:bg-theme-secondary/50 flex cursor-pointer items-center gap-3 rounded-md px-2 py-1 {!isActive
? 'opacity-50'
: ''}"
>
<input
type="checkbox"
checked={selectedSchedules.has(schedule.id)}
disabled={!isActive}
onchange={() => toggleSchedule(schedule, address, account)}
class="h-4 w-4 rounded border-theme text-primary-500 focus:ring-primary-500"
/>
<span class="flex-1 text-sm text-theme">
{schedule.name || 'Service Schedule'}
</span>
<span
class="rounded-full bg-theme-secondary px-2 py-0.5 text-xs text-theme-muted"
>
{formatScheduleDays(schedule)}
</span>
<span
class="rounded-full bg-primary-100 px-2 py-0.5 text-xs font-medium text-primary-700 dark:bg-primary-900/30 dark:text-primary-400"
>
{estimated}
</span>
</label>
{/each}
</div>
</div>
{/if}
{/each}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>
<!-- Footer -->
<div class="flex justify-end gap-3 border-t border-theme px-5 py-4">
{#if results}
<button type="button" class="btn-primary" onclick={onClose}> Done </button>
{:else}
<button type="button" class="btn-secondary" onclick={onClose} disabled={generating}>
Cancel
</button>
<button
type="button"
class="btn-primary"
onclick={handleGenerate}
disabled={generating || selectedSchedules.size === 0}
>
{#if generating}
<span class="flex items-center gap-2">
<span
class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"
></span>
Generating...
</span>
{:else}
Generate {totalEstimatedCount} Service{totalEstimatedCount === 1 ? '' : 's'}
{/if}
</button>
{/if}
</div>
</div>
</div>
{/if}

View File

@ -0,0 +1,43 @@
<script lang="ts">
import { page } from '$app/stores';
import NavIcon from '$lib/components/icons/NavIcon.svelte';
type NavIconName = 'accounts' | 'calendar' | 'history' | 'invoices';
type NavItem = { label: string; href: string; icon: NavIconName; color: string };
const navItems: NavItem[] = [
{ label: 'Accounts', href: '/customer/accounts', icon: 'accounts', color: 'primary' },
{ label: 'Schedule', href: '/customer/schedule', icon: 'calendar', color: 'secondary' },
{ label: 'History', href: '/customer/history', icon: 'history', color: 'accent' },
{ label: 'Invoices', href: '/customer/invoices', icon: 'invoices', color: 'accent2' }
];
function isActive(href: string): boolean {
if (href === '/customer') {
return $page.url.pathname === '/customer';
}
return $page.url.pathname.startsWith(href);
}
function getActiveColor(color: string): string {
return `text-${color}-600 dark:text-${color}-400`;
}
</script>
<nav class="pb-safe fixed inset-x-0 bottom-0 z-30 border-t border-theme bg-theme-secondary">
<div class="mx-auto flex h-16 max-w-lg items-center justify-around px-2">
{#each navItems as item (item.href)}
{@const active = isActive(item.href)}
<a
href={item.href}
class="flex flex-col items-center justify-center rounded-lg px-3 py-1.5 transition-all duration-200 {active
? getActiveColor(item.color)
: 'text-theme-muted hover:text-theme-secondary'}"
aria-current={active ? 'page' : undefined}
>
<NavIcon name={item.icon} class="h-5 w-5 sm:h-6 sm:w-6" />
<span class="mt-0.5 text-[10px] font-semibold sm:text-xs">{item.label}</span>
</a>
{/each}
</div>
</nav>

View File

@ -0,0 +1,15 @@
<script lang="ts">
interface Props {
title: string;
subtitle?: string;
}
let { title, subtitle }: Props = $props();
</script>
<div class="mb-6">
<h1 class="page-title text-primary-600 dark:text-primary-400">{title}</h1>
{#if subtitle}
<p class="page-subtitle">{subtitle}</p>
{/if}
</div>

View File

@ -0,0 +1,84 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { goto } from '$app/navigation';
import { IconChevronLeft } from '$lib/components/icons';
type ColorScheme = 'primary' | 'secondary' | 'accent' | 'accent2';
interface Props {
title: string;
subtitle?: string;
children?: Snippet;
colorScheme?: ColorScheme;
backHref?: string;
}
let {
title,
subtitle,
children,
colorScheme = 'primary',
backHref = '/customer'
}: Props = $props();
let isNavigating = $state(false);
function handleBack(event: MouseEvent) {
event.preventDefault();
isNavigating = true;
goto(backHref);
}
const colorClasses: Record<ColorScheme, string> = {
primary: 'text-primary-600 dark:text-primary-400',
secondary: 'text-secondary-600 dark:text-secondary-400',
accent: 'text-accent-600 dark:text-accent-400',
accent2: 'text-accent2-600 dark:text-accent2-400'
};
</script>
<div class="mb-6">
<div class="grid grid-cols-[auto_1fr] items-start gap-2 sm:gap-4">
<!-- Back button -->
<a
href={backHref}
onclick={handleBack}
class="rounded-lg p-1 text-theme-muted transition-colors hover:bg-black/5 hover:text-theme dark:hover:bg-white/10"
aria-label="Go back"
>
{#if isNavigating}
<svg class="h-5 w-5 animate-spin {colorClasses[colorScheme]}" viewBox="0 0 24 24">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
fill="none"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{:else}
<IconChevronLeft class="h-5 w-5" />
{/if}
</a>
<!-- Title and subtitle -->
<div>
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
<h1 class="text-2xl font-bold md:text-3xl {colorClasses[colorScheme]}">{title}</h1>
{#if children}
{@render children()}
{/if}
</div>
{#if subtitle}
<p class="mt-1 text-theme-secondary">{subtitle}</p>
{/if}
</div>
</div>
</div>

View File

@ -0,0 +1,44 @@
<script lang="ts">
import type { Snippet } from 'svelte';
type ColorScheme = 'primary' | 'secondary' | 'accent' | 'accent2';
interface Props {
title: string;
colorScheme?: ColorScheme;
children: Snippet;
}
let { title, colorScheme = 'primary', children }: Props = $props();
const borderClasses: Record<ColorScheme, string> = {
primary: 'border-primary-500/20',
secondary: 'border-secondary-500/20',
accent: 'border-accent-500/20',
accent2: 'border-accent2-500/20'
};
const gradientClasses: Record<ColorScheme, string> = {
primary: 'from-primary-500/10',
secondary: 'from-secondary-500/10',
accent: 'from-accent-500/10',
accent2: 'from-accent2-500/10'
};
</script>
<section
class="overflow-hidden rounded-xl border bg-theme-card shadow-theme-md {borderClasses[
colorScheme
]}"
>
<div
class="border-b bg-linear-to-r to-transparent px-5 py-3 {borderClasses[
colorScheme
]} {gradientClasses[colorScheme]}"
>
<h3 class="text-lg font-semibold text-theme">{title}</h3>
</div>
<div class="px-5 py-5">
{@render children()}
</div>
</section>

View File

@ -0,0 +1,4 @@
export { default as CustomerBottomNav } from './CustomerBottomNav.svelte';
export { default as CustomerDashboardHeader } from './CustomerDashboardHeader.svelte';
export { default as CustomerPageHeader } from './CustomerPageHeader.svelte';
export { default as CustomerSectionContainer } from './CustomerSectionContainer.svelte';

View File

@ -0,0 +1,89 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { fade, fly } from 'svelte/transition';
interface Props {
open: boolean;
title: string;
onClose: () => void;
children: Snippet;
/** Optional footer slot for action buttons */
footer?: Snippet;
}
let { open, title, onClose, children, footer }: Props = $props();
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
onClose();
}
}
// Lock body scroll when open
$effect(() => {
if (open) {
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = '';
};
}
});
</script>
<svelte:window onkeydown={handleKeydown} />
{#if open}
<!-- Backdrop -->
<button
type="button"
class="fixed inset-0 z-40 cursor-default bg-overlay backdrop-blur-sm"
onclick={onClose}
aria-label="Close drawer"
transition:fade={{ duration: 200 }}
></button>
<!-- Drawer Panel -->
<!-- Mobile: full viewport, slides up from bottom -->
<!-- Desktop (sm+): 50% viewport width, slides in from right -->
<div
class="fixed inset-0 z-50 flex flex-col bg-theme-card
sm:inset-y-0 sm:right-0 sm:left-auto sm:w-1/2 sm:max-w-3xl sm:min-w-[400px] sm:border-l sm:border-theme sm:shadow-2xl"
role="dialog"
aria-modal="true"
aria-labelledby="drawer-title"
tabindex="-1"
transition:fly={{ x: 400, duration: 250 }}
>
<!-- Header -->
<div class="flex shrink-0 items-center justify-between border-b border-theme px-5 py-4">
<h2 id="drawer-title" class="text-lg font-semibold text-theme">{title}</h2>
<button
type="button"
class="rounded-lg p-2 text-theme-muted transition-colors hover:bg-theme-secondary hover:text-theme"
onclick={onClose}
aria-label="Close drawer"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- Content -->
<div class="flex-1 overflow-y-auto px-5 py-5">
{@render children()}
</div>
<!-- Footer (optional) -->
{#if footer}
<div class="shrink-0 border-t border-theme px-5 py-4">
{@render footer()}
</div>
{/if}
</div>
{/if}

View File

@ -0,0 +1 @@
export { default as FormDrawer } from './FormDrawer.svelte';

View File

@ -0,0 +1,209 @@
<script lang="ts">
import { client } from '$lib/graphql/client';
import {
CREATE_ACCOUNT_ADDRESS,
type CreateAccountAddressInput
} from '$lib/graphql/mutations/account';
import { UPDATE_ACCOUNT_ADDRESS } from '$lib/graphql/mutations/location';
import type { AccountAddress } from '$lib/graphql/queries/account';
interface Props {
address?: AccountAddress;
accountId: string;
onSuccess: () => void;
onCancel: () => void;
}
let { address, accountId, onSuccess, onCancel }: Props = $props();
let isEdit = $derived(!!address);
// Form state - use $effect to sync from props
let name = $state('');
let streetAddress = $state('');
let city = $state('');
let stateCode = $state('');
let zipCode = $state('');
let notes = $state('');
let isPrimary = $state(false);
let isActive = $state(true);
// Sync form state when address prop changes
$effect(() => {
name = address?.name ?? '';
streetAddress = address?.streetAddress ?? '';
city = address?.city ?? '';
stateCode = address?.state ?? '';
zipCode = address?.zipCode ?? '';
notes = address?.notes ?? '';
isPrimary = address?.isPrimary ?? false;
isActive = address?.isActive ?? true;
});
let loading = $state(false);
let error = $state<string | null>(null);
async function handleSubmit(event: SubmitEvent) {
event.preventDefault();
loading = true;
error = null;
try {
if (isEdit && address) {
await client.mutate({
mutation: UPDATE_ACCOUNT_ADDRESS,
variables: {
id: address.id,
input: {
name: name || undefined,
streetAddress: streetAddress || undefined,
city: city || undefined,
state: stateCode || undefined,
zipCode: zipCode || undefined,
notes: notes || undefined,
isPrimary,
isActive
}
}
});
} else {
const input: CreateAccountAddressInput = {
name: name || undefined,
streetAddress,
city,
state: stateCode,
zipCode,
notes: notes || undefined,
isPrimary
};
await client.mutate({
mutation: CREATE_ACCOUNT_ADDRESS,
variables: { accountId, input }
});
}
onSuccess();
} catch (err) {
error = err instanceof Error ? err.message : 'An error occurred';
} finally {
loading = false;
}
}
</script>
<form onsubmit={handleSubmit} class="space-y-5">
{#if error}
<div class="rounded-lg border border-error-500/30 bg-error-50/50 p-3 dark:bg-error-950/30">
<p class="text-sm text-error-700 dark:text-error-400">{error}</p>
</div>
{/if}
<div>
<label for="name" class="form-label">Location Name</label>
<input
id="name"
type="text"
bind:value={name}
class="input-base"
placeholder="e.g., Main Building, South Campus"
disabled={loading}
/>
</div>
<div>
<label for="streetAddress" class="form-label">
Street Address <span class="required-indicator">*</span>
</label>
<input
id="streetAddress"
type="text"
bind:value={streetAddress}
class="input-base"
required
disabled={loading}
/>
</div>
<div>
<label for="city" class="form-label">
City <span class="required-indicator">*</span>
</label>
<input id="city" type="text" bind:value={city} class="input-base" required disabled={loading} />
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label for="stateCode" class="form-label">
State <span class="required-indicator">*</span>
</label>
<input
id="stateCode"
type="text"
bind:value={stateCode}
class="input-base"
maxlength="2"
placeholder="TX"
required
disabled={loading}
/>
</div>
<div>
<label for="zipCode" class="form-label">
ZIP Code <span class="required-indicator">*</span>
</label>
<input
id="zipCode"
type="text"
bind:value={zipCode}
class="input-base"
required
disabled={loading}
/>
</div>
</div>
<div>
<label for="notes" class="form-label">Notes</label>
<textarea id="notes" bind:value={notes} class="textarea-base" rows="2" disabled={loading}
></textarea>
</div>
<div class="flex flex-wrap items-center gap-x-6 gap-y-2">
<div class="flex items-center gap-2">
<input
id="isPrimary"
type="checkbox"
bind:checked={isPrimary}
class="h-4 w-4 rounded border-theme text-primary-600 focus:ring-primary-500"
disabled={loading}
/>
<label for="isPrimary" class="text-sm text-theme">Primary Location</label>
</div>
{#if isEdit}
<div class="flex items-center gap-2">
<input
id="isActive"
type="checkbox"
bind:checked={isActive}
class="h-4 w-4 rounded border-theme text-primary-600 focus:ring-primary-500"
disabled={loading}
/>
<label for="isActive" class="text-sm text-theme">Active</label>
</div>
{/if}
</div>
<div class="form-actions border-t border-theme pt-5">
<button type="button" class="btn-cancel" onclick={onCancel} disabled={loading}>Cancel</button>
<button type="submit" class="btn-submit" disabled={loading}>
{#if loading}
Saving...
{:else if isEdit}
Update Location
{:else}
Add Location
{/if}
</button>
</div>
</form>

View File

@ -0,0 +1,162 @@
<script lang="ts">
import { client } from '$lib/graphql/client';
import {
CREATE_ACCOUNT_CONTACT,
UPDATE_ACCOUNT_CONTACT,
type CreateAccountContactInput,
type UpdateAccountContactInput
} from '$lib/graphql/mutations/account';
import type { AccountContact } from '$lib/graphql/queries/account';
interface Props {
contact?: AccountContact;
accountId: string;
onSuccess: () => void;
onCancel: () => void;
}
let { contact, accountId, onSuccess, onCancel }: Props = $props();
let isEdit = $derived(!!contact);
// Form state - use $effect to sync from props
let firstName = $state('');
let lastName = $state('');
let email = $state('');
let phone = $state('');
let notes = $state('');
let isPrimary = $state(false);
$effect(() => {
firstName = contact?.firstName ?? '';
lastName = contact?.lastName ?? '';
email = contact?.email ?? '';
phone = contact?.phone ?? '';
notes = contact?.notes ?? '';
isPrimary = contact?.isPrimary ?? false;
});
let loading = $state(false);
let error = $state<string | null>(null);
async function handleSubmit(event: SubmitEvent) {
event.preventDefault();
loading = true;
error = null;
try {
if (isEdit && contact) {
const input: UpdateAccountContactInput = {
firstName: firstName || undefined,
lastName: lastName || undefined,
email: email || undefined,
phone: phone || undefined,
notes: notes || undefined,
isPrimary
};
await client.mutate({
mutation: UPDATE_ACCOUNT_CONTACT,
variables: { id: contact.id, input }
});
} else {
const input: CreateAccountContactInput = {
firstName,
lastName,
email: email || undefined,
phone: phone || undefined,
notes: notes || undefined,
isPrimary
};
await client.mutate({
mutation: CREATE_ACCOUNT_CONTACT,
variables: { accountId, input }
});
}
onSuccess();
} catch (err) {
error = err instanceof Error ? err.message : 'An error occurred';
} finally {
loading = false;
}
}
</script>
<form onsubmit={handleSubmit} class="space-y-5">
{#if error}
<div class="rounded-lg border border-error-500/30 bg-error-50/50 p-3 dark:bg-error-950/30">
<p class="text-sm text-error-700 dark:text-error-400">{error}</p>
</div>
{/if}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label for="firstName" class="form-label">
First Name <span class="required-indicator">*</span>
</label>
<input
id="firstName"
type="text"
bind:value={firstName}
class="input-base"
required
disabled={loading}
/>
</div>
<div>
<label for="lastName" class="form-label">
Last Name <span class="required-indicator">*</span>
</label>
<input
id="lastName"
type="text"
bind:value={lastName}
class="input-base"
required
disabled={loading}
/>
</div>
</div>
<div>
<label for="email" class="form-label">Email</label>
<input id="email" type="email" bind:value={email} class="input-base" disabled={loading} />
</div>
<div>
<label for="phone" class="form-label">Phone</label>
<input id="phone" type="tel" bind:value={phone} class="input-base" disabled={loading} />
</div>
<div>
<label for="notes" class="form-label">Notes</label>
<textarea id="notes" bind:value={notes} class="textarea-base" rows="3" disabled={loading}
></textarea>
</div>
<div class="flex items-center gap-2">
<input
id="isPrimary"
type="checkbox"
bind:checked={isPrimary}
class="h-4 w-4 rounded border-theme text-primary-600 focus:ring-primary-500"
disabled={loading}
/>
<label for="isPrimary" class="text-sm text-theme">Primary Contact</label>
</div>
<div class="form-actions border-t border-theme pt-5">
<button type="button" class="btn-cancel" onclick={onCancel} disabled={loading}>Cancel</button>
<button type="submit" class="btn-submit" disabled={loading}>
{#if loading}
Saving...
{:else if isEdit}
Update Contact
{:else}
Add Contact
{/if}
</button>
</div>
</form>

View File

@ -0,0 +1,176 @@
<script lang="ts">
import { onMount } from 'svelte';
import { client } from '$lib/graphql/client';
import { CREATE_ACCOUNT, type CreateAccountInput } from '$lib/graphql/mutations/account';
import { CUSTOMERS_QUERY, type CustomersQueryResult } from '$lib/graphql/queries/customers';
interface Props {
onSuccess: (accountId: string) => void;
onCancel: () => void;
}
let { onSuccess, onCancel }: Props = $props();
// Customer list for picker
let customers = $state<{ id: string; name: string }[]>([]);
let customersLoading = $state(true);
// Form state
let customerId = $state('');
let name = $state('');
let status = $state<'ACTIVE' | 'INACTIVE' | 'PENDING'>('ACTIVE');
let startDate = $state(new Date().toISOString().split('T')[0]);
let endDate = $state('');
let loading = $state(false);
let error = $state<string | null>(null);
onMount(async () => {
try {
const result = await client.query<CustomersQueryResult>({
query: CUSTOMERS_QUERY,
variables: { filter: { isActive: true } }
});
customers = (result.data?.customers ?? [])
.map((c) => ({ id: c.id, name: c.name }))
.sort((a, b) => a.name.localeCompare(b.name));
} catch (err) {
error = 'Failed to load customers';
} finally {
customersLoading = false;
}
});
async function handleSubmit(event: SubmitEvent) {
event.preventDefault();
loading = true;
error = null;
try {
const input: CreateAccountInput = {
customerId,
name,
status,
startDate: startDate || undefined,
endDate: endDate || undefined
};
const result = await client.mutate<{ createAccount: { id: string } }>({
mutation: CREATE_ACCOUNT,
variables: { input }
});
const accountId = result.data?.createAccount?.id;
if (!accountId) {
throw new Error('Failed to create account');
}
onSuccess(accountId);
} catch (err) {
error = err instanceof Error ? err.message : 'An error occurred';
} finally {
loading = false;
}
}
</script>
<form onsubmit={handleSubmit} class="space-y-5">
{#if error}
<div class="rounded-lg border border-error-500/30 bg-error-50/50 p-3 dark:bg-error-950/30">
<p class="text-sm text-error-700 dark:text-error-400">{error}</p>
</div>
{/if}
<div>
<label for="customerId" class="form-label">
Customer <span class="required-indicator">*</span>
</label>
{#if customersLoading}
<div class="flex input-base items-center text-theme-muted">
<svg class="mr-2 h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Loading customers...
</div>
{:else}
<select
id="customerId"
bind:value={customerId}
class="select-base"
required
disabled={loading}
>
<option value="">Select a customer...</option>
{#each customers as customer (customer.id)}
<option value={customer.id}>{customer.name}</option>
{/each}
</select>
{/if}
</div>
<div>
<label for="name" class="form-label">
Account Name <span class="required-indicator">*</span>
</label>
<input
id="name"
type="text"
bind:value={name}
class="input-base"
required
disabled={loading}
placeholder="e.g., Main Office, Building A"
/>
</div>
<div>
<label for="status" class="form-label">Status</label>
<select id="status" bind:value={status} class="select-base" disabled={loading}>
<option value="ACTIVE">Active</option>
<option value="INACTIVE">Inactive</option>
<option value="PENDING">Pending</option>
</select>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label for="startDate" class="form-label">Start Date</label>
<input
id="startDate"
type="date"
bind:value={startDate}
class="input-base"
disabled={loading}
/>
</div>
<div>
<label for="endDate" class="form-label">End Date</label>
<input
id="endDate"
type="date"
bind:value={endDate}
class="input-base"
min={startDate || undefined}
disabled={loading}
/>
<p class="mt-1 text-xs text-theme-muted">Leave blank for ongoing account</p>
</div>
</div>
<div class="form-actions border-t border-theme pt-5">
<button type="button" class="btn-cancel" onclick={onCancel} disabled={loading}>Cancel</button>
<button type="submit" class="btn-submit" disabled={loading || customersLoading || !customerId}>
{#if loading}
Creating...
{:else}
Create Account
{/if}
</button>
</div>
</form>

View File

@ -0,0 +1,261 @@
<script lang="ts">
import { client } from '$lib/graphql/client';
import {
UPDATE_ACCOUNT,
CREATE_REVENUE,
UPDATE_REVENUE,
type UpdateAccountInput,
type CreateRevenueInput,
type UpdateRevenueInput
} from '$lib/graphql/mutations/account';
interface Account {
id: string;
name: string;
status: string;
startDate?: string | null;
endDate?: string | null;
}
interface Revenue {
id: string;
amount: string;
startDate: string;
waveServiceId?: string | null;
}
interface WaveProduct {
id: string;
name: string;
unitPrice: number;
isArchived: boolean;
}
interface Props {
account: Account;
activeRevenue?: Revenue | null;
waveProducts?: WaveProduct[];
onSuccess: () => void;
onCancel: () => void;
}
let { account, activeRevenue = null, waveProducts = [], onSuccess, onCancel }: Props = $props();
let activeWaveProducts = $derived(waveProducts.filter((p) => !p.isArchived));
// Form state - use $effect to sync from props
let name = $state('');
let status = $state('');
let startDate = $state('');
let endDate = $state('');
// Revenue state
let revenueAmount = $state('');
let waveServiceId = $state('');
let isNewRate = $state(false);
$effect(() => {
name = account.name;
status = account.status;
startDate = account.startDate ?? '';
endDate = account.endDate ?? '';
revenueAmount = activeRevenue?.amount ?? '';
waveServiceId = activeRevenue?.waveServiceId ?? '';
isNewRate = false;
});
let loading = $state(false);
let error = $state<string | null>(null);
// Check if revenue has changed
let revenueChanged = $derived(
activeRevenue
? revenueAmount !== activeRevenue.amount ||
waveServiceId !== (activeRevenue.waveServiceId ?? '')
: revenueAmount !== ''
);
function getTodayDate(): string {
return new Date().toISOString().split('T')[0];
}
async function handleSubmit(event: SubmitEvent) {
event.preventDefault();
loading = true;
error = null;
try {
// Update account info
const input: UpdateAccountInput = {
name: name || undefined,
status: (status as UpdateAccountInput['status']) || undefined,
startDate: startDate || undefined,
endDate: endDate || undefined
};
await client.mutate({
mutation: UPDATE_ACCOUNT,
variables: { id: account.id, input }
});
// Handle revenue
if (revenueAmount && revenueChanged) {
const amount = parseFloat(revenueAmount);
if (!isNaN(amount)) {
if (activeRevenue && isNewRate) {
// Mark old revenue as inactive, create new one
await client.mutate({
mutation: UPDATE_REVENUE,
variables: {
id: activeRevenue.id,
input: { endDate: getTodayDate(), isActive: false } as UpdateRevenueInput
}
});
const revenueInput: CreateRevenueInput = {
amount,
startDate: getTodayDate(),
waveServiceId: waveServiceId || undefined
};
await client.mutate({
mutation: CREATE_REVENUE,
variables: { accountId: account.id, input: revenueInput }
});
} else if (activeRevenue) {
// Update existing revenue
const revenueInput: UpdateRevenueInput = {
amount,
waveServiceId: waveServiceId || undefined
};
await client.mutate({
mutation: UPDATE_REVENUE,
variables: { id: activeRevenue.id, input: revenueInput }
});
} else {
// Create new revenue
const revenueInput: CreateRevenueInput = {
amount,
startDate: getTodayDate(),
waveServiceId: waveServiceId || undefined
};
await client.mutate({
mutation: CREATE_REVENUE,
variables: { accountId: account.id, input: revenueInput }
});
}
}
}
onSuccess();
} catch (err) {
error = err instanceof Error ? err.message : 'An error occurred';
} finally {
loading = false;
}
}
</script>
<form onsubmit={handleSubmit} class="space-y-5">
{#if error}
<div class="rounded-lg border border-error-500/30 bg-error-50/50 p-3 dark:bg-error-950/30">
<p class="text-sm text-error-700 dark:text-error-400">{error}</p>
</div>
{/if}
<div>
<label for="name" class="form-label">
Account Name <span class="required-indicator">*</span>
</label>
<input id="name" type="text" bind:value={name} class="input-base" required disabled={loading} />
</div>
<div>
<label for="status" class="form-label">Status</label>
<select id="status" bind:value={status} class="input-base" disabled={loading}>
<option value="ACTIVE">Active</option>
<option value="INACTIVE">Inactive</option>
<option value="PENDING">Pending</option>
</select>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label for="startDate" class="form-label">Start Date</label>
<input
id="startDate"
type="date"
bind:value={startDate}
class="input-base"
disabled={loading}
/>
</div>
<div>
<label for="endDate" class="form-label">End Date</label>
<input id="endDate" type="date" bind:value={endDate} class="input-base" disabled={loading} />
</div>
</div>
<!-- Monthly Revenue -->
<div class="border-t border-theme pt-5">
<h3 class="mb-3 text-sm font-medium text-theme">Monthly Revenue</h3>
<div class="space-y-4">
<div>
<label for="revenueAmount" class="form-label">Amount</label>
<div class="relative">
<span class="absolute top-1/2 left-3 -translate-y-1/2 text-theme-muted">$</span>
<input
id="revenueAmount"
type="number"
step="0.01"
min="0"
bind:value={revenueAmount}
class="input-base pl-7"
disabled={loading}
placeholder="0.00"
/>
</div>
</div>
<div>
<label for="waveServiceId" class="form-label">Wave Product</label>
<select id="waveServiceId" bind:value={waveServiceId} class="input-base" disabled={loading}>
<option value="">None</option>
{#each activeWaveProducts as product (product.id)}
<option value={product.id}>{product.name} (${product.unitPrice.toFixed(2)})</option>
{/each}
</select>
<p class="mt-1 text-xs text-theme-muted">Wave accounting product for invoicing</p>
</div>
{#if activeRevenue && revenueChanged}
<label
class="bg-theme-secondary/50 flex items-start gap-3 rounded-lg border border-theme p-3"
>
<input
type="checkbox"
bind:checked={isNewRate}
class="border-theme-muted mt-0.5 h-4 w-4 rounded text-primary-600 focus:ring-primary-500"
disabled={loading}
/>
<div>
<span class="text-sm font-medium text-theme">This is a new rate</span>
<p class="text-xs text-theme-muted">
Check this if the rate changed. The previous rate will be kept in history.
</p>
</div>
</label>
{/if}
</div>
</div>
<div class="form-actions border-t border-theme pt-5">
<button type="button" class="btn-cancel" onclick={onCancel} disabled={loading}>Cancel</button>
<button type="submit" class="btn-submit" disabled={loading}>
{#if loading}
Saving...
{:else}
Update Account
{/if}
</button>
</div>
</form>

Some files were not shown because too many files have changed in this diff Show More