Compare commits
96 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e881e302b6 | ||
|
|
d7ef2c488b | ||
|
|
088bc34e67 | ||
|
|
73e41e5607 | ||
|
|
f5b68c212b | ||
|
|
7c82ba93b0 | ||
|
|
1066101e8a | ||
|
|
ffb7ef0839 | ||
|
|
4dea1c6764 | ||
|
|
6c3f21d4db | ||
|
|
7a8960ca46 | ||
|
|
fdc7187424 | ||
|
|
2fd14165bc | ||
|
|
13bd03997a | ||
|
|
e64bf4f0d1 | ||
|
|
883e10ba7c | ||
|
|
0413a5b882 | ||
|
|
c6b6bafc0f | ||
|
|
988035b952 | ||
|
|
c55427c22b | ||
|
|
f681784e7e | ||
|
|
7b57ee8246 | ||
|
|
6e6e4218c9 | ||
|
|
469b36613b | ||
|
|
bcb8a040f4 | ||
|
|
d52084a081 | ||
|
|
7e7407b137 | ||
|
|
0a77080bca | ||
|
|
a5b30cd1ea | ||
|
|
ffb65edcec | ||
|
|
8b8909e488 | ||
|
|
94de4c2a5b | ||
|
|
66477ff575 | ||
|
|
6c70efcb54 | ||
|
|
1f3a9551c5 | ||
|
|
08a3ad03b7 | ||
|
|
cfcf852e2d | ||
|
|
67f7d634c9 | ||
|
|
6f984c6b78 | ||
|
|
5754b0ca81 | ||
|
|
484a388b62 | ||
|
|
d720c0500f | ||
|
|
cfa9d41b36 | ||
|
|
b974f233f4 | ||
|
|
3741ac2658 | ||
|
|
e8a726af80 | ||
|
|
53f0e55e93 | ||
|
|
06d114b46b | ||
|
|
171ed1a6ab | ||
|
|
dee1f39554 | ||
|
|
adb4ef1af8 | ||
|
|
4cc3e096b5 | ||
|
|
1d26ae481e | ||
|
|
8fe18c7f83 | ||
|
|
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 |
10
.env.example
10
.env.example
@@ -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,9 +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)
|
||||||
SAAS_ADMIN_USER=admin
|
# In SaaS mode, this must be an email address (primary user identity).
|
||||||
|
# In standalone mode, any username is accepted.
|
||||||
|
SAAS_ADMIN_USER=admin@example.com
|
||||||
SAAS_ADMIN_PASS=change_me_in_production
|
SAAS_ADMIN_PASS=change_me_in_production
|
||||||
|
|
||||||
|
# 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
|
||||||
# CERT_FILE=
|
# CERT_FILE=
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build and Test (unit tests only)
|
- name: Build and Test (unit tests only)
|
||||||
run: >-
|
run: >-
|
||||||
mvn clean verify -B
|
mvn clean verify -U -B
|
||||||
-Dsurefire.excludes="**/AuthControllerTest.java,**/TenantControllerTest.java,**/LicenseControllerTest.java,**/AuditRepositoryTest.java,**/CameleerSaasApplicationTest.java,**/EnvironmentControllerTest.java,**/AppControllerTest.java,**/DeploymentControllerTest.java,**/AgentStatusControllerTest.java,**/VendorTenantControllerTest.java,**/TenantPortalControllerTest.java"
|
-Dsurefire.excludes="**/AuthControllerTest.java,**/TenantControllerTest.java,**/LicenseControllerTest.java,**/AuditRepositoryTest.java,**/CameleerSaasApplicationTest.java,**/EnvironmentControllerTest.java,**/AppControllerTest.java,**/DeploymentControllerTest.java,**/AgentStatusControllerTest.java,**/VendorTenantControllerTest.java,**/TenantPortalControllerTest.java"
|
||||||
|
|
||||||
- name: Build sign-in UI
|
- name: Build sign-in UI
|
||||||
|
|||||||
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** (3302 symbols, 6974 relationships, 278 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. `SAAS_ADMIN_USER` IS the email address — there is no separate `SAAS_ADMIN_EMAIL`. The installer enforces email format in SaaS mode (must contain `@`; auto-appends `@<PUBLIC_HOST>` if missing). The bootstrap uses `SAAS_ADMIN_USER` as both the Logto username and primaryEmail. In standalone mode, any username is accepted. 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** (3302 symbols, 6974 relationships, 278 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.
|
||||||
|
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ WORKDIR /build
|
|||||||
COPY .mvn/ .mvn/
|
COPY .mvn/ .mvn/
|
||||||
COPY mvnw pom.xml ./
|
COPY mvnw pom.xml ./
|
||||||
# Cache deps — BuildKit cache mount persists across --no-cache builds
|
# Cache deps — BuildKit cache mount persists across --no-cache builds
|
||||||
RUN --mount=type=cache,target=/root/.m2/repository ./mvnw dependency:go-offline -B || true
|
RUN --mount=type=cache,target=/root/.m2/repository ./mvnw dependency:go-offline -U -B || true
|
||||||
COPY src/ src/
|
COPY src/ src/
|
||||||
COPY --from=frontend /ui/dist/ src/main/resources/static/
|
COPY --from=frontend /ui/dist/ src/main/resources/static/
|
||||||
RUN --mount=type=cache,target=/root/.m2/repository ./mvnw package -DskipTests -B
|
RUN --mount=type=cache,target=/root/.m2/repository ./mvnw package -DskipTests -U -B
|
||||||
|
|
||||||
# Runtime: target platform (amd64)
|
# Runtime: target platform (amd64)
|
||||||
FROM eclipse-temurin:21-jre-alpine
|
FROM eclipse-temurin:21-jre-alpine
|
||||||
|
|||||||
@@ -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). `SAAS_ADMIN_USER` is the admin's email address in SaaS mode — used as both the Logto username and primaryEmail. No separate `SAAS_ADMIN_EMAIL`.
|
||||||
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:
|
||||||
|
|||||||
@@ -25,13 +25,24 @@ API_RESOURCE_INDICATOR="https://api.cameleer.local"
|
|||||||
API_RESOURCE_NAME="Cameleer SaaS API"
|
API_RESOURCE_NAME="Cameleer SaaS API"
|
||||||
|
|
||||||
# Users (configurable via env vars)
|
# Users (configurable via env vars)
|
||||||
|
# In SaaS mode, SAAS_ADMIN_USER is the admin's email address (e.g. admin@company.com).
|
||||||
|
# The local part (before @) is used as the Logto username; the full value as primaryEmail.
|
||||||
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}"
|
||||||
|
# Extract username (local part) for Logto — Logto rejects @ in usernames
|
||||||
|
if echo "$SAAS_ADMIN_USER" | grep -q '@'; then
|
||||||
|
ADMIN_USERNAME="${SAAS_ADMIN_USER%%@*}"
|
||||||
|
ADMIN_EMAIL="$SAAS_ADMIN_USER"
|
||||||
|
else
|
||||||
|
ADMIN_USERNAME="$SAAS_ADMIN_USER"
|
||||||
|
ADMIN_EMAIL=""
|
||||||
|
fi
|
||||||
|
|
||||||
# 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 +58,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)
|
||||||
@@ -387,19 +399,27 @@ log "API resource scopes assigned to organization roles."
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
# --- Platform Owner ---
|
# --- Platform Owner ---
|
||||||
log "Checking for platform owner user '$SAAS_ADMIN_USER'..."
|
log "Checking for platform owner user '$ADMIN_USERNAME'..."
|
||||||
ADMIN_USER_ID=$(api_get "/api/users?search=$SAAS_ADMIN_USER" | jq -r ".[] | select(.username == \"$SAAS_ADMIN_USER\") | .id")
|
ADMIN_USER_ID=$(api_get "/api/users?search=$ADMIN_USERNAME" | jq -r ".[] | select(.username == \"$ADMIN_USERNAME\") | .id")
|
||||||
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'..."
|
# Build user JSON — include primaryEmail only if SAAS_ADMIN_USER is an email
|
||||||
ADMIN_RESPONSE=$(api_post "/api/users" "{
|
ADMIN_USER_JSON="{\"username\": \"$ADMIN_USERNAME\", \"password\": \"$SAAS_ADMIN_PASS\", \"name\": \"Platform Owner\""
|
||||||
\"username\": \"$SAAS_ADMIN_USER\",
|
if [ -n "$ADMIN_EMAIL" ]; then
|
||||||
\"password\": \"$SAAS_ADMIN_PASS\",
|
ADMIN_USER_JSON="$ADMIN_USER_JSON, \"primaryEmail\": \"$ADMIN_EMAIL\""
|
||||||
\"name\": \"Platform Owner\"
|
log "Creating platform owner '$ADMIN_USERNAME' (email: $ADMIN_EMAIL)..."
|
||||||
}")
|
else
|
||||||
|
log "Creating platform owner '$ADMIN_USERNAME'..."
|
||||||
|
fi
|
||||||
|
ADMIN_USER_JSON="$ADMIN_USER_JSON}"
|
||||||
|
ADMIN_RESPONSE=$(api_post "/api/users" "$ADMIN_USER_JSON")
|
||||||
ADMIN_USER_ID=$(echo "$ADMIN_RESPONSE" | jq -r '.id')
|
ADMIN_USER_ID=$(echo "$ADMIN_RESPONSE" | jq -r '.id')
|
||||||
|
if [ -z "$ADMIN_USER_ID" ] || [ "$ADMIN_USER_ID" = "null" ]; then
|
||||||
|
log "ERROR: Failed to create platform owner. Response: $(echo "$ADMIN_RESPONSE" | head -c 300)"
|
||||||
|
else
|
||||||
log "Created platform owner: $ADMIN_USER_ID"
|
log "Created platform owner: $ADMIN_USER_ID"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- Grant SaaS admin Logto console access (admin tenant, port 3002) ---
|
# --- Grant SaaS admin Logto console access (admin tenant, port 3002) ---
|
||||||
@@ -439,12 +459,12 @@ else
|
|||||||
-d "$2" "${LOGTO_ADMIN_ENDPOINT}${1}" 2>/dev/null || true
|
-d "$2" "${LOGTO_ADMIN_ENDPOINT}${1}" 2>/dev/null || true
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check if admin user already exists on admin tenant
|
# Check if admin user already exists on admin tenant (uses ADMIN_USERNAME, not email)
|
||||||
ADMIN_TENANT_USER_ID=$(admin_api_get "/api/users?search=$SAAS_ADMIN_USER" | jq -r ".[] | select(.username == \"$SAAS_ADMIN_USER\") | .id" 2>/dev/null)
|
ADMIN_TENANT_USER_ID=$(admin_api_get "/api/users?search=$ADMIN_USERNAME" | jq -r ".[] | select(.username == \"$ADMIN_USERNAME\") | .id" 2>/dev/null)
|
||||||
if [ -z "$ADMIN_TENANT_USER_ID" ] || [ "$ADMIN_TENANT_USER_ID" = "null" ]; then
|
if [ -z "$ADMIN_TENANT_USER_ID" ] || [ "$ADMIN_TENANT_USER_ID" = "null" ]; then
|
||||||
log "Creating admin console user '$SAAS_ADMIN_USER'..."
|
log "Creating admin console user '$ADMIN_USERNAME'..."
|
||||||
ADMIN_TENANT_RESPONSE=$(admin_api_post "/api/users" "{
|
ADMIN_TENANT_RESPONSE=$(admin_api_post "/api/users" "{
|
||||||
\"username\": \"$SAAS_ADMIN_USER\",
|
\"username\": \"$ADMIN_USERNAME\",
|
||||||
\"password\": \"$SAAS_ADMIN_PASS\",
|
\"password\": \"$SAAS_ADMIN_PASS\",
|
||||||
\"name\": \"Platform Admin\"
|
\"name\": \"Platform Admin\"
|
||||||
}")
|
}")
|
||||||
@@ -532,7 +552,12 @@ CUSTOM_JWT_SCRIPT='const getCustomJwtClaims = async ({ token, context, environme
|
|||||||
if (role.name === "saas-vendor") roles.add("server:admin");
|
if (role.name === "saas-vendor") roles.add("server:admin");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return roles.size > 0 ? { roles: [...roles] } : {};
|
const mfaFactors = context?.user?.mfaVerificationFactors || [];
|
||||||
|
const mfaEnrolled = mfaFactors.some(f => f.type === "Totp");
|
||||||
|
const claims = {};
|
||||||
|
if (roles.size > 0) claims.roles = [...roles];
|
||||||
|
claims.mfa_enrolled = mfaEnrolled;
|
||||||
|
return claims;
|
||||||
};'
|
};'
|
||||||
|
|
||||||
CUSTOM_JWT_PAYLOAD=$(jq -n --arg script "$CUSTOM_JWT_SCRIPT" '{ script: $script }')
|
CUSTOM_JWT_PAYLOAD=$(jq -n --arg script "$CUSTOM_JWT_SCRIPT" '{ script: $script }')
|
||||||
@@ -562,6 +587,38 @@ 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": "email",
|
||||||
|
"password": true,
|
||||||
|
"verificationCode": false,
|
||||||
|
"isPasswordPrimary": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "username",
|
||||||
|
"password": true,
|
||||||
|
"verificationCode": false,
|
||||||
|
"isPasswordPrimary": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"mfa": {
|
||||||
|
"factors": ["Totp", "BackupCode"],
|
||||||
|
"policy": "UserControlled"
|
||||||
|
}
|
||||||
|
}' >/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
449
docs/superpowers/plans/2026-04-26-email-template-polish-plan.md
Normal file
449
docs/superpowers/plans/2026-04-26-email-template-polish-plan.md
Normal file
@@ -0,0 +1,449 @@
|
|||||||
|
# Email Template Polish Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Replace inline HTML email templates with polished, branded HTML files loaded from classpath, featuring playful desert/caravan copy, structured card layout with watermark, and proper header/footer.
|
||||||
|
|
||||||
|
**Architecture:** Extract 4 email templates from `EmailConnectorService.buildSmtpConfig()` into standalone HTML files at `src/main/resources/email-templates/`. Generate a pre-faded watermark PNG served as a static asset. Inject `ProvisioningProperties` to resolve the watermark URL at runtime.
|
||||||
|
|
||||||
|
**Tech Stack:** Java 21, Spring Boot, ImageMagick (one-time asset generation), HTML email (inline styles only)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### File Map
|
||||||
|
|
||||||
|
| Action | File | Purpose |
|
||||||
|
|--------|------|---------|
|
||||||
|
| Create | `src/main/resources/email-templates/register.html` | Registration verification email |
|
||||||
|
| Create | `src/main/resources/email-templates/sign-in.html` | Sign-in verification email |
|
||||||
|
| Create | `src/main/resources/email-templates/forgot-password.html` | Password reset email |
|
||||||
|
| Create | `src/main/resources/email-templates/generic.html` | Generic verification email |
|
||||||
|
| Create | `src/main/resources/static/assets/email-watermark.png` | Pre-faded logo at 7% opacity |
|
||||||
|
| Modify | `src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorService.java` | Load templates from classpath, inject watermark URL |
|
||||||
|
| Modify | `src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java:49` | Permit `/assets/**` for unauthenticated email clients |
|
||||||
|
| Create | `src/test/java/net/siegeln/cameleer/saas/vendor/EmailTemplateLoadingTest.java` | Verify templates load and placeholders resolve |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Generate the pre-faded watermark PNG
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/main/resources/static/assets/email-watermark.png`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Generate the faded watermark using ImageMagick**
|
||||||
|
|
||||||
|
Source the logo from the design-system sibling repo. Apply 7% opacity on a transparent background, output to the static assets directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
magick "C:/Users/Hendrik/Documents/projects/design-system/assets/cameleer-logo.png" \
|
||||||
|
-channel A -evaluate Multiply 0.07 +channel \
|
||||||
|
-resize 320x320 \
|
||||||
|
"src/main/resources/static/assets/email-watermark.png"
|
||||||
|
```
|
||||||
|
|
||||||
|
If `magick` is not available, use Python Pillow as fallback:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -c "
|
||||||
|
from PIL import Image
|
||||||
|
img = Image.open('C:/Users/Hendrik/Documents/projects/design-system/assets/cameleer-logo.png').convert('RGBA')
|
||||||
|
img = img.resize((320, 320), Image.LANCZOS)
|
||||||
|
r, g, b, a = img.split()
|
||||||
|
a = a.point(lambda x: int(x * 0.07))
|
||||||
|
img = Image.merge('RGBA', (r, g, b, a))
|
||||||
|
img.save('src/main/resources/static/assets/email-watermark.png')
|
||||||
|
print('Saved watermark')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify the file exists and is reasonable size**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -la src/main/resources/static/assets/email-watermark.png
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: File exists, roughly 5-30 KB.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/resources/static/assets/email-watermark.png
|
||||||
|
git commit -m "feat: add pre-faded logo watermark for email templates"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Permit static assets in SecurityConfig
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java:49`
|
||||||
|
|
||||||
|
The watermark image must be loadable by email clients without authentication. The current security config has `.anyRequest().authenticated()` as catch-all, so `/assets/**` needs an explicit permit.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `/assets/**` to the permitAll list**
|
||||||
|
|
||||||
|
In `SecurityConfig.java`, find the existing line:
|
||||||
|
|
||||||
|
```java
|
||||||
|
.requestMatchers("/_app/**", "/favicon.ico", "/favicon.svg", "/logo.svg", "/logo-dark.svg").permitAll()
|
||||||
|
```
|
||||||
|
|
||||||
|
Change it to:
|
||||||
|
|
||||||
|
```java
|
||||||
|
.requestMatchers("/_app/**", "/assets/**", "/favicon.ico", "/favicon.svg", "/logo.svg", "/logo-dark.svg").permitAll()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java
|
||||||
|
git commit -m "feat: permit /assets/** for unauthenticated access (email watermark)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Create the 4 HTML email template files
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/main/resources/email-templates/register.html`
|
||||||
|
- Create: `src/main/resources/email-templates/sign-in.html`
|
||||||
|
- Create: `src/main/resources/email-templates/forgot-password.html`
|
||||||
|
- Create: `src/main/resources/email-templates/generic.html`
|
||||||
|
|
||||||
|
All templates use the same card structure. The `{{code}}` placeholder is Logto's built-in substitution. The `{{watermarkUrl}}` placeholder is replaced by `EmailConnectorService` at runtime.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create `register.html`**
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:480px;margin:0 auto;background:#ffffff;border-radius:8px;overflow:hidden;border:1px solid #e8e0d4;">
|
||||||
|
<div style="background:#C6820E;padding:20px 24px;text-align:center;">
|
||||||
|
<span style="font-size:22px;font-weight:700;color:#ffffff;letter-spacing:0.5px;">Cameleer.io</span>
|
||||||
|
</div>
|
||||||
|
<div style="padding:32px 24px 24px;position:relative;overflow:hidden;">
|
||||||
|
<img src="{{watermarkUrl}}" width="320" height="320" style="position:absolute;top:-30px;right:-50px;width:320px;height:320px;opacity:0.07;pointer-events:none;" alt="" />
|
||||||
|
<div style="position:relative;">
|
||||||
|
<p style="color:#1a1a1a;font-size:16px;font-weight:600;margin:0 0 8px;">Welcome to the caravan!</p>
|
||||||
|
<p style="color:#444;font-size:14px;line-height:1.6;margin:0 0 24px;">Enter this code to verify your email and claim your spot. The dunes wait for no one.</p>
|
||||||
|
<div style="text-align:center;margin:0 0 24px;">
|
||||||
|
<div style="display:inline-block;background:#FDF6EC;border:2px solid #C6820E;border-radius:8px;padding:16px 32px;">
|
||||||
|
<span style="font-size:32px;font-weight:700;letter-spacing:8px;color:#C6820E;font-family:'Courier New',Courier,monospace;">{{code}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p style="color:#888;font-size:13px;line-height:1.5;margin:0;">This code expires in 10 minutes. If you didn't request this, you can safely ignore this email — no camels were harmed.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="border-top:1px solid #e8e0d4;padding:16px 24px;text-align:center;">
|
||||||
|
<p style="color:#999;font-size:12px;margin:0;">Questions? Contact your administrator</p>
|
||||||
|
<p style="color:#bbb;font-size:11px;margin:6px 0 0;">Cameleer — Apache Camel observability</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create `sign-in.html`**
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:480px;margin:0 auto;background:#ffffff;border-radius:8px;overflow:hidden;border:1px solid #e8e0d4;">
|
||||||
|
<div style="background:#C6820E;padding:20px 24px;text-align:center;">
|
||||||
|
<span style="font-size:22px;font-weight:700;color:#ffffff;letter-spacing:0.5px;">Cameleer.io</span>
|
||||||
|
</div>
|
||||||
|
<div style="padding:32px 24px 24px;position:relative;overflow:hidden;">
|
||||||
|
<img src="{{watermarkUrl}}" width="320" height="320" style="position:absolute;top:-30px;right:-50px;width:320px;height:320px;opacity:0.07;pointer-events:none;" alt="" />
|
||||||
|
<div style="position:relative;">
|
||||||
|
<p style="color:#1a1a1a;font-size:16px;font-weight:600;margin:0 0 8px;">Back at the oasis already?</p>
|
||||||
|
<p style="color:#444;font-size:14px;line-height:1.6;margin:0 0 24px;">Here's your sign-in code. The caravan master is checking credentials.</p>
|
||||||
|
<div style="text-align:center;margin:0 0 24px;">
|
||||||
|
<div style="display:inline-block;background:#FDF6EC;border:2px solid #C6820E;border-radius:8px;padding:16px 32px;">
|
||||||
|
<span style="font-size:32px;font-weight:700;letter-spacing:8px;color:#C6820E;font-family:'Courier New',Courier,monospace;">{{code}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p style="color:#888;font-size:13px;line-height:1.5;margin:0;">This code expires in 10 minutes.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="border-top:1px solid #e8e0d4;padding:16px 24px;text-align:center;">
|
||||||
|
<p style="color:#999;font-size:12px;margin:0;">Questions? Contact your administrator</p>
|
||||||
|
<p style="color:#bbb;font-size:11px;margin:6px 0 0;">Cameleer — Apache Camel observability</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create `forgot-password.html`**
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:480px;margin:0 auto;background:#ffffff;border-radius:8px;overflow:hidden;border:1px solid #e8e0d4;">
|
||||||
|
<div style="background:#C6820E;padding:20px 24px;text-align:center;">
|
||||||
|
<span style="font-size:22px;font-weight:700;color:#ffffff;letter-spacing:0.5px;">Cameleer.io</span>
|
||||||
|
</div>
|
||||||
|
<div style="padding:32px 24px 24px;position:relative;overflow:hidden;">
|
||||||
|
<img src="{{watermarkUrl}}" width="320" height="320" style="position:absolute;top:-30px;right:-50px;width:320px;height:320px;opacity:0.07;pointer-events:none;" alt="" />
|
||||||
|
<div style="position:relative;">
|
||||||
|
<p style="color:#1a1a1a;font-size:16px;font-weight:600;margin:0 0 8px;">Lost in the dunes?</p>
|
||||||
|
<p style="color:#444;font-size:14px;line-height:1.6;margin:0 0 24px;">No worries — enter this code to reset your password and get back on the trail.</p>
|
||||||
|
<div style="text-align:center;margin:0 0 24px;">
|
||||||
|
<div style="display:inline-block;background:#FDF6EC;border:2px solid #C6820E;border-radius:8px;padding:16px 32px;">
|
||||||
|
<span style="font-size:32px;font-weight:700;letter-spacing:8px;color:#C6820E;font-family:'Courier New',Courier,monospace;">{{code}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p style="color:#888;font-size:13px;line-height:1.5;margin:0;">This code expires in 10 minutes. If you didn't request a password reset, you can safely ignore this email.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="border-top:1px solid #e8e0d4;padding:16px 24px;text-align:center;">
|
||||||
|
<p style="color:#999;font-size:12px;margin:0;">Questions? Contact your administrator</p>
|
||||||
|
<p style="color:#bbb;font-size:11px;margin:6px 0 0;">Cameleer — Apache Camel observability</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Create `generic.html`**
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:480px;margin:0 auto;background:#ffffff;border-radius:8px;overflow:hidden;border:1px solid #e8e0d4;">
|
||||||
|
<div style="background:#C6820E;padding:20px 24px;text-align:center;">
|
||||||
|
<span style="font-size:22px;font-weight:700;color:#ffffff;letter-spacing:0.5px;">Cameleer.io</span>
|
||||||
|
</div>
|
||||||
|
<div style="padding:32px 24px 24px;position:relative;overflow:hidden;">
|
||||||
|
<img src="{{watermarkUrl}}" width="320" height="320" style="position:absolute;top:-30px;right:-50px;width:320px;height:320px;opacity:0.07;pointer-events:none;" alt="" />
|
||||||
|
<div style="position:relative;">
|
||||||
|
<p style="color:#1a1a1a;font-size:16px;font-weight:600;margin:0 0 8px;">Quick checkpoint</p>
|
||||||
|
<p style="color:#444;font-size:14px;line-height:1.6;margin:0 0 24px;">Here's your verification code. Just making sure it's really you at the reins.</p>
|
||||||
|
<div style="text-align:center;margin:0 0 24px;">
|
||||||
|
<div style="display:inline-block;background:#FDF6EC;border:2px solid #C6820E;border-radius:8px;padding:16px 32px;">
|
||||||
|
<span style="font-size:32px;font-weight:700;letter-spacing:8px;color:#C6820E;font-family:'Courier New',Courier,monospace;">{{code}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p style="color:#888;font-size:13px;line-height:1.5;margin:0;">This code expires in 10 minutes.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="border-top:1px solid #e8e0d4;padding:16px 24px;text-align:center;">
|
||||||
|
<p style="color:#999;font-size:12px;margin:0;">Questions? Contact your administrator</p>
|
||||||
|
<p style="color:#bbb;font-size:11px;margin:6px 0 0;">Cameleer — Apache Camel observability</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/resources/email-templates/
|
||||||
|
git commit -m "feat: add branded HTML email templates with desert/caravan copy"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Refactor EmailConnectorService to load templates from classpath
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorService.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `src/test/java/net/siegeln/cameleer/saas/vendor/EmailTemplateLoadingTest.java`:
|
||||||
|
|
||||||
|
```java
|
||||||
|
package net.siegeln.cameleer.saas.vendor;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.core.io.ClassPathResource;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class EmailTemplateLoadingTest {
|
||||||
|
|
||||||
|
private static final String[] TEMPLATE_FILES = {
|
||||||
|
"email-templates/register.html",
|
||||||
|
"email-templates/sign-in.html",
|
||||||
|
"email-templates/forgot-password.html",
|
||||||
|
"email-templates/generic.html"
|
||||||
|
};
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void allTemplateFilesExistOnClasspath() {
|
||||||
|
for (String path : TEMPLATE_FILES) {
|
||||||
|
var resource = new ClassPathResource(path);
|
||||||
|
assertTrue(resource.exists(), "Template file missing: " + path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void templatesContainCodePlaceholder() throws IOException {
|
||||||
|
for (String path : TEMPLATE_FILES) {
|
||||||
|
String content = new ClassPathResource(path).getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
assertTrue(content.contains("{{code}}"),
|
||||||
|
path + " must contain {{code}} placeholder");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void templatesContainWatermarkPlaceholder() throws IOException {
|
||||||
|
for (String path : TEMPLATE_FILES) {
|
||||||
|
String content = new ClassPathResource(path).getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
assertTrue(content.contains("{{watermarkUrl}}"),
|
||||||
|
path + " must contain {{watermarkUrl}} placeholder");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void watermarkPlaceholderIsReplaced() throws IOException {
|
||||||
|
String content = new ClassPathResource("email-templates/register.html")
|
||||||
|
.getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
String resolved = content.replace("{{watermarkUrl}}",
|
||||||
|
"https://example.com/platform/assets/email-watermark.png");
|
||||||
|
assertFalse(resolved.contains("{{watermarkUrl}}"));
|
||||||
|
assertTrue(resolved.contains("https://example.com/platform/assets/email-watermark.png"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void templatesContainBrandElements() throws IOException {
|
||||||
|
for (String path : TEMPLATE_FILES) {
|
||||||
|
String content = new ClassPathResource(path).getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
assertTrue(content.contains("Cameleer.io"),
|
||||||
|
path + " must contain Cameleer.io header");
|
||||||
|
assertTrue(content.contains("Apache Camel observability"),
|
||||||
|
path + " must contain tagline");
|
||||||
|
assertTrue(content.contains("#C6820E"),
|
||||||
|
path + " must use brand color");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they pass (templates exist from Task 3)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./mvnw test -pl . -Dtest=EmailTemplateLoadingTest -Dspring.profiles.active=test
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: All 5 tests PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add `ProvisioningProperties` dependency to `EmailConnectorService`**
|
||||||
|
|
||||||
|
Replace the constructor and add the template loading logic. The full updated `EmailConnectorService.java`:
|
||||||
|
|
||||||
|
Change the imports and fields at the top of the class — add `ProvisioningProperties` import and field:
|
||||||
|
|
||||||
|
```java
|
||||||
|
import net.siegeln.cameleer.saas.provisioning.ProvisioningProperties;
|
||||||
|
import org.springframework.core.io.ClassPathResource;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace the constructor:
|
||||||
|
|
||||||
|
```java
|
||||||
|
private final LogtoManagementClient logtoClient;
|
||||||
|
private final ProvisioningProperties provisioningProps;
|
||||||
|
|
||||||
|
public EmailConnectorService(LogtoManagementClient logtoClient, ProvisioningProperties provisioningProps) {
|
||||||
|
this.logtoClient = logtoClient;
|
||||||
|
this.provisioningProps = provisioningProps;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace the `buildSmtpConfig` method (lines 157-191) with:
|
||||||
|
|
||||||
|
```java
|
||||||
|
/** Load an email template from classpath and resolve the watermark URL placeholder. */
|
||||||
|
private String loadTemplate(String filename) {
|
||||||
|
try {
|
||||||
|
String content = new ClassPathResource("email-templates/" + filename)
|
||||||
|
.getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
String watermarkUrl = provisioningProps.publicProtocol() + "://"
|
||||||
|
+ provisioningProps.publicHost() + "/platform/assets/email-watermark.png";
|
||||||
|
return content.replace("{{watermarkUrl}}", watermarkUrl);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalStateException("Failed to load email template: " + filename, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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", "Your caravan pass is almost ready",
|
||||||
|
"content", loadTemplate("register.html")
|
||||||
|
),
|
||||||
|
Map.of(
|
||||||
|
"usageType", "SignIn",
|
||||||
|
"contentType", "text/html",
|
||||||
|
"subject", "Your Cameleer sign-in code",
|
||||||
|
"content", loadTemplate("sign-in.html")
|
||||||
|
),
|
||||||
|
Map.of(
|
||||||
|
"usageType", "ForgotPassword",
|
||||||
|
"contentType", "text/html",
|
||||||
|
"subject", "Reset your Cameleer password",
|
||||||
|
"content", loadTemplate("forgot-password.html")
|
||||||
|
),
|
||||||
|
Map.of(
|
||||||
|
"usageType", "Generic",
|
||||||
|
"contentType", "text/html",
|
||||||
|
"subject", "Your Cameleer verification code",
|
||||||
|
"content", loadTemplate("generic.html")
|
||||||
|
)
|
||||||
|
));
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify the project compiles**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./mvnw compile -pl .
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: BUILD SUCCESS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run the template tests again to confirm nothing broke**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./mvnw test -pl . -Dtest=EmailTemplateLoadingTest -Dspring.profiles.active=test
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: All 5 tests PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorService.java
|
||||||
|
git add src/test/java/net/siegeln/cameleer/saas/vendor/EmailTemplateLoadingTest.java
|
||||||
|
git commit -m "feat: load email templates from classpath with watermark URL resolution"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Run the full test suite
|
||||||
|
|
||||||
|
**Files:** None (verification only)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run all tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./mvnw test -Dspring.profiles.active=test
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: BUILD SUCCESS, all tests pass. If any existing tests fail due to the new `ProvisioningProperties` constructor parameter on `EmailConnectorService`, they will need their mocks updated — but there are no existing tests for this class.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify the watermark is accessible without auth by checking SecurityConfig**
|
||||||
|
|
||||||
|
Confirm the `/assets/**` matcher is in the `permitAll()` chain (done in Task 2). With context-path `/platform`, the full public URL will be `https://<host>/platform/assets/email-watermark.png`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Final commit if any fixes were needed**
|
||||||
|
|
||||||
|
Only if test failures required changes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "fix: resolve test failures from email template refactor"
|
||||||
|
```
|
||||||
614
docs/superpowers/plans/2026-04-26-license-minter-integration.md
Normal file
614
docs/superpowers/plans/2026-04-26-license-minter-integration.md
Normal file
@@ -0,0 +1,614 @@
|
|||||||
|
# License Minter Integration — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Replace UUID-based license tokens with Ed25519-signed tokens minted by `cameleer-license-minter`, with full vendor UI for configurable minting, distribution, and verification.
|
||||||
|
|
||||||
|
**Architecture:** The SaaS platform embeds `cameleer-license-minter` as a Maven dependency and calls `LicenseMinter.mint()` with an Ed25519 private key stored in the DB. Signed tokens are pushed to tenant servers via env vars and REST API. The vendor UI provides tier presets with per-limit customization, copy/email distribution as env-var bundles, and a token verification tool.
|
||||||
|
|
||||||
|
**Tech Stack:** Spring Boot 3.4, JPA/Flyway/PostgreSQL, Ed25519 (JCE), `cameleer-license-minter` + `cameleer-server-core` (LicenseInfo, LicenseValidator), React 19, @cameleer/design-system, TanStack Query.
|
||||||
|
|
||||||
|
**Decisions:**
|
||||||
|
- Tiers renamed: LOW→STARTER, MID→TEAM, HIGH→BUSINESS, BUSINESS→ENTERPRISE
|
||||||
|
- Tiers are presets only — vendor can customize any limit (becomes "Custom" in UI)
|
||||||
|
- Private key stored in DB (signing_keys table)
|
||||||
|
- Features concept dropped — server enforces caps, not feature flags
|
||||||
|
- Standalone distribution: license bundle = token + public key + tenant ID as env vars
|
||||||
|
- Verify tool: paste token → decode + validate signature → show envelope + state
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Backend Foundation
|
||||||
|
|
||||||
|
### Task 1: Maven dependency + Flyway migration
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `pom.xml`
|
||||||
|
- Create: `src/main/resources/db/migration/V002__license_minter.sql`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add minter dependency to pom.xml**
|
||||||
|
|
||||||
|
Add inside `<dependencies>`:
|
||||||
|
```xml
|
||||||
|
<!-- License Minter (Ed25519 signing) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.cameleer</groupId>
|
||||||
|
<artifactId>cameleer-license-minter</artifactId>
|
||||||
|
<version>1.0-SNAPSHOT</version>
|
||||||
|
</dependency>
|
||||||
|
```
|
||||||
|
|
||||||
|
This transitively brings `cameleer-server-core` (for `LicenseInfo`, `LicenseValidator`).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create Flyway V002 migration**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- V002: License minter integration
|
||||||
|
-- Signing keys for Ed25519 license minting
|
||||||
|
CREATE TABLE signing_keys (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
public_key_b64 TEXT NOT NULL,
|
||||||
|
private_key_b64 TEXT NOT NULL,
|
||||||
|
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Rename tiers: LOW→STARTER, MID→TEAM, HIGH→BUSINESS, BUSINESS→ENTERPRISE
|
||||||
|
UPDATE tenants SET tier = 'STARTER' WHERE tier = 'LOW';
|
||||||
|
UPDATE tenants SET tier = 'TEAM' WHERE tier = 'MID';
|
||||||
|
UPDATE tenants SET tier = 'BUSINESS' WHERE tier = 'HIGH';
|
||||||
|
UPDATE tenants SET tier = 'ENTERPRISE' WHERE tier = 'BUSINESS';
|
||||||
|
-- Fix double-rename: HIGH→BUSINESS rows that got caught by BUSINESS→ENTERPRISE
|
||||||
|
-- Use a single pass via CASE to avoid this:
|
||||||
|
-- Actually, redo with CASE statement in a single UPDATE:
|
||||||
|
|
||||||
|
-- (Replace the 4 UPDATEs above with this single safe statement)
|
||||||
|
UPDATE tenants SET tier = CASE tier
|
||||||
|
WHEN 'LOW' THEN 'STARTER'
|
||||||
|
WHEN 'MID' THEN 'TEAM'
|
||||||
|
WHEN 'HIGH' THEN 'BUSINESS'
|
||||||
|
WHEN 'BUSINESS' THEN 'ENTERPRISE'
|
||||||
|
ELSE tier
|
||||||
|
END WHERE tier IN ('LOW', 'MID', 'HIGH', 'BUSINESS');
|
||||||
|
|
||||||
|
-- Same for licenses table
|
||||||
|
UPDATE licenses SET tier = CASE tier
|
||||||
|
WHEN 'LOW' THEN 'STARTER'
|
||||||
|
WHEN 'MID' THEN 'TEAM'
|
||||||
|
WHEN 'HIGH' THEN 'BUSINESS'
|
||||||
|
WHEN 'BUSINESS' THEN 'ENTERPRISE'
|
||||||
|
ELSE tier
|
||||||
|
END WHERE tier IN ('LOW', 'MID', 'HIGH', 'BUSINESS');
|
||||||
|
|
||||||
|
-- Add new license columns
|
||||||
|
ALTER TABLE licenses ADD COLUMN label VARCHAR(255);
|
||||||
|
ALTER TABLE licenses ADD COLUMN grace_period_days INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- Drop features column (server enforces caps, not feature flags)
|
||||||
|
ALTER TABLE licenses DROP COLUMN features;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify build compiles**
|
||||||
|
|
||||||
|
Run: `mvn compile -q` (just compile, no tests yet — tests will break until Tier enum is updated)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat: add cameleer-license-minter dependency and V002 migration
|
||||||
|
|
||||||
|
Adds Ed25519 license minting library, signing_keys table,
|
||||||
|
renames tiers (LOW→STARTER, MID→TEAM, HIGH→BUSINESS, BUSINESS→ENTERPRISE),
|
||||||
|
adds label + grace_period_days to licenses, drops features column.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 2: Tier enum rename + LicenseDefaults rewrite
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/tenant/Tier.java`
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/tenant/TenantEntity.java`
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/tenant/TenantService.java`
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/license/LicenseDefaults.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update Tier enum**
|
||||||
|
|
||||||
|
```java
|
||||||
|
package net.siegeln.cameleer.saas.tenant;
|
||||||
|
|
||||||
|
public enum Tier {
|
||||||
|
STARTER, TEAM, BUSINESS, ENTERPRISE
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update TenantEntity default**
|
||||||
|
|
||||||
|
Change `private Tier tier = Tier.LOW;` to `private Tier tier = Tier.STARTER;`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update TenantService fallback**
|
||||||
|
|
||||||
|
Change `Tier.valueOf(request.tier()) : Tier.LOW` to `Tier.valueOf(request.tier()) : Tier.STARTER`
|
||||||
|
|
||||||
|
- [ ] **Step 4: Rewrite LicenseDefaults**
|
||||||
|
|
||||||
|
Replace entire file with 13-key limits per tier matching the handoff cap matrix. Drop `featuresForTier()`. Only `limitsForTier()`.
|
||||||
|
|
||||||
|
```java
|
||||||
|
package net.siegeln.cameleer.saas.license;
|
||||||
|
|
||||||
|
import net.siegeln.cameleer.saas.tenant.Tier;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public final class LicenseDefaults {
|
||||||
|
|
||||||
|
private LicenseDefaults() {}
|
||||||
|
|
||||||
|
public static final int DEFAULT_GRACE_PERIOD_DAYS = 14;
|
||||||
|
public static final int DEFAULT_LICENSE_DAYS = 365;
|
||||||
|
|
||||||
|
public static Map<String, Integer> limitsForTier(Tier tier) {
|
||||||
|
return switch (tier) {
|
||||||
|
case STARTER -> Map.ofEntries(
|
||||||
|
Map.entry("max_environments", 2),
|
||||||
|
Map.entry("max_apps", 10),
|
||||||
|
Map.entry("max_agents", 20),
|
||||||
|
Map.entry("max_users", 5),
|
||||||
|
Map.entry("max_outbound_connections", 5),
|
||||||
|
Map.entry("max_alert_rules", 10),
|
||||||
|
Map.entry("max_total_cpu_millis", 8000),
|
||||||
|
Map.entry("max_total_memory_mb", 8192),
|
||||||
|
Map.entry("max_total_replicas", 25),
|
||||||
|
Map.entry("max_execution_retention_days", 7),
|
||||||
|
Map.entry("max_log_retention_days", 7),
|
||||||
|
Map.entry("max_metric_retention_days", 7),
|
||||||
|
Map.entry("max_jar_retention_count", 5)
|
||||||
|
);
|
||||||
|
case TEAM -> Map.ofEntries(
|
||||||
|
Map.entry("max_environments", 5),
|
||||||
|
Map.entry("max_apps", 50),
|
||||||
|
Map.entry("max_agents", 100),
|
||||||
|
Map.entry("max_users", 25),
|
||||||
|
Map.entry("max_outbound_connections", 25),
|
||||||
|
Map.entry("max_alert_rules", 50),
|
||||||
|
Map.entry("max_total_cpu_millis", 32000),
|
||||||
|
Map.entry("max_total_memory_mb", 32768),
|
||||||
|
Map.entry("max_total_replicas", 100),
|
||||||
|
Map.entry("max_execution_retention_days", 30),
|
||||||
|
Map.entry("max_log_retention_days", 30),
|
||||||
|
Map.entry("max_metric_retention_days", 30),
|
||||||
|
Map.entry("max_jar_retention_count", 10)
|
||||||
|
);
|
||||||
|
case BUSINESS -> Map.ofEntries(
|
||||||
|
Map.entry("max_environments", 10),
|
||||||
|
Map.entry("max_apps", 200),
|
||||||
|
Map.entry("max_agents", 500),
|
||||||
|
Map.entry("max_users", 100),
|
||||||
|
Map.entry("max_outbound_connections", 100),
|
||||||
|
Map.entry("max_alert_rules", 200),
|
||||||
|
Map.entry("max_total_cpu_millis", 128000),
|
||||||
|
Map.entry("max_total_memory_mb", 131072),
|
||||||
|
Map.entry("max_total_replicas", 500),
|
||||||
|
Map.entry("max_execution_retention_days", 90),
|
||||||
|
Map.entry("max_log_retention_days", 90),
|
||||||
|
Map.entry("max_metric_retention_days", 90),
|
||||||
|
Map.entry("max_jar_retention_count", 25)
|
||||||
|
);
|
||||||
|
case ENTERPRISE -> Map.ofEntries(
|
||||||
|
Map.entry("max_environments", 50),
|
||||||
|
Map.entry("max_apps", 1000),
|
||||||
|
Map.entry("max_agents", 5000),
|
||||||
|
Map.entry("max_users", 1000),
|
||||||
|
Map.entry("max_outbound_connections", 500),
|
||||||
|
Map.entry("max_alert_rules", 1000),
|
||||||
|
Map.entry("max_total_cpu_millis", 512000),
|
||||||
|
Map.entry("max_total_memory_mb", 524288),
|
||||||
|
Map.entry("max_total_replicas", 2000),
|
||||||
|
Map.entry("max_execution_retention_days", 365),
|
||||||
|
Map.entry("max_log_retention_days", 180),
|
||||||
|
Map.entry("max_metric_retention_days", 180),
|
||||||
|
Map.entry("max_jar_retention_count", 50)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
refactor: rename tiers and rewrite LicenseDefaults to 13-key cap matrix
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 3: SigningKeyService + SigningKeyEntity
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/main/java/net/siegeln/cameleer/saas/license/SigningKeyEntity.java`
|
||||||
|
- Create: `src/main/java/net/siegeln/cameleer/saas/license/SigningKeyRepository.java`
|
||||||
|
- Create: `src/main/java/net/siegeln/cameleer/saas/license/SigningKeyService.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create SigningKeyEntity**
|
||||||
|
|
||||||
|
JPA entity for the `signing_keys` table: id (UUID), publicKeyB64 (text), privateKeyB64 (text), active (boolean), createdAt (Instant).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create SigningKeyRepository**
|
||||||
|
|
||||||
|
JpaRepository with `Optional<SigningKeyEntity> findByActiveTrue()`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create SigningKeyService**
|
||||||
|
|
||||||
|
Methods:
|
||||||
|
- `getOrCreateActiveKey()` → returns the active key, generating a new Ed25519 keypair on first call
|
||||||
|
- `getPublicKeyBase64()` → convenience for the active key's public key
|
||||||
|
- `getPrivateKey()` → reconstructs `PrivateKey` from stored base64
|
||||||
|
|
||||||
|
Key generation:
|
||||||
|
```java
|
||||||
|
KeyPair kp = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
|
||||||
|
String pubB64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
|
||||||
|
String privB64 = Base64.getEncoder().encodeToString(kp.getPrivate().getEncoded());
|
||||||
|
```
|
||||||
|
|
||||||
|
Private key reconstruction:
|
||||||
|
```java
|
||||||
|
byte[] keyBytes = Base64.getDecoder().decode(entity.getPrivateKeyB64());
|
||||||
|
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes);
|
||||||
|
return KeyFactory.getInstance("Ed25519").generatePrivate(spec);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat: add SigningKeyService for Ed25519 keypair management
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 4: Rewrite LicenseService + LicenseEntity
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/license/LicenseEntity.java`
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/license/LicenseService.java`
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/license/dto/LicenseResponse.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update LicenseEntity**
|
||||||
|
|
||||||
|
- Remove `features` field + getter/setter
|
||||||
|
- Add `label` (String) field + getter/setter
|
||||||
|
- Add `gracePeriodDays` (int) field + getter/setter
|
||||||
|
|
||||||
|
- [ ] **Step 2: Rewrite LicenseService**
|
||||||
|
|
||||||
|
- Add `SigningKeyService` dependency
|
||||||
|
- Rewrite `generateLicense(TenantEntity, Map<String,Integer> limits, Instant expiresAt, int gracePeriodDays, String label, UUID actorId)`:
|
||||||
|
- Build `LicenseInfo(UUID.randomUUID(), tenant.getSlug(), label, limits, Instant.now(), expiresAt, gracePeriodDays)`
|
||||||
|
- Call `LicenseMinter.mint(info, signingKeyService.getPrivateKey())`
|
||||||
|
- Store signed token in entity
|
||||||
|
- Add convenience overload `generateLicense(TenantEntity, Duration, UUID actorId)` that uses tier presets
|
||||||
|
- Remove `verifyLicenseToken()` (server validates cryptographically)
|
||||||
|
- Add `verifyToken(String token)` that uses `LicenseValidator`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update LicenseResponse DTO**
|
||||||
|
|
||||||
|
Replace `features` with `label` and `gracePeriodDays`. Add `publicKeyB64` for bundle distribution.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat: rewrite LicenseService to mint Ed25519-signed tokens
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 5: Update controllers + portal service
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/license/LicenseController.java`
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantController.java`
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java`
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java`
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalController.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update VendorTenantController**
|
||||||
|
|
||||||
|
- `POST /{id}/license` now takes a request body with limits, expiresAt, gracePeriodDays, label
|
||||||
|
- Add `GET /license-presets` endpoint returning tier presets
|
||||||
|
- Add `POST /license/verify` endpoint
|
||||||
|
- Add `GET /signing-key/public` endpoint
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update VendorTenantService**
|
||||||
|
|
||||||
|
- `renewLicense()` updated to accept customizable parameters
|
||||||
|
- Add `mintLicense()` method with full limit configuration
|
||||||
|
- Add `verifyToken()` delegation
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update VendorTenantController response types**
|
||||||
|
|
||||||
|
- `VendorTenantSummary` — fix `agentLimit` to use `max_agents` key
|
||||||
|
- `VendorTenantDetail` — license field uses updated LicenseResponse
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update TenantPortalService**
|
||||||
|
|
||||||
|
- `DashboardData` — drop features, keep limits
|
||||||
|
- `LicenseData` — drop features, add label + gracePeriodDays
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat: update vendor/portal APIs for Ed25519 license minting
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 6: Fix tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/test/java/net/siegeln/cameleer/saas/license/LicenseServiceTest.java`
|
||||||
|
- Modify: `src/test/java/net/siegeln/cameleer/saas/license/LicenseControllerTest.java`
|
||||||
|
- Modify: `src/test/java/net/siegeln/cameleer/saas/vendor/VendorTenantServiceTest.java`
|
||||||
|
- Modify: `src/test/java/net/siegeln/cameleer/saas/tenant/TenantServiceTest.java`
|
||||||
|
- Modify: `src/test/java/net/siegeln/cameleer/saas/portal/TenantPortalServiceTest.java`
|
||||||
|
- Modify: `src/test/java/net/siegeln/cameleer/saas/portal/TenantPortalControllerTest.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update all Tier.LOW→STARTER, Tier.MID→TEAM, Tier.HIGH→BUSINESS, Tier.BUSINESS→ENTERPRISE**
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update LicenseServiceTest**
|
||||||
|
|
||||||
|
- `generateLicense_producesUuidToken` → rename to `generateLicense_producesSignedToken`, assert token contains `.` separator
|
||||||
|
- Remove feature-related assertions
|
||||||
|
- Mock `SigningKeyService` to return a test keypair
|
||||||
|
- Remove `verifyLicenseToken` tests
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update LicenseControllerTest**
|
||||||
|
|
||||||
|
- Remove feature assertions (`features.correlation`)
|
||||||
|
- Update tier values in assertions
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests**
|
||||||
|
|
||||||
|
Run: `mvn test -q`
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
test: update tests for Ed25519 license minting and tier rename
|
||||||
|
```
|
||||||
|
|
||||||
|
## Phase 2: Provisioning Integration
|
||||||
|
|
||||||
|
### Task 7: Push public key to tenant containers
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/provisioning/DockerTenantProvisioner.java`
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Inject SigningKeyService into DockerTenantProvisioner**
|
||||||
|
|
||||||
|
Add `SigningKeyService` as a constructor dependency.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add CAMELEER_SERVER_LICENSE_PUBLICKEY env var**
|
||||||
|
|
||||||
|
In `createServerContainer()`, after the existing env vars, add:
|
||||||
|
```java
|
||||||
|
"CAMELEER_SERVER_LICENSE_PUBLICKEY=" + signingKeyService.getPublicKeyBase64()
|
||||||
|
```
|
||||||
|
|
||||||
|
`CAMELEER_SERVER_TENANT_ID` is already set to slug (line 218).
|
||||||
|
`CAMELEER_SERVER_LICENSE_TOKEN` is already set (line 225).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat: push Ed25519 public key to tenant server containers
|
||||||
|
```
|
||||||
|
|
||||||
|
## Phase 3: Vendor API — Configurable Minting
|
||||||
|
|
||||||
|
### Task 8: Vendor license endpoints
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantController.java`
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java`
|
||||||
|
- Create: `src/main/java/net/siegeln/cameleer/saas/license/dto/MintLicenseRequest.java`
|
||||||
|
- Create: `src/main/java/net/siegeln/cameleer/saas/license/dto/VerifyLicenseRequest.java`
|
||||||
|
- Create: `src/main/java/net/siegeln/cameleer/saas/license/dto/VerifyLicenseResponse.java`
|
||||||
|
- Create: `src/main/java/net/siegeln/cameleer/saas/license/dto/LicensePreset.java`
|
||||||
|
- Create: `src/main/java/net/siegeln/cameleer/saas/license/dto/LicenseBundleResponse.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create DTOs**
|
||||||
|
|
||||||
|
`MintLicenseRequest`: tier (optional String), limits (Map<String,Integer>), expiresAt (Instant), gracePeriodDays (Integer), label (String), pushToServer (boolean)
|
||||||
|
|
||||||
|
`VerifyLicenseRequest`: token (String)
|
||||||
|
|
||||||
|
`VerifyLicenseResponse`: valid (boolean), state (String), envelope fields (tenantId, label, limits, issuedAt, expiresAt, gracePeriodDays), error (String)
|
||||||
|
|
||||||
|
`LicensePreset`: tier (String), limits (Map<String,Integer>)
|
||||||
|
|
||||||
|
`LicenseBundleResponse`: extends LicenseResponse + adds publicKeyB64, tenantSlug (for the env-var bundle)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update VendorTenantService**
|
||||||
|
|
||||||
|
Add `mintLicense(UUID tenantId, MintLicenseRequest request, UUID actorId)`:
|
||||||
|
- Resolves limits from request (or tier preset)
|
||||||
|
- Calls `licenseService.generateLicense()` with full params
|
||||||
|
- Optionally pushes to server
|
||||||
|
- Returns the license + public key + slug for the bundle
|
||||||
|
|
||||||
|
Add `verifyToken(String token)`:
|
||||||
|
- Uses LicenseValidator from server-core
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update VendorTenantController**
|
||||||
|
|
||||||
|
- `POST /{id}/license` — takes MintLicenseRequest body, returns LicenseBundleResponse
|
||||||
|
- `GET /license-presets` — returns list of LicensePreset
|
||||||
|
- `POST /license/verify` — takes VerifyLicenseRequest, returns VerifyLicenseResponse
|
||||||
|
- `GET /signing-key/public` — returns `{"publicKey": "<base64>"}`
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat: add vendor license minting, presets, and verify endpoints
|
||||||
|
```
|
||||||
|
|
||||||
|
## Phase 4: Vendor UI — License Minting
|
||||||
|
|
||||||
|
### Task 9: Update frontend types + hooks
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `ui/src/types/api.ts`
|
||||||
|
- Modify: `ui/src/api/vendor-hooks.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update types**
|
||||||
|
|
||||||
|
- `LicenseResponse` — remove `features`, add `label`, `gracePeriodDays`, `publicKeyB64`, `tenantSlug`
|
||||||
|
- Add `MintLicenseRequest`, `VerifyLicenseRequest`, `VerifyLicenseResponse`, `LicensePreset`, `LicenseBundleResponse`
|
||||||
|
- `DashboardData` — remove `features`
|
||||||
|
- `TenantLicenseData` — remove `features`, add `label`, `gracePeriodDays`
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update hooks**
|
||||||
|
|
||||||
|
- `useRenewLicense()` → replace with `useMintLicense(tenantId)` that takes MintLicenseRequest body
|
||||||
|
- Add `useLicensePresets()`
|
||||||
|
- Add `useVerifyLicense()`
|
||||||
|
- Add `usePublicKey()`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(ui): update types and hooks for Ed25519 license minting
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 10: License minting form on TenantDetailPage
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `ui/src/pages/vendor/TenantDetailPage.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace License card**
|
||||||
|
|
||||||
|
Replace the simple "Renew License" button with a minting form:
|
||||||
|
- Tier preset dropdown (STARTER/TEAM/BUSINESS/ENTERPRISE) that pre-fills limits
|
||||||
|
- All 13 limits editable in a grid
|
||||||
|
- Expiry date picker, grace period input, label input
|
||||||
|
- "Custom" indicator when limits diverge from preset
|
||||||
|
- Actions: "Mint & Push to Server" (default), "Mint & Copy Bundle", "Mint & Email Bundle"
|
||||||
|
|
||||||
|
- [ ] **Step 2: License bundle display**
|
||||||
|
|
||||||
|
After minting, show a dialog/card with the full env-var bundle:
|
||||||
|
```
|
||||||
|
CAMELEER_SERVER_TENANT_ID=<slug>
|
||||||
|
CAMELEER_SERVER_LICENSE_PUBLICKEY=<public_key>
|
||||||
|
CAMELEER_SERVER_LICENSE_TOKEN=<token>
|
||||||
|
```
|
||||||
|
With a "Copy Bundle" button.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(ui): add license minting form with tier presets and bundle distribution
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 11: License verify tool + public key viewer
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `ui/src/pages/vendor/LicenseVerifyPage.tsx`
|
||||||
|
- Modify: `ui/src/router.tsx` (add route)
|
||||||
|
- Modify: `ui/src/Layout.tsx` (add nav item)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create LicenseVerifyPage**
|
||||||
|
|
||||||
|
- Textarea to paste a token
|
||||||
|
- "Verify" button
|
||||||
|
- Results: valid/invalid badge, decoded envelope (tenantId, label, limits, expiry, grace period)
|
||||||
|
- State badge (ACTIVE/GRACE/EXPIRED/INVALID)
|
||||||
|
- Public key display section with copy button
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add route and navigation**
|
||||||
|
|
||||||
|
Route: `/vendor/license-verify`
|
||||||
|
Nav: "License Tools" section in vendor sidebar
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(ui): add license verify tool and public key viewer
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 12: Update tier color utility
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `ui/src/utils/tier.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update tierColor**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function tierColor(tier: string): 'primary' | 'success' | 'warning' | 'error' | 'running' | 'auto' {
|
||||||
|
switch (tier?.toUpperCase()) {
|
||||||
|
case 'ENTERPRISE': return 'success';
|
||||||
|
case 'BUSINESS': return 'primary';
|
||||||
|
case 'TEAM': return 'running';
|
||||||
|
case 'STARTER': return 'warning';
|
||||||
|
default: return 'auto';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
fix(ui): update tier color mapping for renamed tiers
|
||||||
|
```
|
||||||
|
|
||||||
|
## Phase 5: Tenant UI Updates
|
||||||
|
|
||||||
|
### Task 13: Update TenantLicensePage
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `ui/src/pages/tenant/TenantLicensePage.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Remove features card, update limits card**
|
||||||
|
|
||||||
|
- Drop the "Features" card entirely
|
||||||
|
- Update "Limits & Usage" card to show all 13 limit keys with proper labels
|
||||||
|
- Show grace period and label if present
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(ui): update tenant license page for Ed25519 model
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 14: Update TenantDashboardPage
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `ui/src/pages/tenant/TenantDashboardPage.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Remove features references**
|
||||||
|
|
||||||
|
Drop any `features` display. Keep limits display.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
fix(ui): remove features from tenant dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 15: Update CreateTenantPage
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `ui/src/pages/vendor/CreateTenantPage.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update tier options**
|
||||||
|
|
||||||
|
Change tier dropdown options from LOW/MID/HIGH/BUSINESS to STARTER/TEAM/BUSINESS/ENTERPRISE.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
fix(ui): update tier options in create tenant form
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
After all tasks:
|
||||||
|
- [ ] `mvn test` passes
|
||||||
|
- [ ] `cd ui && npm run build` succeeds
|
||||||
|
- [ ] Docker compose boots (if available)
|
||||||
|
- [ ] Verify a tenant can be created with STARTER tier
|
||||||
|
- [ ] Verify license is minted with Ed25519 signature (token contains `.`)
|
||||||
|
- [ ] Verify CAMELEER_SERVER_LICENSE_PUBLICKEY appears in container env
|
||||||
2120
docs/superpowers/plans/2026-04-26-password-reset-mfa-plan.md
Normal file
2120
docs/superpowers/plans/2026-04-26-password-reset-mfa-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)
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# Email Template Polish
|
||||||
|
|
||||||
|
Polish the 4 Logto SMTP connector email templates with branded visuals, playful desert/caravan copy, and extract them from inline Java strings to standalone HTML files.
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
All 4 email templates are hardcoded as inline HTML strings in `EmailConnectorService.buildSmtpConfig()` (lines 164-189). They share a minimal structure: centered text "Cameleer" wordmark in `#C6820E`, a one-line message, a large verification code, and a small expiry note. No logo, no footer, no personality.
|
||||||
|
|
||||||
|
## Design Decisions
|
||||||
|
|
||||||
|
- **Tone:** Playful desert/caravan voice matching the sign-in page personality
|
||||||
|
- **Layout:** Structured card — amber header bar, white body with watermark, footer with separator
|
||||||
|
- **Footer:** Help link ("Questions? Contact your administrator") + tagline ("Cameleer — Apache Camel observability")
|
||||||
|
- **Header:** Text-only "Cameleer.io" centered on amber `#C6820E` bar, no logo image
|
||||||
|
- **Watermark:** Cameleer logo at ~7% opacity, oversized, positioned top-right showing compass + cameleer + camel head. For production: pre-faded PNG hosted at a public URL to avoid CSS opacity issues in Outlook desktop.
|
||||||
|
- **Storage:** External HTML template files, not inline Java strings
|
||||||
|
|
||||||
|
## Template Structure
|
||||||
|
|
||||||
|
All 4 templates share the same layout, differing only in subject, headline, body copy, and safety note.
|
||||||
|
|
||||||
|
```
|
||||||
|
+------------------------------------------+
|
||||||
|
| [amber #C6820E header bar] |
|
||||||
|
| Cameleer.io (white) |
|
||||||
|
+------------------------------------------+
|
||||||
|
| |
|
||||||
|
| Headline (bold, 16px) [watermark |
|
||||||
|
| Body text (14px, 1.6lh) logo at |
|
||||||
|
| 7% opacity|
|
||||||
|
| +-------------------+ |
|
||||||
|
| | VERIFICATION CODE | |
|
||||||
|
| | cream pill, amber | |
|
||||||
|
| | border, monospace | |
|
||||||
|
| +-------------------+ |
|
||||||
|
| |
|
||||||
|
| Expiry/safety note (13px, muted) |
|
||||||
|
| |
|
||||||
|
+------------------------------------------+
|
||||||
|
| Questions? Contact your administrator |
|
||||||
|
| Cameleer — Apache Camel observability |
|
||||||
|
+------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
## Copy
|
||||||
|
|
||||||
|
| Type | Subject | Headline | Body | Safety note |
|
||||||
|
|------|---------|----------|------|-------------|
|
||||||
|
| Register | Your caravan pass is almost ready | Welcome to the caravan! | Enter this code to verify your email and claim your spot. The dunes wait for no one. | This code expires in 10 minutes. If you didn't request this, you can safely ignore this email — no camels were harmed. |
|
||||||
|
| SignIn | Your Cameleer sign-in code | Back at the oasis already? | Here's your sign-in code. The caravan master is checking credentials. | This code expires in 10 minutes. |
|
||||||
|
| ForgotPassword | Reset your Cameleer password | Lost in the dunes? | No worries — enter this code to reset your password and get back on the trail. | This code expires in 10 minutes. If you didn't request a password reset, you can safely ignore this email. |
|
||||||
|
| Generic | Your Cameleer verification code | Quick checkpoint | Here's your verification code. Just making sure it's really you at the reins. | This code expires in 10 minutes. |
|
||||||
|
|
||||||
|
## Visual Specifications
|
||||||
|
|
||||||
|
- **Font stack:** `-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif`
|
||||||
|
- **Card max-width:** 480px, no fixed width (shrinks naturally on mobile)
|
||||||
|
- **Card border:** `1px solid #e8e0d4`
|
||||||
|
- **Header:** `background: #C6820E`, padding 20px 24px, text centered, 22px bold white
|
||||||
|
- **Code pill:** `background: #FDF6EC`, `border: 2px solid #C6820E`, border-radius 8px, padding 16px 32px, 32px bold monospace with 8px letter-spacing in `#C6820E`
|
||||||
|
- **Watermark:** Absolutely positioned `top:-30px; right:-50px`, 320x320px, 7% opacity, clipped by `overflow: hidden` on body container
|
||||||
|
- **Footer separator:** `1px solid #e8e0d4`
|
||||||
|
- **Footer text:** "Questions?" at 12px `#999`, tagline at 11px `#bbb`
|
||||||
|
|
||||||
|
## Responsiveness
|
||||||
|
|
||||||
|
No media queries needed. The single-column layout works from ~280px up:
|
||||||
|
- Card uses `max-width: 480px` with no fixed width
|
||||||
|
- Code pill uses `display: inline-block`, wraps within container
|
||||||
|
- All sizes in px (email clients handle rem/em inconsistently)
|
||||||
|
- Watermark clipped by `overflow: hidden`, never causes horizontal scroll
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
### Template files
|
||||||
|
|
||||||
|
Create 4 HTML template files at `src/main/resources/email-templates/`:
|
||||||
|
|
||||||
|
```
|
||||||
|
src/main/resources/email-templates/
|
||||||
|
register.html
|
||||||
|
sign-in.html
|
||||||
|
forgot-password.html
|
||||||
|
generic.html
|
||||||
|
```
|
||||||
|
|
||||||
|
Each file is a complete HTML email body (inline styles, self-contained). The verification code placeholder uses Logto's `{{code}}` syntax.
|
||||||
|
|
||||||
|
### Watermark image
|
||||||
|
|
||||||
|
Create a pre-faded PNG of the Cameleer logo at 7% opacity on a transparent background. Source the logo from `design-system/assets/cameleer-logo.png` and generate the faded version using ImageMagick or similar (one-time step, committed to the repo).
|
||||||
|
|
||||||
|
Place the image at `src/main/resources/static/platform/assets/email-watermark.png`. Spring Boot serves `/platform/assets/**` as static resources automatically. The template files use a placeholder `{{watermarkUrl}}` that `EmailConnectorService` replaces with `https://<PUBLIC_HOST>/platform/assets/email-watermark.png` at runtime.
|
||||||
|
|
||||||
|
### Java changes
|
||||||
|
|
||||||
|
`EmailConnectorService.buildSmtpConfig()`:
|
||||||
|
- Read each template file from classpath (`src/main/resources/email-templates/*.html`) at startup or on first use
|
||||||
|
- Replace `{{watermarkUrl}}` with the configured public host URL
|
||||||
|
- Pass the HTML content as the `content` field in each Logto template config
|
||||||
|
- Keep subjects in Java (they're short strings, no benefit from externalizing)
|
||||||
|
|
||||||
|
## Email Client Compatibility
|
||||||
|
|
||||||
|
- **Gmail, Apple Mail, Outlook.com:** Full support — opacity, absolute positioning, border-radius all work
|
||||||
|
- **Outlook desktop (Word renderer):** CSS `opacity` is ignored. The pre-faded watermark PNG solves this — the transparency is baked into the image itself, not applied via CSS. Absolute positioning is supported via VML fallback that Outlook generates.
|
||||||
|
- **No CSS classes:** Everything uses inline styles (email clients strip `<style>` blocks inconsistently)
|
||||||
|
- **No external fonts:** System font stack only
|
||||||
342
docs/superpowers/specs/2026-04-26-password-reset-mfa-design.md
Normal file
342
docs/superpowers/specs/2026-04-26-password-reset-mfa-design.md
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
# Password Reset & Multi-Factor Authentication Design
|
||||||
|
|
||||||
|
**Date**: 2026-04-26
|
||||||
|
**Status**: Approved
|
||||||
|
**Scope**: Self-service password reset, TOTP MFA with backup codes, per-tenant MFA enforcement
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Add two missing auth capabilities to the Cameleer SaaS platform:
|
||||||
|
|
||||||
|
1. **Self-service password reset** — "Forgot password?" flow in the custom sign-in UI, using Logto's Experience API and the already-configured `ForgotPassword` email template.
|
||||||
|
2. **Multi-factor authentication** — TOTP (authenticator app) as primary factor, backup codes for recovery. Per-tenant enforcement (tenant admins control whether MFA is required for their org) plus optional opt-in for any user.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
| Decision | Choice | Rationale |
|
||||||
|
|----------|--------|-----------|
|
||||||
|
| MFA methods | TOTP + backup codes | Universal, no extra infra. WebAuthn can be added later. |
|
||||||
|
| MFA policy at Logto level | `UserControlled` | Per-tenant enforcement is application-layer; Logto stays permissive. |
|
||||||
|
| Enforcement mechanism | JWT claim (`mfa_enrolled`) | Stateless, works for both SaaS and cameleer-server, extends existing custom JWT script. |
|
||||||
|
| MFA during password reset | Not required | Email verification code is sufficient proof of identity. Requiring TOTP risks deadlock if user lost both. |
|
||||||
|
| Enrollment location (SaaS) | Tenant portal Settings page | Natural home next to existing password change. |
|
||||||
|
| Enrollment location (server) | Cameleer-server UI (handoff doc) | Regular org members interact with server UI day-to-day. |
|
||||||
|
| MFA requirement storage | `settings` JSONB on `TenantEntity` | No migration needed. |
|
||||||
|
|
||||||
|
## 1. Password Reset Flow
|
||||||
|
|
||||||
|
### UI Changes (`ui/sign-in/src/SignInPage.tsx`)
|
||||||
|
|
||||||
|
Add a "Forgot password?" link below the password field on the sign-in form. New mode `forgotPassword` with two sub-steps:
|
||||||
|
|
||||||
|
1. **Email entry** — user enters their email, clicks "Send code"
|
||||||
|
2. **Code + new password** — 6-digit verification code (reuse existing OTP input pattern from registration) + new password + confirm password
|
||||||
|
|
||||||
|
The "Forgot password?" link is hidden when no email connector is configured — check the same sign-in experience config already fetched at page load.
|
||||||
|
|
||||||
|
### Experience API Flow (`ui/sign-in/src/experience-api.ts`)
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /api/experience
|
||||||
|
{ interactionEvent: 'ForgotPassword' }
|
||||||
|
|
||||||
|
POST /api/experience/verification/verification-code
|
||||||
|
{ identifier: { type: 'email', value: email }, interactionEvent: 'ForgotPassword' }
|
||||||
|
|
||||||
|
POST /api/experience/verification/verification-code/verify
|
||||||
|
{ identifier: { type: 'email', value: email }, verificationId, code }
|
||||||
|
|
||||||
|
POST /api/experience/identification
|
||||||
|
{ verificationId }
|
||||||
|
|
||||||
|
POST /api/experience/profile
|
||||||
|
{ type: 'password', value: newPassword }
|
||||||
|
|
||||||
|
POST /api/experience/submit
|
||||||
|
-> redirect to sign-in page
|
||||||
|
```
|
||||||
|
|
||||||
|
### Security Notification Email
|
||||||
|
|
||||||
|
After a successful password reset, Logto sends a confirmation redirect — but no security awareness email. The SaaS backend sends a separate **security notification email** to the user's address with:
|
||||||
|
|
||||||
|
- Subject: "Your Cameleer password was reset"
|
||||||
|
- Body: confirms the password was changed, states that **MFA was not required for this change**, and recommends enabling MFA if not already enrolled
|
||||||
|
- Includes a timestamp and "If this wasn't you, contact your administrator immediately"
|
||||||
|
|
||||||
|
This is triggered by the custom sign-in UI after the Experience API `submit` succeeds — a `POST /api/password-reset-notification` call to the SaaS backend with the user's email. The backend sends the email via the configured SMTP connector. The endpoint is unauthenticated (the user hasn't signed in yet) but rate-limited and accepts only email addresses that exist in Logto.
|
||||||
|
|
||||||
|
### Backend Changes
|
||||||
|
|
||||||
|
Minimal. The password reset flow itself is entirely between the custom sign-in UI and Logto's Experience API. The `ForgotPassword` email template is already configured in `EmailConnectorService`. The only new backend work is the security notification endpoint above.
|
||||||
|
|
||||||
|
## 2. MFA Configuration (Logto Bootstrap)
|
||||||
|
|
||||||
|
### Bootstrap Changes (`docker/logto-bootstrap.sh`)
|
||||||
|
|
||||||
|
Add MFA configuration to the existing Phase 8c sign-in experience patch:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mfa": {
|
||||||
|
"factors": ["Totp", "BackupCode"],
|
||||||
|
"policy": "UserControlled"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This is one additional field in the existing `PATCH /api/sign-in-exp` call — not a separate bootstrap phase.
|
||||||
|
|
||||||
|
### Custom JWT Script Extension (Phase 7b)
|
||||||
|
|
||||||
|
Extend the existing `getCustomJwtClaims` script to include an `mfa_enrolled` claim. Logto's custom JWT context provides `context.user.mfaVerificationFactors` — an array of the user's enrolled MFA factors.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const getCustomJwtClaims = async ({ token, context, environmentVariables }) => {
|
||||||
|
const roleMap = { owner: "server:admin", operator: "server:operator", viewer: "server:viewer" };
|
||||||
|
const roles = new Set();
|
||||||
|
if (context?.user?.organizationRoles) {
|
||||||
|
for (const orgRole of context.user.organizationRoles) {
|
||||||
|
const mapped = roleMap[orgRole.roleName];
|
||||||
|
if (mapped) roles.add(mapped);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (context?.user?.roles) {
|
||||||
|
for (const role of context.user.roles) {
|
||||||
|
if (role.name === "saas-vendor") roles.add("server:admin");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MFA enrollment status
|
||||||
|
const mfaFactors = context?.user?.mfaVerificationFactors || [];
|
||||||
|
const mfaEnrolled = mfaFactors.some(f => f.type === 'Totp');
|
||||||
|
|
||||||
|
const claims = {};
|
||||||
|
if (roles.size > 0) claims.roles = [...roles];
|
||||||
|
claims.mfa_enrolled = mfaEnrolled;
|
||||||
|
return claims;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Every JWT now carries `mfa_enrolled: true/false`, readable by both SaaS backend and cameleer-server.
|
||||||
|
|
||||||
|
## 3. MFA Verification at Sign-in
|
||||||
|
|
||||||
|
### How Logto Signals MFA Is Needed
|
||||||
|
|
||||||
|
After password verification + identification, `POST /api/experience/submit` returns an error with code `mfa_factor_not_satisfied` instead of a redirect URL. The custom sign-in UI intercepts this and prompts for TOTP.
|
||||||
|
|
||||||
|
### UI Changes (`ui/sign-in/src/SignInPage.tsx`)
|
||||||
|
|
||||||
|
New mode `mfaVerify` — shown when submit returns `mfa_factor_not_satisfied`:
|
||||||
|
|
||||||
|
- 6-digit TOTP code input (reuse existing OTP input pattern)
|
||||||
|
- **Prominent backup code fallback** — not a subtle link. Below the TOTP input, a clearly visible secondary action: "Lost your device? Use a backup code" styled as a distinct button or card (not a footnote-sized link). Users in a panic skip small text. The backup code view shows a single text input with clear instructions: "Enter one of your 10 backup codes"
|
||||||
|
|
||||||
|
### Experience API Flow
|
||||||
|
|
||||||
|
**TOTP verification:**
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/experience/verification/totp/verify
|
||||||
|
{ code: '123456' }
|
||||||
|
-> returns { verificationId }
|
||||||
|
|
||||||
|
POST /api/experience/identification
|
||||||
|
{ verificationId }
|
||||||
|
|
||||||
|
POST /api/experience/submit
|
||||||
|
-> redirectTo
|
||||||
|
```
|
||||||
|
|
||||||
|
**Backup code verification:**
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/experience/verification/backup-code/verify
|
||||||
|
{ code: 'abc123def456' }
|
||||||
|
-> returns { verificationId }
|
||||||
|
|
||||||
|
POST /api/experience/identification
|
||||||
|
{ verificationId }
|
||||||
|
|
||||||
|
POST /api/experience/submit
|
||||||
|
-> redirectTo
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modified Sign-in Flow
|
||||||
|
|
||||||
|
The existing `signIn()` function (`init -> verifyPassword -> identify -> submit`) becomes:
|
||||||
|
|
||||||
|
1. `init -> verifyPassword -> identify -> submit`
|
||||||
|
2. If submit returns `mfa_factor_not_satisfied` -> UI shows TOTP input
|
||||||
|
3. User enters code -> `verify TOTP -> identify -> submit -> redirect`
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
| Error | Message |
|
||||||
|
|-------|---------|
|
||||||
|
| Invalid TOTP code | "Invalid code, please try again" |
|
||||||
|
| Backup code already used | "This backup code has already been used" |
|
||||||
|
| All backup codes exhausted | "No backup codes remaining. Contact your administrator." |
|
||||||
|
|
||||||
|
## 4. MFA Enrollment (Tenant Portal)
|
||||||
|
|
||||||
|
### Settings Page UI (`ui/src/pages/SettingsPage.tsx`)
|
||||||
|
|
||||||
|
Add an "MFA" section to the existing Settings page (next to password change).
|
||||||
|
|
||||||
|
**Not enrolled state:**
|
||||||
|
|
||||||
|
- Description: "Protect your account with two-factor authentication"
|
||||||
|
- "Set up authenticator app" button
|
||||||
|
- Enrollment flow:
|
||||||
|
1. Backend generates TOTP secret -> returns secret + QR code URI
|
||||||
|
2. UI renders QR code (lightweight library, e.g., `qrcode.react`)
|
||||||
|
3. User scans with authenticator app, enters 6-digit verification code to confirm
|
||||||
|
4. On success -> backend generates 10 backup codes, displays them once
|
||||||
|
5. User must copy/download before dismissing (checkbox: "I've saved these codes")
|
||||||
|
6. Force token refresh so `mfa_enrolled` JWT claim updates immediately
|
||||||
|
|
||||||
|
**Already enrolled state:**
|
||||||
|
|
||||||
|
- "Authenticator app configured" with green status indicator
|
||||||
|
- "Regenerate backup codes" button (new set of 10, invalidates old)
|
||||||
|
- "Remove MFA" button with confirmation dialog + password re-entry
|
||||||
|
|
||||||
|
### Backend Endpoints (new, in `TenantPortalController`)
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|--------|------|---------|
|
||||||
|
| `GET` | `/api/tenant/mfa/status` | Check current user's MFA enrollment status |
|
||||||
|
| `POST` | `/api/tenant/mfa/totp/setup` | Generate TOTP secret via Logto Management API -> return secret + QR code |
|
||||||
|
| `POST` | `/api/tenant/mfa/totp/verify` | Verify TOTP code + bind to user via Management API |
|
||||||
|
| `POST` | `/api/tenant/mfa/backup-codes` | Generate backup codes via Management API |
|
||||||
|
| `DELETE` | `/api/tenant/mfa/totp` | Remove TOTP factor (requires password confirmation) |
|
||||||
|
|
||||||
|
All proxy to Logto's Management API via `LogtoManagementClient`:
|
||||||
|
|
||||||
|
- `POST /api/users/{userId}/mfa-verifications` — create TOTP or backup codes
|
||||||
|
- `GET /api/users/{userId}/mfa-verifications` — list enrolled factors
|
||||||
|
- `DELETE /api/users/{userId}/mfa-verifications/{verificationId}` — remove a factor
|
||||||
|
|
||||||
|
### Team Management (`ui/src/pages/TeamPage.tsx`)
|
||||||
|
|
||||||
|
Add a "Reset MFA" action for team members — allows tenant owners/operators to remove MFA enrollment for a locked-out user. Calls `DELETE /api/users/{userId}/mfa-verifications/{verificationId}` via a new backend endpoint:
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|--------|------|---------|
|
||||||
|
| `DELETE` | `/api/tenant/users/{userId}/mfa` | Remove all MFA factors for a team member |
|
||||||
|
|
||||||
|
## 5. Per-Tenant MFA Enforcement
|
||||||
|
|
||||||
|
### Tenant Setting
|
||||||
|
|
||||||
|
Stored in the existing `settings` JSONB column on `TenantEntity`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "mfaRequired": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
Default is absent/`false` — MFA is optional, users can still opt in.
|
||||||
|
|
||||||
|
### Tenant Admin Toggle (`ui/src/pages/SettingsPage.tsx`)
|
||||||
|
|
||||||
|
Visible only to tenant admins (owner/operator role):
|
||||||
|
|
||||||
|
- Toggle: "Require MFA for all organization members"
|
||||||
|
- Enabling shows confirmation: "Members without MFA will be prompted to enroll on their next sign-in. Are you sure?"
|
||||||
|
- Calls `PATCH /api/tenant/settings` with `{ "mfaRequired": true/false }`
|
||||||
|
|
||||||
|
### Backend Enforcement
|
||||||
|
|
||||||
|
A Spring Security filter that runs after JWT validation:
|
||||||
|
|
||||||
|
1. Extract `mfa_enrolled` claim from JWT
|
||||||
|
2. Look up the user's tenant -> check `settings.mfaRequired`
|
||||||
|
3. If tenant requires MFA and `mfa_enrolled` is `false`:
|
||||||
|
- Return `403` with a **distinct application error code** to avoid collision with generic Spring Security 403s (which get caught by global error handlers):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "APP_MFA_REQUIRED",
|
||||||
|
"code": "mfa_enrollment_required",
|
||||||
|
"message": "Your organization requires multi-factor authentication"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- The response includes a custom header `X-Cameleer-Error: APP_MFA_REQUIRED` as a belt-and-suspenders signal — frontends can check either the body or the header
|
||||||
|
- **Exempt paths**: `/api/tenant/mfa/*` (enrollment endpoints), `/api/config`, `/api/me`
|
||||||
|
4. Frontend intercepts 403 responses and checks for `APP_MFA_REQUIRED` specifically (not all 403s) -> redirects to Settings page with MFA section highlighted and an inline banner explaining the requirement
|
||||||
|
|
||||||
|
### Cameleer-Server Enforcement (via handoff doc)
|
||||||
|
|
||||||
|
- Read `mfa_enrolled` from JWT (same token, same parsing as `roles` claim)
|
||||||
|
- Query tenant MFA policy: SaaS exposes `GET /api/tenant/{slug}/mfa-policy` returning `{ "mfaRequired": true/false }` — server caches with 5-minute TTL
|
||||||
|
- Same enforcement logic: if required and not enrolled -> 403 with `APP_MFA_REQUIRED` error code (same format as SaaS backend)
|
||||||
|
|
||||||
|
### Token Refresh After Enrollment
|
||||||
|
|
||||||
|
When a user completes MFA enrollment in the tenant portal, the frontend calls `getAccessToken()` from the Logto SDK with a forced refresh. This gets a new JWT with `mfa_enrolled: true`, and subsequent requests pass enforcement.
|
||||||
|
|
||||||
|
### Edge Case: Admin Enables Enforcement While Users Are Active
|
||||||
|
|
||||||
|
Existing sessions have `mfa_enrolled: false`. On next API call, backend returns 403. Frontend redirects to enrollment. After enrolling + token refresh, they're unblocked. No forced logout needed.
|
||||||
|
|
||||||
|
## 6. Backup Codes
|
||||||
|
|
||||||
|
### Generation
|
||||||
|
|
||||||
|
10 codes generated by Logto's Management API (`POST /api/users/{userId}/mfa-verifications` with `{ type: "BackupCode" }`). Logto generates the codes.
|
||||||
|
|
||||||
|
### Display
|
||||||
|
|
||||||
|
Shown exactly once, immediately after TOTP enrollment succeeds:
|
||||||
|
|
||||||
|
- Grid of 10 codes in monospace font
|
||||||
|
- "Copy all" button
|
||||||
|
- "Download as .txt" button
|
||||||
|
- Dialog cannot be dismissed until user checks "I've saved my backup codes"
|
||||||
|
|
||||||
|
### Regeneration
|
||||||
|
|
||||||
|
Available in Settings page for enrolled users. "Regenerate backup codes" creates a new set of 10, invalidates all previous codes. Same display/acknowledgment flow.
|
||||||
|
|
||||||
|
### Low-Code Warning
|
||||||
|
|
||||||
|
When a user signs in with a backup code and fewer than 3 remain, the sign-in UI shows a warning after successful auth: "You have N backup codes remaining."
|
||||||
|
|
||||||
|
### Exhaustion Recovery
|
||||||
|
|
||||||
|
If all backup codes are used and the user can't access their authenticator app, a tenant admin removes their MFA via the "Reset MFA" action on the Team page (`DELETE /api/tenant/users/{userId}/mfa`).
|
||||||
|
|
||||||
|
## 7. Server Handoff Document
|
||||||
|
|
||||||
|
A separate spec file to be delivered alongside this design, covering:
|
||||||
|
|
||||||
|
1. **API contract** — Logto Management API endpoints for MFA enrollment/unenrollment (exact URLs, request/response payloads, M2M token scope requirements)
|
||||||
|
2. **UX requirements** — QR code display, backup code download, verification step, enrollment/removal flows
|
||||||
|
3. **Enforcement model** — reading `mfa_enrolled` from JWT, querying `GET /api/tenant/{slug}/mfa-policy` for tenant requirement, 403 response format
|
||||||
|
4. **Error states** — already enrolled, invalid TOTP, backup code exhaustion, removal while enforcement is active
|
||||||
|
|
||||||
|
## Summary of Changes by Component
|
||||||
|
|
||||||
|
| Component | Changes |
|
||||||
|
|-----------|---------|
|
||||||
|
| `ui/sign-in/src/SignInPage.tsx` | New modes: `forgotPassword`, `mfaVerify` (with prominent backup code fallback) |
|
||||||
|
| `ui/sign-in/src/experience-api.ts` | New functions: `forgotPassword()`, `verifyTotp()`, `verifyBackupCode()`, `notifyPasswordReset()` |
|
||||||
|
| `ui/src/pages/SettingsPage.tsx` | MFA enrollment section, MFA requirement toggle, `APP_MFA_REQUIRED` 403 interceptor |
|
||||||
|
| `ui/src/pages/TeamPage.tsx` | "Reset MFA" action for team members |
|
||||||
|
| `docker/logto-bootstrap.sh` | MFA factors in Phase 8c, `mfa_enrolled` claim in Phase 7b |
|
||||||
|
| `TenantPortalController.java` | MFA endpoints (setup, verify, backup codes, status, remove) |
|
||||||
|
| `TenantPortalService.java` | MFA business logic proxying to `LogtoManagementClient` |
|
||||||
|
| `LogtoManagementClient.java` | New methods for MFA Management API calls |
|
||||||
|
| `SecurityConfig.java` or new filter | MFA enforcement filter (`APP_MFA_REQUIRED` error code + `X-Cameleer-Error` header) |
|
||||||
|
| New: password reset notification | `POST /api/password-reset-notification` — security email after reset (unauthenticated, rate-limited) |
|
||||||
|
| New: server handoff doc | MFA API contract + UX + enforcement spec for cameleer-server team |
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- WebAuthn / passkey support (future addition)
|
||||||
|
- SMS-based MFA
|
||||||
|
- Password policies beyond Logto defaults (complexity, history, expiry)
|
||||||
|
- Passwordless authentication
|
||||||
|
- Social login / OAuth providers
|
||||||
|
- Account lockout configuration
|
||||||
152
docs/superpowers/specs/2026-04-26-server-mfa-handoff.md
Normal file
152
docs/superpowers/specs/2026-04-26-server-mfa-handoff.md
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
# Cameleer-Server MFA Handoff Document
|
||||||
|
|
||||||
|
**Date:** 2026-04-26
|
||||||
|
**For:** cameleer-server team
|
||||||
|
**Context:** The SaaS platform now supports TOTP MFA with backup codes. This document specifies what the server team needs to implement for MFA enrollment in the server UI.
|
||||||
|
|
||||||
|
## 1. JWT Claim: `mfa_enrolled`
|
||||||
|
|
||||||
|
Every access token now includes an `mfa_enrolled: boolean` claim, set by the Logto Custom JWT script. The server already parses JWT claims for the `roles` field — `mfa_enrolled` works identically.
|
||||||
|
|
||||||
|
**Example decoded JWT payload:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sub": "user-id-123",
|
||||||
|
"roles": ["server:admin"],
|
||||||
|
"mfa_enrolled": true,
|
||||||
|
"aud": "https://api.cameleer.local",
|
||||||
|
"scope": "tenant:manage tenant:view"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Enforcement
|
||||||
|
|
||||||
|
### When to enforce
|
||||||
|
|
||||||
|
Check whether the tenant requires MFA:
|
||||||
|
|
||||||
|
- **Endpoint:** `GET /platform/api/tenant/{slug}/mfa-policy`
|
||||||
|
- **Auth:** M2M token (same as existing server -> SaaS API calls)
|
||||||
|
- **Response:** `{ "mfaRequired": true/false }`
|
||||||
|
- **Cache:** 5-minute TTL recommended
|
||||||
|
|
||||||
|
### How to enforce
|
||||||
|
|
||||||
|
On authenticated requests, if `mfaRequired` is `true` and the JWT `mfa_enrolled` claim is `false`:
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```
|
||||||
|
HTTP 403
|
||||||
|
X-Cameleer-Error: APP_MFA_REQUIRED
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"error": "APP_MFA_REQUIRED",
|
||||||
|
"code": "mfa_enrollment_required",
|
||||||
|
"message": "Your organization requires multi-factor authentication"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Exempt paths:** MFA enrollment endpoints (below), health checks, public assets.
|
||||||
|
|
||||||
|
The server UI should intercept 403 responses with `X-Cameleer-Error: APP_MFA_REQUIRED` and redirect to the MFA enrollment page.
|
||||||
|
|
||||||
|
## 3. MFA Enrollment API
|
||||||
|
|
||||||
|
The server needs to call Logto's Management API to manage MFA for users. Use the existing M2M token for authentication.
|
||||||
|
|
||||||
|
### Get MFA status
|
||||||
|
|
||||||
|
```
|
||||||
|
GET https://{logto-endpoint}/api/users/{userId}/mfa-verifications
|
||||||
|
Authorization: Bearer {m2m_token}
|
||||||
|
|
||||||
|
Response: [
|
||||||
|
{ "id": "ver-123", "type": "Totp", "createdAt": "..." },
|
||||||
|
{ "id": "ver-456", "type": "BackupCode", "createdAt": "..." }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generate TOTP secret
|
||||||
|
|
||||||
|
Generate a 20-byte random secret, Base32-encode it, and create a QR code URI:
|
||||||
|
|
||||||
|
```
|
||||||
|
otpauth://totp/Cameleer:{userEmail}?secret={base32Secret}&issuer=Cameleer
|
||||||
|
```
|
||||||
|
|
||||||
|
Show the QR code to the user. After they scan and provide a 6-digit code, verify it server-side using the TOTP algorithm (RFC 6238, HMAC-SHA1, 30-second window, +/-1 step drift).
|
||||||
|
|
||||||
|
### Bind TOTP to user
|
||||||
|
|
||||||
|
After successful verification:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST https://{logto-endpoint}/api/users/{userId}/mfa-verifications
|
||||||
|
Authorization: Bearer {m2m_token}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{ "type": "Totp", "secret": "{base32Secret}" }
|
||||||
|
|
||||||
|
Response: { "type": "Totp", "secret": "...", "secretQrCode": "..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generate backup codes
|
||||||
|
|
||||||
|
After TOTP is bound:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST https://{logto-endpoint}/api/users/{userId}/mfa-verifications
|
||||||
|
Authorization: Bearer {m2m_token}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{ "type": "BackupCode" }
|
||||||
|
|
||||||
|
Response: { "type": "BackupCode", "codes": ["abc123", "def456", ...] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Display the 10 codes once. User must acknowledge saving them before dismissing.
|
||||||
|
|
||||||
|
### Remove MFA (admin action)
|
||||||
|
|
||||||
|
```
|
||||||
|
DELETE https://{logto-endpoint}/api/users/{userId}/mfa-verifications/{verificationId}
|
||||||
|
Authorization: Bearer {m2m_token}
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove all verifications (TOTP + BackupCode) to fully reset MFA for a user.
|
||||||
|
|
||||||
|
## 4. UX Requirements
|
||||||
|
|
||||||
|
### Enrollment flow
|
||||||
|
|
||||||
|
1. User clicks "Set up MFA" in settings
|
||||||
|
2. Show QR code (200x200px) with the TOTP secret URI
|
||||||
|
3. User scans with authenticator app
|
||||||
|
4. User enters 6-digit verification code
|
||||||
|
5. On success -> show 10 backup codes in a 2-column monospace grid
|
||||||
|
6. "Copy all" and "Download .txt" buttons
|
||||||
|
7. Checkbox: "I've saved my backup codes" — must be checked before dismissing
|
||||||
|
8. After dismissal, force token refresh to get `mfa_enrolled: true` in JWT
|
||||||
|
|
||||||
|
### Enrolled state
|
||||||
|
|
||||||
|
- Show "Authenticator app configured" with green status badge
|
||||||
|
- "Regenerate backup codes" button
|
||||||
|
- "Remove MFA" button with confirmation dialog
|
||||||
|
|
||||||
|
### Backup code fallback (sign-in)
|
||||||
|
|
||||||
|
This is handled by the SaaS custom sign-in UI, not the server. No server changes needed for the sign-in flow.
|
||||||
|
|
||||||
|
## 5. Error States
|
||||||
|
|
||||||
|
| Scenario | Response |
|
||||||
|
|----------|----------|
|
||||||
|
| User already has TOTP enrolled | 422 — "TOTP already configured" |
|
||||||
|
| Invalid TOTP code during setup | Show error, let user retry |
|
||||||
|
| Backup code already used (sign-in) | Handled by SaaS sign-in UI |
|
||||||
|
| All backup codes exhausted | Admin removes MFA via team page |
|
||||||
|
| Remove MFA while enforcement active | User will be prompted to re-enroll on next request |
|
||||||
@@ -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 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.
|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -62,16 +61,28 @@ The dashboard provides an at-a-glance overview of your tenant:
|
|||||||
|
|
||||||
The sidebar provides access to all major sections:
|
The sidebar provides access to all major sections:
|
||||||
|
|
||||||
|
**Vendor console** (visible only to platform admins):
|
||||||
|
|
||||||
| Section | Description |
|
| Section | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| **Dashboard** | Tenant overview and KPI metrics |
|
| **Tenants** | List, create, and manage tenants |
|
||||||
| **Environments** | Expandable tree showing all environments and their apps |
|
| **Audit Log** | Platform-wide audit trail |
|
||||||
| **License** | License tier, features, limits, and token |
|
| **Certificates** | TLS certificate lifecycle (stage, activate, restore) |
|
||||||
| **Platform** | Platform-wide tenant management (visible only to platform admins) |
|
| **Metrics** | Tenant usage metrics |
|
||||||
| **View Dashboard** | Opens the observability dashboard (cameleer-server) in a new tab |
|
| **Infrastructure** | PostgreSQL and ClickHouse health |
|
||||||
| **Account** | Log out of the current session |
|
| **Email Connector** | Configure SMTP for email verification and self-service registration |
|
||||||
|
| **Logto Console** | Opens the Logto admin console (external link) |
|
||||||
|
|
||||||
The Environments section in the sidebar renders as a collapsible tree: environments at the top level, with their applications nested underneath. Clicking any item navigates directly to its detail page.
|
**Tenant portal** (visible to tenant admins):
|
||||||
|
|
||||||
|
| Section | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| **Dashboard** | Tenant overview, server health, usage, and quick links |
|
||||||
|
| **License** | License tier, features, limits, and token |
|
||||||
|
| **Security** | SSO connector configuration |
|
||||||
|
| **Team** | Manage team members and reset passwords |
|
||||||
|
| **Audit Log** | Tenant-scoped audit trail |
|
||||||
|
| **Settings** | Change own password, reset server admin password |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -442,7 +453,7 @@ Copy `.env.example` to `.env` and configure as needed:
|
|||||||
| `CAMELEER_SAAS_IDENTITY_SPACLIENTID` | SPA client ID for the frontend | _(empty)_ |
|
| `CAMELEER_SAAS_IDENTITY_SPACLIENTID` | SPA client ID for the frontend | _(empty)_ |
|
||||||
| `PUBLIC_HOST` | Public hostname for Traefik, Logto, and SaaS routing | `localhost` |
|
| `PUBLIC_HOST` | Public hostname for Traefik, Logto, and SaaS routing | `localhost` |
|
||||||
| `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 login (must be an email in SaaS mode) | `admin` |
|
||||||
| `SAAS_ADMIN_PASS` | Platform admin password | `admin` |
|
| `SAAS_ADMIN_PASS` | Platform admin password | `admin` |
|
||||||
| `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` |
|
||||||
@@ -518,7 +529,7 @@ The Cameleer SaaS application itself does not need any changes -- all identity c
|
|||||||
|
|
||||||
### Login Fails or Redirect Loop
|
### Login Fails or Redirect Loop
|
||||||
|
|
||||||
**Symptoms:** Clicking "Sign in with Logto" redirects you in a loop, or you see an error page.
|
**Symptoms:** The sign-in page redirects in a loop, or you see an error page.
|
||||||
|
|
||||||
**Possible causes:**
|
**Possible causes:**
|
||||||
|
|
||||||
|
|||||||
1
installer
Submodule
1
installer
Submodule
Submodule installer added at 531a17397b
@@ -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
|
|
||||||
23
pom.xml
23
pom.xml
@@ -100,6 +100,19 @@
|
|||||||
<version>3.4.1</version>
|
<version>3.4.1</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- License Minter (Ed25519 signing) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.cameleer</groupId>
|
||||||
|
<artifactId>cameleer-license-minter</artifactId>
|
||||||
|
<version>1.0-SNAPSHOT</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Mail (for password-reset security notification) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-mail</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- Test -->
|
<!-- Test -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
@@ -123,6 +136,16 @@
|
|||||||
</dependency>
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
|
<repositories>
|
||||||
|
<repository>
|
||||||
|
<id>gitea-cameleer</id>
|
||||||
|
<url>https://gitea.siegeln.net/api/packages/cameleer/maven</url>
|
||||||
|
<snapshots>
|
||||||
|
<enabled>true</enabled>
|
||||||
|
</snapshots>
|
||||||
|
</repository>
|
||||||
|
</repositories>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
<plugins>
|
<plugins>
|
||||||
<plugin>
|
<plugin>
|
||||||
|
|||||||
@@ -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. `SAAS_ADMIN_USER` IS the email address (no separate `SAAS_ADMIN_EMAIL`). The bootstrap creates the admin user with `SAAS_ADMIN_USER` as both username and `primaryEmail`. The installer enforces email format in SaaS mode. 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)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package net.siegeln.cameleer.saas.config;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import jakarta.servlet.FilterChain;
|
||||||
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import net.siegeln.cameleer.saas.tenant.TenantService;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.security.oauth2.jwt.Jwt;
|
||||||
|
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class MfaEnforcementFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(MfaEnforcementFilter.class);
|
||||||
|
private static final String ERROR_CODE = "APP_MFA_REQUIRED";
|
||||||
|
private static final Set<String> EXEMPT_PREFIXES = Set.of(
|
||||||
|
"/api/tenant/mfa/",
|
||||||
|
"/api/config",
|
||||||
|
"/api/me",
|
||||||
|
"/api/onboarding"
|
||||||
|
);
|
||||||
|
|
||||||
|
private final TenantService tenantService;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
public MfaEnforcementFilter(TenantService tenantService, ObjectMapper objectMapper) {
|
||||||
|
this.tenantService = tenantService;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||||
|
String path = request.getServletPath();
|
||||||
|
if (!path.startsWith("/api/tenant/")) return true;
|
||||||
|
return EXEMPT_PREFIXES.stream().anyMatch(path::startsWith);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||||
|
throws ServletException, IOException {
|
||||||
|
|
||||||
|
var auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
if (!(auth instanceof JwtAuthenticationToken jwtAuth)) {
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Jwt jwt = jwtAuth.getToken();
|
||||||
|
Boolean mfaEnrolled = jwt.getClaim("mfa_enrolled");
|
||||||
|
|
||||||
|
if (Boolean.TRUE.equals(mfaEnrolled)) {
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TenantIsolationInterceptor runs after filters, so TenantContext is not populated yet.
|
||||||
|
// Resolve the tenant directly from the JWT organization_id claim.
|
||||||
|
String orgId = jwt.getClaimAsString("organization_id");
|
||||||
|
if (orgId == null) {
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var tenant = tenantService.getByLogtoOrgId(orgId).orElse(null);
|
||||||
|
if (tenant == null || !Boolean.TRUE.equals(tenant.getSettings().get("mfaRequired"))) {
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("MFA enforcement: blocking user {} — tenant {} requires MFA", jwt.getSubject(), tenant.getSlug());
|
||||||
|
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
|
||||||
|
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||||
|
response.setHeader("X-Cameleer-Error", ERROR_CODE);
|
||||||
|
objectMapper.writeValue(response.getOutputStream(), Map.of(
|
||||||
|
"error", ERROR_CODE,
|
||||||
|
"code", "mfa_enrollment_required",
|
||||||
|
"message", "Your organization requires multi-factor authentication"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import org.springframework.security.oauth2.jwt.JwtIssuerValidator;
|
|||||||
import org.springframework.security.oauth2.jwt.JwtTimestampValidator;
|
import org.springframework.security.oauth2.jwt.JwtTimestampValidator;
|
||||||
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
|
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
|
||||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
|
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
|
||||||
|
import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationFilter;
|
||||||
import org.springframework.security.web.SecurityFilterChain;
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
|
|
||||||
import java.net.URL;
|
import java.net.URL;
|
||||||
@@ -36,23 +37,26 @@ import java.util.List;
|
|||||||
public class SecurityConfig {
|
public class SecurityConfig {
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
public SecurityFilterChain filterChain(HttpSecurity http, MfaEnforcementFilter mfaEnforcementFilter) throws Exception {
|
||||||
http
|
http
|
||||||
.csrf(csrf -> csrf.disable())
|
.csrf(csrf -> csrf.disable())
|
||||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
.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/**", "/assets/**", "/favicon.ico", "/favicon.svg", "/logo.svg", "/logo-dark.svg").permitAll()
|
||||||
|
.requestMatchers("/api/password-reset-notification").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()
|
||||||
)
|
)
|
||||||
.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt ->
|
.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt ->
|
||||||
jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())));
|
jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())))
|
||||||
|
.addFilterAfter(mfaEnforcementFilter, BearerTokenAuthenticationFilter.class);
|
||||||
|
|
||||||
return http.build();
|
return http.build();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -410,6 +526,96 @@ public class LogtoManagementClient {
|
|||||||
.toBodilessEntity();
|
.toBodilessEntity();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- MFA Verification Management ---
|
||||||
|
|
||||||
|
/** List all MFA verifications for a user. Returns a list of MFA factor objects. */
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public List<Map<String, Object>> getUserMfaVerifications(String userId) {
|
||||||
|
if (!isAvailable()) return List.of();
|
||||||
|
try {
|
||||||
|
var resp = restClient.get()
|
||||||
|
.uri(config.getLogtoEndpoint() + "/api/users/" + userId + "/mfa-verifications")
|
||||||
|
.header("Authorization", "Bearer " + getAccessToken())
|
||||||
|
.retrieve()
|
||||||
|
.body(List.class);
|
||||||
|
return resp != null ? resp : List.of();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to get MFA verifications for user {}: {}", userId, e.getMessage());
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a TOTP MFA verification for a user. Returns the secret and QR code. */
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public Map<String, Object> createTotpVerification(String userId, String secret) {
|
||||||
|
if (!isAvailable()) return Map.of();
|
||||||
|
try {
|
||||||
|
return (Map<String, Object>) restClient.post()
|
||||||
|
.uri(config.getLogtoEndpoint() + "/api/users/" + userId + "/mfa-verifications")
|
||||||
|
.header("Authorization", "Bearer " + getAccessToken())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(Map.of("type", "Totp", "secret", secret))
|
||||||
|
.retrieve()
|
||||||
|
.body(Map.class);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to create TOTP verification for user {}: {}", userId, e.getMessage());
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate backup codes for a user. Returns the list of codes. */
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public Map<String, Object> createBackupCodes(String userId) {
|
||||||
|
if (!isAvailable()) return Map.of();
|
||||||
|
try {
|
||||||
|
return (Map<String, Object>) restClient.post()
|
||||||
|
.uri(config.getLogtoEndpoint() + "/api/users/" + userId + "/mfa-verifications")
|
||||||
|
.header("Authorization", "Bearer " + getAccessToken())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(Map.of("type", "BackupCode"))
|
||||||
|
.retrieve()
|
||||||
|
.body(Map.class);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to create backup codes for user {}: {}", userId, e.getMessage());
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete a specific MFA verification for a user. */
|
||||||
|
public void deleteMfaVerification(String userId, String verificationId) {
|
||||||
|
if (!isAvailable()) return;
|
||||||
|
try {
|
||||||
|
restClient.delete()
|
||||||
|
.uri(config.getLogtoEndpoint() + "/api/users/" + userId + "/mfa-verifications/" + verificationId)
|
||||||
|
.header("Authorization", "Bearer " + getAccessToken())
|
||||||
|
.retrieve()
|
||||||
|
.toBodilessEntity();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to delete MFA verification {} for user {}: {}", verificationId, userId, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete all MFA verifications for a user (used for admin MFA reset). */
|
||||||
|
public void deleteAllMfaVerifications(String userId) {
|
||||||
|
List<Map<String, Object>> verifications = getUserMfaVerifications(userId);
|
||||||
|
for (Map<String, Object> v : verifications) {
|
||||||
|
String id = String.valueOf(v.get("id"));
|
||||||
|
deleteMfaVerification(userId, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update a user's profile fields (e.g. name). */
|
||||||
|
public void updateUserProfile(String userId, Map<String, Object> profile) {
|
||||||
|
if (!isAvailable()) throw new IllegalStateException("Logto not configured");
|
||||||
|
restClient.patch()
|
||||||
|
.uri(config.getLogtoEndpoint() + "/api/users/" + userId)
|
||||||
|
.header("Authorization", "Bearer " + getAccessToken())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(profile)
|
||||||
|
.retrieve()
|
||||||
|
.toBodilessEntity();
|
||||||
|
}
|
||||||
|
|
||||||
/** Get a user by ID. Returns username, primaryEmail, name. */
|
/** Get a user by ID. Returns username, primaryEmail, name. */
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
public Map<String, Object> getUser(String userId) {
|
public Map<String, Object> getUser(String userId) {
|
||||||
|
|||||||
@@ -55,15 +55,6 @@ public class LicenseController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private LicenseResponse toResponse(LicenseEntity entity) {
|
private LicenseResponse toResponse(LicenseEntity entity) {
|
||||||
return new LicenseResponse(
|
return LicenseResponse.from(entity);
|
||||||
entity.getId(),
|
|
||||||
entity.getTenantId(),
|
|
||||||
entity.getTier(),
|
|
||||||
entity.getFeatures(),
|
|
||||||
entity.getLimits(),
|
|
||||||
entity.getIssuedAt(),
|
|
||||||
entity.getExpiresAt(),
|
|
||||||
entity.getToken()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,37 +8,71 @@ public final class LicenseDefaults {
|
|||||||
|
|
||||||
private LicenseDefaults() {}
|
private LicenseDefaults() {}
|
||||||
|
|
||||||
public static Map<String, Object> featuresForTier(Tier tier) {
|
public static final int DEFAULT_GRACE_PERIOD_DAYS = 14;
|
||||||
return switch (tier) {
|
public static final int DEFAULT_LICENSE_DAYS = 365;
|
||||||
case LOW -> Map.of(
|
|
||||||
"topology", true, "lineage", false,
|
|
||||||
"correlation", false, "debugger", false, "replay", false);
|
|
||||||
case MID -> Map.of(
|
|
||||||
"topology", true, "lineage", true,
|
|
||||||
"correlation", true, "debugger", false, "replay", false);
|
|
||||||
case HIGH -> Map.of(
|
|
||||||
"topology", true, "lineage", true,
|
|
||||||
"correlation", true, "debugger", true, "replay", true);
|
|
||||||
case BUSINESS -> Map.of(
|
|
||||||
"topology", true, "lineage", true,
|
|
||||||
"correlation", true, "debugger", true, "replay", true);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Map<String, Object> limitsForTier(Tier tier) {
|
public static Map<String, Integer> limitsForTier(Tier tier) {
|
||||||
return switch (tier) {
|
return switch (tier) {
|
||||||
case LOW -> Map.of(
|
case STARTER -> Map.ofEntries(
|
||||||
"max_agents", 3, "retention_days", 7,
|
Map.entry("max_environments", 2),
|
||||||
"max_environments", 1);
|
Map.entry("max_apps", 10),
|
||||||
case MID -> Map.of(
|
Map.entry("max_agents", 20),
|
||||||
"max_agents", 10, "retention_days", 30,
|
Map.entry("max_users", 5),
|
||||||
"max_environments", 2);
|
Map.entry("max_outbound_connections", 5),
|
||||||
case HIGH -> Map.of(
|
Map.entry("max_alert_rules", 10),
|
||||||
"max_agents", 50, "retention_days", 90,
|
Map.entry("max_total_cpu_millis", 8000),
|
||||||
"max_environments", -1);
|
Map.entry("max_total_memory_mb", 8192),
|
||||||
case BUSINESS -> Map.of(
|
Map.entry("max_total_replicas", 25),
|
||||||
"max_agents", -1, "retention_days", 365,
|
Map.entry("max_execution_retention_days", 7),
|
||||||
"max_environments", -1);
|
Map.entry("max_log_retention_days", 7),
|
||||||
|
Map.entry("max_metric_retention_days", 7),
|
||||||
|
Map.entry("max_jar_retention_count", 5)
|
||||||
|
);
|
||||||
|
case TEAM -> Map.ofEntries(
|
||||||
|
Map.entry("max_environments", 5),
|
||||||
|
Map.entry("max_apps", 50),
|
||||||
|
Map.entry("max_agents", 100),
|
||||||
|
Map.entry("max_users", 25),
|
||||||
|
Map.entry("max_outbound_connections", 25),
|
||||||
|
Map.entry("max_alert_rules", 50),
|
||||||
|
Map.entry("max_total_cpu_millis", 32000),
|
||||||
|
Map.entry("max_total_memory_mb", 32768),
|
||||||
|
Map.entry("max_total_replicas", 100),
|
||||||
|
Map.entry("max_execution_retention_days", 30),
|
||||||
|
Map.entry("max_log_retention_days", 30),
|
||||||
|
Map.entry("max_metric_retention_days", 30),
|
||||||
|
Map.entry("max_jar_retention_count", 10)
|
||||||
|
);
|
||||||
|
case BUSINESS -> Map.ofEntries(
|
||||||
|
Map.entry("max_environments", 10),
|
||||||
|
Map.entry("max_apps", 200),
|
||||||
|
Map.entry("max_agents", 500),
|
||||||
|
Map.entry("max_users", 100),
|
||||||
|
Map.entry("max_outbound_connections", 100),
|
||||||
|
Map.entry("max_alert_rules", 200),
|
||||||
|
Map.entry("max_total_cpu_millis", 128000),
|
||||||
|
Map.entry("max_total_memory_mb", 131072),
|
||||||
|
Map.entry("max_total_replicas", 500),
|
||||||
|
Map.entry("max_execution_retention_days", 90),
|
||||||
|
Map.entry("max_log_retention_days", 90),
|
||||||
|
Map.entry("max_metric_retention_days", 90),
|
||||||
|
Map.entry("max_jar_retention_count", 25)
|
||||||
|
);
|
||||||
|
case ENTERPRISE -> Map.ofEntries(
|
||||||
|
Map.entry("max_environments", 50),
|
||||||
|
Map.entry("max_apps", 1000),
|
||||||
|
Map.entry("max_agents", 5000),
|
||||||
|
Map.entry("max_users", 1000),
|
||||||
|
Map.entry("max_outbound_connections", 500),
|
||||||
|
Map.entry("max_alert_rules", 1000),
|
||||||
|
Map.entry("max_total_cpu_millis", 512000),
|
||||||
|
Map.entry("max_total_memory_mb", 524288),
|
||||||
|
Map.entry("max_total_replicas", 2000),
|
||||||
|
Map.entry("max_execution_retention_days", 365),
|
||||||
|
Map.entry("max_log_retention_days", 180),
|
||||||
|
Map.entry("max_metric_retention_days", 180),
|
||||||
|
Map.entry("max_jar_retention_count", 50)
|
||||||
|
);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,14 +28,16 @@ public class LicenseEntity {
|
|||||||
@Column(name = "tier", nullable = false, length = 20)
|
@Column(name = "tier", nullable = false, length = 20)
|
||||||
private String tier;
|
private String tier;
|
||||||
|
|
||||||
@JdbcTypeCode(SqlTypes.JSON)
|
@Column(name = "label")
|
||||||
@Column(name = "features", nullable = false, columnDefinition = "jsonb")
|
private String label;
|
||||||
private Map<String, Object> features;
|
|
||||||
|
|
||||||
@JdbcTypeCode(SqlTypes.JSON)
|
@JdbcTypeCode(SqlTypes.JSON)
|
||||||
@Column(name = "limits", nullable = false, columnDefinition = "jsonb")
|
@Column(name = "limits", nullable = false, columnDefinition = "jsonb")
|
||||||
private Map<String, Object> limits;
|
private Map<String, Object> limits;
|
||||||
|
|
||||||
|
@Column(name = "grace_period_days", nullable = false)
|
||||||
|
private int gracePeriodDays;
|
||||||
|
|
||||||
@Column(name = "issued_at", nullable = false)
|
@Column(name = "issued_at", nullable = false)
|
||||||
private Instant issuedAt;
|
private Instant issuedAt;
|
||||||
|
|
||||||
@@ -62,10 +64,12 @@ public class LicenseEntity {
|
|||||||
public void setTenantId(UUID tenantId) { this.tenantId = tenantId; }
|
public void setTenantId(UUID tenantId) { this.tenantId = tenantId; }
|
||||||
public String getTier() { return tier; }
|
public String getTier() { return tier; }
|
||||||
public void setTier(String tier) { this.tier = tier; }
|
public void setTier(String tier) { this.tier = tier; }
|
||||||
public Map<String, Object> getFeatures() { return features; }
|
public String getLabel() { return label; }
|
||||||
public void setFeatures(Map<String, Object> features) { this.features = features; }
|
public void setLabel(String label) { this.label = label; }
|
||||||
public Map<String, Object> getLimits() { return limits; }
|
public Map<String, Object> getLimits() { return limits; }
|
||||||
public void setLimits(Map<String, Object> limits) { this.limits = limits; }
|
public void setLimits(Map<String, Object> limits) { this.limits = limits; }
|
||||||
|
public int getGracePeriodDays() { return gracePeriodDays; }
|
||||||
|
public void setGracePeriodDays(int gracePeriodDays) { this.gracePeriodDays = gracePeriodDays; }
|
||||||
public Instant getIssuedAt() { return issuedAt; }
|
public Instant getIssuedAt() { return issuedAt; }
|
||||||
public void setIssuedAt(Instant issuedAt) { this.issuedAt = issuedAt; }
|
public void setIssuedAt(Instant issuedAt) { this.issuedAt = issuedAt; }
|
||||||
public Instant getExpiresAt() { return expiresAt; }
|
public Instant getExpiresAt() { return expiresAt; }
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
package net.siegeln.cameleer.saas.license;
|
package net.siegeln.cameleer.saas.license;
|
||||||
|
|
||||||
|
import com.cameleer.license.minter.LicenseMinter;
|
||||||
|
import com.cameleer.license.LicenseInfo;
|
||||||
|
import com.cameleer.license.LicenseValidator;
|
||||||
import net.siegeln.cameleer.saas.audit.AuditAction;
|
import net.siegeln.cameleer.saas.audit.AuditAction;
|
||||||
import net.siegeln.cameleer.saas.audit.AuditService;
|
import net.siegeln.cameleer.saas.audit.AuditService;
|
||||||
import net.siegeln.cameleer.saas.tenant.TenantEntity;
|
import net.siegeln.cameleer.saas.tenant.TenantEntity;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@@ -14,27 +20,50 @@ import java.util.UUID;
|
|||||||
@Service
|
@Service
|
||||||
public class LicenseService {
|
public class LicenseService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(LicenseService.class);
|
||||||
|
|
||||||
private final LicenseRepository licenseRepository;
|
private final LicenseRepository licenseRepository;
|
||||||
private final AuditService auditService;
|
private final AuditService auditService;
|
||||||
|
private final SigningKeyService signingKeyService;
|
||||||
|
|
||||||
public LicenseService(LicenseRepository licenseRepository, AuditService auditService) {
|
public LicenseService(LicenseRepository licenseRepository,
|
||||||
|
AuditService auditService,
|
||||||
|
SigningKeyService signingKeyService) {
|
||||||
this.licenseRepository = licenseRepository;
|
this.licenseRepository = licenseRepository;
|
||||||
this.auditService = auditService;
|
this.auditService = auditService;
|
||||||
|
this.signingKeyService = signingKeyService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public LicenseEntity generateLicense(TenantEntity tenant, Duration validity, UUID actorId) {
|
/**
|
||||||
var features = LicenseDefaults.featuresForTier(tenant.getTier());
|
* Mint an Ed25519-signed license with full control over limits.
|
||||||
var limits = LicenseDefaults.limitsForTier(tenant.getTier());
|
*/
|
||||||
|
public LicenseEntity generateLicense(TenantEntity tenant,
|
||||||
|
Map<String, Integer> limits,
|
||||||
|
Instant expiresAt,
|
||||||
|
int gracePeriodDays,
|
||||||
|
String label,
|
||||||
|
UUID actorId) {
|
||||||
Instant now = Instant.now();
|
Instant now = Instant.now();
|
||||||
Instant expiresAt = now.plus(validity);
|
UUID licenseId = UUID.randomUUID();
|
||||||
|
|
||||||
String token = UUID.randomUUID().toString();
|
LicenseInfo info = new LicenseInfo(
|
||||||
|
licenseId,
|
||||||
|
tenant.getSlug(),
|
||||||
|
label,
|
||||||
|
limits,
|
||||||
|
now,
|
||||||
|
expiresAt,
|
||||||
|
gracePeriodDays
|
||||||
|
);
|
||||||
|
|
||||||
|
String token = LicenseMinter.mint(info, signingKeyService.getPrivateKey());
|
||||||
|
|
||||||
var entity = new LicenseEntity();
|
var entity = new LicenseEntity();
|
||||||
entity.setTenantId(tenant.getId());
|
entity.setTenantId(tenant.getId());
|
||||||
entity.setTier(tenant.getTier().name());
|
entity.setTier(tenant.getTier().name());
|
||||||
entity.setFeatures(features);
|
entity.setLabel(label);
|
||||||
entity.setLimits(limits);
|
entity.setLimits(new HashMap<>(limits));
|
||||||
|
entity.setGracePeriodDays(gracePeriodDays);
|
||||||
entity.setIssuedAt(now);
|
entity.setIssuedAt(now);
|
||||||
entity.setExpiresAt(expiresAt);
|
entity.setExpiresAt(expiresAt);
|
||||||
entity.setToken(token);
|
entity.setToken(token);
|
||||||
@@ -48,6 +77,17 @@ public class LicenseService {
|
|||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience overload using tier presets and default validity.
|
||||||
|
*/
|
||||||
|
public LicenseEntity generateLicense(TenantEntity tenant, Duration validity, UUID actorId) {
|
||||||
|
var limits = LicenseDefaults.limitsForTier(tenant.getTier());
|
||||||
|
Instant expiresAt = Instant.now().plus(validity);
|
||||||
|
String label = tenant.getName() + " (" + tenant.getTier().name() + ")";
|
||||||
|
return generateLicense(tenant, limits, expiresAt,
|
||||||
|
LicenseDefaults.DEFAULT_GRACE_PERIOD_DAYS, label, actorId);
|
||||||
|
}
|
||||||
|
|
||||||
public Optional<LicenseEntity> getActiveLicense(UUID tenantId) {
|
public Optional<LicenseEntity> getActiveLicense(UUID tenantId) {
|
||||||
return licenseRepository.findFirstByTenantIdAndRevokedAtIsNullOrderByCreatedAtDesc(tenantId);
|
return licenseRepository.findFirstByTenantIdAndRevokedAtIsNullOrderByCreatedAtDesc(tenantId);
|
||||||
}
|
}
|
||||||
@@ -64,19 +104,41 @@ public class LicenseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verifies a license token by checking its existence and validity in the database.
|
* Verify a signed license token using the stored public key.
|
||||||
* Returns the license entity's metadata as a map if found and not expired/revoked,
|
* Returns the parsed LicenseInfo if valid, or empty if invalid.
|
||||||
* or empty if the token is unknown or invalid.
|
|
||||||
*/
|
*/
|
||||||
public Optional<Map<String, Object>> verifyLicenseToken(String token) {
|
public Optional<LicenseInfo> verifyToken(String token, String expectedTenantId) {
|
||||||
return licenseRepository.findByToken(token)
|
try {
|
||||||
.filter(e -> e.getRevokedAt() == null)
|
String publicKeyB64 = signingKeyService.getPublicKeyBase64();
|
||||||
.filter(e -> e.getExpiresAt() == null || Instant.now().isBefore(e.getExpiresAt()))
|
LicenseValidator validator = new LicenseValidator(publicKeyB64, expectedTenantId);
|
||||||
.map(e -> Map.<String, Object>of(
|
LicenseInfo info = validator.validate(token);
|
||||||
"tenant_id", e.getTenantId().toString(),
|
return Optional.of(info);
|
||||||
"tier", e.getTier(),
|
} catch (Exception e) {
|
||||||
"features", e.getFeatures(),
|
log.debug("License token verification failed: {}", e.getMessage());
|
||||||
"limits", e.getLimits()
|
return Optional.empty();
|
||||||
));
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a signed license token without tenant ID check (for vendor verify tool).
|
||||||
|
* Decodes the payload and validates the signature only.
|
||||||
|
*/
|
||||||
|
public Optional<LicenseInfo> verifyTokenSignature(String token) {
|
||||||
|
try {
|
||||||
|
// Decode the payload portion to extract tenantId, then validate
|
||||||
|
String payloadB64 = token.split("\\.", 2)[0];
|
||||||
|
String payloadJson = new String(java.util.Base64.getDecoder().decode(payloadB64));
|
||||||
|
var mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||||
|
var tree = mapper.readTree(payloadJson);
|
||||||
|
String tenantId = tree.has("tid") ? tree.get("tid").asText() : tree.get("tenantId").asText();
|
||||||
|
|
||||||
|
String publicKeyB64 = signingKeyService.getPublicKeyBase64();
|
||||||
|
LicenseValidator validator = new LicenseValidator(publicKeyB64, tenantId);
|
||||||
|
LicenseInfo info = validator.validate(token);
|
||||||
|
return Optional.of(info);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("License token signature verification failed: {}", e.getMessage());
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package net.siegeln.cameleer.saas.license;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.PrePersist;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "signing_keys")
|
||||||
|
public class SigningKeyEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.UUID)
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
@Column(name = "public_key_b64", nullable = false, columnDefinition = "text")
|
||||||
|
private String publicKeyB64;
|
||||||
|
|
||||||
|
@Column(name = "private_key_b64", nullable = false, columnDefinition = "text")
|
||||||
|
private String privateKeyB64;
|
||||||
|
|
||||||
|
@Column(name = "active", nullable = false)
|
||||||
|
private boolean active = true;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private Instant createdAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
protected void onCreate() {
|
||||||
|
if (createdAt == null) createdAt = Instant.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
public UUID getId() { return id; }
|
||||||
|
public String getPublicKeyB64() { return publicKeyB64; }
|
||||||
|
public void setPublicKeyB64(String publicKeyB64) { this.publicKeyB64 = publicKeyB64; }
|
||||||
|
public String getPrivateKeyB64() { return privateKeyB64; }
|
||||||
|
public void setPrivateKeyB64(String privateKeyB64) { this.privateKeyB64 = privateKeyB64; }
|
||||||
|
public boolean isActive() { return active; }
|
||||||
|
public void setActive(boolean active) { this.active = active; }
|
||||||
|
public Instant getCreatedAt() { return createdAt; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package net.siegeln.cameleer.saas.license;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public interface SigningKeyRepository extends JpaRepository<SigningKeyEntity, UUID> {
|
||||||
|
|
||||||
|
Optional<SigningKeyEntity> findByActiveTrue();
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package net.siegeln.cameleer.saas.license;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.security.KeyFactory;
|
||||||
|
import java.security.KeyPair;
|
||||||
|
import java.security.KeyPairGenerator;
|
||||||
|
import java.security.PrivateKey;
|
||||||
|
import java.security.PublicKey;
|
||||||
|
import java.security.spec.PKCS8EncodedKeySpec;
|
||||||
|
import java.security.spec.X509EncodedKeySpec;
|
||||||
|
import java.util.Base64;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class SigningKeyService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(SigningKeyService.class);
|
||||||
|
|
||||||
|
private final SigningKeyRepository signingKeyRepository;
|
||||||
|
|
||||||
|
public SigningKeyService(SigningKeyRepository signingKeyRepository) {
|
||||||
|
this.signingKeyRepository = signingKeyRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the active signing key, generating a new Ed25519 keypair on first call.
|
||||||
|
*/
|
||||||
|
public SigningKeyEntity getOrCreateActiveKey() {
|
||||||
|
return signingKeyRepository.findByActiveTrue()
|
||||||
|
.orElseGet(this::generateAndStoreKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the base64-encoded public key (X.509 SPKI format).
|
||||||
|
*/
|
||||||
|
public String getPublicKeyBase64() {
|
||||||
|
return getOrCreateActiveKey().getPublicKeyB64();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconstructs the Ed25519 PrivateKey from the stored base64.
|
||||||
|
*/
|
||||||
|
public PrivateKey getPrivateKey() {
|
||||||
|
SigningKeyEntity key = getOrCreateActiveKey();
|
||||||
|
try {
|
||||||
|
byte[] keyBytes = Base64.getDecoder().decode(key.getPrivateKeyB64());
|
||||||
|
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes);
|
||||||
|
return KeyFactory.getInstance("Ed25519").generatePrivate(spec);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException("Failed to reconstruct Ed25519 private key", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconstructs the Ed25519 PublicKey from the stored base64.
|
||||||
|
*/
|
||||||
|
public PublicKey getPublicKey() {
|
||||||
|
SigningKeyEntity key = getOrCreateActiveKey();
|
||||||
|
try {
|
||||||
|
byte[] keyBytes = Base64.getDecoder().decode(key.getPublicKeyB64());
|
||||||
|
X509EncodedKeySpec spec = new X509EncodedKeySpec(keyBytes);
|
||||||
|
return KeyFactory.getInstance("Ed25519").generatePublic(spec);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException("Failed to reconstruct Ed25519 public key", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private SigningKeyEntity generateAndStoreKey() {
|
||||||
|
try {
|
||||||
|
KeyPair kp = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
|
||||||
|
String pubB64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
|
||||||
|
String privB64 = Base64.getEncoder().encodeToString(kp.getPrivate().getEncoded());
|
||||||
|
|
||||||
|
var entity = new SigningKeyEntity();
|
||||||
|
entity.setPublicKeyB64(pubB64);
|
||||||
|
entity.setPrivateKeyB64(privB64);
|
||||||
|
entity.setActive(true);
|
||||||
|
|
||||||
|
var saved = signingKeyRepository.save(entity);
|
||||||
|
log.info("Generated new Ed25519 signing keypair (id={})", saved.getId());
|
||||||
|
return saved;
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException("Failed to generate Ed25519 keypair", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package net.siegeln.cameleer.saas.license.dto;
|
||||||
|
|
||||||
|
import net.siegeln.cameleer.saas.license.LicenseEntity;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record LicenseBundleResponse(
|
||||||
|
UUID id,
|
||||||
|
UUID tenantId,
|
||||||
|
String tenantSlug,
|
||||||
|
String tier,
|
||||||
|
String label,
|
||||||
|
Map<String, Object> limits,
|
||||||
|
int gracePeriodDays,
|
||||||
|
Instant issuedAt,
|
||||||
|
Instant expiresAt,
|
||||||
|
String token,
|
||||||
|
String publicKeyB64,
|
||||||
|
boolean pushedToServer
|
||||||
|
) {
|
||||||
|
public static LicenseBundleResponse from(LicenseEntity e, String tenantSlug,
|
||||||
|
String publicKeyB64, boolean pushed) {
|
||||||
|
return new LicenseBundleResponse(
|
||||||
|
e.getId(), e.getTenantId(), tenantSlug, e.getTier(),
|
||||||
|
e.getLabel(), e.getLimits(), e.getGracePeriodDays(),
|
||||||
|
e.getIssuedAt(), e.getExpiresAt(), e.getToken(),
|
||||||
|
publicKeyB64, pushed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package net.siegeln.cameleer.saas.license.dto;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public record LicensePreset(String tier, Map<String, Integer> limits) {}
|
||||||
@@ -10,8 +10,9 @@ public record LicenseResponse(
|
|||||||
UUID id,
|
UUID id,
|
||||||
UUID tenantId,
|
UUID tenantId,
|
||||||
String tier,
|
String tier,
|
||||||
Map<String, Object> features,
|
String label,
|
||||||
Map<String, Object> limits,
|
Map<String, Object> limits,
|
||||||
|
int gracePeriodDays,
|
||||||
Instant issuedAt,
|
Instant issuedAt,
|
||||||
Instant expiresAt,
|
Instant expiresAt,
|
||||||
String token
|
String token
|
||||||
@@ -19,7 +20,7 @@ public record LicenseResponse(
|
|||||||
public static LicenseResponse from(LicenseEntity e) {
|
public static LicenseResponse from(LicenseEntity e) {
|
||||||
return new LicenseResponse(
|
return new LicenseResponse(
|
||||||
e.getId(), e.getTenantId(), e.getTier(),
|
e.getId(), e.getTenantId(), e.getTier(),
|
||||||
e.getFeatures(), e.getLimits(),
|
e.getLabel(), e.getLimits(), e.getGracePeriodDays(),
|
||||||
e.getIssuedAt(), e.getExpiresAt(),
|
e.getIssuedAt(), e.getExpiresAt(),
|
||||||
e.getToken()
|
e.getToken()
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package net.siegeln.cameleer.saas.license.dto;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public record MintLicenseRequest(
|
||||||
|
String tier,
|
||||||
|
Map<String, Integer> limits,
|
||||||
|
Instant expiresAt,
|
||||||
|
Integer gracePeriodDays,
|
||||||
|
String label,
|
||||||
|
boolean pushToServer
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package net.siegeln.cameleer.saas.license.dto;
|
||||||
|
|
||||||
|
public record VerifyLicenseRequest(String token) {}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package net.siegeln.cameleer.saas.license.dto;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public record VerifyLicenseResponse(
|
||||||
|
boolean valid,
|
||||||
|
String state,
|
||||||
|
String tenantId,
|
||||||
|
String label,
|
||||||
|
Map<String, Integer> limits,
|
||||||
|
Instant issuedAt,
|
||||||
|
Instant expiresAt,
|
||||||
|
int gracePeriodDays,
|
||||||
|
String error
|
||||||
|
) {
|
||||||
|
public static VerifyLicenseResponse invalid(String error) {
|
||||||
|
return new VerifyLicenseResponse(false, "INVALID", null, null, null, null, null, 0, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package net.siegeln.cameleer.saas.notification;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
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;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/password-reset-notification")
|
||||||
|
public class PasswordResetNotificationController {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(PasswordResetNotificationController.class);
|
||||||
|
|
||||||
|
private static final long WINDOW_MS = 10 * 60 * 1000L; // 10 minutes
|
||||||
|
private static final int MAX_PER_WINDOW = 3;
|
||||||
|
|
||||||
|
private final PasswordResetNotificationService notificationService;
|
||||||
|
|
||||||
|
// email -> [windowStart, count]
|
||||||
|
private final ConcurrentHashMap<String, long[]> rateLimitMap = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
public PasswordResetNotificationController(PasswordResetNotificationService notificationService) {
|
||||||
|
this.notificationService = notificationService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record NotificationRequest(String email) {}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<Map<String, Object>> sendNotification(@RequestBody NotificationRequest request) {
|
||||||
|
String email = request.email();
|
||||||
|
|
||||||
|
if (email == null || !email.contains("@")) {
|
||||||
|
return ResponseEntity.badRequest()
|
||||||
|
.body(Map.of("error", "Invalid email address"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRateLimited(email)) {
|
||||||
|
log.warn("Rate limit exceeded for password-reset notification to {}", email);
|
||||||
|
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
|
||||||
|
.body(Map.of("error", "Too many requests — please wait before retrying"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fire-and-forget: send asynchronously to avoid blocking the sign-in flow
|
||||||
|
Thread.ofVirtual().start(() -> notificationService.sendNotification(email));
|
||||||
|
|
||||||
|
return ResponseEntity.ok(Map.of("sent", true));
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isRateLimited(String email) {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
var entry = rateLimitMap.compute(email, (key, existing) -> {
|
||||||
|
if (existing == null || now - existing[0] >= WINDOW_MS) {
|
||||||
|
// New window
|
||||||
|
return new long[]{now, 1};
|
||||||
|
}
|
||||||
|
// Same window — increment
|
||||||
|
existing[1]++;
|
||||||
|
return existing;
|
||||||
|
});
|
||||||
|
return entry[1] > MAX_PER_WINDOW;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package net.siegeln.cameleer.saas.notification;
|
||||||
|
|
||||||
|
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
|
||||||
|
import net.siegeln.cameleer.saas.provisioning.ProvisioningProperties;
|
||||||
|
import net.siegeln.cameleer.saas.vendor.EmailConnectorService;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.core.io.ClassPathResource;
|
||||||
|
import org.springframework.mail.javamail.JavaMailSenderImpl;
|
||||||
|
import org.springframework.mail.javamail.MimeMessageHelper;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.time.ZonedDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Properties;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class PasswordResetNotificationService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(PasswordResetNotificationService.class);
|
||||||
|
private static final DateTimeFormatter TIMESTAMP_FMT =
|
||||||
|
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm 'UTC'");
|
||||||
|
|
||||||
|
private final EmailConnectorService emailConnectorService;
|
||||||
|
private final LogtoManagementClient logtoClient;
|
||||||
|
private final ProvisioningProperties provisioningProps;
|
||||||
|
|
||||||
|
public PasswordResetNotificationService(EmailConnectorService emailConnectorService,
|
||||||
|
LogtoManagementClient logtoClient,
|
||||||
|
ProvisioningProperties provisioningProps) {
|
||||||
|
this.emailConnectorService = emailConnectorService;
|
||||||
|
this.logtoClient = logtoClient;
|
||||||
|
this.provisioningProps = provisioningProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a password-reset security notification to the given email address.
|
||||||
|
* Fire-and-forget: logs a warning on failure but does not throw.
|
||||||
|
*/
|
||||||
|
public void sendNotification(String toEmail) {
|
||||||
|
try {
|
||||||
|
doSend(toEmail);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to send password-reset notification to {}: {}", toEmail, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private void doSend(String toEmail) throws Exception {
|
||||||
|
// Read the full connector config from Logto (includes password)
|
||||||
|
var connectorStatus = emailConnectorService.getEmailConnector();
|
||||||
|
if (connectorStatus == null) {
|
||||||
|
log.warn("No email connector configured — skipping password-reset notification for {}", toEmail);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-read the raw connector config to get the password (getEmailConnector() omits it)
|
||||||
|
var connectors = logtoClient.listConnectors();
|
||||||
|
var raw = connectors.stream()
|
||||||
|
.filter(c -> "Email".equals(c.get("type")))
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
if (raw == null) {
|
||||||
|
log.warn("Email connector not found in raw list — skipping notification for {}", toEmail);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var config = (Map<String, Object>) raw.getOrDefault("config", Map.of());
|
||||||
|
var auth = (Map<String, Object>) config.getOrDefault("auth", Map.of());
|
||||||
|
|
||||||
|
String host = connectorStatus.host();
|
||||||
|
int port = connectorStatus.port();
|
||||||
|
String username = connectorStatus.username();
|
||||||
|
String fromEmail = connectorStatus.fromEmail();
|
||||||
|
String password = String.valueOf(auth.getOrDefault("pass", ""));
|
||||||
|
|
||||||
|
String htmlBody = buildHtmlBody();
|
||||||
|
|
||||||
|
// Build a programmatic JavaMailSender from the runtime SMTP config
|
||||||
|
var sender = new JavaMailSenderImpl();
|
||||||
|
sender.setHost(host);
|
||||||
|
sender.setPort(port);
|
||||||
|
sender.setUsername(username);
|
||||||
|
sender.setPassword(password);
|
||||||
|
sender.setDefaultEncoding("UTF-8");
|
||||||
|
|
||||||
|
Properties props = sender.getJavaMailProperties();
|
||||||
|
props.put("mail.transport.protocol", "smtp");
|
||||||
|
props.put("mail.smtp.auth", "true");
|
||||||
|
if (port == 465) {
|
||||||
|
props.put("mail.smtp.ssl.enable", "true");
|
||||||
|
} else {
|
||||||
|
props.put("mail.smtp.starttls.enable", "true");
|
||||||
|
}
|
||||||
|
props.put("mail.smtp.timeout", "10000");
|
||||||
|
props.put("mail.smtp.connectiontimeout", "10000");
|
||||||
|
|
||||||
|
var mimeMessage = sender.createMimeMessage();
|
||||||
|
var helper = new MimeMessageHelper(mimeMessage, false, "UTF-8");
|
||||||
|
helper.setTo(toEmail);
|
||||||
|
helper.setFrom(fromEmail);
|
||||||
|
helper.setSubject("Your Cameleer password was reset");
|
||||||
|
helper.setText(htmlBody, true);
|
||||||
|
|
||||||
|
sender.send(mimeMessage);
|
||||||
|
log.info("Password-reset notification sent to {}", toEmail);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildHtmlBody() throws IOException {
|
||||||
|
String content = new ClassPathResource("email-templates/password-reset-notification.html")
|
||||||
|
.getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
String watermarkUrl = provisioningProps.publicProtocol() + "://"
|
||||||
|
+ provisioningProps.publicHost() + "/platform/assets/email-watermark.png";
|
||||||
|
String timestamp = ZonedDateTime.now(ZoneOffset.UTC).format(TIMESTAMP_FMT);
|
||||||
|
|
||||||
|
return content
|
||||||
|
.replace("{{watermarkUrl}}", watermarkUrl)
|
||||||
|
.replace("{{timestamp}}", timestamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package net.siegeln.cameleer.saas.onboarding;
|
||||||
|
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import java.util.Map;
|
||||||
|
import net.siegeln.cameleer.saas.tenant.TenantEntity;
|
||||||
|
import net.siegeln.cameleer.saas.tenant.dto.TenantResponse;
|
||||||
|
import net.siegeln.cameleer.saas.tenant.TenantRepository;
|
||||||
|
import net.siegeln.cameleer.saas.tenant.TenantStatus;
|
||||||
|
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.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.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/onboarding")
|
||||||
|
public class OnboardingController {
|
||||||
|
|
||||||
|
private final OnboardingService onboardingService;
|
||||||
|
private final TenantRepository tenantRepository;
|
||||||
|
|
||||||
|
public OnboardingController(OnboardingService onboardingService, TenantRepository tenantRepository) {
|
||||||
|
this.onboardingService = onboardingService;
|
||||||
|
this.tenantRepository = tenantRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@GetMapping("/slug-available")
|
||||||
|
public ResponseEntity<Map<String, Boolean>> slugAvailable(@RequestParam String slug) {
|
||||||
|
boolean taken = tenantRepository.existsBySlugAndStatusNot(slug, TenantStatus.DELETED);
|
||||||
|
return ResponseEntity.ok(Map.of("available", !taken));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/tenant")
|
||||||
|
public ResponseEntity<TenantResponse> createTrialTenant(
|
||||||
|
@Valid @RequestBody CreateTrialTenantRequest request,
|
||||||
|
@AuthenticationPrincipal Jwt jwt) {
|
||||||
|
|
||||||
|
String userId = jwt.getSubject();
|
||||||
|
try {
|
||||||
|
TenantEntity tenant = onboardingService.createTrialTenant(request.name(), request.slug(), userId);
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(TenantResponse.from(tenant));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.status(HttpStatus.CONFLICT).body(null);
|
||||||
|
} catch (IllegalStateException e) {
|
||||||
|
return ResponseEntity.status(HttpStatus.CONFLICT).body(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
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.Map;
|
||||||
|
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, "STARTER", 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);
|
||||||
|
|
||||||
|
// Set display name from email if not already set (email-registered users have no name)
|
||||||
|
var user = logtoClient.getUser(logtoUserId);
|
||||||
|
if (user != null && (user.get("name") == null || String.valueOf(user.get("name")).isBlank())) {
|
||||||
|
String email = String.valueOf(user.getOrDefault("primaryEmail", ""));
|
||||||
|
if (!email.isBlank() && email.contains("@")) {
|
||||||
|
String displayName = email.substring(0, email.indexOf('@'));
|
||||||
|
logtoClient.updateUserProfile(logtoUserId, Map.of("name", displayName));
|
||||||
|
log.info("Set display name '{}' for user {}", displayName, logtoUserId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package net.siegeln.cameleer.saas.portal;
|
|||||||
import net.siegeln.cameleer.saas.certificate.TenantCaCertEntity;
|
import net.siegeln.cameleer.saas.certificate.TenantCaCertEntity;
|
||||||
import net.siegeln.cameleer.saas.certificate.TenantCaCertService;
|
import net.siegeln.cameleer.saas.certificate.TenantCaCertService;
|
||||||
import net.siegeln.cameleer.saas.config.TenantContext;
|
import net.siegeln.cameleer.saas.config.TenantContext;
|
||||||
|
import net.siegeln.cameleer.saas.tenant.TenantService;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
import org.springframework.security.oauth2.jwt.Jwt;
|
import org.springframework.security.oauth2.jwt.Jwt;
|
||||||
@@ -29,10 +30,14 @@ public class TenantPortalController {
|
|||||||
|
|
||||||
private final TenantPortalService portalService;
|
private final TenantPortalService portalService;
|
||||||
private final TenantCaCertService caCertService;
|
private final TenantCaCertService caCertService;
|
||||||
|
private final TenantService tenantService;
|
||||||
|
|
||||||
public TenantPortalController(TenantPortalService portalService, TenantCaCertService caCertService) {
|
public TenantPortalController(TenantPortalService portalService,
|
||||||
|
TenantCaCertService caCertService,
|
||||||
|
TenantService tenantService) {
|
||||||
this.portalService = portalService;
|
this.portalService = portalService;
|
||||||
this.caCertService = caCertService;
|
this.caCertService = caCertService;
|
||||||
|
this.tenantService = tenantService;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Request bodies ---
|
// --- Request bodies ---
|
||||||
@@ -43,6 +48,8 @@ public class TenantPortalController {
|
|||||||
|
|
||||||
public record PasswordChangeRequest(String password) {}
|
public record PasswordChangeRequest(String password) {}
|
||||||
|
|
||||||
|
public record TotpVerifyRequest(String secret, String code) {}
|
||||||
|
|
||||||
// --- Endpoints ---
|
// --- Endpoints ---
|
||||||
|
|
||||||
@GetMapping("/dashboard")
|
@GetMapping("/dashboard")
|
||||||
@@ -134,6 +141,68 @@ public class TenantPortalController {
|
|||||||
return ResponseEntity.ok(portalService.getSettings());
|
return ResponseEntity.ok(portalService.getSettings());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- MFA endpoints ---
|
||||||
|
|
||||||
|
@GetMapping("/mfa/status")
|
||||||
|
public ResponseEntity<TenantPortalService.MfaStatusData> getMfaStatus(@AuthenticationPrincipal Jwt jwt) {
|
||||||
|
return ResponseEntity.ok(portalService.getMfaStatus(jwt.getSubject()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/mfa/totp/setup")
|
||||||
|
public ResponseEntity<TenantPortalService.MfaSetupData> setupTotp(@AuthenticationPrincipal Jwt jwt) {
|
||||||
|
return ResponseEntity.ok(portalService.setupTotp(jwt.getSubject()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/mfa/totp/verify")
|
||||||
|
public ResponseEntity<?> verifyTotp(@AuthenticationPrincipal Jwt jwt,
|
||||||
|
@RequestBody TotpVerifyRequest request) {
|
||||||
|
boolean valid = portalService.verifyTotpCode(request.secret(), request.code());
|
||||||
|
if (!valid) {
|
||||||
|
return ResponseEntity.unprocessableEntity().body(Map.of("verified", false));
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(Map.of("verified", true));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/mfa/backup-codes")
|
||||||
|
public ResponseEntity<TenantPortalService.BackupCodesData> generateBackupCodes(
|
||||||
|
@AuthenticationPrincipal Jwt jwt) {
|
||||||
|
return ResponseEntity.ok(portalService.generateBackupCodes(jwt.getSubject()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/mfa/totp")
|
||||||
|
public ResponseEntity<Void> removeTotp(@AuthenticationPrincipal Jwt jwt) {
|
||||||
|
portalService.removeTotp(jwt.getSubject());
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/users/{userId}/mfa")
|
||||||
|
public ResponseEntity<Void> resetTeamMemberMfa(@PathVariable String userId) {
|
||||||
|
try {
|
||||||
|
portalService.resetTeamMemberMfa(userId);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.badRequest().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PatchMapping("/settings")
|
||||||
|
public ResponseEntity<Void> updateSettings(@RequestBody Map<String, Object> updates) {
|
||||||
|
portalService.updateTenantSettings(updates);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{slug}/mfa-policy")
|
||||||
|
public ResponseEntity<Map<String, Object>> getMfaPolicy(@PathVariable String slug) {
|
||||||
|
var tenantOpt = tenantService.getBySlug(slug);
|
||||||
|
if (tenantOpt.isEmpty()) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
var tenant = tenantOpt.get();
|
||||||
|
Map<String, Object> settings = tenant.getSettings() != null ? tenant.getSettings() : Map.of();
|
||||||
|
boolean mfaRequired = Boolean.TRUE.equals(settings.get("mfaRequired"));
|
||||||
|
return ResponseEntity.ok(Map.of("mfaRequired", mfaRequired));
|
||||||
|
}
|
||||||
|
|
||||||
// --- CA Certificate management ---
|
// --- CA Certificate management ---
|
||||||
|
|
||||||
public record CaCertResponse(
|
public record CaCertResponse(
|
||||||
|
|||||||
@@ -15,8 +15,13 @@ import org.slf4j.LoggerFactory;
|
|||||||
import org.springframework.context.annotation.Lazy;
|
import org.springframework.context.annotation.Lazy;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import javax.crypto.Mac;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.security.SecureRandom;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.temporal.ChronoUnit;
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@@ -56,13 +61,14 @@ public class TenantPortalService {
|
|||||||
String name, String slug, String tier, String status,
|
String name, String slug, String tier, String status,
|
||||||
boolean serverHealthy, String serverStatus, String serverEndpoint,
|
boolean serverHealthy, String serverStatus, String serverEndpoint,
|
||||||
String licenseTier, long licenseDaysRemaining,
|
String licenseTier, long licenseDaysRemaining,
|
||||||
Map<String, Object> limits, Map<String, Object> features,
|
Map<String, Object> limits,
|
||||||
int agentCount, int environmentCount
|
int agentCount, int environmentCount
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public record LicenseData(
|
public record LicenseData(
|
||||||
UUID id, String tier, Map<String, Object> features, Map<String, Object> limits,
|
UUID id, String tier, String label, Map<String, Object> limits,
|
||||||
Instant issuedAt, Instant expiresAt, String token, long daysRemaining
|
int gracePeriodDays, Instant issuedAt, Instant expiresAt,
|
||||||
|
String token, long daysRemaining
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public record TenantSettingsData(
|
public record TenantSettingsData(
|
||||||
@@ -70,6 +76,12 @@ public class TenantPortalService {
|
|||||||
String serverEndpoint, Instant createdAt
|
String serverEndpoint, Instant createdAt
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
public record MfaStatusData(boolean enrolled, boolean hasBackupCodes) {}
|
||||||
|
|
||||||
|
public record MfaSetupData(String secret, String secretQrCode) {}
|
||||||
|
|
||||||
|
public record BackupCodesData(List<String> codes) {}
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
|
|
||||||
private TenantEntity resolveTenant() {
|
private TenantEntity resolveTenant() {
|
||||||
@@ -107,7 +119,6 @@ public class TenantPortalService {
|
|||||||
String licenseTier = null;
|
String licenseTier = null;
|
||||||
long licenseDaysRemaining = 0;
|
long licenseDaysRemaining = 0;
|
||||||
Map<String, Object> limits = Map.of();
|
Map<String, Object> limits = Map.of();
|
||||||
Map<String, Object> features = Map.of();
|
|
||||||
|
|
||||||
var licenseOpt = licenseService.getActiveLicense(tenant.getId());
|
var licenseOpt = licenseService.getActiveLicense(tenant.getId());
|
||||||
if (licenseOpt.isPresent()) {
|
if (licenseOpt.isPresent()) {
|
||||||
@@ -115,7 +126,6 @@ public class TenantPortalService {
|
|||||||
licenseTier = lic.getTier();
|
licenseTier = lic.getTier();
|
||||||
licenseDaysRemaining = daysUntil(lic.getExpiresAt());
|
licenseDaysRemaining = daysUntil(lic.getExpiresAt());
|
||||||
limits = lic.getLimits() != null ? lic.getLimits() : Map.of();
|
limits = lic.getLimits() != null ? lic.getLimits() : Map.of();
|
||||||
features = lic.getFeatures() != null ? lic.getFeatures() : Map.of();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return new DashboardData(
|
return new DashboardData(
|
||||||
@@ -123,7 +133,7 @@ public class TenantPortalService {
|
|||||||
tenant.getTier().name(), tenant.getStatus().name(),
|
tenant.getTier().name(), tenant.getStatus().name(),
|
||||||
serverHealthy, serverStatus, endpoint,
|
serverHealthy, serverStatus, endpoint,
|
||||||
licenseTier, licenseDaysRemaining,
|
licenseTier, licenseDaysRemaining,
|
||||||
limits, features, agentCount, environmentCount
|
limits, agentCount, environmentCount
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,9 +141,9 @@ public class TenantPortalService {
|
|||||||
TenantEntity tenant = resolveTenant();
|
TenantEntity tenant = resolveTenant();
|
||||||
return licenseService.getActiveLicense(tenant.getId())
|
return licenseService.getActiveLicense(tenant.getId())
|
||||||
.map(lic -> new LicenseData(
|
.map(lic -> new LicenseData(
|
||||||
lic.getId(), lic.getTier(),
|
lic.getId(), lic.getTier(), lic.getLabel(),
|
||||||
lic.getFeatures() != null ? lic.getFeatures() : Map.of(),
|
|
||||||
lic.getLimits() != null ? lic.getLimits() : Map.of(),
|
lic.getLimits() != null ? lic.getLimits() : Map.of(),
|
||||||
|
lic.getGracePeriodDays(),
|
||||||
lic.getIssuedAt(), lic.getExpiresAt(),
|
lic.getIssuedAt(), lic.getExpiresAt(),
|
||||||
lic.getToken(), daysUntil(lic.getExpiresAt())
|
lic.getToken(), daysUntil(lic.getExpiresAt())
|
||||||
))
|
))
|
||||||
@@ -257,4 +267,164 @@ public class TenantPortalService {
|
|||||||
vendorTenantService.provisionAsync(
|
vendorTenantService.provisionAsync(
|
||||||
tenant.getId(), tenant.getSlug(), tenant.getTier().name(), token, null);
|
tenant.getId(), tenant.getSlug(), tenant.getTier().name(), token, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- MFA methods ---
|
||||||
|
|
||||||
|
public MfaStatusData getMfaStatus(String userId) {
|
||||||
|
var verifications = logtoClient.getUserMfaVerifications(userId);
|
||||||
|
boolean enrolled = verifications.stream()
|
||||||
|
.anyMatch(v -> "Totp".equals(String.valueOf(v.get("type"))));
|
||||||
|
boolean hasBackupCodes = verifications.stream()
|
||||||
|
.anyMatch(v -> "BackupCode".equals(String.valueOf(v.get("type"))));
|
||||||
|
return new MfaStatusData(enrolled, hasBackupCodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public MfaSetupData setupTotp(String userId) {
|
||||||
|
byte[] secretBytes = new byte[20];
|
||||||
|
new SecureRandom().nextBytes(secretBytes);
|
||||||
|
String secret = base32Encode(secretBytes);
|
||||||
|
|
||||||
|
var response = logtoClient.createTotpVerification(userId, secret);
|
||||||
|
String qrCode = "";
|
||||||
|
if (response.containsKey("secretQrCode")) {
|
||||||
|
qrCode = String.valueOf(response.get("secretQrCode"));
|
||||||
|
} else if (response.containsKey("qrCode")) {
|
||||||
|
qrCode = String.valueOf(response.get("qrCode"));
|
||||||
|
}
|
||||||
|
return new MfaSetupData(secret, qrCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean verifyTotpCode(String secret, String code) {
|
||||||
|
if (secret == null || code == null || code.length() != 6) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
long currentTimeStep = System.currentTimeMillis() / 30000;
|
||||||
|
// Allow +-1 step drift
|
||||||
|
for (long step = currentTimeStep - 1; step <= currentTimeStep + 1; step++) {
|
||||||
|
String computed = computeTotp(secret, step);
|
||||||
|
if (computed.equals(code)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public BackupCodesData generateBackupCodes(String userId) {
|
||||||
|
var response = logtoClient.createBackupCodes(userId);
|
||||||
|
List<String> codes = List.of();
|
||||||
|
if (response.containsKey("codes")) {
|
||||||
|
var rawCodes = response.get("codes");
|
||||||
|
if (rawCodes instanceof List) {
|
||||||
|
codes = ((List<Object>) rawCodes).stream()
|
||||||
|
.map(String::valueOf)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new BackupCodesData(codes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void removeTotp(String userId) {
|
||||||
|
var verifications = logtoClient.getUserMfaVerifications(userId);
|
||||||
|
for (var v : verifications) {
|
||||||
|
String id = String.valueOf(v.get("id"));
|
||||||
|
logtoClient.deleteMfaVerification(userId, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void resetTeamMemberMfa(String userId) {
|
||||||
|
TenantEntity tenant = resolveTenant();
|
||||||
|
String orgId = tenant.getLogtoOrgId();
|
||||||
|
if (orgId == null || orgId.isBlank()) {
|
||||||
|
throw new IllegalStateException("Tenant has no Logto organization configured");
|
||||||
|
}
|
||||||
|
// Verify the target user belongs to this tenant's org
|
||||||
|
var members = logtoClient.listOrganizationMembers(orgId);
|
||||||
|
boolean isMember = members.stream()
|
||||||
|
.anyMatch(m -> userId.equals(String.valueOf(m.get("id"))));
|
||||||
|
if (!isMember) {
|
||||||
|
throw new IllegalArgumentException("User is not a member of this organization");
|
||||||
|
}
|
||||||
|
logtoClient.deleteAllMfaVerifications(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updateTenantSettings(Map<String, Object> updates) {
|
||||||
|
TenantEntity tenant = resolveTenant();
|
||||||
|
Map<String, Object> settings = new HashMap<>(
|
||||||
|
tenant.getSettings() != null ? tenant.getSettings() : Map.of());
|
||||||
|
// Only allow known keys
|
||||||
|
if (updates.containsKey("mfaRequired")) {
|
||||||
|
settings.put("mfaRequired", Boolean.TRUE.equals(updates.get("mfaRequired")));
|
||||||
|
}
|
||||||
|
tenant.setSettings(settings);
|
||||||
|
tenantService.save(tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- TOTP helpers ---
|
||||||
|
|
||||||
|
private String computeTotp(String base32Secret, long timeStep) {
|
||||||
|
try {
|
||||||
|
byte[] key = base32Decode(base32Secret);
|
||||||
|
byte[] data = ByteBuffer.allocate(8).putLong(timeStep).array();
|
||||||
|
|
||||||
|
Mac mac = Mac.getInstance("HmacSHA1");
|
||||||
|
mac.init(new SecretKeySpec(key, "HmacSHA1"));
|
||||||
|
byte[] hash = mac.doFinal(data);
|
||||||
|
|
||||||
|
int offset = hash[hash.length - 1] & 0x0F;
|
||||||
|
int binary = ((hash[offset] & 0x7F) << 24)
|
||||||
|
| ((hash[offset + 1] & 0xFF) << 16)
|
||||||
|
| ((hash[offset + 2] & 0xFF) << 8)
|
||||||
|
| (hash[offset + 3] & 0xFF);
|
||||||
|
int otp = binary % 1_000_000;
|
||||||
|
return String.format("%06d", otp);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("TOTP computation failed", e);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final String BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||||
|
|
||||||
|
private String base32Encode(byte[] data) {
|
||||||
|
StringBuilder result = new StringBuilder();
|
||||||
|
int buffer = 0;
|
||||||
|
int bitsLeft = 0;
|
||||||
|
for (byte b : data) {
|
||||||
|
buffer = (buffer << 8) | (b & 0xFF);
|
||||||
|
bitsLeft += 8;
|
||||||
|
while (bitsLeft >= 5) {
|
||||||
|
result.append(BASE32_ALPHABET.charAt((buffer >> (bitsLeft - 5)) & 0x1F));
|
||||||
|
bitsLeft -= 5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bitsLeft > 0) {
|
||||||
|
result.append(BASE32_ALPHABET.charAt((buffer << (5 - bitsLeft)) & 0x1F));
|
||||||
|
}
|
||||||
|
return result.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] base32Decode(String encoded) {
|
||||||
|
String upper = encoded.toUpperCase().replaceAll("[=\\s]", "");
|
||||||
|
int[] output = new int[upper.length() * 5 / 8];
|
||||||
|
int buffer = 0;
|
||||||
|
int bitsLeft = 0;
|
||||||
|
int index = 0;
|
||||||
|
for (char c : upper.toCharArray()) {
|
||||||
|
int val = BASE32_ALPHABET.indexOf(c);
|
||||||
|
if (val < 0) continue;
|
||||||
|
buffer = (buffer << 5) | val;
|
||||||
|
bitsLeft += 5;
|
||||||
|
if (bitsLeft >= 8) {
|
||||||
|
output[index++] = (buffer >> (bitsLeft - 8)) & 0xFF;
|
||||||
|
bitsLeft -= 8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
byte[] result = new byte[index];
|
||||||
|
for (int i = 0; i < index; i++) {
|
||||||
|
result[i] = (byte) output[i];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,9 +21,12 @@ public class DockerTenantProvisioner implements TenantProvisioner {
|
|||||||
|
|
||||||
private final DockerClient docker;
|
private final DockerClient docker;
|
||||||
private final ProvisioningProperties props;
|
private final ProvisioningProperties props;
|
||||||
|
private final net.siegeln.cameleer.saas.license.SigningKeyService signingKeyService;
|
||||||
|
|
||||||
public DockerTenantProvisioner(DockerClientConfig config, ProvisioningProperties props) {
|
public DockerTenantProvisioner(DockerClientConfig config, ProvisioningProperties props,
|
||||||
|
net.siegeln.cameleer.saas.license.SigningKeyService signingKeyService) {
|
||||||
this.props = props;
|
this.props = props;
|
||||||
|
this.signingKeyService = signingKeyService;
|
||||||
DockerHttpClient httpClient = new ZerodepDockerHttpClient.Builder()
|
DockerHttpClient httpClient = new ZerodepDockerHttpClient.Builder()
|
||||||
.dockerHost(config.getDockerHost())
|
.dockerHost(config.getDockerHost())
|
||||||
.maxConnections(10)
|
.maxConnections(10)
|
||||||
@@ -223,6 +226,7 @@ public class DockerTenantProvisioner implements TenantProvisioner {
|
|||||||
"CAMELEER_SERVER_SECURITY_OIDC_AUDIENCE=https://api.cameleer.local",
|
"CAMELEER_SERVER_SECURITY_OIDC_AUDIENCE=https://api.cameleer.local",
|
||||||
"CAMELEER_SERVER_SECURITY_CORSALLOWEDORIGINS=" + props.corsOrigins(),
|
"CAMELEER_SERVER_SECURITY_CORSALLOWEDORIGINS=" + props.corsOrigins(),
|
||||||
"CAMELEER_SERVER_LICENSE_TOKEN=" + req.licenseToken(),
|
"CAMELEER_SERVER_LICENSE_TOKEN=" + req.licenseToken(),
|
||||||
|
"CAMELEER_SERVER_LICENSE_PUBLICKEY=" + signingKeyService.getPublicKeyBase64(),
|
||||||
"CAMELEER_SERVER_RUNTIME_ENABLED=true",
|
"CAMELEER_SERVER_RUNTIME_ENABLED=true",
|
||||||
"CAMELEER_SERVER_RUNTIME_SERVERURL=http://" + name + ":8081",
|
"CAMELEER_SERVER_RUNTIME_SERVERURL=http://" + name + ":8081",
|
||||||
"CAMELEER_SERVER_RUNTIME_ROUTINGDOMAIN=" + props.publicHost(),
|
"CAMELEER_SERVER_RUNTIME_ROUTINGDOMAIN=" + props.publicHost(),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package net.siegeln.cameleer.saas.provisioning;
|
|||||||
|
|
||||||
import com.github.dockerjava.core.DefaultDockerClientConfig;
|
import com.github.dockerjava.core.DefaultDockerClientConfig;
|
||||||
import com.github.dockerjava.core.DockerClientConfig;
|
import com.github.dockerjava.core.DockerClientConfig;
|
||||||
|
import net.siegeln.cameleer.saas.license.SigningKeyService;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
@@ -17,13 +18,13 @@ public class TenantProvisionerAutoConfig {
|
|||||||
private static final Logger log = LoggerFactory.getLogger(TenantProvisionerAutoConfig.class);
|
private static final Logger log = LoggerFactory.getLogger(TenantProvisionerAutoConfig.class);
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
TenantProvisioner tenantProvisioner(ProvisioningProperties props) {
|
TenantProvisioner tenantProvisioner(ProvisioningProperties props, SigningKeyService signingKeyService) {
|
||||||
if (Files.exists(Path.of("/var/run/docker.sock"))) {
|
if (Files.exists(Path.of("/var/run/docker.sock"))) {
|
||||||
log.info("Docker socket detected — enabling Docker tenant provisioner");
|
log.info("Docker socket detected — enabling Docker tenant provisioner");
|
||||||
DockerClientConfig config = DefaultDockerClientConfig.createDefaultConfigBuilder()
|
DockerClientConfig config = DefaultDockerClientConfig.createDefaultConfigBuilder()
|
||||||
.withDockerHost("unix:///var/run/docker.sock")
|
.withDockerHost("unix:///var/run/docker.sock")
|
||||||
.build();
|
.build();
|
||||||
return new DockerTenantProvisioner(config, props);
|
return new DockerTenantProvisioner(config, props, signingKeyService);
|
||||||
}
|
}
|
||||||
log.info("No Docker socket — tenant provisioning disabled");
|
log.info("No Docker socket — tenant provisioning disabled");
|
||||||
return new DisabledTenantProvisioner();
|
return new DisabledTenantProvisioner();
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ public class TenantEntity {
|
|||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
@Column(name = "tier", nullable = false, length = 20)
|
@Column(name = "tier", nullable = false, length = 20)
|
||||||
private Tier tier = Tier.LOW;
|
private Tier tier = Tier.STARTER;
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
@Column(name = "status", nullable = false, length = 20)
|
@Column(name = "status", nullable = false, length = 20)
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ public class TenantService {
|
|||||||
var entity = new TenantEntity();
|
var entity = new TenantEntity();
|
||||||
entity.setName(request.name());
|
entity.setName(request.name());
|
||||||
entity.setSlug(request.slug());
|
entity.setSlug(request.slug());
|
||||||
entity.setTier(request.tier() != null ? Tier.valueOf(request.tier()) : Tier.LOW);
|
entity.setTier(request.tier() != null ? Tier.valueOf(request.tier()) : Tier.STARTER);
|
||||||
entity.setStatus(TenantStatus.PROVISIONING);
|
entity.setStatus(TenantStatus.PROVISIONING);
|
||||||
|
|
||||||
var saved = tenantRepository.save(entity);
|
var saved = tenantRepository.save(entity);
|
||||||
@@ -84,6 +84,10 @@ public class TenantService {
|
|||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public TenantEntity save(TenantEntity entity) {
|
||||||
|
return tenantRepository.save(entity);
|
||||||
|
}
|
||||||
|
|
||||||
public TenantEntity suspend(UUID tenantId, UUID actorId) {
|
public TenantEntity suspend(UUID tenantId, UUID actorId) {
|
||||||
var entity = tenantRepository.findById(tenantId)
|
var entity = tenantRepository.findById(tenantId)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Tenant not found"));
|
.orElseThrow(() -> new IllegalArgumentException("Tenant not found"));
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
package net.siegeln.cameleer.saas.tenant;
|
package net.siegeln.cameleer.saas.tenant;
|
||||||
|
|
||||||
public enum Tier {
|
public enum Tier {
|
||||||
LOW, MID, HIGH, BUSINESS
|
STARTER, TEAM, BUSINESS, ENTERPRISE
|
||||||
}
|
}
|
||||||
|
|||||||
131
src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorController.java
vendored
Normal file
131
src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorController.java
vendored
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
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,
|
||||||
|
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<?> save(@Valid @RequestBody SmtpConfigRequest request) {
|
||||||
|
// Resolve password: use provided value, or fall back to existing password from Logto
|
||||||
|
String password = request.password();
|
||||||
|
if (password == null || password.isBlank()) {
|
||||||
|
password = emailConnectorService.getExistingPassword();
|
||||||
|
if (password == null) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", "Password is required for new configuration"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var smtp = new EmailConnectorService.SmtpConfig(
|
||||||
|
request.host(), request.port(), request.username(),
|
||||||
|
password, request.fromEmail()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test SMTP connection before saving
|
||||||
|
try {
|
||||||
|
emailConnectorService.testSmtpConnection(smtp);
|
||||||
|
} catch (IllegalStateException e) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
262
src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorService.java
vendored
Normal file
262
src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorService.java
vendored
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
package net.siegeln.cameleer.saas.vendor;
|
||||||
|
|
||||||
|
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
|
||||||
|
import net.siegeln.cameleer.saas.provisioning.ProvisioningProperties;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.core.io.ClassPathResource;
|
||||||
|
import org.springframework.mail.javamail.JavaMailSenderImpl;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Properties;
|
||||||
|
|
||||||
|
@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;
|
||||||
|
private final ProvisioningProperties provisioningProps;
|
||||||
|
|
||||||
|
public EmailConnectorService(LogtoManagementClient logtoClient, ProvisioningProperties provisioningProps) {
|
||||||
|
this.logtoClient = logtoClient;
|
||||||
|
this.provisioningProps = provisioningProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the existing SMTP password from Logto, or null if not configured.
|
||||||
|
* Used to retain the password when the user edits other fields without re-entering it.
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public String getExistingPassword() {
|
||||||
|
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 pass = String.valueOf(auth.getOrDefault("pass", ""));
|
||||||
|
return pass.isEmpty() ? null : pass;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test SMTP connection by performing EHLO + auth. Throws on failure.
|
||||||
|
*/
|
||||||
|
public void testSmtpConnection(SmtpConfig smtp) {
|
||||||
|
var sender = new JavaMailSenderImpl();
|
||||||
|
sender.setHost(smtp.host());
|
||||||
|
sender.setPort(smtp.port());
|
||||||
|
sender.setUsername(smtp.username());
|
||||||
|
sender.setPassword(smtp.password());
|
||||||
|
sender.setDefaultEncoding("UTF-8");
|
||||||
|
|
||||||
|
Properties props = sender.getJavaMailProperties();
|
||||||
|
props.put("mail.transport.protocol", "smtp");
|
||||||
|
props.put("mail.smtp.auth", "true");
|
||||||
|
if (smtp.port() == 465) {
|
||||||
|
props.put("mail.smtp.ssl.enable", "true");
|
||||||
|
} else {
|
||||||
|
props.put("mail.smtp.starttls.enable", "true");
|
||||||
|
}
|
||||||
|
props.put("mail.smtp.timeout", "10000");
|
||||||
|
props.put("mail.smtp.connectiontimeout", "10000");
|
||||||
|
|
||||||
|
try {
|
||||||
|
sender.testConnection();
|
||||||
|
log.info("SMTP connection test successful: {}:{}", smtp.host(), smtp.port());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("SMTP connection test failed: {}:{} — {}", smtp.host(), smtp.port(), e.getMessage());
|
||||||
|
throw new IllegalStateException("SMTP connection failed: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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", "email", "password", true, "verificationCode", false, "isPasswordPrimary", true),
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load an email template from classpath and resolve the watermark URL placeholder. */
|
||||||
|
private String loadTemplate(String filename) {
|
||||||
|
try {
|
||||||
|
String content = new ClassPathResource("email-templates/" + filename)
|
||||||
|
.getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
String watermarkUrl = provisioningProps.publicProtocol() + "://"
|
||||||
|
+ provisioningProps.publicHost() + "/platform/assets/email-watermark.png";
|
||||||
|
return content.replace("{{watermarkUrl}}", watermarkUrl);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalStateException("Failed to load email template: " + filename, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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", "Your caravan pass is almost ready",
|
||||||
|
"content", loadTemplate("register.html")
|
||||||
|
),
|
||||||
|
Map.of(
|
||||||
|
"usageType", "SignIn",
|
||||||
|
"contentType", "text/html",
|
||||||
|
"subject", "Your Cameleer sign-in code",
|
||||||
|
"content", loadTemplate("sign-in.html")
|
||||||
|
),
|
||||||
|
Map.of(
|
||||||
|
"usageType", "ForgotPassword",
|
||||||
|
"contentType", "text/html",
|
||||||
|
"subject", "Reset your Cameleer password",
|
||||||
|
"content", loadTemplate("forgot-password.html")
|
||||||
|
),
|
||||||
|
Map.of(
|
||||||
|
"usageType", "Generic",
|
||||||
|
"contentType", "text/html",
|
||||||
|
"subject", "Your Cameleer verification code",
|
||||||
|
"content", loadTemplate("generic.html")
|
||||||
|
)
|
||||||
|
));
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -224,9 +224,13 @@ public class InfrastructureService {
|
|||||||
.put(table, cnt);
|
.put(table, cnt);
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
if (e.getMessage() != null && e.getMessage().contains("UNKNOWN_TABLE")) {
|
||||||
|
log.debug("ClickHouse table '{}' does not exist yet — skipping", table);
|
||||||
|
} else {
|
||||||
log.error("Failed to query ClickHouse table '{}' for tenant stats: {}", table, e.getMessage(), e);
|
log.error("Failed to query ClickHouse table '{}' for tenant stats: {}", table, e.getMessage(), e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Failed to get ClickHouse tenant stats: {}", e.getMessage(), e);
|
log.error("Failed to get ClickHouse tenant stats: {}", e.getMessage(), e);
|
||||||
throw new RuntimeException("Failed to get ClickHouse tenant stats", e);
|
throw new RuntimeException("Failed to get ClickHouse tenant stats", e);
|
||||||
@@ -256,10 +260,14 @@ public class InfrastructureService {
|
|||||||
result.add(new ChTableStats(table, rs.getLong("cnt")));
|
result.add(new ChTableStats(table, rs.getLong("cnt")));
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
if (e.getMessage() != null && e.getMessage().contains("UNKNOWN_TABLE")) {
|
||||||
|
log.debug("ClickHouse table '{}' does not exist yet — skipping", table);
|
||||||
|
} else {
|
||||||
log.error("Failed to query ClickHouse table '{}' for tenant '{}': {}",
|
log.error("Failed to query ClickHouse table '{}' for tenant '{}': {}",
|
||||||
table, tenantId, e.getMessage(), e);
|
table, tenantId, e.getMessage(), e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Failed to get ClickHouse tenant detail for '{}': {}", tenantId, e.getMessage(), e);
|
log.error("Failed to get ClickHouse tenant detail for '{}': {}", tenantId, e.getMessage(), e);
|
||||||
throw new RuntimeException("Failed to get ClickHouse tenant detail for: " + tenantId, e);
|
throw new RuntimeException("Failed to get ClickHouse tenant detail for: " + tenantId, e);
|
||||||
|
|||||||
83
src/main/java/net/siegeln/cameleer/saas/vendor/VendorLicenseController.java
vendored
Normal file
83
src/main/java/net/siegeln/cameleer/saas/vendor/VendorLicenseController.java
vendored
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
package net.siegeln.cameleer.saas.vendor;
|
||||||
|
|
||||||
|
import com.cameleer.license.LicenseInfo;
|
||||||
|
import net.siegeln.cameleer.saas.license.LicenseDefaults;
|
||||||
|
import net.siegeln.cameleer.saas.license.LicenseService;
|
||||||
|
import net.siegeln.cameleer.saas.license.dto.LicensePreset;
|
||||||
|
import net.siegeln.cameleer.saas.license.dto.VerifyLicenseRequest;
|
||||||
|
import net.siegeln.cameleer.saas.license.dto.VerifyLicenseResponse;
|
||||||
|
import net.siegeln.cameleer.saas.tenant.Tier;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
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.time.Instant;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/vendor")
|
||||||
|
@PreAuthorize("hasAuthority('SCOPE_platform:admin')")
|
||||||
|
public class VendorLicenseController {
|
||||||
|
|
||||||
|
private final VendorTenantService vendorTenantService;
|
||||||
|
private final LicenseService licenseService;
|
||||||
|
|
||||||
|
public VendorLicenseController(VendorTenantService vendorTenantService,
|
||||||
|
LicenseService licenseService) {
|
||||||
|
this.vendorTenantService = vendorTenantService;
|
||||||
|
this.licenseService = licenseService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/license-presets")
|
||||||
|
public ResponseEntity<List<LicensePreset>> getPresets() {
|
||||||
|
List<LicensePreset> presets = Arrays.stream(Tier.values())
|
||||||
|
.map(t -> new LicensePreset(t.name(), LicenseDefaults.limitsForTier(t)))
|
||||||
|
.toList();
|
||||||
|
return ResponseEntity.ok(presets);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/license/verify")
|
||||||
|
public ResponseEntity<VerifyLicenseResponse> verifyLicense(@RequestBody VerifyLicenseRequest request) {
|
||||||
|
if (request.token() == null || request.token().isBlank()) {
|
||||||
|
return ResponseEntity.badRequest().body(VerifyLicenseResponse.invalid("Token is required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = licenseService.verifyTokenSignature(request.token());
|
||||||
|
if (result.isEmpty()) {
|
||||||
|
return ResponseEntity.ok(VerifyLicenseResponse.invalid("Invalid signature or malformed token"));
|
||||||
|
}
|
||||||
|
|
||||||
|
LicenseInfo info = result.get();
|
||||||
|
String state = computeState(info);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(new VerifyLicenseResponse(
|
||||||
|
true, state,
|
||||||
|
info.tenantId(), info.label(), info.limits(),
|
||||||
|
info.issuedAt(), info.expiresAt(), info.gracePeriodDays(),
|
||||||
|
null
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/signing-key/public")
|
||||||
|
public ResponseEntity<Map<String, String>> getPublicKey() {
|
||||||
|
return ResponseEntity.ok(Map.of("publicKey", vendorTenantService.getPublicKeyBase64()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String computeState(LicenseInfo info) {
|
||||||
|
Instant now = Instant.now();
|
||||||
|
if (now.isBefore(info.expiresAt()) || now.equals(info.expiresAt())) {
|
||||||
|
return "ACTIVE";
|
||||||
|
}
|
||||||
|
Instant graceEnd = info.expiresAt().plusSeconds((long) info.gracePeriodDays() * 86400);
|
||||||
|
if (now.isBefore(graceEnd) || now.equals(graceEnd)) {
|
||||||
|
return "GRACE";
|
||||||
|
}
|
||||||
|
return "EXPIRED";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,9 @@ package net.siegeln.cameleer.saas.vendor;
|
|||||||
|
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import net.siegeln.cameleer.saas.identity.ServerApiClient.ServerHealthResponse;
|
import net.siegeln.cameleer.saas.identity.ServerApiClient.ServerHealthResponse;
|
||||||
|
import net.siegeln.cameleer.saas.license.dto.LicenseBundleResponse;
|
||||||
import net.siegeln.cameleer.saas.license.dto.LicenseResponse;
|
import net.siegeln.cameleer.saas.license.dto.LicenseResponse;
|
||||||
|
import net.siegeln.cameleer.saas.license.dto.MintLicenseRequest;
|
||||||
import net.siegeln.cameleer.saas.provisioning.ServerStatus;
|
import net.siegeln.cameleer.saas.provisioning.ServerStatus;
|
||||||
import net.siegeln.cameleer.saas.tenant.TenantEntity;
|
import net.siegeln.cameleer.saas.tenant.TenantEntity;
|
||||||
import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest;
|
import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest;
|
||||||
@@ -82,8 +84,8 @@ public class VendorTenantController {
|
|||||||
var license = vendorTenantService.getLicenseForTenant(tenant.getId());
|
var license = vendorTenantService.getLicenseForTenant(tenant.getId());
|
||||||
if (license.isPresent() && license.get().getLimits() != null) {
|
if (license.isPresent() && license.get().getLimits() != null) {
|
||||||
var limits = license.get().getLimits();
|
var limits = license.get().getLimits();
|
||||||
if (limits.containsKey("agents")) {
|
if (limits.containsKey("max_agents")) {
|
||||||
agentLimit = ((Number) limits.get("agents")).intValue();
|
agentLimit = ((Number) limits.get("max_agents")).intValue();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return new VendorTenantSummary(
|
return new VendorTenantSummary(
|
||||||
@@ -189,12 +191,22 @@ public class VendorTenantController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/{id}/license")
|
@PostMapping("/{id}/license")
|
||||||
public ResponseEntity<LicenseResponse> renewLicense(@PathVariable UUID id,
|
public ResponseEntity<LicenseBundleResponse> mintLicense(@PathVariable UUID id,
|
||||||
|
@RequestBody(required = false) MintLicenseRequest request,
|
||||||
@AuthenticationPrincipal Jwt jwt) {
|
@AuthenticationPrincipal Jwt jwt) {
|
||||||
UUID actorId = resolveActorId(jwt);
|
UUID actorId = resolveActorId(jwt);
|
||||||
|
// Default to tier-preset, auto-push if no body provided
|
||||||
|
if (request == null) {
|
||||||
|
request = new MintLicenseRequest(null, null, null, null, null, true);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
var license = vendorTenantService.renewLicense(id, actorId);
|
var tenant = vendorTenantService.getById(id)
|
||||||
return ResponseEntity.ok(LicenseResponse.from(license));
|
.orElseThrow(() -> new IllegalArgumentException("Tenant not found"));
|
||||||
|
var license = vendorTenantService.mintLicense(id, request, actorId);
|
||||||
|
String publicKey = vendorTenantService.getPublicKeyBase64();
|
||||||
|
boolean pushed = request.pushToServer() && tenant.getServerEndpoint() != null;
|
||||||
|
return ResponseEntity.ok(LicenseBundleResponse.from(
|
||||||
|
license, tenant.getSlug(), publicKey, pushed));
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,11 @@ import net.siegeln.cameleer.saas.provisioning.ProvisioningProperties;
|
|||||||
import net.siegeln.cameleer.saas.provisioning.TenantDatabaseService;
|
import net.siegeln.cameleer.saas.provisioning.TenantDatabaseService;
|
||||||
import net.siegeln.cameleer.saas.provisioning.TenantDataCleanupService;
|
import net.siegeln.cameleer.saas.provisioning.TenantDataCleanupService;
|
||||||
import net.siegeln.cameleer.saas.identity.ServerApiClient.ServerHealthResponse;
|
import net.siegeln.cameleer.saas.identity.ServerApiClient.ServerHealthResponse;
|
||||||
|
import net.siegeln.cameleer.saas.license.LicenseDefaults;
|
||||||
import net.siegeln.cameleer.saas.license.LicenseEntity;
|
import net.siegeln.cameleer.saas.license.LicenseEntity;
|
||||||
import net.siegeln.cameleer.saas.license.LicenseService;
|
import net.siegeln.cameleer.saas.license.LicenseService;
|
||||||
|
import net.siegeln.cameleer.saas.license.SigningKeyService;
|
||||||
|
import net.siegeln.cameleer.saas.license.dto.MintLicenseRequest;
|
||||||
import net.siegeln.cameleer.saas.provisioning.ProvisionResult;
|
import net.siegeln.cameleer.saas.provisioning.ProvisionResult;
|
||||||
import net.siegeln.cameleer.saas.provisioning.ServerStatus;
|
import net.siegeln.cameleer.saas.provisioning.ServerStatus;
|
||||||
import net.siegeln.cameleer.saas.provisioning.TenantProvisionRequest;
|
import net.siegeln.cameleer.saas.provisioning.TenantProvisionRequest;
|
||||||
@@ -26,6 +29,8 @@ import org.springframework.context.annotation.Lazy;
|
|||||||
import org.springframework.scheduling.annotation.Async;
|
import org.springframework.scheduling.annotation.Async;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.transaction.support.TransactionSynchronization;
|
||||||
|
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -42,6 +47,7 @@ public class VendorTenantService {
|
|||||||
private final TenantService tenantService;
|
private final TenantService tenantService;
|
||||||
private final TenantRepository tenantRepository;
|
private final TenantRepository tenantRepository;
|
||||||
private final LicenseService licenseService;
|
private final LicenseService licenseService;
|
||||||
|
private final SigningKeyService signingKeyService;
|
||||||
private final TenantProvisioner tenantProvisioner;
|
private final TenantProvisioner tenantProvisioner;
|
||||||
private final ServerApiClient serverApiClient;
|
private final ServerApiClient serverApiClient;
|
||||||
private final LogtoManagementClient logtoClient;
|
private final LogtoManagementClient logtoClient;
|
||||||
@@ -55,6 +61,7 @@ public class VendorTenantService {
|
|||||||
public VendorTenantService(TenantService tenantService,
|
public VendorTenantService(TenantService tenantService,
|
||||||
TenantRepository tenantRepository,
|
TenantRepository tenantRepository,
|
||||||
LicenseService licenseService,
|
LicenseService licenseService,
|
||||||
|
SigningKeyService signingKeyService,
|
||||||
TenantProvisioner tenantProvisioner,
|
TenantProvisioner tenantProvisioner,
|
||||||
ServerApiClient serverApiClient,
|
ServerApiClient serverApiClient,
|
||||||
LogtoManagementClient logtoClient,
|
LogtoManagementClient logtoClient,
|
||||||
@@ -67,6 +74,7 @@ public class VendorTenantService {
|
|||||||
this.tenantService = tenantService;
|
this.tenantService = tenantService;
|
||||||
this.tenantRepository = tenantRepository;
|
this.tenantRepository = tenantRepository;
|
||||||
this.licenseService = licenseService;
|
this.licenseService = licenseService;
|
||||||
|
this.signingKeyService = signingKeyService;
|
||||||
this.tenantProvisioner = tenantProvisioner;
|
this.tenantProvisioner = tenantProvisioner;
|
||||||
this.serverApiClient = serverApiClient;
|
this.serverApiClient = serverApiClient;
|
||||||
this.logtoClient = logtoClient;
|
this.logtoClient = logtoClient;
|
||||||
@@ -116,9 +124,19 @@ public class VendorTenantService {
|
|||||||
AuditAction.TENANT_CREATE, "provision:" + tenant.getSlug(),
|
AuditAction.TENANT_CREATE, "provision:" + tenant.getSlug(),
|
||||||
null, null, "SUCCESS", null);
|
null, null, "SUCCESS", null);
|
||||||
|
|
||||||
// 4. Provision server asynchronously (Docker containers, health check, config push)
|
// 4. Provision server asynchronously AFTER transaction commits
|
||||||
|
// (the async thread needs the tenant row to be visible)
|
||||||
if (tenantProvisioner.isAvailable()) {
|
if (tenantProvisioner.isAvailable()) {
|
||||||
self.provisionAsync(tenant.getId(), tenant.getSlug(), tenant.getTier().name(), license.getToken(), actorId);
|
UUID tenantId = tenant.getId();
|
||||||
|
String slug = tenant.getSlug();
|
||||||
|
String tierName = tenant.getTier().name();
|
||||||
|
String token = license.getToken();
|
||||||
|
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||||
|
@Override
|
||||||
|
public void afterCommit() {
|
||||||
|
self.provisionAsync(tenantId, slug, tierName, token, actorId);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return tenant;
|
return tenant;
|
||||||
@@ -352,27 +370,71 @@ public class VendorTenantService {
|
|||||||
null, null, "SUCCESS", null);
|
null, null, "SUCCESS", null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mint a license with configurable limits, expiry, grace period, and label.
|
||||||
|
* Optionally pushes to the tenant's server.
|
||||||
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public LicenseEntity renewLicense(UUID tenantId, UUID actorId) {
|
public LicenseEntity mintLicense(UUID tenantId, MintLicenseRequest request, UUID actorId) {
|
||||||
TenantEntity tenant = tenantService.getById(tenantId)
|
TenantEntity tenant = tenantService.getById(tenantId)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Tenant not found"));
|
.orElseThrow(() -> new IllegalArgumentException("Tenant not found"));
|
||||||
|
|
||||||
// Revoke current license
|
// Revoke current license
|
||||||
licenseService.revokeLicense(tenantId, actorId);
|
licenseService.revokeLicense(tenantId, actorId);
|
||||||
|
|
||||||
// Generate new license
|
// Resolve limits: use provided limits, or fall back to tier preset
|
||||||
LicenseEntity newLicense = licenseService.generateLicense(tenant, DEFAULT_LICENSE_VALIDITY, actorId);
|
Map<String, Integer> limits;
|
||||||
|
if (request.limits() != null && !request.limits().isEmpty()) {
|
||||||
|
limits = request.limits();
|
||||||
|
} else if (request.tier() != null) {
|
||||||
|
limits = LicenseDefaults.limitsForTier(
|
||||||
|
net.siegeln.cameleer.saas.tenant.Tier.valueOf(request.tier()));
|
||||||
|
} else {
|
||||||
|
limits = LicenseDefaults.limitsForTier(tenant.getTier());
|
||||||
|
}
|
||||||
|
|
||||||
// Push to server
|
java.time.Instant expiresAt = request.expiresAt() != null
|
||||||
|
? request.expiresAt()
|
||||||
|
: java.time.Instant.now().plus(DEFAULT_LICENSE_VALIDITY);
|
||||||
|
|
||||||
|
int gracePeriodDays = request.gracePeriodDays() != null
|
||||||
|
? request.gracePeriodDays()
|
||||||
|
: LicenseDefaults.DEFAULT_GRACE_PERIOD_DAYS;
|
||||||
|
|
||||||
|
String label = request.label() != null
|
||||||
|
? request.label()
|
||||||
|
: tenant.getName() + " (" + tenant.getTier().name() + ")";
|
||||||
|
|
||||||
|
LicenseEntity newLicense = licenseService.generateLicense(
|
||||||
|
tenant, limits, expiresAt, gracePeriodDays, label, actorId);
|
||||||
|
|
||||||
|
// Push to server if requested and endpoint available
|
||||||
|
boolean pushed = false;
|
||||||
|
if (request.pushToServer()) {
|
||||||
String endpoint = tenant.getServerEndpoint();
|
String endpoint = tenant.getServerEndpoint();
|
||||||
if (endpoint != null && !endpoint.isBlank()) {
|
if (endpoint != null && !endpoint.isBlank()) {
|
||||||
try {
|
try {
|
||||||
serverApiClient.pushLicense(endpoint, newLicense.getToken());
|
serverApiClient.pushLicense(endpoint, newLicense.getToken());
|
||||||
|
pushed = true;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("Failed to push renewed license to server for tenant {}: {}", tenant.getSlug(), e.getMessage());
|
log.warn("Failed to push license to server for tenant {}: {}", tenant.getSlug(), e.getMessage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return newLicense;
|
return newLicense;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backward-compatible renewal using tier presets and auto-push.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public LicenseEntity renewLicense(UUID tenantId, UUID actorId) {
|
||||||
|
var request = new MintLicenseRequest(null, null, null, null, null, true);
|
||||||
|
return mintLicense(tenantId, request, actorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPublicKeyBase64() {
|
||||||
|
return signingKeyService.getPublicKeyBase64();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
34
src/main/resources/db/migration/V002__license_minter.sql
Normal file
34
src/main/resources/db/migration/V002__license_minter.sql
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
-- V002: License minter integration
|
||||||
|
-- Ed25519 signing keys for license minting
|
||||||
|
CREATE TABLE signing_keys (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
public_key_b64 TEXT NOT NULL,
|
||||||
|
private_key_b64 TEXT NOT NULL,
|
||||||
|
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Rename tiers: LOW→STARTER, MID→TEAM, HIGH→BUSINESS, BUSINESS→ENTERPRISE
|
||||||
|
-- Single CASE pass to avoid double-rename
|
||||||
|
UPDATE tenants SET tier = CASE tier
|
||||||
|
WHEN 'LOW' THEN 'STARTER'
|
||||||
|
WHEN 'MID' THEN 'TEAM'
|
||||||
|
WHEN 'HIGH' THEN 'BUSINESS'
|
||||||
|
WHEN 'BUSINESS' THEN 'ENTERPRISE'
|
||||||
|
ELSE tier
|
||||||
|
END WHERE tier IN ('LOW', 'MID', 'HIGH', 'BUSINESS');
|
||||||
|
|
||||||
|
UPDATE licenses SET tier = CASE tier
|
||||||
|
WHEN 'LOW' THEN 'STARTER'
|
||||||
|
WHEN 'MID' THEN 'TEAM'
|
||||||
|
WHEN 'HIGH' THEN 'BUSINESS'
|
||||||
|
WHEN 'BUSINESS' THEN 'ENTERPRISE'
|
||||||
|
ELSE tier
|
||||||
|
END WHERE tier IN ('LOW', 'MID', 'HIGH', 'BUSINESS');
|
||||||
|
|
||||||
|
-- Add new license columns for Ed25519 model
|
||||||
|
ALTER TABLE licenses ADD COLUMN label VARCHAR(255);
|
||||||
|
ALTER TABLE licenses ADD COLUMN grace_period_days INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- Drop features column (server enforces caps, not feature flags)
|
||||||
|
ALTER TABLE licenses DROP COLUMN features;
|
||||||
23
src/main/resources/email-templates/forgot-password.html
Normal file
23
src/main/resources/email-templates/forgot-password.html
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:480px;margin:0 auto;background:#ffffff;border-radius:8px;overflow:hidden;border:1px solid #e8e0d4;">
|
||||||
|
<div style="background:#C6820E;padding:20px 24px;text-align:center;">
|
||||||
|
<span style="font-size:22px;font-weight:700;color:#ffffff;letter-spacing:0.5px;">Cameleer.io</span>
|
||||||
|
</div>
|
||||||
|
<div style="padding:32px 24px 24px;position:relative;overflow:hidden;">
|
||||||
|
<img src="{{watermarkUrl}}" style="position:absolute;top:-30px;right:-50px;width:320px;height:320px;opacity:0.07;pointer-events:none;border:0;outline:none;" alt="" />
|
||||||
|
<div style="position:relative;">
|
||||||
|
<p style="color:#1a1a1a;font-size:16px;font-weight:600;margin:0 0 8px;">Lost in the dunes?</p>
|
||||||
|
<p style="color:#444;font-size:14px;line-height:1.6;margin:0 0 24px;">No worries — enter this code to reset your password and get back on the trail.</p>
|
||||||
|
<div style="text-align:center;margin:0 0 24px;">
|
||||||
|
<div style="display:inline-block;background:#FDF6EC;border:2px solid #C6820E;border-radius:8px;padding:16px 32px;">
|
||||||
|
<span style="font-size:32px;font-weight:700;letter-spacing:8px;color:#C6820E;font-family:'Courier New',Courier,monospace;">{{code}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p style="color:#888;font-size:13px;line-height:1.5;margin:0 0 16px;">This code expires in 10 minutes. If you didn't request a password reset, you can safely ignore this email.</p>
|
||||||
|
<p style="color:#888;font-size:13px;line-height:1.5;margin:0;">This password reset was requested for your Cameleer account. Cameleer is an observability platform for Apache Camel integrations providing real-time route tracing, message inspection, and performance monitoring. For security, this code can only be used once. If you continue to have trouble accessing your account, please contact your administrator for assistance.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="border-top:1px solid #e8e0d4;padding:16px 24px;text-align:center;">
|
||||||
|
<p style="color:#999;font-size:12px;margin:0;">Questions? Contact your administrator</p>
|
||||||
|
<p style="color:#bbb;font-size:11px;margin:6px 0 0;">Cameleer — Apache Camel observability</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
23
src/main/resources/email-templates/generic.html
Normal file
23
src/main/resources/email-templates/generic.html
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:480px;margin:0 auto;background:#ffffff;border-radius:8px;overflow:hidden;border:1px solid #e8e0d4;">
|
||||||
|
<div style="background:#C6820E;padding:20px 24px;text-align:center;">
|
||||||
|
<span style="font-size:22px;font-weight:700;color:#ffffff;letter-spacing:0.5px;">Cameleer.io</span>
|
||||||
|
</div>
|
||||||
|
<div style="padding:32px 24px 24px;position:relative;overflow:hidden;">
|
||||||
|
<img src="{{watermarkUrl}}" style="position:absolute;top:-30px;right:-50px;width:320px;height:320px;opacity:0.07;pointer-events:none;border:0;outline:none;" alt="" />
|
||||||
|
<div style="position:relative;">
|
||||||
|
<p style="color:#1a1a1a;font-size:16px;font-weight:600;margin:0 0 8px;">Quick checkpoint</p>
|
||||||
|
<p style="color:#444;font-size:14px;line-height:1.6;margin:0 0 24px;">Here's your verification code. Just making sure it's really you at the reins.</p>
|
||||||
|
<div style="text-align:center;margin:0 0 24px;">
|
||||||
|
<div style="display:inline-block;background:#FDF6EC;border:2px solid #C6820E;border-radius:8px;padding:16px 32px;">
|
||||||
|
<span style="font-size:32px;font-weight:700;letter-spacing:8px;color:#C6820E;font-family:'Courier New',Courier,monospace;">{{code}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p style="color:#888;font-size:13px;line-height:1.5;margin:0 0 16px;">This code expires in 10 minutes.</p>
|
||||||
|
<p style="color:#888;font-size:13px;line-height:1.5;margin:0;">You are receiving this email because a verification was requested on your Cameleer account. Cameleer is an observability platform for Apache Camel integrations providing real-time route tracing, message inspection, and performance monitoring. If you did not request this verification, please ignore this email or contact your administrator for assistance.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="border-top:1px solid #e8e0d4;padding:16px 24px;text-align:center;">
|
||||||
|
<p style="color:#999;font-size:12px;margin:0;">Questions? Contact your administrator</p>
|
||||||
|
<p style="color:#bbb;font-size:11px;margin:6px 0 0;">Cameleer — Apache Camel observability</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:480px;margin:0 auto;background:#ffffff;border-radius:8px;overflow:hidden;border:1px solid #e8e0d4;">
|
||||||
|
<div style="background:#C6820E;padding:20px 24px;text-align:center;">
|
||||||
|
<span style="font-size:22px;font-weight:700;color:#ffffff;letter-spacing:0.5px;">Cameleer.io</span>
|
||||||
|
</div>
|
||||||
|
<div style="padding:32px 24px 24px;position:relative;overflow:hidden;">
|
||||||
|
<img src="{{watermarkUrl}}" style="position:absolute;top:-30px;right:-50px;width:320px;height:320px;opacity:0.07;pointer-events:none;border:0;outline:none;" alt="" />
|
||||||
|
<div style="position:relative;">
|
||||||
|
<p style="color:#1a1a1a;font-size:16px;font-weight:600;margin:0 0 8px;">Your password was reset</p>
|
||||||
|
<p style="color:#444;font-size:14px;line-height:1.6;margin:0 0 16px;">Your Cameleer account password was successfully changed on {{timestamp}}.</p>
|
||||||
|
<div style="background:#FDF6EC;border:1px solid #e8e0d4;border-radius:6px;padding:12px 16px;margin:0 0 16px;">
|
||||||
|
<p style="color:#444;font-size:13px;line-height:1.5;margin:0;"><strong>Note:</strong> Multi-factor authentication (MFA) was not required for this password reset. We recommend enabling MFA to add an extra layer of security to your account.</p>
|
||||||
|
</div>
|
||||||
|
<p style="color:#888;font-size:13px;line-height:1.5;margin:0 0 16px;">If this wasn't you, contact your administrator immediately.</p>
|
||||||
|
<p style="color:#888;font-size:13px;line-height:1.5;margin:0;">This is an automated security notification from your Cameleer account. Cameleer is an observability platform for Apache Camel integrations providing real-time route tracing, message inspection, and performance monitoring. You are receiving this notification because a password change was completed. For your security, we recommend reviewing your account activity and ensuring your credentials are kept safe.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="border-top:1px solid #e8e0d4;padding:16px 24px;text-align:center;">
|
||||||
|
<p style="color:#999;font-size:12px;margin:0;">Questions? Contact your administrator</p>
|
||||||
|
<p style="color:#bbb;font-size:11px;margin:6px 0 0;">Cameleer — Apache Camel observability</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
23
src/main/resources/email-templates/register.html
Normal file
23
src/main/resources/email-templates/register.html
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:480px;margin:0 auto;background:#ffffff;border-radius:8px;overflow:hidden;border:1px solid #e8e0d4;">
|
||||||
|
<div style="background:#C6820E;padding:20px 24px;text-align:center;">
|
||||||
|
<span style="font-size:22px;font-weight:700;color:#ffffff;letter-spacing:0.5px;">Cameleer.io</span>
|
||||||
|
</div>
|
||||||
|
<div style="padding:32px 24px 24px;position:relative;overflow:hidden;">
|
||||||
|
<img src="{{watermarkUrl}}" style="position:absolute;top:-30px;right:-50px;width:320px;height:320px;opacity:0.07;pointer-events:none;border:0;outline:none;" alt="" />
|
||||||
|
<div style="position:relative;">
|
||||||
|
<p style="color:#1a1a1a;font-size:16px;font-weight:600;margin:0 0 8px;">Welcome to the caravan!</p>
|
||||||
|
<p style="color:#444;font-size:14px;line-height:1.6;margin:0 0 24px;">Enter this code to verify your email and claim your spot. The dunes wait for no one.</p>
|
||||||
|
<div style="text-align:center;margin:0 0 24px;">
|
||||||
|
<div style="display:inline-block;background:#FDF6EC;border:2px solid #C6820E;border-radius:8px;padding:16px 32px;">
|
||||||
|
<span style="font-size:32px;font-weight:700;letter-spacing:8px;color:#C6820E;font-family:'Courier New',Courier,monospace;">{{code}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p style="color:#888;font-size:13px;line-height:1.5;margin:0 0 16px;">This code expires in 10 minutes. If you didn't request this, you can safely ignore this email — no camels were harmed.</p>
|
||||||
|
<p style="color:#888;font-size:13px;line-height:1.5;margin:0;">Cameleer is an observability platform for Apache Camel integrations. It provides real-time route tracing, message inspection, and performance monitoring to help your team debug and optimize integration flows. Your account gives you access to your dedicated Cameleer instance where you can connect your Camel applications and start monitoring immediately.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="border-top:1px solid #e8e0d4;padding:16px 24px;text-align:center;">
|
||||||
|
<p style="color:#999;font-size:12px;margin:0;">Questions? Contact your administrator</p>
|
||||||
|
<p style="color:#bbb;font-size:11px;margin:6px 0 0;">Cameleer — Apache Camel observability</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
23
src/main/resources/email-templates/sign-in.html
Normal file
23
src/main/resources/email-templates/sign-in.html
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:480px;margin:0 auto;background:#ffffff;border-radius:8px;overflow:hidden;border:1px solid #e8e0d4;">
|
||||||
|
<div style="background:#C6820E;padding:20px 24px;text-align:center;">
|
||||||
|
<span style="font-size:22px;font-weight:700;color:#ffffff;letter-spacing:0.5px;">Cameleer.io</span>
|
||||||
|
</div>
|
||||||
|
<div style="padding:32px 24px 24px;position:relative;overflow:hidden;">
|
||||||
|
<img src="{{watermarkUrl}}" style="position:absolute;top:-30px;right:-50px;width:320px;height:320px;opacity:0.07;pointer-events:none;border:0;outline:none;" alt="" />
|
||||||
|
<div style="position:relative;">
|
||||||
|
<p style="color:#1a1a1a;font-size:16px;font-weight:600;margin:0 0 8px;">Back at the oasis already?</p>
|
||||||
|
<p style="color:#444;font-size:14px;line-height:1.6;margin:0 0 24px;">Here's your sign-in code. The caravan master is checking credentials.</p>
|
||||||
|
<div style="text-align:center;margin:0 0 24px;">
|
||||||
|
<div style="display:inline-block;background:#FDF6EC;border:2px solid #C6820E;border-radius:8px;padding:16px 32px;">
|
||||||
|
<span style="font-size:32px;font-weight:700;letter-spacing:8px;color:#C6820E;font-family:'Courier New',Courier,monospace;">{{code}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p style="color:#888;font-size:13px;line-height:1.5;margin:0 0 16px;">This code expires in 10 minutes.</p>
|
||||||
|
<p style="color:#888;font-size:13px;line-height:1.5;margin:0;">You are receiving this email because a sign-in attempt was made on your Cameleer account. Cameleer is an observability platform for Apache Camel integrations providing real-time route tracing, message inspection, and performance monitoring. If you did not initiate this sign-in, please ignore this email or contact your administrator.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="border-top:1px solid #e8e0d4;padding:16px 24px;text-align:center;">
|
||||||
|
<p style="color:#999;font-size:12px;margin:0;">Questions? Contact your administrator</p>
|
||||||
|
<p style="color:#bbb;font-size:11px;margin:6px 0 0;">Cameleer — Apache Camel observability</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
BIN
src/main/resources/static/assets/email-watermark.png
Normal file
BIN
src/main/resources/static/assets/email-watermark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
@@ -35,7 +35,7 @@ class LicenseControllerTest {
|
|||||||
|
|
||||||
private String createTenantAndGetId() throws Exception {
|
private String createTenantAndGetId() throws Exception {
|
||||||
String slug = "license-tenant-" + System.nanoTime();
|
String slug = "license-tenant-" + System.nanoTime();
|
||||||
var request = new CreateTenantRequest("License Test Org", slug, "MID", null, null);
|
var request = new CreateTenantRequest("License Test Org", slug, "TEAM", null, null);
|
||||||
|
|
||||||
var result = mockMvc.perform(post("/api/tenants")
|
var result = mockMvc.perform(post("/api/tenants")
|
||||||
.with(jwt().jwt(j -> j
|
.with(jwt().jwt(j -> j
|
||||||
@@ -60,8 +60,7 @@ class LicenseControllerTest {
|
|||||||
new SimpleGrantedAuthority("SCOPE_platform:admin"))))
|
new SimpleGrantedAuthority("SCOPE_platform:admin"))))
|
||||||
.andExpect(status().isCreated())
|
.andExpect(status().isCreated())
|
||||||
.andExpect(jsonPath("$.token").isNotEmpty())
|
.andExpect(jsonPath("$.token").isNotEmpty())
|
||||||
.andExpect(jsonPath("$.tier").value("MID"))
|
.andExpect(jsonPath("$.tier").value("TEAM"));
|
||||||
.andExpect(jsonPath("$.features.correlation").value(true));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -78,7 +77,7 @@ class LicenseControllerTest {
|
|||||||
.with(jwt().jwt(j -> j.claim("sub", "test-user"))
|
.with(jwt().jwt(j -> j.claim("sub", "test-user"))
|
||||||
.authorities(new SimpleGrantedAuthority("SCOPE_platform:admin"))))
|
.authorities(new SimpleGrantedAuthority("SCOPE_platform:admin"))))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.tier").value("MID"));
|
.andExpect(jsonPath("$.tier").value("TEAM"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ import org.mockito.ArgumentCaptor;
|
|||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.security.KeyPair;
|
||||||
|
import java.security.KeyPairGenerator;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.Optional;
|
import java.util.Base64;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
@@ -30,11 +32,26 @@ class LicenseServiceTest {
|
|||||||
@Mock
|
@Mock
|
||||||
private AuditService auditService;
|
private AuditService auditService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private SigningKeyService signingKeyService;
|
||||||
|
|
||||||
private LicenseService licenseService;
|
private LicenseService licenseService;
|
||||||
|
|
||||||
|
private KeyPair testKeyPair;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() throws Exception {
|
||||||
licenseService = new LicenseService(licenseRepository, auditService);
|
licenseService = new LicenseService(licenseRepository, auditService, signingKeyService);
|
||||||
|
testKeyPair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void stubPrivateKey() {
|
||||||
|
when(signingKeyService.getPrivateKey()).thenReturn(testKeyPair.getPrivate());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void stubPublicKey() {
|
||||||
|
when(signingKeyService.getPublicKeyBase64()).thenReturn(
|
||||||
|
Base64.getEncoder().encodeToString(testKeyPair.getPublic().getEncoded()));
|
||||||
}
|
}
|
||||||
|
|
||||||
private TenantEntity createTenant(Tier tier) {
|
private TenantEntity createTenant(Tier tier) {
|
||||||
@@ -65,69 +82,71 @@ class LicenseServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void generateLicense_producesUuidToken() {
|
void generateLicense_producesSignedToken() {
|
||||||
var tenant = createTenant(Tier.MID);
|
var tenant = createTenant(Tier.TEAM);
|
||||||
|
stubPrivateKey();
|
||||||
when(licenseRepository.save(any(LicenseEntity.class))).thenAnswer(inv -> withGeneratedId(inv.getArgument(0)));
|
when(licenseRepository.save(any(LicenseEntity.class))).thenAnswer(inv -> withGeneratedId(inv.getArgument(0)));
|
||||||
|
|
||||||
var license = licenseService.generateLicense(tenant, Duration.ofDays(365), UUID.randomUUID());
|
var license = licenseService.generateLicense(tenant, Duration.ofDays(365), UUID.randomUUID());
|
||||||
|
|
||||||
assertThat(license.getToken()).isNotBlank();
|
assertThat(license.getToken()).isNotBlank();
|
||||||
// Token must be a valid UUID string
|
// Ed25519 signed token contains a '.' separator between payload and signature
|
||||||
assertThat(UUID.fromString(license.getToken())).isNotNull();
|
assertThat(license.getToken()).contains(".");
|
||||||
assertThat(license.getTier()).isEqualTo("MID");
|
assertThat(license.getTier()).isEqualTo("TEAM");
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void generateLicense_setsCorrectFeaturesForTier() {
|
|
||||||
var tenant = createTenant(Tier.HIGH);
|
|
||||||
when(licenseRepository.save(any(LicenseEntity.class))).thenAnswer(inv -> withGeneratedId(inv.getArgument(0)));
|
|
||||||
|
|
||||||
var license = licenseService.generateLicense(tenant, Duration.ofDays(30), UUID.randomUUID());
|
|
||||||
|
|
||||||
assertThat(license.getFeatures()).containsEntry("debugger", true);
|
|
||||||
assertThat(license.getFeatures()).containsEntry("replay", true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void generateLicense_setsCorrectLimitsForTier() {
|
void generateLicense_setsCorrectLimitsForTier() {
|
||||||
var tenant = createTenant(Tier.LOW);
|
var tenant = createTenant(Tier.STARTER);
|
||||||
|
stubPrivateKey();
|
||||||
when(licenseRepository.save(any(LicenseEntity.class))).thenAnswer(inv -> withGeneratedId(inv.getArgument(0)));
|
when(licenseRepository.save(any(LicenseEntity.class))).thenAnswer(inv -> withGeneratedId(inv.getArgument(0)));
|
||||||
|
|
||||||
var license = licenseService.generateLicense(tenant, Duration.ofDays(30), UUID.randomUUID());
|
var license = licenseService.generateLicense(tenant, Duration.ofDays(30), UUID.randomUUID());
|
||||||
|
|
||||||
assertThat(license.getLimits()).containsEntry("max_agents", 3);
|
assertThat(license.getLimits()).containsEntry("max_agents", 20);
|
||||||
assertThat(license.getLimits()).containsEntry("retention_days", 7);
|
assertThat(license.getLimits()).containsEntry("max_environments", 2);
|
||||||
|
assertThat(license.getLimits()).containsEntry("max_execution_retention_days", 7);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void verifyLicenseToken_validTokenReturnsPayload() {
|
void generateLicense_setsGracePeriodAndLabel() {
|
||||||
var tenant = createTenant(Tier.MID);
|
var tenant = createTenant(Tier.BUSINESS);
|
||||||
|
stubPrivateKey();
|
||||||
|
when(licenseRepository.save(any(LicenseEntity.class))).thenAnswer(inv -> withGeneratedId(inv.getArgument(0)));
|
||||||
|
|
||||||
|
var license = licenseService.generateLicense(tenant, Duration.ofDays(365), UUID.randomUUID());
|
||||||
|
|
||||||
|
assertThat(license.getGracePeriodDays()).isEqualTo(LicenseDefaults.DEFAULT_GRACE_PERIOD_DAYS);
|
||||||
|
assertThat(license.getLabel()).contains("Test Tenant");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void verifyToken_validTokenReturnsLicenseInfo() {
|
||||||
|
var tenant = createTenant(Tier.TEAM);
|
||||||
|
stubPrivateKey();
|
||||||
|
stubPublicKey();
|
||||||
when(licenseRepository.save(any(LicenseEntity.class))).thenAnswer(inv -> withGeneratedId(inv.getArgument(0)));
|
when(licenseRepository.save(any(LicenseEntity.class))).thenAnswer(inv -> withGeneratedId(inv.getArgument(0)));
|
||||||
|
|
||||||
var license = licenseService.generateLicense(tenant, Duration.ofDays(30), UUID.randomUUID());
|
var license = licenseService.generateLicense(tenant, Duration.ofDays(30), UUID.randomUUID());
|
||||||
|
|
||||||
when(licenseRepository.findByToken(license.getToken())).thenReturn(Optional.of(license));
|
var result = licenseService.verifyToken(license.getToken(), "test");
|
||||||
|
|
||||||
var payload = licenseService.verifyLicenseToken(license.getToken());
|
assertThat(result).isPresent();
|
||||||
|
assertThat(result.get().tenantId()).isEqualTo("test");
|
||||||
assertThat(payload).isPresent();
|
|
||||||
assertThat(payload.get().get("tier")).isEqualTo("MID");
|
|
||||||
assertThat(payload.get().get("tenant_id")).isEqualTo(tenant.getId().toString());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void verifyLicenseToken_unknownTokenReturnsEmpty() {
|
void verifyToken_invalidTokenReturnsEmpty() {
|
||||||
when(licenseRepository.findByToken(any())).thenReturn(Optional.empty());
|
stubPublicKey();
|
||||||
|
var result = licenseService.verifyToken("invalid-token", "test");
|
||||||
var payload = licenseService.verifyLicenseToken("unknown-token");
|
assertThat(result).isEmpty();
|
||||||
|
|
||||||
assertThat(payload).isEmpty();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void generateLicense_logsAuditEvent() {
|
void generateLicense_logsAuditEvent() {
|
||||||
var tenant = createTenant(Tier.LOW);
|
var tenant = createTenant(Tier.STARTER);
|
||||||
var actorId = UUID.randomUUID();
|
var actorId = UUID.randomUUID();
|
||||||
|
stubPrivateKey();
|
||||||
when(licenseRepository.save(any(LicenseEntity.class))).thenAnswer(inv -> withGeneratedId(inv.getArgument(0)));
|
when(licenseRepository.save(any(LicenseEntity.class))).thenAnswer(inv -> withGeneratedId(inv.getArgument(0)));
|
||||||
|
|
||||||
licenseService.generateLicense(tenant, Duration.ofDays(30), actorId);
|
licenseService.generateLicense(tenant, Duration.ofDays(30), actorId);
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ class TenantPortalControllerTest {
|
|||||||
var tenant = new TenantEntity();
|
var tenant = new TenantEntity();
|
||||||
tenant.setName(name);
|
tenant.setName(name);
|
||||||
tenant.setSlug(slug);
|
tenant.setSlug(slug);
|
||||||
tenant.setTier(Tier.LOW);
|
tenant.setTier(Tier.STARTER);
|
||||||
tenant.setStatus(TenantStatus.ACTIVE);
|
tenant.setStatus(TenantStatus.ACTIVE);
|
||||||
tenant.setLogtoOrgId(orgId);
|
tenant.setLogtoOrgId(orgId);
|
||||||
return tenantRepository.save(tenant);
|
return tenantRepository.save(tenant);
|
||||||
|
|||||||
@@ -85,8 +85,8 @@ class TenantPortalServiceTest {
|
|||||||
license.setToken("test-token-" + UUID.randomUUID());
|
license.setToken("test-token-" + UUID.randomUUID());
|
||||||
license.setIssuedAt(Instant.now());
|
license.setIssuedAt(Instant.now());
|
||||||
license.setExpiresAt(expiresAt);
|
license.setExpiresAt(expiresAt);
|
||||||
license.setFeatures(Map.of("feature1", true));
|
license.setLimits(Map.of("max_apps", 10));
|
||||||
license.setLimits(Map.of("maxApps", 10));
|
license.setGracePeriodDays(14);
|
||||||
var f = LicenseEntity.class.getDeclaredField("id");
|
var f = LicenseEntity.class.getDeclaredField("id");
|
||||||
f.setAccessible(true);
|
f.setAccessible(true);
|
||||||
f.set(license, UUID.randomUUID());
|
f.set(license, UUID.randomUUID());
|
||||||
@@ -97,10 +97,10 @@ class TenantPortalServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getDashboard_returnsDashboardData() throws Exception {
|
void getDashboard_returnsDashboardData() throws Exception {
|
||||||
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.LOW, TenantStatus.ACTIVE);
|
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.STARTER, TenantStatus.ACTIVE);
|
||||||
tenant.setServerEndpoint("http://server:8080");
|
tenant.setServerEndpoint("http://server:8080");
|
||||||
var expiresAt = Instant.now().plus(Duration.ofDays(30));
|
var expiresAt = Instant.now().plus(Duration.ofDays(30));
|
||||||
var license = licenseWithId(tenantId, "LOW", expiresAt);
|
var license = licenseWithId(tenantId, "STARTER", expiresAt);
|
||||||
|
|
||||||
when(tenantService.getById(tenantId)).thenReturn(Optional.of(tenant));
|
when(tenantService.getById(tenantId)).thenReturn(Optional.of(tenant));
|
||||||
when(serverApiClient.getHealth("http://server:8080")).thenReturn(new ServerHealthResponse(true, "UP"));
|
when(serverApiClient.getHealth("http://server:8080")).thenReturn(new ServerHealthResponse(true, "UP"));
|
||||||
@@ -112,22 +112,21 @@ class TenantPortalServiceTest {
|
|||||||
|
|
||||||
assertThat(result.name()).isEqualTo("Acme Corp");
|
assertThat(result.name()).isEqualTo("Acme Corp");
|
||||||
assertThat(result.slug()).isEqualTo("acme-corp");
|
assertThat(result.slug()).isEqualTo("acme-corp");
|
||||||
assertThat(result.tier()).isEqualTo("LOW");
|
assertThat(result.tier()).isEqualTo("STARTER");
|
||||||
assertThat(result.status()).isEqualTo("ACTIVE");
|
assertThat(result.status()).isEqualTo("ACTIVE");
|
||||||
assertThat(result.serverHealthy()).isTrue();
|
assertThat(result.serverHealthy()).isTrue();
|
||||||
assertThat(result.serverStatus()).isEqualTo("UP");
|
assertThat(result.serverStatus()).isEqualTo("UP");
|
||||||
assertThat(result.serverEndpoint()).isEqualTo("http://server:8080");
|
assertThat(result.serverEndpoint()).isEqualTo("http://server:8080");
|
||||||
assertThat(result.licenseTier()).isEqualTo("LOW");
|
assertThat(result.licenseTier()).isEqualTo("STARTER");
|
||||||
assertThat(result.licenseDaysRemaining()).isGreaterThanOrEqualTo(29);
|
assertThat(result.licenseDaysRemaining()).isGreaterThanOrEqualTo(29);
|
||||||
assertThat(result.limits()).isNotEmpty();
|
assertThat(result.limits()).isNotEmpty();
|
||||||
assertThat(result.features()).isNotEmpty();
|
|
||||||
assertThat(result.agentCount()).isEqualTo(3);
|
assertThat(result.agentCount()).isEqualTo(3);
|
||||||
assertThat(result.environmentCount()).isEqualTo(1);
|
assertThat(result.environmentCount()).isEqualTo(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getDashboard_handlesNoServer() throws Exception {
|
void getDashboard_handlesNoServer() throws Exception {
|
||||||
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.LOW, TenantStatus.PROVISIONING);
|
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.STARTER, TenantStatus.PROVISIONING);
|
||||||
// serverEndpoint is null by default
|
// serverEndpoint is null by default
|
||||||
|
|
||||||
when(tenantService.getById(tenantId)).thenReturn(Optional.of(tenant));
|
when(tenantService.getById(tenantId)).thenReturn(Optional.of(tenant));
|
||||||
@@ -148,9 +147,9 @@ class TenantPortalServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getLicense_returnsLicenseData() throws Exception {
|
void getLicense_returnsLicenseData() throws Exception {
|
||||||
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.LOW, TenantStatus.ACTIVE);
|
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.STARTER, TenantStatus.ACTIVE);
|
||||||
var expiresAt = Instant.now().plus(Duration.ofDays(60));
|
var expiresAt = Instant.now().plus(Duration.ofDays(60));
|
||||||
var license = licenseWithId(tenantId, "LOW", expiresAt);
|
var license = licenseWithId(tenantId, "STARTER", expiresAt);
|
||||||
|
|
||||||
when(tenantService.getById(tenantId)).thenReturn(Optional.of(tenant));
|
when(tenantService.getById(tenantId)).thenReturn(Optional.of(tenant));
|
||||||
when(licenseService.getActiveLicense(tenantId)).thenReturn(Optional.of(license));
|
when(licenseService.getActiveLicense(tenantId)).thenReturn(Optional.of(license));
|
||||||
@@ -159,18 +158,18 @@ class TenantPortalServiceTest {
|
|||||||
|
|
||||||
assertThat(result).isNotNull();
|
assertThat(result).isNotNull();
|
||||||
assertThat(result.id()).isEqualTo(license.getId());
|
assertThat(result.id()).isEqualTo(license.getId());
|
||||||
assertThat(result.tier()).isEqualTo("LOW");
|
assertThat(result.tier()).isEqualTo("STARTER");
|
||||||
assertThat(result.token()).isEqualTo(license.getToken());
|
assertThat(result.token()).isEqualTo(license.getToken());
|
||||||
assertThat(result.issuedAt()).isEqualTo(license.getIssuedAt());
|
assertThat(result.issuedAt()).isEqualTo(license.getIssuedAt());
|
||||||
assertThat(result.expiresAt()).isEqualTo(expiresAt);
|
assertThat(result.expiresAt()).isEqualTo(expiresAt);
|
||||||
assertThat(result.daysRemaining()).isGreaterThanOrEqualTo(59);
|
assertThat(result.daysRemaining()).isGreaterThanOrEqualTo(59);
|
||||||
assertThat(result.features()).isEqualTo(Map.of("feature1", true));
|
assertThat(result.limits()).isEqualTo(Map.of("max_apps", 10));
|
||||||
assertThat(result.limits()).isEqualTo(Map.of("maxApps", 10));
|
assertThat(result.gracePeriodDays()).isEqualTo(14);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getLicense_returnsNullWhenNoLicense() throws Exception {
|
void getLicense_returnsNullWhenNoLicense() throws Exception {
|
||||||
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.LOW, TenantStatus.ACTIVE);
|
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.STARTER, TenantStatus.ACTIVE);
|
||||||
|
|
||||||
when(tenantService.getById(tenantId)).thenReturn(Optional.of(tenant));
|
when(tenantService.getById(tenantId)).thenReturn(Optional.of(tenant));
|
||||||
when(licenseService.getActiveLicense(tenantId)).thenReturn(Optional.empty());
|
when(licenseService.getActiveLicense(tenantId)).thenReturn(Optional.empty());
|
||||||
@@ -184,7 +183,7 @@ class TenantPortalServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getSettings_returnsSettingsData() throws Exception {
|
void getSettings_returnsSettingsData() throws Exception {
|
||||||
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.MID, TenantStatus.ACTIVE);
|
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.TEAM, TenantStatus.ACTIVE);
|
||||||
tenant.setServerEndpoint("http://server:8080");
|
tenant.setServerEndpoint("http://server:8080");
|
||||||
|
|
||||||
when(tenantService.getById(tenantId)).thenReturn(Optional.of(tenant));
|
when(tenantService.getById(tenantId)).thenReturn(Optional.of(tenant));
|
||||||
@@ -193,7 +192,7 @@ class TenantPortalServiceTest {
|
|||||||
|
|
||||||
assertThat(result.name()).isEqualTo("Acme Corp");
|
assertThat(result.name()).isEqualTo("Acme Corp");
|
||||||
assertThat(result.slug()).isEqualTo("acme-corp");
|
assertThat(result.slug()).isEqualTo("acme-corp");
|
||||||
assertThat(result.tier()).isEqualTo("MID");
|
assertThat(result.tier()).isEqualTo("TEAM");
|
||||||
assertThat(result.status()).isEqualTo("ACTIVE");
|
assertThat(result.status()).isEqualTo("ACTIVE");
|
||||||
assertThat(result.serverEndpoint()).isEqualTo("https://test.example.com/t/acme-corp/");
|
assertThat(result.serverEndpoint()).isEqualTo("https://test.example.com/t/acme-corp/");
|
||||||
assertThat(result.createdAt()).isNull(); // no @PrePersist called in test, createdAt is null
|
assertThat(result.createdAt()).isNull(); // no @PrePersist called in test, createdAt is null
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class TenantControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createTenant_returns201() throws Exception {
|
void createTenant_returns201() throws Exception {
|
||||||
var request = new CreateTenantRequest("Test Org", "test-org-" + System.nanoTime(), "LOW", null, null);
|
var request = new CreateTenantRequest("Test Org", "test-org-" + System.nanoTime(), "STARTER", null, null);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/tenants")
|
mockMvc.perform(post("/api/tenants")
|
||||||
.with(jwt().jwt(j -> j
|
.with(jwt().jwt(j -> j
|
||||||
@@ -47,7 +47,7 @@ class TenantControllerTest {
|
|||||||
.content(objectMapper.writeValueAsString(request)))
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
.andExpect(status().isCreated())
|
.andExpect(status().isCreated())
|
||||||
.andExpect(jsonPath("$.name").value("Test Org"))
|
.andExpect(jsonPath("$.name").value("Test Org"))
|
||||||
.andExpect(jsonPath("$.tier").value("LOW"))
|
.andExpect(jsonPath("$.tier").value("STARTER"))
|
||||||
.andExpect(jsonPath("$.status").value("PROVISIONING"));
|
.andExpect(jsonPath("$.status").value("PROVISIONING"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ class TenantServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void create_savesNewTenantWithCorrectFields() {
|
void create_savesNewTenantWithCorrectFields() {
|
||||||
var request = new CreateTenantRequest("Acme Corp", "acme-corp", "MID", null, null);
|
var request = new CreateTenantRequest("Acme Corp", "acme-corp", "TEAM", null, null);
|
||||||
var actorId = UUID.randomUUID();
|
var actorId = UUID.randomUUID();
|
||||||
|
|
||||||
when(tenantRepository.existsBySlugAndStatusNot("acme-corp", TenantStatus.DELETED)).thenReturn(false);
|
when(tenantRepository.existsBySlugAndStatusNot("acme-corp", TenantStatus.DELETED)).thenReturn(false);
|
||||||
@@ -51,7 +51,7 @@ class TenantServiceTest {
|
|||||||
|
|
||||||
assertThat(result.getName()).isEqualTo("Acme Corp");
|
assertThat(result.getName()).isEqualTo("Acme Corp");
|
||||||
assertThat(result.getSlug()).isEqualTo("acme-corp");
|
assertThat(result.getSlug()).isEqualTo("acme-corp");
|
||||||
assertThat(result.getTier()).isEqualTo(Tier.MID);
|
assertThat(result.getTier()).isEqualTo(Tier.TEAM);
|
||||||
assertThat(result.getStatus()).isEqualTo(TenantStatus.PROVISIONING);
|
assertThat(result.getStatus()).isEqualTo(TenantStatus.PROVISIONING);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ class TenantServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void create_defaultsToLowTier() {
|
void create_defaultsToStarterTier() {
|
||||||
var request = new CreateTenantRequest("Acme Corp", "acme-corp", null, null, null);
|
var request = new CreateTenantRequest("Acme Corp", "acme-corp", null, null, null);
|
||||||
|
|
||||||
when(tenantRepository.existsBySlugAndStatusNot("acme-corp", TenantStatus.DELETED)).thenReturn(false);
|
when(tenantRepository.existsBySlugAndStatusNot("acme-corp", TenantStatus.DELETED)).thenReturn(false);
|
||||||
@@ -90,7 +90,7 @@ class TenantServiceTest {
|
|||||||
|
|
||||||
var result = tenantService.create(request, UUID.randomUUID());
|
var result = tenantService.create(request, UUID.randomUUID());
|
||||||
|
|
||||||
assertThat(result.getTier()).isEqualTo(Tier.LOW);
|
assertThat(result.getTier()).isEqualTo(Tier.STARTER);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
68
src/test/java/net/siegeln/cameleer/saas/vendor/EmailTemplateLoadingTest.java
vendored
Normal file
68
src/test/java/net/siegeln/cameleer/saas/vendor/EmailTemplateLoadingTest.java
vendored
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
package net.siegeln.cameleer.saas.vendor;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.core.io.ClassPathResource;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class EmailTemplateLoadingTest {
|
||||||
|
|
||||||
|
private static final String[] TEMPLATE_FILES = {
|
||||||
|
"email-templates/register.html",
|
||||||
|
"email-templates/sign-in.html",
|
||||||
|
"email-templates/forgot-password.html",
|
||||||
|
"email-templates/generic.html"
|
||||||
|
};
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void allTemplateFilesExistOnClasspath() {
|
||||||
|
for (String path : TEMPLATE_FILES) {
|
||||||
|
var resource = new ClassPathResource(path);
|
||||||
|
assertTrue(resource.exists(), "Template file missing: " + path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void templatesContainCodePlaceholder() throws IOException {
|
||||||
|
for (String path : TEMPLATE_FILES) {
|
||||||
|
String content = new ClassPathResource(path).getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
assertTrue(content.contains("{{code}}"),
|
||||||
|
path + " must contain {{code}} placeholder");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void templatesContainWatermarkPlaceholder() throws IOException {
|
||||||
|
for (String path : TEMPLATE_FILES) {
|
||||||
|
String content = new ClassPathResource(path).getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
assertTrue(content.contains("{{watermarkUrl}}"),
|
||||||
|
path + " must contain {{watermarkUrl}} placeholder");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void watermarkPlaceholderIsReplaced() throws IOException {
|
||||||
|
String content = new ClassPathResource("email-templates/register.html")
|
||||||
|
.getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
String resolved = content.replace("{{watermarkUrl}}",
|
||||||
|
"https://example.com/platform/assets/email-watermark.png");
|
||||||
|
assertFalse(resolved.contains("{{watermarkUrl}}"));
|
||||||
|
assertTrue(resolved.contains("https://example.com/platform/assets/email-watermark.png"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void templatesContainBrandElements() throws IOException {
|
||||||
|
for (String path : TEMPLATE_FILES) {
|
||||||
|
String content = new ClassPathResource(path).getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
assertTrue(content.contains("Cameleer.io"),
|
||||||
|
path + " must contain Cameleer.io header");
|
||||||
|
assertTrue(content.contains("Apache Camel observability"),
|
||||||
|
path + " must contain tagline");
|
||||||
|
assertTrue(content.contains("#C6820E"),
|
||||||
|
path + " must use brand color");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,7 +51,7 @@ class VendorTenantControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
void listTenants_returnsAllTenants() throws Exception {
|
void listTenants_returnsAllTenants() throws Exception {
|
||||||
String slug = "list-test-" + System.nanoTime();
|
String slug = "list-test-" + System.nanoTime();
|
||||||
createTenant("List Test Org", slug, "LOW");
|
createTenant("List Test Org", slug, "STARTER");
|
||||||
|
|
||||||
mockMvc.perform(get("/api/vendor/tenants")
|
mockMvc.perform(get("/api/vendor/tenants")
|
||||||
.with(jwt().jwt(j -> j
|
.with(jwt().jwt(j -> j
|
||||||
@@ -65,7 +65,7 @@ class VendorTenantControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
void createTenant_returns201() throws Exception {
|
void createTenant_returns201() throws Exception {
|
||||||
String slug = "create-test-" + System.nanoTime();
|
String slug = "create-test-" + System.nanoTime();
|
||||||
var request = new CreateTenantRequest("Create Test Org", slug, "MID", null, null);
|
var request = new CreateTenantRequest("Create Test Org", slug, "TEAM", null, null);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/vendor/tenants")
|
mockMvc.perform(post("/api/vendor/tenants")
|
||||||
.with(jwt().jwt(j -> j
|
.with(jwt().jwt(j -> j
|
||||||
@@ -78,16 +78,16 @@ class VendorTenantControllerTest {
|
|||||||
.andExpect(status().isCreated())
|
.andExpect(status().isCreated())
|
||||||
.andExpect(jsonPath("$.name").value("Create Test Org"))
|
.andExpect(jsonPath("$.name").value("Create Test Org"))
|
||||||
.andExpect(jsonPath("$.slug").value(slug))
|
.andExpect(jsonPath("$.slug").value(slug))
|
||||||
.andExpect(jsonPath("$.tier").value("MID"))
|
.andExpect(jsonPath("$.tier").value("TEAM"))
|
||||||
.andExpect(jsonPath("$.id").isNotEmpty());
|
.andExpect(jsonPath("$.id").isNotEmpty());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createTenant_returns409ForDuplicateSlug() throws Exception {
|
void createTenant_returns409ForDuplicateSlug() throws Exception {
|
||||||
String slug = "duplicate-vendor-" + System.nanoTime();
|
String slug = "duplicate-vendor-" + System.nanoTime();
|
||||||
createTenant("First Org", slug, "LOW");
|
createTenant("First Org", slug, "STARTER");
|
||||||
|
|
||||||
var request = new CreateTenantRequest("Second Org", slug, "LOW", null, null);
|
var request = new CreateTenantRequest("Second Org", slug, "STARTER", null, null);
|
||||||
mockMvc.perform(post("/api/vendor/tenants")
|
mockMvc.perform(post("/api/vendor/tenants")
|
||||||
.with(jwt().jwt(j -> j
|
.with(jwt().jwt(j -> j
|
||||||
.claim("sub", "test-user")
|
.claim("sub", "test-user")
|
||||||
@@ -102,7 +102,7 @@ class VendorTenantControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
void getTenantDetail_returnsDetailWithServerStatus() throws Exception {
|
void getTenantDetail_returnsDetailWithServerStatus() throws Exception {
|
||||||
String slug = "detail-test-" + System.nanoTime();
|
String slug = "detail-test-" + System.nanoTime();
|
||||||
String id = createTenant("Detail Test Org", slug, "LOW");
|
String id = createTenant("Detail Test Org", slug, "STARTER");
|
||||||
|
|
||||||
mockMvc.perform(get("/api/vendor/tenants/" + id)
|
mockMvc.perform(get("/api/vendor/tenants/" + id)
|
||||||
.with(jwt().jwt(j -> j
|
.with(jwt().jwt(j -> j
|
||||||
@@ -118,7 +118,7 @@ class VendorTenantControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
void suspendTenant_returnsUpdatedStatus() throws Exception {
|
void suspendTenant_returnsUpdatedStatus() throws Exception {
|
||||||
String slug = "suspend-test-" + System.nanoTime();
|
String slug = "suspend-test-" + System.nanoTime();
|
||||||
String id = createTenant("Suspend Test Org", slug, "LOW");
|
String id = createTenant("Suspend Test Org", slug, "STARTER");
|
||||||
|
|
||||||
mockMvc.perform(post("/api/vendor/tenants/" + id + "/suspend")
|
mockMvc.perform(post("/api/vendor/tenants/" + id + "/suspend")
|
||||||
.with(jwt().jwt(j -> j
|
.with(jwt().jwt(j -> j
|
||||||
@@ -132,7 +132,7 @@ class VendorTenantControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
void deleteTenant_returns204() throws Exception {
|
void deleteTenant_returns204() throws Exception {
|
||||||
String slug = "delete-test-" + System.nanoTime();
|
String slug = "delete-test-" + System.nanoTime();
|
||||||
String id = createTenant("Delete Test Org", slug, "LOW");
|
String id = createTenant("Delete Test Org", slug, "STARTER");
|
||||||
|
|
||||||
mockMvc.perform(delete("/api/vendor/tenants/" + id)
|
mockMvc.perform(delete("/api/vendor/tenants/" + id)
|
||||||
.with(jwt().jwt(j -> j
|
.with(jwt().jwt(j -> j
|
||||||
@@ -144,7 +144,7 @@ class VendorTenantControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createTenant_returns401WithoutAuth() throws Exception {
|
void createTenant_returns401WithoutAuth() throws Exception {
|
||||||
var request = new CreateTenantRequest("No Auth Org", "no-auth-vendor-" + System.nanoTime(), "LOW", null, null);
|
var request = new CreateTenantRequest("No Auth Org", "no-auth-vendor-" + System.nanoTime(), "STARTER", null, null);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/vendor/tenants")
|
mockMvc.perform(post("/api/vendor/tenants")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import net.siegeln.cameleer.saas.provisioning.TenantDataCleanupService;
|
|||||||
import net.siegeln.cameleer.saas.provisioning.TenantDatabaseService;
|
import net.siegeln.cameleer.saas.provisioning.TenantDatabaseService;
|
||||||
import net.siegeln.cameleer.saas.license.LicenseEntity;
|
import net.siegeln.cameleer.saas.license.LicenseEntity;
|
||||||
import net.siegeln.cameleer.saas.license.LicenseService;
|
import net.siegeln.cameleer.saas.license.LicenseService;
|
||||||
|
import net.siegeln.cameleer.saas.license.SigningKeyService;
|
||||||
import net.siegeln.cameleer.saas.provisioning.ProvisionResult;
|
import net.siegeln.cameleer.saas.provisioning.ProvisionResult;
|
||||||
import net.siegeln.cameleer.saas.provisioning.TenantProvisioner;
|
import net.siegeln.cameleer.saas.provisioning.TenantProvisioner;
|
||||||
import net.siegeln.cameleer.saas.tenant.TenantEntity;
|
import net.siegeln.cameleer.saas.tenant.TenantEntity;
|
||||||
@@ -17,11 +18,14 @@ import net.siegeln.cameleer.saas.tenant.TenantService;
|
|||||||
import net.siegeln.cameleer.saas.tenant.TenantStatus;
|
import net.siegeln.cameleer.saas.tenant.TenantStatus;
|
||||||
import net.siegeln.cameleer.saas.tenant.Tier;
|
import net.siegeln.cameleer.saas.tenant.Tier;
|
||||||
import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest;
|
import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.transaction.support.TransactionSynchronization;
|
||||||
|
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@@ -49,6 +53,9 @@ class VendorTenantServiceTest {
|
|||||||
@Mock
|
@Mock
|
||||||
private LicenseService licenseService;
|
private LicenseService licenseService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private SigningKeyService signingKeyService;
|
||||||
|
|
||||||
@Mock
|
@Mock
|
||||||
private TenantProvisioner tenantProvisioner;
|
private TenantProvisioner tenantProvisioner;
|
||||||
|
|
||||||
@@ -82,13 +89,35 @@ class VendorTenantServiceTest {
|
|||||||
// Pass null for self-proxy initially, then re-create with the instance itself
|
// Pass null for self-proxy initially, then re-create with the instance itself
|
||||||
// (in production, Spring's @Lazy proxy handles this circular ref)
|
// (in production, Spring's @Lazy proxy handles this circular ref)
|
||||||
vendorTenantService = new VendorTenantService(
|
vendorTenantService = new VendorTenantService(
|
||||||
tenantService, tenantRepository, licenseService,
|
tenantService, tenantRepository, licenseService, signingKeyService,
|
||||||
tenantProvisioner, serverApiClient, logtoClient, logtoConfig,
|
tenantProvisioner, serverApiClient, logtoClient, logtoConfig,
|
||||||
auditService, provisioningProps, dataCleanupService, tenantDatabaseService, null);
|
auditService, provisioningProps, dataCleanupService, tenantDatabaseService, null);
|
||||||
vendorTenantService = new VendorTenantService(
|
vendorTenantService = new VendorTenantService(
|
||||||
tenantService, tenantRepository, licenseService,
|
tenantService, tenantRepository, licenseService, signingKeyService,
|
||||||
tenantProvisioner, serverApiClient, logtoClient, logtoConfig,
|
tenantProvisioner, serverApiClient, logtoClient, logtoConfig,
|
||||||
auditService, provisioningProps, dataCleanupService, tenantDatabaseService, vendorTenantService);
|
auditService, provisioningProps, dataCleanupService, tenantDatabaseService, vendorTenantService);
|
||||||
|
|
||||||
|
// Enable transaction synchronization so afterCommit callbacks can be registered
|
||||||
|
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||||
|
TransactionSynchronizationManager.initSynchronization();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void tearDown() {
|
||||||
|
if (TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||||
|
TransactionSynchronizationManager.clearSynchronization();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Simulate transaction commit — runs all registered afterCommit callbacks. */
|
||||||
|
private void flushAfterCommit() {
|
||||||
|
var syncs = TransactionSynchronizationManager.getSynchronizations();
|
||||||
|
for (TransactionSynchronization sync : syncs) {
|
||||||
|
sync.afterCommit();
|
||||||
|
}
|
||||||
|
TransactionSynchronizationManager.clearSynchronization();
|
||||||
|
TransactionSynchronizationManager.initSynchronization();
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
@@ -108,7 +137,7 @@ class VendorTenantServiceTest {
|
|||||||
private LicenseEntity licenseWithId(UUID tenantId) throws Exception {
|
private LicenseEntity licenseWithId(UUID tenantId) throws Exception {
|
||||||
var license = new LicenseEntity();
|
var license = new LicenseEntity();
|
||||||
license.setTenantId(tenantId);
|
license.setTenantId(tenantId);
|
||||||
license.setTier("LOW");
|
license.setTier("STARTER");
|
||||||
license.setToken("test-token-" + UUID.randomUUID());
|
license.setToken("test-token-" + UUID.randomUUID());
|
||||||
license.setIssuedAt(Instant.now());
|
license.setIssuedAt(Instant.now());
|
||||||
license.setExpiresAt(Instant.now().plus(Duration.ofDays(365)));
|
license.setExpiresAt(Instant.now().plus(Duration.ofDays(365)));
|
||||||
@@ -122,9 +151,9 @@ class VendorTenantServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createAndProvision_createsTenantAndLicense() throws Exception {
|
void createAndProvision_createsTenantAndLicense() throws Exception {
|
||||||
var request = new CreateTenantRequest("Acme Corp", "acme-corp", "LOW", null, null);
|
var request = new CreateTenantRequest("Acme Corp", "acme-corp", "STARTER", null, null);
|
||||||
var actorId = UUID.randomUUID();
|
var actorId = UUID.randomUUID();
|
||||||
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.LOW);
|
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.STARTER);
|
||||||
var license = licenseWithId(tenant.getId());
|
var license = licenseWithId(tenant.getId());
|
||||||
|
|
||||||
when(tenantService.create(request, actorId)).thenReturn(tenant);
|
when(tenantService.create(request, actorId)).thenReturn(tenant);
|
||||||
@@ -142,9 +171,9 @@ class VendorTenantServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createAndProvision_setsActiveWhenProvisionerSucceeds() throws Exception {
|
void createAndProvision_setsActiveWhenProvisionerSucceeds() throws Exception {
|
||||||
var request = new CreateTenantRequest("Acme Corp", "acme-corp", "LOW", null, null);
|
var request = new CreateTenantRequest("Acme Corp", "acme-corp", "STARTER", null, null);
|
||||||
var actorId = UUID.randomUUID();
|
var actorId = UUID.randomUUID();
|
||||||
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.LOW);
|
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.STARTER);
|
||||||
var license = licenseWithId(tenant.getId());
|
var license = licenseWithId(tenant.getId());
|
||||||
|
|
||||||
when(tenantService.create(request, actorId)).thenReturn(tenant);
|
when(tenantService.create(request, actorId)).thenReturn(tenant);
|
||||||
@@ -155,8 +184,9 @@ class VendorTenantServiceTest {
|
|||||||
when(tenantRepository.save(any(TenantEntity.class))).thenAnswer(inv -> inv.getArgument(0));
|
when(tenantRepository.save(any(TenantEntity.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
vendorTenantService.createAndProvision(request, actorId);
|
vendorTenantService.createAndProvision(request, actorId);
|
||||||
|
flushAfterCommit();
|
||||||
|
|
||||||
// provisionAsync modifies the tenant entity in-place (runs synchronously in unit tests)
|
// provisionAsync runs via afterCommit callback (synchronously in unit tests)
|
||||||
assertThat(tenant.getStatus()).isEqualTo(TenantStatus.ACTIVE);
|
assertThat(tenant.getStatus()).isEqualTo(TenantStatus.ACTIVE);
|
||||||
assertThat(tenant.getServerEndpoint()).isEqualTo("http://server:8080");
|
assertThat(tenant.getServerEndpoint()).isEqualTo("http://server:8080");
|
||||||
assertThat(tenant.getProvisionError()).isNull();
|
assertThat(tenant.getProvisionError()).isNull();
|
||||||
@@ -165,9 +195,9 @@ class VendorTenantServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createAndProvision_setsProvisionErrorOnFailure() throws Exception {
|
void createAndProvision_setsProvisionErrorOnFailure() throws Exception {
|
||||||
var request = new CreateTenantRequest("Acme Corp", "acme-corp", "LOW", null, null);
|
var request = new CreateTenantRequest("Acme Corp", "acme-corp", "STARTER", null, null);
|
||||||
var actorId = UUID.randomUUID();
|
var actorId = UUID.randomUUID();
|
||||||
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.LOW);
|
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.STARTER);
|
||||||
var license = licenseWithId(tenant.getId());
|
var license = licenseWithId(tenant.getId());
|
||||||
|
|
||||||
when(tenantService.create(request, actorId)).thenReturn(tenant);
|
when(tenantService.create(request, actorId)).thenReturn(tenant);
|
||||||
@@ -178,8 +208,9 @@ class VendorTenantServiceTest {
|
|||||||
when(tenantRepository.save(any(TenantEntity.class))).thenAnswer(inv -> inv.getArgument(0));
|
when(tenantRepository.save(any(TenantEntity.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
vendorTenantService.createAndProvision(request, actorId);
|
vendorTenantService.createAndProvision(request, actorId);
|
||||||
|
flushAfterCommit();
|
||||||
|
|
||||||
// provisionAsync modifies the tenant entity in-place (runs synchronously in unit tests)
|
// provisionAsync runs via afterCommit callback (synchronously in unit tests)
|
||||||
assertThat(tenant.getProvisionError()).isEqualTo("Docker failure");
|
assertThat(tenant.getProvisionError()).isEqualTo("Docker failure");
|
||||||
assertThat(tenant.getStatus()).isEqualTo(TenantStatus.PROVISIONING);
|
assertThat(tenant.getStatus()).isEqualTo(TenantStatus.PROVISIONING);
|
||||||
verify(tenantRepository, times(2)).save(tenant);
|
verify(tenantRepository, times(2)).save(tenant);
|
||||||
@@ -187,9 +218,9 @@ class VendorTenantServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createAndProvision_worksWithoutProvisioner() throws Exception {
|
void createAndProvision_worksWithoutProvisioner() throws Exception {
|
||||||
var request = new CreateTenantRequest("Acme Corp", "acme-corp", "LOW", null, null);
|
var request = new CreateTenantRequest("Acme Corp", "acme-corp", "STARTER", null, null);
|
||||||
var actorId = UUID.randomUUID();
|
var actorId = UUID.randomUUID();
|
||||||
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.LOW);
|
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.STARTER);
|
||||||
var license = licenseWithId(tenant.getId());
|
var license = licenseWithId(tenant.getId());
|
||||||
|
|
||||||
when(tenantService.create(request, actorId)).thenReturn(tenant);
|
when(tenantService.create(request, actorId)).thenReturn(tenant);
|
||||||
@@ -209,8 +240,8 @@ class VendorTenantServiceTest {
|
|||||||
void suspend_stopsContainersAndSetsStatus() throws Exception {
|
void suspend_stopsContainersAndSetsStatus() throws Exception {
|
||||||
var tenantId = UUID.randomUUID();
|
var tenantId = UUID.randomUUID();
|
||||||
var actorId = UUID.randomUUID();
|
var actorId = UUID.randomUUID();
|
||||||
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.LOW);
|
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.STARTER);
|
||||||
var suspended = tenantWithId("Acme Corp", "acme-corp", Tier.LOW);
|
var suspended = tenantWithId("Acme Corp", "acme-corp", Tier.STARTER);
|
||||||
suspended.setStatus(TenantStatus.SUSPENDED);
|
suspended.setStatus(TenantStatus.SUSPENDED);
|
||||||
|
|
||||||
when(tenantService.getById(tenantId)).thenReturn(Optional.of(tenant));
|
when(tenantService.getById(tenantId)).thenReturn(Optional.of(tenant));
|
||||||
@@ -228,8 +259,8 @@ class VendorTenantServiceTest {
|
|||||||
void suspend_skipsStopWhenProvisionerUnavailable() throws Exception {
|
void suspend_skipsStopWhenProvisionerUnavailable() throws Exception {
|
||||||
var tenantId = UUID.randomUUID();
|
var tenantId = UUID.randomUUID();
|
||||||
var actorId = UUID.randomUUID();
|
var actorId = UUID.randomUUID();
|
||||||
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.LOW);
|
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.STARTER);
|
||||||
var suspended = tenantWithId("Acme Corp", "acme-corp", Tier.LOW);
|
var suspended = tenantWithId("Acme Corp", "acme-corp", Tier.STARTER);
|
||||||
suspended.setStatus(TenantStatus.SUSPENDED);
|
suspended.setStatus(TenantStatus.SUSPENDED);
|
||||||
|
|
||||||
when(tenantService.getById(tenantId)).thenReturn(Optional.of(tenant));
|
when(tenantService.getById(tenantId)).thenReturn(Optional.of(tenant));
|
||||||
@@ -246,7 +277,7 @@ class VendorTenantServiceTest {
|
|||||||
void delete_removesContainersRevokesLicenseDeletesOrg() throws Exception {
|
void delete_removesContainersRevokesLicenseDeletesOrg() throws Exception {
|
||||||
var tenantId = UUID.randomUUID();
|
var tenantId = UUID.randomUUID();
|
||||||
var actorId = UUID.randomUUID();
|
var actorId = UUID.randomUUID();
|
||||||
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.LOW);
|
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.STARTER);
|
||||||
tenant.setLogtoOrgId("org-123");
|
tenant.setLogtoOrgId("org-123");
|
||||||
|
|
||||||
when(tenantService.getById(tenantId)).thenReturn(Optional.of(tenant));
|
when(tenantService.getById(tenantId)).thenReturn(Optional.of(tenant));
|
||||||
@@ -267,17 +298,20 @@ class VendorTenantServiceTest {
|
|||||||
void renewLicense_revokesOldAndGeneratesNew() throws Exception {
|
void renewLicense_revokesOldAndGeneratesNew() throws Exception {
|
||||||
var tenantId = UUID.randomUUID();
|
var tenantId = UUID.randomUUID();
|
||||||
var actorId = UUID.randomUUID();
|
var actorId = UUID.randomUUID();
|
||||||
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.LOW);
|
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.STARTER);
|
||||||
tenant.setServerEndpoint("http://server:8080");
|
tenant.setServerEndpoint("http://server:8080");
|
||||||
var newLicense = licenseWithId(tenantId);
|
var newLicense = licenseWithId(tenantId);
|
||||||
|
|
||||||
when(tenantService.getById(tenantId)).thenReturn(Optional.of(tenant));
|
when(tenantService.getById(tenantId)).thenReturn(Optional.of(tenant));
|
||||||
when(licenseService.generateLicense(eq(tenant), any(Duration.class), eq(actorId))).thenReturn(newLicense);
|
// mintLicense() calls the full generateLicense overload (limits, expiresAt, grace, label, actor)
|
||||||
|
when(licenseService.generateLicense(eq(tenant), any(java.util.Map.class), any(Instant.class),
|
||||||
|
any(Integer.class), any(String.class), eq(actorId))).thenReturn(newLicense);
|
||||||
|
|
||||||
var result = vendorTenantService.renewLicense(tenantId, actorId);
|
var result = vendorTenantService.renewLicense(tenantId, actorId);
|
||||||
|
|
||||||
verify(licenseService).revokeLicense(tenantId, actorId);
|
verify(licenseService).revokeLicense(tenantId, actorId);
|
||||||
verify(licenseService).generateLicense(eq(tenant), any(Duration.class), eq(actorId));
|
verify(licenseService).generateLicense(eq(tenant), any(java.util.Map.class), any(Instant.class),
|
||||||
|
any(Integer.class), any(String.class), eq(actorId));
|
||||||
verify(serverApiClient).pushLicense("http://server:8080", newLicense.getToken());
|
verify(serverApiClient).pushLicense("http://server:8080", newLicense.getToken());
|
||||||
assertThat(result).isSameAs(newLicense);
|
assertThat(result).isSameAs(newLicense);
|
||||||
}
|
}
|
||||||
|
|||||||
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.
|
||||||
|
|||||||
10
ui/package-lock.json
generated
10
ui/package-lock.json
generated
@@ -13,6 +13,7 @@
|
|||||||
"@logto/react": "^4.0.13",
|
"@logto/react": "^4.0.13",
|
||||||
"@tanstack/react-query": "^5.90.0",
|
"@tanstack/react-query": "^5.90.0",
|
||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.7.0",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router": "^7.13.0",
|
"react-router": "^7.13.0",
|
||||||
@@ -2043,6 +2044,15 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/qrcode.react": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/quick-lru": {
|
"node_modules/quick-lru": {
|
||||||
"version": "6.1.2",
|
"version": "6.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"@logto/react": "^4.0.13",
|
"@logto/react": "^4.0.13",
|
||||||
"@tanstack/react-query": "^5.90.0",
|
"@tanstack/react-query": "^5.90.0",
|
||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.7.0",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router": "^7.13.0",
|
"react-router": "^7.13.0",
|
||||||
|
|||||||
@@ -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,100 @@
|
|||||||
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forgotLink {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 0;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-align: right;
|
||||||
|
align-self: flex-end;
|
||||||
|
margin-top: -8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forgotLink:hover {
|
||||||
|
color: var(--text-link, #C6820E);
|
||||||
|
}
|
||||||
|
|
||||||
|
.verifyHint {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary, var(--text-muted));
|
||||||
|
margin: 0 0 4px;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backupCodeCard {
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--bg-surface, #f8f7f5);
|
||||||
|
border: 1px solid var(--border-default, #e8e0d4);
|
||||||
|
border-radius: 6px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backupCodeText {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backupCodeAction {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-link, #C6820E);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 4px 8px;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backupCodeAction:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
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,
|
||||||
|
startForgotPassword, forgotPasswordVerifyAndReset,
|
||||||
|
verifyTotp, verifyBackupCode, submitMfa,
|
||||||
|
MfaRequiredError,
|
||||||
|
} from './experience-api';
|
||||||
import styles from './SignInPage.module.css';
|
import styles from './SignInPage.module.css';
|
||||||
|
|
||||||
const SUBTITLES = [
|
type Mode = 'signIn' | 'register' | 'verifyCode' | 'forgotPassword' | 'forgotPasswordVerify' | 'mfaVerify' | 'mfaBackupCode';
|
||||||
|
|
||||||
|
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,31 +40,237 @@ 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 [emailConnectorConfigured, setEmailConnectorConfigured] = useState(false);
|
||||||
|
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 [newPassword, setNewPassword] = useState('');
|
||||||
|
const [confirmNewPassword, setConfirmNewPassword] = 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');
|
||||||
|
const hasEmailConnector = Array.isArray(data.signUp?.identifiers) && data.signUp.identifiers.includes('email');
|
||||||
|
setEmailConnectorConfigured(hasEmailConnector);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Reset error when switching modes
|
||||||
|
useEffect(() => { setError(null); }, [mode]);
|
||||||
|
|
||||||
|
const switchMode = (next: Mode) => {
|
||||||
|
setMode(next);
|
||||||
|
setPassword('');
|
||||||
|
setConfirmPassword('');
|
||||||
|
setNewPassword('');
|
||||||
|
setConfirmNewPassword('');
|
||||||
|
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) {
|
||||||
|
if (err instanceof MfaRequiredError) {
|
||||||
|
setMode('mfaVerify');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setError(err instanceof Error ? err.message : 'Sign-in failed');
|
setError(err instanceof Error ? err.message : 'Sign-in failed');
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Forgot password step 1: send reset code ---
|
||||||
|
const handleForgotPassword = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
if (!identifier.includes('@')) {
|
||||||
|
setError('Please enter your email address');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const vId = await startForgotPassword(identifier);
|
||||||
|
setVerificationId(vId);
|
||||||
|
setMode('forgotPasswordVerify');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to send reset code');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Forgot password step 2: verify code + set new password ---
|
||||||
|
const handleForgotPasswordVerify = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
if (newPassword !== confirmNewPassword) {
|
||||||
|
setError('Passwords do not match');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword.length < 8) {
|
||||||
|
setError('Password must be at least 8 characters');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await forgotPasswordVerifyAndReset(identifier, verificationId, code, newPassword);
|
||||||
|
// Send security notification email (fire-and-forget)
|
||||||
|
fetch('/platform/api/password-reset-notification', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email: identifier }),
|
||||||
|
}).catch(() => {});
|
||||||
|
// Reset to sign-in with success message
|
||||||
|
switchMode('signIn');
|
||||||
|
setError(null);
|
||||||
|
setIdentifier(identifier); // preserve email for convenience
|
||||||
|
alert('Password reset successful. Please sign in with your new password.');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Password reset failed');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- MFA: TOTP verification ---
|
||||||
|
const handleMfaVerify = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const verificationId = await verifyTotp(code);
|
||||||
|
const redirectTo = await submitMfa(verificationId);
|
||||||
|
window.location.replace(redirectTo);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Verification failed');
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- MFA: backup code verification ---
|
||||||
|
const handleBackupCodeVerify = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const verificationId = await verifyBackupCode(code);
|
||||||
|
const redirectTo = await submitMfa(verificationId);
|
||||||
|
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,13 +283,16 @@ 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' && (
|
||||||
|
<form className={styles.fields} onSubmit={handleSignIn} aria-label="Sign in" noValidate>
|
||||||
|
<FormField label="Login" htmlFor="login-identifier">
|
||||||
<Input
|
<Input
|
||||||
id="login-username"
|
id="login-identifier"
|
||||||
value={username}
|
type="email"
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
value={identifier}
|
||||||
placeholder="Enter your username"
|
onChange={(e) => setIdentifier(e.target.value)}
|
||||||
|
placeholder="you@company.com"
|
||||||
autoFocus
|
autoFocus
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
@@ -84,7 +300,7 @@ export function SignInPage() {
|
|||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField label="Password" htmlFor="login-password">
|
<FormField label="Password" htmlFor="login-password">
|
||||||
<div style={{ position: 'relative' }}>
|
<div className={styles.passwordWrapper}>
|
||||||
<Input
|
<Input
|
||||||
id="login-password"
|
id="login-password"
|
||||||
type={showPassword ? 'text' : 'password'}
|
type={showPassword ? 'text' : 'password'}
|
||||||
@@ -94,31 +310,326 @@ export function SignInPage() {
|
|||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
|
{passwordToggle}
|
||||||
|
</div>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
{emailConnectorConfigured && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
className={styles.forgotLink}
|
||||||
style={{
|
onClick={() => { setError(null); setMode('forgotPassword'); }}
|
||||||
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} />}
|
Forgot password?
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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>
|
</div>
|
||||||
</FormField>
|
</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
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
type="submit"
|
type="submit"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
disabled={loading || !username || !password}
|
disabled={loading || !identifier || !password || !confirmPassword}
|
||||||
className={styles.submitButton}
|
className={styles.submitButton}
|
||||||
>
|
>
|
||||||
Sign in
|
Create account
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<p className={styles.switchText}>
|
||||||
|
Already have an account?{' '}
|
||||||
|
<button type="button" className={styles.switchLink} onClick={() => switchMode('signIn')}>
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
</form>
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* --- Forgot password: email entry --- */}
|
||||||
|
{mode === 'forgotPassword' && (
|
||||||
|
<form className={styles.fields} onSubmit={handleForgotPassword} aria-label="Reset password" noValidate>
|
||||||
|
<p className={styles.verifyHint}>
|
||||||
|
Enter your email address and we'll send you a code to reset your password.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<FormField label="Email" htmlFor="forgot-email">
|
||||||
|
<Input
|
||||||
|
id="forgot-email"
|
||||||
|
type="email"
|
||||||
|
value={identifier}
|
||||||
|
onChange={(e) => setIdentifier(e.target.value)}
|
||||||
|
placeholder="you@company.com"
|
||||||
|
autoFocus
|
||||||
|
autoComplete="email"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
type="submit"
|
||||||
|
loading={loading}
|
||||||
|
disabled={loading || !identifier}
|
||||||
|
className={styles.submitButton}
|
||||||
|
>
|
||||||
|
Send reset code
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<p className={styles.switchText}>
|
||||||
|
<button type="button" className={styles.switchLink} onClick={() => switchMode('signIn')}>
|
||||||
|
Back to sign in
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* --- Forgot password: verify code + new password --- */}
|
||||||
|
{mode === 'forgotPasswordVerify' && (
|
||||||
|
<form className={styles.fields} onSubmit={handleForgotPasswordVerify} aria-label="Set new password" noValidate>
|
||||||
|
<p className={styles.verifyHint}>
|
||||||
|
We sent a verification code to <strong>{identifier}</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<FormField label="Verification code" htmlFor="forgot-code">
|
||||||
|
<Input
|
||||||
|
id="forgot-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>
|
||||||
|
|
||||||
|
<FormField label="New password" htmlFor="forgot-new-password">
|
||||||
|
<div className={styles.passwordWrapper}>
|
||||||
|
<Input
|
||||||
|
id="forgot-new-password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
placeholder="At least 8 characters"
|
||||||
|
autoComplete="new-password"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
{passwordToggle}
|
||||||
|
</div>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Confirm new password" htmlFor="forgot-confirm-password">
|
||||||
|
<Input
|
||||||
|
id="forgot-confirm-password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={confirmNewPassword}
|
||||||
|
onChange={(e) => setConfirmNewPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
autoComplete="new-password"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
type="submit"
|
||||||
|
loading={loading}
|
||||||
|
disabled={loading || code.length < 6 || !newPassword || !confirmNewPassword}
|
||||||
|
className={styles.submitButton}
|
||||||
|
>
|
||||||
|
Reset password
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<p className={styles.switchText}>
|
||||||
|
<button type="button" className={styles.switchLink} onClick={() => switchMode('forgotPassword')}>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
{/* --- MFA: TOTP verification --- */}
|
||||||
|
{mode === 'mfaVerify' && (
|
||||||
|
<form className={styles.fields} onSubmit={handleMfaVerify} aria-label="Two-factor authentication" noValidate>
|
||||||
|
<p className={styles.verifyHint}>
|
||||||
|
Enter the 6-digit code from your authenticator app.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<FormField label="Authentication code" htmlFor="mfa-totp-code">
|
||||||
|
<Input
|
||||||
|
id="mfa-totp-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
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className={styles.backupCodeCard}>
|
||||||
|
<p className={styles.backupCodeText}>Lost your device?</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.backupCodeAction}
|
||||||
|
onClick={() => { setCode(''); setError(null); setMode('mfaBackupCode'); }}
|
||||||
|
>
|
||||||
|
Use a backup code
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* --- MFA: backup code verification --- */}
|
||||||
|
{mode === 'mfaBackupCode' && (
|
||||||
|
<form className={styles.fields} onSubmit={handleBackupCodeVerify} aria-label="Backup code verification" noValidate>
|
||||||
|
<p className={styles.verifyHint}>
|
||||||
|
Enter one of your 10 backup codes.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<FormField label="Backup code" htmlFor="mfa-backup-code">
|
||||||
|
<Input
|
||||||
|
id="mfa-backup-code"
|
||||||
|
type="text"
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value)}
|
||||||
|
placeholder="Enter backup code"
|
||||||
|
autoFocus
|
||||||
|
autoComplete="off"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
type="submit"
|
||||||
|
loading={loading}
|
||||||
|
disabled={loading || !code}
|
||||||
|
className={styles.submitButton}
|
||||||
|
>
|
||||||
|
Verify backup code
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<p className={styles.switchText}>
|
||||||
|
<button type="button" className={styles.switchLink} onClick={() => { setCode(''); setError(null); setMode('mfaVerify'); }}>
|
||||||
|
Use authenticator app instead
|
||||||
|
</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,246 @@ 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 class MfaRequiredError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('MFA verification required');
|
||||||
|
this.name = 'MfaRequiredError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function trySubmit(): Promise<{ ok: true; redirectTo: string } | { ok: false; status: number; code: string; message: string }> {
|
||||||
|
const res = await request('POST', '/submit');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
return { ok: true, redirectTo: data.redirectTo };
|
||||||
|
}
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
return { ok: false, status: res.status, code: err.code ?? '', message: err.message ?? `Submit failed (${res.status})` };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function skipMfaBinding(): Promise<void> {
|
||||||
|
await request('POST', '/profile/mfa/mfa-skipped');
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
const result = await trySubmit();
|
||||||
|
if (result.ok) return result.redirectTo;
|
||||||
|
|
||||||
|
// MFA already enrolled — user must verify (show TOTP input)
|
||||||
|
if (result.code === 'user.missing_mfa' || result.code === 'session.mfa.require_mfa_verification') {
|
||||||
|
throw new MfaRequiredError();
|
||||||
|
}
|
||||||
|
|
||||||
|
// MFA not enrolled, UserControlled policy — skip the binding prompt.
|
||||||
|
// Also fallback: any 422 with an MFA-related code we don't recognize — try skip before failing.
|
||||||
|
if (result.status === 422 && result.code.includes('mfa')) {
|
||||||
|
await skipMfaBinding();
|
||||||
|
const retry = await trySubmit();
|
||||||
|
if (retry.ok) return retry.redirectTo;
|
||||||
|
throw new Error(retry.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(result.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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);
|
||||||
|
|
||||||
|
const result = await trySubmit();
|
||||||
|
if (result.ok) return result.redirectTo;
|
||||||
|
|
||||||
|
// MFA not enrolled, UserControlled policy — skip the binding prompt
|
||||||
|
if (result.status === 422 && result.code.includes('mfa')) {
|
||||||
|
await skipMfaBinding();
|
||||||
|
const retry = await trySubmit();
|
||||||
|
if (retry.ok) return retry.redirectTo;
|
||||||
|
throw new Error(retry.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(result.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Forgot Password ---
|
||||||
|
|
||||||
|
export async function initForgotPassword(): Promise<void> {
|
||||||
|
const res = await request('PUT', '', { interactionEvent: 'ForgotPassword' });
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.message || `Failed to initialize password reset (${res.status})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function forgotPasswordSendCode(email: string): Promise<string> {
|
||||||
|
const res = await request('POST', '/verification/verification-code', {
|
||||||
|
identifier: { type: 'email', value: email },
|
||||||
|
interactionEvent: 'ForgotPassword',
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
if (res.status === 422) {
|
||||||
|
throw new Error('No account found with this email');
|
||||||
|
}
|
||||||
|
throw new Error(err.message || `Failed to send reset code (${res.status})`);
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
return data.verificationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function forgotPasswordVerifyAndReset(
|
||||||
|
email: string,
|
||||||
|
verificationId: string,
|
||||||
|
code: string,
|
||||||
|
newPassword: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const verifiedId = await verifyCode(email, verificationId, code);
|
||||||
|
await identifyUser(verifiedId);
|
||||||
|
await addProfile('password', newPassword);
|
||||||
|
await submitInteraction();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startForgotPassword(email: string): Promise<string> {
|
||||||
|
await initForgotPassword();
|
||||||
|
return forgotPasswordSendCode(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- MFA Verification ---
|
||||||
|
|
||||||
|
export async function verifyTotp(code: string): Promise<string> {
|
||||||
|
const res = await request('POST', '/verification/totp/verify', { code });
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
if (res.status === 422) {
|
||||||
|
throw new Error('Invalid code, please try again');
|
||||||
|
}
|
||||||
|
throw new Error(err.message || `TOTP verification failed (${res.status})`);
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
return data.verificationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyBackupCode(code: string): Promise<string> {
|
||||||
|
const res = await request('POST', '/verification/backup-code/verify', { code });
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
if (res.status === 422) {
|
||||||
|
const msg = err.code === 'backup_code_consumed'
|
||||||
|
? 'This backup code has already been used'
|
||||||
|
: 'Invalid backup code';
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
throw new Error(err.message || `Backup code verification failed (${res.status})`);
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
return data.verificationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitMfa(verificationId: string): Promise<string> {
|
||||||
await identifyUser(verificationId);
|
await identifyUser(verificationId);
|
||||||
return submitInteraction();
|
return submitInteraction();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,42 @@ export function setTokenProvider(provider: (() => Promise<string | undefined>) |
|
|||||||
tokenProvider = provider;
|
tokenProvider = provider;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom error class that extracts a human-readable message from API responses.
|
||||||
|
* Spring Boot error bodies typically contain: { message, error, status, detail }
|
||||||
|
* Our own controllers may return: { error, message, detail }
|
||||||
|
*/
|
||||||
|
export class ApiError extends Error {
|
||||||
|
public readonly status: number;
|
||||||
|
public readonly detail: string | null;
|
||||||
|
|
||||||
|
constructor(status: number, body: string) {
|
||||||
|
const parsed = ApiError.parseBody(body);
|
||||||
|
super(parsed.message);
|
||||||
|
this.name = 'ApiError';
|
||||||
|
this.status = status;
|
||||||
|
this.detail = parsed.detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static parseBody(body: string): { message: string; detail: string | null } {
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(body);
|
||||||
|
// Try common Spring Boot / custom error fields in priority order
|
||||||
|
const message = json.message || json.error || json.detail || json.reason || `Request failed`;
|
||||||
|
const detail = json.detail || json.path || null;
|
||||||
|
return { message, detail };
|
||||||
|
} catch {
|
||||||
|
// Not JSON — use raw text, truncated
|
||||||
|
return { message: body.slice(0, 200) || 'Request failed', detail: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns just the human message (no "Error:" prefix, no status code, no JSON blob). */
|
||||||
|
toString(): string {
|
||||||
|
return this.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
|
async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
const token = tokenProvider ? await tokenProvider() : null;
|
const token = tokenProvider ? await tokenProvider() : null;
|
||||||
@@ -24,12 +60,20 @@ async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T>
|
|||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
// Don't hard-redirect — let React Query retry (token may not be ready yet).
|
// Don't hard-redirect — let React Query retry (token may not be ready yet).
|
||||||
// The ProtectedRoute handles unauthenticated state.
|
// The ProtectedRoute handles unauthenticated state.
|
||||||
throw new Error('Unauthorized');
|
throw new ApiError(401, '{"message":"Unauthorized"}');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 403) {
|
||||||
|
const errorHeader = response.headers.get('X-Cameleer-Error');
|
||||||
|
if (errorHeader === 'APP_MFA_REQUIRED') {
|
||||||
|
window.location.href = '/platform/tenant/settings?mfa=required';
|
||||||
|
throw new ApiError(403, '{"message":"MFA enrollment required"}');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
throw new Error(`API error ${response.status}: ${text}`);
|
throw new ApiError(response.status, text);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status === 204) return undefined as T;
|
if (response.status === 204) return undefined as T;
|
||||||
@@ -49,3 +93,13 @@ export const api = {
|
|||||||
apiFetch<T>(path, { method: 'PUT', body }),
|
apiFetch<T>(path, { method: 'PUT', body }),
|
||||||
delete: <T>(path: string) => apiFetch<T>(path, { method: 'DELETE' }),
|
delete: <T>(path: string) => apiFetch<T>(path, { method: 'DELETE' }),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract a human-readable error message for toast display.
|
||||||
|
* Works with ApiError (preferred) and plain Error/unknown.
|
||||||
|
*/
|
||||||
|
export function errorMessage(err: unknown): string {
|
||||||
|
if (err instanceof ApiError) return err.message;
|
||||||
|
if (err instanceof Error) return err.message;
|
||||||
|
return String(err);
|
||||||
|
}
|
||||||
|
|||||||
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, ApiError } 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 ApiError && e.status === 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,6 +1,6 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { api } from './client';
|
import { api } from './client';
|
||||||
import type { DashboardData, TenantLicenseData, TenantSettings, AuditLogPage, AuditLogFilters, SsoConnector, CreateSsoConnectorRequest, SsoTestResult } from '../types/api';
|
import type { DashboardData, TenantLicenseData, TenantSettings, AuditLogPage, AuditLogFilters, SsoConnector, CreateSsoConnectorRequest, SsoTestResult, MfaStatus, MfaSetupResponse, BackupCodesResponse } from '../types/api';
|
||||||
|
|
||||||
export function useTenantDashboard() {
|
export function useTenantDashboard() {
|
||||||
return useQuery<DashboardData>({
|
return useQuery<DashboardData>({
|
||||||
@@ -143,3 +143,57 @@ export function useTenantAuditLog(filters: Omit<AuditLogFilters, 'tenantId'>) {
|
|||||||
queryFn: () => api.get(`/tenant/audit?${params.toString()}`),
|
queryFn: () => api.get(`/tenant/audit?${params.toString()}`),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MFA hooks
|
||||||
|
export function useMfaStatus() {
|
||||||
|
return useQuery<MfaStatus>({
|
||||||
|
queryKey: ['tenant', 'mfa', 'status'],
|
||||||
|
queryFn: () => api.get('/tenant/mfa/status'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMfaSetup() {
|
||||||
|
return useMutation<MfaSetupResponse, Error, void>({
|
||||||
|
mutationFn: () => api.post('/tenant/mfa/totp/setup'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMfaVerify() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation<{ verified: boolean }, Error, { secret: string; code: string }>({
|
||||||
|
mutationFn: (body) => api.post('/tenant/mfa/totp/verify', body),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'mfa'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMfaBackupCodes() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation<BackupCodesResponse, Error, void>({
|
||||||
|
mutationFn: () => api.post('/tenant/mfa/backup-codes'),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'mfa'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMfaRemove() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation<void, Error, void>({
|
||||||
|
mutationFn: () => api.delete('/tenant/mfa/totp'),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'mfa'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useResetTeamMemberMfa() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation<void, Error, string>({
|
||||||
|
mutationFn: (userId) => api.delete(`/tenant/users/${userId}/mfa`),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'team'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateTenantSettings() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation<void, Error, Record<string, unknown>>({
|
||||||
|
mutationFn: (updates) => api.patch('/tenant/settings', updates),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'settings'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { api } from './client';
|
import { api } from './client';
|
||||||
import type { VendorTenantSummary, VendorTenantDetail, CreateTenantRequest, TenantResponse, LicenseResponse, AuditLogPage, AuditLogFilters, TenantMetricsEntry } from '../types/api';
|
import type { VendorTenantSummary, VendorTenantDetail, CreateTenantRequest, TenantResponse, LicenseBundleResponse, MintLicenseRequest, LicensePreset, VerifyLicenseResponse, AuditLogPage, AuditLogFilters, TenantMetricsEntry } from '../types/api';
|
||||||
|
|
||||||
export function useVendorTenants() {
|
export function useVendorTenants() {
|
||||||
return useQuery<VendorTenantSummary[]>({
|
return useQuery<VendorTenantSummary[]>({
|
||||||
@@ -70,11 +70,31 @@ export function useUpgradeServer() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useRenewLicense() {
|
export function useMintLicense(tenantId: string) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation<LicenseResponse, Error, string>({
|
return useMutation<LicenseBundleResponse, Error, MintLicenseRequest>({
|
||||||
mutationFn: (tenantId) => api.post(`/vendor/tenants/${tenantId}/license`),
|
mutationFn: (req) => api.post(`/vendor/tenants/${tenantId}/license`, req),
|
||||||
onSuccess: (_, tenantId) => qc.invalidateQueries({ queryKey: ['vendor', 'tenants', tenantId] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'tenants', tenantId] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLicensePresets() {
|
||||||
|
return useQuery<LicensePreset[]>({
|
||||||
|
queryKey: ['vendor', 'license-presets'],
|
||||||
|
queryFn: () => api.get('/vendor/license-presets'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVerifyLicense() {
|
||||||
|
return useMutation<VerifyLicenseResponse, Error, string>({
|
||||||
|
mutationFn: (token) => api.post('/vendor/license/verify', { token }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePublicKey() {
|
||||||
|
return useQuery<{ publicKey: string }>({
|
||||||
|
queryKey: ['vendor', 'signing-key'],
|
||||||
|
queryFn: () => api.get('/vendor/signing-key/public'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user