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:
2026-11-13 22:32:28 +03:00
parent 9daeff3eb5
commit 77d7a081b3
31 changed files with 1564 additions and 21 deletions
+12 -2
View File
@@ -3,7 +3,8 @@
//! Bir route parametrelerine `user: AuthUser` eklediğinde, Axum isteği işlemeden önce:
//! 1. cookie'deki JWT'nin imzasını ve süresini doğrular,
//! 2. kullanıcının veritabanında hâlâ var olduğunu ve aynı haneye ait olduğunu kontrol eder
//! (silinen kullanıcının token'ı anında geçersiz olur, rol değişikliği hemen etkili olur).
//! (silinen kullanıcının token'ı anında geçersiz olur, rol değişikliği hemen etkili olur),
//! 3. jeton şifre değişikliğinden önce verilmişse reddeder (`sessions_valid_from`).
//!
//! Herhangi biri başarısızsa route hiç çalışmaz, 401 döner.
//! **Hane izolasyonu:** route'lar hane kimliğini her zaman `AuthUser.household_id`'den alır,
@@ -67,6 +68,9 @@ impl AuthUser {
#[derive(Deserialize)]
struct RoleOnly {
role: Role,
/// Şifre değişince "şimdi": bundan önce verilmiş jetonlar geçersiz
#[serde(default)]
sessions_valid_from: Option<i64>,
}
impl FromRequestParts<AppState> for AuthUser {
@@ -85,9 +89,15 @@ impl FromRequestParts<AppState> for AuthUser {
.db
.collection::<RoleOnly>(col::USERS)
.find_one(doc! { "_id": user_id, "household_id": household_id, "deleted_at": null })
.projection(doc! { "role": 1 })
.projection(doc! { "role": 1, "sessions_valid_from": 1 })
.await?
.ok_or(AppError::Unauthorized)?;
if current
.sessions_valid_from
.is_some_and(|from| (claims.iat as i64) < from)
{
return Err(AppError::Unauthorized);
}
Ok(Self {
user_id,
+72 -2
View File
@@ -1,5 +1,6 @@
//! Kimlik doğrulama uç noktaları:
//! `POST /api/auth/register`, `POST /api/auth/login`, `POST /api/auth/logout`, `GET /api/auth/me`
//! `POST /api/auth/register`, `POST /api/auth/login`, `POST /api/auth/logout`, `GET /api/auth/me`,
//! `PATCH /api/auth/me` (ad), `POST /api/auth/me/password` (şifre değiştir → diğer oturumlar kapanır)
use axum::{Json, extract::State, http::StatusCode};
use axum_extra::extract::cookie::CookieJar;
@@ -11,7 +12,7 @@ use validator::Validate;
use crate::{
auth::{
AuthUser, password,
session::{removal_cookie, session_cookie, ttl},
session::{COOKIE_NAME, removal_cookie, session_cookie, ttl},
},
db::col,
error::{AppError, AppResult},
@@ -77,6 +78,22 @@ struct HouseholdDto {
name: String,
}
#[derive(Deserialize, Validate, TS)]
#[ts(export)]
pub struct ProfileRequest {
#[validate(length(min = 1, max = 80, message = "Ad 1–80 karakter olmalı"))]
name: String,
}
#[derive(Deserialize, Validate, TS)]
#[ts(export)]
pub struct PasswordChangeRequest {
#[validate(length(min = 1, message = "Mevcut şifreyi girin"))]
current_password: String,
#[validate(length(min = 8, max = 128, message = "Yeni şifre en az 8 karakter olmalı"))]
new_password: String,
}
// ---------- Uç noktalar ----------
pub async fn register(
@@ -200,6 +217,59 @@ pub async fn me(State(state): State<AppState>, auth: AuthUser) -> AppResult<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 ----------
fn normalize_email(email: &str) -> String {
+105 -2
View File
@@ -3,6 +3,8 @@
//! - `GET /api/household/invites` → aktif davetler (yalnızca owner)
//! - `POST /api/household/invites` → yeni davet kodu (yalnızca owner)
//! - `DELETE /api/household/invites/{id}` → daveti iptal et (yalnızca owner)
//! - `PATCH /api/household` → ad + bütçe varsayılanları (yalnızca owner)
//! - `DELETE /api/household/members/{id}` → üyeyi çıkar (yalnızca owner)
use axum::{
Json,
@@ -12,13 +14,15 @@ use axum::{
use mongodb::bson::{DateTime, Uuid, doc};
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use validator::Validate;
use crate::{
auth::AuthUser,
db::col,
error::{AppError, AppResult},
extract::ValidJson,
models::{common::scope, household::Household, invite::Invite, user::Role},
services::invite,
services::{account, invite},
state::AppState,
};
@@ -28,6 +32,41 @@ pub struct HouseholdResponse {
id: String,
name: String,
members: Vec<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)]
@@ -78,9 +117,16 @@ pub async fn get(
let household = state
.db
.collection::<Household>(col::HOUSEHOLDS)
.find_one(hh_filter)
.find_one(hh_filter.clone())
.await?
.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
.db
@@ -105,9 +151,66 @@ pub async fn get(
id: hid.to_string(),
name: household.name,
members,
settings: settings_dto(&settings),
}))
}
fn settings_dto(s: &mongodb::bson::Document) -> SettingsDto {
let pct = s.get_document("budget_split_pct").ok();
let v = |k: &str, d: u32| {
pct.and_then(|p| match p.get(k) {
Some(mongodb::bson::Bson::Int32(v)) => u32::try_from(*v).ok(),
Some(mongodb::bson::Bson::Int64(v)) => u32::try_from(*v).ok(),
_ => None,
})
.unwrap_or(d)
};
SettingsDto {
budget_split_pct: SplitDto {
needs: v("needs", 50),
wants: v("wants", 30),
culture: v("culture", 10),
unexpected: v("unexpected", 10),
},
budget_suggestion: s
.get_str("budget_suggestion")
.unwrap_or("default")
.to_string(),
}
}
pub async fn update(
State(state): State<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(
State(state): State<AppState>,
auth: AuthUser,
+4 -2
View File
@@ -49,8 +49,10 @@ pub fn router(state: AppState) -> Router {
.route("/auth/registration", get(auth::registration_info))
.route("/auth/login", post(auth::login))
.route("/auth/logout", post(auth::logout))
.route("/auth/me", get(auth::me))
.route("/household", get(household::get))
.route("/auth/me", get(auth::me).patch(auth::update_me))
.route("/household", get(household::get).patch(household::update))
.route("/household/members/{id}", delete(household::remove_member))
.route("/auth/me/password", post(auth::change_password))
.route(
"/household/invites",
get(household::list_invites).post(household::create_invite),
+176 -1
View File
@@ -1,4 +1,4 @@
//! Hesap işlemleri: kayıt.
//! Hesap işlemleri: kayıt; profil, şifre, hane ayarları, üye çıkarma.
//!
//! İki yol var:
//! - **Davetsiz:** yeni hane + `owner` kullanıcı + örnek kategori/ödeme türleri
@@ -147,3 +147,178 @@ async fn create_in_tx(
role,
})
}
// ---------- Profil, şifre, hane ayarları, üyeler (Faz 10) ----------
/// Oturumların geçerlilik başlangıcı (Unix saniye). Şifre değişince "şimdi" yapılır:
/// daha önce verilmiş tüm oturum jetonları (`iat` daha küçük) reddedilir.
pub const SESSIONS_VALID_FROM: &str = "sessions_valid_from";
fn unix_now() -> i64 {
time::OffsetDateTime::now_utc().unix_timestamp()
}
fn clean_text(v: &str, what: &str, max: usize) -> AppResult<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(())
}