diff --git a/src/main/java/io/cameleer/saas/onboarding/OnboardingService.java b/src/main/java/io/cameleer/saas/onboarding/OnboardingService.java index 60cc92f..b237398 100644 --- a/src/main/java/io/cameleer/saas/onboarding/OnboardingService.java +++ b/src/main/java/io/cameleer/saas/onboarding/OnboardingService.java @@ -44,7 +44,7 @@ public class OnboardingService { // Create tenant via the existing vendor flow (no admin user — we'll add the caller) UUID actorId = resolveActorId(logtoUserId); - var request = new CreateTenantRequest(name, slug, "STARTER", null, null); + var request = new CreateTenantRequest(name, slug, null, null, null); TenantEntity tenant = vendorTenantService.createAndProvision(request, actorId); // Add the calling user to the Logto org as owner diff --git a/src/main/java/io/cameleer/saas/tenant/TenantService.java b/src/main/java/io/cameleer/saas/tenant/TenantService.java index 713ce7d..fe57ffb 100644 --- a/src/main/java/io/cameleer/saas/tenant/TenantService.java +++ b/src/main/java/io/cameleer/saas/tenant/TenantService.java @@ -31,7 +31,7 @@ public class TenantService { var entity = new TenantEntity(); entity.setName(request.name()); entity.setSlug(request.slug()); - entity.setTier(request.tier() != null ? Tier.valueOf(request.tier()) : Tier.STARTER); + entity.setTier(Tier.STARTER); entity.setStatus(TenantStatus.PROVISIONING); var saved = tenantRepository.save(entity); diff --git a/src/main/java/io/cameleer/saas/tenant/dto/CreateTenantRequest.java b/src/main/java/io/cameleer/saas/tenant/dto/CreateTenantRequest.java index 9bdf073..3e4d2c6 100644 --- a/src/main/java/io/cameleer/saas/tenant/dto/CreateTenantRequest.java +++ b/src/main/java/io/cameleer/saas/tenant/dto/CreateTenantRequest.java @@ -7,7 +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 adminEmail, String adminUsername, String adminPassword ) {} diff --git a/src/main/java/io/cameleer/saas/vendor/VendorTenantService.java b/src/main/java/io/cameleer/saas/vendor/VendorTenantService.java index ba44119..295236a 100644 --- a/src/main/java/io/cameleer/saas/vendor/VendorTenantService.java +++ b/src/main/java/io/cameleer/saas/vendor/VendorTenantService.java @@ -95,12 +95,18 @@ public class VendorTenantService { if (tenant.getLogtoOrgId() != null && logtoClient.isAvailable()) { String ownerRoleId = logtoClient.findOrgRoleIdByName("owner"); - // Create tenant admin - if (request.adminUsername() != null && request.adminPassword() != null) { + // Create tenant admin (email is the primary identity) + if (request.adminEmail() != null && request.adminPassword() != null) { try { - logtoClient.createUserWithPassword( - request.adminUsername(), request.adminPassword(), + String email = request.adminEmail(); + String username = request.adminUsername(); + if (username == null || username.isBlank()) { + username = email.substring(0, email.indexOf('@')); + } + String userId = logtoClient.createUserWithPassword( + username, request.adminPassword(), tenant.getLogtoOrgId(), ownerRoleId); + logtoClient.updateUserProfile(userId, Map.of("primaryEmail", email)); } catch (Exception e) { log.warn("Failed to create admin user for tenant {}: {}", tenant.getSlug(), e.getMessage()); } @@ -351,16 +357,6 @@ public class VendorTenantService { } } - // Drop per-tenant PG user + database - try { - tenantDatabaseService.dropTenantDatabase(tenant.getSlug()); - } catch (Exception e) { - log.warn("Failed to drop tenant database for {}: {}", tenant.getSlug(), e.getMessage()); - } - - // Erase tenant data from ClickHouse (GDPR) - dataCleanupService.cleanupClickHouse(tenant.getSlug()); - // Soft-delete tenant.setStatus(TenantStatus.DELETED); tenantRepository.save(tenant); @@ -368,6 +364,29 @@ public class VendorTenantService { auditService.log(actorId, null, tenantId, AuditAction.TENANT_DELETE, tenant.getSlug(), null, null, "SUCCESS", null); + + // Drop databases and erase ClickHouse data asynchronously after commit + String slug = tenant.getSlug(); + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + self.deleteDataAsync(slug); + } + }); + } + + @Async + public void deleteDataAsync(String slug) { + try { + tenantDatabaseService.dropTenantDatabase(slug); + } catch (Exception e) { + log.warn("Failed to drop tenant database for {}: {}", slug, e.getMessage()); + } + try { + dataCleanupService.cleanupClickHouse(slug); + } catch (Exception e) { + log.warn("Failed to clean up ClickHouse data for {}: {}", slug, e.getMessage()); + } } /** diff --git a/src/test/java/io/cameleer/saas/license/LicenseControllerTest.java b/src/test/java/io/cameleer/saas/license/LicenseControllerTest.java index a2b9b34..5e6d646 100644 --- a/src/test/java/io/cameleer/saas/license/LicenseControllerTest.java +++ b/src/test/java/io/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, "TEAM", null, null); + var request = new CreateTenantRequest("License Test Org", slug, null, null, null); var result = mockMvc.perform(post("/api/tenants") .with(jwt().jwt(j -> j diff --git a/src/test/java/io/cameleer/saas/tenant/TenantControllerTest.java b/src/test/java/io/cameleer/saas/tenant/TenantControllerTest.java index f47d682..7e52066 100644 --- a/src/test/java/io/cameleer/saas/tenant/TenantControllerTest.java +++ b/src/test/java/io/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(), "STARTER", null, null); + var request = new CreateTenantRequest("Test Org", "test-org-" + System.nanoTime(), null, null, null); mockMvc.perform(post("/api/tenants") .with(jwt().jwt(j -> j diff --git a/src/test/java/io/cameleer/saas/tenant/TenantServiceTest.java b/src/test/java/io/cameleer/saas/tenant/TenantServiceTest.java index 89d92a8..5f7fa8c 100644 --- a/src/test/java/io/cameleer/saas/tenant/TenantServiceTest.java +++ b/src/test/java/io/cameleer/saas/tenant/TenantServiceTest.java @@ -41,7 +41,7 @@ class TenantServiceTest { @Test void create_savesNewTenantWithCorrectFields() { - var request = new CreateTenantRequest("Acme Corp", "acme-corp", "TEAM", null, 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); @@ -51,7 +51,7 @@ class TenantServiceTest { assertThat(result.getName()).isEqualTo("Acme Corp"); assertThat(result.getSlug()).isEqualTo("acme-corp"); - assertThat(result.getTier()).isEqualTo(Tier.TEAM); + assertThat(result.getTier()).isEqualTo(Tier.STARTER); assertThat(result.getStatus()).isEqualTo(TenantStatus.PROVISIONING); } diff --git a/src/test/java/io/cameleer/saas/vendor/VendorTenantControllerTest.java b/src/test/java/io/cameleer/saas/vendor/VendorTenantControllerTest.java index 0eaf9ff..d0fc1b6 100644 --- a/src/test/java/io/cameleer/saas/vendor/VendorTenantControllerTest.java +++ b/src/test/java/io/cameleer/saas/vendor/VendorTenantControllerTest.java @@ -33,8 +33,8 @@ class VendorTenantControllerTest { @Autowired private ObjectMapper objectMapper; - private String createTenant(String name, String slug, String tier) throws Exception { - var request = new CreateTenantRequest(name, slug, tier, null, null); + private String createTenant(String name, String slug) throws Exception { + var request = new CreateTenantRequest(name, slug, null, null, null); var result = mockMvc.perform(post("/api/vendor/tenants") .with(jwt().jwt(j -> j .claim("sub", "test-user") @@ -51,7 +51,7 @@ class VendorTenantControllerTest { @Test void listTenants_returnsAllTenants() throws Exception { String slug = "list-test-" + System.nanoTime(); - createTenant("List Test Org", slug, "STARTER"); + createTenant("List Test Org", slug); mockMvc.perform(get("/api/vendor/tenants") .with(jwt().jwt(j -> j @@ -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, "TEAM", null, null); + var request = new CreateTenantRequest("Create Test Org", slug, null, null, null); mockMvc.perform(post("/api/vendor/tenants") .with(jwt().jwt(j -> j @@ -78,16 +78,16 @@ class VendorTenantControllerTest { .andExpect(status().isCreated()) .andExpect(jsonPath("$.name").value("Create Test Org")) .andExpect(jsonPath("$.slug").value(slug)) - .andExpect(jsonPath("$.tier").value("TEAM")) + .andExpect(jsonPath("$.tier").value("STARTER")) .andExpect(jsonPath("$.id").isNotEmpty()); } @Test void createTenant_returns409ForDuplicateSlug() throws Exception { String slug = "duplicate-vendor-" + System.nanoTime(); - createTenant("First Org", slug, "STARTER"); + createTenant("First Org", slug); - var request = new CreateTenantRequest("Second Org", slug, "STARTER", null, null); + var request = new CreateTenantRequest("Second Org", slug, null, null, null); mockMvc.perform(post("/api/vendor/tenants") .with(jwt().jwt(j -> j .claim("sub", "test-user") @@ -102,7 +102,7 @@ class VendorTenantControllerTest { @Test void getTenantDetail_returnsDetailWithServerStatus() throws Exception { String slug = "detail-test-" + System.nanoTime(); - String id = createTenant("Detail Test Org", slug, "STARTER"); + String id = createTenant("Detail Test Org", slug); mockMvc.perform(get("/api/vendor/tenants/" + id) .with(jwt().jwt(j -> j @@ -118,7 +118,7 @@ class VendorTenantControllerTest { @Test void suspendTenant_returnsUpdatedStatus() throws Exception { String slug = "suspend-test-" + System.nanoTime(); - String id = createTenant("Suspend Test Org", slug, "STARTER"); + String id = createTenant("Suspend Test Org", slug); mockMvc.perform(post("/api/vendor/tenants/" + id + "/suspend") .with(jwt().jwt(j -> j @@ -132,7 +132,7 @@ class VendorTenantControllerTest { @Test void deleteTenant_returns204() throws Exception { String slug = "delete-test-" + System.nanoTime(); - String id = createTenant("Delete Test Org", slug, "STARTER"); + String id = createTenant("Delete Test Org", slug); mockMvc.perform(delete("/api/vendor/tenants/" + id) .with(jwt().jwt(j -> j @@ -144,7 +144,7 @@ class VendorTenantControllerTest { @Test void createTenant_returns401WithoutAuth() throws Exception { - var request = new CreateTenantRequest("No Auth Org", "no-auth-vendor-" + System.nanoTime(), "STARTER", null, null); + var request = new CreateTenantRequest("No Auth Org", "no-auth-vendor-" + System.nanoTime(), null, null, null); mockMvc.perform(post("/api/vendor/tenants") .contentType(MediaType.APPLICATION_JSON) diff --git a/src/test/java/io/cameleer/saas/vendor/VendorTenantServiceTest.java b/src/test/java/io/cameleer/saas/vendor/VendorTenantServiceTest.java index 90b93ed..e15dee7 100644 --- a/src/test/java/io/cameleer/saas/vendor/VendorTenantServiceTest.java +++ b/src/test/java/io/cameleer/saas/vendor/VendorTenantServiceTest.java @@ -151,7 +151,7 @@ class VendorTenantServiceTest { @Test void createAndProvision_createsTenantAndLicense() throws Exception { - var request = new CreateTenantRequest("Acme Corp", "acme-corp", "STARTER", null, null); + var request = new CreateTenantRequest("Acme Corp", "acme-corp", null, null, null); var actorId = UUID.randomUUID(); var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.STARTER); var license = licenseWithId(tenant.getId()); @@ -171,7 +171,7 @@ class VendorTenantServiceTest { @Test void createAndProvision_setsActiveWhenProvisionerSucceeds() throws Exception { - var request = new CreateTenantRequest("Acme Corp", "acme-corp", "STARTER", null, null); + var request = new CreateTenantRequest("Acme Corp", "acme-corp", null, null, null); var actorId = UUID.randomUUID(); var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.STARTER); var license = licenseWithId(tenant.getId()); @@ -195,7 +195,7 @@ class VendorTenantServiceTest { @Test void createAndProvision_setsProvisionErrorOnFailure() throws Exception { - var request = new CreateTenantRequest("Acme Corp", "acme-corp", "STARTER", null, null); + var request = new CreateTenantRequest("Acme Corp", "acme-corp", null, null, null); var actorId = UUID.randomUUID(); var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.STARTER); var license = licenseWithId(tenant.getId()); @@ -218,7 +218,7 @@ class VendorTenantServiceTest { @Test void createAndProvision_worksWithoutProvisioner() throws Exception { - var request = new CreateTenantRequest("Acme Corp", "acme-corp", "STARTER", null, null); + var request = new CreateTenantRequest("Acme Corp", "acme-corp", null, null, null); var actorId = UUID.randomUUID(); var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.STARTER); var license = licenseWithId(tenant.getId()); diff --git a/ui/src/pages/vendor/CreateTenantPage.tsx b/ui/src/pages/vendor/CreateTenantPage.tsx index 138bfb2..f90d465 100644 --- a/ui/src/pages/vendor/CreateTenantPage.tsx +++ b/ui/src/pages/vendor/CreateTenantPage.tsx @@ -5,8 +5,6 @@ import { useCreateTenant } from '../../api/vendor-hooks'; import { errorMessage } from '../../api/client'; import { toSlug } from '../../utils/slug'; -const TIERS = ['STARTER', 'TEAM', 'BUSINESS', 'ENTERPRISE']; - export function CreateTenantPage() { const navigate = useNavigate(); const { toast } = useToast(); @@ -15,8 +13,9 @@ export function CreateTenantPage() { const [name, setName] = useState(''); const [slug, setSlug] = useState(''); const [slugTouched, setSlugTouched] = useState(false); - const [tier, setTier] = useState('STARTER'); + const [adminEmail, setAdminEmail] = useState(''); const [adminUsername, setAdminUsername] = useState(''); + const [usernameTouched, setUsernameTouched] = useState(false); const [adminPassword, setAdminPassword] = useState(''); useEffect(() => { @@ -25,11 +24,18 @@ export function CreateTenantPage() { } }, [name, slugTouched]); + useEffect(() => { + if (!usernameTouched && adminEmail.includes('@')) { + setAdminUsername(adminEmail.substring(0, adminEmail.indexOf('@')).replace(/[^a-zA-Z0-9]/g, '')); + } + }, [adminEmail, usernameTouched]); + async function handleSubmit(e: React.FormEvent) { e.preventDefault(); try { const result = await createTenant.mutateAsync({ - name, slug, tier, + name, slug, + adminEmail: adminEmail || undefined, adminUsername: adminUsername || undefined, adminPassword: adminPassword || undefined, }); @@ -71,32 +77,24 @@ export function CreateTenantPage() { /> - - + + setAdminEmail(e.target.value)} + placeholder="admin@acme.com" + /> - + setAdminUsername(e.target.value.replace(/[^a-zA-Z0-9]/g, ''))} + onChange={(e) => { + setAdminUsername(e.target.value.replace(/[^a-zA-Z0-9]/g, '')); + setUsernameTouched(true); + }} placeholder="admin" /> diff --git a/ui/src/types/api.ts b/ui/src/types/api.ts index f2488ef..03cf640 100644 --- a/ui/src/types/api.ts +++ b/ui/src/types/api.ts @@ -95,7 +95,7 @@ export interface VendorTenantDetail { export interface CreateTenantRequest { name: string; slug: string; - tier?: string; + adminEmail?: string; adminUsername?: string; adminPassword?: string; }