From 827e38834980d9a1fb57702f23a09927e16f599b Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 5 Apr 2026 02:50:51 +0200 Subject: [PATCH] feat: bootstrap 2 users, tenant, org-scoped tokens, platform admin UI Bootstrap script now creates: - SaaS Owner (admin/admin) with platform-admin role - Tenant Admin (camel/camel) in Example Tenant org - Traditional Web App for cameleer3-server OIDC - DB records: tenant, default environment, license - Configures cameleer3-server OIDC via its admin API All credentials configurable via env vars. Backend: - Fix LogtoManagementClient resource URL (https://default.logto.app/api) - Add getUserRoles/getUserOrganizations to LogtoManagementClient - Add GET /api/me endpoint (user info, platform admin status, tenants) - Add GET /api/tenants list-all for platform admins - Remove insecure X-header forwarding from Traefik Frontend: - Org-scoped tokens: getAccessToken(resource, orgId) for tenant context - OrgResolver component populates org store from /api/me - useOrganization Zustand store (currentOrgId + currentTenantId) - Platform admin sidebar section + AdminTenantsPage - View Dashboard link points to cameleer3-server on port 8081 Co-Authored-By: Claude Opus 4.6 (1M context) --- docker-compose.dev.yml | 4 + docker-compose.yml | 19 +- docker/logto-bootstrap.sh | 377 +++++++++++++++--- .../cameleer/saas/config/MeController.java | 58 +++ .../cameleer/saas/config/SecurityConfig.java | 2 +- .../saas/identity/LogtoManagementClient.java | 55 ++- .../saas/tenant/TenantController.java | 18 +- .../cameleer/saas/tenant/TenantService.java | 4 + ui/src/api/hooks.ts | 18 +- ui/src/auth/OrgResolver.tsx | 47 +++ ui/src/auth/useAuth.ts | 6 +- ui/src/auth/useOrganization.ts | 31 ++ ui/src/components/Layout.tsx | 27 +- ui/src/main.tsx | 44 +- ui/src/pages/AdminTenantsPage.tsx | 75 ++++ ui/src/router.tsx | 7 +- ui/src/types/api.ts | 11 + 17 files changed, 725 insertions(+), 78 deletions(-) create mode 100644 src/main/java/net/siegeln/cameleer/saas/config/MeController.java create mode 100644 ui/src/auth/OrgResolver.tsx create mode 100644 ui/src/auth/useOrganization.ts create mode 100644 ui/src/pages/AdminTenantsPage.tsx diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 574d1b4..979ddae 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -16,6 +16,10 @@ services: environment: SPRING_PROFILES_ACTIVE: dev + cameleer3-server: + ports: + - "8081:8081" + clickhouse: ports: - "8123:8123" diff --git a/docker-compose.yml b/docker-compose.yml index ceef303..bbde27b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -60,14 +60,26 @@ services: depends_on: logto: condition: service_healthy + cameleer3-server: + condition: service_healthy restart: "no" entrypoint: ["sh", "/scripts/logto-bootstrap.sh"] environment: LOGTO_ENDPOINT: http://logto:3001 LOGTO_ADMIN_ENDPOINT: http://logto:3002 + LOGTO_PUBLIC_ENDPOINT: ${LOGTO_PUBLIC_ENDPOINT:-http://localhost:3001} PG_HOST: 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} + TENANT_ADMIN_USER: ${TENANT_ADMIN_USER:-camel} + TENANT_ADMIN_PASS: ${TENANT_ADMIN_PASS:-camel} + CAMELEER_AUTH_TOKEN: ${CAMELEER_AUTH_TOKEN:-default-bootstrap-token} + SERVER_ENDPOINT: http://cameleer3-server:8081 + SERVER_UI_USER: ${CAMELEER_UI_USER:-admin} + SERVER_UI_PASS: ${CAMELEER_UI_PASSWORD:-admin} volumes: - ./docker/logto-bootstrap.sh:/scripts/logto-bootstrap.sh:ro - bootstrapdata:/data @@ -133,13 +145,18 @@ services: CAMELEER_AUTH_TOKEN: ${CAMELEER_AUTH_TOKEN:-default-bootstrap-token} CAMELEER_JWT_SECRET: ${CAMELEER_JWT_SECRET:-cameleer-dev-jwt-secret-change-in-production} CAMELEER_TENANT_ID: ${CAMELEER_TENANT_SLUG:-default} + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:8081/api/v1/health || exit 1"] + interval: 5s + timeout: 5s + retries: 30 + start_period: 15s labels: - traefik.enable=true - traefik.http.routers.observe.rule=PathPrefix(`/observe`) - traefik.http.routers.observe.service=observe - traefik.http.routers.observe.middlewares=forward-auth - traefik.http.middlewares.forward-auth.forwardauth.address=http://cameleer-saas:8080/auth/verify - - traefik.http.middlewares.forward-auth.forwardauth.authResponseHeaders=X-Tenant-Id,X-User-Id,X-User-Email - traefik.http.services.observe.loadbalancer.server.port=8080 - traefik.http.routers.dashboard.rule=PathPrefix(`/dashboard`) - traefik.http.routers.dashboard.service=dashboard diff --git a/docker/logto-bootstrap.sh b/docker/logto-bootstrap.sh index e863570..8ac1e58 100644 --- a/docker/logto-bootstrap.sh +++ b/docker/logto-bootstrap.sh @@ -1,36 +1,62 @@ #!/bin/sh set -e -# Cameleer SaaS — Logto Bootstrap Script -# Creates SPA app, M2M app, default user via Logto Management API. -# Then removes seeded M2M apps with known secrets (security hardening). +# Cameleer SaaS — Bootstrap Script +# Creates Logto apps, users, organizations, roles. +# Seeds cameleer_saas DB with tenant, environment, license. +# Configures cameleer3-server OIDC. # Idempotent: checks existence before creating. LOGTO_ENDPOINT="${LOGTO_ENDPOINT:-http://logto:3001}" LOGTO_ADMIN_ENDPOINT="${LOGTO_ADMIN_ENDPOINT:-http://logto:3002}" +LOGTO_PUBLIC_ENDPOINT="${LOGTO_PUBLIC_ENDPOINT:-http://localhost:3001}" MGMT_API_RESOURCE="https://default.logto.app/api" BOOTSTRAP_FILE="/data/logto-bootstrap.json" PG_HOST="${PG_HOST:-postgres}" PG_USER="${PG_USER:-cameleer}" -PG_DB="logto" +PG_DB_LOGTO="logto" +PG_DB_SAAS="${PG_DB_SAAS:-cameleer_saas}" +# App names SPA_APP_NAME="Cameleer SaaS" M2M_APP_NAME="Cameleer SaaS Backend" +TRAD_APP_NAME="Cameleer Dashboard" API_RESOURCE_INDICATOR="https://api.cameleer.local" API_RESOURCE_NAME="Cameleer SaaS API" -DEFAULT_USERNAME="camel" -DEFAULT_PASSWORD="camel" -REDIRECT_URIS='["http://localhost/callback","http://localhost:8080/callback","http://localhost:5173/callback"]' -POST_LOGOUT_URIS='["http://localhost","http://localhost:8080","http://localhost:5173"]' +# Users (configurable via env vars) +SAAS_ADMIN_USER="${SAAS_ADMIN_USER:-admin}" +SAAS_ADMIN_PASS="${SAAS_ADMIN_PASS:-admin}" +TENANT_ADMIN_USER="${TENANT_ADMIN_USER:-camel}" +TENANT_ADMIN_PASS="${TENANT_ADMIN_PASS:-camel}" -log() { echo "[logto-bootstrap] $1"; } +# Tenant config +TENANT_NAME="Example Tenant" +TENANT_SLUG="default" +BOOTSTRAP_TOKEN="${CAMELEER_AUTH_TOKEN:-default-bootstrap-token}" -# Install jq + curl (not in postgres:16-alpine by default) +# Server config +SERVER_ENDPOINT="${SERVER_ENDPOINT:-http://cameleer3-server:8081}" +SERVER_UI_USER="${SERVER_UI_USER:-admin}" +SERVER_UI_PASS="${SERVER_UI_PASS:-admin}" + +# Redirect URIs +SPA_REDIRECT_URIS='["http://localhost/callback","http://localhost:8080/callback","http://localhost:5173/callback"]' +SPA_POST_LOGOUT_URIS='["http://localhost/login","http://localhost:8080/login","http://localhost:5173/login"]' +TRAD_REDIRECT_URIS='["http://localhost:8081/oidc/callback"]' +TRAD_POST_LOGOUT_URIS='["http://localhost:8081"]' + +log() { echo "[bootstrap] $1"; } +pgpass() { PGPASSWORD="${PG_PASSWORD:-cameleer_dev}"; export PGPASSWORD; } + +# Install jq + curl apk add --no-cache jq curl >/dev/null 2>&1 -# --- Wait for Logto --- -log "Waiting for Logto to be ready..." +# ============================================================ +# PHASE 1: Wait for services +# ============================================================ + +log "Waiting for Logto..." for i in $(seq 1 60); do if curl -sf "${LOGTO_ENDPOINT}/oidc/.well-known/openid-configuration" >/dev/null 2>&1; then log "Logto is ready." @@ -40,15 +66,26 @@ for i in $(seq 1 60); do sleep 1 done -# --- Read m-default secret from Postgres (admin tenant) --- +log "Waiting for cameleer3-server..." +for i in $(seq 1 60); do + if curl -sf "${SERVER_ENDPOINT}/api/v1/health" >/dev/null 2>&1; then + log "cameleer3-server is ready." + break + fi + [ "$i" -eq 60 ] && { log "WARNING: cameleer3-server not ready after 60s — skipping OIDC config"; } + sleep 1 +done + +# ============================================================ +# PHASE 2: Get Management API token +# ============================================================ + log "Reading m-default secret from database..." -M_DEFAULT_SECRET=$(PGPASSWORD="${PG_PASSWORD:-cameleer_dev}" psql -h "$PG_HOST" -U "$PG_USER" -d "$PG_DB" -t -A -c \ +pgpass +M_DEFAULT_SECRET=$(psql -h "$PG_HOST" -U "$PG_USER" -d "$PG_DB_LOGTO" -t -A -c \ "SELECT secret FROM applications WHERE id = 'm-default' AND tenant_id = 'admin';") +[ -z "$M_DEFAULT_SECRET" ] && { log "ERROR: m-default app not found"; exit 1; } -[ -z "$M_DEFAULT_SECRET" ] && { log "ERROR: m-default app not found in DB"; exit 1; } -log "Got m-default secret." - -# --- Get Management API token --- get_admin_token() { curl -s -X POST "${LOGTO_ADMIN_ENDPOINT}/oidc/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ @@ -65,53 +102,78 @@ get_default_token() { log "Getting Management API token..." TOKEN_RESPONSE=$(get_admin_token "m-default" "$M_DEFAULT_SECRET") -log "Token response: $(echo "$TOKEN_RESPONSE" | head -c 200)" TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r '.access_token' 2>/dev/null) [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ] && { log "ERROR: Failed to get token"; exit 1; } log "Got Management API token." -# --- Helper: API calls --- +# --- Helper: Logto API calls --- api_get() { curl -s -H "Authorization: Bearer $TOKEN" -H "Host: localhost:3001" "${LOGTO_ENDPOINT}${1}" 2>/dev/null || echo "[]" } - api_post() { curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -H "Host: localhost:3001" \ -d "$2" "${LOGTO_ENDPOINT}${1}" 2>/dev/null || true } - +api_put() { + curl -s -X PUT -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -H "Host: localhost:3001" \ + -d "$2" "${LOGTO_ENDPOINT}${1}" 2>/dev/null || true +} api_delete() { curl -s -X DELETE -H "Authorization: Bearer $TOKEN" -H "Host: localhost:3001" "${LOGTO_ENDPOINT}${1}" 2>/dev/null || true } -# --- Find or create SPA app --- -log "Checking for existing SPA app..." -EXISTING_APPS=$(api_get "/api/applications") -SPA_ID=$(echo "$EXISTING_APPS" | jq -r ".[] | select(.name == \"$SPA_APP_NAME\" and .type == \"SPA\") | .id") +# ============================================================ +# PHASE 3: Create Logto applications +# ============================================================ +EXISTING_APPS=$(api_get "/api/applications") + +# --- SPA app (for SaaS frontend) --- +SPA_ID=$(echo "$EXISTING_APPS" | jq -r ".[] | select(.name == \"$SPA_APP_NAME\" and .type == \"SPA\") | .id") if [ -n "$SPA_ID" ]; then - log "SPA app already exists: $SPA_ID" + log "SPA app exists: $SPA_ID" else log "Creating SPA app..." SPA_RESPONSE=$(api_post "/api/applications" "{ \"name\": \"$SPA_APP_NAME\", \"type\": \"SPA\", \"oidcClientMetadata\": { - \"redirectUris\": $REDIRECT_URIS, - \"postLogoutRedirectUris\": $POST_LOGOUT_URIS + \"redirectUris\": $SPA_REDIRECT_URIS, + \"postLogoutRedirectUris\": $SPA_POST_LOGOUT_URIS } }") SPA_ID=$(echo "$SPA_RESPONSE" | jq -r '.id') log "Created SPA app: $SPA_ID" fi -# --- Find or create API resource --- -log "Checking for existing API resource..." +# --- Traditional Web App (for cameleer3-server OIDC) --- +TRAD_ID=$(echo "$EXISTING_APPS" | jq -r ".[] | select(.name == \"$TRAD_APP_NAME\" and .type == \"Traditional\") | .id") +TRAD_SECRET="" +if [ -n "$TRAD_ID" ]; then + log "Traditional app exists: $TRAD_ID" + pgpass + TRAD_SECRET=$(psql -h "$PG_HOST" -U "$PG_USER" -d "$PG_DB_LOGTO" -t -A -c \ + "SELECT secret FROM applications WHERE id = '$TRAD_ID' AND tenant_id = 'default';") +else + log "Creating Traditional Web app..." + TRAD_RESPONSE=$(api_post "/api/applications" "{ + \"name\": \"$TRAD_APP_NAME\", + \"type\": \"Traditional\", + \"oidcClientMetadata\": { + \"redirectUris\": $TRAD_REDIRECT_URIS, + \"postLogoutRedirectUris\": $TRAD_POST_LOGOUT_URIS + } + }") + TRAD_ID=$(echo "$TRAD_RESPONSE" | jq -r '.id') + TRAD_SECRET=$(echo "$TRAD_RESPONSE" | jq -r '.secret') + log "Created Traditional app: $TRAD_ID" +fi + +# --- API resource --- EXISTING_RESOURCES=$(api_get "/api/resources") API_RESOURCE_ID=$(echo "$EXISTING_RESOURCES" | jq -r ".[] | select(.indicator == \"$API_RESOURCE_INDICATOR\") | .id") - if [ -n "$API_RESOURCE_ID" ]; then - log "API resource already exists: $API_RESOURCE_ID" + log "API resource exists: $API_RESOURCE_ID" else log "Creating API resource..." RESOURCE_RESPONSE=$(api_post "/api/resources" "{ @@ -122,14 +184,13 @@ else log "Created API resource: $API_RESOURCE_ID" fi -# --- Find or create M2M app --- -log "Checking for existing M2M app..." +# --- M2M app --- M2M_ID=$(echo "$EXISTING_APPS" | jq -r ".[] | select(.name == \"$M2M_APP_NAME\" and .type == \"MachineToMachine\") | .id") - M2M_SECRET="" if [ -n "$M2M_ID" ]; then - log "M2M app already exists: $M2M_ID" - M2M_SECRET=$(PGPASSWORD="${PG_PASSWORD:-cameleer_dev}" psql -h "$PG_HOST" -U "$PG_USER" -d "$PG_DB" -t -A -c \ + log "M2M app exists: $M2M_ID" + pgpass + M2M_SECRET=$(psql -h "$PG_HOST" -U "$PG_USER" -d "$PG_DB_LOGTO" -t -A -c \ "SELECT secret FROM applications WHERE id = '$M2M_ID' AND tenant_id = 'default';") else log "Creating M2M app..." @@ -142,12 +203,13 @@ else log "Created M2M app: $M2M_ID" # Assign Management API role - log "Assigning Management API access..." - MGMT_RESOURCE_ID=$(PGPASSWORD="${PG_PASSWORD:-cameleer_dev}" psql -h "$PG_HOST" -U "$PG_USER" -d "$PG_DB" -t -A -c \ + log "Assigning Management API access to M2M app..." + pgpass + MGMT_RESOURCE_ID=$(psql -h "$PG_HOST" -U "$PG_USER" -d "$PG_DB_LOGTO" -t -A -c \ "SELECT id FROM resources WHERE indicator = '$MGMT_API_RESOURCE' AND tenant_id = 'default';") if [ -n "$MGMT_RESOURCE_ID" ]; then - SCOPE_IDS=$(PGPASSWORD="${PG_PASSWORD:-cameleer_dev}" psql -h "$PG_HOST" -U "$PG_USER" -d "$PG_DB" -t -A -c \ + SCOPE_IDS=$(psql -h "$PG_HOST" -U "$PG_USER" -d "$PG_DB_LOGTO" -t -A -c \ "SELECT json_agg(id) FROM scopes WHERE resource_id = '$MGMT_RESOURCE_ID' AND tenant_id = 'default';" | tr -d '[:space:]') ROLE_RESPONSE=$(api_post "/api/roles" "{ @@ -162,37 +224,217 @@ else api_post "/api/roles/$ROLE_ID/applications" "{\"applicationIds\": [\"$M2M_ID\"]}" >/dev/null log "Assigned Management API role to M2M app." - # Verify our M2M app works VERIFY=$(get_default_token "$M2M_ID" "$M2M_SECRET") VERIFY_TOKEN=$(echo "$VERIFY" | jq -r '.access_token') if [ -n "$VERIFY_TOKEN" ] && [ "$VERIFY_TOKEN" != "null" ]; then log "Verified M2M app works." else - log "WARNING: M2M app verification failed — skipping seeded app cleanup" + log "WARNING: M2M verification failed" M2M_SECRET="" fi fi fi fi -# --- Find or create default user --- -log "Checking for existing user '$DEFAULT_USERNAME'..." -USER_ID=$(api_get "/api/users?search=$DEFAULT_USERNAME" | jq -r ".[] | select(.username == \"$DEFAULT_USERNAME\") | .id") +# ============================================================ +# PHASE 4: Create roles +# ============================================================ -if [ -n "$USER_ID" ]; then - log "User '$DEFAULT_USERNAME' already exists: $USER_ID" +# --- Global platform-admin role --- +log "Creating platform-admin role..." +EXISTING_ROLES=$(api_get "/api/roles") +PA_ROLE_ID=$(echo "$EXISTING_ROLES" | jq -r '.[] | select(.name == "platform-admin" and .type == "User") | .id') +if [ -n "$PA_ROLE_ID" ]; then + log "platform-admin role exists: $PA_ROLE_ID" else - log "Creating user '$DEFAULT_USERNAME'..." - USER_RESPONSE=$(api_post "/api/users" "{ - \"username\": \"$DEFAULT_USERNAME\", - \"password\": \"$DEFAULT_PASSWORD\", - \"name\": \"Cameleer Admin\" + PA_RESPONSE=$(api_post "/api/roles" "{ + \"name\": \"platform-admin\", + \"description\": \"SaaS platform administrator\", + \"type\": \"User\" }") - USER_ID=$(echo "$USER_RESPONSE" | jq -r '.id') - log "Created user: $USER_ID" + PA_ROLE_ID=$(echo "$PA_RESPONSE" | jq -r '.id') + log "Created platform-admin role: $PA_ROLE_ID" fi -# --- Cleanup seeded M2M apps with known secrets --- +# --- Organization roles --- +log "Creating organization roles..." +EXISTING_ORG_ROLES=$(api_get "/api/organization-roles") +ORG_ADMIN_ROLE_ID=$(echo "$EXISTING_ORG_ROLES" | jq -r '.[] | select(.name == "admin") | .id') +if [ -n "$ORG_ADMIN_ROLE_ID" ]; then + log "Org admin role exists: $ORG_ADMIN_ROLE_ID" +else + ORG_ADMIN_RESPONSE=$(api_post "/api/organization-roles" "{ + \"name\": \"admin\", + \"description\": \"Tenant administrator\" + }") + ORG_ADMIN_ROLE_ID=$(echo "$ORG_ADMIN_RESPONSE" | jq -r '.id') + log "Created org admin role: $ORG_ADMIN_ROLE_ID" +fi + +ORG_MEMBER_ROLE_ID=$(echo "$EXISTING_ORG_ROLES" | jq -r '.[] | select(.name == "member") | .id') +if [ -z "$ORG_MEMBER_ROLE_ID" ]; then + ORG_MEMBER_RESPONSE=$(api_post "/api/organization-roles" "{ + \"name\": \"member\", + \"description\": \"Tenant member\" + }") + ORG_MEMBER_ROLE_ID=$(echo "$ORG_MEMBER_RESPONSE" | jq -r '.id') + log "Created org member role: $ORG_MEMBER_ROLE_ID" +fi + +# ============================================================ +# PHASE 5: Create users +# ============================================================ + +# --- SaaS Owner --- +log "Checking for SaaS owner user '$SAAS_ADMIN_USER'..." +ADMIN_USER_ID=$(api_get "/api/users?search=$SAAS_ADMIN_USER" | jq -r ".[] | select(.username == \"$SAAS_ADMIN_USER\") | .id") +if [ -n "$ADMIN_USER_ID" ]; then + log "SaaS owner exists: $ADMIN_USER_ID" +else + log "Creating SaaS owner '$SAAS_ADMIN_USER'..." + ADMIN_RESPONSE=$(api_post "/api/users" "{ + \"username\": \"$SAAS_ADMIN_USER\", + \"password\": \"$SAAS_ADMIN_PASS\", + \"name\": \"Platform Admin\" + }") + ADMIN_USER_ID=$(echo "$ADMIN_RESPONSE" | jq -r '.id') + log "Created SaaS owner: $ADMIN_USER_ID" + + # Assign platform-admin role + if [ -n "$PA_ROLE_ID" ] && [ "$PA_ROLE_ID" != "null" ]; then + api_post "/api/users/$ADMIN_USER_ID/roles" "{\"roleIds\": [\"$PA_ROLE_ID\"]}" >/dev/null + log "Assigned platform-admin role to SaaS owner." + fi +fi + +# --- Tenant Admin --- +log "Checking for tenant admin '$TENANT_ADMIN_USER'..." +TENANT_USER_ID=$(api_get "/api/users?search=$TENANT_ADMIN_USER" | jq -r ".[] | select(.username == \"$TENANT_ADMIN_USER\") | .id") +if [ -n "$TENANT_USER_ID" ]; then + log "Tenant admin exists: $TENANT_USER_ID" +else + log "Creating tenant admin '$TENANT_ADMIN_USER'..." + TENANT_RESPONSE=$(api_post "/api/users" "{ + \"username\": \"$TENANT_ADMIN_USER\", + \"password\": \"$TENANT_ADMIN_PASS\", + \"name\": \"Tenant Admin\" + }") + TENANT_USER_ID=$(echo "$TENANT_RESPONSE" | jq -r '.id') + log "Created tenant admin: $TENANT_USER_ID" +fi + +# ============================================================ +# PHASE 6: Create organization + add users +# ============================================================ + +log "Checking for organization '$TENANT_NAME'..." +EXISTING_ORGS=$(api_get "/api/organizations") +ORG_ID=$(echo "$EXISTING_ORGS" | jq -r ".[] | select(.name == \"$TENANT_NAME\") | .id") + +if [ -n "$ORG_ID" ]; then + log "Organization exists: $ORG_ID" +else + log "Creating organization '$TENANT_NAME'..." + ORG_RESPONSE=$(api_post "/api/organizations" "{ + \"name\": \"$TENANT_NAME\", + \"description\": \"Bootstrap demo tenant\" + }") + ORG_ID=$(echo "$ORG_RESPONSE" | jq -r '.id') + log "Created organization: $ORG_ID" +fi + +# Add users to organization +if [ -n "$TENANT_USER_ID" ] && [ "$TENANT_USER_ID" != "null" ]; then + log "Adding tenant admin to organization..." + api_post "/api/organizations/$ORG_ID/users" "{\"userIds\": [\"$TENANT_USER_ID\"]}" >/dev/null 2>&1 + api_put "/api/organizations/$ORG_ID/users/$TENANT_USER_ID/roles" "{\"organizationRoleIds\": [\"$ORG_ADMIN_ROLE_ID\"]}" >/dev/null 2>&1 + log "Tenant admin added to org with admin role." +fi + +if [ -n "$ADMIN_USER_ID" ] && [ "$ADMIN_USER_ID" != "null" ]; then + log "Adding SaaS owner to organization..." + api_post "/api/organizations/$ORG_ID/users" "{\"userIds\": [\"$ADMIN_USER_ID\"]}" >/dev/null 2>&1 + api_put "/api/organizations/$ORG_ID/users/$ADMIN_USER_ID/roles" "{\"organizationRoleIds\": [\"$ORG_ADMIN_ROLE_ID\"]}" >/dev/null 2>&1 + log "SaaS owner added to org with admin role." +fi + +# ============================================================ +# PHASE 7: Seed cameleer_saas database +# ============================================================ + +log "Seeding cameleer_saas database..." +pgpass + +# Insert tenant (idempotent via ON CONFLICT) +TENANT_UUID=$(psql -h "$PG_HOST" -U "$PG_USER" -d "$PG_DB_SAAS" -t -A -c " + INSERT INTO tenants (id, name, slug, tier, status, logto_org_id, created_at, updated_at) + VALUES (gen_random_uuid(), '$TENANT_NAME', '$TENANT_SLUG', 'LOW', 'ACTIVE', '$ORG_ID', NOW(), NOW()) + ON CONFLICT (slug) DO UPDATE SET logto_org_id = EXCLUDED.logto_org_id + RETURNING id; +") +log "Tenant ID: $TENANT_UUID" + +# Insert default environment +psql -h "$PG_HOST" -U "$PG_USER" -d "$PG_DB_SAAS" -c " + INSERT INTO environments (id, tenant_id, slug, display_name, bootstrap_token, status, created_at, updated_at) + VALUES (gen_random_uuid(), '$TENANT_UUID', 'default', 'Default', '$BOOTSTRAP_TOKEN', 'ACTIVE', NOW(), NOW()) + ON CONFLICT (tenant_id, slug) DO NOTHING; +" >/dev/null 2>&1 +log "Default environment seeded." + +# Insert license +psql -h "$PG_HOST" -U "$PG_USER" -d "$PG_DB_SAAS" -c " + INSERT INTO licenses (id, tenant_id, tier, features, limits, issued_at, expires_at, token, created_at) + SELECT gen_random_uuid(), '$TENANT_UUID', 'LOW', + '{\"topology\": true, \"lineage\": false, \"correlation\": false, \"debugger\": false, \"replay\": false}'::jsonb, + '{\"max_agents\": 3, \"retention_days\": 7, \"max_environments\": 1}'::jsonb, + NOW(), NOW() + INTERVAL '365 days', 'bootstrap-license', NOW() + WHERE NOT EXISTS (SELECT 1 FROM licenses WHERE tenant_id = '$TENANT_UUID'); +" >/dev/null 2>&1 +log "License seeded." + +# ============================================================ +# PHASE 8: Configure cameleer3-server OIDC +# ============================================================ + +SERVER_HEALTHY=$(curl -sf "${SERVER_ENDPOINT}/api/v1/health" 2>/dev/null && echo "yes" || echo "no") + +if [ "$SERVER_HEALTHY" = "yes" ] && [ -n "$TRAD_SECRET" ]; then + log "Configuring cameleer3-server OIDC..." + + # Login to server as admin + SERVER_TOKEN_RESPONSE=$(curl -s -X POST "${SERVER_ENDPOINT}/api/v1/auth/login" \ + -H "Content-Type: application/json" \ + -d "{\"username\": \"$SERVER_UI_USER\", \"password\": \"$SERVER_UI_PASS\"}") + SERVER_TOKEN=$(echo "$SERVER_TOKEN_RESPONSE" | jq -r '.accessToken' 2>/dev/null) + + if [ -n "$SERVER_TOKEN" ] && [ "$SERVER_TOKEN" != "null" ]; then + # Configure OIDC + OIDC_RESPONSE=$(curl -s -X POST "${SERVER_ENDPOINT}/api/v1/admin/oidc" \ + -H "Authorization: Bearer $SERVER_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{ + \"enabled\": true, + \"issuerUri\": \"$LOGTO_PUBLIC_ENDPOINT/oidc\", + \"clientId\": \"$TRAD_ID\", + \"clientSecret\": \"$TRAD_SECRET\", + \"autoSignup\": true, + \"defaultRoles\": [\"VIEWER\"], + \"displayNameClaim\": \"name\" + }") + log "OIDC config response: $(echo "$OIDC_RESPONSE" | head -c 200)" + log "cameleer3-server OIDC configured." + else + log "WARNING: Could not login to cameleer3-server — skipping OIDC config" + fi +else + log "WARNING: cameleer3-server not available or no Traditional app secret — skipping OIDC config" +fi + +# ============================================================ +# PHASE 9: Cleanup seeded apps +# ============================================================ + if [ -n "$M2M_SECRET" ]; then log "Cleaning up seeded apps with known secrets..." for SEEDED_ID in "m-default" "m-admin" "s6cz3wajdv8gtdyz8e941"; do @@ -201,11 +443,12 @@ if [ -n "$M2M_SECRET" ]; then log "Deleted seeded app: $SEEDED_ID" fi done -else - log "Skipping seeded app cleanup (M2M secret not verified)" fi -# --- Write bootstrap results --- +# ============================================================ +# PHASE 10: Write bootstrap results +# ============================================================ + log "Writing bootstrap config to $BOOTSTRAP_FILE..." mkdir -p "$(dirname "$BOOTSTRAP_FILE")" cat > "$BOOTSTRAP_FILE" < "$BOOTSTRAP_FILE" <> me(Authentication authentication) { + if (!(authentication instanceof JwtAuthenticationToken jwtAuth)) { + return ResponseEntity.status(401).build(); + } + + Jwt jwt = jwtAuth.getToken(); + String userId = jwt.getSubject(); + + List globalRoles = logtoClient.getUserRoles(userId); + boolean isPlatformAdmin = globalRoles.contains("platform-admin"); + + List> logtoOrgs = logtoClient.getUserOrganizations(userId); + + List> tenants = logtoOrgs.stream() + .map(org -> tenantService.getByLogtoOrgId(org.get("id")) + .map(t -> Map.of( + "id", t.getId().toString(), + "name", t.getName(), + "slug", t.getSlug(), + "logtoOrgId", t.getLogtoOrgId() + )) + .orElse(null)) + .filter(t -> t != null) + .toList(); + + return ResponseEntity.ok(Map.of( + "userId", userId, + "isPlatformAdmin", isPlatformAdmin, + "tenants", tenants + )); + } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java b/src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java index b629af2..17bf99a 100644 --- a/src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java +++ b/src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java @@ -50,7 +50,7 @@ public class SecurityConfig { .requestMatchers("/actuator/health").permitAll() .requestMatchers("/auth/verify").permitAll() .requestMatchers("/api/config").permitAll() - .requestMatchers("/", "/index.html", "/login", "/callback", "/environments/**", "/license").permitAll() + .requestMatchers("/", "/index.html", "/login", "/callback", "/environments/**", "/license", "/admin/**").permitAll() .requestMatchers("/assets/**", "/favicon.ico").permitAll() .anyRequest().authenticated() ) diff --git a/src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java b/src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java index f566be0..a4292f7 100644 --- a/src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java +++ b/src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java @@ -8,6 +8,8 @@ import org.springframework.stereotype.Service; import org.springframework.web.client.RestClient; import java.time.Instant; +import java.util.ArrayList; +import java.util.List; import java.util.Map; @Service @@ -73,6 +75,57 @@ public class LogtoManagementClient { .toBodilessEntity(); } + public List getUserRoles(String userId) { + if (!isAvailable()) return List.of(); + + try { + var response = restClient.get() + .uri(config.getLogtoEndpoint() + "/api/users/" + userId + "/roles") + .header("Authorization", "Bearer " + getAccessToken()) + .retrieve() + .body(JsonNode.class); + + List roles = new ArrayList<>(); + if (response != null && response.isArray()) { + for (var node : response) { + roles.add(node.get("name").asText()); + } + } + return roles; + } catch (Exception e) { + log.warn("Failed to get user roles for {}: {}", userId, e.getMessage()); + return List.of(); + } + } + + public List> getUserOrganizations(String userId) { + if (!isAvailable()) return List.of(); + + try { + var response = restClient.get() + .uri(config.getLogtoEndpoint() + "/api/users/" + userId + "/organizations") + .header("Authorization", "Bearer " + getAccessToken()) + .retrieve() + .body(JsonNode.class); + + List> orgs = new ArrayList<>(); + if (response != null && response.isArray()) { + for (var node : response) { + orgs.add(Map.of( + "id", node.get("id").asText(), + "name", node.get("name").asText() + )); + } + } + return orgs; + } catch (Exception e) { + log.warn("Failed to get user organizations for {}: {}", userId, e.getMessage()); + return List.of(); + } + } + + private static final String MGMT_API_RESOURCE = "https://default.logto.app/api"; + private synchronized String getAccessToken() { if (cachedToken != null && Instant.now().isBefore(tokenExpiry.minusSeconds(60))) { return cachedToken; @@ -85,7 +138,7 @@ public class LogtoManagementClient { .body("grant_type=client_credentials" + "&client_id=" + config.getM2mClientId() + "&client_secret=" + config.getM2mClientSecret() - + "&resource=" + config.getLogtoEndpoint() + "/api" + + "&resource=" + MGMT_API_RESOURCE + "&scope=all") .retrieve() .body(JsonNode.class); diff --git a/src/main/java/net/siegeln/cameleer/saas/tenant/TenantController.java b/src/main/java/net/siegeln/cameleer/saas/tenant/TenantController.java index 184b88e..90742c2 100644 --- a/src/main/java/net/siegeln/cameleer/saas/tenant/TenantController.java +++ b/src/main/java/net/siegeln/cameleer/saas/tenant/TenantController.java @@ -1,6 +1,7 @@ package net.siegeln.cameleer.saas.tenant; import jakarta.validation.Valid; +import net.siegeln.cameleer.saas.identity.LogtoManagementClient; import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest; import net.siegeln.cameleer.saas.tenant.dto.TenantResponse; import org.springframework.http.HttpStatus; @@ -13,6 +14,7 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.util.List; import java.util.UUID; @RestController @@ -20,9 +22,23 @@ import java.util.UUID; public class TenantController { private final TenantService tenantService; + private final LogtoManagementClient logtoClient; - public TenantController(TenantService tenantService) { + public TenantController(TenantService tenantService, LogtoManagementClient logtoClient) { this.tenantService = tenantService; + this.logtoClient = logtoClient; + } + + @GetMapping + public ResponseEntity> listAll(Authentication authentication) { + String userId = authentication.getName(); + List roles = logtoClient.getUserRoles(userId); + if (!roles.contains("platform-admin")) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + List tenants = tenantService.findAll().stream() + .map(this::toResponse).toList(); + return ResponseEntity.ok(tenants); } @PostMapping diff --git a/src/main/java/net/siegeln/cameleer/saas/tenant/TenantService.java b/src/main/java/net/siegeln/cameleer/saas/tenant/TenantService.java index 180d30f..030998f 100644 --- a/src/main/java/net/siegeln/cameleer/saas/tenant/TenantService.java +++ b/src/main/java/net/siegeln/cameleer/saas/tenant/TenantService.java @@ -68,6 +68,10 @@ public class TenantService { return tenantRepository.findByLogtoOrgId(logtoOrgId); } + public List findAll() { + return tenantRepository.findAll(); + } + public List listActive() { return tenantRepository.findByStatus(TenantStatus.ACTIVE); } diff --git a/ui/src/api/hooks.ts b/ui/src/api/hooks.ts index 01ad1a8..7b530ba 100644 --- a/ui/src/api/hooks.ts +++ b/ui/src/api/hooks.ts @@ -3,7 +3,7 @@ import { api } from './client'; import type { TenantResponse, EnvironmentResponse, AppResponse, DeploymentResponse, LicenseResponse, AgentStatusResponse, - ObservabilityStatusResponse, LogEntry, + ObservabilityStatusResponse, LogEntry, MeResponse, } from '../types/api'; // Tenant @@ -183,3 +183,19 @@ export function useLogs(appId: string, params?: { since?: string; limit?: number enabled: !!appId, }); } + +// Platform +export function useMe() { + return useQuery({ + queryKey: ['me'], + queryFn: () => api.get('/me'), + staleTime: 60_000, + }); +} + +export function useAllTenants() { + return useQuery({ + queryKey: ['tenants', 'all'], + queryFn: () => api.get('/tenants'), + }); +} diff --git a/ui/src/auth/OrgResolver.tsx b/ui/src/auth/OrgResolver.tsx new file mode 100644 index 0000000..5a4d309 --- /dev/null +++ b/ui/src/auth/OrgResolver.tsx @@ -0,0 +1,47 @@ +import { useEffect } from 'react'; +import { Spinner } from '@cameleer/design-system'; +import { useMe } from '../api/hooks'; +import { useOrgStore } from './useOrganization'; + +/** + * Fetches /api/me and populates the org store with platform admin status + * and tenant-to-org mapping. Renders children once resolved. + */ +export function OrgResolver({ children }: { children: React.ReactNode }) { + const { data: me, isLoading, isError } = useMe(); + const { setIsPlatformAdmin, setOrganizations, setCurrentOrg, currentOrgId } = useOrgStore(); + + useEffect(() => { + if (!me) return; + + setIsPlatformAdmin(me.isPlatformAdmin); + + // Map tenants: logtoOrgId is the org ID for token scoping, id is the DB UUID + const orgEntries = me.tenants.map((t) => ({ + id: t.logtoOrgId, + name: t.name, + slug: t.slug, + tenantId: t.id, + })); + setOrganizations(orgEntries); + + // Auto-select if single tenant and no org selected yet + if (orgEntries.length === 1 && !currentOrgId) { + setCurrentOrg(orgEntries[0].id); + } + }, [me]); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (isError) { + return null; + } + + return <>{children}; +} diff --git a/ui/src/auth/useAuth.ts b/ui/src/auth/useAuth.ts index 2c1ca3d..3617b67 100644 --- a/ui/src/auth/useAuth.ts +++ b/ui/src/auth/useAuth.ts @@ -1,5 +1,6 @@ import { useLogto } from '@logto/react'; import { useState, useEffect, useCallback } from 'react'; +import { useOrgStore } from './useOrganization'; interface IdTokenClaims { sub?: string; @@ -14,6 +15,7 @@ interface IdTokenClaims { export function useAuth() { const { isAuthenticated, isLoading, getIdTokenClaims, signOut, signIn } = useLogto(); const [claims, setClaims] = useState(null); + const { currentTenantId, isPlatformAdmin } = useOrgStore(); useEffect(() => { if (isAuthenticated) { @@ -25,7 +27,8 @@ export function useAuth() { const username = claims?.username ?? claims?.name ?? claims?.email ?? claims?.sub ?? null; const roles = (claims?.roles as string[]) ?? []; - const tenantId = (claims?.organization_id as string) ?? null; + // tenantId is the DB UUID from the org store (set by OrgResolver after /api/me) + const tenantId = currentTenantId; const logout = useCallback(() => { signOut(window.location.origin + '/login'); @@ -37,6 +40,7 @@ export function useAuth() { username, roles, tenantId, + isPlatformAdmin, logout, signIn, }; diff --git a/ui/src/auth/useOrganization.ts b/ui/src/auth/useOrganization.ts new file mode 100644 index 0000000..2b8e50f --- /dev/null +++ b/ui/src/auth/useOrganization.ts @@ -0,0 +1,31 @@ +import { create } from 'zustand'; + +export interface OrgInfo { + id: string; // Logto org ID (for token scoping) + name: string; + slug?: string; + tenantId?: string; // DB tenant UUID (for API calls) +} + +interface OrgState { + currentOrgId: string | null; // Logto org ID — used for getAccessToken(resource, orgId) + currentTenantId: string | null; // DB UUID — used for API calls like /api/tenants/{id} + organizations: OrgInfo[]; + isPlatformAdmin: boolean; + setCurrentOrg: (orgId: string | null) => void; + setOrganizations: (orgs: OrgInfo[]) => void; + setIsPlatformAdmin: (value: boolean) => void; +} + +export const useOrgStore = create((set, get) => ({ + currentOrgId: null, + currentTenantId: null, + organizations: [], + isPlatformAdmin: false, + setCurrentOrg: (orgId) => { + const org = get().organizations.find((o) => o.id === orgId); + set({ currentOrgId: orgId, currentTenantId: org?.tenantId ?? null }); + }, + setOrganizations: (orgs) => set({ organizations: orgs }), + setIsPlatformAdmin: (value) => set({ isPlatformAdmin: value }), +})); diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 032e1de..9bb13c1 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -89,9 +89,18 @@ function UserIcon() { ); } +function PlatformIcon() { + return ( + + ); +} + export function Layout() { const navigate = useNavigate(); - const { username, logout } = useAuth(); + const { username, logout, isPlatformAdmin } = useAuth(); const [envSectionOpen, setEnvSectionOpen] = useState(true); const [collapsed, setCollapsed] = useState(false); @@ -134,12 +143,24 @@ export function Layout() { {null} + {/* Platform Admin section */} + {isPlatformAdmin && ( + } + label="Platform" + open={false} + onToggle={() => navigate('/admin/tenants')} + > + {null} + + )} + - {/* Link to the observability SPA (external) */} + {/* Link to the observability SPA (direct port, not via Traefik prefix) */} } label="View Dashboard" - onClick={() => window.open('/dashboard', '_blank', 'noopener')} + onClick={() => window.open('http://localhost:8081', '_blank', 'noopener')} /> {/* User info + logout */} diff --git a/ui/src/main.tsx b/ui/src/main.tsx index cc5d4c3..2a17a35 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useCallback } from 'react'; import ReactDOM from 'react-dom/client'; -import { LogtoProvider, useLogto } from '@logto/react'; +import { LogtoProvider, UserScope, useLogto } from '@logto/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { BrowserRouter } from 'react-router'; import { ThemeProvider, ToastProvider, BreadcrumbProvider, GlobalFilterProvider, CommandPaletteProvider, Spinner } from '@cameleer/design-system'; @@ -8,6 +8,7 @@ import '@cameleer/design-system/style.css'; import { AppRouter } from './router'; import { fetchConfig } from './config'; import { setTokenProvider, setLogoutHandler } from './api/client'; +import { useOrgStore } from './auth/useOrganization'; const queryClient = new QueryClient({ defaultOptions: { @@ -19,15 +20,44 @@ const queryClient = new QueryClient({ }); function TokenSync({ resource }: { resource: string }) { - const { getAccessToken, isAuthenticated, signOut } = useLogto(); + const { getAccessToken, isAuthenticated, signOut, fetchUserInfo } = useLogto(); + const { currentOrgId, setCurrentOrg, setOrganizations } = useOrgStore(); + // After auth, resolve user's organizations from Logto + useEffect(() => { + if (!isAuthenticated) { + setOrganizations([]); + setCurrentOrg(null); + return; + } + + fetchUserInfo().then((info) => { + const orgData = (info as Record)?.organization_data as + Array<{ id: string; name: string }> | undefined; + const orgs = orgData ?? []; + setOrganizations(orgs.map((o) => ({ id: o.id, name: o.name }))); + + // Auto-select if user has exactly one org + if (orgs.length === 1 && !currentOrgId) { + setCurrentOrg(orgs[0].id); + } + }).catch(() => { + // fetchUserInfo may fail if token is being refreshed + }); + }, [isAuthenticated]); + + // Set token provider — org-scoped if org selected, plain otherwise useEffect(() => { if (isAuthenticated && resource) { - setTokenProvider(() => getAccessToken(resource)); + if (currentOrgId) { + setTokenProvider(() => getAccessToken(resource, currentOrgId)); + } else { + setTokenProvider(() => getAccessToken(resource)); + } } else { setTokenProvider(null); } - }, [isAuthenticated, getAccessToken, resource]); + }, [isAuthenticated, getAccessToken, resource, currentOrgId]); const handleLogout = useCallback(() => { signOut(window.location.origin + '/login'); @@ -66,7 +96,11 @@ function App() { endpoint: config.logtoEndpoint, appId: config.logtoClientId, resources: config.logtoResource ? [config.logtoResource] : [], - scopes: ['openid', 'profile', 'email', 'offline_access'], + scopes: [ + 'openid', 'profile', 'email', 'offline_access', + UserScope.Organizations, + UserScope.OrganizationRoles, + ], }} > diff --git a/ui/src/pages/AdminTenantsPage.tsx b/ui/src/pages/AdminTenantsPage.tsx new file mode 100644 index 0000000..09360ed --- /dev/null +++ b/ui/src/pages/AdminTenantsPage.tsx @@ -0,0 +1,75 @@ +import { useNavigate } from 'react-router'; +import { + Badge, + Card, + DataTable, + Spinner, +} from '@cameleer/design-system'; +import type { Column } from '@cameleer/design-system'; +import { useAllTenants } from '../api/hooks'; +import { useOrgStore } from '../auth/useOrganization'; +import type { TenantResponse } from '../types/api'; + +const columns: Column[] = [ + { key: 'name', header: 'Name' }, + { key: 'slug', header: 'Slug' }, + { + key: 'tier', + header: 'Tier', + render: (_v: unknown, row: TenantResponse) => , + }, + { + key: 'status', + header: 'Status', + render: (_v: unknown, row: TenantResponse) => ( + + ), + }, + { key: 'createdAt', header: 'Created' }, +]; + +export function AdminTenantsPage() { + const navigate = useNavigate(); + const { data: tenants, isLoading } = useAllTenants(); + const { setCurrentOrg } = useOrgStore(); + + if (isLoading) { + return ( +
+ +
+ ); + } + + const handleRowClick = (tenant: TenantResponse) => { + // Find the matching org from the store and switch context + const orgs = useOrgStore.getState().organizations; + const match = orgs.find( + (o) => o.name === tenant.name || o.slug === tenant.slug, + ); + if (match) { + setCurrentOrg(match.id); + navigate('/'); + } + }; + + return ( +
+
+

All Tenants

+ +
+ + + + +
+ ); +} diff --git a/ui/src/router.tsx b/ui/src/router.tsx index 6efc03c..6d80437 100644 --- a/ui/src/router.tsx +++ b/ui/src/router.tsx @@ -2,12 +2,14 @@ import { Routes, Route } from 'react-router'; import { LoginPage } from './auth/LoginPage'; import { CallbackPage } from './auth/CallbackPage'; import { ProtectedRoute } from './auth/ProtectedRoute'; +import { OrgResolver } from './auth/OrgResolver'; import { Layout } from './components/Layout'; import { DashboardPage } from './pages/DashboardPage'; import { EnvironmentsPage } from './pages/EnvironmentsPage'; import { EnvironmentDetailPage } from './pages/EnvironmentDetailPage'; import { AppDetailPage } from './pages/AppDetailPage'; import { LicensePage } from './pages/LicensePage'; +import { AdminTenantsPage } from './pages/AdminTenantsPage'; export function AppRouter() { return ( @@ -17,7 +19,9 @@ export function AppRouter() { - + + + } > @@ -26,6 +30,7 @@ export function AppRouter() { } /> } /> } /> + } /> ); diff --git a/ui/src/types/api.ts b/ui/src/types/api.ts index a009f53..fe17a92 100644 --- a/ui/src/types/api.ts +++ b/ui/src/types/api.ts @@ -83,3 +83,14 @@ export interface LogEntry { stream: string; message: string; } + +export interface MeResponse { + userId: string; + isPlatformAdmin: boolean; + tenants: Array<{ + id: string; + name: string; + slug: string; + logtoOrgId: string; + }>; +}