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