diff --git a/.env.example b/.env.example
index ba0ff0a..109633d 100644
--- a/.env.example
+++ b/.env.example
@@ -28,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=
diff --git a/AGENTS.md b/AGENTS.md
index 760847f..ddab98d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,7 +1,7 @@
# 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.
diff --git a/CLAUDE.md b/CLAUDE.md
index e97f07f..c2afe46 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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 — 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.
diff --git a/docker-compose.yml b/docker-compose.yml
index 034369d..0a8c917 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -79,6 +79,12 @@ 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}
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
diff --git a/docker/CLAUDE.md b/docker/CLAUDE.md
index fce073c..17d34b6 100644
--- a/docker/CLAUDE.md
+++ b/docker/CLAUDE.md
@@ -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`.
diff --git a/docker/logto-bootstrap.sh b/docker/logto-bootstrap.sh
index c6b3372..21f6aca 100644
--- a/docker/logto-bootstrap.sh
+++ b/docker/logto-bootstrap.sh
@@ -564,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: "
Cameleer
Enter this code to verify your email and create your account:
{{code}}
This code expires in 10 minutes. If you did not request this, you can safely ignore this email.
"
+ },
+ {
+ usageType: "SignIn",
+ contentType: "text/html",
+ subject: "Your Cameleer sign-in code",
+ content: "Cameleer
Your sign-in verification code:
{{code}}
This code expires in 10 minutes.
"
+ },
+ {
+ usageType: "ForgotPassword",
+ contentType: "text/html",
+ subject: "Reset your Cameleer password",
+ content: "Cameleer
Enter this code to reset your password:
{{code}}
This code expires in 10 minutes. If you did not request a password reset, you can safely ignore this email.
"
+ },
+ {
+ usageType: "Generic",
+ contentType: "text/html",
+ subject: "Your Cameleer verification code",
+ content: "Cameleer
Your verification code:
{{code}}
This code expires in 10 minutes.
"
+ }
+ ]
+ }')
+
+ # 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
# ============================================================
diff --git a/installer/CLAUDE.md b/installer/CLAUDE.md
index 8e304f5..4249b78 100644
--- a/installer/CLAUDE.md
+++ b/installer/CLAUDE.md
@@ -23,10 +23,19 @@ 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@`). 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.
+
## 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
diff --git a/installer/install.ps1 b/installer/install.ps1
index 07f78da..686afe0 100644
--- a/installer/install.ps1
+++ b/installer/install.ps1
@@ -37,6 +37,11 @@ param(
[string]$DockerSocket,
[string]$NodeTlsReject,
[string]$DeploymentMode,
+ [string]$SmtpHost,
+ [string]$SmtpPort,
+ [string]$SmtpUser,
+ [string]$SmtpPass,
+ [string]$SmtpFromEmail,
[switch]$Reconfigure,
[switch]$Reinstall,
[switch]$ConfirmDestroy,
@@ -84,6 +89,11 @@ $_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
# --- Mutable config state ---
@@ -110,6 +120,11 @@ $script:cfg = @{
DockerSocket = $DockerSocket
NodeTlsReject = $NodeTlsReject
DeploymentMode = $DeploymentMode
+ SmtpHost = $SmtpHost
+ SmtpPort = $SmtpPort
+ SmtpUser = $SmtpUser
+ SmtpPass = $SmtpPass
+ SmtpFromEmail = $SmtpFromEmail
}
if ($Silent) { $script:Mode = 'silent' }
@@ -260,6 +275,11 @@ 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 } }
}
}
}
@@ -289,6 +309,11 @@ 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 }
}
# --- Prerequisites ---
@@ -447,6 +472,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 {
@@ -688,6 +725,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=$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'
@@ -915,6 +959,11 @@ 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)
"@
Write-Utf8File $f $txt
Log-Info 'Saved installer config to cameleer.conf'
diff --git a/installer/install.sh b/installer/install.sh
index 4e1744e..4399182 100644
--- a/installer/install.sh
+++ b/installer/install.sh
@@ -46,6 +46,11 @@ _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:-}"
INSTALL_DIR=""
PUBLIC_HOST=""
@@ -69,6 +74,11 @@ COMPOSE_PROJECT=""
DOCKER_SOCKET=""
NODE_TLS_REJECT=""
DEPLOYMENT_MODE=""
+SMTP_HOST=""
+SMTP_PORT=""
+SMTP_USER=""
+SMTP_PASS=""
+SMTP_FROM_EMAIL=""
# --- State ---
MODE="" # simple, expert, silent
@@ -170,6 +180,11 @@ 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 ;;
--server-admin-user) ADMIN_USER="$2"; shift ;;
--server-admin-password) ADMIN_PASS="$2"; shift ;;
--reconfigure) RERUN_ACTION="reconfigure" ;;
@@ -255,6 +270,11 @@ 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" ;;
esac
done < "$file"
}
@@ -282,6 +302,11 @@ 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"
}
# --- Prerequisites ---
@@ -434,6 +459,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() {
@@ -696,6 +733,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
@@ -867,6 +911,11 @@ 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}
EOF
log_info "Saved installer config to cameleer.conf"
}
diff --git a/installer/templates/.env.example b/installer/templates/.env.example
index 34bb45f..f5618a3 100644
--- a/installer/templates/.env.example
+++ b/installer/templates/.env.example
@@ -60,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
# ============================================================
diff --git a/installer/templates/docker-compose.saas.yml b/installer/templates/docker-compose.saas.yml
index 69d2d69..833bf1f 100644
--- a/installer/templates/docker-compose.saas.yml
+++ b/installer/templates/docker-compose.saas.yml
@@ -26,6 +26,12 @@ 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}
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
diff --git a/src/main/java/net/siegeln/cameleer/saas/config/CLAUDE.md b/src/main/java/net/siegeln/cameleer/saas/config/CLAUDE.md
index 3a0624f..442a427 100644
--- a/src/main/java/net/siegeln/cameleer/saas/config/CLAUDE.md
+++ b/src/main/java/net/siegeln/cameleer/saas/config/CLAUDE.md
@@ -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)
diff --git a/src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java b/src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java
index f16ab1d..a219d56 100644
--- a/src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java
+++ b/src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java
@@ -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()
diff --git a/src/main/java/net/siegeln/cameleer/saas/onboarding/OnboardingController.java b/src/main/java/net/siegeln/cameleer/saas/onboarding/OnboardingController.java
new file mode 100644
index 0000000..df394b0
--- /dev/null
+++ b/src/main/java/net/siegeln/cameleer/saas/onboarding/OnboardingController.java
@@ -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 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));
+ }
+}
diff --git a/src/main/java/net/siegeln/cameleer/saas/onboarding/OnboardingService.java b/src/main/java/net/siegeln/cameleer/saas/onboarding/OnboardingService.java
new file mode 100644
index 0000000..bfd987d
--- /dev/null
+++ b/src/main/java/net/siegeln/cameleer/saas/onboarding/OnboardingService.java
@@ -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());
+ }
+ }
+}
diff --git a/ui/CLAUDE.md b/ui/CLAUDE.md
index c335a9f..98888f5 100644
--- a/ui/CLAUDE.md
+++ b/ui/CLAUDE.md
@@ -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.
diff --git a/ui/sign-in/Dockerfile b/ui/sign-in/Dockerfile
index d95aa69..be2716a 100644
--- a/ui/sign-in/Dockerfile
+++ b/ui/sign-in/Dockerfile
@@ -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/
diff --git a/ui/sign-in/src/SignInPage.module.css b/ui/sign-in/src/SignInPage.module.css
index 5d273d5..6980085 100644
--- a/ui/sign-in/src/SignInPage.module.css
+++ b/ui/sign-in/src/SignInPage.module.css
@@ -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;
+}
diff --git a/ui/sign-in/src/SignInPage.tsx b/ui/sign-in/src/SignInPage.tsx
index 551cef0..502feec 100644
--- a/ui/sign-in/src/SignInPage.tsx
+++ b/ui/sign-in/src/SignInPage.tsx
@@ -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,63 @@ 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);
+ return params.get('first_screen') === 'register' ? 'register' : 'signIn';
+}
+
export function SignInPage() {
- const subtitle = useMemo(() => SUBTITLES[Math.floor(Math.random() * SUBTITLES.length)], []);
- const [username, setUsername] = useState('');
+ const [mode, setMode] = useState(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(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 +99,59 @@ export function SignInPage() {
}
};
+ // --- Register step 1: send verification code ---
+ const handleRegister = async (e: FormEvent) => {
+ e.preventDefault();
+ setError(null);
+ 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 = (
+
+ );
+
return (
-
diff --git a/ui/sign-in/src/experience-api.ts b/ui/sign-in/src/experience-api.ts
index fede0f1..042a736 100644
--- a/ui/sign-in/src/experience-api.ts
+++ b/ui/sign-in/src/experience-api.ts
@@ -10,32 +10,7 @@ async function request(method: string, path: string, body?: unknown): Promise {
- 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 {
- 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 {
const res = await request('POST', '/identification', { verificationId });
@@ -55,9 +30,117 @@ export async function submitInteraction(): Promise {
return data.redirectTo;
}
-export async function signIn(username: string, password: string): Promise {
+// --- Sign-in ---
+
+export async function initInteraction(): Promise {
+ 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 {
+ 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 {
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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ const verifiedId = await verifyCode(email, verificationId, code);
+ await addProfile('password', password);
+ await identifyUser(verifiedId);
+ return submitInteraction();
+}
diff --git a/ui/src/auth/RegisterPage.tsx b/ui/src/auth/RegisterPage.tsx
new file mode 100644
index 0000000..6fb9f80
--- /dev/null
+++ b/ui/src/auth/RegisterPage.tsx
@@ -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 (
+
+
+
+ );
+}
diff --git a/ui/src/pages/OnboardingPage.module.css b/ui/src/pages/OnboardingPage.module.css
new file mode 100644
index 0000000..f10adee
--- /dev/null
+++ b/ui/src/pages/OnboardingPage.module.css
@@ -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%;
+}
diff --git a/ui/src/pages/OnboardingPage.tsx b/ui/src/pages/OnboardingPage.tsx
new file mode 100644
index 0000000..938de71
--- /dev/null
+++ b/ui/src/pages/OnboardingPage.tsx
@@ -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(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('/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 (
+
+
+
+
+
+

+ Welcome to Cameleer
+
+
+ Set up your workspace to start monitoring your Camel routes.
+
+
+ {error && (
+
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/ui/src/router.tsx b/ui/src/router.tsx
index ebf0fd9..670b34c 100644
--- a/ui/src/router.tsx
+++ b/ui/src/router.tsx
@@ -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 ;
+ }
+ // Has org but no scopes resolved yet — stay on tenant portal
return ;
}
@@ -53,9 +59,12 @@ export function AppRouter() {
return (
} />
+ } />
} />
}>
}>
+ {/* Onboarding — outside Layout, shown to users with no tenants */}
+ } />
}>
{/* Vendor console */}