feat(kayıt): yeni hane kaydı yalnızca davetle (REGISTRATION=invite_only)

- REGISTRATION ortam değişkeni: invite_only (varsayılan) | open
- invite_only: ilk hane serbest, sonrasında yalnızca davet koduyla katılım
- Backend'de zorunlu: 403 registration_closed; GET /api/auth/registration
- Kayıt ekranı: kayıt kapalıyken yalnızca davet kodu ve açıklama
- Entegrasyon testleri: kayıt politikası (2)
- Prettier ayarı eklendi (proje stiliyle uyumlu), kaynaklar biçimlendirildi

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-12 01:04:48 +03:00
parent 9cbeaaaf76
commit e188922c0f
29 changed files with 547 additions and 230 deletions
+5
View File
@@ -17,6 +17,8 @@ GET {{baseUrl}}/health
### KAYIT ÖRNEĞİ — bu bir hesap DEĞİL, hesap OLUŞTURMA isteğidir.
### Hesap ancak bu isteği gönderince oluşur (kullanıcı + hane + örnek kategoriler/ödeme türleri).
### Aynı e-postayla ikinci kez gönderirseniz 409 "zaten kayıtlı" alırsınız.
### REGISTRATION=invite_only (varsayılan): sistemde zaten hane varsa davetsiz kayıt 403 "registration_closed" döner
### → aşağıdaki "Davet koduyla kayıt" örneğini kullanın (kodu hane sahibi oluşturur).
POST {{baseUrl}}/auth/register
Content-Type: application/json
@@ -49,6 +51,9 @@ Content-Type: application/json
"remember_me": true
}
### Kayıt politikası: yeni hane kurulabilir mi? (oturum gerektirmez)
GET {{baseUrl}}/auth/registration
### Ben kimim? (oturum yoksa 401)
GET {{baseUrl}}/auth/me
+23
View File
@@ -8,6 +8,17 @@ pub struct Config {
pub jwt_secret: String,
pub app_url: String,
pub port: u16,
pub registration: RegistrationMode,
}
/// Yeni hane kaydı politikası (`REGISTRATION` ortam değişkeni).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RegistrationMode {
/// Varsayılan. Sistemde hiç hane yokken ilk kayıt yeni hane kurar;
/// sonrasında yalnızca davet koduyla katılınır.
InviteOnly,
/// Herkes yeni hane kurabilir.
Open,
}
#[derive(Debug, thiserror::Error)]
@@ -32,6 +43,17 @@ impl Config {
let app_url = env::var("APP_URL").unwrap_or_else(|_| "http://localhost:5173".into());
let registration = match env::var("REGISTRATION").as_deref() {
Err(_) | Ok("") | Ok("invite_only") => RegistrationMode::InviteOnly,
Ok("open") => RegistrationMode::Open,
Ok(_) => {
return Err(ConfigError::Invalid(
"REGISTRATION",
"\"invite_only\" veya \"open\" olmalı",
));
}
};
let port = match env::var("PORT") {
Ok(p) => p
.parse()
@@ -44,6 +66,7 @@ impl Config {
jwt_secret,
app_url,
port,
registration,
})
}
+3
View File
@@ -37,6 +37,8 @@ pub enum AppError {
InvalidCredentials,
#[error("Bu işlem için yetkiniz yok")]
Forbidden,
#[error("Yeni hane kaydı kapalı. Katılmak için hane sahibinden davet bağlantısı isteyin.")]
RegistrationClosed,
#[error("{0}")]
Conflict(String),
#[error("Çok fazla deneme yapıldı, lütfen biraz sonra tekrar deneyin")]
@@ -55,6 +57,7 @@ impl AppError {
Self::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"),
Self::InvalidCredentials => (StatusCode::UNAUTHORIZED, "invalid_credentials"),
Self::Forbidden => (StatusCode::FORBIDDEN, "forbidden"),
Self::RegistrationClosed => (StatusCode::FORBIDDEN, "registration_closed"),
Self::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
Self::TooManyRequests => (StatusCode::TOO_MANY_REQUESTS, "too_many_requests"),
Self::Database(_) | Self::Internal(_) => {
+16
View File
@@ -103,6 +103,7 @@ pub async fn register(
let password_hash = password::hash(req.password).await?;
let created = account::register(
&state.db,
state.config.registration,
NewAccount {
name,
email,
@@ -174,6 +175,21 @@ pub async fn login(
Ok((jar, Json(me)))
}
/// Kayıt ekranı için: yeni hane kurulabilir mi? (oturum gerektirmez)
#[derive(Serialize, TS)]
#[ts(export)]
pub struct RegistrationInfo {
/// `false` ise yalnızca davet koduyla kayıt yapılabilir
new_household_allowed: bool,
}
pub async fn registration_info(State(state): State<AppState>) -> AppResult<Json<RegistrationInfo>> {
let allowed = account::new_household_allowed(&state.db, state.config.registration).await?;
Ok(Json(RegistrationInfo {
new_household_allowed: allowed,
}))
}
pub async fn logout(jar: CookieJar) -> (StatusCode, CookieJar) {
(StatusCode::NO_CONTENT, jar.remove(removal_cookie()))
}
+1
View File
@@ -17,6 +17,7 @@ pub fn router(state: AppState) -> Router {
let api = Router::new()
.route("/health", get(health::health))
.route("/auth/register", post(auth::register))
.route("/auth/registration", get(auth::registration_info))
.route("/auth/login", post(auth::login))
.route("/auth/logout", post(auth::logout))
.route("/auth/me", get(auth::me))
+26 -1
View File
@@ -10,6 +10,7 @@ use mongodb::{
};
use crate::{
config::RegistrationMode,
db::{col, is_duplicate_key},
error::{AppError, AppResult},
models::{
@@ -28,6 +29,21 @@ pub struct NewAccount {
pub invite_code: Option<String>,
}
/// Şu an davetsiz (yeni hane kurarak) kayıt yapılabilir mi?
pub async fn new_household_allowed(db: &Database, mode: RegistrationMode) -> AppResult<bool> {
Ok(match mode {
RegistrationMode::Open => true,
// Hiç hane yoksa ilk kurulum serbest (sunucu sahibi kendi hesabını açar)
RegistrationMode::InviteOnly => {
db.collection::<Document>(col::HOUSEHOLDS)
.count_documents(doc! {})
.limit(1)
.await?
== 0
}
})
}
pub struct CreatedAccount {
pub user_id: Uuid,
pub household_id: Uuid,
@@ -36,7 +52,16 @@ pub struct CreatedAccount {
/// Kaydı **tek transaction** içinde yapar: herhangi bir adım başarısız olursa
/// hiçbir kayıt oluşmaz ve (varsa) davet kodu kullanılmamış sayılır.
pub async fn register(db: &Database, acc: NewAccount) -> AppResult<CreatedAccount> {
pub async fn register(
db: &Database,
mode: RegistrationMode,
acc: NewAccount,
) -> AppResult<CreatedAccount> {
// Davetsiz kayıt = yeni hane → politika kontrolü (arayüzden bağımsız, API'de zorunlu)
if acc.invite_code.is_none() && !new_household_allowed(db, mode).await? {
return Err(AppError::RegistrationClosed);
}
let user_id = new_id();
let mut session = db.client().start_session().await?;
+11 -1
View File
@@ -11,7 +11,11 @@ use axum::{
http::{Method, Request, StatusCode, header},
};
use http_body_util::BodyExt;
use kakeibo::{config::Config, db, routes, state::AppState};
use kakeibo::{
config::{Config, RegistrationMode},
db, routes,
state::AppState,
};
use mongodb::Database;
use serde_json::{Value, json};
use tower::ServiceExt;
@@ -29,7 +33,12 @@ pub struct Session {
}
impl TestApp {
/// Kayıt açık (birden fazla hane gerektiren testler için).
pub async fn new() -> Self {
Self::with_registration(RegistrationMode::Open).await
}
pub async fn with_registration(registration: RegistrationMode) -> Self {
dotenvy::dotenv().ok();
let uri = std::env::var("MONGODB_URI")
.expect("MONGODB_URI tanımlı değil (.env dosyasını kontrol edin)");
@@ -46,6 +55,7 @@ impl TestApp {
jwt_secret: "test-jwt-secret-en-az-32-karakter-olmali!!".into(),
app_url: "http://localhost".into(),
port: 0,
registration,
};
let router = routes::router(AppState::new(db.clone(), config));
Self { router, db }
+70
View File
@@ -0,0 +1,70 @@
//! Kayıt politikası: REGISTRATION=invite_only (varsayılan).
mod common;
use axum::http::{Method, StatusCode};
use common::TestApp;
use kakeibo::config::RegistrationMode;
use serde_json::json;
async fn new_household_allowed(app: &TestApp) -> bool {
let (st, body, _) = app
.send(Method::GET, "/auth/registration", None, None)
.await;
assert_eq!(st, StatusCode::OK);
body["new_household_allowed"].as_bool().unwrap()
}
#[tokio::test]
async fn invite_only_allows_first_household_then_only_invites() {
let app = TestApp::with_registration(RegistrationMode::InviteOnly).await;
// Boş sistem: ilk kurulum serbest
assert!(new_household_allowed(&app).await);
let owner = app.register("Sahip", None).await;
// Artık yeni hane kurulamaz (arayüz bilgisi + API zorunluluğu)
assert!(!new_household_allowed(&app).await);
let (st, body, _) = app
.send(
Method::POST,
"/auth/register",
None,
Some(
json!({ "name": "Yabancı", "email": "yabanci@test.com", "password": "sifre12345" }),
),
)
.await;
assert_eq!(st, StatusCode::FORBIDDEN);
assert_eq!(body["error"]["code"], "registration_closed");
// Davetle katılım serbest
let (_, inv) = app.post("/household/invites", &owner, json!({})).await;
let member = app
.register("Es", Some(inv["code"].as_str().unwrap()))
.await;
assert_eq!(member.household_id, owner.household_id);
// Geçersiz davet kodu yine 400 (politika hatası değil)
let (st, _, _) = app
.send(
Method::POST,
"/auth/register",
None,
Some(json!({ "name": "X", "email": "x@test.com", "password": "sifre12345", "invite_code": "ZZZZZZZZ" })),
)
.await;
assert_eq!(st, StatusCode::BAD_REQUEST);
app.cleanup().await;
}
#[tokio::test]
async fn open_mode_allows_many_households() {
let app = TestApp::with_registration(RegistrationMode::Open).await;
let a = app.register("A", None).await;
let b = app.register("B", None).await;
assert_ne!(a.household_id, b.household_id);
assert!(new_household_allowed(&app).await);
app.cleanup().await;
}