mirror of
https://github.com/SrIzan10/hctv.git
synced 2026-06-06 00:56:56 +00:00
fix(platform): actually check idv status
i'm sorry
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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.';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User"
|
||||
ADD COLUMN "hackClubVerificationCheckedAt" TIMESTAMP(3),
|
||||
ADD COLUMN "hackClubVerificationResult" TEXT;
|
||||
@@ -22,6 +22,9 @@ model User {
|
||||
email String?
|
||||
pfpUrl String
|
||||
|
||||
hackClubVerificationResult String?
|
||||
hackClubVerificationCheckedAt DateTime?
|
||||
|
||||
hasOnboarded Boolean @default(false)
|
||||
isAdmin Boolean @default(false)
|
||||
|
||||
|
||||
93
packages/db/src/populateHackClubVerification.ts
Normal file
93
packages/db/src/populateHackClubVerification.ts
Normal 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();
|
||||
});
|
||||
Reference in New Issue
Block a user