public-ready-init
This commit is contained in:
commit
fa0767e456
15
.dockerignore
Normal file
15
.dockerignore
Normal 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
35
.env.example
Normal 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
14
.gitignore
vendored
Normal 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
4981
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
71
Cargo.toml
Normal file
71
Cargo.toml
Normal 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
56
Dockerfile
Normal 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
9
Dockerfile.migrate
Normal 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
282
README.md
Normal 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
|
||||
32
auth-frontend/.dockerignore
Normal file
32
auth-frontend/.dockerignore
Normal 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
|
||||
27
auth-frontend/.env.development
Normal file
27
auth-frontend/.env.development
Normal 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=
|
||||
27
auth-frontend/.env.production
Normal file
27
auth-frontend/.env.production
Normal 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
26
auth-frontend/.gitignore
vendored
Normal 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
1
auth-frontend/.npmrc
Normal file
@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
9
auth-frontend/.prettierignore
Normal file
9
auth-frontend/.prettierignore
Normal 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
16
auth-frontend/.prettierrc
Normal 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
63
auth-frontend/Dockerfile
Normal 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"]
|
||||
28
auth-frontend/docker-compose.yml
Normal file
28
auth-frontend/docker-compose.yml
Normal 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
|
||||
41
auth-frontend/eslint.config.js
Normal file
41
auth-frontend/eslint.config.js
Normal 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
4925
auth-frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
auth-frontend/package.json
Normal file
45
auth-frontend/package.json
Normal 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
314
auth-frontend/src/app.css
Normal 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
13
auth-frontend/src/app.d.ts
vendored
Normal 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 {};
|
||||
12
auth-frontend/src/app.html
Normal file
12
auth-frontend/src/app.html
Normal 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>
|
||||
1
auth-frontend/src/lib/assets/favicon.svg
Normal file
1
auth-frontend/src/lib/assets/favicon.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
284
auth-frontend/src/lib/components/FlowForm.svelte
Normal file
284
auth-frontend/src/lib/components/FlowForm.svelte
Normal 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>
|
||||
243
auth-frontend/src/lib/components/FormField.svelte
Normal file
243
auth-frontend/src/lib/components/FormField.svelte
Normal 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>
|
||||
159
auth-frontend/src/lib/components/SettingsProfileForm.svelte
Normal file
159
auth-frontend/src/lib/components/SettingsProfileForm.svelte
Normal 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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
183
auth-frontend/src/lib/components/modals/IdentityEditModal.svelte
Normal file
183
auth-frontend/src/lib/components/modals/IdentityEditModal.svelte
Normal 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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
96
auth-frontend/src/lib/flows.ts
Normal file
96
auth-frontend/src/lib/flows.ts
Normal 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
|
||||
});
|
||||
}
|
||||
11
auth-frontend/src/lib/kratos-server.ts
Normal file
11
auth-frontend/src/lib/kratos-server.ts
Normal 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'
|
||||
})
|
||||
);
|
||||
24
auth-frontend/src/lib/kratos.ts
Normal file
24
auth-frontend/src/lib/kratos.ts
Normal 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
|
||||
}
|
||||
})
|
||||
);
|
||||
79
auth-frontend/src/lib/stores/theme.svelte.ts
Normal file
79
auth-frontend/src/lib/stores/theme.svelte.ts
Normal 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();
|
||||
52
auth-frontend/src/lib/utils.ts
Normal file
52
auth-frontend/src/lib/utils.ts
Normal 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;
|
||||
}
|
||||
23
auth-frontend/src/routes/+layout.server.ts
Normal file
23
auth-frontend/src/routes/+layout.server.ts
Normal 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 };
|
||||
}
|
||||
};
|
||||
187
auth-frontend/src/routes/+layout.svelte
Normal file
187
auth-frontend/src/routes/+layout.svelte
Normal 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>
|
||||
135
auth-frontend/src/routes/+page.svelte
Normal file
135
auth-frontend/src/routes/+page.svelte
Normal 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}
|
||||
31
auth-frontend/src/routes/admin/+page.server.ts
Normal file
31
auth-frontend/src/routes/admin/+page.server.ts
Normal 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');
|
||||
}
|
||||
};
|
||||
1444
auth-frontend/src/routes/admin/+page.svelte
Normal file
1444
auth-frontend/src/routes/admin/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
22
auth-frontend/src/routes/error/+page.server.ts
Normal file
22
auth-frontend/src/routes/error/+page.server.ts
Normal 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.'
|
||||
};
|
||||
}
|
||||
};
|
||||
49
auth-frontend/src/routes/error/+page.svelte
Normal file
49
auth-frontend/src/routes/error/+page.svelte
Normal 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>
|
||||
77
auth-frontend/src/routes/login/+page.svelte
Normal file
77
auth-frontend/src/routes/login/+page.svelte
Normal 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>
|
||||
56
auth-frontend/src/routes/logout/+server.ts
Normal file
56
auth-frontend/src/routes/logout/+server.ts
Normal 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');
|
||||
};
|
||||
76
auth-frontend/src/routes/recovery/+page.server.ts
Normal file
76
auth-frontend/src/routes/recovery/+page.server.ts
Normal 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 };
|
||||
};
|
||||
49
auth-frontend/src/routes/recovery/+page.svelte
Normal file
49
auth-frontend/src/routes/recovery/+page.svelte
Normal 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>
|
||||
71
auth-frontend/src/routes/registration/+page.svelte
Normal file
71
auth-frontend/src/routes/registration/+page.svelte
Normal 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>
|
||||
21
auth-frontend/src/routes/settings/+page.server.ts
Normal file
21
auth-frontend/src/routes/settings/+page.server.ts
Normal 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 {};
|
||||
};
|
||||
82
auth-frontend/src/routes/settings/+page.svelte
Normal file
82
auth-frontend/src/routes/settings/+page.svelte
Normal 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>
|
||||
77
auth-frontend/src/routes/verification/+page.server.ts
Normal file
77
auth-frontend/src/routes/verification/+page.server.ts
Normal 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 };
|
||||
};
|
||||
42
auth-frontend/src/routes/verification/+page.svelte
Normal file
42
auth-frontend/src/routes/verification/+page.svelte
Normal 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>
|
||||
3
auth-frontend/static/robots.txt
Normal file
3
auth-frontend/static/robots.txt
Normal file
@ -0,0 +1,3 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
12
auth-frontend/svelte.config.js
Normal file
12
auth-frontend/svelte.config.js
Normal 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;
|
||||
19
auth-frontend/tsconfig.json
Normal file
19
auth-frontend/tsconfig.json
Normal 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
|
||||
}
|
||||
7
auth-frontend/vite.config.ts
Normal file
7
auth-frontend/vite.config.ts
Normal 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
423
docker-compose.yml
Normal 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
32
entrypoint.sh
Normal 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
49
frontend/.dockerignore
Normal 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
24
frontend/.gitignore
vendored
Normal 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
5
frontend/.graphqlrc.yaml
Normal 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
1
frontend/.npmrc
Normal file
@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
9
frontend/.prettierignore
Normal file
9
frontend/.prettierignore
Normal 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
16
frontend/.prettierrc
Normal 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
55
frontend/Dockerfile
Normal 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
38
frontend/README.md
Normal 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
43
frontend/eslint.config.js
Normal 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
4712
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
49
frontend/package.json
Normal file
49
frontend/package.json
Normal 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
56
frontend/src/app.d.ts
vendored
Normal 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
28
frontend/src/app.html
Normal 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>
|
||||
10
frontend/src/hooks.server.ts
Normal file
10
frontend/src/hooks.server.ts
Normal 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);
|
||||
};
|
||||
1
frontend/src/lib/assets/favicon.svg
Normal file
1
frontend/src/lib/assets/favicon.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
BIN
frontend/src/lib/assets/floors.jpg
Normal file
BIN
frontend/src/lib/assets/floors.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
BIN
frontend/src/lib/assets/hero.jpg
Normal file
BIN
frontend/src/lib/assets/hero.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 MiB |
BIN
frontend/src/lib/assets/kitchens.jpg
Normal file
BIN
frontend/src/lib/assets/kitchens.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
BIN
frontend/src/lib/assets/logo-icon.png
Normal file
BIN
frontend/src/lib/assets/logo-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
19
frontend/src/lib/components/PublicBackLink.svelte
Normal file
19
frontend/src/lib/components/PublicBackLink.svelte
Normal 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>
|
||||
148
frontend/src/lib/components/admin/AdminBottomNav.svelte
Normal file
148
frontend/src/lib/components/admin/AdminBottomNav.svelte
Normal 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>
|
||||
250
frontend/src/lib/components/admin/AdminDashboardHeader.svelte
Normal file
250
frontend/src/lib/components/admin/AdminDashboardHeader.svelte
Normal 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>
|
||||
79
frontend/src/lib/components/admin/MonthSelector.svelte
Normal file
79
frontend/src/lib/components/admin/MonthSelector.svelte
Normal 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>
|
||||
116
frontend/src/lib/components/admin/PageHeader.svelte
Normal file
116
frontend/src/lib/components/admin/PageHeader.svelte
Normal 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>
|
||||
123
frontend/src/lib/components/admin/Pagination.svelte
Normal file
123
frontend/src/lib/components/admin/Pagination.svelte
Normal 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}
|
||||
30
frontend/src/lib/components/admin/SectionHeader.svelte
Normal file
30
frontend/src/lib/components/admin/SectionHeader.svelte
Normal 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>
|
||||
111
frontend/src/lib/components/admin/StatusTabs.svelte
Normal file
111
frontend/src/lib/components/admin/StatusTabs.svelte
Normal 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>
|
||||
7
frontend/src/lib/components/admin/index.ts
Normal file
7
frontend/src/lib/components/admin/index.ts
Normal 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';
|
||||
@ -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}
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
4
frontend/src/lib/components/customer/index.ts
Normal file
4
frontend/src/lib/components/customer/index.ts
Normal 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';
|
||||
89
frontend/src/lib/components/drawers/FormDrawer.svelte
Normal file
89
frontend/src/lib/components/drawers/FormDrawer.svelte
Normal 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}
|
||||
1
frontend/src/lib/components/drawers/index.ts
Normal file
1
frontend/src/lib/components/drawers/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default as FormDrawer } from './FormDrawer.svelte';
|
||||
209
frontend/src/lib/components/forms/AccountAddressForm.svelte
Normal file
209
frontend/src/lib/components/forms/AccountAddressForm.svelte
Normal 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>
|
||||
162
frontend/src/lib/components/forms/AccountContactForm.svelte
Normal file
162
frontend/src/lib/components/forms/AccountContactForm.svelte
Normal 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>
|
||||
176
frontend/src/lib/components/forms/AccountCreateForm.svelte
Normal file
176
frontend/src/lib/components/forms/AccountCreateForm.svelte
Normal 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>
|
||||
261
frontend/src/lib/components/forms/AccountForm.svelte
Normal file
261
frontend/src/lib/components/forms/AccountForm.svelte
Normal 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
Loading…
x
Reference in New Issue
Block a user