fix: team invite role resolution, user cleanup, and settings page redesign
- 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:
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user