feat: tenant portal API (dashboard, license, OIDC, team, settings)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-09 21:48:37 +02:00
parent 127834ce4d
commit e56e3fca8a
2 changed files with 269 additions and 0 deletions

View File

@@ -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<TenantPortalService.DashboardData> getDashboard() {
return ResponseEntity.ok(portalService.getDashboard());
}
@GetMapping("/license")
public ResponseEntity<TenantPortalService.LicenseData> getLicense() {
var license = portalService.getLicense();
if (license == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(license);
}
@GetMapping("/oidc")
public ResponseEntity<Map<String, Object>> getOidcConfig() {
return ResponseEntity.ok(portalService.getOidcConfig());
}
@PutMapping("/oidc")
public ResponseEntity<Void> updateOidcConfig(@RequestBody Map<String, Object> body) {
portalService.updateOidcConfig(body);
return ResponseEntity.ok().build();
}
@GetMapping("/team")
public ResponseEntity<List<Map<String, Object>>> listTeamMembers() {
return ResponseEntity.ok(portalService.listTeamMembers());
}
@PostMapping("/team/invite")
public ResponseEntity<Map<String, String>> 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<Void> removeTeamMember(@PathVariable String userId) {
portalService.removeTeamMember(userId);
return ResponseEntity.noContent().build();
}
@PatchMapping("/team/{userId}/role")
public ResponseEntity<Void> changeTeamMemberRole(@PathVariable String userId,
@RequestBody RoleChangeRequest body) {
portalService.changeTeamMemberRole(userId, body.roleId());
return ResponseEntity.ok().build();
}
@GetMapping("/settings")
public ResponseEntity<TenantPortalService.TenantSettingsData> getSettings() {
return ResponseEntity.ok(portalService.getSettings());
}
}

View File

@@ -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<String, Object> limits, Map<String, Object> features
) {}
public record LicenseData(
UUID id, String tier, Map<String, Object> features, Map<String, Object> 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<String, Object> limits = Map.of();
Map<String, Object> 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<String, Object> getOidcConfig() {
TenantEntity tenant = resolveTenant();
String endpoint = tenant.getServerEndpoint();
if (endpoint == null || endpoint.isBlank()) {
return Map.of();
}
return serverApiClient.getOidcConfig(endpoint);
}
public void updateOidcConfig(Map<String, Object> 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<Map<String, Object>> 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()
);
}
}