feat: create initial admin user + add vendor to new tenant orgs
All checks were successful
CI / build (push) Successful in 50s
CI / docker (push) Successful in 41s

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) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-10 07:35:17 +02:00
parent b7a0530466
commit 2dc75c4361
10 changed files with 114 additions and 22 deletions

View File

@@ -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<String, Object>) 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<Map<String, Object>> listOrganizationRoles() {

View File

@@ -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
) {}

View File

@@ -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(),

View File

@@ -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

View File

@@ -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

View File

@@ -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));

View File

@@ -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)

View File

@@ -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());

View File

@@ -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() {
</select>
</FormField>
<FormField label="Admin Username" htmlFor="admin-user" hint="Initial tenant admin (owner role)">
<Input
id="admin-user"
value={adminUsername}
onChange={(e) => setAdminUsername(e.target.value)}
placeholder="admin"
/>
</FormField>
<FormField label="Admin Password" htmlFor="admin-pass">
<Input
id="admin-pass"
type="password"
value={adminPassword}
onChange={(e) => setAdminPassword(e.target.value)}
placeholder="••••••••"
/>
</FormField>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', paddingTop: 8 }}>
<Button
type="button"

View File

@@ -55,6 +55,8 @@ export interface CreateTenantRequest {
name: string;
slug: string;
tier?: string;
adminUsername?: string;
adminPassword?: string;
}
// Tenant portal API types