diff --git a/src/app/admin/login/page.tsx b/src/app/admin/login/page.tsx new file mode 100644 index 0000000..34e47f0 --- /dev/null +++ b/src/app/admin/login/page.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; + +export default function AdminLoginPage() { + const router = useRouter(); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(""); + try { + const res = await fetch("/api/admin/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + const data = await res.json(); + if (!res.ok) { + throw new Error(data.error || "Најавата не успеа"); + } + router.push("/admin/dashboard"); + } catch (err) { + setError(err instanceof Error ? err.message : "Најавата не успеа"); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

Администрација

+
+
+ + setUsername(e.target.value)} + className="mt-1 block w-full rounded-lg border border-stone-200 px-3 py-2.5 text-stone-900 placeholder:text-stone-400 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary" + autoComplete="username" + /> +
+
+ + setPassword(e.target.value)} + className="mt-1 block w-full rounded-lg border border-stone-200 px-3 py-2.5 text-stone-900 placeholder:text-stone-400 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary" + autoComplete="current-password" + /> +
+ {error &&

{error}

} + +
+
+
+ ); +} diff --git a/src/app/api/admin/change-password/route.ts b/src/app/api/admin/change-password/route.ts new file mode 100644 index 0000000..1d6ebf3 --- /dev/null +++ b/src/app/api/admin/change-password/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from "next/server"; +import { hash, compare } from "bcryptjs"; +import { prisma } from "@/lib/prisma"; +import { getAdminSession } from "@/lib/admin-session"; + +export async function POST(req: NextRequest) { + try { + const session = await getAdminSession(); + if (!session) { + return NextResponse.json({ error: "Неавторизирано" }, { status: 401 }); + } + if (session.username === "super") { + return NextResponse.json({ error: "SuperAdmin не може да ја промени лозинката преку овој метод" }, { status: 400 }); + } + + const { currentPassword, newPassword } = await req.json(); + if (!currentPassword || !newPassword) { + return NextResponse.json({ error: "Тековната и новата лозинка се задолжителни" }, { status: 400 }); + } + if (newPassword.length < 6) { + return NextResponse.json({ error: "Новата лозинка мора да има најмалку 6 карактери" }, { status: 400 }); + } + + const admin = await prisma.adminUser.findUnique({ where: { username: session.username } }); + if (!admin) { + return NextResponse.json({ error: "Администраторот не е пронајден" }, { status: 404 }); + } + + const valid = await compare(currentPassword, admin.passwordHash); + if (!valid) { + return NextResponse.json({ error: "Тековната лозинка е неточна" }, { status: 401 }); + } + + const passwordHash = await hash(newPassword, 12); + await prisma.adminUser.update({ + where: { username: session.username }, + data: { passwordHash }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Change password error:", error); + return NextResponse.json({ error: "Внатрешна грешка на серверот" }, { status: 500 }); + } +} diff --git a/src/app/api/admin/login/route.ts b/src/app/api/admin/login/route.ts new file mode 100644 index 0000000..eb7f070 --- /dev/null +++ b/src/app/api/admin/login/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from "next/server"; +import { compare } from "bcryptjs"; +import { prisma } from "@/lib/prisma"; +import { createAdminSession, cookieOptions } from "@/lib/admin-session"; + +const SUPER_USERNAME = "super"; +const SUPER_PASSWORD = "admin"; + +export async function POST(req: NextRequest) { + try { + const { username, password } = await req.json(); + if (!username || !password) { + return NextResponse.json({ error: "Корисничко име и лозинка се задолжителни" }, { status: 400 }); + } + + if (username === SUPER_USERNAME && password === SUPER_PASSWORD) { + const session = createAdminSession({ username: SUPER_USERNAME, role: "SUPER_ADMIN" }); + const res = NextResponse.json({ success: true }); + res.cookies.set(cookieOptions(session)); + return res; + } + + const admin = await prisma.adminUser.findUnique({ where: { username } }); + if (!admin) { + return NextResponse.json({ error: "Невалидно корисничко име или лозинка" }, { status: 401 }); + } + + const valid = await compare(password, admin.passwordHash); + if (!valid) { + return NextResponse.json({ error: "Невалидно корисничко име или лозинка" }, { status: 401 }); + } + + const session = createAdminSession({ username: admin.username, role: admin.role }); + const res = NextResponse.json({ success: true }); + res.cookies.set(cookieOptions(session)); + return res; + } catch (error) { + console.error("Admin login error:", error); + return NextResponse.json({ error: "Внатрешна грешка на серверот" }, { status: 500 }); + } +} diff --git a/src/app/api/admin/logout/route.ts b/src/app/api/admin/logout/route.ts new file mode 100644 index 0000000..15c0cb7 --- /dev/null +++ b/src/app/api/admin/logout/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from "next/server"; + +export async function POST() { + const res = NextResponse.json({ success: true }); + res.cookies.set({ + name: "admin_session", + value: "", + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/admin", + maxAge: 0, + }); + return res; +} diff --git a/src/lib/admin-session.ts b/src/lib/admin-session.ts new file mode 100644 index 0000000..07d2e72 --- /dev/null +++ b/src/lib/admin-session.ts @@ -0,0 +1,68 @@ +import { cookies } from "next/headers"; +import { createHmac, timingSafeEqual } from "crypto"; + +const COOKIE_NAME = "admin_session"; +const SEP = "."; + +function getSecret(): string { + const secret = process.env.ADMIN_SESSION_SECRET; + if (!secret) throw new Error("ADMIN_SESSION_SECRET env var is not set"); + return secret; +} + +function sign(payload: string): string { + const hmac = createHmac("sha256", getSecret()); + hmac.update(payload); + return hmac.digest("base64url"); +} + +export interface AdminSession { + username: string; + role: "SUPER_ADMIN" | "ADMIN"; +} + +export function createAdminSession(data: AdminSession): string { + const payload = Buffer.from(JSON.stringify(data)).toString("base64url"); + return payload + SEP + sign(payload); +} + +export function verifyAdminSession(token: string): AdminSession | null { + const sepIdx = token.lastIndexOf(SEP); + if (sepIdx === -1) return null; + const payload = token.slice(0, sepIdx); + const sig = token.slice(sepIdx + 1); + const expectedSig = sign(payload); + const sigBuf = Buffer.from(sig); + const expectedBuf = Buffer.from(expectedSig); + if (sigBuf.length !== expectedBuf.length) return null; + try { + if (!timingSafeEqual(sigBuf, expectedBuf)) return null; + } catch { + return null; + } + try { + return JSON.parse(Buffer.from(payload, "base64url").toString("utf-8")); + } catch { + return null; + } +} + +export async function getAdminSession(): Promise { + const store = await cookies(); + const token = store.get(COOKIE_NAME)?.value; + if (!token) return null; + return verifyAdminSession(token); +} + +export const COOKIE_NAME_ADMIN = COOKIE_NAME; + +export function cookieOptions(value: string) { + return { + name: COOKIE_NAME, + value, + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax" as const, + path: "/admin", + }; +} diff --git a/src/middleware.ts b/src/middleware.ts index 2964296..2c64e73 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,9 +1,14 @@ import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"; import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; +import { verifyAdminSession, COOKIE_NAME_ADMIN } from "@/lib/admin-session"; const isProtectedRoute = createRouteMatcher(["/dashboard(.*)", "/onboarding(.*)", "/api/publish(.*)", "/api/upload(.*)", "/api/user(.*)"]); +function isAdminRoute(pathname: string): boolean { + return pathname.startsWith("/admin") || pathname.startsWith("/api/admin"); +} + function getSubdomain(req: NextRequest): string | null { const host = req.headers.get("host"); if (!host) return null; @@ -25,6 +30,22 @@ function getSubdomain(req: NextRequest): string | null { } export default clerkMiddleware(async (auth, req: NextRequest) => { + if (isAdminRoute(req.nextUrl.pathname)) { + if (req.nextUrl.pathname === "/admin/login") { + return NextResponse.next(); + } + + const token = req.cookies.get(COOKIE_NAME_ADMIN)?.value; + if (!token || !verifyAdminSession(token)) { + if (req.nextUrl.pathname.startsWith("/api/")) { + return NextResponse.json({ error: "Неавторизирано" }, { status: 401 }); + } + return NextResponse.redirect(new URL("/admin/login", req.url)); + } + + return NextResponse.next(); + } + if (isProtectedRoute(req)) { await auth.protect(); } @@ -41,4 +62,4 @@ export default clerkMiddleware(async (auth, req: NextRequest) => { export const config = { matcher: ["/(api|trpc)(.*)", "/__clerk/:path*", "/((?!_next|api/static|.*\\..*).*)"], -}; \ No newline at end of file +};