Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
929e7d5aed | ||
|
|
3ab6408258 | ||
|
|
f0aa2b7d3a | ||
|
|
9bf6c17d63 | ||
|
|
1a4ae5b49b | ||
|
|
400c32a539 | ||
|
|
2cb818ec71 | ||
|
|
37668dcfe0 | ||
|
|
40ea6e5e69 | ||
|
|
6ab0a3c5a1 | ||
|
|
8130f2053d | ||
|
|
9da908e4d2 | ||
|
|
d0dba73a29 | ||
|
|
9aa535ace8 | ||
|
|
f85b5a3634 | ||
|
|
39e1b39f7a | ||
|
|
283d3e34a0 | ||
|
|
2cd15509ba | ||
|
|
9d87f71bc1 | ||
|
|
6b77a96d52 | ||
|
|
c58bf90604 | ||
|
|
273baf7996 | ||
|
|
5ca118dc93 | ||
|
|
0b8cdf6dd9 | ||
|
|
cafd7e9369 | ||
|
|
b5068250f9 | ||
|
|
0cfa359fc5 | ||
|
|
5cc9f8c9ef | ||
|
|
b066d1abe7 | ||
|
|
ae1d9fa4db | ||
|
|
6fe10432e6 | ||
|
|
9f3faf4816 | ||
|
|
a60095608e | ||
|
|
9f9112c6a5 | ||
|
|
e1a9f6d225 | ||
|
|
180644f0df | ||
|
|
62b74d2d06 | ||
|
|
3e2f035d97 | ||
|
|
9962ee99d9 | ||
|
|
b53840b77b | ||
|
|
9ed2cedc98 | ||
|
|
dc7ac3a1ec |
@@ -7,6 +7,9 @@ VERSION=latest
|
|||||||
# Public access
|
# Public access
|
||||||
PUBLIC_HOST=localhost
|
PUBLIC_HOST=localhost
|
||||||
PUBLIC_PROTOCOL=https
|
PUBLIC_PROTOCOL=https
|
||||||
|
# Auth domain (Logto). Defaults to PUBLIC_HOST for single-domain setups.
|
||||||
|
# Set to a separate subdomain (e.g. auth.cameleer.io) to split auth from the app.
|
||||||
|
# AUTH_HOST=localhost
|
||||||
|
|
||||||
# Ports
|
# Ports
|
||||||
HTTP_PORT=80
|
HTTP_PORT=80
|
||||||
@@ -22,8 +25,14 @@ POSTGRES_DB=cameleer_saas
|
|||||||
CLICKHOUSE_PASSWORD=change_me_in_production
|
CLICKHOUSE_PASSWORD=change_me_in_production
|
||||||
|
|
||||||
# Admin user (created by bootstrap)
|
# Admin user (created by bootstrap)
|
||||||
|
# Email is the primary user identity in SaaS mode. The admin email defaults
|
||||||
|
# to <SAAS_ADMIN_USER>@<PUBLIC_HOST> if not set explicitly.
|
||||||
SAAS_ADMIN_USER=admin
|
SAAS_ADMIN_USER=admin
|
||||||
SAAS_ADMIN_PASS=change_me_in_production
|
SAAS_ADMIN_PASS=change_me_in_production
|
||||||
|
# SAAS_ADMIN_EMAIL=admin@example.com
|
||||||
|
|
||||||
|
# SMTP / email connector configuration is managed at runtime via the vendor
|
||||||
|
# admin UI (Email Connector page at /vendor/email). No SMTP env vars needed.
|
||||||
|
|
||||||
# TLS (leave empty for self-signed)
|
# TLS (leave empty for self-signed)
|
||||||
# NODE_TLS_REJECT=0 # Set to 1 when using real certificates
|
# NODE_TLS_REJECT=0 # Set to 1 when using real certificates
|
||||||
|
|||||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "installer"]
|
||||||
|
path = installer
|
||||||
|
url = https://gitea.siegeln.net/cameleer/cameleer-saas-installer.git
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<!-- gitnexus:start -->
|
<!-- gitnexus:start -->
|
||||||
# GitNexus — Code Intelligence
|
# GitNexus — Code Intelligence
|
||||||
|
|
||||||
This project is indexed by GitNexus as **cameleer-saas** (2816 symbols, 5989 relationships, 238 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
This project is indexed by GitNexus as **cameleer-saas** (2838 symbols, 6037 relationships, 239 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||||
|
|
||||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||||
|
|
||||||
|
|||||||
14
CLAUDE.md
14
CLAUDE.md
@@ -4,7 +4,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## Project
|
## Project
|
||||||
|
|
||||||
Cameleer SaaS — **vendor management plane** for the Cameleer observability stack. Two personas: **vendor** (platform:admin) manages the platform and provisions tenants; **tenant admin** (tenant:manage) manages their observability instance. The vendor creates tenants, which provisions per-tenant cameleer-server + UI instances via Docker API. No example tenant — clean slate bootstrap, vendor creates everything.
|
Cameleer SaaS — **vendor management plane** for the Cameleer observability stack. Three personas: **vendor** (platform:admin) manages the platform and provisions tenants; **tenant admin** (tenant:manage) manages their observability instance; **new user** (authenticated, no scopes) goes through self-service onboarding. Tenants can be created by the vendor OR via self-service sign-up (email registration + onboarding wizard). Each tenant gets per-tenant cameleer-server + UI instances via Docker API.
|
||||||
|
|
||||||
|
**Email is the primary user identity** in SaaS mode. All users — including the admin — must have an email address. The admin email defaults to `<SAAS_ADMIN_USER>@<PUBLIC_HOST>` if not set explicitly via `SAAS_ADMIN_EMAIL`. Self-service registration (email + password + verification code) is disabled by default and enabled via the vendor UI after configuring an email connector.
|
||||||
|
|
||||||
## Ecosystem
|
## Ecosystem
|
||||||
|
|
||||||
@@ -25,7 +27,8 @@ Agent-server protocol is defined in `cameleer/cameleer-common/PROTOCOL.md`. The
|
|||||||
|---------|---------|-------------|
|
|---------|---------|-------------|
|
||||||
| `config/` | Security, tenant isolation, web config | `SecurityConfig`, `TenantIsolationInterceptor`, `TenantContext`, `PublicConfigController`, `MeController` |
|
| `config/` | Security, tenant isolation, web config | `SecurityConfig`, `TenantIsolationInterceptor`, `TenantContext`, `PublicConfigController`, `MeController` |
|
||||||
| `tenant/` | Tenant data model | `TenantEntity` (JPA: id, name, slug, tier, status, logto_org_id, db_password) |
|
| `tenant/` | Tenant data model | `TenantEntity` (JPA: id, name, slug, tier, status, logto_org_id, db_password) |
|
||||||
| `vendor/` | Vendor console (platform:admin) | `VendorTenantService`, `VendorTenantController`, `InfrastructureService` |
|
| `vendor/` | Vendor console (platform:admin) | `VendorTenantService`, `VendorTenantController`, `InfrastructureService`, `EmailConnectorService`, `EmailConnectorController` |
|
||||||
|
| `onboarding/` | Self-service sign-up onboarding | `OnboardingController`, `OnboardingService` |
|
||||||
| `portal/` | Tenant admin portal (org-scoped) | `TenantPortalService`, `TenantPortalController` |
|
| `portal/` | Tenant admin portal (org-scoped) | `TenantPortalService`, `TenantPortalController` |
|
||||||
| `provisioning/` | Pluggable tenant provisioning | `DockerTenantProvisioner`, `TenantDatabaseService`, `TenantDataCleanupService` |
|
| `provisioning/` | Pluggable tenant provisioning | `DockerTenantProvisioner`, `TenantDatabaseService`, `TenantDataCleanupService` |
|
||||||
| `certificate/` | TLS certificate lifecycle | `CertificateService`, `CertificateController`, `TenantCaCertService` |
|
| `certificate/` | TLS certificate lifecycle | `CertificateService`, `CertificateController`, `TenantCaCertService` |
|
||||||
@@ -46,7 +49,7 @@ For detailed architecture docs, see the directory-scoped CLAUDE.md files (loaded
|
|||||||
- **Provisioning flow, env vars, lifecycle** → `src/.../provisioning/CLAUDE.md`
|
- **Provisioning flow, env vars, lifecycle** → `src/.../provisioning/CLAUDE.md`
|
||||||
- **Auth, scopes, JWT, OIDC** → `src/.../config/CLAUDE.md`
|
- **Auth, scopes, JWT, OIDC** → `src/.../config/CLAUDE.md`
|
||||||
- **Docker, routing, networks, bootstrap, deployment pipeline** → `docker/CLAUDE.md`
|
- **Docker, routing, networks, bootstrap, deployment pipeline** → `docker/CLAUDE.md`
|
||||||
- **Installer, deployment modes, compose templates** → `installer/CLAUDE.md`
|
- **Installer, deployment modes, compose templates** → `installer/CLAUDE.md` (git submodule: `cameleer-saas-installer`)
|
||||||
- **Frontend, sign-in UI** → `ui/CLAUDE.md`
|
- **Frontend, sign-in UI** → `ui/CLAUDE.md`
|
||||||
|
|
||||||
## Database Migrations
|
## Database Migrations
|
||||||
@@ -65,7 +68,8 @@ PostgreSQL (Flyway): `src/main/resources/db/migration/`
|
|||||||
- `cameleer-server` / `cameleer-server-ui` — provisioned per-tenant (not in compose, created by `DockerTenantProvisioner`)
|
- `cameleer-server` / `cameleer-server-ui` — provisioned per-tenant (not in compose, created by `DockerTenantProvisioner`)
|
||||||
- `cameleer-runtime-base` — base image for deployed apps (agent JAR + `cameleer-log-appender.jar` + JRE). CI downloads latest agent and log appender SNAPSHOTs from Gitea Maven registry. The Dockerfile ENTRYPOINT is overridden by `DockerRuntimeOrchestrator` at container creation; agent config uses `CAMELEER_AGENT_*` env vars set by `DeploymentExecutor`.
|
- `cameleer-runtime-base` — base image for deployed apps (agent JAR + `cameleer-log-appender.jar` + JRE). CI downloads latest agent and log appender SNAPSHOTs from Gitea Maven registry. The Dockerfile ENTRYPOINT is overridden by `DockerRuntimeOrchestrator` at container creation; agent config uses `CAMELEER_AGENT_*` env vars set by `DeploymentExecutor`.
|
||||||
- Docker builds: `--no-cache`, `--provenance=false` for Gitea compatibility
|
- Docker builds: `--no-cache`, `--provenance=false` for Gitea compatibility
|
||||||
- `docker-compose.dev.yml` — exposes ports for direct access, sets `SPRING_PROFILES_ACTIVE: dev`. Volume-mounts `./ui/dist` into the container so local UI builds are served without rebuilding the Docker image (`SPRING_WEB_RESOURCES_STATIC_LOCATIONS` overrides classpath). Adds Docker socket mount for tenant provisioning.
|
- `docker-compose.yml` (root) — thin dev overlay (ports, volume mounts, `SPRING_PROFILES_ACTIVE: dev`). Chained on top of production templates from the installer submodule via `COMPOSE_FILE` in `.env`.
|
||||||
|
- Installer is a **git submodule** at `installer/` pointing to `cameleer/cameleer-saas-installer` (public repo). Compose templates live there — single source of truth, no duplication. Run `git submodule update --remote installer` to pull template updates.
|
||||||
- Design system: import from `@cameleer/design-system` (Gitea npm registry)
|
- Design system: import from `@cameleer/design-system` (Gitea npm registry)
|
||||||
|
|
||||||
## Disabled Skills
|
## Disabled Skills
|
||||||
@@ -75,7 +79,7 @@ PostgreSQL (Flyway): `src/main/resources/db/migration/`
|
|||||||
<!-- gitnexus:start -->
|
<!-- gitnexus:start -->
|
||||||
# GitNexus — Code Intelligence
|
# GitNexus — Code Intelligence
|
||||||
|
|
||||||
This project is indexed by GitNexus as **cameleer-saas** (2816 symbols, 5989 relationships, 238 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
This project is indexed by GitNexus as **cameleer-saas** (2881 symbols, 6138 relationships, 243 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||||
|
|
||||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||||
|
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
# Development overrides: exposes ports for direct access
|
|
||||||
# Usage: docker compose -f docker-compose.yml -f docker-compose.dev.yml up
|
|
||||||
services:
|
|
||||||
cameleer-postgres:
|
|
||||||
ports:
|
|
||||||
- "5432:5432"
|
|
||||||
|
|
||||||
cameleer-logto:
|
|
||||||
ports:
|
|
||||||
- "3001:3001"
|
|
||||||
|
|
||||||
logto-bootstrap:
|
|
||||||
environment:
|
|
||||||
VENDOR_SEED_ENABLED: "true"
|
|
||||||
|
|
||||||
cameleer-saas:
|
|
||||||
ports:
|
|
||||||
- "8080:8080"
|
|
||||||
volumes:
|
|
||||||
- ./ui/dist:/app/static
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
|
||||||
group_add:
|
|
||||||
- "0"
|
|
||||||
environment:
|
|
||||||
SPRING_PROFILES_ACTIVE: dev
|
|
||||||
SPRING_WEB_RESOURCES_STATIC_LOCATIONS: file:/app/static/,classpath:/static/
|
|
||||||
CAMELEER_SAAS_PROVISIONING_PUBLICHOST: ${PUBLIC_HOST:-localhost}
|
|
||||||
CAMELEER_SAAS_PROVISIONING_PUBLICPROTOCOL: ${PUBLIC_PROTOCOL:-https}
|
|
||||||
CAMELEER_SAAS_PROVISIONING_SERVERIMAGE: gitea.siegeln.net/cameleer/cameleer-server:${VERSION:-latest}
|
|
||||||
CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE: gitea.siegeln.net/cameleer/cameleer-server-ui:${VERSION:-latest}
|
|
||||||
CAMELEER_SAAS_PROVISIONING_RUNTIMEBASEIMAGE: gitea.siegeln.net/cameleer/cameleer-runtime-base:${VERSION:-latest}
|
|
||||||
CAMELEER_SAAS_PROVISIONING_NETWORKNAME: cameleer-saas_cameleer
|
|
||||||
CAMELEER_SAAS_PROVISIONING_TRAEFIKNETWORK: cameleer-traefik
|
|
||||||
|
|
||||||
cameleer-clickhouse:
|
|
||||||
ports:
|
|
||||||
- "8123:8123"
|
|
||||||
@@ -1,158 +1,23 @@
|
|||||||
|
# Dev overrides — layered on top of installer/templates/ via COMPOSE_FILE in .env
|
||||||
|
# Usage: docker compose up (reads .env automatically)
|
||||||
services:
|
services:
|
||||||
cameleer-traefik:
|
|
||||||
image: ${TRAEFIK_IMAGE:-gitea.siegeln.net/cameleer/cameleer-traefik}:${VERSION:-latest}
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "${HTTP_PORT:-80}:80"
|
|
||||||
- "${HTTPS_PORT:-443}:443"
|
|
||||||
- "${LOGTO_CONSOLE_PORT:-3002}:3002"
|
|
||||||
environment:
|
|
||||||
PUBLIC_HOST: ${PUBLIC_HOST:-localhost}
|
|
||||||
CERT_FILE: ${CERT_FILE:-}
|
|
||||||
KEY_FILE: ${KEY_FILE:-}
|
|
||||||
CA_FILE: ${CA_FILE:-}
|
|
||||||
volumes:
|
|
||||||
- cameleer-certs:/certs
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
|
||||||
networks:
|
|
||||||
- cameleer
|
|
||||||
- cameleer-traefik
|
|
||||||
|
|
||||||
cameleer-postgres:
|
cameleer-postgres:
|
||||||
image: ${POSTGRES_IMAGE:-gitea.siegeln.net/cameleer/cameleer-postgres}:${VERSION:-latest}
|
ports:
|
||||||
restart: unless-stopped
|
- "5432:5432"
|
||||||
environment:
|
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-cameleer_saas}
|
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-cameleer}
|
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-cameleer_dev}
|
|
||||||
volumes:
|
|
||||||
- cameleer-pgdata:/var/lib/postgresql/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-cameleer} -d ${POSTGRES_DB:-cameleer_saas}"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
networks:
|
|
||||||
- cameleer
|
|
||||||
|
|
||||||
cameleer-clickhouse:
|
cameleer-clickhouse:
|
||||||
image: ${CLICKHOUSE_IMAGE:-gitea.siegeln.net/cameleer/cameleer-clickhouse}:${VERSION:-latest}
|
ports:
|
||||||
restart: unless-stopped
|
- "8123:8123"
|
||||||
environment:
|
|
||||||
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-cameleer_ch}
|
|
||||||
volumes:
|
|
||||||
- cameleer-chdata:/var/lib/clickhouse
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "clickhouse-client --password ${CLICKHOUSE_PASSWORD:-cameleer_ch} --query 'SELECT 1'"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 3
|
|
||||||
labels:
|
|
||||||
- prometheus.scrape=true
|
|
||||||
- prometheus.path=/metrics
|
|
||||||
- prometheus.port=9363
|
|
||||||
networks:
|
|
||||||
- cameleer
|
|
||||||
|
|
||||||
cameleer-logto:
|
cameleer-logto:
|
||||||
image: ${LOGTO_IMAGE:-gitea.siegeln.net/cameleer/cameleer-logto}:${VERSION:-latest}
|
ports:
|
||||||
restart: unless-stopped
|
- "3001:3001"
|
||||||
depends_on:
|
|
||||||
cameleer-postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
environment:
|
|
||||||
DB_URL: postgres://${POSTGRES_USER:-cameleer}:${POSTGRES_PASSWORD:-cameleer_dev}@cameleer-postgres:5432/logto
|
|
||||||
ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
|
|
||||||
ADMIN_ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}:${LOGTO_CONSOLE_PORT:-3002}
|
|
||||||
TRUST_PROXY_HEADER: 1
|
|
||||||
NODE_TLS_REJECT_UNAUTHORIZED: "${NODE_TLS_REJECT:-0}"
|
|
||||||
LOGTO_ENDPOINT: http://cameleer-logto:3001
|
|
||||||
LOGTO_ADMIN_ENDPOINT: http://cameleer-logto:3002
|
|
||||||
LOGTO_PUBLIC_ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
|
|
||||||
PUBLIC_HOST: ${PUBLIC_HOST:-localhost}
|
|
||||||
PUBLIC_PROTOCOL: ${PUBLIC_PROTOCOL:-https}
|
|
||||||
PG_HOST: cameleer-postgres
|
|
||||||
PG_USER: ${POSTGRES_USER:-cameleer}
|
|
||||||
PG_PASSWORD: ${POSTGRES_PASSWORD:-cameleer_dev}
|
|
||||||
PG_DB_SAAS: ${POSTGRES_DB:-cameleer_saas}
|
|
||||||
SAAS_ADMIN_USER: ${SAAS_ADMIN_USER:-admin}
|
|
||||||
SAAS_ADMIN_PASS: ${SAAS_ADMIN_PASS:-admin}
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3001/oidc/.well-known/openid-configuration', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))\" && test -f /data/logto-bootstrap.json"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 60
|
|
||||||
start_period: 30s
|
|
||||||
labels:
|
|
||||||
- traefik.enable=true
|
|
||||||
- traefik.http.routers.cameleer-logto.rule=PathPrefix(`/`)
|
|
||||||
- traefik.http.routers.cameleer-logto.priority=1
|
|
||||||
- traefik.http.routers.cameleer-logto.entrypoints=websecure
|
|
||||||
- traefik.http.routers.cameleer-logto.tls=true
|
|
||||||
- traefik.http.routers.cameleer-logto.service=cameleer-logto
|
|
||||||
- traefik.http.routers.cameleer-logto.middlewares=cameleer-logto-cors
|
|
||||||
- "traefik.http.middlewares.cameleer-logto-cors.headers.accessControlAllowOriginList=${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}:${LOGTO_CONSOLE_PORT:-3002}"
|
|
||||||
- traefik.http.middlewares.cameleer-logto-cors.headers.accessControlAllowMethods=GET,POST,PUT,PATCH,DELETE,OPTIONS
|
|
||||||
- traefik.http.middlewares.cameleer-logto-cors.headers.accessControlAllowHeaders=Authorization,Content-Type
|
|
||||||
- traefik.http.middlewares.cameleer-logto-cors.headers.accessControlAllowCredentials=true
|
|
||||||
- traefik.http.services.cameleer-logto.loadbalancer.server.port=3001
|
|
||||||
- traefik.http.routers.cameleer-logto-console.rule=PathPrefix(`/`)
|
|
||||||
- traefik.http.routers.cameleer-logto-console.entrypoints=admin-console
|
|
||||||
- traefik.http.routers.cameleer-logto-console.tls=true
|
|
||||||
- traefik.http.routers.cameleer-logto-console.service=cameleer-logto-console
|
|
||||||
- traefik.http.services.cameleer-logto-console.loadbalancer.server.port=3002
|
|
||||||
volumes:
|
|
||||||
- cameleer-bootstrapdata:/data
|
|
||||||
networks:
|
|
||||||
- cameleer
|
|
||||||
|
|
||||||
cameleer-saas:
|
cameleer-saas:
|
||||||
image: ${CAMELEER_IMAGE:-gitea.siegeln.net/cameleer/cameleer-saas}:${VERSION:-latest}
|
ports:
|
||||||
restart: unless-stopped
|
- "8080:8080"
|
||||||
depends_on:
|
|
||||||
cameleer-logto:
|
|
||||||
condition: service_healthy
|
|
||||||
volumes:
|
volumes:
|
||||||
- cameleer-bootstrapdata:/data/bootstrap:ro
|
- ./ui/dist:/app/static
|
||||||
- cameleer-certs:/certs
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
|
||||||
environment:
|
environment:
|
||||||
# SaaS database
|
SPRING_PROFILES_ACTIVE: dev
|
||||||
SPRING_DATASOURCE_URL: jdbc:postgresql://cameleer-postgres:5432/${POSTGRES_DB:-cameleer_saas}
|
SPRING_WEB_RESOURCES_STATIC_LOCATIONS: file:/app/static/,classpath:/static/
|
||||||
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-cameleer}
|
|
||||||
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:-cameleer_dev}
|
|
||||||
# Identity (Logto)
|
|
||||||
CAMELEER_SAAS_IDENTITY_LOGTOENDPOINT: ${LOGTO_ENDPOINT:-http://cameleer-logto:3001}
|
|
||||||
CAMELEER_SAAS_IDENTITY_LOGTOPUBLICENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
|
|
||||||
CAMELEER_SAAS_IDENTITY_M2MCLIENTID: ${LOGTO_M2M_CLIENT_ID:-}
|
|
||||||
CAMELEER_SAAS_IDENTITY_M2MCLIENTSECRET: ${LOGTO_M2M_CLIENT_SECRET:-}
|
|
||||||
CAMELEER_SERVER_SECURITY_JWTSECRET: ${CAMELEER_SERVER_SECURITY_JWTSECRET:-cameleer-dev-jwt-secret}
|
|
||||||
# Provisioning — passed to per-tenant server containers
|
|
||||||
CAMELEER_SAAS_PROVISIONING_PUBLICHOST: ${PUBLIC_HOST:-localhost}
|
|
||||||
CAMELEER_SAAS_PROVISIONING_PUBLICPROTOCOL: ${PUBLIC_PROTOCOL:-https}
|
|
||||||
CAMELEER_SAAS_PROVISIONING_DATASOURCEUSERNAME: ${POSTGRES_USER:-cameleer}
|
|
||||||
CAMELEER_SAAS_PROVISIONING_DATASOURCEPASSWORD: ${POSTGRES_PASSWORD:-cameleer_dev}
|
|
||||||
CAMELEER_SAAS_PROVISIONING_CLICKHOUSEPASSWORD: ${CLICKHOUSE_PASSWORD:-cameleer_ch}
|
|
||||||
labels:
|
|
||||||
- traefik.enable=true
|
|
||||||
- traefik.http.routers.saas.rule=PathPrefix(`/platform`)
|
|
||||||
- traefik.http.routers.saas.entrypoints=websecure
|
|
||||||
- traefik.http.routers.saas.tls=true
|
|
||||||
- traefik.http.services.saas.loadbalancer.server.port=8080
|
|
||||||
group_add:
|
|
||||||
- "${DOCKER_GID:-0}"
|
|
||||||
networks:
|
|
||||||
- cameleer
|
|
||||||
|
|
||||||
networks:
|
|
||||||
cameleer:
|
|
||||||
driver: bridge
|
|
||||||
cameleer-traefik:
|
|
||||||
name: cameleer-traefik
|
|
||||||
driver: bridge
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
cameleer-pgdata:
|
|
||||||
cameleer-chdata:
|
|
||||||
cameleer-certs:
|
|
||||||
cameleer-bootstrapdata:
|
|
||||||
|
|||||||
@@ -42,11 +42,13 @@ Server containers join three networks: tenant network (primary), shared services
|
|||||||
|
|
||||||
## Custom sign-in UI (`ui/sign-in/`)
|
## Custom sign-in UI (`ui/sign-in/`)
|
||||||
|
|
||||||
Separate Vite+React SPA replacing Logto's default sign-in page. Visually matches cameleer-server LoginPage.
|
Separate Vite+React SPA replacing Logto's default sign-in page. Supports both sign-in and self-service registration (registration is disabled by default until the vendor admin configures an email connector via the UI).
|
||||||
|
|
||||||
- Built as custom Logto Docker image (`cameleer-logto`): `ui/sign-in/Dockerfile` = node build stage + `FROM ghcr.io/logto-io/logto:latest` + COPY dist over `/etc/logto/packages/experience/dist/`
|
- Built as custom Logto Docker image (`cameleer-logto`): `ui/sign-in/Dockerfile` = node build stage + `FROM ghcr.io/logto-io/logto:latest` + install official connectors + COPY dist over `/etc/logto/packages/experience/dist/`
|
||||||
- Uses `@cameleer/design-system` components (Card, Input, Button, FormField, Alert)
|
- Uses `@cameleer/design-system` components (Card, Input, Button, FormField, Alert)
|
||||||
- Authenticates via Logto Experience API (4-step: init -> verify password -> identify -> submit -> redirect)
|
- **Sign-in**: Logto Experience API (4-step: init -> verify password -> identify -> submit -> redirect). Auto-detects email vs username identifier.
|
||||||
|
- **Registration**: 2-phase flow. Phase 1: init Register -> send verification code to email. Phase 2: verify code -> set password -> identify (creates user) -> submit -> redirect.
|
||||||
|
- Reads `first_screen=register` from URL query params to show register form initially (set by `@logto/react` SDK's `firstScreen` option)
|
||||||
- `CUSTOM_UI_PATH` env var does NOT work for Logto OSS — must volume-mount or replace the experience dist directory
|
- `CUSTOM_UI_PATH` env var does NOT work for Logto OSS — must volume-mount or replace the experience dist directory
|
||||||
- Favicon bundled in `ui/sign-in/public/favicon.svg` (served by Logto, not SaaS)
|
- Favicon bundled in `ui/sign-in/public/favicon.svg` (served by Logto, not SaaS)
|
||||||
|
|
||||||
@@ -78,11 +80,14 @@ Idempotent script run inside the Logto container entrypoint. **Clean slate** —
|
|||||||
3. Create Logto apps (SPA, Traditional Web App with `skipConsent`, M2M with Management API role + server API role)
|
3. Create Logto apps (SPA, Traditional Web App with `skipConsent`, M2M with Management API role + server API role)
|
||||||
3b. Create API resource scopes (1 platform + 9 tenant + 3 server scopes)
|
3b. Create API resource scopes (1 platform + 9 tenant + 3 server scopes)
|
||||||
4. Create org roles (owner, operator, viewer with API resource scope assignments) + M2M server role (`cameleer-m2m-server` with `server:admin` scope)
|
4. Create org roles (owner, operator, viewer with API resource scope assignments) + M2M server role (`cameleer-m2m-server` with `server:admin` scope)
|
||||||
5. Create admin user (SaaS admin with Logto console access)
|
5. Create admin user (SaaS admin with Logto console access). Email is the primary user identity in SaaS mode — admin email defaults to `<SAAS_ADMIN_USER>@<PUBLIC_HOST>` if `SAAS_ADMIN_EMAIL` is not set.
|
||||||
7b. Configure Logto Custom JWT for access tokens (maps org roles -> `roles` claim: owner->server:admin, operator->server:operator, viewer->server:viewer; saas-vendor global role -> server:admin)
|
7b. Configure Logto Custom JWT for access tokens (maps org roles -> `roles` claim: owner->server:admin, operator->server:operator, viewer->server:viewer; saas-vendor global role -> server:admin)
|
||||||
8. Configure Logto sign-in branding (Cameleer colors `#C6820E`/`#D4941E`, logo from `/platform/logo.svg`)
|
8. Configure Logto sign-in branding (Cameleer colors `#C6820E`/`#D4941E`, logo from `/platform/logo.svg`)
|
||||||
|
8c. Configure sign-in experience (sign-in only) — sets `signInMode: "SignIn"` with username+password method. Registration is disabled by default; the vendor admin enables it via the Email Connector UI after configuring SMTP delivery.
|
||||||
9. Cleanup seeded Logto apps
|
9. Cleanup seeded Logto apps
|
||||||
10. Write bootstrap results to `/data/logto-bootstrap.json`
|
10. Write bootstrap results to `/data/logto-bootstrap.json`
|
||||||
12. Create `saas-vendor` global role with all API scopes and assign to admin user (always runs — admin IS the platform admin).
|
12. Create `saas-vendor` global role with all API scopes and assign to admin user (always runs — admin IS the platform admin).
|
||||||
|
|
||||||
|
SMTP / email connector configuration is managed at runtime via the vendor admin UI (Email Connector page). The bootstrap no longer creates email connectors — it defaults to sign-in only mode. Registration is enabled automatically when the admin configures an email connector through the UI.
|
||||||
|
|
||||||
The multi-tenant compose stack is: Traefik + PostgreSQL + ClickHouse + Logto (with bootstrap entrypoint) + cameleer-saas. No `cameleer-server` or `cameleer-server-ui` in compose — those are provisioned per-tenant by `DockerTenantProvisioner`.
|
The multi-tenant compose stack is: Traefik + PostgreSQL + ClickHouse + Logto (with bootstrap entrypoint) + cameleer-saas. No `cameleer-server` or `cameleer-server-ui` in compose — those are provisioned per-tenant by `DockerTenantProvisioner`.
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
# Build DB_URL from individual env vars so passwords with special characters
|
||||||
|
# are properly URL-encoded (Logto only accepts a connection string)
|
||||||
|
if [ -z "$DB_URL" ]; then
|
||||||
|
ENCODED_PW=$(node -e "process.stdout.write(encodeURIComponent(process.env.PG_PASSWORD || ''))")
|
||||||
|
export DB_URL="postgres://${PG_USER:-cameleer}:${ENCODED_PW}@${PG_HOST:-localhost}:5432/logto"
|
||||||
|
echo "[entrypoint] Built DB_URL from PG_USER/PG_PASSWORD/PG_HOST"
|
||||||
|
fi
|
||||||
|
|
||||||
# Save the real public endpoints for after bootstrap
|
# Save the real public endpoints for after bootstrap
|
||||||
REAL_ENDPOINT="$ENDPOINT"
|
REAL_ENDPOINT="$ENDPOINT"
|
||||||
REAL_ADMIN_ENDPOINT="$ADMIN_ENDPOINT"
|
REAL_ADMIN_ENDPOINT="$ADMIN_ENDPOINT"
|
||||||
|
|||||||
@@ -28,12 +28,20 @@ if [ ! -f "$CERTS_DIR/cert.pem" ]; then
|
|||||||
else
|
else
|
||||||
# Generate self-signed certificate
|
# Generate self-signed certificate
|
||||||
HOST="${PUBLIC_HOST:-localhost}"
|
HOST="${PUBLIC_HOST:-localhost}"
|
||||||
|
AUTH="${AUTH_HOST:-$HOST}"
|
||||||
echo "[certs] Generating self-signed certificate for $HOST..."
|
echo "[certs] Generating self-signed certificate for $HOST..."
|
||||||
|
# Build SAN list; deduplicate when AUTH_HOST equals PUBLIC_HOST
|
||||||
|
if [ "$AUTH" = "$HOST" ]; then
|
||||||
|
SAN="DNS:$HOST,DNS:*.$HOST"
|
||||||
|
else
|
||||||
|
SAN="DNS:$HOST,DNS:*.$HOST,DNS:$AUTH,DNS:*.$AUTH"
|
||||||
|
echo "[certs] (+ auth domain: $AUTH)"
|
||||||
|
fi
|
||||||
openssl req -x509 -newkey rsa:4096 \
|
openssl req -x509 -newkey rsa:4096 \
|
||||||
-keyout "$CERTS_DIR/key.pem" -out "$CERTS_DIR/cert.pem" \
|
-keyout "$CERTS_DIR/key.pem" -out "$CERTS_DIR/cert.pem" \
|
||||||
-days 365 -nodes \
|
-days 365 -nodes \
|
||||||
-subj "/CN=$HOST" \
|
-subj "/CN=$HOST" \
|
||||||
-addext "subjectAltName=DNS:$HOST,DNS:*.$HOST"
|
-addext "subjectAltName=$SAN"
|
||||||
SELF_SIGNED=true
|
SELF_SIGNED=true
|
||||||
echo "[certs] Generated self-signed certificate for $HOST."
|
echo "[certs] Generated self-signed certificate for $HOST."
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -1,21 +1,3 @@
|
|||||||
http:
|
|
||||||
routers:
|
|
||||||
root-redirect:
|
|
||||||
rule: "Path(`/`)"
|
|
||||||
priority: 100
|
|
||||||
entryPoints:
|
|
||||||
- websecure
|
|
||||||
tls: {}
|
|
||||||
middlewares:
|
|
||||||
- root-to-platform
|
|
||||||
service: saas@docker
|
|
||||||
middlewares:
|
|
||||||
root-to-platform:
|
|
||||||
redirectRegex:
|
|
||||||
regex: "^(https?://[^/]+)/?$"
|
|
||||||
replacement: "${1}/platform/"
|
|
||||||
permanent: false
|
|
||||||
|
|
||||||
tls:
|
tls:
|
||||||
stores:
|
stores:
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -27,11 +27,15 @@ API_RESOURCE_NAME="Cameleer SaaS API"
|
|||||||
# Users (configurable via env vars)
|
# Users (configurable via env vars)
|
||||||
SAAS_ADMIN_USER="${SAAS_ADMIN_USER:-admin}"
|
SAAS_ADMIN_USER="${SAAS_ADMIN_USER:-admin}"
|
||||||
SAAS_ADMIN_PASS="${SAAS_ADMIN_PASS:-admin}"
|
SAAS_ADMIN_PASS="${SAAS_ADMIN_PASS:-admin}"
|
||||||
|
# Admin email: use provided value, or derive from username@host.
|
||||||
|
# SaaS enforces email as the user identity — admin must have one.
|
||||||
|
SAAS_ADMIN_EMAIL="${SAAS_ADMIN_EMAIL:-${SAAS_ADMIN_USER}@${PUBLIC_HOST:-localhost}}"
|
||||||
|
|
||||||
# No server config — servers are provisioned dynamically by the admin console
|
# No server config — servers are provisioned dynamically by the admin console
|
||||||
|
|
||||||
# Redirect URIs (derived from PUBLIC_HOST and PUBLIC_PROTOCOL)
|
# Redirect URIs (derived from PUBLIC_HOST and PUBLIC_PROTOCOL)
|
||||||
HOST="${PUBLIC_HOST:-localhost}"
|
HOST="${PUBLIC_HOST:-localhost}"
|
||||||
|
AUTH="${AUTH_HOST:-$HOST}"
|
||||||
PROTO="${PUBLIC_PROTOCOL:-https}"
|
PROTO="${PUBLIC_PROTOCOL:-https}"
|
||||||
SPA_REDIRECT_URIS="[\"${PROTO}://${HOST}/platform/callback\"]"
|
SPA_REDIRECT_URIS="[\"${PROTO}://${HOST}/platform/callback\"]"
|
||||||
SPA_POST_LOGOUT_URIS="[\"${PROTO}://${HOST}/platform/login\",\"${PROTO}://${HOST}/platform/\"]"
|
SPA_POST_LOGOUT_URIS="[\"${PROTO}://${HOST}/platform/login\",\"${PROTO}://${HOST}/platform/\"]"
|
||||||
@@ -47,8 +51,9 @@ if [ "$BOOTSTRAP_LOCAL" = "true" ]; then
|
|||||||
HOST_ARGS=""
|
HOST_ARGS=""
|
||||||
ADMIN_HOST_ARGS=""
|
ADMIN_HOST_ARGS=""
|
||||||
else
|
else
|
||||||
HOST_ARGS="-H Host:${HOST}"
|
# Logto validates Host header against its ENDPOINT, which uses AUTH_HOST
|
||||||
ADMIN_HOST_ARGS="-H Host:${HOST}:3002 -H X-Forwarded-Proto:https"
|
HOST_ARGS="-H Host:${AUTH}"
|
||||||
|
ADMIN_HOST_ARGS="-H Host:${AUTH}:3002 -H X-Forwarded-Proto:https"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Install jq + curl if not already available (deps are baked into cameleer-logto image)
|
# Install jq + curl if not already available (deps are baked into cameleer-logto image)
|
||||||
@@ -392,11 +397,12 @@ ADMIN_USER_ID=$(api_get "/api/users?search=$SAAS_ADMIN_USER" | jq -r ".[] | sele
|
|||||||
if [ -n "$ADMIN_USER_ID" ]; then
|
if [ -n "$ADMIN_USER_ID" ]; then
|
||||||
log "Platform owner exists: $ADMIN_USER_ID"
|
log "Platform owner exists: $ADMIN_USER_ID"
|
||||||
else
|
else
|
||||||
log "Creating platform owner '$SAAS_ADMIN_USER'..."
|
log "Creating platform owner '$SAAS_ADMIN_USER' (email: $SAAS_ADMIN_EMAIL)..."
|
||||||
ADMIN_RESPONSE=$(api_post "/api/users" "{
|
ADMIN_RESPONSE=$(api_post "/api/users" "{
|
||||||
\"username\": \"$SAAS_ADMIN_USER\",
|
\"username\": \"$SAAS_ADMIN_USER\",
|
||||||
\"password\": \"$SAAS_ADMIN_PASS\",
|
\"password\": \"$SAAS_ADMIN_PASS\",
|
||||||
\"name\": \"Platform Owner\"
|
\"name\": \"Platform Owner\",
|
||||||
|
\"primaryEmail\": \"$SAAS_ADMIN_EMAIL\"
|
||||||
}")
|
}")
|
||||||
ADMIN_USER_ID=$(echo "$ADMIN_RESPONSE" | jq -r '.id')
|
ADMIN_USER_ID=$(echo "$ADMIN_RESPONSE" | jq -r '.id')
|
||||||
log "Created platform owner: $ADMIN_USER_ID"
|
log "Created platform owner: $ADMIN_USER_ID"
|
||||||
@@ -562,6 +568,28 @@ api_patch "/api/sign-in-exp" "{
|
|||||||
}"
|
}"
|
||||||
log "Sign-in branding configured."
|
log "Sign-in branding configured."
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# PHASE 8c: Configure sign-in experience (sign-in only)
|
||||||
|
# ============================================================
|
||||||
|
# Registration is disabled by default. The vendor admin enables it
|
||||||
|
# via the Email Connector UI after configuring SMTP delivery.
|
||||||
|
|
||||||
|
log "Configuring sign-in experience (sign-in only, no registration)..."
|
||||||
|
api_patch "/api/sign-in-exp" '{
|
||||||
|
"signInMode": "SignIn",
|
||||||
|
"signIn": {
|
||||||
|
"methods": [
|
||||||
|
{
|
||||||
|
"identifier": "username",
|
||||||
|
"password": true,
|
||||||
|
"verificationCode": false,
|
||||||
|
"isPasswordPrimary": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}' >/dev/null 2>&1
|
||||||
|
log "Sign-in experience configured: SignIn only (registration disabled until email is configured)."
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# PHASE 9: Cleanup seeded apps
|
# PHASE 9: Cleanup seeded apps
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
1371
docs/superpowers/plans/2026-04-25-email-connector-ui-plan.md
Normal file
1371
docs/superpowers/plans/2026-04-25-email-connector-ui-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
147
docs/superpowers/specs/2026-04-25-email-connector-ui-design.md
Normal file
147
docs/superpowers/specs/2026-04-25-email-connector-ui-design.md
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
# Email Connector UI Configuration
|
||||||
|
|
||||||
|
Move email connector setup from the one-shot installer/bootstrap into the vendor admin UI, giving platform admins runtime control over email delivery and self-service registration.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The current flow bakes SMTP configuration into the installer prompts and the Logto bootstrap script. This has two problems: (1) the bootstrap factory selection regex doesn't match the actual Logto SMTP factory ID (`simple-mail-transfer-protocol`), causing it to pick the wrong factory and fail silently; (2) bootstrap is a one-shot — if SMTP is added or changed after first boot, the connector is never created or updated.
|
||||||
|
|
||||||
|
Moving configuration to the UI fixes both issues and gives admins the ability to configure, test, change, or remove email delivery at any time.
|
||||||
|
|
||||||
|
## Design Decisions
|
||||||
|
|
||||||
|
- **SMTP only for now**, but the architecture supports adding other providers (SES, SendGrid, Mailgun, etc.) with one form component and one service method per provider.
|
||||||
|
- **Registration is disabled by default** until email is configured. Admins get a toggle to enable/disable registration independently once email works.
|
||||||
|
- **Test email sends a real email** to a recipient address the admin provides, proving end-to-end delivery.
|
||||||
|
- **Email templates are hardcoded** — four Cameleer-branded HTML templates (Register, SignIn, ForgotPassword, Generic) attached automatically when saving config.
|
||||||
|
- **Email config lives under an expandable "Identity" sidebar section**, replacing the flat external Logto link. The section contains "Email Connector" and "Logto Console" (external link).
|
||||||
|
|
||||||
|
## Section 1: Removal
|
||||||
|
|
||||||
|
### Installer — bash (`installer/install.sh`)
|
||||||
|
|
||||||
|
- Remove SMTP prompt block (~lines 499-509): `prompt_yesno`, `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM_EMAIL`
|
||||||
|
- Remove SMTP vars from `.env` generation
|
||||||
|
- Remove SMTP vars from `cameleer.conf` persistence
|
||||||
|
|
||||||
|
### Installer — PowerShell (`installer/install.ps1`)
|
||||||
|
|
||||||
|
- Remove env var reads (lines 95-99): `$_ENV_SMTP_HOST` through `$_ENV_SMTP_FROM_EMAIL`
|
||||||
|
- Remove config file parsing (lines 305-309): `smtp_host` through `smtp_from_email` cases
|
||||||
|
- Remove env fallback merging (lines 342-346): `if (-not $c.SmtpHost)` blocks
|
||||||
|
- Remove SMTP prompt block (lines 516-523)
|
||||||
|
- Remove SMTP `.env` output (lines 778-782, 789)
|
||||||
|
- Remove SMTP `cameleer.conf` output (lines 1028-1031, 1036)
|
||||||
|
|
||||||
|
### Compose template (`installer/templates/docker-compose.saas.yml`)
|
||||||
|
|
||||||
|
- Remove the 5 SMTP env vars from the cameleer-logto service (lines 30-35): `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM_EMAIL`
|
||||||
|
|
||||||
|
### Bootstrap (`docker/logto-bootstrap.sh`)
|
||||||
|
|
||||||
|
- Remove Phase 8b entirely (lines 568-649): SMTP connector creation via `/api/connector-factories` and `/api/connectors`
|
||||||
|
- Modify Phase 8c (lines 657-682): Change `signInMode` from `"SignInAndRegister"` to `"SignIn"`. Remove `signUp.identifiers: ["email"]` and `signUp.verify: true`. Keep username+password sign-in method for the admin user. Registration gets enabled by the backend when the admin configures email.
|
||||||
|
|
||||||
|
## Section 2: Backend — Email Connector API
|
||||||
|
|
||||||
|
### New controller: `EmailConnectorController`
|
||||||
|
|
||||||
|
Location: `src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorController.java`
|
||||||
|
|
||||||
|
`@RestController`, `@RequestMapping("/api/vendor/email-connector")`, `@PreAuthorize("hasAuthority('SCOPE_platform:admin')")`
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|--------|------|---------|
|
||||||
|
| GET | `/api/vendor/email-connector` | Returns current email connector config (password masked) + registration enabled state. 404 if none configured. |
|
||||||
|
| POST | `/api/vendor/email-connector` | Creates or updates connector. Accepts SMTP config + optional `registrationEnabled` boolean. Attaches branded templates. Enables registration on first save unless explicitly set to false. |
|
||||||
|
| DELETE | `/api/vendor/email-connector` | Removes connector, force-disables registration. |
|
||||||
|
| POST | `/api/vendor/email-connector/test` | Accepts `{to: "email"}`, sends test email through configured connector, returns success/failure with message. |
|
||||||
|
|
||||||
|
### New service: `EmailConnectorService`
|
||||||
|
|
||||||
|
Location: `src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorService.java`
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
- Maps provider-specific DTOs to Logto connector config format
|
||||||
|
- Selects the correct Logto factory ID per provider (SMTP = `simple-mail-transfer-protocol`)
|
||||||
|
- Hardcodes the four Cameleer-branded HTML email templates (Register, SignIn, ForgotPassword, Generic) with `{{code}}` placeholder and `#C6820E` brand color
|
||||||
|
- Manages sign-in experience toggle via `PATCH /api/sign-in-exp`
|
||||||
|
- Handles test email flow
|
||||||
|
|
||||||
|
### New methods on `LogtoManagementClient`
|
||||||
|
|
||||||
|
Location: `src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java`
|
||||||
|
|
||||||
|
Following the existing SSO connector method patterns:
|
||||||
|
|
||||||
|
- `listConnectorFactories()` — `GET /api/connector-factories`
|
||||||
|
- `listConnectors()` — `GET /api/connectors`
|
||||||
|
- `createConnector(factoryId, config)` — `POST /api/connectors`
|
||||||
|
- `updateConnector(connectorId, config)` — `PATCH /api/connectors/{id}`
|
||||||
|
- `deleteConnector(connectorId)` — `DELETE /api/connectors/{id}`
|
||||||
|
- `testConnector(factoryId, email, config)` — `POST /api/connectors/{factoryId}/test` (Logto's built-in test endpoint; sends a real email with the provided config without needing to save first)
|
||||||
|
- `updateSignInExperience(config)` — `PATCH /api/sign-in-exp`
|
||||||
|
- `getSignInExperience()` — `GET /api/sign-in-exp`
|
||||||
|
|
||||||
|
## Section 3: Frontend — Email Configuration Page
|
||||||
|
|
||||||
|
### New page: `EmailConfigPage.tsx`
|
||||||
|
|
||||||
|
Location: `ui/src/pages/vendor/EmailConfigPage.tsx`
|
||||||
|
|
||||||
|
Follows the CertificatesPage pattern (Card layout, form fields, mutation hooks, toast notifications).
|
||||||
|
|
||||||
|
**Three UI states:**
|
||||||
|
|
||||||
|
| Email configured | Registration toggle | signInMode |
|
||||||
|
|---|---|---|
|
||||||
|
| No | Disabled, off | `SignIn` |
|
||||||
|
| Yes | On (default after first save) | `SignInAndRegister` |
|
||||||
|
| Yes | Off (admin chose to disable) | `SignIn` |
|
||||||
|
|
||||||
|
**Unconfigured state:**
|
||||||
|
- Info alert: "Email delivery is not configured. Self-service registration is disabled."
|
||||||
|
- SMTP form: Host (text), Port (number, default 587), Username (text), Password (password), From Email (email). All required.
|
||||||
|
- Save button.
|
||||||
|
|
||||||
|
**Configured state:**
|
||||||
|
- Card showing current config: host, port, username, from-email. Password masked as `••••••••`.
|
||||||
|
- Registration toggle (switch) with label "Enable self-service registration".
|
||||||
|
- Edit button to modify config, Delete button with confirmation dialog warning that removal disables registration.
|
||||||
|
- "Send Test Email" section: text input for recipient + Send button. Success/failure shown inline.
|
||||||
|
|
||||||
|
### New hooks: `email-connector-hooks.ts`
|
||||||
|
|
||||||
|
Location: `ui/src/api/email-connector-hooks.ts`
|
||||||
|
|
||||||
|
Following the certificate-hooks pattern:
|
||||||
|
|
||||||
|
- `useEmailConnector()` — `GET /api/vendor/email-connector`
|
||||||
|
- `useSaveEmailConnector()` — `POST /api/vendor/email-connector`
|
||||||
|
- `useDeleteEmailConnector()` — `DELETE /api/vendor/email-connector`
|
||||||
|
- `useTestEmailConnector()` — `POST /api/vendor/email-connector/test`
|
||||||
|
|
||||||
|
### Router (`router.tsx`)
|
||||||
|
|
||||||
|
- Add `/vendor/email` route inside the vendor `RequireScope` guard
|
||||||
|
|
||||||
|
### Sidebar (`Layout.tsx`)
|
||||||
|
|
||||||
|
- Replace the flat "Identity (Logto)" external link with an expandable "Identity" section
|
||||||
|
- Items: "Email Connector" (internal link to `/vendor/email`), "Logto Console" (external link, preserved)
|
||||||
|
|
||||||
|
## Section 4: Extensibility — Adding Future Providers
|
||||||
|
|
||||||
|
To add a new email provider (e.g. AWS SES):
|
||||||
|
|
||||||
|
1. **Backend**: Add a request DTO and a mapping method in `EmailConnectorService` that maps to the provider's Logto config schema and returns the correct factory ID
|
||||||
|
2. **Frontend**: Add a `SesConfigForm.tsx` component and a new option in the provider selector dropdown on `EmailConfigPage`
|
||||||
|
|
||||||
|
No changes needed to:
|
||||||
|
- `EmailConnectorController` (provider-agnostic endpoints)
|
||||||
|
- `LogtoManagementClient` (works with any factory/connector)
|
||||||
|
- Email templates (shared across providers)
|
||||||
|
- Registration toggle logic (shared across providers)
|
||||||
|
- React Query hooks (provider-agnostic)
|
||||||
@@ -26,13 +26,12 @@ Both audiences share the same UI and workflows. The self-hosted setup section at
|
|||||||
|
|
||||||
### Logging In
|
### Logging In
|
||||||
|
|
||||||
Cameleer SaaS uses Logto for single sign-on (SSO). To log in:
|
Cameleer SaaS uses Logto for single sign-on (SSO). Email is the primary user identity — all users must have an email address. To log in:
|
||||||
|
|
||||||
1. Navigate to the Cameleer SaaS URL in your browser.
|
1. Navigate to the Cameleer SaaS URL in your browser.
|
||||||
2. You will see the login screen with the title "Cameleer SaaS" and a subtitle "Managed Apache Camel Runtime."
|
2. You will be redirected to the Cameleer sign-in page.
|
||||||
3. Click **Sign in with Logto**.
|
3. Enter your email or username and password.
|
||||||
4. Authenticate with your Logto credentials (username/password or any configured social login).
|
4. After successful authentication, you are redirected to the dashboard.
|
||||||
5. After successful authentication, you are redirected back to the dashboard.
|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -444,6 +443,7 @@ Copy `.env.example` to `.env` and configure as needed:
|
|||||||
| `PUBLIC_PROTOCOL` | Public protocol (`http` or `https`) | `https` |
|
| `PUBLIC_PROTOCOL` | Public protocol (`http` or `https`) | `https` |
|
||||||
| `SAAS_ADMIN_USER` | Platform admin username | `admin` |
|
| `SAAS_ADMIN_USER` | Platform admin username | `admin` |
|
||||||
| `SAAS_ADMIN_PASS` | Platform admin password | `admin` |
|
| `SAAS_ADMIN_PASS` | Platform admin password | `admin` |
|
||||||
|
| `SAAS_ADMIN_EMAIL` | Platform admin email (primary identity) | `<SAAS_ADMIN_USER>@<PUBLIC_HOST>` |
|
||||||
| `TENANT_ADMIN_USER` | Tenant admin username | `camel` |
|
| `TENANT_ADMIN_USER` | Tenant admin username | `camel` |
|
||||||
| `TENANT_ADMIN_PASS` | Tenant admin password | `camel` |
|
| `TENANT_ADMIN_PASS` | Tenant admin password | `camel` |
|
||||||
|
|
||||||
|
|||||||
1
installer
Submodule
1
installer
Submodule
Submodule installer added at 0da26160c6
@@ -1,32 +0,0 @@
|
|||||||
# Installer
|
|
||||||
|
|
||||||
## Deployment Modes
|
|
||||||
|
|
||||||
The installer (`installer/install.sh`) supports two deployment modes:
|
|
||||||
|
|
||||||
| | Multi-tenant SaaS (`DEPLOYMENT_MODE=saas`) | Standalone (`DEPLOYMENT_MODE=standalone`) |
|
|
||||||
|---|---|---|
|
|
||||||
| **Containers** | traefik, postgres, clickhouse, logto, cameleer-saas | traefik, postgres, clickhouse, server, server-ui |
|
|
||||||
| **Auth** | Logto OIDC (SaaS admin + tenant users) | Local auth (built-in admin, no identity provider) |
|
|
||||||
| **Tenant management** | SaaS admin creates/manages tenants via UI | Single server instance, no fleet management |
|
|
||||||
| **PostgreSQL** | `cameleer-postgres` image (multi-DB init) | Stock `postgres:16-alpine` (server creates schema via Flyway) |
|
|
||||||
| **Use case** | Platform vendor managing multiple customers | Single customer running the product directly |
|
|
||||||
|
|
||||||
Standalone mode generates a simpler compose with the server running directly. No Logto, no SaaS management plane, no bootstrap. The admin logs in with local credentials at `/`.
|
|
||||||
|
|
||||||
## Compose templates
|
|
||||||
|
|
||||||
The installer uses static docker-compose templates in `installer/templates/`. Templates are copied to the install directory and composed via `COMPOSE_FILE` in `.env`:
|
|
||||||
- `docker-compose.yml` — shared infrastructure (traefik, postgres, clickhouse)
|
|
||||||
- `docker-compose.saas.yml` — SaaS mode (logto, cameleer-saas)
|
|
||||||
- `docker-compose.server.yml` — standalone mode (server, server-ui)
|
|
||||||
- `docker-compose.tls.yml` — overlay: custom TLS cert volume
|
|
||||||
- `docker-compose.monitoring.yml` — overlay: external monitoring network
|
|
||||||
|
|
||||||
## Env var naming convention
|
|
||||||
|
|
||||||
- `CAMELEER_AGENT_*` — agent config (consumed by the Java agent)
|
|
||||||
- `CAMELEER_SERVER_*` — server config (consumed by cameleer-server)
|
|
||||||
- `CAMELEER_SAAS_*` — SaaS management plane config
|
|
||||||
- `CAMELEER_SAAS_PROVISIONING_*` — "SaaS forwards this to provisioned tenant servers"
|
|
||||||
- No prefix (e.g. `POSTGRES_PASSWORD`, `PUBLIC_HOST`) — shared infrastructure, consumed by multiple components
|
|
||||||
File diff suppressed because it is too large
Load Diff
1404
installer/install.sh
1404
installer/install.sh
File diff suppressed because it is too large
Load Diff
@@ -1,89 +0,0 @@
|
|||||||
# Cameleer Configuration
|
|
||||||
# Copy this file to .env and fill in the values.
|
|
||||||
# The installer generates .env automatically — this file is for reference.
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# Compose file assembly (set by installer)
|
|
||||||
# ============================================================
|
|
||||||
# SaaS: docker-compose.yml:docker-compose.saas.yml
|
|
||||||
# Standalone: docker-compose.yml:docker-compose.server.yml
|
|
||||||
# Add :docker-compose.tls.yml for custom TLS certificates
|
|
||||||
# Add :docker-compose.monitoring.yml for external monitoring network
|
|
||||||
COMPOSE_FILE=docker-compose.yml:docker-compose.saas.yml
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# Image version
|
|
||||||
# ============================================================
|
|
||||||
VERSION=latest
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# Public access
|
|
||||||
# ============================================================
|
|
||||||
PUBLIC_HOST=localhost
|
|
||||||
PUBLIC_PROTOCOL=https
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# Ports
|
|
||||||
# ============================================================
|
|
||||||
HTTP_PORT=80
|
|
||||||
HTTPS_PORT=443
|
|
||||||
# Set to 0.0.0.0 to expose Logto admin console externally (default: localhost only)
|
|
||||||
# LOGTO_CONSOLE_BIND=0.0.0.0
|
|
||||||
LOGTO_CONSOLE_PORT=3002
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# PostgreSQL
|
|
||||||
# ============================================================
|
|
||||||
POSTGRES_USER=cameleer
|
|
||||||
POSTGRES_PASSWORD=CHANGE_ME
|
|
||||||
# SaaS: cameleer_saas, Standalone: cameleer
|
|
||||||
POSTGRES_DB=cameleer_saas
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# ClickHouse
|
|
||||||
# ============================================================
|
|
||||||
CLICKHOUSE_PASSWORD=CHANGE_ME
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# Admin credentials (SaaS mode)
|
|
||||||
# ============================================================
|
|
||||||
SAAS_ADMIN_USER=admin
|
|
||||||
SAAS_ADMIN_PASS=CHANGE_ME
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# Admin credentials (standalone mode)
|
|
||||||
# ============================================================
|
|
||||||
# SERVER_ADMIN_USER=admin
|
|
||||||
# SERVER_ADMIN_PASS=CHANGE_ME
|
|
||||||
# BOOTSTRAP_TOKEN=CHANGE_ME
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# TLS
|
|
||||||
# ============================================================
|
|
||||||
# Set to 1 to reject unauthorized TLS certificates (production)
|
|
||||||
NODE_TLS_REJECT=0
|
|
||||||
# Custom TLS certificate paths (inside container, set by installer)
|
|
||||||
# CERT_FILE=/user-certs/cert.pem
|
|
||||||
# KEY_FILE=/user-certs/key.pem
|
|
||||||
# CA_FILE=/user-certs/ca.pem
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# Docker
|
|
||||||
# ============================================================
|
|
||||||
DOCKER_SOCKET=/var/run/docker.sock
|
|
||||||
# GID of the docker socket — detected by installer, used for container group_add
|
|
||||||
DOCKER_GID=0
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# Provisioning images (SaaS mode only)
|
|
||||||
# ============================================================
|
|
||||||
# CAMELEER_SAAS_PROVISIONING_SERVERIMAGE=gitea.siegeln.net/cameleer/cameleer-server:latest
|
|
||||||
# CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE=gitea.siegeln.net/cameleer/cameleer-server-ui:latest
|
|
||||||
# CAMELEER_SAAS_PROVISIONING_RUNTIMEBASEIMAGE=gitea.siegeln.net/cameleer/cameleer-runtime-base:latest
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# Monitoring (optional)
|
|
||||||
# ============================================================
|
|
||||||
# External Docker network name for Prometheus scraping.
|
|
||||||
# Only needed when docker-compose.monitoring.yml is in COMPOSE_FILE.
|
|
||||||
# MONITORING_NETWORK=prometheus
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
# External monitoring network overlay
|
|
||||||
# Overrides the noop monitoring bridge with a real external network
|
|
||||||
|
|
||||||
networks:
|
|
||||||
monitoring:
|
|
||||||
external: true
|
|
||||||
name: ${MONITORING_NETWORK:?MONITORING_NETWORK must be set in .env}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
# Cameleer SaaS — Logto + management plane
|
|
||||||
# Loaded in SaaS deployment mode
|
|
||||||
|
|
||||||
services:
|
|
||||||
cameleer-logto:
|
|
||||||
image: ${LOGTO_IMAGE:-gitea.siegeln.net/cameleer/cameleer-logto}:${VERSION:-latest}
|
|
||||||
restart: unless-stopped
|
|
||||||
depends_on:
|
|
||||||
cameleer-postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
environment:
|
|
||||||
DB_URL: postgres://${POSTGRES_USER:-cameleer}:${POSTGRES_PASSWORD}@cameleer-postgres:5432/logto
|
|
||||||
ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
|
|
||||||
ADMIN_ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}:${LOGTO_CONSOLE_PORT:-3002}
|
|
||||||
TRUST_PROXY_HEADER: 1
|
|
||||||
NODE_TLS_REJECT_UNAUTHORIZED: "${NODE_TLS_REJECT:-0}"
|
|
||||||
LOGTO_ENDPOINT: http://cameleer-logto:3001
|
|
||||||
LOGTO_ADMIN_ENDPOINT: http://cameleer-logto:3002
|
|
||||||
LOGTO_PUBLIC_ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
|
|
||||||
PUBLIC_HOST: ${PUBLIC_HOST:-localhost}
|
|
||||||
PUBLIC_PROTOCOL: ${PUBLIC_PROTOCOL:-https}
|
|
||||||
PG_HOST: cameleer-postgres
|
|
||||||
PG_USER: ${POSTGRES_USER:-cameleer}
|
|
||||||
PG_PASSWORD: ${POSTGRES_PASSWORD}
|
|
||||||
PG_DB_SAAS: cameleer_saas
|
|
||||||
SAAS_ADMIN_USER: ${SAAS_ADMIN_USER:-admin}
|
|
||||||
SAAS_ADMIN_PASS: ${SAAS_ADMIN_PASS:?SAAS_ADMIN_PASS must be set in .env}
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3001/oidc/.well-known/openid-configuration', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))\" && test -f /data/logto-bootstrap.json"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 60
|
|
||||||
start_period: 30s
|
|
||||||
labels:
|
|
||||||
- traefik.enable=true
|
|
||||||
- traefik.http.routers.cameleer-logto.rule=PathPrefix(`/`)
|
|
||||||
- traefik.http.routers.cameleer-logto.priority=1
|
|
||||||
- traefik.http.routers.cameleer-logto.entrypoints=websecure
|
|
||||||
- traefik.http.routers.cameleer-logto.tls=true
|
|
||||||
- traefik.http.routers.cameleer-logto.service=cameleer-logto
|
|
||||||
- traefik.http.routers.cameleer-logto.middlewares=cameleer-logto-cors
|
|
||||||
- "traefik.http.middlewares.cameleer-logto-cors.headers.accessControlAllowOriginList=${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}:${LOGTO_CONSOLE_PORT:-3002}"
|
|
||||||
- traefik.http.middlewares.cameleer-logto-cors.headers.accessControlAllowMethods=GET,POST,PUT,PATCH,DELETE,OPTIONS
|
|
||||||
- traefik.http.middlewares.cameleer-logto-cors.headers.accessControlAllowHeaders=Authorization,Content-Type
|
|
||||||
- traefik.http.middlewares.cameleer-logto-cors.headers.accessControlAllowCredentials=true
|
|
||||||
- traefik.http.services.cameleer-logto.loadbalancer.server.port=3001
|
|
||||||
- traefik.http.routers.cameleer-logto-console.rule=PathPrefix(`/`)
|
|
||||||
- traefik.http.routers.cameleer-logto-console.entrypoints=admin-console
|
|
||||||
- traefik.http.routers.cameleer-logto-console.tls=true
|
|
||||||
- traefik.http.routers.cameleer-logto-console.service=cameleer-logto-console
|
|
||||||
- traefik.http.services.cameleer-logto-console.loadbalancer.server.port=3002
|
|
||||||
volumes:
|
|
||||||
- cameleer-bootstrapdata:/data
|
|
||||||
networks:
|
|
||||||
- cameleer
|
|
||||||
- monitoring
|
|
||||||
|
|
||||||
cameleer-saas:
|
|
||||||
image: ${CAMELEER_IMAGE:-gitea.siegeln.net/cameleer/cameleer-saas}:${VERSION:-latest}
|
|
||||||
restart: unless-stopped
|
|
||||||
depends_on:
|
|
||||||
cameleer-logto:
|
|
||||||
condition: service_healthy
|
|
||||||
environment:
|
|
||||||
# SaaS database
|
|
||||||
SPRING_DATASOURCE_URL: jdbc:postgresql://cameleer-postgres:5432/cameleer_saas
|
|
||||||
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-cameleer}
|
|
||||||
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
|
|
||||||
# Identity (Logto)
|
|
||||||
CAMELEER_SAAS_IDENTITY_LOGTOENDPOINT: http://cameleer-logto:3001
|
|
||||||
CAMELEER_SAAS_IDENTITY_LOGTOPUBLICENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
|
|
||||||
# Provisioning — passed to per-tenant server containers
|
|
||||||
CAMELEER_SAAS_PROVISIONING_PUBLICHOST: ${PUBLIC_HOST:-localhost}
|
|
||||||
CAMELEER_SAAS_PROVISIONING_PUBLICPROTOCOL: ${PUBLIC_PROTOCOL:-https}
|
|
||||||
CAMELEER_SAAS_PROVISIONING_NETWORKNAME: ${COMPOSE_PROJECT_NAME:-cameleer-saas}_cameleer
|
|
||||||
CAMELEER_SAAS_PROVISIONING_TRAEFIKNETWORK: cameleer-traefik
|
|
||||||
CAMELEER_SAAS_PROVISIONING_DATASOURCEUSERNAME: ${POSTGRES_USER:-cameleer}
|
|
||||||
CAMELEER_SAAS_PROVISIONING_DATASOURCEPASSWORD: ${POSTGRES_PASSWORD}
|
|
||||||
CAMELEER_SAAS_PROVISIONING_CLICKHOUSEPASSWORD: ${CLICKHOUSE_PASSWORD}
|
|
||||||
CAMELEER_SERVER_SECURITY_JWTSECRET: ${CAMELEER_SERVER_SECURITY_JWTSECRET:?CAMELEER_SERVER_SECURITY_JWTSECRET must be set in .env}
|
|
||||||
CAMELEER_SAAS_PROVISIONING_SERVERIMAGE: ${CAMELEER_SAAS_PROVISIONING_SERVERIMAGE:-gitea.siegeln.net/cameleer/cameleer-server:latest}
|
|
||||||
CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE: ${CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE:-gitea.siegeln.net/cameleer/cameleer-server-ui:latest}
|
|
||||||
CAMELEER_SAAS_PROVISIONING_RUNTIMEBASEIMAGE: ${CAMELEER_SAAS_PROVISIONING_RUNTIMEBASEIMAGE:-gitea.siegeln.net/cameleer/cameleer-runtime-base:latest}
|
|
||||||
labels:
|
|
||||||
- traefik.enable=true
|
|
||||||
- traefik.http.routers.saas.rule=PathPrefix(`/platform`)
|
|
||||||
- traefik.http.routers.saas.entrypoints=websecure
|
|
||||||
- traefik.http.routers.saas.tls=true
|
|
||||||
- traefik.http.services.saas.loadbalancer.server.port=8080
|
|
||||||
- "prometheus.io/scrape=true"
|
|
||||||
- "prometheus.io/port=8080"
|
|
||||||
- "prometheus.io/path=/platform/actuator/prometheus"
|
|
||||||
volumes:
|
|
||||||
- cameleer-bootstrapdata:/data/bootstrap:ro
|
|
||||||
- cameleer-certs:/certs
|
|
||||||
- ${DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock
|
|
||||||
group_add:
|
|
||||||
- "${DOCKER_GID:-0}"
|
|
||||||
networks:
|
|
||||||
- cameleer
|
|
||||||
- monitoring
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
cameleer-bootstrapdata:
|
|
||||||
|
|
||||||
networks:
|
|
||||||
monitoring:
|
|
||||||
name: cameleer-monitoring-noop
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
# Cameleer Server (standalone)
|
|
||||||
# Loaded in standalone deployment mode
|
|
||||||
|
|
||||||
services:
|
|
||||||
cameleer-traefik:
|
|
||||||
volumes:
|
|
||||||
- ./traefik-dynamic.yml:/etc/traefik/dynamic.yml:ro
|
|
||||||
|
|
||||||
cameleer-postgres:
|
|
||||||
image: postgres:16-alpine
|
|
||||||
environment:
|
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-cameleer}
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-cameleer} -d $${POSTGRES_DB:-cameleer}"]
|
|
||||||
|
|
||||||
cameleer-server:
|
|
||||||
image: ${SERVER_IMAGE:-gitea.siegeln.net/cameleer/cameleer-server}:${VERSION:-latest}
|
|
||||||
container_name: cameleer-server
|
|
||||||
restart: unless-stopped
|
|
||||||
depends_on:
|
|
||||||
cameleer-postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
environment:
|
|
||||||
CAMELEER_SERVER_TENANT_ID: default
|
|
||||||
SPRING_DATASOURCE_URL: jdbc:postgresql://cameleer-postgres:5432/${POSTGRES_DB:-cameleer}?currentSchema=tenant_default
|
|
||||||
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-cameleer}
|
|
||||||
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
|
|
||||||
CAMELEER_SERVER_CLICKHOUSE_URL: jdbc:clickhouse://cameleer-clickhouse:8123/cameleer
|
|
||||||
CAMELEER_SERVER_CLICKHOUSE_USERNAME: default
|
|
||||||
CAMELEER_SERVER_CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD}
|
|
||||||
CAMELEER_SERVER_SECURITY_BOOTSTRAPTOKEN: ${BOOTSTRAP_TOKEN:?BOOTSTRAP_TOKEN must be set in .env}
|
|
||||||
CAMELEER_SERVER_SECURITY_JWTSECRET: ${CAMELEER_SERVER_SECURITY_JWTSECRET:?CAMELEER_SERVER_SECURITY_JWTSECRET must be set in .env}
|
|
||||||
CAMELEER_SERVER_SECURITY_UIUSER: ${SERVER_ADMIN_USER:-admin}
|
|
||||||
CAMELEER_SERVER_SECURITY_UIPASSWORD: ${SERVER_ADMIN_PASS:?SERVER_ADMIN_PASS must be set in .env}
|
|
||||||
CAMELEER_SERVER_SECURITY_CORSALLOWEDORIGINS: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
|
|
||||||
CAMELEER_SERVER_RUNTIME_ENABLED: "true"
|
|
||||||
CAMELEER_SERVER_RUNTIME_SERVERURL: http://cameleer-server:8081
|
|
||||||
CAMELEER_SERVER_RUNTIME_ROUTINGDOMAIN: ${PUBLIC_HOST:-localhost}
|
|
||||||
CAMELEER_SERVER_RUNTIME_ROUTINGMODE: path
|
|
||||||
CAMELEER_SERVER_RUNTIME_JARSTORAGEPATH: /data/jars
|
|
||||||
CAMELEER_SERVER_RUNTIME_DOCKERNETWORK: cameleer-apps
|
|
||||||
CAMELEER_SERVER_RUNTIME_JARDOCKERVOLUME: cameleer-jars
|
|
||||||
CAMELEER_SERVER_RUNTIME_BASEIMAGE: gitea.siegeln.net/cameleer/cameleer-runtime-base:${VERSION:-latest}
|
|
||||||
labels:
|
|
||||||
- traefik.enable=true
|
|
||||||
- traefik.http.routers.server-api.rule=PathPrefix(`/api`)
|
|
||||||
- traefik.http.routers.server-api.entrypoints=websecure
|
|
||||||
- traefik.http.routers.server-api.tls=true
|
|
||||||
- traefik.http.services.server-api.loadbalancer.server.port=8081
|
|
||||||
- traefik.docker.network=cameleer-traefik
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "curl -sf http://localhost:8081/api/v1/health || exit 1"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 30
|
|
||||||
start_period: 30s
|
|
||||||
volumes:
|
|
||||||
- jars:/data/jars
|
|
||||||
- cameleer-certs:/certs:ro
|
|
||||||
- ${DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock
|
|
||||||
group_add:
|
|
||||||
- "${DOCKER_GID:-0}"
|
|
||||||
networks:
|
|
||||||
- cameleer
|
|
||||||
- cameleer-traefik
|
|
||||||
- cameleer-apps
|
|
||||||
- monitoring
|
|
||||||
|
|
||||||
cameleer-server-ui:
|
|
||||||
image: ${SERVER_UI_IMAGE:-gitea.siegeln.net/cameleer/cameleer-server-ui}:${VERSION:-latest}
|
|
||||||
restart: unless-stopped
|
|
||||||
depends_on:
|
|
||||||
cameleer-server:
|
|
||||||
condition: service_healthy
|
|
||||||
environment:
|
|
||||||
CAMELEER_API_URL: http://cameleer-server:8081
|
|
||||||
BASE_PATH: ""
|
|
||||||
labels:
|
|
||||||
- traefik.enable=true
|
|
||||||
- traefik.http.routers.ui.rule=PathPrefix(`/`)
|
|
||||||
- traefik.http.routers.ui.priority=1
|
|
||||||
- traefik.http.routers.ui.entrypoints=websecure
|
|
||||||
- traefik.http.routers.ui.tls=true
|
|
||||||
- traefik.http.services.ui.loadbalancer.server.port=80
|
|
||||||
- traefik.docker.network=cameleer-traefik
|
|
||||||
networks:
|
|
||||||
- cameleer-traefik
|
|
||||||
- monitoring
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
jars:
|
|
||||||
name: cameleer-jars
|
|
||||||
|
|
||||||
networks:
|
|
||||||
cameleer-apps:
|
|
||||||
name: cameleer-apps
|
|
||||||
driver: bridge
|
|
||||||
monitoring:
|
|
||||||
name: cameleer-monitoring-noop
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
# Custom TLS certificates overlay
|
|
||||||
# Adds user-supplied certificate volume to traefik
|
|
||||||
|
|
||||||
services:
|
|
||||||
cameleer-traefik:
|
|
||||||
volumes:
|
|
||||||
- ./certs:/user-certs:ro
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
# Cameleer Infrastructure
|
|
||||||
# Shared base — always loaded. Mode-specific services in separate compose files.
|
|
||||||
|
|
||||||
services:
|
|
||||||
cameleer-traefik:
|
|
||||||
image: ${TRAEFIK_IMAGE:-gitea.siegeln.net/cameleer/cameleer-traefik}:${VERSION:-latest}
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "${HTTP_PORT:-80}:80"
|
|
||||||
- "${HTTPS_PORT:-443}:443"
|
|
||||||
- "${LOGTO_CONSOLE_BIND:-127.0.0.1}:${LOGTO_CONSOLE_PORT:-3002}:3002"
|
|
||||||
environment:
|
|
||||||
PUBLIC_HOST: ${PUBLIC_HOST:-localhost}
|
|
||||||
CERT_FILE: ${CERT_FILE:-}
|
|
||||||
KEY_FILE: ${KEY_FILE:-}
|
|
||||||
CA_FILE: ${CA_FILE:-}
|
|
||||||
volumes:
|
|
||||||
- cameleer-certs:/certs
|
|
||||||
- ${DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock:ro
|
|
||||||
labels:
|
|
||||||
- "prometheus.io/scrape=true"
|
|
||||||
- "prometheus.io/port=8082"
|
|
||||||
- "prometheus.io/path=/metrics"
|
|
||||||
networks:
|
|
||||||
- cameleer
|
|
||||||
- cameleer-traefik
|
|
||||||
- monitoring
|
|
||||||
|
|
||||||
cameleer-postgres:
|
|
||||||
image: ${POSTGRES_IMAGE:-gitea.siegeln.net/cameleer/cameleer-postgres}:${VERSION:-latest}
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-cameleer_saas}
|
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-cameleer}
|
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set in .env}
|
|
||||||
volumes:
|
|
||||||
- cameleer-pgdata:/var/lib/postgresql/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-cameleer} -d $${POSTGRES_DB:-cameleer_saas}"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
networks:
|
|
||||||
- cameleer
|
|
||||||
- monitoring
|
|
||||||
|
|
||||||
cameleer-clickhouse:
|
|
||||||
image: ${CLICKHOUSE_IMAGE:-gitea.siegeln.net/cameleer/cameleer-clickhouse}:${VERSION:-latest}
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:?CLICKHOUSE_PASSWORD must be set in .env}
|
|
||||||
volumes:
|
|
||||||
- cameleer-chdata:/var/lib/clickhouse
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "clickhouse-client --password $${CLICKHOUSE_PASSWORD} --query 'SELECT 1'"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 3
|
|
||||||
labels:
|
|
||||||
- "prometheus.io/scrape=true"
|
|
||||||
- "prometheus.io/port=9363"
|
|
||||||
- "prometheus.io/path=/metrics"
|
|
||||||
networks:
|
|
||||||
- cameleer
|
|
||||||
- monitoring
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
cameleer-pgdata:
|
|
||||||
cameleer-chdata:
|
|
||||||
cameleer-certs:
|
|
||||||
|
|
||||||
networks:
|
|
||||||
cameleer:
|
|
||||||
driver: bridge
|
|
||||||
cameleer-traefik:
|
|
||||||
name: cameleer-traefik
|
|
||||||
driver: bridge
|
|
||||||
monitoring:
|
|
||||||
name: cameleer-monitoring-noop
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
tls:
|
|
||||||
stores:
|
|
||||||
default:
|
|
||||||
defaultCertificate:
|
|
||||||
certFile: /certs/cert.pem
|
|
||||||
keyFile: /certs/key.pem
|
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
# Auth & Security Config
|
# Auth & Security Config
|
||||||
|
|
||||||
|
## User identity
|
||||||
|
|
||||||
|
**Email is the primary user identity** in SaaS mode. All users must have an email address — Logto enforces this via `signUp.identifiers: ["email"]` when registration is enabled. The bootstrap creates the admin user with `primaryEmail` set to `SAAS_ADMIN_EMAIL` (defaults to `<SAAS_ADMIN_USER>@<PUBLIC_HOST>`). Self-service registration requires email verification via a configured email connector (vendor UI at `/vendor/email`).
|
||||||
|
|
||||||
## Auth enforcement
|
## Auth enforcement
|
||||||
|
|
||||||
- All API endpoints enforce OAuth2 scopes via `@PreAuthorize("hasAuthority('SCOPE_xxx')")` annotations
|
- All API endpoints enforce OAuth2 scopes via `@PreAuthorize("hasAuthority('SCOPE_xxx')")` annotations
|
||||||
@@ -18,10 +22,13 @@
|
|||||||
| SaaS admin | `saas-vendor` (global) | `platform:admin` | `/vendor/tenants` |
|
| SaaS admin | `saas-vendor` (global) | `platform:admin` | `/vendor/tenants` |
|
||||||
| Tenant admin | org `owner` | `tenant:manage` | `/tenant` (dashboard) |
|
| Tenant admin | org `owner` | `tenant:manage` | `/tenant` (dashboard) |
|
||||||
| Regular user (operator/viewer) | org member | `server:operator` or `server:viewer` | Redirected to server dashboard directly |
|
| Regular user (operator/viewer) | org member | `server:operator` or `server:viewer` | Redirected to server dashboard directly |
|
||||||
|
| New user (just registered) | none (authenticated only) | none | `/onboarding` (self-service tenant creation) |
|
||||||
|
|
||||||
- `LandingRedirect` component waits for scopes to load, then routes to the correct persona landing page
|
- `LandingRedirect` component waits for scopes to load, then routes to the correct persona landing page. If user has zero organizations, redirects to `/onboarding`.
|
||||||
- `RequireScope` guard on route groups enforces scope requirements
|
- `RequireScope` guard on route groups enforces scope requirements
|
||||||
- SSO bridge: Logto session carries over to provisioned server's OIDC flow (Traditional Web App per tenant)
|
- SSO bridge: Logto session carries over to provisioned server's OIDC flow (Traditional Web App per tenant)
|
||||||
|
- Self-service sign-up flow: `/platform/register` → Logto OIDC with `firstScreen: 'register'` → custom sign-in UI (email + password + verification code) → callback → `LandingRedirect` → `/onboarding` → `POST /api/onboarding/tenant` → tenant provisioned, user added as org owner
|
||||||
|
- `OnboardingController` at `/api/onboarding/**` requires `authenticated()` only (no specific scope). `OnboardingService` enforces one trial tenant per user, reuses `VendorTenantService.createAndProvision()`, and adds the calling user to the Logto org as `owner`.
|
||||||
|
|
||||||
## Server OIDC role extraction (two paths)
|
## Server OIDC role extraction (two paths)
|
||||||
|
|
||||||
|
|||||||
@@ -43,10 +43,11 @@ public class SecurityConfig {
|
|||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
.requestMatchers("/actuator/health").permitAll()
|
.requestMatchers("/actuator/health").permitAll()
|
||||||
.requestMatchers("/api/config").permitAll()
|
.requestMatchers("/api/config").permitAll()
|
||||||
.requestMatchers("/", "/index.html", "/login", "/callback",
|
.requestMatchers("/", "/index.html", "/login", "/register", "/callback",
|
||||||
"/vendor/**", "/tenant/**",
|
"/vendor/**", "/tenant/**", "/onboarding",
|
||||||
"/environments/**", "/license", "/admin/**").permitAll()
|
"/environments/**", "/license", "/admin/**").permitAll()
|
||||||
.requestMatchers("/_app/**", "/favicon.ico", "/favicon.svg", "/logo.svg", "/logo-dark.svg").permitAll()
|
.requestMatchers("/_app/**", "/favicon.ico", "/favicon.svg", "/logo.svg", "/logo-dark.svg").permitAll()
|
||||||
|
.requestMatchers("/api/onboarding/**").authenticated()
|
||||||
.requestMatchers("/api/vendor/**").hasAuthority("SCOPE_platform:admin")
|
.requestMatchers("/api/vendor/**").hasAuthority("SCOPE_platform:admin")
|
||||||
.requestMatchers("/api/tenant/**").authenticated()
|
.requestMatchers("/api/tenant/**").authenticated()
|
||||||
.anyRequest().authenticated()
|
.anyRequest().authenticated()
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
|||||||
public class SpaController {
|
public class SpaController {
|
||||||
|
|
||||||
@RequestMapping(value = {
|
@RequestMapping(value = {
|
||||||
"/", "/login", "/callback",
|
"/", "/login", "/register", "/callback", "/onboarding",
|
||||||
"/vendor/**", "/tenant/**"
|
"/vendor/**", "/tenant/**"
|
||||||
})
|
})
|
||||||
public String forward() {
|
public String forward() {
|
||||||
|
|||||||
@@ -398,6 +398,122 @@ public class LogtoManagementClient {
|
|||||||
.toBodilessEntity();
|
.toBodilessEntity();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Email Connector Management ---
|
||||||
|
|
||||||
|
/** List all connector factories available in Logto. */
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public List<Map<String, Object>> listConnectorFactories() {
|
||||||
|
if (!isAvailable()) return List.of();
|
||||||
|
try {
|
||||||
|
var resp = restClient.get()
|
||||||
|
.uri(config.getLogtoEndpoint() + "/api/connector-factories")
|
||||||
|
.header("Authorization", "Bearer " + getAccessToken())
|
||||||
|
.retrieve()
|
||||||
|
.body(List.class);
|
||||||
|
return resp != null ? resp : List.of();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to list connector factories: {}", e.getMessage());
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List all connectors. */
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public List<Map<String, Object>> listConnectors() {
|
||||||
|
if (!isAvailable()) return List.of();
|
||||||
|
try {
|
||||||
|
var resp = restClient.get()
|
||||||
|
.uri(config.getLogtoEndpoint() + "/api/connectors")
|
||||||
|
.header("Authorization", "Bearer " + getAccessToken())
|
||||||
|
.retrieve()
|
||||||
|
.body(List.class);
|
||||||
|
return resp != null ? resp : List.of();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to list connectors: {}", e.getMessage());
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a connector from a factory. */
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public Map<String, Object> createConnector(String factoryId, Map<String, Object> connectorConfig) {
|
||||||
|
if (!isAvailable()) return null;
|
||||||
|
var body = new java.util.HashMap<String, Object>();
|
||||||
|
body.put("connectorId", factoryId);
|
||||||
|
body.put("config", connectorConfig);
|
||||||
|
return (Map<String, Object>) restClient.post()
|
||||||
|
.uri(config.getLogtoEndpoint() + "/api/connectors")
|
||||||
|
.header("Authorization", "Bearer " + getAccessToken())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(body)
|
||||||
|
.retrieve()
|
||||||
|
.body(Map.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update an existing connector's config. */
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public Map<String, Object> updateConnector(String connectorId, Map<String, Object> connectorConfig) {
|
||||||
|
if (!isAvailable()) return null;
|
||||||
|
return (Map<String, Object>) restClient.patch()
|
||||||
|
.uri(config.getLogtoEndpoint() + "/api/connectors/" + connectorId)
|
||||||
|
.header("Authorization", "Bearer " + getAccessToken())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(Map.of("config", connectorConfig))
|
||||||
|
.retrieve()
|
||||||
|
.body(Map.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete a connector. */
|
||||||
|
public void deleteConnector(String connectorId) {
|
||||||
|
if (!isAvailable()) return;
|
||||||
|
restClient.delete()
|
||||||
|
.uri(config.getLogtoEndpoint() + "/api/connectors/" + connectorId)
|
||||||
|
.header("Authorization", "Bearer " + getAccessToken())
|
||||||
|
.retrieve()
|
||||||
|
.toBodilessEntity();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test a connector by sending a real email. Uses Logto's built-in test endpoint. */
|
||||||
|
public void testConnector(String factoryId, String email, Map<String, Object> connectorConfig) {
|
||||||
|
if (!isAvailable()) throw new IllegalStateException("Logto not configured");
|
||||||
|
restClient.post()
|
||||||
|
.uri(config.getLogtoEndpoint() + "/api/connectors/" + factoryId + "/test")
|
||||||
|
.header("Authorization", "Bearer " + getAccessToken())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(Map.of("email", email, "config", connectorConfig))
|
||||||
|
.retrieve()
|
||||||
|
.toBodilessEntity();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the current sign-in experience config. */
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public Map<String, Object> getSignInExperience() {
|
||||||
|
if (!isAvailable()) return null;
|
||||||
|
try {
|
||||||
|
return (Map<String, Object>) restClient.get()
|
||||||
|
.uri(config.getLogtoEndpoint() + "/api/sign-in-exp")
|
||||||
|
.header("Authorization", "Bearer " + getAccessToken())
|
||||||
|
.retrieve()
|
||||||
|
.body(Map.class);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to get sign-in experience: {}", e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update the sign-in experience config (partial update). */
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public Map<String, Object> updateSignInExperience(Map<String, Object> updates) {
|
||||||
|
if (!isAvailable()) return null;
|
||||||
|
return (Map<String, Object>) restClient.patch()
|
||||||
|
.uri(config.getLogtoEndpoint() + "/api/sign-in-exp")
|
||||||
|
.header("Authorization", "Bearer " + getAccessToken())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(updates)
|
||||||
|
.retrieve()
|
||||||
|
.body(Map.class);
|
||||||
|
}
|
||||||
|
|
||||||
/** Update a user's password. */
|
/** Update a user's password. */
|
||||||
public void updateUserPassword(String userId, String newPassword) {
|
public void updateUserPassword(String userId, String newPassword) {
|
||||||
if (!isAvailable()) throw new IllegalStateException("Logto not configured");
|
if (!isAvailable()) throw new IllegalStateException("Logto not configured");
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package net.siegeln.cameleer.saas.onboarding;
|
||||||
|
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import net.siegeln.cameleer.saas.tenant.TenantEntity;
|
||||||
|
import net.siegeln.cameleer.saas.tenant.dto.TenantResponse;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.security.oauth2.jwt.Jwt;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/onboarding")
|
||||||
|
public class OnboardingController {
|
||||||
|
|
||||||
|
private final OnboardingService onboardingService;
|
||||||
|
|
||||||
|
public OnboardingController(OnboardingService onboardingService) {
|
||||||
|
this.onboardingService = onboardingService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CreateTrialTenantRequest(
|
||||||
|
@jakarta.validation.constraints.NotBlank
|
||||||
|
@jakarta.validation.constraints.Size(max = 255)
|
||||||
|
String name,
|
||||||
|
|
||||||
|
@jakarta.validation.constraints.NotBlank
|
||||||
|
@jakarta.validation.constraints.Size(max = 100)
|
||||||
|
@jakarta.validation.constraints.Pattern(
|
||||||
|
regexp = "^[a-z0-9][a-z0-9-]*[a-z0-9]$",
|
||||||
|
message = "Slug must be lowercase alphanumeric with hyphens")
|
||||||
|
String slug
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@PostMapping("/tenant")
|
||||||
|
public ResponseEntity<TenantResponse> createTrialTenant(
|
||||||
|
@Valid @RequestBody CreateTrialTenantRequest request,
|
||||||
|
@AuthenticationPrincipal Jwt jwt) {
|
||||||
|
|
||||||
|
String userId = jwt.getSubject();
|
||||||
|
TenantEntity tenant = onboardingService.createTrialTenant(request.name(), request.slug(), userId);
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(TenantResponse.from(tenant));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package net.siegeln.cameleer.saas.onboarding;
|
||||||
|
|
||||||
|
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
|
||||||
|
import net.siegeln.cameleer.saas.tenant.TenantEntity;
|
||||||
|
import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest;
|
||||||
|
import net.siegeln.cameleer.saas.vendor.VendorTenantService;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Self-service onboarding: lets a newly registered user create their own trial tenant.
|
||||||
|
* Reuses VendorTenantService for the heavy lifting (Logto org, license, Docker provisioning)
|
||||||
|
* but adds the calling user as the tenant owner instead of creating a new admin user.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class OnboardingService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(OnboardingService.class);
|
||||||
|
|
||||||
|
private final VendorTenantService vendorTenantService;
|
||||||
|
private final LogtoManagementClient logtoClient;
|
||||||
|
|
||||||
|
public OnboardingService(VendorTenantService vendorTenantService,
|
||||||
|
LogtoManagementClient logtoClient) {
|
||||||
|
this.vendorTenantService = vendorTenantService;
|
||||||
|
this.logtoClient = logtoClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TenantEntity createTrialTenant(String name, String slug, String logtoUserId) {
|
||||||
|
// Guard: check if user already has a tenant (prevent abuse)
|
||||||
|
if (logtoClient.isAvailable()) {
|
||||||
|
var orgs = logtoClient.getUserOrganizations(logtoUserId);
|
||||||
|
if (!orgs.isEmpty()) {
|
||||||
|
throw new IllegalStateException("You already have a tenant. Only one trial tenant per account.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create tenant via the existing vendor flow (no admin user — we'll add the caller)
|
||||||
|
UUID actorId = resolveActorId(logtoUserId);
|
||||||
|
var request = new CreateTenantRequest(name, slug, "LOW", null, null);
|
||||||
|
TenantEntity tenant = vendorTenantService.createAndProvision(request, actorId);
|
||||||
|
|
||||||
|
// Add the calling user to the Logto org as owner
|
||||||
|
if (tenant.getLogtoOrgId() != null && logtoClient.isAvailable()) {
|
||||||
|
try {
|
||||||
|
String ownerRoleId = logtoClient.findOrgRoleIdByName("owner");
|
||||||
|
logtoClient.addUserToOrganization(tenant.getLogtoOrgId(), logtoUserId);
|
||||||
|
if (ownerRoleId != null) {
|
||||||
|
logtoClient.assignOrganizationRole(tenant.getLogtoOrgId(), logtoUserId, ownerRoleId);
|
||||||
|
}
|
||||||
|
log.info("Added user {} as owner of tenant {}", logtoUserId, slug);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to add user {} to org for tenant {}: {}", logtoUserId, slug, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
private UUID resolveActorId(String subject) {
|
||||||
|
try {
|
||||||
|
return UUID.fromString(subject);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return UUID.nameUUIDFromBytes(subject.getBytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
114
src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorController.java
vendored
Normal file
114
src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorController.java
vendored
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
package net.siegeln.cameleer.saas.vendor;
|
||||||
|
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.Email;
|
||||||
|
import jakarta.validation.constraints.Max;
|
||||||
|
import jakarta.validation.constraints.Min;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/vendor/email-connector")
|
||||||
|
@PreAuthorize("hasAuthority('SCOPE_platform:admin')")
|
||||||
|
public class EmailConnectorController {
|
||||||
|
|
||||||
|
private final EmailConnectorService emailConnectorService;
|
||||||
|
|
||||||
|
public EmailConnectorController(EmailConnectorService emailConnectorService) {
|
||||||
|
this.emailConnectorService = emailConnectorService;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Request/Response types ---
|
||||||
|
|
||||||
|
public record SmtpConfigRequest(
|
||||||
|
@NotBlank String host,
|
||||||
|
@Min(1) @Max(65535) int port,
|
||||||
|
@NotBlank String username,
|
||||||
|
@NotBlank String password,
|
||||||
|
@NotBlank @Email String fromEmail,
|
||||||
|
Boolean registrationEnabled
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public record TestEmailRequest(
|
||||||
|
@NotBlank @Email String to
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public record EmailConnectorResponse(
|
||||||
|
String connectorId,
|
||||||
|
String factoryId,
|
||||||
|
String host,
|
||||||
|
int port,
|
||||||
|
String username,
|
||||||
|
String fromEmail,
|
||||||
|
boolean registrationEnabled
|
||||||
|
) {
|
||||||
|
static EmailConnectorResponse from(EmailConnectorService.EmailConnectorStatus status) {
|
||||||
|
return new EmailConnectorResponse(
|
||||||
|
status.connectorId(),
|
||||||
|
status.factoryId(),
|
||||||
|
status.host(),
|
||||||
|
status.port(),
|
||||||
|
status.username(),
|
||||||
|
status.fromEmail(),
|
||||||
|
status.registrationEnabled()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Endpoints ---
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<EmailConnectorResponse> get() {
|
||||||
|
var status = emailConnectorService.getEmailConnector();
|
||||||
|
if (status == null) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(EmailConnectorResponse.from(status));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<EmailConnectorResponse> save(@Valid @RequestBody SmtpConfigRequest request) {
|
||||||
|
var smtp = new EmailConnectorService.SmtpConfig(
|
||||||
|
request.host(), request.port(), request.username(),
|
||||||
|
request.password(), request.fromEmail()
|
||||||
|
);
|
||||||
|
var status = emailConnectorService.saveSmtpConnector(smtp, request.registrationEnabled());
|
||||||
|
return ResponseEntity.ok(EmailConnectorResponse.from(status));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping
|
||||||
|
public ResponseEntity<Void> delete() {
|
||||||
|
emailConnectorService.deleteEmailConnector();
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/test")
|
||||||
|
public ResponseEntity<Map<String, String>> test(@Valid @RequestBody TestEmailRequest request) {
|
||||||
|
try {
|
||||||
|
emailConnectorService.sendTestEmail(request.to());
|
||||||
|
return ResponseEntity.ok(Map.of("status", "sent", "message", "Test email sent to " + request.to()));
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("status", "failed", "message", e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/registration")
|
||||||
|
public ResponseEntity<Void> toggleRegistration(@RequestBody Map<String, Boolean> body) {
|
||||||
|
boolean enabled = body.getOrDefault("enabled", false);
|
||||||
|
var existing = emailConnectorService.getEmailConnector();
|
||||||
|
if (existing == null && enabled) {
|
||||||
|
return ResponseEntity.badRequest().build();
|
||||||
|
}
|
||||||
|
emailConnectorService.setRegistrationEnabled(enabled);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
191
src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorService.java
vendored
Normal file
191
src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorService.java
vendored
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
package net.siegeln.cameleer.saas.vendor;
|
||||||
|
|
||||||
|
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class EmailConnectorService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(EmailConnectorService.class);
|
||||||
|
private static final String SMTP_FACTORY_ID = "simple-mail-transfer-protocol";
|
||||||
|
|
||||||
|
private final LogtoManagementClient logtoClient;
|
||||||
|
|
||||||
|
public EmailConnectorService(LogtoManagementClient logtoClient) {
|
||||||
|
this.logtoClient = logtoClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record SmtpConfig(String host, int port, String username, String password, String fromEmail) {}
|
||||||
|
|
||||||
|
public record EmailConnectorStatus(
|
||||||
|
String connectorId,
|
||||||
|
String factoryId,
|
||||||
|
String host,
|
||||||
|
int port,
|
||||||
|
String username,
|
||||||
|
String fromEmail,
|
||||||
|
boolean registrationEnabled
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Get the current email connector config, or null if none is configured. */
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public EmailConnectorStatus getEmailConnector() {
|
||||||
|
var connectors = logtoClient.listConnectors();
|
||||||
|
var emailConnector = connectors.stream()
|
||||||
|
.filter(c -> "Email".equals(c.get("type")))
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
|
||||||
|
if (emailConnector == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var config = (Map<String, Object>) emailConnector.getOrDefault("config", Map.of());
|
||||||
|
var auth = (Map<String, Object>) config.getOrDefault("auth", Map.of());
|
||||||
|
String host = String.valueOf(config.getOrDefault("host", ""));
|
||||||
|
int port = config.containsKey("port") ? ((Number) config.get("port")).intValue() : 587;
|
||||||
|
String username = String.valueOf(auth.getOrDefault("user", ""));
|
||||||
|
String fromEmail = String.valueOf(config.getOrDefault("fromEmail", ""));
|
||||||
|
|
||||||
|
boolean registrationEnabled = isRegistrationEnabled();
|
||||||
|
|
||||||
|
return new EmailConnectorStatus(
|
||||||
|
String.valueOf(emailConnector.get("id")),
|
||||||
|
String.valueOf(emailConnector.get("connectorId")),
|
||||||
|
host, port, username, fromEmail, registrationEnabled
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create or update the SMTP email connector. Returns the connector status. */
|
||||||
|
public EmailConnectorStatus saveSmtpConnector(SmtpConfig smtp, Boolean registrationEnabled) {
|
||||||
|
var connectorConfig = buildSmtpConfig(smtp);
|
||||||
|
|
||||||
|
// Check if an email connector already exists
|
||||||
|
var existing = getEmailConnector();
|
||||||
|
if (existing != null) {
|
||||||
|
logtoClient.updateConnector(existing.connectorId(), connectorConfig);
|
||||||
|
log.info("Updated SMTP email connector: {}", existing.connectorId());
|
||||||
|
} else {
|
||||||
|
var result = logtoClient.createConnector(SMTP_FACTORY_ID, connectorConfig);
|
||||||
|
log.info("Created SMTP email connector: {}", result != null ? result.get("id") : "unknown");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle registration toggle
|
||||||
|
boolean enableReg = registrationEnabled != null ? registrationEnabled : (existing == null);
|
||||||
|
setRegistrationEnabled(enableReg);
|
||||||
|
|
||||||
|
return getEmailConnector();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete the email connector and disable registration. */
|
||||||
|
public void deleteEmailConnector() {
|
||||||
|
var existing = getEmailConnector();
|
||||||
|
if (existing != null) {
|
||||||
|
logtoClient.deleteConnector(existing.connectorId());
|
||||||
|
setRegistrationEnabled(false);
|
||||||
|
log.info("Deleted email connector: {}", existing.connectorId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send a test email through the configured connector. */
|
||||||
|
public void sendTestEmail(String toEmail) {
|
||||||
|
var existing = getEmailConnector();
|
||||||
|
if (existing == null) {
|
||||||
|
throw new IllegalStateException("No email connector configured");
|
||||||
|
}
|
||||||
|
// Re-read the full config from Logto to pass to the test endpoint
|
||||||
|
var connectors = logtoClient.listConnectors();
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
var emailConnector = connectors.stream()
|
||||||
|
.filter(c -> "Email".equals(c.get("type")))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow(() -> new IllegalStateException("Email connector not found"));
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
var config = (Map<String, Object>) emailConnector.getOrDefault("config", Map.of());
|
||||||
|
logtoClient.testConnector(existing.factoryId(), toEmail, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set registration mode on the Logto sign-in experience. */
|
||||||
|
public void setRegistrationEnabled(boolean enabled) {
|
||||||
|
if (enabled) {
|
||||||
|
logtoClient.updateSignInExperience(Map.of(
|
||||||
|
"signInMode", "SignInAndRegister",
|
||||||
|
"signUp", Map.of(
|
||||||
|
"identifiers", List.of("email"),
|
||||||
|
"password", true,
|
||||||
|
"verify", true
|
||||||
|
),
|
||||||
|
"signIn", Map.of(
|
||||||
|
"methods", List.of(
|
||||||
|
Map.of("identifier", "email", "password", true, "verificationCode", false, "isPasswordPrimary", true),
|
||||||
|
Map.of("identifier", "username", "password", true, "verificationCode", false, "isPasswordPrimary", true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
logtoClient.updateSignInExperience(Map.of(
|
||||||
|
"signInMode", "SignIn",
|
||||||
|
"signUp", Map.of(
|
||||||
|
"identifiers", List.of("username"),
|
||||||
|
"password", true,
|
||||||
|
"verify", false
|
||||||
|
),
|
||||||
|
"signIn", Map.of(
|
||||||
|
"methods", List.of(
|
||||||
|
Map.of("identifier", "username", "password", true, "verificationCode", false, "isPasswordPrimary", true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if registration is currently enabled in Logto. */
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private boolean isRegistrationEnabled() {
|
||||||
|
var signInExp = logtoClient.getSignInExperience();
|
||||||
|
if (signInExp == null) return false;
|
||||||
|
return "SignInAndRegister".equals(signInExp.get("signInMode"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the Logto SMTP connector config with Cameleer-branded email templates. */
|
||||||
|
private Map<String, Object> buildSmtpConfig(SmtpConfig smtp) {
|
||||||
|
var config = new HashMap<String, Object>();
|
||||||
|
config.put("host", smtp.host());
|
||||||
|
config.put("port", smtp.port());
|
||||||
|
config.put("auth", Map.of("user", smtp.username(), "pass", smtp.password()));
|
||||||
|
config.put("fromEmail", smtp.fromEmail());
|
||||||
|
config.put("templates", List.of(
|
||||||
|
Map.of(
|
||||||
|
"usageType", "Register",
|
||||||
|
"contentType", "text/html",
|
||||||
|
"subject", "Verify your email for Cameleer",
|
||||||
|
"content", "<div style=\"font-family:sans-serif;max-width:480px;margin:0 auto;padding:24px\"><div style=\"text-align:center;margin-bottom:24px\"><span style=\"font-size:24px;font-weight:700;color:#C6820E\">Cameleer</span></div><p style=\"color:#333;font-size:15px;line-height:1.6\">Enter this code to verify your email and create your account:</p><div style=\"text-align:center;margin:24px 0\"><span style=\"font-size:32px;font-weight:700;letter-spacing:6px;color:#C6820E\">{{code}}</span></div><p style=\"color:#666;font-size:13px\">This code expires in 10 minutes. If you did not request this, you can safely ignore this email.</p></div>"
|
||||||
|
),
|
||||||
|
Map.of(
|
||||||
|
"usageType", "SignIn",
|
||||||
|
"contentType", "text/html",
|
||||||
|
"subject", "Your Cameleer sign-in code",
|
||||||
|
"content", "<div style=\"font-family:sans-serif;max-width:480px;margin:0 auto;padding:24px\"><div style=\"text-align:center;margin-bottom:24px\"><span style=\"font-size:24px;font-weight:700;color:#C6820E\">Cameleer</span></div><p style=\"color:#333;font-size:15px;line-height:1.6\">Your sign-in verification code:</p><div style=\"text-align:center;margin:24px 0\"><span style=\"font-size:32px;font-weight:700;letter-spacing:6px;color:#C6820E\">{{code}}</span></div><p style=\"color:#666;font-size:13px\">This code expires in 10 minutes.</p></div>"
|
||||||
|
),
|
||||||
|
Map.of(
|
||||||
|
"usageType", "ForgotPassword",
|
||||||
|
"contentType", "text/html",
|
||||||
|
"subject", "Reset your Cameleer password",
|
||||||
|
"content", "<div style=\"font-family:sans-serif;max-width:480px;margin:0 auto;padding:24px\"><div style=\"text-align:center;margin-bottom:24px\"><span style=\"font-size:24px;font-weight:700;color:#C6820E\">Cameleer</span></div><p style=\"color:#333;font-size:15px;line-height:1.6\">Enter this code to reset your password:</p><div style=\"text-align:center;margin:24px 0\"><span style=\"font-size:32px;font-weight:700;letter-spacing:6px;color:#C6820E\">{{code}}</span></div><p style=\"color:#666;font-size:13px\">This code expires in 10 minutes. If you did not request a password reset, you can safely ignore this email.</p></div>"
|
||||||
|
),
|
||||||
|
Map.of(
|
||||||
|
"usageType", "Generic",
|
||||||
|
"contentType", "text/html",
|
||||||
|
"subject", "Your Cameleer verification code",
|
||||||
|
"content", "<div style=\"font-family:sans-serif;max-width:480px;margin:0 auto;padding:24px\"><div style=\"text-align:center;margin-bottom:24px\"><span style=\"font-size:24px;font-weight:700;color:#C6820E\">Cameleer</span></div><p style=\"color:#333;font-size:15px;line-height:1.6\">Your verification code:</p><div style=\"text-align:center;margin:24px 0\"><span style=\"font-size:32px;font-weight:700;letter-spacing:6px;color:#C6820E\">{{code}}</span></div><p style=\"color:#666;font-size:13px\">This code expires in 10 minutes.</p></div>"
|
||||||
|
)
|
||||||
|
));
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ spring:
|
|||||||
oauth2:
|
oauth2:
|
||||||
resourceserver:
|
resourceserver:
|
||||||
jwt:
|
jwt:
|
||||||
issuer-uri: ${cameleer.saas.provisioning.publicprotocol:https}://${cameleer.saas.provisioning.publichost:localhost}/oidc
|
issuer-uri: ${cameleer.saas.provisioning.publicprotocol:https}://${cameleer.saas.identity.authhost:localhost}/oidc
|
||||||
jwk-set-uri: ${cameleer.saas.identity.logtoendpoint:http://cameleer-logto:3001}/oidc/jwks
|
jwk-set-uri: ${cameleer.saas.identity.logtoendpoint:http://cameleer-logto:3001}/oidc/jwks
|
||||||
|
|
||||||
management:
|
management:
|
||||||
@@ -35,6 +35,7 @@ management:
|
|||||||
cameleer:
|
cameleer:
|
||||||
saas:
|
saas:
|
||||||
identity:
|
identity:
|
||||||
|
authhost: ${CAMELEER_SAAS_IDENTITY_AUTHHOST:${cameleer.saas.provisioning.publichost:localhost}}
|
||||||
logtoendpoint: ${CAMELEER_SAAS_IDENTITY_LOGTOENDPOINT:}
|
logtoendpoint: ${CAMELEER_SAAS_IDENTITY_LOGTOENDPOINT:}
|
||||||
logtopublicendpoint: ${CAMELEER_SAAS_IDENTITY_LOGTOPUBLICENDPOINT:}
|
logtopublicendpoint: ${CAMELEER_SAAS_IDENTITY_LOGTOPUBLICENDPOINT:}
|
||||||
m2mclientid: ${CAMELEER_SAAS_IDENTITY_M2MCLIENTID:}
|
m2mclientid: ${CAMELEER_SAAS_IDENTITY_M2MCLIENTID:}
|
||||||
@@ -43,9 +44,9 @@ cameleer:
|
|||||||
audience: ${CAMELEER_SAAS_IDENTITY_AUDIENCE:https://api.cameleer.local}
|
audience: ${CAMELEER_SAAS_IDENTITY_AUDIENCE:https://api.cameleer.local}
|
||||||
serverendpoint: ${CAMELEER_SAAS_IDENTITY_SERVERENDPOINT:http://cameleer-server:8081}
|
serverendpoint: ${CAMELEER_SAAS_IDENTITY_SERVERENDPOINT:http://cameleer-server:8081}
|
||||||
provisioning:
|
provisioning:
|
||||||
serverimage: ${CAMELEER_SAAS_PROVISIONING_SERVERIMAGE:gitea.siegeln.net/cameleer/cameleer-server:latest}
|
serverimage: ${CAMELEER_SAAS_PROVISIONING_SERVERIMAGE:registry.cameleer.io/cameleer/cameleer-server:latest}
|
||||||
serveruiimage: ${CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE:gitea.siegeln.net/cameleer/cameleer-server-ui:latest}
|
serveruiimage: ${CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE:registry.cameleer.io/cameleer/cameleer-server-ui:latest}
|
||||||
runtimebaseimage: ${CAMELEER_SAAS_PROVISIONING_RUNTIMEBASEIMAGE:gitea.siegeln.net/cameleer/cameleer-runtime-base:latest}
|
runtimebaseimage: ${CAMELEER_SAAS_PROVISIONING_RUNTIMEBASEIMAGE:registry.cameleer.io/cameleer/cameleer-runtime-base:latest}
|
||||||
networkname: ${CAMELEER_SAAS_PROVISIONING_NETWORKNAME:cameleer-saas_cameleer}
|
networkname: ${CAMELEER_SAAS_PROVISIONING_NETWORKNAME:cameleer-saas_cameleer}
|
||||||
traefiknetwork: ${CAMELEER_SAAS_PROVISIONING_TRAEFIKNETWORK:cameleer-traefik}
|
traefiknetwork: ${CAMELEER_SAAS_PROVISIONING_TRAEFIKNETWORK:cameleer-traefik}
|
||||||
publichost: ${CAMELEER_SAAS_PROVISIONING_PUBLICHOST:localhost}
|
publichost: ${CAMELEER_SAAS_PROVISIONING_PUBLICHOST:localhost}
|
||||||
@@ -56,7 +57,7 @@ cameleer:
|
|||||||
clickhouseurl: ${CAMELEER_SAAS_PROVISIONING_CLICKHOUSEURL:jdbc:clickhouse://cameleer-clickhouse:8123/cameleer}
|
clickhouseurl: ${CAMELEER_SAAS_PROVISIONING_CLICKHOUSEURL:jdbc:clickhouse://cameleer-clickhouse:8123/cameleer}
|
||||||
clickhouseuser: ${CAMELEER_SAAS_PROVISIONING_CLICKHOUSEUSER:default}
|
clickhouseuser: ${CAMELEER_SAAS_PROVISIONING_CLICKHOUSEUSER:default}
|
||||||
clickhousepassword: ${CAMELEER_SAAS_PROVISIONING_CLICKHOUSEPASSWORD:${CLICKHOUSE_PASSWORD:cameleer_ch}}
|
clickhousepassword: ${CAMELEER_SAAS_PROVISIONING_CLICKHOUSEPASSWORD:${CLICKHOUSE_PASSWORD:cameleer_ch}}
|
||||||
oidcissueruri: ${cameleer.saas.provisioning.publicprotocol}://${cameleer.saas.provisioning.publichost}/oidc
|
oidcissueruri: ${cameleer.saas.provisioning.publicprotocol}://${cameleer.saas.identity.authhost}/oidc
|
||||||
oidcjwkseturi: http://cameleer-logto:3001/oidc/jwks
|
oidcjwkseturi: http://cameleer-logto:3001/oidc/jwks
|
||||||
corsorigins: ${cameleer.saas.provisioning.publicprotocol}://${cameleer.saas.provisioning.publichost}
|
corsorigins: ${cameleer.saas.provisioning.publicprotocol}://${cameleer.saas.provisioning.publichost}
|
||||||
certs:
|
certs:
|
||||||
|
|||||||
13
ui/CLAUDE.md
13
ui/CLAUDE.md
@@ -5,8 +5,8 @@ React 19 SPA served at `/platform/*` by the Spring Boot backend.
|
|||||||
## Core files
|
## Core files
|
||||||
|
|
||||||
- `main.tsx` — React 19 root
|
- `main.tsx` — React 19 root
|
||||||
- `router.tsx` — `/vendor/*` + `/tenant/*` with `RequireScope` guards and `LandingRedirect` that waits for scopes
|
- `router.tsx` — `/vendor/*` + `/tenant/*` with `RequireScope` guards, `LandingRedirect` that waits for scopes (redirects to `/onboarding` if user has zero orgs), `/register` route for OIDC sign-up flow, `/onboarding` route for self-service tenant creation
|
||||||
- `Layout.tsx` — persona-aware sidebar: vendor sees expandable "Vendor" section (Tenants, Audit Log, Certificates, Infrastructure, Identity/Logto), tenant admin sees Dashboard/License/SSO/Team/Audit/Settings
|
- `Layout.tsx` — persona-aware sidebar: vendor sees expandable "Vendor" section (Tenants, Audit Log, Certificates, Metrics, Infrastructure, Email Connector, Logto Console), tenant admin sees Dashboard/License/SSO/Team/Audit/Settings
|
||||||
- `OrgResolver.tsx` — merges global + org-scoped token scopes (vendor's platform:admin is global)
|
- `OrgResolver.tsx` — merges global + org-scoped token scopes (vendor's platform:admin is global)
|
||||||
- `config.ts` — fetch Logto config from /platform/api/config
|
- `config.ts` — fetch Logto config from /platform/api/config
|
||||||
|
|
||||||
@@ -16,15 +16,18 @@ React 19 SPA served at `/platform/*` by the Spring Boot backend.
|
|||||||
- `auth/useOrganization.ts` — Zustand store for current tenant
|
- `auth/useOrganization.ts` — Zustand store for current tenant
|
||||||
- `auth/useScopes.ts` — decode JWT scopes, hasScope()
|
- `auth/useScopes.ts` — decode JWT scopes, hasScope()
|
||||||
- `auth/ProtectedRoute.tsx` — guard (redirects to /login)
|
- `auth/ProtectedRoute.tsx` — guard (redirects to /login)
|
||||||
|
- `auth/LoginPage.tsx` — redirects to Logto OIDC sign-in
|
||||||
|
- `auth/RegisterPage.tsx` — redirects to Logto OIDC with `firstScreen: 'register'`
|
||||||
|
|
||||||
## Pages
|
## Pages
|
||||||
|
|
||||||
- **Vendor pages**: `VendorTenantsPage.tsx`, `CreateTenantPage.tsx`, `TenantDetailPage.tsx`, `VendorAuditPage.tsx`, `CertificatesPage.tsx`, `InfrastructurePage.tsx`
|
- **Onboarding**: `OnboardingPage.tsx` — self-service trial tenant creation (org name + slug), shown to users with zero org memberships after sign-up
|
||||||
|
- **Vendor pages**: `VendorTenantsPage.tsx`, `CreateTenantPage.tsx`, `TenantDetailPage.tsx`, `VendorAuditPage.tsx`, `CertificatesPage.tsx`, `InfrastructurePage.tsx`, `EmailConfigPage.tsx` (SMTP connector config, registration toggle, test email)
|
||||||
- **Tenant pages**: `TenantDashboardPage.tsx` (restart + upgrade server), `TenantLicensePage.tsx`, `SsoPage.tsx`, `TeamPage.tsx` (reset member passwords), `TenantAuditPage.tsx`, `SettingsPage.tsx` (change own password, reset server admin password)
|
- **Tenant pages**: `TenantDashboardPage.tsx` (restart + upgrade server), `TenantLicensePage.tsx`, `SsoPage.tsx`, `TeamPage.tsx` (reset member passwords), `TenantAuditPage.tsx`, `SettingsPage.tsx` (change own password, reset server admin password)
|
||||||
|
|
||||||
## Custom Sign-in UI (`ui/sign-in/`)
|
## Custom Sign-in UI (`ui/sign-in/`)
|
||||||
|
|
||||||
Separate Vite+React SPA replacing Logto's default sign-in page. Built as custom Logto Docker image — see `docker/CLAUDE.md` for details.
|
Separate Vite+React SPA replacing Logto's default sign-in page. Built as custom Logto Docker image — see `docker/CLAUDE.md` for details.
|
||||||
|
|
||||||
- `SignInPage.tsx` — form with @cameleer/design-system components
|
- `SignInPage.tsx` — sign-in + registration form with @cameleer/design-system components. Three modes: `signIn` (email/username + password), `register` (email + password + confirm), `verifyCode` (6-digit email verification). Reads `first_screen=register` from URL query params to determine initial view. Registration is disabled by default — the vendor admin enables it via the Email Connector page after configuring SMTP.
|
||||||
- `experience-api.ts` — Logto Experience API client (4-step: init -> verify -> identify -> submit)
|
- `experience-api.ts` — Logto Experience API client. Sign-in: init -> verify password -> identify -> submit. Registration: init Register -> send verification code -> verify code -> add password profile -> identify -> submit. Auto-detects email vs username identifiers.
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ FROM ghcr.io/logto-io/logto:latest
|
|||||||
# Install bootstrap dependencies (curl, jq for API calls; postgresql16-client for DB reads)
|
# Install bootstrap dependencies (curl, jq for API calls; postgresql16-client for DB reads)
|
||||||
RUN apk add --no-cache curl jq postgresql16-client
|
RUN apk add --no-cache curl jq postgresql16-client
|
||||||
|
|
||||||
|
# Install all official Logto connectors (email, SMS, social — configured at runtime via vendor UI)
|
||||||
|
RUN cd /etc/logto/packages/core && npm run cli connector add -- --official 2>/dev/null || true
|
||||||
|
|
||||||
# Custom sign-in UI
|
# Custom sign-in UI
|
||||||
COPY --from=build /ui/dist/ /etc/logto/packages/experience/dist/
|
COPY --from=build /ui/dist/ /etc/logto/packages/experience/dist/
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
padding: 32px;
|
padding: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loginForm {
|
.formContainer {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -53,6 +53,53 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.passwordWrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.passwordToggle {
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.submitButton {
|
.submitButton {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.switchText {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switchLink {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-link, #C6820E);
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 0;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switchLink:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verifyHint {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary, var(--text-muted));
|
||||||
|
margin: 0 0 4px;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { type FormEvent, useMemo, useState } from 'react';
|
import { type FormEvent, useEffect, useMemo, useState } from 'react';
|
||||||
import { Eye, EyeOff } from 'lucide-react';
|
import { Eye, EyeOff } from 'lucide-react';
|
||||||
import { Card, Input, Button, Alert, FormField } from '@cameleer/design-system';
|
import { Card, Input, Button, Alert, FormField } from '@cameleer/design-system';
|
||||||
import cameleerLogo from '@cameleer/design-system/assets/cameleer-logo.svg';
|
import cameleerLogo from '@cameleer/design-system/assets/cameleer-logo.svg';
|
||||||
import { signIn } from './experience-api';
|
import { signIn, startRegistration, completeRegistration } from './experience-api';
|
||||||
import styles from './SignInPage.module.css';
|
import styles from './SignInPage.module.css';
|
||||||
|
|
||||||
const SUBTITLES = [
|
type Mode = 'signIn' | 'register' | 'verifyCode';
|
||||||
|
|
||||||
|
const SIGN_IN_SUBTITLES = [
|
||||||
"Prove you're not a mirage",
|
"Prove you're not a mirage",
|
||||||
"Only authorized cameleers beyond this dune",
|
"Only authorized cameleers beyond this dune",
|
||||||
"Halt, traveler — state your business",
|
"Halt, traveler — state your business",
|
||||||
@@ -33,20 +35,78 @@ const SUBTITLES = [
|
|||||||
"No ticket, no caravan",
|
"No ticket, no caravan",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const REGISTER_SUBTITLES = [
|
||||||
|
"Every great journey starts with a single sign-up",
|
||||||
|
"Welcome to the caravan — let's get you registered",
|
||||||
|
"A new cameleer approaches the oasis",
|
||||||
|
"Join the caravan. We have dashboards.",
|
||||||
|
"The desert is better with company",
|
||||||
|
"First time here? The camels don't bite.",
|
||||||
|
"Pack your bags, you're joining the caravan",
|
||||||
|
"Room for one more on this caravan",
|
||||||
|
"New rider? Excellent. Credentials, please.",
|
||||||
|
"The Silk Road awaits — just need your email first",
|
||||||
|
];
|
||||||
|
|
||||||
|
function pickRandom(arr: string[]) {
|
||||||
|
return arr[Math.floor(Math.random() * arr.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitialMode(): Mode {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
if (params.get('first_screen') === 'register') return 'register';
|
||||||
|
if (window.location.pathname.endsWith('/register')) return 'register';
|
||||||
|
return 'signIn';
|
||||||
|
}
|
||||||
|
|
||||||
export function SignInPage() {
|
export function SignInPage() {
|
||||||
const subtitle = useMemo(() => SUBTITLES[Math.floor(Math.random() * SUBTITLES.length)], []);
|
const [mode, setMode] = useState<Mode>(getInitialMode);
|
||||||
const [username, setUsername] = useState('');
|
const [registrationEnabled, setRegistrationEnabled] = useState(true);
|
||||||
|
const subtitle = useMemo(
|
||||||
|
() => pickRandom(mode === 'signIn' ? SIGN_IN_SUBTITLES : REGISTER_SUBTITLES),
|
||||||
|
[mode === 'signIn' ? 'signIn' : 'register'],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [identifier, setIdentifier] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [code, setCode] = useState('');
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [verificationId, setVerificationId] = useState('');
|
||||||
|
|
||||||
const handleSubmit = async (e: FormEvent) => {
|
// Fetch sign-in experience to check if registration is enabled
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/.well-known/sign-in-exp')
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
const enabled = data.signInMode === 'SignInAndRegister';
|
||||||
|
setRegistrationEnabled(enabled);
|
||||||
|
if (!enabled && mode !== 'signIn') setMode('signIn');
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Reset error when switching modes
|
||||||
|
useEffect(() => { setError(null); }, [mode]);
|
||||||
|
|
||||||
|
const switchMode = (next: Mode) => {
|
||||||
|
setMode(next);
|
||||||
|
setPassword('');
|
||||||
|
setConfirmPassword('');
|
||||||
|
setCode('');
|
||||||
|
setShowPassword(false);
|
||||||
|
setVerificationId('');
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Sign-in ---
|
||||||
|
const handleSignIn = async (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const redirectTo = await signIn(username, password);
|
const redirectTo = await signIn(identifier, password);
|
||||||
window.location.replace(redirectTo);
|
window.location.replace(redirectTo);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Sign-in failed');
|
setError(err instanceof Error ? err.message : 'Sign-in failed');
|
||||||
@@ -54,10 +114,63 @@ export function SignInPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- Register step 1: send verification code ---
|
||||||
|
const handleRegister = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
if (!identifier.includes('@')) {
|
||||||
|
setError('Please enter a valid email address');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setError('Passwords do not match');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password.length < 8) {
|
||||||
|
setError('Password must be at least 8 characters');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const vId = await startRegistration(identifier);
|
||||||
|
setVerificationId(vId);
|
||||||
|
setMode('verifyCode');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Registration failed');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Register step 2: verify code + complete ---
|
||||||
|
const handleVerifyCode = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const redirectTo = await completeRegistration(identifier, password, verificationId, code);
|
||||||
|
window.location.replace(redirectTo);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Verification failed');
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const passwordToggle = (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className={styles.passwordToggle}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.page}>
|
<div className={styles.page}>
|
||||||
<Card className={styles.card}>
|
<Card className={styles.card}>
|
||||||
<div className={styles.loginForm}>
|
<div className={styles.formContainer}>
|
||||||
<div className={styles.logo}>
|
<div className={styles.logo}>
|
||||||
<img src={cameleerLogo} alt="" className={styles.logoImg} />
|
<img src={cameleerLogo} alt="" className={styles.logoImg} />
|
||||||
Cameleer
|
Cameleer
|
||||||
@@ -70,55 +183,158 @@ export function SignInPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form className={styles.fields} onSubmit={handleSubmit} aria-label="Sign in" noValidate>
|
{/* --- Sign-in form --- */}
|
||||||
<FormField label="Username" htmlFor="login-username">
|
{mode === 'signIn' && (
|
||||||
<Input
|
<form className={styles.fields} onSubmit={handleSignIn} aria-label="Sign in" noValidate>
|
||||||
id="login-username"
|
<FormField label="Email or username" htmlFor="login-identifier">
|
||||||
value={username}
|
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
|
||||||
placeholder="Enter your username"
|
|
||||||
autoFocus
|
|
||||||
autoComplete="username"
|
|
||||||
disabled={loading}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField label="Password" htmlFor="login-password">
|
|
||||||
<div style={{ position: 'relative' }}>
|
|
||||||
<Input
|
<Input
|
||||||
id="login-password"
|
id="login-identifier"
|
||||||
type={showPassword ? 'text' : 'password'}
|
type="email"
|
||||||
value={password}
|
value={identifier}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setIdentifier(e.target.value)}
|
||||||
placeholder="••••••••"
|
placeholder="you@company.com"
|
||||||
autoComplete="current-password"
|
autoFocus
|
||||||
|
autoComplete="username"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
<button
|
</FormField>
|
||||||
type="button"
|
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
|
||||||
style={{
|
|
||||||
position: 'absolute', right: 8, top: '50%', transform: 'translateY(-50%)',
|
|
||||||
background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)',
|
|
||||||
padding: 4, display: 'flex', alignItems: 'center',
|
|
||||||
}}
|
|
||||||
tabIndex={-1}
|
|
||||||
>
|
|
||||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<Button
|
<FormField label="Password" htmlFor="login-password">
|
||||||
variant="primary"
|
<div className={styles.passwordWrapper}>
|
||||||
type="submit"
|
<Input
|
||||||
loading={loading}
|
id="login-password"
|
||||||
disabled={loading || !username || !password}
|
type={showPassword ? 'text' : 'password'}
|
||||||
className={styles.submitButton}
|
value={password}
|
||||||
>
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
Sign in
|
placeholder="••••••••"
|
||||||
</Button>
|
autoComplete="current-password"
|
||||||
</form>
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
{passwordToggle}
|
||||||
|
</div>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
type="submit"
|
||||||
|
loading={loading}
|
||||||
|
disabled={loading || !identifier || !password}
|
||||||
|
className={styles.submitButton}
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{registrationEnabled && (
|
||||||
|
<p className={styles.switchText}>
|
||||||
|
Don't have an account?{' '}
|
||||||
|
<button type="button" className={styles.switchLink} onClick={() => switchMode('register')}>
|
||||||
|
Sign up
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* --- Register form --- */}
|
||||||
|
{mode === 'register' && (
|
||||||
|
<form className={styles.fields} onSubmit={handleRegister} aria-label="Create account" noValidate>
|
||||||
|
<FormField label="Email" htmlFor="register-email">
|
||||||
|
<Input
|
||||||
|
id="register-email"
|
||||||
|
type="email"
|
||||||
|
value={identifier}
|
||||||
|
onChange={(e) => setIdentifier(e.target.value)}
|
||||||
|
placeholder="you@company.com"
|
||||||
|
autoFocus
|
||||||
|
autoComplete="email"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Password" htmlFor="register-password">
|
||||||
|
<div className={styles.passwordWrapper}>
|
||||||
|
<Input
|
||||||
|
id="register-password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="At least 8 characters"
|
||||||
|
autoComplete="new-password"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
{passwordToggle}
|
||||||
|
</div>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Confirm password" htmlFor="register-confirm">
|
||||||
|
<Input
|
||||||
|
id="register-confirm"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
autoComplete="new-password"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
type="submit"
|
||||||
|
loading={loading}
|
||||||
|
disabled={loading || !identifier || !password || !confirmPassword}
|
||||||
|
className={styles.submitButton}
|
||||||
|
>
|
||||||
|
Create account
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<p className={styles.switchText}>
|
||||||
|
Already have an account?{' '}
|
||||||
|
<button type="button" className={styles.switchLink} onClick={() => switchMode('signIn')}>
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* --- Verification code form --- */}
|
||||||
|
{mode === 'verifyCode' && (
|
||||||
|
<form className={styles.fields} onSubmit={handleVerifyCode} aria-label="Verify email" noValidate>
|
||||||
|
<p className={styles.verifyHint}>
|
||||||
|
We sent a verification code to <strong>{identifier}</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<FormField label="Verification code" htmlFor="verify-code">
|
||||||
|
<Input
|
||||||
|
id="verify-code"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||||
|
placeholder="000000"
|
||||||
|
autoFocus
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
type="submit"
|
||||||
|
loading={loading}
|
||||||
|
disabled={loading || code.length < 6}
|
||||||
|
className={styles.submitButton}
|
||||||
|
>
|
||||||
|
Verify & create account
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<p className={styles.switchText}>
|
||||||
|
<button type="button" className={styles.switchLink} onClick={() => switchMode('register')}>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,32 +10,7 @@ async function request(method: string, path: string, body?: unknown): Promise<Re
|
|||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function initInteraction(): Promise<void> {
|
// --- Shared ---
|
||||||
const res = await request('PUT', '', { interactionEvent: 'SignIn' });
|
|
||||||
if (!res.ok) {
|
|
||||||
const err = await res.json().catch(() => ({}));
|
|
||||||
throw new Error(err.message || `Failed to initialize sign-in (${res.status})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function verifyPassword(
|
|
||||||
username: string,
|
|
||||||
password: string
|
|
||||||
): Promise<string> {
|
|
||||||
const res = await request('POST', '/verification/password', {
|
|
||||||
identifier: { type: 'username', value: username },
|
|
||||||
password,
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const err = await res.json().catch(() => ({}));
|
|
||||||
if (res.status === 422) {
|
|
||||||
throw new Error('Invalid username or password');
|
|
||||||
}
|
|
||||||
throw new Error(err.message || `Authentication failed (${res.status})`);
|
|
||||||
}
|
|
||||||
const data = await res.json();
|
|
||||||
return data.verificationId;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function identifyUser(verificationId: string): Promise<void> {
|
export async function identifyUser(verificationId: string): Promise<void> {
|
||||||
const res = await request('POST', '/identification', { verificationId });
|
const res = await request('POST', '/identification', { verificationId });
|
||||||
@@ -55,9 +30,117 @@ export async function submitInteraction(): Promise<string> {
|
|||||||
return data.redirectTo;
|
return data.redirectTo;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function signIn(username: string, password: string): Promise<string> {
|
// --- Sign-in ---
|
||||||
|
|
||||||
|
export async function initInteraction(): Promise<void> {
|
||||||
|
const res = await request('PUT', '', { interactionEvent: 'SignIn' });
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.message || `Failed to initialize sign-in (${res.status})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectIdentifierType(input: string): 'email' | 'username' {
|
||||||
|
return input.includes('@') ? 'email' : 'username';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPassword(
|
||||||
|
identifier: string,
|
||||||
|
password: string
|
||||||
|
): Promise<string> {
|
||||||
|
const type = detectIdentifierType(identifier);
|
||||||
|
const res = await request('POST', '/verification/password', {
|
||||||
|
identifier: { type, value: identifier },
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
if (res.status === 422) {
|
||||||
|
throw new Error('Invalid credentials');
|
||||||
|
}
|
||||||
|
throw new Error(err.message || `Authentication failed (${res.status})`);
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
return data.verificationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signIn(identifier: string, password: string): Promise<string> {
|
||||||
await initInteraction();
|
await initInteraction();
|
||||||
const verificationId = await verifyPassword(username, password);
|
const verificationId = await verifyPassword(identifier, password);
|
||||||
await identifyUser(verificationId);
|
await identifyUser(verificationId);
|
||||||
return submitInteraction();
|
return submitInteraction();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Registration ---
|
||||||
|
|
||||||
|
export async function initRegistration(): Promise<void> {
|
||||||
|
const res = await request('PUT', '', { interactionEvent: 'Register' });
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.message || `Failed to initialize registration (${res.status})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendVerificationCode(email: string): Promise<string> {
|
||||||
|
const res = await request('POST', '/verification/verification-code', {
|
||||||
|
identifier: { type: 'email', value: email },
|
||||||
|
interactionEvent: 'Register',
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
if (res.status === 422) {
|
||||||
|
throw new Error('This email is already registered');
|
||||||
|
}
|
||||||
|
throw new Error(err.message || `Failed to send verification code (${res.status})`);
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
return data.verificationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyCode(
|
||||||
|
email: string,
|
||||||
|
verificationId: string,
|
||||||
|
code: string
|
||||||
|
): Promise<string> {
|
||||||
|
const res = await request('POST', '/verification/verification-code/verify', {
|
||||||
|
identifier: { type: 'email', value: email },
|
||||||
|
verificationId,
|
||||||
|
code,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
if (res.status === 422) {
|
||||||
|
throw new Error('Invalid or expired verification code');
|
||||||
|
}
|
||||||
|
throw new Error(err.message || `Verification failed (${res.status})`);
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
return data.verificationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addProfile(type: string, value: string): Promise<void> {
|
||||||
|
const res = await request('POST', '/profile', { type, value });
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.message || `Failed to update profile (${res.status})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Phase 1: init registration + send verification email. Returns verificationId for phase 2. */
|
||||||
|
export async function startRegistration(email: string): Promise<string> {
|
||||||
|
await initRegistration();
|
||||||
|
return sendVerificationCode(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Phase 2: verify code, set password, create user, submit. Returns redirect URL. */
|
||||||
|
export async function completeRegistration(
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
verificationId: string,
|
||||||
|
code: string
|
||||||
|
): Promise<string> {
|
||||||
|
const verifiedId = await verifyCode(email, verificationId, code);
|
||||||
|
await addProfile('password', password);
|
||||||
|
await identifyUser(verifiedId);
|
||||||
|
return submitInteraction();
|
||||||
|
}
|
||||||
|
|||||||
70
ui/src/api/email-connector-hooks.ts
Normal file
70
ui/src/api/email-connector-hooks.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { api } from './client';
|
||||||
|
|
||||||
|
export interface EmailConnectorResponse {
|
||||||
|
connectorId: string;
|
||||||
|
factoryId: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
fromEmail: string;
|
||||||
|
registrationEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SmtpConfigRequest {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
fromEmail: string;
|
||||||
|
registrationEnabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestEmailResult {
|
||||||
|
status: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEmailConnector() {
|
||||||
|
return useQuery<EmailConnectorResponse | null>({
|
||||||
|
queryKey: ['vendor', 'email-connector'],
|
||||||
|
queryFn: async () => {
|
||||||
|
try {
|
||||||
|
return await api.get<EmailConnectorResponse>('/vendor/email-connector');
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error && e.message.includes('404')) return null;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSaveEmailConnector() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation<EmailConnectorResponse, Error, SmtpConfigRequest>({
|
||||||
|
mutationFn: (config) => api.post('/vendor/email-connector', config),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'email-connector'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteEmailConnector() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation<void, Error, void>({
|
||||||
|
mutationFn: () => api.delete('/vendor/email-connector'),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'email-connector'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTestEmailConnector() {
|
||||||
|
return useMutation<TestEmailResult, Error, string>({
|
||||||
|
mutationFn: (to) => api.post('/vendor/email-connector/test', { to }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToggleRegistration() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation<void, Error, boolean>({
|
||||||
|
mutationFn: (enabled) => api.post('/vendor/email-connector/registration', { enabled }),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'email-connector'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,13 +1,21 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useLogto } from '@logto/react';
|
import { useLogto } from '@logto/react';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { Spinner } from '@cameleer/design-system';
|
import { Button, Card, Spinner } from '@cameleer/design-system';
|
||||||
|
import cameleerLogo from '@cameleer/design-system/assets/cameleer-logo.svg';
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const { signIn, isAuthenticated, isLoading } = useLogto();
|
const { signIn, isAuthenticated, isLoading } = useLogto();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const redirected = useRef(false);
|
const redirected = useRef(false);
|
||||||
|
|
||||||
|
// Check if we arrived here from a logout redirect (set by useAuth before signOut)
|
||||||
|
const [signedOut] = useState(() => {
|
||||||
|
const flag = sessionStorage.getItem('cameleer:signed_out');
|
||||||
|
if (flag) sessionStorage.removeItem('cameleer:signed_out');
|
||||||
|
return !!flag;
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
navigate('/', { replace: true });
|
navigate('/', { replace: true });
|
||||||
@@ -15,11 +23,50 @@ export function LoginPage() {
|
|||||||
}, [isAuthenticated, navigate]);
|
}, [isAuthenticated, navigate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Don't auto-redirect after logout — show the signed-out state instead
|
||||||
|
if (signedOut) return;
|
||||||
if (!isLoading && !isAuthenticated && !redirected.current) {
|
if (!isLoading && !isAuthenticated && !redirected.current) {
|
||||||
redirected.current = true;
|
redirected.current = true;
|
||||||
signIn(`${window.location.origin}/platform/callback`);
|
signIn(`${window.location.origin}/platform/callback`);
|
||||||
}
|
}
|
||||||
}, [isLoading, isAuthenticated, signIn]);
|
}, [isLoading, isAuthenticated, signIn, signedOut]);
|
||||||
|
|
||||||
|
if (signedOut && !isAuthenticated) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
minHeight: '100vh', background: 'var(--bg-base)',
|
||||||
|
}}>
|
||||||
|
<Card className="signed-out-card">
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||||
|
width: '100%', fontFamily: 'var(--font-body)',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 10,
|
||||||
|
marginBottom: 8, fontSize: 24, fontWeight: 700, color: 'var(--text-primary)',
|
||||||
|
}}>
|
||||||
|
<img src={cameleerLogo} alt="" width="36" height="36" />
|
||||||
|
Cameleer
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text-muted)', margin: '0 0 24px' }}>
|
||||||
|
You have been signed out successfully.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => {
|
||||||
|
window.location.href = window.location.origin + '/platform/login';
|
||||||
|
}}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
Sign in again
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<style>{`.signed-out-card { width: 100%; max-width: 400px; padding: 32px; }`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}>
|
||||||
|
|||||||
32
ui/src/auth/RegisterPage.tsx
Normal file
32
ui/src/auth/RegisterPage.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useLogto } from '@logto/react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
import { Spinner } from '@cameleer/design-system';
|
||||||
|
|
||||||
|
export function RegisterPage() {
|
||||||
|
const { signIn, isAuthenticated, isLoading } = useLogto();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const redirected = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
navigate('/', { replace: true });
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, navigate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading && !isAuthenticated && !redirected.current) {
|
||||||
|
redirected.current = true;
|
||||||
|
signIn({
|
||||||
|
redirectUri: `${window.location.origin}/platform/callback`,
|
||||||
|
firstScreen: 'register',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isLoading, isAuthenticated, signIn]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}>
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ export function useAuth() {
|
|||||||
const { currentTenantId } = useOrgStore();
|
const { currentTenantId } = useOrgStore();
|
||||||
|
|
||||||
const logout = useCallback(() => {
|
const logout = useCallback(() => {
|
||||||
|
sessionStorage.setItem('cameleer:signed_out', '1');
|
||||||
signOut(window.location.origin + '/platform/login');
|
signOut(window.location.origin + '/platform/login');
|
||||||
}, [signOut]);
|
}, [signOut]);
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
Sidebar,
|
Sidebar,
|
||||||
TopBar,
|
TopBar,
|
||||||
} from '@cameleer/design-system';
|
} from '@cameleer/design-system';
|
||||||
import { LayoutDashboard, ShieldCheck, Users, Settings, Shield, Building, ScrollText } from 'lucide-react';
|
import { LayoutDashboard, ShieldCheck, Users, Settings, Shield, Building, ScrollText, Mail } from 'lucide-react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useAuth } from '../auth/useAuth';
|
import { useAuth } from '../auth/useAuth';
|
||||||
import { useScopes } from '../auth/useScopes';
|
import { useScopes } from '../auth/useScopes';
|
||||||
@@ -125,11 +125,20 @@ export function Layout() {
|
|||||||
>
|
>
|
||||||
Infrastructure
|
Infrastructure
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
style={{ padding: '6px 12px 6px 36px', fontSize: 13, cursor: 'pointer',
|
||||||
|
fontWeight: isActive(location, '/vendor/email') ? 600 : 400,
|
||||||
|
color: isActive(location, '/vendor/email') ? 'var(--amber)' : 'var(--text-muted)' }}
|
||||||
|
onClick={() => navigate('/vendor/email')}
|
||||||
|
>
|
||||||
|
<Mail size={12} style={{ marginRight: 6, verticalAlign: -1 }} />
|
||||||
|
Email Connector
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{ padding: '6px 12px 6px 36px', fontSize: 13, cursor: 'pointer', color: 'var(--text-muted)' }}
|
style={{ padding: '6px 12px 6px 36px', fontSize: 13, cursor: 'pointer', color: 'var(--text-muted)' }}
|
||||||
onClick={() => window.open(`${window.location.protocol}//${window.location.hostname}:3002`, '_blank', 'noopener')}
|
onClick={() => window.open(`${window.location.protocol}//${window.location.hostname}:3002`, '_blank', 'noopener')}
|
||||||
>
|
>
|
||||||
Identity (Logto)
|
Logto Console
|
||||||
</div>
|
</div>
|
||||||
</Sidebar.Section>
|
</Sidebar.Section>
|
||||||
)}
|
)}
|
||||||
|
|||||||
63
ui/src/pages/OnboardingPage.module.css
Normal file
63
ui/src/pages/OnboardingPage.module.css
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
.page {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoImg {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0 0 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
103
ui/src/pages/OnboardingPage.tsx
Normal file
103
ui/src/pages/OnboardingPage.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Card, Input, Button, FormField, Alert } from '@cameleer/design-system';
|
||||||
|
import cameleerLogo from '@cameleer/design-system/assets/cameleer-logo.svg';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import { toSlug } from '../utils/slug';
|
||||||
|
import styles from './OnboardingPage.module.css';
|
||||||
|
|
||||||
|
interface TenantResponse {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OnboardingPage() {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [slug, setSlug] = useState('');
|
||||||
|
const [slugTouched, setSlugTouched] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!slugTouched) {
|
||||||
|
setSlug(toSlug(name));
|
||||||
|
}
|
||||||
|
}, [name, slugTouched]);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await api.post<TenantResponse>('/onboarding/tenant', { name, slug });
|
||||||
|
// Tenant created — force a full page reload so the Logto SDK
|
||||||
|
// picks up the new org membership and scopes on the next token refresh.
|
||||||
|
window.location.href = '/platform/';
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to create tenant');
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<div className={styles.wrapper}>
|
||||||
|
<Card className={styles.card}>
|
||||||
|
<div className={styles.inner}>
|
||||||
|
<div className={styles.logo}>
|
||||||
|
<img src={cameleerLogo} alt="" className={styles.logoImg} />
|
||||||
|
Welcome to Cameleer
|
||||||
|
</div>
|
||||||
|
<p className={styles.subtitle}>
|
||||||
|
Set up your workspace to start monitoring your Camel routes.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className={styles.error}>
|
||||||
|
<Alert variant="error">{error}</Alert>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className={styles.form} noValidate>
|
||||||
|
<FormField label="Organization name" htmlFor="onboard-name" required>
|
||||||
|
<Input
|
||||||
|
id="onboard-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Acme Corp"
|
||||||
|
autoFocus
|
||||||
|
disabled={loading}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="URL slug" htmlFor="onboard-slug" required
|
||||||
|
hint="Auto-generated from name. Appears in your dashboard URL."
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="onboard-slug"
|
||||||
|
value={slug}
|
||||||
|
onChange={(e) => { setSlugTouched(true); setSlug(e.target.value); }}
|
||||||
|
placeholder="acme-corp"
|
||||||
|
disabled={loading}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
type="submit"
|
||||||
|
loading={loading}
|
||||||
|
disabled={loading || !name || !slug}
|
||||||
|
className={styles.submit}
|
||||||
|
>
|
||||||
|
{loading ? 'Creating...' : 'Create workspace'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
296
ui/src/pages/vendor/EmailConfigPage.tsx
vendored
Normal file
296
ui/src/pages/vendor/EmailConfigPage.tsx
vendored
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
FormField,
|
||||||
|
Input,
|
||||||
|
Spinner,
|
||||||
|
useToast,
|
||||||
|
} from '@cameleer/design-system';
|
||||||
|
import { Send, Trash2, Save, Power } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
useEmailConnector,
|
||||||
|
useSaveEmailConnector,
|
||||||
|
useDeleteEmailConnector,
|
||||||
|
useTestEmailConnector,
|
||||||
|
useToggleRegistration,
|
||||||
|
} from '../../api/email-connector-hooks';
|
||||||
|
import styles from '../../styles/platform.module.css';
|
||||||
|
|
||||||
|
export function EmailConfigPage() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { data: connector, isLoading, isError } = useEmailConnector();
|
||||||
|
const saveMutation = useSaveEmailConnector();
|
||||||
|
const deleteMutation = useDeleteEmailConnector();
|
||||||
|
const testMutation = useTestEmailConnector();
|
||||||
|
const toggleMutation = useToggleRegistration();
|
||||||
|
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [host, setHost] = useState('');
|
||||||
|
const [port, setPort] = useState('587');
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [fromEmail, setFromEmail] = useState('');
|
||||||
|
const [testTo, setTestTo] = useState('');
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
|
||||||
|
const isConfigured = connector != null;
|
||||||
|
const showForm = !isConfigured || editing;
|
||||||
|
|
||||||
|
function startEditing() {
|
||||||
|
if (connector) {
|
||||||
|
setHost(connector.host);
|
||||||
|
setPort(String(connector.port));
|
||||||
|
setUsername(connector.username);
|
||||||
|
setPassword('');
|
||||||
|
setFromEmail(connector.fromEmail);
|
||||||
|
}
|
||||||
|
setEditing(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!host || !username || !password || !fromEmail) {
|
||||||
|
toast({ title: 'All fields are required', variant: 'error' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await saveMutation.mutateAsync({
|
||||||
|
host,
|
||||||
|
port: parseInt(port, 10) || 587,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
fromEmail,
|
||||||
|
});
|
||||||
|
toast({ title: 'Email connector saved', variant: 'success' });
|
||||||
|
setEditing(false);
|
||||||
|
setPassword('');
|
||||||
|
} catch (err) {
|
||||||
|
toast({ title: 'Failed to save', description: String(err), variant: 'error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
try {
|
||||||
|
await deleteMutation.mutateAsync();
|
||||||
|
toast({ title: 'Email connector removed', variant: 'success' });
|
||||||
|
setConfirmDelete(false);
|
||||||
|
setEditing(false);
|
||||||
|
} catch (err) {
|
||||||
|
toast({ title: 'Failed to delete', description: String(err), variant: 'error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTest() {
|
||||||
|
if (!testTo) {
|
||||||
|
toast({ title: 'Enter a recipient email address', variant: 'error' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await testMutation.mutateAsync(testTo);
|
||||||
|
if (result.status === 'sent') {
|
||||||
|
toast({ title: 'Test email sent', description: result.message, variant: 'success' });
|
||||||
|
} else {
|
||||||
|
toast({ title: 'Test failed', description: result.message, variant: 'error' });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast({ title: 'Test failed', description: String(err), variant: 'error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleToggleRegistration() {
|
||||||
|
if (!connector) return;
|
||||||
|
const newValue = !connector.registrationEnabled;
|
||||||
|
try {
|
||||||
|
await toggleMutation.mutateAsync(newValue);
|
||||||
|
toast({
|
||||||
|
title: newValue ? 'Registration enabled' : 'Registration disabled',
|
||||||
|
variant: 'success',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
toast({ title: 'Failed to toggle registration', description: String(err), variant: 'error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', padding: 64 }}>
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24 }}>
|
||||||
|
<Alert variant="error" title="Failed to load email configuration">
|
||||||
|
Could not fetch email connector data. Please refresh.
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||||
|
<h1 className={styles.heading}>Email Connector</h1>
|
||||||
|
|
||||||
|
{!isConfigured && (
|
||||||
|
<Alert variant="info" title="Email delivery not configured">
|
||||||
|
Self-service registration is disabled. Configure an SMTP connector to enable email
|
||||||
|
verification for sign-up and password reset.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(380px, 1fr))', gap: 16 }}>
|
||||||
|
{/* Current config card (when configured and not editing) */}
|
||||||
|
{isConfigured && !editing && (
|
||||||
|
<Card title="SMTP Configuration">
|
||||||
|
<div className={styles.dividerList}>
|
||||||
|
<div className={styles.kvRow}>
|
||||||
|
<span className={styles.kvLabel}>Host</span>
|
||||||
|
<span className={styles.kvValue}>{connector.host}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.kvRow}>
|
||||||
|
<span className={styles.kvLabel}>Port</span>
|
||||||
|
<span className={styles.kvValue}>{connector.port}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.kvRow}>
|
||||||
|
<span className={styles.kvLabel}>Username</span>
|
||||||
|
<span className={styles.kvValue}>{connector.username}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.kvRow}>
|
||||||
|
<span className={styles.kvLabel}>Password</span>
|
||||||
|
<span className={styles.kvValueMono}>{'*'.repeat(8)}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.kvRow}>
|
||||||
|
<span className={styles.kvLabel}>From Email</span>
|
||||||
|
<span className={styles.kvValue}>{connector.fromEmail}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ paddingTop: 8, display: 'flex', gap: 8 }}>
|
||||||
|
<Button variant="secondary" onClick={startEditing}>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
{!confirmDelete ? (
|
||||||
|
<Button variant="secondary" onClick={() => setConfirmDelete(true)}>
|
||||||
|
<Trash2 size={14} style={{ marginRight: 6 }} />
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button variant="primary" onClick={handleDelete} loading={deleteMutation.isPending}
|
||||||
|
style={{ background: 'var(--error)' }}>
|
||||||
|
Confirm removal
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={() => setConfirmDelete(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* SMTP form (when unconfigured or editing) */}
|
||||||
|
{showForm && (
|
||||||
|
<Card title={isConfigured ? 'Edit SMTP Configuration' : 'SMTP Configuration'}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<FormField label="SMTP Host *">
|
||||||
|
<Input
|
||||||
|
placeholder="smtp.example.com"
|
||||||
|
value={host}
|
||||||
|
onChange={(e) => setHost(e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="SMTP Port *">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="587"
|
||||||
|
value={port}
|
||||||
|
onChange={(e) => setPort(e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Username *">
|
||||||
|
<Input
|
||||||
|
placeholder="user@example.com"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Password *">
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder={isConfigured ? 'Enter new password' : 'Password'}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="From Email *">
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
placeholder="noreply@example.com"
|
||||||
|
value={fromEmail}
|
||||||
|
onChange={(e) => setFromEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<Button variant="primary" onClick={handleSave} loading={saveMutation.isPending}>
|
||||||
|
<Save size={14} style={{ marginRight: 6 }} />
|
||||||
|
{isConfigured ? 'Update' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
{editing && (
|
||||||
|
<Button variant="secondary" onClick={() => setEditing(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Registration toggle (when configured) */}
|
||||||
|
{isConfigured && !editing && (
|
||||||
|
<Card title="Self-Service Registration">
|
||||||
|
<div className={styles.dividerList}>
|
||||||
|
<p className={styles.description}>
|
||||||
|
{connector.registrationEnabled
|
||||||
|
? 'New users can register with their email address and verify via a code sent to their inbox.'
|
||||||
|
: 'Registration is disabled. Only admin-invited users can sign in.'}
|
||||||
|
</p>
|
||||||
|
<div style={{ paddingTop: 8 }}>
|
||||||
|
<Button
|
||||||
|
variant={connector.registrationEnabled ? 'secondary' : 'primary'}
|
||||||
|
onClick={handleToggleRegistration}
|
||||||
|
loading={toggleMutation.isPending}
|
||||||
|
>
|
||||||
|
<Power size={14} style={{ marginRight: 6 }} />
|
||||||
|
{connector.registrationEnabled ? 'Disable Registration' : 'Enable Registration'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Test email (when configured and not editing) */}
|
||||||
|
{isConfigured && !editing && (
|
||||||
|
<Card title="Send Test Email">
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<FormField label="Recipient">
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
value={testTo}
|
||||||
|
onChange={(e) => setTestTo(e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<Button variant="primary" onClick={handleTest} loading={testMutation.isPending}>
|
||||||
|
<Send size={14} style={{ marginRight: 6 }} />
|
||||||
|
Send Test Email
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Routes, Route, Navigate } from 'react-router';
|
import { Routes, Route, Navigate } from 'react-router';
|
||||||
import { LoginPage } from './auth/LoginPage';
|
import { LoginPage } from './auth/LoginPage';
|
||||||
|
import { RegisterPage } from './auth/RegisterPage';
|
||||||
import { CallbackPage } from './auth/CallbackPage';
|
import { CallbackPage } from './auth/CallbackPage';
|
||||||
import { ProtectedRoute } from './auth/ProtectedRoute';
|
import { ProtectedRoute } from './auth/ProtectedRoute';
|
||||||
import { OrgResolver } from './auth/OrgResolver';
|
import { OrgResolver } from './auth/OrgResolver';
|
||||||
@@ -15,12 +16,14 @@ import { VendorAuditPage } from './pages/vendor/VendorAuditPage';
|
|||||||
import { CertificatesPage } from './pages/vendor/CertificatesPage';
|
import { CertificatesPage } from './pages/vendor/CertificatesPage';
|
||||||
import { InfrastructurePage } from './pages/vendor/InfrastructurePage';
|
import { InfrastructurePage } from './pages/vendor/InfrastructurePage';
|
||||||
import { VendorMetricsPage } from './pages/vendor/VendorMetricsPage';
|
import { VendorMetricsPage } from './pages/vendor/VendorMetricsPage';
|
||||||
|
import { EmailConfigPage } from './pages/vendor/EmailConfigPage';
|
||||||
import { TenantDashboardPage } from './pages/tenant/TenantDashboardPage';
|
import { TenantDashboardPage } from './pages/tenant/TenantDashboardPage';
|
||||||
import { TenantLicensePage } from './pages/tenant/TenantLicensePage';
|
import { TenantLicensePage } from './pages/tenant/TenantLicensePage';
|
||||||
import { SsoPage } from './pages/tenant/SsoPage';
|
import { SsoPage } from './pages/tenant/SsoPage';
|
||||||
import { TeamPage } from './pages/tenant/TeamPage';
|
import { TeamPage } from './pages/tenant/TeamPage';
|
||||||
import { SettingsPage } from './pages/tenant/SettingsPage';
|
import { SettingsPage } from './pages/tenant/SettingsPage';
|
||||||
import { TenantAuditPage } from './pages/tenant/TenantAuditPage';
|
import { TenantAuditPage } from './pages/tenant/TenantAuditPage';
|
||||||
|
import { OnboardingPage } from './pages/OnboardingPage';
|
||||||
|
|
||||||
function LandingRedirect() {
|
function LandingRedirect() {
|
||||||
const scopes = useScopes();
|
const scopes = useScopes();
|
||||||
@@ -45,7 +48,11 @@ function LandingRedirect() {
|
|||||||
window.location.href = `/t/${currentOrg.slug}/`;
|
window.location.href = `/t/${currentOrg.slug}/`;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
// No org resolved yet — stay on tenant portal
|
// No org membership at all → onboarding (self-service tenant creation)
|
||||||
|
if (organizations.length === 0) {
|
||||||
|
return <Navigate to="/onboarding" replace />;
|
||||||
|
}
|
||||||
|
// Has org but no scopes resolved yet — stay on tenant portal
|
||||||
return <Navigate to="/tenant" replace />;
|
return <Navigate to="/tenant" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,9 +60,12 @@ export function AppRouter() {
|
|||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
<Route path="/callback" element={<CallbackPage />} />
|
<Route path="/callback" element={<CallbackPage />} />
|
||||||
<Route element={<ProtectedRoute />}>
|
<Route element={<ProtectedRoute />}>
|
||||||
<Route element={<OrgResolver />}>
|
<Route element={<OrgResolver />}>
|
||||||
|
{/* Onboarding — outside Layout, shown to users with no tenants */}
|
||||||
|
<Route path="/onboarding" element={<OnboardingPage />} />
|
||||||
<Route element={<Layout />}>
|
<Route element={<Layout />}>
|
||||||
{/* Vendor console */}
|
{/* Vendor console */}
|
||||||
<Route path="/vendor/tenants" element={
|
<Route path="/vendor/tenants" element={
|
||||||
@@ -93,6 +103,11 @@ export function AppRouter() {
|
|||||||
<InfrastructurePage />
|
<InfrastructurePage />
|
||||||
</RequireScope>
|
</RequireScope>
|
||||||
} />
|
} />
|
||||||
|
<Route path="/vendor/email" element={
|
||||||
|
<RequireScope scope="platform:admin" fallback={<Navigate to="/tenant" replace />}>
|
||||||
|
<EmailConfigPage />
|
||||||
|
</RequireScope>
|
||||||
|
} />
|
||||||
|
|
||||||
{/* Tenant portal */}
|
{/* Tenant portal */}
|
||||||
<Route path="/tenant" element={<TenantDashboardPage />} />
|
<Route path="/tenant" element={<TenantDashboardPage />} />
|
||||||
|
|||||||
Reference in New Issue
Block a user