feat: panele auth sistemi ekle (kullanıcı/şifre + oturum + middleware)

Panel şu ana kadar herkese açıktı — URL'yi bilen herkes kanal
ekleyip/silebilir, cookie güncelleyebilir, kayıt indirebilirdi.

- users tablosu (bcrypt şifre hash'i)
- /login: hiç kullanıcı yoksa "ilk admin hesabı oluştur" formu, varsa
  normal giriş formu
- jose ile imzalanmış, httpOnly çerezde tutulan 30 günlük oturum
- middleware.ts: /login hariç tüm rotaları korur (video stream route'u
  dahil — aynı origin istekleri çerezi otomatik taşır)
- layout: oturum yoksa nav hiç gösterilmiyor, varsa kullanıcı adı +
  çıkış butonu ekleniyor

SESSION_SECRET production'da zorunlu (docker-compose derleme zamanında
kontrol ediyor); Coolify'a rastgele bir değer eklendi.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-01 11:26:42 +03:00
co-authored by Claude Sonnet 5
parent ef1dc1b7ac
commit fb8485d638
12 changed files with 282 additions and 6 deletions
+23 -6
View File
@@ -1,20 +1,37 @@
import type { Metadata } from "next";
import Link from "next/link";
import "./globals.css";
import { getSession } from "../lib/auth";
import { logout } from "./login/actions";
export const metadata: Metadata = {
title: "StreamClipper AI — Panel",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const session = await getSession();
return (
<html lang="tr">
<body>
<nav className="nav">
<Link href="/">Kanal Durumu</Link>
<Link href="/segments">Segment & Aday Kütüphanesi</Link>
<Link href="/settings">Ayarlar</Link>
</nav>
{session && (
<nav className="nav">
<Link href="/">Kanal Durumu</Link>
<Link href="/segments">Segment & Aday Kütüphanesi</Link>
<Link href="/settings">Ayarlar</Link>
<span style={{ marginLeft: "auto", display: "flex", gap: "1rem", alignItems: "center" }}>
<span className="mono">{session.username}</span>
<form action={logout}>
<button
type="submit"
style={{ background: "none", border: "none", color: "var(--muted)", cursor: "pointer", font: "inherit" }}
>
Çıkış Yap
</button>
</form>
</span>
</nav>
)}
<main>{children}</main>
</body>
</html>
+60
View File
@@ -0,0 +1,60 @@
"use server";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import bcrypt from "bcryptjs";
import { prisma } from "@streamclipper/db";
import { createSessionToken, SESSION_COOKIE_NAME } from "../../lib/auth";
const SESSION_MAX_AGE_SEC = 60 * 60 * 24 * 30;
async function setSessionCookie(userId: string, username: string) {
const token = await createSessionToken({ sub: userId, username });
const jar = await cookies();
jar.set(SESSION_COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: SESSION_MAX_AGE_SEC,
});
}
export async function bootstrapAdmin(formData: FormData) {
const username = String(formData.get("username") ?? "").trim();
const password = String(formData.get("password") ?? "");
if (!username || password.length < 8) {
throw new Error("Kullanıcı adı gerekli, şifre en az 8 karakter olmalı.");
}
const existing = await prisma.user.count();
if (existing > 0) {
redirect("/login");
}
const passwordHash = await bcrypt.hash(password, 12);
const user = await prisma.user.create({ data: { username, passwordHash } });
await setSessionCookie(user.id, user.username);
redirect("/");
}
export async function login(formData: FormData) {
const username = String(formData.get("username") ?? "").trim();
const password = String(formData.get("password") ?? "");
const user = await prisma.user.findUnique({ where: { username } });
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
redirect("/login?error=1");
}
await setSessionCookie(user.id, user.username);
redirect("/");
}
export async function logout() {
const jar = await cookies();
jar.delete(SESSION_COOKIE_NAME);
redirect("/login");
}
+71
View File
@@ -0,0 +1,71 @@
import { prisma } from "@streamclipper/db";
import { bootstrapAdmin, login } from "./actions";
export const dynamic = "force-dynamic";
const fieldStyle = {
background: "var(--bg)",
border: "1px solid var(--border)",
borderRadius: "6px",
padding: "0.6rem 0.7rem",
color: "var(--text)",
fontSize: "0.9rem",
};
const buttonStyle = {
background: "var(--accent)",
border: "none",
borderRadius: "6px",
padding: "0.6rem 1rem",
color: "#fff",
fontWeight: 600,
fontSize: "0.9rem",
cursor: "pointer",
};
export default async function LoginPage({
searchParams,
}: {
searchParams: Promise<{ error?: string }>;
}) {
const { error } = await searchParams;
const userCount = await prisma.user.count();
const isBootstrap = userCount === 0;
return (
<div style={{ maxWidth: "360px", margin: "4rem auto" }}>
<h1>{isBootstrap ? "İlk Yönetici Hesabını Oluştur" : "Giriş Yap"}</h1>
{isBootstrap && (
<p className="empty" style={{ marginBottom: "1rem" }}>
Henüz kullanıcı yok panele erişecek ilk admin hesabını burada oluştur.
</p>
)}
{!isBootstrap && error && (
<p style={{ color: "var(--err)", fontSize: "0.9rem", marginBottom: "1rem" }}>
Kullanıcı adı veya şifre hatalı.
</p>
)}
<form
action={isBootstrap ? bootstrapAdmin : login}
className="card"
style={{ display: "flex", flexDirection: "column", gap: "0.6rem" }}
>
<input name="username" placeholder="Kullanıcı adı" required style={fieldStyle} />
<input
name="password"
type="password"
placeholder="Şifre"
required
minLength={isBootstrap ? 8 : undefined}
style={fieldStyle}
/>
<button type="submit" style={buttonStyle}>
{isBootstrap ? "Hesabı Oluştur" : "Giriş Yap"}
</button>
</form>
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { cookies } from "next/headers";
import { SignJWT, jwtVerify } from "jose";
export const SESSION_COOKIE_NAME = "session";
const secret = new TextEncoder().encode(
process.env.SESSION_SECRET ?? "dev-insecure-secret-change-me-in-production",
);
export interface SessionPayload {
sub: string;
username: string;
}
export async function createSessionToken(payload: SessionPayload): Promise<string> {
return new SignJWT({ username: payload.username })
.setProtectedHeader({ alg: "HS256" })
.setSubject(payload.sub)
.setIssuedAt()
.setExpirationTime("30d")
.sign(secret);
}
export async function verifySessionToken(token: string): Promise<SessionPayload | null> {
try {
const { payload } = await jwtVerify(token, secret);
if (typeof payload.sub !== "string" || typeof payload.username !== "string") return null;
return { sub: payload.sub, username: payload.username };
} catch {
return null;
}
}
/** Server Component helper — reads and verifies the session cookie. */
export async function getSession(): Promise<SessionPayload | null> {
const jar = await cookies();
const token = jar.get(SESSION_COOKIE_NAME)?.value;
if (!token) return null;
return verifySessionToken(token);
}
+21
View File
@@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from "next/server";
import { SESSION_COOKIE_NAME, verifySessionToken } from "./lib/auth";
export async function middleware(req: NextRequest) {
if (req.nextUrl.pathname.startsWith("/login")) {
return NextResponse.next();
}
const token = req.cookies.get(SESSION_COOKIE_NAME)?.value;
const session = token ? await verifySessionToken(token) : null;
if (!session) {
return NextResponse.redirect(new URL("/login", req.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};
+3
View File
@@ -9,11 +9,14 @@
},
"dependencies": {
"@streamclipper/db": "workspace:*",
"bcryptjs": "^2.4.3",
"jose": "^5.9.6",
"next": "^15.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/node": "^22.10.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",