# Platform Redesign Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Redesign the cameleer-saas platform from a read-only viewer into a vendor management plane that provisions per-tenant cameleer-server instances, with vendor CRUD and customer self-service. **Architecture:** Two-persona split (vendor console at `/vendor/*`, tenant portal at `/tenant/*`). Pluggable `TenantProvisioner` interface with Docker implementation. Backend orchestrates provisioning + Logto + licensing in a single create-tenant flow. Frontend adapts sidebar/routes by persona. **Tech Stack:** Spring Boot 3.4 (Java 21), docker-java, React 19, React Router 7, TanStack Query 5, Zustand 5, @cameleer/design-system 0.1.38, Logto 4, lucide-react **Spec:** `docs/superpowers/specs/2026-04-09-platform-redesign.md` --- ## File Structure ### New Backend Files | File | Responsibility | |------|---------------| | `src/.../provisioning/TenantProvisioner.java` | Pluggable provisioning interface | | `src/.../provisioning/TenantProvisionRequest.java` | Immutable request record | | `src/.../provisioning/ProvisionResult.java` | Provisioning outcome record | | `src/.../provisioning/ServerStatus.java` | Server health record | | `src/.../provisioning/DockerTenantProvisioner.java` | Docker API implementation | | `src/.../provisioning/DisabledTenantProvisioner.java` | No-op fallback | | `src/.../provisioning/TenantProvisionerAutoConfig.java` | Auto-detection bean config | | `src/.../provisioning/ProvisioningProperties.java` | Externalized config (image, networks, env template) | | `src/.../vendor/VendorTenantService.java` | Vendor business logic: create/provision/suspend/delete | | `src/.../vendor/VendorTenantController.java` | Vendor REST endpoints | | `src/.../portal/TenantPortalService.java` | Customer business logic: dashboard/OIDC/team | | `src/.../portal/TenantPortalController.java` | Customer REST endpoints | | `src/main/resources/db/migration/V011__add_provisioning_fields.sql` | DB schema update | ### New Frontend Files | File | Responsibility | |------|---------------| | `ui/src/pages/vendor/VendorTenantsPage.tsx` | Fleet overview with health | | `ui/src/pages/vendor/CreateTenantPage.tsx` | Tenant creation wizard | | `ui/src/pages/vendor/TenantDetailPage.tsx` | Tenant detail + actions | | `ui/src/pages/tenant/TenantDashboardPage.tsx` | Customer dashboard | | `ui/src/pages/tenant/TenantLicensePage.tsx` | License with usage data | | `ui/src/pages/tenant/OidcConfigPage.tsx` | External OIDC config | | `ui/src/pages/tenant/TeamPage.tsx` | Team member management | | `ui/src/pages/tenant/SettingsPage.tsx` | Org settings (read-only) | | `ui/src/components/ServerStatusBadge.tsx` | Health indicator dot | | `ui/src/components/UsageIndicator.tsx` | Usage vs limit bar | | `ui/src/api/vendor-hooks.ts` | React Query hooks for vendor API | | `ui/src/api/tenant-hooks.ts` | React Query hooks for tenant API | ### Modified Files | File | Changes | |------|---------| | `pom.xml` | Add docker-java dependency | | `src/.../tenant/TenantEntity.java` | Add serverEndpoint, provisionError fields | | `src/.../identity/ServerApiClient.java` | Per-tenant endpoints, health/usage/OIDC methods | | `src/.../identity/LogtoManagementClient.java` | Member listing, invite, remove, role change | | `src/.../license/LicenseService.java` | Add revokeLicense() method | | `src/.../config/SecurityConfig.java` | Vendor/portal endpoint security rules | | `src/.../config/TenantIsolationInterceptor.java` | Handle `/api/tenant/*` path (no path variable) | | `src/.../config/SpaController.java` | Forward new SPA routes | | `src/main/resources/application.yml` | Add provisioning config block | | `ui/src/router.tsx` | `/vendor/*` + `/tenant/*` route split | | `ui/src/components/Layout.tsx` | Persona-aware sidebar | | `ui/src/auth/OrgResolver.tsx` | Vendor redirect logic | | `ui/src/types/api.ts` | New API response types | | `ui/src/main.tsx` | Remove server-specific providers | | `docker-compose.yml` | Docker socket mount | | `docker-compose.dev.yml` | Docker socket + group_add | ### Files to Remove | File | Reason | |------|--------| | `ui/src/pages/DashboardPage.tsx` | Replaced by TenantDashboardPage | | `ui/src/pages/LicensePage.tsx` | Replaced by TenantLicensePage | | `ui/src/pages/AdminTenantsPage.tsx` | Replaced by VendorTenantsPage | --- ## Task 1: Database Migration + Entity Updates **Files:** - Create: `src/main/resources/db/migration/V011__add_provisioning_fields.sql` - Modify: `src/main/java/net/siegeln/cameleer/saas/tenant/TenantEntity.java` - [ ] **Step 1: Create migration V011** ```sql -- V011__add_provisioning_fields.sql ALTER TABLE tenants ADD COLUMN server_endpoint VARCHAR(512); ALTER TABLE tenants ADD COLUMN provision_error TEXT; ``` - [ ] **Step 2: Update TenantEntity** Add these fields after the `settings` field (around line 55 in TenantEntity.java): ```java @Column(name = "server_endpoint", length = 512) private String serverEndpoint; @Column(name = "provision_error", columnDefinition = "TEXT") private String provisionError; ``` Add getters and setters: ```java public String getServerEndpoint() { return serverEndpoint; } public void setServerEndpoint(String serverEndpoint) { this.serverEndpoint = serverEndpoint; } public String getProvisionError() { return provisionError; } public void setProvisionError(String provisionError) { this.provisionError = provisionError; } ``` - [ ] **Step 3: Update TenantResponse record** Modify `src/.../tenant/TenantResponse.java` to include the new fields: ```java package net.siegeln.cameleer.saas.tenant; import java.time.Instant; import java.util.UUID; public record TenantResponse( UUID id, String name, String slug, String tier, String status, String serverEndpoint, String provisionError, Instant createdAt, Instant updatedAt ) { public static TenantResponse from(TenantEntity e) { return new TenantResponse( e.getId(), e.getName(), e.getSlug(), e.getTier().name(), e.getStatus().name(), e.getServerEndpoint(), e.getProvisionError(), e.getCreatedAt(), e.getUpdatedAt() ); } } ``` - [ ] **Step 4: Add revokeLicense to LicenseService** Add to `src/.../license/LicenseService.java`: ```java public void revokeLicense(UUID tenantId, UUID actorId) { licenseRepository.findFirstByTenantIdAndRevokedAtIsNullOrderByCreatedAtDesc(tenantId) .ifPresent(license -> { license.setRevokedAt(Instant.now()); licenseRepository.save(license); auditService.log(AuditAction.LICENSE_REVOKE, actorId, null, tenantId, "license", null, null, null, Map.of("licenseId", license.getId().toString())); }); } ``` Add the `LICENSE_REVOKE` constant to `AuditAction.java` if not present. - [ ] **Step 5: Verify compilation** ```bash cd /c/Users/Hendrik/Documents/projects/cameleer-saas mvn compile -q ``` Expected: BUILD SUCCESS - [ ] **Step 6: Commit** ```bash git add src/main/resources/db/migration/V011__add_provisioning_fields.sql \ src/main/java/net/siegeln/cameleer/saas/tenant/TenantEntity.java \ src/main/java/net/siegeln/cameleer/saas/tenant/TenantResponse.java \ src/main/java/net/siegeln/cameleer/saas/license/LicenseService.java \ src/main/java/net/siegeln/cameleer/saas/audit/AuditAction.java git commit -m "feat: add provisioning fields to tenants + license revoke" ``` --- ## Task 2: Provisioning Interface + Auto-Config **Files:** - Modify: `pom.xml` - Create: `src/.../provisioning/TenantProvisioner.java` - Create: `src/.../provisioning/TenantProvisionRequest.java` - Create: `src/.../provisioning/ProvisionResult.java` - Create: `src/.../provisioning/ServerStatus.java` - Create: `src/.../provisioning/DisabledTenantProvisioner.java` - Create: `src/.../provisioning/TenantProvisionerAutoConfig.java` - Create: `src/.../provisioning/ProvisioningProperties.java` - Modify: `src/main/resources/application.yml` All paths below are relative to `src/main/java/net/siegeln/cameleer/saas/provisioning/`. - [ ] **Step 1: Add docker-java dependency to pom.xml** Add inside ``: ```xml com.github.docker-java docker-java-core 3.4.1 com.github.docker-java docker-java-transport-zerodep 3.4.1 ``` - [ ] **Step 2: Create TenantProvisionRequest record** ```java package net.siegeln.cameleer.saas.provisioning; import java.util.UUID; public record TenantProvisionRequest( UUID tenantId, String slug, String tier, String licenseToken ) {} ``` - [ ] **Step 3: Create ProvisionResult record** ```java package net.siegeln.cameleer.saas.provisioning; public record ProvisionResult( boolean success, String serverEndpoint, String error ) { public static ProvisionResult ok(String endpoint) { return new ProvisionResult(true, endpoint, null); } public static ProvisionResult fail(String error) { return new ProvisionResult(false, null, error); } } ``` - [ ] **Step 4: Create ServerStatus record** ```java package net.siegeln.cameleer.saas.provisioning; public record ServerStatus( State state, String containerId, String error ) { public enum State { RUNNING, STOPPED, NOT_FOUND, ERROR } public static ServerStatus running(String containerId) { return new ServerStatus(State.RUNNING, containerId, null); } public static ServerStatus stopped(String containerId) { return new ServerStatus(State.STOPPED, containerId, null); } public static ServerStatus notFound() { return new ServerStatus(State.NOT_FOUND, null, null); } public static ServerStatus error(String error) { return new ServerStatus(State.ERROR, null, error); } } ``` - [ ] **Step 5: Create TenantProvisioner interface** ```java package net.siegeln.cameleer.saas.provisioning; public interface TenantProvisioner { boolean isAvailable(); ProvisionResult provision(TenantProvisionRequest request); void start(String slug); void stop(String slug); void remove(String slug); ServerStatus getStatus(String slug); String getServerEndpoint(String slug); } ``` - [ ] **Step 6: Create DisabledTenantProvisioner** ```java package net.siegeln.cameleer.saas.provisioning; import org.slf4j.Logger; import org.slf4j.LoggerFactory; public class DisabledTenantProvisioner implements TenantProvisioner { private static final Logger log = LoggerFactory.getLogger(DisabledTenantProvisioner.class); @Override public boolean isAvailable() { return false; } @Override public ProvisionResult provision(TenantProvisionRequest request) { log.warn("Provisioning disabled — no Docker socket or K8s detected"); return ProvisionResult.fail("Provisioning not available"); } @Override public void start(String slug) { log.warn("Cannot start: provisioning disabled"); } @Override public void stop(String slug) { log.warn("Cannot stop: provisioning disabled"); } @Override public void remove(String slug) { log.warn("Cannot remove: provisioning disabled"); } @Override public ServerStatus getStatus(String slug) { return ServerStatus.notFound(); } @Override public String getServerEndpoint(String slug) { return null; } } ``` - [ ] **Step 7: Create ProvisioningProperties** ```java package net.siegeln.cameleer.saas.provisioning; import org.springframework.boot.context.properties.ConfigurationProperties; @ConfigurationProperties(prefix = "cameleer.provisioning") public record ProvisioningProperties( String serverImage, String serverUiImage, String networkName, String traefikNetwork, String publicHost, String publicProtocol, String datasourceUrl, String oidcIssuerUri, String oidcJwkSetUri, String corsOrigins ) {} ``` - [ ] **Step 8: Create TenantProvisionerAutoConfig** ```java package net.siegeln.cameleer.saas.provisioning; import com.github.dockerjava.core.DefaultDockerClientConfig; import com.github.dockerjava.core.DockerClientConfig; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.nio.file.Files; import java.nio.file.Path; @Configuration @EnableConfigurationProperties(ProvisioningProperties.class) public class TenantProvisionerAutoConfig { private static final Logger log = LoggerFactory.getLogger(TenantProvisionerAutoConfig.class); @Bean TenantProvisioner tenantProvisioner(ProvisioningProperties props) { if (Files.exists(Path.of("/var/run/docker.sock"))) { log.info("Docker socket detected — enabling Docker tenant provisioner"); DockerClientConfig config = DefaultDockerClientConfig.createDefaultConfigBuilder() .withDockerHost("unix:///var/run/docker.sock") .build(); return new DockerTenantProvisioner(config, props); } // Future: if (Files.exists(Path.of("/var/run/secrets/kubernetes.io/serviceaccount/token"))) log.info("No Docker socket — tenant provisioning disabled"); return new DisabledTenantProvisioner(); } } ``` - [ ] **Step 9: Add provisioning config to application.yml** Append to `application.yml`: ```yaml cameleer: provisioning: server-image: ${CAMELEER_SERVER_IMAGE:gitea.siegeln.net/cameleer/cameleer-server:latest} server-ui-image: ${CAMELEER_SERVER_UI_IMAGE:gitea.siegeln.net/cameleer/cameleer-server-ui:latest} network-name: ${CAMELEER_NETWORK:cameleer-saas_cameleer} traefik-network: ${CAMELEER_TRAEFIK_NETWORK:cameleer-traefik} public-host: ${PUBLIC_HOST:localhost} public-protocol: ${PUBLIC_PROTOCOL:https} datasource-url: ${CAMELEER_SERVER_DB_URL:jdbc:postgresql://postgres:5432/cameleer} oidc-issuer-uri: ${PUBLIC_PROTOCOL:https}://${PUBLIC_HOST:localhost}/oidc oidc-jwk-set-uri: http://logto:3001/oidc/jwks cors-origins: ${PUBLIC_PROTOCOL:https}://${PUBLIC_HOST:localhost} ``` - [ ] **Step 10: Verify compilation** ```bash mvn compile -q ``` Expected: BUILD SUCCESS - [ ] **Step 11: Commit** ```bash git add pom.xml src/main/java/net/siegeln/cameleer/saas/provisioning/ src/main/resources/application.yml git commit -m "feat: add TenantProvisioner interface with auto-detection" ``` --- ## Task 3: DockerTenantProvisioner **Files:** - Create: `src/.../provisioning/DockerTenantProvisioner.java` - [ ] **Step 1: Implement DockerTenantProvisioner** ```java package net.siegeln.cameleer.saas.provisioning; import com.github.dockerjava.api.DockerClient; import com.github.dockerjava.api.command.CreateContainerResponse; import com.github.dockerjava.api.command.InspectContainerResponse; import com.github.dockerjava.api.exception.NotFoundException; import com.github.dockerjava.api.model.*; import com.github.dockerjava.core.DockerClientConfig; import com.github.dockerjava.core.DockerClientImpl; import com.github.dockerjava.transport.DockerHttpClient; import com.github.dockerjava.zerodep.ZerodepDockerHttpClient; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.time.Duration; import java.util.List; import java.util.Map; public class DockerTenantProvisioner implements TenantProvisioner { private static final Logger log = LoggerFactory.getLogger(DockerTenantProvisioner.class); private final DockerClient docker; private final ProvisioningProperties props; public DockerTenantProvisioner(DockerClientConfig config, ProvisioningProperties props) { this.props = props; DockerHttpClient httpClient = new ZerodepDockerHttpClient.Builder() .dockerHost(config.getDockerHost()) .maxConnections(10) .connectionTimeout(Duration.ofSeconds(5)) .responseTimeout(Duration.ofSeconds(30)) .build(); this.docker = DockerClientImpl.getInstance(config, httpClient); } @Override public boolean isAvailable() { return true; } @Override public ProvisionResult provision(TenantProvisionRequest req) { String serverName = serverContainerName(req.slug()); String uiName = uiContainerName(req.slug()); String endpoint = "http://" + serverName + ":8081"; try { // Pull images if not present pullIfMissing(props.serverImage()); pullIfMissing(props.serverUiImage()); // Create server container createServerContainer(req, serverName); docker.startContainerCmd(serverName).exec(); // Create UI container createUiContainer(req.slug(), uiName, serverName); docker.startContainerCmd(uiName).exec(); // Wait for health if (!waitForHealth(serverName, 60)) { return ProvisionResult.fail("Server did not become healthy within 60s"); } log.info("Provisioned tenant '{}': server={}, ui={}", req.slug(), serverName, uiName); return ProvisionResult.ok(endpoint); } catch (Exception e) { log.error("Failed to provision tenant '{}'", req.slug(), e); return ProvisionResult.fail(e.getMessage()); } } @Override public void start(String slug) { try { docker.startContainerCmd(serverContainerName(slug)).exec(); docker.startContainerCmd(uiContainerName(slug)).exec(); } catch (Exception e) { log.error("Failed to start containers for '{}'", slug, e); throw new RuntimeException("Start failed: " + e.getMessage(), e); } } @Override public void stop(String slug) { try { stopIfRunning(serverContainerName(slug)); stopIfRunning(uiContainerName(slug)); } catch (Exception e) { log.error("Failed to stop containers for '{}'", slug, e); throw new RuntimeException("Stop failed: " + e.getMessage(), e); } } @Override public void remove(String slug) { removeContainer(uiContainerName(slug)); removeContainer(serverContainerName(slug)); } @Override public ServerStatus getStatus(String slug) { try { InspectContainerResponse info = docker.inspectContainerCmd(serverContainerName(slug)).exec(); String state = info.getState().getStatus(); String id = info.getId(); if ("running".equals(state)) return ServerStatus.running(id); return ServerStatus.stopped(id); } catch (NotFoundException e) { return ServerStatus.notFound(); } catch (Exception e) { return ServerStatus.error(e.getMessage()); } } @Override public String getServerEndpoint(String slug) { return "http://" + serverContainerName(slug) + ":8081"; } // --- private helpers --- private void createServerContainer(TenantProvisionRequest req, String name) { String slug = req.slug(); Map labels = Map.of( "traefik.enable", "true", "traefik.http.routers.server-" + slug + ".rule", "PathPrefix(`/t/" + slug + "/api`) || PathPrefix(`/t/" + slug + "/actuator`)", "traefik.http.routers.server-" + slug + ".tls", "true", "traefik.http.services.server-" + slug + ".loadbalancer.server.port", "8081", "cameleer.tenant", slug, "cameleer.role", "server" ); List env = List.of( "SPRING_DATASOURCE_URL=" + props.datasourceUrl(), "SPRING_DATASOURCE_USERNAME=cameleer", "SPRING_DATASOURCE_PASSWORD=cameleer_dev", "CAMELEER_TENANT_ID=" + slug, "CAMELEER_OIDC_ISSUER_URI=" + props.oidcIssuerUri(), "CAMELEER_OIDC_JWK_SET_URI=" + props.oidcJwkSetUri(), "CAMELEER_OIDC_TLS_SKIP_VERIFY=true", "CAMELEER_CORS_ALLOWED_ORIGINS=" + props.corsOrigins(), "CAMELEER_LICENSE_TOKEN=" + req.licenseToken(), "CAMELEER_RUNTIME_ENABLED=true", "CAMELEER_SERVER_URL=http://" + name + ":8081", "CAMELEER_ROUTING_DOMAIN=" + props.publicHost(), "CAMELEER_ROUTING_MODE=path" ); HostConfig hostConfig = HostConfig.newHostConfig() .withRestartPolicy(RestartPolicy.unlessStoppedRestart()) .withNetworkMode(props.networkName()); CreateContainerResponse resp = docker.createContainerCmd(props.serverImage()) .withName(name) .withLabels(labels) .withEnv(env) .withHostConfig(hostConfig) .withHealthcheck(new HealthCheck() .withTest(List.of("CMD-SHELL", "wget -q -O- http://localhost:8081/actuator/health || exit 1")) .withInterval(5_000_000_000L) .withTimeout(3_000_000_000L) .withRetries(12)) .exec(); // Connect to traefik network docker.connectToNetworkCmd() .withNetworkId(props.traefikNetwork()) .withContainerId(resp.getId()) .withContainerNetwork(new ContainerNetwork().withAliases(List.of(name))) .exec(); } private void createUiContainer(String slug, String uiName, String serverName) { Map labels = Map.of( "traefik.enable", "true", "traefik.http.routers.ui-" + slug + ".rule", "PathPrefix(`/t/" + slug + "`)", "traefik.http.routers.ui-" + slug + ".tls", "true", "traefik.http.routers.ui-" + slug + ".priority", "1", "traefik.http.services.ui-" + slug + ".loadbalancer.server.port", "80", "traefik.http.middlewares.ui-strip-" + slug + ".stripprefix.prefixes", "/t/" + slug, "traefik.http.routers.ui-" + slug + ".middlewares", "ui-strip-" + slug, "cameleer.tenant", slug, "cameleer.role", "server-ui" ); List env = List.of( "BASE_PATH=/t/" + slug, "API_URL=http://" + serverName + ":8081" ); HostConfig hostConfig = HostConfig.newHostConfig() .withRestartPolicy(RestartPolicy.unlessStoppedRestart()) .withNetworkMode(props.networkName()); CreateContainerResponse resp = docker.createContainerCmd(props.serverUiImage()) .withName(uiName) .withLabels(labels) .withEnv(env) .withHostConfig(hostConfig) .exec(); docker.connectToNetworkCmd() .withNetworkId(props.traefikNetwork()) .withContainerId(resp.getId()) .exec(); } private boolean waitForHealth(String containerName, int timeoutSeconds) { long deadline = System.currentTimeMillis() + timeoutSeconds * 1000L; while (System.currentTimeMillis() < deadline) { try { InspectContainerResponse info = docker.inspectContainerCmd(containerName).exec(); InspectContainerResponse.ContainerState state = info.getState(); if (state.getHealth() != null && "healthy".equals(state.getHealth().getStatus())) { return true; } Thread.sleep(2000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; } catch (Exception e) { log.debug("Health check poll for '{}': {}", containerName, e.getMessage()); try { Thread.sleep(2000); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); return false; } } } return false; } private void pullIfMissing(String image) { try { docker.inspectImageCmd(image).exec(); } catch (NotFoundException e) { log.info("Pulling image: {}", image); try { docker.pullImageCmd(image).start().awaitCompletion(); } catch (Exception ex) { log.warn("Failed to pull {}: {}", image, ex.getMessage()); } } } private void stopIfRunning(String name) { try { docker.stopContainerCmd(name).withTimeout(30).exec(); } catch (NotFoundException ignored) {} } private void removeContainer(String name) { try { docker.removeContainerCmd(name).withForce(true).exec(); } catch (NotFoundException ignored) {} } private String serverContainerName(String slug) { return "cameleer-server-" + slug; } private String uiContainerName(String slug) { return "cameleer-server-ui-" + slug; } } ``` - [ ] **Step 2: Verify compilation** ```bash mvn compile -q ``` - [ ] **Step 3: Commit** ```bash git add src/main/java/net/siegeln/cameleer/saas/provisioning/DockerTenantProvisioner.java git commit -m "feat: implement DockerTenantProvisioner with container lifecycle" ``` --- ## Task 4: ServerApiClient Enhancements **Files:** - Modify: `src/.../identity/ServerApiClient.java` - [ ] **Step 1: Add per-tenant health, usage, and OIDC methods** Add these methods to `ServerApiClient.java` (after the existing methods): ```java /** Health check for a specific tenant's server. */ public ServerHealthResponse getHealth(String serverEndpoint) { try { String url = serverEndpoint + "/actuator/health"; var resp = RestClient.create().get().uri(url) .header("Authorization", "Bearer " + getAccessToken()) .header("X-Cameleer-Protocol-Version", "1") .retrieve() .body(Map.class); String status = resp != null ? String.valueOf(resp.get("status")) : "UNKNOWN"; return new ServerHealthResponse("UP".equals(status), status); } catch (Exception e) { log.warn("Health check failed for {}: {}", serverEndpoint, e.getMessage()); return new ServerHealthResponse(false, "DOWN"); } } /** Push OIDC configuration to a tenant's server. */ public void pushOidcConfig(String serverEndpoint, Map oidcConfig) { try { RestClient.create().put() .uri(serverEndpoint + "/api/admin/oidc") .header("Authorization", "Bearer " + getAccessToken()) .header("X-Cameleer-Protocol-Version", "1") .header("Content-Type", "application/json") .body(oidcConfig) .retrieve() .toBodilessEntity(); log.info("Pushed OIDC config to {}", serverEndpoint); } catch (Exception e) { log.error("Failed to push OIDC config to {}: {}", serverEndpoint, e.getMessage()); throw new RuntimeException("OIDC config push failed: " + e.getMessage(), e); } } /** Get OIDC configuration from a tenant's server. */ @SuppressWarnings("unchecked") public Map getOidcConfig(String serverEndpoint) { try { return RestClient.create().get() .uri(serverEndpoint + "/api/admin/oidc") .header("Authorization", "Bearer " + getAccessToken()) .header("X-Cameleer-Protocol-Version", "1") .retrieve() .body(Map.class); } catch (Exception e) { log.warn("Failed to get OIDC config from {}: {}", serverEndpoint, e.getMessage()); return Map.of(); } } public record ServerHealthResponse(boolean healthy, String status) {} ``` - [ ] **Step 2: Verify compilation** ```bash mvn compile -q ``` - [ ] **Step 3: Commit** ```bash git add src/main/java/net/siegeln/cameleer/saas/identity/ServerApiClient.java git commit -m "feat: add per-tenant health, OIDC methods to ServerApiClient" ``` --- ## Task 5: LogtoManagementClient Enhancements **Files:** - Modify: `src/.../identity/LogtoManagementClient.java` - [ ] **Step 1: Add member management methods** Add these methods to `LogtoManagementClient.java`: ```java /** List members of a Logto organization. */ @SuppressWarnings("unchecked") public List> listOrganizationMembers(String orgId) { if (!isAvailable()) return List.of(); try { String token = getAccessToken(); var resp = RestClient.create().get() .uri(logtoEndpoint + "/api/organizations/" + orgId + "/users") .header("Authorization", "Bearer " + token) .retrieve() .body(List.class); return resp != null ? resp : List.of(); } catch (Exception e) { log.warn("Failed to list org members for {}: {}", orgId, e.getMessage()); return List.of(); } } /** Get roles assigned to a user within an organization. */ @SuppressWarnings("unchecked") public List> getUserOrganizationRoles(String orgId, String userId) { if (!isAvailable()) return List.of(); try { String token = getAccessToken(); var resp = RestClient.create().get() .uri(logtoEndpoint + "/api/organizations/" + orgId + "/users/" + userId + "/roles") .header("Authorization", "Bearer " + token) .retrieve() .body(List.class); return resp != null ? resp : List.of(); } catch (Exception e) { log.warn("Failed to get user roles: {}", e.getMessage()); return List.of(); } } /** Assign a role to a user in an organization. */ public void assignOrganizationRole(String orgId, String userId, String roleId) { if (!isAvailable()) return; try { String token = getAccessToken(); RestClient.create().post() .uri(logtoEndpoint + "/api/organizations/" + orgId + "/users/" + userId + "/roles") .header("Authorization", "Bearer " + token) .header("Content-Type", "application/json") .body(Map.of("organizationRoleIds", List.of(roleId))) .retrieve() .toBodilessEntity(); } catch (Exception e) { log.warn("Failed to assign role: {}", e.getMessage()); } } /** Remove a user from an organization. */ public void removeUserFromOrganization(String orgId, String userId) { if (!isAvailable()) return; try { String token = getAccessToken(); RestClient.create().delete() .uri(logtoEndpoint + "/api/organizations/" + orgId + "/users/" + userId) .header("Authorization", "Bearer " + token) .retrieve() .toBodilessEntity(); } catch (Exception e) { log.warn("Failed to remove user from org: {}", e.getMessage()); } } /** Create a user in Logto and add to organization with role. */ public String createAndInviteUser(String email, String orgId, String roleId) { if (!isAvailable()) return null; try { String token = getAccessToken(); // Create user @SuppressWarnings("unchecked") var userResp = (Map) RestClient.create().post() .uri(logtoEndpoint + "/api/users") .header("Authorization", "Bearer " + token) .header("Content-Type", "application/json") .body(Map.of("primaryEmail", email, "name", email.split("@")[0])) .retrieve() .body(Map.class); String userId = String.valueOf(userResp.get("id")); // Add to org addUserToOrganization(orgId, userId); // Assign role if (roleId != null) { assignOrganizationRole(orgId, userId, roleId); } return userId; } catch (Exception e) { log.error("Failed to create and invite user: {}", e.getMessage()); throw new RuntimeException("Invite failed: " + e.getMessage(), e); } } /** List available organization roles. */ @SuppressWarnings("unchecked") public List> listOrganizationRoles() { if (!isAvailable()) return List.of(); try { String token = getAccessToken(); var resp = RestClient.create().get() .uri(logtoEndpoint + "/api/organization-roles") .header("Authorization", "Bearer " + token) .retrieve() .body(List.class); return resp != null ? resp : List.of(); } catch (Exception e) { log.warn("Failed to list org roles: {}", e.getMessage()); return List.of(); } } ``` - [ ] **Step 2: Verify compilation** ```bash mvn compile -q ``` - [ ] **Step 3: Commit** ```bash git add src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java git commit -m "feat: add member management to LogtoManagementClient" ``` --- ## Task 6: Vendor Backend — Service + Controller + Security **Files:** - Create: `src/.../vendor/VendorTenantService.java` - Create: `src/.../vendor/VendorTenantController.java` - Modify: `src/.../config/SecurityConfig.java` - Modify: `src/.../config/TenantIsolationInterceptor.java` - [ ] **Step 1: Create VendorTenantService** ```java package net.siegeln.cameleer.saas.vendor; import net.siegeln.cameleer.saas.audit.AuditAction; import net.siegeln.cameleer.saas.audit.AuditService; import net.siegeln.cameleer.saas.identity.LogtoManagementClient; import net.siegeln.cameleer.saas.identity.ServerApiClient; import net.siegeln.cameleer.saas.license.LicenseEntity; import net.siegeln.cameleer.saas.license.LicenseService; import net.siegeln.cameleer.saas.provisioning.*; import net.siegeln.cameleer.saas.tenant.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.Duration; import java.util.List; import java.util.Map; import java.util.UUID; @Service public class VendorTenantService { private static final Logger log = LoggerFactory.getLogger(VendorTenantService.class); private final TenantService tenantService; private final LicenseService licenseService; private final TenantProvisioner provisioner; private final LogtoManagementClient logtoClient; private final ServerApiClient serverApiClient; private final AuditService auditService; public VendorTenantService(TenantService tenantService, LicenseService licenseService, TenantProvisioner provisioner, LogtoManagementClient logtoClient, ServerApiClient serverApiClient, AuditService auditService) { this.tenantService = tenantService; this.licenseService = licenseService; this.provisioner = provisioner; this.logtoClient = logtoClient; this.serverApiClient = serverApiClient; this.auditService = auditService; } /** Full tenant creation: DB record → Logto org → license → provision server. */ @Transactional public TenantEntity createAndProvision(CreateTenantRequest request, UUID actorId) { // 1. Create tenant record + Logto org TenantEntity tenant = tenantService.create(request, actorId); // 2. Generate license LicenseEntity license = licenseService.generateLicense(tenant, Duration.ofDays(365), actorId); // 3. Provision server if (provisioner.isAvailable()) { var provReq = new TenantProvisionRequest( tenant.getId(), tenant.getSlug(), tenant.getTier().name(), license.getToken() ); ProvisionResult result = provisioner.provision(provReq); if (result.success()) { tenant.setServerEndpoint(result.serverEndpoint()); tenant.setProvisionError(null); tenant.setStatus(TenantStatus.ACTIVE); auditService.log(AuditAction.TENANT_UPDATE, actorId, null, tenant.getId(), "tenant", null, null, "SUCCESS", Map.of("action", "provision")); } else { tenant.setProvisionError(result.error()); log.error("Provisioning failed for '{}': {}", tenant.getSlug(), result.error()); } } else { // No provisioner — mark active without server (development mode) tenant.setStatus(TenantStatus.ACTIVE); } return tenant; } public List listAll() { return tenantService.findAll(); } public TenantEntity getById(UUID id) { return tenantService.getById(id); } public ServerStatus getServerStatus(TenantEntity tenant) { return provisioner.getStatus(tenant.getSlug()); } public ServerApiClient.ServerHealthResponse getServerHealth(TenantEntity tenant) { if (tenant.getServerEndpoint() == null) { return new ServerApiClient.ServerHealthResponse(false, "NO_SERVER"); } return serverApiClient.getHealth(tenant.getServerEndpoint()); } @Transactional public TenantEntity suspend(UUID tenantId, UUID actorId) { TenantEntity tenant = tenantService.getById(tenantId); if (provisioner.isAvailable()) { provisioner.stop(tenant.getSlug()); } tenantService.suspend(tenantId, actorId); tenant.setStatus(TenantStatus.SUSPENDED); return tenant; } @Transactional public TenantEntity activate(UUID tenantId, UUID actorId) { TenantEntity tenant = tenantService.getById(tenantId); if (provisioner.isAvailable()) { provisioner.start(tenant.getSlug()); } tenantService.activate(tenantId, actorId); tenant.setStatus(TenantStatus.ACTIVE); return tenant; } @Transactional public void delete(UUID tenantId, UUID actorId) { TenantEntity tenant = tenantService.getById(tenantId); // Stop and remove containers if (provisioner.isAvailable()) { provisioner.remove(tenant.getSlug()); } // Revoke license licenseService.revokeLicense(tenantId, actorId); // Delete Logto org if (tenant.getLogtoOrgId() != null) { logtoClient.deleteOrganization(tenant.getLogtoOrgId()); } // Soft delete tenant.setStatus(TenantStatus.DELETED); auditService.log(AuditAction.TENANT_DELETE, actorId, null, tenantId, "tenant", null, null, "SUCCESS", null); } @Transactional public LicenseEntity renewLicense(UUID tenantId, UUID actorId) { TenantEntity tenant = tenantService.getById(tenantId); licenseService.revokeLicense(tenantId, actorId); LicenseEntity license = licenseService.generateLicense(tenant, Duration.ofDays(365), actorId); // Push to server if available if (tenant.getServerEndpoint() != null) { try { serverApiClient.pushLicense(tenant.getServerEndpoint(), license.getToken()); } catch (Exception e) { log.warn("Failed to push license to server for '{}': {}", tenant.getSlug(), e.getMessage()); } } return license; } } ``` - [ ] **Step 2: Create VendorTenantController** ```java package net.siegeln.cameleer.saas.vendor; import net.siegeln.cameleer.saas.identity.ServerApiClient; import net.siegeln.cameleer.saas.license.LicenseEntity; import net.siegeln.cameleer.saas.license.LicenseResponse; import net.siegeln.cameleer.saas.license.LicenseService; import net.siegeln.cameleer.saas.provisioning.ServerStatus; import net.siegeln.cameleer.saas.tenant.*; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.web.bind.annotation.*; import java.util.List; import java.util.Map; import java.util.UUID; @RestController @RequestMapping("/api/vendor/tenants") @PreAuthorize("hasAuthority('SCOPE_platform:admin')") public class VendorTenantController { private final VendorTenantService vendorService; private final LicenseService licenseService; public VendorTenantController(VendorTenantService vendorService, LicenseService licenseService) { this.vendorService = vendorService; this.licenseService = licenseService; } @GetMapping public List listTenants() { return vendorService.listAll().stream().map(t -> { ServerStatus serverStatus = vendorService.getServerStatus(t); var license = licenseService.getActiveLicense(t.getId()).orElse(null); return VendorTenantSummary.from(t, serverStatus, license); }).toList(); } @PostMapping public ResponseEntity createTenant( @RequestBody CreateTenantRequest request, @AuthenticationPrincipal Jwt jwt) { UUID actorId = resolveActorId(jwt); TenantEntity tenant = vendorService.createAndProvision(request, actorId); return ResponseEntity.ok(TenantResponse.from(tenant)); } @GetMapping("/{id}") public ResponseEntity getTenant(@PathVariable UUID id) { TenantEntity tenant = vendorService.getById(id); if (tenant == null) return ResponseEntity.notFound().build(); ServerStatus serverStatus = vendorService.getServerStatus(tenant); ServerApiClient.ServerHealthResponse health = vendorService.getServerHealth(tenant); var license = licenseService.getActiveLicense(id).orElse(null); return ResponseEntity.ok(new VendorTenantDetail( TenantResponse.from(tenant), serverStatus.state().name(), health.healthy(), health.status(), license != null ? LicenseResponse.from(license) : null )); } @PostMapping("/{id}/suspend") public ResponseEntity suspend(@PathVariable UUID id, @AuthenticationPrincipal Jwt jwt) { TenantEntity tenant = vendorService.suspend(id, resolveActorId(jwt)); return ResponseEntity.ok(TenantResponse.from(tenant)); } @PostMapping("/{id}/activate") public ResponseEntity activate(@PathVariable UUID id, @AuthenticationPrincipal Jwt jwt) { TenantEntity tenant = vendorService.activate(id, resolveActorId(jwt)); return ResponseEntity.ok(TenantResponse.from(tenant)); } @DeleteMapping("/{id}") public ResponseEntity delete(@PathVariable UUID id, @AuthenticationPrincipal Jwt jwt) { vendorService.delete(id, resolveActorId(jwt)); return ResponseEntity.noContent().build(); } @PostMapping("/{id}/license") public ResponseEntity renewLicense(@PathVariable UUID id, @AuthenticationPrincipal Jwt jwt) { LicenseEntity license = vendorService.renewLicense(id, resolveActorId(jwt)); return ResponseEntity.ok(LicenseResponse.from(license)); } @GetMapping("/{id}/health") public ResponseEntity> health(@PathVariable UUID id) { TenantEntity tenant = vendorService.getById(id); if (tenant == null) return ResponseEntity.notFound().build(); var health = vendorService.getServerHealth(tenant); var status = vendorService.getServerStatus(tenant); return ResponseEntity.ok(Map.of( "healthy", health.healthy(), "status", health.status(), "containerState", status.state().name() )); } private UUID resolveActorId(Jwt jwt) { try { return UUID.fromString(jwt.getSubject()); } catch (Exception e) { return UUID.nameUUIDFromBytes(jwt.getSubject().getBytes()); } } public record VendorTenantSummary( UUID id, String name, String slug, String tier, String status, String serverState, String licenseExpiry, String provisionError ) { public static VendorTenantSummary from(TenantEntity t, ServerStatus ss, LicenseEntity license) { return new VendorTenantSummary( t.getId(), t.getName(), t.getSlug(), t.getTier().name(), t.getStatus().name(), ss.state().name(), license != null ? license.getExpiresAt().toString() : null, t.getProvisionError() ); } } public record VendorTenantDetail( TenantResponse tenant, String serverState, boolean serverHealthy, String serverStatus, LicenseResponse license ) {} } ``` - [ ] **Step 3: Update SecurityConfig** In `SecurityConfig.java`, update the `filterChain` method. Find the `authorizeHttpRequests` section and add vendor/portal rules: ```java .requestMatchers("/api/vendor/**").hasAuthority("SCOPE_platform:admin") .requestMatchers("/api/tenant/**").authenticated() ``` These should go before the existing `.requestMatchers("/api/**").authenticated()` line. - [ ] **Step 4: Update TenantIsolationInterceptor** Add an early-return bypass for `/api/vendor/` paths (platform:admin already enforced by Spring Security) and handle `/api/tenant/` paths (resolve tenant from JWT org context, no path variable): At the top of `preHandle()`, after the `platform:admin` bypass check, add: ```java String path = request.getRequestURI(); // Vendor endpoints: platform:admin already enforced by Spring Security, skip path-variable check if (path.startsWith("/platform/api/vendor/")) { return true; } // Tenant portal endpoints: resolve tenant from JWT org context (no tenantId in path) if (path.startsWith("/platform/api/tenant/")) { // TenantContext already set from JWT org_id resolution above return TenantContext.getCurrentTenantId() != null; } ``` - [ ] **Step 5: Add LicenseResponse.from() if missing** Check `LicenseResponse.java` — it needs a static `from(LicenseEntity)` factory method. Add if not present: ```java public static LicenseResponse from(LicenseEntity e) { return new LicenseResponse( e.getId(), e.getTenantId(), e.getTier(), e.getFeatures(), e.getLimits(), e.getIssuedAt(), e.getExpiresAt(), e.getToken() ); } ``` - [ ] **Step 6: Verify compilation** ```bash mvn compile -q ``` - [ ] **Step 7: Commit** ```bash git add src/main/java/net/siegeln/cameleer/saas/vendor/ \ src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java \ src/main/java/net/siegeln/cameleer/saas/config/TenantIsolationInterceptor.java \ src/main/java/net/siegeln/cameleer/saas/license/LicenseResponse.java git commit -m "feat: vendor tenant API with provisioning, suspend, delete" ``` --- ## Task 7: Tenant Portal Backend — Service + Controller **Files:** - Create: `src/.../portal/TenantPortalService.java` - Create: `src/.../portal/TenantPortalController.java` - [ ] **Step 1: Create TenantPortalService** ```java package net.siegeln.cameleer.saas.portal; import net.siegeln.cameleer.saas.config.TenantContext; import net.siegeln.cameleer.saas.identity.LogtoManagementClient; import net.siegeln.cameleer.saas.identity.ServerApiClient; import net.siegeln.cameleer.saas.license.LicenseEntity; import net.siegeln.cameleer.saas.license.LicenseService; import net.siegeln.cameleer.saas.tenant.TenantEntity; import net.siegeln.cameleer.saas.tenant.TenantService; import org.springframework.stereotype.Service; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.UUID; @Service public class TenantPortalService { private final TenantService tenantService; private final LicenseService licenseService; private final ServerApiClient serverApiClient; private final LogtoManagementClient logtoClient; public TenantPortalService(TenantService tenantService, LicenseService licenseService, ServerApiClient serverApiClient, LogtoManagementClient logtoClient) { this.tenantService = tenantService; this.licenseService = licenseService; this.serverApiClient = serverApiClient; this.logtoClient = logtoClient; } public DashboardData getDashboard() { UUID tenantId = TenantContext.getCurrentTenantId(); TenantEntity tenant = tenantService.getById(tenantId); Optional license = licenseService.getActiveLicense(tenantId); boolean serverHealthy = false; String serverStatus = "NO_SERVER"; if (tenant.getServerEndpoint() != null) { var health = serverApiClient.getHealth(tenant.getServerEndpoint()); serverHealthy = health.healthy(); serverStatus = health.status(); } long daysRemaining = license.map(l -> ChronoUnit.DAYS.between(Instant.now(), l.getExpiresAt()) ).orElse(0L); return new DashboardData( tenant.getName(), tenant.getSlug(), tenant.getTier().name(), tenant.getStatus().name(), serverHealthy, serverStatus, tenant.getServerEndpoint(), license.map(LicenseEntity::getTier).orElse(null), daysRemaining, license.map(LicenseEntity::getLimits).orElse(Map.of()), license.map(LicenseEntity::getFeatures).orElse(Map.of()) ); } public LicenseData getLicense() { UUID tenantId = TenantContext.getCurrentTenantId(); Optional license = licenseService.getActiveLicense(tenantId); return license.map(l -> new LicenseData( l.getId(), l.getTier(), l.getFeatures(), l.getLimits(), l.getIssuedAt(), l.getExpiresAt(), l.getToken(), ChronoUnit.DAYS.between(Instant.now(), l.getExpiresAt()) )).orElse(null); } public Map getOidcConfig() { TenantEntity tenant = tenantService.getById(TenantContext.getCurrentTenantId()); if (tenant.getServerEndpoint() == null) return Map.of(); return serverApiClient.getOidcConfig(tenant.getServerEndpoint()); } public void updateOidcConfig(Map config) { TenantEntity tenant = tenantService.getById(TenantContext.getCurrentTenantId()); if (tenant.getServerEndpoint() == null) { throw new IllegalStateException("No server provisioned for this tenant"); } serverApiClient.pushOidcConfig(tenant.getServerEndpoint(), config); } public List> listTeamMembers() { TenantEntity tenant = tenantService.getById(TenantContext.getCurrentTenantId()); if (tenant.getLogtoOrgId() == null) return List.of(); return logtoClient.listOrganizationMembers(tenant.getLogtoOrgId()); } public String inviteTeamMember(String email, String roleId) { TenantEntity tenant = tenantService.getById(TenantContext.getCurrentTenantId()); return logtoClient.createAndInviteUser(email, tenant.getLogtoOrgId(), roleId); } public void removeTeamMember(String userId) { TenantEntity tenant = tenantService.getById(TenantContext.getCurrentTenantId()); logtoClient.removeUserFromOrganization(tenant.getLogtoOrgId(), userId); } public void changeTeamMemberRole(String userId, String roleId) { TenantEntity tenant = tenantService.getById(TenantContext.getCurrentTenantId()); logtoClient.assignOrganizationRole(tenant.getLogtoOrgId(), userId, roleId); } public TenantSettingsData getSettings() { TenantEntity tenant = tenantService.getById(TenantContext.getCurrentTenantId()); return new TenantSettingsData( tenant.getName(), tenant.getSlug(), tenant.getTier().name(), tenant.getStatus().name(), tenant.getServerEndpoint(), tenant.getCreatedAt() ); } // Response records public record DashboardData( String name, String slug, String tier, String status, boolean serverHealthy, String serverStatus, String serverEndpoint, String licenseTier, long licenseDaysRemaining, Map limits, Map features ) {} public record LicenseData( UUID id, String tier, Map features, Map limits, Instant issuedAt, Instant expiresAt, String token, long daysRemaining ) {} public record TenantSettingsData( String name, String slug, String tier, String status, String serverEndpoint, Instant createdAt ) {} } ``` - [ ] **Step 2: Create TenantPortalController** ```java package net.siegeln.cameleer.saas.portal; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.List; import java.util.Map; @RestController @RequestMapping("/api/tenant") public class TenantPortalController { private final TenantPortalService portalService; public TenantPortalController(TenantPortalService portalService) { this.portalService = portalService; } @GetMapping("/dashboard") public TenantPortalService.DashboardData dashboard() { return portalService.getDashboard(); } @GetMapping("/license") public ResponseEntity license() { var data = portalService.getLicense(); return data != null ? ResponseEntity.ok(data) : ResponseEntity.notFound().build(); } @GetMapping("/oidc") public Map getOidc() { return portalService.getOidcConfig(); } @PutMapping("/oidc") public ResponseEntity updateOidc(@RequestBody Map config) { portalService.updateOidcConfig(config); return ResponseEntity.ok().build(); } @GetMapping("/team") public List> listTeam() { return portalService.listTeamMembers(); } @PostMapping("/team/invite") public ResponseEntity> inviteTeamMember(@RequestBody Map body) { String userId = portalService.inviteTeamMember(body.get("email"), body.get("roleId")); return ResponseEntity.ok(Map.of("userId", userId)); } @DeleteMapping("/team/{userId}") public ResponseEntity removeTeamMember(@PathVariable String userId) { portalService.removeTeamMember(userId); return ResponseEntity.noContent().build(); } @PatchMapping("/team/{userId}/role") public ResponseEntity changeRole(@PathVariable String userId, @RequestBody Map body) { portalService.changeTeamMemberRole(userId, body.get("roleId")); return ResponseEntity.ok().build(); } @GetMapping("/settings") public TenantPortalService.TenantSettingsData settings() { return portalService.getSettings(); } } ``` - [ ] **Step 3: Verify compilation** ```bash mvn compile -q ``` - [ ] **Step 4: Commit** ```bash git add src/main/java/net/siegeln/cameleer/saas/portal/ git commit -m "feat: tenant portal API (dashboard, license, OIDC, team, settings)" ``` --- ## Task 8: Frontend Foundation — Routes, Layout, Types, Hooks **Files:** - Modify: `ui/src/router.tsx` - Modify: `ui/src/components/Layout.tsx` - Modify: `ui/src/auth/OrgResolver.tsx` - Modify: `ui/src/types/api.ts` - Create: `ui/src/api/vendor-hooks.ts` - Create: `ui/src/api/tenant-hooks.ts` - Modify: `ui/src/main.tsx` - Modify: `ui/src/config/SpaController.java` - Remove: `ui/src/pages/DashboardPage.tsx`, `ui/src/pages/LicensePage.tsx`, `ui/src/pages/AdminTenantsPage.tsx` - [ ] **Step 1: Update types/api.ts** Replace the full contents: ```typescript export interface TenantResponse { id: string; name: string; slug: string; tier: string; status: string; serverEndpoint: string | null; provisionError: string | null; createdAt: string; updatedAt: string; } export interface LicenseResponse { id: string; tenantId: string; tier: string; features: Record; limits: Record; issuedAt: string; expiresAt: string; token: string; } export interface MeResponse { userId: string; tenants: Array<{ id: string; name: string; slug: string; logtoOrgId: string; }>; } // Vendor API types export interface VendorTenantSummary { id: string; name: string; slug: string; tier: string; status: string; serverState: string; licenseExpiry: string | null; provisionError: string | null; } export interface VendorTenantDetail { tenant: TenantResponse; serverState: string; serverHealthy: boolean; serverStatus: string; license: LicenseResponse | null; } export interface CreateTenantRequest { name: string; slug: string; tier?: string; } // Tenant portal API types export interface DashboardData { name: string; slug: string; tier: string; status: string; serverHealthy: boolean; serverStatus: string; serverEndpoint: string | null; licenseTier: string | null; licenseDaysRemaining: number; limits: Record; features: Record; } export interface TenantLicenseData { id: string; tier: string; features: Record; limits: Record; issuedAt: string; expiresAt: string; token: string; daysRemaining: number; } export interface TenantSettings { name: string; slug: string; tier: string; status: string; serverEndpoint: string | null; createdAt: string; } ``` - [ ] **Step 2: Create api/vendor-hooks.ts** ```typescript import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { api } from './client'; import type { VendorTenantSummary, VendorTenantDetail, CreateTenantRequest, TenantResponse, LicenseResponse } from '../types/api'; export function useVendorTenants() { return useQuery({ queryKey: ['vendor', 'tenants'], queryFn: () => api.get('/vendor/tenants'), refetchInterval: 30_000, }); } export function useVendorTenant(id: string | null) { return useQuery({ queryKey: ['vendor', 'tenants', id], queryFn: () => api.get(`/vendor/tenants/${id}`), enabled: !!id, }); } export function useCreateTenant() { const qc = useQueryClient(); return useMutation({ mutationFn: (req) => api.post('/vendor/tenants', req), onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'tenants'] }), }); } export function useSuspendTenant() { const qc = useQueryClient(); return useMutation({ mutationFn: (id) => api.post(`/vendor/tenants/${id}/suspend`), onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'tenants'] }), }); } export function useActivateTenant() { const qc = useQueryClient(); return useMutation({ mutationFn: (id) => api.post(`/vendor/tenants/${id}/activate`), onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'tenants'] }), }); } export function useDeleteTenant() { const qc = useQueryClient(); return useMutation({ mutationFn: (id) => api.delete(`/vendor/tenants/${id}`), onSuccess: () => qc.invalidateQueries({ queryKey: ['vendor', 'tenants'] }), }); } export function useRenewLicense() { const qc = useQueryClient(); return useMutation({ mutationFn: (tenantId) => api.post(`/vendor/tenants/${tenantId}/license`), onSuccess: (_, tenantId) => qc.invalidateQueries({ queryKey: ['vendor', 'tenants', tenantId] }), }); } ``` - [ ] **Step 3: Create api/tenant-hooks.ts** ```typescript import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { api } from './client'; import type { DashboardData, TenantLicenseData, TenantSettings } from '../types/api'; export function useTenantDashboard() { return useQuery({ queryKey: ['tenant', 'dashboard'], queryFn: () => api.get('/tenant/dashboard'), refetchInterval: 30_000, }); } export function useTenantLicense() { return useQuery({ queryKey: ['tenant', 'license'], queryFn: () => api.get('/tenant/license'), }); } export function useTenantOidc() { return useQuery>({ queryKey: ['tenant', 'oidc'], queryFn: () => api.get('/tenant/oidc'), }); } export function useUpdateOidc() { const qc = useQueryClient(); return useMutation>({ mutationFn: (config) => api.put('/tenant/oidc', config as unknown as FormData), onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'oidc'] }), }); } export function useTenantTeam() { return useQuery>>({ queryKey: ['tenant', 'team'], queryFn: () => api.get('/tenant/team'), }); } export function useInviteTeamMember() { const qc = useQueryClient(); return useMutation<{ userId: string }, Error, { email: string; roleId: string }>({ mutationFn: (body) => api.post('/tenant/team/invite', body), onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'team'] }), }); } export function useRemoveTeamMember() { const qc = useQueryClient(); return useMutation({ mutationFn: (userId) => api.delete(`/tenant/team/${userId}`), onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'team'] }), }); } export function useTenantSettings() { return useQuery({ queryKey: ['tenant', 'settings'], queryFn: () => api.get('/tenant/settings'), }); } ``` - [ ] **Step 4: Update router.tsx** Replace the full contents: ```tsx import { Routes, Route, Navigate } 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 { RequireScope } from './components/RequireScope'; // Lazy-load pages import { VendorTenantsPage } from './pages/vendor/VendorTenantsPage'; import { CreateTenantPage } from './pages/vendor/CreateTenantPage'; import { TenantDetailPage } from './pages/vendor/TenantDetailPage'; import { TenantDashboardPage } from './pages/tenant/TenantDashboardPage'; import { TenantLicensePage } from './pages/tenant/TenantLicensePage'; import { OidcConfigPage } from './pages/tenant/OidcConfigPage'; import { TeamPage } from './pages/tenant/TeamPage'; import { SettingsPage } from './pages/tenant/SettingsPage'; export function AppRouter() { return ( } /> } /> }> }> }> {/* Vendor console */} }> } /> }> } /> }> } /> {/* Tenant portal */} } /> } /> } /> } /> } /> {/* Default redirect */} } /> ); } function LandingRedirect() { // Will be implemented in Layout — vendor goes to /vendor/tenants, customer to /tenant return ; } ``` - [ ] **Step 5: Update Layout.tsx** Replace the full contents of `ui/src/components/Layout.tsx`: ```tsx import { Outlet, useNavigate, useLocation } from 'react-router'; import { useMemo, useState, useEffect } from 'react'; import { AppShell, Sidebar, TopBar } from '@cameleer/design-system'; import { LayoutDashboard, ShieldCheck, Server, Users, Settings, KeyRound, Building } from 'lucide-react'; import { useAuth } from '../auth/useAuth'; import { useScopes } from '../auth/useScopes'; import { useOrgStore } from '../auth/useOrganization'; import logo from '@cameleer/design-system/assets/cameleer-logo.svg'; export function Layout() { const navigate = useNavigate(); const location = useLocation(); const { logout } = useAuth(); const scopes = useScopes(); const username = useOrgStore((s) => s.username); const currentSlug = useOrgStore((s) => { const org = s.organizations.find((o) => o.id === s.currentOrgId); return org?.slug ?? null; }); const isVendor = scopes.has('platform:admin'); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); // Vendor landing redirect useEffect(() => { if (isVendor && location.pathname === '/') { navigate('/vendor/tenants', { replace: true }); } else if (!isVendor && location.pathname === '/') { navigate('/tenant', { replace: true }); } }, [isVendor, location.pathname, navigate]); const isActive = (path: string) => location.pathname === path || location.pathname.startsWith(path + '/'); const breadcrumb = useMemo(() => { const p = location.pathname; if (p.includes('/vendor/tenants/new')) return [{ label: 'Tenants', href: '/vendor/tenants' }, { label: 'Create' }]; if (p.match(/\/vendor\/tenants\/[^/]+/)) return [{ label: 'Tenants', href: '/vendor/tenants' }, { label: 'Detail' }]; if (p.includes('/vendor/tenants')) return [{ label: 'Tenants' }]; if (p.includes('/tenant/license')) return [{ label: 'Dashboard', href: '/tenant' }, { label: 'License' }]; if (p.includes('/tenant/oidc')) return [{ label: 'Dashboard', href: '/tenant' }, { label: 'OIDC' }]; if (p.includes('/tenant/team')) return [{ label: 'Dashboard', href: '/tenant' }, { label: 'Team' }]; if (p.includes('/tenant/settings')) return [{ label: 'Dashboard', href: '/tenant' }, { label: 'Settings' }]; if (p.includes('/tenant')) return [{ label: 'Dashboard' }]; return [{ label: 'Home' }]; }, [location.pathname]); const displayName = username || 'User'; const serverUrl = currentSlug ? `/t/${currentSlug}/` : '/server/'; return ( setSidebarCollapsed((c) => !c)} header={ Cameleer {!sidebarCollapsed && Cameleer SaaS} } footer={ } label="Open Server Dashboard" open={false} onToggle={() => window.open(serverUrl, '_blank', 'noopener')} >{null} } > {isVendor && ( } label="Tenants" open={false} active={isActive('/vendor/tenants')} onToggle={() => navigate('/vendor/tenants')} >{null} )} } label="Dashboard" open={false} active={isActive('/tenant') && !isActive('/tenant/')} onToggle={() => navigate('/tenant')} >{null} } label="License" open={false} active={isActive('/tenant/license')} onToggle={() => navigate('/tenant/license')} >{null} } label="OIDC" open={false} active={isActive('/tenant/oidc')} onToggle={() => navigate('/tenant/oidc')} >{null} } label="Team" open={false} active={isActive('/tenant/team')} onToggle={() => navigate('/tenant/team')} >{null} } label="Settings" open={false} active={isActive('/tenant/settings')} onToggle={() => navigate('/tenant/settings')} >{null} ); } ``` - [ ] **Step 6: Update OrgResolver.tsx** In `OrgResolver.tsx`, the existing redirect logic sends users to `/`. Update to also check for vendor scope and redirect accordingly. The key change is in how `LandingRedirect` in `router.tsx` handles it — and the `Layout` `useEffect` above handles the actual redirect based on scopes. No changes needed to OrgResolver itself — the Layout handles the redirect. - [ ] **Step 7: Update SpaController.java** Replace the route list in `SpaController.java` to forward the new SPA paths: ```java @Controller public class SpaController { @RequestMapping(value = { "/", "/login", "/callback", "/vendor/**", "/tenant/**" }) public String forward() { return "forward:/index.html"; } } ``` - [ ] **Step 8: Remove old pages** ```bash rm ui/src/pages/DashboardPage.tsx ui/src/pages/LicensePage.tsx ui/src/pages/AdminTenantsPage.tsx ``` - [ ] **Step 9: Remove server-specific providers from main.tsx** In `ui/src/main.tsx`, remove `GlobalFilterProvider` and `CommandPaletteProvider` wrapping (these are server-specific controls). Keep: `ThemeProvider`, `ToastProvider`, `BreadcrumbProvider`. - [ ] **Step 10: Commit** ```bash git add ui/src/ src/main/java/net/siegeln/cameleer/saas/config/SpaController.java git commit -m "feat: restructure frontend routes — vendor/tenant persona split" ``` --- ## Task 9: Shared Components + Vendor Console Pages **Files:** - Create: `ui/src/components/ServerStatusBadge.tsx` - Create: `ui/src/components/UsageIndicator.tsx` - Create: `ui/src/pages/vendor/VendorTenantsPage.tsx` - Create: `ui/src/pages/vendor/CreateTenantPage.tsx` - Create: `ui/src/pages/vendor/TenantDetailPage.tsx` - [ ] **Step 1: Create ServerStatusBadge** ```tsx import { Badge } from '@cameleer/design-system'; interface Props { state: string; size?: 'sm' | 'md'; } export function ServerStatusBadge({ state, size = 'md' }: Props) { const config: Record = { RUNNING: { color: 'success', label: 'Running' }, STOPPED: { color: 'error', label: 'Stopped' }, NOT_FOUND: { color: 'auto', label: 'No Server' }, ERROR: { color: 'error', label: 'Error' }, }; const c = config[state] ?? { color: 'auto' as const, label: state }; return {c.label}; } ``` - [ ] **Step 2: Create UsageIndicator** ```tsx import styles from '../styles/platform.module.css'; interface Props { used: number; limit: number; label: string; } export function UsageIndicator({ used, limit, label }: Props) { const unlimited = limit < 0; const pct = unlimited ? 0 : Math.min((used / limit) * 100, 100); const color = pct >= 100 ? 'var(--error)' : pct >= 80 ? 'var(--warning)' : 'var(--success)'; return (
{label} {used} / {unlimited ? '∞' : limit}
{!unlimited && (
)}
); } ``` - [ ] **Step 3: Create VendorTenantsPage** ```tsx import { useNavigate } from 'react-router'; import { Card, Button, Badge, DataTable, Spinner } from '@cameleer/design-system'; import { Plus } from 'lucide-react'; import { useVendorTenants } from '../../api/vendor-hooks'; import { ServerStatusBadge } from '../../components/ServerStatusBadge'; import { tierColor } from '../../utils/tier'; import styles from '../../styles/platform.module.css'; import type { VendorTenantSummary } from '../../types/api'; export function VendorTenantsPage() { const navigate = useNavigate(); const { data: tenants, isLoading } = useVendorTenants(); if (isLoading) return ; const columns = [ { key: 'name', header: 'Name', render: (t: VendorTenantSummary) => {t.name} }, { key: 'slug', header: 'Slug', render: (t: VendorTenantSummary) => {t.slug} }, { key: 'tier', header: 'Tier', render: (t: VendorTenantSummary) => {t.tier} }, { key: 'status', header: 'Status', render: (t: VendorTenantSummary) => { const statusColor: Record = { ACTIVE: 'success', PROVISIONING: 'primary', SUSPENDED: 'warning', DELETED: 'auto', }; return {t.status}; }, }, { key: 'server', header: 'Server', render: (t: VendorTenantSummary) => }, { key: 'license', header: 'License', render: (t: VendorTenantSummary) => { if (!t.licenseExpiry) return None; const days = Math.ceil((new Date(t.licenseExpiry).getTime() - Date.now()) / 86400000); return {days}d remaining; }, }, ]; return (

Tenants

navigate(`/vendor/tenants/${t.id}`)} emptyMessage="No tenants yet. Create one to get started." />
); } ``` - [ ] **Step 4: Create CreateTenantPage** ```tsx import { useState, useEffect } from 'react'; import { useNavigate } from 'react-router'; import { Card, Button, Input, FormField } from '@cameleer/design-system'; import { useCreateTenant } from '../../api/vendor-hooks'; import { toSlug } from '../../utils/slug'; import styles from '../../styles/platform.module.css'; const TIERS = ['LOW', 'MID', 'HIGH', 'BUSINESS']; export function CreateTenantPage() { const navigate = useNavigate(); const createMutation = useCreateTenant(); const [name, setName] = useState(''); const [slug, setSlug] = useState(''); const [slugEdited, setSlugEdited] = useState(false); const [tier, setTier] = useState('LOW'); useEffect(() => { if (!slugEdited) setSlug(toSlug(name)); }, [name, slugEdited]); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); const result = await createMutation.mutateAsync({ name, slug, tier }); navigate(`/vendor/tenants/${result.id}`); }; return (

Create Tenant

setName(e.target.value)} placeholder="Acme Corp" /> { setSlug(e.target.value); setSlugEdited(true); }} placeholder="acme-corp" pattern="[a-z0-9-]+" />
{createMutation.isError && (
{createMutation.error.message}
)}
); } ``` - [ ] **Step 5: Create TenantDetailPage** ```tsx import { useParams, useNavigate } from 'react-router'; import { Card, Button, Badge, KpiStrip, Spinner, AlertDialog } from '@cameleer/design-system'; import { useState } from 'react'; import { useVendorTenant, useSuspendTenant, useActivateTenant, useDeleteTenant, useRenewLicense } from '../../api/vendor-hooks'; import { ServerStatusBadge } from '../../components/ServerStatusBadge'; import { tierColor } from '../../utils/tier'; import styles from '../../styles/platform.module.css'; export function TenantDetailPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const { data, isLoading } = useVendorTenant(id ?? null); const suspendMutation = useSuspendTenant(); const activateMutation = useActivateTenant(); const deleteMutation = useDeleteTenant(); const renewMutation = useRenewLicense(); const [showDelete, setShowDelete] = useState(false); if (isLoading || !data) return ; const { tenant: t, serverState, serverHealthy, license } = data; const isActive = t.status === 'ACTIVE'; const isSuspended = t.status === 'SUSPENDED'; const daysRemaining = license ? Math.ceil((new Date(license.expiresAt).getTime() - Date.now()) / 86400000) : 0; const kpis = [ { label: 'Server', value: serverState, trend: serverHealthy ? 'up' as const : 'down' as const }, { label: 'Tier', value: t.tier }, { label: 'Status', value: t.status }, { label: 'License', value: license ? `${daysRemaining}d remaining` : 'None' }, ]; return (

{t.name}

{t.tier} {t.status}

Server

Status
Endpoint {t.serverEndpoint ?? 'Not provisioned'}
{t.provisionError && (
{t.provisionError}
)}

License

{license ? (
Tier {license.tier}
Expires {new Date(license.expiresAt).toLocaleDateString()}
Days remaining 30 ? 'success' : daysRemaining > 0 ? 'warning' : 'error'}> {daysRemaining} days
) : (

No active license

)}

Info

Slug {t.slug}
Created {new Date(t.createdAt).toLocaleDateString()}

Actions

{isActive && ( )} {isSuspended && ( )}
setShowDelete(false)} title="Delete Tenant" description={`This will stop the server, revoke the license, and remove all resources for "${t.name}". This cannot be undone.`} confirmLabel="Delete" onConfirm={async () => { if (id) { await deleteMutation.mutateAsync(id); navigate('/vendor/tenants'); } }} />
); } ``` - [ ] **Step 6: Commit** ```bash git add ui/src/components/ServerStatusBadge.tsx ui/src/components/UsageIndicator.tsx \ ui/src/pages/vendor/ git commit -m "feat: vendor console — tenant list, create wizard, detail page" ``` --- ## Task 10: Tenant Portal Pages — Dashboard, License, Settings **Files:** - Create: `ui/src/pages/tenant/TenantDashboardPage.tsx` - Create: `ui/src/pages/tenant/TenantLicensePage.tsx` - Create: `ui/src/pages/tenant/SettingsPage.tsx` - [ ] **Step 1: Create TenantDashboardPage** ```tsx import { Card, KpiStrip, Badge, Button, Spinner, EmptyState } from '@cameleer/design-system'; import { useNavigate } from 'react-router'; import { useTenantDashboard } from '../../api/tenant-hooks'; import { ServerStatusBadge } from '../../components/ServerStatusBadge'; import { UsageIndicator } from '../../components/UsageIndicator'; import { tierColor } from '../../utils/tier'; import styles from '../../styles/platform.module.css'; export function TenantDashboardPage() { const navigate = useNavigate(); const { data, isLoading, isError } = useTenantDashboard(); if (isLoading) return ; if (isError || !data) return ; const kpis = [ { label: 'Tier', value: data.tier }, { label: 'Status', value: data.status }, { label: 'Server', value: data.serverHealthy ? 'Healthy' : 'Down' }, { label: 'License', value: data.licenseDaysRemaining > 0 ? `${data.licenseDaysRemaining}d remaining` : 'Expired' }, ]; const limits = data.limits ?? {}; const maxAgents = limits.max_agents ?? 0; const maxEnvs = limits.max_environments ?? 0; return (

{data.name}

{data.tier}
{!data.serverHealthy && data.serverStatus !== 'NO_SERVER' && (
Server is down — Contact support if this persists.
)}

Usage

Quick Links

); } ``` - [ ] **Step 2: Create TenantLicensePage** ```tsx import { useState } from 'react'; import { Card, Badge, Button, Spinner, EmptyState } from '@cameleer/design-system'; import { Copy } from 'lucide-react'; import { useTenantLicense } from '../../api/tenant-hooks'; import { UsageIndicator } from '../../components/UsageIndicator'; import { tierColor } from '../../utils/tier'; import styles from '../../styles/platform.module.css'; import { useToast } from '@cameleer/design-system'; export function TenantLicensePage() { const { data: license, isLoading, isError } = useTenantLicense(); const [tokenVisible, setTokenVisible] = useState(false); const { toast } = useToast(); if (isLoading) return ; if (isError || !license) return ; const features = license.features ?? {}; const limits = license.limits ?? {}; const daysColor = license.daysRemaining > 30 ? 'success' : license.daysRemaining > 0 ? 'warning' : 'error'; return (

License

{license.tier}

Validity

Issued {new Date(license.issuedAt).toLocaleDateString()}
Expires {new Date(license.expiresAt).toLocaleDateString()}
Days remaining {license.daysRemaining} days

Features

{Object.entries(features).map(([name, enabled]) => (
{name} {enabled ? 'Enabled' : 'Not included'}
))}

Limits & Usage

Retention {limits.retention_days ?? 0} days

License Token

{tokenVisible && (
{license.token}
)}
); } ``` - [ ] **Step 3: Create SettingsPage** ```tsx import { Card, Spinner, EmptyState } from '@cameleer/design-system'; import { useTenantSettings } from '../../api/tenant-hooks'; import styles from '../../styles/platform.module.css'; export function SettingsPage() { const { data, isLoading, isError } = useTenantSettings(); if (isLoading) return ; if (isError || !data) return ; return (

Organization Settings

Name {data.name}
Slug {data.slug}
Tier {data.tier}
Status {data.status}
Server Endpoint {data.serverEndpoint ?? 'Not provisioned'}
Created {new Date(data.createdAt).toLocaleDateString()}

To change your tier, contact support.

); } ``` - [ ] **Step 4: Commit** ```bash git add ui/src/pages/tenant/TenantDashboardPage.tsx \ ui/src/pages/tenant/TenantLicensePage.tsx \ ui/src/pages/tenant/SettingsPage.tsx git commit -m "feat: tenant portal — dashboard, license, settings pages" ``` --- ## Task 11: Tenant Portal Pages — OIDC + Team **Files:** - Create: `ui/src/pages/tenant/OidcConfigPage.tsx` - Create: `ui/src/pages/tenant/TeamPage.tsx` - [ ] **Step 1: Create OidcConfigPage** ```tsx import { useState, useEffect } from 'react'; import { Card, Button, Input, FormField, Spinner, EmptyState } from '@cameleer/design-system'; import { useTenantOidc, useUpdateOidc } from '../../api/tenant-hooks'; import { useToast } from '@cameleer/design-system'; import styles from '../../styles/platform.module.css'; export function OidcConfigPage() { const { data: oidcConfig, isLoading, isError } = useTenantOidc(); const updateMutation = useUpdateOidc(); const { toast } = useToast(); const [issuerUri, setIssuerUri] = useState(''); const [clientId, setClientId] = useState(''); const [clientSecret, setClientSecret] = useState(''); const [audience, setAudience] = useState(''); const [rolesClaim, setRolesClaim] = useState('roles'); useEffect(() => { if (oidcConfig) { setIssuerUri(String(oidcConfig.issuerUri ?? '')); setClientId(String(oidcConfig.clientId ?? '')); setClientSecret(''); setAudience(String(oidcConfig.audience ?? '')); setRolesClaim(String(oidcConfig.rolesClaim ?? 'roles')); } }, [oidcConfig]); if (isLoading) return ; if (isError) return ; const isConfigured = !!oidcConfig?.issuerUri; const handleSave = async () => { try { await updateMutation.mutateAsync({ issuerUri, clientId, clientSecret: clientSecret || undefined, audience, rolesClaim, }); toast({ title: 'OIDC configuration saved', variant: 'success' }); } catch { toast({ title: 'Failed to save OIDC configuration', variant: 'error' }); } }; const handleReset = async () => { try { await updateMutation.mutateAsync({ issuerUri: '', clientId: '', clientSecret: '', audience: '', rolesClaim: '' }); toast({ title: 'Reset to Logto (default)', variant: 'success' }); } catch { toast({ title: 'Failed to reset', variant: 'error' }); } }; return (

OIDC Configuration

{isConfigured ? 'External OIDC configured — your team authenticates via your identity provider.' : 'Using Logto (default) — configure an external OIDC provider for your team.'}

setIssuerUri(e.target.value)} placeholder="https://accounts.google.com" /> setClientId(e.target.value)} placeholder="your-client-id" /> setClientSecret(e.target.value)} placeholder="••••••••" /> setAudience(e.target.value)} placeholder="https://api.cameleer.local" /> setRolesClaim(e.target.value)} placeholder="roles" />
{isConfigured && ( )}
); } ``` - [ ] **Step 2: Create TeamPage** ```tsx import { useState } from 'react'; import { Card, Button, DataTable, Input, FormField, Spinner, EmptyState, AlertDialog } from '@cameleer/design-system'; import { UserPlus } from 'lucide-react'; import { useTenantTeam, useInviteTeamMember, useRemoveTeamMember } from '../../api/tenant-hooks'; import { useToast } from '@cameleer/design-system'; import styles from '../../styles/platform.module.css'; export function TeamPage() { const { data: members, isLoading, isError } = useTenantTeam(); const inviteMutation = useInviteTeamMember(); const removeMutation = useRemoveTeamMember(); const { toast } = useToast(); const [showInvite, setShowInvite] = useState(false); const [email, setEmail] = useState(''); const [role, setRole] = useState('viewer'); const [removeTarget, setRemoveTarget] = useState(null); if (isLoading) return ; if (isError) return ; const roleMap: Record = { owner: 'Owner', operator: 'Operator', viewer: 'Viewer' }; const columns = [ { key: 'name', header: 'Name', render: (m: Record) => {String(m.name ?? m.username ?? '—')} }, { key: 'email', header: 'Email', render: (m: Record) => {String(m.primaryEmail ?? '—')} }, { key: 'actions', header: '', render: (m: Record) => ( ), }, ]; const handleInvite = async () => { try { await inviteMutation.mutateAsync({ email, roleId: role }); toast({ title: `Invited ${email}`, variant: 'success' }); setShowInvite(false); setEmail(''); } catch { toast({ title: 'Failed to invite member', variant: 'error' }); } }; return (

Team

{/* Invite dialog */} setShowInvite(false)} title="Invite Team Member" description="" confirmLabel="Invite" onConfirm={handleInvite} >
setEmail(e.target.value)} placeholder="user@example.com" type="email" />
{/* Remove confirmation */} setRemoveTarget(null)} title="Remove Member" description="Are you sure you want to remove this member from the organization?" confirmLabel="Remove" onConfirm={async () => { if (removeTarget) { await removeMutation.mutateAsync(removeTarget); setRemoveTarget(null); toast({ title: 'Member removed', variant: 'success' }); } }} />
); } ``` - [ ] **Step 3: Commit** ```bash git add ui/src/pages/tenant/OidcConfigPage.tsx ui/src/pages/tenant/TeamPage.tsx git commit -m "feat: tenant portal — OIDC configuration and team management" ``` --- ## Task 12: Docker Compose + Build + Integration Test **Files:** - Modify: `docker-compose.yml` - Modify: `docker-compose.dev.yml` - [ ] **Step 1: Add Docker socket mount to docker-compose.dev.yml** In the `cameleer-saas` service in `docker-compose.dev.yml`, add: ```yaml volumes: - /var/run/docker.sock:/var/run/docker.sock group_add: - "0" ``` This gives the SaaS container access to the Docker daemon for provisioning. - [ ] **Step 2: Add provisioning env vars to docker-compose.dev.yml** Add to the `cameleer-saas` environment section: ```yaml CAMELEER_SERVER_IMAGE: gitea.siegeln.net/cameleer/cameleer-server:${VERSION:-latest} CAMELEER_SERVER_UI_IMAGE: gitea.siegeln.net/cameleer/cameleer-server-ui:${VERSION:-latest} CAMELEER_NETWORK: cameleer-saas_cameleer CAMELEER_TRAEFIK_NETWORK: cameleer-traefik ``` - [ ] **Step 3: Build frontend** ```bash cd ui && npm run build ``` Expected: Vite build completes with no errors. - [ ] **Step 4: Build backend** ```bash cd /c/Users/Hendrik/Documents/projects/cameleer-saas mvn package -DskipTests -q ``` Expected: BUILD SUCCESS, JAR in `target/`. - [ ] **Step 5: Rebuild Docker image** ```bash docker compose build cameleer-saas ``` - [ ] **Step 6: Start the stack** ```bash docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d ``` - [ ] **Step 7: Verify platform loads** Use Playwright to navigate to `https://desktop-fb5vgj9.siegeln.internal/platform/` and verify: - Sign-in page loads - Login with admin/admin - Vendor user lands on `/vendor/tenants` - Tenant list page renders - Create Tenant form works - Tenant portal pages render - [ ] **Step 8: Commit all remaining changes** ```bash git add docker-compose.yml docker-compose.dev.yml git commit -m "feat: Docker socket mount for tenant provisioning" ``` --- ## Self-Review **Spec coverage check:** - V1 Create tenant ✅ Task 6 (VendorTenantService.createAndProvision) + Task 9 (CreateTenantPage) - V2 Provision server ✅ Task 3 (DockerTenantProvisioner) + Task 6 (VendorTenantService) - V3 Generate license ✅ Task 6 (VendorTenantService.createAndProvision generates license) + Task 6 (renewLicense) - V4 Suspend tenant ✅ Task 6 (VendorTenantService.suspend) + Task 9 (TenantDetailPage) - V5 Fleet health ✅ Task 9 (VendorTenantsPage with health indicators) - V6 Delete tenant ✅ Task 6 (VendorTenantService.delete) + Task 9 (TenantDetailPage) - C1 Dashboard ✅ Task 10 (TenantDashboardPage) - C2 OIDC config ✅ Task 4 (ServerApiClient) + Task 7 (TenantPortalService) + Task 11 (OidcConfigPage) - C3 Team management ✅ Task 5 (LogtoManagementClient) + Task 7 (TenantPortalService) + Task 11 (TeamPage) - C4 Server access ✅ Task 8 (Layout server link) + Task 10 (TenantDashboardPage quick links) - C5 License details ✅ Task 10 (TenantLicensePage) - C6 Org settings ✅ Task 10 (SettingsPage) - Pluggable provisioner ✅ Task 2 (interface + auto-config) + Task 3 (Docker impl) - Route restructure ✅ Task 8 (router.tsx + Layout.tsx) - Security updates ✅ Task 6 (SecurityConfig + TenantIsolationInterceptor) - DB migration ✅ Task 1 (V011) **No placeholders found.** All code is complete and buildable.