16 Commits
v1.0.0 ... main

Author SHA1 Message Date
hsiegeln
0cfa359fc5 fix(sign-in): detect register mode from URL path, not just query param
All checks were successful
CI / build (push) Successful in 1m19s
CI / docker (push) Successful in 44s
Newer Logto versions redirect to /register?app_id=... instead of
/sign-in?first_screen=register. Check the pathname in addition to
the query param so the registration form shows correctly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 10:10:57 +02:00
hsiegeln
5cc9f8c9ef fix(spa): add /register and /onboarding to SPA forward routes
All checks were successful
CI / build (push) Successful in 1m18s
CI / docker (push) Successful in 43s
These routes were missing from SpaController, so requests to
/platform/register and /platform/onboarding had no handler. Spring
forwarded to /error, which isn't in the permitAll() list, resulting
in a 401 instead of serving the SPA.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 10:05:56 +02:00
hsiegeln
b066d1abe7 fix(sign-in): validate email format before registration attempt
All checks were successful
CI / build (push) Successful in 1m17s
CI / docker (push) Successful in 46s
Show "Please enter a valid email address" when the user enters a
username instead of an email in the sign-up form, rather than letting
it hit Logto's API and returning a cryptic 400.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 09:41:41 +02:00
hsiegeln
ae1d9fa4db fix(docker): add extra_hosts so Logto can reach itself via public hostname
All checks were successful
CI / build (push) Successful in 1m14s
CI / docker (push) Successful in 18s
Logto validates M2M tokens by fetching its own JWKS from the ENDPOINT
URL (e.g. https://app.cameleer.io/oidc/jwks). Behind a Cloudflare
tunnel, that hostname resolves to Cloudflare's IP and the container
can't route back through the tunnel — the fetch times out (ETIMEDOUT),
causing all Management API calls to return 500.

Adding extra_hosts maps AUTH_HOST to host-gateway so the request goes
to the Docker host, which has Traefik on :443, which routes back to
Logto internally. This hairpin works because NODE_TLS_REJECT=0 accepts
the self-signed cert.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 09:13:39 +02:00
hsiegeln
6fe10432e6 fix(installer): remove duplicate config load that kills upgrade silently
All checks were successful
CI / build (push) Successful in 1m18s
CI / docker (push) Successful in 15s
The upgrade path in handle_rerun called load_config_file a second time
(already called by detect_existing_install). On the second pass, every
variable is already set, so [ -z "$VAR" ] && VAR="$value" returns
exit code 1 (test fails, && short-circuits). With set -e, the non-zero
exit from the case clause kills the script silently after printing
"[INFO] Upgrading installation..." — no error, no further output.

Removed the redundant load_config_file and load_env_overrides calls.
Both were already executed in main() before handle_rerun is reached.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 09:03:07 +02:00
hsiegeln
9f3faf4816 fix(traefik): set Logto router priority=1 to prevent route hijacking
All checks were successful
CI / build (push) Successful in 1m17s
CI / docker (push) Successful in 18s
Traefik auto-calculates router priority from rule string length. When
deployed with a domain longer than 23 chars (e.g. app.cameleer.io),
Host(`app.cameleer.io`) (25 chars) outranks PathPrefix(`/platform`)
(23 chars), causing ALL requests — including /platform/* — to route
to Logto instead of the SaaS app. This breaks login because the sign-in
UI loads without an OIDC interaction session.

Setting priority=1 makes Logto a true catch-all, matching the intent
documented in docker/CLAUDE.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 08:50:16 +02:00
hsiegeln
a60095608e fix(installer): send correct Host header in Traefik routing check
All checks were successful
CI / build (push) Successful in 1m18s
CI / docker (push) Successful in 19s
The root redirect rule matches Host(`PUBLIC_HOST`), not localhost.
Curl with --resolve (bash) and Host header (PS1) so the health
check sends the right hostname when verifying Traefik routing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 08:15:18 +02:00
hsiegeln
9f9112c6a5 feat(installer): add interactive registry prompts
Some checks failed
CI / build (push) Successful in 1m19s
CI / docker (push) Successful in 16s
SonarQube Analysis / sonarqube (push) Failing after 1m47s
Both simple and expert modes now ask "Pull images from a private
registry?" with follow-up prompts for URL, username, and token.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 02:17:52 +02:00
hsiegeln
e1a9f6d225 feat(installer): add --registry, --registry-user, --registry-token
All checks were successful
CI / build (push) Successful in 1m21s
CI / docker (push) Successful in 15s
Both installers (bash + PS1) now support pulling images from a
custom Docker registry. Writes *_IMAGE env vars to .env so compose
templates use the configured registry. Runs docker login before
pull when credentials are provided. Persisted in cameleer.conf
for upgrades/reconfigure.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 02:10:48 +02:00
hsiegeln
180644f0df fix(installer): SIGPIPE crash in generate_password with pipefail
All checks were successful
CI / build (push) Successful in 1m15s
CI / docker (push) Successful in 18s
`tr | head -c 32` causes tr to receive SIGPIPE when head exits early.
With `set -eo pipefail`, exit code 141 kills the script right after
"Configuration validated" before any passwords are generated.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 01:41:47 +02:00
hsiegeln
62b74d2d06 ci: remove sync-images workflow
All checks were successful
CI / build (push) Successful in 1m16s
CI / docker (push) Successful in 16s
Remote server will pull directly from the Gitea registry instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 01:25:56 +02:00
hsiegeln
3e2f035d97 fix(ci): use POSIX-compatible loop instead of bash arrays
All checks were successful
CI / build (push) Successful in 1m18s
CI / docker (push) Successful in 18s
The docker-builder container runs ash/sh, not bash — arrays with ()
are not supported. Use a simple for-in loop instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 00:32:12 +02:00
hsiegeln
9962ee99d9 fix(ci): drop ssh-keyscan, use StrictHostKeyChecking=accept-new instead
All checks were successful
CI / build (push) Successful in 1m16s
CI / docker (push) Successful in 17s
ssh-keyscan fails when the runner can't reach the host on port 22
during that step. Using accept-new on the ssh command itself is
equivalent for an ephemeral CI runner.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 00:29:52 +02:00
hsiegeln
b53840b77b ci: add manual workflow to sync Docker images to remote server
Some checks failed
CI / docker (push) Has been cancelled
CI / build (push) Has been cancelled
Pulls all :latest images from the Gitea registry and pipes them
via `docker save | ssh docker load` to the APP_HOST server.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 00:28:39 +02:00
hsiegeln
9ed2cedc98 feat: self-service sign-up with email verification and onboarding
All checks were successful
CI / build (push) Successful in 1m14s
CI / docker (push) Successful in 1m15s
Complete sign-up pipeline: email registration via Logto Experience API,
SMTP email verification, and self-service trial tenant creation.

Layer 1 — Logto config:
- Bootstrap Phase 8b: SMTP email connector with branded HTML templates
- Bootstrap Phase 8c: enable SignInAndRegister (email+password sign-up)
- Dockerfile installs official Logto connectors (ensures SMTP available)
- SMTP env vars in docker-compose, installer templates, .env.example

Layer 2 — Experience API (ui/sign-in/experience-api.ts):
- Registration flow: initRegistration → sendVerificationCode → verifyCode
  → addProfile (password) → identifyUser → submit
- Sign-in auto-detects email vs username identifier

Layer 3 — Custom sign-in UI (ui/sign-in/SignInPage.tsx):
- Three-mode state machine: signIn / register / verifyCode
- Reads first_screen=register from URL query params
- Toggle links between sign-in and register views

Layer 4 — Post-registration onboarding:
- OnboardingService: reuses VendorTenantService.createAndProvision(),
  adds calling user to Logto org as owner, enforces one trial per user
- OnboardingController: POST /api/onboarding/tenant (authenticated only)
- OnboardingPage.tsx: org name + auto-slug form
- LandingRedirect: detects zero orgs → redirects to /onboarding
- RegisterPage.tsx: /platform/register initiates OIDC with firstScreen

Installers (install.sh + install.ps1):
- Both prompt for SMTP config in SaaS mode
- CLI args, env var capture, cameleer.conf persistence

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 00:21:07 +02:00
hsiegeln
dc7ac3a1ec feat: split auth domain — Logto gets dedicated AUTH_HOST
All checks were successful
CI / build (push) Successful in 1m22s
CI / docker (push) Successful in 48s
Support separate auth domain (e.g. auth.cameleer.io) for Logto while
keeping the SaaS app on PUBLIC_HOST (e.g. app.cameleer.io). AUTH_HOST
defaults to PUBLIC_HOST for backward-compatible single-domain setups.

- Logto routing: Host(AUTH_HOST) replaces PathPrefix('/') catch-all
- Root redirect moved from traefik-dynamic.yml to Docker labels with
  Host(PUBLIC_HOST) scope so it doesn't intercept auth domain
- Self-signed cert generates SANs for both domains
- Bootstrap Host header uses AUTH_HOST for Logto endpoint validation
- Spring issuer-uri and oidcissueruri use new authhost property
- Both installers (sh + ps1) prompt for AUTH_HOST in expert mode

Local dev: AUTH_HOST=auth.localhost (resolves to 127.0.0.1, no hosts file)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 18:11:47 +02:00
29 changed files with 1264 additions and 157 deletions

View File

@@ -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
@@ -25,6 +28,14 @@ CLICKHOUSE_PASSWORD=change_me_in_production
SAAS_ADMIN_USER=admin SAAS_ADMIN_USER=admin
SAAS_ADMIN_PASS=change_me_in_production SAAS_ADMIN_PASS=change_me_in_production
# SMTP (for email verification during registration)
# Required for self-service sign-up. Without SMTP, only admin-created users can sign in.
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
SMTP_FROM_EMAIL=noreply@cameleer.io
# 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=

View File

@@ -1,7 +1,7 @@
<!-- gitnexus:start --> <!-- gitnexus:start -->
# GitNexus — Code Intelligence # GitNexus — Code Intelligence
This project is indexed by GitNexus as **cameleer-saas** (2816 symbols, 5989 relationships, 238 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. This project is indexed by GitNexus as **cameleer-saas** (2838 symbols, 6037 relationships, 239 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.

View File

@@ -4,7 +4,7 @@ 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.
## Ecosystem ## Ecosystem
@@ -26,6 +26,7 @@ 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` |
| `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` |
@@ -75,7 +76,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** (2838 symbols, 6037 relationships, 239 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.

View File

@@ -8,6 +8,7 @@ services:
- "${LOGTO_CONSOLE_PORT:-3002}:3002" - "${LOGTO_CONSOLE_PORT:-3002}:3002"
environment: environment:
PUBLIC_HOST: ${PUBLIC_HOST:-localhost} PUBLIC_HOST: ${PUBLIC_HOST:-localhost}
AUTH_HOST: ${AUTH_HOST:-localhost}
CERT_FILE: ${CERT_FILE:-} CERT_FILE: ${CERT_FILE:-}
KEY_FILE: ${KEY_FILE:-} KEY_FILE: ${KEY_FILE:-}
CA_FILE: ${CA_FILE:-} CA_FILE: ${CA_FILE:-}
@@ -62,14 +63,15 @@ services:
condition: service_healthy condition: service_healthy
environment: environment:
DB_URL: postgres://${POSTGRES_USER:-cameleer}:${POSTGRES_PASSWORD:-cameleer_dev}@cameleer-postgres:5432/logto DB_URL: postgres://${POSTGRES_USER:-cameleer}:${POSTGRES_PASSWORD:-cameleer_dev}@cameleer-postgres:5432/logto
ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost} ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${AUTH_HOST:-localhost}
ADMIN_ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}:${LOGTO_CONSOLE_PORT:-3002} ADMIN_ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${AUTH_HOST:-localhost}:${LOGTO_CONSOLE_PORT:-3002}
TRUST_PROXY_HEADER: 1 TRUST_PROXY_HEADER: 1
NODE_TLS_REJECT_UNAUTHORIZED: "${NODE_TLS_REJECT:-0}" NODE_TLS_REJECT_UNAUTHORIZED: "${NODE_TLS_REJECT:-0}"
LOGTO_ENDPOINT: http://cameleer-logto:3001 LOGTO_ENDPOINT: http://cameleer-logto:3001
LOGTO_ADMIN_ENDPOINT: http://cameleer-logto:3002 LOGTO_ADMIN_ENDPOINT: http://cameleer-logto:3002
LOGTO_PUBLIC_ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost} LOGTO_PUBLIC_ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${AUTH_HOST:-localhost}
PUBLIC_HOST: ${PUBLIC_HOST:-localhost} PUBLIC_HOST: ${PUBLIC_HOST:-localhost}
AUTH_HOST: ${AUTH_HOST:-localhost}
PUBLIC_PROTOCOL: ${PUBLIC_PROTOCOL:-https} PUBLIC_PROTOCOL: ${PUBLIC_PROTOCOL:-https}
PG_HOST: cameleer-postgres PG_HOST: cameleer-postgres
PG_USER: ${POSTGRES_USER:-cameleer} PG_USER: ${POSTGRES_USER:-cameleer}
@@ -77,6 +79,14 @@ services:
PG_DB_SAAS: ${POSTGRES_DB:-cameleer_saas} PG_DB_SAAS: ${POSTGRES_DB:-cameleer_saas}
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}
# SMTP (for email verification during registration)
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_USER: ${SMTP_USER:-}
SMTP_PASS: ${SMTP_PASS:-}
SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL:-noreply@cameleer.io}
extra_hosts:
- "${AUTH_HOST:-localhost}:host-gateway"
healthcheck: 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"] 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 interval: 10s
@@ -85,13 +95,13 @@ services:
start_period: 30s start_period: 30s
labels: labels:
- traefik.enable=true - traefik.enable=true
- traefik.http.routers.cameleer-logto.rule=PathPrefix(`/`) - "traefik.http.routers.cameleer-logto.rule=Host(`${AUTH_HOST:-localhost}`)"
- traefik.http.routers.cameleer-logto.priority=1 - traefik.http.routers.cameleer-logto.priority=1
- traefik.http.routers.cameleer-logto.entrypoints=websecure - traefik.http.routers.cameleer-logto.entrypoints=websecure
- traefik.http.routers.cameleer-logto.tls=true - traefik.http.routers.cameleer-logto.tls=true
- traefik.http.routers.cameleer-logto.service=cameleer-logto - traefik.http.routers.cameleer-logto.service=cameleer-logto
- traefik.http.routers.cameleer-logto.middlewares=cameleer-logto-cors - 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.accessControlAllowOriginList=${PUBLIC_PROTOCOL:-https}://${AUTH_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.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.accessControlAllowHeaders=Authorization,Content-Type
- traefik.http.middlewares.cameleer-logto-cors.headers.accessControlAllowCredentials=true - traefik.http.middlewares.cameleer-logto-cors.headers.accessControlAllowCredentials=true
@@ -123,7 +133,8 @@ services:
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:-cameleer_dev} SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:-cameleer_dev}
# Identity (Logto) # Identity (Logto)
CAMELEER_SAAS_IDENTITY_LOGTOENDPOINT: ${LOGTO_ENDPOINT:-http://cameleer-logto:3001} CAMELEER_SAAS_IDENTITY_LOGTOENDPOINT: ${LOGTO_ENDPOINT:-http://cameleer-logto:3001}
CAMELEER_SAAS_IDENTITY_LOGTOPUBLICENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost} CAMELEER_SAAS_IDENTITY_LOGTOPUBLICENDPOINT: ${PUBLIC_PROTOCOL:-https}://${AUTH_HOST:-localhost}
CAMELEER_SAAS_IDENTITY_AUTHHOST: ${AUTH_HOST:-localhost}
CAMELEER_SAAS_IDENTITY_M2MCLIENTID: ${LOGTO_M2M_CLIENT_ID:-} CAMELEER_SAAS_IDENTITY_M2MCLIENTID: ${LOGTO_M2M_CLIENT_ID:-}
CAMELEER_SAAS_IDENTITY_M2MCLIENTSECRET: ${LOGTO_M2M_CLIENT_SECRET:-} CAMELEER_SAAS_IDENTITY_M2MCLIENTSECRET: ${LOGTO_M2M_CLIENT_SECRET:-}
CAMELEER_SERVER_SECURITY_JWTSECRET: ${CAMELEER_SERVER_SECURITY_JWTSECRET:-cameleer-dev-jwt-secret} CAMELEER_SERVER_SECURITY_JWTSECRET: ${CAMELEER_SERVER_SECURITY_JWTSECRET:-cameleer-dev-jwt-secret}
@@ -139,6 +150,16 @@ services:
- traefik.http.routers.saas.entrypoints=websecure - traefik.http.routers.saas.entrypoints=websecure
- traefik.http.routers.saas.tls=true - traefik.http.routers.saas.tls=true
- traefik.http.services.saas.loadbalancer.server.port=8080 - traefik.http.services.saas.loadbalancer.server.port=8080
# Root redirect: / → /platform/ (scoped to app host so it doesn't catch auth domain)
- "traefik.http.routers.saas-root.rule=Host(`${PUBLIC_HOST:-localhost}`) && Path(`/`)"
- traefik.http.routers.saas-root.priority=100
- traefik.http.routers.saas-root.entrypoints=websecure
- traefik.http.routers.saas-root.tls=true
- traefik.http.routers.saas-root.middlewares=root-to-platform
- traefik.http.routers.saas-root.service=saas
- "traefik.http.middlewares.root-to-platform.redirectRegex.regex=^(https?://[^/]+)/?$$"
- "traefik.http.middlewares.root-to-platform.redirectRegex.replacement=$${1}/platform/"
- traefik.http.middlewares.root-to-platform.redirectRegex.permanent=false
group_add: group_add:
- "${DOCKER_GID:-0}" - "${DOCKER_GID:-0}"
networks: networks:

View File

@@ -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.
- 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 (SMTP) + 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)
@@ -81,8 +83,12 @@ Idempotent script run inside the Logto container entrypoint. **Clean slate** —
5. Create admin user (SaaS admin with Logto console access) 5. Create admin user (SaaS admin with Logto console access)
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`)
8b. Configure SMTP email connector (if `SMTP_HOST`/`SMTP_USER` env vars set) — discovers factory via `/api/connector-factories`, creates connector with Cameleer-branded HTML email templates for Register/SignIn/ForgotPassword/Generic. Skips gracefully if SMTP not configured.
8c. Enable self-service registration — sets `signInMode: "SignInAndRegister"`, `signUp: { identifiers: ["email"], password: true, verify: true }`, sign-in methods: email+password and username+password (backwards-compatible with admin user).
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 env vars for email verification: `SMTP_HOST`, `SMTP_PORT` (default 587), `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM_EMAIL` (default `noreply@cameleer.io`). Passed to `cameleer-logto` container via docker-compose. Both installers prompt for these in SaaS mode.
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`.

View File

@@ -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

View File

@@ -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:

View File

@@ -32,6 +32,7 @@ SAAS_ADMIN_PASS="${SAAS_ADMIN_PASS:-admin}"
# 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 +48,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)
@@ -562,6 +564,123 @@ api_patch "/api/sign-in-exp" "{
}" }"
log "Sign-in branding configured." log "Sign-in branding configured."
# ============================================================
# PHASE 8b: Configure SMTP email connector
# ============================================================
# Required for email verification during registration and password reset.
# Skipped if SMTP_HOST is not set (registration will not work without email delivery).
if [ -n "${SMTP_HOST:-}" ] && [ -n "${SMTP_USER:-}" ]; then
log "Configuring SMTP email connector..."
# Discover available email connector factories
FACTORIES=$(api_get "/api/connector-factories")
# Prefer a factory with "smtp" in the ID
SMTP_FACTORY_ID=$(echo "$FACTORIES" | jq -r '[.[] | select(.type == "Email" and (.id | test("smtp"; "i")))] | .[0].id // empty')
if [ -z "$SMTP_FACTORY_ID" ]; then
# Fall back to any non-demo Email factory
SMTP_FACTORY_ID=$(echo "$FACTORIES" | jq -r '[.[] | select(.type == "Email" and .isDemo != true)] | .[0].id // empty')
fi
if [ -n "$SMTP_FACTORY_ID" ]; then
# Build SMTP config JSON
SMTP_CONFIG=$(jq -n \
--arg host "$SMTP_HOST" \
--arg port "${SMTP_PORT:-587}" \
--arg user "$SMTP_USER" \
--arg pass "${SMTP_PASS:-}" \
--arg from "${SMTP_FROM_EMAIL:-noreply@cameleer.io}" \
'{
host: $host,
port: ($port | tonumber),
auth: { user: $user, pass: $pass },
fromEmail: $from,
templates: [
{
usageType: "Register",
contentType: "text/html",
subject: "Verify your email for Cameleer",
content: "<div style=\"font-family:sans-serif;max-width:480px;margin:0 auto;padding:24px\"><div style=\"text-align:center;margin-bottom:24px\"><span style=\"font-size:24px;font-weight:700;color:#C6820E\">Cameleer</span></div><p style=\"color:#333;font-size:15px;line-height:1.6\">Enter this code to verify your email and create your account:</p><div style=\"text-align:center;margin:24px 0\"><span style=\"font-size:32px;font-weight:700;letter-spacing:6px;color:#C6820E\">{{code}}</span></div><p style=\"color:#666;font-size:13px\">This code expires in 10 minutes. If you did not request this, you can safely ignore this email.</p></div>"
},
{
usageType: "SignIn",
contentType: "text/html",
subject: "Your Cameleer sign-in code",
content: "<div style=\"font-family:sans-serif;max-width:480px;margin:0 auto;padding:24px\"><div style=\"text-align:center;margin-bottom:24px\"><span style=\"font-size:24px;font-weight:700;color:#C6820E\">Cameleer</span></div><p style=\"color:#333;font-size:15px;line-height:1.6\">Your sign-in verification code:</p><div style=\"text-align:center;margin:24px 0\"><span style=\"font-size:32px;font-weight:700;letter-spacing:6px;color:#C6820E\">{{code}}</span></div><p style=\"color:#666;font-size:13px\">This code expires in 10 minutes.</p></div>"
},
{
usageType: "ForgotPassword",
contentType: "text/html",
subject: "Reset your Cameleer password",
content: "<div style=\"font-family:sans-serif;max-width:480px;margin:0 auto;padding:24px\"><div style=\"text-align:center;margin-bottom:24px\"><span style=\"font-size:24px;font-weight:700;color:#C6820E\">Cameleer</span></div><p style=\"color:#333;font-size:15px;line-height:1.6\">Enter this code to reset your password:</p><div style=\"text-align:center;margin:24px 0\"><span style=\"font-size:32px;font-weight:700;letter-spacing:6px;color:#C6820E\">{{code}}</span></div><p style=\"color:#666;font-size:13px\">This code expires in 10 minutes. If you did not request a password reset, you can safely ignore this email.</p></div>"
},
{
usageType: "Generic",
contentType: "text/html",
subject: "Your Cameleer verification code",
content: "<div style=\"font-family:sans-serif;max-width:480px;margin:0 auto;padding:24px\"><div style=\"text-align:center;margin-bottom:24px\"><span style=\"font-size:24px;font-weight:700;color:#C6820E\">Cameleer</span></div><p style=\"color:#333;font-size:15px;line-height:1.6\">Your verification code:</p><div style=\"text-align:center;margin:24px 0\"><span style=\"font-size:32px;font-weight:700;letter-spacing:6px;color:#C6820E\">{{code}}</span></div><p style=\"color:#666;font-size:13px\">This code expires in 10 minutes.</p></div>"
}
]
}')
# Check if an email connector already exists
EXISTING_CONNECTORS=$(api_get "/api/connectors")
EMAIL_CONNECTOR_ID=$(echo "$EXISTING_CONNECTORS" | jq -r '[.[] | select(.type == "Email")] | .[0].id // empty')
if [ -n "$EMAIL_CONNECTOR_ID" ]; then
api_patch "/api/connectors/$EMAIL_CONNECTOR_ID" "{\"config\": $SMTP_CONFIG}" >/dev/null 2>&1
log "Updated existing email connector: $EMAIL_CONNECTOR_ID"
else
CONNECTOR_RESPONSE=$(api_post "/api/connectors" "{\"connectorId\": \"$SMTP_FACTORY_ID\", \"config\": $SMTP_CONFIG}")
CREATED_ID=$(echo "$CONNECTOR_RESPONSE" | jq -r '.id // empty')
if [ -n "$CREATED_ID" ]; then
log "Created SMTP email connector: $CREATED_ID (factory: $SMTP_FACTORY_ID)"
else
log "WARNING: Failed to create SMTP connector. Response: $(echo "$CONNECTOR_RESPONSE" | head -c 300)"
fi
fi
else
log "WARNING: No email connector factory found — email delivery will not work."
log "Available factories: $(echo "$FACTORIES" | jq -c '[.[] | select(.type == "Email") | .id]')"
fi
else
log "SMTP not configured (SMTP_HOST/SMTP_USER not set) — email delivery disabled."
log "Set SMTP_HOST, SMTP_USER, SMTP_PASS, SMTP_FROM_EMAIL env vars to enable."
fi
# ============================================================
# PHASE 8c: Enable registration (email + password)
# ============================================================
# Configures sign-in experience to allow self-service registration with email verification.
# This runs AFTER the SMTP connector so email delivery is ready before registration opens.
log "Configuring sign-in experience for registration..."
api_patch "/api/sign-in-exp" '{
"signInMode": "SignInAndRegister",
"signUp": {
"identifiers": ["email"],
"password": true,
"verify": true
},
"signIn": {
"methods": [
{
"identifier": "email",
"password": true,
"verificationCode": false,
"isPasswordPrimary": true
},
{
"identifier": "username",
"password": true,
"verificationCode": false,
"isPasswordPrimary": true
}
]
}
}' >/dev/null 2>&1
log "Sign-in experience configured: SignInAndRegister (email + password)."
# ============================================================ # ============================================================
# PHASE 9: Cleanup seeded apps # PHASE 9: Cleanup seeded apps
# ============================================================ # ============================================================

View File

@@ -23,10 +23,27 @@ The installer uses static docker-compose templates in `installer/templates/`. Te
- `docker-compose.tls.yml` — overlay: custom TLS cert volume - `docker-compose.tls.yml` — overlay: custom TLS cert volume
- `docker-compose.monitoring.yml` — overlay: external monitoring network - `docker-compose.monitoring.yml` — overlay: external monitoring network
## SMTP configuration
Both installers (`install.sh` and `install.ps1`) prompt for SMTP settings in SaaS mode when the user opts in ("Configure SMTP for email verification?"). SMTP is required for self-service sign-up — without it, only admin-created users can sign in.
Env vars: `SMTP_HOST`, `SMTP_PORT` (default 587), `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM_EMAIL` (default `noreply@<PUBLIC_HOST>`). Passed to the `cameleer-logto` container. The bootstrap script (Phase 8b) discovers the SMTP connector factory and creates the connector with Cameleer-branded email templates.
CLI args: `--smtp-host`, `--smtp-port`, `--smtp-user`, `--smtp-pass`, `--smtp-from-email` (bash) / `-SmtpHost`, `-SmtpPort`, `-SmtpUser`, `-SmtpPass`, `-SmtpFromEmail` (PS1). Persisted in `cameleer.conf` for upgrades/reconfigure.
## Registry configuration
Both installers support pulling images from a custom Docker registry via `--registry` (bash) / `-Registry` (PS1). Default: `gitea.siegeln.net/cameleer`.
When a registry is configured, the installer writes `*_IMAGE` env vars to `.env` (e.g. `TRAEFIK_IMAGE`, `POSTGRES_IMAGE`, `CAMELEER_IMAGE`) which override the defaults baked into the compose templates. In SaaS mode, provisioning image refs (`CAMELEER_SAAS_PROVISIONING_*IMAGE`) are also set from the registry.
For private registries, pass `--registry-user` / `--registry-token` (bash) or `-RegistryUser` / `-RegistryToken` (PS1). The installer runs `docker login` before pulling images. Credentials are persisted in `cameleer.conf` for upgrades/reconfigure.
## Env var naming convention ## Env var naming convention
- `CAMELEER_AGENT_*` — agent config (consumed by the Java agent) - `CAMELEER_AGENT_*` — agent config (consumed by the Java agent)
- `CAMELEER_SERVER_*` — server config (consumed by cameleer-server) - `CAMELEER_SERVER_*` — server config (consumed by cameleer-server)
- `CAMELEER_SAAS_*` — SaaS management plane config - `CAMELEER_SAAS_*` — SaaS management plane config
- `CAMELEER_SAAS_PROVISIONING_*` — "SaaS forwards this to provisioned tenant servers" - `CAMELEER_SAAS_PROVISIONING_*` — "SaaS forwards this to provisioned tenant servers"
- `SMTP_*` — email delivery config for Logto (consumed by bootstrap, SaaS mode only)
- No prefix (e.g. `POSTGRES_PASSWORD`, `PUBLIC_HOST`) — shared infrastructure, consumed by multiple components - No prefix (e.g. `POSTGRES_PASSWORD`, `PUBLIC_HOST`) — shared infrastructure, consumed by multiple components

View File

@@ -17,6 +17,7 @@ param(
[string]$Config, [string]$Config,
[string]$InstallDir, [string]$InstallDir,
[string]$PublicHost, [string]$PublicHost,
[string]$AuthHost,
[string]$PublicProtocol, [string]$PublicProtocol,
[string]$AdminUser, [string]$AdminUser,
[string]$AdminPassword, [string]$AdminPassword,
@@ -36,6 +37,14 @@ param(
[string]$DockerSocket, [string]$DockerSocket,
[string]$NodeTlsReject, [string]$NodeTlsReject,
[string]$DeploymentMode, [string]$DeploymentMode,
[string]$SmtpHost,
[string]$SmtpPort,
[string]$SmtpUser,
[string]$SmtpPass,
[string]$SmtpFromEmail,
[string]$Registry,
[string]$RegistryUser,
[string]$RegistryToken,
[switch]$Reconfigure, [switch]$Reconfigure,
[switch]$Reinstall, [switch]$Reinstall,
[switch]$ConfirmDestroy, [switch]$ConfirmDestroy,
@@ -49,7 +58,7 @@ $ErrorActionPreference = 'Stop'
$CAMELEER_INSTALLER_VERSION = '1.0.0' $CAMELEER_INSTALLER_VERSION = '1.0.0'
$CAMELEER_DEFAULT_VERSION = 'latest' $CAMELEER_DEFAULT_VERSION = 'latest'
$REGISTRY = 'gitea.siegeln.net/cameleer' $DEFAULT_REGISTRY = 'gitea.siegeln.net/cameleer'
$DEFAULT_INSTALL_DIR = './cameleer' $DEFAULT_INSTALL_DIR = './cameleer'
$DEFAULT_PUBLIC_PROTOCOL = 'https' $DEFAULT_PUBLIC_PROTOCOL = 'https'
@@ -66,6 +75,7 @@ $DEFAULT_DOCKER_SOCKET = '/var/run/docker.sock'
# --- Capture env vars before any overrides --- # --- Capture env vars before any overrides ---
$_ENV_PUBLIC_HOST = $env:PUBLIC_HOST $_ENV_PUBLIC_HOST = $env:PUBLIC_HOST
$_ENV_AUTH_HOST = $env:AUTH_HOST
$_ENV_PUBLIC_PROTOCOL = $env:PUBLIC_PROTOCOL $_ENV_PUBLIC_PROTOCOL = $env:PUBLIC_PROTOCOL
$_ENV_POSTGRES_PASSWORD = $env:POSTGRES_PASSWORD $_ENV_POSTGRES_PASSWORD = $env:POSTGRES_PASSWORD
$_ENV_CLICKHOUSE_PASSWORD = $env:CLICKHOUSE_PASSWORD $_ENV_CLICKHOUSE_PASSWORD = $env:CLICKHOUSE_PASSWORD
@@ -82,12 +92,21 @@ $_ENV_COMPOSE_PROJECT = $env:COMPOSE_PROJECT
$_ENV_DOCKER_SOCKET = $env:DOCKER_SOCKET $_ENV_DOCKER_SOCKET = $env:DOCKER_SOCKET
$_ENV_NODE_TLS_REJECT = $env:NODE_TLS_REJECT $_ENV_NODE_TLS_REJECT = $env:NODE_TLS_REJECT
$_ENV_DEPLOYMENT_MODE = $env:DEPLOYMENT_MODE $_ENV_DEPLOYMENT_MODE = $env:DEPLOYMENT_MODE
$_ENV_SMTP_HOST = $env:SMTP_HOST
$_ENV_SMTP_PORT = $env:SMTP_PORT
$_ENV_SMTP_USER = $env:SMTP_USER
$_ENV_SMTP_PASS = $env:SMTP_PASS
$_ENV_SMTP_FROM_EMAIL = $env:SMTP_FROM_EMAIL
$_ENV_REGISTRY = $env:REGISTRY
$_ENV_REGISTRY_USER = $env:REGISTRY_USER
$_ENV_REGISTRY_TOKEN = $env:REGISTRY_TOKEN
# --- Mutable config state --- # --- Mutable config state ---
$script:cfg = @{ $script:cfg = @{
InstallDir = $InstallDir InstallDir = $InstallDir
PublicHost = $PublicHost PublicHost = $PublicHost
AuthHost = $AuthHost
PublicProtocol = $PublicProtocol PublicProtocol = $PublicProtocol
AdminUser = $AdminUser AdminUser = $AdminUser
AdminPass = $AdminPassword AdminPass = $AdminPassword
@@ -107,6 +126,14 @@ $script:cfg = @{
DockerSocket = $DockerSocket DockerSocket = $DockerSocket
NodeTlsReject = $NodeTlsReject NodeTlsReject = $NodeTlsReject
DeploymentMode = $DeploymentMode DeploymentMode = $DeploymentMode
SmtpHost = $SmtpHost
SmtpPort = $SmtpPort
SmtpUser = $SmtpUser
SmtpPass = $SmtpPass
SmtpFromEmail = $SmtpFromEmail
Registry = $Registry
RegistryUser = $RegistryUser
RegistryToken = $RegistryToken
} }
if ($Silent) { $script:Mode = 'silent' } if ($Silent) { $script:Mode = 'silent' }
@@ -150,6 +177,7 @@ function Show-Help {
Write-Host 'Options:' Write-Host 'Options:'
Write-Host ' -InstallDir DIR Install directory (default: ./cameleer)' Write-Host ' -InstallDir DIR Install directory (default: ./cameleer)'
Write-Host ' -PublicHost HOST Public hostname (default: auto-detect)' Write-Host ' -PublicHost HOST Public hostname (default: auto-detect)'
Write-Host ' -AuthHost HOST Auth domain for Logto (default: same as PublicHost)'
Write-Host ' -AdminUser USER Admin username (default: admin)' Write-Host ' -AdminUser USER Admin username (default: admin)'
Write-Host ' -AdminPassword PASS Admin password (default: generated)' Write-Host ' -AdminPassword PASS Admin password (default: generated)'
Write-Host ' -TlsMode MODE self-signed or custom (default: self-signed)' Write-Host ' -TlsMode MODE self-signed or custom (default: self-signed)'
@@ -161,6 +189,11 @@ function Show-Help {
Write-Host ' -Config FILE Load config from file' Write-Host ' -Config FILE Load config from file'
Write-Host ' -Help Show this help' Write-Host ' -Help Show this help'
Write-Host '' Write-Host ''
Write-Host 'Registry options:'
Write-Host ' -Registry REGISTRY Image registry (default: gitea.siegeln.net/cameleer)'
Write-Host ' -RegistryUser USER Registry username for docker login'
Write-Host ' -RegistryToken TOKEN Registry token/password for docker login'
Write-Host ''
Write-Host 'Expert options:' Write-Host 'Expert options:'
Write-Host ' -PostgresPassword, -ClickhousePassword, -HttpPort,' Write-Host ' -PostgresPassword, -ClickhousePassword, -HttpPort,'
Write-Host ' -HttpsPort, -LogtoConsolePort, -LogtoConsoleExposed,' Write-Host ' -HttpsPort, -LogtoConsolePort, -LogtoConsoleExposed,'
@@ -236,6 +269,7 @@ function Load-ConfigFile {
switch ($key) { switch ($key) {
'install_dir' { if (-not $script:cfg.InstallDir) { $script:cfg.InstallDir = $val } } 'install_dir' { if (-not $script:cfg.InstallDir) { $script:cfg.InstallDir = $val } }
'public_host' { if (-not $script:cfg.PublicHost) { $script:cfg.PublicHost = $val } } 'public_host' { if (-not $script:cfg.PublicHost) { $script:cfg.PublicHost = $val } }
'auth_host' { if (-not $script:cfg.AuthHost) { $script:cfg.AuthHost = $val } }
'public_protocol' { if (-not $script:cfg.PublicProtocol) { $script:cfg.PublicProtocol = $val } } 'public_protocol' { if (-not $script:cfg.PublicProtocol) { $script:cfg.PublicProtocol = $val } }
'admin_user' { if (-not $script:cfg.AdminUser) { $script:cfg.AdminUser = $val } } 'admin_user' { if (-not $script:cfg.AdminUser) { $script:cfg.AdminUser = $val } }
'admin_password' { if (-not $script:cfg.AdminPass) { $script:cfg.AdminPass = $val } } 'admin_password' { if (-not $script:cfg.AdminPass) { $script:cfg.AdminPass = $val } }
@@ -255,6 +289,14 @@ function Load-ConfigFile {
'docker_socket' { if (-not $script:cfg.DockerSocket) { $script:cfg.DockerSocket = $val } } 'docker_socket' { if (-not $script:cfg.DockerSocket) { $script:cfg.DockerSocket = $val } }
'node_tls_reject' { if (-not $script:cfg.NodeTlsReject) { $script:cfg.NodeTlsReject = $val } } 'node_tls_reject' { if (-not $script:cfg.NodeTlsReject) { $script:cfg.NodeTlsReject = $val } }
'deployment_mode' { if (-not $script:cfg.DeploymentMode) { $script:cfg.DeploymentMode = $val } } 'deployment_mode' { if (-not $script:cfg.DeploymentMode) { $script:cfg.DeploymentMode = $val } }
'smtp_host' { if (-not $script:cfg.SmtpHost) { $script:cfg.SmtpHost = $val } }
'smtp_port' { if (-not $script:cfg.SmtpPort) { $script:cfg.SmtpPort = $val } }
'smtp_user' { if (-not $script:cfg.SmtpUser) { $script:cfg.SmtpUser = $val } }
'smtp_pass' { if (-not $script:cfg.SmtpPass) { $script:cfg.SmtpPass = $val } }
'smtp_from_email' { if (-not $script:cfg.SmtpFromEmail) { $script:cfg.SmtpFromEmail = $val } }
'registry' { if (-not $script:cfg.Registry) { $script:cfg.Registry = $val } }
'registry_user' { if (-not $script:cfg.RegistryUser) { $script:cfg.RegistryUser = $val } }
'registry_token' { if (-not $script:cfg.RegistryToken) { $script:cfg.RegistryToken = $val } }
} }
} }
} }
@@ -264,6 +306,7 @@ function Load-EnvOverrides {
$c = $script:cfg $c = $script:cfg
if (-not $c.InstallDir) { $c.InstallDir = $env:CAMELEER_INSTALL_DIR } if (-not $c.InstallDir) { $c.InstallDir = $env:CAMELEER_INSTALL_DIR }
if (-not $c.PublicHost) { $c.PublicHost = $_ENV_PUBLIC_HOST } if (-not $c.PublicHost) { $c.PublicHost = $_ENV_PUBLIC_HOST }
if (-not $c.AuthHost) { $c.AuthHost = $_ENV_AUTH_HOST }
if (-not $c.PublicProtocol) { $c.PublicProtocol = $_ENV_PUBLIC_PROTOCOL } if (-not $c.PublicProtocol) { $c.PublicProtocol = $_ENV_PUBLIC_PROTOCOL }
if (-not $c.AdminUser) { $c.AdminUser = $env:SAAS_ADMIN_USER } if (-not $c.AdminUser) { $c.AdminUser = $env:SAAS_ADMIN_USER }
if (-not $c.AdminPass) { $c.AdminPass = $env:SAAS_ADMIN_PASS } if (-not $c.AdminPass) { $c.AdminPass = $env:SAAS_ADMIN_PASS }
@@ -283,6 +326,14 @@ function Load-EnvOverrides {
if (-not $c.DockerSocket) { $c.DockerSocket = $_ENV_DOCKER_SOCKET } if (-not $c.DockerSocket) { $c.DockerSocket = $_ENV_DOCKER_SOCKET }
if (-not $c.NodeTlsReject) { $c.NodeTlsReject = $_ENV_NODE_TLS_REJECT } if (-not $c.NodeTlsReject) { $c.NodeTlsReject = $_ENV_NODE_TLS_REJECT }
if (-not $c.DeploymentMode) { $c.DeploymentMode = $_ENV_DEPLOYMENT_MODE } if (-not $c.DeploymentMode) { $c.DeploymentMode = $_ENV_DEPLOYMENT_MODE }
if (-not $c.SmtpHost) { $c.SmtpHost = $_ENV_SMTP_HOST }
if (-not $c.SmtpPort) { $c.SmtpPort = $_ENV_SMTP_PORT }
if (-not $c.SmtpUser) { $c.SmtpUser = $_ENV_SMTP_USER }
if (-not $c.SmtpPass) { $c.SmtpPass = $_ENV_SMTP_PASS }
if (-not $c.SmtpFromEmail) { $c.SmtpFromEmail = $_ENV_SMTP_FROM_EMAIL }
if (-not $c.Registry) { $c.Registry = $_ENV_REGISTRY }
if (-not $c.RegistryUser) { $c.RegistryUser = $_ENV_REGISTRY_USER }
if (-not $c.RegistryToken) { $c.RegistryToken = $_ENV_REGISTRY_TOKEN }
} }
# --- Prerequisites --- # --- Prerequisites ---
@@ -433,7 +484,14 @@ function Run-SimplePrompts {
Write-Host '' Write-Host ''
$c.MonitoringNetwork = Prompt-Value 'Monitoring network name (empty = skip)' '' $c.MonitoringNetwork = Prompt-Value 'Monitoring network name (empty = skip)' ''
Write-Host ''
if (Prompt-YesNo 'Pull images from a private registry?') {
$c.Registry = Prompt-Value 'Registry' (Coalesce $c.Registry $DEFAULT_REGISTRY)
$c.RegistryUser = Prompt-Value 'Registry username' (Coalesce $c.RegistryUser '')
$c.RegistryToken = Prompt-Password 'Registry token/password' (Coalesce $c.RegistryToken '')
}
Write-Host '' Write-Host ''
Write-Host ' Deployment mode:' Write-Host ' Deployment mode:'
Write-Host ' [1] Multi-tenant SaaS -- manage platform, provision tenants on demand' Write-Host ' [1] Multi-tenant SaaS -- manage platform, provision tenants on demand'
@@ -441,6 +499,18 @@ function Run-SimplePrompts {
Write-Host '' Write-Host ''
$deployChoice = Read-Host ' Select mode [1]' $deployChoice = Read-Host ' Select mode [1]'
if ($deployChoice -eq '2') { $c.DeploymentMode = 'standalone' } else { $c.DeploymentMode = 'saas' } if ($deployChoice -eq '2') { $c.DeploymentMode = 'standalone' } else { $c.DeploymentMode = 'saas' }
# SMTP for email verification (SaaS mode only)
if ($c.DeploymentMode -eq 'saas') {
Write-Host ''
if (Prompt-YesNo 'Configure SMTP for email verification? (required for self-service sign-up)') {
$c.SmtpHost = Prompt-Value 'SMTP host' (Coalesce $c.SmtpHost '')
$c.SmtpPort = Prompt-Value 'SMTP port' (Coalesce $c.SmtpPort '587')
$c.SmtpUser = Prompt-Value 'SMTP username' (Coalesce $c.SmtpUser '')
$c.SmtpPass = Prompt-Password 'SMTP password' (Coalesce $c.SmtpPass '')
$c.SmtpFromEmail = Prompt-Value 'From email address' (Coalesce $c.SmtpFromEmail "noreply@$($c.PublicHost)")
}
}
} }
function Run-ExpertPrompts { function Run-ExpertPrompts {
@@ -474,6 +544,7 @@ function Run-ExpertPrompts {
if ($c.DeploymentMode -eq 'saas') { if ($c.DeploymentMode -eq 'saas') {
Write-Host '' Write-Host ''
Write-Host ' Logto:' -ForegroundColor Cyan Write-Host ' Logto:' -ForegroundColor Cyan
$c.AuthHost = Prompt-Value 'Auth domain (Logto) -- same as hostname for single-domain' (Coalesce $c.AuthHost $c.PublicHost)
if (Prompt-YesNo 'Expose Logto admin console externally?' 'y') { if (Prompt-YesNo 'Expose Logto admin console externally?' 'y') {
$c.LogtoConsoleExposed = 'true' $c.LogtoConsoleExposed = 'true'
} else { } else {
@@ -498,6 +569,7 @@ function Merge-Config {
if (-not $c.LogtoConsoleExposed) { $c.LogtoConsoleExposed = $DEFAULT_LOGTO_CONSOLE_EXPOSED } if (-not $c.LogtoConsoleExposed) { $c.LogtoConsoleExposed = $DEFAULT_LOGTO_CONSOLE_EXPOSED }
if (-not $c.Version) { $c.Version = $CAMELEER_DEFAULT_VERSION } if (-not $c.Version) { $c.Version = $CAMELEER_DEFAULT_VERSION }
if (-not $c.DockerSocket) { $c.DockerSocket = $DEFAULT_DOCKER_SOCKET } if (-not $c.DockerSocket) { $c.DockerSocket = $DEFAULT_DOCKER_SOCKET }
if (-not $c.Registry) { $c.Registry = $DEFAULT_REGISTRY }
if (-not $c.ComposeProject) { if (-not $c.ComposeProject) {
if ($c.DeploymentMode -eq 'standalone') { if ($c.DeploymentMode -eq 'standalone') {
@@ -507,8 +579,12 @@ function Merge-Config {
} }
} }
# Default AUTH_HOST to PUBLIC_HOST (single-domain setup)
if (-not $c.AuthHost) { $c.AuthHost = $c.PublicHost }
# Force lowercase -- Logto normalises internally; case mismatch breaks JWT validation # Force lowercase -- Logto normalises internally; case mismatch breaks JWT validation
$c.PublicHost = $c.PublicHost.ToLower() $c.PublicHost = $c.PublicHost.ToLower()
$c.AuthHost = $c.AuthHost.ToLower()
if ($c.DeploymentMode -ne 'standalone' -and (-not $c.NodeTlsReject)) { if ($c.DeploymentMode -ne 'standalone' -and (-not $c.NodeTlsReject)) {
if ($c.TlsMode -eq 'custom') { $c.NodeTlsReject = '1' } else { $c.NodeTlsReject = '0' } if ($c.TlsMode -eq 'custom') { $c.NodeTlsReject = '1' } else { $c.NodeTlsReject = '0' }
@@ -614,6 +690,12 @@ DOCKER_SOCKET=$($c.DockerSocket)
DOCKER_GID=$gid DOCKER_GID=$gid
POSTGRES_IMAGE=postgres:16-alpine POSTGRES_IMAGE=postgres:16-alpine
# Registry
TRAEFIK_IMAGE=$($c.Registry)/cameleer-traefik
CLICKHOUSE_IMAGE=$($c.Registry)/cameleer-clickhouse
SERVER_IMAGE=$($c.Registry)/cameleer-server
SERVER_UI_IMAGE=$($c.Registry)/cameleer-server-ui
"@ "@
if ($c.TlsMode -eq 'custom') { if ($c.TlsMode -eq 'custom') {
$content += "`nCERT_FILE=/user-certs/cert.pem" $content += "`nCERT_FILE=/user-certs/cert.pem"
@@ -636,6 +718,7 @@ POSTGRES_IMAGE=postgres:16-alpine
VERSION=$($c.Version) VERSION=$($c.Version)
PUBLIC_HOST=$($c.PublicHost) PUBLIC_HOST=$($c.PublicHost)
AUTH_HOST=$($c.AuthHost)
PUBLIC_PROTOCOL=$($c.PublicProtocol) PUBLIC_PROTOCOL=$($c.PublicProtocol)
HTTP_PORT=$($c.HttpPort) HTTP_PORT=$($c.HttpPort)
@@ -663,19 +746,34 @@ NODE_TLS_REJECT=$($c.NodeTlsReject)
$content += "`nKEY_FILE=/user-certs/key.pem" $content += "`nKEY_FILE=/user-certs/key.pem"
if ($c.CaFile) { $content += "`nCA_FILE=/user-certs/ca.pem" } if ($c.CaFile) { $content += "`nCA_FILE=/user-certs/ca.pem" }
} }
$reg = $c.Registry
$provisioningBlock = @" $provisioningBlock = @"
# Docker # Docker
DOCKER_SOCKET=$($c.DockerSocket) DOCKER_SOCKET=$($c.DockerSocket)
DOCKER_GID=$gid DOCKER_GID=$gid
# Registry
TRAEFIK_IMAGE=$reg/cameleer-traefik
POSTGRES_IMAGE=$reg/cameleer-postgres
CLICKHOUSE_IMAGE=$reg/cameleer-clickhouse
LOGTO_IMAGE=$reg/cameleer-logto
CAMELEER_IMAGE=$reg/cameleer-saas
# Provisioning images # Provisioning images
CAMELEER_SAAS_PROVISIONING_SERVERIMAGE=${REGISTRY}/cameleer-server:$($c.Version) CAMELEER_SAAS_PROVISIONING_SERVERIMAGE=$reg/cameleer-server:$($c.Version)
CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE=${REGISTRY}/cameleer-server-ui:$($c.Version) CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE=$reg/cameleer-server-ui:$($c.Version)
CAMELEER_SAAS_PROVISIONING_RUNTIMEBASEIMAGE=${REGISTRY}/cameleer-runtime-base:$($c.Version) CAMELEER_SAAS_PROVISIONING_RUNTIMEBASEIMAGE=$reg/cameleer-runtime-base:$($c.Version)
# JWT signing secret (forwarded to provisioned tenant servers, must be non-empty) # JWT signing secret (forwarded to provisioned tenant servers, must be non-empty)
CAMELEER_SERVER_SECURITY_JWTSECRET=$jwtSecret CAMELEER_SERVER_SECURITY_JWTSECRET=$jwtSecret
# SMTP (for email verification during registration)
SMTP_HOST=$($c.SmtpHost)
SMTP_PORT=$(if ($c.SmtpPort) { $c.SmtpPort } else { '587' })
SMTP_USER=$($c.SmtpUser)
SMTP_PASS=$($c.SmtpPass)
SMTP_FROM_EMAIL=$(if ($c.SmtpFromEmail) { $c.SmtpFromEmail } else { "noreply@$($c.PublicHost)" })
"@ "@
$content += $provisioningBlock $content += $provisioningBlock
$composeFile = 'docker-compose.yml;docker-compose.saas.yml' $composeFile = 'docker-compose.yml;docker-compose.saas.yml'
@@ -722,7 +820,18 @@ function Copy-Templates {
} }
# --- Docker operations --- # --- Docker operations ---
function Invoke-RegistryLogin {
$c = $script:cfg
if ($c.RegistryUser -and $c.RegistryToken) {
$registryHost = $c.Registry.Split('/')[0]
Log-Info "Logging in to registry ${registryHost}..."
$c.RegistryToken | docker login $registryHost -u $c.RegistryUser --password-stdin
if ($LASTEXITCODE -ne 0) { Log-Error 'Registry login failed.'; exit 1 }
Log-Success 'Registry login successful.'
}
}
function Invoke-ComposePull { function Invoke-ComposePull {
$c = $script:cfg $c = $script:cfg
Log-Info 'Pulling Docker images...' Log-Info 'Pulling Docker images...'
@@ -815,9 +924,11 @@ function Wait-DockerHealthy {
} }
function Test-Endpoint { function Test-Endpoint {
param([string]$Name, [string]$Url, [int]$TimeoutSecs = 120) param([string]$Name, [string]$Url, [int]$TimeoutSecs = 120, [string]$HostHeader = '')
$start = Get-Date $start = Get-Date
$lastDot = -1 $lastDot = -1
$headers = @{}
if ($HostHeader) { $headers['Host'] = $HostHeader }
while ($true) { while ($true) {
$elapsed = [int]((Get-Date) - $start).TotalSeconds $elapsed = [int]((Get-Date) - $start).TotalSeconds
if ($elapsed -ge $TimeoutSecs) { if ($elapsed -ge $TimeoutSecs) {
@@ -826,7 +937,7 @@ function Test-Endpoint {
} }
try { try {
# -SkipCertificateCheck is PS6+ only; SSL trust is handled by Enable-TrustAllCerts above # -SkipCertificateCheck is PS6+ only; SSL trust is handled by Enable-TrustAllCerts above
$resp = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop $resp = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 5 -Headers $headers -ErrorAction Stop
$dur = [int]((Get-Date) - $start).TotalSeconds $dur = [int]((Get-Date) - $start).TotalSeconds
Write-Host (" [ok] {0,-20} ready ({1}s)" -f $Name, $dur) -ForegroundColor Green Write-Host (" [ok] {0,-20} ready ({1}s)" -f $Name, $dur) -ForegroundColor Green
return $true return $true
@@ -867,7 +978,7 @@ function Verify-Health {
if (-not (Test-Endpoint 'Cameleer SaaS' "https://localhost:$($c.HttpsPort)/platform/api/config" 120)) { $failed = $true } if (-not (Test-Endpoint 'Cameleer SaaS' "https://localhost:$($c.HttpsPort)/platform/api/config" 120)) { $failed = $true }
} }
if (-not $failed) { if (-not $failed) {
if (-not (Test-Endpoint 'Traefik routing' "https://localhost:$($c.HttpsPort)/" 30)) { $failed = $true } if (-not (Test-Endpoint 'Traefik routing' "https://localhost:$($c.HttpsPort)/" 30 $c.PublicHost)) { $failed = $true }
} }
} }
@@ -889,6 +1000,7 @@ function Write-ConfigFile {
install_dir=$($c.InstallDir) install_dir=$($c.InstallDir)
public_host=$($c.PublicHost) public_host=$($c.PublicHost)
auth_host=$($c.AuthHost)
public_protocol=$($c.PublicProtocol) public_protocol=$($c.PublicProtocol)
admin_user=$($c.AdminUser) admin_user=$($c.AdminUser)
tls_mode=$($c.TlsMode) tls_mode=$($c.TlsMode)
@@ -902,6 +1014,14 @@ compose_project=$($c.ComposeProject)
docker_socket=$($c.DockerSocket) docker_socket=$($c.DockerSocket)
node_tls_reject=$($c.NodeTlsReject) node_tls_reject=$($c.NodeTlsReject)
deployment_mode=$($c.DeploymentMode) deployment_mode=$($c.DeploymentMode)
smtp_host=$($c.SmtpHost)
smtp_port=$($c.SmtpPort)
smtp_user=$($c.SmtpUser)
smtp_pass=$($c.SmtpPass)
smtp_from_email=$($c.SmtpFromEmail)
registry=$($c.Registry)
registry_user=$($c.RegistryUser)
registry_token=$($c.RegistryToken)
"@ "@
Write-Utf8File $f $txt Write-Utf8File $f $txt
Log-Info 'Saved installer config to cameleer.conf' Log-Info 'Saved installer config to cameleer.conf'
@@ -931,7 +1051,7 @@ ClickHouse: default / $($c.ClickhousePassword)
"@ "@
} else { } else {
if ($c.LogtoConsoleExposed -eq 'true') { if ($c.LogtoConsoleExposed -eq 'true') {
$logtoLine = "Logto Console: $($c.PublicProtocol)://$($c.PublicHost):$($c.LogtoConsolePort)" $logtoLine = "Logto Console: $($c.PublicProtocol)://$($c.AuthHost):$($c.LogtoConsolePort)"
} else { } else {
$logtoLine = 'Logto Console: (not exposed)' $logtoLine = 'Logto Console: (not exposed)'
} }
@@ -980,7 +1100,7 @@ function Generate-InstallDoc {
if ($c.TlsMode -eq 'custom') { $tlsDesc = 'Custom certificate' } else { $tlsDesc = 'Self-signed (auto-generated)' } if ($c.TlsMode -eq 'custom') { $tlsDesc = 'Custom certificate' } else { $tlsDesc = 'Self-signed (auto-generated)' }
if ($c.LogtoConsoleExposed -eq 'true') { if ($c.LogtoConsoleExposed -eq 'true') {
$logtoConsoleRow = "- **Logto Admin Console:** $($c.PublicProtocol)://$($c.PublicHost):$($c.LogtoConsolePort)" $logtoConsoleRow = "- **Logto Admin Console:** $($c.PublicProtocol)://$($c.AuthHost):$($c.LogtoConsolePort)"
$logtoPortRow = "| $($c.LogtoConsolePort) | Logto Admin Console |" $logtoPortRow = "| $($c.LogtoConsolePort) | Logto Admin Console |"
} else { } else {
$logtoConsoleRow = '' $logtoConsoleRow = ''
@@ -1256,7 +1376,7 @@ function Print-Credentials {
Write-Host '' Write-Host ''
if ($c.DeploymentMode -eq 'saas' -and $c.LogtoConsoleExposed -eq 'true') { if ($c.DeploymentMode -eq 'saas' -and $c.LogtoConsoleExposed -eq 'true') {
Write-Host ' Logto Console: ' -NoNewline Write-Host ' Logto Console: ' -NoNewline
Write-Host "$($c.PublicProtocol)://$($c.PublicHost):$($c.LogtoConsolePort)" -ForegroundColor Blue Write-Host "$($c.PublicProtocol)://$($c.AuthHost):$($c.LogtoConsolePort)" -ForegroundColor Blue
Write-Host '' Write-Host ''
} }
Write-Host " Credentials saved to: $($c.InstallDir)\credentials.txt" Write-Host " Credentials saved to: $($c.InstallDir)\credentials.txt"
@@ -1331,6 +1451,7 @@ function Handle-Rerun {
$script:cfg.InstallDir = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath( $script:cfg.InstallDir = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath(
$script:cfg.InstallDir) $script:cfg.InstallDir)
Copy-Templates Copy-Templates
Invoke-RegistryLogin
Invoke-ComposePull Invoke-ComposePull
Invoke-ComposeDown Invoke-ComposeDown
Invoke-ComposeUp Invoke-ComposeUp
@@ -1415,10 +1536,11 @@ function Main {
Copy-Templates Copy-Templates
Write-ConfigFile Write-ConfigFile
Invoke-RegistryLogin
Invoke-ComposePull Invoke-ComposePull
Invoke-ComposeUp Invoke-ComposeUp
Verify-Health Verify-Health
Generate-CredentialsFile Generate-CredentialsFile
Generate-InstallDoc Generate-InstallDoc

View File

@@ -3,7 +3,7 @@ set -euo pipefail
CAMELEER_INSTALLER_VERSION="1.0.0" CAMELEER_INSTALLER_VERSION="1.0.0"
CAMELEER_DEFAULT_VERSION="latest" CAMELEER_DEFAULT_VERSION="latest"
REGISTRY="gitea.siegeln.net/cameleer" DEFAULT_REGISTRY="gitea.siegeln.net/cameleer"
# --- Colors --- # --- Colors ---
RED='\033[0;31m' RED='\033[0;31m'
@@ -29,6 +29,7 @@ DEFAULT_DOCKER_SOCKET="/var/run/docker.sock"
# --- Config values (set by args/env/config/prompts) --- # --- Config values (set by args/env/config/prompts) ---
# Save environment values before initialization (CLI args override these) # Save environment values before initialization (CLI args override these)
_ENV_PUBLIC_HOST="${PUBLIC_HOST:-}" _ENV_PUBLIC_HOST="${PUBLIC_HOST:-}"
_ENV_AUTH_HOST="${AUTH_HOST:-}"
_ENV_PUBLIC_PROTOCOL="${PUBLIC_PROTOCOL:-}" _ENV_PUBLIC_PROTOCOL="${PUBLIC_PROTOCOL:-}"
_ENV_POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-}" _ENV_POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-}"
_ENV_CLICKHOUSE_PASSWORD="${CLICKHOUSE_PASSWORD:-}" _ENV_CLICKHOUSE_PASSWORD="${CLICKHOUSE_PASSWORD:-}"
@@ -45,9 +46,18 @@ _ENV_COMPOSE_PROJECT="${COMPOSE_PROJECT:-}"
_ENV_DOCKER_SOCKET="${DOCKER_SOCKET:-}" _ENV_DOCKER_SOCKET="${DOCKER_SOCKET:-}"
_ENV_NODE_TLS_REJECT="${NODE_TLS_REJECT:-}" _ENV_NODE_TLS_REJECT="${NODE_TLS_REJECT:-}"
_ENV_DEPLOYMENT_MODE="${DEPLOYMENT_MODE:-}" _ENV_DEPLOYMENT_MODE="${DEPLOYMENT_MODE:-}"
_ENV_SMTP_HOST="${SMTP_HOST:-}"
_ENV_SMTP_PORT="${SMTP_PORT:-}"
_ENV_SMTP_USER="${SMTP_USER:-}"
_ENV_SMTP_PASS="${SMTP_PASS:-}"
_ENV_SMTP_FROM_EMAIL="${SMTP_FROM_EMAIL:-}"
_ENV_REGISTRY="${REGISTRY:-}"
_ENV_REGISTRY_USER="${REGISTRY_USER:-}"
_ENV_REGISTRY_TOKEN="${REGISTRY_TOKEN:-}"
INSTALL_DIR="" INSTALL_DIR=""
PUBLIC_HOST="" PUBLIC_HOST=""
AUTH_HOST=""
PUBLIC_PROTOCOL="" PUBLIC_PROTOCOL=""
ADMIN_USER="" ADMIN_USER=""
ADMIN_PASS="" ADMIN_PASS=""
@@ -67,6 +77,14 @@ COMPOSE_PROJECT=""
DOCKER_SOCKET="" DOCKER_SOCKET=""
NODE_TLS_REJECT="" NODE_TLS_REJECT=""
DEPLOYMENT_MODE="" DEPLOYMENT_MODE=""
SMTP_HOST=""
SMTP_PORT=""
SMTP_USER=""
SMTP_PASS=""
SMTP_FROM_EMAIL=""
REGISTRY=""
REGISTRY_USER=""
REGISTRY_TOKEN=""
# --- State --- # --- State ---
MODE="" # simple, expert, silent MODE="" # simple, expert, silent
@@ -135,7 +153,7 @@ prompt_yesno() {
} }
generate_password() { generate_password() {
tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 32 tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 32 || :
} }
# --- Argument parsing --- # --- Argument parsing ---
@@ -148,6 +166,7 @@ parse_args() {
--config) CONFIG_FILE_PATH="$2"; shift ;; --config) CONFIG_FILE_PATH="$2"; shift ;;
--install-dir) INSTALL_DIR="$2"; shift ;; --install-dir) INSTALL_DIR="$2"; shift ;;
--public-host) PUBLIC_HOST="$2"; shift ;; --public-host) PUBLIC_HOST="$2"; shift ;;
--auth-host) AUTH_HOST="$2"; shift ;;
--public-protocol) PUBLIC_PROTOCOL="$2"; shift ;; --public-protocol) PUBLIC_PROTOCOL="$2"; shift ;;
--admin-user) ADMIN_USER="$2"; shift ;; --admin-user) ADMIN_USER="$2"; shift ;;
--admin-password) ADMIN_PASS="$2"; shift ;; --admin-password) ADMIN_PASS="$2"; shift ;;
@@ -167,6 +186,14 @@ parse_args() {
--docker-socket) DOCKER_SOCKET="$2"; shift ;; --docker-socket) DOCKER_SOCKET="$2"; shift ;;
--node-tls-reject) NODE_TLS_REJECT="$2"; shift ;; --node-tls-reject) NODE_TLS_REJECT="$2"; shift ;;
--deployment-mode) DEPLOYMENT_MODE="$2"; shift ;; --deployment-mode) DEPLOYMENT_MODE="$2"; shift ;;
--smtp-host) SMTP_HOST="$2"; shift ;;
--smtp-port) SMTP_PORT="$2"; shift ;;
--smtp-user) SMTP_USER="$2"; shift ;;
--smtp-pass) SMTP_PASS="$2"; shift ;;
--smtp-from-email) SMTP_FROM_EMAIL="$2"; shift ;;
--registry) REGISTRY="$2"; shift ;;
--registry-user) REGISTRY_USER="$2"; shift ;;
--registry-token) REGISTRY_TOKEN="$2"; shift ;;
--server-admin-user) ADMIN_USER="$2"; shift ;; --server-admin-user) ADMIN_USER="$2"; shift ;;
--server-admin-password) ADMIN_PASS="$2"; shift ;; --server-admin-password) ADMIN_PASS="$2"; shift ;;
--reconfigure) RERUN_ACTION="reconfigure" ;; --reconfigure) RERUN_ACTION="reconfigure" ;;
@@ -194,6 +221,7 @@ show_help() {
echo "Options:" echo "Options:"
echo " --install-dir DIR Install directory (default: ./cameleer)" echo " --install-dir DIR Install directory (default: ./cameleer)"
echo " --public-host HOST Public hostname (default: auto-detect)" echo " --public-host HOST Public hostname (default: auto-detect)"
echo " --auth-host HOST Auth domain for Logto (default: same as public-host)"
echo " --admin-user USER Admin username (default: admin)" echo " --admin-user USER Admin username (default: admin)"
echo " --admin-password PASS Admin password (default: generated)" echo " --admin-password PASS Admin password (default: generated)"
echo " --tls-mode MODE self-signed or custom (default: self-signed)" echo " --tls-mode MODE self-signed or custom (default: self-signed)"
@@ -205,6 +233,11 @@ show_help() {
echo " --config FILE Load config from file" echo " --config FILE Load config from file"
echo " --help Show this help" echo " --help Show this help"
echo "" echo ""
echo "Registry options:"
echo " --registry REGISTRY Image registry (default: gitea.siegeln.net/cameleer)"
echo " --registry-user USER Registry username for docker login"
echo " --registry-token TOKEN Registry token/password for docker login"
echo ""
echo "Expert options:" echo "Expert options:"
echo " --postgres-password, --clickhouse-password, --http-port," echo " --postgres-password, --clickhouse-password, --http-port,"
echo " --https-port, --logto-console-port, --logto-console-exposed," echo " --https-port, --logto-console-port, --logto-console-exposed,"
@@ -231,6 +264,7 @@ load_config_file() {
case "$key" in case "$key" in
install_dir) [ -z "$INSTALL_DIR" ] && INSTALL_DIR="$value" ;; install_dir) [ -z "$INSTALL_DIR" ] && INSTALL_DIR="$value" ;;
public_host) [ -z "$PUBLIC_HOST" ] && PUBLIC_HOST="$value" ;; public_host) [ -z "$PUBLIC_HOST" ] && PUBLIC_HOST="$value" ;;
auth_host) [ -z "$AUTH_HOST" ] && AUTH_HOST="$value" ;;
public_protocol) [ -z "$PUBLIC_PROTOCOL" ] && PUBLIC_PROTOCOL="$value" ;; public_protocol) [ -z "$PUBLIC_PROTOCOL" ] && PUBLIC_PROTOCOL="$value" ;;
admin_user) [ -z "$ADMIN_USER" ] && ADMIN_USER="$value" ;; admin_user) [ -z "$ADMIN_USER" ] && ADMIN_USER="$value" ;;
admin_password) [ -z "$ADMIN_PASS" ] && ADMIN_PASS="$value" ;; admin_password) [ -z "$ADMIN_PASS" ] && ADMIN_PASS="$value" ;;
@@ -250,6 +284,14 @@ load_config_file() {
docker_socket) [ -z "$DOCKER_SOCKET" ] && DOCKER_SOCKET="$value" ;; docker_socket) [ -z "$DOCKER_SOCKET" ] && DOCKER_SOCKET="$value" ;;
node_tls_reject) [ -z "$NODE_TLS_REJECT" ] && NODE_TLS_REJECT="$value" ;; node_tls_reject) [ -z "$NODE_TLS_REJECT" ] && NODE_TLS_REJECT="$value" ;;
deployment_mode) [ -z "$DEPLOYMENT_MODE" ] && DEPLOYMENT_MODE="$value" ;; deployment_mode) [ -z "$DEPLOYMENT_MODE" ] && DEPLOYMENT_MODE="$value" ;;
smtp_host) [ -z "$SMTP_HOST" ] && SMTP_HOST="$value" ;;
smtp_port) [ -z "$SMTP_PORT" ] && SMTP_PORT="$value" ;;
smtp_user) [ -z "$SMTP_USER" ] && SMTP_USER="$value" ;;
smtp_pass) [ -z "$SMTP_PASS" ] && SMTP_PASS="$value" ;;
smtp_from_email) [ -z "$SMTP_FROM_EMAIL" ] && SMTP_FROM_EMAIL="$value" ;;
registry) [ -z "$REGISTRY" ] && REGISTRY="$value" ;;
registry_user) [ -z "$REGISTRY_USER" ] && REGISTRY_USER="$value" ;;
registry_token) [ -z "$REGISTRY_TOKEN" ] && REGISTRY_TOKEN="$value" ;;
esac esac
done < "$file" done < "$file"
} }
@@ -257,6 +299,7 @@ load_config_file() {
load_env_overrides() { load_env_overrides() {
[ -z "$INSTALL_DIR" ] && INSTALL_DIR="${CAMELEER_INSTALL_DIR:-}" [ -z "$INSTALL_DIR" ] && INSTALL_DIR="${CAMELEER_INSTALL_DIR:-}"
[ -z "$PUBLIC_HOST" ] && PUBLIC_HOST="$_ENV_PUBLIC_HOST" [ -z "$PUBLIC_HOST" ] && PUBLIC_HOST="$_ENV_PUBLIC_HOST"
[ -z "$AUTH_HOST" ] && AUTH_HOST="$_ENV_AUTH_HOST"
[ -z "$PUBLIC_PROTOCOL" ] && PUBLIC_PROTOCOL="$_ENV_PUBLIC_PROTOCOL" [ -z "$PUBLIC_PROTOCOL" ] && PUBLIC_PROTOCOL="$_ENV_PUBLIC_PROTOCOL"
[ -z "$ADMIN_USER" ] && ADMIN_USER="${SAAS_ADMIN_USER:-}" [ -z "$ADMIN_USER" ] && ADMIN_USER="${SAAS_ADMIN_USER:-}"
[ -z "$ADMIN_PASS" ] && ADMIN_PASS="${SAAS_ADMIN_PASS:-}" [ -z "$ADMIN_PASS" ] && ADMIN_PASS="${SAAS_ADMIN_PASS:-}"
@@ -276,6 +319,14 @@ load_env_overrides() {
[ -z "$DOCKER_SOCKET" ] && DOCKER_SOCKET="$_ENV_DOCKER_SOCKET" [ -z "$DOCKER_SOCKET" ] && DOCKER_SOCKET="$_ENV_DOCKER_SOCKET"
[ -z "$NODE_TLS_REJECT" ] && NODE_TLS_REJECT="$_ENV_NODE_TLS_REJECT" [ -z "$NODE_TLS_REJECT" ] && NODE_TLS_REJECT="$_ENV_NODE_TLS_REJECT"
[ -z "$DEPLOYMENT_MODE" ] && DEPLOYMENT_MODE="$_ENV_DEPLOYMENT_MODE" [ -z "$DEPLOYMENT_MODE" ] && DEPLOYMENT_MODE="$_ENV_DEPLOYMENT_MODE"
[ -z "$SMTP_HOST" ] && SMTP_HOST="$_ENV_SMTP_HOST"
[ -z "$SMTP_PORT" ] && SMTP_PORT="$_ENV_SMTP_PORT"
[ -z "$SMTP_USER" ] && SMTP_USER="$_ENV_SMTP_USER"
[ -z "$SMTP_PASS" ] && SMTP_PASS="$_ENV_SMTP_PASS"
[ -z "$SMTP_FROM_EMAIL" ] && SMTP_FROM_EMAIL="$_ENV_SMTP_FROM_EMAIL"
[ -z "$REGISTRY" ] && REGISTRY="$_ENV_REGISTRY"
[ -z "$REGISTRY_USER" ] && REGISTRY_USER="$_ENV_REGISTRY_USER"
[ -z "$REGISTRY_TOKEN" ] && REGISTRY_TOKEN="$_ENV_REGISTRY_TOKEN"
} }
# --- Prerequisites --- # --- Prerequisites ---
@@ -413,6 +464,13 @@ run_simple_prompts() {
echo "" echo ""
prompt MONITORING_NETWORK "Monitoring network name (empty = skip)" "" prompt MONITORING_NETWORK "Monitoring network name (empty = skip)" ""
echo ""
if prompt_yesno "Pull images from a private registry?"; then
prompt REGISTRY "Registry" "${REGISTRY:-$DEFAULT_REGISTRY}"
prompt REGISTRY_USER "Registry username" "${REGISTRY_USER:-}"
prompt_password REGISTRY_TOKEN "Registry token/password" "${REGISTRY_TOKEN:-}"
fi
echo "" echo ""
echo " Deployment mode:" echo " Deployment mode:"
echo " [1] Multi-tenant SaaS — manage platform, provision tenants on demand" echo " [1] Multi-tenant SaaS — manage platform, provision tenants on demand"
@@ -428,6 +486,18 @@ run_simple_prompts() {
DEPLOYMENT_MODE="saas" DEPLOYMENT_MODE="saas"
;; ;;
esac esac
# SMTP for email verification (SaaS mode only)
if [ "$DEPLOYMENT_MODE" = "saas" ]; then
echo ""
if prompt_yesno "Configure SMTP for email verification? (required for self-service sign-up)"; then
prompt SMTP_HOST "SMTP host" "${SMTP_HOST:-}"
prompt SMTP_PORT "SMTP port" "${SMTP_PORT:-587}"
prompt SMTP_USER "SMTP username" "${SMTP_USER:-}"
prompt_password SMTP_PASS "SMTP password" "${SMTP_PASS:-}"
prompt SMTP_FROM_EMAIL "From email address" "${SMTP_FROM_EMAIL:-noreply@${PUBLIC_HOST}}"
fi
fi
} }
run_expert_prompts() { run_expert_prompts() {
@@ -463,6 +533,7 @@ run_expert_prompts() {
if [ "$DEPLOYMENT_MODE" = "saas" ]; then if [ "$DEPLOYMENT_MODE" = "saas" ]; then
echo "" echo ""
echo -e "${BOLD} Logto:${NC}" echo -e "${BOLD} Logto:${NC}"
prompt AUTH_HOST "Auth domain (Logto) — same as hostname for single-domain" "${AUTH_HOST:-$PUBLIC_HOST}"
if prompt_yesno "Expose Logto admin console externally?" "y"; then if prompt_yesno "Expose Logto admin console externally?" "y"; then
LOGTO_CONSOLE_EXPOSED="true" LOGTO_CONSOLE_EXPOSED="true"
else else
@@ -486,6 +557,7 @@ merge_config() {
: "${LOGTO_CONSOLE_EXPOSED:=$DEFAULT_LOGTO_CONSOLE_EXPOSED}" : "${LOGTO_CONSOLE_EXPOSED:=$DEFAULT_LOGTO_CONSOLE_EXPOSED}"
: "${VERSION:=$CAMELEER_DEFAULT_VERSION}" : "${VERSION:=$CAMELEER_DEFAULT_VERSION}"
: "${DOCKER_SOCKET:=$DEFAULT_DOCKER_SOCKET}" : "${DOCKER_SOCKET:=$DEFAULT_DOCKER_SOCKET}"
: "${REGISTRY:=$DEFAULT_REGISTRY}"
if [ "$DEPLOYMENT_MODE" = "standalone" ]; then if [ "$DEPLOYMENT_MODE" = "standalone" ]; then
: "${COMPOSE_PROJECT:=$DEFAULT_COMPOSE_PROJECT_STANDALONE}" : "${COMPOSE_PROJECT:=$DEFAULT_COMPOSE_PROJECT_STANDALONE}"
@@ -493,8 +565,12 @@ merge_config() {
: "${COMPOSE_PROJECT:=$DEFAULT_COMPOSE_PROJECT}" : "${COMPOSE_PROJECT:=$DEFAULT_COMPOSE_PROJECT}"
fi fi
# Force lowercase hostname — Logto normalizes internally, case mismatch breaks JWT validation # Default AUTH_HOST to PUBLIC_HOST (single-domain setup)
: "${AUTH_HOST:=$PUBLIC_HOST}"
# Force lowercase hostnames — Logto normalizes internally, case mismatch breaks JWT validation
PUBLIC_HOST=$(echo "$PUBLIC_HOST" | tr '[:upper:]' '[:lower:]') PUBLIC_HOST=$(echo "$PUBLIC_HOST" | tr '[:upper:]' '[:lower:]')
AUTH_HOST=$(echo "$AUTH_HOST" | tr '[:upper:]' '[:lower:]')
if [ "$DEPLOYMENT_MODE" != "standalone" ]; then if [ "$DEPLOYMENT_MODE" != "standalone" ]; then
if [ -z "$NODE_TLS_REJECT" ]; then if [ -z "$NODE_TLS_REJECT" ]; then
@@ -609,6 +685,12 @@ DOCKER_GID=$(stat -c '%g' "${DOCKER_SOCKET}" 2>/dev/null || echo "0")
POSTGRES_IMAGE=postgres:16-alpine POSTGRES_IMAGE=postgres:16-alpine
# Registry
TRAEFIK_IMAGE=${REGISTRY}/cameleer-traefik
CLICKHOUSE_IMAGE=${REGISTRY}/cameleer-clickhouse
SERVER_IMAGE=${REGISTRY}/cameleer-server
SERVER_UI_IMAGE=${REGISTRY}/cameleer-server-ui
# Compose file assembly # Compose file assembly
COMPOSE_FILE=docker-compose.yml:docker-compose.server.yml$([ "$TLS_MODE" = "custom" ] && echo ":docker-compose.tls.yml")$([ -n "$MONITORING_NETWORK" ] && echo ":docker-compose.monitoring.yml") COMPOSE_FILE=docker-compose.yml:docker-compose.server.yml$([ "$TLS_MODE" = "custom" ] && echo ":docker-compose.tls.yml")$([ -n "$MONITORING_NETWORK" ] && echo ":docker-compose.monitoring.yml")
EOF EOF
@@ -636,6 +718,7 @@ VERSION=${VERSION}
# Public access # Public access
PUBLIC_HOST=${PUBLIC_HOST} PUBLIC_HOST=${PUBLIC_HOST}
AUTH_HOST=${AUTH_HOST}
PUBLIC_PROTOCOL=${PUBLIC_PROTOCOL} PUBLIC_PROTOCOL=${PUBLIC_PROTOCOL}
# Ports # Ports
@@ -676,6 +759,13 @@ EOF
DOCKER_SOCKET=${DOCKER_SOCKET} DOCKER_SOCKET=${DOCKER_SOCKET}
DOCKER_GID=$(stat -c '%g' "${DOCKER_SOCKET}" 2>/dev/null || echo "0") DOCKER_GID=$(stat -c '%g' "${DOCKER_SOCKET}" 2>/dev/null || echo "0")
# Registry
TRAEFIK_IMAGE=${REGISTRY}/cameleer-traefik
POSTGRES_IMAGE=${REGISTRY}/cameleer-postgres
CLICKHOUSE_IMAGE=${REGISTRY}/cameleer-clickhouse
LOGTO_IMAGE=${REGISTRY}/cameleer-logto
CAMELEER_IMAGE=${REGISTRY}/cameleer-saas
# Provisioning images # Provisioning images
CAMELEER_SAAS_PROVISIONING_SERVERIMAGE=${REGISTRY}/cameleer-server:${VERSION} CAMELEER_SAAS_PROVISIONING_SERVERIMAGE=${REGISTRY}/cameleer-server:${VERSION}
CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE=${REGISTRY}/cameleer-server-ui:${VERSION} CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE=${REGISTRY}/cameleer-server-ui:${VERSION}
@@ -684,6 +774,13 @@ CAMELEER_SAAS_PROVISIONING_RUNTIMEBASEIMAGE=${REGISTRY}/cameleer-runtime-base:${
# JWT signing secret (forwarded to provisioned tenant servers, must be non-empty) # JWT signing secret (forwarded to provisioned tenant servers, must be non-empty)
CAMELEER_SERVER_SECURITY_JWTSECRET=$(generate_password) CAMELEER_SERVER_SECURITY_JWTSECRET=$(generate_password)
# SMTP (for email verification during registration)
SMTP_HOST=${SMTP_HOST}
SMTP_PORT=${SMTP_PORT:-587}
SMTP_USER=${SMTP_USER}
SMTP_PASS=${SMTP_PASS}
SMTP_FROM_EMAIL=${SMTP_FROM_EMAIL:-noreply@${PUBLIC_HOST}}
# Compose file assembly # Compose file assembly
COMPOSE_FILE=docker-compose.yml:docker-compose.saas.yml$([ "$TLS_MODE" = "custom" ] && echo ":docker-compose.tls.yml")$([ -n "$MONITORING_NETWORK" ] && echo ":docker-compose.monitoring.yml") COMPOSE_FILE=docker-compose.yml:docker-compose.saas.yml$([ "$TLS_MODE" = "custom" ] && echo ":docker-compose.tls.yml")$([ -n "$MONITORING_NETWORK" ] && echo ":docker-compose.monitoring.yml")
EOF EOF
@@ -727,6 +824,16 @@ copy_templates() {
# --- Docker operations --- # --- Docker operations ---
docker_registry_login() {
if [ -n "$REGISTRY_USER" ] && [ -n "$REGISTRY_TOKEN" ]; then
local registry_host
registry_host=$(echo "$REGISTRY" | cut -d/ -f1)
log_info "Logging in to registry ${registry_host}..."
echo "$REGISTRY_TOKEN" | docker login "$registry_host" -u "$REGISTRY_USER" --password-stdin
log_success "Registry login successful."
fi
}
docker_compose_pull() { docker_compose_pull() {
log_info "Pulling Docker images..." log_info "Pulling Docker images..."
(cd "$INSTALL_DIR" && docker compose -p "$COMPOSE_PROJECT" pull) (cd "$INSTALL_DIR" && docker compose -p "$COMPOSE_PROJECT" pull)
@@ -778,8 +885,10 @@ wait_for_docker_healthy() {
} }
check_endpoint() { check_endpoint() {
local name="$1" url="$2" timeout_secs="${3:-120}" local name="$1" url="$2" timeout_secs="${3:-120}" resolve="${4:-}"
local start_time=$(date +%s) local start_time=$(date +%s)
local extra_flags=""
[ -n "$resolve" ] && extra_flags="--resolve $resolve"
while true; do while true; do
local elapsed=$(( $(date +%s) - start_time )) local elapsed=$(( $(date +%s) - start_time ))
@@ -787,7 +896,7 @@ check_endpoint() {
printf " ${RED}[FAIL]${NC} %-20s not reachable after %ds\n" "$name" "$timeout_secs" printf " ${RED}[FAIL]${NC} %-20s not reachable after %ds\n" "$name" "$timeout_secs"
return 1 return 1
fi fi
if curl -sfk -o /dev/null "$url" 2>/dev/null; then if curl -sfk $extra_flags -o /dev/null "$url" 2>/dev/null; then
local duration=$(( $(date +%s) - start_time )) local duration=$(( $(date +%s) - start_time ))
printf " ${GREEN}[ok]${NC} %-20s ready (%ds)\n" "$name" "$duration" printf " ${GREEN}[ok]${NC} %-20s ready (%ds)\n" "$name" "$duration"
return 0 return 0
@@ -820,7 +929,7 @@ verify_health() {
check_endpoint "Cameleer SaaS" "https://localhost:${HTTPS_PORT}/platform/api/config" 120 || failed=1 check_endpoint "Cameleer SaaS" "https://localhost:${HTTPS_PORT}/platform/api/config" 120 || failed=1
[ $failed -eq 0 ] && \ [ $failed -eq 0 ] && \
check_endpoint "Traefik routing" "https://localhost:${HTTPS_PORT}/" 30 || failed=1 check_endpoint "Traefik routing" "https://${PUBLIC_HOST}:${HTTPS_PORT}/" 30 "${PUBLIC_HOST}:${HTTPS_PORT}:127.0.0.1" || failed=1
fi fi
echo "" echo ""
@@ -841,6 +950,7 @@ write_config_file() {
install_dir=${INSTALL_DIR} install_dir=${INSTALL_DIR}
public_host=${PUBLIC_HOST} public_host=${PUBLIC_HOST}
auth_host=${AUTH_HOST}
public_protocol=${PUBLIC_PROTOCOL} public_protocol=${PUBLIC_PROTOCOL}
admin_user=${ADMIN_USER} admin_user=${ADMIN_USER}
tls_mode=${TLS_MODE} tls_mode=${TLS_MODE}
@@ -854,6 +964,14 @@ compose_project=${COMPOSE_PROJECT}
docker_socket=${DOCKER_SOCKET} docker_socket=${DOCKER_SOCKET}
node_tls_reject=${NODE_TLS_REJECT} node_tls_reject=${NODE_TLS_REJECT}
deployment_mode=${DEPLOYMENT_MODE} deployment_mode=${DEPLOYMENT_MODE}
smtp_host=${SMTP_HOST}
smtp_port=${SMTP_PORT}
smtp_user=${SMTP_USER}
smtp_pass=${SMTP_PASS}
smtp_from_email=${SMTP_FROM_EMAIL}
registry=${REGISTRY}
registry_user=${REGISTRY_USER}
registry_token=${REGISTRY_TOKEN}
EOF EOF
log_info "Saved installer config to cameleer.conf" log_info "Saved installer config to cameleer.conf"
} }
@@ -902,7 +1020,7 @@ ClickHouse: default / ${CLICKHOUSE_PASSWORD}
EOF EOF
if [ "$LOGTO_CONSOLE_EXPOSED" = "true" ]; then if [ "$LOGTO_CONSOLE_EXPOSED" = "true" ]; then
echo "Logto Console: ${PUBLIC_PROTOCOL}://${PUBLIC_HOST}:${LOGTO_CONSOLE_PORT}" >> "$f" echo "Logto Console: ${PUBLIC_PROTOCOL}://${AUTH_HOST}:${LOGTO_CONSOLE_PORT}" >> "$f"
else else
echo "Logto Console: (not exposed)" >> "$f" echo "Logto Console: (not exposed)" >> "$f"
fi fi
@@ -941,7 +1059,7 @@ generate_install_doc() {
EOF EOF
if [ "$LOGTO_CONSOLE_EXPOSED" = "true" ]; then if [ "$LOGTO_CONSOLE_EXPOSED" = "true" ]; then
echo "- **Logto Admin Console:** ${PUBLIC_PROTOCOL}://${PUBLIC_HOST}:${LOGTO_CONSOLE_PORT}" >> "$f" echo "- **Logto Admin Console:** ${PUBLIC_PROTOCOL}://${AUTH_HOST}:${LOGTO_CONSOLE_PORT}" >> "$f"
fi fi
cat >> "$f" << 'EOF' cat >> "$f" << 'EOF'
@@ -1219,7 +1337,7 @@ print_credentials() {
if [ "$DEPLOYMENT_MODE" = "saas" ]; then if [ "$DEPLOYMENT_MODE" = "saas" ]; then
if [ "$LOGTO_CONSOLE_EXPOSED" = "true" ]; then if [ "$LOGTO_CONSOLE_EXPOSED" = "true" ]; then
echo -e " Logto Console: ${BLUE}${PUBLIC_PROTOCOL}://${PUBLIC_HOST}:${LOGTO_CONSOLE_PORT}${NC}" echo -e " Logto Console: ${BLUE}${PUBLIC_PROTOCOL}://${AUTH_HOST}:${LOGTO_CONSOLE_PORT}${NC}"
echo "" echo ""
fi fi
fi fi
@@ -1288,10 +1406,12 @@ handle_rerun() {
case "$RERUN_ACTION" in case "$RERUN_ACTION" in
upgrade) upgrade)
log_info "Upgrading installation..." log_info "Upgrading installation..."
load_config_file "$INSTALL_DIR/cameleer.conf" # Config already loaded by detect_existing_install + load_env_overrides in main.
load_env_overrides # Calling load_config_file again with set -e causes silent exit because
# [ -z "$VAR" ] && VAR="$value" returns 1 for every already-set variable.
merge_config merge_config
copy_templates copy_templates
docker_registry_login
docker_compose_pull docker_compose_pull
docker_compose_down docker_compose_down
docker_compose_up docker_compose_up
@@ -1386,6 +1506,7 @@ main() {
write_config_file write_config_file
# Pull and start # Pull and start
docker_registry_login
docker_compose_pull docker_compose_pull
docker_compose_up docker_compose_up

View File

@@ -21,6 +21,9 @@ VERSION=latest
# ============================================================ # ============================================================
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
@@ -57,6 +60,16 @@ SAAS_ADMIN_PASS=CHANGE_ME
# SERVER_ADMIN_PASS=CHANGE_ME # SERVER_ADMIN_PASS=CHANGE_ME
# BOOTSTRAP_TOKEN=CHANGE_ME # BOOTSTRAP_TOKEN=CHANGE_ME
# ============================================================
# SMTP (for email verification during registration)
# ============================================================
# Required for self-service sign-up. Without SMTP, only admin-created users can sign in.
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
SMTP_FROM_EMAIL=noreply@cameleer.io
# ============================================================ # ============================================================
# TLS # TLS
# ============================================================ # ============================================================

View File

@@ -10,14 +10,15 @@ services:
condition: service_healthy condition: service_healthy
environment: environment:
DB_URL: postgres://${POSTGRES_USER:-cameleer}:${POSTGRES_PASSWORD}@cameleer-postgres:5432/logto DB_URL: postgres://${POSTGRES_USER:-cameleer}:${POSTGRES_PASSWORD}@cameleer-postgres:5432/logto
ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost} ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${AUTH_HOST:-localhost}
ADMIN_ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}:${LOGTO_CONSOLE_PORT:-3002} ADMIN_ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${AUTH_HOST:-localhost}:${LOGTO_CONSOLE_PORT:-3002}
TRUST_PROXY_HEADER: 1 TRUST_PROXY_HEADER: 1
NODE_TLS_REJECT_UNAUTHORIZED: "${NODE_TLS_REJECT:-0}" NODE_TLS_REJECT_UNAUTHORIZED: "${NODE_TLS_REJECT:-0}"
LOGTO_ENDPOINT: http://cameleer-logto:3001 LOGTO_ENDPOINT: http://cameleer-logto:3001
LOGTO_ADMIN_ENDPOINT: http://cameleer-logto:3002 LOGTO_ADMIN_ENDPOINT: http://cameleer-logto:3002
LOGTO_PUBLIC_ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost} LOGTO_PUBLIC_ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${AUTH_HOST:-localhost}
PUBLIC_HOST: ${PUBLIC_HOST:-localhost} PUBLIC_HOST: ${PUBLIC_HOST:-localhost}
AUTH_HOST: ${AUTH_HOST:-localhost}
PUBLIC_PROTOCOL: ${PUBLIC_PROTOCOL:-https} PUBLIC_PROTOCOL: ${PUBLIC_PROTOCOL:-https}
PG_HOST: cameleer-postgres PG_HOST: cameleer-postgres
PG_USER: ${POSTGRES_USER:-cameleer} PG_USER: ${POSTGRES_USER:-cameleer}
@@ -25,6 +26,17 @@ services:
PG_DB_SAAS: cameleer_saas PG_DB_SAAS: cameleer_saas
SAAS_ADMIN_USER: ${SAAS_ADMIN_USER:-admin} SAAS_ADMIN_USER: ${SAAS_ADMIN_USER:-admin}
SAAS_ADMIN_PASS: ${SAAS_ADMIN_PASS:?SAAS_ADMIN_PASS must be set in .env} SAAS_ADMIN_PASS: ${SAAS_ADMIN_PASS:?SAAS_ADMIN_PASS must be set in .env}
# SMTP (for email verification during registration)
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_USER: ${SMTP_USER:-}
SMTP_PASS: ${SMTP_PASS:-}
SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL:-noreply@cameleer.io}
extra_hosts:
# Logto validates M2M tokens by fetching its own JWKS from ENDPOINT.
# Route the public hostname back to the Docker host (Traefik on :443)
# so the container can reach itself without going through the tunnel.
- "${AUTH_HOST:-localhost}:host-gateway"
healthcheck: 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"] 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 interval: 10s
@@ -33,13 +45,13 @@ services:
start_period: 30s start_period: 30s
labels: labels:
- traefik.enable=true - traefik.enable=true
- traefik.http.routers.cameleer-logto.rule=PathPrefix(`/`) - "traefik.http.routers.cameleer-logto.rule=Host(`${AUTH_HOST:-localhost}`)"
- traefik.http.routers.cameleer-logto.priority=1 - traefik.http.routers.cameleer-logto.priority=1
- traefik.http.routers.cameleer-logto.entrypoints=websecure - traefik.http.routers.cameleer-logto.entrypoints=websecure
- traefik.http.routers.cameleer-logto.tls=true - traefik.http.routers.cameleer-logto.tls=true
- traefik.http.routers.cameleer-logto.service=cameleer-logto - traefik.http.routers.cameleer-logto.service=cameleer-logto
- traefik.http.routers.cameleer-logto.middlewares=cameleer-logto-cors - 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.accessControlAllowOriginList=${PUBLIC_PROTOCOL:-https}://${AUTH_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.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.accessControlAllowHeaders=Authorization,Content-Type
- traefik.http.middlewares.cameleer-logto-cors.headers.accessControlAllowCredentials=true - traefik.http.middlewares.cameleer-logto-cors.headers.accessControlAllowCredentials=true
@@ -68,7 +80,8 @@ services:
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD} SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
# Identity (Logto) # Identity (Logto)
CAMELEER_SAAS_IDENTITY_LOGTOENDPOINT: http://cameleer-logto:3001 CAMELEER_SAAS_IDENTITY_LOGTOENDPOINT: http://cameleer-logto:3001
CAMELEER_SAAS_IDENTITY_LOGTOPUBLICENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost} CAMELEER_SAAS_IDENTITY_LOGTOPUBLICENDPOINT: ${PUBLIC_PROTOCOL:-https}://${AUTH_HOST:-localhost}
CAMELEER_SAAS_IDENTITY_AUTHHOST: ${AUTH_HOST:-localhost}
# Provisioning — passed to per-tenant server containers # Provisioning — passed to per-tenant server containers
CAMELEER_SAAS_PROVISIONING_PUBLICHOST: ${PUBLIC_HOST:-localhost} CAMELEER_SAAS_PROVISIONING_PUBLICHOST: ${PUBLIC_HOST:-localhost}
CAMELEER_SAAS_PROVISIONING_PUBLICPROTOCOL: ${PUBLIC_PROTOCOL:-https} CAMELEER_SAAS_PROVISIONING_PUBLICPROTOCOL: ${PUBLIC_PROTOCOL:-https}
@@ -87,6 +100,16 @@ services:
- traefik.http.routers.saas.entrypoints=websecure - traefik.http.routers.saas.entrypoints=websecure
- traefik.http.routers.saas.tls=true - traefik.http.routers.saas.tls=true
- traefik.http.services.saas.loadbalancer.server.port=8080 - traefik.http.services.saas.loadbalancer.server.port=8080
# Root redirect: / → /platform/ (scoped to app host so it doesn't catch auth domain)
- "traefik.http.routers.saas-root.rule=Host(`${PUBLIC_HOST:-localhost}`) && Path(`/`)"
- traefik.http.routers.saas-root.priority=100
- traefik.http.routers.saas-root.entrypoints=websecure
- traefik.http.routers.saas-root.tls=true
- traefik.http.routers.saas-root.middlewares=root-to-platform
- traefik.http.routers.saas-root.service=saas
- "traefik.http.middlewares.root-to-platform.redirectRegex.regex=^(https?://[^/]+)/?$$"
- "traefik.http.middlewares.root-to-platform.redirectRegex.replacement=$${1}/platform/"
- traefik.http.middlewares.root-to-platform.redirectRegex.permanent=false
- "prometheus.io/scrape=true" - "prometheus.io/scrape=true"
- "prometheus.io/port=8080" - "prometheus.io/port=8080"
- "prometheus.io/path=/platform/actuator/prometheus" - "prometheus.io/path=/platform/actuator/prometheus"

View File

@@ -11,6 +11,7 @@ services:
- "${LOGTO_CONSOLE_BIND:-127.0.0.1}:${LOGTO_CONSOLE_PORT:-3002}:3002" - "${LOGTO_CONSOLE_BIND:-127.0.0.1}:${LOGTO_CONSOLE_PORT:-3002}:3002"
environment: environment:
PUBLIC_HOST: ${PUBLIC_HOST:-localhost} PUBLIC_HOST: ${PUBLIC_HOST:-localhost}
AUTH_HOST: ${AUTH_HOST:-localhost}
CERT_FILE: ${CERT_FILE:-} CERT_FILE: ${CERT_FILE:-}
KEY_FILE: ${KEY_FILE:-} KEY_FILE: ${KEY_FILE:-}
CA_FILE: ${CA_FILE:-} CA_FILE: ${CA_FILE:-}

View File

@@ -18,10 +18,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)

View File

@@ -43,10 +43,11 @@ public class SecurityConfig {
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health").permitAll() .requestMatchers("/actuator/health").permitAll()
.requestMatchers("/api/config").permitAll() .requestMatchers("/api/config").permitAll()
.requestMatchers("/", "/index.html", "/login", "/callback", .requestMatchers("/", "/index.html", "/login", "/register", "/callback",
"/vendor/**", "/tenant/**", "/vendor/**", "/tenant/**", "/onboarding",
"/environments/**", "/license", "/admin/**").permitAll() "/environments/**", "/license", "/admin/**").permitAll()
.requestMatchers("/_app/**", "/favicon.ico", "/favicon.svg", "/logo.svg", "/logo-dark.svg").permitAll() .requestMatchers("/_app/**", "/favicon.ico", "/favicon.svg", "/logo.svg", "/logo-dark.svg").permitAll()
.requestMatchers("/api/onboarding/**").authenticated()
.requestMatchers("/api/vendor/**").hasAuthority("SCOPE_platform:admin") .requestMatchers("/api/vendor/**").hasAuthority("SCOPE_platform:admin")
.requestMatchers("/api/tenant/**").authenticated() .requestMatchers("/api/tenant/**").authenticated()
.anyRequest().authenticated() .anyRequest().authenticated()

View File

@@ -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() {

View File

@@ -0,0 +1,47 @@
package net.siegeln.cameleer.saas.onboarding;
import jakarta.validation.Valid;
import net.siegeln.cameleer.saas.tenant.TenantEntity;
import net.siegeln.cameleer.saas.tenant.dto.TenantResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/onboarding")
public class OnboardingController {
private final OnboardingService onboardingService;
public OnboardingController(OnboardingService onboardingService) {
this.onboardingService = onboardingService;
}
public record CreateTrialTenantRequest(
@jakarta.validation.constraints.NotBlank
@jakarta.validation.constraints.Size(max = 255)
String name,
@jakarta.validation.constraints.NotBlank
@jakarta.validation.constraints.Size(max = 100)
@jakarta.validation.constraints.Pattern(
regexp = "^[a-z0-9][a-z0-9-]*[a-z0-9]$",
message = "Slug must be lowercase alphanumeric with hyphens")
String slug
) {}
@PostMapping("/tenant")
public ResponseEntity<TenantResponse> createTrialTenant(
@Valid @RequestBody CreateTrialTenantRequest request,
@AuthenticationPrincipal Jwt jwt) {
String userId = jwt.getSubject();
TenantEntity tenant = onboardingService.createTrialTenant(request.name(), request.slug(), userId);
return ResponseEntity.status(HttpStatus.CREATED).body(TenantResponse.from(tenant));
}
}

View File

@@ -0,0 +1,70 @@
package net.siegeln.cameleer.saas.onboarding;
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
import net.siegeln.cameleer.saas.tenant.TenantEntity;
import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest;
import net.siegeln.cameleer.saas.vendor.VendorTenantService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.util.UUID;
/**
* Self-service onboarding: lets a newly registered user create their own trial tenant.
* Reuses VendorTenantService for the heavy lifting (Logto org, license, Docker provisioning)
* but adds the calling user as the tenant owner instead of creating a new admin user.
*/
@Service
public class OnboardingService {
private static final Logger log = LoggerFactory.getLogger(OnboardingService.class);
private final VendorTenantService vendorTenantService;
private final LogtoManagementClient logtoClient;
public OnboardingService(VendorTenantService vendorTenantService,
LogtoManagementClient logtoClient) {
this.vendorTenantService = vendorTenantService;
this.logtoClient = logtoClient;
}
public TenantEntity createTrialTenant(String name, String slug, String logtoUserId) {
// Guard: check if user already has a tenant (prevent abuse)
if (logtoClient.isAvailable()) {
var orgs = logtoClient.getUserOrganizations(logtoUserId);
if (!orgs.isEmpty()) {
throw new IllegalStateException("You already have a tenant. Only one trial tenant per account.");
}
}
// Create tenant via the existing vendor flow (no admin user — we'll add the caller)
UUID actorId = resolveActorId(logtoUserId);
var request = new CreateTenantRequest(name, slug, "LOW", null, null);
TenantEntity tenant = vendorTenantService.createAndProvision(request, actorId);
// Add the calling user to the Logto org as owner
if (tenant.getLogtoOrgId() != null && logtoClient.isAvailable()) {
try {
String ownerRoleId = logtoClient.findOrgRoleIdByName("owner");
logtoClient.addUserToOrganization(tenant.getLogtoOrgId(), logtoUserId);
if (ownerRoleId != null) {
logtoClient.assignOrganizationRole(tenant.getLogtoOrgId(), logtoUserId, ownerRoleId);
}
log.info("Added user {} as owner of tenant {}", logtoUserId, slug);
} catch (Exception e) {
log.warn("Failed to add user {} to org for tenant {}: {}", logtoUserId, slug, e.getMessage());
}
}
return tenant;
}
private UUID resolveActorId(String subject) {
try {
return UUID.fromString(subject);
} catch (IllegalArgumentException e) {
return UUID.nameUUIDFromBytes(subject.getBytes());
}
}
}

View File

@@ -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:}
@@ -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:

View File

@@ -5,7 +5,7 @@ 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, Infrastructure, Identity/Logto), 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,9 +16,12 @@ 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
- **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` - **Vendor pages**: `VendorTenantsPage.tsx`, `CreateTenantPage.tsx`, `TenantDetailPage.tsx`, `VendorAuditPage.tsx`, `CertificatesPage.tsx`, `InfrastructurePage.tsx`
- **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)
@@ -26,5 +29,5 @@ React 19 SPA served at `/platform/*` by the Spring Boot backend.
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.
- `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.

View File

@@ -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 (ensures SMTP email is available for self-hosted)
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/

View File

@@ -12,7 +12,7 @@
padding: 32px; padding: 32px;
} }
.loginForm { .formContainer {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -53,6 +53,53 @@
width: 100%; width: 100%;
} }
.passwordWrapper {
position: relative;
}
.passwordToggle {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
cursor: pointer;
color: var(--text-muted);
padding: 4px;
display: flex;
align-items: center;
}
.submitButton { .submitButton {
width: 100%; width: 100%;
} }
.switchText {
text-align: center;
font-size: 13px;
color: var(--text-muted);
margin: 4px 0 0;
}
.switchLink {
background: none;
border: none;
cursor: pointer;
color: var(--text-link, #C6820E);
font-size: 13px;
padding: 0;
text-decoration: underline;
}
.switchLink:hover {
opacity: 0.8;
}
.verifyHint {
font-size: 14px;
color: var(--text-secondary, var(--text-muted));
margin: 0 0 4px;
text-align: center;
line-height: 1.5;
}

View File

@@ -1,11 +1,13 @@
import { type FormEvent, useMemo, useState } from 'react'; import { type FormEvent, useEffect, useMemo, useState } from 'react';
import { Eye, EyeOff } from 'lucide-react'; import { Eye, EyeOff } from 'lucide-react';
import { Card, Input, Button, Alert, FormField } from '@cameleer/design-system'; import { Card, Input, Button, Alert, FormField } from '@cameleer/design-system';
import cameleerLogo from '@cameleer/design-system/assets/cameleer-logo.svg'; import cameleerLogo from '@cameleer/design-system/assets/cameleer-logo.svg';
import { signIn } from './experience-api'; import { signIn, startRegistration, completeRegistration } from './experience-api';
import styles from './SignInPage.module.css'; import styles from './SignInPage.module.css';
const SUBTITLES = [ type Mode = 'signIn' | 'register' | 'verifyCode';
const SIGN_IN_SUBTITLES = [
"Prove you're not a mirage", "Prove you're not a mirage",
"Only authorized cameleers beyond this dune", "Only authorized cameleers beyond this dune",
"Halt, traveler — state your business", "Halt, traveler — state your business",
@@ -33,20 +35,65 @@ 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 subtitle = useMemo(
() => pickRandom(mode === 'signIn' ? SIGN_IN_SUBTITLES : REGISTER_SUBTITLES),
[mode === 'signIn' ? 'signIn' : 'register'],
);
const [identifier, setIdentifier] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [code, setCode] = useState('');
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [verificationId, setVerificationId] = useState('');
const handleSubmit = async (e: FormEvent) => { // Reset error when switching modes
useEffect(() => { setError(null); }, [mode]);
const switchMode = (next: Mode) => {
setMode(next);
setPassword('');
setConfirmPassword('');
setCode('');
setShowPassword(false);
setVerificationId('');
};
// --- Sign-in ---
const handleSignIn = async (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(null); setError(null);
setLoading(true); setLoading(true);
try { try {
const redirectTo = await signIn(username, password); const redirectTo = await signIn(identifier, password);
window.location.replace(redirectTo); window.location.replace(redirectTo);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Sign-in failed'); setError(err instanceof Error ? err.message : 'Sign-in failed');
@@ -54,10 +101,63 @@ export function SignInPage() {
} }
}; };
// --- Register step 1: send verification code ---
const handleRegister = async (e: FormEvent) => {
e.preventDefault();
setError(null);
if (!identifier.includes('@')) {
setError('Please enter a valid email address');
return;
}
if (password !== confirmPassword) {
setError('Passwords do not match');
return;
}
if (password.length < 8) {
setError('Password must be at least 8 characters');
return;
}
setLoading(true);
try {
const vId = await startRegistration(identifier);
setVerificationId(vId);
setMode('verifyCode');
} catch (err) {
setError(err instanceof Error ? err.message : 'Registration failed');
} finally {
setLoading(false);
}
};
// --- Register step 2: verify code + complete ---
const handleVerifyCode = async (e: FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
const redirectTo = await completeRegistration(identifier, password, verificationId, code);
window.location.replace(redirectTo);
} catch (err) {
setError(err instanceof Error ? err.message : 'Verification failed');
setLoading(false);
}
};
const passwordToggle = (
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className={styles.passwordToggle}
tabIndex={-1}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
);
return ( return (
<div className={styles.page}> <div className={styles.page}>
<Card className={styles.card}> <Card className={styles.card}>
<div className={styles.loginForm}> <div className={styles.formContainer}>
<div className={styles.logo}> <div className={styles.logo}>
<img src={cameleerLogo} alt="" className={styles.logoImg} /> <img src={cameleerLogo} alt="" className={styles.logoImg} />
Cameleer Cameleer
@@ -70,55 +170,156 @@ export function SignInPage() {
</div> </div>
)} )}
<form className={styles.fields} onSubmit={handleSubmit} aria-label="Sign in" noValidate> {/* --- Sign-in form --- */}
<FormField label="Username" htmlFor="login-username"> {mode === 'signIn' && (
<Input <form className={styles.fields} onSubmit={handleSignIn} aria-label="Sign in" noValidate>
id="login-username" <FormField label="Email or username" htmlFor="login-identifier">
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter your username"
autoFocus
autoComplete="username"
disabled={loading}
/>
</FormField>
<FormField label="Password" htmlFor="login-password">
<div style={{ position: 'relative' }}>
<Input <Input
id="login-password" id="login-identifier"
type={showPassword ? 'text' : 'password'} type="email"
value={password} value={identifier}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setIdentifier(e.target.value)}
placeholder="••••••••" placeholder="you@company.com"
autoComplete="current-password" autoFocus
autoComplete="username"
disabled={loading} disabled={loading}
/> />
<button </FormField>
type="button"
onClick={() => setShowPassword(!showPassword)}
style={{
position: 'absolute', right: 8, top: '50%', transform: 'translateY(-50%)',
background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)',
padding: 4, display: 'flex', alignItems: 'center',
}}
tabIndex={-1}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</FormField>
<Button <FormField label="Password" htmlFor="login-password">
variant="primary" <div className={styles.passwordWrapper}>
type="submit" <Input
loading={loading} id="login-password"
disabled={loading || !username || !password} type={showPassword ? 'text' : 'password'}
className={styles.submitButton} value={password}
> onChange={(e) => setPassword(e.target.value)}
Sign in placeholder="••••••••"
</Button> autoComplete="current-password"
</form> disabled={loading}
/>
{passwordToggle}
</div>
</FormField>
<Button
variant="primary"
type="submit"
loading={loading}
disabled={loading || !identifier || !password}
className={styles.submitButton}
>
Sign in
</Button>
<p className={styles.switchText}>
Don't have an account?{' '}
<button type="button" className={styles.switchLink} onClick={() => switchMode('register')}>
Sign up
</button>
</p>
</form>
)}
{/* --- Register form --- */}
{mode === 'register' && (
<form className={styles.fields} onSubmit={handleRegister} aria-label="Create account" noValidate>
<FormField label="Email" htmlFor="register-email">
<Input
id="register-email"
type="email"
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
placeholder="you@company.com"
autoFocus
autoComplete="email"
disabled={loading}
/>
</FormField>
<FormField label="Password" htmlFor="register-password">
<div className={styles.passwordWrapper}>
<Input
id="register-password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="At least 8 characters"
autoComplete="new-password"
disabled={loading}
/>
{passwordToggle}
</div>
</FormField>
<FormField label="Confirm password" htmlFor="register-confirm">
<Input
id="register-confirm"
type={showPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••"
autoComplete="new-password"
disabled={loading}
/>
</FormField>
<Button
variant="primary"
type="submit"
loading={loading}
disabled={loading || !identifier || !password || !confirmPassword}
className={styles.submitButton}
>
Create account
</Button>
<p className={styles.switchText}>
Already have an account?{' '}
<button type="button" className={styles.switchLink} onClick={() => switchMode('signIn')}>
Sign in
</button>
</p>
</form>
)}
{/* --- Verification code form --- */}
{mode === 'verifyCode' && (
<form className={styles.fields} onSubmit={handleVerifyCode} aria-label="Verify email" noValidate>
<p className={styles.verifyHint}>
We sent a verification code to <strong>{identifier}</strong>
</p>
<FormField label="Verification code" htmlFor="verify-code">
<Input
id="verify-code"
type="text"
inputMode="numeric"
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="000000"
autoFocus
autoComplete="one-time-code"
disabled={loading}
/>
</FormField>
<Button
variant="primary"
type="submit"
loading={loading}
disabled={loading || code.length < 6}
className={styles.submitButton}
>
Verify & create account
</Button>
<p className={styles.switchText}>
<button type="button" className={styles.switchLink} onClick={() => switchMode('register')}>
Back
</button>
</p>
</form>
)}
</div> </div>
</Card> </Card>
</div> </div>

View File

@@ -10,32 +10,7 @@ async function request(method: string, path: string, body?: unknown): Promise<Re
return res; return res;
} }
export async function initInteraction(): Promise<void> { // --- Shared ---
const res = await request('PUT', '', { interactionEvent: 'SignIn' });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.message || `Failed to initialize sign-in (${res.status})`);
}
}
export async function verifyPassword(
username: string,
password: string
): Promise<string> {
const res = await request('POST', '/verification/password', {
identifier: { type: 'username', value: username },
password,
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
if (res.status === 422) {
throw new Error('Invalid username or password');
}
throw new Error(err.message || `Authentication failed (${res.status})`);
}
const data = await res.json();
return data.verificationId;
}
export async function identifyUser(verificationId: string): Promise<void> { export async function identifyUser(verificationId: string): Promise<void> {
const res = await request('POST', '/identification', { verificationId }); const res = await request('POST', '/identification', { verificationId });
@@ -55,9 +30,117 @@ export async function submitInteraction(): Promise<string> {
return data.redirectTo; return data.redirectTo;
} }
export async function signIn(username: string, password: string): Promise<string> { // --- Sign-in ---
export async function initInteraction(): Promise<void> {
const res = await request('PUT', '', { interactionEvent: 'SignIn' });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.message || `Failed to initialize sign-in (${res.status})`);
}
}
function detectIdentifierType(input: string): 'email' | 'username' {
return input.includes('@') ? 'email' : 'username';
}
export async function verifyPassword(
identifier: string,
password: string
): Promise<string> {
const type = detectIdentifierType(identifier);
const res = await request('POST', '/verification/password', {
identifier: { type, value: identifier },
password,
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
if (res.status === 422) {
throw new Error('Invalid credentials');
}
throw new Error(err.message || `Authentication failed (${res.status})`);
}
const data = await res.json();
return data.verificationId;
}
export async function signIn(identifier: string, password: string): Promise<string> {
await initInteraction(); await initInteraction();
const verificationId = await verifyPassword(username, password); const verificationId = await verifyPassword(identifier, password);
await identifyUser(verificationId); await identifyUser(verificationId);
return submitInteraction(); return submitInteraction();
} }
// --- Registration ---
export async function initRegistration(): Promise<void> {
const res = await request('PUT', '', { interactionEvent: 'Register' });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.message || `Failed to initialize registration (${res.status})`);
}
}
export async function sendVerificationCode(email: string): Promise<string> {
const res = await request('POST', '/verification/verification-code', {
identifier: { type: 'email', value: email },
interactionEvent: 'Register',
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
if (res.status === 422) {
throw new Error('This email is already registered');
}
throw new Error(err.message || `Failed to send verification code (${res.status})`);
}
const data = await res.json();
return data.verificationId;
}
export async function verifyCode(
email: string,
verificationId: string,
code: string
): Promise<string> {
const res = await request('POST', '/verification/verification-code/verify', {
identifier: { type: 'email', value: email },
verificationId,
code,
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
if (res.status === 422) {
throw new Error('Invalid or expired verification code');
}
throw new Error(err.message || `Verification failed (${res.status})`);
}
const data = await res.json();
return data.verificationId;
}
export async function addProfile(type: string, value: string): Promise<void> {
const res = await request('POST', '/profile', { type, value });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.message || `Failed to update profile (${res.status})`);
}
}
/** Phase 1: init registration + send verification email. Returns verificationId for phase 2. */
export async function startRegistration(email: string): Promise<string> {
await initRegistration();
return sendVerificationCode(email);
}
/** Phase 2: verify code, set password, create user, submit. Returns redirect URL. */
export async function completeRegistration(
email: string,
password: string,
verificationId: string,
code: string
): Promise<string> {
const verifiedId = await verifyCode(email, verificationId, code);
await addProfile('password', password);
await identifyUser(verifiedId);
return submitInteraction();
}

View 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>
);
}

View File

@@ -0,0 +1,59 @@
.page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: var(--bg-base);
}
.wrapper {
width: 100%;
max-width: 480px;
padding: 16px;
}
.inner {
display: flex;
flex-direction: column;
align-items: center;
font-family: var(--font-body);
width: 100%;
}
.logo {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
}
.logoImg {
width: 36px;
height: 36px;
}
.subtitle {
font-size: 14px;
color: var(--text-muted);
margin: 0 0 24px;
text-align: center;
}
.error {
width: 100%;
margin-bottom: 16px;
}
.form {
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
}
.submit {
width: 100%;
}

View File

@@ -0,0 +1,103 @@
import { useState, useEffect } from 'react';
import { Card, Input, Button, FormField, Alert } from '@cameleer/design-system';
import cameleerLogo from '@cameleer/design-system/assets/cameleer-logo.svg';
import { api } from '../api/client';
import { toSlug } from '../utils/slug';
import styles from './OnboardingPage.module.css';
interface TenantResponse {
id: string;
name: string;
slug: string;
status: string;
}
export function OnboardingPage() {
const [name, setName] = useState('');
const [slug, setSlug] = useState('');
const [slugTouched, setSlugTouched] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!slugTouched) {
setSlug(toSlug(name));
}
}, [name, slugTouched]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setLoading(true);
try {
await api.post<TenantResponse>('/onboarding/tenant', { name, slug });
// Tenant created — force a full page reload so the Logto SDK
// picks up the new org membership and scopes on the next token refresh.
window.location.href = '/platform/';
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create tenant');
setLoading(false);
}
}
return (
<div className={styles.page}>
<div className={styles.wrapper}>
<Card>
<div className={styles.inner}>
<div className={styles.logo}>
<img src={cameleerLogo} alt="" className={styles.logoImg} />
Welcome to Cameleer
</div>
<p className={styles.subtitle}>
Set up your workspace to start monitoring your Camel routes.
</p>
{error && (
<div className={styles.error}>
<Alert variant="error">{error}</Alert>
</div>
)}
<form onSubmit={handleSubmit} className={styles.form}>
<FormField label="Organization name" htmlFor="onboard-name" required>
<Input
id="onboard-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Acme Corp"
autoFocus
disabled={loading}
required
/>
</FormField>
<FormField label="URL slug" htmlFor="onboard-slug" required
hint="Auto-generated from name. Appears in your dashboard URL."
>
<Input
id="onboard-slug"
value={slug}
onChange={(e) => { setSlugTouched(true); setSlug(e.target.value); }}
placeholder="acme-corp"
disabled={loading}
required
/>
</FormField>
<Button
variant="primary"
type="submit"
loading={loading}
disabled={loading || !name || !slug}
className={styles.submit}
>
{loading ? 'Creating...' : 'Create workspace'}
</Button>
</form>
</div>
</Card>
</div>
</div>
);
}

View File

@@ -1,5 +1,6 @@
import { Routes, Route, Navigate } from 'react-router'; import { Routes, Route, Navigate } from 'react-router';
import { LoginPage } from './auth/LoginPage'; import { LoginPage } from './auth/LoginPage';
import { RegisterPage } from './auth/RegisterPage';
import { CallbackPage } from './auth/CallbackPage'; import { CallbackPage } from './auth/CallbackPage';
import { ProtectedRoute } from './auth/ProtectedRoute'; import { ProtectedRoute } from './auth/ProtectedRoute';
import { OrgResolver } from './auth/OrgResolver'; import { OrgResolver } from './auth/OrgResolver';
@@ -21,6 +22,7 @@ import { SsoPage } from './pages/tenant/SsoPage';
import { TeamPage } from './pages/tenant/TeamPage'; import { TeamPage } from './pages/tenant/TeamPage';
import { SettingsPage } from './pages/tenant/SettingsPage'; import { SettingsPage } from './pages/tenant/SettingsPage';
import { TenantAuditPage } from './pages/tenant/TenantAuditPage'; import { TenantAuditPage } from './pages/tenant/TenantAuditPage';
import { OnboardingPage } from './pages/OnboardingPage';
function LandingRedirect() { function LandingRedirect() {
const scopes = useScopes(); const scopes = useScopes();
@@ -45,7 +47,11 @@ function LandingRedirect() {
window.location.href = `/t/${currentOrg.slug}/`; window.location.href = `/t/${currentOrg.slug}/`;
return null; return null;
} }
// No org resolved yet — stay on tenant portal // No org membership at all → onboarding (self-service tenant creation)
if (organizations.length === 0) {
return <Navigate to="/onboarding" replace />;
}
// Has org but no scopes resolved yet — stay on tenant portal
return <Navigate to="/tenant" replace />; return <Navigate to="/tenant" replace />;
} }
@@ -53,9 +59,12 @@ export function AppRouter() {
return ( return (
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/callback" element={<CallbackPage />} /> <Route path="/callback" element={<CallbackPage />} />
<Route element={<ProtectedRoute />}> <Route element={<ProtectedRoute />}>
<Route element={<OrgResolver />}> <Route element={<OrgResolver />}>
{/* Onboarding — outside Layout, shown to users with no tenants */}
<Route path="/onboarding" element={<OnboardingPage />} />
<Route element={<Layout />}> <Route element={<Layout />}>
{/* Vendor console */} {/* Vendor console */}
<Route path="/vendor/tenants" element={ <Route path="/vendor/tenants" element={