feat: self-service sign-up with email verification and onboarding
All checks were successful
CI / build (push) Successful in 1m14s
CI / docker (push) Successful in 1m15s

Complete sign-up pipeline: email registration via Logto Experience API,
SMTP email verification, and self-service trial tenant creation.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-25 00:21:07 +02:00
parent dc7ac3a1ec
commit 9ed2cedc98
24 changed files with 1011 additions and 95 deletions

View File

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

View File

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