From 2dc75c4361c56067e24b3bb2e8c40292404b7666 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Fri, 10 Apr 2026 07:35:17 +0200 Subject: [PATCH] feat: create initial admin user + add vendor to new tenant orgs When creating a tenant, the vendor can specify adminUsername + adminPassword. The backend creates the user in Logto and assigns them the owner org role. The vendor user is also auto-added to every new org for support access. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../saas/identity/LogtoManagementClient.java | 36 +++++++++++++++++++ .../saas/tenant/dto/CreateTenantRequest.java | 4 ++- .../saas/vendor/VendorTenantService.java | 33 +++++++++++++++-- .../saas/license/LicenseControllerTest.java | 2 +- .../saas/tenant/TenantControllerTest.java | 8 ++--- .../saas/tenant/TenantServiceTest.java | 8 ++--- .../vendor/VendorTenantControllerTest.java | 8 ++--- .../saas/vendor/VendorTenantServiceTest.java | 8 ++--- ui/src/pages/vendor/CreateTenantPage.tsx | 27 +++++++++++++- ui/src/types/api.ts | 2 ++ 10 files changed, 114 insertions(+), 22 deletions(-) diff --git a/src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java b/src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java index 3f95e4d..11be18f 100644 --- a/src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java +++ b/src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java @@ -189,6 +189,42 @@ public class LogtoManagementClient { } } + /** Create a user with username/password and add to org with role. */ + @SuppressWarnings("unchecked") + public String createUserWithPassword(String username, String password, String orgId, String roleId) { + if (!isAvailable()) return null; + try { + var userResp = (Map) restClient.post() + .uri(config.getLogtoEndpoint() + "/api/users") + .header("Authorization", "Bearer " + getAccessToken()) + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("username", username, "password", password, "name", username)) + .retrieve() + .body(Map.class); + String userId = String.valueOf(userResp.get("id")); + addUserToOrganization(orgId, userId); + if (roleId != null) { + assignOrganizationRole(orgId, userId, roleId); + } + log.info("Created user '{}' and added to org {} with role {}", username, orgId, roleId); + return userId; + } catch (Exception e) { + log.error("Failed to create user '{}': {}", username, e.getMessage()); + throw new RuntimeException("User creation failed: " + e.getMessage(), e); + } + } + + /** Find org role ID by name (e.g., "owner", "operator", "viewer"). */ + @SuppressWarnings("unchecked") + public String findOrgRoleIdByName(String roleName) { + var roles = listOrganizationRoles(); + return roles.stream() + .filter(r -> roleName.equals(r.get("name"))) + .map(r -> String.valueOf(r.get("id"))) + .findFirst() + .orElse(null); + } + /** List available organization roles. */ @SuppressWarnings("unchecked") public List> listOrganizationRoles() { diff --git a/src/main/java/net/siegeln/cameleer/saas/tenant/dto/CreateTenantRequest.java b/src/main/java/net/siegeln/cameleer/saas/tenant/dto/CreateTenantRequest.java index b9bed27..c57d84b 100644 --- a/src/main/java/net/siegeln/cameleer/saas/tenant/dto/CreateTenantRequest.java +++ b/src/main/java/net/siegeln/cameleer/saas/tenant/dto/CreateTenantRequest.java @@ -7,5 +7,7 @@ import jakarta.validation.constraints.Size; public record CreateTenantRequest( @NotBlank @Size(max = 255) String name, @NotBlank @Size(max = 100) @Pattern(regexp = "^[a-z0-9][a-z0-9-]*[a-z0-9]$", message = "Slug must be lowercase alphanumeric with hyphens") String slug, - String tier + String tier, + String adminUsername, + String adminPassword ) {} diff --git a/src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java b/src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java index dc6bef3..8007e3c 100644 --- a/src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java +++ b/src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java @@ -58,13 +58,40 @@ public class VendorTenantService { @Transactional public TenantEntity createAndProvision(CreateTenantRequest request, UUID actorId) { - // 1. Create tenant record (sets status = PROVISIONING) + // 1. Create tenant record (sets status = PROVISIONING) + Logto org TenantEntity tenant = tenantService.create(request, actorId); - // 2. Generate license + // 2. Create initial admin user in Logto org (if credentials provided) + if (tenant.getLogtoOrgId() != null && logtoClient.isAvailable()) { + String ownerRoleId = logtoClient.findOrgRoleIdByName("owner"); + + // Create tenant admin + if (request.adminUsername() != null && request.adminPassword() != null) { + try { + logtoClient.createUserWithPassword( + request.adminUsername(), request.adminPassword(), + tenant.getLogtoOrgId(), ownerRoleId); + } catch (Exception e) { + log.warn("Failed to create admin user for tenant {}: {}", tenant.getSlug(), e.getMessage()); + } + } + + // Add the current vendor user to the new org for support access + try { + String vendorUserId = actorId.toString(); + logtoClient.addUserToOrganization(tenant.getLogtoOrgId(), vendorUserId); + if (ownerRoleId != null) { + logtoClient.assignOrganizationRole(tenant.getLogtoOrgId(), vendorUserId, ownerRoleId); + } + } catch (Exception e) { + log.warn("Failed to add vendor to org for tenant {}: {}", tenant.getSlug(), e.getMessage()); + } + } + + // 3. Generate license LicenseEntity license = licenseService.generateLicense(tenant, DEFAULT_LICENSE_VALIDITY, actorId); - // 3. Provision server if provisioner is available + // 4. Provision server if provisioner is available if (tenantProvisioner.isAvailable()) { var provisionRequest = new TenantProvisionRequest( tenant.getId(), tenant.getSlug(), diff --git a/src/test/java/net/siegeln/cameleer/saas/license/LicenseControllerTest.java b/src/test/java/net/siegeln/cameleer/saas/license/LicenseControllerTest.java index f6f7500..6e57711 100644 --- a/src/test/java/net/siegeln/cameleer/saas/license/LicenseControllerTest.java +++ b/src/test/java/net/siegeln/cameleer/saas/license/LicenseControllerTest.java @@ -35,7 +35,7 @@ class LicenseControllerTest { private String createTenantAndGetId() throws Exception { String slug = "license-tenant-" + System.nanoTime(); - var request = new CreateTenantRequest("License Test Org", slug, "MID"); + var request = new CreateTenantRequest("License Test Org", slug, "MID", null, null); var result = mockMvc.perform(post("/api/tenants") .with(jwt().jwt(j -> j diff --git a/src/test/java/net/siegeln/cameleer/saas/tenant/TenantControllerTest.java b/src/test/java/net/siegeln/cameleer/saas/tenant/TenantControllerTest.java index de5b589..a624cf6 100644 --- a/src/test/java/net/siegeln/cameleer/saas/tenant/TenantControllerTest.java +++ b/src/test/java/net/siegeln/cameleer/saas/tenant/TenantControllerTest.java @@ -35,7 +35,7 @@ class TenantControllerTest { @Test void createTenant_returns201() throws Exception { - var request = new CreateTenantRequest("Test Org", "test-org-" + System.nanoTime(), "LOW"); + var request = new CreateTenantRequest("Test Org", "test-org-" + System.nanoTime(), "LOW", null, null); mockMvc.perform(post("/api/tenants") .with(jwt().jwt(j -> j @@ -54,7 +54,7 @@ class TenantControllerTest { @Test void createTenant_returns409ForDuplicateSlug() throws Exception { String slug = "duplicate-slug-" + System.nanoTime(); - var request = new CreateTenantRequest("First", slug, null); + var request = new CreateTenantRequest("First", slug, null, null, null); mockMvc.perform(post("/api/tenants") .with(jwt().jwt(j -> j @@ -77,7 +77,7 @@ class TenantControllerTest { @Test void createTenant_returns401WithoutToken() throws Exception { - var request = new CreateTenantRequest("Test", "no-auth-test", null); + var request = new CreateTenantRequest("Test", "no-auth-test", null, null, null); mockMvc.perform(post("/api/tenants") .contentType(MediaType.APPLICATION_JSON) @@ -88,7 +88,7 @@ class TenantControllerTest { @Test void getTenant_returnsTenantById() throws Exception { String slug = "get-test-" + System.nanoTime(); - var request = new CreateTenantRequest("Get Test", slug, null); + var request = new CreateTenantRequest("Get Test", slug, null, null, null); var createResult = mockMvc.perform(post("/api/tenants") .with(jwt().jwt(j -> j diff --git a/src/test/java/net/siegeln/cameleer/saas/tenant/TenantServiceTest.java b/src/test/java/net/siegeln/cameleer/saas/tenant/TenantServiceTest.java index c12af09..c71de55 100644 --- a/src/test/java/net/siegeln/cameleer/saas/tenant/TenantServiceTest.java +++ b/src/test/java/net/siegeln/cameleer/saas/tenant/TenantServiceTest.java @@ -41,7 +41,7 @@ class TenantServiceTest { @Test void create_savesNewTenantWithCorrectFields() { - var request = new CreateTenantRequest("Acme Corp", "acme-corp", "MID"); + var request = new CreateTenantRequest("Acme Corp", "acme-corp", "MID", null, null); var actorId = UUID.randomUUID(); when(tenantRepository.existsBySlugAndStatusNot("acme-corp", TenantStatus.DELETED)).thenReturn(false); @@ -57,7 +57,7 @@ class TenantServiceTest { @Test void create_throwsForDuplicateSlug() { - var request = new CreateTenantRequest("Acme Corp", "acme-corp", null); + var request = new CreateTenantRequest("Acme Corp", "acme-corp", null, null, null); when(tenantRepository.existsBySlugAndStatusNot("acme-corp", TenantStatus.DELETED)).thenReturn(true); @@ -68,7 +68,7 @@ class TenantServiceTest { @Test void create_logsAuditEvent() { - var request = new CreateTenantRequest("Acme Corp", "acme-corp", null); + var request = new CreateTenantRequest("Acme Corp", "acme-corp", null, null, null); var actorId = UUID.randomUUID(); when(tenantRepository.existsBySlugAndStatusNot("acme-corp", TenantStatus.DELETED)).thenReturn(false); @@ -83,7 +83,7 @@ class TenantServiceTest { @Test void create_defaultsToLowTier() { - var request = new CreateTenantRequest("Acme Corp", "acme-corp", null); + var request = new CreateTenantRequest("Acme Corp", "acme-corp", null, null, null); when(tenantRepository.existsBySlugAndStatusNot("acme-corp", TenantStatus.DELETED)).thenReturn(false); when(tenantRepository.save(any(TenantEntity.class))).thenAnswer(inv -> inv.getArgument(0)); diff --git a/src/test/java/net/siegeln/cameleer/saas/vendor/VendorTenantControllerTest.java b/src/test/java/net/siegeln/cameleer/saas/vendor/VendorTenantControllerTest.java index cc4fd46..21644a5 100644 --- a/src/test/java/net/siegeln/cameleer/saas/vendor/VendorTenantControllerTest.java +++ b/src/test/java/net/siegeln/cameleer/saas/vendor/VendorTenantControllerTest.java @@ -34,7 +34,7 @@ class VendorTenantControllerTest { private ObjectMapper objectMapper; private String createTenant(String name, String slug, String tier) throws Exception { - var request = new CreateTenantRequest(name, slug, tier); + var request = new CreateTenantRequest(name, slug, tier, null, null); var result = mockMvc.perform(post("/api/vendor/tenants") .with(jwt().jwt(j -> j .claim("sub", "test-user") @@ -65,7 +65,7 @@ class VendorTenantControllerTest { @Test void createTenant_returns201() throws Exception { String slug = "create-test-" + System.nanoTime(); - var request = new CreateTenantRequest("Create Test Org", slug, "MID"); + var request = new CreateTenantRequest("Create Test Org", slug, "MID", null, null); mockMvc.perform(post("/api/vendor/tenants") .with(jwt().jwt(j -> j @@ -87,7 +87,7 @@ class VendorTenantControllerTest { String slug = "duplicate-vendor-" + System.nanoTime(); createTenant("First Org", slug, "LOW"); - var request = new CreateTenantRequest("Second Org", slug, "LOW"); + var request = new CreateTenantRequest("Second Org", slug, "LOW", null, null); mockMvc.perform(post("/api/vendor/tenants") .with(jwt().jwt(j -> j .claim("sub", "test-user") @@ -144,7 +144,7 @@ class VendorTenantControllerTest { @Test void createTenant_returns401WithoutAuth() throws Exception { - var request = new CreateTenantRequest("No Auth Org", "no-auth-vendor-" + System.nanoTime(), "LOW"); + var request = new CreateTenantRequest("No Auth Org", "no-auth-vendor-" + System.nanoTime(), "LOW", null, null); mockMvc.perform(post("/api/vendor/tenants") .contentType(MediaType.APPLICATION_JSON) diff --git a/src/test/java/net/siegeln/cameleer/saas/vendor/VendorTenantServiceTest.java b/src/test/java/net/siegeln/cameleer/saas/vendor/VendorTenantServiceTest.java index c2bb776..c1b2220 100644 --- a/src/test/java/net/siegeln/cameleer/saas/vendor/VendorTenantServiceTest.java +++ b/src/test/java/net/siegeln/cameleer/saas/vendor/VendorTenantServiceTest.java @@ -96,7 +96,7 @@ class VendorTenantServiceTest { @Test void createAndProvision_createsTenantAndLicense() throws Exception { - var request = new CreateTenantRequest("Acme Corp", "acme-corp", "LOW"); + var request = new CreateTenantRequest("Acme Corp", "acme-corp", "LOW", null, null); var actorId = UUID.randomUUID(); var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.LOW); var license = licenseWithId(tenant.getId()); @@ -116,7 +116,7 @@ class VendorTenantServiceTest { @Test void createAndProvision_setsActiveWhenProvisionerSucceeds() throws Exception { - var request = new CreateTenantRequest("Acme Corp", "acme-corp", "LOW"); + var request = new CreateTenantRequest("Acme Corp", "acme-corp", "LOW", null, null); var actorId = UUID.randomUUID(); var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.LOW); var license = licenseWithId(tenant.getId()); @@ -137,7 +137,7 @@ class VendorTenantServiceTest { @Test void createAndProvision_setsProvisionErrorOnFailure() throws Exception { - var request = new CreateTenantRequest("Acme Corp", "acme-corp", "LOW"); + var request = new CreateTenantRequest("Acme Corp", "acme-corp", "LOW", null, null); var actorId = UUID.randomUUID(); var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.LOW); var license = licenseWithId(tenant.getId()); @@ -157,7 +157,7 @@ class VendorTenantServiceTest { @Test void createAndProvision_worksWithoutProvisioner() throws Exception { - var request = new CreateTenantRequest("Acme Corp", "acme-corp", "LOW"); + var request = new CreateTenantRequest("Acme Corp", "acme-corp", "LOW", null, null); var actorId = UUID.randomUUID(); var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.LOW); var license = licenseWithId(tenant.getId()); diff --git a/ui/src/pages/vendor/CreateTenantPage.tsx b/ui/src/pages/vendor/CreateTenantPage.tsx index 2f1e8c9..d015548 100644 --- a/ui/src/pages/vendor/CreateTenantPage.tsx +++ b/ui/src/pages/vendor/CreateTenantPage.tsx @@ -15,6 +15,8 @@ export function CreateTenantPage() { const [slug, setSlug] = useState(''); const [slugTouched, setSlugTouched] = useState(false); const [tier, setTier] = useState('LOW'); + const [adminUsername, setAdminUsername] = useState(''); + const [adminPassword, setAdminPassword] = useState(''); useEffect(() => { if (!slugTouched) { @@ -25,7 +27,11 @@ export function CreateTenantPage() { async function handleSubmit(e: React.FormEvent) { e.preventDefault(); try { - const result = await createTenant.mutateAsync({ name, slug, tier }); + const result = await createTenant.mutateAsync({ + name, slug, tier, + adminUsername: adminUsername || undefined, + adminPassword: adminPassword || undefined, + }); toast({ title: 'Tenant created', variant: 'success' }); navigate(`/vendor/tenants/${result.id}`); } catch (err) { @@ -85,6 +91,25 @@ export function CreateTenantPage() { + + setAdminUsername(e.target.value)} + placeholder="admin" + /> + + + + setAdminPassword(e.target.value)} + placeholder="••••••••" + /> + +