diff --git a/CHANGELOG.md b/CHANGELOG.md index e0fee12..26f1b47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,37 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır ## [Yayınlanmamış] +## [0.3.0] - 2026-11-05 + +Faz 3: Backend — kimlik doğrulama, yetki katmanı ve güvenlik. + +### Eklendi + +- Better Auth (MongoDB adaptörü, replica set transaction): e-posta veya **kullanıcı adı** ile giriş, kayıt (sign-up) kapalı, admin plugin, 30 günlük oturum, `HttpOnly`/`Secure`/`SameSite=Lax` cookie. +- Rate limit (veritabanında): genel 100 istek/dk, giriş denemeleri 5/dk. +- Data Access Layer (`lib/dal.ts`): `getCurrentUser` (istek başına tek okuma), `requireSession`, `requireRole`, `getActionUser`. Askıdaki kullanıcının oturumu reddedilir. +- `proxy.ts`: iyimser oturum cookie kontrolü (oturumsuz → `/login`); asıl kontrol DAL'da. +- Admin kullanıcı yönetimi Server Action'ları: oluşturma, düzenleme, rol, şifre sıfırlama (diğer oturumları kapatır), askıya alma/kaldırma, silme. Kendini silememe/askıya alamama ve son admini düşürememe sunucuda da uygulanır; Better Auth hataları Türkçe alan hatalarına çevrilir. +- E-postası olmayan kullanıcılar için `.invalid` yer tutucu adres (arayüzde gizlenir). +- MongoDB (`lib/db/mongo.ts`) ve Mongoose (`lib/db/mongoose.ts`) bağlantıları (HMR güvenli tekil örnek, tembel oluşturma). +- Güvenlik başlıkları: CSP (nonce'suz, Cache Components uyumlu), `X-Content-Type-Options`, `Referrer-Policy`, `X-Frame-Options`, `Permissions-Policy`, `Cross-Origin-Opener-Policy`; production'da HSTS. +- `GET /api/health` veritabanı ping'i; erişilemezse 503. +- `npm run seed`: ilk admini (`SEED_ADMIN_*`, kullanıcı adı `admin`) idempotent olarak oluşturur. +- `npm run test:integration`: gerçek MongoDB'de (`butcem_test`) kimlik doğrulama sözleşmesi testleri (kayıt kapalı, giriş, askı, admin yetkisi, rate limit). +- BÜTÇEM logosu ve ikon seti (`logo.svg`, `logo-dark.svg`, `icon.svg`, `icon-maskable.svg`); `npm run icons` SVG kaynaktan üretir. + +### Değişti + +- Giriş, çıkış, dashboard ve admin sayfaları mock yerine gerçek oturum ve verilerle çalışır; oturuma bağlı kabuk Suspense içinde akış olarak gelir. + +### Düzeltildi + +- Kullanıcı adı normalizasyonu: `tr-TR` küçük harf dönüşümü "ADMIN"i "admın" yapıyordu; artık İ/I/ı → i ve ASCII küçük harf. + +### Kaldırıldı + +- Oturum ve admin kullanıcı listesi mock verileri (hane mock'u Faz 5'e kadar duruyor). + ## [0.2.0] - 2026-11-05 Faz 2: Frontend iskeleti (veriler Faz 3'e kadar mock). @@ -50,6 +81,7 @@ Faz 1: Framework, klasör yapısı ve altyapı. - `.nvmrc` (Node 26), `engines.node`, `typecheck`, `test`, `format`, `db:up`, `seed` scriptleri. - `docs/PROMPT.md` (ana prompt ve Faz 0 kararları), `README.md`, `CHANGELOG.md`. -[Yayınlanmamış]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.2.0...HEAD +[Yayınlanmamış]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.3.0...HEAD +[0.3.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.2.0...v0.3.0 [0.2.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.1.0...v0.2.0 [0.1.0]: https://gitea.ta1sph.info/suphicakir/butcem/releases/tag/v0.1.0 diff --git a/README.md b/README.md index af77d27..7f8b5dd 100644 --- a/README.md +++ b/README.md @@ -26,10 +26,15 @@ cp .env.example .env.local # 3) MongoDB (tek düğümlü replica set rs0, host portu 27019) — sağlıklı olana kadar bekler npm run db:up -# 4) Uygulama -npm run dev # http://localhost:3000 +# 4) İlk admin (SEED_ADMIN_EMAIL / SEED_ADMIN_PASSWORD; kullanıcı adı: admin) +npm run seed + +# 5) Uygulama +npm run dev # http://localhost:3000 → /login ``` +Giriş kullanıcı adı **veya** e-posta ile yapılır. Kayıt kapalıdır; kullanıcıları admin `/admin/kullanicilar` sayfasından oluşturur. + ## Scriptler | Komut | Açıklama | @@ -45,7 +50,7 @@ npm run dev # http://localhost:3000 ## Marka dosyaları -`public/brand/` altındaki `logo.svg`, `logo-dark.svg` ve `icon.png` (512×512) şu an **yer tutucudur**. Gerçek logoları aynı adlarla koyup `npm run icons` çalıştırın. +`public/brand/`: `logo.svg` (açık tema), `logo-dark.svg` (koyu tema), `icon.svg` (uygulama ikonu), `icon-maskable.svg` (Android/iOS, köşesiz). Yazı Noto Sans Bold (OFL) harf yollarıdır. İkonları değiştirdikten sonra `npm run icons` çalıştırın; favicon ve PWA ikonları bu kaynaklardan üretilir. ## Docker imajı diff --git a/next.config.ts b/next.config.ts index d80125d..8e8b26e 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,5 +1,47 @@ import type { NextConfig } from "next"; +const isDev = process.env.NODE_ENV === "development"; + +/** + * Content-Security-Policy (nonce'suz): Cache Components'in statik kabuğu ile uyumlu. + * 'unsafe-inline' script: Next.js'in satır içi hydration verisi ve next-themes'in tema betiği için. + * Grafikler (Recharts, SVG) ve carousel (Embla) ek kaynak gerektirmez; ek dosyalar + * kendi origin'imizden (/api/files/...) ve blob: önizlemelerle servis edilir. + */ +const csp = [ + "default-src 'self'", + `script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ""}`, + "style-src 'self' 'unsafe-inline'", + "img-src 'self' blob: data:", + "font-src 'self'", + "connect-src 'self'", + "media-src 'self' blob:", + "worker-src 'self' blob:", + "manifest-src 'self'", + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", + "frame-ancestors 'none'", + ...(isDev ? [] : ["upgrade-insecure-requests"]), +].join("; "); + +const securityHeaders = [ + { key: "Content-Security-Policy", value: csp }, + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + { key: "X-Frame-Options", value: "DENY" }, + { key: "Cross-Origin-Opener-Policy", value: "same-origin" }, + // Kamera: Faz 6'da fiş fotoğrafı için yalnızca kendi origin'imiz. + { + key: "Permissions-Policy", + value: "camera=(self), microphone=(), geolocation=(), payment=(), usb=()", + }, + ...(isDev + ? [] + : // includeSubDomains/preload bilinçli olarak yok: ta1sph.website'in diğer alt alanlarını bağlamasın. + [{ key: "Strict-Transport-Security", value: "max-age=31536000" }]), +]; + const nextConfig: NextConfig = { // Docker imajı için yalnızca gerekli dosyaları .next/standalone altına kopyala. output: "standalone", @@ -17,6 +59,9 @@ const nextConfig: NextConfig = { }, }, }, + async headers() { + return [{ source: "/:path*", headers: securityHeaders }]; + }, }; export default nextConfig; diff --git a/package.json b/package.json index a2686e4..ad71ec8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "butcem", - "version": "0.2.0", + "version": "0.3.0", "private": true, "description": "BÜTÇEM — aile bütçe, cari, birikim, ev stoğu ve akıllı alışveriş uygulaması", "engines": { @@ -18,9 +18,10 @@ "format:check": "prettier --check .", "db:up": "docker compose up -d --wait", "db:down": "docker compose down", - "seed": "tsx --env-file=.env.local scripts/seed.ts", - "seed:reset": "tsx --env-file=.env.local scripts/seed.ts --reset", - "icons": "tsx scripts/generate-icons.ts" + "seed": "tsx --conditions=react-server --env-file=.env.local scripts/seed.ts", + "seed:reset": "tsx --conditions=react-server --env-file=.env.local scripts/seed.ts --reset", + "icons": "tsx scripts/generate-icons.ts", + "test:integration": "vitest run --config vitest.integration.config.mts" }, "dependencies": { "@hookform/resolvers": "^7.0.2", diff --git a/public/brand/icon-maskable.svg b/public/brand/icon-maskable.svg new file mode 100644 index 0000000..ac5755b --- /dev/null +++ b/public/brand/icon-maskable.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/brand/icon.png b/public/brand/icon.png index b83858d..b5f2934 100644 Binary files a/public/brand/icon.png and b/public/brand/icon.png differ diff --git a/public/brand/icon.svg b/public/brand/icon.svg new file mode 100644 index 0000000..bf52c7b --- /dev/null +++ b/public/brand/icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/brand/logo-dark.svg b/public/brand/logo-dark.svg index 700c969..b5274a2 100644 --- a/public/brand/logo-dark.svg +++ b/public/brand/logo-dark.svg @@ -1,5 +1,14 @@ - - - - BÜTÇEM + + + + + + + + + + + + + diff --git a/public/brand/logo.svg b/public/brand/logo.svg index 60fd7b4..d36e9db 100644 --- a/public/brand/logo.svg +++ b/public/brand/logo.svg @@ -1,5 +1,14 @@ - - - - BÜTÇEM + + + + + + + + + + + + + diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png index 0147bc1..495c57f 100644 Binary files a/public/icons/icon-192.png and b/public/icons/icon-192.png differ diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png index faf8265..c00dd30 100644 Binary files a/public/icons/icon-512.png and b/public/icons/icon-512.png differ diff --git a/public/icons/maskable-512.png b/public/icons/maskable-512.png index 609e48e..6767574 100644 Binary files a/public/icons/maskable-512.png and b/public/icons/maskable-512.png differ diff --git a/scripts/generate-icons.ts b/scripts/generate-icons.ts index c55f90d..5cdfc66 100644 --- a/scripts/generate-icons.ts +++ b/scripts/generate-icons.ts @@ -1,23 +1,30 @@ /** * Uygulama ikonlarını logodan üretir: npm run icons * - * Kaynak: public/brand/icon.png (kare, en az 512×512). + * Kaynak: public/brand/icon.svg (varsa; önce 512×512 icon.png olarak render edilir) + * veya doğrudan public/brand/icon.png (kare, en az 512×512). * Çıktılar (commit edilir): + * public/brand/icon.png 512 px (icon.svg varsa ondan üretilir) * src/app/favicon.ico 16/32/48 px (PNG gömülü ICO) * src/app/icon.png 192 px →
{children}
diff --git a/src/app/(auth)/login/login-form.tsx b/src/app/(auth)/login/login-form.tsx index 3d0e25a..7763127 100644 --- a/src/app/(auth)/login/login-form.tsx +++ b/src/app/(auth)/login/login-form.tsx @@ -10,6 +10,8 @@ import { Button } from "@/components/ui/button"; import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; import { tr } from "@/i18n/tr"; +import { authClient } from "@/lib/auth-client"; +import { looksLikeEmail, normalizeUsername } from "@/shared/schemas/common"; import { loginSchema, type LoginInput } from "@/shared/schemas/auth"; export function LoginForm() { @@ -20,11 +22,23 @@ export function LoginForm() { defaultValues: { identifier: "", password: "" }, }); - async function onSubmit(_values: LoginInput) { + async function onSubmit({ identifier, password }: LoginInput) { setFormError(null); - // FAZ 3: looksLikeEmail(identifier) ? authClient.signIn.email : authClient.signIn.username - await new Promise((r) => setTimeout(r, 400)); + const value = identifier.trim(); + const { error } = looksLikeEmail(value) + ? await authClient.signIn.email({ email: value.toLowerCase(), password }) + : await authClient.signIn.username({ username: normalizeUsername(value), password }); + + if (error) { + // Hangi bilginin yanlış olduğu söylenmez (hesap tahmini zorlaşır). + if (error.status === 429) setFormError(tr.auth.tooManyAttempts); + else if (error.code === "BANNED_USER") setFormError(tr.auth.banned); + else setFormError(tr.auth.invalidCredentials); + form.setValue("password", ""); + return; + } router.replace("/dashboard"); + router.refresh(); } const submitting = form.formState.isSubmitting; diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 1c00480..c6bca9f 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -1,14 +1,27 @@ import type { Metadata } from "next"; +import { redirect } from "next/navigation"; +import { Suspense } from "react"; import { Logo } from "@/components/brand/logo"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { tr } from "@/i18n/tr"; +import { getCurrentUser } from "@/lib/dal"; import { LoginForm } from "./login-form"; export const metadata: Metadata = { title: tr.auth.loginTitle }; +/** Oturum açıksa dashboard'a yönlendirir (istek anında, Suspense içinde). */ +async function RedirectIfSignedIn() { + const user = await getCurrentUser(); + if (user) redirect("/dashboard"); + return null; +} + export default function LoginPage() { return (
+ + + diff --git a/src/app/api/auth/[...all]/route.ts b/src/app/api/auth/[...all]/route.ts new file mode 100644 index 0000000..50c5361 --- /dev/null +++ b/src/app/api/auth/[...all]/route.ts @@ -0,0 +1,11 @@ +import { toNextJsHandler } from "better-auth/next-js"; +import { getAuth } from "@/lib/auth"; + +/** Better Auth uç noktaları (/api/auth/*). Kayıt (sign-up) yapılandırmada kapalıdır. */ +export function GET(request: Request) { + return toNextJsHandler(getAuth()).GET(request); +} + +export function POST(request: Request) { + return toNextJsHandler(getAuth()).POST(request); +} diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts index 5edfb33..47b26c1 100644 --- a/src/app/api/health/route.ts +++ b/src/app/api/health/route.ts @@ -1,20 +1,23 @@ import { connection } from "next/server"; +import { pingDb } from "@/lib/db/mongo"; import { APP_COMMIT, APP_VERSION } from "@/lib/version"; /** - * Sağlık kontrolü (Dokploy / Docker HEALTHCHECK). - * Faz 3'te veritabanı ping'i eklenecek; DB erişilemezse 503 dönecek. + * Sağlık kontrolü (Dokploy / Docker HEALTHCHECK). Veritabanına erişilemezse 503. + * İç hata detayı döndürülmez. */ export async function GET() { // Cache Components açıkken GET handler'ı build'de prerender edilmesin. await connection(); + const dbOk = await pingDb(); return Response.json( { - status: "ok", + status: dbOk ? "ok" : "degraded", + db: dbOk ? "ok" : "unreachable", version: APP_VERSION, commit: APP_COMMIT, time: new Date().toISOString(), }, - { headers: { "Cache-Control": "no-store" } }, + { status: dbOk ? 200 : 503, headers: { "Cache-Control": "no-store" } }, ); } diff --git a/src/app/apple-icon.png b/src/app/apple-icon.png index d442934..5132667 100644 Binary files a/src/app/apple-icon.png and b/src/app/apple-icon.png differ diff --git a/src/app/favicon.ico b/src/app/favicon.ico index fbab8dd..38bd217 100644 Binary files a/src/app/favicon.ico and b/src/app/favicon.ico differ diff --git a/src/app/icon.png b/src/app/icon.png index 0147bc1..495c57f 100644 Binary files a/src/app/icon.png and b/src/app/icon.png differ diff --git a/src/app/page.tsx b/src/app/page.tsx index 0abc6c8..24c584c 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,6 +1,6 @@ import { redirect } from "next/navigation"; -/** Uygulama her zaman login ile açılır. FAZ 3: oturum varsa /dashboard'a yönlenecek. */ +/** Oturum yoksa proxy zaten /login'e yönlendirir; varsa dashboard. */ export default function Home() { - redirect("/login"); + redirect("/dashboard"); } diff --git a/src/components/brand/logo.tsx b/src/components/brand/logo.tsx index d544b6c..d9688bb 100644 --- a/src/components/brand/logo.tsx +++ b/src/components/brand/logo.tsx @@ -7,7 +7,7 @@ import { tr } from "@/i18n/tr"; * hydration uyumsuzluğu olmaz). Dosyalar public/brand/ altında. */ export function Logo({ className, priority }: { className?: string; priority?: boolean }) { - const common = { width: 120, height: 32, priority, unoptimized: true } as const; + const common = { width: 114, height: 32, priority, unoptimized: true } as const; return ( +
+ + {Array.from({ length: 8 }, (_, i) => ( + + ))} +
+
+
+ + +
+
+ + +
+
+ + ); +} diff --git a/src/components/layout/user-menu.tsx b/src/components/layout/user-menu.tsx index 3987572..434a33f 100644 --- a/src/components/layout/user-menu.tsx +++ b/src/components/layout/user-menu.tsx @@ -15,6 +15,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import type { SessionUser } from "@/features/auth/types"; +import { authClient } from "@/lib/auth-client"; import { tr } from "@/i18n/tr"; export function initials(name: string) { @@ -30,10 +31,15 @@ export function initials(name: string) { export function UserMenu({ user, version }: { user: SessionUser; version: string }) { const router = useRouter(); - function logout() { - // FAZ 3: authClient.signOut() sonrası yönlendirme. + async function logout() { + const { error } = await authClient.signOut(); + if (error) { + toast.error(tr.common.genericError); + return; + } toast.success(tr.userMenu.loggedOut); router.replace("/login"); + router.refresh(); } return ( @@ -61,7 +67,7 @@ export function UserMenu({ user, version }: { user: SessionUser; version: string - + void logout()}> {tr.userMenu.logout} diff --git a/src/features/admin-users/actions.ts b/src/features/admin-users/actions.ts new file mode 100644 index 0000000..38e62df --- /dev/null +++ b/src/features/admin-users/actions.ts @@ -0,0 +1,250 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { headers } from "next/headers"; +import { z } from "zod"; +import { tr } from "@/i18n/tr"; +import { fail, ok, type ActionResult } from "@/lib/action-result"; +import { getAuth } from "@/lib/auth"; +import { authErrorCode } from "@/lib/auth-errors"; +import { getActionUser } from "@/lib/dal"; +import { storedEmail } from "@/shared/account-email"; +import { systemRoleSchema, type SystemRole } from "@/shared/schemas/common"; +import { + banUserSchema, + setPasswordSchema, + userCreateSchema, + userUpdateSchema, +} from "@/shared/schemas/user"; +import { checkAdminAction, type AdminAction, type GuardViolation } from "./guards"; + +/** + * Admin kullanıcı yönetimi Server Action'ları. + * Sıra: yetki (sunucuda) → zod doğrulama → iş kuralları (guards) → Better Auth admin API → revalidate. + */ + +const t = tr.adminUsers; +const PATH = "/admin/kullanicilar"; +const userIdSchema = z.string().min(1).max(64); + +const guardMessage = (v: GuardViolation) => (v === "self" ? t.guards.self : t.guards.lastAdmin); + +/** Bilinen Better Auth hata kodlarını kullanıcıya gösterilecek alan hatalarına çevirir. */ +function mapAuthError(error: unknown): ActionResult { + const code = authErrorCode(error); + switch (code) { + case "USERNAME_IS_ALREADY_TAKEN": + return fail(t.errors.checkFields, { username: t.errors.usernameTaken }); + case "USER_ALREADY_EXISTS": + case "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL": + return fail(t.errors.checkFields, { email: t.errors.emailTaken }); + case "INVALID_USERNAME": + case "USERNAME_TOO_SHORT": + case "USERNAME_TOO_LONG": + return fail(t.errors.checkFields, { username: tr.validation.usernameFormat }); + case "INVALID_EMAIL": + return fail(t.errors.checkFields, { email: tr.validation.email }); + case "PASSWORD_TOO_SHORT": + case "PASSWORD_TOO_LONG": + return fail(t.errors.checkFields, { password: tr.validation.passwordMin }); + case "YOU_CANNOT_BAN_YOURSELF": + case "YOU_CANNOT_REMOVE_YOURSELF": + return fail(t.guards.self); + default: + // İç detay kullanıcıya sızdırılmaz; sunucuda loglanır. + console.error("[admin-users] beklenmeyen hata", code ?? error); + return fail(tr.common.genericError); + } +} + +/** Fiziksel kontrol: oturumdaki kullanıcı admin mi? Değilse işlem yapılmaz. */ +async function adminContext() { + const actor = await getActionUser("admin"); + if (!actor) return null; + return { actor, headers: await headers(), api: getAuth().api }; +} + +type Ctx = NonNullable>>; + +async function loadTarget(ctx: Ctx, userId: string) { + try { + const user = await ctx.api.getUser({ headers: ctx.headers, query: { id: userId } }); + return user + ? { + id: user.id, + role: (user.role === "admin" ? "admin" : "user") as SystemRole, + name: user.name, + } + : null; + } catch { + return null; + } +} + +async function countAdmins(ctx: Ctx): Promise { + const result = await ctx.api.listUsers({ + headers: ctx.headers, + query: { filterField: "role", filterOperator: "eq", filterValue: "admin", limit: 1 }, + }); + return result.total; +} + +async function guard(ctx: Ctx, action: AdminAction, target: { id: string; role: SystemRole }) { + const adminCount = target.role === "admin" ? await countAdmins(ctx) : Number.POSITIVE_INFINITY; + return checkAdminAction(action, { actorId: ctx.actor.id, target, adminCount }); +} + +// --- Action'lar --- + +export async function createUserAction(input: unknown): Promise { + const ctx = await adminContext(); + if (!ctx) return fail(tr.common.unauthorized); + const parsed = userCreateSchema.safeParse(input); + if (!parsed.success) return fail(t.errors.checkFields); + const v = parsed.data; + try { + await ctx.api.createUser({ + headers: ctx.headers, + body: { + name: v.name, + email: storedEmail(v.username, v.email), + password: v.password, + role: v.role, + data: { username: v.username }, + }, + }); + } catch (error) { + return mapAuthError(error); + } + revalidatePath(PATH); + return ok(undefined); +} + +export async function updateUserAction(userId: unknown, input: unknown): Promise { + const ctx = await adminContext(); + if (!ctx) return fail(tr.common.unauthorized); + const id = userIdSchema.safeParse(userId); + const parsed = userUpdateSchema.safeParse(input); + if (!id.success || !parsed.success) return fail(t.errors.checkFields); + const target = await loadTarget(ctx, id.data); + if (!target) return fail(tr.common.notFound); + const v = parsed.data; + + if (v.role !== target.role) { + const violation = await guard(ctx, { type: "setRole", role: v.role }, target); + if (violation) return fail(guardMessage(violation), { role: guardMessage(violation) }); + } + try { + await ctx.api.adminUpdateUser({ + headers: ctx.headers, + body: { + userId: target.id, + data: { name: v.name, username: v.username, email: storedEmail(v.username, v.email) }, + }, + }); + if (v.role !== target.role) { + await ctx.api.setRole({ headers: ctx.headers, body: { userId: target.id, role: v.role } }); + } + } catch (error) { + return mapAuthError(error); + } + revalidatePath(PATH); + return ok(undefined); +} + +export async function setRoleAction(userId: unknown, role: unknown): Promise { + const ctx = await adminContext(); + if (!ctx) return fail(tr.common.unauthorized); + const id = userIdSchema.safeParse(userId); + const r = systemRoleSchema.safeParse(role); + if (!id.success || !r.success) return fail(tr.common.genericError); + const target = await loadTarget(ctx, id.data); + if (!target) return fail(tr.common.notFound); + const violation = await guard(ctx, { type: "setRole", role: r.data }, target); + if (violation) return fail(guardMessage(violation)); + try { + await ctx.api.setRole({ headers: ctx.headers, body: { userId: target.id, role: r.data } }); + } catch (error) { + return mapAuthError(error); + } + revalidatePath(PATH); + return ok(undefined); +} + +export async function setPasswordAction(userId: unknown, input: unknown): Promise { + const ctx = await adminContext(); + if (!ctx) return fail(tr.common.unauthorized); + const id = userIdSchema.safeParse(userId); + const parsed = setPasswordSchema.safeParse(input); + if (!id.success || !parsed.success) return fail(t.errors.checkFields); + const target = await loadTarget(ctx, id.data); + if (!target) return fail(tr.common.notFound); + try { + await ctx.api.setUserPassword({ + headers: ctx.headers, + body: { userId: target.id, newPassword: parsed.data.password }, + }); + // Başka bir kullanıcının şifresi sıfırlandıysa açık oturumlarını kapat. + if (target.id !== ctx.actor.id) { + await ctx.api.revokeUserSessions({ headers: ctx.headers, body: { userId: target.id } }); + } + } catch (error) { + return mapAuthError(error); + } + return ok(undefined); +} + +export async function banUserAction(userId: unknown, input: unknown): Promise { + const ctx = await adminContext(); + if (!ctx) return fail(tr.common.unauthorized); + const id = userIdSchema.safeParse(userId); + const parsed = banUserSchema.safeParse(input); + if (!id.success || !parsed.success) return fail(t.errors.checkFields); + const target = await loadTarget(ctx, id.data); + if (!target) return fail(tr.common.notFound); + const violation = await guard(ctx, { type: "ban" }, target); + if (violation) return fail(guardMessage(violation)); + try { + // banUser açık oturumları da sonlandırır. + await ctx.api.banUser({ + headers: ctx.headers, + body: { userId: target.id, banReason: parsed.data.reason }, + }); + } catch (error) { + return mapAuthError(error); + } + revalidatePath(PATH); + return ok(undefined); +} + +export async function unbanUserAction(userId: unknown): Promise { + const ctx = await adminContext(); + if (!ctx) return fail(tr.common.unauthorized); + const id = userIdSchema.safeParse(userId); + if (!id.success) return fail(tr.common.genericError); + try { + await ctx.api.unbanUser({ headers: ctx.headers, body: { userId: id.data } }); + } catch (error) { + return mapAuthError(error); + } + revalidatePath(PATH); + return ok(undefined); +} + +export async function removeUserAction(userId: unknown): Promise { + const ctx = await adminContext(); + if (!ctx) return fail(tr.common.unauthorized); + const id = userIdSchema.safeParse(userId); + if (!id.success) return fail(tr.common.genericError); + const target = await loadTarget(ctx, id.data); + if (!target) return fail(tr.common.notFound); + const violation = await guard(ctx, { type: "delete" }, target); + if (violation) return fail(guardMessage(violation)); + try { + await ctx.api.removeUser({ headers: ctx.headers, body: { userId: target.id } }); + } catch (error) { + return mapAuthError(error); + } + revalidatePath(PATH); + return ok(undefined); +} diff --git a/src/features/admin-users/components/users-table.tsx b/src/features/admin-users/components/users-table.tsx index e0486a9..388bbf2 100644 --- a/src/features/admin-users/components/users-table.tsx +++ b/src/features/admin-users/components/users-table.tsx @@ -59,18 +59,22 @@ import { TableRow, } from "@/components/ui/table"; import { tr } from "@/i18n/tr"; -import { fail, ok, type ActionResult } from "@/lib/action-result"; +import { fail, type ActionResult } from "@/lib/action-result"; import { cn } from "@/lib/utils"; import { formatDate } from "@/shared/format"; import type { SystemRole } from "@/shared/schemas/common"; import type { UserCreate, UserUpdate } from "@/shared/schemas/user"; import { compareTr, includesTr } from "@/shared/search"; import { - checkAdminAction, - findUniqueConflicts, - type AdminAction, - type GuardViolation, -} from "../guards"; + banUserAction, + createUserAction, + removeUserAction, + setPasswordAction, + setRoleAction, + unbanUserAction, + updateUserAction, +} from "../actions"; +import { checkAdminAction, type AdminAction, type GuardViolation } from "../guards"; import type { AdminUser } from "../types"; import { BanUserDialog, DeleteUserDialog, SetPasswordDialog } from "./user-action-dialogs"; import { UserFormDialog } from "./user-form-dialog"; @@ -311,15 +315,14 @@ const columns = helper.columns([ ]); export function UsersTable({ - initialUsers, + users, currentUserId, }: { - initialUsers: AdminUser[]; + users: AdminUser[]; currentUserId: string; }) { - // FAZ 3: yerel durum yerine Server Action + revalidatePath. - const [users, setUsers] = useState(initialUsers); const [dialog, setDialog] = useState(null); + // Arayüzdeki kural kontrolü yalnızca erken geri bildirim içindir; asıl kontrol sunucuda. const adminCount = users.filter((u) => u.role === "admin").length; const guard = (action: AdminAction, target: AdminUser): GuardViolation | null => @@ -327,84 +330,41 @@ export function UsersTable({ const guardMessage = (v: GuardViolation) => (v === "self" ? t.guards.self : t.guards.lastAdmin); - // --- Mock işlemler (FAZ 3'te Server Action'lara taşınacak; aynı ActionResult sözleşmesi) --- - - async function createUser(values: UserCreate): Promise { - const conflicts = findUniqueConflicts(users, values); - if (conflicts.username || conflicts.email) { - return fail(t.errors.checkFields, { - username: conflicts.username && t.errors.usernameTaken, - email: conflicts.email && t.errors.emailTaken, - }); + /** Server Action'ı çağırır; başarıda bildirim gösterir. Sayfa revalidatePath ile tazelenir. */ + async function run(promise: Promise, success: string): Promise { + try { + const result = await promise; + if (result.ok) toast.success(success); + return result; + } catch { + return fail(tr.common.genericError); } - const { password: _password, ...rest } = values; - setUsers((prev) => [ - { - ...rest, - id: `u_${crypto.randomUUID().slice(0, 8)}`, - banned: false, - createdAt: new Date().toISOString(), - }, - ...prev, - ]); - toast.success(t.toast.created(values.name)); - return ok(undefined); } - async function updateUser(id: string, values: UserUpdate): Promise { - const target = users.find((u) => u.id === id); - if (!target) return fail(tr.common.genericError); - const conflicts = findUniqueConflicts(users, values, id); - if (conflicts.username || conflicts.email) { - return fail(t.errors.checkFields, { - username: conflicts.username && t.errors.usernameTaken, - email: conflicts.email && t.errors.emailTaken, - }); - } - const v = - values.role !== target.role ? guard({ type: "setRole", role: values.role }, target) : null; - if (v) return fail(guardMessage(v), { role: guardMessage(v) }); - setUsers((prev) => prev.map((u) => (u.id === id ? { ...u, ...values } : u))); - toast.success(t.toast.updated(values.name)); - return ok(undefined); + const createUser = (values: UserCreate) => + run(createUserAction(values), t.toast.created(values.name)); + + const updateUser = (id: string, values: UserUpdate) => + run(updateUserAction(id, values), t.toast.updated(values.name)); + + async function setRole(user: AdminUser, role: SystemRole) { + const result = await run(setRoleAction(user.id, role), t.toast.roleChanged(user.name)); + if (!result.ok) toast.error(result.error); } - function setRole(user: AdminUser, role: SystemRole) { - const v = guard({ type: "setRole", role }, user); - if (v) return void toast.error(guardMessage(v)); - setUsers((prev) => prev.map((u) => (u.id === user.id ? { ...u, role } : u))); - toast.success(t.toast.roleChanged(user.name)); + const setPassword = (user: AdminUser, values: { password: string }) => + run(setPasswordAction(user.id, values), t.toast.passwordSet(user.name)); + + const banUser = (user: AdminUser, values: { reason?: string }) => + run(banUserAction(user.id, values), t.toast.banned(user.name)); + + async function unbanUser(user: AdminUser) { + const result = await run(unbanUserAction(user.id), t.toast.unbanned(user.name)); + if (!result.ok) toast.error(result.error); } - async function setPassword(user: AdminUser): Promise { - toast.success(t.toast.passwordSet(user.name)); - return ok(undefined); - } - - async function banUser(user: AdminUser, { reason }: { reason?: string }): Promise { - const v = guard({ type: "ban" }, user); - if (v) return fail(guardMessage(v)); - setUsers((prev) => - prev.map((u) => (u.id === user.id ? { ...u, banned: true, banReason: reason } : u)), - ); - toast.success(t.toast.banned(user.name)); - return ok(undefined); - } - - function unbanUser(user: AdminUser) { - setUsers((prev) => - prev.map((u) => (u.id === user.id ? { ...u, banned: false, banReason: undefined } : u)), - ); - toast.success(t.toast.unbanned(user.name)); - } - - async function removeUser(user: AdminUser): Promise { - const v = guard({ type: "delete" }, user); - if (v) return fail(guardMessage(v)); - setUsers((prev) => prev.filter((u) => u.id !== user.id)); - toast.success(t.toast.removed(user.name)); - return ok(undefined); - } + const removeUser = (user: AdminUser) => + run(removeUserAction(user.id), t.toast.removed(user.name)); const table = useTable({ features, diff --git a/src/features/admin-users/guards.test.ts b/src/features/admin-users/guards.test.ts index 8df02f7..63108e2 100644 --- a/src/features/admin-users/guards.test.ts +++ b/src/features/admin-users/guards.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { checkAdminAction, findUniqueConflicts } from "./guards"; +import { checkAdminAction } from "./guards"; const admin = { id: "a1", role: "admin" as const }; const user = { id: "u1", role: "user" as const }; @@ -40,20 +40,3 @@ describe("checkAdminAction", () => { expect(checkAdminAction({ type: "setRole", role: "admin" }, ctx)).toBeNull(); }); }); - -describe("findUniqueConflicts", () => { - const users = [ - { id: "1", username: "ayse", email: "ayse@x.com" }, - { id: "2", username: "emre" }, - ]; - it("çakışan kullanıcı adı ve e-postayı bulur", () => { - expect(findUniqueConflicts(users, { username: "ayse", email: "ayse@x.com" })).toEqual({ - username: "taken", - email: "taken", - }); - }); - it("düzenlenen kaydın kendisini saymaz, boş e-postayı kontrol etmez", () => { - expect(findUniqueConflicts(users, { username: "ayse", email: "ayse@x.com" }, "1")).toEqual({}); - expect(findUniqueConflicts(users, { username: "yeni" })).toEqual({}); - }); -}); diff --git a/src/features/admin-users/guards.ts b/src/features/admin-users/guards.ts index a3230e1..e1fbde2 100644 --- a/src/features/admin-users/guards.ts +++ b/src/features/admin-users/guards.ts @@ -35,18 +35,3 @@ export function checkAdminAction(action: AdminAction, ctx: GuardContext): GuardV if (removesAdmin && ctx.adminCount <= 1) return "lastAdmin"; return null; } - -/** Kullanıcı adı / e-posta benzersizlik kontrolü (normalize edilmiş değerlerle). */ -export function findUniqueConflicts( - users: ReadonlyArray<{ id: string; username: string; email?: string }>, - values: { username: string; email?: string }, - excludeId?: string, -): { username?: "taken"; email?: "taken" } { - const others = users.filter((u) => u.id !== excludeId); - return { - ...(others.some((u) => u.username === values.username) ? { username: "taken" as const } : {}), - ...(values.email && others.some((u) => u.email === values.email) - ? { email: "taken" as const } - : {}), - }; -} diff --git a/src/features/admin-users/mapper.ts b/src/features/admin-users/mapper.ts new file mode 100644 index 0000000..b836c8f --- /dev/null +++ b/src/features/admin-users/mapper.ts @@ -0,0 +1,27 @@ +import { visibleEmail } from "@/shared/account-email"; +import type { AdminUser } from "./types"; + +type RawAdminUser = { + id: string; + name: string; + email: string; + username?: string | null; + role?: string | null; + banned?: boolean | null; + banReason?: string | null; + createdAt: Date | string; +}; + +/** Better Auth kullanıcı kaydını arayüz satırına çevirir (yer tutucu e-posta gizlenir). */ +export function toAdminUser(u: RawAdminUser): AdminUser { + return { + id: u.id, + name: u.name, + username: u.username ?? "", + email: visibleEmail(u.email), + role: u.role === "admin" ? "admin" : "user", + banned: Boolean(u.banned), + banReason: u.banReason ?? undefined, + createdAt: new Date(u.createdAt).toISOString(), + }; +} diff --git a/src/features/admin-users/mock.ts b/src/features/admin-users/mock.ts deleted file mode 100644 index 724387a..0000000 --- a/src/features/admin-users/mock.ts +++ /dev/null @@ -1,111 +0,0 @@ -import type { AdminUser } from "./types"; - -/** FAZ 2 GEÇİCİ: Faz 3'te Better Auth admin API'sinden gelen listeyle değiştirilecek. */ -export const MOCK_ADMIN_USERS: AdminUser[] = [ - { - id: "u_admin", - name: "Suphi Çakır", - username: "suphi", - email: "admin@butcem.local", - role: "admin", - banned: false, - createdAt: "2026-05-02T08:00:00Z", - }, - { - id: "u_ayse", - name: "Ayşe Çakır", - username: "ayse", - email: "ayse@butcem.local", - role: "user", - banned: false, - createdAt: "2026-05-02T08:10:00Z", - }, - { - id: "u_emre", - name: "Emre Çakır", - username: "emre", - role: "user", - banned: false, - createdAt: "2026-05-03T17:45:00Z", - }, - { - id: "u_zeynep", - name: "Zeynep Çakır", - username: "zeynep", - role: "user", - banned: false, - createdAt: "2026-05-03T17:50:00Z", - }, - { - id: "u_fatma", - name: "Fatma Demir", - username: "fatma.demir", - email: "fatma@butcem.local", - role: "user", - banned: false, - createdAt: "2026-06-11T12:00:00Z", - }, - { - id: "u_mehmet", - name: "Mehmet Demir", - username: "mehmet.demir", - email: "mehmet@butcem.local", - role: "user", - banned: false, - createdAt: "2026-06-11T12:05:00Z", - }, - { - id: "u_ozan", - name: "Ozan Kaya", - username: "ozan", - email: "ozan@butcem.local", - role: "user", - banned: true, - banReason: "Eski ev arkadaşı", - createdAt: "2026-07-20T09:30:00Z", - }, - { - id: "u_elif", - name: "Elif Şahin", - username: "elif_s", - email: "elif@butcem.local", - role: "user", - banned: false, - createdAt: "2026-08-14T19:15:00Z", - }, - { - id: "u_burak", - name: "Burak Öztürk", - username: "burak", - email: "burak@butcem.local", - role: "admin", - banned: false, - createdAt: "2026-09-01T06:40:00Z", - }, - { - id: "u_irem", - name: "İrem Güneş", - username: "irem", - role: "user", - banned: false, - createdAt: "2026-09-22T15:00:00Z", - }, - { - id: "u_can", - name: "Can Yıldız", - username: "can.y", - email: "can@butcem.local", - role: "user", - banned: false, - createdAt: "2026-10-05T10:20:00Z", - }, - { - id: "u_selin", - name: "Selin Aydın", - username: "selin", - email: "selin@butcem.local", - role: "user", - banned: false, - createdAt: "2026-10-30T21:05:00Z", - }, -]; diff --git a/src/features/admin-users/queries.ts b/src/features/admin-users/queries.ts new file mode 100644 index 0000000..f043a2f --- /dev/null +++ b/src/features/admin-users/queries.ts @@ -0,0 +1,16 @@ +import "server-only"; +import { headers } from "next/headers"; +import { getAuth } from "@/lib/auth"; +import { requireRole } from "@/lib/dal"; +import { toAdminUser } from "./mapper"; +import type { AdminUser } from "./types"; + +/** Admin: tüm kullanıcılar (en yeni önce). Kişisel kullanım ölçeğinde tek sayfa yeterli. */ +export async function listAdminUsers(): Promise { + await requireRole("admin"); + const result = await getAuth().api.listUsers({ + headers: await headers(), + query: { limit: 1000, sortBy: "createdAt", sortDirection: "desc" }, + }); + return result.users.map(toAdminUser); +} diff --git a/src/features/auth/mock.ts b/src/features/auth/mock.ts deleted file mode 100644 index 07d770d..0000000 --- a/src/features/auth/mock.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { SessionUser } from "./types"; - -/** - * FAZ 2 GEÇİCİ: Better Auth bağlanana kadar arayüzü besleyen sahte oturum. - * Faz 3'te `requireSession()` (lib/dal.ts) ile değiştirilecek ve bu dosya silinecek. - */ -export const MOCK_SESSION_USER: SessionUser = { - id: "u_admin", - name: "Suphi Çakır", - username: "suphi", - email: "admin@butcem.local", - role: "admin", -}; diff --git a/src/i18n/tr.ts b/src/i18n/tr.ts index 9b583a2..e76d8ee 100644 --- a/src/i18n/tr.ts +++ b/src/i18n/tr.ts @@ -31,6 +31,8 @@ export const tr = { showPassword: "Şifreyi göster", hidePassword: "Şifreyi gizle", genericError: "Beklenmeyen bir hata oluştu. Lütfen tekrar deneyin.", + unauthorized: "Bu işlem için yetkiniz yok.", + notFound: "Kayıt bulunamadı.", }, theme: { label: "Tema", @@ -90,6 +92,8 @@ export const tr = { submit: "Giriş yap", submitting: "Giriş yapılıyor…", invalidCredentials: "Kullanıcı adı/e-posta veya şifre hatalı.", + banned: "Hesabınız askıya alınmış. Lütfen yöneticiyle iletişime geçin.", + tooManyAttempts: "Çok fazla deneme yapıldı. Lütfen bir dakika sonra tekrar deneyin.", noSignUp: "Hesaplar yalnızca yönetici tarafından açılır.", identifierRequired: "Kullanıcı adı veya e-posta girin", passwordRequired: "Şifre girin", diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts new file mode 100644 index 0000000..cf80318 --- /dev/null +++ b/src/lib/auth-client.ts @@ -0,0 +1,9 @@ +"use client"; + +import { adminClient, usernameClient } from "better-auth/client/plugins"; +import { createAuthClient } from "better-auth/react"; + +/** Tarayıcı tarafı Better Auth istemcisi (aynı origin: /api/auth). */ +export const authClient = createAuthClient({ + plugins: [usernameClient(), adminClient()], +}); diff --git a/src/lib/auth-errors.ts b/src/lib/auth-errors.ts new file mode 100644 index 0000000..8989af7 --- /dev/null +++ b/src/lib/auth-errors.ts @@ -0,0 +1,8 @@ +import { isAPIError } from "better-auth/api"; + +/** Better Auth hatasından makine okunur kodu çıkarır (ör. "USERNAME_IS_ALREADY_TAKEN"). */ +export function authErrorCode(error: unknown): string | undefined { + if (!isAPIError(error)) return undefined; + const body = error.body as { code?: unknown } | undefined; + return typeof body?.code === "string" ? body.code : undefined; +} diff --git a/src/lib/auth.int.test.ts b/src/lib/auth.int.test.ts new file mode 100644 index 0000000..e3a7ba0 --- /dev/null +++ b/src/lib/auth.int.test.ts @@ -0,0 +1,191 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { getAuth } from "@/lib/auth"; +import { authErrorCode } from "@/lib/auth-errors"; +import { getDb, getMongoClient } from "@/lib/db/mongo"; +import { placeholderEmail } from "@/shared/account-email"; + +/** + * Better Auth yapılandırmasının güvenlik sözleşmesi (gerçek MongoDB, butcem_test veritabanı). + */ + +const auth = getAuth(); +const PASSWORD = "Gizli-Sifre-123"; + +/** Giriş yapıp oturum cookie'sini içeren istek başlıklarını döner. */ +async function signIn(username: string, password = PASSWORD): Promise { + const { headers } = await auth.api.signInUsername({ + body: { username, password }, + returnHeaders: true, + }); + const cookie = headers + .getSetCookie() + .map((c) => c.split(";")[0]) + .join("; "); + return new Headers({ cookie }); +} + +async function expectAuthError(promise: Promise, code: string) { + const error = await promise.then( + () => null, + (e: unknown) => e, + ); + expect(error, `beklenen hata: ${code}`).not.toBeNull(); + expect(authErrorCode(error)).toBe(code); +} + +beforeAll(async () => { + const db = getDb(); + expect(db.databaseName).toBe("butcem_test"); + await db.dropDatabase(); + await auth.api.createUser({ + body: { + email: "admin@test.local", + password: PASSWORD, + name: "Admin", + role: "admin", + data: { username: "admin" }, + }, + }); + await auth.api.createUser({ + body: { + email: placeholderEmail("emre"), + password: PASSWORD, + name: "Emre", + role: "user", + data: { username: "Emre" }, + }, + }); +}); + +afterAll(async () => { + await getDb().dropDatabase(); + await getMongoClient().close(); +}); + +describe("kayıt kapalı", () => { + it("sign-up/email uç noktası reddedilir ve kullanıcı oluşmaz", async () => { + const res = await auth.handler( + new Request("http://localhost:3000/api/auth/sign-up/email", { + method: "POST", + headers: { "content-type": "application/json", origin: "http://localhost:3000" }, + body: JSON.stringify({ email: "yeni@test.local", password: PASSWORD, name: "Yeni" }), + }), + ); + expect(res.ok).toBe(false); + expect(await getDb().collection("user").countDocuments({ email: "yeni@test.local" })).toBe(0); + }); +}); + +describe("giriş", () => { + it("kullanıcı adı küçük harfe normalize edilir ve kullanıcı adıyla giriş yapılır", async () => { + const stored = await getDb() + .collection("user") + .findOne({ email: placeholderEmail("emre") }); + expect(stored?.username).toBe("emre"); + const headers = await signIn("EMRE"); + const session = await auth.api.getSession({ headers }); + expect(session?.user.name).toBe("Emre"); + expect(session?.user.role).toBe("user"); + }); + + it("HTTP üzerinden büyük harfli (I içeren) kullanıcı adıyla giriş yapılır", async () => { + const res = await auth.handler( + new Request("http://localhost:3000/api/auth/sign-in/username", { + method: "POST", + headers: { + "content-type": "application/json", + origin: "http://localhost:3000", + "x-forwarded-for": "198.51.100.1", + }, + // "I" içeren büyük harfli giriş (tr-TR'de "ADMIN" → "admın" tuzağı) + body: JSON.stringify({ username: "ADMIN", password: PASSWORD }), + }), + ); + expect(res.status).toBe(200); + expect(res.headers.getSetCookie().some((c) => c.includes("session_token"))).toBe(true); + }); + + it("e-posta ile giriş yapılır", async () => { + const result = await auth.api.signInEmail({ + body: { email: "admin@test.local", password: PASSWORD }, + }); + expect(result.user.email).toBe("admin@test.local"); + }); + + it("yanlış şifre reddedilir", async () => { + await expectAuthError( + auth.api.signInUsername({ body: { username: "emre", password: "yanlis-sifre" } }), + "INVALID_USERNAME_OR_PASSWORD", + ); + }); + + it("aynı kullanıcı adı ikinci kez kullanılamaz", async () => { + await expectAuthError( + auth.api.createUser({ + body: { + email: "baska@test.local", + password: PASSWORD, + name: "Kopya", + data: { username: "emre" }, + }, + }), + "USERNAME_IS_ALREADY_TAKEN", + ); + }); +}); + +describe("admin yetkileri", () => { + it("admin olmayan kullanıcı admin API'sini çağıramaz", async () => { + const headers = await signIn("emre"); + const res = await auth.api + .listUsers({ headers, query: { limit: 10 } }) + .then(() => "ok") + .catch((e: unknown) => authErrorCode(e) ?? "error"); + expect(res).not.toBe("ok"); + }); + + it("askıya alınan kullanıcı giriş yapamaz, askı kaldırılınca yapabilir", async () => { + const adminHeaders = await signIn("admin"); + const emre = await getDb().collection("user").findOne({ username: "emre" }); + const userId = String(emre?._id); + + await auth.api.banUser({ headers: adminHeaders, body: { userId, banReason: "test" } }); + await expectAuthError( + auth.api.signInUsername({ body: { username: "emre", password: PASSWORD } }), + "BANNED_USER", + ); + + await auth.api.unbanUser({ headers: adminHeaders, body: { userId } }); + await expect(signIn("emre")).resolves.toBeInstanceOf(Headers); + }); + + it("admin kendini askıya alamaz", async () => { + const adminHeaders = await signIn("admin"); + const self = await auth.api.getSession({ headers: adminHeaders }); + await expectAuthError( + auth.api.banUser({ headers: adminHeaders, body: { userId: self!.user.id } }), + "YOU_CANNOT_BAN_YOURSELF", + ); + }); +}); + +describe("rate limit", () => { + it("giriş denemeleri dakikada 5 ile sınırlıdır", async () => { + const attempt = () => + auth.handler( + new Request("http://localhost:3000/api/auth/sign-in/username", { + method: "POST", + headers: { + "content-type": "application/json", + origin: "http://localhost:3000", + "x-forwarded-for": "203.0.113.7", + }, + body: JSON.stringify({ username: "emre", password: "yanlis-sifre" }), + }), + ); + const statuses: number[] = []; + for (let i = 0; i < 6; i++) statuses.push((await attempt()).status); + expect(statuses.slice(0, 5).every((s) => s !== 429)).toBe(true); + expect(statuses[5]).toBe(429); + }); +}); diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..d53d67f --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,81 @@ +import "server-only"; +import { betterAuth } from "better-auth"; +import { mongodbAdapter } from "better-auth/adapters/mongodb"; +import { nextCookies } from "better-auth/next-js"; +import { admin, username } from "better-auth/plugins"; +import { getMongoClient } from "@/lib/db/mongo"; +import { env } from "@/lib/env"; +import { normalizeUsername, USERNAME_PATTERN } from "@/shared/schemas/common"; + +function createAuth() { + const e = env(); + const client = getMongoClient(); + const isProd = e.NODE_ENV === "production"; + + return betterAuth({ + appName: "BÜTÇEM", + baseURL: e.BETTER_AUTH_URL, + secret: e.BETTER_AUTH_SECRET, + // client verildiği için çok adımlı auth yazmaları transaction içinde yapılır (replica set). + database: mongodbAdapter(client.db(), { client }), + emailAndPassword: { + enabled: true, + // Kullanıcılar kendi hesabını açamaz; hesaplar yalnızca admin panelinden oluşturulur. + disableSignUp: true, + minPasswordLength: 8, + maxPasswordLength: 128, + revokeSessionsOnPasswordReset: true, + }, + session: { + expiresIn: 60 * 60 * 24 * 30, // 30 gün + updateAge: 60 * 60 * 24, // günde bir uzat + }, + rateLimit: { + enabled: true, + // Yeniden başlatmalarda sıfırlanmasın; tek örnek çalıştığı için yeterli. + storage: "database", + window: 60, + max: 100, + customRules: { + "/sign-in/email": { window: 60, max: 5 }, + "/sign-in/username": { window: 60, max: 5 }, + }, + }, + advanced: { + useSecureCookies: isProd, + cookiePrefix: "butcem", + // Rate limit istemci IP'sini X-Forwarded-For'dan okur. Canlıda Traefik güvenilmeyen + // istemcilerden gelen X-Forwarded-* başlıklarını silip kendisi yazmalı + // (forwardedHeaders.insecure=false, varsayılan) — docs/DEPLOY.md (Faz 15). + }, + telemetry: { enabled: false }, + plugins: [ + username({ + minUsernameLength: 3, + maxUsernameLength: 30, + // shared/schemas/common.ts ile aynı kural (İ/I/ı → i, ASCII küçük harf). + usernameNormalization: normalizeUsername, + // Giriş uç noktası normalizasyondan ÖNCE doğrular; büyük harfli girişler ("ADMIN") + // reddedilmesin diye doğrulama her zaman normalize edilmiş değer üzerinde yapılır. + usernameValidator: (value) => USERNAME_PATTERN.test(normalizeUsername(value)), + validationOrder: { username: "post-normalization" }, + }), + admin({ + defaultRole: "user", + adminRoles: ["admin"], + bannedUserMessage: "Hesabınız askıya alınmış. Lütfen yöneticiyle iletişime geçin.", + }), + nextCookies(), // Server Action'larda cookie ayarlamak için en sonda olmalı + ], + }); +} + +export type Auth = ReturnType; + +const globalForAuth = globalThis as unknown as { __butcemAuth?: Auth }; + +/** Better Auth örneği (tembel: build sırasında ortam değişkeni gerektirmez). */ +export function getAuth(): Auth { + globalForAuth.__butcemAuth ??= createAuth(); + return globalForAuth.__butcemAuth; +} diff --git a/src/lib/dal.ts b/src/lib/dal.ts new file mode 100644 index 0000000..f648cc4 --- /dev/null +++ b/src/lib/dal.ts @@ -0,0 +1,68 @@ +import "server-only"; +import { headers } from "next/headers"; +import { notFound, redirect } from "next/navigation"; +import { cache } from "react"; +import type { SessionUser } from "@/features/auth/types"; +import { getAuth } from "@/lib/auth"; +import { visibleEmail } from "@/shared/account-email"; +import type { SystemRole } from "@/shared/schemas/common"; + +/** + * Data Access Layer — her korumalı sayfa, layout, Server Action ve Route Handler oturumu + * BURADAN okur. proxy.ts yalnızca iyimser (cookie var mı?) kontrol yapar; güvenlik bu katmandadır. + */ + +type RawUser = { + id: string; + name: string; + email: string; + username?: string | null; + role?: string | null; + banned?: boolean | null; + image?: string | null; +}; + +export function toSessionUser(user: RawUser): SessionUser { + return { + id: user.id, + name: user.name, + username: user.username ?? "", + email: visibleEmail(user.email), + role: user.role === "admin" ? "admin" : "user", + image: user.image ?? undefined, + }; +} + +/** İstek başına bir kez çalışır (React cache). Oturum yoksa veya kullanıcı askıdaysa null. */ +export const getCurrentUser = cache(async (): Promise => { + // headers() önce çağrılır: Cache Components prerender'ı burada durur, auth hiç oluşturulmaz. + const requestHeaders = await headers(); + const session = await getAuth().api.getSession({ headers: requestHeaders }); + if (!session || session.user.banned) return null; + return toSessionUser(session.user); +}); + +/** Sayfa/layout: oturum yoksa /login'e yönlendirir. */ +export async function requireSession(): Promise { + const user = await getCurrentUser(); + if (!user) redirect("/login"); + return user; +} + +/** Sayfa/layout: rol yoksa 404 (sayfanın varlığı bile gösterilmez). */ +export async function requireRole(role: SystemRole): Promise { + const user = await requireSession(); + if (user.role !== role) notFound(); + return user; +} + +/** + * Server Action / Route Handler: yönlendirme yapmaz, yetkisizse null döner + * (çağıran `fail(...)` veya 401/403 döndürür). + */ +export async function getActionUser(role?: SystemRole): Promise { + const user = await getCurrentUser(); + if (!user) return null; + if (role && user.role !== role) return null; + return user; +} diff --git a/src/lib/db/.gitkeep b/src/lib/db/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/lib/db/mongo.ts b/src/lib/db/mongo.ts new file mode 100644 index 0000000..20f8d5f --- /dev/null +++ b/src/lib/db/mongo.ts @@ -0,0 +1,36 @@ +import "server-only"; +import { MongoClient, type Db } from "mongodb"; +import { env } from "@/lib/env"; + +/** + * Uygulama genelinde tek MongoClient. Geliştirmede HMR yeniden yüklemelerinde bağlantı + * çoğalmasın diye globalThis üzerinde saklanır. İstemci tembel oluşturulur: `next build` + * sırasında ortam değişkeni veya bağlantı gerektirmez (sürücü ilk işlemde bağlanır). + */ +const globalForMongo = globalThis as unknown as { __butcemMongo?: MongoClient }; + +export function getMongoClient(): MongoClient { + if (!globalForMongo.__butcemMongo) { + globalForMongo.__butcemMongo = new MongoClient(env().MONGODB_URI, { + appName: "butcem", + maxPoolSize: 20, + serverSelectionTimeoutMS: 5_000, + }); + } + return globalForMongo.__butcemMongo; +} + +/** Bağlantı URI'sindeki veritabanı (ör. /butcem). */ +export function getDb(): Db { + return getMongoClient().db(); +} + +/** Sağlık kontrolü için hızlı ping. */ +export async function pingDb(timeoutMs = 2_000): Promise { + try { + await getDb().command({ ping: 1 }, { timeoutMS: timeoutMs }); + return true; + } catch { + return false; + } +} diff --git a/src/lib/db/mongoose.ts b/src/lib/db/mongoose.ts new file mode 100644 index 0000000..b36cc51 --- /dev/null +++ b/src/lib/db/mongoose.ts @@ -0,0 +1,19 @@ +import "server-only"; +import mongoose from "mongoose"; +import { env } from "@/lib/env"; + +/** + * Alan (domain) modelleri için Mongoose bağlantısı; MongoClient ile aynı önbellekleme deseni. + * Kimlik doğrulama koleksiyonları Better Auth tarafından native sürücüyle yönetilir. + */ +const globalForMongoose = globalThis as unknown as { __butcemMongoose?: Promise }; + +export function connectMongoose(): Promise { + globalForMongoose.__butcemMongoose ??= mongoose.connect(env().MONGODB_URI, { + appName: "butcem-odm", + maxPoolSize: 20, + serverSelectionTimeoutMS: 5_000, + autoIndex: env().NODE_ENV !== "production", + }); + return globalForMongoose.__butcemMongoose; +} diff --git a/src/proxy.ts b/src/proxy.ts new file mode 100644 index 0000000..a292ad9 --- /dev/null +++ b/src/proxy.ts @@ -0,0 +1,22 @@ +import { getSessionCookie } from "better-auth/cookies"; +import { NextResponse, type NextRequest } from "next/server"; + +/** + * İYİMSER kontrol: yalnızca oturum cookie'si var mı diye bakar (veritabanına gitmez). + * Cookie yoksa korumalı sayfaları /login'e yönlendirir. Asıl yetki kontrolü lib/dal.ts'de; + * bu katman tek başına güvenlik sağlamaz. + */ +export function proxy(request: NextRequest) { + const hasSessionCookie = Boolean(getSessionCookie(request, { cookiePrefix: "butcem" })); + if (!hasSessionCookie) { + return NextResponse.redirect(new URL("/login", request.url)); + } + return NextResponse.next(); +} + +export const config = { + // Herkese açık yollar hariç: giriş, auth ve health API'leri, statik dosyalar, ikonlar, manifest. + matcher: [ + "/((?!login|api/auth|api/health|_next/static|_next/image|brand/|icons/|favicon.ico|icon.png|apple-icon.png|manifest.webmanifest|robots.txt).*)", + ], +}; diff --git a/src/shared/account-email.test.ts b/src/shared/account-email.test.ts new file mode 100644 index 0000000..f4a4a65 --- /dev/null +++ b/src/shared/account-email.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { isPlaceholderEmail, placeholderEmail, storedEmail, visibleEmail } from "./account-email"; + +describe("yer tutucu e-posta", () => { + it("kullanıcı adından .invalid adres üretir ve tanır", () => { + const e = placeholderEmail("emre"); + expect(e).toBe("emre@kullanici.butcem.invalid"); + expect(isPlaceholderEmail(e)).toBe(true); + expect(isPlaceholderEmail("EMRE@KULLANICI.BUTCEM.INVALID")).toBe(true); + expect(isPlaceholderEmail("emre@ornek.com")).toBe(false); + expect(isPlaceholderEmail(undefined)).toBe(false); + }); + + it("kaydedilen ve gösterilen e-posta", () => { + expect(storedEmail("emre", undefined)).toBe("emre@kullanici.butcem.invalid"); + expect(storedEmail("ayse", "ayse@ornek.com")).toBe("ayse@ornek.com"); + expect(visibleEmail("emre@kullanici.butcem.invalid")).toBeUndefined(); + expect(visibleEmail("ayse@ornek.com")).toBe("ayse@ornek.com"); + }); +}); diff --git a/src/shared/account-email.ts b/src/shared/account-email.ts new file mode 100644 index 0000000..47a7b8b --- /dev/null +++ b/src/shared/account-email.ts @@ -0,0 +1,24 @@ +/** + * E-postası olmayan kullanıcılar (ör. çocuklar) kullanıcı adıyla giriş yapar. Better Auth + * e-postayı zorunlu tuttuğu için bunlara ayrılmış `.invalid` alan adında (RFC 2606) + * yer tutucu bir adres verilir; arayüzde "e-posta yok" olarak gösterilir. + */ +export const PLACEHOLDER_EMAIL_DOMAIN = "kullanici.butcem.invalid"; + +export function placeholderEmail(username: string): string { + return `${username}@${PLACEHOLDER_EMAIL_DOMAIN}`; +} + +export function isPlaceholderEmail(email: string | null | undefined): boolean { + return Boolean(email?.toLowerCase().endsWith(`@${PLACEHOLDER_EMAIL_DOMAIN}`)); +} + +/** Kaydedilecek e-posta: girilmişse o, yoksa kullanıcı adından yer tutucu. */ +export function storedEmail(username: string, email: string | undefined): string { + return email ?? placeholderEmail(username); +} + +/** Gösterilecek e-posta: yer tutucuysa undefined. */ +export function visibleEmail(email: string | null | undefined): string | undefined { + return email && !isPlaceholderEmail(email) ? email : undefined; +} diff --git a/src/shared/schemas/common.ts b/src/shared/schemas/common.ts index 6b90e49..377dd4e 100644 --- a/src/shared/schemas/common.ts +++ b/src/shared/schemas/common.ts @@ -12,11 +12,17 @@ export const USERNAME_PATTERN = /^[a-z0-9._]{3,30}$/; export const nameSchema = z.string().trim().min(2, v.nameMin).max(80, v.nameMax); -/** Kullanıcı adı küçük harfe çevrilir; Türkçe büyük İ/I sorununu önlemek için tr-TR kullanılır. */ +/** + * Kullanıcı adı normalizasyonu (ASCII). Türkçe yerel ayarı KULLANILMAZ: tr-TR'de "I" → "ı" olur + * ve "ADMIN" "admın"e dönüşür. Bunun yerine İ/I/ı hepsi "i" yapılır, sonra küçük harfe çevrilir. + */ +export function normalizeUsername(value: string): string { + return value.trim().replace(/[İIı]/g, "i").toLowerCase(); +} + export const usernameSchema = z .string() - .trim() - .transform((s) => s.toLocaleLowerCase("tr-TR")) + .transform(normalizeUsername) .pipe(z.string().regex(USERNAME_PATTERN, v.usernameFormat)); export const emailSchema = z.string().trim().toLowerCase().pipe(z.email(v.email)); diff --git a/src/shared/schemas/user.test.ts b/src/shared/schemas/user.test.ts index a1d2630..d5c6f61 100644 --- a/src/shared/schemas/user.test.ts +++ b/src/shared/schemas/user.test.ts @@ -12,11 +12,18 @@ const base = { }; describe("usernameSchema", () => { - it("küçük harfe çevirir ve boşlukları kırpar (Türkçe İ dahil)", () => { + it("küçük harfe çevirir ve boşlukları kırpar", () => { expect(usernameSchema.parse(" AYSE.K ")).toBe("ayse.k"); expect(usernameSchema.parse("Ali_2")).toBe("ali_2"); }); + it("I/İ/ı harflerini i yapar (tr-TR'deki I → ı tuzağı)", () => { + expect(usernameSchema.parse("ADMIN")).toBe("admin"); + expect(usernameSchema.parse("ALI")).toBe("ali"); + expect(usernameSchema.parse("İREM")).toBe("irem"); + expect(usernameSchema.parse("ırmak")).toBe("irmak"); + }); + it("Türkçe karakter, boşluk ve kısa değerleri reddeder", () => { for (const bad of ["ayşe", "ali veli", "ab", "a".repeat(31), "ali-veli"]) { expect(usernameSchema.safeParse(bad).success, bad).toBe(false); diff --git a/src/test/server-only-stub.ts b/src/test/server-only-stub.ts new file mode 100644 index 0000000..b159a7a --- /dev/null +++ b/src/test/server-only-stub.ts @@ -0,0 +1,2 @@ +// Testlerde "server-only" paketinin yerine geçer (paket React Server ortamı dışında hata fırlatır). +export {}; diff --git a/vitest.config.mts b/vitest.config.mts index c50fe37..5f08968 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -3,12 +3,15 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ resolve: { - alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) }, + alias: { + "@": fileURLToPath(new URL("./src", import.meta.url)), + "server-only": fileURLToPath(new URL("./src/test/server-only-stub.ts", import.meta.url)), + }, }, test: { environment: "node", include: ["src/**/*.test.ts", "scripts/**/*.test.ts"], - // "server-only" paketi test ortamında hata fırlatmasın. - server: { deps: { inline: ["server-only"] } }, + // Veritabanı gerektiren testler ayrı: npm run test:integration + exclude: ["**/*.int.test.ts", "node_modules/**"], }, }); diff --git a/vitest.integration.config.mts b/vitest.integration.config.mts new file mode 100644 index 0000000..6b388c5 --- /dev/null +++ b/vitest.integration.config.mts @@ -0,0 +1,37 @@ +import { fileURLToPath } from "node:url"; +import { loadEnv } from "vite"; +import { defineConfig } from "vitest/config"; + +/** + * Entegrasyon testleri: gerçek MongoDB (npm run db:up) üzerinde, ayrı "butcem_test" veritabanı. + * Çalıştırma: npm run test:integration + */ +const fileEnv = loadEnv("test", process.cwd(), ""); +const local = { ...loadEnv("development", process.cwd(), ""), ...fileEnv }; +const baseUri = + process.env.MONGODB_URI_TEST ?? + ( + local.MONGODB_URI ?? "mongodb://localhost:27019/butcem?replicaSet=rs0&directConnection=true" + ).replace(/\/([^/?]+)(\?|$)/, "/butcem_test$2"); + +export default defineConfig({ + resolve: { + alias: { + "@": fileURLToPath(new URL("./src", import.meta.url)), + "server-only": fileURLToPath(new URL("./src/test/server-only-stub.ts", import.meta.url)), + }, + }, + test: { + environment: "node", + include: ["src/**/*.int.test.ts"], + fileParallelism: false, + testTimeout: 20_000, + hookTimeout: 30_000, + env: { + NODE_ENV: "test", + MONGODB_URI: baseUri, + BETTER_AUTH_SECRET: "test-secret-test-secret-test-secret-0123", + BETTER_AUTH_URL: "http://localhost:3000", + }, + }, +});