From 3dcb7262077a41651f63c57fd67dd28f49a0469d Mon Sep 17 00:00:00 2001 From: Izan Gil <66965250+SrIzan10@users.noreply.github.com> Date: Mon, 30 Mar 2026 00:49:48 +0200 Subject: [PATCH] feat(auth): add the ability for users to bypass idv checks --- .../(ui)/(protected)/admin/page.client.tsx | 226 +++++++++++++++--- .../(ui)/(protected)/api/admin/users/route.ts | 37 ++- .../(public)/auth/hackclub/callback/route.ts | 22 +- .../migration.sql | 2 + .../migration.sql | 10 + .../db/prisma/migrations/migration_lock.toml | 2 +- packages/db/prisma/schema.prisma | 3 + 7 files changed, 262 insertions(+), 40 deletions(-) create mode 100644 packages/db/prisma/migrations/20260329192629_bypass_verification_col/migration.sql create mode 100644 packages/db/prisma/migrations/20260329210956_add_bypass_verification_audit_actions/migration.sql diff --git a/apps/web/src/app/(ui)/(protected)/admin/page.client.tsx b/apps/web/src/app/(ui)/(protected)/admin/page.client.tsx index 89441cc..a6a99af 100644 --- a/apps/web/src/app/(ui)/(protected)/admin/page.client.tsx +++ b/apps/web/src/app/(ui)/(protected)/admin/page.client.tsx @@ -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: }, { id: 'audit', label: 'Audit Log', icon: }, { id: 'reports', label: 'Reports', icon: }, + { id: 'settings', label: 'Settings', icon: }, ]; -// Audit action colour coding const AUDIT_SOURCE_DOT: Record = { platform: 'bg-primary', chat: 'bg-amber-500', @@ -83,6 +82,8 @@ const AUDIT_ACTION_COLOR: Record = { 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 = { 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 (
{/* ── Page header ─────────────────────────────────────────────────── */} @@ -577,17 +582,6 @@ export default function AdminPanelClient({ currentUser }: AdminPanelClientProps)
- -
} label={`${users.length} users`} /> | @@ -1192,6 +1186,175 @@ export default function AdminPanelClient({ currentUser }: AdminPanelClientProps) })}
)} +
+ )} + + {activeTab === 'settings' && ( +
+ } + title="Platform Settings" + description="Manage verification bypass and platform configuration." + /> + +
+
+
+
+ +
+
+

+ ID Verification Bypass +

+

+ Allow existing users to bypass HCA verification and let them access the platform. +

+
+
+ +
+ + setUserSearch(e.target.value)} + className="pl-10 h-9 bg-background/50 border-2 focus:border-primary/50 transition-colors" + /> +
+ + {usersLoading ? ( + + ) : !userSearch ? ( +
+
+ +
+

+ Start searching to manage users +

+

+ Type an email or username above to find users and toggle their verification bypass +

+
+ ) : users.length === 0 ? ( +
+ +

No users found

+

+ Try a different search term +

+
+ ) : ( + <> +
+ {users.map((user) => ( +
+
+ + + + {user.personalChannel?.name?.[0]?.toUpperCase() ?? 'U'} + + + +
+
+ + {user.personalChannel?.name ?? user.slack_id} + + {user.isAdmin && ( + + + Admin + + )} + {user.bypassVerification && ( + + + Bypass Active + + )} +
+

+ {user.email ?? 'No email'} +

+
+ + +
+ + {user.bypassVerification && ( +
+ )} +
+ ))} +
+ + )} +
+ +
+
+
+ +
+
+

Session Management

+

+ Force logout all other sessions except your current one. Useful for security maintenance. +

+ +
+
+
+
)}
@@ -1331,6 +1494,7 @@ interface UserWithBan { email: string | null; pfpUrl: string; isAdmin: boolean; + bypassVerification: boolean; ban: { id: string; reason: string; diff --git a/apps/web/src/app/(ui)/(protected)/api/admin/users/route.ts b/apps/web/src/app/(ui)/(protected)/api/admin/users/route.ts index ba3c553..e6abe90 100644 --- a/apps/web/src/app/(ui)/(protected)/api/admin/users/route.ts +++ b/apps/web/src/app/(ui)/(protected)/api/admin/users/route.ts @@ -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 }); } diff --git a/apps/web/src/app/(ui)/(public)/auth/hackclub/callback/route.ts b/apps/web/src/app/(ui)/(public)/auth/hackclub/callback/route.ts index aae72ef..513bfb1 100644 --- a/apps/web/src/app/(ui)/(public)/auth/hackclub/callback/route.ts +++ b/apps/web/src/app/(ui)/(public)/auth/hackclub/callback/route.ts @@ -36,11 +36,14 @@ export async function GET(request: Request): Promise { 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 { 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 { + const user = await prisma.user.findFirst({ where: { email } }); + return user?.bypassVerification ?? false; +} diff --git a/packages/db/prisma/migrations/20260329192629_bypass_verification_col/migration.sql b/packages/db/prisma/migrations/20260329192629_bypass_verification_col/migration.sql new file mode 100644 index 0000000..7f73ae4 --- /dev/null +++ b/packages/db/prisma/migrations/20260329192629_bypass_verification_col/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "bypassVerification" BOOLEAN NOT NULL DEFAULT false; diff --git a/packages/db/prisma/migrations/20260329210956_add_bypass_verification_audit_actions/migration.sql b/packages/db/prisma/migrations/20260329210956_add_bypass_verification_audit_actions/migration.sql new file mode 100644 index 0000000..a76a753 --- /dev/null +++ b/packages/db/prisma/migrations/20260329210956_add_bypass_verification_audit_actions/migration.sql @@ -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'; diff --git a/packages/db/prisma/migrations/migration_lock.toml b/packages/db/prisma/migrations/migration_lock.toml index 044d57c..648c57f 100644 --- a/packages/db/prisma/migrations/migration_lock.toml +++ b/packages/db/prisma/migrations/migration_lock.toml @@ -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" \ No newline at end of file diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 7624898..f22d936 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -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 {