From 2fdbc6e4f8907cd6130771248cf5caa51789f90f Mon Sep 17 00:00:00 2001 From: cleanjunc Date: Tue, 26 May 2026 12:55:05 +0000 Subject: [PATCH 1/2] refactor: extract admin auth gate into requireAdmin() helper --- src/app/api/admin/pending-count/route.ts | 8 +++----- src/app/api/admin/users/[id]/approve/route.ts | 9 ++++----- src/app/api/admin/users/[id]/promote/route.ts | 9 ++++----- src/app/api/admin/users/[id]/reject/route.ts | 9 ++++----- src/app/api/admin/users/route.ts | 9 ++++----- src/lib/auth.ts | 17 +++++++++++++++++ 6 files changed, 36 insertions(+), 25 deletions(-) diff --git a/src/app/api/admin/pending-count/route.ts b/src/app/api/admin/pending-count/route.ts index 90b4c98..4237383 100644 --- a/src/app/api/admin/pending-count/route.ts +++ b/src/app/api/admin/pending-count/route.ts @@ -1,13 +1,11 @@ import { NextResponse } from 'next/server'; -import { getSessionFromCookies, getUserById, pendingCount, recentPendingUsers } from '@/lib/auth'; +import { pendingCount, recentPendingUsers, requireAdmin } from '@/lib/auth'; export const dynamic = 'force-dynamic'; export async function GET() { - const sess = await getSessionFromCookies(); - if (!sess) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - const me = getUserById(sess.uid); - if (!me || !me.is_admin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + const gate = await requireAdmin(); + if (gate instanceof NextResponse) return gate; const count = pendingCount(); const latest = recentPendingUsers(20).map((u) => ({ id: u.id, diff --git a/src/app/api/admin/users/[id]/approve/route.ts b/src/app/api/admin/users/[id]/approve/route.ts index 3b79076..73f6390 100644 --- a/src/app/api/admin/users/[id]/approve/route.ts +++ b/src/app/api/admin/users/[id]/approve/route.ts @@ -1,14 +1,13 @@ import { NextResponse } from 'next/server'; -import { approveUser, getSessionFromCookies, getUserById } from '@/lib/auth'; +import { approveUser, requireAdmin } from '@/lib/auth'; export const dynamic = 'force-dynamic'; export async function POST(_req: Request, ctx: { params: Promise<{ id: string }> }) { const params = await ctx.params; - const sess = await getSessionFromCookies(); - if (!sess) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - const me = getUserById(sess.uid); - if (!me || !me.is_admin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + const gate = await requireAdmin(); + if (gate instanceof NextResponse) return gate; + const { user: me } = gate; const id = Number(params.id); if (!Number.isFinite(id)) return NextResponse.json({ error: 'Invalid id' }, { status: 400 }); const updated = approveUser(id, me.id); diff --git a/src/app/api/admin/users/[id]/promote/route.ts b/src/app/api/admin/users/[id]/promote/route.ts index 6c175f4..6242044 100644 --- a/src/app/api/admin/users/[id]/promote/route.ts +++ b/src/app/api/admin/users/[id]/promote/route.ts @@ -1,14 +1,13 @@ import { NextResponse } from 'next/server'; -import { getSessionFromCookies, getUserById, promoteUser, RoleError } from '@/lib/auth'; +import { promoteUser, requireAdmin, RoleError } from '@/lib/auth'; export const dynamic = 'force-dynamic'; export async function POST(_req: Request, ctx: { params: Promise<{ id: string }> }) { const params = await ctx.params; - const sess = await getSessionFromCookies(); - if (!sess) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - const me = getUserById(sess.uid); - if (!me || !me.is_admin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + const gate = await requireAdmin(); + if (gate instanceof NextResponse) return gate; + const { user: me } = gate; const id = Number(params.id); if (!Number.isFinite(id)) return NextResponse.json({ error: 'Invalid id' }, { status: 400 }); try { diff --git a/src/app/api/admin/users/[id]/reject/route.ts b/src/app/api/admin/users/[id]/reject/route.ts index 51502fd..a4f5d07 100644 --- a/src/app/api/admin/users/[id]/reject/route.ts +++ b/src/app/api/admin/users/[id]/reject/route.ts @@ -1,14 +1,13 @@ import { NextResponse } from 'next/server'; -import { getSessionFromCookies, getUserById, rejectUser } from '@/lib/auth'; +import { rejectUser, requireAdmin } from '@/lib/auth'; export const dynamic = 'force-dynamic'; export async function POST(_req: Request, ctx: { params: Promise<{ id: string }> }) { const params = await ctx.params; - const sess = await getSessionFromCookies(); - if (!sess) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - const me = getUserById(sess.uid); - if (!me || !me.is_admin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + const gate = await requireAdmin(); + if (gate instanceof NextResponse) return gate; + const { user: me } = gate; const id = Number(params.id); if (!Number.isFinite(id)) return NextResponse.json({ error: 'Invalid id' }, { status: 400 }); const updated = rejectUser(id, me.id); diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts index 41fe942..879edfb 100644 --- a/src/app/api/admin/users/route.ts +++ b/src/app/api/admin/users/route.ts @@ -1,13 +1,12 @@ import { NextResponse } from 'next/server'; -import { getSessionFromCookies, getUserById, listUsers } from '@/lib/auth'; +import { listUsers, requireAdmin } from '@/lib/auth'; export const dynamic = 'force-dynamic'; export async function GET() { - const sess = await getSessionFromCookies(); - if (!sess) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - const me = getUserById(sess.uid); - if (!me || !me.is_admin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + const gate = await requireAdmin(); + if (gate instanceof NextResponse) return gate; + const { user: me } = gate; const users = listUsers(); return NextResponse.json({ me: { id: me.id, github_login: me.github_login }, diff --git a/src/lib/auth.ts b/src/lib/auth.ts index c0364e2..e9a5045 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -2,12 +2,14 @@ import { randomBytes } from 'crypto'; import fs from 'fs'; import path from 'path'; import { cookies, headers } from 'next/headers'; +import { NextResponse } from 'next/server'; import { getDb } from '@/lib/db'; import { encodeSession, verifySessionToken, SESSION_COOKIE_NAME, SESSION_MAX_AGE_SEC, + type SessionPayload, type SessionStatus, } from '@/lib/session-token'; @@ -96,6 +98,21 @@ export async function getSessionFromCookies() { return verifySessionToken(token); } +/** + * Admin auth gate for API routes. On success returns the verified session and + * the corresponding fresh user row. On failure returns a 401/403 NextResponse + * the caller can return as-is. + */ +export async function requireAdmin(): Promise< + { session: SessionPayload; user: UserRow } | NextResponse +> { + const session = await getSessionFromCookies(); + if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + const user = getUserById(session.uid); + if (!user || !user.is_admin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + return { session, user }; +} + export { SESSION_COOKIE_NAME }; // --------------------------------------------------------------------------- From bb30f66c82dfdbe84332c01c1c046c437a4bda03 Mon Sep 17 00:00:00 2001 From: cleanjunc Date: Wed, 27 May 2026 12:02:35 +0000 Subject: [PATCH 2/2] refactor: migrate demote and user-repos routes to shared requireAdmin() --- src/app/api/admin/users/[id]/demote/route.ts | 9 ++++----- src/app/api/user-repos/route.ts | 14 +++----------- 2 files changed, 7 insertions(+), 16 deletions(-) diff --git a/src/app/api/admin/users/[id]/demote/route.ts b/src/app/api/admin/users/[id]/demote/route.ts index 03b9d7d..609c50e 100644 --- a/src/app/api/admin/users/[id]/demote/route.ts +++ b/src/app/api/admin/users/[id]/demote/route.ts @@ -1,14 +1,13 @@ import { NextResponse } from 'next/server'; -import { demoteUser, getSessionFromCookies, getUserById, RoleError } from '@/lib/auth'; +import { demoteUser, requireAdmin, RoleError } from '@/lib/auth'; export const dynamic = 'force-dynamic'; export async function POST(_req: Request, ctx: { params: Promise<{ id: string }> }) { const params = await ctx.params; - const sess = await getSessionFromCookies(); - if (!sess) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - const me = getUserById(sess.uid); - if (!me || !me.is_admin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + const gate = await requireAdmin(); + if (gate instanceof NextResponse) return gate; + const { user: me } = gate; const id = Number(params.id); if (!Number.isFinite(id)) return NextResponse.json({ error: 'Invalid id' }, { status: 400 }); try { diff --git a/src/app/api/user-repos/route.ts b/src/app/api/user-repos/route.ts index 3277ddc..23965fd 100644 --- a/src/app/api/user-repos/route.ts +++ b/src/app/api/user-repos/route.ts @@ -1,18 +1,10 @@ import { NextRequest, NextResponse } from 'next/server'; import { getDb } from '@/lib/db'; -import { getSessionFromCookies, getUserById } from '@/lib/auth'; +import { requireAdmin } from '@/lib/auth'; import type { UserRepo } from '@/types/entities'; export const dynamic = 'force-dynamic'; -async function requireAdmin() { - const sess = await getSessionFromCookies(); - if (!sess) return { error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) }; - const me = getUserById(sess.uid); - if (!me || !me.is_admin) return { error: NextResponse.json({ error: 'Forbidden — admin only' }, { status: 403 }) }; - return { error: null as null }; -} - export async function GET() { const db = getDb(); const rows = db @@ -23,7 +15,7 @@ export async function GET() { export async function POST(req: NextRequest) { const gate = await requireAdmin(); - if (gate.error) return gate.error; + if (gate instanceof NextResponse) return gate; const body = await req.json().catch(() => null); if (!body || typeof body.full_name !== 'string') { @@ -47,7 +39,7 @@ export async function POST(req: NextRequest) { export async function DELETE(req: NextRequest) { const gate = await requireAdmin(); - if (gate.error) return gate.error; + if (gate instanceof NextResponse) return gate; const url = new URL(req.url); const fullName = url.searchParams.get('full_name');