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