From 14a0ecd763152d30cf7d1ae1ca33989eeed2e703 Mon Sep 17 00:00:00 2001 From: Izan Gil <66965250+SrIzan10@users.noreply.github.com> Date: Mon, 16 Mar 2026 22:32:38 +0100 Subject: [PATCH] fix(platform): actually check idv status i'm sorry --- .../(ui)/(protected)/admin/page.client.tsx | 90 ++++++++++++++---- .../(ui)/(protected)/api/admin/users/route.ts | 44 ++++++++- .../(public)/auth/hackclub/callback/route.ts | 52 ++++++++--- packages/db/package.json | 2 + .../migration.sql | 4 + packages/db/prisma/schema.prisma | 3 + .../db/src/populateHackClubVerification.ts | 93 +++++++++++++++++++ 7 files changed, 255 insertions(+), 33 deletions(-) create mode 100644 packages/db/prisma/migrations/20260316120000_add_hack_club_verification_fields/migration.sql create mode 100644 packages/db/src/populateHackClubVerification.ts 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 a429d32..89441cc 100644 --- a/apps/web/src/app/(ui)/(protected)/admin/page.client.tsx +++ b/apps/web/src/app/(ui)/(protected)/admin/page.client.tsx @@ -13,6 +13,7 @@ import { Users, Tv, Ban, + LogOut, ShieldOff, Search, CalendarIcon, @@ -43,6 +44,7 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import { useConfirm } from '@omit/react-confirm-dialog'; import { toast } from 'sonner'; import type { User } from '@hctv/db'; import { cn } from '@/lib/utils'; @@ -235,6 +237,7 @@ function DateTimePicker({ // ─── Main component ────────────────────────────────────────────────────────── export default function AdminPanelClient({ currentUser }: AdminPanelClientProps) { + const confirm = useConfirm(); const router = useRouter(); const [tabParam, setTabParam] = useQueryState('tab', parseAsString.withDefault('users')); const [reportIdParam, setReportIdParam] = useQueryState('reportId'); @@ -247,6 +250,7 @@ export default function AdminPanelClient({ currentUser }: AdminPanelClientProps) const [channelsLoading, setChannelsLoading] = useState(false); const [auditLoading, setAuditLoading] = useState(false); const [reportsLoading, setReportsLoading] = useState(false); + const [loggingOutOthers, setLoggingOutOthers] = useState(false); const [auditLogs, setAuditLogs] = useState([]); const [reports, setReports] = useState([]); const [highlightReportId, setHighlightReportId] = useState(null); @@ -498,6 +502,44 @@ export default function AdminPanelClient({ currentUser }: AdminPanelClientProps) } }; + const handleLogoutOthers = async () => { + const confirmed = await confirm({ + title: 'Log Out Everyone Else', + description: + 'This will immediately sign out every other active session on hackclub.tv and keep only your current session active.', + confirmText: 'Log Out Others', + cancelText: 'Cancel', + }); + + if (!confirmed) return; + + setLoggingOutOthers(true); + try { + const res = await fetch('/api/admin/users', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'logout_others' }), + }); + + if (!res.ok) { + toast.error((await res.text()) || 'Failed to log out other sessions'); + return; + } + + const data = (await res.json()) as { invalidatedSessions: number }; + toast.success( + data.invalidatedSessions > 0 + ? `Logged out ${data.invalidatedSessions} other session${data.invalidatedSessions === 1 ? '' : 's'}` + : 'No other active sessions were found' + ); + router.refresh(); + } catch { + toast.error('Failed to log out other sessions'); + } finally { + setLoggingOutOthers(false); + } + }; + // ── Derived stats ───────────────────────────────────────────────────────── const openReports = reports.filter((r) => r.status === 'OPEN').length; @@ -534,24 +576,36 @@ export default function AdminPanelClient({ currentUser }: AdminPanelClientProps) - {/* Quick stats */} -
- } label={`${users.length} users`} /> - | - } - label={`${auditLogs.length} events`} - /> - {openReports > 0 && ( - <> - | - } - label={`${openReports} open`} - className="text-destructive" - /> - - )} +
+ + +
+ } label={`${users.length} users`} /> + | + } + label={`${auditLogs.length} events`} + /> + {openReports > 0 && ( + <> + | + } + label={`${openReports} open`} + className="text-destructive" + /> + + )} +
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 2c813f0..ba3c553 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 @@ -1,5 +1,5 @@ import { validateRequest } from '@/lib/auth/validate'; -import { AdminAuditAction, prisma } from '@hctv/db'; +import { AdminAuditAction, getRedisConnection, prisma } from '@hctv/db'; import { NextRequest } from 'next/server'; export async function GET(request: NextRequest) { @@ -32,14 +32,14 @@ export async function GET(request: NextRequest) { } export async function POST(request: NextRequest) { - const { user } = await validateRequest(); + const { user, session } = await validateRequest(); if (!user?.isAdmin) { return new Response('Forbidden', { status: 403 }); } let body: { - userId: string; - action: 'ban' | 'unban' | 'promote' | 'demote'; + userId?: string; + action: 'ban' | 'unban' | 'promote' | 'demote' | 'logout_others'; reason?: string; expiresAt?: string; }; @@ -52,6 +52,42 @@ export async function POST(request: NextRequest) { const { userId, action, reason, expiresAt } = body; + if (action === 'logout_others') { + if (!session) { + return new Response('No active session found', { status: 400 }); + } + + const sessionsToDelete = await prisma.session.findMany({ + where: { + id: { + not: session.id, + }, + }, + select: { + id: true, + }, + }); + + if (sessionsToDelete.length > 0) { + const redis = getRedisConnection(); + await prisma.session.deleteMany({ + where: { + id: { + in: sessionsToDelete.map((existingSession) => existingSession.id), + }, + }, + }); + await redis.unlink( + ...sessionsToDelete.map((existingSession) => `sessions:${existingSession.id}`) + ); + } + + return Response.json({ + success: true, + invalidatedSessions: sessionsToDelete.length, + }); + } + if (!userId || !action) { return new Response('Missing required fields', { 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 f1dd731..dd3f44b 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 @@ -8,30 +8,43 @@ import { getRedisConnection } from '@hctv/db'; export async function GET(request: Request): Promise { const cookies = await nextCookies(); const url = new URL(request.url); - const code = url.searchParams.get("code"); - const state = url.searchParams.get("state"); - const storedState = cookies.get("hackclub_oauth_state")?.value ?? null; - if (!code || !state || !storedState || state !== storedState) { + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + const storedState = cookies.get('hackclub_oauth_state')?.value ?? null; + if (!code || !state || !storedState || state !== storedState) { console.log('invalid state stuff'); - return new Response(null, { - status: 400 - }); - } + return new Response(null, { + status: 400, + }); + } try { const tokens = await hackClub.validateAuthorizationCode(HCID_TOKEN_URL, code, null); const accessToken = tokens.accessToken(); const userResponse = await fetch(HCID_USER_INFO_URL, { headers: { - 'Authorization': `Bearer ${accessToken}`, + Authorization: `Bearer ${accessToken}`, }, }); + + if (!userResponse.ok) { + return new Response('Unable to verify your Hack Club identity right now. Please try again.', { + status: 502, + }); + } + const userResult: HackClubUserResponse = await userResponse.json(); const identity = userResult.identity; + if (identity.verification_status !== 'verified') { + return new Response(getVerificationErrorMessage(identity.verification_status), { + status: 403, + }); + } + const slackId = identity.slack_id; if (!slackId) { - return new Response("Please make sure to have a Slack account before continuing.", { + return new Response('Please make sure to have a Slack account before continuing.', { status: 400, }); } @@ -70,7 +83,9 @@ export async function GET(request: Request): Promise { id: userId, slack_id: slackId, email: identity.primary_email, - pfpUrl: identity.slack_id ? `https://cachet.dunkirk.sh/users/${identity.slack_id}/r` : 'https://github.com/hackclub.png', + pfpUrl: identity.slack_id + ? `https://cachet.dunkirk.sh/users/${identity.slack_id}/r` + : 'https://github.com/hackclub.png', hasOnboarded: false, }, }); @@ -106,9 +121,24 @@ interface HackClubIdentity { first_name: string; last_name: string; primary_email: string; + verification_status: VerificationStatus; } interface HackClubUserResponse { identity: HackClubIdentity; } +type VerificationStatus = 'needs_submission' | 'pending' | 'verified' | 'ineligible'; + +function getVerificationErrorMessage(status: VerificationStatus): string { + switch (status) { + case 'needs_submission': + return 'Please complete Hack Club Identity verification before signing in to hackclub.tv.'; + case 'pending': + return 'Your Hack Club Identity verification is still being reviewed. Please try again once it is approved.'; + case 'ineligible': + return 'Your Hack Club Identity verification was rejected, so you cannot access hackclub.tv right now.'; + case 'verified': + return 'Verified users can continue.'; + } +} diff --git a/packages/db/package.json b/packages/db/package.json index 4915fc1..39516e5 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -18,11 +18,13 @@ "db:generate": "prisma generate", "db:migrate": "prisma migrate dev", "db:deploy": "prisma migrate deploy", + "db:populate-verification": "tsx src/populateHackClubVerification.ts", "build": "prisma generate && tsc --build", "dev": "tsc --watch --preserveWatchOutput" }, "devDependencies": { "@types/node": "^24.0.1", + "tsx": "^4.7.1", "typescript": "^5.8.2" } } diff --git a/packages/db/prisma/migrations/20260316120000_add_hack_club_verification_fields/migration.sql b/packages/db/prisma/migrations/20260316120000_add_hack_club_verification_fields/migration.sql new file mode 100644 index 0000000..48d8b34 --- /dev/null +++ b/packages/db/prisma/migrations/20260316120000_add_hack_club_verification_fields/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "User" +ADD COLUMN "hackClubVerificationCheckedAt" TIMESTAMP(3), +ADD COLUMN "hackClubVerificationResult" TEXT; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index eec8aff..7624898 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -22,6 +22,9 @@ model User { email String? pfpUrl String + hackClubVerificationResult String? + hackClubVerificationCheckedAt DateTime? + hasOnboarded Boolean @default(false) isAdmin Boolean @default(false) diff --git a/packages/db/src/populateHackClubVerification.ts b/packages/db/src/populateHackClubVerification.ts new file mode 100644 index 0000000..df8bd2f --- /dev/null +++ b/packages/db/src/populateHackClubVerification.ts @@ -0,0 +1,93 @@ +import { prisma } from './client.js'; + +const HACK_CLUB_CHECK_URL = 'https://auth.hackclub.com/api/external/check'; + +type HackClubCheckResult = + | 'needs_submission' + | 'pending' + | 'verified_eligible' + | 'verified_but_over_18' + | 'rejected' + | 'not_found'; + +type HackClubCheckResponse = { + result: HackClubCheckResult; +}; + +async function fetchVerificationResult(user: { + id: string; + email: string | null; + slack_id: string; +}) { + const query = new URLSearchParams(); + + if (user.email) { + query.set('email', user.email); + } else { + query.set('slack_id', user.slack_id); + } + + const response = await fetch(`${HACK_CLUB_CHECK_URL}?${query.toString()}`); + if (!response.ok) { + throw new Error(`Hack Club check failed for user ${user.id}: ${response.status}`); + } + + return (await response.json()) as HackClubCheckResponse; +} + +async function main() { + const users = await prisma.user.findMany({ + select: { + id: true, + email: true, + slack_id: true, + }, + orderBy: { + id: 'asc', + }, + }); + + if (users.length === 0) { + console.log('No users found.'); + return; + } + + let updatedCount = 0; + + for (const user of users) { + if (!user.email && !user.slack_id) { + console.warn(`Skipping user ${user.id}: no email or Slack ID available.`); + continue; + } + + try { + const result = await fetchVerificationResult(user); + + await prisma.user.update({ + where: { + id: user.id, + }, + data: { + hackClubVerificationResult: result.result, + hackClubVerificationCheckedAt: new Date(), + }, + }); + + updatedCount += 1; + console.log(`Updated ${user.id} (${user.email ?? user.slack_id}) -> ${result.result}`); + } catch (error) { + console.error(`Failed to update ${user.id}:`, error); + } + } + + console.log(`Finished updating ${updatedCount} of ${users.length} users.`); +} + +main() + .catch((error) => { + console.error('Failed to populate Hack Club verification data:', error); + process.exitCode = 1; + }) + .finally(async () => { + await prisma.$disconnect(); + });