feat: some changes and admin

This commit is contained in:
2026-03-31 16:09:03 +09:00
parent b5a1c08024
commit fd0f451e7c
54 changed files with 2156 additions and 8 deletions

View File

@@ -0,0 +1,20 @@
import { NextResponse } from 'next/server';
import type { AdminUserRow } from '@/lib/auth/session';
import { getSessionUser } from '@/lib/auth/session';
export async function requireAdminApi(): Promise<
{ ok: true; user: AdminUserRow } | { ok: false; response: NextResponse }
> {
const user = await getSessionUser();
if (!user) {
return { ok: false, response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) };
}
if (!user.is_approved) {
return { ok: false, response: NextResponse.json({ error: 'Pending approval' }, { status: 403 }) };
}
if (!user.is_admin) {
return { ok: false, response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }) };
}
return { ok: true, user };
}

View File

@@ -0,0 +1,11 @@
import bcrypt from 'bcryptjs';
const SALT_ROUNDS = 12;
export function hashPassword(plain: string): string {
return bcrypt.hashSync(plain, SALT_ROUNDS);
}
export function verifyPassword(plain: string, passwordHash: string): boolean {
return bcrypt.compareSync(plain, passwordHash);
}

View File

@@ -0,0 +1,22 @@
import { redirect } from 'next/navigation';
import { getSessionUser } from '@/lib/auth/session';
export async function requireLogin(): Promise<NonNullable<Awaited<ReturnType<typeof getSessionUser>>>> {
const user = await getSessionUser();
if (!user) {
redirect('/admin/login');
}
if (!user.is_approved) {
redirect('/admin/pending');
}
return user;
}
export async function requireAdmin(): Promise<NonNullable<Awaited<ReturnType<typeof getSessionUser>>>> {
const user = await requireLogin();
if (!user.is_admin) {
redirect('/admin/no-access');
}
return user;
}

View File

@@ -0,0 +1,88 @@
import { createHash, randomBytes } from 'crypto';
import type { RowDataPacket } from 'mysql2';
import { cookies } from 'next/headers';
import { getPool } from '@/lib/db';
export const SESSION_COOKIE = 'admin_session';
const SESSION_MAX_AGE_SEC = 60 * 60 * 24 * 7; // 7 days
export type AdminUserRow = {
id: number;
email: string;
password_hash: string;
is_approved: boolean;
is_admin: boolean;
};
function hashToken(token: string): string {
return createHash('sha256').update(token, 'utf8').digest('hex');
}
export async function createSession(userId: number): Promise<string> {
const token = randomBytes(32).toString('hex');
const tokenHash = hashToken(token);
const expiresAt = new Date(Date.now() + SESSION_MAX_AGE_SEC * 1000);
const pool = getPool();
await pool.query(
`INSERT INTO admin_sessions (user_id, token_hash, expires_at) VALUES (?, ?, ?)`,
[userId, tokenHash, expiresAt]
);
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: SESSION_MAX_AGE_SEC,
path: '/',
});
return token;
}
export async function destroySession(): Promise<void> {
const cookieStore = await cookies();
const token = cookieStore.get(SESSION_COOKIE)?.value;
if (token) {
const pool = getPool();
await pool.query(
'DELETE FROM admin_sessions WHERE token_hash = ?',
[hashToken(token)]
);
}
cookieStore.delete(SESSION_COOKIE);
}
export async function getSessionUser(): Promise<AdminUserRow | null> {
const cookieStore = await cookies();
const token = cookieStore.get(SESSION_COOKIE)?.value;
if (!token) {
return null;
}
const tokenHash = hashToken(token);
const pool = getPool();
const [rows] = await pool.query<RowDataPacket[]>(
`SELECT u.id, u.email, u.password_hash, u.is_approved, u.is_admin
FROM admin_sessions s
JOIN admin_users u ON u.id = s.user_id
WHERE s.token_hash = ? AND s.expires_at > NOW()`,
[tokenHash]
);
if (rows.length === 0) {
cookieStore.delete(SESSION_COOKIE);
return null;
}
const row = rows[0] as Record<string, unknown>;
return {
id: row.id as number,
email: row.email as string,
password_hash: row.password_hash as string,
is_approved: Boolean(row.is_approved),
is_admin: Boolean(row.is_admin),
};
}