From 9ed2cedc98d8263f51684467b5ec6edb1db7527c Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sat, 25 Apr 2026 00:21:07 +0200 Subject: [PATCH] feat: self-service sign-up with email verification and onboarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete sign-up pipeline: email registration via Logto Experience API, SMTP email verification, and self-service trial tenant creation. Layer 1 — Logto config: - Bootstrap Phase 8b: SMTP email connector with branded HTML templates - Bootstrap Phase 8c: enable SignInAndRegister (email+password sign-up) - Dockerfile installs official Logto connectors (ensures SMTP available) - SMTP env vars in docker-compose, installer templates, .env.example Layer 2 — Experience API (ui/sign-in/experience-api.ts): - Registration flow: initRegistration → sendVerificationCode → verifyCode → addProfile (password) → identifyUser → submit - Sign-in auto-detects email vs username identifier Layer 3 — Custom sign-in UI (ui/sign-in/SignInPage.tsx): - Three-mode state machine: signIn / register / verifyCode - Reads first_screen=register from URL query params - Toggle links between sign-in and register views Layer 4 — Post-registration onboarding: - OnboardingService: reuses VendorTenantService.createAndProvision(), adds calling user to Logto org as owner, enforces one trial per user - OnboardingController: POST /api/onboarding/tenant (authenticated only) - OnboardingPage.tsx: org name + auto-slug form - LandingRedirect: detects zero orgs → redirects to /onboarding - RegisterPage.tsx: /platform/register initiates OIDC with firstScreen Installers (install.sh + install.ps1): - Both prompt for SMTP config in SaaS mode - CLI args, env var capture, cameleer.conf persistence Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 8 + AGENTS.md | 2 +- CLAUDE.md | 5 +- docker-compose.yml | 6 + docker/CLAUDE.md | 12 +- docker/logto-bootstrap.sh | 117 +++++++ installer/CLAUDE.md | 9 + installer/install.ps1 | 49 +++ installer/install.sh | 49 +++ installer/templates/.env.example | 10 + installer/templates/docker-compose.saas.yml | 6 + .../siegeln/cameleer/saas/config/CLAUDE.md | 5 +- .../cameleer/saas/config/SecurityConfig.java | 5 +- .../saas/onboarding/OnboardingController.java | 47 +++ .../saas/onboarding/OnboardingService.java | 70 ++++ ui/CLAUDE.md | 9 +- ui/sign-in/Dockerfile | 3 + ui/sign-in/src/SignInPage.module.css | 49 ++- ui/sign-in/src/SignInPage.tsx | 301 +++++++++++++++--- ui/sign-in/src/experience-api.ts | 139 ++++++-- ui/src/auth/RegisterPage.tsx | 32 ++ ui/src/pages/OnboardingPage.module.css | 59 ++++ ui/src/pages/OnboardingPage.tsx | 103 ++++++ ui/src/router.tsx | 11 +- 24 files changed, 1011 insertions(+), 95 deletions(-) create mode 100644 src/main/java/net/siegeln/cameleer/saas/onboarding/OnboardingController.java create mode 100644 src/main/java/net/siegeln/cameleer/saas/onboarding/OnboardingService.java create mode 100644 ui/src/auth/RegisterPage.tsx create mode 100644 ui/src/pages/OnboardingPage.module.css create mode 100644 ui/src/pages/OnboardingPage.tsx 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 (
-
+
Cameleer @@ -70,55 +164,156 @@ export function SignInPage() {
)} -
- - setUsername(e.target.value)} - placeholder="Enter your username" - autoFocus - autoComplete="username" - disabled={loading} - /> - - - -
+ {/* --- Sign-in form --- */} + {mode === 'signIn' && ( + + setPassword(e.target.value)} - placeholder="••••••••" - autoComplete="current-password" + id="login-identifier" + type="email" + value={identifier} + onChange={(e) => setIdentifier(e.target.value)} + placeholder="you@company.com" + autoFocus + autoComplete="username" disabled={loading} /> - -
-
+ - -
+ +
+ setPassword(e.target.value)} + placeholder="••••••••" + autoComplete="current-password" + disabled={loading} + /> + {passwordToggle} +
+
+ + + +

+ Don't have an account?{' '} + +

+ + )} + + {/* --- Register form --- */} + {mode === 'register' && ( +
+ + setIdentifier(e.target.value)} + placeholder="you@company.com" + autoFocus + autoComplete="email" + disabled={loading} + /> + + + +
+ setPassword(e.target.value)} + placeholder="At least 8 characters" + autoComplete="new-password" + disabled={loading} + /> + {passwordToggle} +
+
+ + + setConfirmPassword(e.target.value)} + placeholder="••••••••" + autoComplete="new-password" + disabled={loading} + /> + + + + +

+ Already have an account?{' '} + +

+
+ )} + + {/* --- Verification code form --- */} + {mode === 'verifyCode' && ( +
+

+ We sent a verification code to {identifier} +

+ + + setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} + placeholder="000000" + autoFocus + autoComplete="one-time-code" + disabled={loading} + /> + + + + +

+ +

+
+ )}
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 && ( +
+ {error} +
+ )} + +
+ + setName(e.target.value)} + placeholder="Acme Corp" + autoFocus + disabled={loading} + required + /> + + + + { setSlugTouched(true); setSlug(e.target.value); }} + placeholder="acme-corp" + disabled={loading} + required + /> + + + +
+
+
+
+
+ ); +} 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 */}