From e188922c0fc10b9c9bb3cb9c6b1f522e7eb5854c Mon Sep 17 00:00:00 2001 From: suphicakir Date: Thu, 12 Nov 2026 01:04:48 +0300 Subject: [PATCH] =?UTF-8?q?feat(kay=C4=B1t):=20yeni=20hane=20kayd=C4=B1=20?= =?UTF-8?q?yaln=C4=B1zca=20davetle=20(REGISTRATION=3Dinvite=5Fonly)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - REGISTRATION ortam değişkeni: invite_only (varsayılan) | open - invite_only: ilk hane serbest, sonrasında yalnızca davet koduyla katılım - Backend'de zorunlu: 403 registration_closed; GET /api/auth/registration - Kayıt ekranı: kayıt kapalıyken yalnızca davet kodu ve açıklama - Entegrasyon testleri: kayıt politikası (2) - Prettier ayarı eklendi (proje stiliyle uyumlu), kaynaklar biçimlendirildi Co-Authored-By: Claude Opus 5.5 --- .env.example | 6 + CLAUDE.md | 2 + backend/api.http | 5 + backend/src/config.rs | 23 ++ backend/src/error.rs | 3 + backend/src/routes/auth.rs | 16 + backend/src/routes/mod.rs | 1 + backend/src/services/account.rs | 27 +- backend/tests/common/mod.rs | 12 +- backend/tests/registration.rs | 70 ++++ docs/MEMORY.md | 6 + frontend/.prettierignore | 5 + frontend/.prettierrc.json | 6 + frontend/package-lock.json | 17 + frontend/package.json | 5 +- frontend/src/components/form-field.tsx | 22 +- frontend/src/components/theme-toggle.tsx | 8 +- frontend/src/features/auth/login-page.tsx | 13 +- frontend/src/features/auth/register-page.tsx | 116 +++++-- frontend/src/features/auth/schemas.test.ts | 33 +- frontend/src/features/auth/schemas.ts | 9 +- frontend/src/features/dev/design-preview.tsx | 7 +- frontend/src/index.css | 318 +++++++++---------- frontend/src/lib/auth.ts | 5 +- frontend/src/lib/query.ts | 1 + frontend/src/lib/return-path.test.ts | 17 +- frontend/src/lib/strings.ts | 12 +- frontend/src/lib/utils.ts | 2 +- frontend/src/types/RegistrationInfo.ts | 10 + 29 files changed, 547 insertions(+), 230 deletions(-) create mode 100644 backend/tests/registration.rs create mode 100644 frontend/.prettierignore create mode 100644 frontend/.prettierrc.json create mode 100644 frontend/src/types/RegistrationInfo.ts diff --git a/.env.example b/.env.example index e72106a..550861a 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,12 @@ JWT_SECRET=degistir-beni-en-az-32-karakterlik-gizli-anahtar # Uygulamanın erişildiği adres APP_URL=http://localhost:5173 +# Yeni hane kaydı: invite_only (varsayılan, önerilen) | open +# invite_only → sistemde hiç hane yokken ilk kayıt yeni hane kurar (sunucu sahibi), +# sonra yalnızca davet koduyla katılınır. +# open → herkes /kayit ekranından yeni hane kurabilir. +REGISTRATION=invite_only + # Backend portu PORT=8080 diff --git a/CLAUDE.md b/CLAUDE.md index 7655251..70cad68 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,6 +73,8 @@ npm run icons # frontend/brand/*.svg → favicon, PWA, Apple, Tauri ikonl - Grup tanımları: `lib/groups.ts`; tema: `lib/theme.tsx` (+ `index.html` içindeki ilk-boya betiği); biçimlendirme: `lib/format.ts`. - API örnekleri: `backend/api.http` - Backend = lib (`src/lib.rs`) + bin (`src/main.rs`). Entegrasyon testleri `backend/tests/` (her test geçici `kakeibo_test_` DB, `tests/common` yardımcıları). +- Kayıt politikası: `REGISTRATION=invite_only` (varsayılan; ilk hane serbest, sonra yalnızca davet) | `open`. Backend'de zorunlu (403 `registration_closed`), `GET /api/auth/registration` arayüze bildirir. Testler `TestApp::new()` = open, `with_registration(..)`. +- Biçim: Prettier (`frontend/.prettierrc.json`: tek tırnak, `;` yok, 100 sütun); `npm --prefix frontend run format`. `src/types/` ve `components/ui/` hariç. - Hane izolasyonu: hane kimliği yalnızca `AuthUser.household_id`'den; sorgular `scope(hid)` ile başlar; başka hane → 404. ## Ortam diff --git a/backend/api.http b/backend/api.http index b3524bb..d489b14 100644 --- a/backend/api.http +++ b/backend/api.http @@ -17,6 +17,8 @@ GET {{baseUrl}}/health ### KAYIT ÖRNEĞİ — bu bir hesap DEĞİL, hesap OLUŞTURMA isteğidir. ### Hesap ancak bu isteği gönderince oluşur (kullanıcı + hane + örnek kategoriler/ödeme türleri). ### Aynı e-postayla ikinci kez gönderirseniz 409 "zaten kayıtlı" alırsınız. +### REGISTRATION=invite_only (varsayılan): sistemde zaten hane varsa davetsiz kayıt 403 "registration_closed" döner +### → aşağıdaki "Davet koduyla kayıt" örneğini kullanın (kodu hane sahibi oluşturur). POST {{baseUrl}}/auth/register Content-Type: application/json @@ -49,6 +51,9 @@ Content-Type: application/json "remember_me": true } +### Kayıt politikası: yeni hane kurulabilir mi? (oturum gerektirmez) +GET {{baseUrl}}/auth/registration + ### Ben kimim? (oturum yoksa 401) GET {{baseUrl}}/auth/me diff --git a/backend/src/config.rs b/backend/src/config.rs index c54ad1e..a1a18e2 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -8,6 +8,17 @@ pub struct Config { pub jwt_secret: String, pub app_url: String, pub port: u16, + pub registration: RegistrationMode, +} + +/// Yeni hane kaydı politikası (`REGISTRATION` ortam değişkeni). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RegistrationMode { + /// Varsayılan. Sistemde hiç hane yokken ilk kayıt yeni hane kurar; + /// sonrasında yalnızca davet koduyla katılınır. + InviteOnly, + /// Herkes yeni hane kurabilir. + Open, } #[derive(Debug, thiserror::Error)] @@ -32,6 +43,17 @@ impl Config { let app_url = env::var("APP_URL").unwrap_or_else(|_| "http://localhost:5173".into()); + let registration = match env::var("REGISTRATION").as_deref() { + Err(_) | Ok("") | Ok("invite_only") => RegistrationMode::InviteOnly, + Ok("open") => RegistrationMode::Open, + Ok(_) => { + return Err(ConfigError::Invalid( + "REGISTRATION", + "\"invite_only\" veya \"open\" olmalı", + )); + } + }; + let port = match env::var("PORT") { Ok(p) => p .parse() @@ -44,6 +66,7 @@ impl Config { jwt_secret, app_url, port, + registration, }) } diff --git a/backend/src/error.rs b/backend/src/error.rs index 698dd17..cc7c4ca 100644 --- a/backend/src/error.rs +++ b/backend/src/error.rs @@ -37,6 +37,8 @@ pub enum AppError { InvalidCredentials, #[error("Bu işlem için yetkiniz yok")] Forbidden, + #[error("Yeni hane kaydı kapalı. Katılmak için hane sahibinden davet bağlantısı isteyin.")] + RegistrationClosed, #[error("{0}")] Conflict(String), #[error("Çok fazla deneme yapıldı, lütfen biraz sonra tekrar deneyin")] @@ -55,6 +57,7 @@ impl AppError { Self::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"), Self::InvalidCredentials => (StatusCode::UNAUTHORIZED, "invalid_credentials"), Self::Forbidden => (StatusCode::FORBIDDEN, "forbidden"), + Self::RegistrationClosed => (StatusCode::FORBIDDEN, "registration_closed"), Self::Conflict(_) => (StatusCode::CONFLICT, "conflict"), Self::TooManyRequests => (StatusCode::TOO_MANY_REQUESTS, "too_many_requests"), Self::Database(_) | Self::Internal(_) => { diff --git a/backend/src/routes/auth.rs b/backend/src/routes/auth.rs index 24345d1..afe5850 100644 --- a/backend/src/routes/auth.rs +++ b/backend/src/routes/auth.rs @@ -103,6 +103,7 @@ pub async fn register( let password_hash = password::hash(req.password).await?; let created = account::register( &state.db, + state.config.registration, NewAccount { name, email, @@ -174,6 +175,21 @@ pub async fn login( Ok((jar, Json(me))) } +/// Kayıt ekranı için: yeni hane kurulabilir mi? (oturum gerektirmez) +#[derive(Serialize, TS)] +#[ts(export)] +pub struct RegistrationInfo { + /// `false` ise yalnızca davet koduyla kayıt yapılabilir + new_household_allowed: bool, +} + +pub async fn registration_info(State(state): State) -> AppResult> { + let allowed = account::new_household_allowed(&state.db, state.config.registration).await?; + Ok(Json(RegistrationInfo { + new_household_allowed: allowed, + })) +} + pub async fn logout(jar: CookieJar) -> (StatusCode, CookieJar) { (StatusCode::NO_CONTENT, jar.remove(removal_cookie())) } diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index bc85d49..6a6e156 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -17,6 +17,7 @@ pub fn router(state: AppState) -> Router { let api = Router::new() .route("/health", get(health::health)) .route("/auth/register", post(auth::register)) + .route("/auth/registration", get(auth::registration_info)) .route("/auth/login", post(auth::login)) .route("/auth/logout", post(auth::logout)) .route("/auth/me", get(auth::me)) diff --git a/backend/src/services/account.rs b/backend/src/services/account.rs index 2f11468..adf4e5c 100644 --- a/backend/src/services/account.rs +++ b/backend/src/services/account.rs @@ -10,6 +10,7 @@ use mongodb::{ }; use crate::{ + config::RegistrationMode, db::{col, is_duplicate_key}, error::{AppError, AppResult}, models::{ @@ -28,6 +29,21 @@ pub struct NewAccount { pub invite_code: Option, } +/// Şu an davetsiz (yeni hane kurarak) kayıt yapılabilir mi? +pub async fn new_household_allowed(db: &Database, mode: RegistrationMode) -> AppResult { + Ok(match mode { + RegistrationMode::Open => true, + // Hiç hane yoksa ilk kurulum serbest (sunucu sahibi kendi hesabını açar) + RegistrationMode::InviteOnly => { + db.collection::(col::HOUSEHOLDS) + .count_documents(doc! {}) + .limit(1) + .await? + == 0 + } + }) +} + pub struct CreatedAccount { pub user_id: Uuid, pub household_id: Uuid, @@ -36,7 +52,16 @@ pub struct CreatedAccount { /// Kaydı **tek transaction** içinde yapar: herhangi bir adım başarısız olursa /// hiçbir kayıt oluşmaz ve (varsa) davet kodu kullanılmamış sayılır. -pub async fn register(db: &Database, acc: NewAccount) -> AppResult { +pub async fn register( + db: &Database, + mode: RegistrationMode, + acc: NewAccount, +) -> AppResult { + // Davetsiz kayıt = yeni hane → politika kontrolü (arayüzden bağımsız, API'de zorunlu) + if acc.invite_code.is_none() && !new_household_allowed(db, mode).await? { + return Err(AppError::RegistrationClosed); + } + let user_id = new_id(); let mut session = db.client().start_session().await?; diff --git a/backend/tests/common/mod.rs b/backend/tests/common/mod.rs index 4923c84..346d249 100644 --- a/backend/tests/common/mod.rs +++ b/backend/tests/common/mod.rs @@ -11,7 +11,11 @@ use axum::{ http::{Method, Request, StatusCode, header}, }; use http_body_util::BodyExt; -use kakeibo::{config::Config, db, routes, state::AppState}; +use kakeibo::{ + config::{Config, RegistrationMode}, + db, routes, + state::AppState, +}; use mongodb::Database; use serde_json::{Value, json}; use tower::ServiceExt; @@ -29,7 +33,12 @@ pub struct Session { } impl TestApp { + /// Kayıt açık (birden fazla hane gerektiren testler için). pub async fn new() -> Self { + Self::with_registration(RegistrationMode::Open).await + } + + pub async fn with_registration(registration: RegistrationMode) -> Self { dotenvy::dotenv().ok(); let uri = std::env::var("MONGODB_URI") .expect("MONGODB_URI tanımlı değil (.env dosyasını kontrol edin)"); @@ -46,6 +55,7 @@ impl TestApp { jwt_secret: "test-jwt-secret-en-az-32-karakter-olmali!!".into(), app_url: "http://localhost".into(), port: 0, + registration, }; let router = routes::router(AppState::new(db.clone(), config)); Self { router, db } diff --git a/backend/tests/registration.rs b/backend/tests/registration.rs new file mode 100644 index 0000000..2f7ca82 --- /dev/null +++ b/backend/tests/registration.rs @@ -0,0 +1,70 @@ +//! Kayıt politikası: REGISTRATION=invite_only (varsayılan). + +mod common; + +use axum::http::{Method, StatusCode}; +use common::TestApp; +use kakeibo::config::RegistrationMode; +use serde_json::json; + +async fn new_household_allowed(app: &TestApp) -> bool { + let (st, body, _) = app + .send(Method::GET, "/auth/registration", None, None) + .await; + assert_eq!(st, StatusCode::OK); + body["new_household_allowed"].as_bool().unwrap() +} + +#[tokio::test] +async fn invite_only_allows_first_household_then_only_invites() { + let app = TestApp::with_registration(RegistrationMode::InviteOnly).await; + + // Boş sistem: ilk kurulum serbest + assert!(new_household_allowed(&app).await); + let owner = app.register("Sahip", None).await; + + // Artık yeni hane kurulamaz (arayüz bilgisi + API zorunluluğu) + assert!(!new_household_allowed(&app).await); + let (st, body, _) = app + .send( + Method::POST, + "/auth/register", + None, + Some( + json!({ "name": "Yabancı", "email": "yabanci@test.com", "password": "sifre12345" }), + ), + ) + .await; + assert_eq!(st, StatusCode::FORBIDDEN); + assert_eq!(body["error"]["code"], "registration_closed"); + + // Davetle katılım serbest + let (_, inv) = app.post("/household/invites", &owner, json!({})).await; + let member = app + .register("Es", Some(inv["code"].as_str().unwrap())) + .await; + assert_eq!(member.household_id, owner.household_id); + + // Geçersiz davet kodu yine 400 (politika hatası değil) + let (st, _, _) = app + .send( + Method::POST, + "/auth/register", + None, + Some(json!({ "name": "X", "email": "x@test.com", "password": "sifre12345", "invite_code": "ZZZZZZZZ" })), + ) + .await; + assert_eq!(st, StatusCode::BAD_REQUEST); + + app.cleanup().await; +} + +#[tokio::test] +async fn open_mode_allows_many_households() { + let app = TestApp::with_registration(RegistrationMode::Open).await; + let a = app.register("A", None).await; + let b = app.register("B", None).await; + assert_ne!(a.household_id, b.household_id); + assert!(new_household_allowed(&app).await); + app.cleanup().await; +} diff --git a/docs/MEMORY.md b/docs/MEMORY.md index 9a1aa81..c31cda8 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -31,6 +31,8 @@ - Canlıda SPA yönlendirmesi (Axum: bilinmeyen yol → index.html) Faz 3'te yapılmalı. - Kayıt ekranı: "Yeni hane kur" / "Davetle katıl"; davet bağlantısı `/kayit?davet=KOD`. Sunucu 409 → e-posta alanı, "Davet…" 400 → kod alanı. - Yönlendirme sonrası toast için `mutateAsync` + await kullanıldı (mutate() callback'leri bileşen kalkınca çalışmayabilir). +- **Kayıt politikası (kullanıcı kararı, 2026-11-12): Seçenek 1** → `REGISTRATION=invite_only` varsayılan; sistemde hiç hane yokken ilk kayıt yeni hane kurar (sunucu sahibi), sonra yalnızca davetle. `open` isteğe bağlı. Canlıda ilk hesabı kullanıcı `/kayit`'tan açar; demo hesaplar canlıya taşınmaz. +- Prettier ayarı eklendi (önceden yoktu → VS Code kaydedince çift tırnağa çevirebilirdi). - Sürümler: Rust 1.99.1 (edition 2024), Axum 0.8, React 19, Vite 8, TypeScript 7, Tailwind 4.3, shadcn 4.23, Vitest 5, react-router 8, TanStack Query 5. ## ❓ Açık Konular @@ -62,3 +64,7 @@ - **Adım 3:** ts-rs eklendi (17 tip), health ve hata gövdesi struct'a çevrildi (`ApiErrorBody`). Frontend: @tanstack/react-query, `lib/api.ts` (+7 vitest), `lib/query.ts`, `lib/auth.ts`, main.tsx'te QueryClientProvider. Geçici oturum paneli. Tarayıcı e2e (CDP betiği): yanlış şifre mesajı, giriş, yenilemede kalıcılık, httpOnly (document.cookie'de yok), çıkış — hepsi geçti. - **Adım 4:** react-router 8 (createBrowserRouter), `RequireAuth`/`RequireGuest`/`Splash`, `AppLayout` (masaüstü kenar çubuğu + mobil üst çubuk + 5'li alt menü, safe-area), Diğer sayfası, 7 yer tutucu sayfa, 404, `lib/strings.ts`, `useDocumentTitle`, `lib/return-path.ts` (+10 test, açık yönlendirme koruması), ThemeToggle `compact`. Tasarım önizlemesi → `/dev/tasarim` (yalnızca DEV). CDP e2e (10 kontrol) iki hata buldu, düzeltildi: giriş sonrası `donus` yarışı, çıkışta `qc.clear()`. Masaüstü içerik boşluğu düzeltildi. - **Adım 5:** react-hook-form, zod 4, @hookform/resolvers, sonner; shadcn input/label/checkbox/card (44 px / 20 px'e büyütüldü). `components/form-field.tsx`, `components/toaster.tsx`, `features/auth/` (auth-shell, login-page, register-page, schemas + 8 vitest). Metinler `t.auth`. CDP e2e 11 kontrol geçti (boş form, yanlış şifre, giriş + toast, davet linki, kayıtlı e-posta → alan hatası, geçersiz davet → alan hatası, yeni hane). Frontend 47 test. **Faz 2 tamamlandı.** + +### 2026-11-12 — Faz 3 öncesi +- Kullanıcı `api.http`'deki kayıt örneğini (yeni@ornek.com) hesap sandı → dosya başlığına hazır hesaplar kutusu ve "bu bir KAYIT isteğidir" uyarısı eklendi. +- Kayıt politikası: `config.rs` `RegistrationMode`, `AppError::RegistrationClosed` (403), `account::new_household_allowed`, `GET /api/auth/registration` (`RegistrationInfo` TS tipi). `tests/registration.rs` (2). Kayıt ekranı kapalıyken yalnızca davet modu + açıklama. `.env`/`.env.example`: `REGISTRATION=invite_only`. Prettier (`.prettierrc.json`, `.prettierignore`, `format` / `format:check`), tüm src biçimlendirildi (yalnızca boşluk). diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 0000000..47c1b9c --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1,5 @@ +# ts-rs tarafından üretilir (npm run types) +src/types/ +# shadcn/ui kaynakları (kendi biçimleriyle bırakılır) +src/components/ui/ +dist/ diff --git a/frontend/.prettierrc.json b/frontend/.prettierrc.json new file mode 100644 index 0000000..3ee2d11 --- /dev/null +++ b/frontend/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "semi": false, + "singleQuote": true, + "printWidth": 100, + "trailingComma": "all" +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0cff873..4ac5127 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -32,6 +32,7 @@ "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^6.1.2", "oxlint": "^1.90.0", + "prettier": "^3.10.0", "typescript": "~7.0.2", "vite": "^8.3.6", "vitest": "^5.1.0" @@ -5945,6 +5946,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/prettier": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.10.0.tgz", + "integrity": "sha512-mBi4765LgjO1PFjlEs7pUeK0nb1bw71c+N4/ItfeUWNdY5cV2pRKaFpbbFRYmDJMO+5ZwzGxHRqHxFZna7iv5w==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-ms": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 2a0751d..a38652e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,7 +9,9 @@ "lint": "oxlint", "preview": "vite preview", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "format": "prettier --write \"src/**/*.{ts,tsx,css}\"", + "format:check": "prettier --check \"src/**/*.{ts,tsx,css}\"" }, "dependencies": { "@fontsource-variable/inter": "^5.3.0", @@ -36,6 +38,7 @@ "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^6.1.2", "oxlint": "^1.90.0", + "prettier": "^3.10.0", "typescript": "~7.0.2", "vite": "^8.3.6", "vitest": "^5.1.0" diff --git a/frontend/src/components/form-field.tsx b/frontend/src/components/form-field.tsx index e2aef3c..5bc78bd 100644 --- a/frontend/src/components/form-field.tsx +++ b/frontend/src/components/form-field.tsx @@ -22,7 +22,11 @@ export function FormField({ hint?: string className?: string /** Alanı üreten fonksiyon: kimlik ve aria özelliklerini alır */ - children: (field: { id: string; 'aria-invalid': boolean; 'aria-describedby'?: string }) => ReactNode + children: (field: { + id: string + 'aria-invalid': boolean + 'aria-describedby'?: string + }) => ReactNode }) { const id = useId() const messageId = `${id}-message` @@ -37,7 +41,10 @@ export function FormField({ 'aria-describedby': message ? messageId : undefined, })} {message && ( -

+

{message}

)} @@ -50,7 +57,11 @@ export function PasswordInput(props: ComponentProps) { const [visible, setVisible] = useState(false) return (
- + - ))} -
- )} - /> + {!newAllowed && ( +

+ + {s.closedNote} +

+ )} + {newAllowed && ( + ( +
+ {( + [ + ['new', s.modeNew, Home], + ['join', s.modeJoin, Users], + ] as const + ).map(([value, label, Icon]) => ( + + ))} +
+ )} + /> + )} {mode === 'new' ? ( - - {(f) => } + + {(f) => ( + + )} ) : ( diff --git a/frontend/src/features/auth/schemas.test.ts b/frontend/src/features/auth/schemas.test.ts index d258000..e43ea16 100644 --- a/frontend/src/features/auth/schemas.test.ts +++ b/frontend/src/features/auth/schemas.test.ts @@ -1,9 +1,18 @@ import { describe, expect, it } from 'vitest' import { loginSchema, normalizeInviteCode, registerSchema } from './schemas' -const base = { name: 'Suphi', email: 'a@b.co', password: '12345678', mode: 'new' as const, household_name: '', invite_code: '' } -const issues = (r: { success: boolean; error?: { issues: { path: PropertyKey[]; message: string }[] } }) => - Object.fromEntries((r.error?.issues ?? []).map((i) => [i.path.join('.'), i.message])) +const base = { + name: 'Suphi', + email: 'a@b.co', + password: '12345678', + mode: 'new' as const, + household_name: '', + invite_code: '', +} +const issues = (r: { + success: boolean + error?: { issues: { path: PropertyKey[]; message: string }[] } +}) => Object.fromEntries((r.error?.issues ?? []).map((i) => [i.path.join('.'), i.message])) describe('loginSchema', () => { it('boş alanlarda Türkçe mesaj', () => { @@ -13,9 +22,9 @@ describe('loginSchema', () => { }) }) it('geçersiz e-posta', () => { - expect(issues(loginSchema.safeParse({ email: 'abc', password: 'x', remember_me: false })).email).toBe( - 'Geçerli bir e-posta adresi girin', - ) + expect( + issues(loginSchema.safeParse({ email: 'abc', password: 'x', remember_me: false })).email, + ).toBe('Geçerli bir e-posta adresi girin') }) it('e-postadaki boşlukları kırpar', () => { const r = loginSchema.safeParse({ email: ' a@b.co ', password: 'x', remember_me: true }) @@ -34,11 +43,15 @@ describe('registerSchema', () => { }) }) it('davet modunda kod zorunlu ve 8 karakter', () => { - expect(issues(registerSchema.safeParse({ ...base, mode: 'join' })).invite_code).toBe('Davet kodunu yaz') - expect(issues(registerSchema.safeParse({ ...base, mode: 'join', invite_code: 'ABC' })).invite_code).toBe( - 'Davet kodu 8 karakter olmalı', + expect(issues(registerSchema.safeParse({ ...base, mode: 'join' })).invite_code).toBe( + 'Davet kodunu yaz', ) - expect(registerSchema.safeParse({ ...base, mode: 'join', invite_code: 'abcd-efgh' }).success).toBe(true) + expect( + issues(registerSchema.safeParse({ ...base, mode: 'join', invite_code: 'ABC' })).invite_code, + ).toBe('Davet kodu 8 karakter olmalı') + expect( + registerSchema.safeParse({ ...base, mode: 'join', invite_code: 'abcd-efgh' }).success, + ).toBe(true) }) it('yeni hane modunda davet kodu yok sayılır', () => { expect(registerSchema.safeParse({ ...base, invite_code: 'x' }).success).toBe(true) diff --git a/frontend/src/features/auth/schemas.ts b/frontend/src/features/auth/schemas.ts index 069a1e3..a768b45 100644 --- a/frontend/src/features/auth/schemas.ts +++ b/frontend/src/features/auth/schemas.ts @@ -7,7 +7,11 @@ import { t } from '@/lib/strings' const m = t.auth.validation -const email = z.string().trim().min(1, { error: m.emailRequired }).pipe(z.email({ error: m.emailInvalid })) +const email = z + .string() + .trim() + .min(1, { error: m.emailRequired }) + .pipe(z.email({ error: m.emailInvalid })) export const loginSchema = z.object({ email, @@ -32,7 +36,8 @@ export const registerSchema = z if (v.mode !== 'join') return const code = normalizeInviteCode(v.invite_code) if (!code) ctx.addIssue({ code: 'custom', path: ['invite_code'], message: m.inviteRequired }) - else if (code.length !== 8) ctx.addIssue({ code: 'custom', path: ['invite_code'], message: m.inviteInvalid }) + else if (code.length !== 8) + ctx.addIssue({ code: 'custom', path: ['invite_code'], message: m.inviteInvalid }) }) export type RegisterForm = z.infer diff --git a/frontend/src/features/dev/design-preview.tsx b/frontend/src/features/dev/design-preview.tsx index 4a3a327..8602b7c 100644 --- a/frontend/src/features/dev/design-preview.tsx +++ b/frontend/src/features/dev/design-preview.tsx @@ -32,7 +32,9 @@ export function DesignPreview() {

{health.isError && ● {health.error.message}} - {health.isPending && ● Backend kontrol ediliyor…} + {health.isPending && ( + ● Backend kontrol ediliyor… + )} {health.data && ( ● Backend çalışıyor (v{health.data.version}, DB: {health.data.db}) @@ -40,7 +42,6 @@ export function DesignPreview() { )}

-

Ana gruplar

@@ -65,7 +66,7 @@ export function DesignPreview() { {[ ['Gelir', formatMoney(4_500_000, 'TRY', { signed: true }), 'text-income'], ['Gider', formatMoney(-125_000), 'text-expense'], - ['Bütçenin %85\'i kullanıldı', formatMoney(85_000), 'text-warning'], + ["Bütçenin %85'i kullanıldı", formatMoney(85_000), 'text-warning'], ['Euro', formatMoney(30_000, 'EUR'), ''], ['Dolar', formatMoney(15_000, 'USD'), ''], ['Gram altın', formatQuantity(12_500_000, 'gr'), ''], diff --git a/frontend/src/index.css b/frontend/src/index.css index 14f5192..6637856 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,184 +1,184 @@ -@import "tailwindcss"; -@import "tw-animate-css"; -@import "shadcn/tailwind.css"; -@import "@fontsource-variable/inter"; +@import 'tailwindcss'; +@import 'tw-animate-css'; +@import 'shadcn/tailwind.css'; +@import '@fontsource-variable/inter'; @custom-variant dark (&:is(.dark *)); @theme inline { - --font-heading: var(--font-sans); - --font-sans: 'Inter Variable', system-ui, sans-serif; + --font-heading: var(--font-sans); + --font-sans: 'Inter Variable', system-ui, sans-serif; - /* Kakeibo: 4 ana grup + durum renkleri (aşağıdaki :root / .dark değişkenlerinden) */ - --color-needs: var(--needs); - --color-needs-fg: var(--needs-fg); - --color-needs-soft: var(--needs-soft); - --color-wants: var(--wants); - --color-wants-fg: var(--wants-fg); - --color-wants-soft: var(--wants-soft); - --color-culture: var(--culture); - --color-culture-fg: var(--culture-fg); - --color-culture-soft: var(--culture-soft); - --color-unexpected: var(--unexpected); - --color-unexpected-fg: var(--unexpected-fg); - --color-unexpected-soft: var(--unexpected-soft); - --color-income: var(--income); - --color-expense: var(--expense); - --color-warning: var(--warning); - --color-sidebar-ring: var(--sidebar-ring); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar: var(--sidebar); - --color-chart-5: var(--chart-5); - --color-chart-4: var(--chart-4); - --color-chart-3: var(--chart-3); - --color-chart-2: var(--chart-2); - --color-chart-1: var(--chart-1); - --color-ring: var(--ring); - --color-input: var(--input); - --color-border: var(--border); - --color-destructive: var(--destructive); - --color-accent-foreground: var(--accent-foreground); - --color-accent: var(--accent); - --color-muted-foreground: var(--muted-foreground); - --color-muted: var(--muted); - --color-secondary-foreground: var(--secondary-foreground); - --color-secondary: var(--secondary); - --color-primary-foreground: var(--primary-foreground); - --color-primary: var(--primary); - --color-popover-foreground: var(--popover-foreground); - --color-popover: var(--popover); - --color-card-foreground: var(--card-foreground); - --color-card: var(--card); - --color-foreground: var(--foreground); - --color-background: var(--background); - --radius-sm: calc(var(--radius) * 0.6); - --radius-md: calc(var(--radius) * 0.8); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) * 1.4); - --radius-2xl: calc(var(--radius) * 1.8); - --radius-3xl: calc(var(--radius) * 2.2); - --radius-4xl: calc(var(--radius) * 2.6); + /* Kakeibo: 4 ana grup + durum renkleri (aşağıdaki :root / .dark değişkenlerinden) */ + --color-needs: var(--needs); + --color-needs-fg: var(--needs-fg); + --color-needs-soft: var(--needs-soft); + --color-wants: var(--wants); + --color-wants-fg: var(--wants-fg); + --color-wants-soft: var(--wants-soft); + --color-culture: var(--culture); + --color-culture-fg: var(--culture-fg); + --color-culture-soft: var(--culture-soft); + --color-unexpected: var(--unexpected); + --color-unexpected-fg: var(--unexpected-fg); + --color-unexpected-soft: var(--unexpected-soft); + --color-income: var(--income); + --color-expense: var(--expense); + --color-warning: var(--warning); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --color-foreground: var(--foreground); + --color-background: var(--background); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); } :root { - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.42 0.13 268); /* ai-iro: Japon indigosu */ - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.55 0.13 268); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); - --radius: 0.625rem; - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.42 0.13 268); /* ai-iro: Japon indigosu */ + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.55 0.13 268); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); - /* 4 ana grup — Okabe-Ito (renk körlüğüne uygun) paletinden. + /* 4 ana grup — Okabe-Ito (renk körlüğüne uygun) paletinden. *: çubuk/dolgu · *-fg: yazı (WCAG AA ≥ 4.5:1) · *-soft: zemin */ - --needs: #0072b2; - --needs-fg: #005a8c; - --needs-soft: #e6f1f8; - --wants: #cc79a7; - --wants-fg: #9c3f72; - --wants-soft: #f9eef4; - --culture: #009e73; - --culture-fg: #00714f; - --culture-soft: #e5f5f0; - --unexpected: #d55e00; - --unexpected-fg: #a84a00; - --unexpected-soft: #fbefe5; + --needs: #0072b2; + --needs-fg: #005a8c; + --needs-soft: #e6f1f8; + --wants: #cc79a7; + --wants-fg: #9c3f72; + --wants-soft: #f9eef4; + --culture: #009e73; + --culture-fg: #00714f; + --culture-soft: #e5f5f0; + --unexpected: #d55e00; + --unexpected-fg: #a84a00; + --unexpected-soft: #fbefe5; - /* Durum: gelir/olumlu · gider/aşım · uyarı (yazı olarak AA uyumlu) */ - --income: #15803d; - --expense: #c62828; - --warning: #b45309; + /* Durum: gelir/olumlu · gider/aşım · uyarı (yazı olarak AA uyumlu) */ + --income: #15803d; + --expense: #c62828; + --warning: #b45309; } .dark { - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.72 0.12 268); - --primary-foreground: oklch(0.18 0.03 268); - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.62 0.12 268); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); - --sidebar: oklch(0.205 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.556 0 0); + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.72 0.12 268); + --primary-foreground: oklch(0.18 0.03 268); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.62 0.12 268); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); - /* Koyu zeminde daha açık tonlar (kontrast için) */ - --needs: #3a9fdb; - --needs-fg: #7cc3f0; - --needs-soft: #10283a; - --wants: #d98fb8; - --wants-fg: #ecb6d4; - --wants-soft: #35202c; - --culture: #1fbf8f; - --culture-fg: #6fdcb9; - --culture-soft: #0c2e25; - --unexpected: #f07a24; - --unexpected-fg: #f8a96b; - --unexpected-soft: #3a2010; + /* Koyu zeminde daha açık tonlar (kontrast için) */ + --needs: #3a9fdb; + --needs-fg: #7cc3f0; + --needs-soft: #10283a; + --wants: #d98fb8; + --wants-fg: #ecb6d4; + --wants-soft: #35202c; + --culture: #1fbf8f; + --culture-fg: #6fdcb9; + --culture-soft: #0c2e25; + --unexpected: #f07a24; + --unexpected-fg: #f8a96b; + --unexpected-soft: #3a2010; - --income: #4ade80; - --expense: #f87171; - --warning: #fbbf24; + --income: #4ade80; + --expense: #f87171; + --warning: #fbbf24; } @layer base { * { @apply border-border outline-ring/50; - } + } body { @apply bg-background text-foreground; - } + } html { @apply font-sans; font-size: 16px; @@ -206,4 +206,4 @@ transition-duration: 0.01ms !important; scroll-behavior: auto !important; } -} \ No newline at end of file +} diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index d9d1f29..31ae087 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -43,7 +43,10 @@ export function useRegister() { const qc = useQueryClient() return useMutation({ mutationFn: (body: RegisterRequest) => api.post('/auth/register', body), - onSuccess: (me) => qc.setQueryData(queryKeys.me, me), + onSuccess: (me) => { + qc.setQueryData(queryKeys.me, me) + qc.removeQueries({ queryKey: queryKeys.registration }) + }, }) } diff --git a/frontend/src/lib/query.ts b/frontend/src/lib/query.ts index 7c6384b..36c6511 100644 --- a/frontend/src/lib/query.ts +++ b/frontend/src/lib/query.ts @@ -7,6 +7,7 @@ import { ApiError } from './api' /** Önbellek anahtarları tek yerde (yazım hatası olmasın) */ export const queryKeys = { me: ['me'] as const, + registration: ['registration'] as const, health: ['health'] as const, household: ['household'] as const, categories: (includeInactive = false) => ['categories', { includeInactive }] as const, diff --git a/frontend/src/lib/return-path.test.ts b/frontend/src/lib/return-path.test.ts index 4147f2d..83d1f5c 100644 --- a/frontend/src/lib/return-path.test.ts +++ b/frontend/src/lib/return-path.test.ts @@ -6,10 +6,15 @@ describe('safeReturnPath', () => { expect(safeReturnPath(p)).toBe(p) }) - it.each([null, '', 'defter', '//kotu.com', '/\\kotu.com', 'https://kotu.com', 'javascript:alert(1)'])( - 'güvensiz → panele: %s', - (p) => { - expect(safeReturnPath(p)).toBe('/') - }, - ) + it.each([ + null, + '', + 'defter', + '//kotu.com', + '/\\kotu.com', + 'https://kotu.com', + 'javascript:alert(1)', + ])('güvensiz → panele: %s', (p) => { + expect(safeReturnPath(p)).toBe('/') + }) }) diff --git a/frontend/src/lib/strings.ts b/frontend/src/lib/strings.ts index 6eb8cbd..19fa003 100644 --- a/frontend/src/lib/strings.ts +++ b/frontend/src/lib/strings.ts @@ -23,9 +23,15 @@ export const t = { pages: { dashboard: { title: 'Panel', description: 'Gelir, harcama ve birikimin tek bakışta özeti.' }, ledger: { title: 'Günlük Defter', description: 'Haftalık harcama girişleri.' }, - planning: { title: 'Aylık Plan', description: 'Gelir, sabit giderler, birikim hedefi ve bütçe dağıtımı.' }, + planning: { + title: 'Aylık Plan', + description: 'Gelir, sabit giderler, birikim hedefi ve bütçe dağıtımı.', + }, savings: { title: 'Birikimler', description: 'TL, altın ve döviz varlıkların, kâr/zarar.' }, - review: { title: 'Ay Sonu Değerlendirmesi', description: "Kakeibo'nun dört sorusu ve dönem kapanışı." }, + review: { + title: 'Ay Sonu Değerlendirmesi', + description: "Kakeibo'nun dört sorusu ve dönem kapanışı.", + }, reports: { title: 'Raporlar', description: 'Gelir–gider, kategori ve bütçe sapma raporları.' }, settings: { title: 'Ayarlar', description: 'Kategoriler, ödeme türleri, hane ve hesap.' }, more: { title: 'Diğer' }, @@ -73,6 +79,8 @@ export const t = { householdHint: (name: string) => (name ? `Boş bırakırsan: "${name} Hanesi"` : 'İsteğe bağlı'), inviteCode: 'Davet kodu', inviteHint: 'Hane sahibinden aldığın 8 karakterlik kod (ör. ABCD-EFGH).', + closedNote: + 'Bu sunucuda yeni hane kaydı kapalı. Katılmak için hane sahibinden davet bağlantısı ya da kodu iste.', submit: 'Hesabı oluştur', submitting: 'Oluşturuluyor…', hasAccount: 'Zaten hesabın var mı?', diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index f815ded..ae9e5a7 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -1 +1 @@ -export { cn } from "cn" +export { cn } from 'cn' diff --git a/frontend/src/types/RegistrationInfo.ts b/frontend/src/types/RegistrationInfo.ts new file mode 100644 index 0000000..96cb132 --- /dev/null +++ b/frontend/src/types/RegistrationInfo.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Kayıt ekranı için: yeni hane kurulabilir mi? (oturum gerektirmez) + */ +export type RegistrationInfo = { +/** + * `false` ise yalnızca davet koduyla kayıt yapılabilir + */ +new_household_allowed: boolean, };