From e56e3fca8a52647f3e8e0ef4eaa4b0fba4e583e9 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 9 Apr 2026 21:48:37 +0200 Subject: [PATCH] feat: tenant portal API (dashboard, license, OIDC, team, settings) Co-Authored-By: Claude Sonnet 4.6 --- .../saas/portal/TenantPortalController.java | 88 +++++++++ .../saas/portal/TenantPortalService.java | 181 ++++++++++++++++++ 2 files changed, 269 insertions(+) create mode 100644 src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalController.java create mode 100644 src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java diff --git a/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalController.java b/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalController.java new file mode 100644 index 0000000..ab1e472 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalController.java @@ -0,0 +1,88 @@ +package net.siegeln.cameleer.saas.portal; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +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.Map; + +@RestController +@RequestMapping("/api/tenant") +public class TenantPortalController { + + private final TenantPortalService portalService; + + public TenantPortalController(TenantPortalService portalService) { + this.portalService = portalService; + } + + // --- Request bodies --- + + public record InviteRequest(String email, String roleId) {} + + public record RoleChangeRequest(String roleId) {} + + // --- Endpoints --- + + @GetMapping("/dashboard") + public ResponseEntity getDashboard() { + return ResponseEntity.ok(portalService.getDashboard()); + } + + @GetMapping("/license") + public ResponseEntity getLicense() { + var license = portalService.getLicense(); + if (license == null) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(license); + } + + @GetMapping("/oidc") + public ResponseEntity> getOidcConfig() { + return ResponseEntity.ok(portalService.getOidcConfig()); + } + + @PutMapping("/oidc") + public ResponseEntity updateOidcConfig(@RequestBody Map body) { + portalService.updateOidcConfig(body); + return ResponseEntity.ok().build(); + } + + @GetMapping("/team") + public ResponseEntity>> listTeamMembers() { + return ResponseEntity.ok(portalService.listTeamMembers()); + } + + @PostMapping("/team/invite") + public ResponseEntity> inviteTeamMember(@RequestBody InviteRequest body) { + String userId = portalService.inviteTeamMember(body.email(), body.roleId()); + return ResponseEntity.ok(Map.of("userId", userId != null ? userId : "")); + } + + @DeleteMapping("/team/{userId}") + public ResponseEntity removeTeamMember(@PathVariable String userId) { + portalService.removeTeamMember(userId); + return ResponseEntity.noContent().build(); + } + + @PatchMapping("/team/{userId}/role") + public ResponseEntity changeTeamMemberRole(@PathVariable String userId, + @RequestBody RoleChangeRequest body) { + portalService.changeTeamMemberRole(userId, body.roleId()); + return ResponseEntity.ok().build(); + } + + @GetMapping("/settings") + public ResponseEntity getSettings() { + return ResponseEntity.ok(portalService.getSettings()); + } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java b/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java new file mode 100644 index 0000000..8ce144f --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java @@ -0,0 +1,181 @@ +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.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; + } + + // --- Inner 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 + ) {} + + // --- Helpers --- + + private TenantEntity resolveTenant() { + UUID tenantId = TenantContext.getTenantId(); + return tenantService.getById(tenantId) + .orElseThrow(() -> new IllegalStateException("Tenant not found: " + tenantId)); + } + + private long daysUntil(Instant instant) { + if (instant == null) return 0; + long days = ChronoUnit.DAYS.between(Instant.now(), instant); + return Math.max(0, days); + } + + // --- Service methods --- + + public DashboardData getDashboard() { + TenantEntity tenant = resolveTenant(); + String endpoint = tenant.getServerEndpoint(); + + boolean serverHealthy = false; + String serverStatus = "NO_ENDPOINT"; + if (endpoint != null && !endpoint.isBlank()) { + var health = serverApiClient.getHealth(endpoint); + serverHealthy = health.healthy(); + serverStatus = health.status(); + } + + String licenseTier = null; + long licenseDaysRemaining = 0; + Map limits = Map.of(); + Map features = Map.of(); + + var licenseOpt = licenseService.getActiveLicense(tenant.getId()); + if (licenseOpt.isPresent()) { + LicenseEntity lic = licenseOpt.get(); + licenseTier = lic.getTier(); + licenseDaysRemaining = daysUntil(lic.getExpiresAt()); + limits = lic.getLimits() != null ? lic.getLimits() : Map.of(); + features = lic.getFeatures() != null ? lic.getFeatures() : Map.of(); + } + + return new DashboardData( + tenant.getName(), tenant.getSlug(), + tenant.getTier().name(), tenant.getStatus().name(), + serverHealthy, serverStatus, endpoint, + licenseTier, licenseDaysRemaining, + limits, features + ); + } + + public LicenseData getLicense() { + TenantEntity tenant = resolveTenant(); + return licenseService.getActiveLicense(tenant.getId()) + .map(lic -> new LicenseData( + lic.getId(), lic.getTier(), + lic.getFeatures() != null ? lic.getFeatures() : Map.of(), + lic.getLimits() != null ? lic.getLimits() : Map.of(), + lic.getIssuedAt(), lic.getExpiresAt(), + lic.getToken(), daysUntil(lic.getExpiresAt()) + )) + .orElse(null); + } + + public Map getOidcConfig() { + TenantEntity tenant = resolveTenant(); + String endpoint = tenant.getServerEndpoint(); + if (endpoint == null || endpoint.isBlank()) { + return Map.of(); + } + return serverApiClient.getOidcConfig(endpoint); + } + + public void updateOidcConfig(Map config) { + TenantEntity tenant = resolveTenant(); + String endpoint = tenant.getServerEndpoint(); + if (endpoint == null || endpoint.isBlank()) { + throw new IllegalStateException("Tenant has no server endpoint configured"); + } + serverApiClient.pushOidcConfig(endpoint, config); + } + + public List> listTeamMembers() { + TenantEntity tenant = resolveTenant(); + String orgId = tenant.getLogtoOrgId(); + if (orgId == null || orgId.isBlank()) { + return List.of(); + } + return logtoClient.listOrganizationMembers(orgId); + } + + public String inviteTeamMember(String email, String roleId) { + TenantEntity tenant = resolveTenant(); + String orgId = tenant.getLogtoOrgId(); + if (orgId == null || orgId.isBlank()) { + throw new IllegalStateException("Tenant has no Logto organization configured"); + } + return logtoClient.createAndInviteUser(email, orgId, roleId); + } + + public void removeTeamMember(String userId) { + TenantEntity tenant = resolveTenant(); + String orgId = tenant.getLogtoOrgId(); + if (orgId == null || orgId.isBlank()) { + throw new IllegalStateException("Tenant has no Logto organization configured"); + } + logtoClient.removeUserFromOrganization(orgId, userId); + } + + public void changeTeamMemberRole(String userId, String roleId) { + TenantEntity tenant = resolveTenant(); + String orgId = tenant.getLogtoOrgId(); + if (orgId == null || orgId.isBlank()) { + throw new IllegalStateException("Tenant has no Logto organization configured"); + } + logtoClient.assignOrganizationRole(orgId, userId, roleId); + } + + public TenantSettingsData getSettings() { + TenantEntity tenant = resolveTenant(); + return new TenantSettingsData( + tenant.getName(), tenant.getSlug(), + tenant.getTier().name(), tenant.getStatus().name(), + tenant.getServerEndpoint(), tenant.getCreatedAt() + ); + } +}