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