feat: Faz 3 - backend, kimlik doğrulama ve güvenlik
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@@ -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
|
||||
|
||||
@@ -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ı
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512" role="img" aria-label="BÜTÇEM">
|
||||
<!-- BÜTÇEM maskable/Apple ikonu (köşesiz, tam zemin). Kaynak: npm run icons -->
|
||||
<defs><linearGradient id="mg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#1d7cd5"/><stop offset="1" stop-color="#0e49a5"/></linearGradient></defs>
|
||||
<rect width="512" height="512" fill="url(#mg)"/>
|
||||
<path d="M108 240 256 124 404 240" fill="none" stroke="#ffffff" stroke-width="38" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<rect x="152" y="318" width="52" height="76" rx="13" fill="#ffffff"/>
|
||||
<rect x="230" y="280" width="52" height="114" rx="13" fill="#ffffff"/>
|
||||
<rect x="308" y="240" width="52" height="154" rx="13" fill="#8fd5fd"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 785 B |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512" role="img" aria-label="BÜTÇEM">
|
||||
<!-- BÜTÇEM uygulama ikonu: hane (çatı) + yükselen bütçe/birikim sütunları. Kaynak: npm run icons -->
|
||||
<defs><linearGradient id="ig" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#1d7cd5"/><stop offset="1" stop-color="#0e49a5"/></linearGradient></defs>
|
||||
<rect width="512" height="512" rx="112" fill="url(#ig)"/>
|
||||
<path d="M108 240 256 124 404 240" fill="none" stroke="#ffffff" stroke-width="38" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<rect x="152" y="318" width="52" height="76" rx="13" fill="#ffffff"/>
|
||||
<rect x="230" y="280" width="52" height="114" rx="13" fill="#ffffff"/>
|
||||
<rect x="308" y="240" width="52" height="154" rx="13" fill="#8fd5fd"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 820 B |
@@ -1,5 +1,14 @@
|
||||
<!-- YER TUTUCU: gerçek BÜTÇEM logosu ile değiştirin (koyu tema). -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="240" height="64" viewBox="0 0 240 64" role="img" aria-label="BÜTÇEM">
|
||||
<rect x="1" y="1" width="238" height="62" rx="12" fill="none" stroke="#6bb9f8" stroke-width="2" stroke-dasharray="6 4"/>
|
||||
<text x="120" y="40" text-anchor="middle" font-family="sans-serif" font-size="26" font-weight="700" fill="#6bb9f8">BÜTÇEM</text>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 227 64" width="227" height="64" role="img" aria-label="BÜTÇEM">
|
||||
<!-- BÜTÇEM logosu (koyu tema). Yazı Noto Sans Bold (OFL) yollarıdır; font gerektirmez. -->
|
||||
<g transform="scale(0.125)">
|
||||
<defs><linearGradient id="dg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#1d7cd5"/><stop offset="1" stop-color="#0e49a5"/></linearGradient></defs>
|
||||
<rect width="512" height="512" rx="112" fill="url(#dg)"/>
|
||||
<path d="M108 240 256 124 404 240" fill="none" stroke="#ffffff" stroke-width="38" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<rect x="152" y="318" width="52" height="76" rx="13" fill="#ffffff"/>
|
||||
<rect x="230" y="280" width="52" height="114" rx="13" fill="#ffffff"/>
|
||||
<rect x="308" y="240" width="52" height="154" rx="13" fill="#8fd5fd"/>
|
||||
</g>
|
||||
<path fill="#edf2f8" d="M88.744 19.863999999999997Q93.742 19.863999999999997 96.02000000000001 21.342999999999996Q98.298 22.822 98.298 25.916Q98.298 28.194 97.09100000000001 29.537Q95.884 30.88 94.184 31.253999999999998V31.424Q95.374 31.662 96.46199999999999 32.274Q97.55 32.885999999999996 98.22999999999999 34.042Q98.91 35.198 98.91 37.102000000000004Q98.91 40.434 96.513 42.287Q94.116 44.14 89.968 44.14H80.89V19.863999999999997ZM89.016 29.588Q91.226 29.588 92.093 28.772Q92.96000000000001 27.956 92.96000000000001 26.63Q92.96000000000001 25.236 91.94 24.573Q90.92 23.91 88.778 23.91H86.092V29.588ZM86.092 33.498V40.026H89.39Q91.702 40.026 92.60300000000001 39.091Q93.504 38.156 93.504 36.694Q93.504 35.368 92.58600000000001 34.433Q91.668 33.498 89.22 33.498Z M124.10400000000001 35.572Q124.10400000000001 38.088 122.99900000000001 40.111000000000004Q121.894 42.134 119.667 43.307Q117.44000000000001 44.480000000000004 114.04 44.480000000000004Q109.212 44.480000000000004 106.696 42.015Q104.18 39.55 104.18 35.504V19.863999999999997H109.31400000000001V34.722Q109.31400000000001 37.714 110.53800000000001 38.938Q111.762 40.162 114.176 40.162Q116.69200000000001 40.162 117.831 38.836Q118.97 37.51 118.97 34.688V19.863999999999997H124.10400000000001ZM108.05600000000001 15.103999999999996Q108.05600000000001 13.913999999999994 108.71900000000001 13.369999999999996Q109.382 12.825999999999997 110.40200000000002 12.825999999999997Q111.32000000000001 12.825999999999997 112.03400000000002 13.369999999999996Q112.74800000000002 13.913999999999994 112.74800000000002 15.103999999999996Q112.74800000000002 16.225999999999996 112.03400000000002 16.769999999999996Q111.32000000000001 17.313999999999997 110.40200000000002 17.313999999999997Q109.382 17.313999999999997 108.71900000000001 16.769999999999996Q108.05600000000001 16.225999999999996 108.05600000000001 15.103999999999996ZM115.4 15.103999999999996Q115.4 13.913999999999994 116.08000000000001 13.369999999999996Q116.76000000000002 12.825999999999997 117.74600000000001 12.825999999999997Q118.69800000000001 12.825999999999997 119.39500000000001 13.369999999999996Q120.09200000000001 13.913999999999994 120.09200000000001 15.103999999999996Q120.09200000000001 16.225999999999996 119.39500000000001 16.769999999999996Q118.69800000000001 17.313999999999997 117.74600000000001 17.313999999999997Q116.76000000000002 17.313999999999997 116.08000000000001 16.769999999999996Q115.4 16.225999999999996 115.4 15.103999999999996Z M140.084 44.14H134.882V24.182H128.32V19.863999999999997H146.64600000000002V24.182H140.084Z M161.50400000000002 23.842Q158.58 23.842 156.897 26.035Q155.214 28.228 155.214 32.07Q155.214 35.912 156.829 38.037Q158.44400000000002 40.162 161.708 40.162Q163.306 40.162 164.78500000000003 39.788Q166.264 39.414 167.76000000000002 38.836V43.256Q164.87 44.480000000000004 161.13 44.480000000000004Q157.25400000000002 44.480000000000004 154.755 42.93300000000001Q152.256 41.386 151.049 38.581Q149.842 35.775999999999996 149.842 32.036Q149.842 28.363999999999997 151.202 25.541999999999998Q152.562 22.72 155.18 21.122Q157.798 19.523999999999997 161.572 19.523999999999997Q163.37400000000002 19.523999999999997 165.22700000000003 19.915Q167.08 20.305999999999997 168.746 21.122L167.04600000000002 25.27Q165.822 24.692 164.445 24.267Q163.068 23.842 161.50400000000002 23.842ZM164.496 48.526Q164.496 50.26 163.238 51.28Q161.98000000000002 52.3 159.73600000000002 52.3Q158.818 52.3 158.07000000000002 52.181Q157.32200000000003 52.062 156.846 51.892V49.206Q157.83200000000002 49.512 158.88600000000002 49.512Q160.68800000000002 49.512 160.68800000000002 48.322Q160.68800000000002 47.642 160.00800000000004 47.268Q159.32800000000003 46.894 158.03600000000003 46.69L159.22600000000003 44.14H161.94600000000003L161.436 45.466Q162.15 45.636 162.86400000000003 45.976Q163.57800000000003 46.316 164.03700000000003 46.928Q164.496 47.54 164.496 48.526Z"/>
|
||||
<path fill="#6bb9f8" d="M187.514 44.14H173.54000000000002V19.863999999999997H187.514V24.08H178.674V29.418H186.90200000000002V33.634H178.674V39.89H187.514Z M203.52800000000002 44.14 197.68 25.099999999999998H197.544Q197.57800000000003 25.779999999999998 197.64600000000002 27.14Q197.71400000000003 28.5 197.78200000000004 30.047Q197.85000000000002 31.594 197.85000000000002 32.852000000000004V44.14H193.26000000000002V19.863999999999997H200.264L206.01000000000002 38.428H206.11200000000002L212.198 19.863999999999997H219.20200000000003V44.14H214.40800000000002V32.647999999999996Q214.40800000000002 31.491999999999997 214.459 29.996Q214.51000000000002 28.5 214.56100000000004 27.157Q214.61200000000002 25.814 214.64600000000002 25.134H214.51000000000002L208.25400000000002 44.14Z"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 457 B After Width: | Height: | Size: 5.4 KiB |
@@ -1,5 +1,14 @@
|
||||
<!-- YER TUTUCU: gerçek BÜTÇEM logosu ile değiştirin (açık tema). -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="240" height="64" viewBox="0 0 240 64" role="img" aria-label="BÜTÇEM">
|
||||
<rect x="1" y="1" width="238" height="62" rx="12" fill="none" stroke="#105bbe" stroke-width="2" stroke-dasharray="6 4"/>
|
||||
<text x="120" y="40" text-anchor="middle" font-family="sans-serif" font-size="26" font-weight="700" fill="#105bbe">BÜTÇEM</text>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 227 64" width="227" height="64" role="img" aria-label="BÜTÇEM">
|
||||
<!-- BÜTÇEM logosu (açık tema). Yazı Noto Sans Bold (OFL) yollarıdır; font gerektirmez. -->
|
||||
<g transform="scale(0.125)">
|
||||
<defs><linearGradient id="lg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#1d7cd5"/><stop offset="1" stop-color="#0e49a5"/></linearGradient></defs>
|
||||
<rect width="512" height="512" rx="112" fill="url(#lg)"/>
|
||||
<path d="M108 240 256 124 404 240" fill="none" stroke="#ffffff" stroke-width="38" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<rect x="152" y="318" width="52" height="76" rx="13" fill="#ffffff"/>
|
||||
<rect x="230" y="280" width="52" height="114" rx="13" fill="#ffffff"/>
|
||||
<rect x="308" y="240" width="52" height="154" rx="13" fill="#8fd5fd"/>
|
||||
</g>
|
||||
<path fill="#101a2d" d="M88.744 19.863999999999997Q93.742 19.863999999999997 96.02000000000001 21.342999999999996Q98.298 22.822 98.298 25.916Q98.298 28.194 97.09100000000001 29.537Q95.884 30.88 94.184 31.253999999999998V31.424Q95.374 31.662 96.46199999999999 32.274Q97.55 32.885999999999996 98.22999999999999 34.042Q98.91 35.198 98.91 37.102000000000004Q98.91 40.434 96.513 42.287Q94.116 44.14 89.968 44.14H80.89V19.863999999999997ZM89.016 29.588Q91.226 29.588 92.093 28.772Q92.96000000000001 27.956 92.96000000000001 26.63Q92.96000000000001 25.236 91.94 24.573Q90.92 23.91 88.778 23.91H86.092V29.588ZM86.092 33.498V40.026H89.39Q91.702 40.026 92.60300000000001 39.091Q93.504 38.156 93.504 36.694Q93.504 35.368 92.58600000000001 34.433Q91.668 33.498 89.22 33.498Z M124.10400000000001 35.572Q124.10400000000001 38.088 122.99900000000001 40.111000000000004Q121.894 42.134 119.667 43.307Q117.44000000000001 44.480000000000004 114.04 44.480000000000004Q109.212 44.480000000000004 106.696 42.015Q104.18 39.55 104.18 35.504V19.863999999999997H109.31400000000001V34.722Q109.31400000000001 37.714 110.53800000000001 38.938Q111.762 40.162 114.176 40.162Q116.69200000000001 40.162 117.831 38.836Q118.97 37.51 118.97 34.688V19.863999999999997H124.10400000000001ZM108.05600000000001 15.103999999999996Q108.05600000000001 13.913999999999994 108.71900000000001 13.369999999999996Q109.382 12.825999999999997 110.40200000000002 12.825999999999997Q111.32000000000001 12.825999999999997 112.03400000000002 13.369999999999996Q112.74800000000002 13.913999999999994 112.74800000000002 15.103999999999996Q112.74800000000002 16.225999999999996 112.03400000000002 16.769999999999996Q111.32000000000001 17.313999999999997 110.40200000000002 17.313999999999997Q109.382 17.313999999999997 108.71900000000001 16.769999999999996Q108.05600000000001 16.225999999999996 108.05600000000001 15.103999999999996ZM115.4 15.103999999999996Q115.4 13.913999999999994 116.08000000000001 13.369999999999996Q116.76000000000002 12.825999999999997 117.74600000000001 12.825999999999997Q118.69800000000001 12.825999999999997 119.39500000000001 13.369999999999996Q120.09200000000001 13.913999999999994 120.09200000000001 15.103999999999996Q120.09200000000001 16.225999999999996 119.39500000000001 16.769999999999996Q118.69800000000001 17.313999999999997 117.74600000000001 17.313999999999997Q116.76000000000002 17.313999999999997 116.08000000000001 16.769999999999996Q115.4 16.225999999999996 115.4 15.103999999999996Z M140.084 44.14H134.882V24.182H128.32V19.863999999999997H146.64600000000002V24.182H140.084Z M161.50400000000002 23.842Q158.58 23.842 156.897 26.035Q155.214 28.228 155.214 32.07Q155.214 35.912 156.829 38.037Q158.44400000000002 40.162 161.708 40.162Q163.306 40.162 164.78500000000003 39.788Q166.264 39.414 167.76000000000002 38.836V43.256Q164.87 44.480000000000004 161.13 44.480000000000004Q157.25400000000002 44.480000000000004 154.755 42.93300000000001Q152.256 41.386 151.049 38.581Q149.842 35.775999999999996 149.842 32.036Q149.842 28.363999999999997 151.202 25.541999999999998Q152.562 22.72 155.18 21.122Q157.798 19.523999999999997 161.572 19.523999999999997Q163.37400000000002 19.523999999999997 165.22700000000003 19.915Q167.08 20.305999999999997 168.746 21.122L167.04600000000002 25.27Q165.822 24.692 164.445 24.267Q163.068 23.842 161.50400000000002 23.842ZM164.496 48.526Q164.496 50.26 163.238 51.28Q161.98000000000002 52.3 159.73600000000002 52.3Q158.818 52.3 158.07000000000002 52.181Q157.32200000000003 52.062 156.846 51.892V49.206Q157.83200000000002 49.512 158.88600000000002 49.512Q160.68800000000002 49.512 160.68800000000002 48.322Q160.68800000000002 47.642 160.00800000000004 47.268Q159.32800000000003 46.894 158.03600000000003 46.69L159.22600000000003 44.14H161.94600000000003L161.436 45.466Q162.15 45.636 162.86400000000003 45.976Q163.57800000000003 46.316 164.03700000000003 46.928Q164.496 47.54 164.496 48.526Z"/>
|
||||
<path fill="#105bbe" d="M187.514 44.14H173.54000000000002V19.863999999999997H187.514V24.08H178.674V29.418H186.90200000000002V33.634H178.674V39.89H187.514Z M203.52800000000002 44.14 197.68 25.099999999999998H197.544Q197.57800000000003 25.779999999999998 197.64600000000002 27.14Q197.71400000000003 28.5 197.78200000000004 30.047Q197.85000000000002 31.594 197.85000000000002 32.852000000000004V44.14H193.26000000000002V19.863999999999997H200.264L206.01000000000002 38.428H206.11200000000002L212.198 19.863999999999997H219.20200000000003V44.14H214.40800000000002V32.647999999999996Q214.40800000000002 31.491999999999997 214.459 29.996Q214.51000000000002 28.5 214.56100000000004 27.157Q214.61200000000002 25.814 214.64600000000002 25.134H214.51000000000002L208.25400000000002 44.14Z"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 459 B After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 16 KiB |
@@ -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 → <link rel="icon">
|
||||
* src/app/apple-icon.png 180 px, saydamsız → iOS ana ekran
|
||||
* public/icons/icon-192.png PWA manifest
|
||||
* public/icons/icon-512.png PWA manifest
|
||||
* public/icons/maskable-512.png PWA manifest (purpose: maskable, %80 güvenli alan)
|
||||
* public/icons/maskable-512.png PWA manifest (purpose: maskable)
|
||||
*
|
||||
* public/brand/icon-maskable.svg (köşesiz, tam zemin) varsa maskable ve Apple ikonları ondan
|
||||
* üretilir; yoksa icon.png marka mavisi zemine %80 güvenli alanla yerleştirilir.
|
||||
*
|
||||
* Logoyu değiştirdikten sonra bu betiği yeniden çalıştırın.
|
||||
*/
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { access, mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import sharp from "sharp";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const SOURCE = path.join(ROOT, "public/brand/icon.png");
|
||||
const SOURCE_SVG = path.join(ROOT, "public/brand/icon.svg");
|
||||
const MASKABLE_SVG = path.join(ROOT, "public/brand/icon-maskable.svg");
|
||||
/** Saydamlık kabul etmeyen ikonlarda arka plan: marka mavisi (--primary, açık tema). */
|
||||
const BRAND_BG = "#105bbe";
|
||||
|
||||
@@ -60,19 +67,47 @@ function toIco(images: { size: number; data: Buffer }[]): Buffer {
|
||||
return Buffer.concat([header, ...entries, ...images.map((i) => i.data)]);
|
||||
}
|
||||
|
||||
async function exists(file: string) {
|
||||
return access(file).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (await exists(SOURCE_SVG)) {
|
||||
// Vektörden yüksek yoğunlukta render: küçük boyutlarda da keskin kenarlar.
|
||||
const png512 = await sharp(SOURCE_SVG, { density: 288 }).resize(512, 512).png().toBuffer();
|
||||
await writeFile(SOURCE, png512);
|
||||
console.log(`✓ public/brand/icon.png ← icon.svg (${png512.length} bayt)`);
|
||||
}
|
||||
const meta = await sharp(SOURCE).metadata();
|
||||
if (!meta.width || !meta.height || meta.width !== meta.height || meta.width < 512) {
|
||||
throw new Error(`${SOURCE} kare ve en az 512×512 olmalı (şu an ${meta.width}×${meta.height}).`);
|
||||
}
|
||||
await mkdir(path.join(ROOT, "public/icons"), { recursive: true });
|
||||
|
||||
const fullBleed = (await exists(MASKABLE_SVG))
|
||||
? (size: number) =>
|
||||
sharp(MASKABLE_SVG, { density: 288 })
|
||||
.resize(size, size)
|
||||
.flatten({ background: BRAND_BG })
|
||||
.png()
|
||||
.toBuffer()
|
||||
: null;
|
||||
|
||||
const out: [string, Buffer][] = [
|
||||
["src/app/icon.png", await png(192)],
|
||||
["src/app/apple-icon.png", await png(180, { flatten: true })],
|
||||
[
|
||||
"src/app/apple-icon.png",
|
||||
fullBleed ? await fullBleed(180) : await png(180, { flatten: true }),
|
||||
],
|
||||
["public/icons/icon-192.png", await png(192)],
|
||||
["public/icons/icon-512.png", await png(512)],
|
||||
["public/icons/maskable-512.png", await png(512, { padding: 0.1 })],
|
||||
[
|
||||
"public/icons/maskable-512.png",
|
||||
fullBleed ? await fullBleed(512) : await png(512, { padding: 0.1 }),
|
||||
],
|
||||
[
|
||||
"src/app/favicon.ico",
|
||||
toIco(await Promise.all([16, 32, 48].map(async (size) => ({ size, data: await png(size) })))),
|
||||
|
||||
@@ -1,10 +1,68 @@
|
||||
/**
|
||||
* Seed betiği — Faz 4'te doldurulacak (admin, örnek hane, aile üyeleri ve alan verisi).
|
||||
* Çalıştırma: npm run seed | Sıfırlayıp doldurma: npm run seed:reset
|
||||
* Seed betiği: npm run seed | Sıfırlayıp doldurma: npm run seed:reset
|
||||
*
|
||||
* Faz 3: yalnızca ilk admin (SEED_ADMIN_EMAIL / SEED_ADMIN_PASSWORD, kullanıcı adı "admin").
|
||||
* Faz 4: örnek kullanıcılar, hane ve aile üyeleri eklenecek.
|
||||
*
|
||||
* İdempotent: kullanıcı e-postasına göre kontrol eder, tekrar çalıştırınca kopya oluşturmaz.
|
||||
* Kullanıcılar Better Auth API'siyle oluşturulur (şifre doğru hashlenir; koleksiyona elle yazılmaz).
|
||||
* `server-only` modülleri Next dışında yükleyebilmek için --conditions=react-server ile çalışır.
|
||||
*/
|
||||
if (process.env.NODE_ENV === "production" && process.argv.includes("--reset")) {
|
||||
console.error("seed:reset production ortamında çalıştırılamaz.");
|
||||
process.exit(1);
|
||||
import { getAuth } from "@/lib/auth";
|
||||
import { getDb, getMongoClient } from "@/lib/db/mongo";
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
const reset = process.argv.includes("--reset");
|
||||
|
||||
async function main() {
|
||||
const e = env();
|
||||
if (reset && e.NODE_ENV === "production") {
|
||||
throw new Error("seed:reset production ortamında çalıştırılamaz.");
|
||||
}
|
||||
if (!e.SEED_ADMIN_EMAIL || !e.SEED_ADMIN_PASSWORD) {
|
||||
throw new Error("SEED_ADMIN_EMAIL ve SEED_ADMIN_PASSWORD tanımlı olmalı (.env.local).");
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
if (reset) {
|
||||
for (const name of ["user", "account", "session", "verification", "rateLimit"]) {
|
||||
await db.collection(name).deleteMany({});
|
||||
}
|
||||
console.log("• Kimlik doğrulama koleksiyonları temizlendi.");
|
||||
}
|
||||
|
||||
const email = e.SEED_ADMIN_EMAIL.toLowerCase();
|
||||
const existing = await db.collection("user").findOne({ email });
|
||||
if (existing) {
|
||||
console.log(`• Admin zaten var: ${email}`);
|
||||
} else {
|
||||
await getAuth().api.createUser({
|
||||
body: {
|
||||
email,
|
||||
password: e.SEED_ADMIN_PASSWORD,
|
||||
name: "Yönetici",
|
||||
role: "admin",
|
||||
data: { username: "admin" },
|
||||
},
|
||||
});
|
||||
console.log(`✓ Admin oluşturuldu: ${email} (kullanıcı adı: admin)`);
|
||||
}
|
||||
|
||||
if (e.NODE_ENV !== "production") {
|
||||
console.table([
|
||||
{
|
||||
rol: "admin",
|
||||
kullaniciAdi: "admin",
|
||||
eposta: email,
|
||||
sifre: "SEED_ADMIN_PASSWORD (.env.local)",
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Seed henüz uygulanmadı (Faz 4).");
|
||||
main()
|
||||
.catch((err: unknown) => {
|
||||
console.error(err instanceof Error ? err.message : err);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(() => getMongoClient().close());
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import type { Metadata } from "next";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { UsersTable } from "@/features/admin-users/components/users-table";
|
||||
import { MOCK_ADMIN_USERS } from "@/features/admin-users/mock";
|
||||
import { MOCK_SESSION_USER } from "@/features/auth/mock";
|
||||
import { listAdminUsers } from "@/features/admin-users/queries";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireRole } from "@/lib/dal";
|
||||
|
||||
export const metadata: Metadata = { title: tr.adminUsers.title };
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
// FAZ 3: const users = await listUsers() (Better Auth admin API)
|
||||
export default async function AdminUsersPage() {
|
||||
const [admin, users] = await Promise.all([requireRole("admin"), listAdminUsers()]);
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={tr.adminUsers.title} description={tr.adminUsers.description} />
|
||||
<UsersTable initialUsers={MOCK_ADMIN_USERS} currentUserId={MOCK_SESSION_USER.id} />
|
||||
<UsersTable users={users} currentUserId={admin.id} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { MOCK_SESSION_USER } from "@/features/auth/mock";
|
||||
import { requireRole } from "@/lib/dal";
|
||||
|
||||
/**
|
||||
* Yalnızca sistem admini. Admin olmayana 404 (sayfanın varlığını bile göstermez).
|
||||
* FAZ 3: `await requireRole("admin")` (lib/dal.ts) — sunucu tarafı gerçek kontrol.
|
||||
*/
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
if (MOCK_SESSION_USER.role !== "admin") notFound();
|
||||
/** Yalnızca sistem admini; diğerlerine 404. Sayfalar ayrıca kendi kontrolünü yapar. */
|
||||
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
await requireRole("admin");
|
||||
return children;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ import type { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { MOCK_SESSION_USER } from "@/features/auth/mock";
|
||||
import { SummaryCardsSkeleton } from "@/features/dashboard/components/summary-cards-skeleton";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireSession } from "@/lib/dal";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatMoney } from "@/shared/format";
|
||||
|
||||
@@ -51,8 +51,8 @@ async function SummaryCards() {
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const user = MOCK_SESSION_USER;
|
||||
export default async function DashboardPage() {
|
||||
const user = await requireSession();
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
|
||||
@@ -1,23 +1,35 @@
|
||||
import { Suspense } from "react";
|
||||
import { AppHeader } from "@/components/layout/app-header";
|
||||
import { AppShellFallback } from "@/components/layout/app-shell-fallback";
|
||||
import { AppSidebar } from "@/components/layout/app-sidebar";
|
||||
import { BottomNav } from "@/components/layout/bottom-nav";
|
||||
import { QuickAddProvider } from "@/components/layout/quick-add";
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { MOCK_SESSION_USER } from "@/features/auth/mock";
|
||||
import { MOCK_HOUSEHOLDS } from "@/features/households/mock";
|
||||
import { requireSession } from "@/lib/dal";
|
||||
import { APP_VERSION } from "@/lib/version";
|
||||
|
||||
/**
|
||||
* Oturum gerektiren alan: yan menü (masaüstü), üst bar, alt menü (mobil), hızlı ekle.
|
||||
* FAZ 3: `const session = await requireSession()` — oturum yoksa /login.
|
||||
* Oturum gerektiren alan. Oturum okuması istek anında yapılır (Cache Components), bu yüzden
|
||||
* kullanıcıya bağlı kabuk Suspense içinde render edilir. Her sayfa ayrıca kendi yetkisini
|
||||
* DAL üzerinden kontrol eder (layout istemci gezinmesinde yeniden çalışmaz).
|
||||
*/
|
||||
export default function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
const user = MOCK_SESSION_USER;
|
||||
return (
|
||||
<Suspense fallback={<AppShellFallback />}>
|
||||
<AuthenticatedShell>{children}</AuthenticatedShell>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
async function AuthenticatedShell({ children }: { children: React.ReactNode }) {
|
||||
const user = await requireSession();
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<QuickAddProvider>
|
||||
<AppSidebar isAdmin={user.role === "admin"} version={APP_VERSION} />
|
||||
<SidebarInset>
|
||||
{/* FAZ 5: kullanıcının üye olduğu haneler */}
|
||||
<AppHeader user={user} households={MOCK_HOUSEHOLDS} version={APP_VERSION} />
|
||||
{/* Mobilde alt menü yüksekliği kadar boşluk */}
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pb-24 md:p-6 md:pb-6">{children}</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<main className="flex w-full max-w-sm flex-col items-center gap-6">
|
||||
<Suspense fallback={null}>
|
||||
<RedirectIfSignedIn />
|
||||
</Suspense>
|
||||
<Logo priority className="scale-125" />
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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" } },
|
||||
);
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 11 KiB |
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<span className={cn("inline-flex items-center", className)}>
|
||||
<Image
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { tr } from "@/i18n/tr";
|
||||
|
||||
/** Oturum okunurken gösterilen kabuk iskeleti (yan menü + üst bar + içerik). */
|
||||
export function AppShellFallback() {
|
||||
return (
|
||||
<div role="status" aria-label={tr.common.loading} className="flex min-h-dvh w-full">
|
||||
<div className="hidden w-64 shrink-0 flex-col gap-3 border-r bg-sidebar p-3 md:flex">
|
||||
<Skeleton className="h-8 w-28" />
|
||||
{Array.from({ length: 8 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-7 w-full" />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex h-14 items-center gap-2 border-b px-4">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="ml-auto size-8 rounded-full" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 p-4 md:p-6">
|
||||
<Skeleton className="h-8 w-56" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
</Badge>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" onSelect={logout}>
|
||||
<DropdownMenuItem variant="destructive" onSelect={() => void logout()}>
|
||||
<LogOut />
|
||||
{tr.userMenu.logout}
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -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<never> {
|
||||
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<Awaited<ReturnType<typeof adminContext>>>;
|
||||
|
||||
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<number> {
|
||||
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<ActionResult> {
|
||||
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<ActionResult> {
|
||||
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<ActionResult> {
|
||||
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<ActionResult> {
|
||||
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<ActionResult> {
|
||||
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<ActionResult> {
|
||||
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<ActionResult> {
|
||||
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);
|
||||
}
|
||||
@@ -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<DialogState>(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<ActionResult> {
|
||||
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<ActionResult>, success: string): Promise<ActionResult> {
|
||||
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<ActionResult> {
|
||||
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<ActionResult> {
|
||||
toast.success(t.toast.passwordSet(user.name));
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
async function banUser(user: AdminUser, { reason }: { reason?: string }): Promise<ActionResult> {
|
||||
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<ActionResult> {
|
||||
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,
|
||||
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
@@ -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<AdminUser[]> {
|
||||
await requireRole("admin");
|
||||
const result = await getAuth().api.listUsers({
|
||||
headers: await headers(),
|
||||
query: { limit: 1000, sortBy: "createdAt", sortDirection: "desc" },
|
||||
});
|
||||
return result.users.map(toAdminUser);
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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()],
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<Headers> {
|
||||
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<unknown>, 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);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof createAuth>;
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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<SessionUser | null> => {
|
||||
// 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<SessionUser> {
|
||||
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<SessionUser> {
|
||||
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<SessionUser | null> {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return null;
|
||||
if (role && user.role !== role) return null;
|
||||
return user;
|
||||
}
|
||||
@@ -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<boolean> {
|
||||
try {
|
||||
await getDb().command({ ping: 1 }, { timeoutMS: timeoutMs });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -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<typeof mongoose> };
|
||||
|
||||
export function connectMongoose(): Promise<typeof mongoose> {
|
||||
globalForMongoose.__butcemMongoose ??= mongoose.connect(env().MONGODB_URI, {
|
||||
appName: "butcem-odm",
|
||||
maxPoolSize: 20,
|
||||
serverSelectionTimeoutMS: 5_000,
|
||||
autoIndex: env().NODE_ENV !== "production",
|
||||
});
|
||||
return globalForMongoose.__butcemMongoose;
|
||||
}
|
||||
@@ -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).*)",
|
||||
],
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
// Testlerde "server-only" paketinin yerine geçer (paket React Server ortamı dışında hata fırlatır).
|
||||
export {};
|
||||
@@ -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/**"],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||