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:
parent
5037d64948
commit
5f4e326804
4 changed files with 128 additions and 75 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue