diff --git a/src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java b/src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java index f870d80..76b8e5d 100644 --- a/src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java +++ b/src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java @@ -209,19 +209,67 @@ public class LogtoManagementClient { } } - /** Create a user in Logto and add to organization with role. */ + /** Delete a user from Logto entirely. */ + public void deleteUser(String userId) { + if (!isAvailable() || userId == null) return; + try { + restClient.delete() + .uri(config.getLogtoEndpoint() + "/api/users/" + userId) + .header("Authorization", "Bearer " + getAccessToken()) + .retrieve() + .toBodilessEntity(); + log.info("Deleted user {} from Logto", userId); + } catch (Exception e) { + log.warn("Failed to delete user {}: {}", userId, e.getMessage()); + } + } + + /** Find a user by email. Returns user map or null if not found. */ + @SuppressWarnings("unchecked") + public Map findUserByEmail(String email) { + if (!isAvailable() || email == null) return null; + try { + var resp = restClient.get() + .uri(config.getLogtoEndpoint() + "/api/users?search=" + + java.net.URLEncoder.encode(email, java.nio.charset.StandardCharsets.UTF_8) + + "&search.primaryEmail=" + + java.net.URLEncoder.encode(email, java.nio.charset.StandardCharsets.UTF_8) + + "&page_size=5") + .header("Authorization", "Bearer " + getAccessToken()) + .retrieve() + .body(List.class); + if (resp == null) return null; + return ((List>) resp).stream() + .filter(u -> email.equalsIgnoreCase(String.valueOf(u.get("primaryEmail")))) + .findFirst() + .orElse(null); + } catch (Exception e) { + log.warn("Failed to find user by email: {}", e.getMessage()); + return null; + } + } + + /** Create a user in Logto (or find existing by email) and add to organization with role. */ @SuppressWarnings("unchecked") public String createAndInviteUser(String email, String orgId, String roleId) { if (!isAvailable()) return null; try { - var userResp = (Map) restClient.post() - .uri(config.getLogtoEndpoint() + "/api/users") - .header("Authorization", "Bearer " + getAccessToken()) - .contentType(MediaType.APPLICATION_JSON) - .body(Map.of("primaryEmail", email, "name", email.split("@")[0])) - .retrieve() - .body(Map.class); - String userId = String.valueOf(userResp.get("id")); + String userId; + // Check if user already exists in Logto + var existing = findUserByEmail(email); + if (existing != null) { + userId = String.valueOf(existing.get("id")); + log.info("User '{}' already exists in Logto ({}), adding to org", email, userId); + } else { + var userResp = (Map) restClient.post() + .uri(config.getLogtoEndpoint() + "/api/users") + .header("Authorization", "Bearer " + getAccessToken()) + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("primaryEmail", email, "name", email.split("@")[0])) + .retrieve() + .body(Map.class); + userId = String.valueOf(userResp.get("id")); + } if (orgId != null) { addUserToOrganization(orgId, userId); if (roleId != null) { diff --git a/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java b/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java index ead647a..29cff96 100644 --- a/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java +++ b/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java @@ -181,13 +181,14 @@ public class TenantPortalService { return logtoClient.listOrganizationMembers(orgId); } - public String inviteTeamMember(String email, String roleId) { + public String inviteTeamMember(String email, String roleName) { TenantEntity tenant = resolveTenant(); String orgId = tenant.getLogtoOrgId(); if (orgId == null || orgId.isBlank()) { throw new IllegalStateException("Tenant has no Logto organization configured"); } - return logtoClient.createAndInviteUser(email, orgId, roleId); + String resolvedRoleId = resolveOrgRoleId(roleName); + return logtoClient.createAndInviteUser(email, orgId, resolvedRoleId); } public void removeTeamMember(String userId) { @@ -197,15 +198,33 @@ public class TenantPortalService { throw new IllegalStateException("Tenant has no Logto organization configured"); } logtoClient.removeUserFromOrganization(orgId, userId); + + // If the user has no remaining org memberships, delete from Logto entirely + var remainingOrgs = logtoClient.getUserOrganizations(userId); + if (remainingOrgs.isEmpty()) { + log.info("User {} has no remaining org memberships — deleting from Logto", userId); + logtoClient.deleteUser(userId); + } } - public void changeTeamMemberRole(String userId, String roleId) { + public void changeTeamMemberRole(String userId, String roleName) { TenantEntity tenant = resolveTenant(); String orgId = tenant.getLogtoOrgId(); if (orgId == null || orgId.isBlank()) { throw new IllegalStateException("Tenant has no Logto organization configured"); } - logtoClient.assignOrganizationRole(orgId, userId, roleId); + String resolvedRoleId = resolveOrgRoleId(roleName); + logtoClient.assignOrganizationRole(orgId, userId, resolvedRoleId); + } + + /** Resolve a role name (e.g. "viewer") to a Logto organization role ID. */ + private String resolveOrgRoleId(String roleName) { + if (roleName == null || roleName.isBlank()) return null; + String resolved = logtoClient.findOrgRoleIdByName(roleName); + if (resolved == null) { + throw new IllegalArgumentException("Unknown organization role: " + roleName); + } + return resolved; } public void resetServerAdminPassword(String newPassword) { diff --git a/ui/src/components/account/MfaSection.tsx b/ui/src/components/account/MfaSection.tsx index 47052a4..ee484ba 100644 --- a/ui/src/components/account/MfaSection.tsx +++ b/ui/src/components/account/MfaSection.tsx @@ -21,7 +21,7 @@ import { } from '../../api/account-hooks'; import styles from '../../styles/platform.module.css'; -export function MfaSection() { +export function MfaSection({ bare }: { bare?: boolean }) { const { toast } = useToast(); const { data: mfaStatus, isLoading: statusLoading } = useAccountMfaStatus(); const setup = useAccountMfaSetup(); @@ -121,18 +121,12 @@ export function MfaSection() { } if (statusLoading) { - return ( - -
- -
-
- ); + const spinner =
; + return bare ? spinner : {spinner}; } - return ( + const content = ( <> -
Status: {mfaStatus?.enrolled ? ( @@ -179,7 +173,12 @@ export function MfaSection() {
)} -
+ + ); + + return ( + <> + {bare ? content : {content}} + const content = ( + <>

Use your fingerprint, face, or security key to sign in faster.

@@ -95,7 +95,7 @@ export function PasskeySection() { No passkeys registered. You can register a passkey during sign-in.

) : ( -
+
{credentials.map((pk) => (
@@ -131,6 +131,8 @@ export function PasskeySection() { ))}
)} - + ); + + return bare ? content : {content}; } diff --git a/ui/src/pages/tenant/SettingsPage.tsx b/ui/src/pages/tenant/SettingsPage.tsx index eecc411..2290ed0 100644 --- a/ui/src/pages/tenant/SettingsPage.tsx +++ b/ui/src/pages/tenant/SettingsPage.tsx @@ -13,14 +13,11 @@ import { import { useTenantSettings, useResetServerAdminPassword, - useUpdateTenantSettings, useTenantAuthSettings, useUpdateTenantAuthSettings, } from '../../api/tenant-hooks'; import { MfaSection } from '../../components/account/MfaSection'; -import { PasskeyNudgeBanner, PasskeySection } from '../../components/account/PasskeySection'; -import { PasswordChangeSection } from '../../components/account/PasswordChangeSection'; +import { PasskeySection } from '../../components/account/PasskeySection'; import { useScopes } from '../../auth/useScopes'; -import { tierColor } from '../../utils/tier'; import styles from '../../styles/platform.module.css'; function statusColor(status: string): 'success' | 'error' | 'warning' | 'auto' { @@ -33,78 +30,6 @@ function statusColor(status: string): 'success' | 'error' | 'warning' | 'auto' { } } -function MfaEnforcementToggle() { - const scopes = useScopes(); - const { toast } = useToast(); - const { data: settings } = useTenantSettings(); - const updateSettings = useUpdateTenantSettings(); - const [confirmEnable, setConfirmEnable] = useState(false); - - if (!scopes.has('tenant:manage')) return null; - - const mfaRequired = settings?.mfaRequired ?? false; - - async function handleToggle() { - if (!mfaRequired) { - setConfirmEnable(true); - return; - } - try { - await updateSettings.mutateAsync({ mfaRequired: false }); - toast({ title: 'MFA requirement disabled for all members', variant: 'success' }); - } catch (err) { - toast({ title: 'Failed to update MFA setting', description: errorMessage(err), variant: 'error' }); - } - } - - async function handleConfirmEnable() { - try { - await updateSettings.mutateAsync({ mfaRequired: true }); - setConfirmEnable(false); - toast({ title: 'MFA is now required for all members', variant: 'success' }); - } catch (err) { - toast({ title: 'Failed to update MFA setting', description: errorMessage(err), variant: 'error' }); - } - } - - return ( - -

- When enabled, all team members will be required to set up multi-factor authentication before accessing this tenant. -

-
- Require MFA for all members - -
- {confirmEnable ? ( -
- - All team members who have not enrolled in MFA will need to set it up on their next login. Are you sure? - -
- - -
-
- ) : ( -
- -
- )} -
- ); -} - function AuthPolicySection() { const scopes = useScopes(); const { toast } = useToast(); @@ -214,8 +139,8 @@ export function SettingsPage() { return (

Settings

- + {/* Card 1: Tenant Details */}
@@ -226,10 +151,6 @@ export function SettingsPage() { Slug {data.slug}
-
- Tier - -
Status @@ -244,56 +165,59 @@ export function SettingsPage() {
-

- To change your tier or other billing-related settings, please contact support. -

-
- - - - -

- Reset the built-in admin password for your server dashboard (local login at /login?local). -

-
{ - e.preventDefault(); - if (serverAdminPw.length < 8) { - toast({ title: 'Password must be at least 8 characters', variant: 'error' }); - return; - } - try { - await resetServerAdmin.mutateAsync(serverAdminPw); - toast({ title: 'Server admin password reset successfully', variant: 'success' }); - setServerAdminPw(''); - } catch (err) { - toast({ title: 'Failed to reset server admin password', description: errorMessage(err), variant: 'error' }); - } - }} - style={{ display: 'flex', flexDirection: 'column', gap: 16, marginTop: 12 }} - > - - setServerAdminPw(e.target.value)} - placeholder="Enter new admin password" - required - minLength={8} - /> - -
+
+

+ Reset the built-in admin password for your server dashboard (local login at /login?local). +

+ { + e.preventDefault(); + if (serverAdminPw.length < 8) { + toast({ title: 'Password must be at least 8 characters', variant: 'error' }); + return; + } + try { + await resetServerAdmin.mutateAsync(serverAdminPw); + toast({ title: 'Server admin password reset successfully', variant: 'success' }); + setServerAdminPw(''); + } catch (err) { + toast({ title: 'Failed to reset server admin password', description: errorMessage(err), variant: 'error' }); + } + }} + style={{ display: 'flex', gap: 8, alignItems: 'flex-end' }} + > +
+ + setServerAdminPw(e.target.value)} + placeholder="Enter new admin password" + required + minLength={8} + /> + +
-
- + +
- - - + {/* Card 2: Multi-Factor Authentication (MFA + Passkeys combined) */} + + +
+

+ Passkeys +

+ +
+
+ + {/* Card 3: Authentication Policy (org-wide settings) */}
);