feat: phase 0 skeleton — next.js 16 + better-auth + prisma

This commit is contained in:
Semih
2026-05-13 09:17:50 +00:00
commit 67a7c5b887
26 changed files with 1254 additions and 0 deletions

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
node_modules
.next
.turbo
.git
.env*
!.env.example
*.log
README.md
super-panel-prd.md

5
.env.example Normal file
View File

@@ -0,0 +1,5 @@
DATABASE_URL_PANEL=postgresql://panel:panel@panel-postgres:5432/panel
REDIS_URL=redis://panel-redis:6379
BETTER_AUTH_SECRET=replace-with-32+char-random-string
BETTER_AUTH_URL=https://sp.semih.ai
NODE_ENV=production

12
.gitignore vendored Normal file
View File

@@ -0,0 +1,12 @@
node_modules/
.next/
.turbo/
dist/
.env
.env.local
.env.*.local
*.log
.DS_Store
.pnpm-store/
coverage/
prisma/migrations/dev/

22
README.md Normal file
View File

@@ -0,0 +1,22 @@
# Süper Panel
Internal admin panel — `sp.semih.ai` — Tailscale-only access.
Phase 0 skeleton: Next.js 16 + Better Auth (email/password + TOTP) + Prisma + Postgres + Redis.
Deployed on Coolify, fronted by Traefik with Let's Encrypt (Cloudflare DNS-01).
See `super-panel-prd.md` for the full PRD.
## Local dev
```bash
pnpm install
cp .env.example .env
# fill DATABASE_URL_PANEL, BETTER_AUTH_SECRET (32+ chars)
pnpm --filter @panel/web prisma:generate
pnpm dev
```
## Production deploy
Pushes to `main` trigger Coolify deploy via webhook.

43
apps/web/Dockerfile Normal file
View File

@@ -0,0 +1,43 @@
FROM node:22-alpine AS base
RUN apk add --no-cache libc6-compat openssl
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
# ---- deps ----
FROM base AS deps
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml ./
COPY apps/web/package.json apps/web/package.json
RUN pnpm install --frozen-lockfile || pnpm install
# ---- builder ----
FROM base AS builder
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules
COPY . .
WORKDIR /app/apps/web
RUN pnpm prisma generate
RUN pnpm build
# ---- runner ----
FROM base AS runner
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
WORKDIR /app
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
COPY --from=builder /app/apps/web/public ./apps/web/public
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/prisma ./apps/web/prisma
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/node_modules/.prisma ./apps/web/node_modules/.prisma
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/node_modules/@prisma ./apps/web/node_modules/@prisma
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/prisma ./node_modules/prisma
COPY apps/web/docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
USER nextjs
EXPOSE 3000
ENV PORT=3000 HOSTNAME=0.0.0.0
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["node", "apps/web/server.js"]

View File

@@ -0,0 +1,6 @@
#!/bin/sh
set -e
cd /app/apps/web
npx prisma db push --accept-data-loss --skip-generate || echo "prisma db push skipped/failed (continuing)"
cd /app
exec "$@"

21
apps/web/middleware.ts Normal file
View File

@@ -0,0 +1,21 @@
import { NextResponse, type NextRequest } from "next/server";
const PUBLIC = new Set(["/login", "/setup-2fa"]);
export function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
if (pathname.startsWith("/api/auth") || PUBLIC.has(pathname) || pathname === "/_next" || pathname.startsWith("/_next")) {
return NextResponse.next();
}
const hasSession = req.cookies.get("sp.session_token") || req.cookies.get("sp.session_token.sig");
if (!hasSession && pathname === "/") {
const url = req.nextUrl.clone();
url.pathname = "/login";
return NextResponse.redirect(url);
}
return NextResponse.next();
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico|api/health).*)"],
};

11
apps/web/next.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import type { NextConfig } from "next";
const config: NextConfig = {
output: "standalone",
reactStrictMode: true,
experimental: {
serverActions: { allowedOrigins: ["sp.semih.ai", "localhost:3000"] },
},
};
export default config;

32
apps/web/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "@panel/web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",
"build": "prisma generate && next build",
"start": "next start -p 3000 -H 0.0.0.0",
"lint": "next lint",
"typecheck": "tsc --noEmit",
"prisma:generate": "prisma generate",
"prisma:migrate:deploy": "prisma migrate deploy"
},
"dependencies": {
"@prisma/client": "^5.22.0",
"better-auth": "^1.2.0",
"next": "^16.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0.0",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"postcss": "^8.4.49",
"prisma": "^5.22.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.6.0"
}
}

View File

@@ -0,0 +1,3 @@
export default {
plugins: { "@tailwindcss/postcss": {} },
};

View File

@@ -0,0 +1,96 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL_PANEL")
}
model User {
id String @id
name String
email String @unique
emailVerified Boolean @default(false)
image String?
twoFactorEnabled Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sessions Session[]
accounts Account[]
twoFactors TwoFactor[]
}
model Session {
id String @id
userId String
token String @unique
expiresAt DateTime
ipAddress String?
userAgent String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model Account {
id String @id
userId String
accountId String
providerId String
accessToken String?
refreshToken String?
accessTokenExpiresAt DateTime?
refreshTokenExpiresAt DateTime?
scope String?
idToken String?
password String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model Verification {
id String @id
identifier String
value String
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model TwoFactor {
id String @id
userId String
secret String
backupCodes String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model Project {
id String @id @default(cuid())
key String @unique
name String
description String?
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model AuditLog {
id String @id @default(cuid())
actorUserId String?
projectKey String?
endpoint String
method String
requestHash String?
responseStatus Int?
durationMs Int?
sourceIp String?
userAgent String?
createdAt DateTime @default(now())
@@index([createdAt])
@@index([actorUserId])
@@index([projectKey])
}

View File

@@ -0,0 +1,4 @@
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";
export const { GET, POST } = toNextJsHandler(auth);

View File

@@ -0,0 +1,11 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function GET() {
try {
await prisma.$queryRaw`SELECT 1`;
return NextResponse.json({ ok: true });
} catch {
return NextResponse.json({ ok: false }, { status: 503 });
}
}

View File

@@ -0,0 +1,16 @@
@import "tailwindcss";
@theme {
--color-bg: #0a0a0a;
--color-surface: #111111;
--color-border: #1f1f1f;
--color-fg: #ededed;
--color-muted: #8b8b8b;
--color-accent: #f97316;
}
html, body {
background: var(--color-bg);
color: var(--color-fg);
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}

View File

@@ -0,0 +1,16 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Süper Panel",
description: "Internal admin panel",
robots: { index: false, follow: false },
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="tr" className="dark">
<body>{children}</body>
</html>
);
}

View File

@@ -0,0 +1,101 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { signIn, twoFactor } from "@/lib/auth-client";
export default function LoginPage() {
const router = useRouter();
const [stage, setStage] = useState<"creds" | "totp">("creds");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [code, setCode] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function submitCreds(e: React.FormEvent) {
e.preventDefault();
setError(null);
setLoading(true);
const { data, error } = await signIn.email({ email, password });
setLoading(false);
if (error) return setError(error.message ?? "Login failed");
if ((data as { twoFactorRedirect?: boolean })?.twoFactorRedirect) {
setStage("totp");
return;
}
router.replace("/");
}
async function submitTotp(e: React.FormEvent) {
e.preventDefault();
setError(null);
setLoading(true);
const { error } = await twoFactor.verifyTotp({ code });
setLoading(false);
if (error) return setError(error.message ?? "Invalid code");
router.replace("/");
}
return (
<main className="flex min-h-screen items-center justify-center px-4">
<div className="w-full max-w-sm rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-8">
<h1 className="text-xl font-semibold">Süper Panel</h1>
<p className="mt-1 text-sm text-[var(--color-muted)]">
{stage === "creds" ? "Sign in" : "Two-factor code"}
</p>
{stage === "creds" ? (
<form onSubmit={submitCreds} className="mt-6 space-y-3">
<input
type="email"
required
placeholder="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full rounded-md border border-[var(--color-border)] bg-black px-3 py-2 text-sm outline-none focus:border-[var(--color-accent)]"
/>
<input
type="password"
required
minLength={12}
placeholder="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-md border border-[var(--color-border)] bg-black px-3 py-2 text-sm outline-none focus:border-[var(--color-accent)]"
/>
<button
type="submit"
disabled={loading}
className="w-full rounded-md bg-[var(--color-accent)] py-2 text-sm font-medium text-black disabled:opacity-60"
>
{loading ? "…" : "Sign in"}
</button>
</form>
) : (
<form onSubmit={submitTotp} className="mt-6 space-y-3">
<input
inputMode="numeric"
pattern="[0-9]*"
required
maxLength={6}
placeholder="123456"
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, ""))}
className="w-full rounded-md border border-[var(--color-border)] bg-black px-3 py-2 text-center text-lg tracking-[0.4em] outline-none focus:border-[var(--color-accent)]"
/>
<button
type="submit"
disabled={loading || code.length !== 6}
className="w-full rounded-md bg-[var(--color-accent)] py-2 text-sm font-medium text-black disabled:opacity-60"
>
{loading ? "…" : "Verify"}
</button>
</form>
)}
{error && <p className="mt-4 text-sm text-red-400">{error}</p>}
</div>
</main>
);
}

47
apps/web/src/app/page.tsx Normal file
View File

@@ -0,0 +1,47 @@
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
export default async function Home() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/login");
return (
<main className="min-h-screen p-8">
<header className="mb-10 flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Süper Panel</h1>
<p className="text-sm text-[var(--color-muted)]">{session.user.email}</p>
</div>
<form action="/api/auth/sign-out" method="POST">
<button
type="submit"
className="rounded-md border border-[var(--color-border)] px-3 py-1.5 text-sm hover:bg-[var(--color-surface)]"
>
Sign out
</button>
</form>
</header>
<section className="grid grid-cols-1 gap-4 md:grid-cols-3">
<Card title="Projects" value="0" hint="No spokes wired yet" />
<Card title="Events (24h)" value="0" hint="Redis Streams idle" />
<Card title="Audit (24h)" value="0" hint="No actions" />
</section>
<p className="mt-10 text-xs text-[var(--color-muted)]">
Phase 0 skeleton · Tailscale-only · sp.semih.ai
</p>
</main>
);
}
function Card({ title, value, hint }: { title: string; value: string; hint: string }) {
return (
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-5">
<div className="text-xs uppercase tracking-wide text-[var(--color-muted)]">{title}</div>
<div className="mt-2 text-3xl font-semibold">{value}</div>
<div className="mt-1 text-xs text-[var(--color-muted)]">{hint}</div>
</div>
);
}

View File

@@ -0,0 +1,91 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { twoFactor, useSession } from "@/lib/auth-client";
export default function Setup2FA() {
const router = useRouter();
const { data: session, isPending } = useSession();
const [password, setPassword] = useState("");
const [otpAuthUri, setOtpAuthUri] = useState<string | null>(null);
const [backupCodes, setBackupCodes] = useState<string[] | null>(null);
const [code, setCode] = useState("");
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!isPending && !session) router.replace("/login");
}, [isPending, session, router]);
async function enable(e: React.FormEvent) {
e.preventDefault();
setError(null);
const { data, error } = await twoFactor.enable({ password });
if (error) return setError(error.message ?? "Failed");
setOtpAuthUri(data?.totpURI ?? null);
setBackupCodes(data?.backupCodes ?? null);
}
async function verify(e: React.FormEvent) {
e.preventDefault();
setError(null);
const { error } = await twoFactor.verifyTotp({ code });
if (error) return setError(error.message ?? "Invalid code");
router.replace("/");
}
return (
<main className="flex min-h-screen items-center justify-center px-4">
<div className="w-full max-w-md rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-8">
<h1 className="text-xl font-semibold">Enable two-factor</h1>
<p className="mt-1 text-sm text-[var(--color-muted)]">Scan the URI in your authenticator</p>
{!otpAuthUri ? (
<form onSubmit={enable} className="mt-6 space-y-3">
<input
type="password"
required
placeholder="current password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-md border border-[var(--color-border)] bg-black px-3 py-2 text-sm outline-none"
/>
<button type="submit" className="w-full rounded-md bg-[var(--color-accent)] py-2 text-sm font-medium text-black">
Generate TOTP
</button>
</form>
) : (
<div className="mt-6 space-y-4">
<code className="block break-all rounded-md border border-[var(--color-border)] bg-black p-3 text-xs">
{otpAuthUri}
</code>
{backupCodes && (
<div>
<p className="text-xs uppercase tracking-wide text-[var(--color-muted)]">Backup codes</p>
<ul className="mt-1 grid grid-cols-2 gap-1 text-xs">
{backupCodes.map((c) => <li key={c} className="font-mono">{c}</li>)}
</ul>
</div>
)}
<form onSubmit={verify} className="space-y-3">
<input
inputMode="numeric"
required
maxLength={6}
placeholder="123456"
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, ""))}
className="w-full rounded-md border border-[var(--color-border)] bg-black px-3 py-2 text-center text-lg tracking-[0.4em] outline-none"
/>
<button type="submit" className="w-full rounded-md bg-[var(--color-accent)] py-2 text-sm font-medium text-black">
Verify &amp; finish
</button>
</form>
</div>
)}
{error && <p className="mt-4 text-sm text-red-400">{error}</p>}
</div>
</main>
);
}

View File

@@ -0,0 +1,10 @@
"use client";
import { createAuthClient } from "better-auth/react";
import { twoFactorClient } from "better-auth/client/plugins";
export const authClient = createAuthClient({
plugins: [twoFactorClient()],
});
export const { signIn, signUp, signOut, useSession, twoFactor } = authClient;

29
apps/web/src/lib/auth.ts Normal file
View File

@@ -0,0 +1,29 @@
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { twoFactor } from "better-auth/plugins";
import { prisma } from "./db";
export const auth = betterAuth({
database: prismaAdapter(prisma, { provider: "postgresql" }),
secret: process.env.BETTER_AUTH_SECRET,
baseURL: process.env.BETTER_AUTH_URL,
emailAndPassword: {
enabled: true,
autoSignIn: true,
minPasswordLength: 12,
},
session: {
expiresIn: 60 * 60 * 8,
updateAge: 60 * 60,
cookieCache: { enabled: true, maxAge: 5 * 60 },
},
advanced: {
cookiePrefix: "sp",
useSecureCookies: process.env.NODE_ENV === "production",
},
plugins: [
twoFactor({
issuer: "Süper Panel",
}),
],
});

9
apps/web/src/lib/db.ts Normal file
View File

@@ -0,0 +1,9 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({ log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"] });
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

21
apps/web/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./src/*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

16
package.json Normal file
View File

@@ -0,0 +1,16 @@
{
"name": "super-panel",
"version": "0.1.0",
"private": true,
"packageManager": "pnpm@9.12.0",
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"lint": "turbo run lint",
"typecheck": "turbo run typecheck"
},
"devDependencies": {
"turbo": "^2.5.0",
"typescript": "^5.6.0"
}
}

3
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,3 @@
packages:
- apps/*
- packages/*

603
super-panel-prd.md Normal file
View File

@@ -0,0 +1,603 @@
# Süper Panel — Mimari & Altyapı PRD
**Proje Adı:** Süper Panel
**Domain:** `sp.semih.ai`
**Sahibi:** Thinxtra SaaS Studio (solo founder)
**Doküman Türü:** Mimari & Altyapı PRD (ürün özellikleri kapsam dışıdır)
**Durum:** Draft v1.0
---
## 1. Vizyon ve Bağlam
Thinxtra SaaS Studio portföyündeki birden çok bağımsız projeyi (Sase.tr, Catvicer, Kokpit, otoyedekparca.co, Eryaman Evleri TYY, 312 Döner ve gelecekteki projeler) tek bir merkezi yönetim katmanından izleme, yönetme ve otomatize etme yeteneği sağlamak.
Süper Panel **bir SaaS değildir**. Dış dünyaya kapalı, tek kullanıcılı (founder), salt yönetimsel amaçlı bir internal tool'dur. Bu doküman, panel **iskeletini ve altyapısını** tanımlar; spesifik proje feature'ları (Sase.tr kullanıcı yönetimi, Catvicer tenant modül aktivasyonu, vb.) ayrı feature PRD'leriyle ele alınacaktır.
### Çözülen Temel Problem
Solopreneur olarak birden çok projeyi yönetirken yaşanan friction:
- Her projenin admin paneline ayrı ayrı login olmak
- Cross-project metriklerin manuel toplanması (toplam MRR, toplam aktif kullanıcı, vb.)
- Operasyonel komutların (deploy, migrate, backup kontrolü) farklı yerlerde dağınık olması
- Audit / trace edilebilirlik eksikliği
### Süper Panel'in Konumu
Mevcut araçlarla ilişki:
- **Coolify**: Deployment ve container management katmanı; Süper Panel bunu **tüketir**, yerine geçmez
- **OpenObserve / Uptime Kuma**: Observability tooling; Süper Panel bunların API'lerinden veri çeker
- **Sentry**: Error monitoring; Süper Panel bağlantı kurar
- **Her projenin kendi admin paneli**: Mevcut paneller silinmez; Süper Panel onların üstüne bir meta-katman koyar
---
## 2. Hedefler ve Hedef Olmayanlar
### Hedefler
1. Tüm proje veritabanlarına merkezi read-only erişim (raporlama, metrik, debug)
2. Her projenin "internal admin API"sine merkezi mutasyonel erişim (kontrollü, audit'li)
3. Cross-project event observability (Redis Streams üzerinden)
4. Operasyonel komutlar (Coolify restart, migration trigger, backup status) için tek pencere
5. Sıkı güvenlik: closed network, MFA mecburi, audit log her aksiyon için
6. Solo founder ergonomics: hızlı navigation, klavye-friendly, mobil görüntülenebilir
7. Production'a 4-6 hafta içinde Phase 1 ile çıkmak
### Hedef Olmayanlar
- Public bir SaaS ürünü olmak (asla satışa çıkmayacak)
- Multi-user / ekip kullanımı (RBAC karmaşıklığı yok, tek kullanıcı: founder)
- Müşteri-facing herhangi bir UI
- Direkt müşteri verilerinin Süper Panel'de aggregate edilmesi (hassas veriler kaynak projede kalmalı)
- Projelere ait business logic'in panel tarafına taşınması (panel **sadece consume eder**, business logic kaynak projede kalır)
- Ödeme akışlarının kullanıcı tarafından **gerçekleştirilmesi** (sadece görüntüleme/refund tetikleme; ödeme alma kaynak projede)
---
## 3. Mimari: Hub & Spoke
```
┌──────────────────────────────┐
│ Süper Panel (Hub) │
│ sp.semih.ai │
│ │
│ Next.js Web + Worker │
│ Panel PG + Panel Redis │
└──────┬───────────┬───────────┘
│ │
┌──────────────────┼───────────┼──────────────────┐
│ │ │ │
▼ ▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Sase.tr │ │ Catvicer │ │ Kokpit │ │ …diğerleri │
│ (Spoke) │ │ (Spoke) │ │ (Spoke) │ │ │
│ │ │ │ │ │ │ │
│ DB (RO user) │ │ DB (RO user) │ │ DB (RO user) │ │ DB (RO user) │
│ /internal │ │ /internal │ │ /internal │ │ /internal │
│ Redis Stream │ │ Redis Stream │ │ Redis Stream │ │ Redis Stream │
└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘
```
### Üç Erişim Katmanı
Her spoke projeyle üç farklı yolla iletişim kurulur; her birinin amacı ve güvenlik profili farklıdır.
#### Katman 1: Read-Only Database Connection
- Her projenin PostgreSQL veritabanında `super_panel_reader` rolü
- Sadece `SELECT` yetkisi, schema migration yetkisi yok
- Her proje için ayrı Prisma client (`@panel/db-sase`, `@panel/db-catvicer`, vb.)
- Kullanım: metrik dashboard'ları, raporlama, debug query'leri, listeleme
- Asla yazma için kullanılmaz
#### Katman 2: Internal Admin API (mutasyonel)
- Her proje kendi tarafında `/internal/admin/*` endpoint'leri expose eder
- Bu endpoint'ler **sadece Süper Panel'den çağrılabilir** (network-level whitelist + token auth)
- Public internet'ten erişilemez
- Süper Panel ortak bir `@panel/admin-sdk` paketi üzerinden type-safe çağrı yapar
- Tüm çağrılar Süper Panel tarafında audit log'a yazılır (kim, ne zaman, hangi endpoint, hangi payload, response status)
- Business logic kaynak projede kalır; panel sadece tetikler
#### Katman 3: Event Bus (asenkron observability)
- Redis Streams üzerinden event publish/subscribe
- Her proje önemli olayları kendi stream'ine yazar (örn. `sase:events`, `catvicer:events`)
- Panel worker'ı bu stream'leri tüketir, panel'in kendi `events` tablosuna persist eder ve real-time UI'a (SSE veya WebSocket ile) push eder
- Event şeması versiyonlu (`event_type`, `version`, `source`, `payload`, `occurred_at`)
- Worker yeniden başlasa bile consumer group offset'i sayesinde event kaybı olmaz
---
## 4. Tech Stack
### Frontend (apps/web)
| Katman | Seçim | Gerekçe |
|--------|-------|---------|
| Framework | Next.js 16 (App Router) | Fullstack tek deploy, Server Components ile DB direkt erişim |
| UI Library | shadcn/ui (blocks-first) | Copy-paste, owned code, customization serbestliği |
| Styling | Tailwind CSS v4 | shadcn ile native uyum |
| Data Fetching | TanStack Query | Cache, optimistic update, mutation lifecycle |
| Tables | TanStack Table | Büyük veri grid'leri için olmazsa olmaz |
| URL State | nuqs | Filtre/pagination URL persistence |
| Charts | Tremor + Recharts | Dashboard görselleri |
| Form | React Hook Form + Zod | Type-safe validation |
| Real-time | Server-Sent Events | Bidirectional gerek yok, SSE yeterli |
| Icons | Lucide React | shadcn default |
### Backend (apps/web içinde)
Ayrı bir backend süreci yok. Next.js'in kendisi backend:
- **Server Components**: read-only DB query'leri direkt server'da
- **Server Actions**: mutasyonel işlemler (admin API çağrıları, audit log yazımı)
- **Route Handlers**: webhook receiver'ları (Stripe central webhook gibi), cron trigger endpoint'leri
- **Middleware**: auth gate, request logging, rate limit
### Worker (apps/worker)
Ayrı bir Node.js process'i, ayrı container:
- Plain Node + tsx (TypeScript runtime)
- BullMQ (job queue, scheduled jobs)
- node-cron (basit zamanlı tetikler)
- Aynı Prisma client'ları, aynı admin SDK, kod paylaşımı paketler üzerinden
NestJS gibi framework yok — solopreneur için gereksiz boilerplate. Worker tek dosyalık `index.ts`'ten başlayıp organic büyür.
### Panel'in Kendi Veritabanı (PostgreSQL)
Hetzner VPS'te ayrı bir PostgreSQL instance veya mevcut PostgreSQL üzerinde ayrı bir DB. İçerik:
- `users` — sadece kendi hesabın
- `sessions`, `accounts` — Better Auth tabloları
- `projects` — yönetilen projelerin meta-verisi (connection string referansları, vb.)
- `audit_log` — her aksiyon, append-only
- `events` — spoke'lardan gelen event'lerin kopyası
- `module_registry` — Catvicer benzeri config-driven projeler için modül durumu
- `secrets_metadata` — secret'ların kendisini değil, hangi secret nerede var bilgisini tutar
- Materialized view'lar — cross-project aggregate metrikler (nightly refresh)
### Cache & Queue
- Redis (panel'in kendi instance'ı): BullMQ queue, session cache, Redis Streams consumer state
### Object Storage
- MinIO (mevcut Maestro instance'ı): audit log artifact'leri (büyük JSON payload'lar, screenshot'lar)
### Auth
- **Better Auth** (mevcut Eryaman/Catvicer'la tutarlı)
- TOTP **zorunlu**, Passkey opsiyonel ama önerilir
- Tek kullanıcı: founder; account creation kapalı
- Session 8 saat, rolling refresh
- IP audit (her login'in IP'si loglanır)
### Secret Management
- **Phase 1-3**: Coolify environment variables (encrypted at rest)
- **Phase 4+**: Infisical self-hosted (zaten Phase 4 roadmap'inde)
- Hiçbir secret git'e commit edilmez; `.env.example` placeholder'larla repo'da
### Observability
- Sentry self-hosted (mevcut) — frontend ve backend error tracking
- OpenObserve (mevcut) — application log'ları
- Uptime Kuma — panel'in kendisi için uptime
- Panel'in kendi `audit_log` tablosu — business action trail
---
## 5. Güvenlik Modeli
### Network Topolojisi
```
[Tarayıcı] → [Cloudflare] → [Cloudflare Tunnel] → [Coolify Container]
[Cloudflare Access]
Email allowlist + TOTP
```
- **Origin sunucuda public port açık değil.** Hetzner VPS firewall'unda sadece SSH + Tailscale; HTTP/HTTPS port'ları kapalı
- Cloudflare Tunnel (`cloudflared`) bir sidecar container olarak çalışır, Coolify container'ına internal network üzerinden bağlanır
- Public DNS: `sp.semih.ai` → Cloudflare → Tunnel → Container
- Cloudflare Access katmanı: email allowlist (sadece founder email'i) + TOTP zorunlu
Bu yapı şu güvenceleri sağlar:
1. Port scanner'lar VPS'i bulsa bile panel'e ulaşamaz (port kapalı)
2. DNS leak veya subdomain enumeration olsa bile Cloudflare Access olmadan login sayfasına bile ulaşılamaz
3. Cloudflare Access bypass edilse bile Better Auth + TOTP devrede
### İki-Faktörlü Auth Katmanı
| Katman | Mekanizma | Bypass edilirse |
|--------|-----------|------------------|
| 1 | Cloudflare Access (email + TOTP) | Login sayfası görünür ama Better Auth durur |
| 2 | Better Auth (password + TOTP) | Erişim engellenir |
### Spoke Projelere Erişim Güvenliği
#### Read-only DB connection
- Her spoke PostgreSQL'inde `super_panel_reader` rolü, sadece `SELECT`
- Connection string Coolify env'de, sadece panel container'ından erişilebilir
- Spoke PostgreSQL'lerinin public erişimi zaten kapalı (Coolify internal network)
#### Internal Admin API
İki opsiyon, Phase'lere göre seçilebilir:
**Opsiyon A (Phase 1-2): Shared Secret Token**
- Her proje `INTERNAL_API_TOKEN` env'i ile başlar
- Panel bu token'ı `X-Internal-Token` header'ında gönderir
- Spoke tarafında middleware token doğrular + Coolify internal network IP kontrolü
- Basit, hızlı kurulur
**Opsiyon B (Phase 4+): mTLS**
- Self-signed CA, panel ve her spoke için client cert
- Daha güçlü ama operational complexity artıyor
- Phase 4'te degerlendirilir
#### Audit
Her admin API çağrısı **panel tarafında** persist edilir:
```
audit_log (
id, actor_user_id, project_key, endpoint, method,
request_payload_hash, response_status, duration_ms,
source_ip, user_agent, created_at
)
```
Payload'ın kendisi MinIO'ya yazılır (büyük olabilir); tabloda sadece hash + MinIO key.
### Veri Sınıflandırması
- **Hassas veriler asla panel DB'sine kopyalanmaz** (KVKK + minimize prensibi)
- Panel sadece "pointer" tutar: hangi spoke'ta hangi user_id var
- Listeleme yapılırken spoke'a query gider, sonuç render edilir, panel DB'sinde saklanmaz
- Aggregate metrikler (toplam kullanıcı sayısı, MRR, vb.) materialized view'larda nightly hesaplanır — bunlar PII değil
---
## 6. Monorepo Yapısı
```
super-panel/
├── apps/
│ ├── web/ # Next.js 16 - admin UI + API
│ │ ├── app/
│ │ │ ├── (auth)/ # Login, 2FA setup
│ │ │ ├── (dashboard)/ # Auth-gated rotalar
│ │ │ │ ├── page.tsx # Overview dashboard
│ │ │ │ ├── projects/ # Proje detay sayfaları
│ │ │ │ ├── operations/ # Coolify, backup, deploy
│ │ │ │ ├── events/ # Cross-project event log
│ │ │ │ ├── audit/ # Audit log viewer
│ │ │ │ └── settings/ # Self-config
│ │ │ └── api/
│ │ │ ├── webhooks/ # Stripe central, vb.
│ │ │ └── trigger/ # Cron trigger endpoint'leri
│ │ ├── components/ # App-specific components
│ │ ├── lib/ # Auth, db connections, utils
│ │ └── next.config.ts
│ └── worker/ # Node + BullMQ + cron
│ ├── src/
│ │ ├── jobs/ # Job tanımları
│ │ ├── schedulers/ # node-cron schedule'ları
│ │ ├── consumers/ # Redis Streams consumer'ları
│ │ └── index.ts
│ └── tsconfig.json
├── packages/
│ ├── db-clients/ # Her spoke için Prisma client
│ │ ├── sase/
│ │ ├── catvicer/
│ │ ├── kokpit/
│ │ └── panel/ # Panel'in kendi DB'si
│ ├── admin-sdk/ # Spoke admin API client'ları
│ │ ├── sase/
│ │ ├── catvicer/
│ │ └── ...
│ ├── event-bus/ # Redis Streams wrapper
│ ├── ui/ # Shared shadcn components
│ ├── audit/ # Audit log helper
│ └── config/ # ESLint, TS, Tailwind shared config
├── .github/workflows/
│ ├── ci.yml # lint + typecheck + test
│ └── deploy.yml # Coolify webhook trigger
├── docker/
│ ├── web.Dockerfile
│ ├── worker.Dockerfile
│ └── cloudflared.Dockerfile
├── docker-compose.yml # Lokal dev için
├── turbo.json
├── pnpm-workspace.yaml
└── package.json
```
### Paket Yönetimi
- **pnpm** + workspaces
- **Turborepo** — paralel build, cache
- TypeScript strict mode, project references
### Tooling
| Amaç | Araç |
|------|------|
| Lint | ESLint (Antfu config) |
| Format | Prettier |
| Git hooks | Lefthook |
| Commit message | Commitlint (Conventional Commits) |
| Unused detection | Knip |
| Env validation | T3 Env |
| Unit test | Vitest |
| E2E test | Playwright |
| Type check | tsc --noEmit |
---
## 7. UI Bilgi Mimarisi
Spesifik feature'lar değil, sayfa hiyerarşisi ve shadcn block mapping'i.
### Sayfa Hiyerarşisi
```
/ — Dashboard (cross-project overview)
/projects — Proje listesi
/projects/[key] — Tek proje detay (her proje için kendi feature PRD'sinde tanımlanacak)
/operations — Coolify deploy, backup status, migration runner
/events — Cross-project event timeline (Redis Streams'ten gelen)
/audit — Audit log viewer
/settings — Panel ayarları, secret management, profile
/login — Better Auth entry
/setup-2fa — İlk login'de TOTP setup
```
### shadcn Block Mapping
| Sayfa | Önerilen Block | Notlar |
|-------|----------------|---------|
| Tüm auth-gated sayfalar | `sidebar-07` veya `sidebar-13` | Collapsible, proje listesi sidebar'da grupların |
| Dashboard | `dashboard-01` | Chart + KPI cards |
| Liste sayfaları (audit, events) | `data-table` examples | TanStack Table ile entegre |
| Settings | `settings-04` | Sekmeli yapı |
| Login | `login-04` | Better Auth ile bağlanır |
| Empty state'ler | shadcn `Empty` component | Yeni proje eklenmediğinde |
| Command palette (global search) | `command` component (Cmd+K) | Hızlı navigation, proje arama |
### Tasarım Prensipleri
- **Density-first**: Solo admin tool, dense layout, max info per screen
- **Klavye navigation**: Tüm primary action'lar shortcut'lı (Cmd+K, j/k navigation, Enter to confirm)
- **Dark mode default**: Eye strain için
- **Mobile-respectful**: Tam responsive değil ama panik durumda mobilden bakılabilir olmalı (deploy status, alarm görme)
- **No marketing pages**: Hero section, feature highlight, vb. yok
---
## 8. Domain ve DNS Yapılandırması
### sp.semih.ai
1. **Cloudflare** üzerinde `semih.ai` zone'una `sp` CNAME kaydı eklenir
2. Cloudflare Tunnel kurulur (`cloudflared tunnel create super-panel`)
3. Tunnel UUID'si DNS'e CNAME olarak otomatik eklenir (Cloudflare One UI üzerinden)
4. Cloudflare Access policy:
- Application: `sp.semih.ai`
- Policy: Allow if email in `["semih@thinxtra.studio"]` AND TOTP verified
- Session duration: 24 saat
5. HSTS açık, secure cookies (Better Auth zaten varsayılan secure cookie ile yapılandırılır)
6. Cloudflare proxy **açık** (orange cloud) — DDoS + WAF + bot protection ücretsiz alınır
### Lokal Development
- `localhost:3000` (web), `localhost:3001` (worker health)
- Better Auth local'da TOTP zorunluluğunu opsiyonel yapabilir (env flag)
- `pnpm dev` ile her ikisi paralel çalışır (Turborepo)
---
## 9. Coolify Deployment Planı
### Container'lar
| Container | Build Source | Purpose | Resource (başlangıç) |
|-----------|--------------|---------|----------------------|
| `panel-web` | `docker/web.Dockerfile` | Next.js production | 1 vCPU / 1 GB |
| `panel-worker` | `docker/worker.Dockerfile` | Node + BullMQ | 0.5 vCPU / 512 MB |
| `panel-postgres` | Coolify Database | Panel'in kendi DB'si | 1 vCPU / 1 GB + Volume |
| `panel-redis` | Coolify Database | Queue + cache + streams | 0.5 vCPU / 512 MB |
| `panel-cloudflared` | `docker/cloudflared.Dockerfile` | Tunnel | 0.25 vCPU / 256 MB |
### Networking
- Tüm container'lar aynı Coolify internal network'te
- Sadece `panel-cloudflared` dışarıyla konuşur, o da sadece Cloudflare edge'iyle
- `panel-web` ve `panel-worker`, `panel-postgres` ile internal hostname üzerinden konuşur (`panel-postgres:5432`)
### Volumes
- `panel-postgres-data` — kalıcı PG verisi
- `panel-redis-data` — RDB snapshot
- MinIO için ayrı bir volume gerekmez (mevcut Maestro MinIO kullanılır)
### Environment Variables (Coolify UI'da)
Phase 1'de en kritik olanlar (T3 Env ile validate edilecek):
```
DATABASE_URL_PANEL=...
DATABASE_URL_SASE_RO=...
DATABASE_URL_CATVICER_RO=...
DATABASE_URL_KOKPIT_RO=...
REDIS_URL=...
BETTER_AUTH_SECRET=...
BETTER_AUTH_URL=https://sp.semih.ai
INTERNAL_API_TOKEN_SASE=...
INTERNAL_API_TOKEN_CATVICER=...
SASE_ADMIN_API_BASE=https://internal.sase.tr
CATVICER_ADMIN_API_BASE=https://internal.catvicer.com
MINIO_ENDPOINT=...
MINIO_ACCESS_KEY=...
MINIO_SECRET_KEY=...
SENTRY_DSN=...
NODE_ENV=production
```
### CI/CD
- GitHub Actions: PR'da lint + typecheck + test
- `main`'e merge → Coolify deploy webhook → otomatik deploy
- Deploy adımları:
1. Docker image build
2. Migration: `prisma migrate deploy` (sadece panel DB)
3. Health check: `/api/health` 200 dönüyorsa container "live"
4. Eski container 30 saniye grace period sonrası kapatılır
### Backup
- `panel-postgres`: Coolify scheduled backup, daily, S3-compatible storage'a (MinIO)
- 30 gün retention
- Aylık restore test (manuel)
---
## 10. Phased Roadmap
### Phase 0 — Foundation (1 hafta)
- Monorepo iskeleti (pnpm + Turborepo)
- Next.js + Tailwind v4 + shadcn init
- Better Auth setup, TOTP enrollment flow
- Panel PostgreSQL şeması v1 (`users`, `sessions`, `audit_log`, `projects`)
- Coolify üzerinde dev environment
- Cloudflare Tunnel + Access yapılandırması
- sp.semih.ai çalışır, login yapılabilir, dashboard boş ama render olur
**Çıktı:** Login olabildiğin boş bir panel.
### Phase 1 — İlk Spoke Bağlantısı (1-2 hafta)
- `@panel/db-clients/sase` paketi (read-only Prisma client)
- `@panel/admin-sdk/sase` paketi (boş skeleton, Sase.tr'ye internal API eklendikçe doldurulacak)
- Sase.tr DB'sinde `super_panel_reader` rolü
- Projects sayfası: Sase.tr için generic "data ulaşılabilir mi" health check
- Audit log yazımı tam çalışır
**Çıktı:** Bir spoke'a uçtan uca read-only bağlantı.
### Phase 2 — Multi-Spoke Pattern (2-3 hafta)
- Catvicer, Kokpit, otoyedekparca, Eryaman için aynı pattern (db-clients + admin-sdk skeleton)
- Project switcher (Cmd+K palette)
- Common operations: connection health, basic metrics widget
- Generic dashboard'da her proje için "card" görünümü
**Çıktı:** Tüm projelere read-only uçtan uca erişim.
### Phase 3 — Worker & Event Bus (1-2 hafta)
- Worker container deploy
- BullMQ queue ve dashboard
- Redis Streams consumer pattern (`packages/event-bus`)
- Bir spoke'ta (örn. Sase.tr) test publisher; panel'de consumer ve `events` tablosu
- SSE endpoint ile UI'a real-time event push
- Scheduled jobs: nightly materialized view refresh
**Çıktı:** Asenkron event flow + scheduled job altyapısı.
### Phase 4 — Security & Operations Hardening (1-2 hafta)
- Infisical self-hosted entegrasyonu (env'ler buradan çekilir)
- mTLS opsiyonel olarak admin API'lere
- Audit log archive: 90 gün sonrası MinIO'ya taşınır
- Rate limit middleware
- Disaster recovery dokümantasyonu + dry-run test
- Backup restore drill
**Çıktı:** Production-grade security posture.
### Phase 5 — Operational Tooling (2-3 hafta)
- Coolify API entegrasyonu: deploy trigger, restart, env update UI
- Migration runner (her proje için "Migrate Now" butonu, log stream)
- Backup status dashboard (her projenin son backup'ı, boyut, başarı)
- Stripe central webhook receiver
- Spesifik proje feature'ları ayrı PRD'lerde — bu phase sonrası başlar
**Çıktı:** "Tek tıkla operasyon" katmanı.
---
## 11. Riskler ve Azaltma Stratejileri
| Risk | Olasılık | Etki | Azaltma |
|------|----------|------|---------|
| PG connection pool patlaması (N proje × pool size) | Yüksek | Yüksek | PgBouncer veya per-client küçük pool (5 max); spoke'lara fiziksel ayrı PG instance |
| Cloudflare Tunnel down | Düşük | Yüksek | Acil durum için Tailscale ikinci yol; founder bu emergency access'i ayarlar |
| Panel'in kendi DB'si data loss | Düşük | Orta | Daily backup + monthly restore drill; spoke verileri panel'de yok zaten |
| Spoke admin API'leri panel'e güvenir, panel compromise olursa | Düşük | Yüksek | Audit log + TOTP + Cloudflare Access üç katmanlı koruma; mTLS Phase 4'te |
| Schema drift (spoke DB schema değişir, panel client güncellenmez) | Yüksek | Orta | CI'da spoke'ların Prisma schema'larına diff check; semver gibi versiyonla |
| Single point of failure (panel down olunca alarm gelmez) | Orta | Orta | Uptime Kuma'yı **panel-dışı** bir VPS'te tut; SMS/Telegram alert |
| Founder TOTP cihazını kaybeder | Düşük | Kritik | Recovery code'lar Bitwarden'da; Cloudflare Access fallback ile re-enroll |
| Worker silently fail (job stuck) | Orta | Orta | BullMQ failed job alert → Telegram; daily summary email |
---
## 12. Başarı Kriterleri
Phase 1 tamamlandığında:
- [ ] sp.semih.ai üzerinden login + TOTP + dashboard render
- [ ] En az 1 spoke'tan canlı veri okunabiliyor
- [ ] Her aksiyon audit_log'a yazılıyor
- [ ] Cloudflare Access + Better Auth iki katmanı çalışıyor
- [ ] Coolify üzerinde deploy + auto-migration çalışıyor
- [ ] Mobil tarayıcıdan login olunabiliyor
Phase 5 tamamlandığında:
- [ ] 5+ proje aynı pattern ile bağlanmış
- [ ] Real-time event stream çalışıyor
- [ ] Bir Coolify deploy panel'den tetiklenebiliyor
- [ ] Daily backup restore drill başarılı
- [ ] Pazartesi sabahı 30 saniyede tüm projelerin durumunu görebiliyorum
---
## 13. Açık Sorular
Sonraki iterasyonda netleştirilecek:
1. **Tailscale tamamen out mı?** Cloudflare Tunnel + Access yeterli mi yoksa "belt and suspenders" yaklaşımı için Tailscale fallback şart mı?
2. **Sentry self-hosted mı kalacak yoksa cloud'a mı geçilecek?** Self-hosted Phase 1'de zaten var, panel oraya bağlanacak.
3. **Infisical Phase 1'de mi gelsin Phase 4'te mi?** Phase 1 hızıısından Coolify env yeterli, ama Infisical de Phase 0'da kurulabilir.
4. **Stripe webhook central receiver Phase 5'te mi kalmalı?** Eğer şu an Stripe entegre edilen proje yoksa beklemek mantıklı.
5. **Mobile-first deneyim ne kadar önemli?** "Acil durumda mobil'den bakma" senaryosu için ne kadar yatırım yapılacak?
---
## 14. Karar Logu
| Tarih | Karar | Gerekçe |
|-------|-------|---------|
| 2026-05-13 | Next.js fullstack, NestJS yok | Solopreneur için tek deploy, daha az boilerplate |
| 2026-05-13 | shadcn blocks tabanlı UI, ixartz boilerplate reddedildi | Stack uyumsuzluğu (Clerk, Drizzle), gereksiz dependency yükü |
| 2026-05-13 | Cloudflare Tunnel + Access ana erişim yolu | Public DNS koruması, port kapatma, ücretsiz |
| 2026-05-13 | Better Auth (Clerk yerine) | Diğer projelerle tutarlılık, self-hosted, paid service yok |
| 2026-05-13 | Prisma multi-client (Drizzle yerine) | Mevcut spoke'larda Prisma var, ORM birleşmesi |
| 2026-05-13 | Plain Node worker (NestJS worker yerine) | Solopreneur, framework overhead gereksiz |
---
**Doküman Sonu**

17
turbo.json Normal file
View File

@@ -0,0 +1,17 @@
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {},
"typecheck": {
"dependsOn": ["^build"]
}
}
}