diff --git a/CLAUDE.md b/CLAUDE.md index 2596f0b..7659052 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,7 +83,7 @@ npm run db:restore -- [--force] - Biçim: Prettier (`frontend/.prettierrc.json`: tek tırnak, `;` yok, 100 sütun); `npm --prefix frontend run format`. `src/types/` ve `components/ui/` hariç. - JSON yedeği: `services/backup.rs` (`collect` → `to_json`; `parse` doğrular; `restore` = geri dönüş noktası + tam değiştirme tek transaction; `retarget` kimlik çevirir). Yeni hane koleksiyonu eklenirse `backup::KINDS`'a da ekle. Biçim: DATA_MODEL §3.14b. Arayüz: `features/settings/backup-section.tsx`, `lib/backup.ts` (geri yükleme/geri alma sonrası tüm sorgular yenilenir). - Aylık rapor: `services/monthly_report.rs` (`gather` → `Facts` → saf `analyze` → `render`), şablon `templates/monthly_report.md` (`include_str!`; Dockerfile `templates/`'u kopyalar). Filtreler `tl`/`pct`/`cell`. Şablonda ham HTML yok (arayüz göstermez). Arayüz `features/review/report-page.tsx` (`/degerlendirme/rapor?donem=`), `lib/monthly-report.ts`; `api.getText`; `Markdown` tabloları `.table-wrap` içinde kayar; yazdırma `@page A4` + `PageHeader printHidden`. Cümlelerde sayıdan sonra ek kullanma ("%429'u" gibi ünlü uyumu gerekir) → "X / Y", "kullanımı %N". -- Ayarlar: `/ayarlar` menü (+ tema) → alt sayfalar `SettingsSubpage` ("‹ Ayarlar"): `/ayarlar/kategoriler`, `/odeme-turleri`, `/veri` (yedek, CSV, ekstre). Katalog yönetimi `features/settings/catalog-editor.tsx` + `lib/catalog.ts` (`useCreateCatalog`/`useUpdateCatalog`/`useReorderCatalog`, `moveId`); sıralama isteği grubun **tüm** kimlikleri (pasifler dahil); grup sonradan değişmez. +- Ayarlar: `/ayarlar` menü (+ tema) → alt sayfalar `SettingsSubpage` ("‹ Ayarlar"): `/ayarlar/kategoriler`, `/odeme-turleri`, `/butce` (varsayılan yüzdeler + öneri yöntemi), `/hane` (ad, üyeler, davet bağlantısı `/kayit?davet=`), `/hesap` (ad, şifre), `/veri` (yedek, CSV, ekstre). Kancalar `lib/household.ts`. Şifre değişince `users.sessions_valid_from` → eski jetonlar 401 (`AuthUser`). Katalog yönetimi `features/settings/catalog-editor.tsx` + `lib/catalog.ts` (`useCreateCatalog`/`useUpdateCatalog`/`useReorderCatalog`, `moveId`); sıralama isteği grubun **tüm** kimlikleri (pasifler dahil); grup sonradan değişmez. - 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 3cec949..8977b8b 100644 --- a/backend/api.http +++ b/backend/api.http @@ -425,3 +425,26 @@ POST {{baseUrl}}/backup/restore-points/{{pointId}}/restore ### Dönemin raporu (Markdown; açık dönem → ara rapor) GET {{baseUrl}}/periods/{{periodId}}/report.md + +# ─────────────────────────── Hesap ve hane ayarları ─────────────────────────── + +### Profil adı +PATCH {{baseUrl}}/auth/me +Content-Type: application/json + +{ "name": "Suphi" } + +### Şifre değiştir (diğer cihazlarda oturum kapanır, bu cihaza yeni çerez) +POST {{baseUrl}}/auth/me/password +Content-Type: application/json + +{ "current_password": "…", "new_password": "…" } + +### Hane adı + bütçe varsayılanları (sahip; toplam 100) +PATCH {{baseUrl}}/household +Content-Type: application/json + +{ "name": "Çakır Hanesi", "budget_split_pct": { "needs": 50, "wants": 30, "culture": 10, "unexpected": 10 }, "budget_suggestion": "avg3" } + +### Üyeyi çıkar (sahip) +DELETE {{baseUrl}}/household/members/{{memberId}} diff --git a/backend/src/auth/session.rs b/backend/src/auth/session.rs index 3c3ef43..2186924 100644 --- a/backend/src/auth/session.rs +++ b/backend/src/auth/session.rs @@ -3,7 +3,8 @@ //! Bir route parametrelerine `user: AuthUser` eklediğinde, Axum isteği işlemeden önce: //! 1. cookie'deki JWT'nin imzasını ve süresini doğrular, //! 2. kullanıcının veritabanında hâlâ var olduğunu ve aynı haneye ait olduğunu kontrol eder -//! (silinen kullanıcının token'ı anında geçersiz olur, rol değişikliği hemen etkili olur). +//! (silinen kullanıcının token'ı anında geçersiz olur, rol değişikliği hemen etkili olur), +//! 3. jeton şifre değişikliğinden önce verilmişse reddeder (`sessions_valid_from`). //! //! Herhangi biri başarısızsa route hiç çalışmaz, 401 döner. //! **Hane izolasyonu:** route'lar hane kimliğini her zaman `AuthUser.household_id`'den alır, @@ -67,6 +68,9 @@ impl AuthUser { #[derive(Deserialize)] struct RoleOnly { role: Role, + /// Şifre değişince "şimdi": bundan önce verilmiş jetonlar geçersiz + #[serde(default)] + sessions_valid_from: Option, } impl FromRequestParts for AuthUser { @@ -85,9 +89,15 @@ impl FromRequestParts for AuthUser { .db .collection::(col::USERS) .find_one(doc! { "_id": user_id, "household_id": household_id, "deleted_at": null }) - .projection(doc! { "role": 1 }) + .projection(doc! { "role": 1, "sessions_valid_from": 1 }) .await? .ok_or(AppError::Unauthorized)?; + if current + .sessions_valid_from + .is_some_and(|from| (claims.iat as i64) < from) + { + return Err(AppError::Unauthorized); + } Ok(Self { user_id, diff --git a/backend/src/routes/auth.rs b/backend/src/routes/auth.rs index afe5850..b6eaa99 100644 --- a/backend/src/routes/auth.rs +++ b/backend/src/routes/auth.rs @@ -1,5 +1,6 @@ //! Kimlik doğrulama uç noktaları: -//! `POST /api/auth/register`, `POST /api/auth/login`, `POST /api/auth/logout`, `GET /api/auth/me` +//! `POST /api/auth/register`, `POST /api/auth/login`, `POST /api/auth/logout`, `GET /api/auth/me`, +//! `PATCH /api/auth/me` (ad), `POST /api/auth/me/password` (şifre değiştir → diğer oturumlar kapanır) use axum::{Json, extract::State, http::StatusCode}; use axum_extra::extract::cookie::CookieJar; @@ -11,7 +12,7 @@ use validator::Validate; use crate::{ auth::{ AuthUser, password, - session::{removal_cookie, session_cookie, ttl}, + session::{COOKIE_NAME, removal_cookie, session_cookie, ttl}, }, db::col, error::{AppError, AppResult}, @@ -77,6 +78,22 @@ struct HouseholdDto { name: String, } +#[derive(Deserialize, Validate, TS)] +#[ts(export)] +pub struct ProfileRequest { + #[validate(length(min = 1, max = 80, message = "Ad 1–80 karakter olmalı"))] + name: String, +} + +#[derive(Deserialize, Validate, TS)] +#[ts(export)] +pub struct PasswordChangeRequest { + #[validate(length(min = 1, message = "Mevcut şifreyi girin"))] + current_password: String, + #[validate(length(min = 8, max = 128, message = "Yeni şifre en az 8 karakter olmalı"))] + new_password: String, +} + // ---------- Uç noktalar ---------- pub async fn register( @@ -200,6 +217,59 @@ pub async fn me(State(state): State, auth: AuthUser) -> AppResult, + auth: AuthUser, + ValidJson(req): ValidJson, +) -> AppResult> { + account::update_name(&state.db, auth.household_id, auth.user_id, &req.name).await?; + load_me(&state, auth.user_id, auth.household_id) + .await + .map(Json) +} + +/// Şifre değiştir: mevcut şifre doğrulanır (deneme sınırlı), diğer cihazlardaki oturumlar +/// kapanır, bu cihaza yeni oturum çerezi verilir ("beni hatırla" durumu korunur). +pub async fn change_password( + State(state): State, + jar: CookieJar, + auth: AuthUser, + ValidJson(req): ValidJson, +) -> AppResult<(StatusCode, CookieJar)> { + let key = format!("password:{}", auth.user_id); + if !state.login_limiter.is_allowed(&key) { + return Err(AppError::TooManyRequests); + } + let remember = jar + .get(COOKIE_NAME) + .and_then(|c| state.jwt.verify(c.value())) + .is_some_and(|c| c.exp.saturating_sub(c.iat) > ttl(false).as_secs()); + + match account::change_password( + &state.db, + auth.household_id, + auth.user_id, + req.current_password, + req.new_password, + ) + .await + { + Ok(()) => state.login_limiter.reset(&key), + Err(e) => { + if matches!(&e, AppError::BadRequest(m) if m.contains("Mevcut şifre")) { + state.login_limiter.record_failure(&key); + } + return Err(e); + } + } + + let token = state + .jwt + .issue(auth.user_id, auth.household_id, auth.role, ttl(remember))?; + let jar = jar.add(session_cookie(token, remember, state.config.is_https())); + Ok((StatusCode::NO_CONTENT, jar)) +} + // ---------- Yardımcılar ---------- fn normalize_email(email: &str) -> String { diff --git a/backend/src/routes/household.rs b/backend/src/routes/household.rs index 8faeba1..f3774d6 100644 --- a/backend/src/routes/household.rs +++ b/backend/src/routes/household.rs @@ -3,6 +3,8 @@ //! - `GET /api/household/invites` → aktif davetler (yalnızca owner) //! - `POST /api/household/invites` → yeni davet kodu (yalnızca owner) //! - `DELETE /api/household/invites/{id}` → daveti iptal et (yalnızca owner) +//! - `PATCH /api/household` → ad + bütçe varsayılanları (yalnızca owner) +//! - `DELETE /api/household/members/{id}` → üyeyi çıkar (yalnızca owner) use axum::{ Json, @@ -12,13 +14,15 @@ use axum::{ use mongodb::bson::{DateTime, Uuid, doc}; use serde::{Deserialize, Serialize}; use ts_rs::TS; +use validator::Validate; use crate::{ auth::AuthUser, db::col, error::{AppError, AppResult}, + extract::ValidJson, models::{common::scope, household::Household, invite::Invite, user::Role}, - services::invite, + services::{account, invite}, state::AppState, }; @@ -28,6 +32,41 @@ pub struct HouseholdResponse { id: String, name: String, members: Vec, + settings: SettingsDto, +} + +#[derive(Serialize, Deserialize, Validate, TS, Clone, Copy)] +#[ts(export, rename = "BudgetSplitPct")] +pub struct SplitDto { + #[validate(range(max = 100))] + needs: u32, + #[validate(range(max = 100))] + wants: u32, + #[validate(range(max = 100))] + culture: u32, + #[validate(range(max = 100))] + unexpected: u32, +} + +#[derive(Serialize, TS)] +#[ts(export, rename = "HouseholdSettings")] +struct SettingsDto { + /// Varsayılan bütçe dağılımı (yüzde, toplam 100) + budget_split_pct: SplitDto, + /// Plan ekranında önce seçili öneri yöntemi + #[ts(type = "\"default\" | \"previous\" | \"avg3\"")] + budget_suggestion: String, +} + +#[derive(Deserialize, Validate, TS)] +#[ts(export, optional_fields)] +pub struct HouseholdUpdateRequest { + #[validate(length(min = 1, max = 80, message = "Hane adı 1–80 karakter olmalı"))] + name: Option, + #[validate(nested)] + budget_split_pct: Option, + #[ts(type = "\"default\" | \"previous\" | \"avg3\"", optional)] + budget_suggestion: Option, } #[derive(Serialize, TS)] @@ -78,9 +117,16 @@ pub async fn get( let household = state .db .collection::(col::HOUSEHOLDS) - .find_one(hh_filter) + .find_one(hh_filter.clone()) .await? .ok_or(AppError::NotFound)?; + let settings = state + .db + .collection::(col::HOUSEHOLDS) + .find_one(hh_filter) + .await? + .and_then(|d| d.get_document("settings").ok().cloned()) + .unwrap_or_default(); let mut cursor = state .db @@ -105,9 +151,66 @@ pub async fn get( id: hid.to_string(), name: household.name, members, + settings: settings_dto(&settings), })) } +fn settings_dto(s: &mongodb::bson::Document) -> SettingsDto { + let pct = s.get_document("budget_split_pct").ok(); + let v = |k: &str, d: u32| { + pct.and_then(|p| match p.get(k) { + Some(mongodb::bson::Bson::Int32(v)) => u32::try_from(*v).ok(), + Some(mongodb::bson::Bson::Int64(v)) => u32::try_from(*v).ok(), + _ => None, + }) + .unwrap_or(d) + }; + SettingsDto { + budget_split_pct: SplitDto { + needs: v("needs", 50), + wants: v("wants", 30), + culture: v("culture", 10), + unexpected: v("unexpected", 10), + }, + budget_suggestion: s + .get_str("budget_suggestion") + .unwrap_or("default") + .to_string(), + } +} + +pub async fn update( + State(state): State, + auth: AuthUser, + ValidJson(req): ValidJson, +) -> AppResult> { + auth.require_owner()?; + account::update_household( + &state.db, + auth.household_id, + account::HouseholdChanges { + name: req.name, + split_pct: req + .budget_split_pct + .map(|p| [p.needs, p.wants, p.culture, p.unexpected]), + suggestion: req.budget_suggestion, + }, + ) + .await?; + get(State(state), auth).await +} + +pub async fn remove_member( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> AppResult { + auth.require_owner()?; + let id = Uuid::parse_str(&id).map_err(|_| AppError::NotFound)?; + account::remove_member(&state.db, auth.household_id, auth.user_id, id).await?; + Ok(StatusCode::NO_CONTENT) +} + pub async fn list_invites( State(state): State, auth: AuthUser, diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 013af1a..34e0630 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -49,8 +49,10 @@ pub fn router(state: AppState) -> Router { .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)) - .route("/household", get(household::get)) + .route("/auth/me", get(auth::me).patch(auth::update_me)) + .route("/household", get(household::get).patch(household::update)) + .route("/household/members/{id}", delete(household::remove_member)) + .route("/auth/me/password", post(auth::change_password)) .route( "/household/invites", get(household::list_invites).post(household::create_invite), diff --git a/backend/src/services/account.rs b/backend/src/services/account.rs index 7abe79e..d61e7d5 100644 --- a/backend/src/services/account.rs +++ b/backend/src/services/account.rs @@ -1,4 +1,4 @@ -//! Hesap işlemleri: kayıt. +//! Hesap işlemleri: kayıt; profil, şifre, hane ayarları, üye çıkarma. //! //! İki yol var: //! - **Davetsiz:** yeni hane + `owner` kullanıcı + örnek kategori/ödeme türleri @@ -147,3 +147,178 @@ async fn create_in_tx( role, }) } + +// ---------- Profil, şifre, hane ayarları, üyeler (Faz 10) ---------- + +/// Oturumların geçerlilik başlangıcı (Unix saniye). Şifre değişince "şimdi" yapılır: +/// daha önce verilmiş tüm oturum jetonları (`iat` daha küçük) reddedilir. +pub const SESSIONS_VALID_FROM: &str = "sessions_valid_from"; + +fn unix_now() -> i64 { + time::OffsetDateTime::now_utc().unix_timestamp() +} + +fn clean_text(v: &str, what: &str, max: usize) -> AppResult { + let v = v.trim().to_string(); + if v.is_empty() { + return Err(AppError::BadRequest(format!("{what} boş olamaz"))); + } + if v.chars().count() > max { + return Err(AppError::BadRequest(format!( + "{what} en fazla {max} karakter olabilir" + ))); + } + Ok(v) +} + +pub async fn update_name( + db: &Database, + household_id: Uuid, + user_id: Uuid, + name: &str, +) -> AppResult<()> { + let name = clean_text(name, "Ad", 80)?; + let r = db + .collection::(col::USERS) + .update_one( + doc! { "_id": user_id, "household_id": household_id, "deleted_at": null }, + doc! { "$set": { "name": name, "updated_at": crate::models::common::now() }, "$inc": { "version": 1 } }, + ) + .await?; + if r.matched_count == 0 { + return Err(AppError::Unauthorized); + } + Ok(()) +} + +/// Mevcut şifre doğruysa yenisini yazar ve bu andan önce verilmiş tüm oturumları geçersiz +/// kılar (route bu cihaza hemen yeni bir oturum verir). +pub async fn change_password( + db: &Database, + household_id: Uuid, + user_id: Uuid, + current: String, + new: String, +) -> AppResult<()> { + if new.chars().count() < 8 || new.chars().count() > 128 { + return Err(AppError::BadRequest( + "Yeni şifre en az 8 karakter olmalı".into(), + )); + } + if new == current { + return Err(AppError::BadRequest( + "Yeni şifre mevcut şifreden farklı olmalı".into(), + )); + } + let users = db.collection::(col::USERS); + let filter = doc! { "_id": user_id, "household_id": household_id, "deleted_at": null }; + let user = users + .find_one(filter.clone()) + .await? + .ok_or(AppError::Unauthorized)?; + let hash = user.get_str("password_hash").ok().map(str::to_string); + if !crate::auth::password::verify(current, hash).await { + return Err(AppError::BadRequest("Mevcut şifre hatalı".into())); + } + let new_hash = crate::auth::password::hash(new).await?; + users + .update_one( + filter, + doc! { + "$set": { + "password_hash": new_hash, + SESSIONS_VALID_FROM: unix_now(), + "updated_at": crate::models::common::now(), + }, + "$inc": { "version": 1 }, + }, + ) + .await?; + tracing::info!("Kullanıcı {user_id} şifresini değiştirdi; diğer oturumlar kapatıldı"); + Ok(()) +} + +/// Hane ayarlarında değiştirilebilenler (`None` → dokunma) +#[derive(Default)] +pub struct HouseholdChanges { + pub name: Option, + /// `Group::ALL` sırasıyla, toplamı 100 + pub split_pct: Option<[u32; 4]>, + /// `default` | `previous` | `avg3` + pub suggestion: Option, +} + +pub async fn update_household( + db: &Database, + household_id: Uuid, + c: HouseholdChanges, +) -> AppResult<()> { + let mut set = doc! { "updated_at": crate::models::common::now() }; + if let Some(name) = c.name { + set.insert("name", clean_text(&name, "Hane adı", 80)?); + } + if let Some(p) = c.split_pct { + if p.iter().any(|v| *v > 100) || p.iter().sum::() != 100 { + return Err(AppError::BadRequest("Yüzdelerin toplamı 100 olmalı".into())); + } + let mut pct = Document::new(); + for (g, v) in crate::models::group::Group::ALL.iter().zip(p) { + pct.insert(g.as_str(), v as i32); + } + set.insert("settings.budget_split_pct", pct); + } + if let Some(m) = c.suggestion { + if !["default", "previous", "avg3"].contains(&m.as_str()) { + return Err(AppError::BadRequest("Geçersiz öneri yöntemi".into())); + } + set.insert("settings.budget_suggestion", m); + } + db.collection::(col::HOUSEHOLDS) + .update_one( + doc! { "_id": household_id, "deleted_at": null }, + doc! { "$set": set, "$inc": { "version": 1 } }, + ) + .await?; + Ok(()) +} + +/// Sahip bir üyeyi çıkarır: kullanıcı silinmiş işaretlenir (oturumu anında geçersiz), +/// e-postası serbest kalır (aynı adresle yeniden davet edilebilir). Kayıtları hanede kalır. +pub async fn remove_member( + db: &Database, + household_id: Uuid, + owner_id: Uuid, + member_id: Uuid, +) -> AppResult<()> { + if member_id == owner_id { + return Err(AppError::BadRequest("Hane sahibi kendini çıkaramaz".into())); + } + let users = db.collection::(col::USERS); + let filter = doc! { "_id": member_id, "household_id": household_id, "deleted_at": null }; + let user = users + .find_one(filter.clone()) + .await? + .ok_or(AppError::NotFound)?; + if user.get_str("role") == Ok(Role::Owner.as_str()) { + return Err(AppError::BadRequest("Hane sahibi çıkarılamaz".into())); + } + let email = user.get_str("email").unwrap_or_default().to_string(); + let ts = crate::models::common::now(); + users + .update_one( + filter, + doc! { + "$set": { + "deleted_at": ts, + "updated_at": ts, + // Benzersiz e-posta indeksi: adres serbest kalsın, kayıtta iz kalsın + "email": format!("{email}#removed-{member_id}"), + "removed_email": email, + }, + "$inc": { "version": 1 }, + }, + ) + .await?; + tracing::info!("Hane {household_id}: üye {member_id} çıkarıldı"); + Ok(()) +} diff --git a/backend/tests/account.rs b/backend/tests/account.rs new file mode 100644 index 0000000..287eea0 --- /dev/null +++ b/backend/tests/account.rs @@ -0,0 +1,267 @@ +//! Hesap ve hane ayarları: profil adı, şifre değiştirme (diğer oturumlar kapanır), +//! hane adı + bütçe varsayılanları, üye çıkarma, yetkiler. + +mod common; + +use std::time::Duration; + +use axum::http::{Method, StatusCode}; +use common::{Session, TestApp}; +use serde_json::{Value, json}; + +async fn login(app: &TestApp, email: &str, password: &str) -> (StatusCode, Option) { + let (st, _, cookie) = app + .send( + Method::POST, + "/auth/login", + None, + Some(json!({ "email": email, "password": password, "remember_me": true })), + ) + .await; + (st, cookie) +} + +async fn me(app: &TestApp, s: &Session) -> (StatusCode, Value) { + app.get("/auth/me", s).await +} + +fn with_cookie(s: &Session, cookie: String) -> Session { + Session { + cookie, + household_id: s.household_id.clone(), + } +} + +#[tokio::test] +async fn profile_name_and_password_change_closes_other_sessions() { + let app = TestApp::new().await; + let a = app.register("Ayse", None).await; + let (_, m) = me(&app, &a).await; + let email = m["user"]["email"].as_str().unwrap().to_string(); + + // Ad + let (st, m) = app + .patch("/auth/me", &a, json!({ "name": " Ayşe Ç. " })) + .await; + assert_eq!( + (st, m["user"]["name"].as_str()), + (StatusCode::OK, Some("Ayşe Ç.")) + ); + let (st, _) = app.patch("/auth/me", &a, json!({ "name": " " })).await; + assert_eq!(st, StatusCode::BAD_REQUEST); + + // İkinci cihaz + let (st, other) = login(&app, &email, "test-sifre-123").await; + assert_eq!(st, StatusCode::OK); + let other = with_cookie(&a, other.unwrap()); + tokio::time::sleep(Duration::from_millis(1100)).await; // iat saniye hassasiyetinde + + // Yanlış mevcut şifre / kısa / aynı şifre + let pw = |cur: &str, new: &str| json!({ "current_password": cur, "new_password": new }); + let (st, b) = app + .post( + "/auth/me/password", + &a, + pw("yanlis-sifre", "yeni-sifre-456"), + ) + .await; + assert_eq!(st, StatusCode::BAD_REQUEST); + assert_eq!(b["error"]["message"], "Mevcut şifre hatalı"); + let (st, _) = app + .post("/auth/me/password", &a, pw("test-sifre-123", "kisa")) + .await; + assert_eq!(st, StatusCode::BAD_REQUEST); + let (st, b) = app + .post( + "/auth/me/password", + &a, + pw("test-sifre-123", "test-sifre-123"), + ) + .await; + assert_eq!(st, StatusCode::BAD_REQUEST, "{b}"); + + // Doğru → bu cihaz yeni çerezle devam, diğer cihaz düşer + let (st, _, cookie) = app + .send( + Method::POST, + "/auth/me/password", + Some(&a.cookie), + Some(pw("test-sifre-123", "yeni-sifre-456")), + ) + .await; + assert_eq!(st, StatusCode::NO_CONTENT); + let fresh = with_cookie(&a, cookie.expect("yeni oturum çerezi")); + assert_eq!(me(&app, &fresh).await.0, StatusCode::OK); + assert_eq!( + me(&app, &other).await.0, + StatusCode::UNAUTHORIZED, + "diğer cihaz" + ); + assert_eq!(me(&app, &a).await.0, StatusCode::UNAUTHORIZED, "eski çerez"); + + // Giriş: eski şifre geçmez, yeni geçer + assert_eq!( + login(&app, &email, "test-sifre-123").await.0, + StatusCode::UNAUTHORIZED + ); + assert_eq!( + login(&app, &email, "yeni-sifre-456").await.0, + StatusCode::OK + ); + + app.cleanup().await; +} + +#[tokio::test] +async fn wrong_current_password_is_rate_limited() { + let app = TestApp::new().await; + let a = app.register("Ayse", None).await; + let body = json!({ "current_password": "yanlis", "new_password": "yeni-sifre-456" }); + for _ in 0..5 { + let (st, _) = app.post("/auth/me/password", &a, body.clone()).await; + assert_eq!(st, StatusCode::BAD_REQUEST); + } + let (st, _) = app + .post( + "/auth/me/password", + &a, + json!({ "current_password": "test-sifre-123", "new_password": "yeni-sifre-456" }), + ) + .await; + assert_eq!(st, StatusCode::TOO_MANY_REQUESTS, "doğru şifre de bekler"); + app.cleanup().await; +} + +#[tokio::test] +async fn household_settings_and_budget_default_split() { + let app = TestApp::new().await; + let owner = app.register("Sahip", None).await; + let (_, inv) = app.post("/household/invites", &owner, json!({})).await; + let member = app + .register("Uye", Some(inv["code"].as_str().unwrap())) + .await; + + let (_, h) = app.get("/household", &owner).await; + assert_eq!( + h["settings"], + json!({ "budget_split_pct": { "needs": 50, "wants": 30, "culture": 10, "unexpected": 10 }, "budget_suggestion": "default" }) + ); + + let update = json!({ + "name": "Çakır Ailesi", + "budget_split_pct": { "needs": 60, "wants": 20, "culture": 10, "unexpected": 10 }, + "budget_suggestion": "avg3", + }); + let (st, _) = app.patch("/household", &member, update.clone()).await; + assert_eq!(st, StatusCode::FORBIDDEN, "üye değiştiremez"); + let (st, h) = app.patch("/household", &owner, update).await; + assert_eq!(st, StatusCode::OK, "{h}"); + assert_eq!(h["name"], "Çakır Ailesi"); + assert_eq!(h["settings"]["budget_split_pct"]["needs"], 60); + assert_eq!(h["settings"]["budget_suggestion"], "avg3"); + let (_, m) = me(&app, &member).await; + assert_eq!( + m["household"]["name"], "Çakır Ailesi", + "üye de yeni adı görür" + ); + + // Toplam 100 değil / geçersiz yöntem + for bad in [ + json!({ "budget_split_pct": { "needs": 60, "wants": 30, "culture": 10, "unexpected": 10 } }), + json!({ "budget_suggestion": "hepsi" }), + json!({ "name": "" }), + ] { + let (st, b) = app.patch("/household", &owner, bad).await; + assert_eq!(st, StatusCode::BAD_REQUEST, "{b}"); + } + + // Varsayılan öneri yeni yüzdeleri kullanır: harcanabilir 100.000 → 60/20/10/10 + let (_, p) = app + .post( + "/periods", + &owner, + json!({ "start_date": "2026-11-01", "end_date": "2026-12-01" }), + ) + .await; + let pid = p["id"].as_str().unwrap(); + app.post( + &format!("/periods/{pid}/plan-items"), + &owner, + json!({ "kind": "income", "name": "Maaş", "amount": 100_000 }), + ) + .await; + let (_, sug) = app + .get( + &format!("/periods/{pid}/budget/suggestion?method=default"), + &owner, + ) + .await; + assert_eq!( + sug["amounts"], + json!({ "needs": 60_000, "wants": 20_000, "culture": 10_000, "unexpected": 10_000 }) + ); + + app.cleanup().await; +} + +#[tokio::test] +async fn owner_removes_member_who_can_be_invited_again() { + let app = TestApp::new().await; + let owner = app.register("Sahip", None).await; + let (_, inv) = app.post("/household/invites", &owner, json!({})).await; + let member = app + .register("Uye", Some(inv["code"].as_str().unwrap())) + .await; + let (_, mm) = me(&app, &member).await; + let member_id = mm["user"]["id"].as_str().unwrap().to_string(); + let member_email = mm["user"]["email"].as_str().unwrap().to_string(); + let (_, om) = me(&app, &owner).await; + let owner_id = om["user"]["id"].as_str().unwrap().to_string(); + + // Üye kimseyi çıkaramaz; sahip kendini çıkaramaz; başka hane bulunamaz + let (st, _) = app + .delete(&format!("/household/members/{owner_id}"), &member) + .await; + assert_eq!(st, StatusCode::FORBIDDEN); + let (st, _) = app + .delete(&format!("/household/members/{owner_id}"), &owner) + .await; + assert_eq!(st, StatusCode::BAD_REQUEST); + let stranger = app.register("Yabanci", None).await; + let (st, _) = app + .delete(&format!("/household/members/{member_id}"), &stranger) + .await; + assert_eq!(st, StatusCode::NOT_FOUND); + + // Çıkar → oturumu anında düşer, listede yok, giriş yapamaz + let (st, _) = app + .delete(&format!("/household/members/{member_id}"), &owner) + .await; + assert_eq!(st, StatusCode::NO_CONTENT); + assert_eq!(me(&app, &member).await.0, StatusCode::UNAUTHORIZED); + let (_, h) = app.get("/household", &owner).await; + assert_eq!(h["members"].as_array().unwrap().len(), 1); + assert_eq!( + login(&app, &member_email, "test-sifre-123").await.0, + StatusCode::UNAUTHORIZED + ); + + // Aynı e-posta yeni davetle yeniden katılabilir + let (_, inv) = app.post("/household/invites", &owner, json!({})).await; + let (st, b, _) = app + .send( + Method::POST, + "/auth/register", + None, + Some(json!({ + "name": "Uye", + "email": member_email, + "password": "test-sifre-123", + "invite_code": inv["code"], + })), + ) + .await; + assert_eq!(st, StatusCode::CREATED, "{b}"); + + app.cleanup().await; +} diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 21c1a0a..b98742a 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -54,9 +54,12 @@ Renkler ve ikonlar frontend tasarım token'larında. { _id, household_id, email /* küçük harf, benzersiz */, password_hash /* argon2 */, name, role: "owner" | "member", preferences: { theme: "light" | "dark" | "system" }, - last_login_at, created_at, updated_at, deleted_at, version } + last_login_at, + sessions_valid_from, // Unix sn; şifre değişince "şimdi" → önceki oturum jetonları (iat <) geçersiz + removed_email, // üye çıkarılınca: e-posta "#removed-" yapılır (adres serbest), asıl adres burada + created_at, updated_at, deleted_at, version } ``` -- Bir kullanıcı **tek bir haneye** üyedir. +- Bir kullanıcı **tek bir haneye** üyedir. Üye çıkarma = `deleted_at` (oturum anında düşer, kayıtları hanede kalır). ### 3.2 `households` ```js diff --git a/docs/MEMORY.md b/docs/MEMORY.md index 849af92..fa383a2 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -1,7 +1,7 @@ # 🧠 Proje Hafızası ## ▶️ Sıradaki Adım -**Faz 10 – Adım 2:** Hane ve hesap (Ayarlar'a yeni alt sayfalar). Backend'de var: `GET /household`, davetler `GET/POST /household/invites`, `DELETE /household/invites/{id}` (arayüz yok → davet şu an uygulamadan yapılamıyor!). Eksik backend: hane adı değiştir, üye çıkar (sahip), profil adı, şifre değiştir (eski şifre + argon2, diğer oturumlar?), varsayılan bütçe yüzdeleri + öneri yöntemi (`households.settings`). Önce mevcut household/auth route'larını oku. +**Faz 10 – Adım 3:** Arayüz cilası ve erişilebilirlik — tüm sayfaları (Panel, Defter, Plan, sihirbaz, Değerlendirme, Rapor, Birikim, Raporlar, Ayarlar, giriş/kayıt, 404) telefon 390 / masaüstü 1280 / koyu temada gez, ekran görüntüsüyle bul-düzelt; boş durumlar + eylem, skeleton, %80 bütçe uyarısı (KAKEIBO_PROMPT §9), odak halkası/klavye, kontrast, `prefers-reduced-motion`, riskli işlemlerde onay. Önce tarama listesini çıkar, kullanıcıya özet sun. **Bekleyen (kullanıcıda):** Faz 5 – Adım 5 canlı yedek kurulumu (`docs/DEPLOY.md` §6.1–6.3) → çıktılar gelince Faz 5 ✅. ## 📌 Alınan Kararlar @@ -149,3 +149,4 @@ - **Faz 9 – Adım 6a (2026-11-13):** `services/monthly_report.rs` + `routes/monthly_report.rs` (`GET /periods/{id}/report.md`, `text/markdown`), `templates/monthly_report.md` (minijinja 3.1, `serde` özelliği; trim/lstrip blocks; art arda boş satırlar sadeleştirilir). `minijinja` yeni bağımlılık (şablonda koşul/döngü). Dockerfile: `COPY templates/ /app/templates/` (`include_str!("../../../templates/…")`), `docker build --target api` geçti. Testler: 5 birim (biçim, eşikler, kapalı dönem yorumları, ara rapor + ilk dönem, tam şablon + hücre kaçışı) + 1 entegrasyon (Ekim kapalı → Kasım raporu: karşılaştırma, notlar, devir, öneri, 404). 5 bilinçli bozmadan 2'si önce kaçtı → eşik testleri eklendi, hepsi yakalandı. bson `DateTime::to_time_0_3` crate-içi → `from_unix_timestamp`. Backend 196 test. - **Faz 9 – Adım 6b (2026-11-13) → Faz 9 ✅:** `features/review/report-page.tsx` (`paths.monthlyReport = /degerlendirme/rapor`, dönem seçici, Yazdır/PDF `printSection()`, "Markdown indir" `downloadFile` (csv.ts'e genel yardımcı), `reportFileName` = backend slug). Bağlantılar: Değerlendirme başlığı, kapatma sonucu, dönem geçmişinde ayrı simge bağlantı (iç içe `` olmasın). Bulunan/düzeltilen: (1) şablondaki `` dipnot react-markdown'da hiç görünmüyordu → düz `_…_`; (2) telefonda tablolar sayfayı 541 px'e taşırıyordu (mobil emülasyonda `innerWidth` da büyür → kontrol 390 ile) → `Markdown` tablo sarmalayıcısı `.table-wrap`; (3) PDF ilk sayfada sayfa başlığı/dönem seçici → `PageHeader printHidden`; (4) ara raporda başlamamış haftalar "✅ %0" → "—"; (5) hedef kutuları sayfa sonunda bölünüyordu. PDF: Chrome `printToPDF` → 4 sayfa A4, `pdftoppm` ile gözle kontrol. Frontend 135, backend 196 test; e2e 22 kontrol (DB silindi). - **Faz 10 – Adım 1 (2026-11-13):** Ayarlar menüsü (`features/settings/page.tsx`: Kategoriler, Ödeme türleri, Veri ve yedek + tema) ve alt sayfalar (`subpage.tsx`, `catalog-pages.tsx`, `data-page.tsx`); ekstre sayfası "‹ Veri ve yedek"e döner. `catalog-editor.tsx`: satırda ↑ ↓ ✎ (44 px, `aria-label` adlı), düzenleme formu ad + emoji (hızlı seçim paleti, `aria-pressed`), Pasifleştir/Yeniden etkinleştir + toast "Geri al" (sürümsüz PATCH), aynı ad hatası backend'den. `lib/catalog.ts` mutasyonları `['categories'|'payment-methods']` + rapor/dönem önbelleğini yeniler. Telefonda grup başlığı bölünüyordu → açıklama alta. Frontend 136 test; e2e 25 kontrol (DB silindi). +- **Faz 10 – Adım 2 (2026-11-13):** Backend: `PATCH /auth/me` (ad), `POST /auth/me/password` (mevcut şifre + limiter `password:`, `sessions_valid_from` → diğer oturumlar 401, bu cihaza yeni çerez, "beni hatırla" eski çerezin `exp-iat`'ından), `PATCH /household` (sahip: ad, `budget_split_pct` toplam 100, `budget_suggestion`), `DELETE /household/members/{id}` (sahip; kendini/sahibi çıkaramaz; e-posta `#removed-` → yeniden davet edilebilir). `GET /household` artık `settings` döner. `services/account.rs`. Testler `tests/account.rs` (4) + 2 bilinçli bozma yakalandı. Frontend: `/ayarlar/hane` (ad, üyeler + çıkar onaylı, davet oluştur → panoya kopyala / paylaş / iptal), `/ayarlar/hesap` (ad, e-posta salt okunur, şifre formu: zod tekrar + farklı, sunucu hatası alana), `/ayarlar/butce` (4 yüzde canlı toplam, 50/30/10/10 düğmesi, yöntem radyo; üye salt okunur). Öneri seçici ve sihirbaz hanenin yöntemiyle açılır (veri yoksa yüzdelere düşer). E2E 28 kontrol: **davet bağlantısıyla gerçek kayıt** dahil (`REGISTRATION=invite_only`). Not: başsız Chrome'da pano için `Emulation.setFocusEmulationEnabled`. Backend 205, frontend 140 test. diff --git a/docs/PHASES.md b/docs/PHASES.md index d3ce228..60d1fa8 100644 --- a/docs/PHASES.md +++ b/docs/PHASES.md @@ -88,7 +88,7 @@ ## Faz 10 Adımları (web uygulamasının eksikleri + cila) - ✅ 1. Ayarlar yeniden düzeni (`/ayarlar` menü + tema) · **Kategori yönetimi** (grup grup ekle, ad/emoji düzenle, sırala, pasifleştir) · **Ödeme türü yönetimi** · Veri sayfası `/ayarlar/veri` -- 🔄 2. Hane ve hesap: hane adı, üyeler, **davet bağlantısı** (oluştur/kopyala/iptal), üye çıkarma; profil adı, **şifre değiştirme**; varsayılan bütçe yüzdeleri + öneri yöntemi -- ⏳ 3. Arayüz cilası ve erişilebilirlik: tüm sayfaların telefon/masaüstü/koyu tema taraması, boş durumlar, skeleton, %80 bütçe uyarısı, odak/klavye, kontrast, onay diyalogları +- ✅ 2. Hane ve hesap: hane adı, üyeler, **davet bağlantısı** (oluştur/kopyala/iptal), üye çıkarma; profil adı, **şifre değiştirme**; varsayılan bütçe yüzdeleri + öneri yöntemi +- 🔄 3. Arayüz cilası ve erişilebilirlik: tüm sayfaların telefon/masaüstü/koyu tema taraması, boş durumlar, skeleton, %80 bütçe uyarısı, odak/klavye, kontrast, onay diyalogları - ⏳ 4. Performans: sayfa bazlı kod bölme (lazy route), statik dosyalarda uzun önbellek, paket boyutu - ⏳ 5. PWA: manifest + service worker (uygulama kabuğu; `/api` asla önbelleğe alınmaz), kurulum, güncelleme bildirimi diff --git a/frontend/src/components/layout/nav-items.ts b/frontend/src/components/layout/nav-items.ts index 9def1b0..737e157 100644 --- a/frontend/src/components/layout/nav-items.ts +++ b/frontend/src/components/layout/nav-items.ts @@ -33,6 +33,9 @@ export const paths = { settingsCategories: '/ayarlar/kategoriler', settingsPaymentMethods: '/ayarlar/odeme-turleri', settingsData: '/ayarlar/veri', + settingsHousehold: '/ayarlar/hane', + settingsAccount: '/ayarlar/hesap', + settingsBudget: '/ayarlar/butce', /** Banka / kart ekstresi içe aktarma */ statement: '/ayarlar/ekstre', login: '/giris', diff --git a/frontend/src/features/period-wizard/step-budget.tsx b/frontend/src/features/period-wizard/step-budget.tsx index 561da69..aa708c4 100644 --- a/frontend/src/features/period-wizard/step-budget.tsx +++ b/frontend/src/features/period-wizard/step-budget.tsx @@ -9,6 +9,7 @@ import { SummaryTiles } from '@/features/planning/summary-tiles' import { ApiError } from '@/lib/api' import { parseMoney } from '@/lib/format' import { GROUPS } from '@/lib/groups' +import { useHousehold } from '@/lib/household' import { toKurus } from '@/lib/money-schema' import { useBudget, useSetBudget, useSuggestion, useSummary } from '@/lib/periods' import { moneyToText } from '@/lib/plan-draft' @@ -46,12 +47,18 @@ export function StepBudget({ }) { const summary = useSummary(period.id) const budget = useBudget(period.id) - const suggestion = useSuggestion(period.id, 'default') + // Hanenin varsayılan yöntemi (Ayarlar); verisi yoksa (ör. ilk ay, önceki dönem yok) yüzdeler + const household = useHousehold() + const preferred = household.data?.settings.budget_suggestion ?? 'default' + const fallback = useSuggestion(period.id, 'default') + const chosen = useSuggestion(period.id, preferred) + const suggestion = chosen.data?.available ? chosen : fallback - const queries = [summary, budget, suggestion] + const queries = [summary, budget, household, fallback, chosen] const failed = queries.find((q) => q.isError) if (failed?.error) return - if (!summary.data || !budget.data || !suggestion.data) return + if (!summary.data || !budget.data || !household.data || !chosen.data || !suggestion.data) + return const saved = Object.fromEntries(budget.data.map((g) => [g.group, g.monthly])) as GroupAmounts const hasSaved = budget.data.some((g) => g.monthly > 0) diff --git a/frontend/src/features/planning/suggestion-picker.tsx b/frontend/src/features/planning/suggestion-picker.tsx index e3b75b3..7c67647 100644 --- a/frontend/src/features/planning/suggestion-picker.tsx +++ b/frontend/src/features/planning/suggestion-picker.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' import { Loader2, Sparkles } from 'lucide-react' import { Button } from '@/components/ui/button' +import { useHousehold } from '@/lib/household' import { useSuggestion } from '@/lib/periods' import { t } from '@/lib/strings' import type { GroupAmounts } from '@/types/GroupAmounts' @@ -19,7 +20,10 @@ export function SuggestionPicker({ onApply: (amounts: GroupAmounts) => void hint?: string }) { - const [method, setMethod] = useState('default') + // Önce hanenin varsayılan yöntemi (Ayarlar → Bütçe varsayılanları); kullanıcı seçince o + const household = useHousehold() + const [chosen, setMethod] = useState(null) + const method = chosen ?? household.data?.settings.budget_suggestion ?? 'default' const suggestion = useSuggestion(periodId, method) return ( diff --git a/frontend/src/features/settings/account-page.tsx b/frontend/src/features/settings/account-page.tsx new file mode 100644 index 0000000..3150b7f --- /dev/null +++ b/frontend/src/features/settings/account-page.tsx @@ -0,0 +1,151 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { Loader2 } from 'lucide-react' +import { useForm } from 'react-hook-form' +import { toast } from 'sonner' +import { FormError, FormField, PasswordInput } from '@/components/form-field' +import { LogoutButton } from '@/components/layout/app-layout' +import { PageLoading } from '@/components/page' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Section } from '@/features/planning/plan-read' +import { useMe } from '@/lib/auth' +import { useChangePassword, useUpdateProfile } from '@/lib/household' +import { t } from '@/lib/strings' +import type { MeResponse } from '@/types/MeResponse' +import { nameSchema, passwordSchema, type NameForm, type PasswordForm } from './schemas' +import { SettingsSubpage } from './subpage' + +const s = t.settings.account +const message = (e: unknown) => (e instanceof Error ? e.message : String(e)) + +/** Ayarlar → Hesabım: ad, e-posta (salt okunur), şifre değiştirme, çıkış */ +export function AccountPage() { + const me = useMe() + return ( + + {!me.data ? ( + + ) : ( +
+ + +
+ +
+
+ )} +
+ ) +} + +function Profile({ me }: { me: MeResponse }) { + const update = useUpdateProfile() + const { + register, + handleSubmit, + setError, + reset, + formState: { errors, isDirty }, + } = useForm({ + resolver: zodResolver(nameSchema), + mode: 'onTouched', + defaultValues: { name: me.user.name }, + }) + const onSubmit = handleSubmit(async (v) => { + try { + const next = await update.mutateAsync(v.name) + reset({ name: next.user.name }) + toast.success(s.saved) + } catch (e) { + setError('name', { message: message(e) }) + } + }) + return ( +
+
+ + {(f) => } + + + {(f) => } + +

+ {s.role}: + {me.user.role === 'owner' ? t.account.owner : t.account.member} · {me.household.name} +

+ +
+
+ ) +} + +function ChangePassword() { + const change = useChangePassword() + const { + register, + handleSubmit, + setError, + reset, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(passwordSchema), + mode: 'onTouched', + defaultValues: { current_password: '', new_password: '', repeat: '' }, + }) + const onSubmit = handleSubmit(async (v) => { + try { + await change.mutateAsync({ + current_password: v.current_password, + new_password: v.new_password, + }) + reset() + toast.success(s.changed) + } catch (e) { + const msg = message(e) + // "Mevcut şifre hatalı" → ilgili alana; diğerleri formun altına + if (/mevcut/i.test(msg)) setError('current_password', { message: msg }, { shouldFocus: true }) + else setError('root', { message: msg }) + } + }) + return ( +
+
+

{s.otherDevices}

+ {/* Parola yöneticileri için gizli kullanıcı adı alanı */} + + + {(f) => ( + + )} + + + {(f) => ( + + )} + + + {(f) => } + + + + +
+ ) +} diff --git a/frontend/src/features/settings/budget-page.tsx b/frontend/src/features/settings/budget-page.tsx new file mode 100644 index 0000000..9a30084 --- /dev/null +++ b/frontend/src/features/settings/budget-page.tsx @@ -0,0 +1,177 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { Loader2 } from 'lucide-react' +import { useForm, useWatch, type FieldError } from 'react-hook-form' +import { toast } from 'sonner' +import { FormError, FormField } from '@/components/form-field' +import { PageError, PageLoading } from '@/components/page' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Section } from '@/features/planning/plan-read' +import { useMe } from '@/lib/auth' +import { GROUPS } from '@/lib/groups' +import { pctTotal, useHousehold, useUpdateHousehold } from '@/lib/household' +import { t } from '@/lib/strings' +import { cn } from '@/lib/utils' +import type { Household } from '@/types/Household' +import { budgetSchema, type BudgetForm, type BudgetValues } from './schemas' +import { SettingsSubpage } from './subpage' + +const s = t.settings.budget +const METHODS = ['default', 'previous', 'avg3'] as const + +/** Ayarlar → Bütçe varsayılanları: öneri yüzdeleri + önce seçili öneri yöntemi */ +export function BudgetDefaultsPage() { + const me = useMe() + const household = useHousehold() + return ( + + {household.isError ? ( + + ) : !household.data || !me.data ? ( + + ) : ( + + )} + + ) +} + +function BudgetForm({ household, readOnly }: { household: Household; readOnly: boolean }) { + const update = useUpdateHousehold() + const st = household.settings + const { + register, + handleSubmit, + setError, + setValue, + reset, + control, + formState: { errors, isDirty }, + } = useForm({ + resolver: zodResolver(budgetSchema), + mode: 'onTouched', + defaultValues: { ...st.budget_split_pct, budget_suggestion: st.budget_suggestion }, + }) + const watched = useWatch({ control }) + const total = pctTotal({ + needs: watched.needs as number, + wants: watched.wants as number, + culture: watched.culture as number, + unexpected: watched.unexpected as number, + }) + const method = watched.budget_suggestion + + const onSubmit = handleSubmit(async (v) => { + try { + const h = await update.mutateAsync({ + budget_split_pct: { + needs: v.needs, + wants: v.wants, + culture: v.culture, + unexpected: v.unexpected, + }, + budget_suggestion: v.budget_suggestion, + }) + reset({ ...h.settings.budget_split_pct, budget_suggestion: h.settings.budget_suggestion }) + toast.success(s.saved) + } catch (e) { + setError('root', { message: e instanceof Error ? e.message : String(e) }) + } + }) + + return ( +
+ {readOnly && ( +

{s.readOnly}

+ )} +
+
+

{s.splitHint}

+
+ {/* z.coerce girişi `unknown` → hata türü genişler; alanlar düz değer */} + {GROUPS.map((g) => ( + + {(f) => ( +
+ + + % + +
+ )} +
+ ))} +
+
+

+ {s.total(total)} +

+ {!readOnly && ( + + )} +
+
+
+ +
+
+ {s.method} +

{s.methodHint}

+
+ {METHODS.map((m) => ( + + ))} +
+
+
+ + + {!readOnly && ( + + )} + + ) +} diff --git a/frontend/src/features/settings/household-page.tsx b/frontend/src/features/settings/household-page.tsx new file mode 100644 index 0000000..b2a82c8 --- /dev/null +++ b/frontend/src/features/settings/household-page.tsx @@ -0,0 +1,251 @@ +import { useState } from 'react' +import { zodResolver } from '@hookform/resolvers/zod' +import { Copy, Loader2, Share2, UserMinus, UserPlus } from 'lucide-react' +import { useForm } from 'react-hook-form' +import { toast } from 'sonner' +import { FormError, FormField } from '@/components/form-field' +import { PageError, PageLoading } from '@/components/page' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Section } from '@/features/planning/plan-read' +import { useMe } from '@/lib/auth' +import { formatDate } from '@/lib/format' +import { + inviteLink, + useCreateInvite, + useHousehold, + useInvites, + useRemoveMember, + useRevokeInvite, + useUpdateHousehold, +} from '@/lib/household' +import { t } from '@/lib/strings' +import type { Household } from '@/types/Household' +import type { Invite } from '@/types/Invite' +import { nameSchema, type NameForm } from './schemas' +import { SettingsSubpage } from './subpage' + +const s = t.settings.household +const message = (e: unknown) => (e instanceof Error ? e.message : String(e)) + +/** Ayarlar → Hane ve üyeler: ad, üyeler (çıkar), davet bağlantıları (yalnız sahip yönetir) */ +export function HouseholdPage() { + const me = useMe() + const household = useHousehold() + const isOwner = me.data?.user.role === 'owner' + + return ( + + {household.isError ? ( + + ) : !household.data || !me.data ? ( + + ) : ( +
+ {isOwner ? ( + + ) : ( +

+ {s.ownerOnly} +

+ )} + + {isOwner && } +
+ )} +
+ ) +} + +function NameCard({ household }: { household: Household }) { + const update = useUpdateHousehold() + const { + register, + handleSubmit, + setError, + reset, + formState: { errors, isDirty }, + } = useForm({ + resolver: zodResolver(nameSchema), + mode: 'onTouched', + defaultValues: { name: household.name }, + }) + const onSubmit = handleSubmit(async (v) => { + try { + const h = await update.mutateAsync({ name: v.name }) + reset({ name: h.name }) + toast.success(s.saved) + } catch (e) { + setError('name', { message: message(e) }) + } + }) + return ( +
+
+ + {(f) => } + + +
+
+ ) +} + +function Members({ + household, + myId, + isOwner, +}: { + household: Household + myId: string + isOwner: boolean +}) { + const remove = useRemoveMember() + async function onRemove(id: string, name: string) { + if (!window.confirm(s.removeConfirm(name))) return + try { + await remove.mutateAsync(id) + toast.success(s.removed(name)) + } catch (e) { + toast.error(message(e)) + } + } + return ( +
+
    + {household.members.map((m) => ( +
  • +
    +

    + {m.name} {m.id === myId && {s.you}} +

    +

    + {m.email} · {m.role === 'owner' ? t.account.owner : t.account.member} +

    +
    + {isOwner && m.role !== 'owner' && ( + + )} +
  • + ))} +
+
+ ) +} + +function Invites({ householdName }: { householdName: string }) { + const invites = useInvites(true) + const create = useCreateInvite() + const [error, setError] = useState() + + async function onCreate() { + setError(undefined) + try { + const inv = await create.mutateAsync() + await copy(inv) + } catch (e) { + setError(message(e)) + } + } + + return ( +
+
+

{s.invitesHint}

+ + + {invites.data && invites.data.length === 0 && ( +

{s.noInvites}

+ )} + {invites.data && invites.data.length > 0 && ( +
    + {invites.data.map((inv) => ( + + ))} +
+ )} +
+
+ ) +} + +/** Panoya kopyala (HTTPS / localhost gerekir); olmazsa kullanıcı elle seçer */ +async function copy(inv: Invite) { + try { + await navigator.clipboard.writeText(inviteLink(inv.code)) + toast.success(s.copied) + } catch { + toast.error(s.copyFailed) + } +} + +function InviteRow({ invite, householdName }: { invite: Invite; householdName: string }) { + const revoke = useRevokeInvite() + const link = inviteLink(invite.code) + const canShare = typeof navigator !== 'undefined' && 'share' in navigator + return ( +
  • + e.currentTarget.select()} + className="font-mono text-sm" + /> +

    + {s.code(invite.code)} · {s.expires(formatDate(invite.expires_at))} +

    +
    + + {canShare && ( + + )} + +
    +
  • + ) +} diff --git a/frontend/src/features/settings/page.tsx b/frontend/src/features/settings/page.tsx index d7d2ee3..3b5ba0d 100644 --- a/frontend/src/features/settings/page.tsx +++ b/frontend/src/features/settings/page.tsx @@ -1,4 +1,13 @@ -import { ChevronRight, Database, Tags, Wallet, type LucideIcon } from 'lucide-react' +import { + ChevronRight, + Database, + PieChart, + Tags, + UserRound, + Users, + Wallet, + type LucideIcon, +} from 'lucide-react' import { Link } from 'react-router' import { paths } from '@/components/layout/nav-items' import { PageHeader } from '@/components/page' @@ -10,6 +19,9 @@ const s = t.settings const items: { to: string; icon: LucideIcon; title: string; hint: string }[] = [ { to: paths.settingsCategories, icon: Tags, ...s.menu.categories }, { to: paths.settingsPaymentMethods, icon: Wallet, ...s.menu.paymentMethods }, + { to: paths.settingsBudget, icon: PieChart, ...s.menu.budget }, + { to: paths.settingsHousehold, icon: Users, ...s.menu.household }, + { to: paths.settingsAccount, icon: UserRound, ...s.menu.account }, { to: paths.settingsData, icon: Database, ...s.menu.data }, ] diff --git a/frontend/src/features/settings/schemas.test.ts b/frontend/src/features/settings/schemas.test.ts new file mode 100644 index 0000000..1a48149 --- /dev/null +++ b/frontend/src/features/settings/schemas.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { budgetSchema, passwordSchema } from './schemas' + +describe('settings forms', () => { + it('password: length, repeat and different from current', () => { + const ok = { + current_password: 'eski-sifre', + new_password: 'yeni-sifre-1', + repeat: 'yeni-sifre-1', + } + expect(passwordSchema.safeParse(ok).success).toBe(true) + const issues = (v: object) => + passwordSchema.safeParse({ ...ok, ...v }).error?.issues.map((i) => i.path.join('.')) + expect(issues({ new_password: 'kisa', repeat: 'kisa' })).toContain('new_password') + expect(issues({ repeat: 'baska' })).toEqual(['repeat']) + expect(issues({ new_password: 'eski-sifre', repeat: 'eski-sifre' })).toEqual(['new_password']) + }) + + it('budget split must total 100', () => { + const base = { + needs: '50', + wants: '30', + culture: '10', + unexpected: '10', + budget_suggestion: 'avg3', + } + expect(budgetSchema.parse(base)).toMatchObject({ needs: 50, budget_suggestion: 'avg3' }) + const r = budgetSchema.safeParse({ ...base, needs: '60' }) + expect(r.success).toBe(false) + expect(r.error?.issues[0].path).toEqual(['root']) + expect(budgetSchema.safeParse({ ...base, needs: '50.5' }).success).toBe(false) + }) +}) diff --git a/frontend/src/features/settings/schemas.ts b/frontend/src/features/settings/schemas.ts new file mode 100644 index 0000000..e341c6b --- /dev/null +++ b/frontend/src/features/settings/schemas.ts @@ -0,0 +1,44 @@ +import { z } from 'zod' +import { t } from '@/lib/strings' + +const a = t.settings.account +const b = t.settings.budget + +export const nameSchema = z.object({ + name: z.string().trim().min(1, t.settings.catalog.nameRequired).max(80, 'En fazla 80 karakter'), +}) +export type NameForm = z.infer + +export const passwordSchema = z + .object({ + current_password: z.string().min(1, a.currentRequired), + new_password: z.string().min(8, a.tooShort).max(128, 'En fazla 128 karakter'), + repeat: z.string(), + }) + .refine((v) => v.repeat === v.new_password, { path: ['repeat'], message: a.mismatch }) + .refine((v) => v.new_password !== v.current_password, { + path: ['new_password'], + message: a.same, + }) +export type PasswordForm = z.infer + +const pct = z.coerce + .number({ message: '0–100 arası' }) + .int('Tam sayı girin') + .min(0, '0–100 arası') + .max(100, '0–100 arası') + +export const budgetSchema = z + .object({ + needs: pct, + wants: pct, + culture: pct, + unexpected: pct, + budget_suggestion: z.enum(['default', 'previous', 'avg3']), + }) + .refine((v) => v.needs + v.wants + v.culture + v.unexpected === 100, { + path: ['root'], + message: b.totalError, + }) +export type BudgetForm = z.input +export type BudgetValues = z.output diff --git a/frontend/src/lib/household.test.ts b/frontend/src/lib/household.test.ts new file mode 100644 index 0000000..1758400 --- /dev/null +++ b/frontend/src/lib/household.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' +import { inviteLink, pctTotal } from './household' + +describe('household helpers', () => { + it('builds the invite link for the register page', () => { + expect(inviteLink('ABCD-EFGH', 'https://kakeibo.ornek')).toBe( + 'https://kakeibo.ornek/kayit?davet=ABCD-EFGH', + ) + }) + it('sums percentages, ignoring empty fields', () => { + expect(pctTotal({ needs: 50, wants: '30', culture: '', unexpected: 10 })).toBe(90) + }) +}) diff --git a/frontend/src/lib/household.ts b/frontend/src/lib/household.ts new file mode 100644 index 0000000..efa00e3 --- /dev/null +++ b/frontend/src/lib/household.ts @@ -0,0 +1,90 @@ +/** + * Hane, üyeler, davetler, profil ve şifre (Ayarlar). Hane adı / üyeler değişince `me` de + * yenilenir (kenar çubuğunda hane adı görünür). + */ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import type { Household } from '@/types/Household' +import type { HouseholdUpdateRequest } from '@/types/HouseholdUpdateRequest' +import type { Invite } from '@/types/Invite' +import type { MeResponse } from '@/types/MeResponse' +import type { PasswordChangeRequest } from '@/types/PasswordChangeRequest' +import { api } from './api' +import { queryKeys } from './query' + +export function useHousehold() { + return useQuery({ + queryKey: queryKeys.household, + queryFn: () => api.get('/household'), + }) +} + +export function useUpdateHousehold() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (body: HouseholdUpdateRequest) => api.patch('/household', body), + onSuccess: (h) => { + qc.setQueryData(queryKeys.household, h) + void qc.invalidateQueries({ queryKey: queryKeys.me }) + }, + }) +} + +export function useRemoveMember() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (id: string) => api.delete(`/household/members/${id}`), + onSettled: () => qc.invalidateQueries({ queryKey: queryKeys.household }), + }) +} + +export function useInvites(enabled: boolean) { + return useQuery({ + queryKey: queryKeys.invites, + queryFn: () => api.get('/household/invites'), + enabled, + }) +} + +export function useCreateInvite() { + const qc = useQueryClient() + return useMutation({ + mutationFn: () => api.post('/household/invites'), + onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.invites }), + }) +} + +export function useRevokeInvite() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (id: string) => api.delete(`/household/invites/${id}`), + onSettled: () => qc.invalidateQueries({ queryKey: queryKeys.invites }), + }) +} + +/** Davet bağlantısı: kayıt ekranı `?davet=` ile "Davetle katıl"ı seçili açar */ +export function inviteLink(code: string, origin = window.location.origin): string { + return `${origin}/kayit?davet=${encodeURIComponent(code)}` +} + +export function useUpdateProfile() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (name: string) => api.patch('/auth/me', { name }), + onSuccess: (me) => { + qc.setQueryData(queryKeys.me, me) + void qc.invalidateQueries({ queryKey: queryKeys.household }) + }, + }) +} + +/** Başarılıysa sunucu bu cihaza yeni oturum çerezi verir; diğer cihazlar çıkış yapar */ +export function useChangePassword() { + return useMutation({ + mutationFn: (body: PasswordChangeRequest) => api.post('/auth/me/password', body), + }) +} + +/** Yüzdelerin toplamı (bütçe varsayılanları formu) */ +export function pctTotal(p: Record): number { + return Object.values(p).reduce((a, v) => a + (Number(v) || 0), 0) +} diff --git a/frontend/src/lib/query.ts b/frontend/src/lib/query.ts index 207e09c..b585129 100644 --- a/frontend/src/lib/query.ts +++ b/frontend/src/lib/query.ts @@ -10,6 +10,7 @@ export const queryKeys = { registration: ['registration'] as const, health: ['health'] as const, household: ['household'] as const, + invites: ['household', 'invites'] as const, categories: (includeInactive = false) => ['categories', { includeInactive }] as const, paymentMethods: (includeInactive = false) => ['payment-methods', { includeInactive }] as const, /** Dönem listesi; `currentPeriod` da bu önekle başlar (birlikte yenilenir) */ diff --git a/frontend/src/lib/strings.ts b/frontend/src/lib/strings.ts index 2b4278a..ca12a60 100644 --- a/frontend/src/lib/strings.ts +++ b/frontend/src/lib/strings.ts @@ -308,6 +308,77 @@ export const t = { }, paymentMethods: { title: 'Ödeme türleri', hint: 'Nakit, kart, havale…' }, data: { title: 'Veri ve yedek', hint: 'JSON yedek, CSV, banka ekstresi' }, + household: { title: 'Hane ve üyeler', hint: 'Hane adı, üyeler, davet bağlantısı' }, + account: { title: 'Hesabım', hint: 'Adın, e-postan, şifre değiştirme' }, + budget: { title: 'Bütçe varsayılanları', hint: 'Öneri yüzdeleri ve yöntemi' }, + }, + household: { + title: 'Hane ve üyeler', + description: + 'Defteri birlikte tuttuğun kişiler. Herkes aynı verileri görür ve kayıt girebilir.', + name: 'Hane adı', + save: 'Kaydet', + saved: 'Hane adı kaydedildi', + members: 'Üyeler', + you: '(sen)', + remove: (name: string) => `${name}: haneden çıkar`, + removeConfirm: (name: string) => + `${name} haneden çıkarılsın mı? Oturumu hemen kapanır; girdiği kayıtlar hanede kalır. İstersen sonra yeniden davet edebilirsin.`, + removed: (name: string) => `${name} haneden çıkarıldı`, + invites: 'Davet', + invitesHint: + 'Bağlantıyı eşine ya da ev arkadaşına gönder: açınca kayıt ekranı “Davetle katıl” seçili gelir. Her bağlantı bir kez kullanılır ve 7 gün geçerlidir.', + newInvite: 'Davet bağlantısı oluştur', + copy: 'Bağlantıyı kopyala', + copied: 'Bağlantı kopyalandı', + copyFailed: 'Kopyalanamadı; bağlantıyı seçip elle kopyala.', + share: 'Paylaş', + shareText: (household: string) => `Kakeibo'da “${household}” defterine katıl:`, + code: (code: string) => `Kod: ${code}`, + expires: (date: string) => `${date} tarihine kadar geçerli`, + revoke: 'İptal et', + revoked: 'Davet iptal edildi', + noInvites: 'Bekleyen davet yok.', + ownerOnly: 'Üye ekleme ve hane ayarları yalnızca hane sahibindedir.', + }, + account: { + title: 'Hesabım', + description: 'Profil bilgilerin ve şifren.', + profile: 'Profil', + name: 'Adın', + email: 'E-posta', + emailHint: 'E-posta adresi giriş için kullanılır; şimdilik değiştirilemez.', + role: 'Rolün', + save: 'Kaydet', + saved: 'Adın kaydedildi', + password: 'Şifre değiştir', + current: 'Mevcut şifre', + next: 'Yeni şifre', + nextHint: 'En az 8 karakter.', + repeat: 'Yeni şifre (tekrar)', + change: 'Şifreyi değiştir', + changed: 'Şifren değişti. Diğer cihazlarda oturum kapandı.', + otherDevices: + 'Şifreni değiştirince bu cihaz açık kalır, diğer tüm cihazlarda yeniden giriş gerekir.', + currentRequired: 'Mevcut şifreyi girin', + tooShort: 'Yeni şifre en az 8 karakter olmalı', + mismatch: 'Şifreler aynı değil', + same: 'Yeni şifre mevcut şifreden farklı olmalı', + }, + budget: { + title: 'Bütçe varsayılanları', + description: + 'Yeni dönemde bütçe önerisi bu ayarlardan gelir; her dönemde istediğin gibi değiştirebilirsin.', + split: 'Varsayılan dağılım', + splitHint: 'Harcanabilir tutarın gruplara yüzdesi. Kakeibo klasiği: 50 / 30 / 10 / 10.', + total: (n: number) => `Toplam: %${n}`, + totalError: 'Toplam %100 olmalı', + classic: '50 / 30 / 10 / 10', + method: 'Önce seçili öneri yöntemi', + methodHint: 'Plan ekranında ve yeni dönem sihirbazında ilk gösterilen öneri.', + save: 'Kaydet', + saved: 'Bütçe varsayılanları kaydedildi', + readOnly: 'Bu ayarları yalnızca hane sahibi değiştirebilir.', }, categories: { title: 'Kategoriler', diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index bf5f46c..05c38ee 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -18,7 +18,10 @@ import { ReviewPage } from '@/features/review/page' import { AssetPage } from '@/features/savings/asset-page' import { SavingsPage } from '@/features/savings/page' import { CategoriesPage, PaymentMethodsPage } from '@/features/settings/catalog-pages' +import { AccountPage } from '@/features/settings/account-page' +import { BudgetDefaultsPage } from '@/features/settings/budget-page' import { DataPage } from '@/features/settings/data-page' +import { HouseholdPage } from '@/features/settings/household-page' import { SettingsPage } from '@/features/settings/page' import { StatementPage } from '@/features/statement/page' @@ -52,6 +55,9 @@ const routes: RouteObject[] = [ { path: paths.settingsCategories, element: }, { path: paths.settingsPaymentMethods, element: }, { path: paths.settingsData, element: }, + { path: paths.settingsHousehold, element: }, + { path: paths.settingsAccount, element: }, + { path: paths.settingsBudget, element: }, { path: paths.statement, element: }, { path: paths.more, element: }, { path: '*', element: }, diff --git a/frontend/src/types/BudgetSplitPct.ts b/frontend/src/types/BudgetSplitPct.ts new file mode 100644 index 0000000..c27fff4 --- /dev/null +++ b/frontend/src/types/BudgetSplitPct.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type BudgetSplitPct = { needs: number, wants: number, culture: number, unexpected: number, }; diff --git a/frontend/src/types/Household.ts b/frontend/src/types/Household.ts index 919216c..6cc681a 100644 --- a/frontend/src/types/Household.ts +++ b/frontend/src/types/Household.ts @@ -1,4 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { HouseholdSettings } from "./HouseholdSettings"; import type { Member } from "./Member"; -export type Household = { id: string, name: string, members: Array, }; +export type Household = { id: string, name: string, members: Array, settings: HouseholdSettings, }; diff --git a/frontend/src/types/HouseholdSettings.ts b/frontend/src/types/HouseholdSettings.ts new file mode 100644 index 0000000..2b361c7 --- /dev/null +++ b/frontend/src/types/HouseholdSettings.ts @@ -0,0 +1,12 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { BudgetSplitPct } from "./BudgetSplitPct"; + +export type HouseholdSettings = { +/** + * Varsayılan bütçe dağılımı (yüzde, toplam 100) + */ +budget_split_pct: BudgetSplitPct, +/** + * Plan ekranında önce seçili öneri yöntemi + */ +budget_suggestion: "default" | "previous" | "avg3", }; diff --git a/frontend/src/types/HouseholdUpdateRequest.ts b/frontend/src/types/HouseholdUpdateRequest.ts new file mode 100644 index 0000000..e60607f --- /dev/null +++ b/frontend/src/types/HouseholdUpdateRequest.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { BudgetSplitPct } from "./BudgetSplitPct"; + +export type HouseholdUpdateRequest = { name?: string, budget_split_pct?: BudgetSplitPct, budget_suggestion?: "default" | "previous" | "avg3", }; diff --git a/frontend/src/types/PasswordChangeRequest.ts b/frontend/src/types/PasswordChangeRequest.ts new file mode 100644 index 0000000..7e5dd31 --- /dev/null +++ b/frontend/src/types/PasswordChangeRequest.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type PasswordChangeRequest = { current_password: string, new_password: string, }; diff --git a/frontend/src/types/ProfileRequest.ts b/frontend/src/types/ProfileRequest.ts new file mode 100644 index 0000000..b55a5cb --- /dev/null +++ b/frontend/src/types/ProfileRequest.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ProfileRequest = { name: string, };