feat: self-service sign-up with email verification and onboarding
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:
@@ -28,6 +28,14 @@ CLICKHOUSE_PASSWORD=change_me_in_production
|
|||||||
SAAS_ADMIN_USER=admin
|
SAAS_ADMIN_USER=admin
|
||||||
SAAS_ADMIN_PASS=change_me_in_production
|
SAAS_ADMIN_PASS=change_me_in_production
|
||||||
|
|
||||||
|
# SMTP (for email verification during registration)
|
||||||
|
# Required for self-service sign-up. Without SMTP, only admin-created users can sign in.
|
||||||
|
SMTP_HOST=
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=
|
||||||
|
SMTP_PASS=
|
||||||
|
SMTP_FROM_EMAIL=noreply@cameleer.io
|
||||||
|
|
||||||
# TLS (leave empty for self-signed)
|
# TLS (leave empty for self-signed)
|
||||||
# NODE_TLS_REJECT=0 # Set to 1 when using real certificates
|
# NODE_TLS_REJECT=0 # Set to 1 when using real certificates
|
||||||
# CERT_FILE=
|
# CERT_FILE=
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<!-- gitnexus:start -->
|
<!-- gitnexus:start -->
|
||||||
# GitNexus — Code Intelligence
|
# GitNexus — Code Intelligence
|
||||||
|
|
||||||
This project is indexed by GitNexus as **cameleer-saas** (2816 symbols, 5989 relationships, 238 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
This project is indexed by GitNexus as **cameleer-saas** (2838 symbols, 6037 relationships, 239 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||||
|
|
||||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## Project
|
## Project
|
||||||
|
|
||||||
Cameleer SaaS — **vendor management plane** for the Cameleer observability stack. Two personas: **vendor** (platform:admin) manages the platform and provisions tenants; **tenant admin** (tenant:manage) manages their observability instance. The vendor creates tenants, which provisions per-tenant cameleer-server + UI instances via Docker API. No example tenant — clean slate bootstrap, vendor creates everything.
|
Cameleer SaaS — **vendor management plane** for the Cameleer observability stack. Three personas: **vendor** (platform:admin) manages the platform and provisions tenants; **tenant admin** (tenant:manage) manages their observability instance; **new user** (authenticated, no scopes) goes through self-service onboarding. Tenants can be created by the vendor OR via self-service sign-up (email registration + onboarding wizard). Each tenant gets per-tenant cameleer-server + UI instances via Docker API.
|
||||||
|
|
||||||
## Ecosystem
|
## Ecosystem
|
||||||
|
|
||||||
@@ -26,6 +26,7 @@ Agent-server protocol is defined in `cameleer/cameleer-common/PROTOCOL.md`. The
|
|||||||
| `config/` | Security, tenant isolation, web config | `SecurityConfig`, `TenantIsolationInterceptor`, `TenantContext`, `PublicConfigController`, `MeController` |
|
| `config/` | Security, tenant isolation, web config | `SecurityConfig`, `TenantIsolationInterceptor`, `TenantContext`, `PublicConfigController`, `MeController` |
|
||||||
| `tenant/` | Tenant data model | `TenantEntity` (JPA: id, name, slug, tier, status, logto_org_id, db_password) |
|
| `tenant/` | Tenant data model | `TenantEntity` (JPA: id, name, slug, tier, status, logto_org_id, db_password) |
|
||||||
| `vendor/` | Vendor console (platform:admin) | `VendorTenantService`, `VendorTenantController`, `InfrastructureService` |
|
| `vendor/` | Vendor console (platform:admin) | `VendorTenantService`, `VendorTenantController`, `InfrastructureService` |
|
||||||
|
| `onboarding/` | Self-service sign-up onboarding | `OnboardingController`, `OnboardingService` |
|
||||||
| `portal/` | Tenant admin portal (org-scoped) | `TenantPortalService`, `TenantPortalController` |
|
| `portal/` | Tenant admin portal (org-scoped) | `TenantPortalService`, `TenantPortalController` |
|
||||||
| `provisioning/` | Pluggable tenant provisioning | `DockerTenantProvisioner`, `TenantDatabaseService`, `TenantDataCleanupService` |
|
| `provisioning/` | Pluggable tenant provisioning | `DockerTenantProvisioner`, `TenantDatabaseService`, `TenantDataCleanupService` |
|
||||||
| `certificate/` | TLS certificate lifecycle | `CertificateService`, `CertificateController`, `TenantCaCertService` |
|
| `certificate/` | TLS certificate lifecycle | `CertificateService`, `CertificateController`, `TenantCaCertService` |
|
||||||
@@ -75,7 +76,7 @@ PostgreSQL (Flyway): `src/main/resources/db/migration/`
|
|||||||
<!-- gitnexus:start -->
|
<!-- gitnexus:start -->
|
||||||
# GitNexus — Code Intelligence
|
# GitNexus — Code Intelligence
|
||||||
|
|
||||||
This project is indexed by GitNexus as **cameleer-saas** (2816 symbols, 5989 relationships, 238 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
This project is indexed by GitNexus as **cameleer-saas** (2838 symbols, 6037 relationships, 239 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||||
|
|
||||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,12 @@ services:
|
|||||||
PG_DB_SAAS: ${POSTGRES_DB:-cameleer_saas}
|
PG_DB_SAAS: ${POSTGRES_DB:-cameleer_saas}
|
||||||
SAAS_ADMIN_USER: ${SAAS_ADMIN_USER:-admin}
|
SAAS_ADMIN_USER: ${SAAS_ADMIN_USER:-admin}
|
||||||
SAAS_ADMIN_PASS: ${SAAS_ADMIN_PASS:-admin}
|
SAAS_ADMIN_PASS: ${SAAS_ADMIN_PASS:-admin}
|
||||||
|
# SMTP (for email verification during registration)
|
||||||
|
SMTP_HOST: ${SMTP_HOST:-}
|
||||||
|
SMTP_PORT: ${SMTP_PORT:-587}
|
||||||
|
SMTP_USER: ${SMTP_USER:-}
|
||||||
|
SMTP_PASS: ${SMTP_PASS:-}
|
||||||
|
SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL:-noreply@cameleer.io}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3001/oidc/.well-known/openid-configuration', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))\" && test -f /data/logto-bootstrap.json"]
|
test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3001/oidc/.well-known/openid-configuration', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))\" && test -f /data/logto-bootstrap.json"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
|
|||||||
@@ -42,11 +42,13 @@ Server containers join three networks: tenant network (primary), shared services
|
|||||||
|
|
||||||
## Custom sign-in UI (`ui/sign-in/`)
|
## Custom sign-in UI (`ui/sign-in/`)
|
||||||
|
|
||||||
Separate Vite+React SPA replacing Logto's default sign-in page. Visually matches cameleer-server LoginPage.
|
Separate Vite+React SPA replacing Logto's default sign-in page. Supports both sign-in and self-service registration.
|
||||||
|
|
||||||
- Built as custom Logto Docker image (`cameleer-logto`): `ui/sign-in/Dockerfile` = node build stage + `FROM ghcr.io/logto-io/logto:latest` + COPY dist over `/etc/logto/packages/experience/dist/`
|
- Built as custom Logto Docker image (`cameleer-logto`): `ui/sign-in/Dockerfile` = node build stage + `FROM ghcr.io/logto-io/logto:latest` + install official connectors (SMTP) + COPY dist over `/etc/logto/packages/experience/dist/`
|
||||||
- Uses `@cameleer/design-system` components (Card, Input, Button, FormField, Alert)
|
- Uses `@cameleer/design-system` components (Card, Input, Button, FormField, Alert)
|
||||||
- Authenticates via Logto Experience API (4-step: init -> verify password -> identify -> submit -> redirect)
|
- **Sign-in**: Logto Experience API (4-step: init -> verify password -> identify -> submit -> redirect). Auto-detects email vs username identifier.
|
||||||
|
- **Registration**: 2-phase flow. Phase 1: init Register -> send verification code to email. Phase 2: verify code -> set password -> identify (creates user) -> submit -> redirect.
|
||||||
|
- Reads `first_screen=register` from URL query params to show register form initially (set by `@logto/react` SDK's `firstScreen` option)
|
||||||
- `CUSTOM_UI_PATH` env var does NOT work for Logto OSS — must volume-mount or replace the experience dist directory
|
- `CUSTOM_UI_PATH` env var does NOT work for Logto OSS — must volume-mount or replace the experience dist directory
|
||||||
- Favicon bundled in `ui/sign-in/public/favicon.svg` (served by Logto, not SaaS)
|
- Favicon bundled in `ui/sign-in/public/favicon.svg` (served by Logto, not SaaS)
|
||||||
|
|
||||||
@@ -81,8 +83,12 @@ Idempotent script run inside the Logto container entrypoint. **Clean slate** —
|
|||||||
5. Create admin user (SaaS admin with Logto console access)
|
5. Create admin user (SaaS admin with Logto console access)
|
||||||
7b. Configure Logto Custom JWT for access tokens (maps org roles -> `roles` claim: owner->server:admin, operator->server:operator, viewer->server:viewer; saas-vendor global role -> server:admin)
|
7b. Configure Logto Custom JWT for access tokens (maps org roles -> `roles` claim: owner->server:admin, operator->server:operator, viewer->server:viewer; saas-vendor global role -> server:admin)
|
||||||
8. Configure Logto sign-in branding (Cameleer colors `#C6820E`/`#D4941E`, logo from `/platform/logo.svg`)
|
8. Configure Logto sign-in branding (Cameleer colors `#C6820E`/`#D4941E`, logo from `/platform/logo.svg`)
|
||||||
|
8b. Configure SMTP email connector (if `SMTP_HOST`/`SMTP_USER` env vars set) — discovers factory via `/api/connector-factories`, creates connector with Cameleer-branded HTML email templates for Register/SignIn/ForgotPassword/Generic. Skips gracefully if SMTP not configured.
|
||||||
|
8c. Enable self-service registration — sets `signInMode: "SignInAndRegister"`, `signUp: { identifiers: ["email"], password: true, verify: true }`, sign-in methods: email+password and username+password (backwards-compatible with admin user).
|
||||||
9. Cleanup seeded Logto apps
|
9. Cleanup seeded Logto apps
|
||||||
10. Write bootstrap results to `/data/logto-bootstrap.json`
|
10. Write bootstrap results to `/data/logto-bootstrap.json`
|
||||||
12. Create `saas-vendor` global role with all API scopes and assign to admin user (always runs — admin IS the platform admin).
|
12. Create `saas-vendor` global role with all API scopes and assign to admin user (always runs — admin IS the platform admin).
|
||||||
|
|
||||||
|
SMTP env vars for email verification: `SMTP_HOST`, `SMTP_PORT` (default 587), `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM_EMAIL` (default `noreply@cameleer.io`). Passed to `cameleer-logto` container via docker-compose. Both installers prompt for these in SaaS mode.
|
||||||
|
|
||||||
The multi-tenant compose stack is: Traefik + PostgreSQL + ClickHouse + Logto (with bootstrap entrypoint) + cameleer-saas. No `cameleer-server` or `cameleer-server-ui` in compose — those are provisioned per-tenant by `DockerTenantProvisioner`.
|
The multi-tenant compose stack is: Traefik + PostgreSQL + ClickHouse + Logto (with bootstrap entrypoint) + cameleer-saas. No `cameleer-server` or `cameleer-server-ui` in compose — those are provisioned per-tenant by `DockerTenantProvisioner`.
|
||||||
|
|||||||
@@ -564,6 +564,123 @@ api_patch "/api/sign-in-exp" "{
|
|||||||
}"
|
}"
|
||||||
log "Sign-in branding configured."
|
log "Sign-in branding configured."
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# PHASE 8b: Configure SMTP email connector
|
||||||
|
# ============================================================
|
||||||
|
# Required for email verification during registration and password reset.
|
||||||
|
# Skipped if SMTP_HOST is not set (registration will not work without email delivery).
|
||||||
|
|
||||||
|
if [ -n "${SMTP_HOST:-}" ] && [ -n "${SMTP_USER:-}" ]; then
|
||||||
|
log "Configuring SMTP email connector..."
|
||||||
|
|
||||||
|
# Discover available email connector factories
|
||||||
|
FACTORIES=$(api_get "/api/connector-factories")
|
||||||
|
# Prefer a factory with "smtp" in the ID
|
||||||
|
SMTP_FACTORY_ID=$(echo "$FACTORIES" | jq -r '[.[] | select(.type == "Email" and (.id | test("smtp"; "i")))] | .[0].id // empty')
|
||||||
|
if [ -z "$SMTP_FACTORY_ID" ]; then
|
||||||
|
# Fall back to any non-demo Email factory
|
||||||
|
SMTP_FACTORY_ID=$(echo "$FACTORIES" | jq -r '[.[] | select(.type == "Email" and .isDemo != true)] | .[0].id // empty')
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$SMTP_FACTORY_ID" ]; then
|
||||||
|
# Build SMTP config JSON
|
||||||
|
SMTP_CONFIG=$(jq -n \
|
||||||
|
--arg host "$SMTP_HOST" \
|
||||||
|
--arg port "${SMTP_PORT:-587}" \
|
||||||
|
--arg user "$SMTP_USER" \
|
||||||
|
--arg pass "${SMTP_PASS:-}" \
|
||||||
|
--arg from "${SMTP_FROM_EMAIL:-noreply@cameleer.io}" \
|
||||||
|
'{
|
||||||
|
host: $host,
|
||||||
|
port: ($port | tonumber),
|
||||||
|
auth: { user: $user, pass: $pass },
|
||||||
|
fromEmail: $from,
|
||||||
|
templates: [
|
||||||
|
{
|
||||||
|
usageType: "Register",
|
||||||
|
contentType: "text/html",
|
||||||
|
subject: "Verify your email for Cameleer",
|
||||||
|
content: "<div style=\"font-family:sans-serif;max-width:480px;margin:0 auto;padding:24px\"><div style=\"text-align:center;margin-bottom:24px\"><span style=\"font-size:24px;font-weight:700;color:#C6820E\">Cameleer</span></div><p style=\"color:#333;font-size:15px;line-height:1.6\">Enter this code to verify your email and create your account:</p><div style=\"text-align:center;margin:24px 0\"><span style=\"font-size:32px;font-weight:700;letter-spacing:6px;color:#C6820E\">{{code}}</span></div><p style=\"color:#666;font-size:13px\">This code expires in 10 minutes. If you did not request this, you can safely ignore this email.</p></div>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
usageType: "SignIn",
|
||||||
|
contentType: "text/html",
|
||||||
|
subject: "Your Cameleer sign-in code",
|
||||||
|
content: "<div style=\"font-family:sans-serif;max-width:480px;margin:0 auto;padding:24px\"><div style=\"text-align:center;margin-bottom:24px\"><span style=\"font-size:24px;font-weight:700;color:#C6820E\">Cameleer</span></div><p style=\"color:#333;font-size:15px;line-height:1.6\">Your sign-in verification code:</p><div style=\"text-align:center;margin:24px 0\"><span style=\"font-size:32px;font-weight:700;letter-spacing:6px;color:#C6820E\">{{code}}</span></div><p style=\"color:#666;font-size:13px\">This code expires in 10 minutes.</p></div>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
usageType: "ForgotPassword",
|
||||||
|
contentType: "text/html",
|
||||||
|
subject: "Reset your Cameleer password",
|
||||||
|
content: "<div style=\"font-family:sans-serif;max-width:480px;margin:0 auto;padding:24px\"><div style=\"text-align:center;margin-bottom:24px\"><span style=\"font-size:24px;font-weight:700;color:#C6820E\">Cameleer</span></div><p style=\"color:#333;font-size:15px;line-height:1.6\">Enter this code to reset your password:</p><div style=\"text-align:center;margin:24px 0\"><span style=\"font-size:32px;font-weight:700;letter-spacing:6px;color:#C6820E\">{{code}}</span></div><p style=\"color:#666;font-size:13px\">This code expires in 10 minutes. If you did not request a password reset, you can safely ignore this email.</p></div>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
usageType: "Generic",
|
||||||
|
contentType: "text/html",
|
||||||
|
subject: "Your Cameleer verification code",
|
||||||
|
content: "<div style=\"font-family:sans-serif;max-width:480px;margin:0 auto;padding:24px\"><div style=\"text-align:center;margin-bottom:24px\"><span style=\"font-size:24px;font-weight:700;color:#C6820E\">Cameleer</span></div><p style=\"color:#333;font-size:15px;line-height:1.6\">Your verification code:</p><div style=\"text-align:center;margin:24px 0\"><span style=\"font-size:32px;font-weight:700;letter-spacing:6px;color:#C6820E\">{{code}}</span></div><p style=\"color:#666;font-size:13px\">This code expires in 10 minutes.</p></div>"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}')
|
||||||
|
|
||||||
|
# Check if an email connector already exists
|
||||||
|
EXISTING_CONNECTORS=$(api_get "/api/connectors")
|
||||||
|
EMAIL_CONNECTOR_ID=$(echo "$EXISTING_CONNECTORS" | jq -r '[.[] | select(.type == "Email")] | .[0].id // empty')
|
||||||
|
|
||||||
|
if [ -n "$EMAIL_CONNECTOR_ID" ]; then
|
||||||
|
api_patch "/api/connectors/$EMAIL_CONNECTOR_ID" "{\"config\": $SMTP_CONFIG}" >/dev/null 2>&1
|
||||||
|
log "Updated existing email connector: $EMAIL_CONNECTOR_ID"
|
||||||
|
else
|
||||||
|
CONNECTOR_RESPONSE=$(api_post "/api/connectors" "{\"connectorId\": \"$SMTP_FACTORY_ID\", \"config\": $SMTP_CONFIG}")
|
||||||
|
CREATED_ID=$(echo "$CONNECTOR_RESPONSE" | jq -r '.id // empty')
|
||||||
|
if [ -n "$CREATED_ID" ]; then
|
||||||
|
log "Created SMTP email connector: $CREATED_ID (factory: $SMTP_FACTORY_ID)"
|
||||||
|
else
|
||||||
|
log "WARNING: Failed to create SMTP connector. Response: $(echo "$CONNECTOR_RESPONSE" | head -c 300)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log "WARNING: No email connector factory found — email delivery will not work."
|
||||||
|
log "Available factories: $(echo "$FACTORIES" | jq -c '[.[] | select(.type == "Email") | .id]')"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log "SMTP not configured (SMTP_HOST/SMTP_USER not set) — email delivery disabled."
|
||||||
|
log "Set SMTP_HOST, SMTP_USER, SMTP_PASS, SMTP_FROM_EMAIL env vars to enable."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# PHASE 8c: Enable registration (email + password)
|
||||||
|
# ============================================================
|
||||||
|
# Configures sign-in experience to allow self-service registration with email verification.
|
||||||
|
# This runs AFTER the SMTP connector so email delivery is ready before registration opens.
|
||||||
|
|
||||||
|
log "Configuring sign-in experience for registration..."
|
||||||
|
api_patch "/api/sign-in-exp" '{
|
||||||
|
"signInMode": "SignInAndRegister",
|
||||||
|
"signUp": {
|
||||||
|
"identifiers": ["email"],
|
||||||
|
"password": true,
|
||||||
|
"verify": true
|
||||||
|
},
|
||||||
|
"signIn": {
|
||||||
|
"methods": [
|
||||||
|
{
|
||||||
|
"identifier": "email",
|
||||||
|
"password": true,
|
||||||
|
"verificationCode": false,
|
||||||
|
"isPasswordPrimary": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "username",
|
||||||
|
"password": true,
|
||||||
|
"verificationCode": false,
|
||||||
|
"isPasswordPrimary": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}' >/dev/null 2>&1
|
||||||
|
log "Sign-in experience configured: SignInAndRegister (email + password)."
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# PHASE 9: Cleanup seeded apps
|
# PHASE 9: Cleanup seeded apps
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
@@ -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.tls.yml` — overlay: custom TLS cert volume
|
||||||
- `docker-compose.monitoring.yml` — overlay: external monitoring network
|
- `docker-compose.monitoring.yml` — overlay: external monitoring network
|
||||||
|
|
||||||
|
## SMTP configuration
|
||||||
|
|
||||||
|
Both installers (`install.sh` and `install.ps1`) prompt for SMTP settings in SaaS mode when the user opts in ("Configure SMTP for email verification?"). SMTP is required for self-service sign-up — without it, only admin-created users can sign in.
|
||||||
|
|
||||||
|
Env vars: `SMTP_HOST`, `SMTP_PORT` (default 587), `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM_EMAIL` (default `noreply@<PUBLIC_HOST>`). Passed to the `cameleer-logto` container. The bootstrap script (Phase 8b) discovers the SMTP connector factory and creates the connector with Cameleer-branded email templates.
|
||||||
|
|
||||||
|
CLI args: `--smtp-host`, `--smtp-port`, `--smtp-user`, `--smtp-pass`, `--smtp-from-email` (bash) / `-SmtpHost`, `-SmtpPort`, `-SmtpUser`, `-SmtpPass`, `-SmtpFromEmail` (PS1). Persisted in `cameleer.conf` for upgrades/reconfigure.
|
||||||
|
|
||||||
## Env var naming convention
|
## Env var naming convention
|
||||||
|
|
||||||
- `CAMELEER_AGENT_*` — agent config (consumed by the Java agent)
|
- `CAMELEER_AGENT_*` — agent config (consumed by the Java agent)
|
||||||
- `CAMELEER_SERVER_*` — server config (consumed by cameleer-server)
|
- `CAMELEER_SERVER_*` — server config (consumed by cameleer-server)
|
||||||
- `CAMELEER_SAAS_*` — SaaS management plane config
|
- `CAMELEER_SAAS_*` — SaaS management plane config
|
||||||
- `CAMELEER_SAAS_PROVISIONING_*` — "SaaS forwards this to provisioned tenant servers"
|
- `CAMELEER_SAAS_PROVISIONING_*` — "SaaS forwards this to provisioned tenant servers"
|
||||||
|
- `SMTP_*` — email delivery config for Logto (consumed by bootstrap, SaaS mode only)
|
||||||
- No prefix (e.g. `POSTGRES_PASSWORD`, `PUBLIC_HOST`) — shared infrastructure, consumed by multiple components
|
- No prefix (e.g. `POSTGRES_PASSWORD`, `PUBLIC_HOST`) — shared infrastructure, consumed by multiple components
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ param(
|
|||||||
[string]$DockerSocket,
|
[string]$DockerSocket,
|
||||||
[string]$NodeTlsReject,
|
[string]$NodeTlsReject,
|
||||||
[string]$DeploymentMode,
|
[string]$DeploymentMode,
|
||||||
|
[string]$SmtpHost,
|
||||||
|
[string]$SmtpPort,
|
||||||
|
[string]$SmtpUser,
|
||||||
|
[string]$SmtpPass,
|
||||||
|
[string]$SmtpFromEmail,
|
||||||
[switch]$Reconfigure,
|
[switch]$Reconfigure,
|
||||||
[switch]$Reinstall,
|
[switch]$Reinstall,
|
||||||
[switch]$ConfirmDestroy,
|
[switch]$ConfirmDestroy,
|
||||||
@@ -84,6 +89,11 @@ $_ENV_COMPOSE_PROJECT = $env:COMPOSE_PROJECT
|
|||||||
$_ENV_DOCKER_SOCKET = $env:DOCKER_SOCKET
|
$_ENV_DOCKER_SOCKET = $env:DOCKER_SOCKET
|
||||||
$_ENV_NODE_TLS_REJECT = $env:NODE_TLS_REJECT
|
$_ENV_NODE_TLS_REJECT = $env:NODE_TLS_REJECT
|
||||||
$_ENV_DEPLOYMENT_MODE = $env:DEPLOYMENT_MODE
|
$_ENV_DEPLOYMENT_MODE = $env:DEPLOYMENT_MODE
|
||||||
|
$_ENV_SMTP_HOST = $env:SMTP_HOST
|
||||||
|
$_ENV_SMTP_PORT = $env:SMTP_PORT
|
||||||
|
$_ENV_SMTP_USER = $env:SMTP_USER
|
||||||
|
$_ENV_SMTP_PASS = $env:SMTP_PASS
|
||||||
|
$_ENV_SMTP_FROM_EMAIL = $env:SMTP_FROM_EMAIL
|
||||||
|
|
||||||
# --- Mutable config state ---
|
# --- Mutable config state ---
|
||||||
|
|
||||||
@@ -110,6 +120,11 @@ $script:cfg = @{
|
|||||||
DockerSocket = $DockerSocket
|
DockerSocket = $DockerSocket
|
||||||
NodeTlsReject = $NodeTlsReject
|
NodeTlsReject = $NodeTlsReject
|
||||||
DeploymentMode = $DeploymentMode
|
DeploymentMode = $DeploymentMode
|
||||||
|
SmtpHost = $SmtpHost
|
||||||
|
SmtpPort = $SmtpPort
|
||||||
|
SmtpUser = $SmtpUser
|
||||||
|
SmtpPass = $SmtpPass
|
||||||
|
SmtpFromEmail = $SmtpFromEmail
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($Silent) { $script:Mode = 'silent' }
|
if ($Silent) { $script:Mode = 'silent' }
|
||||||
@@ -260,6 +275,11 @@ function Load-ConfigFile {
|
|||||||
'docker_socket' { if (-not $script:cfg.DockerSocket) { $script:cfg.DockerSocket = $val } }
|
'docker_socket' { if (-not $script:cfg.DockerSocket) { $script:cfg.DockerSocket = $val } }
|
||||||
'node_tls_reject' { if (-not $script:cfg.NodeTlsReject) { $script:cfg.NodeTlsReject = $val } }
|
'node_tls_reject' { if (-not $script:cfg.NodeTlsReject) { $script:cfg.NodeTlsReject = $val } }
|
||||||
'deployment_mode' { if (-not $script:cfg.DeploymentMode) { $script:cfg.DeploymentMode = $val } }
|
'deployment_mode' { if (-not $script:cfg.DeploymentMode) { $script:cfg.DeploymentMode = $val } }
|
||||||
|
'smtp_host' { if (-not $script:cfg.SmtpHost) { $script:cfg.SmtpHost = $val } }
|
||||||
|
'smtp_port' { if (-not $script:cfg.SmtpPort) { $script:cfg.SmtpPort = $val } }
|
||||||
|
'smtp_user' { if (-not $script:cfg.SmtpUser) { $script:cfg.SmtpUser = $val } }
|
||||||
|
'smtp_pass' { if (-not $script:cfg.SmtpPass) { $script:cfg.SmtpPass = $val } }
|
||||||
|
'smtp_from_email' { if (-not $script:cfg.SmtpFromEmail) { $script:cfg.SmtpFromEmail = $val } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -289,6 +309,11 @@ function Load-EnvOverrides {
|
|||||||
if (-not $c.DockerSocket) { $c.DockerSocket = $_ENV_DOCKER_SOCKET }
|
if (-not $c.DockerSocket) { $c.DockerSocket = $_ENV_DOCKER_SOCKET }
|
||||||
if (-not $c.NodeTlsReject) { $c.NodeTlsReject = $_ENV_NODE_TLS_REJECT }
|
if (-not $c.NodeTlsReject) { $c.NodeTlsReject = $_ENV_NODE_TLS_REJECT }
|
||||||
if (-not $c.DeploymentMode) { $c.DeploymentMode = $_ENV_DEPLOYMENT_MODE }
|
if (-not $c.DeploymentMode) { $c.DeploymentMode = $_ENV_DEPLOYMENT_MODE }
|
||||||
|
if (-not $c.SmtpHost) { $c.SmtpHost = $_ENV_SMTP_HOST }
|
||||||
|
if (-not $c.SmtpPort) { $c.SmtpPort = $_ENV_SMTP_PORT }
|
||||||
|
if (-not $c.SmtpUser) { $c.SmtpUser = $_ENV_SMTP_USER }
|
||||||
|
if (-not $c.SmtpPass) { $c.SmtpPass = $_ENV_SMTP_PASS }
|
||||||
|
if (-not $c.SmtpFromEmail) { $c.SmtpFromEmail = $_ENV_SMTP_FROM_EMAIL }
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Prerequisites ---
|
# --- Prerequisites ---
|
||||||
@@ -447,6 +472,18 @@ function Run-SimplePrompts {
|
|||||||
Write-Host ''
|
Write-Host ''
|
||||||
$deployChoice = Read-Host ' Select mode [1]'
|
$deployChoice = Read-Host ' Select mode [1]'
|
||||||
if ($deployChoice -eq '2') { $c.DeploymentMode = 'standalone' } else { $c.DeploymentMode = 'saas' }
|
if ($deployChoice -eq '2') { $c.DeploymentMode = 'standalone' } else { $c.DeploymentMode = 'saas' }
|
||||||
|
|
||||||
|
# SMTP for email verification (SaaS mode only)
|
||||||
|
if ($c.DeploymentMode -eq 'saas') {
|
||||||
|
Write-Host ''
|
||||||
|
if (Prompt-YesNo 'Configure SMTP for email verification? (required for self-service sign-up)') {
|
||||||
|
$c.SmtpHost = Prompt-Value 'SMTP host' (Coalesce $c.SmtpHost '')
|
||||||
|
$c.SmtpPort = Prompt-Value 'SMTP port' (Coalesce $c.SmtpPort '587')
|
||||||
|
$c.SmtpUser = Prompt-Value 'SMTP username' (Coalesce $c.SmtpUser '')
|
||||||
|
$c.SmtpPass = Prompt-Password 'SMTP password' (Coalesce $c.SmtpPass '')
|
||||||
|
$c.SmtpFromEmail = Prompt-Value 'From email address' (Coalesce $c.SmtpFromEmail "noreply@$($c.PublicHost)")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function Run-ExpertPrompts {
|
function Run-ExpertPrompts {
|
||||||
@@ -688,6 +725,13 @@ CAMELEER_SAAS_PROVISIONING_RUNTIMEBASEIMAGE=${REGISTRY}/cameleer-runtime-base:$(
|
|||||||
|
|
||||||
# JWT signing secret (forwarded to provisioned tenant servers, must be non-empty)
|
# JWT signing secret (forwarded to provisioned tenant servers, must be non-empty)
|
||||||
CAMELEER_SERVER_SECURITY_JWTSECRET=$jwtSecret
|
CAMELEER_SERVER_SECURITY_JWTSECRET=$jwtSecret
|
||||||
|
|
||||||
|
# SMTP (for email verification during registration)
|
||||||
|
SMTP_HOST=$($c.SmtpHost)
|
||||||
|
SMTP_PORT=$(if ($c.SmtpPort) { $c.SmtpPort } else { '587' })
|
||||||
|
SMTP_USER=$($c.SmtpUser)
|
||||||
|
SMTP_PASS=$($c.SmtpPass)
|
||||||
|
SMTP_FROM_EMAIL=$(if ($c.SmtpFromEmail) { $c.SmtpFromEmail } else { "noreply@$($c.PublicHost)" })
|
||||||
"@
|
"@
|
||||||
$content += $provisioningBlock
|
$content += $provisioningBlock
|
||||||
$composeFile = 'docker-compose.yml;docker-compose.saas.yml'
|
$composeFile = 'docker-compose.yml;docker-compose.saas.yml'
|
||||||
@@ -915,6 +959,11 @@ compose_project=$($c.ComposeProject)
|
|||||||
docker_socket=$($c.DockerSocket)
|
docker_socket=$($c.DockerSocket)
|
||||||
node_tls_reject=$($c.NodeTlsReject)
|
node_tls_reject=$($c.NodeTlsReject)
|
||||||
deployment_mode=$($c.DeploymentMode)
|
deployment_mode=$($c.DeploymentMode)
|
||||||
|
smtp_host=$($c.SmtpHost)
|
||||||
|
smtp_port=$($c.SmtpPort)
|
||||||
|
smtp_user=$($c.SmtpUser)
|
||||||
|
smtp_pass=$($c.SmtpPass)
|
||||||
|
smtp_from_email=$($c.SmtpFromEmail)
|
||||||
"@
|
"@
|
||||||
Write-Utf8File $f $txt
|
Write-Utf8File $f $txt
|
||||||
Log-Info 'Saved installer config to cameleer.conf'
|
Log-Info 'Saved installer config to cameleer.conf'
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ _ENV_COMPOSE_PROJECT="${COMPOSE_PROJECT:-}"
|
|||||||
_ENV_DOCKER_SOCKET="${DOCKER_SOCKET:-}"
|
_ENV_DOCKER_SOCKET="${DOCKER_SOCKET:-}"
|
||||||
_ENV_NODE_TLS_REJECT="${NODE_TLS_REJECT:-}"
|
_ENV_NODE_TLS_REJECT="${NODE_TLS_REJECT:-}"
|
||||||
_ENV_DEPLOYMENT_MODE="${DEPLOYMENT_MODE:-}"
|
_ENV_DEPLOYMENT_MODE="${DEPLOYMENT_MODE:-}"
|
||||||
|
_ENV_SMTP_HOST="${SMTP_HOST:-}"
|
||||||
|
_ENV_SMTP_PORT="${SMTP_PORT:-}"
|
||||||
|
_ENV_SMTP_USER="${SMTP_USER:-}"
|
||||||
|
_ENV_SMTP_PASS="${SMTP_PASS:-}"
|
||||||
|
_ENV_SMTP_FROM_EMAIL="${SMTP_FROM_EMAIL:-}"
|
||||||
|
|
||||||
INSTALL_DIR=""
|
INSTALL_DIR=""
|
||||||
PUBLIC_HOST=""
|
PUBLIC_HOST=""
|
||||||
@@ -69,6 +74,11 @@ COMPOSE_PROJECT=""
|
|||||||
DOCKER_SOCKET=""
|
DOCKER_SOCKET=""
|
||||||
NODE_TLS_REJECT=""
|
NODE_TLS_REJECT=""
|
||||||
DEPLOYMENT_MODE=""
|
DEPLOYMENT_MODE=""
|
||||||
|
SMTP_HOST=""
|
||||||
|
SMTP_PORT=""
|
||||||
|
SMTP_USER=""
|
||||||
|
SMTP_PASS=""
|
||||||
|
SMTP_FROM_EMAIL=""
|
||||||
|
|
||||||
# --- State ---
|
# --- State ---
|
||||||
MODE="" # simple, expert, silent
|
MODE="" # simple, expert, silent
|
||||||
@@ -170,6 +180,11 @@ parse_args() {
|
|||||||
--docker-socket) DOCKER_SOCKET="$2"; shift ;;
|
--docker-socket) DOCKER_SOCKET="$2"; shift ;;
|
||||||
--node-tls-reject) NODE_TLS_REJECT="$2"; shift ;;
|
--node-tls-reject) NODE_TLS_REJECT="$2"; shift ;;
|
||||||
--deployment-mode) DEPLOYMENT_MODE="$2"; shift ;;
|
--deployment-mode) DEPLOYMENT_MODE="$2"; shift ;;
|
||||||
|
--smtp-host) SMTP_HOST="$2"; shift ;;
|
||||||
|
--smtp-port) SMTP_PORT="$2"; shift ;;
|
||||||
|
--smtp-user) SMTP_USER="$2"; shift ;;
|
||||||
|
--smtp-pass) SMTP_PASS="$2"; shift ;;
|
||||||
|
--smtp-from-email) SMTP_FROM_EMAIL="$2"; shift ;;
|
||||||
--server-admin-user) ADMIN_USER="$2"; shift ;;
|
--server-admin-user) ADMIN_USER="$2"; shift ;;
|
||||||
--server-admin-password) ADMIN_PASS="$2"; shift ;;
|
--server-admin-password) ADMIN_PASS="$2"; shift ;;
|
||||||
--reconfigure) RERUN_ACTION="reconfigure" ;;
|
--reconfigure) RERUN_ACTION="reconfigure" ;;
|
||||||
@@ -255,6 +270,11 @@ load_config_file() {
|
|||||||
docker_socket) [ -z "$DOCKER_SOCKET" ] && DOCKER_SOCKET="$value" ;;
|
docker_socket) [ -z "$DOCKER_SOCKET" ] && DOCKER_SOCKET="$value" ;;
|
||||||
node_tls_reject) [ -z "$NODE_TLS_REJECT" ] && NODE_TLS_REJECT="$value" ;;
|
node_tls_reject) [ -z "$NODE_TLS_REJECT" ] && NODE_TLS_REJECT="$value" ;;
|
||||||
deployment_mode) [ -z "$DEPLOYMENT_MODE" ] && DEPLOYMENT_MODE="$value" ;;
|
deployment_mode) [ -z "$DEPLOYMENT_MODE" ] && DEPLOYMENT_MODE="$value" ;;
|
||||||
|
smtp_host) [ -z "$SMTP_HOST" ] && SMTP_HOST="$value" ;;
|
||||||
|
smtp_port) [ -z "$SMTP_PORT" ] && SMTP_PORT="$value" ;;
|
||||||
|
smtp_user) [ -z "$SMTP_USER" ] && SMTP_USER="$value" ;;
|
||||||
|
smtp_pass) [ -z "$SMTP_PASS" ] && SMTP_PASS="$value" ;;
|
||||||
|
smtp_from_email) [ -z "$SMTP_FROM_EMAIL" ] && SMTP_FROM_EMAIL="$value" ;;
|
||||||
esac
|
esac
|
||||||
done < "$file"
|
done < "$file"
|
||||||
}
|
}
|
||||||
@@ -282,6 +302,11 @@ load_env_overrides() {
|
|||||||
[ -z "$DOCKER_SOCKET" ] && DOCKER_SOCKET="$_ENV_DOCKER_SOCKET"
|
[ -z "$DOCKER_SOCKET" ] && DOCKER_SOCKET="$_ENV_DOCKER_SOCKET"
|
||||||
[ -z "$NODE_TLS_REJECT" ] && NODE_TLS_REJECT="$_ENV_NODE_TLS_REJECT"
|
[ -z "$NODE_TLS_REJECT" ] && NODE_TLS_REJECT="$_ENV_NODE_TLS_REJECT"
|
||||||
[ -z "$DEPLOYMENT_MODE" ] && DEPLOYMENT_MODE="$_ENV_DEPLOYMENT_MODE"
|
[ -z "$DEPLOYMENT_MODE" ] && DEPLOYMENT_MODE="$_ENV_DEPLOYMENT_MODE"
|
||||||
|
[ -z "$SMTP_HOST" ] && SMTP_HOST="$_ENV_SMTP_HOST"
|
||||||
|
[ -z "$SMTP_PORT" ] && SMTP_PORT="$_ENV_SMTP_PORT"
|
||||||
|
[ -z "$SMTP_USER" ] && SMTP_USER="$_ENV_SMTP_USER"
|
||||||
|
[ -z "$SMTP_PASS" ] && SMTP_PASS="$_ENV_SMTP_PASS"
|
||||||
|
[ -z "$SMTP_FROM_EMAIL" ] && SMTP_FROM_EMAIL="$_ENV_SMTP_FROM_EMAIL"
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Prerequisites ---
|
# --- Prerequisites ---
|
||||||
@@ -434,6 +459,18 @@ run_simple_prompts() {
|
|||||||
DEPLOYMENT_MODE="saas"
|
DEPLOYMENT_MODE="saas"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
# SMTP for email verification (SaaS mode only)
|
||||||
|
if [ "$DEPLOYMENT_MODE" = "saas" ]; then
|
||||||
|
echo ""
|
||||||
|
if prompt_yesno "Configure SMTP for email verification? (required for self-service sign-up)"; then
|
||||||
|
prompt SMTP_HOST "SMTP host" "${SMTP_HOST:-}"
|
||||||
|
prompt SMTP_PORT "SMTP port" "${SMTP_PORT:-587}"
|
||||||
|
prompt SMTP_USER "SMTP username" "${SMTP_USER:-}"
|
||||||
|
prompt_password SMTP_PASS "SMTP password" "${SMTP_PASS:-}"
|
||||||
|
prompt SMTP_FROM_EMAIL "From email address" "${SMTP_FROM_EMAIL:-noreply@${PUBLIC_HOST}}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
run_expert_prompts() {
|
run_expert_prompts() {
|
||||||
@@ -696,6 +733,13 @@ CAMELEER_SAAS_PROVISIONING_RUNTIMEBASEIMAGE=${REGISTRY}/cameleer-runtime-base:${
|
|||||||
# JWT signing secret (forwarded to provisioned tenant servers, must be non-empty)
|
# JWT signing secret (forwarded to provisioned tenant servers, must be non-empty)
|
||||||
CAMELEER_SERVER_SECURITY_JWTSECRET=$(generate_password)
|
CAMELEER_SERVER_SECURITY_JWTSECRET=$(generate_password)
|
||||||
|
|
||||||
|
# SMTP (for email verification during registration)
|
||||||
|
SMTP_HOST=${SMTP_HOST}
|
||||||
|
SMTP_PORT=${SMTP_PORT:-587}
|
||||||
|
SMTP_USER=${SMTP_USER}
|
||||||
|
SMTP_PASS=${SMTP_PASS}
|
||||||
|
SMTP_FROM_EMAIL=${SMTP_FROM_EMAIL:-noreply@${PUBLIC_HOST}}
|
||||||
|
|
||||||
# Compose file assembly
|
# Compose file assembly
|
||||||
COMPOSE_FILE=docker-compose.yml:docker-compose.saas.yml$([ "$TLS_MODE" = "custom" ] && echo ":docker-compose.tls.yml")$([ -n "$MONITORING_NETWORK" ] && echo ":docker-compose.monitoring.yml")
|
COMPOSE_FILE=docker-compose.yml:docker-compose.saas.yml$([ "$TLS_MODE" = "custom" ] && echo ":docker-compose.tls.yml")$([ -n "$MONITORING_NETWORK" ] && echo ":docker-compose.monitoring.yml")
|
||||||
EOF
|
EOF
|
||||||
@@ -867,6 +911,11 @@ compose_project=${COMPOSE_PROJECT}
|
|||||||
docker_socket=${DOCKER_SOCKET}
|
docker_socket=${DOCKER_SOCKET}
|
||||||
node_tls_reject=${NODE_TLS_REJECT}
|
node_tls_reject=${NODE_TLS_REJECT}
|
||||||
deployment_mode=${DEPLOYMENT_MODE}
|
deployment_mode=${DEPLOYMENT_MODE}
|
||||||
|
smtp_host=${SMTP_HOST}
|
||||||
|
smtp_port=${SMTP_PORT}
|
||||||
|
smtp_user=${SMTP_USER}
|
||||||
|
smtp_pass=${SMTP_PASS}
|
||||||
|
smtp_from_email=${SMTP_FROM_EMAIL}
|
||||||
EOF
|
EOF
|
||||||
log_info "Saved installer config to cameleer.conf"
|
log_info "Saved installer config to cameleer.conf"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,16 @@ SAAS_ADMIN_PASS=CHANGE_ME
|
|||||||
# SERVER_ADMIN_PASS=CHANGE_ME
|
# SERVER_ADMIN_PASS=CHANGE_ME
|
||||||
# BOOTSTRAP_TOKEN=CHANGE_ME
|
# BOOTSTRAP_TOKEN=CHANGE_ME
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# SMTP (for email verification during registration)
|
||||||
|
# ============================================================
|
||||||
|
# Required for self-service sign-up. Without SMTP, only admin-created users can sign in.
|
||||||
|
SMTP_HOST=
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=
|
||||||
|
SMTP_PASS=
|
||||||
|
SMTP_FROM_EMAIL=noreply@cameleer.io
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# TLS
|
# TLS
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ services:
|
|||||||
PG_DB_SAAS: cameleer_saas
|
PG_DB_SAAS: cameleer_saas
|
||||||
SAAS_ADMIN_USER: ${SAAS_ADMIN_USER:-admin}
|
SAAS_ADMIN_USER: ${SAAS_ADMIN_USER:-admin}
|
||||||
SAAS_ADMIN_PASS: ${SAAS_ADMIN_PASS:?SAAS_ADMIN_PASS must be set in .env}
|
SAAS_ADMIN_PASS: ${SAAS_ADMIN_PASS:?SAAS_ADMIN_PASS must be set in .env}
|
||||||
|
# SMTP (for email verification during registration)
|
||||||
|
SMTP_HOST: ${SMTP_HOST:-}
|
||||||
|
SMTP_PORT: ${SMTP_PORT:-587}
|
||||||
|
SMTP_USER: ${SMTP_USER:-}
|
||||||
|
SMTP_PASS: ${SMTP_PASS:-}
|
||||||
|
SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL:-noreply@cameleer.io}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3001/oidc/.well-known/openid-configuration', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))\" && test -f /data/logto-bootstrap.json"]
|
test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3001/oidc/.well-known/openid-configuration', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))\" && test -f /data/logto-bootstrap.json"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
|
|||||||
@@ -18,10 +18,13 @@
|
|||||||
| SaaS admin | `saas-vendor` (global) | `platform:admin` | `/vendor/tenants` |
|
| SaaS admin | `saas-vendor` (global) | `platform:admin` | `/vendor/tenants` |
|
||||||
| Tenant admin | org `owner` | `tenant:manage` | `/tenant` (dashboard) |
|
| Tenant admin | org `owner` | `tenant:manage` | `/tenant` (dashboard) |
|
||||||
| Regular user (operator/viewer) | org member | `server:operator` or `server:viewer` | Redirected to server dashboard directly |
|
| Regular user (operator/viewer) | org member | `server:operator` or `server:viewer` | Redirected to server dashboard directly |
|
||||||
|
| New user (just registered) | none (authenticated only) | none | `/onboarding` (self-service tenant creation) |
|
||||||
|
|
||||||
- `LandingRedirect` component waits for scopes to load, then routes to the correct persona landing page
|
- `LandingRedirect` component waits for scopes to load, then routes to the correct persona landing page. If user has zero organizations, redirects to `/onboarding`.
|
||||||
- `RequireScope` guard on route groups enforces scope requirements
|
- `RequireScope` guard on route groups enforces scope requirements
|
||||||
- SSO bridge: Logto session carries over to provisioned server's OIDC flow (Traditional Web App per tenant)
|
- SSO bridge: Logto session carries over to provisioned server's OIDC flow (Traditional Web App per tenant)
|
||||||
|
- Self-service sign-up flow: `/platform/register` → Logto OIDC with `firstScreen: 'register'` → custom sign-in UI (email + password + verification code) → callback → `LandingRedirect` → `/onboarding` → `POST /api/onboarding/tenant` → tenant provisioned, user added as org owner
|
||||||
|
- `OnboardingController` at `/api/onboarding/**` requires `authenticated()` only (no specific scope). `OnboardingService` enforces one trial tenant per user, reuses `VendorTenantService.createAndProvision()`, and adds the calling user to the Logto org as `owner`.
|
||||||
|
|
||||||
## Server OIDC role extraction (two paths)
|
## Server OIDC role extraction (two paths)
|
||||||
|
|
||||||
|
|||||||
@@ -43,10 +43,11 @@ public class SecurityConfig {
|
|||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
.requestMatchers("/actuator/health").permitAll()
|
.requestMatchers("/actuator/health").permitAll()
|
||||||
.requestMatchers("/api/config").permitAll()
|
.requestMatchers("/api/config").permitAll()
|
||||||
.requestMatchers("/", "/index.html", "/login", "/callback",
|
.requestMatchers("/", "/index.html", "/login", "/register", "/callback",
|
||||||
"/vendor/**", "/tenant/**",
|
"/vendor/**", "/tenant/**", "/onboarding",
|
||||||
"/environments/**", "/license", "/admin/**").permitAll()
|
"/environments/**", "/license", "/admin/**").permitAll()
|
||||||
.requestMatchers("/_app/**", "/favicon.ico", "/favicon.svg", "/logo.svg", "/logo-dark.svg").permitAll()
|
.requestMatchers("/_app/**", "/favicon.ico", "/favicon.svg", "/logo.svg", "/logo-dark.svg").permitAll()
|
||||||
|
.requestMatchers("/api/onboarding/**").authenticated()
|
||||||
.requestMatchers("/api/vendor/**").hasAuthority("SCOPE_platform:admin")
|
.requestMatchers("/api/vendor/**").hasAuthority("SCOPE_platform:admin")
|
||||||
.requestMatchers("/api/tenant/**").authenticated()
|
.requestMatchers("/api/tenant/**").authenticated()
|
||||||
.anyRequest().authenticated()
|
.anyRequest().authenticated()
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package net.siegeln.cameleer.saas.onboarding;
|
||||||
|
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import net.siegeln.cameleer.saas.tenant.TenantEntity;
|
||||||
|
import net.siegeln.cameleer.saas.tenant.dto.TenantResponse;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.security.oauth2.jwt.Jwt;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/onboarding")
|
||||||
|
public class OnboardingController {
|
||||||
|
|
||||||
|
private final OnboardingService onboardingService;
|
||||||
|
|
||||||
|
public OnboardingController(OnboardingService onboardingService) {
|
||||||
|
this.onboardingService = onboardingService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CreateTrialTenantRequest(
|
||||||
|
@jakarta.validation.constraints.NotBlank
|
||||||
|
@jakarta.validation.constraints.Size(max = 255)
|
||||||
|
String name,
|
||||||
|
|
||||||
|
@jakarta.validation.constraints.NotBlank
|
||||||
|
@jakarta.validation.constraints.Size(max = 100)
|
||||||
|
@jakarta.validation.constraints.Pattern(
|
||||||
|
regexp = "^[a-z0-9][a-z0-9-]*[a-z0-9]$",
|
||||||
|
message = "Slug must be lowercase alphanumeric with hyphens")
|
||||||
|
String slug
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@PostMapping("/tenant")
|
||||||
|
public ResponseEntity<TenantResponse> createTrialTenant(
|
||||||
|
@Valid @RequestBody CreateTrialTenantRequest request,
|
||||||
|
@AuthenticationPrincipal Jwt jwt) {
|
||||||
|
|
||||||
|
String userId = jwt.getSubject();
|
||||||
|
TenantEntity tenant = onboardingService.createTrialTenant(request.name(), request.slug(), userId);
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(TenantResponse.from(tenant));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package net.siegeln.cameleer.saas.onboarding;
|
||||||
|
|
||||||
|
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
|
||||||
|
import net.siegeln.cameleer.saas.tenant.TenantEntity;
|
||||||
|
import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest;
|
||||||
|
import net.siegeln.cameleer.saas.vendor.VendorTenantService;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Self-service onboarding: lets a newly registered user create their own trial tenant.
|
||||||
|
* Reuses VendorTenantService for the heavy lifting (Logto org, license, Docker provisioning)
|
||||||
|
* but adds the calling user as the tenant owner instead of creating a new admin user.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class OnboardingService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(OnboardingService.class);
|
||||||
|
|
||||||
|
private final VendorTenantService vendorTenantService;
|
||||||
|
private final LogtoManagementClient logtoClient;
|
||||||
|
|
||||||
|
public OnboardingService(VendorTenantService vendorTenantService,
|
||||||
|
LogtoManagementClient logtoClient) {
|
||||||
|
this.vendorTenantService = vendorTenantService;
|
||||||
|
this.logtoClient = logtoClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TenantEntity createTrialTenant(String name, String slug, String logtoUserId) {
|
||||||
|
// Guard: check if user already has a tenant (prevent abuse)
|
||||||
|
if (logtoClient.isAvailable()) {
|
||||||
|
var orgs = logtoClient.getUserOrganizations(logtoUserId);
|
||||||
|
if (!orgs.isEmpty()) {
|
||||||
|
throw new IllegalStateException("You already have a tenant. Only one trial tenant per account.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create tenant via the existing vendor flow (no admin user — we'll add the caller)
|
||||||
|
UUID actorId = resolveActorId(logtoUserId);
|
||||||
|
var request = new CreateTenantRequest(name, slug, "LOW", null, null);
|
||||||
|
TenantEntity tenant = vendorTenantService.createAndProvision(request, actorId);
|
||||||
|
|
||||||
|
// Add the calling user to the Logto org as owner
|
||||||
|
if (tenant.getLogtoOrgId() != null && logtoClient.isAvailable()) {
|
||||||
|
try {
|
||||||
|
String ownerRoleId = logtoClient.findOrgRoleIdByName("owner");
|
||||||
|
logtoClient.addUserToOrganization(tenant.getLogtoOrgId(), logtoUserId);
|
||||||
|
if (ownerRoleId != null) {
|
||||||
|
logtoClient.assignOrganizationRole(tenant.getLogtoOrgId(), logtoUserId, ownerRoleId);
|
||||||
|
}
|
||||||
|
log.info("Added user {} as owner of tenant {}", logtoUserId, slug);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to add user {} to org for tenant {}: {}", logtoUserId, slug, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
private UUID resolveActorId(String subject) {
|
||||||
|
try {
|
||||||
|
return UUID.fromString(subject);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return UUID.nameUUIDFromBytes(subject.getBytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ React 19 SPA served at `/platform/*` by the Spring Boot backend.
|
|||||||
## Core files
|
## Core files
|
||||||
|
|
||||||
- `main.tsx` — React 19 root
|
- `main.tsx` — React 19 root
|
||||||
- `router.tsx` — `/vendor/*` + `/tenant/*` with `RequireScope` guards and `LandingRedirect` that waits for scopes
|
- `router.tsx` — `/vendor/*` + `/tenant/*` with `RequireScope` guards, `LandingRedirect` that waits for scopes (redirects to `/onboarding` if user has zero orgs), `/register` route for OIDC sign-up flow, `/onboarding` route for self-service tenant creation
|
||||||
- `Layout.tsx` — persona-aware sidebar: vendor sees expandable "Vendor" section (Tenants, Audit Log, Certificates, Infrastructure, Identity/Logto), tenant admin sees Dashboard/License/SSO/Team/Audit/Settings
|
- `Layout.tsx` — persona-aware sidebar: vendor sees expandable "Vendor" section (Tenants, Audit Log, Certificates, Infrastructure, Identity/Logto), tenant admin sees Dashboard/License/SSO/Team/Audit/Settings
|
||||||
- `OrgResolver.tsx` — merges global + org-scoped token scopes (vendor's platform:admin is global)
|
- `OrgResolver.tsx` — merges global + org-scoped token scopes (vendor's platform:admin is global)
|
||||||
- `config.ts` — fetch Logto config from /platform/api/config
|
- `config.ts` — fetch Logto config from /platform/api/config
|
||||||
@@ -16,9 +16,12 @@ React 19 SPA served at `/platform/*` by the Spring Boot backend.
|
|||||||
- `auth/useOrganization.ts` — Zustand store for current tenant
|
- `auth/useOrganization.ts` — Zustand store for current tenant
|
||||||
- `auth/useScopes.ts` — decode JWT scopes, hasScope()
|
- `auth/useScopes.ts` — decode JWT scopes, hasScope()
|
||||||
- `auth/ProtectedRoute.tsx` — guard (redirects to /login)
|
- `auth/ProtectedRoute.tsx` — guard (redirects to /login)
|
||||||
|
- `auth/LoginPage.tsx` — redirects to Logto OIDC sign-in
|
||||||
|
- `auth/RegisterPage.tsx` — redirects to Logto OIDC with `firstScreen: 'register'`
|
||||||
|
|
||||||
## Pages
|
## Pages
|
||||||
|
|
||||||
|
- **Onboarding**: `OnboardingPage.tsx` — self-service trial tenant creation (org name + slug), shown to users with zero org memberships after sign-up
|
||||||
- **Vendor pages**: `VendorTenantsPage.tsx`, `CreateTenantPage.tsx`, `TenantDetailPage.tsx`, `VendorAuditPage.tsx`, `CertificatesPage.tsx`, `InfrastructurePage.tsx`
|
- **Vendor pages**: `VendorTenantsPage.tsx`, `CreateTenantPage.tsx`, `TenantDetailPage.tsx`, `VendorAuditPage.tsx`, `CertificatesPage.tsx`, `InfrastructurePage.tsx`
|
||||||
- **Tenant pages**: `TenantDashboardPage.tsx` (restart + upgrade server), `TenantLicensePage.tsx`, `SsoPage.tsx`, `TeamPage.tsx` (reset member passwords), `TenantAuditPage.tsx`, `SettingsPage.tsx` (change own password, reset server admin password)
|
- **Tenant pages**: `TenantDashboardPage.tsx` (restart + upgrade server), `TenantLicensePage.tsx`, `SsoPage.tsx`, `TeamPage.tsx` (reset member passwords), `TenantAuditPage.tsx`, `SettingsPage.tsx` (change own password, reset server admin password)
|
||||||
|
|
||||||
@@ -26,5 +29,5 @@ React 19 SPA served at `/platform/*` by the Spring Boot backend.
|
|||||||
|
|
||||||
Separate Vite+React SPA replacing Logto's default sign-in page. Built as custom Logto Docker image — see `docker/CLAUDE.md` for details.
|
Separate Vite+React SPA replacing Logto's default sign-in page. Built as custom Logto Docker image — see `docker/CLAUDE.md` for details.
|
||||||
|
|
||||||
- `SignInPage.tsx` — form with @cameleer/design-system components
|
- `SignInPage.tsx` — sign-in + registration form with @cameleer/design-system components. Three modes: `signIn` (email/username + password), `register` (email + password + confirm), `verifyCode` (6-digit email verification). Reads `first_screen=register` from URL query params to determine initial view.
|
||||||
- `experience-api.ts` — Logto Experience API client (4-step: init -> verify -> identify -> submit)
|
- `experience-api.ts` — Logto Experience API client. Sign-in: init -> verify password -> identify -> submit. Registration: init Register -> send verification code -> verify code -> add password profile -> identify -> submit. Auto-detects email vs username identifiers.
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ FROM ghcr.io/logto-io/logto:latest
|
|||||||
# Install bootstrap dependencies (curl, jq for API calls; postgresql16-client for DB reads)
|
# Install bootstrap dependencies (curl, jq for API calls; postgresql16-client for DB reads)
|
||||||
RUN apk add --no-cache curl jq postgresql16-client
|
RUN apk add --no-cache curl jq postgresql16-client
|
||||||
|
|
||||||
|
# Install all official Logto connectors (ensures SMTP email is available for self-hosted)
|
||||||
|
RUN cd /etc/logto/packages/core && npm run cli connector add -- --official 2>/dev/null || true
|
||||||
|
|
||||||
# Custom sign-in UI
|
# Custom sign-in UI
|
||||||
COPY --from=build /ui/dist/ /etc/logto/packages/experience/dist/
|
COPY --from=build /ui/dist/ /etc/logto/packages/experience/dist/
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
padding: 32px;
|
padding: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loginForm {
|
.formContainer {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -53,6 +53,53 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.passwordWrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.passwordToggle {
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.submitButton {
|
.submitButton {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.switchText {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switchLink {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-link, #C6820E);
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 0;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switchLink:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verifyHint {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary, var(--text-muted));
|
||||||
|
margin: 0 0 4px;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { type FormEvent, useMemo, useState } from 'react';
|
import { type FormEvent, useEffect, useMemo, useState } from 'react';
|
||||||
import { Eye, EyeOff } from 'lucide-react';
|
import { Eye, EyeOff } from 'lucide-react';
|
||||||
import { Card, Input, Button, Alert, FormField } from '@cameleer/design-system';
|
import { Card, Input, Button, Alert, FormField } from '@cameleer/design-system';
|
||||||
import cameleerLogo from '@cameleer/design-system/assets/cameleer-logo.svg';
|
import cameleerLogo from '@cameleer/design-system/assets/cameleer-logo.svg';
|
||||||
import { signIn } from './experience-api';
|
import { signIn, startRegistration, completeRegistration } from './experience-api';
|
||||||
import styles from './SignInPage.module.css';
|
import styles from './SignInPage.module.css';
|
||||||
|
|
||||||
const SUBTITLES = [
|
type Mode = 'signIn' | 'register' | 'verifyCode';
|
||||||
|
|
||||||
|
const SIGN_IN_SUBTITLES = [
|
||||||
"Prove you're not a mirage",
|
"Prove you're not a mirage",
|
||||||
"Only authorized cameleers beyond this dune",
|
"Only authorized cameleers beyond this dune",
|
||||||
"Halt, traveler — state your business",
|
"Halt, traveler — state your business",
|
||||||
@@ -33,20 +35,63 @@ const SUBTITLES = [
|
|||||||
"No ticket, no caravan",
|
"No ticket, no caravan",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const REGISTER_SUBTITLES = [
|
||||||
|
"Every great journey starts with a single sign-up",
|
||||||
|
"Welcome to the caravan — let's get you registered",
|
||||||
|
"A new cameleer approaches the oasis",
|
||||||
|
"Join the caravan. We have dashboards.",
|
||||||
|
"The desert is better with company",
|
||||||
|
"First time here? The camels don't bite.",
|
||||||
|
"Pack your bags, you're joining the caravan",
|
||||||
|
"Room for one more on this caravan",
|
||||||
|
"New rider? Excellent. Credentials, please.",
|
||||||
|
"The Silk Road awaits — just need your email first",
|
||||||
|
];
|
||||||
|
|
||||||
|
function pickRandom(arr: string[]) {
|
||||||
|
return arr[Math.floor(Math.random() * arr.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitialMode(): Mode {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
return params.get('first_screen') === 'register' ? 'register' : 'signIn';
|
||||||
|
}
|
||||||
|
|
||||||
export function SignInPage() {
|
export function SignInPage() {
|
||||||
const subtitle = useMemo(() => SUBTITLES[Math.floor(Math.random() * SUBTITLES.length)], []);
|
const [mode, setMode] = useState<Mode>(getInitialMode);
|
||||||
const [username, setUsername] = useState('');
|
const subtitle = useMemo(
|
||||||
|
() => pickRandom(mode === 'signIn' ? SIGN_IN_SUBTITLES : REGISTER_SUBTITLES),
|
||||||
|
[mode === 'signIn' ? 'signIn' : 'register'],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [identifier, setIdentifier] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [code, setCode] = useState('');
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [verificationId, setVerificationId] = useState('');
|
||||||
|
|
||||||
const handleSubmit = async (e: FormEvent) => {
|
// Reset error when switching modes
|
||||||
|
useEffect(() => { setError(null); }, [mode]);
|
||||||
|
|
||||||
|
const switchMode = (next: Mode) => {
|
||||||
|
setMode(next);
|
||||||
|
setPassword('');
|
||||||
|
setConfirmPassword('');
|
||||||
|
setCode('');
|
||||||
|
setShowPassword(false);
|
||||||
|
setVerificationId('');
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Sign-in ---
|
||||||
|
const handleSignIn = async (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const redirectTo = await signIn(username, password);
|
const redirectTo = await signIn(identifier, password);
|
||||||
window.location.replace(redirectTo);
|
window.location.replace(redirectTo);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Sign-in failed');
|
setError(err instanceof Error ? err.message : 'Sign-in failed');
|
||||||
@@ -54,10 +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 = (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className={styles.passwordToggle}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.page}>
|
<div className={styles.page}>
|
||||||
<Card className={styles.card}>
|
<Card className={styles.card}>
|
||||||
<div className={styles.loginForm}>
|
<div className={styles.formContainer}>
|
||||||
<div className={styles.logo}>
|
<div className={styles.logo}>
|
||||||
<img src={cameleerLogo} alt="" className={styles.logoImg} />
|
<img src={cameleerLogo} alt="" className={styles.logoImg} />
|
||||||
Cameleer
|
Cameleer
|
||||||
@@ -70,55 +164,156 @@ export function SignInPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form className={styles.fields} onSubmit={handleSubmit} aria-label="Sign in" noValidate>
|
{/* --- Sign-in form --- */}
|
||||||
<FormField label="Username" htmlFor="login-username">
|
{mode === 'signIn' && (
|
||||||
<Input
|
<form className={styles.fields} onSubmit={handleSignIn} aria-label="Sign in" noValidate>
|
||||||
id="login-username"
|
<FormField label="Email or username" htmlFor="login-identifier">
|
||||||
value={username}
|
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
|
||||||
placeholder="Enter your username"
|
|
||||||
autoFocus
|
|
||||||
autoComplete="username"
|
|
||||||
disabled={loading}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField label="Password" htmlFor="login-password">
|
|
||||||
<div style={{ position: 'relative' }}>
|
|
||||||
<Input
|
<Input
|
||||||
id="login-password"
|
id="login-identifier"
|
||||||
type={showPassword ? 'text' : 'password'}
|
type="email"
|
||||||
value={password}
|
value={identifier}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setIdentifier(e.target.value)}
|
||||||
placeholder="••••••••"
|
placeholder="you@company.com"
|
||||||
autoComplete="current-password"
|
autoFocus
|
||||||
|
autoComplete="username"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
<button
|
</FormField>
|
||||||
type="button"
|
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
|
||||||
style={{
|
|
||||||
position: 'absolute', right: 8, top: '50%', transform: 'translateY(-50%)',
|
|
||||||
background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)',
|
|
||||||
padding: 4, display: 'flex', alignItems: 'center',
|
|
||||||
}}
|
|
||||||
tabIndex={-1}
|
|
||||||
>
|
|
||||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<Button
|
<FormField label="Password" htmlFor="login-password">
|
||||||
variant="primary"
|
<div className={styles.passwordWrapper}>
|
||||||
type="submit"
|
<Input
|
||||||
loading={loading}
|
id="login-password"
|
||||||
disabled={loading || !username || !password}
|
type={showPassword ? 'text' : 'password'}
|
||||||
className={styles.submitButton}
|
value={password}
|
||||||
>
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
Sign in
|
placeholder="••••••••"
|
||||||
</Button>
|
autoComplete="current-password"
|
||||||
</form>
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
{passwordToggle}
|
||||||
|
</div>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
type="submit"
|
||||||
|
loading={loading}
|
||||||
|
disabled={loading || !identifier || !password}
|
||||||
|
className={styles.submitButton}
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<p className={styles.switchText}>
|
||||||
|
Don't have an account?{' '}
|
||||||
|
<button type="button" className={styles.switchLink} onClick={() => switchMode('register')}>
|
||||||
|
Sign up
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* --- Register form --- */}
|
||||||
|
{mode === 'register' && (
|
||||||
|
<form className={styles.fields} onSubmit={handleRegister} aria-label="Create account" noValidate>
|
||||||
|
<FormField label="Email" htmlFor="register-email">
|
||||||
|
<Input
|
||||||
|
id="register-email"
|
||||||
|
type="email"
|
||||||
|
value={identifier}
|
||||||
|
onChange={(e) => setIdentifier(e.target.value)}
|
||||||
|
placeholder="you@company.com"
|
||||||
|
autoFocus
|
||||||
|
autoComplete="email"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Password" htmlFor="register-password">
|
||||||
|
<div className={styles.passwordWrapper}>
|
||||||
|
<Input
|
||||||
|
id="register-password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="At least 8 characters"
|
||||||
|
autoComplete="new-password"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
{passwordToggle}
|
||||||
|
</div>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Confirm password" htmlFor="register-confirm">
|
||||||
|
<Input
|
||||||
|
id="register-confirm"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
autoComplete="new-password"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
type="submit"
|
||||||
|
loading={loading}
|
||||||
|
disabled={loading || !identifier || !password || !confirmPassword}
|
||||||
|
className={styles.submitButton}
|
||||||
|
>
|
||||||
|
Create account
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<p className={styles.switchText}>
|
||||||
|
Already have an account?{' '}
|
||||||
|
<button type="button" className={styles.switchLink} onClick={() => switchMode('signIn')}>
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* --- Verification code form --- */}
|
||||||
|
{mode === 'verifyCode' && (
|
||||||
|
<form className={styles.fields} onSubmit={handleVerifyCode} aria-label="Verify email" noValidate>
|
||||||
|
<p className={styles.verifyHint}>
|
||||||
|
We sent a verification code to <strong>{identifier}</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<FormField label="Verification code" htmlFor="verify-code">
|
||||||
|
<Input
|
||||||
|
id="verify-code"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||||
|
placeholder="000000"
|
||||||
|
autoFocus
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
type="submit"
|
||||||
|
loading={loading}
|
||||||
|
disabled={loading || code.length < 6}
|
||||||
|
className={styles.submitButton}
|
||||||
|
>
|
||||||
|
Verify & create account
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<p className={styles.switchText}>
|
||||||
|
<button type="button" className={styles.switchLink} onClick={() => switchMode('register')}>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,32 +10,7 @@ async function request(method: string, path: string, body?: unknown): Promise<Re
|
|||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function initInteraction(): Promise<void> {
|
// --- Shared ---
|
||||||
const res = await request('PUT', '', { interactionEvent: 'SignIn' });
|
|
||||||
if (!res.ok) {
|
|
||||||
const err = await res.json().catch(() => ({}));
|
|
||||||
throw new Error(err.message || `Failed to initialize sign-in (${res.status})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function verifyPassword(
|
|
||||||
username: string,
|
|
||||||
password: string
|
|
||||||
): Promise<string> {
|
|
||||||
const res = await request('POST', '/verification/password', {
|
|
||||||
identifier: { type: 'username', value: username },
|
|
||||||
password,
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const err = await res.json().catch(() => ({}));
|
|
||||||
if (res.status === 422) {
|
|
||||||
throw new Error('Invalid username or password');
|
|
||||||
}
|
|
||||||
throw new Error(err.message || `Authentication failed (${res.status})`);
|
|
||||||
}
|
|
||||||
const data = await res.json();
|
|
||||||
return data.verificationId;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function identifyUser(verificationId: string): Promise<void> {
|
export async function identifyUser(verificationId: string): Promise<void> {
|
||||||
const res = await request('POST', '/identification', { verificationId });
|
const res = await request('POST', '/identification', { verificationId });
|
||||||
@@ -55,9 +30,117 @@ export async function submitInteraction(): Promise<string> {
|
|||||||
return data.redirectTo;
|
return data.redirectTo;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function signIn(username: string, password: string): Promise<string> {
|
// --- Sign-in ---
|
||||||
|
|
||||||
|
export async function initInteraction(): Promise<void> {
|
||||||
|
const res = await request('PUT', '', { interactionEvent: 'SignIn' });
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.message || `Failed to initialize sign-in (${res.status})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectIdentifierType(input: string): 'email' | 'username' {
|
||||||
|
return input.includes('@') ? 'email' : 'username';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPassword(
|
||||||
|
identifier: string,
|
||||||
|
password: string
|
||||||
|
): Promise<string> {
|
||||||
|
const type = detectIdentifierType(identifier);
|
||||||
|
const res = await request('POST', '/verification/password', {
|
||||||
|
identifier: { type, value: identifier },
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
if (res.status === 422) {
|
||||||
|
throw new Error('Invalid credentials');
|
||||||
|
}
|
||||||
|
throw new Error(err.message || `Authentication failed (${res.status})`);
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
return data.verificationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signIn(identifier: string, password: string): Promise<string> {
|
||||||
await initInteraction();
|
await initInteraction();
|
||||||
const verificationId = await verifyPassword(username, password);
|
const verificationId = await verifyPassword(identifier, password);
|
||||||
await identifyUser(verificationId);
|
await identifyUser(verificationId);
|
||||||
return submitInteraction();
|
return submitInteraction();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Registration ---
|
||||||
|
|
||||||
|
export async function initRegistration(): Promise<void> {
|
||||||
|
const res = await request('PUT', '', { interactionEvent: 'Register' });
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.message || `Failed to initialize registration (${res.status})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendVerificationCode(email: string): Promise<string> {
|
||||||
|
const res = await request('POST', '/verification/verification-code', {
|
||||||
|
identifier: { type: 'email', value: email },
|
||||||
|
interactionEvent: 'Register',
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
if (res.status === 422) {
|
||||||
|
throw new Error('This email is already registered');
|
||||||
|
}
|
||||||
|
throw new Error(err.message || `Failed to send verification code (${res.status})`);
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
return data.verificationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyCode(
|
||||||
|
email: string,
|
||||||
|
verificationId: string,
|
||||||
|
code: string
|
||||||
|
): Promise<string> {
|
||||||
|
const res = await request('POST', '/verification/verification-code/verify', {
|
||||||
|
identifier: { type: 'email', value: email },
|
||||||
|
verificationId,
|
||||||
|
code,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
if (res.status === 422) {
|
||||||
|
throw new Error('Invalid or expired verification code');
|
||||||
|
}
|
||||||
|
throw new Error(err.message || `Verification failed (${res.status})`);
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
return data.verificationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addProfile(type: string, value: string): Promise<void> {
|
||||||
|
const res = await request('POST', '/profile', { type, value });
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.message || `Failed to update profile (${res.status})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Phase 1: init registration + send verification email. Returns verificationId for phase 2. */
|
||||||
|
export async function startRegistration(email: string): Promise<string> {
|
||||||
|
await initRegistration();
|
||||||
|
return sendVerificationCode(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Phase 2: verify code, set password, create user, submit. Returns redirect URL. */
|
||||||
|
export async function completeRegistration(
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
verificationId: string,
|
||||||
|
code: string
|
||||||
|
): Promise<string> {
|
||||||
|
const verifiedId = await verifyCode(email, verificationId, code);
|
||||||
|
await addProfile('password', password);
|
||||||
|
await identifyUser(verifiedId);
|
||||||
|
return submitInteraction();
|
||||||
|
}
|
||||||
|
|||||||
32
ui/src/auth/RegisterPage.tsx
Normal file
32
ui/src/auth/RegisterPage.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useLogto } from '@logto/react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
import { Spinner } from '@cameleer/design-system';
|
||||||
|
|
||||||
|
export function RegisterPage() {
|
||||||
|
const { signIn, isAuthenticated, isLoading } = useLogto();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const redirected = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
navigate('/', { replace: true });
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, navigate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading && !isAuthenticated && !redirected.current) {
|
||||||
|
redirected.current = true;
|
||||||
|
signIn({
|
||||||
|
redirectUri: `${window.location.origin}/platform/callback`,
|
||||||
|
firstScreen: 'register',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isLoading, isAuthenticated, signIn]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}>
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
ui/src/pages/OnboardingPage.module.css
Normal file
59
ui/src/pages/OnboardingPage.module.css
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
.page {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoImg {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0 0 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
103
ui/src/pages/OnboardingPage.tsx
Normal file
103
ui/src/pages/OnboardingPage.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Card, Input, Button, FormField, Alert } from '@cameleer/design-system';
|
||||||
|
import cameleerLogo from '@cameleer/design-system/assets/cameleer-logo.svg';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import { toSlug } from '../utils/slug';
|
||||||
|
import styles from './OnboardingPage.module.css';
|
||||||
|
|
||||||
|
interface TenantResponse {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OnboardingPage() {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [slug, setSlug] = useState('');
|
||||||
|
const [slugTouched, setSlugTouched] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!slugTouched) {
|
||||||
|
setSlug(toSlug(name));
|
||||||
|
}
|
||||||
|
}, [name, slugTouched]);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await api.post<TenantResponse>('/onboarding/tenant', { name, slug });
|
||||||
|
// Tenant created — force a full page reload so the Logto SDK
|
||||||
|
// picks up the new org membership and scopes on the next token refresh.
|
||||||
|
window.location.href = '/platform/';
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to create tenant');
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<div className={styles.wrapper}>
|
||||||
|
<Card>
|
||||||
|
<div className={styles.inner}>
|
||||||
|
<div className={styles.logo}>
|
||||||
|
<img src={cameleerLogo} alt="" className={styles.logoImg} />
|
||||||
|
Welcome to Cameleer
|
||||||
|
</div>
|
||||||
|
<p className={styles.subtitle}>
|
||||||
|
Set up your workspace to start monitoring your Camel routes.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className={styles.error}>
|
||||||
|
<Alert variant="error">{error}</Alert>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className={styles.form}>
|
||||||
|
<FormField label="Organization name" htmlFor="onboard-name" required>
|
||||||
|
<Input
|
||||||
|
id="onboard-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Acme Corp"
|
||||||
|
autoFocus
|
||||||
|
disabled={loading}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="URL slug" htmlFor="onboard-slug" required
|
||||||
|
hint="Auto-generated from name. Appears in your dashboard URL."
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="onboard-slug"
|
||||||
|
value={slug}
|
||||||
|
onChange={(e) => { setSlugTouched(true); setSlug(e.target.value); }}
|
||||||
|
placeholder="acme-corp"
|
||||||
|
disabled={loading}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
type="submit"
|
||||||
|
loading={loading}
|
||||||
|
disabled={loading || !name || !slug}
|
||||||
|
className={styles.submit}
|
||||||
|
>
|
||||||
|
{loading ? 'Creating...' : 'Create workspace'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Routes, Route, Navigate } from 'react-router';
|
import { Routes, Route, Navigate } from 'react-router';
|
||||||
import { LoginPage } from './auth/LoginPage';
|
import { LoginPage } from './auth/LoginPage';
|
||||||
|
import { RegisterPage } from './auth/RegisterPage';
|
||||||
import { CallbackPage } from './auth/CallbackPage';
|
import { CallbackPage } from './auth/CallbackPage';
|
||||||
import { ProtectedRoute } from './auth/ProtectedRoute';
|
import { ProtectedRoute } from './auth/ProtectedRoute';
|
||||||
import { OrgResolver } from './auth/OrgResolver';
|
import { OrgResolver } from './auth/OrgResolver';
|
||||||
@@ -21,6 +22,7 @@ import { SsoPage } from './pages/tenant/SsoPage';
|
|||||||
import { TeamPage } from './pages/tenant/TeamPage';
|
import { TeamPage } from './pages/tenant/TeamPage';
|
||||||
import { SettingsPage } from './pages/tenant/SettingsPage';
|
import { SettingsPage } from './pages/tenant/SettingsPage';
|
||||||
import { TenantAuditPage } from './pages/tenant/TenantAuditPage';
|
import { TenantAuditPage } from './pages/tenant/TenantAuditPage';
|
||||||
|
import { OnboardingPage } from './pages/OnboardingPage';
|
||||||
|
|
||||||
function LandingRedirect() {
|
function LandingRedirect() {
|
||||||
const scopes = useScopes();
|
const scopes = useScopes();
|
||||||
@@ -45,7 +47,11 @@ function LandingRedirect() {
|
|||||||
window.location.href = `/t/${currentOrg.slug}/`;
|
window.location.href = `/t/${currentOrg.slug}/`;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
// No org resolved yet — stay on tenant portal
|
// No org membership at all → onboarding (self-service tenant creation)
|
||||||
|
if (organizations.length === 0) {
|
||||||
|
return <Navigate to="/onboarding" replace />;
|
||||||
|
}
|
||||||
|
// Has org but no scopes resolved yet — stay on tenant portal
|
||||||
return <Navigate to="/tenant" replace />;
|
return <Navigate to="/tenant" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,9 +59,12 @@ export function AppRouter() {
|
|||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
<Route path="/callback" element={<CallbackPage />} />
|
<Route path="/callback" element={<CallbackPage />} />
|
||||||
<Route element={<ProtectedRoute />}>
|
<Route element={<ProtectedRoute />}>
|
||||||
<Route element={<OrgResolver />}>
|
<Route element={<OrgResolver />}>
|
||||||
|
{/* Onboarding — outside Layout, shown to users with no tenants */}
|
||||||
|
<Route path="/onboarding" element={<OnboardingPage />} />
|
||||||
<Route element={<Layout />}>
|
<Route element={<Layout />}>
|
||||||
{/* Vendor console */}
|
{/* Vendor console */}
|
||||||
<Route path="/vendor/tenants" element={
|
<Route path="/vendor/tenants" element={
|
||||||
|
|||||||
Reference in New Issue
Block a user