feat: phase 0 skeleton — next.js 16 + better-auth + prisma
This commit is contained in:
9
.dockerignore
Normal file
9
.dockerignore
Normal 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
5
.env.example
Normal 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
12
.gitignore
vendored
Normal 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
22
README.md
Normal 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
43
apps/web/Dockerfile
Normal 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"]
|
||||
6
apps/web/docker-entrypoint.sh
Normal file
6
apps/web/docker-entrypoint.sh
Normal 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
21
apps/web/middleware.ts
Normal 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
11
apps/web/next.config.ts
Normal 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
32
apps/web/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
3
apps/web/postcss.config.mjs
Normal file
3
apps/web/postcss.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
plugins: { "@tailwindcss/postcss": {} },
|
||||
};
|
||||
96
apps/web/prisma/schema.prisma
Normal file
96
apps/web/prisma/schema.prisma
Normal 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])
|
||||
}
|
||||
4
apps/web/src/app/api/auth/[...all]/route.ts
Normal file
4
apps/web/src/app/api/auth/[...all]/route.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { auth } from "@/lib/auth";
|
||||
import { toNextJsHandler } from "better-auth/next-js";
|
||||
|
||||
export const { GET, POST } = toNextJsHandler(auth);
|
||||
11
apps/web/src/app/api/health/route.ts
Normal file
11
apps/web/src/app/api/health/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
16
apps/web/src/app/globals.css
Normal file
16
apps/web/src/app/globals.css
Normal 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;
|
||||
}
|
||||
16
apps/web/src/app/layout.tsx
Normal file
16
apps/web/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
101
apps/web/src/app/login/page.tsx
Normal file
101
apps/web/src/app/login/page.tsx
Normal 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
47
apps/web/src/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
91
apps/web/src/app/setup-2fa/page.tsx
Normal file
91
apps/web/src/app/setup-2fa/page.tsx
Normal 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 & finish
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="mt-4 text-sm text-red-400">{error}</p>}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
10
apps/web/src/lib/auth-client.ts
Normal file
10
apps/web/src/lib/auth-client.ts
Normal 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
29
apps/web/src/lib/auth.ts
Normal 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
9
apps/web/src/lib/db.ts
Normal 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
21
apps/web/tsconfig.json
Normal 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
16
package.json
Normal 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
3
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
- apps/*
|
||||
- packages/*
|
||||
603
super-panel-prd.md
Normal file
603
super-panel-prd.md
Normal 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ı açı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
17
turbo.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user