feat(ayarlar): hane ve üyeler, davet bağlantısı, hesap ve şifre, bütçe varsayılanları
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -83,7 +83,7 @@ npm run db:restore -- <dosya|latest> <hedef_db> [--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ç.
|
- 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).
|
- 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".
|
- 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.
|
- Hane izolasyonu: hane kimliği yalnızca `AuthUser.household_id`'den; sorgular `scope(hid)` ile başlar; başka hane → 404.
|
||||||
|
|
||||||
## Ortam
|
## Ortam
|
||||||
|
|||||||
@@ -425,3 +425,26 @@ POST {{baseUrl}}/backup/restore-points/{{pointId}}/restore
|
|||||||
|
|
||||||
### Dönemin raporu (Markdown; açık dönem → ara rapor)
|
### Dönemin raporu (Markdown; açık dönem → ara rapor)
|
||||||
GET {{baseUrl}}/periods/{{periodId}}/report.md
|
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}}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
//! Bir route parametrelerine `user: AuthUser` eklediğinde, Axum isteği işlemeden önce:
|
//! 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,
|
//! 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
|
//! 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.
|
//! 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,
|
//! **Hane izolasyonu:** route'lar hane kimliğini her zaman `AuthUser.household_id`'den alır,
|
||||||
@@ -67,6 +68,9 @@ impl AuthUser {
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct RoleOnly {
|
struct RoleOnly {
|
||||||
role: Role,
|
role: Role,
|
||||||
|
/// Şifre değişince "şimdi": bundan önce verilmiş jetonlar geçersiz
|
||||||
|
#[serde(default)]
|
||||||
|
sessions_valid_from: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FromRequestParts<AppState> for AuthUser {
|
impl FromRequestParts<AppState> for AuthUser {
|
||||||
@@ -85,9 +89,15 @@ impl FromRequestParts<AppState> for AuthUser {
|
|||||||
.db
|
.db
|
||||||
.collection::<RoleOnly>(col::USERS)
|
.collection::<RoleOnly>(col::USERS)
|
||||||
.find_one(doc! { "_id": user_id, "household_id": household_id, "deleted_at": null })
|
.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?
|
.await?
|
||||||
.ok_or(AppError::Unauthorized)?;
|
.ok_or(AppError::Unauthorized)?;
|
||||||
|
if current
|
||||||
|
.sessions_valid_from
|
||||||
|
.is_some_and(|from| (claims.iat as i64) < from)
|
||||||
|
{
|
||||||
|
return Err(AppError::Unauthorized);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
user_id,
|
user_id,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
//! Kimlik doğrulama uç noktaları:
|
//! 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::{Json, extract::State, http::StatusCode};
|
||||||
use axum_extra::extract::cookie::CookieJar;
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
@@ -11,7 +12,7 @@ use validator::Validate;
|
|||||||
use crate::{
|
use crate::{
|
||||||
auth::{
|
auth::{
|
||||||
AuthUser, password,
|
AuthUser, password,
|
||||||
session::{removal_cookie, session_cookie, ttl},
|
session::{COOKIE_NAME, removal_cookie, session_cookie, ttl},
|
||||||
},
|
},
|
||||||
db::col,
|
db::col,
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
@@ -77,6 +78,22 @@ struct HouseholdDto {
|
|||||||
name: String,
|
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 ----------
|
// ---------- Uç noktalar ----------
|
||||||
|
|
||||||
pub async fn register(
|
pub async fn register(
|
||||||
@@ -200,6 +217,59 @@ pub async fn me(State(state): State<AppState>, auth: AuthUser) -> AppResult<Json
|
|||||||
.map(Json)
|
.map(Json)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn update_me(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
ValidJson(req): ValidJson<ProfileRequest>,
|
||||||
|
) -> AppResult<Json<MeResponse>> {
|
||||||
|
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<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
auth: AuthUser,
|
||||||
|
ValidJson(req): ValidJson<PasswordChangeRequest>,
|
||||||
|
) -> 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 ----------
|
// ---------- Yardımcılar ----------
|
||||||
|
|
||||||
fn normalize_email(email: &str) -> String {
|
fn normalize_email(email: &str) -> String {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
//! - `GET /api/household/invites` → aktif davetler (yalnızca owner)
|
//! - `GET /api/household/invites` → aktif davetler (yalnızca owner)
|
||||||
//! - `POST /api/household/invites` → yeni davet kodu (yalnızca owner)
|
//! - `POST /api/household/invites` → yeni davet kodu (yalnızca owner)
|
||||||
//! - `DELETE /api/household/invites/{id}` → daveti iptal et (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::{
|
use axum::{
|
||||||
Json,
|
Json,
|
||||||
@@ -12,13 +14,15 @@ use axum::{
|
|||||||
use mongodb::bson::{DateTime, Uuid, doc};
|
use mongodb::bson::{DateTime, Uuid, doc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use ts_rs::TS;
|
use ts_rs::TS;
|
||||||
|
use validator::Validate;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
auth::AuthUser,
|
auth::AuthUser,
|
||||||
db::col,
|
db::col,
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
|
extract::ValidJson,
|
||||||
models::{common::scope, household::Household, invite::Invite, user::Role},
|
models::{common::scope, household::Household, invite::Invite, user::Role},
|
||||||
services::invite,
|
services::{account, invite},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -28,6 +32,41 @@ pub struct HouseholdResponse {
|
|||||||
id: String,
|
id: String,
|
||||||
name: String,
|
name: String,
|
||||||
members: Vec<MemberDto>,
|
members: Vec<MemberDto>,
|
||||||
|
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<String>,
|
||||||
|
#[validate(nested)]
|
||||||
|
budget_split_pct: Option<SplitDto>,
|
||||||
|
#[ts(type = "\"default\" | \"previous\" | \"avg3\"", optional)]
|
||||||
|
budget_suggestion: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, TS)]
|
#[derive(Serialize, TS)]
|
||||||
@@ -78,9 +117,16 @@ pub async fn get(
|
|||||||
let household = state
|
let household = state
|
||||||
.db
|
.db
|
||||||
.collection::<Household>(col::HOUSEHOLDS)
|
.collection::<Household>(col::HOUSEHOLDS)
|
||||||
.find_one(hh_filter)
|
.find_one(hh_filter.clone())
|
||||||
.await?
|
.await?
|
||||||
.ok_or(AppError::NotFound)?;
|
.ok_or(AppError::NotFound)?;
|
||||||
|
let settings = state
|
||||||
|
.db
|
||||||
|
.collection::<mongodb::bson::Document>(col::HOUSEHOLDS)
|
||||||
|
.find_one(hh_filter)
|
||||||
|
.await?
|
||||||
|
.and_then(|d| d.get_document("settings").ok().cloned())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
let mut cursor = state
|
let mut cursor = state
|
||||||
.db
|
.db
|
||||||
@@ -105,9 +151,66 @@ pub async fn get(
|
|||||||
id: hid.to_string(),
|
id: hid.to_string(),
|
||||||
name: household.name,
|
name: household.name,
|
||||||
members,
|
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<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
ValidJson(req): ValidJson<HouseholdUpdateRequest>,
|
||||||
|
) -> AppResult<Json<HouseholdResponse>> {
|
||||||
|
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<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> AppResult<StatusCode> {
|
||||||
|
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(
|
pub async fn list_invites(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
|
|||||||
@@ -49,8 +49,10 @@ pub fn router(state: AppState) -> Router {
|
|||||||
.route("/auth/registration", get(auth::registration_info))
|
.route("/auth/registration", get(auth::registration_info))
|
||||||
.route("/auth/login", post(auth::login))
|
.route("/auth/login", post(auth::login))
|
||||||
.route("/auth/logout", post(auth::logout))
|
.route("/auth/logout", post(auth::logout))
|
||||||
.route("/auth/me", get(auth::me))
|
.route("/auth/me", get(auth::me).patch(auth::update_me))
|
||||||
.route("/household", get(household::get))
|
.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(
|
.route(
|
||||||
"/household/invites",
|
"/household/invites",
|
||||||
get(household::list_invites).post(household::create_invite),
|
get(household::list_invites).post(household::create_invite),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! Hesap işlemleri: kayıt.
|
//! Hesap işlemleri: kayıt; profil, şifre, hane ayarları, üye çıkarma.
|
||||||
//!
|
//!
|
||||||
//! İki yol var:
|
//! İki yol var:
|
||||||
//! - **Davetsiz:** yeni hane + `owner` kullanıcı + örnek kategori/ödeme türleri
|
//! - **Davetsiz:** yeni hane + `owner` kullanıcı + örnek kategori/ödeme türleri
|
||||||
@@ -147,3 +147,178 @@ async fn create_in_tx(
|
|||||||
role,
|
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<String> {
|
||||||
|
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::<Document>(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::<Document>(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<String>,
|
||||||
|
/// `Group::ALL` sırasıyla, toplamı 100
|
||||||
|
pub split_pct: Option<[u32; 4]>,
|
||||||
|
/// `default` | `previous` | `avg3`
|
||||||
|
pub suggestion: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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::<u32>() != 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::<Document>(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::<Document>(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(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<String>) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
+5
-2
@@ -54,9 +54,12 @@ Renkler ve ikonlar frontend tasarım token'larında.
|
|||||||
{ _id, household_id, email /* küçük harf, benzersiz */, password_hash /* argon2 */,
|
{ _id, household_id, email /* küçük harf, benzersiz */, password_hash /* argon2 */,
|
||||||
name, role: "owner" | "member",
|
name, role: "owner" | "member",
|
||||||
preferences: { theme: "light" | "dark" | "system" },
|
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 "<adres>#removed-<id>" 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`
|
### 3.2 `households`
|
||||||
```js
|
```js
|
||||||
|
|||||||
+2
-1
@@ -1,7 +1,7 @@
|
|||||||
# 🧠 Proje Hafızası
|
# 🧠 Proje Hafızası
|
||||||
|
|
||||||
## ▶️ Sıradaki Adım
|
## ▶️ 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 ✅.
|
**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
|
## 📌 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 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 `<a>` olmasın). Bulunan/düzeltilen: (1) şablondaki `<small>` 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 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 `<a>` olmasın). Bulunan/düzeltilen: (1) şablondaki `<small>` 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 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:<uid>`, `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-<id>` → 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.
|
||||||
|
|||||||
+2
-2
@@ -88,7 +88,7 @@
|
|||||||
|
|
||||||
## Faz 10 Adımları (web uygulamasının eksikleri + cila)
|
## 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`
|
- ✅ 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
|
- ✅ 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ı
|
- 🔄 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
|
- ⏳ 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
|
- ⏳ 5. PWA: manifest + service worker (uygulama kabuğu; `/api` asla önbelleğe alınmaz), kurulum, güncelleme bildirimi
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ export const paths = {
|
|||||||
settingsCategories: '/ayarlar/kategoriler',
|
settingsCategories: '/ayarlar/kategoriler',
|
||||||
settingsPaymentMethods: '/ayarlar/odeme-turleri',
|
settingsPaymentMethods: '/ayarlar/odeme-turleri',
|
||||||
settingsData: '/ayarlar/veri',
|
settingsData: '/ayarlar/veri',
|
||||||
|
settingsHousehold: '/ayarlar/hane',
|
||||||
|
settingsAccount: '/ayarlar/hesap',
|
||||||
|
settingsBudget: '/ayarlar/butce',
|
||||||
/** Banka / kart ekstresi içe aktarma */
|
/** Banka / kart ekstresi içe aktarma */
|
||||||
statement: '/ayarlar/ekstre',
|
statement: '/ayarlar/ekstre',
|
||||||
login: '/giris',
|
login: '/giris',
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { SummaryTiles } from '@/features/planning/summary-tiles'
|
|||||||
import { ApiError } from '@/lib/api'
|
import { ApiError } from '@/lib/api'
|
||||||
import { parseMoney } from '@/lib/format'
|
import { parseMoney } from '@/lib/format'
|
||||||
import { GROUPS } from '@/lib/groups'
|
import { GROUPS } from '@/lib/groups'
|
||||||
|
import { useHousehold } from '@/lib/household'
|
||||||
import { toKurus } from '@/lib/money-schema'
|
import { toKurus } from '@/lib/money-schema'
|
||||||
import { useBudget, useSetBudget, useSuggestion, useSummary } from '@/lib/periods'
|
import { useBudget, useSetBudget, useSuggestion, useSummary } from '@/lib/periods'
|
||||||
import { moneyToText } from '@/lib/plan-draft'
|
import { moneyToText } from '@/lib/plan-draft'
|
||||||
@@ -46,12 +47,18 @@ export function StepBudget({
|
|||||||
}) {
|
}) {
|
||||||
const summary = useSummary(period.id)
|
const summary = useSummary(period.id)
|
||||||
const budget = useBudget(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)
|
const failed = queries.find((q) => q.isError)
|
||||||
if (failed?.error) return <PageError message={failed.error.message} onRetry={failed.refetch} />
|
if (failed?.error) return <PageError message={failed.error.message} onRetry={failed.refetch} />
|
||||||
if (!summary.data || !budget.data || !suggestion.data) return <PageLoading />
|
if (!summary.data || !budget.data || !household.data || !chosen.data || !suggestion.data)
|
||||||
|
return <PageLoading />
|
||||||
|
|
||||||
const saved = Object.fromEntries(budget.data.map((g) => [g.group, g.monthly])) as GroupAmounts
|
const saved = Object.fromEntries(budget.data.map((g) => [g.group, g.monthly])) as GroupAmounts
|
||||||
const hasSaved = budget.data.some((g) => g.monthly > 0)
|
const hasSaved = budget.data.some((g) => g.monthly > 0)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Loader2, Sparkles } from 'lucide-react'
|
import { Loader2, Sparkles } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { useHousehold } from '@/lib/household'
|
||||||
import { useSuggestion } from '@/lib/periods'
|
import { useSuggestion } from '@/lib/periods'
|
||||||
import { t } from '@/lib/strings'
|
import { t } from '@/lib/strings'
|
||||||
import type { GroupAmounts } from '@/types/GroupAmounts'
|
import type { GroupAmounts } from '@/types/GroupAmounts'
|
||||||
@@ -19,7 +20,10 @@ export function SuggestionPicker({
|
|||||||
onApply: (amounts: GroupAmounts) => void
|
onApply: (amounts: GroupAmounts) => void
|
||||||
hint?: string
|
hint?: string
|
||||||
}) {
|
}) {
|
||||||
const [method, setMethod] = useState<SuggestMethod>('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<SuggestMethod | null>(null)
|
||||||
|
const method = chosen ?? household.data?.settings.budget_suggestion ?? 'default'
|
||||||
const suggestion = useSuggestion(periodId, method)
|
const suggestion = useSuggestion(periodId, method)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<SettingsSubpage title={s.title} description={s.description}>
|
||||||
|
{!me.data ? (
|
||||||
|
<PageLoading />
|
||||||
|
) : (
|
||||||
|
<div className="max-w-xl space-y-4">
|
||||||
|
<Profile me={me.data} />
|
||||||
|
<ChangePassword />
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<LogoutButton />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SettingsSubpage>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Profile({ me }: { me: MeResponse }) {
|
||||||
|
const update = useUpdateProfile()
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
setError,
|
||||||
|
reset,
|
||||||
|
formState: { errors, isDirty },
|
||||||
|
} = useForm<NameForm>({
|
||||||
|
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 (
|
||||||
|
<Section title={s.profile}>
|
||||||
|
<form onSubmit={onSubmit} className="space-y-4" noValidate>
|
||||||
|
<FormField label={s.name} error={errors.name}>
|
||||||
|
{(f) => <Input {...f} {...register('name')} autoComplete="name" maxLength={80} />}
|
||||||
|
</FormField>
|
||||||
|
<FormField label={s.email} hint={s.emailHint}>
|
||||||
|
{(f) => <Input {...f} value={me.user.email} readOnly disabled />}
|
||||||
|
</FormField>
|
||||||
|
<p className="text-sm">
|
||||||
|
<span className="text-muted-foreground">{s.role}: </span>
|
||||||
|
{me.user.role === 'owner' ? t.account.owner : t.account.member} · {me.household.name}
|
||||||
|
</p>
|
||||||
|
<Button type="submit" disabled={!isDirty || update.isPending}>
|
||||||
|
{update.isPending && <Loader2 className="animate-spin" aria-hidden />}
|
||||||
|
{s.save}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChangePassword() {
|
||||||
|
const change = useChangePassword()
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
setError,
|
||||||
|
reset,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<PasswordForm>({
|
||||||
|
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 (
|
||||||
|
<Section title={s.password}>
|
||||||
|
<form onSubmit={onSubmit} className="space-y-4" noValidate>
|
||||||
|
<p className="text-sm text-muted-foreground">{s.otherDevices}</p>
|
||||||
|
{/* Parola yöneticileri için gizli kullanıcı adı alanı */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
autoComplete="username"
|
||||||
|
className="hidden"
|
||||||
|
readOnly
|
||||||
|
aria-hidden
|
||||||
|
tabIndex={-1}
|
||||||
|
/>
|
||||||
|
<FormField label={s.current} error={errors.current_password}>
|
||||||
|
{(f) => (
|
||||||
|
<PasswordInput
|
||||||
|
{...f}
|
||||||
|
{...register('current_password')}
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</FormField>
|
||||||
|
<FormField label={s.next} error={errors.new_password} hint={s.nextHint}>
|
||||||
|
{(f) => (
|
||||||
|
<PasswordInput {...f} {...register('new_password')} autoComplete="new-password" />
|
||||||
|
)}
|
||||||
|
</FormField>
|
||||||
|
<FormField label={s.repeat} error={errors.repeat}>
|
||||||
|
{(f) => <PasswordInput {...f} {...register('repeat')} autoComplete="new-password" />}
|
||||||
|
</FormField>
|
||||||
|
<FormError message={errors.root?.message} />
|
||||||
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
|
{isSubmitting && <Loader2 className="animate-spin" aria-hidden />}
|
||||||
|
{s.change}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<SettingsSubpage title={s.title} description={s.description}>
|
||||||
|
{household.isError ? (
|
||||||
|
<PageError message={household.error.message} onRetry={household.refetch} />
|
||||||
|
) : !household.data || !me.data ? (
|
||||||
|
<PageLoading />
|
||||||
|
) : (
|
||||||
|
<BudgetForm household={household.data} readOnly={me.data.user.role !== 'owner'} />
|
||||||
|
)}
|
||||||
|
</SettingsSubpage>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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<BudgetForm, unknown, BudgetValues>({
|
||||||
|
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 (
|
||||||
|
<form onSubmit={onSubmit} className="max-w-2xl space-y-4" noValidate>
|
||||||
|
{readOnly && (
|
||||||
|
<p className="rounded-xl bg-muted px-3 py-2 text-sm text-muted-foreground">{s.readOnly}</p>
|
||||||
|
)}
|
||||||
|
<Section title={s.split}>
|
||||||
|
<fieldset disabled={readOnly} className="space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">{s.splitHint}</p>
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
{/* z.coerce girişi `unknown` → hata türü genişler; alanlar düz değer */}
|
||||||
|
{GROUPS.map((g) => (
|
||||||
|
<FormField
|
||||||
|
key={g.code}
|
||||||
|
label={`${g.emoji} ${g.label}`}
|
||||||
|
error={errors[g.code] as FieldError | undefined}
|
||||||
|
>
|
||||||
|
{(f) => (
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
{...f}
|
||||||
|
{...register(g.code)}
|
||||||
|
inputMode="numeric"
|
||||||
|
className="money pr-8 text-right"
|
||||||
|
/>
|
||||||
|
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-muted-foreground">
|
||||||
|
%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</FormField>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<p
|
||||||
|
role="status"
|
||||||
|
className={cn('money font-medium', total === 100 ? 'text-income' : 'text-warning')}
|
||||||
|
>
|
||||||
|
{s.total(total)}
|
||||||
|
</p>
|
||||||
|
{!readOnly && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const classic = { needs: 50, wants: 30, culture: 10, unexpected: 10 }
|
||||||
|
for (const [k, v] of Object.entries(classic))
|
||||||
|
setValue(k as keyof typeof classic, v, {
|
||||||
|
shouldDirty: true,
|
||||||
|
shouldValidate: true,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s.classic}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title={s.method}>
|
||||||
|
<fieldset disabled={readOnly} className="space-y-3">
|
||||||
|
<legend className="sr-only">{s.method}</legend>
|
||||||
|
<p className="text-sm text-muted-foreground">{s.methodHint}</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{METHODS.map((m) => (
|
||||||
|
<label
|
||||||
|
key={m}
|
||||||
|
className={cn(
|
||||||
|
'flex min-h-11 cursor-pointer items-center gap-2 rounded-lg border px-3 has-[:focus-visible]:ring-3 has-[:focus-visible]:ring-ring/50',
|
||||||
|
method === m && 'border-primary bg-primary/10',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
value={m}
|
||||||
|
{...register('budget_suggestion')}
|
||||||
|
className="accent-primary"
|
||||||
|
/>
|
||||||
|
{t.wizard.budget.methods[m]}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<FormError message={errors.root?.message} />
|
||||||
|
{!readOnly && (
|
||||||
|
<Button type="submit" disabled={!isDirty || update.isPending}>
|
||||||
|
{update.isPending && <Loader2 className="animate-spin" aria-hidden />}
|
||||||
|
{s.save}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<SettingsSubpage title={s.title} description={s.description}>
|
||||||
|
{household.isError ? (
|
||||||
|
<PageError message={household.error.message} onRetry={household.refetch} />
|
||||||
|
) : !household.data || !me.data ? (
|
||||||
|
<PageLoading />
|
||||||
|
) : (
|
||||||
|
<div className="max-w-2xl space-y-4">
|
||||||
|
{isOwner ? (
|
||||||
|
<NameCard household={household.data} />
|
||||||
|
) : (
|
||||||
|
<p className="rounded-xl bg-muted px-3 py-2 text-sm text-muted-foreground">
|
||||||
|
{s.ownerOnly}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Members household={household.data} myId={me.data.user.id} isOwner={isOwner} />
|
||||||
|
{isOwner && <Invites householdName={household.data.name} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SettingsSubpage>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NameCard({ household }: { household: Household }) {
|
||||||
|
const update = useUpdateHousehold()
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
setError,
|
||||||
|
reset,
|
||||||
|
formState: { errors, isDirty },
|
||||||
|
} = useForm<NameForm>({
|
||||||
|
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 (
|
||||||
|
<Section title={s.name}>
|
||||||
|
<form onSubmit={onSubmit} className="flex flex-wrap items-start gap-2" noValidate>
|
||||||
|
<FormField label={s.name} error={errors.name} className="min-w-0 flex-1 [&>label]:sr-only">
|
||||||
|
{(f) => <Input {...f} {...register('name')} autoComplete="off" maxLength={80} />}
|
||||||
|
</FormField>
|
||||||
|
<Button type="submit" disabled={!isDirty || update.isPending}>
|
||||||
|
{update.isPending && <Loader2 className="animate-spin" aria-hidden />}
|
||||||
|
{s.save}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Section title={s.members}>
|
||||||
|
<ul className="divide-y">
|
||||||
|
{household.members.map((m) => (
|
||||||
|
<li key={m.id} className="flex min-h-14 items-center gap-3 py-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate font-medium">
|
||||||
|
{m.name} {m.id === myId && <span className="text-muted-foreground">{s.you}</span>}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-sm text-muted-foreground">
|
||||||
|
{m.email} · {m.role === 'owner' ? t.account.owner : t.account.member}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{isOwner && m.role !== 'owner' && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-expense"
|
||||||
|
aria-label={s.remove(m.name)}
|
||||||
|
disabled={remove.isPending}
|
||||||
|
onClick={() => void onRemove(m.id, m.name)}
|
||||||
|
>
|
||||||
|
<UserMinus aria-hidden />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invites({ householdName }: { householdName: string }) {
|
||||||
|
const invites = useInvites(true)
|
||||||
|
const create = useCreateInvite()
|
||||||
|
const [error, setError] = useState<string>()
|
||||||
|
|
||||||
|
async function onCreate() {
|
||||||
|
setError(undefined)
|
||||||
|
try {
|
||||||
|
const inv = await create.mutateAsync()
|
||||||
|
await copy(inv)
|
||||||
|
} catch (e) {
|
||||||
|
setError(message(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section title={s.invites}>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-muted-foreground">{s.invitesHint}</p>
|
||||||
|
<Button onClick={() => void onCreate()} disabled={create.isPending}>
|
||||||
|
{create.isPending ? (
|
||||||
|
<Loader2 className="animate-spin" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<UserPlus aria-hidden />
|
||||||
|
)}
|
||||||
|
{s.newInvite}
|
||||||
|
</Button>
|
||||||
|
<FormError message={error} />
|
||||||
|
{invites.data && invites.data.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">{s.noInvites}</p>
|
||||||
|
)}
|
||||||
|
{invites.data && invites.data.length > 0 && (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{invites.data.map((inv) => (
|
||||||
|
<InviteRow key={inv.id} invite={inv} householdName={householdName} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 (
|
||||||
|
<li className="space-y-2 rounded-xl border p-3">
|
||||||
|
<Input
|
||||||
|
readOnly
|
||||||
|
value={link}
|
||||||
|
aria-label={s.code(invite.code)}
|
||||||
|
onFocus={(e) => e.currentTarget.select()}
|
||||||
|
className="font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{s.code(invite.code)} · {s.expires(formatDate(invite.expires_at))}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button variant="outline" onClick={() => void copy(invite)}>
|
||||||
|
<Copy aria-hidden />
|
||||||
|
{s.copy}
|
||||||
|
</Button>
|
||||||
|
{canShare && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() =>
|
||||||
|
void navigator
|
||||||
|
.share({ text: s.shareText(householdName), url: link })
|
||||||
|
.catch(() => undefined)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Share2 aria-hidden />
|
||||||
|
{s.share}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="ml-auto text-expense"
|
||||||
|
disabled={revoke.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
revoke.mutate(invite.id, {
|
||||||
|
onSuccess: () => toast(s.revoked),
|
||||||
|
onError: (e) => toast.error(message(e)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{s.revoke}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 { Link } from 'react-router'
|
||||||
import { paths } from '@/components/layout/nav-items'
|
import { paths } from '@/components/layout/nav-items'
|
||||||
import { PageHeader } from '@/components/page'
|
import { PageHeader } from '@/components/page'
|
||||||
@@ -10,6 +19,9 @@ const s = t.settings
|
|||||||
const items: { to: string; icon: LucideIcon; title: string; hint: string }[] = [
|
const items: { to: string; icon: LucideIcon; title: string; hint: string }[] = [
|
||||||
{ to: paths.settingsCategories, icon: Tags, ...s.menu.categories },
|
{ to: paths.settingsCategories, icon: Tags, ...s.menu.categories },
|
||||||
{ to: paths.settingsPaymentMethods, icon: Wallet, ...s.menu.paymentMethods },
|
{ 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 },
|
{ to: paths.settingsData, icon: Database, ...s.menu.data },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<typeof nameSchema>
|
||||||
|
|
||||||
|
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<typeof passwordSchema>
|
||||||
|
|
||||||
|
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<typeof budgetSchema>
|
||||||
|
export type BudgetValues = z.output<typeof budgetSchema>
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>('/household'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateHousehold() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: HouseholdUpdateRequest) => api.patch<Household>('/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<void>(`/household/members/${id}`),
|
||||||
|
onSettled: () => qc.invalidateQueries({ queryKey: queryKeys.household }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInvites(enabled: boolean) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.invites,
|
||||||
|
queryFn: () => api.get<Invite[]>('/household/invites'),
|
||||||
|
enabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateInvite() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () => api.post<Invite>('/household/invites'),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.invites }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRevokeInvite() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => api.delete<void>(`/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<MeResponse>('/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<void>('/auth/me/password', body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Yüzdelerin toplamı (bütçe varsayılanları formu) */
|
||||||
|
export function pctTotal(p: Record<string, number | string>): number {
|
||||||
|
return Object.values(p).reduce<number>((a, v) => a + (Number(v) || 0), 0)
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ export const queryKeys = {
|
|||||||
registration: ['registration'] as const,
|
registration: ['registration'] as const,
|
||||||
health: ['health'] as const,
|
health: ['health'] as const,
|
||||||
household: ['household'] as const,
|
household: ['household'] as const,
|
||||||
|
invites: ['household', 'invites'] as const,
|
||||||
categories: (includeInactive = false) => ['categories', { includeInactive }] as const,
|
categories: (includeInactive = false) => ['categories', { includeInactive }] as const,
|
||||||
paymentMethods: (includeInactive = false) => ['payment-methods', { includeInactive }] as const,
|
paymentMethods: (includeInactive = false) => ['payment-methods', { includeInactive }] as const,
|
||||||
/** Dönem listesi; `currentPeriod` da bu önekle başlar (birlikte yenilenir) */
|
/** Dönem listesi; `currentPeriod` da bu önekle başlar (birlikte yenilenir) */
|
||||||
|
|||||||
@@ -308,6 +308,77 @@ export const t = {
|
|||||||
},
|
},
|
||||||
paymentMethods: { title: 'Ödeme türleri', hint: 'Nakit, kart, havale…' },
|
paymentMethods: { title: 'Ödeme türleri', hint: 'Nakit, kart, havale…' },
|
||||||
data: { title: 'Veri ve yedek', hint: 'JSON yedek, CSV, banka ekstresi' },
|
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: {
|
categories: {
|
||||||
title: 'Kategoriler',
|
title: 'Kategoriler',
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ import { ReviewPage } from '@/features/review/page'
|
|||||||
import { AssetPage } from '@/features/savings/asset-page'
|
import { AssetPage } from '@/features/savings/asset-page'
|
||||||
import { SavingsPage } from '@/features/savings/page'
|
import { SavingsPage } from '@/features/savings/page'
|
||||||
import { CategoriesPage, PaymentMethodsPage } from '@/features/settings/catalog-pages'
|
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 { DataPage } from '@/features/settings/data-page'
|
||||||
|
import { HouseholdPage } from '@/features/settings/household-page'
|
||||||
import { SettingsPage } from '@/features/settings/page'
|
import { SettingsPage } from '@/features/settings/page'
|
||||||
import { StatementPage } from '@/features/statement/page'
|
import { StatementPage } from '@/features/statement/page'
|
||||||
|
|
||||||
@@ -52,6 +55,9 @@ const routes: RouteObject[] = [
|
|||||||
{ path: paths.settingsCategories, element: <CategoriesPage /> },
|
{ path: paths.settingsCategories, element: <CategoriesPage /> },
|
||||||
{ path: paths.settingsPaymentMethods, element: <PaymentMethodsPage /> },
|
{ path: paths.settingsPaymentMethods, element: <PaymentMethodsPage /> },
|
||||||
{ path: paths.settingsData, element: <DataPage /> },
|
{ path: paths.settingsData, element: <DataPage /> },
|
||||||
|
{ path: paths.settingsHousehold, element: <HouseholdPage /> },
|
||||||
|
{ path: paths.settingsAccount, element: <AccountPage /> },
|
||||||
|
{ path: paths.settingsBudget, element: <BudgetDefaultsPage /> },
|
||||||
{ path: paths.statement, element: <StatementPage /> },
|
{ path: paths.statement, element: <StatementPage /> },
|
||||||
{ path: paths.more, element: <MorePage /> },
|
{ path: paths.more, element: <MorePage /> },
|
||||||
{ path: '*', element: <NotFoundPage /> },
|
{ path: '*', element: <NotFoundPage /> },
|
||||||
|
|||||||
@@ -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, };
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// 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";
|
import type { Member } from "./Member";
|
||||||
|
|
||||||
export type Household = { id: string, name: string, members: Array<Member>, };
|
export type Household = { id: string, name: string, members: Array<Member>, settings: HouseholdSettings, };
|
||||||
|
|||||||
@@ -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", };
|
||||||
@@ -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", };
|
||||||
@@ -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, };
|
||||||
@@ -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, };
|
||||||
Reference in New Issue
Block a user