42 Commits
v1.0.0 ... main

Author SHA1 Message Date
hsiegeln
929e7d5aed chore: update installer submodule (add SAAS_ADMIN_EMAIL to both installers)
All checks were successful
CI / build (push) Successful in 1m57s
CI / docker (push) Successful in 20s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 20:26:57 +02:00
hsiegeln
3ab6408258 feat: enforce email as primary user identity in SaaS mode
All checks were successful
CI / build (push) Successful in 2m23s
CI / docker (push) Successful in 53s
All users in SaaS mode must have an email address. The bootstrap creates
the admin user with primaryEmail set to SAAS_ADMIN_EMAIL (defaults to
<SAAS_ADMIN_USER>@<PUBLIC_HOST>). This prevents the admin from being
locked out when self-service registration (which requires email) is
enabled via the Email Connector UI.

Documentation updated across all CLAUDE.md files, .env.example,
user-manual.md, and installer submodule (README, .env.example, compose).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 20:23:30 +02:00
hsiegeln
f0aa2b7d3a fix: reset signUp identifiers when disabling registration
All checks were successful
CI / build (push) Successful in 1m45s
CI / docker (push) Successful in 1m17s
When registration is disabled, signUp.identifiers must be reset to
["username"] with verify:false. Otherwise Logto enforces email as a
mandatory profile field on all users, blocking username-only users
(like the admin) from signing in.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 20:08:46 +02:00
hsiegeln
9bf6c17d63 fix: hide registration option when sign-in mode is SignIn only
Some checks failed
CI / build (push) Successful in 2m4s
CI / docker (push) Has been cancelled
Fetch /api/.well-known/sign-in-exp on mount and check signInMode.
If not SignInAndRegister, hide the "Sign up" link and force sign-in
mode (even if ?first_screen=register was in the URL).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 20:06:17 +02:00
hsiegeln
1a4ae5b49b fix: style signed-out page to match sign-in UI
Some checks failed
CI / build (push) Successful in 2m12s
CI / docker (push) Has been cancelled
Use same layout as SignInPage: bg-base background, 400px card,
Cameleer logo with text header, matching font sizes and spacing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 20:03:04 +02:00
hsiegeln
400c32a539 fix: use sessionStorage instead of query param for logout flag
All checks were successful
CI / build (push) Successful in 2m2s
CI / docker (push) Successful in 1m12s
Logto does exact-match on post_logout_redirect_uri, so ?signed_out
caused "not registered" error. Use sessionStorage flag instead —
set before signOut, read and cleared on LoginPage mount.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 19:56:10 +02:00
hsiegeln
2cb818ec71 fix: prevent logout loop by showing signed-out state instead of auto-redirecting
All checks were successful
CI / build (push) Successful in 2m45s
CI / docker (push) Successful in 1m50s
After logout, redirect to /platform/login?signed_out which shows a
"Signed out" card with a "Sign in again" button instead of immediately
redirecting back to Logto OIDC (which would auto-authenticate if the
Logto session cookie persists).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 18:52:26 +02:00
hsiegeln
37668dcfe0 docs: update all documentation for email connector UI migration
All checks were successful
CI / build (push) Successful in 2m3s
CI / docker (push) Successful in 1m34s
- CLAUDE.md: add EmailConnectorService/Controller to vendor package
- .env.example: replace SMTP vars with note about runtime UI config
- docker/CLAUDE.md: update sign-in UI and bootstrap descriptions
- ui/CLAUDE.md: add EmailConfigPage, update sidebar and registration notes
- ui/sign-in/Dockerfile: update connector install comment
- installer: update README, .env.example (submodule)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 18:16:19 +02:00
hsiegeln
40ea6e5e69 docs: update docker CLAUDE.md and installer submodule for SMTP removal
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 18:09:57 +02:00
hsiegeln
6ab0a3c5a1 chore: update installer submodule (remove SMTP from both installers) 2026-04-25 18:08:51 +02:00
hsiegeln
8130f2053d chore: update installer submodule (remove SMTP from compose)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 18:05:42 +02:00
hsiegeln
9da908e4d2 feat: remove SMTP connector from bootstrap, default to sign-in only
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 18:05:17 +02:00
hsiegeln
d0dba73a29 feat: add email connector route and sidebar navigation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 18:03:39 +02:00
hsiegeln
9aa535ace8 feat: add EmailConfigPage with SMTP form, registration toggle, and test email 2026-04-25 18:02:30 +02:00
hsiegeln
f85b5a3634 feat: add React Query hooks for email connector API 2026-04-25 18:00:47 +02:00
hsiegeln
39e1b39f7a feat: add EmailConnectorController with CRUD, test, and registration toggle endpoints 2026-04-25 17:59:40 +02:00
hsiegeln
283d3e34a0 feat: add EmailConnectorService for Logto email connector management 2026-04-25 17:58:26 +02:00
hsiegeln
2cd15509ba feat: add email connector and sign-in experience methods to LogtoManagementClient
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 17:56:37 +02:00
hsiegeln
9d87f71bc1 docs: add email connector UI design spec and implementation plan
Move email connector configuration from installer/bootstrap into the
vendor admin UI for runtime control over SMTP delivery and self-service
registration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 17:50:47 +02:00
hsiegeln
6b77a96d52 fix: handle special characters in passwords during setup
All checks were successful
CI / build (push) Successful in 1m38s
CI / docker (push) Successful in 22s
- Logto entrypoint builds DB_URL from PG_USER/PG_PASSWORD/PG_HOST with
  URL-encoding via node's encodeURIComponent, instead of embedding the
  raw password in the connection string
- Installer submodule updated: passwords single-quoted in .env/.conf

Fixes SMTP and DB auth failures when passwords contain $, &, ;, [, etc.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 16:34:00 +02:00
hsiegeln
c58bf90604 chore: update installer submodule (restore install.ps1)
All checks were successful
CI / build (push) Successful in 1m56s
CI / docker (push) Successful in 21s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 15:32:09 +02:00
hsiegeln
273baf7996 chore: use registry.cameleer.io as default image registry
All checks were successful
CI / build (push) Successful in 2m0s
CI / docker (push) Successful in 59s
Customer-facing image defaults now reference the public registry URL.
Updates installer templates and Spring Boot provisioning defaults.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 15:24:03 +02:00
hsiegeln
5ca118dc93 docs: update CLAUDE.md for installer submodule structure
All checks were successful
CI / build (push) Successful in 1m17s
CI / docker (push) Successful in 14s
Reflect that installer/ is now a git submodule pointing to the public
cameleer-saas-installer repo, and that docker-compose.yml is a thin
dev overlay chained via COMPOSE_FILE.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 13:05:20 +02:00
hsiegeln
0b8cdf6dd9 refactor: move installer to dedicated repo, wire as git submodule
All checks were successful
CI / build (push) Successful in 1m19s
CI / docker (push) Successful in 20s
The installer (install.sh, templates/, bootstrap scripts) now lives in
cameleer/cameleer-saas-installer (public repo). Added as a git submodule
at installer/ so compose templates remain the single source of truth.

Dev compose is now a thin overlay (ports + volume mount + dev env vars).
Production templates are chained via COMPOSE_FILE in .env:
  installer/templates/docker-compose.yml
  installer/templates/docker-compose.saas.yml
  docker-compose.yml (dev overrides)

No code duplication — fixes to compose templates go to the installer
repo and propagate to both production deployments and dev via submodule.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 12:59:44 +02:00
hsiegeln
cafd7e9369 feat: add bootstrap scripts for one-line installer download
All checks were successful
CI / build (push) Successful in 1m39s
CI / docker (push) Successful in 18s
get-cameleer.sh and get-cameleer.ps1 download just the installer
files from Gitea into a local ./installer directory. Usage:

  curl -fsSL https://gitea.siegeln.net/.../get-cameleer.sh | bash
  irm https://gitea.siegeln.net/.../get-cameleer.ps1 | iex

Supports --version=v1.2.0 to pin a specific tag, defaults to main.
Pass --run to auto-execute the installer after download.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 12:48:23 +02:00
hsiegeln
b5068250f9 fix(ui): improve onboarding page styling to match sign-in page
All checks were successful
CI / build (push) Successful in 1m51s
CI / docker (push) Successful in 1m16s
Add 32px card padding and reduce max-width to 420px for consistency
with the sign-in page. Add noValidate to prevent browser-native
required validation outlines on inputs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 12:43:11 +02:00
hsiegeln
0cfa359fc5 fix(sign-in): detect register mode from URL path, not just query param
All checks were successful
CI / build (push) Successful in 1m19s
CI / docker (push) Successful in 44s
Newer Logto versions redirect to /register?app_id=... instead of
/sign-in?first_screen=register. Check the pathname in addition to
the query param so the registration form shows correctly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 10:10:57 +02:00
hsiegeln
5cc9f8c9ef fix(spa): add /register and /onboarding to SPA forward routes
All checks were successful
CI / build (push) Successful in 1m18s
CI / docker (push) Successful in 43s
These routes were missing from SpaController, so requests to
/platform/register and /platform/onboarding had no handler. Spring
forwarded to /error, which isn't in the permitAll() list, resulting
in a 401 instead of serving the SPA.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 10:05:56 +02:00
hsiegeln
b066d1abe7 fix(sign-in): validate email format before registration attempt
All checks were successful
CI / build (push) Successful in 1m17s
CI / docker (push) Successful in 46s
Show "Please enter a valid email address" when the user enters a
username instead of an email in the sign-up form, rather than letting
it hit Logto's API and returning a cryptic 400.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 09:41:41 +02:00
hsiegeln
ae1d9fa4db fix(docker): add extra_hosts so Logto can reach itself via public hostname
All checks were successful
CI / build (push) Successful in 1m14s
CI / docker (push) Successful in 18s
Logto validates M2M tokens by fetching its own JWKS from the ENDPOINT
URL (e.g. https://app.cameleer.io/oidc/jwks). Behind a Cloudflare
tunnel, that hostname resolves to Cloudflare's IP and the container
can't route back through the tunnel — the fetch times out (ETIMEDOUT),
causing all Management API calls to return 500.

Adding extra_hosts maps AUTH_HOST to host-gateway so the request goes
to the Docker host, which has Traefik on :443, which routes back to
Logto internally. This hairpin works because NODE_TLS_REJECT=0 accepts
the self-signed cert.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 09:13:39 +02:00
hsiegeln
6fe10432e6 fix(installer): remove duplicate config load that kills upgrade silently
All checks were successful
CI / build (push) Successful in 1m18s
CI / docker (push) Successful in 15s
The upgrade path in handle_rerun called load_config_file a second time
(already called by detect_existing_install). On the second pass, every
variable is already set, so [ -z "$VAR" ] && VAR="$value" returns
exit code 1 (test fails, && short-circuits). With set -e, the non-zero
exit from the case clause kills the script silently after printing
"[INFO] Upgrading installation..." — no error, no further output.

Removed the redundant load_config_file and load_env_overrides calls.
Both were already executed in main() before handle_rerun is reached.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 09:03:07 +02:00
hsiegeln
9f3faf4816 fix(traefik): set Logto router priority=1 to prevent route hijacking
All checks were successful
CI / build (push) Successful in 1m17s
CI / docker (push) Successful in 18s
Traefik auto-calculates router priority from rule string length. When
deployed with a domain longer than 23 chars (e.g. app.cameleer.io),
Host(`app.cameleer.io`) (25 chars) outranks PathPrefix(`/platform`)
(23 chars), causing ALL requests — including /platform/* — to route
to Logto instead of the SaaS app. This breaks login because the sign-in
UI loads without an OIDC interaction session.

Setting priority=1 makes Logto a true catch-all, matching the intent
documented in docker/CLAUDE.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 08:50:16 +02:00
hsiegeln
a60095608e fix(installer): send correct Host header in Traefik routing check
All checks were successful
CI / build (push) Successful in 1m18s
CI / docker (push) Successful in 19s
The root redirect rule matches Host(`PUBLIC_HOST`), not localhost.
Curl with --resolve (bash) and Host header (PS1) so the health
check sends the right hostname when verifying Traefik routing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 08:15:18 +02:00
hsiegeln
9f9112c6a5 feat(installer): add interactive registry prompts
Some checks failed
CI / build (push) Successful in 1m19s
CI / docker (push) Successful in 16s
SonarQube Analysis / sonarqube (push) Failing after 1m47s
Both simple and expert modes now ask "Pull images from a private
registry?" with follow-up prompts for URL, username, and token.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 02:17:52 +02:00
hsiegeln
e1a9f6d225 feat(installer): add --registry, --registry-user, --registry-token
All checks were successful
CI / build (push) Successful in 1m21s
CI / docker (push) Successful in 15s
Both installers (bash + PS1) now support pulling images from a
custom Docker registry. Writes *_IMAGE env vars to .env so compose
templates use the configured registry. Runs docker login before
pull when credentials are provided. Persisted in cameleer.conf
for upgrades/reconfigure.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 02:10:48 +02:00
hsiegeln
180644f0df fix(installer): SIGPIPE crash in generate_password with pipefail
All checks were successful
CI / build (push) Successful in 1m15s
CI / docker (push) Successful in 18s
`tr | head -c 32` causes tr to receive SIGPIPE when head exits early.
With `set -eo pipefail`, exit code 141 kills the script right after
"Configuration validated" before any passwords are generated.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 01:41:47 +02:00
hsiegeln
62b74d2d06 ci: remove sync-images workflow
All checks were successful
CI / build (push) Successful in 1m16s
CI / docker (push) Successful in 16s
Remote server will pull directly from the Gitea registry instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 01:25:56 +02:00
hsiegeln
3e2f035d97 fix(ci): use POSIX-compatible loop instead of bash arrays
All checks were successful
CI / build (push) Successful in 1m18s
CI / docker (push) Successful in 18s
The docker-builder container runs ash/sh, not bash — arrays with ()
are not supported. Use a simple for-in loop instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 00:32:12 +02:00
hsiegeln
9962ee99d9 fix(ci): drop ssh-keyscan, use StrictHostKeyChecking=accept-new instead
All checks were successful
CI / build (push) Successful in 1m16s
CI / docker (push) Successful in 17s
ssh-keyscan fails when the runner can't reach the host on port 22
during that step. Using accept-new on the ssh command itself is
equivalent for an ephemeral CI runner.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 00:29:52 +02:00
hsiegeln
b53840b77b ci: add manual workflow to sync Docker images to remote server
Some checks failed
CI / docker (push) Has been cancelled
CI / build (push) Has been cancelled
Pulls all :latest images from the Gitea registry and pipes them
via `docker save | ssh docker load` to the APP_HOST server.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 00:28:39 +02:00
hsiegeln
9ed2cedc98 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>
2026-04-25 00:21:07 +02:00
hsiegeln
dc7ac3a1ec feat: split auth domain — Logto gets dedicated AUTH_HOST
All checks were successful
CI / build (push) Successful in 1m22s
CI / docker (push) Successful in 48s
Support separate auth domain (e.g. auth.cameleer.io) for Logto while
keeping the SaaS app on PUBLIC_HOST (e.g. app.cameleer.io). AUTH_HOST
defaults to PUBLIC_HOST for backward-compatible single-domain setups.

- Logto routing: Host(AUTH_HOST) replaces PathPrefix('/') catch-all
- Root redirect moved from traefik-dynamic.yml to Docker labels with
  Host(PUBLIC_HOST) scope so it doesn't intercept auth domain
- Self-signed cert generates SANs for both domains
- Bootstrap Host header uses AUTH_HOST for Logto endpoint validation
- Spring issuer-uri and oidcissueruri use new authhost property
- Both installers (sh + ps1) prompt for AUTH_HOST in expert mode

Local dev: AUTH_HOST=auth.localhost (resolves to 127.0.0.1, no hosts file)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 18:11:47 +02:00
48 changed files with 3254 additions and 3585 deletions

View File

@@ -7,6 +7,9 @@ VERSION=latest
# Public access # Public access
PUBLIC_HOST=localhost PUBLIC_HOST=localhost
PUBLIC_PROTOCOL=https PUBLIC_PROTOCOL=https
# Auth domain (Logto). Defaults to PUBLIC_HOST for single-domain setups.
# Set to a separate subdomain (e.g. auth.cameleer.io) to split auth from the app.
# AUTH_HOST=localhost
# Ports # Ports
HTTP_PORT=80 HTTP_PORT=80
@@ -22,8 +25,14 @@ POSTGRES_DB=cameleer_saas
CLICKHOUSE_PASSWORD=change_me_in_production CLICKHOUSE_PASSWORD=change_me_in_production
# Admin user (created by bootstrap) # Admin user (created by bootstrap)
# Email is the primary user identity in SaaS mode. The admin email defaults
# to <SAAS_ADMIN_USER>@<PUBLIC_HOST> if not set explicitly.
SAAS_ADMIN_USER=admin SAAS_ADMIN_USER=admin
SAAS_ADMIN_PASS=change_me_in_production SAAS_ADMIN_PASS=change_me_in_production
# SAAS_ADMIN_EMAIL=admin@example.com
# SMTP / email connector configuration is managed at runtime via the vendor
# admin UI (Email Connector page at /vendor/email). No SMTP env vars needed.
# 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

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "installer"]
path = installer
url = https://gitea.siegeln.net/cameleer/cameleer-saas-installer.git

View 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.

View File

@@ -4,7 +4,9 @@ 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.
**Email is the primary user identity** in SaaS mode. All users — including the admin — must have an email address. The admin email defaults to `<SAAS_ADMIN_USER>@<PUBLIC_HOST>` if not set explicitly via `SAAS_ADMIN_EMAIL`. Self-service registration (email + password + verification code) is disabled by default and enabled via the vendor UI after configuring an email connector.
## Ecosystem ## Ecosystem
@@ -25,7 +27,8 @@ 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`, `EmailConnectorService`, `EmailConnectorController` |
| `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` |
@@ -46,7 +49,7 @@ For detailed architecture docs, see the directory-scoped CLAUDE.md files (loaded
- **Provisioning flow, env vars, lifecycle** → `src/.../provisioning/CLAUDE.md` - **Provisioning flow, env vars, lifecycle** → `src/.../provisioning/CLAUDE.md`
- **Auth, scopes, JWT, OIDC** → `src/.../config/CLAUDE.md` - **Auth, scopes, JWT, OIDC** → `src/.../config/CLAUDE.md`
- **Docker, routing, networks, bootstrap, deployment pipeline** → `docker/CLAUDE.md` - **Docker, routing, networks, bootstrap, deployment pipeline** → `docker/CLAUDE.md`
- **Installer, deployment modes, compose templates** → `installer/CLAUDE.md` - **Installer, deployment modes, compose templates** → `installer/CLAUDE.md` (git submodule: `cameleer-saas-installer`)
- **Frontend, sign-in UI** → `ui/CLAUDE.md` - **Frontend, sign-in UI** → `ui/CLAUDE.md`
## Database Migrations ## Database Migrations
@@ -65,7 +68,8 @@ PostgreSQL (Flyway): `src/main/resources/db/migration/`
- `cameleer-server` / `cameleer-server-ui` — provisioned per-tenant (not in compose, created by `DockerTenantProvisioner`) - `cameleer-server` / `cameleer-server-ui` — provisioned per-tenant (not in compose, created by `DockerTenantProvisioner`)
- `cameleer-runtime-base` — base image for deployed apps (agent JAR + `cameleer-log-appender.jar` + JRE). CI downloads latest agent and log appender SNAPSHOTs from Gitea Maven registry. The Dockerfile ENTRYPOINT is overridden by `DockerRuntimeOrchestrator` at container creation; agent config uses `CAMELEER_AGENT_*` env vars set by `DeploymentExecutor`. - `cameleer-runtime-base` — base image for deployed apps (agent JAR + `cameleer-log-appender.jar` + JRE). CI downloads latest agent and log appender SNAPSHOTs from Gitea Maven registry. The Dockerfile ENTRYPOINT is overridden by `DockerRuntimeOrchestrator` at container creation; agent config uses `CAMELEER_AGENT_*` env vars set by `DeploymentExecutor`.
- Docker builds: `--no-cache`, `--provenance=false` for Gitea compatibility - Docker builds: `--no-cache`, `--provenance=false` for Gitea compatibility
- `docker-compose.dev.yml` — exposes ports for direct access, sets `SPRING_PROFILES_ACTIVE: dev`. Volume-mounts `./ui/dist` into the container so local UI builds are served without rebuilding the Docker image (`SPRING_WEB_RESOURCES_STATIC_LOCATIONS` overrides classpath). Adds Docker socket mount for tenant provisioning. - `docker-compose.yml` (root) — thin dev overlay (ports, volume mounts, `SPRING_PROFILES_ACTIVE: dev`). Chained on top of production templates from the installer submodule via `COMPOSE_FILE` in `.env`.
- Installer is a **git submodule** at `installer/` pointing to `cameleer/cameleer-saas-installer` (public repo). Compose templates live there — single source of truth, no duplication. Run `git submodule update --remote installer` to pull template updates.
- Design system: import from `@cameleer/design-system` (Gitea npm registry) - Design system: import from `@cameleer/design-system` (Gitea npm registry)
## Disabled Skills ## Disabled Skills
@@ -75,7 +79,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** (2881 symbols, 6138 relationships, 243 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.

View File

@@ -1,37 +0,0 @@
# Development overrides: exposes ports for direct access
# Usage: docker compose -f docker-compose.yml -f docker-compose.dev.yml up
services:
cameleer-postgres:
ports:
- "5432:5432"
cameleer-logto:
ports:
- "3001:3001"
logto-bootstrap:
environment:
VENDOR_SEED_ENABLED: "true"
cameleer-saas:
ports:
- "8080:8080"
volumes:
- ./ui/dist:/app/static
- /var/run/docker.sock:/var/run/docker.sock
group_add:
- "0"
environment:
SPRING_PROFILES_ACTIVE: dev
SPRING_WEB_RESOURCES_STATIC_LOCATIONS: file:/app/static/,classpath:/static/
CAMELEER_SAAS_PROVISIONING_PUBLICHOST: ${PUBLIC_HOST:-localhost}
CAMELEER_SAAS_PROVISIONING_PUBLICPROTOCOL: ${PUBLIC_PROTOCOL:-https}
CAMELEER_SAAS_PROVISIONING_SERVERIMAGE: gitea.siegeln.net/cameleer/cameleer-server:${VERSION:-latest}
CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE: gitea.siegeln.net/cameleer/cameleer-server-ui:${VERSION:-latest}
CAMELEER_SAAS_PROVISIONING_RUNTIMEBASEIMAGE: gitea.siegeln.net/cameleer/cameleer-runtime-base:${VERSION:-latest}
CAMELEER_SAAS_PROVISIONING_NETWORKNAME: cameleer-saas_cameleer
CAMELEER_SAAS_PROVISIONING_TRAEFIKNETWORK: cameleer-traefik
cameleer-clickhouse:
ports:
- "8123:8123"

View File

@@ -1,158 +1,23 @@
# Dev overrides — layered on top of installer/templates/ via COMPOSE_FILE in .env
# Usage: docker compose up (reads .env automatically)
services: services:
cameleer-traefik:
image: ${TRAEFIK_IMAGE:-gitea.siegeln.net/cameleer/cameleer-traefik}:${VERSION:-latest}
restart: unless-stopped
ports:
- "${HTTP_PORT:-80}:80"
- "${HTTPS_PORT:-443}:443"
- "${LOGTO_CONSOLE_PORT:-3002}:3002"
environment:
PUBLIC_HOST: ${PUBLIC_HOST:-localhost}
CERT_FILE: ${CERT_FILE:-}
KEY_FILE: ${KEY_FILE:-}
CA_FILE: ${CA_FILE:-}
volumes:
- cameleer-certs:/certs
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- cameleer
- cameleer-traefik
cameleer-postgres: cameleer-postgres:
image: ${POSTGRES_IMAGE:-gitea.siegeln.net/cameleer/cameleer-postgres}:${VERSION:-latest} ports:
restart: unless-stopped - "5432:5432"
environment:
POSTGRES_DB: ${POSTGRES_DB:-cameleer_saas}
POSTGRES_USER: ${POSTGRES_USER:-cameleer}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-cameleer_dev}
volumes:
- cameleer-pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-cameleer} -d ${POSTGRES_DB:-cameleer_saas}"]
interval: 5s
timeout: 5s
retries: 5
networks:
- cameleer
cameleer-clickhouse: cameleer-clickhouse:
image: ${CLICKHOUSE_IMAGE:-gitea.siegeln.net/cameleer/cameleer-clickhouse}:${VERSION:-latest} ports:
restart: unless-stopped - "8123:8123"
environment:
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-cameleer_ch}
volumes:
- cameleer-chdata:/var/lib/clickhouse
healthcheck:
test: ["CMD-SHELL", "clickhouse-client --password ${CLICKHOUSE_PASSWORD:-cameleer_ch} --query 'SELECT 1'"]
interval: 10s
timeout: 5s
retries: 3
labels:
- prometheus.scrape=true
- prometheus.path=/metrics
- prometheus.port=9363
networks:
- cameleer
cameleer-logto: cameleer-logto:
image: ${LOGTO_IMAGE:-gitea.siegeln.net/cameleer/cameleer-logto}:${VERSION:-latest} ports:
restart: unless-stopped - "3001:3001"
depends_on:
cameleer-postgres:
condition: service_healthy
environment:
DB_URL: postgres://${POSTGRES_USER:-cameleer}:${POSTGRES_PASSWORD:-cameleer_dev}@cameleer-postgres:5432/logto
ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
ADMIN_ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}:${LOGTO_CONSOLE_PORT:-3002}
TRUST_PROXY_HEADER: 1
NODE_TLS_REJECT_UNAUTHORIZED: "${NODE_TLS_REJECT:-0}"
LOGTO_ENDPOINT: http://cameleer-logto:3001
LOGTO_ADMIN_ENDPOINT: http://cameleer-logto:3002
LOGTO_PUBLIC_ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
PUBLIC_HOST: ${PUBLIC_HOST:-localhost}
PUBLIC_PROTOCOL: ${PUBLIC_PROTOCOL:-https}
PG_HOST: cameleer-postgres
PG_USER: ${POSTGRES_USER:-cameleer}
PG_PASSWORD: ${POSTGRES_PASSWORD:-cameleer_dev}
PG_DB_SAAS: ${POSTGRES_DB:-cameleer_saas}
SAAS_ADMIN_USER: ${SAAS_ADMIN_USER:-admin}
SAAS_ADMIN_PASS: ${SAAS_ADMIN_PASS:-admin}
healthcheck:
test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3001/oidc/.well-known/openid-configuration', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))\" && test -f /data/logto-bootstrap.json"]
interval: 10s
timeout: 5s
retries: 60
start_period: 30s
labels:
- traefik.enable=true
- traefik.http.routers.cameleer-logto.rule=PathPrefix(`/`)
- traefik.http.routers.cameleer-logto.priority=1
- traefik.http.routers.cameleer-logto.entrypoints=websecure
- traefik.http.routers.cameleer-logto.tls=true
- traefik.http.routers.cameleer-logto.service=cameleer-logto
- traefik.http.routers.cameleer-logto.middlewares=cameleer-logto-cors
- "traefik.http.middlewares.cameleer-logto-cors.headers.accessControlAllowOriginList=${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}:${LOGTO_CONSOLE_PORT:-3002}"
- traefik.http.middlewares.cameleer-logto-cors.headers.accessControlAllowMethods=GET,POST,PUT,PATCH,DELETE,OPTIONS
- traefik.http.middlewares.cameleer-logto-cors.headers.accessControlAllowHeaders=Authorization,Content-Type
- traefik.http.middlewares.cameleer-logto-cors.headers.accessControlAllowCredentials=true
- traefik.http.services.cameleer-logto.loadbalancer.server.port=3001
- traefik.http.routers.cameleer-logto-console.rule=PathPrefix(`/`)
- traefik.http.routers.cameleer-logto-console.entrypoints=admin-console
- traefik.http.routers.cameleer-logto-console.tls=true
- traefik.http.routers.cameleer-logto-console.service=cameleer-logto-console
- traefik.http.services.cameleer-logto-console.loadbalancer.server.port=3002
volumes:
- cameleer-bootstrapdata:/data
networks:
- cameleer
cameleer-saas: cameleer-saas:
image: ${CAMELEER_IMAGE:-gitea.siegeln.net/cameleer/cameleer-saas}:${VERSION:-latest} ports:
restart: unless-stopped - "8080:8080"
depends_on:
cameleer-logto:
condition: service_healthy
volumes: volumes:
- cameleer-bootstrapdata:/data/bootstrap:ro - ./ui/dist:/app/static
- cameleer-certs:/certs
- /var/run/docker.sock:/var/run/docker.sock
environment: environment:
# SaaS database SPRING_PROFILES_ACTIVE: dev
SPRING_DATASOURCE_URL: jdbc:postgresql://cameleer-postgres:5432/${POSTGRES_DB:-cameleer_saas} SPRING_WEB_RESOURCES_STATIC_LOCATIONS: file:/app/static/,classpath:/static/
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-cameleer}
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:-cameleer_dev}
# Identity (Logto)
CAMELEER_SAAS_IDENTITY_LOGTOENDPOINT: ${LOGTO_ENDPOINT:-http://cameleer-logto:3001}
CAMELEER_SAAS_IDENTITY_LOGTOPUBLICENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
CAMELEER_SAAS_IDENTITY_M2MCLIENTID: ${LOGTO_M2M_CLIENT_ID:-}
CAMELEER_SAAS_IDENTITY_M2MCLIENTSECRET: ${LOGTO_M2M_CLIENT_SECRET:-}
CAMELEER_SERVER_SECURITY_JWTSECRET: ${CAMELEER_SERVER_SECURITY_JWTSECRET:-cameleer-dev-jwt-secret}
# Provisioning — passed to per-tenant server containers
CAMELEER_SAAS_PROVISIONING_PUBLICHOST: ${PUBLIC_HOST:-localhost}
CAMELEER_SAAS_PROVISIONING_PUBLICPROTOCOL: ${PUBLIC_PROTOCOL:-https}
CAMELEER_SAAS_PROVISIONING_DATASOURCEUSERNAME: ${POSTGRES_USER:-cameleer}
CAMELEER_SAAS_PROVISIONING_DATASOURCEPASSWORD: ${POSTGRES_PASSWORD:-cameleer_dev}
CAMELEER_SAAS_PROVISIONING_CLICKHOUSEPASSWORD: ${CLICKHOUSE_PASSWORD:-cameleer_ch}
labels:
- traefik.enable=true
- traefik.http.routers.saas.rule=PathPrefix(`/platform`)
- traefik.http.routers.saas.entrypoints=websecure
- traefik.http.routers.saas.tls=true
- traefik.http.services.saas.loadbalancer.server.port=8080
group_add:
- "${DOCKER_GID:-0}"
networks:
- cameleer
networks:
cameleer:
driver: bridge
cameleer-traefik:
name: cameleer-traefik
driver: bridge
volumes:
cameleer-pgdata:
cameleer-chdata:
cameleer-certs:
cameleer-bootstrapdata:

View File

@@ -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 (registration is disabled by default until the vendor admin configures an email connector via the UI).
- 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 + 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)
@@ -78,11 +80,14 @@ Idempotent script run inside the Logto container entrypoint. **Clean slate** —
3. Create Logto apps (SPA, Traditional Web App with `skipConsent`, M2M with Management API role + server API role) 3. Create Logto apps (SPA, Traditional Web App with `skipConsent`, M2M with Management API role + server API role)
3b. Create API resource scopes (1 platform + 9 tenant + 3 server scopes) 3b. Create API resource scopes (1 platform + 9 tenant + 3 server scopes)
4. Create org roles (owner, operator, viewer with API resource scope assignments) + M2M server role (`cameleer-m2m-server` with `server:admin` scope) 4. Create org roles (owner, operator, viewer with API resource scope assignments) + M2M server role (`cameleer-m2m-server` with `server:admin` scope)
5. Create admin user (SaaS admin with Logto console access) 5. Create admin user (SaaS admin with Logto console access). Email is the primary user identity in SaaS mode — admin email defaults to `<SAAS_ADMIN_USER>@<PUBLIC_HOST>` if `SAAS_ADMIN_EMAIL` is not set.
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`)
8c. Configure sign-in experience (sign-in only) — sets `signInMode: "SignIn"` with username+password method. Registration is disabled by default; the vendor admin enables it via the Email Connector UI after configuring SMTP delivery.
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 / email connector configuration is managed at runtime via the vendor admin UI (Email Connector page). The bootstrap no longer creates email connectors — it defaults to sign-in only mode. Registration is enabled automatically when the admin configures an email connector through the UI.
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`.

View File

@@ -1,6 +1,14 @@
#!/bin/sh #!/bin/sh
set -e set -e
# Build DB_URL from individual env vars so passwords with special characters
# are properly URL-encoded (Logto only accepts a connection string)
if [ -z "$DB_URL" ]; then
ENCODED_PW=$(node -e "process.stdout.write(encodeURIComponent(process.env.PG_PASSWORD || ''))")
export DB_URL="postgres://${PG_USER:-cameleer}:${ENCODED_PW}@${PG_HOST:-localhost}:5432/logto"
echo "[entrypoint] Built DB_URL from PG_USER/PG_PASSWORD/PG_HOST"
fi
# Save the real public endpoints for after bootstrap # Save the real public endpoints for after bootstrap
REAL_ENDPOINT="$ENDPOINT" REAL_ENDPOINT="$ENDPOINT"
REAL_ADMIN_ENDPOINT="$ADMIN_ENDPOINT" REAL_ADMIN_ENDPOINT="$ADMIN_ENDPOINT"

View File

@@ -28,12 +28,20 @@ if [ ! -f "$CERTS_DIR/cert.pem" ]; then
else else
# Generate self-signed certificate # Generate self-signed certificate
HOST="${PUBLIC_HOST:-localhost}" HOST="${PUBLIC_HOST:-localhost}"
AUTH="${AUTH_HOST:-$HOST}"
echo "[certs] Generating self-signed certificate for $HOST..." echo "[certs] Generating self-signed certificate for $HOST..."
# Build SAN list; deduplicate when AUTH_HOST equals PUBLIC_HOST
if [ "$AUTH" = "$HOST" ]; then
SAN="DNS:$HOST,DNS:*.$HOST"
else
SAN="DNS:$HOST,DNS:*.$HOST,DNS:$AUTH,DNS:*.$AUTH"
echo "[certs] (+ auth domain: $AUTH)"
fi
openssl req -x509 -newkey rsa:4096 \ openssl req -x509 -newkey rsa:4096 \
-keyout "$CERTS_DIR/key.pem" -out "$CERTS_DIR/cert.pem" \ -keyout "$CERTS_DIR/key.pem" -out "$CERTS_DIR/cert.pem" \
-days 365 -nodes \ -days 365 -nodes \
-subj "/CN=$HOST" \ -subj "/CN=$HOST" \
-addext "subjectAltName=DNS:$HOST,DNS:*.$HOST" -addext "subjectAltName=$SAN"
SELF_SIGNED=true SELF_SIGNED=true
echo "[certs] Generated self-signed certificate for $HOST." echo "[certs] Generated self-signed certificate for $HOST."
fi fi

View File

@@ -1,21 +1,3 @@
http:
routers:
root-redirect:
rule: "Path(`/`)"
priority: 100
entryPoints:
- websecure
tls: {}
middlewares:
- root-to-platform
service: saas@docker
middlewares:
root-to-platform:
redirectRegex:
regex: "^(https?://[^/]+)/?$"
replacement: "${1}/platform/"
permanent: false
tls: tls:
stores: stores:
default: default:

View File

@@ -27,11 +27,15 @@ API_RESOURCE_NAME="Cameleer SaaS API"
# Users (configurable via env vars) # Users (configurable via env vars)
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}"
# Admin email: use provided value, or derive from username@host.
# SaaS enforces email as the user identity — admin must have one.
SAAS_ADMIN_EMAIL="${SAAS_ADMIN_EMAIL:-${SAAS_ADMIN_USER}@${PUBLIC_HOST:-localhost}}"
# No server config — servers are provisioned dynamically by the admin console # No server config — servers are provisioned dynamically by the admin console
# Redirect URIs (derived from PUBLIC_HOST and PUBLIC_PROTOCOL) # Redirect URIs (derived from PUBLIC_HOST and PUBLIC_PROTOCOL)
HOST="${PUBLIC_HOST:-localhost}" HOST="${PUBLIC_HOST:-localhost}"
AUTH="${AUTH_HOST:-$HOST}"
PROTO="${PUBLIC_PROTOCOL:-https}" PROTO="${PUBLIC_PROTOCOL:-https}"
SPA_REDIRECT_URIS="[\"${PROTO}://${HOST}/platform/callback\"]" SPA_REDIRECT_URIS="[\"${PROTO}://${HOST}/platform/callback\"]"
SPA_POST_LOGOUT_URIS="[\"${PROTO}://${HOST}/platform/login\",\"${PROTO}://${HOST}/platform/\"]" SPA_POST_LOGOUT_URIS="[\"${PROTO}://${HOST}/platform/login\",\"${PROTO}://${HOST}/platform/\"]"
@@ -47,8 +51,9 @@ if [ "$BOOTSTRAP_LOCAL" = "true" ]; then
HOST_ARGS="" HOST_ARGS=""
ADMIN_HOST_ARGS="" ADMIN_HOST_ARGS=""
else else
HOST_ARGS="-H Host:${HOST}" # Logto validates Host header against its ENDPOINT, which uses AUTH_HOST
ADMIN_HOST_ARGS="-H Host:${HOST}:3002 -H X-Forwarded-Proto:https" HOST_ARGS="-H Host:${AUTH}"
ADMIN_HOST_ARGS="-H Host:${AUTH}:3002 -H X-Forwarded-Proto:https"
fi fi
# Install jq + curl if not already available (deps are baked into cameleer-logto image) # Install jq + curl if not already available (deps are baked into cameleer-logto image)
@@ -392,11 +397,12 @@ ADMIN_USER_ID=$(api_get "/api/users?search=$SAAS_ADMIN_USER" | jq -r ".[] | sele
if [ -n "$ADMIN_USER_ID" ]; then if [ -n "$ADMIN_USER_ID" ]; then
log "Platform owner exists: $ADMIN_USER_ID" log "Platform owner exists: $ADMIN_USER_ID"
else else
log "Creating platform owner '$SAAS_ADMIN_USER'..." log "Creating platform owner '$SAAS_ADMIN_USER' (email: $SAAS_ADMIN_EMAIL)..."
ADMIN_RESPONSE=$(api_post "/api/users" "{ ADMIN_RESPONSE=$(api_post "/api/users" "{
\"username\": \"$SAAS_ADMIN_USER\", \"username\": \"$SAAS_ADMIN_USER\",
\"password\": \"$SAAS_ADMIN_PASS\", \"password\": \"$SAAS_ADMIN_PASS\",
\"name\": \"Platform Owner\" \"name\": \"Platform Owner\",
\"primaryEmail\": \"$SAAS_ADMIN_EMAIL\"
}") }")
ADMIN_USER_ID=$(echo "$ADMIN_RESPONSE" | jq -r '.id') ADMIN_USER_ID=$(echo "$ADMIN_RESPONSE" | jq -r '.id')
log "Created platform owner: $ADMIN_USER_ID" log "Created platform owner: $ADMIN_USER_ID"
@@ -562,6 +568,28 @@ api_patch "/api/sign-in-exp" "{
}" }"
log "Sign-in branding configured." log "Sign-in branding configured."
# ============================================================
# PHASE 8c: Configure sign-in experience (sign-in only)
# ============================================================
# Registration is disabled by default. The vendor admin enables it
# via the Email Connector UI after configuring SMTP delivery.
log "Configuring sign-in experience (sign-in only, no registration)..."
api_patch "/api/sign-in-exp" '{
"signInMode": "SignIn",
"signIn": {
"methods": [
{
"identifier": "username",
"password": true,
"verificationCode": false,
"isPasswordPrimary": true
}
]
}
}' >/dev/null 2>&1
log "Sign-in experience configured: SignIn only (registration disabled until email is configured)."
# ============================================================ # ============================================================
# PHASE 9: Cleanup seeded apps # PHASE 9: Cleanup seeded apps
# ============================================================ # ============================================================

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,147 @@
# Email Connector UI Configuration
Move email connector setup from the one-shot installer/bootstrap into the vendor admin UI, giving platform admins runtime control over email delivery and self-service registration.
## Context
The current flow bakes SMTP configuration into the installer prompts and the Logto bootstrap script. This has two problems: (1) the bootstrap factory selection regex doesn't match the actual Logto SMTP factory ID (`simple-mail-transfer-protocol`), causing it to pick the wrong factory and fail silently; (2) bootstrap is a one-shot — if SMTP is added or changed after first boot, the connector is never created or updated.
Moving configuration to the UI fixes both issues and gives admins the ability to configure, test, change, or remove email delivery at any time.
## Design Decisions
- **SMTP only for now**, but the architecture supports adding other providers (SES, SendGrid, Mailgun, etc.) with one form component and one service method per provider.
- **Registration is disabled by default** until email is configured. Admins get a toggle to enable/disable registration independently once email works.
- **Test email sends a real email** to a recipient address the admin provides, proving end-to-end delivery.
- **Email templates are hardcoded** — four Cameleer-branded HTML templates (Register, SignIn, ForgotPassword, Generic) attached automatically when saving config.
- **Email config lives under an expandable "Identity" sidebar section**, replacing the flat external Logto link. The section contains "Email Connector" and "Logto Console" (external link).
## Section 1: Removal
### Installer — bash (`installer/install.sh`)
- Remove SMTP prompt block (~lines 499-509): `prompt_yesno`, `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM_EMAIL`
- Remove SMTP vars from `.env` generation
- Remove SMTP vars from `cameleer.conf` persistence
### Installer — PowerShell (`installer/install.ps1`)
- Remove env var reads (lines 95-99): `$_ENV_SMTP_HOST` through `$_ENV_SMTP_FROM_EMAIL`
- Remove config file parsing (lines 305-309): `smtp_host` through `smtp_from_email` cases
- Remove env fallback merging (lines 342-346): `if (-not $c.SmtpHost)` blocks
- Remove SMTP prompt block (lines 516-523)
- Remove SMTP `.env` output (lines 778-782, 789)
- Remove SMTP `cameleer.conf` output (lines 1028-1031, 1036)
### Compose template (`installer/templates/docker-compose.saas.yml`)
- Remove the 5 SMTP env vars from the cameleer-logto service (lines 30-35): `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM_EMAIL`
### Bootstrap (`docker/logto-bootstrap.sh`)
- Remove Phase 8b entirely (lines 568-649): SMTP connector creation via `/api/connector-factories` and `/api/connectors`
- Modify Phase 8c (lines 657-682): Change `signInMode` from `"SignInAndRegister"` to `"SignIn"`. Remove `signUp.identifiers: ["email"]` and `signUp.verify: true`. Keep username+password sign-in method for the admin user. Registration gets enabled by the backend when the admin configures email.
## Section 2: Backend — Email Connector API
### New controller: `EmailConnectorController`
Location: `src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorController.java`
`@RestController`, `@RequestMapping("/api/vendor/email-connector")`, `@PreAuthorize("hasAuthority('SCOPE_platform:admin')")`
Endpoints:
| Method | Path | Purpose |
|--------|------|---------|
| GET | `/api/vendor/email-connector` | Returns current email connector config (password masked) + registration enabled state. 404 if none configured. |
| POST | `/api/vendor/email-connector` | Creates or updates connector. Accepts SMTP config + optional `registrationEnabled` boolean. Attaches branded templates. Enables registration on first save unless explicitly set to false. |
| DELETE | `/api/vendor/email-connector` | Removes connector, force-disables registration. |
| POST | `/api/vendor/email-connector/test` | Accepts `{to: "email"}`, sends test email through configured connector, returns success/failure with message. |
### New service: `EmailConnectorService`
Location: `src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorService.java`
Responsibilities:
- Maps provider-specific DTOs to Logto connector config format
- Selects the correct Logto factory ID per provider (SMTP = `simple-mail-transfer-protocol`)
- Hardcodes the four Cameleer-branded HTML email templates (Register, SignIn, ForgotPassword, Generic) with `{{code}}` placeholder and `#C6820E` brand color
- Manages sign-in experience toggle via `PATCH /api/sign-in-exp`
- Handles test email flow
### New methods on `LogtoManagementClient`
Location: `src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java`
Following the existing SSO connector method patterns:
- `listConnectorFactories()``GET /api/connector-factories`
- `listConnectors()``GET /api/connectors`
- `createConnector(factoryId, config)``POST /api/connectors`
- `updateConnector(connectorId, config)``PATCH /api/connectors/{id}`
- `deleteConnector(connectorId)``DELETE /api/connectors/{id}`
- `testConnector(factoryId, email, config)``POST /api/connectors/{factoryId}/test` (Logto's built-in test endpoint; sends a real email with the provided config without needing to save first)
- `updateSignInExperience(config)``PATCH /api/sign-in-exp`
- `getSignInExperience()``GET /api/sign-in-exp`
## Section 3: Frontend — Email Configuration Page
### New page: `EmailConfigPage.tsx`
Location: `ui/src/pages/vendor/EmailConfigPage.tsx`
Follows the CertificatesPage pattern (Card layout, form fields, mutation hooks, toast notifications).
**Three UI states:**
| Email configured | Registration toggle | signInMode |
|---|---|---|
| No | Disabled, off | `SignIn` |
| Yes | On (default after first save) | `SignInAndRegister` |
| Yes | Off (admin chose to disable) | `SignIn` |
**Unconfigured state:**
- Info alert: "Email delivery is not configured. Self-service registration is disabled."
- SMTP form: Host (text), Port (number, default 587), Username (text), Password (password), From Email (email). All required.
- Save button.
**Configured state:**
- Card showing current config: host, port, username, from-email. Password masked as `••••••••`.
- Registration toggle (switch) with label "Enable self-service registration".
- Edit button to modify config, Delete button with confirmation dialog warning that removal disables registration.
- "Send Test Email" section: text input for recipient + Send button. Success/failure shown inline.
### New hooks: `email-connector-hooks.ts`
Location: `ui/src/api/email-connector-hooks.ts`
Following the certificate-hooks pattern:
- `useEmailConnector()``GET /api/vendor/email-connector`
- `useSaveEmailConnector()``POST /api/vendor/email-connector`
- `useDeleteEmailConnector()``DELETE /api/vendor/email-connector`
- `useTestEmailConnector()``POST /api/vendor/email-connector/test`
### Router (`router.tsx`)
- Add `/vendor/email` route inside the vendor `RequireScope` guard
### Sidebar (`Layout.tsx`)
- Replace the flat "Identity (Logto)" external link with an expandable "Identity" section
- Items: "Email Connector" (internal link to `/vendor/email`), "Logto Console" (external link, preserved)
## Section 4: Extensibility — Adding Future Providers
To add a new email provider (e.g. AWS SES):
1. **Backend**: Add a request DTO and a mapping method in `EmailConnectorService` that maps to the provider's Logto config schema and returns the correct factory ID
2. **Frontend**: Add a `SesConfigForm.tsx` component and a new option in the provider selector dropdown on `EmailConfigPage`
No changes needed to:
- `EmailConnectorController` (provider-agnostic endpoints)
- `LogtoManagementClient` (works with any factory/connector)
- Email templates (shared across providers)
- Registration toggle logic (shared across providers)
- React Query hooks (provider-agnostic)

View File

@@ -26,13 +26,12 @@ Both audiences share the same UI and workflows. The self-hosted setup section at
### Logging In ### Logging In
Cameleer SaaS uses Logto for single sign-on (SSO). To log in: Cameleer SaaS uses Logto for single sign-on (SSO). Email is the primary user identity — all users must have an email address. To log in:
1. Navigate to the Cameleer SaaS URL in your browser. 1. Navigate to the Cameleer SaaS URL in your browser.
2. You will see the login screen with the title "Cameleer SaaS" and a subtitle "Managed Apache Camel Runtime." 2. You will be redirected to the Cameleer sign-in page.
3. Click **Sign in with Logto**. 3. Enter your email or username and password.
4. Authenticate with your Logto credentials (username/password or any configured social login). 4. After successful authentication, you are redirected to the dashboard.
5. After successful authentication, you are redirected back to the dashboard.
![Login page](screenshots/login-page.png) ![Login page](screenshots/login-page.png)
@@ -444,6 +443,7 @@ Copy `.env.example` to `.env` and configure as needed:
| `PUBLIC_PROTOCOL` | Public protocol (`http` or `https`) | `https` | | `PUBLIC_PROTOCOL` | Public protocol (`http` or `https`) | `https` |
| `SAAS_ADMIN_USER` | Platform admin username | `admin` | | `SAAS_ADMIN_USER` | Platform admin username | `admin` |
| `SAAS_ADMIN_PASS` | Platform admin password | `admin` | | `SAAS_ADMIN_PASS` | Platform admin password | `admin` |
| `SAAS_ADMIN_EMAIL` | Platform admin email (primary identity) | `<SAAS_ADMIN_USER>@<PUBLIC_HOST>` |
| `TENANT_ADMIN_USER` | Tenant admin username | `camel` | | `TENANT_ADMIN_USER` | Tenant admin username | `camel` |
| `TENANT_ADMIN_PASS` | Tenant admin password | `camel` | | `TENANT_ADMIN_PASS` | Tenant admin password | `camel` |

1
installer Submodule

Submodule installer added at 0da26160c6

View File

@@ -1,32 +0,0 @@
# Installer
## Deployment Modes
The installer (`installer/install.sh`) supports two deployment modes:
| | Multi-tenant SaaS (`DEPLOYMENT_MODE=saas`) | Standalone (`DEPLOYMENT_MODE=standalone`) |
|---|---|---|
| **Containers** | traefik, postgres, clickhouse, logto, cameleer-saas | traefik, postgres, clickhouse, server, server-ui |
| **Auth** | Logto OIDC (SaaS admin + tenant users) | Local auth (built-in admin, no identity provider) |
| **Tenant management** | SaaS admin creates/manages tenants via UI | Single server instance, no fleet management |
| **PostgreSQL** | `cameleer-postgres` image (multi-DB init) | Stock `postgres:16-alpine` (server creates schema via Flyway) |
| **Use case** | Platform vendor managing multiple customers | Single customer running the product directly |
Standalone mode generates a simpler compose with the server running directly. No Logto, no SaaS management plane, no bootstrap. The admin logs in with local credentials at `/`.
## Compose templates
The installer uses static docker-compose templates in `installer/templates/`. Templates are copied to the install directory and composed via `COMPOSE_FILE` in `.env`:
- `docker-compose.yml` — shared infrastructure (traefik, postgres, clickhouse)
- `docker-compose.saas.yml` — SaaS mode (logto, cameleer-saas)
- `docker-compose.server.yml` — standalone mode (server, server-ui)
- `docker-compose.tls.yml` — overlay: custom TLS cert volume
- `docker-compose.monitoring.yml` — overlay: external monitoring network
## Env var naming convention
- `CAMELEER_AGENT_*` — agent config (consumed by the Java agent)
- `CAMELEER_SERVER_*` — server config (consumed by cameleer-server)
- `CAMELEER_SAAS_*` — SaaS management plane config
- `CAMELEER_SAAS_PROVISIONING_*` — "SaaS forwards this to provisioned tenant servers"
- No prefix (e.g. `POSTGRES_PASSWORD`, `PUBLIC_HOST`) — shared infrastructure, consumed by multiple components

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,89 +0,0 @@
# Cameleer Configuration
# Copy this file to .env and fill in the values.
# The installer generates .env automatically — this file is for reference.
# ============================================================
# Compose file assembly (set by installer)
# ============================================================
# SaaS: docker-compose.yml:docker-compose.saas.yml
# Standalone: docker-compose.yml:docker-compose.server.yml
# Add :docker-compose.tls.yml for custom TLS certificates
# Add :docker-compose.monitoring.yml for external monitoring network
COMPOSE_FILE=docker-compose.yml:docker-compose.saas.yml
# ============================================================
# Image version
# ============================================================
VERSION=latest
# ============================================================
# Public access
# ============================================================
PUBLIC_HOST=localhost
PUBLIC_PROTOCOL=https
# ============================================================
# Ports
# ============================================================
HTTP_PORT=80
HTTPS_PORT=443
# Set to 0.0.0.0 to expose Logto admin console externally (default: localhost only)
# LOGTO_CONSOLE_BIND=0.0.0.0
LOGTO_CONSOLE_PORT=3002
# ============================================================
# PostgreSQL
# ============================================================
POSTGRES_USER=cameleer
POSTGRES_PASSWORD=CHANGE_ME
# SaaS: cameleer_saas, Standalone: cameleer
POSTGRES_DB=cameleer_saas
# ============================================================
# ClickHouse
# ============================================================
CLICKHOUSE_PASSWORD=CHANGE_ME
# ============================================================
# Admin credentials (SaaS mode)
# ============================================================
SAAS_ADMIN_USER=admin
SAAS_ADMIN_PASS=CHANGE_ME
# ============================================================
# Admin credentials (standalone mode)
# ============================================================
# SERVER_ADMIN_USER=admin
# SERVER_ADMIN_PASS=CHANGE_ME
# BOOTSTRAP_TOKEN=CHANGE_ME
# ============================================================
# TLS
# ============================================================
# Set to 1 to reject unauthorized TLS certificates (production)
NODE_TLS_REJECT=0
# Custom TLS certificate paths (inside container, set by installer)
# CERT_FILE=/user-certs/cert.pem
# KEY_FILE=/user-certs/key.pem
# CA_FILE=/user-certs/ca.pem
# ============================================================
# Docker
# ============================================================
DOCKER_SOCKET=/var/run/docker.sock
# GID of the docker socket — detected by installer, used for container group_add
DOCKER_GID=0
# ============================================================
# Provisioning images (SaaS mode only)
# ============================================================
# CAMELEER_SAAS_PROVISIONING_SERVERIMAGE=gitea.siegeln.net/cameleer/cameleer-server:latest
# CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE=gitea.siegeln.net/cameleer/cameleer-server-ui:latest
# CAMELEER_SAAS_PROVISIONING_RUNTIMEBASEIMAGE=gitea.siegeln.net/cameleer/cameleer-runtime-base:latest
# ============================================================
# Monitoring (optional)
# ============================================================
# External Docker network name for Prometheus scraping.
# Only needed when docker-compose.monitoring.yml is in COMPOSE_FILE.
# MONITORING_NETWORK=prometheus

View File

@@ -1,7 +0,0 @@
# External monitoring network overlay
# Overrides the noop monitoring bridge with a real external network
networks:
monitoring:
external: true
name: ${MONITORING_NETWORK:?MONITORING_NETWORK must be set in .env}

View File

@@ -1,108 +0,0 @@
# Cameleer SaaS — Logto + management plane
# Loaded in SaaS deployment mode
services:
cameleer-logto:
image: ${LOGTO_IMAGE:-gitea.siegeln.net/cameleer/cameleer-logto}:${VERSION:-latest}
restart: unless-stopped
depends_on:
cameleer-postgres:
condition: service_healthy
environment:
DB_URL: postgres://${POSTGRES_USER:-cameleer}:${POSTGRES_PASSWORD}@cameleer-postgres:5432/logto
ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
ADMIN_ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}:${LOGTO_CONSOLE_PORT:-3002}
TRUST_PROXY_HEADER: 1
NODE_TLS_REJECT_UNAUTHORIZED: "${NODE_TLS_REJECT:-0}"
LOGTO_ENDPOINT: http://cameleer-logto:3001
LOGTO_ADMIN_ENDPOINT: http://cameleer-logto:3002
LOGTO_PUBLIC_ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
PUBLIC_HOST: ${PUBLIC_HOST:-localhost}
PUBLIC_PROTOCOL: ${PUBLIC_PROTOCOL:-https}
PG_HOST: cameleer-postgres
PG_USER: ${POSTGRES_USER:-cameleer}
PG_PASSWORD: ${POSTGRES_PASSWORD}
PG_DB_SAAS: cameleer_saas
SAAS_ADMIN_USER: ${SAAS_ADMIN_USER:-admin}
SAAS_ADMIN_PASS: ${SAAS_ADMIN_PASS:?SAAS_ADMIN_PASS must be set in .env}
healthcheck:
test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3001/oidc/.well-known/openid-configuration', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))\" && test -f /data/logto-bootstrap.json"]
interval: 10s
timeout: 5s
retries: 60
start_period: 30s
labels:
- traefik.enable=true
- traefik.http.routers.cameleer-logto.rule=PathPrefix(`/`)
- traefik.http.routers.cameleer-logto.priority=1
- traefik.http.routers.cameleer-logto.entrypoints=websecure
- traefik.http.routers.cameleer-logto.tls=true
- traefik.http.routers.cameleer-logto.service=cameleer-logto
- traefik.http.routers.cameleer-logto.middlewares=cameleer-logto-cors
- "traefik.http.middlewares.cameleer-logto-cors.headers.accessControlAllowOriginList=${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}:${LOGTO_CONSOLE_PORT:-3002}"
- traefik.http.middlewares.cameleer-logto-cors.headers.accessControlAllowMethods=GET,POST,PUT,PATCH,DELETE,OPTIONS
- traefik.http.middlewares.cameleer-logto-cors.headers.accessControlAllowHeaders=Authorization,Content-Type
- traefik.http.middlewares.cameleer-logto-cors.headers.accessControlAllowCredentials=true
- traefik.http.services.cameleer-logto.loadbalancer.server.port=3001
- traefik.http.routers.cameleer-logto-console.rule=PathPrefix(`/`)
- traefik.http.routers.cameleer-logto-console.entrypoints=admin-console
- traefik.http.routers.cameleer-logto-console.tls=true
- traefik.http.routers.cameleer-logto-console.service=cameleer-logto-console
- traefik.http.services.cameleer-logto-console.loadbalancer.server.port=3002
volumes:
- cameleer-bootstrapdata:/data
networks:
- cameleer
- monitoring
cameleer-saas:
image: ${CAMELEER_IMAGE:-gitea.siegeln.net/cameleer/cameleer-saas}:${VERSION:-latest}
restart: unless-stopped
depends_on:
cameleer-logto:
condition: service_healthy
environment:
# SaaS database
SPRING_DATASOURCE_URL: jdbc:postgresql://cameleer-postgres:5432/cameleer_saas
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-cameleer}
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
# Identity (Logto)
CAMELEER_SAAS_IDENTITY_LOGTOENDPOINT: http://cameleer-logto:3001
CAMELEER_SAAS_IDENTITY_LOGTOPUBLICENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
# Provisioning — passed to per-tenant server containers
CAMELEER_SAAS_PROVISIONING_PUBLICHOST: ${PUBLIC_HOST:-localhost}
CAMELEER_SAAS_PROVISIONING_PUBLICPROTOCOL: ${PUBLIC_PROTOCOL:-https}
CAMELEER_SAAS_PROVISIONING_NETWORKNAME: ${COMPOSE_PROJECT_NAME:-cameleer-saas}_cameleer
CAMELEER_SAAS_PROVISIONING_TRAEFIKNETWORK: cameleer-traefik
CAMELEER_SAAS_PROVISIONING_DATASOURCEUSERNAME: ${POSTGRES_USER:-cameleer}
CAMELEER_SAAS_PROVISIONING_DATASOURCEPASSWORD: ${POSTGRES_PASSWORD}
CAMELEER_SAAS_PROVISIONING_CLICKHOUSEPASSWORD: ${CLICKHOUSE_PASSWORD}
CAMELEER_SERVER_SECURITY_JWTSECRET: ${CAMELEER_SERVER_SECURITY_JWTSECRET:?CAMELEER_SERVER_SECURITY_JWTSECRET must be set in .env}
CAMELEER_SAAS_PROVISIONING_SERVERIMAGE: ${CAMELEER_SAAS_PROVISIONING_SERVERIMAGE:-gitea.siegeln.net/cameleer/cameleer-server:latest}
CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE: ${CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE:-gitea.siegeln.net/cameleer/cameleer-server-ui:latest}
CAMELEER_SAAS_PROVISIONING_RUNTIMEBASEIMAGE: ${CAMELEER_SAAS_PROVISIONING_RUNTIMEBASEIMAGE:-gitea.siegeln.net/cameleer/cameleer-runtime-base:latest}
labels:
- traefik.enable=true
- traefik.http.routers.saas.rule=PathPrefix(`/platform`)
- traefik.http.routers.saas.entrypoints=websecure
- traefik.http.routers.saas.tls=true
- traefik.http.services.saas.loadbalancer.server.port=8080
- "prometheus.io/scrape=true"
- "prometheus.io/port=8080"
- "prometheus.io/path=/platform/actuator/prometheus"
volumes:
- cameleer-bootstrapdata:/data/bootstrap:ro
- cameleer-certs:/certs
- ${DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock
group_add:
- "${DOCKER_GID:-0}"
networks:
- cameleer
- monitoring
volumes:
cameleer-bootstrapdata:
networks:
monitoring:
name: cameleer-monitoring-noop

View File

@@ -1,99 +0,0 @@
# Cameleer Server (standalone)
# Loaded in standalone deployment mode
services:
cameleer-traefik:
volumes:
- ./traefik-dynamic.yml:/etc/traefik/dynamic.yml:ro
cameleer-postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB:-cameleer}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-cameleer} -d $${POSTGRES_DB:-cameleer}"]
cameleer-server:
image: ${SERVER_IMAGE:-gitea.siegeln.net/cameleer/cameleer-server}:${VERSION:-latest}
container_name: cameleer-server
restart: unless-stopped
depends_on:
cameleer-postgres:
condition: service_healthy
environment:
CAMELEER_SERVER_TENANT_ID: default
SPRING_DATASOURCE_URL: jdbc:postgresql://cameleer-postgres:5432/${POSTGRES_DB:-cameleer}?currentSchema=tenant_default
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-cameleer}
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
CAMELEER_SERVER_CLICKHOUSE_URL: jdbc:clickhouse://cameleer-clickhouse:8123/cameleer
CAMELEER_SERVER_CLICKHOUSE_USERNAME: default
CAMELEER_SERVER_CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD}
CAMELEER_SERVER_SECURITY_BOOTSTRAPTOKEN: ${BOOTSTRAP_TOKEN:?BOOTSTRAP_TOKEN must be set in .env}
CAMELEER_SERVER_SECURITY_JWTSECRET: ${CAMELEER_SERVER_SECURITY_JWTSECRET:?CAMELEER_SERVER_SECURITY_JWTSECRET must be set in .env}
CAMELEER_SERVER_SECURITY_UIUSER: ${SERVER_ADMIN_USER:-admin}
CAMELEER_SERVER_SECURITY_UIPASSWORD: ${SERVER_ADMIN_PASS:?SERVER_ADMIN_PASS must be set in .env}
CAMELEER_SERVER_SECURITY_CORSALLOWEDORIGINS: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
CAMELEER_SERVER_RUNTIME_ENABLED: "true"
CAMELEER_SERVER_RUNTIME_SERVERURL: http://cameleer-server:8081
CAMELEER_SERVER_RUNTIME_ROUTINGDOMAIN: ${PUBLIC_HOST:-localhost}
CAMELEER_SERVER_RUNTIME_ROUTINGMODE: path
CAMELEER_SERVER_RUNTIME_JARSTORAGEPATH: /data/jars
CAMELEER_SERVER_RUNTIME_DOCKERNETWORK: cameleer-apps
CAMELEER_SERVER_RUNTIME_JARDOCKERVOLUME: cameleer-jars
CAMELEER_SERVER_RUNTIME_BASEIMAGE: gitea.siegeln.net/cameleer/cameleer-runtime-base:${VERSION:-latest}
labels:
- traefik.enable=true
- traefik.http.routers.server-api.rule=PathPrefix(`/api`)
- traefik.http.routers.server-api.entrypoints=websecure
- traefik.http.routers.server-api.tls=true
- traefik.http.services.server-api.loadbalancer.server.port=8081
- traefik.docker.network=cameleer-traefik
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:8081/api/v1/health || exit 1"]
interval: 10s
timeout: 5s
retries: 30
start_period: 30s
volumes:
- jars:/data/jars
- cameleer-certs:/certs:ro
- ${DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock
group_add:
- "${DOCKER_GID:-0}"
networks:
- cameleer
- cameleer-traefik
- cameleer-apps
- monitoring
cameleer-server-ui:
image: ${SERVER_UI_IMAGE:-gitea.siegeln.net/cameleer/cameleer-server-ui}:${VERSION:-latest}
restart: unless-stopped
depends_on:
cameleer-server:
condition: service_healthy
environment:
CAMELEER_API_URL: http://cameleer-server:8081
BASE_PATH: ""
labels:
- traefik.enable=true
- traefik.http.routers.ui.rule=PathPrefix(`/`)
- traefik.http.routers.ui.priority=1
- traefik.http.routers.ui.entrypoints=websecure
- traefik.http.routers.ui.tls=true
- traefik.http.services.ui.loadbalancer.server.port=80
- traefik.docker.network=cameleer-traefik
networks:
- cameleer-traefik
- monitoring
volumes:
jars:
name: cameleer-jars
networks:
cameleer-apps:
name: cameleer-apps
driver: bridge
monitoring:
name: cameleer-monitoring-noop

View File

@@ -1,7 +0,0 @@
# Custom TLS certificates overlay
# Adds user-supplied certificate volume to traefik
services:
cameleer-traefik:
volumes:
- ./certs:/user-certs:ro

View File

@@ -1,79 +0,0 @@
# Cameleer Infrastructure
# Shared base — always loaded. Mode-specific services in separate compose files.
services:
cameleer-traefik:
image: ${TRAEFIK_IMAGE:-gitea.siegeln.net/cameleer/cameleer-traefik}:${VERSION:-latest}
restart: unless-stopped
ports:
- "${HTTP_PORT:-80}:80"
- "${HTTPS_PORT:-443}:443"
- "${LOGTO_CONSOLE_BIND:-127.0.0.1}:${LOGTO_CONSOLE_PORT:-3002}:3002"
environment:
PUBLIC_HOST: ${PUBLIC_HOST:-localhost}
CERT_FILE: ${CERT_FILE:-}
KEY_FILE: ${KEY_FILE:-}
CA_FILE: ${CA_FILE:-}
volumes:
- cameleer-certs:/certs
- ${DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock:ro
labels:
- "prometheus.io/scrape=true"
- "prometheus.io/port=8082"
- "prometheus.io/path=/metrics"
networks:
- cameleer
- cameleer-traefik
- monitoring
cameleer-postgres:
image: ${POSTGRES_IMAGE:-gitea.siegeln.net/cameleer/cameleer-postgres}:${VERSION:-latest}
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-cameleer_saas}
POSTGRES_USER: ${POSTGRES_USER:-cameleer}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set in .env}
volumes:
- cameleer-pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-cameleer} -d $${POSTGRES_DB:-cameleer_saas}"]
interval: 5s
timeout: 5s
retries: 5
networks:
- cameleer
- monitoring
cameleer-clickhouse:
image: ${CLICKHOUSE_IMAGE:-gitea.siegeln.net/cameleer/cameleer-clickhouse}:${VERSION:-latest}
restart: unless-stopped
environment:
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:?CLICKHOUSE_PASSWORD must be set in .env}
volumes:
- cameleer-chdata:/var/lib/clickhouse
healthcheck:
test: ["CMD-SHELL", "clickhouse-client --password $${CLICKHOUSE_PASSWORD} --query 'SELECT 1'"]
interval: 10s
timeout: 5s
retries: 3
labels:
- "prometheus.io/scrape=true"
- "prometheus.io/port=9363"
- "prometheus.io/path=/metrics"
networks:
- cameleer
- monitoring
volumes:
cameleer-pgdata:
cameleer-chdata:
cameleer-certs:
networks:
cameleer:
driver: bridge
cameleer-traefik:
name: cameleer-traefik
driver: bridge
monitoring:
name: cameleer-monitoring-noop

View File

@@ -1,6 +0,0 @@
tls:
stores:
default:
defaultCertificate:
certFile: /certs/cert.pem
keyFile: /certs/key.pem

View File

@@ -1,5 +1,9 @@
# Auth & Security Config # Auth & Security Config
## User identity
**Email is the primary user identity** in SaaS mode. All users must have an email address — Logto enforces this via `signUp.identifiers: ["email"]` when registration is enabled. The bootstrap creates the admin user with `primaryEmail` set to `SAAS_ADMIN_EMAIL` (defaults to `<SAAS_ADMIN_USER>@<PUBLIC_HOST>`). Self-service registration requires email verification via a configured email connector (vendor UI at `/vendor/email`).
## Auth enforcement ## Auth enforcement
- All API endpoints enforce OAuth2 scopes via `@PreAuthorize("hasAuthority('SCOPE_xxx')")` annotations - All API endpoints enforce OAuth2 scopes via `@PreAuthorize("hasAuthority('SCOPE_xxx')")` annotations
@@ -18,10 +22,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)

View File

@@ -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()

View File

@@ -7,7 +7,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
public class SpaController { public class SpaController {
@RequestMapping(value = { @RequestMapping(value = {
"/", "/login", "/callback", "/", "/login", "/register", "/callback", "/onboarding",
"/vendor/**", "/tenant/**" "/vendor/**", "/tenant/**"
}) })
public String forward() { public String forward() {

View File

@@ -398,6 +398,122 @@ public class LogtoManagementClient {
.toBodilessEntity(); .toBodilessEntity();
} }
// --- Email Connector Management ---
/** List all connector factories available in Logto. */
@SuppressWarnings("unchecked")
public List<Map<String, Object>> listConnectorFactories() {
if (!isAvailable()) return List.of();
try {
var resp = restClient.get()
.uri(config.getLogtoEndpoint() + "/api/connector-factories")
.header("Authorization", "Bearer " + getAccessToken())
.retrieve()
.body(List.class);
return resp != null ? resp : List.of();
} catch (Exception e) {
log.warn("Failed to list connector factories: {}", e.getMessage());
return List.of();
}
}
/** List all connectors. */
@SuppressWarnings("unchecked")
public List<Map<String, Object>> listConnectors() {
if (!isAvailable()) return List.of();
try {
var resp = restClient.get()
.uri(config.getLogtoEndpoint() + "/api/connectors")
.header("Authorization", "Bearer " + getAccessToken())
.retrieve()
.body(List.class);
return resp != null ? resp : List.of();
} catch (Exception e) {
log.warn("Failed to list connectors: {}", e.getMessage());
return List.of();
}
}
/** Create a connector from a factory. */
@SuppressWarnings("unchecked")
public Map<String, Object> createConnector(String factoryId, Map<String, Object> connectorConfig) {
if (!isAvailable()) return null;
var body = new java.util.HashMap<String, Object>();
body.put("connectorId", factoryId);
body.put("config", connectorConfig);
return (Map<String, Object>) restClient.post()
.uri(config.getLogtoEndpoint() + "/api/connectors")
.header("Authorization", "Bearer " + getAccessToken())
.contentType(MediaType.APPLICATION_JSON)
.body(body)
.retrieve()
.body(Map.class);
}
/** Update an existing connector's config. */
@SuppressWarnings("unchecked")
public Map<String, Object> updateConnector(String connectorId, Map<String, Object> connectorConfig) {
if (!isAvailable()) return null;
return (Map<String, Object>) restClient.patch()
.uri(config.getLogtoEndpoint() + "/api/connectors/" + connectorId)
.header("Authorization", "Bearer " + getAccessToken())
.contentType(MediaType.APPLICATION_JSON)
.body(Map.of("config", connectorConfig))
.retrieve()
.body(Map.class);
}
/** Delete a connector. */
public void deleteConnector(String connectorId) {
if (!isAvailable()) return;
restClient.delete()
.uri(config.getLogtoEndpoint() + "/api/connectors/" + connectorId)
.header("Authorization", "Bearer " + getAccessToken())
.retrieve()
.toBodilessEntity();
}
/** Test a connector by sending a real email. Uses Logto's built-in test endpoint. */
public void testConnector(String factoryId, String email, Map<String, Object> connectorConfig) {
if (!isAvailable()) throw new IllegalStateException("Logto not configured");
restClient.post()
.uri(config.getLogtoEndpoint() + "/api/connectors/" + factoryId + "/test")
.header("Authorization", "Bearer " + getAccessToken())
.contentType(MediaType.APPLICATION_JSON)
.body(Map.of("email", email, "config", connectorConfig))
.retrieve()
.toBodilessEntity();
}
/** Get the current sign-in experience config. */
@SuppressWarnings("unchecked")
public Map<String, Object> getSignInExperience() {
if (!isAvailable()) return null;
try {
return (Map<String, Object>) restClient.get()
.uri(config.getLogtoEndpoint() + "/api/sign-in-exp")
.header("Authorization", "Bearer " + getAccessToken())
.retrieve()
.body(Map.class);
} catch (Exception e) {
log.warn("Failed to get sign-in experience: {}", e.getMessage());
return null;
}
}
/** Update the sign-in experience config (partial update). */
@SuppressWarnings("unchecked")
public Map<String, Object> updateSignInExperience(Map<String, Object> updates) {
if (!isAvailable()) return null;
return (Map<String, Object>) restClient.patch()
.uri(config.getLogtoEndpoint() + "/api/sign-in-exp")
.header("Authorization", "Bearer " + getAccessToken())
.contentType(MediaType.APPLICATION_JSON)
.body(updates)
.retrieve()
.body(Map.class);
}
/** Update a user's password. */ /** Update a user's password. */
public void updateUserPassword(String userId, String newPassword) { public void updateUserPassword(String userId, String newPassword) {
if (!isAvailable()) throw new IllegalStateException("Logto not configured"); if (!isAvailable()) throw new IllegalStateException("Logto not configured");

View File

@@ -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));
}
}

View File

@@ -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());
}
}
}

View File

@@ -0,0 +1,114 @@
package net.siegeln.cameleer.saas.vendor;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
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;
import java.util.Map;
@RestController
@RequestMapping("/api/vendor/email-connector")
@PreAuthorize("hasAuthority('SCOPE_platform:admin')")
public class EmailConnectorController {
private final EmailConnectorService emailConnectorService;
public EmailConnectorController(EmailConnectorService emailConnectorService) {
this.emailConnectorService = emailConnectorService;
}
// --- Request/Response types ---
public record SmtpConfigRequest(
@NotBlank String host,
@Min(1) @Max(65535) int port,
@NotBlank String username,
@NotBlank String password,
@NotBlank @Email String fromEmail,
Boolean registrationEnabled
) {}
public record TestEmailRequest(
@NotBlank @Email String to
) {}
public record EmailConnectorResponse(
String connectorId,
String factoryId,
String host,
int port,
String username,
String fromEmail,
boolean registrationEnabled
) {
static EmailConnectorResponse from(EmailConnectorService.EmailConnectorStatus status) {
return new EmailConnectorResponse(
status.connectorId(),
status.factoryId(),
status.host(),
status.port(),
status.username(),
status.fromEmail(),
status.registrationEnabled()
);
}
}
// --- Endpoints ---
@GetMapping
public ResponseEntity<EmailConnectorResponse> get() {
var status = emailConnectorService.getEmailConnector();
if (status == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(EmailConnectorResponse.from(status));
}
@PostMapping
public ResponseEntity<EmailConnectorResponse> save(@Valid @RequestBody SmtpConfigRequest request) {
var smtp = new EmailConnectorService.SmtpConfig(
request.host(), request.port(), request.username(),
request.password(), request.fromEmail()
);
var status = emailConnectorService.saveSmtpConnector(smtp, request.registrationEnabled());
return ResponseEntity.ok(EmailConnectorResponse.from(status));
}
@DeleteMapping
public ResponseEntity<Void> delete() {
emailConnectorService.deleteEmailConnector();
return ResponseEntity.noContent().build();
}
@PostMapping("/test")
public ResponseEntity<Map<String, String>> test(@Valid @RequestBody TestEmailRequest request) {
try {
emailConnectorService.sendTestEmail(request.to());
return ResponseEntity.ok(Map.of("status", "sent", "message", "Test email sent to " + request.to()));
} catch (Exception e) {
return ResponseEntity.badRequest().body(Map.of("status", "failed", "message", e.getMessage()));
}
}
@PostMapping("/registration")
public ResponseEntity<Void> toggleRegistration(@RequestBody Map<String, Boolean> body) {
boolean enabled = body.getOrDefault("enabled", false);
var existing = emailConnectorService.getEmailConnector();
if (existing == null && enabled) {
return ResponseEntity.badRequest().build();
}
emailConnectorService.setRegistrationEnabled(enabled);
return ResponseEntity.noContent().build();
}
}

View File

@@ -0,0 +1,191 @@
package net.siegeln.cameleer.saas.vendor;
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
public class EmailConnectorService {
private static final Logger log = LoggerFactory.getLogger(EmailConnectorService.class);
private static final String SMTP_FACTORY_ID = "simple-mail-transfer-protocol";
private final LogtoManagementClient logtoClient;
public EmailConnectorService(LogtoManagementClient logtoClient) {
this.logtoClient = logtoClient;
}
public record SmtpConfig(String host, int port, String username, String password, String fromEmail) {}
public record EmailConnectorStatus(
String connectorId,
String factoryId,
String host,
int port,
String username,
String fromEmail,
boolean registrationEnabled
) {}
/** Get the current email connector config, or null if none is configured. */
@SuppressWarnings("unchecked")
public EmailConnectorStatus getEmailConnector() {
var connectors = logtoClient.listConnectors();
var emailConnector = connectors.stream()
.filter(c -> "Email".equals(c.get("type")))
.findFirst()
.orElse(null);
if (emailConnector == null) {
return null;
}
var config = (Map<String, Object>) emailConnector.getOrDefault("config", Map.of());
var auth = (Map<String, Object>) config.getOrDefault("auth", Map.of());
String host = String.valueOf(config.getOrDefault("host", ""));
int port = config.containsKey("port") ? ((Number) config.get("port")).intValue() : 587;
String username = String.valueOf(auth.getOrDefault("user", ""));
String fromEmail = String.valueOf(config.getOrDefault("fromEmail", ""));
boolean registrationEnabled = isRegistrationEnabled();
return new EmailConnectorStatus(
String.valueOf(emailConnector.get("id")),
String.valueOf(emailConnector.get("connectorId")),
host, port, username, fromEmail, registrationEnabled
);
}
/** Create or update the SMTP email connector. Returns the connector status. */
public EmailConnectorStatus saveSmtpConnector(SmtpConfig smtp, Boolean registrationEnabled) {
var connectorConfig = buildSmtpConfig(smtp);
// Check if an email connector already exists
var existing = getEmailConnector();
if (existing != null) {
logtoClient.updateConnector(existing.connectorId(), connectorConfig);
log.info("Updated SMTP email connector: {}", existing.connectorId());
} else {
var result = logtoClient.createConnector(SMTP_FACTORY_ID, connectorConfig);
log.info("Created SMTP email connector: {}", result != null ? result.get("id") : "unknown");
}
// Handle registration toggle
boolean enableReg = registrationEnabled != null ? registrationEnabled : (existing == null);
setRegistrationEnabled(enableReg);
return getEmailConnector();
}
/** Delete the email connector and disable registration. */
public void deleteEmailConnector() {
var existing = getEmailConnector();
if (existing != null) {
logtoClient.deleteConnector(existing.connectorId());
setRegistrationEnabled(false);
log.info("Deleted email connector: {}", existing.connectorId());
}
}
/** Send a test email through the configured connector. */
public void sendTestEmail(String toEmail) {
var existing = getEmailConnector();
if (existing == null) {
throw new IllegalStateException("No email connector configured");
}
// Re-read the full config from Logto to pass to the test endpoint
var connectors = logtoClient.listConnectors();
@SuppressWarnings("unchecked")
var emailConnector = connectors.stream()
.filter(c -> "Email".equals(c.get("type")))
.findFirst()
.orElseThrow(() -> new IllegalStateException("Email connector not found"));
@SuppressWarnings("unchecked")
var config = (Map<String, Object>) emailConnector.getOrDefault("config", Map.of());
logtoClient.testConnector(existing.factoryId(), toEmail, config);
}
/** Set registration mode on the Logto sign-in experience. */
public void setRegistrationEnabled(boolean enabled) {
if (enabled) {
logtoClient.updateSignInExperience(Map.of(
"signInMode", "SignInAndRegister",
"signUp", Map.of(
"identifiers", List.of("email"),
"password", true,
"verify", true
),
"signIn", Map.of(
"methods", List.of(
Map.of("identifier", "email", "password", true, "verificationCode", false, "isPasswordPrimary", true),
Map.of("identifier", "username", "password", true, "verificationCode", false, "isPasswordPrimary", true)
)
)
));
} else {
logtoClient.updateSignInExperience(Map.of(
"signInMode", "SignIn",
"signUp", Map.of(
"identifiers", List.of("username"),
"password", true,
"verify", false
),
"signIn", Map.of(
"methods", List.of(
Map.of("identifier", "username", "password", true, "verificationCode", false, "isPasswordPrimary", true)
)
)
));
}
}
/** Check if registration is currently enabled in Logto. */
@SuppressWarnings("unchecked")
private boolean isRegistrationEnabled() {
var signInExp = logtoClient.getSignInExperience();
if (signInExp == null) return false;
return "SignInAndRegister".equals(signInExp.get("signInMode"));
}
/** Build the Logto SMTP connector config with Cameleer-branded email templates. */
private Map<String, Object> buildSmtpConfig(SmtpConfig smtp) {
var config = new HashMap<String, Object>();
config.put("host", smtp.host());
config.put("port", smtp.port());
config.put("auth", Map.of("user", smtp.username(), "pass", smtp.password()));
config.put("fromEmail", smtp.fromEmail());
config.put("templates", List.of(
Map.of(
"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>"
),
Map.of(
"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>"
),
Map.of(
"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>"
),
Map.of(
"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>"
)
));
return config;
}
}

View File

@@ -20,7 +20,7 @@ spring:
oauth2: oauth2:
resourceserver: resourceserver:
jwt: jwt:
issuer-uri: ${cameleer.saas.provisioning.publicprotocol:https}://${cameleer.saas.provisioning.publichost:localhost}/oidc issuer-uri: ${cameleer.saas.provisioning.publicprotocol:https}://${cameleer.saas.identity.authhost:localhost}/oidc
jwk-set-uri: ${cameleer.saas.identity.logtoendpoint:http://cameleer-logto:3001}/oidc/jwks jwk-set-uri: ${cameleer.saas.identity.logtoendpoint:http://cameleer-logto:3001}/oidc/jwks
management: management:
@@ -35,6 +35,7 @@ management:
cameleer: cameleer:
saas: saas:
identity: identity:
authhost: ${CAMELEER_SAAS_IDENTITY_AUTHHOST:${cameleer.saas.provisioning.publichost:localhost}}
logtoendpoint: ${CAMELEER_SAAS_IDENTITY_LOGTOENDPOINT:} logtoendpoint: ${CAMELEER_SAAS_IDENTITY_LOGTOENDPOINT:}
logtopublicendpoint: ${CAMELEER_SAAS_IDENTITY_LOGTOPUBLICENDPOINT:} logtopublicendpoint: ${CAMELEER_SAAS_IDENTITY_LOGTOPUBLICENDPOINT:}
m2mclientid: ${CAMELEER_SAAS_IDENTITY_M2MCLIENTID:} m2mclientid: ${CAMELEER_SAAS_IDENTITY_M2MCLIENTID:}
@@ -43,9 +44,9 @@ cameleer:
audience: ${CAMELEER_SAAS_IDENTITY_AUDIENCE:https://api.cameleer.local} audience: ${CAMELEER_SAAS_IDENTITY_AUDIENCE:https://api.cameleer.local}
serverendpoint: ${CAMELEER_SAAS_IDENTITY_SERVERENDPOINT:http://cameleer-server:8081} serverendpoint: ${CAMELEER_SAAS_IDENTITY_SERVERENDPOINT:http://cameleer-server:8081}
provisioning: provisioning:
serverimage: ${CAMELEER_SAAS_PROVISIONING_SERVERIMAGE:gitea.siegeln.net/cameleer/cameleer-server:latest} serverimage: ${CAMELEER_SAAS_PROVISIONING_SERVERIMAGE:registry.cameleer.io/cameleer/cameleer-server:latest}
serveruiimage: ${CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE:gitea.siegeln.net/cameleer/cameleer-server-ui:latest} serveruiimage: ${CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE:registry.cameleer.io/cameleer/cameleer-server-ui:latest}
runtimebaseimage: ${CAMELEER_SAAS_PROVISIONING_RUNTIMEBASEIMAGE:gitea.siegeln.net/cameleer/cameleer-runtime-base:latest} runtimebaseimage: ${CAMELEER_SAAS_PROVISIONING_RUNTIMEBASEIMAGE:registry.cameleer.io/cameleer/cameleer-runtime-base:latest}
networkname: ${CAMELEER_SAAS_PROVISIONING_NETWORKNAME:cameleer-saas_cameleer} networkname: ${CAMELEER_SAAS_PROVISIONING_NETWORKNAME:cameleer-saas_cameleer}
traefiknetwork: ${CAMELEER_SAAS_PROVISIONING_TRAEFIKNETWORK:cameleer-traefik} traefiknetwork: ${CAMELEER_SAAS_PROVISIONING_TRAEFIKNETWORK:cameleer-traefik}
publichost: ${CAMELEER_SAAS_PROVISIONING_PUBLICHOST:localhost} publichost: ${CAMELEER_SAAS_PROVISIONING_PUBLICHOST:localhost}
@@ -56,7 +57,7 @@ cameleer:
clickhouseurl: ${CAMELEER_SAAS_PROVISIONING_CLICKHOUSEURL:jdbc:clickhouse://cameleer-clickhouse:8123/cameleer} clickhouseurl: ${CAMELEER_SAAS_PROVISIONING_CLICKHOUSEURL:jdbc:clickhouse://cameleer-clickhouse:8123/cameleer}
clickhouseuser: ${CAMELEER_SAAS_PROVISIONING_CLICKHOUSEUSER:default} clickhouseuser: ${CAMELEER_SAAS_PROVISIONING_CLICKHOUSEUSER:default}
clickhousepassword: ${CAMELEER_SAAS_PROVISIONING_CLICKHOUSEPASSWORD:${CLICKHOUSE_PASSWORD:cameleer_ch}} clickhousepassword: ${CAMELEER_SAAS_PROVISIONING_CLICKHOUSEPASSWORD:${CLICKHOUSE_PASSWORD:cameleer_ch}}
oidcissueruri: ${cameleer.saas.provisioning.publicprotocol}://${cameleer.saas.provisioning.publichost}/oidc oidcissueruri: ${cameleer.saas.provisioning.publicprotocol}://${cameleer.saas.identity.authhost}/oidc
oidcjwkseturi: http://cameleer-logto:3001/oidc/jwks oidcjwkseturi: http://cameleer-logto:3001/oidc/jwks
corsorigins: ${cameleer.saas.provisioning.publicprotocol}://${cameleer.saas.provisioning.publichost} corsorigins: ${cameleer.saas.provisioning.publicprotocol}://${cameleer.saas.provisioning.publichost}
certs: certs:

View File

@@ -5,8 +5,8 @@ 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, Metrics, Infrastructure, Email Connector, Logto Console), 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,15 +16,18 @@ 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
- **Vendor pages**: `VendorTenantsPage.tsx`, `CreateTenantPage.tsx`, `TenantDetailPage.tsx`, `VendorAuditPage.tsx`, `CertificatesPage.tsx`, `InfrastructurePage.tsx` - **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`, `EmailConfigPage.tsx` (SMTP connector config, registration toggle, test email)
- **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)
## 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. 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. Registration is disabled by default — the vendor admin enables it via the Email Connector page after configuring SMTP.
- `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.

View File

@@ -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 (email, SMS, social — configured at runtime via vendor UI)
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/

View File

@@ -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;
}

View File

@@ -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,78 @@ 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);
if (params.get('first_screen') === 'register') return 'register';
if (window.location.pathname.endsWith('/register')) return 'register';
return '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 [registrationEnabled, setRegistrationEnabled] = useState(true);
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) => { // Fetch sign-in experience to check if registration is enabled
useEffect(() => {
fetch('/api/.well-known/sign-in-exp')
.then((r) => r.json())
.then((data) => {
const enabled = data.signInMode === 'SignInAndRegister';
setRegistrationEnabled(enabled);
if (!enabled && mode !== 'signIn') setMode('signIn');
})
.catch(() => {});
}, []);
// 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 +114,63 @@ export function SignInPage() {
} }
}; };
// --- Register step 1: send verification code ---
const handleRegister = async (e: FormEvent) => {
e.preventDefault();
setError(null);
if (!identifier.includes('@')) {
setError('Please enter a valid email address');
return;
}
if (password !== confirmPassword) {
setError('Passwords do not match');
return;
}
if (password.length < 8) {
setError('Password must be at least 8 characters');
return;
}
setLoading(true);
try {
const vId = await startRegistration(identifier);
setVerificationId(vId);
setMode('verifyCode');
} catch (err) {
setError(err instanceof Error ? err.message : 'Registration failed');
} finally {
setLoading(false);
}
};
// --- Register step 2: verify code + complete ---
const handleVerifyCode = async (e: FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
const redirectTo = await completeRegistration(identifier, password, verificationId, code);
window.location.replace(redirectTo);
} catch (err) {
setError(err instanceof Error ? err.message : 'Verification failed');
setLoading(false);
}
};
const passwordToggle = (
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className={styles.passwordToggle}
tabIndex={-1}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
);
return ( 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 +183,158 @@ 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>
{registrationEnabled && (
<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>

View File

@@ -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();
}

View File

@@ -0,0 +1,70 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from './client';
export interface EmailConnectorResponse {
connectorId: string;
factoryId: string;
host: string;
port: number;
username: string;
fromEmail: string;
registrationEnabled: boolean;
}
export interface SmtpConfigRequest {
host: string;
port: number;
username: string;
password: string;
fromEmail: string;
registrationEnabled?: boolean;
}
export interface TestEmailResult {
status: string;
message: string;
}
export function useEmailConnector() {
return useQuery<EmailConnectorResponse | null>({
queryKey: ['vendor', 'email-connector'],
queryFn: async () => {
try {
return await api.get<EmailConnectorResponse>('/vendor/email-connector');
} catch (e) {
if (e instanceof Error && e.message.includes('404')) return null;
throw e;
}
},
});
}
export function useSaveEmailConnector() {
const qc = useQueryClient();
return useMutation<EmailConnectorResponse, Error, SmtpConfigRequest>({
mutationFn: (config) => api.post('/vendor/email-connector', config),
onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'email-connector'] }),
});
}
export function useDeleteEmailConnector() {
const qc = useQueryClient();
return useMutation<void, Error, void>({
mutationFn: () => api.delete('/vendor/email-connector'),
onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'email-connector'] }),
});
}
export function useTestEmailConnector() {
return useMutation<TestEmailResult, Error, string>({
mutationFn: (to) => api.post('/vendor/email-connector/test', { to }),
});
}
export function useToggleRegistration() {
const qc = useQueryClient();
return useMutation<void, Error, boolean>({
mutationFn: (enabled) => api.post('/vendor/email-connector/registration', { enabled }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'email-connector'] }),
});
}

View File

@@ -1,13 +1,21 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useLogto } from '@logto/react'; import { useLogto } from '@logto/react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { Spinner } from '@cameleer/design-system'; import { Button, Card, Spinner } from '@cameleer/design-system';
import cameleerLogo from '@cameleer/design-system/assets/cameleer-logo.svg';
export function LoginPage() { export function LoginPage() {
const { signIn, isAuthenticated, isLoading } = useLogto(); const { signIn, isAuthenticated, isLoading } = useLogto();
const navigate = useNavigate(); const navigate = useNavigate();
const redirected = useRef(false); const redirected = useRef(false);
// Check if we arrived here from a logout redirect (set by useAuth before signOut)
const [signedOut] = useState(() => {
const flag = sessionStorage.getItem('cameleer:signed_out');
if (flag) sessionStorage.removeItem('cameleer:signed_out');
return !!flag;
});
useEffect(() => { useEffect(() => {
if (isAuthenticated) { if (isAuthenticated) {
navigate('/', { replace: true }); navigate('/', { replace: true });
@@ -15,11 +23,50 @@ export function LoginPage() {
}, [isAuthenticated, navigate]); }, [isAuthenticated, navigate]);
useEffect(() => { useEffect(() => {
// Don't auto-redirect after logout — show the signed-out state instead
if (signedOut) return;
if (!isLoading && !isAuthenticated && !redirected.current) { if (!isLoading && !isAuthenticated && !redirected.current) {
redirected.current = true; redirected.current = true;
signIn(`${window.location.origin}/platform/callback`); signIn(`${window.location.origin}/platform/callback`);
} }
}, [isLoading, isAuthenticated, signIn]); }, [isLoading, isAuthenticated, signIn, signedOut]);
if (signedOut && !isAuthenticated) {
return (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
minHeight: '100vh', background: 'var(--bg-base)',
}}>
<Card className="signed-out-card">
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center',
width: '100%', fontFamily: 'var(--font-body)',
}}>
<div style={{
display: 'flex', alignItems: 'center', gap: 10,
marginBottom: 8, fontSize: 24, fontWeight: 700, color: 'var(--text-primary)',
}}>
<img src={cameleerLogo} alt="" width="36" height="36" />
Cameleer
</div>
<p style={{ fontSize: 13, color: 'var(--text-muted)', margin: '0 0 24px' }}>
You have been signed out successfully.
</p>
<Button
variant="primary"
onClick={() => {
window.location.href = window.location.origin + '/platform/login';
}}
style={{ width: '100%' }}
>
Sign in again
</Button>
</div>
</Card>
<style>{`.signed-out-card { width: 100%; max-width: 400px; padding: 32px; }`}</style>
</div>
);
}
return ( return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}>

View 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>
);
}

View File

@@ -7,6 +7,7 @@ export function useAuth() {
const { currentTenantId } = useOrgStore(); const { currentTenantId } = useOrgStore();
const logout = useCallback(() => { const logout = useCallback(() => {
sessionStorage.setItem('cameleer:signed_out', '1');
signOut(window.location.origin + '/platform/login'); signOut(window.location.origin + '/platform/login');
}, [signOut]); }, [signOut]);

View File

@@ -4,7 +4,7 @@ import {
Sidebar, Sidebar,
TopBar, TopBar,
} from '@cameleer/design-system'; } from '@cameleer/design-system';
import { LayoutDashboard, ShieldCheck, Users, Settings, Shield, Building, ScrollText } from 'lucide-react'; import { LayoutDashboard, ShieldCheck, Users, Settings, Shield, Building, ScrollText, Mail } from 'lucide-react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useAuth } from '../auth/useAuth'; import { useAuth } from '../auth/useAuth';
import { useScopes } from '../auth/useScopes'; import { useScopes } from '../auth/useScopes';
@@ -125,11 +125,20 @@ export function Layout() {
> >
Infrastructure Infrastructure
</div> </div>
<div
style={{ padding: '6px 12px 6px 36px', fontSize: 13, cursor: 'pointer',
fontWeight: isActive(location, '/vendor/email') ? 600 : 400,
color: isActive(location, '/vendor/email') ? 'var(--amber)' : 'var(--text-muted)' }}
onClick={() => navigate('/vendor/email')}
>
<Mail size={12} style={{ marginRight: 6, verticalAlign: -1 }} />
Email Connector
</div>
<div <div
style={{ padding: '6px 12px 6px 36px', fontSize: 13, cursor: 'pointer', color: 'var(--text-muted)' }} style={{ padding: '6px 12px 6px 36px', fontSize: 13, cursor: 'pointer', color: 'var(--text-muted)' }}
onClick={() => window.open(`${window.location.protocol}//${window.location.hostname}:3002`, '_blank', 'noopener')} onClick={() => window.open(`${window.location.protocol}//${window.location.hostname}:3002`, '_blank', 'noopener')}
> >
Identity (Logto) Logto Console
</div> </div>
</Sidebar.Section> </Sidebar.Section>
)} )}

View File

@@ -0,0 +1,63 @@
.page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: var(--bg-base);
}
.wrapper {
width: 100%;
max-width: 420px;
padding: 16px;
}
.card {
padding: 32px;
}
.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%;
}

View 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 className={styles.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} noValidate>
<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>
);
}

296
ui/src/pages/vendor/EmailConfigPage.tsx vendored Normal file
View File

@@ -0,0 +1,296 @@
import { useState } from 'react';
import {
Alert,
Button,
Card,
FormField,
Input,
Spinner,
useToast,
} from '@cameleer/design-system';
import { Send, Trash2, Save, Power } from 'lucide-react';
import {
useEmailConnector,
useSaveEmailConnector,
useDeleteEmailConnector,
useTestEmailConnector,
useToggleRegistration,
} from '../../api/email-connector-hooks';
import styles from '../../styles/platform.module.css';
export function EmailConfigPage() {
const { toast } = useToast();
const { data: connector, isLoading, isError } = useEmailConnector();
const saveMutation = useSaveEmailConnector();
const deleteMutation = useDeleteEmailConnector();
const testMutation = useTestEmailConnector();
const toggleMutation = useToggleRegistration();
const [editing, setEditing] = useState(false);
const [host, setHost] = useState('');
const [port, setPort] = useState('587');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [fromEmail, setFromEmail] = useState('');
const [testTo, setTestTo] = useState('');
const [confirmDelete, setConfirmDelete] = useState(false);
const isConfigured = connector != null;
const showForm = !isConfigured || editing;
function startEditing() {
if (connector) {
setHost(connector.host);
setPort(String(connector.port));
setUsername(connector.username);
setPassword('');
setFromEmail(connector.fromEmail);
}
setEditing(true);
}
async function handleSave() {
if (!host || !username || !password || !fromEmail) {
toast({ title: 'All fields are required', variant: 'error' });
return;
}
try {
await saveMutation.mutateAsync({
host,
port: parseInt(port, 10) || 587,
username,
password,
fromEmail,
});
toast({ title: 'Email connector saved', variant: 'success' });
setEditing(false);
setPassword('');
} catch (err) {
toast({ title: 'Failed to save', description: String(err), variant: 'error' });
}
}
async function handleDelete() {
try {
await deleteMutation.mutateAsync();
toast({ title: 'Email connector removed', variant: 'success' });
setConfirmDelete(false);
setEditing(false);
} catch (err) {
toast({ title: 'Failed to delete', description: String(err), variant: 'error' });
}
}
async function handleTest() {
if (!testTo) {
toast({ title: 'Enter a recipient email address', variant: 'error' });
return;
}
try {
const result = await testMutation.mutateAsync(testTo);
if (result.status === 'sent') {
toast({ title: 'Test email sent', description: result.message, variant: 'success' });
} else {
toast({ title: 'Test failed', description: result.message, variant: 'error' });
}
} catch (err) {
toast({ title: 'Test failed', description: String(err), variant: 'error' });
}
}
async function handleToggleRegistration() {
if (!connector) return;
const newValue = !connector.registrationEnabled;
try {
await toggleMutation.mutateAsync(newValue);
toast({
title: newValue ? 'Registration enabled' : 'Registration disabled',
variant: 'success',
});
} catch (err) {
toast({ title: 'Failed to toggle registration', description: String(err), variant: 'error' });
}
}
if (isLoading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 64 }}>
<Spinner />
</div>
);
}
if (isError) {
return (
<div style={{ padding: 24 }}>
<Alert variant="error" title="Failed to load email configuration">
Could not fetch email connector data. Please refresh.
</Alert>
</div>
);
}
return (
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 20 }}>
<h1 className={styles.heading}>Email Connector</h1>
{!isConfigured && (
<Alert variant="info" title="Email delivery not configured">
Self-service registration is disabled. Configure an SMTP connector to enable email
verification for sign-up and password reset.
</Alert>
)}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(380px, 1fr))', gap: 16 }}>
{/* Current config card (when configured and not editing) */}
{isConfigured && !editing && (
<Card title="SMTP Configuration">
<div className={styles.dividerList}>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Host</span>
<span className={styles.kvValue}>{connector.host}</span>
</div>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Port</span>
<span className={styles.kvValue}>{connector.port}</span>
</div>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Username</span>
<span className={styles.kvValue}>{connector.username}</span>
</div>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Password</span>
<span className={styles.kvValueMono}>{'*'.repeat(8)}</span>
</div>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>From Email</span>
<span className={styles.kvValue}>{connector.fromEmail}</span>
</div>
<div style={{ paddingTop: 8, display: 'flex', gap: 8 }}>
<Button variant="secondary" onClick={startEditing}>
Edit
</Button>
{!confirmDelete ? (
<Button variant="secondary" onClick={() => setConfirmDelete(true)}>
<Trash2 size={14} style={{ marginRight: 6 }} />
Remove
</Button>
) : (
<>
<Button variant="primary" onClick={handleDelete} loading={deleteMutation.isPending}
style={{ background: 'var(--error)' }}>
Confirm removal
</Button>
<Button variant="secondary" onClick={() => setConfirmDelete(false)}>
Cancel
</Button>
</>
)}
</div>
</div>
</Card>
)}
{/* SMTP form (when unconfigured or editing) */}
{showForm && (
<Card title={isConfigured ? 'Edit SMTP Configuration' : 'SMTP Configuration'}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<FormField label="SMTP Host *">
<Input
placeholder="smtp.example.com"
value={host}
onChange={(e) => setHost(e.target.value)}
/>
</FormField>
<FormField label="SMTP Port *">
<Input
type="number"
placeholder="587"
value={port}
onChange={(e) => setPort(e.target.value)}
/>
</FormField>
<FormField label="Username *">
<Input
placeholder="user@example.com"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</FormField>
<FormField label="Password *">
<Input
type="password"
placeholder={isConfigured ? 'Enter new password' : 'Password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</FormField>
<FormField label="From Email *">
<Input
type="email"
placeholder="noreply@example.com"
value={fromEmail}
onChange={(e) => setFromEmail(e.target.value)}
/>
</FormField>
<div style={{ display: 'flex', gap: 8 }}>
<Button variant="primary" onClick={handleSave} loading={saveMutation.isPending}>
<Save size={14} style={{ marginRight: 6 }} />
{isConfigured ? 'Update' : 'Save'}
</Button>
{editing && (
<Button variant="secondary" onClick={() => setEditing(false)}>
Cancel
</Button>
)}
</div>
</div>
</Card>
)}
{/* Registration toggle (when configured) */}
{isConfigured && !editing && (
<Card title="Self-Service Registration">
<div className={styles.dividerList}>
<p className={styles.description}>
{connector.registrationEnabled
? 'New users can register with their email address and verify via a code sent to their inbox.'
: 'Registration is disabled. Only admin-invited users can sign in.'}
</p>
<div style={{ paddingTop: 8 }}>
<Button
variant={connector.registrationEnabled ? 'secondary' : 'primary'}
onClick={handleToggleRegistration}
loading={toggleMutation.isPending}
>
<Power size={14} style={{ marginRight: 6 }} />
{connector.registrationEnabled ? 'Disable Registration' : 'Enable Registration'}
</Button>
</div>
</div>
</Card>
)}
{/* Test email (when configured and not editing) */}
{isConfigured && !editing && (
<Card title="Send Test Email">
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<FormField label="Recipient">
<Input
type="email"
placeholder="you@example.com"
value={testTo}
onChange={(e) => setTestTo(e.target.value)}
/>
</FormField>
<Button variant="primary" onClick={handleTest} loading={testMutation.isPending}>
<Send size={14} style={{ marginRight: 6 }} />
Send Test Email
</Button>
</div>
</Card>
)}
</div>
</div>
);
}

View File

@@ -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';
@@ -15,12 +16,14 @@ import { VendorAuditPage } from './pages/vendor/VendorAuditPage';
import { CertificatesPage } from './pages/vendor/CertificatesPage'; import { CertificatesPage } from './pages/vendor/CertificatesPage';
import { InfrastructurePage } from './pages/vendor/InfrastructurePage'; import { InfrastructurePage } from './pages/vendor/InfrastructurePage';
import { VendorMetricsPage } from './pages/vendor/VendorMetricsPage'; import { VendorMetricsPage } from './pages/vendor/VendorMetricsPage';
import { EmailConfigPage } from './pages/vendor/EmailConfigPage';
import { TenantDashboardPage } from './pages/tenant/TenantDashboardPage'; import { TenantDashboardPage } from './pages/tenant/TenantDashboardPage';
import { TenantLicensePage } from './pages/tenant/TenantLicensePage'; import { TenantLicensePage } from './pages/tenant/TenantLicensePage';
import { SsoPage } from './pages/tenant/SsoPage'; 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 +48,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 +60,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={
@@ -93,6 +103,11 @@ export function AppRouter() {
<InfrastructurePage /> <InfrastructurePage />
</RequireScope> </RequireScope>
} /> } />
<Route path="/vendor/email" element={
<RequireScope scope="platform:admin" fallback={<Navigate to="/tenant" replace />}>
<EmailConfigPage />
</RequireScope>
} />
{/* Tenant portal */} {/* Tenant portal */}
<Route path="/tenant" element={<TenantDashboardPage />} /> <Route path="/tenant" element={<TenantDashboardPage />} />