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:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user