feat: Separate bypass vs disabled users in Duo UI

Bypass = security risk (MFA not enforced) — shown in red, expandable panel
Disabled = locked out, no threat — shown in muted gray, separate expandable panel

- Split /api/duo/status counts into bypass and disabled separately
- /api/duo/users/flagged returns { bypass: [], disabled: [] } instead of flat list
- Overview card: only bypass triggers red warning icon (disabled does not)
- Detail page: two separate expandable sections with distinct severity styling
- Both sections include user, email, account name, enrolled status, last login, notes
- Covers all accounts (parent + children)
This commit is contained in:
lorentz 2026-03-27 11:41:52 -04:00
parent 5037d64948
commit 5f4e326804
4 changed files with 128 additions and 75 deletions

View file

@ -3,7 +3,7 @@
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { RefreshCw, ArrowLeft, Loader2, CheckCircle2, AlertTriangle, Shield, Users, Smartphone, ScrollText, Layers, AppWindow, ChevronDown, ChevronUp, ShieldOff } from 'lucide-react';
import { RefreshCw, ArrowLeft, Loader2, CheckCircle2, AlertTriangle, Shield, Users, Smartphone, ScrollText, Layers, AppWindow, ChevronDown, ChevronUp, ShieldOff, ShieldAlert, ShieldX } from 'lucide-react';
interface DuoStatus {
connected: boolean;
@ -16,7 +16,8 @@ interface DuoStatus {
authLogs: number;
groups: number;
integrations: number;
bypassed: number;
bypass: number;
disabled: number;
};
}
@ -50,8 +51,10 @@ export default function DuoSyncPage() {
const [accounts, setAccounts] = useState<DuoAccount[]>([]);
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
const [flaggedUsers, setFlaggedUsers] = useState<FlaggedUser[]>([]);
const [showFlagged, setShowFlagged] = useState(false);
const [bypassUsers, setBypassUsers] = useState<FlaggedUser[]>([]);
const [disabledUsers, setDisabledUsers] = useState<FlaggedUser[]>([]);
const [showBypass, setShowBypass] = useState(false);
const [showDisabled, setShowDisabled] = useState(false);
const fetchData = async () => {
try {
@ -67,7 +70,8 @@ export default function DuoSyncPage() {
}
if (flaggedRes.ok) {
const d = await flaggedRes.json();
setFlaggedUsers(d.users ?? []);
setBypassUsers(d.bypass ?? []);
setDisabledUsers(d.disabled ?? []);
}
} catch (e) {
console.error(e);
@ -151,70 +155,56 @@ export default function DuoSyncPage() {
)}
{/* Last sync + warnings */}
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
<span>Last sync: <strong className="text-foreground">{fmtDate(status?.lastSync ?? null)}</strong></span>
{flaggedUsers.length > 0 && (
{bypassUsers.length > 0 && (
<button
onClick={() => setShowFlagged(!showFlagged)}
className="flex items-center gap-1 text-yellow-600 hover:text-yellow-500 transition-colors"
onClick={() => setShowBypass(!showBypass)}
className="flex items-center gap-1 text-red-600 hover:text-red-500 transition-colors font-medium"
>
<AlertTriangle className="w-4 h-4" />
{flaggedUsers.length} user(s) in bypass/disabled status
{showFlagged ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
<ShieldAlert className="w-4 h-4" />
{bypassUsers.length} user(s) in bypass MFA not enforced
{showBypass ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
</button>
)}
{disabledUsers.length > 0 && (
<button
onClick={() => setShowDisabled(!showDisabled)}
className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
>
<ShieldOff className="w-4 h-4" />
{disabledUsers.length} disabled user(s)
{showDisabled ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
</button>
)}
</div>
{/* Bypass / Disabled users panel */}
{showFlagged && flaggedUsers.length > 0 && (
<div className="rounded-lg border border-yellow-500/30 bg-yellow-500/5 overflow-hidden">
<div className="px-4 py-3 border-b border-yellow-500/20 flex items-center gap-2">
<ShieldOff className="w-4 h-4 text-yellow-600" />
<h3 className="text-sm font-semibold text-yellow-700">Bypass / Disabled Users ({flaggedUsers.length})</h3>
</div>
<table className="w-full text-sm">
<thead className="bg-yellow-500/5">
<tr>
<th className="text-left px-4 py-2 font-medium">User</th>
<th className="text-left px-4 py-2 font-medium">Email</th>
<th className="text-left px-4 py-2 font-medium">Account</th>
<th className="text-center px-4 py-2 font-medium">Status</th>
<th className="text-center px-4 py-2 font-medium">Enrolled</th>
<th className="text-left px-4 py-2 font-medium">Last Login</th>
<th className="text-left px-4 py-2 font-medium">Notes</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{flaggedUsers.map(u => (
<tr key={u.user_id} className="hover:bg-yellow-500/5">
<td className="px-4 py-2">
<div className="font-medium">{u.realname || u.username}</div>
{u.realname && <div className="text-xs text-muted-foreground">{u.username}</div>}
</td>
<td className="px-4 py-2 text-muted-foreground">{u.email || '—'}</td>
<td className="px-4 py-2">{u.account_name}</td>
<td className="px-4 py-2 text-center">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
u.status === 'bypass'
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400'
: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'
}`}>
{u.status}
</span>
</td>
<td className="px-4 py-2 text-center">
{u.is_enrolled
? <CheckCircle2 className="w-4 h-4 text-green-500 mx-auto" />
: <span className="text-muted-foreground">No</span>
}
</td>
<td className="px-4 py-2 text-muted-foreground">{u.last_login ? fmtDate(u.last_login) : 'Never'}</td>
<td className="px-4 py-2 text-muted-foreground text-xs max-w-[200px] truncate">{u.notes || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Bypass users — security concern */}
{showBypass && bypassUsers.length > 0 && (
<FlaggedUsersTable
title="Bypass Users — MFA Not Enforced"
description="These users can authenticate without completing MFA. This is a security risk."
users={bypassUsers}
icon={<ShieldAlert className="w-4 h-4 text-red-600" />}
borderColor="border-red-500/30"
bgColor="bg-red-500/5"
headerColor="text-red-700 dark:text-red-400"
fmtDate={fmtDate}
/>
)}
{/* Disabled users — informational */}
{showDisabled && disabledUsers.length > 0 && (
<FlaggedUsersTable
title="Disabled Users"
description="These users are locked out and cannot authenticate. No action needed unless unexpected."
users={disabledUsers}
icon={<ShieldOff className="w-4 h-4 text-muted-foreground" />}
borderColor="border-border"
bgColor="bg-muted/5"
headerColor="text-muted-foreground"
fmtDate={fmtDate}
/>
)}
{/* Parent account */}
@ -284,3 +274,60 @@ function StatCard({ icon, label, value }: { icon: React.ReactNode; label: string
</div>
);
}
function FlaggedUsersTable({ title, description, users, icon, borderColor, bgColor, headerColor, fmtDate }: {
title: string;
description: string;
users: FlaggedUser[];
icon: React.ReactNode;
borderColor: string;
bgColor: string;
headerColor: string;
fmtDate: (d: string | null) => string;
}) {
return (
<div className={`rounded-lg border ${borderColor} ${bgColor} overflow-hidden`}>
<div className={`px-4 py-3 border-b ${borderColor}`}>
<div className="flex items-center gap-2">
{icon}
<h3 className={`text-sm font-semibold ${headerColor}`}>{title} ({users.length})</h3>
</div>
<p className="text-xs text-muted-foreground mt-1">{description}</p>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className={bgColor}>
<tr>
<th className="text-left px-4 py-2 font-medium">User</th>
<th className="text-left px-4 py-2 font-medium">Email</th>
<th className="text-left px-4 py-2 font-medium">Account</th>
<th className="text-center px-4 py-2 font-medium">Enrolled</th>
<th className="text-left px-4 py-2 font-medium">Last Login</th>
<th className="text-left px-4 py-2 font-medium">Notes</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{users.map(u => (
<tr key={u.user_id} className={`hover:${bgColor}`}>
<td className="px-4 py-2">
<div className="font-medium">{u.realname || u.username}</div>
{u.realname && <div className="text-xs text-muted-foreground">{u.username}</div>}
</td>
<td className="px-4 py-2 text-muted-foreground">{u.email || '\u2014'}</td>
<td className="px-4 py-2">{u.account_name}</td>
<td className="px-4 py-2 text-center">
{u.is_enrolled
? <CheckCircle2 className="w-4 h-4 text-green-500 mx-auto" />
: <span className="text-muted-foreground">No</span>
}
</td>
<td className="px-4 py-2 text-muted-foreground">{u.last_login ? fmtDate(u.last_login) : 'Never'}</td>
<td className="px-4 py-2 text-muted-foreground text-xs max-w-[200px] truncate">{u.notes || '\u2014'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -176,7 +176,7 @@ export default function SyncOverviewPage() {
users: c.users ?? 0,
phones: c.phones ?? 0,
authLogs: c.authLogs ?? 0,
bypassed: c.bypassed ?? 0,
bypass: c.bypass ?? 0,
};
}
return null;
@ -215,7 +215,7 @@ export default function SyncOverviewPage() {
}
if (id === 'duo') {
if (!summary.connected) return <Clock className="w-4 h-4 text-muted-foreground" />;
if ((summary as any).bypassed > 0) return <AlertTriangle className="w-4 h-4 text-yellow-500" />;
if ((summary as any).bypass > 0) return <AlertTriangle className="w-4 h-4 text-red-500" />;
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
}
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
@ -400,10 +400,10 @@ export default function SyncOverviewPage() {
<span>Auth logs</span>
<span className="font-medium text-foreground">{(summary as any).authLogs?.toLocaleString()}</span>
</div>
{(summary as any).bypassed > 0 && (
<div className="flex justify-between text-yellow-700">
<span>Bypass / Disabled</span>
<span className="font-medium">{(summary as any).bypassed}</span>
{(summary as any).bypass > 0 && (
<div className="flex justify-between text-red-600">
<span>Bypass (MFA skipped)</span>
<span className="font-medium">{(summary as any).bypass}</span>
</div>
)}
</>

View file

@ -14,9 +14,10 @@ export async function GET() {
postgresClient.query(`SELECT MAX(synced_at) as last_sync FROM duo_accounts`),
]);
const bypassed = await postgresClient.query(
`SELECT COUNT(*) as cnt FROM duo_users WHERE status IN ('bypass', 'disabled')`
);
const [bypassRes, disabledRes] = await Promise.all([
postgresClient.query(`SELECT COUNT(*) as cnt FROM duo_users WHERE status = 'bypass'`),
postgresClient.query(`SELECT COUNT(*) as cnt FROM duo_users WHERE status = 'disabled'`),
]);
return NextResponse.json({
connected: true,
@ -29,7 +30,8 @@ export async function GET() {
authLogs: Number(authLogs.rows[0].cnt),
groups: Number(groups.rows[0].cnt),
integrations: Number(integrations.rows[0].cnt),
bypassed: Number(bypassed.rows[0].cnt),
bypass: Number(bypassRes.rows[0].cnt),
disabled: Number(disabledRes.rows[0].cnt),
},
});
} catch (error: any) {

View file

@ -6,14 +6,18 @@ export async function GET() {
const result = await postgresClient.query(`
SELECT u.user_id, u.username, u.email, u.realname, u.status,
u.is_enrolled, u.last_login, u.notes, u.synced_at,
da.name as account_name, da.account_id as duo_account_id
da.name as account_name, da.account_id as duo_account_id,
da.is_parent as is_parent_account
FROM duo_users u
JOIN duo_accounts da ON da.account_id = u.duo_account_id
WHERE u.status IN ('bypass', 'disabled')
ORDER BY u.status ASC, da.name ASC, u.username ASC
ORDER BY da.name ASC, u.username ASC
`);
return NextResponse.json({ users: result.rows, total: result.rowCount });
const bypass = result.rows.filter((u: any) => u.status === 'bypass');
const disabled = result.rows.filter((u: any) => u.status === 'disabled');
return NextResponse.json({ bypass, disabled, total: result.rowCount });
} catch (error: any) {
console.error('[DuoAPI] Error fetching flagged users:', error);
return NextResponse.json({ error: error.message }, { status: 500 });