fix: team invite role resolution, user cleanup, and settings page redesign
All checks were successful
CI / build (push) Successful in 2m9s
CI / docker (push) Successful in 1m33s

- Resolve org role names to Logto role IDs in invite and role change flows
  (fixes entity.relation_foreign_key_not_found on invite)
- Handle existing Logto users on re-invite instead of failing with
  email_already_in_use
- Delete users from Logto when removed from last org membership
- Consolidate tenant settings page into 3 cards: Tenant Details, MFA,
  Authentication Policy — remove duplicate MFA Enforcement and Change
  Password (now in Account Settings)
- Make passkey list scrollable

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-27 22:36:21 +02:00
parent e21a9d6046
commit 7fc8a4d407
5 changed files with 148 additions and 156 deletions

View File

@@ -209,11 +209,58 @@ 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<String, Object> 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<Map<String, Object>>) 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") @SuppressWarnings("unchecked")
public String createAndInviteUser(String email, String orgId, String roleId) { public String createAndInviteUser(String email, String orgId, String roleId) {
if (!isAvailable()) return null; if (!isAvailable()) return null;
try { try {
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<String, Object>) restClient.post() var userResp = (Map<String, Object>) restClient.post()
.uri(config.getLogtoEndpoint() + "/api/users") .uri(config.getLogtoEndpoint() + "/api/users")
.header("Authorization", "Bearer " + getAccessToken()) .header("Authorization", "Bearer " + getAccessToken())
@@ -221,7 +268,8 @@ public class LogtoManagementClient {
.body(Map.of("primaryEmail", email, "name", email.split("@")[0])) .body(Map.of("primaryEmail", email, "name", email.split("@")[0]))
.retrieve() .retrieve()
.body(Map.class); .body(Map.class);
String userId = String.valueOf(userResp.get("id")); userId = String.valueOf(userResp.get("id"));
}
if (orgId != null) { if (orgId != null) {
addUserToOrganization(orgId, userId); addUserToOrganization(orgId, userId);
if (roleId != null) { if (roleId != null) {

View File

@@ -181,13 +181,14 @@ public class TenantPortalService {
return logtoClient.listOrganizationMembers(orgId); return logtoClient.listOrganizationMembers(orgId);
} }
public String inviteTeamMember(String email, String roleId) { public String inviteTeamMember(String email, String roleName) {
TenantEntity tenant = resolveTenant(); TenantEntity tenant = resolveTenant();
String orgId = tenant.getLogtoOrgId(); String orgId = tenant.getLogtoOrgId();
if (orgId == null || orgId.isBlank()) { if (orgId == null || orgId.isBlank()) {
throw new IllegalStateException("Tenant has no Logto organization configured"); 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) { public void removeTeamMember(String userId) {
@@ -197,15 +198,33 @@ public class TenantPortalService {
throw new IllegalStateException("Tenant has no Logto organization configured"); throw new IllegalStateException("Tenant has no Logto organization configured");
} }
logtoClient.removeUserFromOrganization(orgId, userId); 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(); TenantEntity tenant = resolveTenant();
String orgId = tenant.getLogtoOrgId(); String orgId = tenant.getLogtoOrgId();
if (orgId == null || orgId.isBlank()) { if (orgId == null || orgId.isBlank()) {
throw new IllegalStateException("Tenant has no Logto organization configured"); 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) { public void resetServerAdminPassword(String newPassword) {

View File

@@ -21,7 +21,7 @@ import {
} from '../../api/account-hooks'; } from '../../api/account-hooks';
import styles from '../../styles/platform.module.css'; import styles from '../../styles/platform.module.css';
export function MfaSection() { export function MfaSection({ bare }: { bare?: boolean }) {
const { toast } = useToast(); const { toast } = useToast();
const { data: mfaStatus, isLoading: statusLoading } = useAccountMfaStatus(); const { data: mfaStatus, isLoading: statusLoading } = useAccountMfaStatus();
const setup = useAccountMfaSetup(); const setup = useAccountMfaSetup();
@@ -121,18 +121,12 @@ export function MfaSection() {
} }
if (statusLoading) { if (statusLoading) {
return ( const spinner = <div style={{ display: 'flex', justifyContent: 'center', padding: 24 }}><Spinner /></div>;
<Card title="Multi-Factor Authentication"> return bare ? spinner : <Card title="Multi-Factor Authentication">{spinner}</Card>;
<div style={{ display: 'flex', justifyContent: 'center', padding: 24 }}>
<Spinner />
</div>
</Card>
);
} }
return ( const content = (
<> <>
<Card title="Multi-Factor Authentication">
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<span className={styles.description} style={{ margin: 0 }}>Status:</span> <span className={styles.description} style={{ margin: 0 }}>Status:</span>
{mfaStatus?.enrolled ? ( {mfaStatus?.enrolled ? (
@@ -179,7 +173,12 @@ export function MfaSection() {
</div> </div>
</> </>
)} )}
</Card> </>
);
return (
<>
{bare ? content : <Card title="Multi-Factor Authentication">{content}</Card>}
<Modal <Modal
open={modalOpen} open={modalOpen}

View File

@@ -39,7 +39,7 @@ export function PasskeyNudgeBanner() {
); );
} }
export function PasskeySection() { export function PasskeySection({ bare }: { bare?: boolean }) {
const { toast } = useToast(); const { toast } = useToast();
const { data: passkeys, isLoading } = useAccountPasskeyList(); const { data: passkeys, isLoading } = useAccountPasskeyList();
const renamePasskey = useAccountRenamePasskey(); const renamePasskey = useAccountRenamePasskey();
@@ -85,8 +85,8 @@ export function PasskeySection() {
if (isLoading) return null; if (isLoading) return null;
const credentials = passkeys ?? []; const credentials = passkeys ?? [];
return ( const content = (
<Card title="Passkeys"> <>
<p className={styles.description} style={{ marginTop: 0 }}> <p className={styles.description} style={{ marginTop: 0 }}>
Use your fingerprint, face, or security key to sign in faster. Use your fingerprint, face, or security key to sign in faster.
</p> </p>
@@ -95,7 +95,7 @@ export function PasskeySection() {
No passkeys registered. You can register a passkey during sign-in. No passkeys registered. You can register a passkey during sign-in.
</p> </p>
) : ( ) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> <div style={{ maxHeight: 240, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 12 }}>
{credentials.map((pk) => ( {credentials.map((pk) => (
<div key={pk.id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '8px 0', borderBottom: '1px solid var(--border)' }}> <div key={pk.id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '8px 0', borderBottom: '1px solid var(--border)' }}>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
@@ -131,6 +131,8 @@ export function PasskeySection() {
))} ))}
</div> </div>
)} )}
</Card> </>
); );
return bare ? content : <Card title="Passkeys">{content}</Card>;
} }

View File

@@ -13,14 +13,11 @@ import {
import { import {
useTenantSettings, useTenantSettings,
useResetServerAdminPassword, useResetServerAdminPassword,
useUpdateTenantSettings,
useTenantAuthSettings, useUpdateTenantAuthSettings, useTenantAuthSettings, useUpdateTenantAuthSettings,
} from '../../api/tenant-hooks'; } from '../../api/tenant-hooks';
import { MfaSection } from '../../components/account/MfaSection'; import { MfaSection } from '../../components/account/MfaSection';
import { PasskeyNudgeBanner, PasskeySection } from '../../components/account/PasskeySection'; import { PasskeySection } from '../../components/account/PasskeySection';
import { PasswordChangeSection } from '../../components/account/PasswordChangeSection';
import { useScopes } from '../../auth/useScopes'; import { useScopes } from '../../auth/useScopes';
import { tierColor } from '../../utils/tier';
import styles from '../../styles/platform.module.css'; import styles from '../../styles/platform.module.css';
function statusColor(status: string): 'success' | 'error' | 'warning' | 'auto' { 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 (
<Card title="MFA Enforcement">
<p className={styles.description} style={{ marginTop: 0 }}>
When enabled, all team members will be required to set up multi-factor authentication before accessing this tenant.
</p>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ fontSize: '0.875rem' }}>Require MFA for all members</span>
<Badge label={mfaRequired ? 'Required' : 'Optional'} color={mfaRequired ? 'success' : 'auto'} />
</div>
{confirmEnable ? (
<div style={{ marginTop: 12 }}>
<Alert variant="warning" title="Confirm MFA requirement">
All team members who have not enrolled in MFA will need to set it up on their next login. Are you sure?
</Alert>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<Button variant="primary" onClick={handleConfirmEnable} loading={updateSettings.isPending}>
Yes, require MFA
</Button>
<Button variant="secondary" onClick={() => setConfirmEnable(false)}>
Cancel
</Button>
</div>
</div>
) : (
<div style={{ marginTop: 12 }}>
<Button
variant={mfaRequired ? 'danger' : 'primary'}
onClick={handleToggle}
loading={updateSettings.isPending}
>
{mfaRequired ? 'Disable MFA requirement' : 'Enable MFA requirement'}
</Button>
</div>
)}
</Card>
);
}
function AuthPolicySection() { function AuthPolicySection() {
const scopes = useScopes(); const scopes = useScopes();
const { toast } = useToast(); const { toast } = useToast();
@@ -214,8 +139,8 @@ export function SettingsPage() {
return ( return (
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 20, overflowY: 'auto', flex: 1 }}> <div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 20, overflowY: 'auto', flex: 1 }}>
<h1 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 600 }}>Settings</h1> <h1 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 600 }}>Settings</h1>
<PasskeyNudgeBanner />
{/* Card 1: Tenant Details */}
<Card title="Tenant Details"> <Card title="Tenant Details">
<div className={styles.dividerList}> <div className={styles.dividerList}>
<div className={styles.kvRow}> <div className={styles.kvRow}>
@@ -226,10 +151,6 @@ export function SettingsPage() {
<span className={styles.kvLabel}>Slug</span> <span className={styles.kvLabel}>Slug</span>
<span className={styles.kvValueMono}>{data.slug}</span> <span className={styles.kvValueMono}>{data.slug}</span>
</div> </div>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Tier</span>
<Badge label={data.tier} color={tierColor(data.tier)} />
</div>
<div className={styles.kvRow}> <div className={styles.kvRow}>
<span className={styles.kvLabel}>Status</span> <span className={styles.kvLabel}>Status</span>
<Badge label={data.status} color={statusColor(data.status)} /> <Badge label={data.status} color={statusColor(data.status)} />
@@ -244,15 +165,8 @@ export function SettingsPage() {
</div> </div>
</div> </div>
<p className={styles.description} style={{ marginTop: 16 }}> <div style={{ borderTop: '1px solid var(--border)', marginTop: 16, paddingTop: 16 }}>
To change your tier or other billing-related settings, please contact support. <p className={styles.description} style={{ margin: '0 0 12px' }}>
</p>
</Card>
<PasswordChangeSection />
<Card title="Server Admin Password">
<p className={styles.description} style={{ marginTop: 0 }}>
Reset the built-in admin password for your server dashboard (local login at <code>/login?local</code>). Reset the built-in admin password for your server dashboard (local login at <code>/login?local</code>).
</p> </p>
<form <form
@@ -270,9 +184,10 @@ export function SettingsPage() {
toast({ title: 'Failed to reset server admin password', description: errorMessage(err), variant: 'error' }); toast({ title: 'Failed to reset server admin password', description: errorMessage(err), variant: 'error' });
} }
}} }}
style={{ display: 'flex', flexDirection: 'column', gap: 16, marginTop: 12 }} style={{ display: 'flex', gap: 8, alignItems: 'flex-end' }}
> >
<FormField label="New admin password" htmlFor="server-admin-pw"> <div style={{ flex: 1 }}>
<FormField label="Server admin password" htmlFor="server-admin-pw">
<Input <Input
id="server-admin-pw" id="server-admin-pw"
type="password" type="password"
@@ -283,17 +198,26 @@ export function SettingsPage() {
minLength={8} minLength={8}
/> />
</FormField> </FormField>
<div>
<Button type="submit" variant="primary" loading={resetServerAdmin.isPending}>
Reset Admin Password
</Button>
</div> </div>
<Button type="submit" variant="primary" loading={resetServerAdmin.isPending}>
Reset
</Button>
</form> </form>
</div>
</Card> </Card>
<MfaSection /> {/* Card 2: Multi-Factor Authentication (MFA + Passkeys combined) */}
<MfaEnforcementToggle /> <Card title="Multi-Factor Authentication">
<PasskeySection /> <MfaSection bare />
<div style={{ borderTop: '1px solid var(--border)', marginTop: 16, paddingTop: 16 }}>
<h3 style={{ margin: '0 0 8px', fontSize: '0.875rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-muted)' }}>
Passkeys
</h3>
<PasskeySection bare />
</div>
</Card>
{/* Card 3: Authentication Policy (org-wide settings) */}
<AuthPolicySection /> <AuthPolicySection />
</div> </div>
); );