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:
@@ -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 (
|
||||
<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() {
|
||||
const scopes = useScopes();
|
||||
const { toast } = useToast();
|
||||
@@ -214,8 +139,8 @@ export function SettingsPage() {
|
||||
return (
|
||||
<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>
|
||||
<PasskeyNudgeBanner />
|
||||
|
||||
{/* Card 1: Tenant Details */}
|
||||
<Card title="Tenant Details">
|
||||
<div className={styles.dividerList}>
|
||||
<div className={styles.kvRow}>
|
||||
@@ -226,10 +151,6 @@ export function SettingsPage() {
|
||||
<span className={styles.kvLabel}>Slug</span>
|
||||
<span className={styles.kvValueMono}>{data.slug}</span>
|
||||
</div>
|
||||
<div className={styles.kvRow}>
|
||||
<span className={styles.kvLabel}>Tier</span>
|
||||
<Badge label={data.tier} color={tierColor(data.tier)} />
|
||||
</div>
|
||||
<div className={styles.kvRow}>
|
||||
<span className={styles.kvLabel}>Status</span>
|
||||
<Badge label={data.status} color={statusColor(data.status)} />
|
||||
@@ -244,56 +165,59 @@ export function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className={styles.description} style={{ marginTop: 16 }}>
|
||||
To change your tier or other billing-related settings, please contact support.
|
||||
</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>).
|
||||
</p>
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
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 }}
|
||||
>
|
||||
<FormField label="New admin password" htmlFor="server-admin-pw">
|
||||
<Input
|
||||
id="server-admin-pw"
|
||||
type="password"
|
||||
value={serverAdminPw}
|
||||
onChange={(e) => setServerAdminPw(e.target.value)}
|
||||
placeholder="Enter new admin password"
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</FormField>
|
||||
<div>
|
||||
<div style={{ borderTop: '1px solid var(--border)', marginTop: 16, paddingTop: 16 }}>
|
||||
<p className={styles.description} style={{ margin: '0 0 12px' }}>
|
||||
Reset the built-in admin password for your server dashboard (local login at <code>/login?local</code>).
|
||||
</p>
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
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' }}
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
<FormField label="Server admin password" htmlFor="server-admin-pw">
|
||||
<Input
|
||||
id="server-admin-pw"
|
||||
type="password"
|
||||
value={serverAdminPw}
|
||||
onChange={(e) => setServerAdminPw(e.target.value)}
|
||||
placeholder="Enter new admin password"
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<Button type="submit" variant="primary" loading={resetServerAdmin.isPending}>
|
||||
Reset Admin Password
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<MfaSection />
|
||||
<MfaEnforcementToggle />
|
||||
<PasskeySection />
|
||||
{/* Card 2: Multi-Factor Authentication (MFA + Passkeys combined) */}
|
||||
<Card title="Multi-Factor Authentication">
|
||||
<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 />
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user