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:
@@ -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?;
|
||||
|
||||
Reference in New Issue
Block a user