refactor(tenant): replace tier+username with email-first creation
- Remove tier from create tenant form (always defaults to STARTER, controlled via license minting) - Admin email is now the primary identity field - Username auto-derived from email local part, optionally overridable - Set primaryEmail on Logto user at creation (prevents invalid accounts) - Async tenant delete: PG/ClickHouse cleanup runs after commit instead of blocking the HTTP response - Remove legacy /server/* OIDC redirect URIs from bootstrap Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
) {}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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());
|
||||
|
||||
48
ui/src/pages/vendor/CreateTenantPage.tsx
vendored
48
ui/src/pages/vendor/CreateTenantPage.tsx
vendored
@@ -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() {
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Tier" htmlFor="tenant-tier" required>
|
||||
<select
|
||||
id="tenant-tier"
|
||||
value={tier}
|
||||
onChange={(e) => setTier(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 6,
|
||||
background: 'var(--bg-surface)',
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: '0.875rem',
|
||||
}}
|
||||
>
|
||||
{TIERS.map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
<FormField label="Admin Email" htmlFor="admin-email" hint="Initial tenant admin (owner role)">
|
||||
<Input
|
||||
id="admin-email"
|
||||
type="email"
|
||||
value={adminEmail}
|
||||
onChange={(e) => setAdminEmail(e.target.value)}
|
||||
placeholder="admin@acme.com"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Admin Username" htmlFor="admin-user" hint="Initial tenant admin (owner role). Alphanumeric only, no hyphens.">
|
||||
<FormField label="Username" htmlFor="admin-user" hint="Auto-generated from email, override if needed">
|
||||
<Input
|
||||
id="admin-user"
|
||||
value={adminUsername}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
@@ -95,7 +95,7 @@ export interface VendorTenantDetail {
|
||||
export interface CreateTenantRequest {
|
||||
name: string;
|
||||
slug: string;
|
||||
tier?: string;
|
||||
adminEmail?: string;
|
||||
adminUsername?: string;
|
||||
adminPassword?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user