feat(auth): add the ability for users to bypass idv checks

This commit is contained in:
2026-03-30 00:49:48 +02:00
parent 5b7e9e7a82
commit 3dcb726207
7 changed files with 262 additions and 40 deletions

View File

@@ -34,6 +34,7 @@ import {
Activity,
Hash,
ShieldAlert,
Settings,
} from 'lucide-react';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import {
@@ -51,9 +52,7 @@ import { cn } from '@/lib/utils';
import { parseAsString, useQueryState } from 'nuqs';
import { useRouter } from 'next/navigation';
// ─── Constants ───────────────────────────────────────────────────────────────
const ADMIN_TABS = ['users', 'channels', 'audit', 'reports'] as const;
const ADMIN_TABS = ['users', 'channels', 'audit', 'reports', 'settings'] as const;
type AdminTab = (typeof ADMIN_TABS)[number];
const NAV_ITEMS: Array<{ id: AdminTab; label: string; icon: React.ReactNode }> = [
@@ -61,9 +60,9 @@ const NAV_ITEMS: Array<{ id: AdminTab; label: string; icon: React.ReactNode }> =
{ id: 'channels', label: 'Channels', icon: <Tv className="h-4 w-4" /> },
{ id: 'audit', label: 'Audit Log', icon: <ClipboardList className="h-4 w-4" /> },
{ id: 'reports', label: 'Reports', icon: <Flag className="h-4 w-4" /> },
{ id: 'settings', label: 'Settings', icon: <Settings className="h-4 w-4" /> },
];
// Audit action colour coding
const AUDIT_SOURCE_DOT: Record<string, string> = {
platform: 'bg-primary',
chat: 'bg-amber-500',
@@ -83,6 +82,8 @@ const AUDIT_ACTION_COLOR: Record<string, string> = {
TIMEOUT_USER: 'text-amber-500',
BAN_FROM_CHAT: 'text-destructive',
LIFT_CHAT_BAN: 'text-green-600 dark:text-green-400',
BYPASS_VERIFICATION_ENABLED: 'text-green-600 dark:text-green-400',
BYPASS_VERIFICATION_DISABLED: 'text-amber-500',
};
const REPORT_STATUS_CONFIG = {
@@ -118,8 +119,6 @@ const LAST_ACTION_LABELS: Record<string, string> = {
UNBAN_PLATFORM: 'Platform unbanned',
};
// ─── Small helpers ───────────────────────────────────────────────────────────
function SectionHeader({
icon,
title,
@@ -168,8 +167,6 @@ function LoadingRows({ cols }: { cols: number }) {
);
}
// ─── Date/time picker shared component ──────────────────────────────────────
function DateTimePicker({
value,
onChange,
@@ -234,8 +231,6 @@ function DateTimePicker({
);
}
// ─── Main component ──────────────────────────────────────────────────────────
export default function AdminPanelClient({ currentUser }: AdminPanelClientProps) {
const confirm = useConfirm();
const router = useRouter();
@@ -317,8 +312,6 @@ export default function AdminPanelClient({ currentUser }: AdminPanelClientProps)
}
}, []);
// ── Effects ────────────────────────────────────────────────────────────────
useEffect(() => {
if (tabParam && ADMIN_TABS.includes(tabParam as AdminTab)) {
setActiveTab(tabParam as AdminTab);
@@ -357,8 +350,6 @@ export default function AdminPanelClient({ currentUser }: AdminPanelClientProps)
return () => clearTimeout(timer);
}, [channelSearch, fetchChannels]);
// ── Actions ────────────────────────────────────────────────────────────────
const resetDialogState = () => {
setReason('');
setExpiresAt(undefined);
@@ -502,6 +493,26 @@ export default function AdminPanelClient({ currentUser }: AdminPanelClientProps)
}
};
const handleToggleBypassVerification = async (userId: string) => {
try {
const res = await fetch('/api/admin/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId, action: 'toggle_bypass_verification' }),
});
if (res.ok) {
const data = await res.json();
toast.success(data.message);
fetchUsers(userSearch);
fetchAuditLogs();
} else {
toast.error((await res.text()) || 'Failed to toggle bypass verification');
}
} catch {
toast.error('Failed to toggle bypass verification');
}
};
const handleLogoutOthers = async () => {
const confirmed = await confirm({
title: 'Log Out Everyone Else',
@@ -540,12 +551,8 @@ export default function AdminPanelClient({ currentUser }: AdminPanelClientProps)
}
};
// ── Derived stats ─────────────────────────────────────────────────────────
const openReports = reports.filter((r) => r.status === 'OPEN').length;
// ── Tab switch helper ─────────────────────────────────────────────────────
const switchTab = async (tab: AdminTab) => {
setActiveTab(tab);
await setTabParam(tab);
@@ -555,8 +562,6 @@ export default function AdminPanelClient({ currentUser }: AdminPanelClientProps)
}
};
// ── Render ────────────────────────────────────────────────────────────────
return (
<div className="min-h-screen">
{/* ── Page header ─────────────────────────────────────────────────── */}
@@ -577,17 +582,6 @@ export default function AdminPanelClient({ currentUser }: AdminPanelClientProps)
</div>
<div className="flex flex-col items-end gap-3">
<Button
variant="destructive"
size="sm"
onClick={handleLogoutOthers}
disabled={loggingOutOthers}
className="gap-2"
>
<LogOut className="h-4 w-4" />
{loggingOutOthers ? 'Logging out others...' : 'Log everyone else out'}
</Button>
<div className="hidden sm:flex items-center gap-1 rounded-full border border-border bg-background px-3 py-1.5">
<StatPill icon={<Users className="h-3 w-3" />} label={`${users.length} users`} />
<span className="text-border mx-1">|</span>
@@ -1192,6 +1186,175 @@ export default function AdminPanelClient({ currentUser }: AdminPanelClientProps)
})}
</div>
)}
</div>
)}
{activeTab === 'settings' && (
<div>
<SectionHeader
icon={<Settings className="h-4 w-4" />}
title="Platform Settings"
description="Manage verification bypass and platform configuration."
/>
<div className="space-y-5">
<div className="rounded-xl border-2 border-primary/20 bg-gradient-to-br from-primary/5 via-background to-background p-5 shadow-lg">
<div className="mb-5 flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 ring-2 ring-primary/20">
<ShieldAlert className="h-5 w-5 text-primary" />
</div>
<div className="flex-1">
<h3 className="text-base font-bold tracking-tight">
ID Verification Bypass
</h3>
<p className="mt-0.5 text-xs text-muted-foreground leading-relaxed">
Allow existing users to bypass HCA verification and let them access the platform.
</p>
</div>
</div>
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by email or username to manage bypass…"
value={userSearch}
onChange={(e) => setUserSearch(e.target.value)}
className="pl-10 h-9 bg-background/50 border-2 focus:border-primary/50 transition-colors"
/>
</div>
{usersLoading ? (
<LoadingRows cols={1} />
) : !userSearch ? (
<div className="rounded-lg border-2 border-dashed border-primary/20 bg-primary/5 p-6 text-center">
<div className="mx-auto w-fit rounded-full bg-primary/10 p-3 mb-2">
<Search className="h-5 w-5 text-primary" />
</div>
<p className="text-sm font-medium text-foreground mb-1">
Start searching to manage users
</p>
<p className="text-xs text-muted-foreground">
Type an email or username above to find users and toggle their verification bypass
</p>
</div>
) : users.length === 0 ? (
<div className="rounded-lg border-2 border-dashed border-border bg-muted/30 p-6 text-center">
<XCircle className="mx-auto h-8 w-8 text-muted-foreground/50 mb-2" />
<p className="text-sm font-medium text-foreground mb-1">No users found</p>
<p className="text-xs text-muted-foreground">
Try a different search term
</p>
</div>
) : (
<>
<div className="space-y-2 mb-3">
{users.map((user) => (
<div
key={user.id}
className={cn(
'group relative overflow-hidden rounded-lg border-2 bg-card transition-all duration-200',
user.bypassVerification
? 'border-primary/40 bg-primary/5 hover:border-primary/50 hover:shadow-md hover:shadow-primary/5'
: 'border-border hover:border-primary/30 hover:shadow-sm'
)}
>
<div className="flex items-center gap-3 px-3 py-2.5">
<Avatar className="h-9 w-9 shrink-0 ring-2 ring-background">
<AvatarImage src={user.pfpUrl} />
<AvatarFallback className="text-xs font-semibold">
{user.personalChannel?.name?.[0]?.toUpperCase() ?? 'U'}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap mb-0.5">
<span className="font-semibold text-sm">
{user.personalChannel?.name ?? user.slack_id}
</span>
{user.isAdmin && (
<span className="inline-flex items-center gap-0.5 text-[10px] font-bold px-1.5 py-0.5 rounded-full bg-primary/10 text-primary border border-primary/30">
<Shield className="h-2.5 w-2.5" />
Admin
</span>
)}
{user.bypassVerification && (
<span className="inline-flex items-center gap-0.5 text-[10px] font-bold px-1.5 py-0.5 rounded-full bg-primary/10 text-primary border border-primary/30 animate-in fade-in zoom-in duration-200">
<CheckCircle2 className="h-2.5 w-2.5" />
Bypass Active
</span>
)}
</div>
<p className="text-xs text-muted-foreground truncate">
{user.email ?? 'No email'}
</p>
</div>
<Button
variant={user.bypassVerification ? 'outline' : 'default'}
size="sm"
onClick={() => handleToggleBypassVerification(user.id)}
className={cn(
'h-7 text-xs gap-1 shrink-0 font-semibold transition-all duration-200',
user.bypassVerification && 'border-primary/30 hover:bg-primary/10 hover:border-primary/50'
)}
>
{user.bypassVerification ? (
<>
<XCircle className="h-3 w-3" />
Disable
</>
) : (
<>
<CheckCircle2 className="h-3 w-3" />
Enable
</>
)}
</Button>
</div>
{user.bypassVerification && (
<div className="absolute inset-x-0 bottom-0 h-0.5 bg-gradient-to-r from-primary/0 via-primary/50 to-primary/0" />
)}
</div>
))}
</div>
</>
)}
</div>
<div className="rounded-xl border-2 border-border bg-card p-4 shadow-lg">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-muted ring-2 ring-border">
<LogOut className="h-5 w-5 text-foreground" />
</div>
<div className="flex-1">
<h3 className="text-base font-bold tracking-tight">Session Management</h3>
<p className="mt-0.5 text-xs text-muted-foreground mb-3 leading-relaxed">
Force logout all other sessions except your current one. Useful for security maintenance.
</p>
<Button
variant="outline"
size="sm"
onClick={handleLogoutOthers}
disabled={loggingOutOthers}
className="h-7 gap-1.5 font-semibold text-xs"
>
{loggingOutOthers ? (
<>
<RefreshCw className="h-3.5 w-3.5 animate-spin" />
Logging out...
</>
) : (
<>
<LogOut className="h-3.5 w-3.5" />
Logout All Other Sessions
</>
)}
</Button>
</div>
</div>
</div>
</div>
</div>
)}
</div>
@@ -1331,6 +1494,7 @@ interface UserWithBan {
email: string | null;
pfpUrl: string;
isAdmin: boolean;
bypassVerification: boolean;
ban: {
id: string;
reason: string;

View File

@@ -22,7 +22,13 @@ export async function GET(request: NextRequest) {
hasOnboarded: true,
}
: undefined,
include: {
select: {
id: true,
slack_id: true,
email: true,
pfpUrl: true,
isAdmin: true,
bypassVerification: true,
ban: true,
personalChannel: { select: { name: true } },
},
@@ -39,7 +45,7 @@ export async function POST(request: NextRequest) {
let body: {
userId?: string;
action: 'ban' | 'unban' | 'promote' | 'demote' | 'logout_others';
action: 'ban' | 'unban' | 'promote' | 'demote' | 'logout_others' | 'toggle_bypass_verification';
reason?: string;
expiresAt?: string;
};
@@ -210,5 +216,32 @@ export async function POST(request: NextRequest) {
return Response.json({ success: true, message: 'User demoted from admin' });
}
if (action === 'toggle_bypass_verification') {
const newBypassStatus = !targetUser.bypassVerification;
await prisma.user.update({
where: { id: userId },
data: { bypassVerification: newBypassStatus },
});
await prisma.adminAuditLog.create({
data: {
action: newBypassStatus
? AdminAuditAction.BYPASS_VERIFICATION_ENABLED
: AdminAuditAction.BYPASS_VERIFICATION_DISABLED,
actorId: user.id,
targetUserId: userId,
},
});
return Response.json({
success: true,
message: newBypassStatus
? 'Email verification bypass enabled'
: 'Email verification bypass disabled',
bypassVerification: newBypassStatus,
});
}
return new Response('Invalid action', { status: 400 });
}

View File

@@ -36,11 +36,14 @@ export async function GET(request: Request): Promise<Response> {
const userResult: HackClubUserResponse = await userResponse.json();
const identity = userResult.identity;
const bypass = await checkIfBypass(identity.primary_email);
if (identity.verification_status !== 'verified') {
return new Response(getVerificationErrorMessage(identity.verification_status), {
status: 403,
});
if (!bypass) {
return new Response(getVerificationErrorMessage(identity.verification_status), {
status: 403,
});
}
}
const slackId = identity.slack_id;
@@ -52,9 +55,11 @@ export async function GET(request: Request): Promise<Response> {
const slackValidation = await validateSlackUser(slackId);
if (!slackValidation.success) {
return new Response(slackValidation.message, {
status: slackValidation.status,
});
if (!bypass) {
return new Response(slackValidation.message, {
status: slackValidation.status,
});
}
}
const existingUser = await prisma.user.findFirst({
@@ -206,3 +211,8 @@ async function validateSlackUser(slackId: string): Promise<SlackValidationResult
};
}
}
async function checkIfBypass(email: string): Promise<boolean> {
const user = await prisma.user.findFirst({ where: { email } });
return user?.bypassVerification ?? false;
}

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "bypassVerification" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,10 @@
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "AdminAuditAction" ADD VALUE 'BYPASS_VERIFICATION_ENABLED';
ALTER TYPE "AdminAuditAction" ADD VALUE 'BYPASS_VERIFICATION_DISABLED';

View File

@@ -1,3 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
provider = "postgresql"

View File

@@ -24,6 +24,7 @@ model User {
hackClubVerificationResult String?
hackClubVerificationCheckedAt DateTime?
bypassVerification Boolean @default(false)
hasOnboarded Boolean @default(false)
isAdmin Boolean @default(false)
@@ -311,6 +312,8 @@ enum AdminAuditAction {
REPORT_REVIEWED
REPORT_DISMISSED
REPORT_ENFORCEMENT
BYPASS_VERIFICATION_ENABLED
BYPASS_VERIFICATION_DISABLED
}
enum ChatModerationAction {