diff --git a/src/app/admin/AdminSidebar.tsx b/src/app/admin/AdminSidebar.tsx new file mode 100644 index 0000000..d1c83a3 --- /dev/null +++ b/src/app/admin/AdminSidebar.tsx @@ -0,0 +1,57 @@ +"use client"; + +import Link from "next/link"; +import { useRouter, usePathname } from "next/navigation"; + +interface Props { + username: string; + role: "SUPER_ADMIN" | "ADMIN"; +} + +export default function AdminSidebar({ username, role }: Props) { + const router = useRouter(); + const pathname = usePathname(); + + const handleLogout = async () => { + await fetch("/api/admin/logout", { method: "POST" }); + router.push("/admin/login"); + }; + + const links = [ + { href: "/admin/dashboard", label: "Контролна табла" }, + ...(role === "SUPER_ADMIN" ? [{ href: "/admin/users", label: "Администратори" }] : []), + { href: "/admin/codes", label: "Кодови" }, + ]; + + return ( + + ); +} diff --git a/src/app/admin/codes/page.tsx b/src/app/admin/codes/page.tsx new file mode 100644 index 0000000..1b895b4 --- /dev/null +++ b/src/app/admin/codes/page.tsx @@ -0,0 +1,144 @@ +"use client"; + +import { useEffect, useState } from "react"; + +interface Code { + id: string; + code: string; + usedByUserId: string | null; + usedAt: string | null; + createdAt: string; + createdBy: { username: string }; +} + +export default function AdminCodesPage() { + const [codes, setCodes] = useState([]); + const [loading, setLoading] = useState(true); + const [generating, setGenerating] = useState(false); + const [newCode, setNewCode] = useState(""); + const [error, setError] = useState(""); + + const fetchCodes = async () => { + setLoading(true); + try { + const res = await fetch("/api/admin/codes"); + if (res.ok) { + setCodes(await res.json()); + } + } catch { + // ignore + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchCodes(); + }, []); + + const handleGenerate = async () => { + setGenerating(true); + setError(""); + setNewCode(""); + try { + const res = await fetch("/api/admin/codes", { method: "POST" }); + const data = await res.json(); + if (!res.ok) { + throw new Error(data.error || "Грешка при генерирање"); + } + setNewCode(data.code); + fetchCodes(); + } catch (err) { + setError(err instanceof Error ? err.message : "Грешка при генерирање"); + } finally { + setGenerating(false); + } + }; + + const handleDelete = async (id: string) => { + if (!confirm("Дали сте сигурни?")) return; + try { + await fetch(`/api/admin/codes/${id}`, { method: "DELETE" }); + fetchCodes(); + } catch { + // ignore + } + }; + + return ( +
+
+

Кодови

+ +
+ + {newCode && ( +
+

Нов код:

+

{newCode}

+

Копирајте го кодот. Ќе биде прикажан само еднаш.

+
+ )} + + {error && ( +
{error}
+ )} + +
+ {loading ? ( +

Вчитување...

+ ) : codes.length === 0 ? ( +

Нема генерирани кодови

+ ) : ( + + + + + + + + + + + + {codes.map((item) => ( + + + + + + + + ))} + +
КодКреиран одКреиранСтатусАкции
{item.code}{item.createdBy.username}{new Date(item.createdAt).toLocaleDateString("mk-MK")} + {item.usedByUserId ? ( + + Искористен + + ) : ( + + Неискористен + + )} + + {!item.usedByUserId && ( + + )} +
+ )} +
+
+ ); +} diff --git a/src/app/admin/dashboard/page.tsx b/src/app/admin/dashboard/page.tsx new file mode 100644 index 0000000..1044b60 --- /dev/null +++ b/src/app/admin/dashboard/page.tsx @@ -0,0 +1,29 @@ +import { prisma } from "@/lib/prisma"; + +export default async function AdminDashboardPage() { + const [adminCount, codeCount, usedCodeCount] = await Promise.all([ + prisma.adminUser.count(), + prisma.code.count(), + prisma.code.count({ where: { usedByUserId: { not: null } } }), + ]); + + return ( +
+

Контролна табла

+
+
+

Администратори

+

{adminCount}

+
+
+

Генерирани кодови

+

{codeCount}

+
+
+

Искористени кодови

+

{usedCodeCount}

+
+
+
+ ); +} diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx new file mode 100644 index 0000000..21748ee --- /dev/null +++ b/src/app/admin/layout.tsx @@ -0,0 +1,17 @@ +import { getAdminSession } from "@/lib/admin-session"; +import { redirect } from "next/navigation"; +import AdminSidebar from "./AdminSidebar"; + +export default async function AdminLayout({ children }: { children: React.ReactNode }) { + const session = await getAdminSession(); + if (!session) { + redirect("/admin/login"); + } + + return ( +
+ +
{children}
+
+ ); +} diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx new file mode 100644 index 0000000..a3d5c5b --- /dev/null +++ b/src/app/admin/users/page.tsx @@ -0,0 +1,187 @@ +"use client"; + +import { useEffect, useState } from "react"; + +interface AdminUser { + id: string; + username: string; + role: string; + createdAt: string; +} + +export default function AdminUsersPage() { + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [showCreate, setShowCreate] = useState(false); + const [newUsername, setNewUsername] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [error, setError] = useState(""); + + const fetchUsers = async () => { + setLoading(true); + try { + const res = await fetch("/api/admin/users"); + if (res.ok) { + setUsers(await res.json()); + } + } catch { + // ignore + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchUsers(); + }, []); + + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + try { + const res = await fetch("/api/admin/users", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: newUsername, password: newPassword }), + }); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || "Грешка при креирање"); + } + setShowCreate(false); + setNewUsername(""); + setNewPassword(""); + fetchUsers(); + } catch (err) { + setError(err instanceof Error ? err.message : "Грешка при креирање"); + } + }; + + const handleDelete = async (id: string) => { + if (!confirm("Дали сте сигурни дека сакате да го избришете овој администратор?")) return; + try { + const res = await fetch(`/api/admin/users/${id}`, { method: "DELETE" }); + if (res.ok) { + fetchUsers(); + } + } catch { + // ignore + } + }; + + const handleResetPassword = async (id: string) => { + const newPw = prompt("Внесете нова лозинка:"); + if (!newPw || newPw.length < 6) { + alert("Лозинката мора да има најмалку 6 карактери"); + return; + } + try { + const res = await fetch(`/api/admin/users/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password: newPw }), + }); + if (res.ok) { + alert("Лозинката е променета"); + } else { + const data = await res.json(); + alert(data.error || "Грешка"); + } + } catch { + alert("Грешка"); + } + }; + + return ( +
+
+

Администратори

+ +
+ + {showCreate && ( +
+
+
+ + setNewUsername(e.target.value)} + className="mt-1 block w-full rounded-lg border border-stone-200 px-3 py-2.5 text-stone-900 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary" + required + /> +
+
+ + setNewPassword(e.target.value)} + className="mt-1 block w-full rounded-lg border border-stone-200 px-3 py-2.5 text-stone-900 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary" + required + minLength={6} + /> +
+
+ {error &&

{error}

} + +
+ )} + +
+ {loading ? ( +

Вчитување...

+ ) : users.length === 0 ? ( +

Нема администратори

+ ) : ( + + + + + + + + + + + {users.map((user) => ( + + + + + + + ))} + +
Корисничко имеУлогаКреиранАкции
{user.username}{user.role === "SUPER_ADMIN" ? "SuperAdmin" : "Admin"}{new Date(user.createdAt).toLocaleDateString("mk-MK")} + + {user.role !== "SUPER_ADMIN" && ( + + )} +
+ )} +
+
+ ); +} diff --git a/src/app/api/admin/codes/[id]/route.ts b/src/app/api/admin/codes/[id]/route.ts new file mode 100644 index 0000000..d7c8ece --- /dev/null +++ b/src/app/api/admin/codes/[id]/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { getAdminSession } from "@/lib/admin-session"; + +export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const session = await getAdminSession(); + if (!session) { + return NextResponse.json({ error: "Неавторизирано" }, { status: 401 }); + } + + const { id } = await params; + const code = await prisma.code.findUnique({ where: { id } }); + if (!code) { + return NextResponse.json({ error: "Кодот не е пронајден" }, { status: 404 }); + } + if (code.usedByUserId) { + return NextResponse.json({ error: "Не можете да избришете искористен код" }, { status: 400 }); + } + + if (session.role !== "SUPER_ADMIN") { + const admin = await prisma.adminUser.findUnique({ where: { username: session.username } }); + if (!admin || code.createdById !== admin.id) { + return NextResponse.json({ error: "Немате дозвола" }, { status: 403 }); + } + } + + await prisma.code.delete({ where: { id } }); + return NextResponse.json({ success: true }); +} diff --git a/src/app/api/admin/codes/route.ts b/src/app/api/admin/codes/route.ts new file mode 100644 index 0000000..6465a77 --- /dev/null +++ b/src/app/api/admin/codes/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from "next/server"; +import { randomBytes } from "crypto"; +import { prisma } from "@/lib/prisma"; +import { getAdminSession } from "@/lib/admin-session"; + +export async function GET() { + const session = await getAdminSession(); + if (!session) { + return NextResponse.json({ error: "Неавторизирано" }, { status: 401 }); + } + + const where = session.role === "SUPER_ADMIN" ? {} : { createdBy: { username: session.username } }; + + const codes = await prisma.code.findMany({ + where, + orderBy: { createdAt: "desc" }, + include: { createdBy: { select: { username: true } } }, + }); + + return NextResponse.json(codes); +} + +export async function POST() { + const session = await getAdminSession(); + if (!session) { + return NextResponse.json({ error: "Неавторизирано" }, { status: 401 }); + } + + const admin = await prisma.adminUser.findUnique({ where: { username: session.username } }); + if (!admin) { + return NextResponse.json({ error: "Администраторот не е пронајден" }, { status: 404 }); + } + + const code = randomBytes(6).toString("hex").toUpperCase(); + + const created = await prisma.code.create({ + data: { + code, + createdById: admin.id, + }, + }); + + return NextResponse.json(created, { status: 201 }); +} diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts new file mode 100644 index 0000000..751970c --- /dev/null +++ b/src/app/api/admin/users/[id]/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from "next/server"; +import { hash } from "bcryptjs"; +import { prisma } from "@/lib/prisma"; +import { getAdminSession } from "@/lib/admin-session"; + +export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const session = await getAdminSession(); + if (!session || session.role !== "SUPER_ADMIN") { + return NextResponse.json({ error: "Немате дозвола" }, { status: 403 }); + } + + const { id } = await params; + const user = await prisma.adminUser.findUnique({ where: { id } }); + if (!user) { + return NextResponse.json({ error: "Администраторот не е пронајден" }, { status: 404 }); + } + if (user.role === "SUPER_ADMIN") { + return NextResponse.json({ error: "Не можете да избришете SuperAdmin" }, { status: 400 }); + } + + await prisma.adminUser.delete({ where: { id } }); + return NextResponse.json({ success: true }); +} + +export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const session = await getAdminSession(); + if (!session || session.role !== "SUPER_ADMIN") { + return NextResponse.json({ error: "Немате дозвола" }, { status: 403 }); + } + + try { + const { id } = await params; + const { password } = await req.json(); + if (!password || password.length < 6) { + return NextResponse.json({ error: "Лозинката мора да има најмалку 6 карактери" }, { status: 400 }); + } + + const user = await prisma.adminUser.findUnique({ where: { id } }); + if (!user) { + return NextResponse.json({ error: "Администраторот не е пронајден" }, { status: 404 }); + } + + const passwordHash = await hash(password, 12); + await prisma.adminUser.update({ + where: { id }, + data: { passwordHash }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Update admin error:", error); + return NextResponse.json({ error: "Внатрешна грешка на серверот" }, { status: 500 }); + } +} diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts new file mode 100644 index 0000000..49f4194 --- /dev/null +++ b/src/app/api/admin/users/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from "next/server"; +import { hash } from "bcryptjs"; +import { prisma } from "@/lib/prisma"; +import { getAdminSession } from "@/lib/admin-session"; + +export async function GET() { + const session = await getAdminSession(); + if (!session || session.role !== "SUPER_ADMIN") { + return NextResponse.json({ error: "Немате дозвола" }, { status: 403 }); + } + + const users = await prisma.adminUser.findMany({ + orderBy: { createdAt: "desc" }, + }); + + return NextResponse.json(users); +} + +export async function POST(req: NextRequest) { + const session = await getAdminSession(); + if (!session || session.role !== "SUPER_ADMIN") { + return NextResponse.json({ error: "Немате дозвола" }, { status: 403 }); + } + + try { + const { username, password } = await req.json(); + if (!username || !password) { + return NextResponse.json({ error: "Корисничко име и лозинка се задолжителни" }, { status: 400 }); + } + if (password.length < 6) { + return NextResponse.json({ error: "Лозинката мора да има најмалку 6 карактери" }, { status: 400 }); + } + + const existing = await prisma.adminUser.findUnique({ where: { username } }); + if (existing) { + return NextResponse.json({ error: "Корисничкото име веќе постои" }, { status: 409 }); + } + + const passwordHash = await hash(password, 12); + const user = await prisma.adminUser.create({ + data: { username, passwordHash, role: "ADMIN" }, + }); + + return NextResponse.json(user, { status: 201 }); + } catch (error) { + console.error("Create admin error:", error); + return NextResponse.json({ error: "Внатрешна грешка на серверот" }, { status: 500 }); + } +}