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. */
|
/** List available organization roles. */
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
public List<Map<String, Object>> listOrganizationRoles() {
|
public List<Map<String, Object>> listOrganizationRoles() {
|
||||||
|
|||||||
@@ -7,5 +7,7 @@ import jakarta.validation.constraints.Size;
|
|||||||
public record CreateTenantRequest(
|
public record CreateTenantRequest(
|
||||||
@NotBlank @Size(max = 255) String name,
|
@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,
|
@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
|
@Transactional
|
||||||
public TenantEntity createAndProvision(CreateTenantRequest request, UUID actorId) {
|
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);
|
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);
|
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()) {
|
if (tenantProvisioner.isAvailable()) {
|
||||||
var provisionRequest = new TenantProvisionRequest(
|
var provisionRequest = new TenantProvisionRequest(
|
||||||
tenant.getId(), tenant.getSlug(),
|
tenant.getId(), tenant.getSlug(),
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class LicenseControllerTest {
|
|||||||
|
|
||||||
private String createTenantAndGetId() throws Exception {
|
private String createTenantAndGetId() throws Exception {
|
||||||
String slug = "license-tenant-" + System.nanoTime();
|
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")
|
var result = mockMvc.perform(post("/api/tenants")
|
||||||
.with(jwt().jwt(j -> j
|
.with(jwt().jwt(j -> j
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class TenantControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createTenant_returns201() throws Exception {
|
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")
|
mockMvc.perform(post("/api/tenants")
|
||||||
.with(jwt().jwt(j -> j
|
.with(jwt().jwt(j -> j
|
||||||
@@ -54,7 +54,7 @@ class TenantControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
void createTenant_returns409ForDuplicateSlug() throws Exception {
|
void createTenant_returns409ForDuplicateSlug() throws Exception {
|
||||||
String slug = "duplicate-slug-" + System.nanoTime();
|
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")
|
mockMvc.perform(post("/api/tenants")
|
||||||
.with(jwt().jwt(j -> j
|
.with(jwt().jwt(j -> j
|
||||||
@@ -77,7 +77,7 @@ class TenantControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createTenant_returns401WithoutToken() throws Exception {
|
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")
|
mockMvc.perform(post("/api/tenants")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
@@ -88,7 +88,7 @@ class TenantControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
void getTenant_returnsTenantById() throws Exception {
|
void getTenant_returnsTenantById() throws Exception {
|
||||||
String slug = "get-test-" + System.nanoTime();
|
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")
|
var createResult = mockMvc.perform(post("/api/tenants")
|
||||||
.with(jwt().jwt(j -> j
|
.with(jwt().jwt(j -> j
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ class TenantServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void create_savesNewTenantWithCorrectFields() {
|
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();
|
var actorId = UUID.randomUUID();
|
||||||
|
|
||||||
when(tenantRepository.existsBySlugAndStatusNot("acme-corp", TenantStatus.DELETED)).thenReturn(false);
|
when(tenantRepository.existsBySlugAndStatusNot("acme-corp", TenantStatus.DELETED)).thenReturn(false);
|
||||||
@@ -57,7 +57,7 @@ class TenantServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void create_throwsForDuplicateSlug() {
|
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);
|
when(tenantRepository.existsBySlugAndStatusNot("acme-corp", TenantStatus.DELETED)).thenReturn(true);
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ class TenantServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void create_logsAuditEvent() {
|
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();
|
var actorId = UUID.randomUUID();
|
||||||
|
|
||||||
when(tenantRepository.existsBySlugAndStatusNot("acme-corp", TenantStatus.DELETED)).thenReturn(false);
|
when(tenantRepository.existsBySlugAndStatusNot("acme-corp", TenantStatus.DELETED)).thenReturn(false);
|
||||||
@@ -83,7 +83,7 @@ class TenantServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void create_defaultsToLowTier() {
|
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.existsBySlugAndStatusNot("acme-corp", TenantStatus.DELETED)).thenReturn(false);
|
||||||
when(tenantRepository.save(any(TenantEntity.class))).thenAnswer(inv -> inv.getArgument(0));
|
when(tenantRepository.save(any(TenantEntity.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ class VendorTenantControllerTest {
|
|||||||
private ObjectMapper objectMapper;
|
private ObjectMapper objectMapper;
|
||||||
|
|
||||||
private String createTenant(String name, String slug, String tier) throws Exception {
|
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")
|
var result = mockMvc.perform(post("/api/vendor/tenants")
|
||||||
.with(jwt().jwt(j -> j
|
.with(jwt().jwt(j -> j
|
||||||
.claim("sub", "test-user")
|
.claim("sub", "test-user")
|
||||||
@@ -65,7 +65,7 @@ class VendorTenantControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
void createTenant_returns201() throws Exception {
|
void createTenant_returns201() throws Exception {
|
||||||
String slug = "create-test-" + System.nanoTime();
|
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")
|
mockMvc.perform(post("/api/vendor/tenants")
|
||||||
.with(jwt().jwt(j -> j
|
.with(jwt().jwt(j -> j
|
||||||
@@ -87,7 +87,7 @@ class VendorTenantControllerTest {
|
|||||||
String slug = "duplicate-vendor-" + System.nanoTime();
|
String slug = "duplicate-vendor-" + System.nanoTime();
|
||||||
createTenant("First Org", slug, "LOW");
|
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")
|
mockMvc.perform(post("/api/vendor/tenants")
|
||||||
.with(jwt().jwt(j -> j
|
.with(jwt().jwt(j -> j
|
||||||
.claim("sub", "test-user")
|
.claim("sub", "test-user")
|
||||||
@@ -144,7 +144,7 @@ class VendorTenantControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createTenant_returns401WithoutAuth() throws Exception {
|
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")
|
mockMvc.perform(post("/api/vendor/tenants")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ class VendorTenantServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createAndProvision_createsTenantAndLicense() throws Exception {
|
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 actorId = UUID.randomUUID();
|
||||||
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.LOW);
|
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.LOW);
|
||||||
var license = licenseWithId(tenant.getId());
|
var license = licenseWithId(tenant.getId());
|
||||||
@@ -116,7 +116,7 @@ class VendorTenantServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createAndProvision_setsActiveWhenProvisionerSucceeds() throws Exception {
|
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 actorId = UUID.randomUUID();
|
||||||
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.LOW);
|
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.LOW);
|
||||||
var license = licenseWithId(tenant.getId());
|
var license = licenseWithId(tenant.getId());
|
||||||
@@ -137,7 +137,7 @@ class VendorTenantServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createAndProvision_setsProvisionErrorOnFailure() throws Exception {
|
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 actorId = UUID.randomUUID();
|
||||||
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.LOW);
|
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.LOW);
|
||||||
var license = licenseWithId(tenant.getId());
|
var license = licenseWithId(tenant.getId());
|
||||||
@@ -157,7 +157,7 @@ class VendorTenantServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createAndProvision_worksWithoutProvisioner() throws Exception {
|
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 actorId = UUID.randomUUID();
|
||||||
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.LOW);
|
var tenant = tenantWithId("Acme Corp", "acme-corp", Tier.LOW);
|
||||||
var license = licenseWithId(tenant.getId());
|
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 [slug, setSlug] = useState('');
|
||||||
const [slugTouched, setSlugTouched] = useState(false);
|
const [slugTouched, setSlugTouched] = useState(false);
|
||||||
const [tier, setTier] = useState('LOW');
|
const [tier, setTier] = useState('LOW');
|
||||||
|
const [adminUsername, setAdminUsername] = useState('');
|
||||||
|
const [adminPassword, setAdminPassword] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!slugTouched) {
|
if (!slugTouched) {
|
||||||
@@ -25,7 +27,11 @@ export function CreateTenantPage() {
|
|||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
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' });
|
toast({ title: 'Tenant created', variant: 'success' });
|
||||||
navigate(`/vendor/tenants/${result.id}`);
|
navigate(`/vendor/tenants/${result.id}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -85,6 +91,25 @@ export function CreateTenantPage() {
|
|||||||
</select>
|
</select>
|
||||||
</FormField>
|
</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 }}>
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', paddingTop: 8 }}>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ export interface CreateTenantRequest {
|
|||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
tier?: string;
|
tier?: string;
|
||||||
|
adminUsername?: string;
|
||||||
|
adminPassword?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tenant portal API types
|
// Tenant portal API types
|
||||||
|
|||||||
Reference in New Issue
Block a user