Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0cfa359fc5 | ||
|
|
5cc9f8c9ef | ||
|
|
b066d1abe7 | ||
|
|
ae1d9fa4db | ||
|
|
6fe10432e6 | ||
|
|
9f3faf4816 | ||
|
|
a60095608e | ||
|
|
9f9112c6a5 | ||
|
|
e1a9f6d225 | ||
|
|
180644f0df | ||
|
|
62b74d2d06 | ||
|
|
3e2f035d97 | ||
|
|
9962ee99d9 | ||
|
|
b53840b77b | ||
|
|
9ed2cedc98 | ||
|
|
dc7ac3a1ec |
11
.env.example
11
.env.example
@@ -7,6 +7,9 @@ VERSION=latest
|
||||
# Public access
|
||||
PUBLIC_HOST=localhost
|
||||
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
|
||||
HTTP_PORT=80
|
||||
@@ -25,6 +28,14 @@ CLICKHOUSE_PASSWORD=change_me_in_production
|
||||
SAAS_ADMIN_USER=admin
|
||||
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)
|
||||
# NODE_TLS_REJECT=0 # Set to 1 when using real certificates
|
||||
# CERT_FILE=
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- gitnexus:start -->
|
||||
# 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.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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` |
|
||||
| `tenant/` | Tenant data model | `TenantEntity` (JPA: id, name, slug, tier, status, logto_org_id, db_password) |
|
||||
| `vendor/` | Vendor console (platform:admin) | `VendorTenantService`, `VendorTenantController`, `InfrastructureService` |
|
||||
| `onboarding/` | Self-service sign-up onboarding | `OnboardingController`, `OnboardingService` |
|
||||
| `portal/` | Tenant admin portal (org-scoped) | `TenantPortalService`, `TenantPortalController` |
|
||||
| `provisioning/` | Pluggable tenant provisioning | `DockerTenantProvisioner`, `TenantDatabaseService`, `TenantDataCleanupService` |
|
||||
| `certificate/` | TLS certificate lifecycle | `CertificateService`, `CertificateController`, `TenantCaCertService` |
|
||||
@@ -75,7 +76,7 @@ PostgreSQL (Flyway): `src/main/resources/db/migration/`
|
||||
<!-- gitnexus:start -->
|
||||
# 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.
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ services:
|
||||
- "${LOGTO_CONSOLE_PORT:-3002}:3002"
|
||||
environment:
|
||||
PUBLIC_HOST: ${PUBLIC_HOST:-localhost}
|
||||
AUTH_HOST: ${AUTH_HOST:-localhost}
|
||||
CERT_FILE: ${CERT_FILE:-}
|
||||
KEY_FILE: ${KEY_FILE:-}
|
||||
CA_FILE: ${CA_FILE:-}
|
||||
@@ -62,14 +63,15 @@ services:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DB_URL: postgres://${POSTGRES_USER:-cameleer}:${POSTGRES_PASSWORD:-cameleer_dev}@cameleer-postgres:5432/logto
|
||||
ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
|
||||
ADMIN_ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}:${LOGTO_CONSOLE_PORT:-3002}
|
||||
ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${AUTH_HOST:-localhost}
|
||||
ADMIN_ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${AUTH_HOST:-localhost}:${LOGTO_CONSOLE_PORT:-3002}
|
||||
TRUST_PROXY_HEADER: 1
|
||||
NODE_TLS_REJECT_UNAUTHORIZED: "${NODE_TLS_REJECT:-0}"
|
||||
LOGTO_ENDPOINT: http://cameleer-logto:3001
|
||||
LOGTO_ADMIN_ENDPOINT: http://cameleer-logto:3002
|
||||
LOGTO_PUBLIC_ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
|
||||
LOGTO_PUBLIC_ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${AUTH_HOST:-localhost}
|
||||
PUBLIC_HOST: ${PUBLIC_HOST:-localhost}
|
||||
AUTH_HOST: ${AUTH_HOST:-localhost}
|
||||
PUBLIC_PROTOCOL: ${PUBLIC_PROTOCOL:-https}
|
||||
PG_HOST: cameleer-postgres
|
||||
PG_USER: ${POSTGRES_USER:-cameleer}
|
||||
@@ -77,6 +79,14 @@ services:
|
||||
PG_DB_SAAS: ${POSTGRES_DB:-cameleer_saas}
|
||||
SAAS_ADMIN_USER: ${SAAS_ADMIN_USER:-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:
|
||||
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
|
||||
@@ -85,13 +95,13 @@ services:
|
||||
start_period: 30s
|
||||
labels:
|
||||
- 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.entrypoints=websecure
|
||||
- traefik.http.routers.cameleer-logto.tls=true
|
||||
- traefik.http.routers.cameleer-logto.service=cameleer-logto
|
||||
- traefik.http.routers.cameleer-logto.middlewares=cameleer-logto-cors
|
||||
- "traefik.http.middlewares.cameleer-logto-cors.headers.accessControlAllowOriginList=${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}:${LOGTO_CONSOLE_PORT:-3002}"
|
||||
- "traefik.http.middlewares.cameleer-logto-cors.headers.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.accessControlAllowHeaders=Authorization,Content-Type
|
||||
- traefik.http.middlewares.cameleer-logto-cors.headers.accessControlAllowCredentials=true
|
||||
@@ -123,7 +133,8 @@ services:
|
||||
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:-cameleer_dev}
|
||||
# Identity (Logto)
|
||||
CAMELEER_SAAS_IDENTITY_LOGTOENDPOINT: ${LOGTO_ENDPOINT:-http://cameleer-logto:3001}
|
||||
CAMELEER_SAAS_IDENTITY_LOGTOPUBLICENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
|
||||
CAMELEER_SAAS_IDENTITY_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_M2MCLIENTSECRET: ${LOGTO_M2M_CLIENT_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.tls=true
|
||||
- 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:
|
||||
- "${DOCKER_GID:-0}"
|
||||
networks:
|
||||
|
||||
@@ -42,11 +42,13 @@ Server containers join three networks: tenant network (primary), shared services
|
||||
|
||||
## 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)
|
||||
- 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
|
||||
- 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)
|
||||
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`)
|
||||
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
|
||||
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).
|
||||
|
||||
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`.
|
||||
|
||||
@@ -28,12 +28,20 @@ if [ ! -f "$CERTS_DIR/cert.pem" ]; then
|
||||
else
|
||||
# Generate self-signed certificate
|
||||
HOST="${PUBLIC_HOST:-localhost}"
|
||||
AUTH="${AUTH_HOST:-$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 \
|
||||
-keyout "$CERTS_DIR/key.pem" -out "$CERTS_DIR/cert.pem" \
|
||||
-days 365 -nodes \
|
||||
-subj "/CN=$HOST" \
|
||||
-addext "subjectAltName=DNS:$HOST,DNS:*.$HOST"
|
||||
-addext "subjectAltName=$SAN"
|
||||
SELF_SIGNED=true
|
||||
echo "[certs] Generated self-signed certificate for $HOST."
|
||||
fi
|
||||
|
||||
@@ -1,21 +1,3 @@
|
||||
http:
|
||||
routers:
|
||||
root-redirect:
|
||||
rule: "Path(`/`)"
|
||||
priority: 100
|
||||
entryPoints:
|
||||
- websecure
|
||||
tls: {}
|
||||
middlewares:
|
||||
- root-to-platform
|
||||
service: saas@docker
|
||||
middlewares:
|
||||
root-to-platform:
|
||||
redirectRegex:
|
||||
regex: "^(https?://[^/]+)/?$"
|
||||
replacement: "${1}/platform/"
|
||||
permanent: false
|
||||
|
||||
tls:
|
||||
stores:
|
||||
default:
|
||||
|
||||
@@ -32,6 +32,7 @@ SAAS_ADMIN_PASS="${SAAS_ADMIN_PASS:-admin}"
|
||||
|
||||
# Redirect URIs (derived from PUBLIC_HOST and PUBLIC_PROTOCOL)
|
||||
HOST="${PUBLIC_HOST:-localhost}"
|
||||
AUTH="${AUTH_HOST:-$HOST}"
|
||||
PROTO="${PUBLIC_PROTOCOL:-https}"
|
||||
SPA_REDIRECT_URIS="[\"${PROTO}://${HOST}/platform/callback\"]"
|
||||
SPA_POST_LOGOUT_URIS="[\"${PROTO}://${HOST}/platform/login\",\"${PROTO}://${HOST}/platform/\"]"
|
||||
@@ -47,8 +48,9 @@ if [ "$BOOTSTRAP_LOCAL" = "true" ]; then
|
||||
HOST_ARGS=""
|
||||
ADMIN_HOST_ARGS=""
|
||||
else
|
||||
HOST_ARGS="-H Host:${HOST}"
|
||||
ADMIN_HOST_ARGS="-H Host:${HOST}:3002 -H X-Forwarded-Proto:https"
|
||||
# Logto validates Host header against its ENDPOINT, which uses AUTH_HOST
|
||||
HOST_ARGS="-H Host:${AUTH}"
|
||||
ADMIN_HOST_ARGS="-H Host:${AUTH}:3002 -H X-Forwarded-Proto:https"
|
||||
fi
|
||||
|
||||
# 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."
|
||||
|
||||
# ============================================================
|
||||
# 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
|
||||
# ============================================================
|
||||
|
||||
@@ -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.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
|
||||
|
||||
- `CAMELEER_AGENT_*` — agent config (consumed by the Java agent)
|
||||
- `CAMELEER_SERVER_*` — server config (consumed by cameleer-server)
|
||||
- `CAMELEER_SAAS_*` — SaaS management plane config
|
||||
- `CAMELEER_SAAS_PROVISIONING_*` — "SaaS forwards this to provisioned tenant servers"
|
||||
- `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
|
||||
|
||||
@@ -17,6 +17,7 @@ param(
|
||||
[string]$Config,
|
||||
[string]$InstallDir,
|
||||
[string]$PublicHost,
|
||||
[string]$AuthHost,
|
||||
[string]$PublicProtocol,
|
||||
[string]$AdminUser,
|
||||
[string]$AdminPassword,
|
||||
@@ -36,6 +37,14 @@ param(
|
||||
[string]$DockerSocket,
|
||||
[string]$NodeTlsReject,
|
||||
[string]$DeploymentMode,
|
||||
[string]$SmtpHost,
|
||||
[string]$SmtpPort,
|
||||
[string]$SmtpUser,
|
||||
[string]$SmtpPass,
|
||||
[string]$SmtpFromEmail,
|
||||
[string]$Registry,
|
||||
[string]$RegistryUser,
|
||||
[string]$RegistryToken,
|
||||
[switch]$Reconfigure,
|
||||
[switch]$Reinstall,
|
||||
[switch]$ConfirmDestroy,
|
||||
@@ -49,7 +58,7 @@ $ErrorActionPreference = 'Stop'
|
||||
|
||||
$CAMELEER_INSTALLER_VERSION = '1.0.0'
|
||||
$CAMELEER_DEFAULT_VERSION = 'latest'
|
||||
$REGISTRY = 'gitea.siegeln.net/cameleer'
|
||||
$DEFAULT_REGISTRY = 'gitea.siegeln.net/cameleer'
|
||||
|
||||
$DEFAULT_INSTALL_DIR = './cameleer'
|
||||
$DEFAULT_PUBLIC_PROTOCOL = 'https'
|
||||
@@ -66,6 +75,7 @@ $DEFAULT_DOCKER_SOCKET = '/var/run/docker.sock'
|
||||
# --- Capture env vars before any overrides ---
|
||||
|
||||
$_ENV_PUBLIC_HOST = $env:PUBLIC_HOST
|
||||
$_ENV_AUTH_HOST = $env:AUTH_HOST
|
||||
$_ENV_PUBLIC_PROTOCOL = $env:PUBLIC_PROTOCOL
|
||||
$_ENV_POSTGRES_PASSWORD = $env:POSTGRES_PASSWORD
|
||||
$_ENV_CLICKHOUSE_PASSWORD = $env:CLICKHOUSE_PASSWORD
|
||||
@@ -82,12 +92,21 @@ $_ENV_COMPOSE_PROJECT = $env:COMPOSE_PROJECT
|
||||
$_ENV_DOCKER_SOCKET = $env:DOCKER_SOCKET
|
||||
$_ENV_NODE_TLS_REJECT = $env:NODE_TLS_REJECT
|
||||
$_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 ---
|
||||
|
||||
$script:cfg = @{
|
||||
InstallDir = $InstallDir
|
||||
PublicHost = $PublicHost
|
||||
AuthHost = $AuthHost
|
||||
PublicProtocol = $PublicProtocol
|
||||
AdminUser = $AdminUser
|
||||
AdminPass = $AdminPassword
|
||||
@@ -107,6 +126,14 @@ $script:cfg = @{
|
||||
DockerSocket = $DockerSocket
|
||||
NodeTlsReject = $NodeTlsReject
|
||||
DeploymentMode = $DeploymentMode
|
||||
SmtpHost = $SmtpHost
|
||||
SmtpPort = $SmtpPort
|
||||
SmtpUser = $SmtpUser
|
||||
SmtpPass = $SmtpPass
|
||||
SmtpFromEmail = $SmtpFromEmail
|
||||
Registry = $Registry
|
||||
RegistryUser = $RegistryUser
|
||||
RegistryToken = $RegistryToken
|
||||
}
|
||||
|
||||
if ($Silent) { $script:Mode = 'silent' }
|
||||
@@ -150,6 +177,7 @@ function Show-Help {
|
||||
Write-Host 'Options:'
|
||||
Write-Host ' -InstallDir DIR Install directory (default: ./cameleer)'
|
||||
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 ' -AdminPassword PASS Admin password (default: generated)'
|
||||
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 ' -Help Show this help'
|
||||
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 ' -PostgresPassword, -ClickhousePassword, -HttpPort,'
|
||||
Write-Host ' -HttpsPort, -LogtoConsolePort, -LogtoConsoleExposed,'
|
||||
@@ -236,6 +269,7 @@ function Load-ConfigFile {
|
||||
switch ($key) {
|
||||
'install_dir' { if (-not $script:cfg.InstallDir) { $script:cfg.InstallDir = $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 } }
|
||||
'admin_user' { if (-not $script:cfg.AdminUser) { $script:cfg.AdminUser = $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 } }
|
||||
'node_tls_reject' { if (-not $script:cfg.NodeTlsReject) { $script:cfg.NodeTlsReject = $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
|
||||
if (-not $c.InstallDir) { $c.InstallDir = $env:CAMELEER_INSTALL_DIR }
|
||||
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.AdminUser) { $c.AdminUser = $env:SAAS_ADMIN_USER }
|
||||
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.NodeTlsReject) { $c.NodeTlsReject = $_ENV_NODE_TLS_REJECT }
|
||||
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 ---
|
||||
@@ -434,6 +485,13 @@ function Run-SimplePrompts {
|
||||
Write-Host ''
|
||||
$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 ' Deployment mode:'
|
||||
Write-Host ' [1] Multi-tenant SaaS -- manage platform, provision tenants on demand'
|
||||
@@ -441,6 +499,18 @@ function Run-SimplePrompts {
|
||||
Write-Host ''
|
||||
$deployChoice = Read-Host ' Select mode [1]'
|
||||
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 {
|
||||
@@ -474,6 +544,7 @@ function Run-ExpertPrompts {
|
||||
if ($c.DeploymentMode -eq 'saas') {
|
||||
Write-Host ''
|
||||
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') {
|
||||
$c.LogtoConsoleExposed = 'true'
|
||||
} else {
|
||||
@@ -498,6 +569,7 @@ function Merge-Config {
|
||||
if (-not $c.LogtoConsoleExposed) { $c.LogtoConsoleExposed = $DEFAULT_LOGTO_CONSOLE_EXPOSED }
|
||||
if (-not $c.Version) { $c.Version = $CAMELEER_DEFAULT_VERSION }
|
||||
if (-not $c.DockerSocket) { $c.DockerSocket = $DEFAULT_DOCKER_SOCKET }
|
||||
if (-not $c.Registry) { $c.Registry = $DEFAULT_REGISTRY }
|
||||
|
||||
if (-not $c.ComposeProject) {
|
||||
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
|
||||
$c.PublicHost = $c.PublicHost.ToLower()
|
||||
$c.AuthHost = $c.AuthHost.ToLower()
|
||||
|
||||
if ($c.DeploymentMode -ne 'standalone' -and (-not $c.NodeTlsReject)) {
|
||||
if ($c.TlsMode -eq 'custom') { $c.NodeTlsReject = '1' } else { $c.NodeTlsReject = '0' }
|
||||
@@ -614,6 +690,12 @@ DOCKER_SOCKET=$($c.DockerSocket)
|
||||
DOCKER_GID=$gid
|
||||
|
||||
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') {
|
||||
$content += "`nCERT_FILE=/user-certs/cert.pem"
|
||||
@@ -636,6 +718,7 @@ POSTGRES_IMAGE=postgres:16-alpine
|
||||
VERSION=$($c.Version)
|
||||
|
||||
PUBLIC_HOST=$($c.PublicHost)
|
||||
AUTH_HOST=$($c.AuthHost)
|
||||
PUBLIC_PROTOCOL=$($c.PublicProtocol)
|
||||
|
||||
HTTP_PORT=$($c.HttpPort)
|
||||
@@ -663,19 +746,34 @@ NODE_TLS_REJECT=$($c.NodeTlsReject)
|
||||
$content += "`nKEY_FILE=/user-certs/key.pem"
|
||||
if ($c.CaFile) { $content += "`nCA_FILE=/user-certs/ca.pem" }
|
||||
}
|
||||
$reg = $c.Registry
|
||||
$provisioningBlock = @"
|
||||
|
||||
# Docker
|
||||
DOCKER_SOCKET=$($c.DockerSocket)
|
||||
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
|
||||
CAMELEER_SAAS_PROVISIONING_SERVERIMAGE=${REGISTRY}/cameleer-server:$($c.Version)
|
||||
CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE=${REGISTRY}/cameleer-server-ui:$($c.Version)
|
||||
CAMELEER_SAAS_PROVISIONING_RUNTIMEBASEIMAGE=${REGISTRY}/cameleer-runtime-base:$($c.Version)
|
||||
CAMELEER_SAAS_PROVISIONING_SERVERIMAGE=$reg/cameleer-server:$($c.Version)
|
||||
CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE=$reg/cameleer-server-ui:$($c.Version)
|
||||
CAMELEER_SAAS_PROVISIONING_RUNTIMEBASEIMAGE=$reg/cameleer-runtime-base:$($c.Version)
|
||||
|
||||
# JWT signing secret (forwarded to provisioned tenant servers, must be non-empty)
|
||||
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
|
||||
$composeFile = 'docker-compose.yml;docker-compose.saas.yml'
|
||||
@@ -723,6 +821,17 @@ function Copy-Templates {
|
||||
|
||||
# --- 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 {
|
||||
$c = $script:cfg
|
||||
Log-Info 'Pulling Docker images...'
|
||||
@@ -815,9 +924,11 @@ function Wait-DockerHealthy {
|
||||
}
|
||||
|
||||
function Test-Endpoint {
|
||||
param([string]$Name, [string]$Url, [int]$TimeoutSecs = 120)
|
||||
param([string]$Name, [string]$Url, [int]$TimeoutSecs = 120, [string]$HostHeader = '')
|
||||
$start = Get-Date
|
||||
$lastDot = -1
|
||||
$headers = @{}
|
||||
if ($HostHeader) { $headers['Host'] = $HostHeader }
|
||||
while ($true) {
|
||||
$elapsed = [int]((Get-Date) - $start).TotalSeconds
|
||||
if ($elapsed -ge $TimeoutSecs) {
|
||||
@@ -826,7 +937,7 @@ function Test-Endpoint {
|
||||
}
|
||||
try {
|
||||
# -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
|
||||
Write-Host (" [ok] {0,-20} ready ({1}s)" -f $Name, $dur) -ForegroundColor Green
|
||||
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 $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)
|
||||
public_host=$($c.PublicHost)
|
||||
auth_host=$($c.AuthHost)
|
||||
public_protocol=$($c.PublicProtocol)
|
||||
admin_user=$($c.AdminUser)
|
||||
tls_mode=$($c.TlsMode)
|
||||
@@ -902,6 +1014,14 @@ compose_project=$($c.ComposeProject)
|
||||
docker_socket=$($c.DockerSocket)
|
||||
node_tls_reject=$($c.NodeTlsReject)
|
||||
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
|
||||
Log-Info 'Saved installer config to cameleer.conf'
|
||||
@@ -931,7 +1051,7 @@ ClickHouse: default / $($c.ClickhousePassword)
|
||||
"@
|
||||
} else {
|
||||
if ($c.LogtoConsoleExposed -eq 'true') {
|
||||
$logtoLine = "Logto Console: $($c.PublicProtocol)://$($c.PublicHost):$($c.LogtoConsolePort)"
|
||||
$logtoLine = "Logto Console: $($c.PublicProtocol)://$($c.AuthHost):$($c.LogtoConsolePort)"
|
||||
} else {
|
||||
$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.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 |"
|
||||
} else {
|
||||
$logtoConsoleRow = ''
|
||||
@@ -1256,7 +1376,7 @@ function Print-Credentials {
|
||||
Write-Host ''
|
||||
if ($c.DeploymentMode -eq 'saas' -and $c.LogtoConsoleExposed -eq 'true') {
|
||||
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 " Credentials saved to: $($c.InstallDir)\credentials.txt"
|
||||
@@ -1331,6 +1451,7 @@ function Handle-Rerun {
|
||||
$script:cfg.InstallDir = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath(
|
||||
$script:cfg.InstallDir)
|
||||
Copy-Templates
|
||||
Invoke-RegistryLogin
|
||||
Invoke-ComposePull
|
||||
Invoke-ComposeDown
|
||||
Invoke-ComposeUp
|
||||
@@ -1415,6 +1536,7 @@ function Main {
|
||||
Copy-Templates
|
||||
Write-ConfigFile
|
||||
|
||||
Invoke-RegistryLogin
|
||||
Invoke-ComposePull
|
||||
Invoke-ComposeUp
|
||||
Verify-Health
|
||||
|
||||
@@ -3,7 +3,7 @@ set -euo pipefail
|
||||
|
||||
CAMELEER_INSTALLER_VERSION="1.0.0"
|
||||
CAMELEER_DEFAULT_VERSION="latest"
|
||||
REGISTRY="gitea.siegeln.net/cameleer"
|
||||
DEFAULT_REGISTRY="gitea.siegeln.net/cameleer"
|
||||
|
||||
# --- Colors ---
|
||||
RED='\033[0;31m'
|
||||
@@ -29,6 +29,7 @@ DEFAULT_DOCKER_SOCKET="/var/run/docker.sock"
|
||||
# --- Config values (set by args/env/config/prompts) ---
|
||||
# Save environment values before initialization (CLI args override these)
|
||||
_ENV_PUBLIC_HOST="${PUBLIC_HOST:-}"
|
||||
_ENV_AUTH_HOST="${AUTH_HOST:-}"
|
||||
_ENV_PUBLIC_PROTOCOL="${PUBLIC_PROTOCOL:-}"
|
||||
_ENV_POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-}"
|
||||
_ENV_CLICKHOUSE_PASSWORD="${CLICKHOUSE_PASSWORD:-}"
|
||||
@@ -45,9 +46,18 @@ _ENV_COMPOSE_PROJECT="${COMPOSE_PROJECT:-}"
|
||||
_ENV_DOCKER_SOCKET="${DOCKER_SOCKET:-}"
|
||||
_ENV_NODE_TLS_REJECT="${NODE_TLS_REJECT:-}"
|
||||
_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=""
|
||||
PUBLIC_HOST=""
|
||||
AUTH_HOST=""
|
||||
PUBLIC_PROTOCOL=""
|
||||
ADMIN_USER=""
|
||||
ADMIN_PASS=""
|
||||
@@ -67,6 +77,14 @@ COMPOSE_PROJECT=""
|
||||
DOCKER_SOCKET=""
|
||||
NODE_TLS_REJECT=""
|
||||
DEPLOYMENT_MODE=""
|
||||
SMTP_HOST=""
|
||||
SMTP_PORT=""
|
||||
SMTP_USER=""
|
||||
SMTP_PASS=""
|
||||
SMTP_FROM_EMAIL=""
|
||||
REGISTRY=""
|
||||
REGISTRY_USER=""
|
||||
REGISTRY_TOKEN=""
|
||||
|
||||
# --- State ---
|
||||
MODE="" # simple, expert, silent
|
||||
@@ -135,7 +153,7 @@ prompt_yesno() {
|
||||
}
|
||||
|
||||
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 ---
|
||||
@@ -148,6 +166,7 @@ parse_args() {
|
||||
--config) CONFIG_FILE_PATH="$2"; shift ;;
|
||||
--install-dir) INSTALL_DIR="$2"; shift ;;
|
||||
--public-host) PUBLIC_HOST="$2"; shift ;;
|
||||
--auth-host) AUTH_HOST="$2"; shift ;;
|
||||
--public-protocol) PUBLIC_PROTOCOL="$2"; shift ;;
|
||||
--admin-user) ADMIN_USER="$2"; shift ;;
|
||||
--admin-password) ADMIN_PASS="$2"; shift ;;
|
||||
@@ -167,6 +186,14 @@ parse_args() {
|
||||
--docker-socket) DOCKER_SOCKET="$2"; shift ;;
|
||||
--node-tls-reject) NODE_TLS_REJECT="$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-password) ADMIN_PASS="$2"; shift ;;
|
||||
--reconfigure) RERUN_ACTION="reconfigure" ;;
|
||||
@@ -194,6 +221,7 @@ show_help() {
|
||||
echo "Options:"
|
||||
echo " --install-dir DIR Install directory (default: ./cameleer)"
|
||||
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-password PASS Admin password (default: generated)"
|
||||
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 " --help Show this help"
|
||||
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 " --postgres-password, --clickhouse-password, --http-port,"
|
||||
echo " --https-port, --logto-console-port, --logto-console-exposed,"
|
||||
@@ -231,6 +264,7 @@ load_config_file() {
|
||||
case "$key" in
|
||||
install_dir) [ -z "$INSTALL_DIR" ] && INSTALL_DIR="$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" ;;
|
||||
admin_user) [ -z "$ADMIN_USER" ] && ADMIN_USER="$value" ;;
|
||||
admin_password) [ -z "$ADMIN_PASS" ] && ADMIN_PASS="$value" ;;
|
||||
@@ -250,6 +284,14 @@ load_config_file() {
|
||||
docker_socket) [ -z "$DOCKER_SOCKET" ] && DOCKER_SOCKET="$value" ;;
|
||||
node_tls_reject) [ -z "$NODE_TLS_REJECT" ] && NODE_TLS_REJECT="$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
|
||||
done < "$file"
|
||||
}
|
||||
@@ -257,6 +299,7 @@ load_config_file() {
|
||||
load_env_overrides() {
|
||||
[ -z "$INSTALL_DIR" ] && INSTALL_DIR="${CAMELEER_INSTALL_DIR:-}"
|
||||
[ -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 "$ADMIN_USER" ] && ADMIN_USER="${SAAS_ADMIN_USER:-}"
|
||||
[ -z "$ADMIN_PASS" ] && ADMIN_PASS="${SAAS_ADMIN_PASS:-}"
|
||||
@@ -276,6 +319,14 @@ load_env_overrides() {
|
||||
[ -z "$DOCKER_SOCKET" ] && DOCKER_SOCKET="$_ENV_DOCKER_SOCKET"
|
||||
[ -z "$NODE_TLS_REJECT" ] && NODE_TLS_REJECT="$_ENV_NODE_TLS_REJECT"
|
||||
[ -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 ---
|
||||
@@ -413,6 +464,13 @@ run_simple_prompts() {
|
||||
echo ""
|
||||
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 " Deployment mode:"
|
||||
echo " [1] Multi-tenant SaaS — manage platform, provision tenants on demand"
|
||||
@@ -428,6 +486,18 @@ run_simple_prompts() {
|
||||
DEPLOYMENT_MODE="saas"
|
||||
;;
|
||||
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() {
|
||||
@@ -463,6 +533,7 @@ run_expert_prompts() {
|
||||
if [ "$DEPLOYMENT_MODE" = "saas" ]; then
|
||||
echo ""
|
||||
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
|
||||
LOGTO_CONSOLE_EXPOSED="true"
|
||||
else
|
||||
@@ -486,6 +557,7 @@ merge_config() {
|
||||
: "${LOGTO_CONSOLE_EXPOSED:=$DEFAULT_LOGTO_CONSOLE_EXPOSED}"
|
||||
: "${VERSION:=$CAMELEER_DEFAULT_VERSION}"
|
||||
: "${DOCKER_SOCKET:=$DEFAULT_DOCKER_SOCKET}"
|
||||
: "${REGISTRY:=$DEFAULT_REGISTRY}"
|
||||
|
||||
if [ "$DEPLOYMENT_MODE" = "standalone" ]; then
|
||||
: "${COMPOSE_PROJECT:=$DEFAULT_COMPOSE_PROJECT_STANDALONE}"
|
||||
@@ -493,8 +565,12 @@ merge_config() {
|
||||
: "${COMPOSE_PROJECT:=$DEFAULT_COMPOSE_PROJECT}"
|
||||
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:]')
|
||||
AUTH_HOST=$(echo "$AUTH_HOST" | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
if [ "$DEPLOYMENT_MODE" != "standalone" ]; 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
|
||||
|
||||
# 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=docker-compose.yml:docker-compose.server.yml$([ "$TLS_MODE" = "custom" ] && echo ":docker-compose.tls.yml")$([ -n "$MONITORING_NETWORK" ] && echo ":docker-compose.monitoring.yml")
|
||||
EOF
|
||||
@@ -636,6 +718,7 @@ VERSION=${VERSION}
|
||||
|
||||
# Public access
|
||||
PUBLIC_HOST=${PUBLIC_HOST}
|
||||
AUTH_HOST=${AUTH_HOST}
|
||||
PUBLIC_PROTOCOL=${PUBLIC_PROTOCOL}
|
||||
|
||||
# Ports
|
||||
@@ -676,6 +759,13 @@ EOF
|
||||
DOCKER_SOCKET=${DOCKER_SOCKET}
|
||||
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
|
||||
CAMELEER_SAAS_PROVISIONING_SERVERIMAGE=${REGISTRY}/cameleer-server:${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)
|
||||
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=docker-compose.yml:docker-compose.saas.yml$([ "$TLS_MODE" = "custom" ] && echo ":docker-compose.tls.yml")$([ -n "$MONITORING_NETWORK" ] && echo ":docker-compose.monitoring.yml")
|
||||
EOF
|
||||
@@ -727,6 +824,16 @@ copy_templates() {
|
||||
|
||||
# --- 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() {
|
||||
log_info "Pulling Docker images..."
|
||||
(cd "$INSTALL_DIR" && docker compose -p "$COMPOSE_PROJECT" pull)
|
||||
@@ -778,8 +885,10 @@ wait_for_docker_healthy() {
|
||||
}
|
||||
|
||||
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 extra_flags=""
|
||||
[ -n "$resolve" ] && extra_flags="--resolve $resolve"
|
||||
|
||||
while true; do
|
||||
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"
|
||||
return 1
|
||||
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 ))
|
||||
printf " ${GREEN}[ok]${NC} %-20s ready (%ds)\n" "$name" "$duration"
|
||||
return 0
|
||||
@@ -820,7 +929,7 @@ verify_health() {
|
||||
check_endpoint "Cameleer SaaS" "https://localhost:${HTTPS_PORT}/platform/api/config" 120 || failed=1
|
||||
|
||||
[ $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
|
||||
|
||||
echo ""
|
||||
@@ -841,6 +950,7 @@ write_config_file() {
|
||||
|
||||
install_dir=${INSTALL_DIR}
|
||||
public_host=${PUBLIC_HOST}
|
||||
auth_host=${AUTH_HOST}
|
||||
public_protocol=${PUBLIC_PROTOCOL}
|
||||
admin_user=${ADMIN_USER}
|
||||
tls_mode=${TLS_MODE}
|
||||
@@ -854,6 +964,14 @@ compose_project=${COMPOSE_PROJECT}
|
||||
docker_socket=${DOCKER_SOCKET}
|
||||
node_tls_reject=${NODE_TLS_REJECT}
|
||||
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
|
||||
log_info "Saved installer config to cameleer.conf"
|
||||
}
|
||||
@@ -902,7 +1020,7 @@ ClickHouse: default / ${CLICKHOUSE_PASSWORD}
|
||||
EOF
|
||||
|
||||
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
|
||||
echo "Logto Console: (not exposed)" >> "$f"
|
||||
fi
|
||||
@@ -941,7 +1059,7 @@ generate_install_doc() {
|
||||
EOF
|
||||
|
||||
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
|
||||
|
||||
cat >> "$f" << 'EOF'
|
||||
@@ -1219,7 +1337,7 @@ print_credentials() {
|
||||
|
||||
if [ "$DEPLOYMENT_MODE" = "saas" ]; 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 ""
|
||||
fi
|
||||
fi
|
||||
@@ -1288,10 +1406,12 @@ handle_rerun() {
|
||||
case "$RERUN_ACTION" in
|
||||
upgrade)
|
||||
log_info "Upgrading installation..."
|
||||
load_config_file "$INSTALL_DIR/cameleer.conf"
|
||||
load_env_overrides
|
||||
# Config already loaded by detect_existing_install + load_env_overrides in main.
|
||||
# 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
|
||||
copy_templates
|
||||
docker_registry_login
|
||||
docker_compose_pull
|
||||
docker_compose_down
|
||||
docker_compose_up
|
||||
@@ -1386,6 +1506,7 @@ main() {
|
||||
write_config_file
|
||||
|
||||
# Pull and start
|
||||
docker_registry_login
|
||||
docker_compose_pull
|
||||
docker_compose_up
|
||||
|
||||
|
||||
@@ -21,6 +21,9 @@ VERSION=latest
|
||||
# ============================================================
|
||||
PUBLIC_HOST=localhost
|
||||
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
|
||||
@@ -57,6 +60,16 @@ SAAS_ADMIN_PASS=CHANGE_ME
|
||||
# SERVER_ADMIN_PASS=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
|
||||
# ============================================================
|
||||
|
||||
@@ -10,14 +10,15 @@ services:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DB_URL: postgres://${POSTGRES_USER:-cameleer}:${POSTGRES_PASSWORD}@cameleer-postgres:5432/logto
|
||||
ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
|
||||
ADMIN_ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}:${LOGTO_CONSOLE_PORT:-3002}
|
||||
ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${AUTH_HOST:-localhost}
|
||||
ADMIN_ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${AUTH_HOST:-localhost}:${LOGTO_CONSOLE_PORT:-3002}
|
||||
TRUST_PROXY_HEADER: 1
|
||||
NODE_TLS_REJECT_UNAUTHORIZED: "${NODE_TLS_REJECT:-0}"
|
||||
LOGTO_ENDPOINT: http://cameleer-logto:3001
|
||||
LOGTO_ADMIN_ENDPOINT: http://cameleer-logto:3002
|
||||
LOGTO_PUBLIC_ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
|
||||
LOGTO_PUBLIC_ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${AUTH_HOST:-localhost}
|
||||
PUBLIC_HOST: ${PUBLIC_HOST:-localhost}
|
||||
AUTH_HOST: ${AUTH_HOST:-localhost}
|
||||
PUBLIC_PROTOCOL: ${PUBLIC_PROTOCOL:-https}
|
||||
PG_HOST: cameleer-postgres
|
||||
PG_USER: ${POSTGRES_USER:-cameleer}
|
||||
@@ -25,6 +26,17 @@ services:
|
||||
PG_DB_SAAS: cameleer_saas
|
||||
SAAS_ADMIN_USER: ${SAAS_ADMIN_USER:-admin}
|
||||
SAAS_ADMIN_PASS: ${SAAS_ADMIN_PASS:?SAAS_ADMIN_PASS must be set in .env}
|
||||
# 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:
|
||||
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
|
||||
@@ -33,13 +45,13 @@ services:
|
||||
start_period: 30s
|
||||
labels:
|
||||
- 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.entrypoints=websecure
|
||||
- traefik.http.routers.cameleer-logto.tls=true
|
||||
- traefik.http.routers.cameleer-logto.service=cameleer-logto
|
||||
- traefik.http.routers.cameleer-logto.middlewares=cameleer-logto-cors
|
||||
- "traefik.http.middlewares.cameleer-logto-cors.headers.accessControlAllowOriginList=${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}:${LOGTO_CONSOLE_PORT:-3002}"
|
||||
- "traefik.http.middlewares.cameleer-logto-cors.headers.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.accessControlAllowHeaders=Authorization,Content-Type
|
||||
- traefik.http.middlewares.cameleer-logto-cors.headers.accessControlAllowCredentials=true
|
||||
@@ -68,7 +80,8 @@ services:
|
||||
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
# Identity (Logto)
|
||||
CAMELEER_SAAS_IDENTITY_LOGTOENDPOINT: http://cameleer-logto:3001
|
||||
CAMELEER_SAAS_IDENTITY_LOGTOPUBLICENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
|
||||
CAMELEER_SAAS_IDENTITY_LOGTOPUBLICENDPOINT: ${PUBLIC_PROTOCOL:-https}://${AUTH_HOST:-localhost}
|
||||
CAMELEER_SAAS_IDENTITY_AUTHHOST: ${AUTH_HOST:-localhost}
|
||||
# Provisioning — passed to per-tenant server containers
|
||||
CAMELEER_SAAS_PROVISIONING_PUBLICHOST: ${PUBLIC_HOST:-localhost}
|
||||
CAMELEER_SAAS_PROVISIONING_PUBLICPROTOCOL: ${PUBLIC_PROTOCOL:-https}
|
||||
@@ -87,6 +100,16 @@ services:
|
||||
- traefik.http.routers.saas.entrypoints=websecure
|
||||
- traefik.http.routers.saas.tls=true
|
||||
- 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/port=8080"
|
||||
- "prometheus.io/path=/platform/actuator/prometheus"
|
||||
|
||||
@@ -11,6 +11,7 @@ services:
|
||||
- "${LOGTO_CONSOLE_BIND:-127.0.0.1}:${LOGTO_CONSOLE_PORT:-3002}:3002"
|
||||
environment:
|
||||
PUBLIC_HOST: ${PUBLIC_HOST:-localhost}
|
||||
AUTH_HOST: ${AUTH_HOST:-localhost}
|
||||
CERT_FILE: ${CERT_FILE:-}
|
||||
KEY_FILE: ${KEY_FILE:-}
|
||||
CA_FILE: ${CA_FILE:-}
|
||||
|
||||
@@ -18,10 +18,13 @@
|
||||
| SaaS admin | `saas-vendor` (global) | `platform:admin` | `/vendor/tenants` |
|
||||
| Tenant admin | org `owner` | `tenant:manage` | `/tenant` (dashboard) |
|
||||
| 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
|
||||
- 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)
|
||||
|
||||
|
||||
@@ -43,10 +43,11 @@ public class SecurityConfig {
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/actuator/health").permitAll()
|
||||
.requestMatchers("/api/config").permitAll()
|
||||
.requestMatchers("/", "/index.html", "/login", "/callback",
|
||||
"/vendor/**", "/tenant/**",
|
||||
.requestMatchers("/", "/index.html", "/login", "/register", "/callback",
|
||||
"/vendor/**", "/tenant/**", "/onboarding",
|
||||
"/environments/**", "/license", "/admin/**").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/tenant/**").authenticated()
|
||||
.anyRequest().authenticated()
|
||||
|
||||
@@ -7,7 +7,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
public class SpaController {
|
||||
|
||||
@RequestMapping(value = {
|
||||
"/", "/login", "/callback",
|
||||
"/", "/login", "/register", "/callback", "/onboarding",
|
||||
"/vendor/**", "/tenant/**"
|
||||
})
|
||||
public String forward() {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package net.siegeln.cameleer.saas.onboarding;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import net.siegeln.cameleer.saas.tenant.TenantEntity;
|
||||
import net.siegeln.cameleer.saas.tenant.dto.TenantResponse;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/onboarding")
|
||||
public class OnboardingController {
|
||||
|
||||
private final OnboardingService onboardingService;
|
||||
|
||||
public OnboardingController(OnboardingService onboardingService) {
|
||||
this.onboardingService = onboardingService;
|
||||
}
|
||||
|
||||
public record CreateTrialTenantRequest(
|
||||
@jakarta.validation.constraints.NotBlank
|
||||
@jakarta.validation.constraints.Size(max = 255)
|
||||
String name,
|
||||
|
||||
@jakarta.validation.constraints.NotBlank
|
||||
@jakarta.validation.constraints.Size(max = 100)
|
||||
@jakarta.validation.constraints.Pattern(
|
||||
regexp = "^[a-z0-9][a-z0-9-]*[a-z0-9]$",
|
||||
message = "Slug must be lowercase alphanumeric with hyphens")
|
||||
String slug
|
||||
) {}
|
||||
|
||||
@PostMapping("/tenant")
|
||||
public ResponseEntity<TenantResponse> createTrialTenant(
|
||||
@Valid @RequestBody CreateTrialTenantRequest request,
|
||||
@AuthenticationPrincipal Jwt jwt) {
|
||||
|
||||
String userId = jwt.getSubject();
|
||||
TenantEntity tenant = onboardingService.createTrialTenant(request.name(), request.slug(), userId);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(TenantResponse.from(tenant));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package net.siegeln.cameleer.saas.onboarding;
|
||||
|
||||
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
|
||||
import net.siegeln.cameleer.saas.tenant.TenantEntity;
|
||||
import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest;
|
||||
import net.siegeln.cameleer.saas.vendor.VendorTenantService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Self-service onboarding: lets a newly registered user create their own trial tenant.
|
||||
* Reuses VendorTenantService for the heavy lifting (Logto org, license, Docker provisioning)
|
||||
* but adds the calling user as the tenant owner instead of creating a new admin user.
|
||||
*/
|
||||
@Service
|
||||
public class OnboardingService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(OnboardingService.class);
|
||||
|
||||
private final VendorTenantService vendorTenantService;
|
||||
private final LogtoManagementClient logtoClient;
|
||||
|
||||
public OnboardingService(VendorTenantService vendorTenantService,
|
||||
LogtoManagementClient logtoClient) {
|
||||
this.vendorTenantService = vendorTenantService;
|
||||
this.logtoClient = logtoClient;
|
||||
}
|
||||
|
||||
public TenantEntity createTrialTenant(String name, String slug, String logtoUserId) {
|
||||
// Guard: check if user already has a tenant (prevent abuse)
|
||||
if (logtoClient.isAvailable()) {
|
||||
var orgs = logtoClient.getUserOrganizations(logtoUserId);
|
||||
if (!orgs.isEmpty()) {
|
||||
throw new IllegalStateException("You already have a tenant. Only one trial tenant per account.");
|
||||
}
|
||||
}
|
||||
|
||||
// Create tenant via the existing vendor flow (no admin user — we'll add the caller)
|
||||
UUID actorId = resolveActorId(logtoUserId);
|
||||
var request = new CreateTenantRequest(name, slug, "LOW", null, null);
|
||||
TenantEntity tenant = vendorTenantService.createAndProvision(request, actorId);
|
||||
|
||||
// Add the calling user to the Logto org as owner
|
||||
if (tenant.getLogtoOrgId() != null && logtoClient.isAvailable()) {
|
||||
try {
|
||||
String ownerRoleId = logtoClient.findOrgRoleIdByName("owner");
|
||||
logtoClient.addUserToOrganization(tenant.getLogtoOrgId(), logtoUserId);
|
||||
if (ownerRoleId != null) {
|
||||
logtoClient.assignOrganizationRole(tenant.getLogtoOrgId(), logtoUserId, ownerRoleId);
|
||||
}
|
||||
log.info("Added user {} as owner of tenant {}", logtoUserId, slug);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to add user {} to org for tenant {}: {}", logtoUserId, slug, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return tenant;
|
||||
}
|
||||
|
||||
private UUID resolveActorId(String subject) {
|
||||
try {
|
||||
return UUID.fromString(subject);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return UUID.nameUUIDFromBytes(subject.getBytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ spring:
|
||||
oauth2:
|
||||
resourceserver:
|
||||
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
|
||||
|
||||
management:
|
||||
@@ -35,6 +35,7 @@ management:
|
||||
cameleer:
|
||||
saas:
|
||||
identity:
|
||||
authhost: ${CAMELEER_SAAS_IDENTITY_AUTHHOST:${cameleer.saas.provisioning.publichost:localhost}}
|
||||
logtoendpoint: ${CAMELEER_SAAS_IDENTITY_LOGTOENDPOINT:}
|
||||
logtopublicendpoint: ${CAMELEER_SAAS_IDENTITY_LOGTOPUBLICENDPOINT:}
|
||||
m2mclientid: ${CAMELEER_SAAS_IDENTITY_M2MCLIENTID:}
|
||||
@@ -56,7 +57,7 @@ cameleer:
|
||||
clickhouseurl: ${CAMELEER_SAAS_PROVISIONING_CLICKHOUSEURL:jdbc:clickhouse://cameleer-clickhouse:8123/cameleer}
|
||||
clickhouseuser: ${CAMELEER_SAAS_PROVISIONING_CLICKHOUSEUSER:default}
|
||||
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
|
||||
corsorigins: ${cameleer.saas.provisioning.publicprotocol}://${cameleer.saas.provisioning.publichost}
|
||||
certs:
|
||||
|
||||
@@ -5,7 +5,7 @@ React 19 SPA served at `/platform/*` by the Spring Boot backend.
|
||||
## Core files
|
||||
|
||||
- `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
|
||||
- `OrgResolver.tsx` — merges global + org-scoped token scopes (vendor's platform:admin is global)
|
||||
- `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/useScopes.ts` — decode JWT scopes, hasScope()
|
||||
- `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
|
||||
|
||||
- **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`
|
||||
- **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.
|
||||
|
||||
- `SignInPage.tsx` — form with @cameleer/design-system components
|
||||
- `experience-api.ts` — Logto Experience API client (4-step: init -> verify -> identify -> submit)
|
||||
- `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. Sign-in: init -> verify password -> identify -> submit. Registration: init Register -> send verification code -> verify code -> add password profile -> identify -> submit. Auto-detects email vs username identifiers.
|
||||
|
||||
@@ -15,6 +15,9 @@ FROM ghcr.io/logto-io/logto:latest
|
||||
# Install bootstrap dependencies (curl, jq for API calls; postgresql16-client for DB reads)
|
||||
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
|
||||
COPY --from=build /ui/dist/ /etc/logto/packages/experience/dist/
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.loginForm {
|
||||
.formContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -53,6 +53,53 @@
|
||||
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 {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.switchText {
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.switchLink {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-link, #C6820E);
|
||||
font-size: 13px;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.switchLink:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.verifyHint {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary, var(--text-muted));
|
||||
margin: 0 0 4px;
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { type FormEvent, useMemo, useState } from 'react';
|
||||
import { type FormEvent, useEffect, useMemo, useState } from 'react';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import { Card, Input, Button, Alert, FormField } from '@cameleer/design-system';
|
||||
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';
|
||||
|
||||
const SUBTITLES = [
|
||||
type Mode = 'signIn' | 'register' | 'verifyCode';
|
||||
|
||||
const SIGN_IN_SUBTITLES = [
|
||||
"Prove you're not a mirage",
|
||||
"Only authorized cameleers beyond this dune",
|
||||
"Halt, traveler — state your business",
|
||||
@@ -33,20 +35,65 @@ const SUBTITLES = [
|
||||
"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() {
|
||||
const subtitle = useMemo(() => SUBTITLES[Math.floor(Math.random() * SUBTITLES.length)], []);
|
||||
const [username, setUsername] = useState('');
|
||||
const [mode, setMode] = useState<Mode>(getInitialMode);
|
||||
const subtitle = useMemo(
|
||||
() => pickRandom(mode === 'signIn' ? SIGN_IN_SUBTITLES : REGISTER_SUBTITLES),
|
||||
[mode === 'signIn' ? 'signIn' : 'register'],
|
||||
);
|
||||
|
||||
const [identifier, setIdentifier] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
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();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const redirectTo = await signIn(username, password);
|
||||
const redirectTo = await signIn(identifier, password);
|
||||
window.location.replace(redirectTo);
|
||||
} catch (err) {
|
||||
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 (
|
||||
<div className={styles.page}>
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.loginForm}>
|
||||
<div className={styles.formContainer}>
|
||||
<div className={styles.logo}>
|
||||
<img src={cameleerLogo} alt="" className={styles.logoImg} />
|
||||
Cameleer
|
||||
@@ -70,13 +170,16 @@ export function SignInPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className={styles.fields} onSubmit={handleSubmit} aria-label="Sign in" noValidate>
|
||||
<FormField label="Username" htmlFor="login-username">
|
||||
{/* --- Sign-in form --- */}
|
||||
{mode === 'signIn' && (
|
||||
<form className={styles.fields} onSubmit={handleSignIn} aria-label="Sign in" noValidate>
|
||||
<FormField label="Email or username" htmlFor="login-identifier">
|
||||
<Input
|
||||
id="login-username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Enter your username"
|
||||
id="login-identifier"
|
||||
type="email"
|
||||
value={identifier}
|
||||
onChange={(e) => setIdentifier(e.target.value)}
|
||||
placeholder="you@company.com"
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
disabled={loading}
|
||||
@@ -84,7 +187,7 @@ export function SignInPage() {
|
||||
</FormField>
|
||||
|
||||
<FormField label="Password" htmlFor="login-password">
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div className={styles.passwordWrapper}>
|
||||
<Input
|
||||
id="login-password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
@@ -94,18 +197,7 @@ export function SignInPage() {
|
||||
autoComplete="current-password"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
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>
|
||||
{passwordToggle}
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
@@ -113,12 +205,121 @@ export function SignInPage() {
|
||||
variant="primary"
|
||||
type="submit"
|
||||
loading={loading}
|
||||
disabled={loading || !username || !password}
|
||||
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>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -10,32 +10,7 @@ async function request(method: string, path: string, body?: unknown): Promise<Re
|
||||
return res;
|
||||
}
|
||||
|
||||
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})`);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
// --- Shared ---
|
||||
|
||||
export async function identifyUser(verificationId: string): Promise<void> {
|
||||
const res = await request('POST', '/identification', { verificationId });
|
||||
@@ -55,9 +30,117 @@ export async function submitInteraction(): Promise<string> {
|
||||
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();
|
||||
const verificationId = await verifyPassword(username, password);
|
||||
const verificationId = await verifyPassword(identifier, password);
|
||||
await identifyUser(verificationId);
|
||||
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();
|
||||
}
|
||||
|
||||
32
ui/src/auth/RegisterPage.tsx
Normal file
32
ui/src/auth/RegisterPage.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useLogto } from '@logto/react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Spinner } from '@cameleer/design-system';
|
||||
|
||||
export function RegisterPage() {
|
||||
const { signIn, isAuthenticated, isLoading } = useLogto();
|
||||
const navigate = useNavigate();
|
||||
const redirected = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
navigate('/', { replace: true });
|
||||
}
|
||||
}, [isAuthenticated, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated && !redirected.current) {
|
||||
redirected.current = true;
|
||||
signIn({
|
||||
redirectUri: `${window.location.origin}/platform/callback`,
|
||||
firstScreen: 'register',
|
||||
});
|
||||
}
|
||||
}, [isLoading, isAuthenticated, signIn]);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}>
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
ui/src/pages/OnboardingPage.module.css
Normal file
59
ui/src/pages/OnboardingPage.module.css
Normal 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%;
|
||||
}
|
||||
103
ui/src/pages/OnboardingPage.tsx
Normal file
103
ui/src/pages/OnboardingPage.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, Input, Button, FormField, Alert } from '@cameleer/design-system';
|
||||
import cameleerLogo from '@cameleer/design-system/assets/cameleer-logo.svg';
|
||||
import { api } from '../api/client';
|
||||
import { toSlug } from '../utils/slug';
|
||||
import styles from './OnboardingPage.module.css';
|
||||
|
||||
interface TenantResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export function OnboardingPage() {
|
||||
const [name, setName] = useState('');
|
||||
const [slug, setSlug] = useState('');
|
||||
const [slugTouched, setSlugTouched] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!slugTouched) {
|
||||
setSlug(toSlug(name));
|
||||
}
|
||||
}, [name, slugTouched]);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.post<TenantResponse>('/onboarding/tenant', { name, slug });
|
||||
// Tenant created — force a full page reload so the Logto SDK
|
||||
// picks up the new org membership and scopes on the next token refresh.
|
||||
window.location.href = '/platform/';
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create tenant');
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.wrapper}>
|
||||
<Card>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Routes, Route, Navigate } from 'react-router';
|
||||
import { LoginPage } from './auth/LoginPage';
|
||||
import { RegisterPage } from './auth/RegisterPage';
|
||||
import { CallbackPage } from './auth/CallbackPage';
|
||||
import { ProtectedRoute } from './auth/ProtectedRoute';
|
||||
import { OrgResolver } from './auth/OrgResolver';
|
||||
@@ -21,6 +22,7 @@ import { SsoPage } from './pages/tenant/SsoPage';
|
||||
import { TeamPage } from './pages/tenant/TeamPage';
|
||||
import { SettingsPage } from './pages/tenant/SettingsPage';
|
||||
import { TenantAuditPage } from './pages/tenant/TenantAuditPage';
|
||||
import { OnboardingPage } from './pages/OnboardingPage';
|
||||
|
||||
function LandingRedirect() {
|
||||
const scopes = useScopes();
|
||||
@@ -45,7 +47,11 @@ function LandingRedirect() {
|
||||
window.location.href = `/t/${currentOrg.slug}/`;
|
||||
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 />;
|
||||
}
|
||||
|
||||
@@ -53,9 +59,12 @@ export function AppRouter() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route path="/callback" element={<CallbackPage />} />
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route element={<OrgResolver />}>
|
||||
{/* Onboarding — outside Layout, shown to users with no tenants */}
|
||||
<Route path="/onboarding" element={<OnboardingPage />} />
|
||||
<Route element={<Layout />}>
|
||||
{/* Vendor console */}
|
||||
<Route path="/vendor/tenants" element={
|
||||
|
||||
Reference in New Issue
Block a user