fix(platform): actually check idv status

i'm sorry
This commit is contained in:
2026-03-16 22:32:38 +01:00
parent 43ead562a8
commit 14a0ecd763
7 changed files with 255 additions and 33 deletions

View File

@@ -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<AuditLog[]>([]);
const [reports, setReports] = useState<ChatReport[]>([]);
const [highlightReportId, setHighlightReportId] = useState<string | null>(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)
</div>
</div>
{/* Quick stats */}
<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>
<StatPill
icon={<Activity className="h-3 w-3" />}
label={`${auditLogs.length} events`}
/>
{openReports > 0 && (
<>
<span className="text-border mx-1">|</span>
<StatPill
icon={<Flag className="h-3 w-3" />}
label={`${openReports} open`}
className="text-destructive"
/>
</>
)}
<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>
<StatPill
icon={<Activity className="h-3 w-3" />}
label={`${auditLogs.length} events`}
/>
{openReports > 0 && (
<>
<span className="text-border mx-1">|</span>
<StatPill
icon={<Flag className="h-3 w-3" />}
label={`${openReports} open`}
className="text-destructive"
/>
</>
)}
</div>
</div>
</div>
</div>

View File

@@ -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 });
}

View File

@@ -8,30 +8,43 @@ import { getRedisConnection } from '@hctv/db';
export async function GET(request: Request): Promise<Response> {
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<Response> {
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.';
}
}

View File

@@ -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"
}
}

View File

@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "User"
ADD COLUMN "hackClubVerificationCheckedAt" TIMESTAMP(3),
ADD COLUMN "hackClubVerificationResult" TEXT;

View File

@@ -22,6 +22,9 @@ model User {
email String?
pfpUrl String
hackClubVerificationResult String?
hackClubVerificationCheckedAt DateTime?
hasOnboarded Boolean @default(false)
isAdmin Boolean @default(false)

View File

@@ -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();
});