16 Commits

Author SHA1 Message Date
suphicakir 7fda139199 feat: Faz 5 - haneler, roller ve veri izolasyonu
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:35:01 +03:00
suphicakir be9f2a5c3a docs: CHANGELOG, README ve PROMPT (0.5.0)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:35:01 +03:00
suphicakir b79951601c feat(db): migration mekanizması ve ilk index migration'ı
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:35:01 +03:00
suphicakir 3644e081b1 feat(households): hane seçici, ayarlar, admin hane yönetimi ve hane-yok sayfası
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:35:01 +03:00
suphicakir de91f2505a feat(households): hane rolleri, servis, DAL hane kapsamı ve denetim kaydı
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:35:01 +03:00
suphicakir 291a117b4f feat: Faz 4 - örnek test verisi
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:14:10 +03:00
suphicakir cd98694f0d docs: CHANGELOG ve README (0.4.0)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:14:10 +03:00
suphicakir 2b648975ae feat(seed): örnek aile kullanıcıları, haneler ve roller (idempotent)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:14:10 +03:00
suphicakir 38e2a7a26d feat(models): households ve householdMembers Mongoose modelleri
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:14:10 +03:00
suphicakir 3e11a34235 feat: Faz 3 - backend, kimlik doğrulama ve güvenlik
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:02:08 +03:00
suphicakir cdbd7a0446 docs: CHANGELOG ve README (0.3.0)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:02:00 +03:00
suphicakir fc3e0ae21b test: admin seed ve kimlik doğrulama entegrasyon testleri
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:02:00 +03:00
suphicakir 97e7ebe9aa feat(security): CSP ve güvenlik başlıkları, health DB ping
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:02:00 +03:00
suphicakir 2035f73e7d feat(admin): Server Action'larla kullanıcı yönetimi ve gerçek oturumlu sayfalar
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:02:00 +03:00
suphicakir 5191a5cce1 feat(auth): Better Auth, kullanıcı adı ile giriş, DAL ve proxy
- MongoDB/Mongoose bağlantıları, kayıt kapalı, admin + username plugin, rate limit
- DAL: getCurrentUser, requireSession, requireRole, getActionUser
- Kullanıcı adı normalizasyonu: İ/I/ı → i (tr-TR 'ADMIN' → 'admın' hatası)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:02:00 +03:00
suphicakir 381214c2cb feat(brand): BÜTÇEM logosu ve ikon seti
- public/brand: logo.svg, logo-dark.svg, icon.svg, icon-maskable.svg (yazı Noto Sans Bold yolları, OFL)
- npm run icons artık icon.svg / icon-maskable.svg'den üretir; maskable ve Apple ikonları köşesiz

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 01:44:59 +03:00
94 changed files with 3919 additions and 328 deletions
+2
View File
@@ -12,6 +12,8 @@ BETTER_AUTH_URL="http://localhost:3000"
# Faz 4 seed'i için ilk admin hesabı # Faz 4 seed'i için ilk admin hesabı
SEED_ADMIN_EMAIL="admin@butcem.local" SEED_ADMIN_EMAIL="admin@butcem.local"
SEED_ADMIN_PASSWORD="" SEED_ADMIN_PASSWORD=""
# Örnek aile kullanıcılarının ortak şifresi (yalnızca geliştirme; production'da örnek kullanıcı oluşturulmaz)
SEED_USER_PASSWORD=""
# Ek dosyalar (canlıda Docker volume: /app/uploads) # Ek dosyalar (canlıda Docker volume: /app/uploads)
UPLOAD_DIR="./uploads" UPLOAD_DIR="./uploads"
+74 -1
View File
@@ -6,6 +6,76 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır
## [Yayınlanmamış] ## [Yayınlanmamış]
## [0.5.0] - 2026-11-05
Faz 5: Haneler, hane içi roller ve veri izolasyonu.
### Eklendi
- Hane içi roller (`viewer < member < owner`) ve kuralları: son sahip düşürülemez veya çıkarılamaz.
- DAL: `getHouseholdContext` (istek başına tek okuma), `requireActiveHousehold(minRole)`, `getHouseholdActionContext(minRole)`, `requireHouseholdMember(householdId, minRole)`. Sistem admini için hane verisinde ayrıcalık yok.
- Hane servisi (`features/households/service.ts`, framework'ten bağımsız): her hane kapsamlı fonksiyon üyeliği kendisi de doğrular; hane oluşturma + ilk sahip + denetim kaydı tek MongoDB transaction'ında.
- Aktif hane seçici (üst bar): seçim sunucuda doğrulanır, `butcem.hh` cookie'sine yazılır; geçersiz/uydurma cookie yok sayılır. Tercih yoksa en yüksek rolün olduğu hane açılır.
- `/ayarlar`: hane adı, bütçe ayı başlangıç günü, taksit modu, günlük plan dağıtımı, "yalnızca ben" hesaplarının toplamlara dahil edilmesi, ek mağaza tasarruf eşiği; üye rolleri ve üye çıkarma (yalnızca sahip; diğer roller salt okunur).
- `/admin/haneler`: hane oluşturma (ilk sahip seçerek), kullanıcıyı haneye ekleme, hane içi rol değiştirme, üye çıkarma.
- `/hane-yok`: hiçbir haneye üye olmayan kullanıcılar için yönlendirme sayfası.
- Denetim kaydı (`auditLogs`): hane ayarları, üye ekleme/rol/çıkarma, hane oluşturma ve tüm admin kullanıcı işlemleri (oluşturma, düzenleme, rol, şifre sıfırlama, askı, silme).
- Migration mekanizması: `npm run migrate`, `scripts/migrations/NNN-*.ts`, uygulananlar `migrations` koleksiyonunda; `001-initial-indexes`. Seed önce migration'ları çalıştırır.
- Veri izolasyonu entegrasyon testleri (başka hanenin üyeliği/ayrıntısı/ayarları, rol yetersizliği, uydurma ve enjeksiyon denemesi kimlikler, hanesi olmayan admin, çapraz hane üyelik değişikliği, son sahip, transaction).
### Değişti
- İzleyici (viewer) rolünde Hızlı ekle gizlenir/pasifleşir.
- Kullanıcı silindiğinde tüm hane üyelikleri de temizlenir.
### Kaldırıldı
- Hane mock verisi.
## [0.4.0] - 2026-11-05
Faz 4: Örnek test verisi.
### Eklendi
- Seed (`npm run seed`): admin + 7 örnek kullanıcı (biri askıda, biri hanesi olmayan ikinci admin, ikisi e-postasız ve kullanıcı adıyla giriş yapan), 2 hane ("Çakır Ailesi", "Annemler") ve hane içi roller (owner / member / viewer; bir kullanıcı iki hanede farklı rollerle).
- Seed idempotenttir (kullanıcı adı, hane adı ve üyelik çiftine göre); kullanıcılar Better Auth API'siyle oluşturulur. Geliştirme hesapları konsolda tablo olarak gösterilir.
- `npm run seed:reset`: kimlik doğrulama ve hane koleksiyonlarını temizleyip yeniden doldurur; production'da reddedilir. Production'da seed yalnızca admini oluşturur.
- Mongoose modelleri: `households` (ayarlar: bütçe ayı başlangıcı, taksit ve günlük plan tercihleri, özel hesaplar, ek mağaza eşiği) ve `householdMembers` (benzersiz `{householdId, userId}` index'i).
- `SEED_USER_PASSWORD` ortam değişkeni (örnek kullanıcıların ortak geliştirme şifresi).
- Seed entegrasyon testleri (idempotentlik, roller, askı, e-postasız giriş, reset).
## [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 ## [0.2.0] - 2026-11-05
Faz 2: Frontend iskeleti (veriler Faz 3'e kadar mock). Faz 2: Frontend iskeleti (veriler Faz 3'e kadar mock).
@@ -50,6 +120,9 @@ Faz 1: Framework, klasör yapısı ve altyapı.
- `.nvmrc` (Node 26), `engines.node`, `typecheck`, `test`, `format`, `db:up`, `seed` scriptleri. - `.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`. - `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.5.0...HEAD
[0.5.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.4.0...v0.5.0
[0.4.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.3.0...v0.4.0
[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.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 [0.1.0]: https://gitea.ta1sph.info/suphicakir/butcem/releases/tag/v0.1.0
+29 -3
View File
@@ -26,10 +26,30 @@ cp .env.example .env.local
# 3) MongoDB (tek düğümlü replica set rs0, host portu 27019) — sağlıklı olana kadar bekler # 3) MongoDB (tek düğümlü replica set rs0, host portu 27019) — sağlıklı olana kadar bekler
npm run db:up npm run db:up
# 4) Uygulama # 4) Örnek veri: admin + aile kullanıcıları + 2 hane (tekrar çalıştırmak güvenli)
npm run dev # http://localhost:3000 npm run seed
# 5) Uygulama
npm run dev # http://localhost:3000 → /login
``` ```
### Geliştirme hesapları (`npm run seed`)
| Kullanıcı adı | Rol | Hane(ler) | Not |
| ------------- | ----- | --------------------------------------- | ------------------------------- |
| `admin` | admin | Çakır Ailesi (owner) | şifre `SEED_ADMIN_PASSWORD` |
| `ayse` | user | Çakır Ailesi (owner), Annemler (viewer) | |
| `emre` | user | Çakır Ailesi (member) | e-postasız |
| `zeynep` | user | Çakır Ailesi (viewer) | e-postasız |
| `fatma` | user | Annemler (owner) | |
| `mehmet` | user | Annemler (member) | |
| `burak` | admin | — | hanesi yok → hane verisi görmez |
| `ozan` | user | Çakır Ailesi (member) | askıda, giriş yapamaz |
Admin dışındaki hesapların şifresi `SEED_USER_PASSWORD` (`.env.local`). Production'da yalnızca admin oluşturulur.
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 ## Scriptler
| Komut | Açıklama | | Komut | Açıklama |
@@ -43,9 +63,15 @@ npm run dev # http://localhost:3000
| `npm run db:up` / `db:down` | Lokal MongoDB'yi başlat / durdur | | `npm run db:up` / `db:down` | Lokal MongoDB'yi başlat / durdur |
| `npm run seed` / `seed:reset` | Örnek veri (Faz 4) | | `npm run seed` / `seed:reset` | Örnek veri (Faz 4) |
## Haneler ve yetki
- Tüm alan verisi bir **haneye** (`householdId`) aittir; kullanıcı yalnızca üyesi olduğu hanelerin verisini görür. Sistem admini de bu kurala tabidir.
- Hane içi roller: **sahip** (ayarlar, üyeler), **üye** (veri girer), **izleyici** (yalnızca görüntüler).
- Her sayfa, Server Action ve API, hane yetkisini `src/lib/dal.ts` üzerinden sunucuda doğrular; servisler de üyeliği ayrıca kontrol eder.
## Marka dosyaları ## 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ı ## Docker imajı
+1
View File
@@ -317,6 +317,7 @@ Aşağıdakileri varsayılan olarak kabul ettim. Faz 0'da yalnızca bunlardan em
- **Değerleme:** varlıklar ve aynî alacaklar **alış** fiyatıyla, aynî borçlar **satış** fiyatıyla değerlenir. Tek fiyat girilirse ikisine de yazılır. - **Değerleme:** varlıklar ve aynî alacaklar **alış** fiyatıyla, aynî borçlar **satış** fiyatıyla değerlenir. Tek fiyat girilirse ikisine de yazılır.
- **URL'ler:** ekran yolları Türkçe ASCII (`/islemler`, `/butce`); kod, koleksiyon adları ve `/api/v1` İngilizce. - **URL'ler:** ekran yolları Türkçe ASCII (`/islemler`, `/butce`); kod, koleksiyon adları ve `/api/v1` İngilizce.
- **Canlı domain:** `butcem.ta1sph.website` (Gitea `gitea.ta1sph.info` üzerinde; fark kasıtlı). - **Canlı domain:** `butcem.ta1sph.website` (Gitea `gitea.ta1sph.info` üzerinde; fark kasıtlı).
- **Aktif hane varsayılanı:** tercih (cookie) yoksa kullanıcının en yüksek rolde olduğu hane açılır.
- **Lokal MongoDB portu:** host'ta `27019` (27017 ve 27018 geliştirme makinesinde dolu). `BUTCEM_MONGO_PORT` ile değiştirilebilir. - **Lokal MongoDB portu:** host'ta `27019` (27017 ve 27018 geliştirme makinesinde dolu). `BUTCEM_MONGO_PORT` ile değiştirilebilir.
--- ---
+45
View File
@@ -1,5 +1,47 @@
import type { NextConfig } from "next"; 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 = { const nextConfig: NextConfig = {
// Docker imajı için yalnızca gerekli dosyaları .next/standalone altına kopyala. // Docker imajı için yalnızca gerekli dosyaları .next/standalone altına kopyala.
output: "standalone", output: "standalone",
@@ -17,6 +59,9 @@ const nextConfig: NextConfig = {
}, },
}, },
}, },
async headers() {
return [{ source: "/:path*", headers: securityHeaders }];
},
}; };
export default nextConfig; export default nextConfig;
+6 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "butcem", "name": "butcem",
"version": "0.2.0", "version": "0.5.0",
"private": true, "private": true,
"description": "BÜTÇEM — aile bütçe, cari, birikim, ev stoğu ve akıllı alışveriş uygulaması", "description": "BÜTÇEM — aile bütçe, cari, birikim, ev stoğu ve akıllı alışveriş uygulaması",
"engines": { "engines": {
@@ -18,9 +18,11 @@
"format:check": "prettier --check .", "format:check": "prettier --check .",
"db:up": "docker compose up -d --wait", "db:up": "docker compose up -d --wait",
"db:down": "docker compose down", "db:down": "docker compose down",
"seed": "tsx --env-file=.env.local scripts/seed.ts", "seed": "tsx --conditions=react-server --env-file=.env.local scripts/seed.ts",
"seed:reset": "tsx --env-file=.env.local scripts/seed.ts --reset", "seed:reset": "tsx --conditions=react-server --env-file=.env.local scripts/seed.ts --reset",
"icons": "tsx scripts/generate-icons.ts" "icons": "tsx scripts/generate-icons.ts",
"test:integration": "vitest run --config vitest.integration.config.mts",
"migrate": "tsx --conditions=react-server --env-file=.env.local scripts/migrate.ts"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^7.0.2", "@hookform/resolvers": "^7.0.2",
+10
View File
@@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 22 KiB

+10
View File
@@ -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

+13 -4
View File
@@ -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" viewBox="0 0 227 64" width="227" height="64" role="img" aria-label="BÜTÇEM">
<svg xmlns="http://www.w3.org/2000/svg" width="240" height="64" viewBox="0 0 240 64" role="img" aria-label="BÜTÇEM"> <!-- BÜTÇEM logosu (koyu tema). Yazı Noto Sans Bold (OFL) yollarıdır; font gerektirmez. -->
<rect x="1" y="1" width="238" height="62" rx="12" fill="none" stroke="#6bb9f8" stroke-width="2" stroke-dasharray="6 4"/> <g transform="scale(0.125)">
<text x="120" y="40" text-anchor="middle" font-family="sans-serif" font-size="26" font-weight="700" fill="#6bb9f8">BÜTÇEM</text> <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> </svg>

Before

Width:  |  Height:  |  Size: 457 B

After

Width:  |  Height:  |  Size: 5.4 KiB

+13 -4
View File
@@ -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" viewBox="0 0 227 64" width="227" height="64" role="img" aria-label="BÜTÇEM">
<svg xmlns="http://www.w3.org/2000/svg" width="240" height="64" viewBox="0 0 240 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. -->
<rect x="1" y="1" width="238" height="62" rx="12" fill="none" stroke="#105bbe" stroke-width="2" stroke-dasharray="6 4"/> <g transform="scale(0.125)">
<text x="120" y="40" text-anchor="middle" font-family="sans-serif" font-size="26" font-weight="700" fill="#105bbe">BÜTÇEM</text> <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> </svg>

Before

Width:  |  Height:  |  Size: 459 B

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 16 KiB

+40 -5
View File
@@ -1,23 +1,30 @@
/** /**
* Uygulama ikonlarını logodan üretir: npm run icons * 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): * Çı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/favicon.ico 16/32/48 px (PNG gömülü ICO)
* src/app/icon.png 192 px → <link rel="icon"> * src/app/icon.png 192 px → <link rel="icon">
* src/app/apple-icon.png 180 px, saydamsız → iOS ana ekran * src/app/apple-icon.png 180 px, saydamsız → iOS ana ekran
* public/icons/icon-192.png PWA manifest * public/icons/icon-192.png PWA manifest
* public/icons/icon-512.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. * 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 path from "node:path";
import sharp from "sharp"; import sharp from "sharp";
const ROOT = process.cwd(); const ROOT = process.cwd();
const SOURCE = path.join(ROOT, "public/brand/icon.png"); 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). */ /** Saydamlık kabul etmeyen ikonlarda arka plan: marka mavisi (--primary, açık tema). */
const BRAND_BG = "#105bbe"; 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)]); return Buffer.concat([header, ...entries, ...images.map((i) => i.data)]);
} }
async function exists(file: string) {
return access(file).then(
() => true,
() => false,
);
}
async function main() { 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(); const meta = await sharp(SOURCE).metadata();
if (!meta.width || !meta.height || meta.width !== meta.height || meta.width < 512) { 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}).`); 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 }); 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][] = [ const out: [string, Buffer][] = [
["src/app/icon.png", await png(192)], ["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-192.png", await png(192)],
["public/icons/icon-512.png", await png(512)], ["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", "src/app/favicon.ico",
toIco(await Promise.all([16, 32, 48].map(async (size) => ({ size, data: await png(size) })))), toIco(await Promise.all([16, 32, 48].map(async (size) => ({ size, data: await png(size) })))),
+17
View File
@@ -0,0 +1,17 @@
/**
* Migration'ları uygular: npm run migrate (canlıda deploy sonrası; bkz. docs/DEPLOY.md, Faz 15).
* `server-only` modülleri Next dışında yükleyebilmek için --conditions=react-server ile çalışır.
*/
import mongoose from "mongoose";
import { getMongoClient } from "@/lib/db/mongo";
import { runMigrations } from "./migrations";
runMigrations()
.catch((err: unknown) => {
console.error(err instanceof Error ? err.message : err);
process.exitCode = 1;
})
.finally(async () => {
await mongoose.disconnect();
await getMongoClient().close();
});
View File
+15
View File
@@ -0,0 +1,15 @@
import { AuditLogModel } from "@/models/audit-log";
import { HouseholdModel } from "@/models/household";
import { HouseholdMemberModel } from "@/models/household-member";
import type { Migration } from "./types";
/** Hane, üyelik ve denetim kaydı index'leri (Mongoose şemalarındaki tanımlarla). */
export const migration: Migration = {
name: "001-initial-indexes",
description: "households, householdMembers ve auditLogs index'leri",
async up() {
await HouseholdModel.createIndexes();
await HouseholdMemberModel.createIndexes();
await AuditLogModel.createIndexes();
},
};
+35
View File
@@ -0,0 +1,35 @@
import { getDb } from "@/lib/db/mongo";
import { connectMongoose } from "@/lib/db/mongoose";
import { migration as m001 } from "./001-initial-indexes";
import type { Migration } from "./types";
/** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */
export const MIGRATIONS: Migration[] = [m001];
const COLLECTION = "migrations";
/**
* Uygulanmamış migration'ları sırayla çalıştırır; her biri bir kez (`migrations` koleksiyonu).
* Aynı anda iki çalıştırma olursa benzersiz `name` index'i ikinci kaydı reddeder.
*/
export async function runMigrations(log: (line: string) => void = console.log): Promise<string[]> {
const db = getDb();
await connectMongoose();
const col = db.collection<{ name: string; appliedAt: Date; durationMs: number }>(COLLECTION);
await col.createIndex({ name: 1 }, { unique: true });
const applied = new Set(
(await col.find({}, { projection: { name: 1 } }).toArray()).map((d) => d.name),
);
const ran: string[] = [];
for (const m of MIGRATIONS) {
if (applied.has(m.name)) continue;
const started = Date.now();
await m.up(db);
await col.insertOne({ name: m.name, appliedAt: new Date(), durationMs: Date.now() - started });
ran.push(m.name);
log(`✓ Migration: ${m.name} — ${m.description}`);
}
if (!ran.length) log("• Bekleyen migration yok.");
return ran;
}
+8
View File
@@ -0,0 +1,8 @@
import type { Db } from "mongodb";
/** Bir migration: adı dosya adıyla aynı olmalı (ör. "001-initial-indexes"). Geri alma yok; ileri yönlü. */
export type Migration = {
name: string;
description: string;
up: (db: Db) => Promise<void>;
};
+30 -6
View File
@@ -1,10 +1,34 @@
/** /**
* Seed betiği — Faz 4'te doldurulacak (admin, örnek hane, aile üyeleri ve alan verisi). * Seed: npm run seed | Sıfırlayıp doldurma: npm run seed:reset (production'da reddedilir)
* Çalıştırma: npm run seed | Sıfırlayıp doldurma: npm run seed:reset *
* Oluşturulanlar: admin (SEED_ADMIN_*), 7 örnek kullanıcı (biri askıda, biri ikinci admin),
* 2 hane ve hane içi rolleri. Ayrıntı: scripts/seed/fixtures.ts
* `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")) { import { getMongoClient } from "@/lib/db/mongo";
console.error("seed:reset production ortamında çalıştırılamaz."); import { env } from "@/lib/env";
process.exit(1); import { closeSeedConnections, runSeed } from "./seed/run";
async function main() {
const summary = await runSeed({ reset: process.argv.includes("--reset") });
const { users, households, memberships } = summary.created;
console.log(`\nOluşturulan: ${users} kullanıcı, ${households} hane, ${memberships} üyelik.`);
// Hesap tablosu yalnızca geliştirme ortamında gösterilir.
if (env().NODE_ENV !== "production" && summary.accounts.length) {
console.log(
"\nGeliştirme hesapları (şifreler: admin → SEED_ADMIN_PASSWORD, diğerleri → SEED_USER_PASSWORD):",
);
console.table(summary.accounts);
}
} }
console.log("Seed henüz uygulanmadı (Faz 4)."); main()
.catch((err: unknown) => {
console.error(err instanceof Error ? err.message : err);
process.exitCode = 1;
})
.finally(async () => {
await closeSeedConnections();
await getMongoClient().close();
});
+58
View File
@@ -0,0 +1,58 @@
import type { HouseholdRole } from "@/features/households/types";
import type { SystemRole } from "@/shared/schemas/common";
/** Örnek (geliştirme) kullanıcıları. E-postası olmayanlar kullanıcı adıyla giriş yapar. */
export type SeedUser = {
username: string;
name: string;
email?: string;
role: SystemRole;
banReason?: string;
};
export const SEED_USERS: SeedUser[] = [
{ username: "ayse", name: "Ayşe Çakır", email: "ayse@butcem.local", role: "user" },
{ username: "emre", name: "Emre Çakır", role: "user" },
{ username: "zeynep", name: "Zeynep Çakır", role: "user" },
{ username: "fatma", name: "Fatma Demir", email: "fatma@butcem.local", role: "user" },
{ username: "mehmet", name: "Mehmet Demir", email: "mehmet@butcem.local", role: "user" },
// İkinci admin: hiçbir haneye üye değil → hane verisini göremez (Faz 0 kararı).
{ username: "burak", name: "Burak Öztürk", email: "burak@butcem.local", role: "admin" },
{
username: "ozan",
name: "Ozan Kaya",
email: "ozan@butcem.local",
role: "user",
banReason: "Ev arkadaşlığı sona erdi; erişim kaldırıldı.",
},
];
/** Seed admin'in kullanıcı adı (SEED_ADMIN_EMAIL ile oluşturulur). */
export const ADMIN_USERNAME = "admin";
export type SeedHousehold = {
name: string;
members: { username: string; role: HouseholdRole }[];
};
export const SEED_HOUSEHOLDS: SeedHousehold[] = [
{
name: "Çakır Ailesi",
members: [
{ username: ADMIN_USERNAME, role: "owner" },
{ username: "ayse", role: "owner" },
{ username: "emre", role: "member" },
{ username: "zeynep", role: "viewer" },
{ username: "ozan", role: "member" },
],
},
{
name: "Annemler",
members: [
{ username: "fatma", role: "owner" },
{ username: "mehmet", role: "member" },
// Bir kullanıcı birden çok haneye üye olabilir.
{ username: "ayse", role: "viewer" },
],
},
];
+84
View File
@@ -0,0 +1,84 @@
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 { HouseholdModel } from "@/models/household";
import { HouseholdMemberModel } from "@/models/household-member";
import { closeSeedConnections, runSeed } from "./run";
import { SEED_HOUSEHOLDS, SEED_USERS } from "./fixtures";
const silent = () => {};
beforeAll(async () => {
expect(getDb().databaseName).toBe("butcem_test");
await getDb().dropDatabase();
});
afterAll(async () => {
await getDb().dropDatabase();
await closeSeedConnections();
await getMongoClient().close();
});
describe("seed", () => {
it("ilk çalıştırmada admin, örnek kullanıcılar, haneler ve üyelikleri oluşturur", async () => {
const summary = await runSeed({ log: silent });
const memberCount = SEED_HOUSEHOLDS.reduce((n, h) => n + h.members.length, 0);
expect(summary.created).toEqual({
users: SEED_USERS.length + 1,
households: SEED_HOUSEHOLDS.length,
memberships: memberCount,
});
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
expect(await HouseholdMemberModel.countDocuments()).toBe(memberCount);
});
it("idempotenttir: ikinci çalıştırma kopya oluşturmaz", async () => {
const summary = await runSeed({ log: silent });
expect(summary.created).toEqual({ users: 0, households: 0, memberships: 0 });
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
expect(await HouseholdModel.countDocuments()).toBe(SEED_HOUSEHOLDS.length);
});
it("roller ve askı durumu doğru", async () => {
const users = await getDb()
.collection("user")
.find({}, { projection: { username: 1, role: 1, banned: 1 } })
.toArray();
const by = new Map(users.map((u) => [u.username as string, u]));
expect(by.get("admin")?.role).toBe("admin");
expect(by.get("burak")?.role).toBe("admin");
expect(by.get("ozan")?.banned).toBe(true);
expect(users.filter((u) => u.banned)).toHaveLength(1);
// Bir kullanıcı birden çok hanede, farklı rollerle.
const ayse = by.get("ayse")!;
const roles = await HouseholdMemberModel.find({ userId: ayse._id }).lean();
expect(roles.map((m) => m.role).sort()).toEqual(["owner", "viewer"]);
// Hanesi olmayan admin.
expect(await HouseholdMemberModel.countDocuments({ userId: by.get("burak")!._id })).toBe(0);
});
it("e-postası olmayan kullanıcı kullanıcı adıyla girer; askıdaki kullanıcı giremez", async () => {
const api = getAuth().api;
const emre = await api.signInUsername({
body: { username: "emre", password: "Test-User-12345" },
});
expect(emre.user.name).toBe("Emre Çakır");
const error = await api
.signInUsername({ body: { username: "ozan", password: "Test-User-12345" } })
.then(
() => null,
(e: unknown) => e,
);
expect(authErrorCode(error)).toBe("BANNED_USER");
});
it("reset koleksiyonları temizleyip yeniden doldurur", async () => {
const summary = await runSeed({ reset: true, log: silent });
expect(summary.created.users).toBe(SEED_USERS.length + 1);
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
});
});
+201
View File
@@ -0,0 +1,201 @@
import mongoose, { Types } from "mongoose";
import { getAuth } from "@/lib/auth";
import { getDb } from "@/lib/db/mongo";
import { connectMongoose } from "@/lib/db/mongoose";
import { env } from "@/lib/env";
import { HouseholdModel } from "@/models/household";
import { HouseholdMemberModel } from "@/models/household-member";
import { storedEmail } from "@/shared/account-email";
import { runMigrations } from "../migrations";
import { ADMIN_USERNAME, SEED_HOUSEHOLDS, SEED_USERS, type SeedUser } from "./fixtures";
/**
* İdempotent seed. Kullanıcılar Better Auth API'siyle oluşturulur (şifreler doğru hashlenir);
* tekrar çalıştırınca kopya oluşturmaz (kullanıcı adı / hane adı / üyelik çiftine göre).
* Production'da yalnızca admin oluşturulur; örnek kullanıcı ve hane oluşturulmaz.
*/
export type SeedOptions = { reset?: boolean; log?: (line: string) => void };
export type SeedAccountRow = {
rol: string;
kullaniciAdi: string;
eposta: string;
durum: string;
haneler: string;
};
export type SeedSummary = {
created: { users: number; households: number; memberships: number };
accounts: SeedAccountRow[];
};
const AUTH_COLLECTIONS = ["user", "account", "session", "verification", "rateLimit"];
const DOMAIN_COLLECTIONS = ["households", "householdMembers", "auditLogs"];
type AuthApi = ReturnType<typeof getAuth>["api"];
async function findUserIdByUsername(username: string): Promise<string | null> {
const doc = await getDb()
.collection("user")
.findOne({ username }, { projection: { _id: 1 } });
return doc ? String(doc._id) : null;
}
/** Admin olarak giriş yapıp oturum başlıklarını döner (ban gibi oturum isteyen admin API'leri için). */
async function adminHeaders(api: AuthApi, password: string): Promise<Headers> {
const { headers } = await api.signInUsername({
body: { username: ADMIN_USERNAME, password },
returnHeaders: true,
});
const cookie = headers
.getSetCookie()
.map((c) => c.split(";")[0])
.join("; ");
return new Headers({ cookie });
}
export async function runSeed({
reset = false,
log = console.log,
}: SeedOptions = {}): Promise<SeedSummary> {
const e = env();
const isProd = e.NODE_ENV === "production";
if (reset && isProd) 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ı.");
}
if (!isProd && !e.SEED_USER_PASSWORD) {
throw new Error("SEED_USER_PASSWORD tanımlı olmalı (örnek kullanıcıların ortak şifresi).");
}
const api = getAuth().api;
const db = getDb();
await connectMongoose();
const summary: SeedSummary = {
created: { users: 0, households: 0, memberships: 0 },
accounts: [],
};
if (reset) {
for (const name of [...AUTH_COLLECTIONS, ...DOMAIN_COLLECTIONS]) {
await db.collection(name).deleteMany({});
}
log("• Kimlik doğrulama ve hane koleksiyonları temizlendi.");
}
// Index'ler ve şema değişiklikleri (production'da Mongoose autoIndex kapalı).
await runMigrations(log);
// 1) Admin
const adminEmail = e.SEED_ADMIN_EMAIL.toLowerCase();
let adminId = await findUserIdByUsername(ADMIN_USERNAME);
if (!adminId) {
const res = await api.createUser({
body: {
email: adminEmail,
password: e.SEED_ADMIN_PASSWORD,
name: "Yönetici",
role: "admin",
data: { username: ADMIN_USERNAME },
},
});
adminId = res.user.id;
summary.created.users++;
log(`✓ Admin oluşturuldu: ${adminEmail} (kullanıcı adı: ${ADMIN_USERNAME})`);
} else {
log(`• Admin zaten var: ${ADMIN_USERNAME}`);
}
if (isProd) {
log("• Production: örnek kullanıcı ve hane oluşturulmadı.");
return summary;
}
// 2) Örnek kullanıcılar
const ids = new Map<string, string>([[ADMIN_USERNAME, adminId]]);
const toBan: SeedUser[] = [];
for (const u of SEED_USERS) {
let id = await findUserIdByUsername(u.username);
if (!id) {
const res = await api.createUser({
body: {
email: storedEmail(u.username, u.email),
password: e.SEED_USER_PASSWORD!,
name: u.name,
role: u.role,
data: { username: u.username },
},
});
id = res.user.id;
summary.created.users++;
log(`✓ Kullanıcı: ${u.name} (@${u.username})`);
}
ids.set(u.username, id);
if (u.banReason) toBan.push(u);
}
// 3) Askıdaki kullanıcı(lar) — admin API'si oturum ister.
const pendingBans: { user: SeedUser; id: string }[] = [];
for (const u of toBan) {
const id = ids.get(u.username)!;
const doc = await db
.collection("user")
.findOne({ _id: new Types.ObjectId(id) }, { projection: { banned: 1 } });
if (!doc?.banned) pendingBans.push({ user: u, id });
}
if (pendingBans.length) {
const headers = await adminHeaders(api, e.SEED_ADMIN_PASSWORD);
for (const { user, id } of pendingBans) {
await api.banUser({ headers, body: { userId: id, banReason: user.banReason } });
log(`✓ Askıya alındı: @${user.username}`);
}
}
// 4) Haneler ve üyelikler
const memberships = new Map<string, string[]>();
const actor = new Types.ObjectId(adminId);
for (const h of SEED_HOUSEHOLDS) {
let household = await HouseholdModel.findOne({ name: h.name });
if (!household) {
household = await HouseholdModel.create({ name: h.name, createdBy: actor, updatedBy: actor });
summary.created.households++;
log(`✓ Hane: ${h.name}`);
}
for (const m of h.members) {
const userId = new Types.ObjectId(ids.get(m.username)!);
const result = await HouseholdMemberModel.updateOne(
{ householdId: household._id, userId },
{
$setOnInsert: { householdId: household._id, userId, createdBy: actor },
$set: { role: m.role, updatedBy: actor },
},
{ upsert: true },
);
if (result.upsertedCount) summary.created.memberships++;
memberships.set(m.username, [
...(memberships.get(m.username) ?? []),
`${h.name} (${m.role})`,
]);
}
}
// 5) Geliştirme hesapları tablosu
const all = [
{ username: ADMIN_USERNAME, name: "Yönetici", email: adminEmail, role: "admin" as const },
...SEED_USERS,
];
summary.accounts = all.map((u) => ({
rol: u.role,
kullaniciAdi: u.username,
eposta: u.email ?? "—",
durum: "banReason" in u && u.banReason ? "askıda" : "aktif",
haneler: (memberships.get(u.username) ?? ["—"]).join(", "),
}));
return summary;
}
/** Seed sonrası bağlantıları kapatır (CLI ve testler). */
export async function closeSeedConnections() {
await mongoose.disconnect();
}
+18
View File
@@ -0,0 +1,18 @@
import type { Metadata } from "next";
import { PageHeader } from "@/components/page-header";
import { HouseholdsAdmin } from "@/features/admin-households/components/households-admin";
import { getAdminHouseholdsData } from "@/features/admin-households/queries";
import { tr } from "@/i18n/tr";
import { requireRole } from "@/lib/dal";
export const metadata: Metadata = { title: tr.adminHouseholds.title };
export default async function AdminHouseholdsPage() {
const [admin, data] = await Promise.all([requireRole("admin"), getAdminHouseholdsData()]);
return (
<>
<PageHeader title={tr.adminHouseholds.title} description={tr.adminHouseholds.description} />
<HouseholdsAdmin households={data.households} users={data.users} currentUserId={admin.id} />
</>
);
}
+5 -5
View File
@@ -1,18 +1,18 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { PageHeader } from "@/components/page-header"; import { PageHeader } from "@/components/page-header";
import { UsersTable } from "@/features/admin-users/components/users-table"; import { UsersTable } from "@/features/admin-users/components/users-table";
import { MOCK_ADMIN_USERS } from "@/features/admin-users/mock"; import { listAdminUsers } from "@/features/admin-users/queries";
import { MOCK_SESSION_USER } from "@/features/auth/mock";
import { tr } from "@/i18n/tr"; import { tr } from "@/i18n/tr";
import { requireRole } from "@/lib/dal";
export const metadata: Metadata = { title: tr.adminUsers.title }; export const metadata: Metadata = { title: tr.adminUsers.title };
export default function AdminUsersPage() { export default async function AdminUsersPage() {
// FAZ 3: const users = await listUsers() (Better Auth admin API) const [admin, users] = await Promise.all([requireRole("admin"), listAdminUsers()]);
return ( return (
<> <>
<PageHeader title={tr.adminUsers.title} description={tr.adminUsers.description} /> <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} />
</> </>
); );
} }
+4 -8
View File
@@ -1,11 +1,7 @@
import { notFound } from "next/navigation"; import { requireRole } from "@/lib/dal";
import { MOCK_SESSION_USER } from "@/features/auth/mock";
/** /** Yalnızca sistem admini; diğerlerine 404. Sayfalar ayrıca kendi kontrolünü yapar. */
* Yalnızca sistem admini. Admin olmayana 404 (sayfanın varlığını bile göstermez). export default async function AdminLayout({ children }: { children: React.ReactNode }) {
* FAZ 3: `await requireRole("admin")` (lib/dal.ts) — sunucu tarafı gerçek kontrol. await requireRole("admin");
*/
export default function AdminLayout({ children }: { children: React.ReactNode }) {
if (MOCK_SESSION_USER.role !== "admin") notFound();
return children; return children;
} }
+50
View File
@@ -0,0 +1,50 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { HouseholdSettingsForm } from "@/features/households/components/household-settings-form";
import { SettingsMembers } from "@/features/households/components/settings-members";
import { getHouseholdDetail } from "@/features/households/service";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
export const metadata: Metadata = { title: tr.settings.title };
export default async function SettingsPage() {
const { user, household } = await requireActiveHousehold("viewer");
const detail = await getHouseholdDetail(user.id, household.id);
if (!detail) notFound();
const isOwner = household.role === "owner";
const t = tr.settings;
return (
<>
<PageHeader title={t.title} description={t.description} />
<div className="grid gap-4 lg:grid-cols-2 lg:items-start">
<Card>
<CardHeader>
<CardTitle>{t.household}</CardTitle>
<CardDescription>{isOwner ? t.householdDescription : t.ownerOnly}</CardDescription>
</CardHeader>
<CardContent>
<HouseholdSettingsForm settings={detail.settings} canEdit={isOwner} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t.members}</CardTitle>
<CardDescription>{t.membersDescription}</CardDescription>
</CardHeader>
<CardContent>
<SettingsMembers
members={detail.members}
householdName={detail.name}
currentUserId={user.id}
canManage={isOwner}
/>
</CardContent>
</Card>
</div>
</>
);
}
+4 -4
View File
@@ -2,9 +2,9 @@ import type { Metadata } from "next";
import { Suspense } from "react"; import { Suspense } from "react";
import { PageHeader } from "@/components/page-header"; import { PageHeader } from "@/components/page-header";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; 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 { SummaryCardsSkeleton } from "@/features/dashboard/components/summary-cards-skeleton";
import { tr } from "@/i18n/tr"; import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { formatMoney } from "@/shared/format"; import { formatMoney } from "@/shared/format";
@@ -51,13 +51,13 @@ async function SummaryCards() {
); );
} }
export default function DashboardPage() { export default async function DashboardPage() {
const user = MOCK_SESSION_USER; const { user, household } = await requireActiveHousehold("viewer");
return ( return (
<> <>
<PageHeader <PageHeader
title={tr.dashboard.welcome(user.name.split(" ")[0] ?? user.name)} title={tr.dashboard.welcome(user.name.split(" ")[0] ?? user.name)}
description={tr.dashboard.welcomeDescription} description={`${household.name} · ${tr.dashboard.welcomeDescription}`}
/> />
<Suspense fallback={<SummaryCardsSkeleton />}> <Suspense fallback={<SummaryCardsSkeleton />}>
<SummaryCards /> <SummaryCards />
+39
View File
@@ -0,0 +1,39 @@
import { House } from "lucide-react";
import type { Metadata } from "next";
import Link from "next/link";
import { redirect } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { tr } from "@/i18n/tr";
import { getHouseholdContext } from "@/lib/dal";
export const metadata: Metadata = { title: tr.households.noHousehold.title };
/** Hiçbir haneye üye olmayan kullanıcılar (ör. hanesi olmayan admin) buraya yönlenir. */
export default async function NoHouseholdPage() {
const ctx = await getHouseholdContext();
if (!ctx) redirect("/login");
if (ctx.household) redirect("/dashboard");
const t = tr.households.noHousehold;
return (
<div className="flex flex-1 items-center justify-center">
<Card className="max-w-md">
<CardHeader>
<span className="mb-2 flex size-12 items-center justify-center rounded-full bg-primary/10 text-primary">
<House className="size-6" />
</span>
<CardTitle className="text-xl">{t.title}</CardTitle>
<CardDescription>{t.description}</CardDescription>
</CardHeader>
{ctx.user.role === "admin" ? (
<CardContent className="flex flex-col items-start gap-3">
<p className="text-sm text-muted-foreground">{t.adminHint}</p>
<Button asChild>
<Link href="/admin/haneler">{t.adminLink}</Link>
</Button>
</CardContent>
) : null}
</Card>
</div>
);
}
+28 -7
View File
@@ -1,28 +1,49 @@
import { redirect } from "next/navigation";
import { Suspense } from "react";
import { AppHeader } from "@/components/layout/app-header"; import { AppHeader } from "@/components/layout/app-header";
import { AppShellFallback } from "@/components/layout/app-shell-fallback";
import { AppSidebar } from "@/components/layout/app-sidebar"; import { AppSidebar } from "@/components/layout/app-sidebar";
import { BottomNav } from "@/components/layout/bottom-nav"; import { BottomNav } from "@/components/layout/bottom-nav";
import { QuickAddProvider } from "@/components/layout/quick-add"; import { QuickAddProvider } from "@/components/layout/quick-add";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { MOCK_SESSION_USER } from "@/features/auth/mock"; import { hasHouseholdRole } from "@/features/households/roles";
import { MOCK_HOUSEHOLDS } from "@/features/households/mock"; import { getHouseholdContext } from "@/lib/dal";
import { APP_VERSION } from "@/lib/version"; import { APP_VERSION } from "@/lib/version";
/** /**
* Oturum gerektiren alan: yan menü (masaüstü), üst bar, alt menü (mobil), hızlı ekle. * Oturum gerektiren alan. Oturum okuması istek anında yapılır (Cache Components), bu yüzden
* FAZ 3: `const session = await requireSession()` — oturum yoksa /login. * 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 }) { 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 ctx = await getHouseholdContext();
if (!ctx) redirect("/login");
const { user, household, memberships } = ctx;
const canWrite = household ? hasHouseholdRole(household.role, "member") : false;
return ( return (
<SidebarProvider> <SidebarProvider>
<QuickAddProvider> <QuickAddProvider>
<AppSidebar isAdmin={user.role === "admin"} version={APP_VERSION} /> <AppSidebar isAdmin={user.role === "admin"} version={APP_VERSION} />
<SidebarInset> <SidebarInset>
<AppHeader user={user} households={MOCK_HOUSEHOLDS} version={APP_VERSION} /> <AppHeader
user={user}
households={memberships}
activeHouseholdId={household?.id}
canWrite={canWrite}
version={APP_VERSION}
/>
{/* Mobilde alt menü yüksekliği kadar boşluk */} {/* 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> <div className="flex flex-1 flex-col gap-4 p-4 pb-24 md:p-6 md:pb-6">{children}</div>
</SidebarInset> </SidebarInset>
<BottomNav /> <BottomNav canWrite={canWrite} />
</QuickAddProvider> </QuickAddProvider>
</SidebarProvider> </SidebarProvider>
); );
+17 -3
View File
@@ -10,6 +10,8 @@ import { Button } from "@/components/ui/button";
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"; import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { tr } from "@/i18n/tr"; 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"; import { loginSchema, type LoginInput } from "@/shared/schemas/auth";
export function LoginForm() { export function LoginForm() {
@@ -20,11 +22,23 @@ export function LoginForm() {
defaultValues: { identifier: "", password: "" }, defaultValues: { identifier: "", password: "" },
}); });
async function onSubmit(_values: LoginInput) { async function onSubmit({ identifier, password }: LoginInput) {
setFormError(null); setFormError(null);
// FAZ 3: looksLikeEmail(identifier) ? authClient.signIn.email : authClient.signIn.username const value = identifier.trim();
await new Promise((r) => setTimeout(r, 400)); 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.replace("/dashboard");
router.refresh();
} }
const submitting = form.formState.isSubmitting; const submitting = form.formState.isSubmitting;
+13
View File
@@ -1,14 +1,27 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { Suspense } from "react";
import { Logo } from "@/components/brand/logo"; import { Logo } from "@/components/brand/logo";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { tr } from "@/i18n/tr"; import { tr } from "@/i18n/tr";
import { getCurrentUser } from "@/lib/dal";
import { LoginForm } from "./login-form"; import { LoginForm } from "./login-form";
export const metadata: Metadata = { title: tr.auth.loginTitle }; 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() { export default function LoginPage() {
return ( return (
<main className="flex w-full max-w-sm flex-col items-center gap-6"> <main className="flex w-full max-w-sm flex-col items-center gap-6">
<Suspense fallback={null}>
<RedirectIfSignedIn />
</Suspense>
<Logo priority className="scale-125" /> <Logo priority className="scale-125" />
<Card className="w-full"> <Card className="w-full">
<CardHeader> <CardHeader>
+11
View File
@@ -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);
}
+7 -4
View File
@@ -1,20 +1,23 @@
import { connection } from "next/server"; import { connection } from "next/server";
import { pingDb } from "@/lib/db/mongo";
import { APP_COMMIT, APP_VERSION } from "@/lib/version"; import { APP_COMMIT, APP_VERSION } from "@/lib/version";
/** /**
* Sağlık kontrolü (Dokploy / Docker HEALTHCHECK). * Sağlık kontrolü (Dokploy / Docker HEALTHCHECK). Veritabanına erişilemezse 503.
* Faz 3'te veritabanı ping'i eklenecek; DB erişilemezse 503 dönecek. * İç hata detayı döndürülmez.
*/ */
export async function GET() { export async function GET() {
// Cache Components açıkken GET handler'ı build'de prerender edilmesin. // Cache Components açıkken GET handler'ı build'de prerender edilmesin.
await connection(); await connection();
const dbOk = await pingDb();
return Response.json( return Response.json(
{ {
status: "ok", status: dbOk ? "ok" : "degraded",
db: dbOk ? "ok" : "unreachable",
version: APP_VERSION, version: APP_VERSION,
commit: APP_COMMIT, commit: APP_COMMIT,
time: new Date().toISOString(), time: new Date().toISOString(),
}, },
{ headers: { "Cache-Control": "no-store" } }, { status: dbOk ? 200 : 503, headers: { "Cache-Control": "no-store" } },
); );
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 11 KiB

+2 -2
View File
@@ -1,6 +1,6 @@
import { redirect } from "next/navigation"; 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() { export default function Home() {
redirect("/login"); redirect("/dashboard");
} }
+1 -1
View File
@@ -7,7 +7,7 @@ import { tr } from "@/i18n/tr";
* hydration uyumsuzluğu olmaz). Dosyalar public/brand/ altında. * hydration uyumsuzluğu olmaz). Dosyalar public/brand/ altında.
*/ */
export function Logo({ className, priority }: { className?: string; priority?: boolean }) { 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 ( return (
<span className={cn("inline-flex items-center", className)}> <span className={cn("inline-flex items-center", className)}>
<Image <Image
+7 -2
View File
@@ -11,18 +11,23 @@ import { UserMenu } from "./user-menu";
export function AppHeader({ export function AppHeader({
user, user,
households, households,
activeHouseholdId,
canWrite,
version, version,
}: { }: {
user: SessionUser; user: SessionUser;
households: HouseholdSummary[]; households: HouseholdSummary[];
activeHouseholdId: string | undefined;
/** Aktif hanede en az "member" mi? (İzleyici veri giremez.) */
canWrite: boolean;
version: string; version: string;
}) { }) {
return ( return (
<header className="sticky top-0 z-30 flex h-14 items-center gap-2 border-b bg-background/95 px-3 backdrop-blur supports-backdrop-filter:bg-background/80 md:px-4"> <header className="sticky top-0 z-30 flex h-14 items-center gap-2 border-b bg-background/95 px-3 backdrop-blur supports-backdrop-filter:bg-background/80 md:px-4">
<SidebarTrigger className="hidden md:inline-flex" aria-label={tr.nav.toggleSidebar} /> <SidebarTrigger className="hidden md:inline-flex" aria-label={tr.nav.toggleSidebar} />
<HouseholdSwitcher households={households} /> <HouseholdSwitcher households={households} activeId={activeHouseholdId} />
<div className="ml-auto flex items-center gap-1"> <div className="ml-auto flex items-center gap-1">
<QuickAddButton className="hidden md:inline-flex" /> {canWrite ? <QuickAddButton className="hidden md:inline-flex" /> : null}
<ThemeToggle /> <ThemeToggle />
<UserMenu user={user} version={version} /> <UserMenu user={user} version={version} />
</div> </div>
@@ -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>
);
}
+4 -2
View File
@@ -40,7 +40,7 @@ function BottomNavLink({ item, pathname }: { item: NavItem; pathname: string })
} }
/** Mobil alt gezinme çubuğu (md altı). */ /** Mobil alt gezinme çubuğu (md altı). */
export function BottomNav() { export function BottomNav({ canWrite }: { canWrite: boolean }) {
const pathname = usePathname(); const pathname = usePathname();
const { openQuickAdd } = useQuickAdd(); const { openQuickAdd } = useQuickAdd();
const { setOpenMobile } = useSidebar(); const { setOpenMobile } = useSidebar();
@@ -57,8 +57,10 @@ export function BottomNav() {
<button <button
type="button" type="button"
onClick={openQuickAdd} onClick={openQuickAdd}
disabled={!canWrite}
title={canWrite ? undefined : tr.households.viewerReadOnly}
aria-label={tr.quickAdd.button} aria-label={tr.quickAdd.button}
className="-mt-5 flex size-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg outline-none focus-visible:ring-4 focus-visible:ring-ring/50" className="-mt-5 flex size-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg outline-none focus-visible:ring-4 focus-visible:ring-ring/50 disabled:opacity-40"
> >
<Plus className="size-6" /> <Plus className="size-6" />
</button> </button>
+29 -7
View File
@@ -1,7 +1,8 @@
"use client"; "use client";
import { House } from "lucide-react"; import { House } from "lucide-react";
import { useState } from "react"; import { useTransition } from "react";
import { toast } from "sonner";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -9,17 +10,38 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { switchHouseholdAction } from "@/features/households/actions";
import type { HouseholdSummary } from "@/features/households/types"; import type { HouseholdSummary } from "@/features/households/types";
import { tr } from "@/i18n/tr"; import { tr } from "@/i18n/tr";
/** Üst bar aktif hane seçici. FAZ 5: seçim sunucuda (cookie) saklanacak. */ /** Üst bar aktif hane seçici. Seçim sunucuda doğrulanır ve cookie'ye yazılır. */
export function HouseholdSwitcher({ households }: { households: HouseholdSummary[] }) { export function HouseholdSwitcher({
const [value, setValue] = useState(households[0]?.id); households,
const current = households.find((h) => h.id === value); activeId,
}: {
households: HouseholdSummary[];
activeId: string | undefined;
}) {
const [pending, startTransition] = useTransition();
const current = households.find((h) => h.id === activeId);
if (households.length === 0) return null; if (households.length === 0) return null;
function onChange(id: string) {
if (id === activeId) return;
startTransition(async () => {
const result = await switchHouseholdAction(id);
if (result.ok) toast.success(tr.household.switched(result.data.name));
else toast.error(result.error);
});
}
return ( return (
<Select value={value} onValueChange={setValue}> <Select value={activeId} onValueChange={onChange} disabled={pending || households.length < 2}>
<SelectTrigger aria-label={tr.household.label} className="w-[min(46vw,13rem)]"> <SelectTrigger
aria-label={tr.household.label}
className="w-[min(46vw,13rem)]"
aria-busy={pending}
>
<House className="text-muted-foreground" /> <House className="text-muted-foreground" />
{/* Etiket children ile verilir: Radix aksi halde hydration'a kadar boş gösterir. */} {/* Etiket children ile verilir: Radix aksi halde hydration'a kadar boş gösterir. */}
<SelectValue placeholder={tr.household.select}>{current?.name}</SelectValue> <SelectValue placeholder={tr.household.select}>{current?.name}</SelectValue>
+1 -1
View File
@@ -25,7 +25,7 @@ export type NavItem = {
}; };
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */ /** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
export const CURRENT_PHASE = 2; export const CURRENT_PHASE = 5;
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE; export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
+9 -3
View File
@@ -15,6 +15,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import type { SessionUser } from "@/features/auth/types"; import type { SessionUser } from "@/features/auth/types";
import { authClient } from "@/lib/auth-client";
import { tr } from "@/i18n/tr"; import { tr } from "@/i18n/tr";
export function initials(name: string) { export function initials(name: string) {
@@ -30,10 +31,15 @@ export function initials(name: string) {
export function UserMenu({ user, version }: { user: SessionUser; version: string }) { export function UserMenu({ user, version }: { user: SessionUser; version: string }) {
const router = useRouter(); const router = useRouter();
function logout() { async function logout() {
// FAZ 3: authClient.signOut() sonrası yönlendirme. const { error } = await authClient.signOut();
if (error) {
toast.error(tr.common.genericError);
return;
}
toast.success(tr.userMenu.loggedOut); toast.success(tr.userMenu.loggedOut);
router.replace("/login"); router.replace("/login");
router.refresh();
} }
return ( return (
@@ -61,7 +67,7 @@ export function UserMenu({ user, version }: { user: SessionUser; version: string
</Badge> </Badge>
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onSelect={logout}> <DropdownMenuItem variant="destructive" onSelect={() => void logout()}>
<LogOut /> <LogOut />
{tr.userMenu.logout} {tr.userMenu.logout}
</DropdownMenuItem> </DropdownMenuItem>
+74
View File
@@ -0,0 +1,74 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import {
adminAddMember,
adminCreateHousehold,
adminRemoveMember,
adminSetMemberRole,
} from "@/features/households/service";
import { tr } from "@/i18n/tr";
import { fail, type ActionResult } from "@/lib/action-result";
import { getActionUser } from "@/lib/dal";
import {
addMemberSchema,
householdCreateSchema,
householdRoleSchema,
} from "@/shared/schemas/household";
/** Sistem admini: hane ve üyelik yönetimi. Hanenin finansal verisine dokunmaz. */
const PATH = "/admin/haneler";
const idSchema = z.string().regex(/^[a-f0-9]{24}$/i);
export async function createHouseholdAction(input: unknown): Promise<ActionResult<{ id: string }>> {
const admin = await getActionUser("admin");
if (!admin) return fail(tr.common.unauthorized);
const parsed = householdCreateSchema.safeParse(input);
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields);
const result = await adminCreateHousehold(admin.id, parsed.data);
if (result.ok) revalidatePath("/", "layout");
return result;
}
export async function addMemberAction(householdId: unknown, input: unknown): Promise<ActionResult> {
const admin = await getActionUser("admin");
if (!admin) return fail(tr.common.unauthorized);
const hid = idSchema.safeParse(householdId);
const parsed = addMemberSchema.safeParse(input);
if (!hid.success || !parsed.success) return fail(tr.adminUsers.errors.checkFields);
const result = await adminAddMember(admin.id, hid.data, parsed.data);
if (result.ok) revalidatePath("/", "layout");
return result;
}
export async function adminSetMemberRoleAction(
householdId: unknown,
userId: unknown,
role: unknown,
): Promise<ActionResult> {
const admin = await getActionUser("admin");
if (!admin) return fail(tr.common.unauthorized);
const hid = idSchema.safeParse(householdId);
const uid = idSchema.safeParse(userId);
const r = householdRoleSchema.safeParse(role);
if (!hid.success || !uid.success || !r.success) return fail(tr.common.genericError);
const result = await adminSetMemberRole(admin.id, hid.data, uid.data, r.data);
if (result.ok) revalidatePath(PATH);
return result;
}
export async function adminRemoveMemberAction(
householdId: unknown,
userId: unknown,
): Promise<ActionResult> {
const admin = await getActionUser("admin");
if (!admin) return fail(tr.common.unauthorized);
const hid = idSchema.safeParse(householdId);
const uid = idSchema.safeParse(userId);
if (!hid.success || !uid.success) return fail(tr.common.genericError);
const result = await adminRemoveMember(admin.id, hid.data, uid.data);
if (result.ok) revalidatePath("/", "layout");
return result;
}
@@ -0,0 +1,306 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2, Plus, UserPlus } from "lucide-react";
import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { toast } from "sonner";
import { ResponsiveDialog } from "@/components/responsive-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { MembersList } from "@/features/households/components/members-list";
import type { AdminHouseholdRow, UserOption } from "@/features/households/service";
import { HOUSEHOLD_ROLES } from "@/features/households/types";
import { tr } from "@/i18n/tr";
import {
addMemberSchema,
householdCreateSchema,
type AddMemberInput,
type HouseholdCreateInput,
} from "@/shared/schemas/household";
import {
addMemberAction,
adminRemoveMemberAction,
adminSetMemberRoleAction,
createHouseholdAction,
} from "../actions";
import { UserSelect } from "./user-select";
const t = tr.adminHouseholds;
function CreateHouseholdDialog({
open,
onOpenChange,
users,
}: {
open: boolean;
onOpenChange: (o: boolean) => void;
users: UserOption[];
}) {
const form = useForm<HouseholdCreateInput>({
resolver: zodResolver(householdCreateSchema),
defaultValues: { name: "", ownerUserId: "" },
});
useEffect(() => {
if (open) form.reset({ name: "", ownerUserId: "" });
}, [open, form]);
async function onSubmit(values: HouseholdCreateInput) {
const result = await createHouseholdAction(values);
if (result.ok) {
toast.success(t.created(values.name));
onOpenChange(false);
} else {
form.setError("root", { message: result.error });
}
}
return (
<ResponsiveDialog
open={open}
onOpenChange={onOpenChange}
title={t.createTitle}
description={t.createDescription}
footer={
<>
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
{tr.common.cancel}
</Button>
<Button type="submit" form="household-create" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? <Loader2 className="animate-spin" /> : null}
{tr.common.create}
</Button>
</>
}
>
<form id="household-create" onSubmit={form.handleSubmit(onSubmit)} noValidate>
<FieldGroup>
<Controller
name="name"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="hh-create-name">{t.name}</FieldLabel>
<Input {...field} id="hh-create-name" aria-invalid={fieldState.invalid} />
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
<Controller
name="ownerUserId"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="hh-create-owner">{t.owner}</FieldLabel>
<UserSelect
id="hh-create-owner"
users={users}
value={field.value}
onChange={field.onChange}
invalid={fieldState.invalid}
/>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
{form.formState.errors.root ? (
<FieldError>{form.formState.errors.root.message}</FieldError>
) : null}
</FieldGroup>
</form>
</ResponsiveDialog>
);
}
function AddMemberDialog({
household,
users,
onOpenChange,
}: {
household: AdminHouseholdRow | null;
users: UserOption[];
onOpenChange: (o: boolean) => void;
}) {
const form = useForm<AddMemberInput>({
resolver: zodResolver(addMemberSchema),
defaultValues: { userId: "", role: "member" },
});
useEffect(() => {
if (household) form.reset({ userId: "", role: "member" });
}, [household, form]);
async function onSubmit(values: AddMemberInput) {
if (!household) return;
const result = await addMemberAction(household.id, values);
if (result.ok) {
toast.success(t.memberAdded(users.find((u) => u.id === values.userId)?.name ?? ""));
onOpenChange(false);
} else {
form.setError("root", { message: result.error });
}
}
return (
<ResponsiveDialog
open={Boolean(household)}
onOpenChange={onOpenChange}
title={household ? t.addMemberTitle(household.name) : t.addMember}
description={t.addMemberDescription}
footer={
<>
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
{tr.common.cancel}
</Button>
<Button type="submit" form="household-add-member" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? <Loader2 className="animate-spin" /> : null}
{t.addMember}
</Button>
</>
}
>
<form id="household-add-member" onSubmit={form.handleSubmit(onSubmit)} noValidate>
<FieldGroup>
<Controller
name="userId"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="hh-member-user">{t.user}</FieldLabel>
<UserSelect
id="hh-member-user"
users={users}
value={field.value}
onChange={field.onChange}
invalid={fieldState.invalid}
/>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
<Controller
name="role"
control={form.control}
render={({ field }) => (
<Field>
<FieldLabel htmlFor="hh-member-role">{t.role}</FieldLabel>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger
id="hh-member-role"
className="w-full"
aria-describedby="hh-member-role-hint"
>
<SelectValue>{tr.household.roles[field.value]}</SelectValue>
</SelectTrigger>
<SelectContent>
{HOUSEHOLD_ROLES.map((r) => (
<SelectItem key={r} value={r}>
{tr.household.roles[r]}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldDescription id="hh-member-role-hint">
{tr.household.roleHints[field.value]}
</FieldDescription>
</Field>
)}
/>
{form.formState.errors.root ? (
<FieldError>{form.formState.errors.root.message}</FieldError>
) : null}
</FieldGroup>
</form>
</ResponsiveDialog>
);
}
export function HouseholdsAdmin({
households,
users,
currentUserId,
}: {
households: AdminHouseholdRow[];
users: UserOption[];
currentUserId: string;
}) {
const [creating, setCreating] = useState(false);
const [addingTo, setAddingTo] = useState<AdminHouseholdRow | null>(null);
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-3">
<p className="max-w-2xl text-sm text-muted-foreground">{t.privacyNote}</p>
<Button className="ml-auto" onClick={() => setCreating(true)}>
<Plus />
{t.newHousehold}
</Button>
</div>
{households.length === 0 ? (
<p className="rounded-xl border border-dashed p-8 text-center text-muted-foreground">
{t.empty}
</p>
) : (
<div className="grid gap-4 xl:grid-cols-2">
{households.map((h) => (
<Card key={h.id}>
<CardHeader>
<CardTitle>{h.name}</CardTitle>
<CardDescription className="flex flex-wrap items-center gap-2">
<span>{t.members(h.memberCount)}</span>
{h.owners.length ? (
<span>
· {t.owners}: {h.owners.join(", ")}
</span>
) : (
<Badge variant="destructive">{t.noOwner}</Badge>
)}
</CardDescription>
<CardAction>
<Button variant="outline" size="sm" onClick={() => setAddingTo(h)}>
<UserPlus />
{t.addMember}
</Button>
</CardAction>
</CardHeader>
<CardContent>
<MembersList
members={h.members}
householdName={h.name}
currentUserId={currentUserId}
canManage
onSetRole={(userId, role) => adminSetMemberRoleAction(h.id, userId, role)}
onRemove={(userId) => adminRemoveMemberAction(h.id, userId)}
/>
</CardContent>
</Card>
))}
</div>
)}
<CreateHouseholdDialog open={creating} onOpenChange={setCreating} users={users} />
<AddMemberDialog
household={addingTo}
users={users}
onOpenChange={(o) => !o && setAddingTo(null)}
/>
</div>
);
}
@@ -0,0 +1,48 @@
"use client";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { UserOption } from "@/features/households/service";
import { tr } from "@/i18n/tr";
/** Kullanıcı seçici (askıdakiler işaretli). */
export function UserSelect({
id,
users,
value,
onChange,
invalid,
}: {
id: string;
users: UserOption[];
value: string;
onChange: (id: string) => void;
invalid?: boolean;
}) {
const selected = users.find((u) => u.id === value);
return (
<Select value={value || undefined} onValueChange={onChange}>
<SelectTrigger id={id} className="w-full" aria-invalid={invalid}>
<SelectValue placeholder={tr.adminHouseholds.selectUser}>
{selected ? `${selected.name} (@${selected.username})` : null}
</SelectValue>
</SelectTrigger>
<SelectContent className="max-h-72">
{users.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.name}
<span className="text-xs text-muted-foreground">
@{u.username}
{u.banned ? ` · ${tr.settings.banned}` : ""}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
);
}
+10
View File
@@ -0,0 +1,10 @@
import "server-only";
import { adminListHouseholds, adminListUserOptions } from "@/features/households/service";
import { requireRole } from "@/lib/dal";
/** Admin: haneler (üyeleriyle) ve hane atamaları için kullanıcı listesi. */
export async function getAdminHouseholdsData() {
await requireRole("admin");
const [households, users] = await Promise.all([adminListHouseholds(), adminListUserOptions()]);
return { households, users };
}
+302
View File
@@ -0,0 +1,302 @@
"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 { writeAudit } from "@/lib/audit";
import { getActionUser } from "@/lib/dal";
import { removeAllMembershipsOfUser } from "@/features/households/service";
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;
let createdId = "";
try {
const created = 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 },
},
});
createdId = created.user.id;
} catch (error) {
return mapAuthError(error);
}
await writeAudit({
action: "user.create",
actorId: ctx.actor.id,
targetType: "user",
targetId: createdId,
diff: { username: v.username, role: v.role },
});
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);
}
await writeAudit({
action: "user.update",
actorId: ctx.actor.id,
targetType: "user",
targetId: target.id,
diff: { name: v.name, username: v.username, role: v.role },
});
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);
}
await writeAudit({
action: "user.role",
actorId: ctx.actor.id,
targetType: "user",
targetId: target.id,
diff: { role: { from: target.role, to: r.data } },
});
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);
}
await writeAudit({
action: "user.password.reset",
actorId: ctx.actor.id,
targetType: "user",
targetId: target.id,
});
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);
}
await writeAudit({
action: "user.ban",
actorId: ctx.actor.id,
targetType: "user",
targetId: target.id,
diff: { reason: parsed.data.reason ?? null },
});
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);
}
await writeAudit({
action: "user.unban",
actorId: ctx.actor.id,
targetType: "user",
targetId: id.data,
});
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);
}
const removedMemberships = await removeAllMembershipsOfUser(target.id);
await writeAudit({
action: "user.remove",
actorId: ctx.actor.id,
targetType: "user",
targetId: target.id,
diff: { name: target.name, removedMemberships },
});
revalidatePath(PATH);
return ok(undefined);
}
@@ -59,18 +59,22 @@ import {
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { tr } from "@/i18n/tr"; 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 { cn } from "@/lib/utils";
import { formatDate } from "@/shared/format"; import { formatDate } from "@/shared/format";
import type { SystemRole } from "@/shared/schemas/common"; import type { SystemRole } from "@/shared/schemas/common";
import type { UserCreate, UserUpdate } from "@/shared/schemas/user"; import type { UserCreate, UserUpdate } from "@/shared/schemas/user";
import { compareTr, includesTr } from "@/shared/search"; import { compareTr, includesTr } from "@/shared/search";
import { import {
checkAdminAction, banUserAction,
findUniqueConflicts, createUserAction,
type AdminAction, removeUserAction,
type GuardViolation, setPasswordAction,
} from "../guards"; setRoleAction,
unbanUserAction,
updateUserAction,
} from "../actions";
import { checkAdminAction, type AdminAction, type GuardViolation } from "../guards";
import type { AdminUser } from "../types"; import type { AdminUser } from "../types";
import { BanUserDialog, DeleteUserDialog, SetPasswordDialog } from "./user-action-dialogs"; import { BanUserDialog, DeleteUserDialog, SetPasswordDialog } from "./user-action-dialogs";
import { UserFormDialog } from "./user-form-dialog"; import { UserFormDialog } from "./user-form-dialog";
@@ -311,15 +315,14 @@ const columns = helper.columns([
]); ]);
export function UsersTable({ export function UsersTable({
initialUsers, users,
currentUserId, currentUserId,
}: { }: {
initialUsers: AdminUser[]; users: AdminUser[];
currentUserId: string; currentUserId: string;
}) { }) {
// FAZ 3: yerel durum yerine Server Action + revalidatePath.
const [users, setUsers] = useState(initialUsers);
const [dialog, setDialog] = useState<DialogState>(null); 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 adminCount = users.filter((u) => u.role === "admin").length;
const guard = (action: AdminAction, target: AdminUser): GuardViolation | null => 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); 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) --- /** 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> {
async function createUser(values: UserCreate): Promise<ActionResult> { try {
const conflicts = findUniqueConflicts(users, values); const result = await promise;
if (conflicts.username || conflicts.email) { if (result.ok) toast.success(success);
return fail(t.errors.checkFields, { return result;
username: conflicts.username && t.errors.usernameTaken, } catch {
email: conflicts.email && t.errors.emailTaken, 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 createUser = (values: UserCreate) =>
const target = users.find((u) => u.id === id); run(createUserAction(values), t.toast.created(values.name));
if (!target) return fail(tr.common.genericError);
const conflicts = findUniqueConflicts(users, values, id); const updateUser = (id: string, values: UserUpdate) =>
if (conflicts.username || conflicts.email) { run(updateUserAction(id, values), t.toast.updated(values.name));
return fail(t.errors.checkFields, {
username: conflicts.username && t.errors.usernameTaken, async function setRole(user: AdminUser, role: SystemRole) {
email: conflicts.email && t.errors.emailTaken, const result = await run(setRoleAction(user.id, role), t.toast.roleChanged(user.name));
}); if (!result.ok) toast.error(result.error);
}
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);
} }
function setRole(user: AdminUser, role: SystemRole) { const setPassword = (user: AdminUser, values: { password: string }) =>
const v = guard({ type: "setRole", role }, user); run(setPasswordAction(user.id, values), t.toast.passwordSet(user.name));
if (v) return void toast.error(guardMessage(v));
setUsers((prev) => prev.map((u) => (u.id === user.id ? { ...u, role } : u))); const banUser = (user: AdminUser, values: { reason?: string }) =>
toast.success(t.toast.roleChanged(user.name)); 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> { const removeUser = (user: AdminUser) =>
toast.success(t.toast.passwordSet(user.name)); run(removeUserAction(user.id), t.toast.removed(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 table = useTable({ const table = useTable({
features, features,
+1 -18
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { checkAdminAction, findUniqueConflicts } from "./guards"; import { checkAdminAction } from "./guards";
const admin = { id: "a1", role: "admin" as const }; const admin = { id: "a1", role: "admin" as const };
const user = { id: "u1", role: "user" as const }; const user = { id: "u1", role: "user" as const };
@@ -40,20 +40,3 @@ describe("checkAdminAction", () => {
expect(checkAdminAction({ type: "setRole", role: "admin" }, ctx)).toBeNull(); 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({});
});
});
-15
View File
@@ -35,18 +35,3 @@ export function checkAdminAction(action: AdminAction, ctx: GuardContext): GuardV
if (removesAdmin && ctx.adminCount <= 1) return "lastAdmin"; if (removesAdmin && ctx.adminCount <= 1) return "lastAdmin";
return null; 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 }
: {}),
};
}
+27
View File
@@ -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(),
};
}
-111
View File
@@ -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",
},
];
+16
View File
@@ -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);
}
-13
View File
@@ -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",
};
+60
View File
@@ -0,0 +1,60 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { tr } from "@/i18n/tr";
import { fail, ok, type ActionResult } from "@/lib/action-result";
import { writeActiveHouseholdId } from "@/lib/active-household";
import { getCurrentUser, getHouseholdActionContext } from "@/lib/dal";
import { householdRoleSchema, householdSettingsSchema } from "@/shared/schemas/household";
import { removeMember, resolveMembership, setMemberRole, updateHouseholdSettings } from "./service";
const idSchema = z.string().regex(/^[a-f0-9]{24}$/i);
/** Aktif haneyi değiştirir: önce üyelik doğrulanır, sonra cookie yazılır. */
export async function switchHouseholdAction(
householdId: unknown,
): Promise<ActionResult<{ name: string }>> {
const user = await getCurrentUser();
const id = idSchema.safeParse(householdId);
if (!user || !id.success) return fail(tr.common.unauthorized);
const membership = await resolveMembership(user.id, id.data, "viewer");
if (!membership) return fail(tr.common.unauthorized);
await writeActiveHouseholdId(id.data);
revalidatePath("/", "layout");
return ok({ name: membership.name });
}
/** Owner: hane adı ve ayarları (aktif hane). */
export async function updateHouseholdSettingsAction(input: unknown): Promise<ActionResult> {
const ctx = await getHouseholdActionContext("owner");
if (!ctx) return fail(tr.common.unauthorized);
const parsed = householdSettingsSchema.safeParse(input);
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields);
const result = await updateHouseholdSettings(ctx.user.id, ctx.household.id, parsed.data);
if (result.ok) revalidatePath("/", "layout");
return result;
}
/** Owner: aktif hanedeki üyenin rolü. */
export async function setMemberRoleAction(userId: unknown, role: unknown): Promise<ActionResult> {
const ctx = await getHouseholdActionContext("owner");
if (!ctx) return fail(tr.common.unauthorized);
const uid = idSchema.safeParse(userId);
const r = householdRoleSchema.safeParse(role);
if (!uid.success || !r.success) return fail(tr.common.genericError);
const result = await setMemberRole(ctx.user.id, ctx.household.id, uid.data, r.data);
if (result.ok) revalidatePath("/ayarlar");
return result;
}
/** Owner: üyeyi aktif haneden çıkarır. */
export async function removeMemberAction(userId: unknown): Promise<ActionResult> {
const ctx = await getHouseholdActionContext("owner");
if (!ctx) return fail(tr.common.unauthorized);
const uid = idSchema.safeParse(userId);
if (!uid.success) return fail(tr.common.genericError);
const result = await removeMember(ctx.user.id, ctx.household.id, uid.data);
if (result.ok) revalidatePath("/", "layout");
return result;
}
@@ -0,0 +1,228 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2 } from "lucide-react";
import { Controller, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldSet,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { tr } from "@/i18n/tr";
import { householdSettingsSchema, type HouseholdSettings } from "@/shared/schemas/household";
import { updateHouseholdSettingsAction } from "../actions";
const t = tr.settings.fields;
/** Form şeması: eşik TL (tam sayı) olarak girilir, kuruşa sunucuya giderken çevrilir. */
const formSchema = householdSettingsSchema
.omit({ extraStoreSavingThresholdMinor: true, budgetMonthStartDay: true })
.extend({
budgetMonthStartDay: z.number().int().min(1).max(28),
extraStoreSavingThresholdTl: z
.number({ error: tr.validation.wholeNumber })
.int(tr.validation.wholeNumber)
.min(0)
.max(100_000),
});
type FormValues = z.infer<typeof formSchema>;
const DAYS = Array.from({ length: 28 }, (_, i) => i + 1);
export function HouseholdSettingsForm({
settings,
canEdit,
}: {
settings: HouseholdSettings;
canEdit: boolean;
}) {
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
name: settings.name,
budgetMonthStartDay: settings.budgetMonthStartDay,
installmentBudgetMode: settings.installmentBudgetMode,
dailyPlanMode: settings.dailyPlanMode,
includePrivateAccountsInTotals: settings.includePrivateAccountsInTotals,
extraStoreSavingThresholdTl: Math.round(settings.extraStoreSavingThresholdMinor / 100),
},
});
async function onSubmit({ extraStoreSavingThresholdTl, ...rest }: FormValues) {
const result = await updateHouseholdSettingsAction({
...rest,
extraStoreSavingThresholdMinor: extraStoreSavingThresholdTl * 100,
});
if (result.ok) {
toast.success(tr.settings.saved);
form.reset({ ...rest, extraStoreSavingThresholdTl });
} else {
toast.error(result.error);
}
}
const submitting = form.formState.isSubmitting;
return (
<form onSubmit={form.handleSubmit(onSubmit)} noValidate>
{/* Sahip olmayanlar için tüm alanlar salt okunur. */}
<FieldSet disabled={!canEdit || submitting}>
<FieldGroup>
<Controller
name="name"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="hh-name">{t.name}</FieldLabel>
<Input {...field} id="hh-name" aria-invalid={fieldState.invalid} />
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
<Controller
name="budgetMonthStartDay"
control={form.control}
render={({ field }) => (
<Field>
<FieldLabel htmlFor="hh-start-day">{t.budgetMonthStartDay}</FieldLabel>
<Select
value={String(field.value)}
onValueChange={(v) => field.onChange(Number(v))}
disabled={!canEdit}
>
<SelectTrigger
id="hh-start-day"
className="w-28"
aria-describedby="hh-start-day-hint"
>
<SelectValue>{field.value}</SelectValue>
</SelectTrigger>
<SelectContent className="max-h-64">
{DAYS.map((d) => (
<SelectItem key={d} value={String(d)}>
{d}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldDescription id="hh-start-day-hint">
{t.budgetMonthStartDayHint}
</FieldDescription>
</Field>
)}
/>
<Controller
name="installmentBudgetMode"
control={form.control}
render={({ field }) => (
<Field>
<FieldLabel htmlFor="hh-installment">{t.installmentBudgetMode}</FieldLabel>
<Select value={field.value} onValueChange={field.onChange} disabled={!canEdit}>
<SelectTrigger id="hh-installment" className="w-full sm:w-80">
<SelectValue>{t.installmentModes[field.value]}</SelectValue>
</SelectTrigger>
<SelectContent>
{(["per_installment", "purchase_month"] as const).map((m) => (
<SelectItem key={m} value={m}>
{t.installmentModes[m]}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
)}
/>
<Controller
name="dailyPlanMode"
control={form.control}
render={({ field }) => (
<Field>
<FieldLabel htmlFor="hh-daily">{t.dailyPlanMode}</FieldLabel>
<Select value={field.value} onValueChange={field.onChange} disabled={!canEdit}>
<SelectTrigger id="hh-daily" className="w-full sm:w-80">
<SelectValue>{t.dailyPlanModes[field.value]}</SelectValue>
</SelectTrigger>
<SelectContent>
{(["even", "previous_month_pattern"] as const).map((m) => (
<SelectItem key={m} value={m}>
{t.dailyPlanModes[m]}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
)}
/>
<Controller
name="extraStoreSavingThresholdTl"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="hh-threshold">{t.extraStoreSavingThreshold}</FieldLabel>
<Input
id="hh-threshold"
type="number"
inputMode="numeric"
min={0}
step={1}
className="w-36 tabular-nums"
name={field.name}
ref={field.ref}
onBlur={field.onBlur}
value={Number.isNaN(field.value) ? "" : field.value}
onChange={(e) => field.onChange(e.target.valueAsNumber)}
aria-invalid={fieldState.invalid}
aria-describedby="hh-threshold-hint"
/>
<FieldDescription id="hh-threshold-hint">
{t.extraStoreSavingThresholdHint}
</FieldDescription>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
<Controller
name="includePrivateAccountsInTotals"
control={form.control}
render={({ field }) => (
<Field orientation="horizontal">
<Checkbox
id="hh-private"
checked={field.value}
onCheckedChange={(v) => field.onChange(v === true)}
disabled={!canEdit}
/>
<FieldLabel htmlFor="hh-private" className="font-normal">
{t.includePrivateAccountsInTotals}
</FieldLabel>
</Field>
)}
/>
{canEdit ? (
<div>
<Button type="submit" disabled={submitting || !form.formState.isDirty}>
{submitting ? <Loader2 className="animate-spin" /> : null}
{tr.common.save}
</Button>
</div>
) : null}
</FieldGroup>
</FieldSet>
</form>
);
}
@@ -0,0 +1,169 @@
"use client";
import { UserMinus } from "lucide-react";
import { useState, useTransition } from "react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { initials } from "@/components/layout/user-menu";
import { tr } from "@/i18n/tr";
import type { ActionResult } from "@/lib/action-result";
import { HOUSEHOLD_ROLES, type HouseholdRole } from "../types";
export type MemberRow = {
userId: string;
name: string;
username: string;
role: HouseholdRole;
banned: boolean;
};
type Props = {
members: MemberRow[];
householdName: string;
currentUserId: string;
canManage: boolean;
onSetRole: (userId: string, role: HouseholdRole) => Promise<ActionResult>;
onRemove: (userId: string) => Promise<ActionResult>;
};
/** Hane üyeleri: owner (veya admin) rol değiştirir / haneden çıkarır. Kurallar sunucuda. */
export function MembersList({
members,
householdName,
currentUserId,
canManage,
onSetRole,
onRemove,
}: Props) {
const [pending, startTransition] = useTransition();
const [removing, setRemoving] = useState<MemberRow | null>(null);
const t = tr.settings;
function changeRole(member: MemberRow, role: HouseholdRole) {
startTransition(async () => {
const result = await onSetRole(member.userId, role);
if (result.ok) toast.success(t.roleChanged(member.name));
else toast.error(result.error);
});
}
function confirmRemove() {
const member = removing;
if (!member) return;
startTransition(async () => {
const result = await onRemove(member.userId);
if (result.ok) {
toast.success(t.memberRemoved(member.name));
setRemoving(null);
} else {
toast.error(result.error);
}
});
}
return (
<>
<ul className="divide-y rounded-xl border" aria-busy={pending}>
{members.map((m) => (
<li key={m.userId} className="flex flex-wrap items-center gap-3 p-3">
<Avatar>
<AvatarFallback className="bg-primary/10 text-xs font-medium text-primary">
{initials(m.name)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">
{m.name}
{m.userId === currentUserId ? (
<span className="ml-1.5 text-xs font-normal text-muted-foreground">
({tr.adminUsers.you})
</span>
) : null}
</p>
<p className="truncate text-xs text-muted-foreground">@{m.username}</p>
</div>
{m.banned ? <Badge variant="destructive">{t.banned}</Badge> : null}
{canManage ? (
<div className="flex items-center gap-1">
<Select
value={m.role}
onValueChange={(v) => changeRole(m, v as HouseholdRole)}
disabled={pending}
>
<SelectTrigger
size="sm"
className="w-32"
aria-label={`${m.name} — ${t.changeRole}`}
>
<SelectValue>{tr.household.roles[m.role]}</SelectValue>
</SelectTrigger>
<SelectContent align="end">
{HOUSEHOLD_ROLES.map((r) => (
<SelectItem key={r} value={r}>
<span className="flex flex-col">
<span>{tr.household.roles[r]}</span>
<span className="text-xs text-muted-foreground">
{tr.household.roleHints[r]}
</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setRemoving(m)}
disabled={pending}
aria-label={`${m.name} — ${t.removeMember}`}
title={t.removeMember}
>
<UserMinus />
</Button>
</div>
) : (
<Badge variant={m.role === "owner" ? "default" : "secondary"}>
{tr.household.roles[m.role]}
</Badge>
)}
</li>
))}
</ul>
<AlertDialog open={Boolean(removing)} onOpenChange={(open) => !open && setRemoving(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t.removeTitle}</AlertDialogTitle>
<AlertDialogDescription>
{removing ? t.removeDescription(removing.name, householdName) : null}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={pending}>{tr.common.cancel}</AlertDialogCancel>
<Button variant="destructive" onClick={confirmRemove} disabled={pending}>
{t.removeConfirm}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -0,0 +1,21 @@
"use client";
import { removeMemberAction, setMemberRoleAction } from "../actions";
import type { HouseholdRole } from "../types";
import { MembersList, type MemberRow } from "./members-list";
/** /ayarlar: aktif hanenin üyeleri (owner action'larına bağlı). */
export function SettingsMembers(props: {
members: MemberRow[];
householdName: string;
currentUserId: string;
canManage: boolean;
}) {
return (
<MembersList
{...props}
onSetRole={(userId, role: HouseholdRole) => setMemberRoleAction(userId, role)}
onRemove={(userId) => removeMemberAction(userId)}
/>
);
}
-7
View File
@@ -1,7 +0,0 @@
import type { HouseholdSummary } from "./types";
/** FAZ 2 GEÇİCİ: Faz 5'te kullanıcının üye olduğu hanelerle değiştirilecek. */
export const MOCK_HOUSEHOLDS: HouseholdSummary[] = [
{ id: "h_cakir", name: "Çakır Ailesi", role: "owner" },
{ id: "h_anne", name: "Annemler", role: "viewer" },
];
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { checkMemberChange, hasHouseholdRole, pickActiveHousehold } from "./roles";
describe("hasHouseholdRole", () => {
it("viewer < member < owner sırasını uygular", () => {
expect(hasHouseholdRole("owner", "member")).toBe(true);
expect(hasHouseholdRole("member", "member")).toBe(true);
expect(hasHouseholdRole("viewer", "member")).toBe(false);
expect(hasHouseholdRole("member", "owner")).toBe(false);
expect(hasHouseholdRole("viewer", "viewer")).toBe(true);
});
});
describe("checkMemberChange", () => {
it("son sahip düşürülemez veya çıkarılamaz", () => {
const ctx = { targetRole: "owner" as const, ownerCount: 1 };
expect(checkMemberChange({ type: "remove" }, ctx)).toBe("lastOwner");
expect(checkMemberChange({ type: "setRole", role: "member" }, ctx)).toBe("lastOwner");
expect(checkMemberChange({ type: "setRole", role: "owner" }, ctx)).toBeNull();
});
it("birden çok sahip varken biri düşürülebilir; sahip olmayanlar serbest", () => {
expect(
checkMemberChange({ type: "remove" }, { targetRole: "owner", ownerCount: 2 }),
).toBeNull();
expect(
checkMemberChange({ type: "remove" }, { targetRole: "viewer", ownerCount: 1 }),
).toBeNull();
expect(
checkMemberChange(
{ type: "setRole", role: "owner" },
{ targetRole: "member", ownerCount: 1 },
),
).toBeNull();
});
});
describe("pickActiveHousehold", () => {
const list = [
{ id: "a", role: "viewer" as const },
{ id: "b", role: "owner" as const },
{ id: "c", role: "owner" as const },
];
it("üyesi olunan tercih edilen haneyi seçer", () => {
expect(pickActiveHousehold(list, "a")?.id).toBe("a");
});
it("tercih yoksa veya uydurmaysa en yüksek rolün olduğu ilk haneye düşer", () => {
expect(pickActiveHousehold(list, "x")?.id).toBe("b");
expect(pickActiveHousehold(list, undefined)?.id).toBe("b");
});
it("hiç üyelik yoksa null", () => {
expect(pickActiveHousehold([], "a")).toBeNull();
});
});
+43
View File
@@ -0,0 +1,43 @@
import type { HouseholdRole } from "./types";
/** Hane içi yetki sırası: viewer < member < owner. */
const RANK: Record<HouseholdRole, number> = { viewer: 0, member: 1, owner: 2 };
/** `role`, en az `min` yetkisine sahip mi? */
export function hasHouseholdRole(role: HouseholdRole, min: HouseholdRole): boolean {
return RANK[role] >= RANK[min];
}
export type MemberChange = { type: "setRole"; role: HouseholdRole } | { type: "remove" };
export type MemberChangeViolation = "lastOwner";
/**
* Üyelik değişikliği kuralı: hanenin son sahibi (owner) düşürülemez veya çıkarılamaz.
* Böylece her hanenin ayarlarını ve üyelerini yönetebilecek en az bir kişi kalır.
*/
export function checkMemberChange(
change: MemberChange,
ctx: { targetRole: HouseholdRole; ownerCount: number },
): MemberChangeViolation | null {
const removesOwner =
ctx.targetRole === "owner" &&
(change.type === "remove" || (change.type === "setRole" && change.role !== "owner"));
return removesOwner && ctx.ownerCount <= 1 ? "lastOwner" : null;
}
/**
* Aktif hane seçimi: tercih edilen (cookie) hane kullanıcının üyelikleri arasındaysa o;
* değilse en yüksek rolün olduğu hane (eşitlikte listedeki ilk). Uydurma veya artık üye
* olunmayan bir hane kimliği asla seçilmez.
*/
export function pickActiveHousehold<T extends { id: string; role: HouseholdRole }>(
memberships: T[],
preferredId: string | undefined,
): T | null {
const preferred = memberships.find((m) => m.id === preferredId);
if (preferred) return preferred;
let best: T | null = null;
for (const m of memberships) if (!best || RANK[m.role] > RANK[best.role]) best = m;
return best;
}
+207
View File
@@ -0,0 +1,207 @@
import mongoose, { Types } from "mongoose";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { getDb, getMongoClient } from "@/lib/db/mongo";
import { AuditLogModel } from "@/models/audit-log";
import { HouseholdModel } from "@/models/household";
import { HouseholdMemberModel } from "@/models/household-member";
import { runSeed } from "../../../scripts/seed/run";
import {
adminCreateHousehold,
adminSetMemberRole,
getHouseholdDetail,
listMemberships,
removeAllMembershipsOfUser,
removeMember,
resolveMembership,
setMemberRole,
updateHouseholdSettings,
} from "./service";
/**
* VERİ İZOLASYONU (Bölüm 5): başka hanenin verisine erişim imkânsız olmalı.
* Seed verisi: Çakır Ailesi (admin+ayse owner, emre member, zeynep viewer, ozan member),
* Annemler (fatma owner, mehmet member, ayse viewer), burak = hanesi olmayan admin.
*/
const ids = new Map<string, string>();
let cakir = "";
let annemler = "";
const settings = {
name: "Çakır Ailesi",
budgetMonthStartDay: 15,
installmentBudgetMode: "purchase_month" as const,
dailyPlanMode: "even" as const,
includePrivateAccountsInTotals: false,
extraStoreSavingThresholdMinor: 25_000,
};
beforeAll(async () => {
expect(getDb().databaseName).toBe("butcem_test");
await getDb().dropDatabase();
await runSeed({ log: () => {} });
for (const u of await getDb().collection("user").find().toArray())
ids.set(String(u.username), String(u._id));
cakir = String((await HouseholdModel.findOne({ name: "Çakır Ailesi" }))!._id);
annemler = String((await HouseholdModel.findOne({ name: "Annemler" }))!._id);
});
afterAll(async () => {
await getDb().dropDatabase();
await mongoose.disconnect();
await getMongoClient().close();
});
const id = (username: string) => ids.get(username)!;
describe("üyelik çözümleme (izolasyon çekirdeği)", () => {
it("üye olmayan kullanıcı başka hanenin üyeliğini çözemez", async () => {
expect(await resolveMembership(id("fatma"), cakir)).toBeNull();
expect(await resolveMembership(id("emre"), annemler)).toBeNull();
});
it("rol yetmezse null döner", async () => {
expect((await resolveMembership(id("ayse"), annemler, "viewer"))?.role).toBe("viewer");
expect(await resolveMembership(id("ayse"), annemler, "member")).toBeNull();
expect(await resolveMembership(id("zeynep"), cakir, "member")).toBeNull();
expect((await resolveMembership(id("emre"), cakir, "member"))?.role).toBe("member");
});
it("hanesi olmayan admin hiçbir haneyi göremez (admin ayrıcalığı yok)", async () => {
expect(await listMemberships(id("burak"))).toEqual([]);
expect(await resolveMembership(id("burak"), cakir)).toBeNull();
expect(await getHouseholdDetail(id("burak"), cakir)).toBeNull();
});
it("geçersiz ve uydurma kimlikler reddedilir", async () => {
const forged = new Types.ObjectId().toHexString();
expect(await resolveMembership(id("ayse"), forged)).toBeNull();
expect(await resolveMembership(id("ayse"), "not-an-id")).toBeNull();
expect(await resolveMembership(id("ayse"), { $ne: null } as unknown as string)).toBeNull();
expect(await resolveMembership("", cakir)).toBeNull();
});
it("kullanıcı yalnızca kendi hanelerini listeler", async () => {
expect((await listMemberships(id("ayse"))).map((m) => [m.name, m.role])).toEqual([
["Annemler", "viewer"],
["Çakır Ailesi", "owner"],
]);
expect((await listMemberships(id("fatma"))).map((m) => m.name)).toEqual(["Annemler"]);
});
});
describe("hane ayrıntısı ve ayarlar", () => {
it("başka hanenin ayrıntısı okunamaz", async () => {
expect(await getHouseholdDetail(id("fatma"), cakir)).toBeNull();
const own = await getHouseholdDetail(id("emre"), cakir);
expect(own?.members.map((m) => m.username).sort()).toEqual([
"admin",
"ayse",
"emre",
"ozan",
"zeynep",
]);
});
it("başka hanenin sahibi, üye ve izleyici ayarları değiştiremez", async () => {
for (const u of ["fatma", "emre", "zeynep", "burak"]) {
const res = await updateHouseholdSettings(id(u), cakir, settings);
expect(res.ok, u).toBe(false);
}
const h = await HouseholdModel.findById(cakir).lean();
expect(h?.settings?.budgetMonthStartDay).toBe(1);
});
it("sahip ayarları değiştirir ve denetim kaydı yazılır", async () => {
const res = await updateHouseholdSettings(id("ayse"), cakir, settings);
expect(res.ok).toBe(true);
const h = await HouseholdModel.findById(cakir).lean();
expect(h?.settings?.budgetMonthStartDay).toBe(15);
const audit = await AuditLogModel.findOne({
action: "household.settings.update",
householdId: cakir,
}).lean();
expect(audit?.actorId.toString()).toBe(id("ayse"));
expect((audit?.diff as Record<string, unknown>).budgetMonthStartDay).toEqual({
from: 1,
to: 15,
});
});
});
describe("üyelik değişiklikleri", () => {
it("üye ve izleyici rol değiştiremez, üye çıkaramaz", async () => {
expect((await setMemberRole(id("emre"), cakir, id("zeynep"), "owner")).ok).toBe(false);
expect((await removeMember(id("zeynep"), cakir, id("emre"))).ok).toBe(false);
});
it("bir hanenin sahibi başka hanedeki üyeliği etkileyemez", async () => {
// ayse Çakır'ın sahibi; fatma Çakır üyesi değil → işlem bulunamaz, Annemler'deki rolü değişmez.
expect((await setMemberRole(id("ayse"), cakir, id("fatma"), "viewer")).ok).toBe(false);
expect((await removeMember(id("ayse"), cakir, id("mehmet"))).ok).toBe(false);
// fatma Annemler'in sahibi; Çakır üzerinde yetkisi yok.
expect((await setMemberRole(id("fatma"), cakir, id("emre"), "viewer")).ok).toBe(false);
const fatma = await HouseholdMemberModel.findOne({
householdId: annemler,
userId: id("fatma"),
}).lean();
const mehmet = await HouseholdMemberModel.findOne({
householdId: annemler,
userId: id("mehmet"),
}).lean();
const emre = await HouseholdMemberModel.findOne({
householdId: cakir,
userId: id("emre"),
}).lean();
expect([fatma?.role, mehmet?.role, emre?.role]).toEqual(["owner", "member", "member"]);
});
it("son sahip düşürülemez; birden çok sahip varken düşürülebilir", async () => {
expect((await adminSetMemberRole(id("admin"), annemler, id("fatma"), "member")).ok).toBe(false);
expect((await setMemberRole(id("fatma"), annemler, id("fatma"), "member")).ok).toBe(false);
// Çakır'da iki sahip var (admin + ayse).
expect((await setMemberRole(id("ayse"), cakir, id("admin"), "member")).ok).toBe(true);
// Artık ayse son sahip.
expect((await setMemberRole(id("ayse"), cakir, id("ayse"), "member")).ok).toBe(false);
expect((await setMemberRole(id("ayse"), cakir, id("admin"), "owner")).ok).toBe(true);
expect(
await AuditLogModel.countDocuments({ action: "household.member.role", householdId: cakir }),
).toBe(2);
});
it("sahip üyeyi haneden çıkarır; kullanıcı artık haneyi göremez", async () => {
expect((await removeMember(id("ayse"), cakir, id("ozan"))).ok).toBe(true);
expect(await resolveMembership(id("ozan"), cakir)).toBeNull();
expect(await listMemberships(id("ozan"))).toEqual([]);
});
});
describe("admin işlemleri", () => {
it("hane + ilk sahip + denetim kaydı tek transaction'da oluşur", async () => {
const res = await adminCreateHousehold(id("admin"), {
name: "Yazlık",
ownerUserId: id("burak"),
});
expect(res.ok).toBe(true);
const hid = res.ok ? res.data.id : "";
expect((await resolveMembership(id("burak"), hid, "owner"))?.name).toBe("Yazlık");
expect(
await AuditLogModel.countDocuments({ action: "household.create", householdId: hid }),
).toBe(1);
});
it("var olmayan kullanıcıyla hane oluşturulmaz", async () => {
const before = await HouseholdModel.countDocuments();
const res = await adminCreateHousehold(id("admin"), {
name: "Hayalet",
ownerUserId: new Types.ObjectId().toHexString(),
});
expect(res.ok).toBe(false);
expect(await HouseholdModel.countDocuments()).toBe(before);
});
it("kullanıcı silinince tüm üyelikleri temizlenir", async () => {
expect(await removeAllMembershipsOfUser(id("mehmet"))).toBe(1);
expect(await listMemberships(id("mehmet"))).toEqual([]);
});
});
+399
View File
@@ -0,0 +1,399 @@
import "server-only";
import { Types } from "mongoose";
import { tr } from "@/i18n/tr";
import { fail, ok, type ActionResult } from "@/lib/action-result";
import { diffFields, writeAudit } from "@/lib/audit";
import { getDb } from "@/lib/db/mongo";
import { connectMongoose } from "@/lib/db/mongoose";
import { toObjectId } from "@/lib/object-id";
import { HouseholdModel } from "@/models/household";
import { HouseholdMemberModel } from "@/models/household-member";
import type { HouseholdSettings } from "@/shared/schemas/household";
import { compareTr } from "@/shared/search";
import { checkMemberChange, hasHouseholdRole, type MemberChange } from "./roles";
import type { HouseholdRole, HouseholdSummary } from "./types";
/**
* Hane servisi — framework'ten bağımsız (web Server Action'ları ve ileride /api/v1 aynı
* fonksiyonları çağırır). Hane kapsamlı her fonksiyon üyeliği KENDİSİ de doğrular:
* çağıran taraf DAL kontrolünü atlasa bile başka hanenin verisine erişilemez.
*/
const t = tr.households;
export type Membership = { householdId: Types.ObjectId; role: HouseholdRole; name: string };
export type HouseholdMemberView = {
userId: string;
name: string;
username: string;
role: HouseholdRole;
banned: boolean;
};
export type HouseholdDetail = {
id: string;
name: string;
settings: HouseholdSettings;
members: HouseholdMemberView[];
};
/** Kullanıcının üye olduğu (arşivlenmemiş) haneler, ada göre sıralı. */
export async function listMemberships(userId: string): Promise<HouseholdSummary[]> {
const uid = toObjectId(userId);
if (!uid) return [];
await connectMongoose();
const memberships = await HouseholdMemberModel.find({ userId: uid }).lean();
if (!memberships.length) return [];
const households = await HouseholdModel.find({
_id: { $in: memberships.map((m) => m.householdId) },
archivedAt: null,
})
.select({ name: 1 })
.lean();
const roleOf = new Map(memberships.map((m) => [String(m.householdId), m.role]));
return households
.map((h) => ({ id: String(h._id), name: h.name, role: roleOf.get(String(h._id))! }))
.sort((a, b) => compareTr(a.name, b.name));
}
/**
* VERİ İZOLASYONUNUN ÇEKİRDEĞİ: kullanıcı bu hanenin en az `minRole` yetkili üyesi mi?
* Değilse null. Geçersiz/uydurma kimlikler de null döner.
*/
export async function resolveMembership(
userId: string,
householdId: string,
minRole: HouseholdRole = "viewer",
): Promise<Membership | null> {
const uid = toObjectId(userId);
const hid = toObjectId(householdId);
if (!uid || !hid) return null;
await connectMongoose();
const member = await HouseholdMemberModel.findOne({ userId: uid, householdId: hid }).lean();
if (!member || !hasHouseholdRole(member.role, minRole)) return null;
const household = await HouseholdModel.findOne({ _id: hid, archivedAt: null })
.select({ name: 1 })
.lean();
if (!household) return null;
return { householdId: hid, role: member.role, name: household.name };
}
async function memberViews(householdId: Types.ObjectId): Promise<HouseholdMemberView[]> {
const members = await HouseholdMemberModel.find({ householdId }).lean();
const users = await getDb()
.collection("user")
.find(
{ _id: { $in: members.map((m) => m.userId) } },
{ projection: { name: 1, username: 1, banned: 1 } },
)
.toArray();
const byId = new Map(users.map((u) => [String(u._id), u]));
return members
.map((m) => {
const u = byId.get(String(m.userId));
return {
userId: String(m.userId),
name: (u?.name as string | undefined) ?? "—",
username: (u?.username as string | undefined) ?? "",
role: m.role,
banned: Boolean(u?.banned),
};
})
.sort((a, b) => compareTr(a.name, b.name));
}
function toSettings(h: {
name: string;
settings?: Partial<HouseholdSettings> | null;
}): HouseholdSettings {
const s = h.settings ?? {};
return {
name: h.name,
budgetMonthStartDay: s.budgetMonthStartDay ?? 1,
installmentBudgetMode: s.installmentBudgetMode ?? "per_installment",
dailyPlanMode: s.dailyPlanMode ?? "even",
includePrivateAccountsInTotals: s.includePrivateAccountsInTotals ?? true,
extraStoreSavingThresholdMinor: s.extraStoreSavingThresholdMinor ?? 10_000,
};
}
/** Üye için hane ayrıntısı (ayarlar + üyeler). Üye değilse null. */
export async function getHouseholdDetail(
userId: string,
householdId: string,
): Promise<HouseholdDetail | null> {
const membership = await resolveMembership(userId, householdId, "viewer");
if (!membership) return null;
const household = await HouseholdModel.findById(membership.householdId).lean();
if (!household) return null;
return {
id: String(household._id),
name: household.name,
settings: toSettings(household),
members: await memberViews(membership.householdId),
};
}
/** Owner: hane adı ve ayarları. */
export async function updateHouseholdSettings(
actorId: string,
householdId: string,
input: HouseholdSettings,
): Promise<ActionResult> {
const membership = await resolveMembership(actorId, householdId, "owner");
if (!membership) return fail(tr.common.unauthorized);
const before = await HouseholdModel.findById(membership.householdId).lean();
if (!before) return fail(tr.common.notFound);
const { name, ...settings } = input;
const actor = new Types.ObjectId(actorId);
await HouseholdModel.updateOne(
{ _id: membership.householdId },
{ $set: { name, settings, updatedBy: actor } },
);
await writeAudit({
action: "household.settings.update",
actorId: actor,
householdId: membership.householdId,
targetType: "household",
targetId: String(membership.householdId),
diff: diffFields(toSettings(before), input),
});
return ok(undefined);
}
/** Ortak üyelik değişikliği (owner yolu ve admin yolu aynı kuralı uygular). */
async function applyMemberChange(
actor: Types.ObjectId,
householdId: Types.ObjectId,
targetUserId: string,
change: MemberChange,
): Promise<ActionResult> {
const target = toObjectId(targetUserId);
if (!target) return fail(tr.common.notFound);
const member = await HouseholdMemberModel.findOne({ householdId, userId: target }).lean();
if (!member) return fail(tr.common.notFound);
if (change.type === "setRole" && change.role === member.role) return ok(undefined);
const ownerCount = await HouseholdMemberModel.countDocuments({ householdId, role: "owner" });
if (checkMemberChange(change, { targetRole: member.role, ownerCount })) return fail(t.lastOwner);
if (change.type === "remove") {
await HouseholdMemberModel.deleteOne({ _id: member._id });
} else {
await HouseholdMemberModel.updateOne(
{ _id: member._id },
{ $set: { role: change.role, updatedBy: actor } },
);
}
await writeAudit({
action: change.type === "remove" ? "household.member.remove" : "household.member.role",
actorId: actor,
householdId,
targetType: "user",
targetId: targetUserId,
diff:
change.type === "remove"
? { role: { from: member.role, to: null } }
: { role: { from: member.role, to: change.role } },
});
return ok(undefined);
}
/** Owner: üyenin hane içi rolünü değiştirir. */
export async function setMemberRole(
actorId: string,
householdId: string,
targetUserId: string,
role: HouseholdRole,
): Promise<ActionResult> {
const membership = await resolveMembership(actorId, householdId, "owner");
if (!membership) return fail(tr.common.unauthorized);
return applyMemberChange(new Types.ObjectId(actorId), membership.householdId, targetUserId, {
type: "setRole",
role,
});
}
/** Owner: üyeyi haneden çıkarır. */
export async function removeMember(
actorId: string,
householdId: string,
targetUserId: string,
): Promise<ActionResult> {
const membership = await resolveMembership(actorId, householdId, "owner");
if (!membership) return fail(tr.common.unauthorized);
return applyMemberChange(new Types.ObjectId(actorId), membership.householdId, targetUserId, {
type: "remove",
});
}
// --- Sistem admini (yönetim) — çağıran Server Action admin rolünü doğrular. ---
// Admin haneleri ve üyelikleri yönetir; hanenin finansal verisini GÖRMEZ (Faz 0 kararı).
export type AdminHouseholdRow = {
id: string;
name: string;
memberCount: number;
owners: string[];
members: HouseholdMemberView[];
createdAt: string;
};
export async function adminListHouseholds(): Promise<AdminHouseholdRow[]> {
await connectMongoose();
const households = await HouseholdModel.find({ archivedAt: null }).sort({ name: 1 }).lean();
const rows = await Promise.all(
households.map(async (h) => {
const members = await memberViews(h._id);
return {
id: String(h._id),
name: h.name,
memberCount: members.length,
owners: members.filter((m) => m.role === "owner").map((m) => m.name),
members,
createdAt: new Date(h.createdAt).toISOString(),
};
}),
);
return rows.sort((a, b) => compareTr(a.name, b.name));
}
export type UserOption = { id: string; name: string; username: string; banned: boolean };
export async function adminListUserOptions(): Promise<UserOption[]> {
const users = await getDb()
.collection("user")
.find({}, { projection: { name: 1, username: 1, banned: 1 } })
.toArray();
return users
.map((u) => ({
id: String(u._id),
name: String(u.name ?? ""),
username: String(u.username ?? ""),
banned: Boolean(u.banned),
}))
.sort((a, b) => compareTr(a.name, b.name));
}
/** Admin: hane + ilk sahip üyeliği tek transaction'da. */
export async function adminCreateHousehold(
actorId: string,
input: { name: string; ownerUserId: string },
): Promise<ActionResult<{ id: string }>> {
const actor = new Types.ObjectId(actorId);
const owner = toObjectId(input.ownerUserId);
if (!owner) return fail(tr.validation.selectUser);
const ownerExists = await getDb().collection("user").countDocuments({ _id: owner }, { limit: 1 });
if (!ownerExists) return fail(tr.validation.selectUser);
const mongoose = await connectMongoose();
let createdId = "";
await mongoose.connection.transaction(async (session) => {
const [household] = await HouseholdModel.create(
[{ name: input.name, createdBy: actor, updatedBy: actor }],
{
session,
},
);
createdId = String(household!._id);
await HouseholdMemberModel.create(
[
{
householdId: household!._id,
userId: owner,
role: "owner",
createdBy: actor,
updatedBy: actor,
},
],
{ session },
);
await writeAudit(
{
action: "household.create",
actorId: actor,
householdId: household!._id,
targetType: "household",
targetId: createdId,
diff: { name: input.name, ownerUserId: input.ownerUserId },
},
session,
);
// FAZ 6: varsayılan Türkçe kategori seti bu transaction içinde yüklenecek.
});
return ok({ id: createdId });
}
/** Admin: kullanıcıyı haneye ekler (zaten üyeyse rolünü günceller). */
export async function adminAddMember(
actorId: string,
householdId: string,
input: { userId: string; role: HouseholdRole },
): Promise<ActionResult> {
const actor = new Types.ObjectId(actorId);
const hid = toObjectId(householdId);
const uid = toObjectId(input.userId);
if (!hid || !uid) return fail(tr.common.notFound);
await connectMongoose();
const household = await HouseholdModel.exists({ _id: hid, archivedAt: null });
const user = await getDb().collection("user").countDocuments({ _id: uid }, { limit: 1 });
if (!household || !user) return fail(tr.common.notFound);
const existing = await HouseholdMemberModel.findOne({ householdId: hid, userId: uid }).lean();
if (existing)
return applyMemberChange(actor, hid, input.userId, { type: "setRole", role: input.role });
await HouseholdMemberModel.create({
householdId: hid,
userId: uid,
role: input.role,
createdBy: actor,
updatedBy: actor,
});
await writeAudit({
action: "household.member.add",
actorId: actor,
householdId: hid,
targetType: "user",
targetId: input.userId,
diff: { role: { from: null, to: input.role } },
});
return ok(undefined);
}
export async function adminSetMemberRole(
actorId: string,
householdId: string,
targetUserId: string,
role: HouseholdRole,
): Promise<ActionResult> {
const hid = toObjectId(householdId);
if (!hid) return fail(tr.common.notFound);
await connectMongoose();
return applyMemberChange(new Types.ObjectId(actorId), hid, targetUserId, {
type: "setRole",
role,
});
}
export async function adminRemoveMember(
actorId: string,
householdId: string,
targetUserId: string,
): Promise<ActionResult> {
const hid = toObjectId(householdId);
if (!hid) return fail(tr.common.notFound);
await connectMongoose();
return applyMemberChange(new Types.ObjectId(actorId), hid, targetUserId, { type: "remove" });
}
/** Kullanıcı silindiğinde tüm üyeliklerini temizler (admin kullanıcı silme akışı). */
export async function removeAllMembershipsOfUser(userId: string): Promise<number> {
const uid = toObjectId(userId);
if (!uid) return 0;
await connectMongoose();
const res = await HouseholdMemberModel.deleteMany({ userId: uid });
return res.deletedCount;
}
+89
View File
@@ -31,6 +31,8 @@ export const tr = {
showPassword: "Şifreyi göster", showPassword: "Şifreyi göster",
hidePassword: "Şifreyi gizle", hidePassword: "Şifreyi gizle",
genericError: "Beklenmeyen bir hata oluştu. Lütfen tekrar deneyin.", genericError: "Beklenmeyen bir hata oluştu. Lütfen tekrar deneyin.",
unauthorized: "Bu işlem için yetkiniz yok.",
notFound: "Kayıt bulunamadı.",
}, },
theme: { theme: {
label: "Tema", label: "Tema",
@@ -61,6 +63,87 @@ export const tr = {
label: "Aktif hane", label: "Aktif hane",
select: "Hane seçin", select: "Hane seçin",
roles: { owner: "Sahip", member: "Üye", viewer: "İzleyici" }, roles: { owner: "Sahip", member: "Üye", viewer: "İzleyici" },
roleHints: {
owner: "Hane ayarları, üyeler ve hesaplar",
member: "İşlem girer, liste ve stok günceller",
viewer: "Yalnızca görüntüler",
},
switched: (name: string) => `Aktif hane: ${name}`,
},
households: {
lastOwner: "Hanenin son sahibi düşürülemez veya çıkarılamaz.",
noHousehold: {
title: "Henüz bir haneye üye değilsiniz",
description:
"Bütçe ve diğer verileri görmek için bir haneye üye olmanız gerekir. Yöneticiden sizi bir haneye eklemesini isteyin.",
adminHint:
"Yönetici olarak Yönetim › Haneler sayfasından hane oluşturabilir ve üye ekleyebilirsiniz.",
adminLink: "Hanelere git",
},
viewerReadOnly: "İzleyici rolündesiniz; bu hanede değişiklik yapamazsınız.",
},
settings: {
title: "Ayarlar",
description: "Aktif hanenin ayarları ve üyeleri.",
household: "Hane",
householdDescription: "Bütçe ve planlama tercihleri tüm hane için geçerlidir.",
ownerOnly: "Bu ayarları yalnızca hane sahipleri değiştirebilir.",
fields: {
name: "Hane adı",
budgetMonthStartDay: "Bütçe ayı başlangıç günü",
budgetMonthStartDayHint:
"1 = takvim ayı. Örneğin 15 seçilirse bütçe ayı her ayın 15. günü başlar.",
installmentBudgetMode: "Taksitlerin bütçeye etkisi",
installmentModes: {
per_installment: "Her taksit kendi ayına",
purchase_month: "Tamamı harcama ayına",
},
dailyPlanMode: "Günlük plan dağıtımı",
dailyPlanModes: {
even: "Değişken bütçe günlere eşit",
previous_month_pattern: "Önceki ayın harcama örüntüsüne göre",
},
includePrivateAccountsInTotals: "“Yalnızca ben” hesapları hane toplamlarına dahil edilsin",
extraStoreSavingThreshold: "Ek mağaza tasarruf eşiği (TL)",
extraStoreSavingThresholdHint:
"Alışveriş planlayıcı, ek bir mağazayı yalnızca tasarruf bu tutarı aşarsa önerir.",
},
saved: "Hane ayarları kaydedildi.",
members: "Üyeler",
membersDescription: "Hane içi roller. Yeni üyeyi yönetici ekler.",
memberActions: (name: string) => `${name} — işlemler`,
changeRole: "Rolü değiştir",
removeMember: "Haneden çıkar",
removeTitle: "Üye haneden çıkarılsın mı?",
removeDescription: (name: string, household: string) =>
`${name}, “${household}” hanesine artık erişemeyecek. Kullanıcı hesabı silinmez.`,
removeConfirm: "Haneden çıkar",
roleChanged: (name: string) => `${name} için rol güncellendi.`,
memberRemoved: (name: string) => `${name} haneden çıkarıldı.`,
banned: "Askıda",
},
adminHouseholds: {
title: "Haneler",
description: "Hane oluşturma, kullanıcıları haneye atama ve hane içi roller.",
newHousehold: "Yeni hane",
createTitle: "Yeni hane",
createDescription: "Hane oluşturulur ve seçilen kullanıcı ilk sahibi olur.",
name: "Hane adı",
owner: "İlk sahip",
selectUser: "Kullanıcı seçin",
created: (name: string) => `${name} hanesi oluşturuldu.`,
members: (n: number) => `${n} üye`,
owners: "Sahipler",
noOwner: "Sahibi yok",
addMember: "Üye ekle",
addMemberTitle: (household: string) => `${household} — üye ekle`,
addMemberDescription: "Kullanıcı zaten üyeyse rolü güncellenir.",
user: "Kullanıcı",
role: "Hane içi rol",
memberAdded: (name: string) => `${name} haneye eklendi.`,
empty: "Henüz hane yok.",
privacyNote:
"Yöneticiler haneleri ve üyelikleri yönetir; üyesi olmadıkları hanenin finansal verisini göremez.",
}, },
userMenu: { userMenu: {
open: "Kullanıcı menüsü", open: "Kullanıcı menüsü",
@@ -90,6 +173,8 @@ export const tr = {
submit: "Giriş yap", submit: "Giriş yap",
submitting: "Giriş yapılıyor…", submitting: "Giriş yapılıyor…",
invalidCredentials: "Kullanıcı adı/e-posta veya şifre hatalı.", 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.", noSignUp: "Hesaplar yalnızca yönetici tarafından açılır.",
identifierRequired: "Kullanıcı adı veya e-posta girin", identifierRequired: "Kullanıcı adı veya e-posta girin",
passwordRequired: "Şifre girin", passwordRequired: "Şifre girin",
@@ -192,6 +277,10 @@ export const tr = {
passwordMin: "Şifre en az 8 karakter olmalı", passwordMin: "Şifre en az 8 karakter olmalı",
passwordMax: "Şifre en fazla 128 karakter olabilir", passwordMax: "Şifre en fazla 128 karakter olabilir",
banReasonMax: "Neden en fazla 200 karakter olabilir", banReasonMax: "Neden en fazla 200 karakter olabilir",
householdNameMin: "Hane adı en az 2 karakter olmalı",
householdNameMax: "Hane adı en fazla 80 karakter olabilir",
selectUser: "Bir kullanıcı seçin",
wholeNumber: "Tam sayı girin",
}, },
errors: { errors: {
notFoundTitle: "Sayfa bulunamadı", notFoundTitle: "Sayfa bulunamadı",
+24
View File
@@ -0,0 +1,24 @@
import "server-only";
import { cookies } from "next/headers";
import { env } from "@/lib/env";
/**
* Aktif hane seçimi cookie'de tutulur. Cookie yalnızca bir TERCİHtir: DAL her istekte
* kullanıcının gerçekten o hanenin üyesi olduğunu doğrular; değilse ilk haneye düşer.
*/
export const ACTIVE_HOUSEHOLD_COOKIE = "butcem.hh";
export async function readActiveHouseholdId(): Promise<string | undefined> {
return (await cookies()).get(ACTIVE_HOUSEHOLD_COOKIE)?.value;
}
/** Yalnızca Server Action / Route Handler içinde çağrılabilir. */
export async function writeActiveHouseholdId(householdId: string): Promise<void> {
(await cookies()).set(ACTIVE_HOUSEHOLD_COOKIE, householdId, {
httpOnly: true,
sameSite: "lax",
secure: env().NODE_ENV === "production",
path: "/",
maxAge: 60 * 60 * 24 * 365,
});
}
+13
View File
@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { diffFields } from "./audit";
describe("diffFields", () => {
it("yalnızca değişen alanları döner", () => {
expect(diffFields({ a: 1, b: "x", c: true }, { a: 2, b: "x" })).toEqual({
a: { from: 1, to: 2 },
});
});
it("iç içe değerleri içerik olarak karşılaştırır", () => {
expect(diffFields({ s: { d: 1 } }, { s: { d: 1 } })).toEqual({});
});
});
+41
View File
@@ -0,0 +1,41 @@
import "server-only";
import type { ClientSession, Types } from "mongoose";
import { connectMongoose } from "@/lib/db/mongoose";
import { AuditLogModel, type AuditAction } from "@/models/audit-log";
export type AuditEntry = {
action: AuditAction;
actorId: Types.ObjectId | string;
householdId?: Types.ObjectId | null;
targetType: string;
targetId: string;
diff?: Record<string, unknown> | null;
};
/** Önce/sonra nesnelerinden yalnızca değişen alanları çıkarır: { alan: { from, to } }. */
export function diffFields<T extends Record<string, unknown>>(before: T, after: Partial<T>) {
const out: Record<string, { from: unknown; to: unknown }> = {};
for (const [key, to] of Object.entries(after)) {
const from = before[key];
if (JSON.stringify(from) !== JSON.stringify(to)) out[key] = { from, to };
}
return out;
}
/**
* Denetim kaydı yazar. `session` verilirse transaction'ın parçası olur (işlem geri alınırsa
* kayıt da geri alınır). Session yoksa hata asıl işlemi bozmaz; sunucuda loglanır.
*/
export async function writeAudit(entry: AuditEntry, session?: ClientSession): Promise<void> {
const doc = { ...entry, householdId: entry.householdId ?? null, diff: entry.diff ?? null };
if (session) {
await AuditLogModel.create([doc], { session });
return;
}
try {
await connectMongoose();
await AuditLogModel.create(doc);
} catch (error) {
console.error("[audit] kayıt yazılamadı", entry.action, error);
}
}
+9
View File
@@ -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()],
});
+8
View File
@@ -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;
}
+191
View File
@@ -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);
});
});
+81
View File
@@ -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;
}
+136
View File
@@ -0,0 +1,136 @@
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 { hasHouseholdRole, pickActiveHousehold } from "@/features/households/roles";
import { listMemberships, resolveMembership, type Membership } from "@/features/households/service";
import type { HouseholdRole, HouseholdSummary } from "@/features/households/types";
import { readActiveHouseholdId } from "@/lib/active-household";
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;
}
// --- Hane kapsamı (veri izolasyonu) ---
export type HouseholdContext = {
user: SessionUser;
/** Aktif hane; kullanıcı hiçbir haneye üye değilse null. */
household: HouseholdSummary | null;
memberships: HouseholdSummary[];
};
/**
* Oturum + kullanıcının haneleri + aktif hane (istek başına bir kez). Cookie'deki hane
* kullanıcının üyesi olduğu bir hane değilse yok sayılır ve ilk haneye düşülür.
* Sistem admini için ayrıcalık YOKTUR: yalnızca üyesi olduğu haneler görünür.
*/
export const getHouseholdContext = cache(async (): Promise<HouseholdContext | null> => {
const user = await getCurrentUser();
if (!user) return null;
const [memberships, preferred] = await Promise.all([
listMemberships(user.id),
readActiveHouseholdId(),
]);
const household = pickActiveHousehold(memberships, preferred);
return { user, household, memberships };
});
export type ActiveHousehold = HouseholdContext & { household: HouseholdSummary };
/**
* Sayfa: aktif hanede en az `minRole` yetkisi. Oturum yoksa /login, hanesi yoksa /hane-yok,
* yetkisi yetmiyorsa 404.
*/
export async function requireActiveHousehold(
minRole: HouseholdRole = "viewer",
): Promise<ActiveHousehold> {
const ctx = await getHouseholdContext();
if (!ctx) redirect("/login");
if (!ctx.household) redirect("/hane-yok");
if (!hasHouseholdRole(ctx.household.role, minRole)) notFound();
return ctx as ActiveHousehold;
}
/** Server Action: aktif hanede en az `minRole` yetkisi; yoksa null (yönlendirme yapmaz). */
export async function getHouseholdActionContext(
minRole: HouseholdRole = "viewer",
): Promise<ActiveHousehold | null> {
const ctx = await getHouseholdContext();
if (!ctx?.household || !hasHouseholdRole(ctx.household.role, minRole)) return null;
return ctx as ActiveHousehold;
}
/**
* Belirli bir hane için üyelik kontrolü (ör. URL'den veya istek gövdesinden gelen householdId).
* Üye değilse veya yetkisi yetmiyorsa null — çağıran 404/403 döner.
*/
export async function requireHouseholdMember(
householdId: string,
minRole: HouseholdRole = "viewer",
): Promise<{ user: SessionUser; membership: Membership } | null> {
const user = await getCurrentUser();
if (!user) return null;
const membership = await resolveMembership(user.id, householdId, minRole);
return membership ? { user, membership } : null;
}
View File
+36
View File
@@ -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;
}
}
+19
View File
@@ -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;
}
+2
View File
@@ -19,6 +19,8 @@ export const serverEnvSchema = z.object({
BETTER_AUTH_URL: z.url("BETTER_AUTH_URL geçerli bir URL olmalı"), BETTER_AUTH_URL: z.url("BETTER_AUTH_URL geçerli bir URL olmalı"),
SEED_ADMIN_EMAIL: z.email().optional(), SEED_ADMIN_EMAIL: z.email().optional(),
SEED_ADMIN_PASSWORD: z.string().min(8).optional(), SEED_ADMIN_PASSWORD: z.string().min(8).optional(),
/** Örnek (geliştirme) kullanıcılarının ortak şifresi; production'da kullanılmaz. */
SEED_USER_PASSWORD: z.string().min(8).optional(),
UPLOAD_DIR: z.string().min(1).default("./uploads"), UPLOAD_DIR: z.string().min(1).default("./uploads"),
MAX_UPLOAD_MB: z.coerce.number().int().positive().max(100).default(10), MAX_UPLOAD_MB: z.coerce.number().int().positive().max(100).default(10),
APP_VERSION: z.string().optional(), APP_VERSION: z.string().optional(),
+8
View File
@@ -0,0 +1,8 @@
import { Types } from "mongoose";
/** Geçerli 24 haneli hex ise ObjectId, değilse null (kullanıcı girdisi asla doğrudan sorguya girmez). */
export function toObjectId(value: unknown): Types.ObjectId | null {
return typeof value === "string" && /^[a-f0-9]{24}$/i.test(value)
? new Types.ObjectId(value)
: null;
}
View File
+44
View File
@@ -0,0 +1,44 @@
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
/**
* Denetim kaydı (yalnızca eklenir, güncellenmez). Bölüm 7.2: hane ayarları, üye değişiklikleri,
* silmeler, aynî borç kapatma ve mutabakat düzeltmeleri; ayrıca admin kullanıcı işlemleri.
*/
export const AUDIT_ACTIONS = [
"household.create",
"household.settings.update",
"household.member.add",
"household.member.role",
"household.member.remove",
"user.create",
"user.update",
"user.role",
"user.password.reset",
"user.ban",
"user.unban",
"user.remove",
] as const;
export type AuditAction = (typeof AUDIT_ACTIONS)[number];
const auditLogSchema = new Schema(
{
/** Hane kapsamındaki işlemlerde dolu; sistem (admin) işlemlerinde null. */
householdId: { type: Schema.Types.ObjectId, default: null },
actorId: { type: Schema.Types.ObjectId, required: true },
action: { type: String, enum: AUDIT_ACTIONS, required: true },
targetType: { type: String, required: true },
targetId: { type: String, required: true },
/** Değişen alanlar: { alan: { from, to } } veya özet bilgi. Şifre vb. sır asla yazılmaz. */
diff: { type: Schema.Types.Mixed, default: null },
at: { type: Date, default: () => new Date() },
},
{ collection: "auditLogs", versionKey: false },
);
auditLogSchema.index({ householdId: 1, at: -1 });
auditLogSchema.index({ actorId: 1, at: -1 });
export type AuditLog = InferSchemaType<typeof auditLogSchema>;
export const AuditLogModel: Model<AuditLog> =
(models.AuditLog as Model<AuditLog> | undefined) ?? model<AuditLog>("AuditLog", auditLogSchema);
+26
View File
@@ -0,0 +1,26 @@
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
import { HOUSEHOLD_ROLES } from "@/features/households/types";
/** Kullanıcının bir hanedeki üyeliği ve hane içi rolü (owner | member | viewer). */
const householdMemberSchema = new Schema(
{
householdId: { type: Schema.Types.ObjectId, ref: "Household", required: true },
/** Better Auth `user` koleksiyonundaki _id. */
userId: { type: Schema.Types.ObjectId, required: true },
role: { type: String, enum: HOUSEHOLD_ROLES, required: true },
createdBy: { type: Schema.Types.ObjectId, required: true },
updatedBy: { type: Schema.Types.ObjectId, required: true },
},
{ timestamps: true, collection: "householdMembers" },
);
// Bir kullanıcı bir hanede yalnızca bir kez üye olabilir.
householdMemberSchema.index({ householdId: 1, userId: 1 }, { unique: true });
// Kullanıcının hanelerini hızlı listelemek için.
householdMemberSchema.index({ userId: 1, householdId: 1 });
export type HouseholdMember = InferSchemaType<typeof householdMemberSchema>;
export const HouseholdMemberModel: Model<HouseholdMember> =
(models.HouseholdMember as Model<HouseholdMember> | undefined) ??
model<HouseholdMember>("HouseholdMember", householdMemberSchema);
+52
View File
@@ -0,0 +1,52 @@
import {
model,
models,
Schema,
type HydratedDocument,
type InferSchemaType,
type Model,
} from "mongoose";
/** Hane ayarları (Faz 0 kararları: bütçe ayı başlangıcı, taksit ve günlük plan tercihleri). */
const householdSettingsSchema = new Schema(
{
/** Bütçe ayının başlangıç günü (1 = takvim ayı). */
budgetMonthStartDay: { type: Number, min: 1, max: 28, default: 1 },
/** Taksitlerin bütçe etkisi: her taksit kendi ayına | tamamı harcama ayına. */
installmentBudgetMode: {
type: String,
enum: ["per_installment", "purchase_month"],
default: "per_installment",
},
/** Günlük plan: değişken bütçe eşit dağıtılır | önceki ayın örüntüsüne göre. */
dailyPlanMode: { type: String, enum: ["even", "previous_month_pattern"], default: "even" },
/** "Yalnızca ben" hesapları hane toplamlarına dahil edilsin mi? */
includePrivateAccountsInTotals: { type: Boolean, default: true },
/** Akıllı mağaza önerisi: ek mağaza ancak tasarruf bu tutarı aşarsa (kuruş). */
extraStoreSavingThresholdMinor: { type: Number, min: 0, default: 10_000 },
},
{ _id: false },
);
const householdSchema = new Schema(
{
name: { type: String, required: true, trim: true, minlength: 2, maxlength: 80 },
currency: { type: String, enum: ["TRY"], default: "TRY" },
timezone: { type: String, default: "Europe/Istanbul" },
settings: { type: householdSettingsSchema, default: () => ({}) },
archivedAt: { type: Date, default: null },
createdBy: { type: Schema.Types.ObjectId, required: true },
updatedBy: { type: Schema.Types.ObjectId, required: true },
},
{ timestamps: true, collection: "households" },
);
householdSchema.index({ name: 1 });
export type Household = InferSchemaType<typeof householdSchema>;
export type HouseholdDoc = HydratedDocument<Household>;
/** HMR'de modelin yeniden derlenmesini önler. */
export const HouseholdModel: Model<Household> =
(models.Household as Model<Household> | undefined) ??
model<Household>("Household", householdSchema);
+22
View File
@@ -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).*)",
],
};
+20
View File
@@ -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");
});
});
+24
View File
@@ -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;
}
+9 -3
View File
@@ -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); 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 export const usernameSchema = z
.string() .string()
.trim() .transform(normalizeUsername)
.transform((s) => s.toLocaleLowerCase("tr-TR"))
.pipe(z.string().regex(USERNAME_PATTERN, v.usernameFormat)); .pipe(z.string().regex(USERNAME_PATTERN, v.usernameFormat));
export const emailSchema = z.string().trim().toLowerCase().pipe(z.email(v.email)); export const emailSchema = z.string().trim().toLowerCase().pipe(z.email(v.email));
+37
View File
@@ -0,0 +1,37 @@
import { z } from "zod";
import { HOUSEHOLD_ROLES } from "../../features/households/types";
import { tr } from "../../i18n/tr";
export const householdRoleSchema = z.enum(HOUSEHOLD_ROLES);
export const householdNameSchema = z
.string()
.trim()
.min(2, tr.validation.householdNameMin)
.max(80, tr.validation.householdNameMax);
/** Hane ayarları formu (owner). Tutarlar kuruş cinsinden tamsayı. */
export const householdSettingsSchema = z.object({
name: householdNameSchema,
budgetMonthStartDay: z.coerce.number().int().min(1).max(28),
installmentBudgetMode: z.enum(["per_installment", "purchase_month"]),
dailyPlanMode: z.enum(["even", "previous_month_pattern"]),
includePrivateAccountsInTotals: z.boolean(),
extraStoreSavingThresholdMinor: z.coerce.number().int().min(0).max(100_000_00),
});
export const householdCreateSchema = z.object({
name: householdNameSchema,
/** İlk sahip (owner) olacak kullanıcı. */
ownerUserId: z.string().regex(/^[a-f0-9]{24}$/i, tr.validation.selectUser),
});
export const addMemberSchema = z.object({
userId: z.string().regex(/^[a-f0-9]{24}$/i, tr.validation.selectUser),
role: householdRoleSchema,
});
export type HouseholdSettingsInput = z.input<typeof householdSettingsSchema>;
export type HouseholdSettings = z.output<typeof householdSettingsSchema>;
export type HouseholdCreateInput = z.input<typeof householdCreateSchema>;
export type AddMemberInput = z.input<typeof addMemberSchema>;
+8 -1
View File
@@ -12,11 +12,18 @@ const base = {
}; };
describe("usernameSchema", () => { 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(" AYSE.K ")).toBe("ayse.k");
expect(usernameSchema.parse("Ali_2")).toBe("ali_2"); 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", () => { 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"]) { for (const bad of ["ayşe", "ali veli", "ab", "a".repeat(31), "ali-veli"]) {
expect(usernameSchema.safeParse(bad).success, bad).toBe(false); expect(usernameSchema.safeParse(bad).success, bad).toBe(false);
+2
View File
@@ -0,0 +1,2 @@
// Testlerde "server-only" paketinin yerine geçer (paket React Server ortamı dışında hata fırlatır).
export {};
+6 -3
View File
@@ -3,12 +3,15 @@ import { defineConfig } from "vitest/config";
export default defineConfig({ export default defineConfig({
resolve: { 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: { test: {
environment: "node", environment: "node",
include: ["src/**/*.test.ts", "scripts/**/*.test.ts"], include: ["src/**/*.test.ts", "scripts/**/*.test.ts"],
// "server-only" paketi test ortamında hata fırlatmasın. // Veritabanı gerektiren testler ayrı: npm run test:integration
server: { deps: { inline: ["server-only"] } }, exclude: ["**/*.int.test.ts", "node_modules/**"],
}, },
}); });
+40
View File
@@ -0,0 +1,40 @@
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", "scripts/**/*.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",
SEED_ADMIN_EMAIL: "admin@test.local",
SEED_ADMIN_PASSWORD: "Test-Admin-12345",
SEED_USER_PASSWORD: "Test-User-12345",
},
},
});