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) <noreply@anthropic.com>
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
) {}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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());
|
||||
|
||||
27
ui/src/pages/vendor/CreateTenantPage.tsx
vendored
27
ui/src/pages/vendor/CreateTenantPage.tsx
vendored
@@ -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"
|
||||
|
||||
@@ -55,6 +55,8 @@ export interface CreateTenantRequest {
|
||||
name: string;
|
||||
slug: string;
|
||||
tier?: string;
|
||||
adminUsername?: string;
|
||||
adminPassword?: string;
|
||||
}
|
||||
|
||||
// Tenant portal API types
|
||||
|
||||
Reference in New Issue
Block a user