Compare commits
3 Commits
9cbeaaaf76
...
f86cff4755
| Author | SHA1 | Date | |
|---|---|---|---|
| f86cff4755 | |||
| ad90551c6d | |||
| e188922c0f |
@@ -0,0 +1,11 @@
|
||||
# Docker build bağlamına girmeyecekler (hız + gizli bilgi sızmasın)
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
**/node_modules
|
||||
**/target
|
||||
**/dist
|
||||
backups/
|
||||
.vscode/
|
||||
*.log
|
||||
@@ -9,8 +9,18 @@ JWT_SECRET=degistir-beni-en-az-32-karakterlik-gizli-anahtar
|
||||
# Uygulamanın erişildiği adres
|
||||
APP_URL=http://localhost:5173
|
||||
|
||||
# Yeni hane kaydı: invite_only (varsayılan, önerilen) | open
|
||||
# invite_only → sistemde hiç hane yokken ilk kayıt yeni hane kurar (sunucu sahibi),
|
||||
# sonra yalnızca davet koduyla katılınır.
|
||||
# open → herkes /kayit ekranından yeni hane kurabilir.
|
||||
REGISTRATION=invite_only
|
||||
|
||||
# Backend portu
|
||||
PORT=8080
|
||||
|
||||
# Log seviyesi
|
||||
RUST_LOG=kakeibo=debug,tower_http=info
|
||||
|
||||
# Derlenmiş arayüz klasörü. Geliştirmede BOŞ bırakın (arayüzü Vite sunar).
|
||||
# Docker imajında /app/static olarak ayarlıdır.
|
||||
# STATIC_DIR=
|
||||
|
||||
@@ -72,7 +72,10 @@ npm run icons # frontend/brand/*.svg → favicon, PWA, Apple, Tauri ikonl
|
||||
- Formlar: react-hook-form + zod (`mode: 'onTouched'`), alanlar `components/form-field.tsx` (`FormField`, `PasswordInput`, `FormError`); sunucu hatası `setError` ile ilgili alana. Bildirim: `toast` (sonner, `components/toaster.tsx`).
|
||||
- Grup tanımları: `lib/groups.ts`; tema: `lib/theme.tsx` (+ `index.html` içindeki ilk-boya betiği); biçimlendirme: `lib/format.ts`.
|
||||
- API örnekleri: `backend/api.http`
|
||||
- Canlı: `Dockerfile` (tek imaj), `docs/DEPLOY.md` (ortam değişkenleri, Dokploy, MongoDB, yedek). `routes::app()` = API + `STATIC_DIR` varsa SPA (`routes/spa.rs`) + sıkıştırma + TraceLayer. Testler `routes::router()` kullanır.
|
||||
- Backend = lib (`src/lib.rs`) + bin (`src/main.rs`). Entegrasyon testleri `backend/tests/` (her test geçici `kakeibo_test_<uuid>` DB, `tests/common` yardımcıları).
|
||||
- Kayıt politikası: `REGISTRATION=invite_only` (varsayılan; ilk hane serbest, sonra yalnızca davet) | `open`. Backend'de zorunlu (403 `registration_closed`), `GET /api/auth/registration` arayüze bildirir. Testler `TestApp::new()` = open, `with_registration(..)`.
|
||||
- Biçim: Prettier (`frontend/.prettierrc.json`: tek tırnak, `;` yok, 100 sütun); `npm --prefix frontend run format`. `src/types/` ve `components/ui/` hariç.
|
||||
- Hane izolasyonu: hane kimliği yalnızca `AuthUser.household_id`'den; sorgular `scope(hid)` ile başlar; başka hane → 404.
|
||||
|
||||
## Ortam
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
# Kakeibo — canlı ortam imajı (tek servis: Axum hem /api'yi hem arayüzü sunar)
|
||||
# Yerelde deneme: docker build -t kakeibo . && docker run --rm -p 8090:8080 --env-file .env kakeibo
|
||||
|
||||
# ─── 1) Arayüz (React + Vite) ─────────────────────────────────────────────
|
||||
FROM node:26-alpine AS web
|
||||
WORKDIR /app/frontend
|
||||
# Önce yalnızca paket dosyaları → bağımlılık katmanı önbellekte kalır
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci --no-audit --no-fund
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
# ─── 2) Backend (Rust, release) ───────────────────────────────────────────
|
||||
FROM rust:1.99-slim-trixie AS api
|
||||
WORKDIR /app/backend
|
||||
# Bağımlılıkları ayrı katmanda derle (kod değişince yeniden derlenmez)
|
||||
COPY backend/Cargo.toml backend/Cargo.lock ./
|
||||
RUN mkdir src && echo 'fn main() {}' > src/main.rs && touch src/lib.rs \
|
||||
&& cargo build --release --locked \
|
||||
&& rm -rf src target/release/deps/*kakeibo* target/release/.fingerprint/kakeibo-* target/release/kakeibo*
|
||||
COPY backend/src ./src
|
||||
# touch: sahte lib.rs/main.rs'ten daha yeni olsun → Cargo gerçek kodu kesinlikle yeniden derler
|
||||
RUN touch src/lib.rs src/main.rs && cargo build --release --locked
|
||||
|
||||
# ─── 3) Çalışma imajı (küçük, root olmayan kullanıcı) ─────────────────────
|
||||
FROM debian:trixie-slim
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates curl \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& useradd --system --uid 10001 --no-create-home kakeibo
|
||||
COPY --from=api /app/backend/target/release/kakeibo /usr/local/bin/kakeibo
|
||||
COPY --from=web /app/frontend/dist /app/static
|
||||
|
||||
ENV STATIC_DIR=/app/static \
|
||||
PORT=8080 \
|
||||
RUST_LOG=kakeibo=info,tower_http=info
|
||||
EXPOSE 8080
|
||||
USER kakeibo
|
||||
# Dokploy/Docker sağlık kontrolü (uygulama + veritabanı)
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD curl -fsS http://localhost:${PORT}/api/health || exit 1
|
||||
CMD ["kakeibo"]
|
||||
Generated
+164
@@ -2,6 +2,12 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
@@ -24,6 +30,21 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alloc-no-stdlib"
|
||||
version = "3.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a"
|
||||
|
||||
[[package]]
|
||||
name = "alloc-stdlib"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.6.0"
|
||||
@@ -36,6 +57,18 @@ dependencies = [
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-compression"
|
||||
version = "0.4.52"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ad47a740fb906fffdd891e8b6d5fde2511600f7bfaebd68939fa08a14999459"
|
||||
dependencies = [
|
||||
"compression-codecs",
|
||||
"compression-core",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.92"
|
||||
@@ -196,6 +229,27 @@ dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli"
|
||||
version = "9.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "627b7cfb0578f0eafce087a90fcd40add1231900b69e4d4de2d456260a4f5d81"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
"brotli-decompressor",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli-decompressor"
|
||||
version = "6.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75c74c9c51b9140663e196b5fb88bc94c334d7a497c8311cb759d8557874747f"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bson"
|
||||
version = "2.15.0"
|
||||
@@ -274,6 +328,24 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compression-codecs"
|
||||
version = "0.4.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84f840cff84d10085260e460023934d0af699e71212b09b2a41452e8bd9cda43"
|
||||
dependencies = [
|
||||
"brotli",
|
||||
"compression-core",
|
||||
"flate2",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compression-core"
|
||||
version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ebdda89edc534bf8d5dd2857562633b6495557e5846552ade994b99ed13c36a"
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.9.6"
|
||||
@@ -351,6 +423,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b929dd2a135771f40afabd1073f85eed59c800b7c61901e5c254270497dad64"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "critical-section"
|
||||
version = "1.2.0"
|
||||
@@ -711,6 +792,17 @@ version = "0.1.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "626d4928326b646e70f65ed36fc7cf9f206bdb68a0019ee7c6b4709da4e084c4"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
@@ -994,6 +1086,12 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-range-header"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
@@ -1296,6 +1394,7 @@ dependencies = [
|
||||
"time",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"ts-rs",
|
||||
@@ -1430,6 +1529,26 @@ version = "0.3.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d5848f5a9e8117cf94f2f90faac24a87ff81600ff8628c1e3deaab572e76bfa1"
|
||||
|
||||
[[package]]
|
||||
name = "mime_guess"
|
||||
version = "2.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||
dependencies = [
|
||||
"mime",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.4"
|
||||
@@ -2269,6 +2388,12 @@ dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||
|
||||
[[package]]
|
||||
name = "simd_cesu8"
|
||||
version = "1.2.0"
|
||||
@@ -2592,6 +2717,33 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-http"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c"
|
||||
dependencies = [
|
||||
"async-compression",
|
||||
"bitflags",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"http-range-header",
|
||||
"httpdate",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-layer"
|
||||
version = "0.3.3"
|
||||
@@ -2714,6 +2866,12 @@ version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-bidi"
|
||||
version = "0.3.18"
|
||||
@@ -3174,6 +3332,12 @@ dependencies = [
|
||||
"syn 3.0.10",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.6.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
|
||||
@@ -15,6 +15,7 @@ serde_json = "1.0.154"
|
||||
thiserror = "2.0.21"
|
||||
time = "0.3.55"
|
||||
tokio = { version = "1.54.1", features = ["full"] }
|
||||
tower-http = { version = "0.7.1", features = ["fs", "compression-gzip", "compression-br", "trace"], default-features = false }
|
||||
tracing = "0.1.44"
|
||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||
ts-rs = "12.0.1"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+33
-1
@@ -1,6 +1,6 @@
|
||||
//! Ortam değişkenlerinden uygulama ayarlarını okur.
|
||||
|
||||
use std::env;
|
||||
use std::{env, path::PathBuf};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
@@ -8,6 +8,20 @@ pub struct Config {
|
||||
pub jwt_secret: String,
|
||||
pub app_url: String,
|
||||
pub port: u16,
|
||||
pub registration: RegistrationMode,
|
||||
/// Derlenmiş frontend klasörü (`STATIC_DIR`). Tanımlıysa Axum arayüzü de sunar
|
||||
/// (canlıda tek servis). Geliştirmede boş: arayüzü Vite sunar.
|
||||
pub static_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// 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 +46,22 @@ 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 static_dir = env::var("STATIC_DIR")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.map(PathBuf::from);
|
||||
|
||||
let port = match env::var("PORT") {
|
||||
Ok(p) => p
|
||||
.parse()
|
||||
@@ -44,6 +74,8 @@ impl Config {
|
||||
jwt_secret,
|
||||
app_url,
|
||||
port,
|
||||
registration,
|
||||
static_dir,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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(_) => {
|
||||
|
||||
+28
-2
@@ -43,7 +43,7 @@ async fn main() -> ExitCode {
|
||||
|
||||
let port = config.port;
|
||||
let state = AppState::new(db, config);
|
||||
let app = routes::router(state);
|
||||
let app = routes::app(state);
|
||||
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
||||
let listener = match tokio::net::TcpListener::bind(addr).await {
|
||||
@@ -57,9 +57,35 @@ async fn main() -> ExitCode {
|
||||
};
|
||||
|
||||
tracing::info!("Kakeibo API çalışıyor: http://localhost:{port}");
|
||||
if let Err(e) = axum::serve(listener, app).await {
|
||||
if let Err(e) = axum::serve(listener, app)
|
||||
.with_graceful_shutdown(shutdown_signal())
|
||||
.await
|
||||
{
|
||||
tracing::error!("Sunucu hatası: {e}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
|
||||
/// Docker/Dokploy durdururken (SIGTERM) veya Ctrl+C'de: yeni istek almayı bırak,
|
||||
/// süren istekleri bitir, sonra kapan (yarım kalan yazma olmasın).
|
||||
async fn shutdown_signal() {
|
||||
let ctrl_c = async {
|
||||
let _ = tokio::signal::ctrl_c().await;
|
||||
};
|
||||
#[cfg(unix)]
|
||||
let terminate = async {
|
||||
if let Ok(mut s) = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||
{
|
||||
s.recv().await;
|
||||
}
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let terminate = std::future::pending::<()>();
|
||||
|
||||
tokio::select! {
|
||||
_ = ctrl_c => {},
|
||||
_ = terminate => {},
|
||||
}
|
||||
tracing::info!("Kapatma sinyali alındı, sunucu kapanıyor…");
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
|
||||
@@ -4,19 +4,34 @@ mod auth;
|
||||
mod catalog;
|
||||
mod health;
|
||||
mod household;
|
||||
mod spa;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
routing::{delete, get, patch, post, put},
|
||||
};
|
||||
use tower_http::{compression::CompressionLayer, trace::TraceLayer};
|
||||
|
||||
use crate::state::AppState;
|
||||
use crate::{error::AppError, state::AppState};
|
||||
|
||||
/// Sunucunun tamamı: API + (STATIC_DIR tanımlıysa) arayüz dosyaları,
|
||||
/// sıkıştırma ve istek logları. `main.rs` bunu kullanır.
|
||||
pub fn app(state: AppState) -> Router {
|
||||
let static_dir = state.config.static_dir.clone();
|
||||
let mut app = router(state);
|
||||
if let Some(dir) = static_dir {
|
||||
app = app.fallback_service(spa::router(&dir));
|
||||
}
|
||||
app.layer(CompressionLayer::new())
|
||||
.layer(TraceLayer::new_for_http())
|
||||
}
|
||||
|
||||
/// Tüm API route'ları. Testler de bu fonksiyonu kullanır.
|
||||
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))
|
||||
@@ -43,7 +58,9 @@ pub fn router(state: AppState) -> Router {
|
||||
.route(
|
||||
"/payment-methods/{id}",
|
||||
patch(catalog::update_payment_method),
|
||||
);
|
||||
)
|
||||
// Bilinmeyen /api/... → JSON 404 (arayüzün index.html'i dönmesin)
|
||||
.fallback(|| async { AppError::NotFound });
|
||||
|
||||
Router::new().nest("/api", api).with_state(state)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
//! Derlenmiş arayüzün (frontend/dist) sunulması — tek sayfa uygulama (SPA).
|
||||
//!
|
||||
//! - `/assets/*` : adları içerik özetli (hash) → 1 yıl önbellek, `immutable`
|
||||
//! - diğer dosyalar (favicon, ikonlar): 1 saat önbellek
|
||||
//! - olmayan yol (`/defter`, `/plan` ...) → `index.html` (yönlendirmeyi React Router yapar)
|
||||
//! - HTML hiç önbelleğe alınmaz → yeni sürüm yayınlanınca hemen yüklenir
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
extract::Request,
|
||||
http::{HeaderValue, header},
|
||||
middleware::{self, Next},
|
||||
response::Response,
|
||||
};
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
|
||||
pub fn router(dir: &Path) -> Router {
|
||||
let files = ServeDir::new(dir).fallback(ServeFile::new(dir.join("index.html")));
|
||||
Router::new()
|
||||
.fallback_service(files)
|
||||
.layer(middleware::from_fn(cache_headers))
|
||||
}
|
||||
|
||||
async fn cache_headers(req: Request, next: Next) -> Response {
|
||||
let is_asset = req.uri().path().starts_with("/assets/");
|
||||
let mut res = next.run(req).await;
|
||||
|
||||
let is_html = res
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.is_some_and(|v| v.as_bytes().starts_with(b"text/html"));
|
||||
let policy = match (is_html, is_asset) {
|
||||
(true, _) => "no-cache",
|
||||
(false, true) => "public, max-age=31536000, immutable",
|
||||
(false, false) => "public, max-age=3600",
|
||||
};
|
||||
res.headers_mut()
|
||||
.insert(header::CACHE_CONTROL, HeaderValue::from_static(policy));
|
||||
res
|
||||
}
|
||||
@@ -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,8 @@ impl TestApp {
|
||||
jwt_secret: "test-jwt-secret-en-az-32-karakter-olmali!!".into(),
|
||||
app_url: "http://localhost".into(),
|
||||
port: 0,
|
||||
registration,
|
||||
static_dir: None,
|
||||
};
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
# 🚀 Canlıya Alma (Atlas Homeserver + Dokploy)
|
||||
|
||||
> Durum: Faz 3 — Adım 1 ✅ (Docker imajı) · Adım 2–5 devam ediyor
|
||||
|
||||
## 1. Mimari
|
||||
|
||||
```
|
||||
İnternet ──HTTPS──▶ Traefik (Dokploy) ──▶ kakeibo konteyneri :8080 ──▶ MongoDB (rs0)
|
||||
├─ /api/* → Rust (Axum) API
|
||||
└─ diğer → derlenmiş arayüz (React)
|
||||
```
|
||||
|
||||
- **Tek imaj, tek servis, tek alan adı** → CORS yok, cookie aynı origin'de.
|
||||
- İmaj: çok aşamalı `Dockerfile` (Node → arayüz derlemesi, Rust → release derleme, `debian:trixie-slim` çalışma imajı, root olmayan kullanıcı `kakeibo`, ~175 MB).
|
||||
- Sağlık kontrolü: `GET /api/health` → `200 {"status":"ok","db":"ok"}`; veritabanı yoksa `503`.
|
||||
- Kapanış: `SIGTERM` gelince süren istekler bitirilir, sonra kapanır.
|
||||
|
||||
## 2. Ortam değişkenleri
|
||||
|
||||
| Değişken | Zorunlu | Örnek / varsayılan | Açıklama |
|
||||
|---|---|---|---|
|
||||
| `MONGODB_URI` | ✅ | `mongodb://kakeibo_app:<şifre>@<host>:27017/kakeibo?authSource=kakeibo&replicaSet=rs0` | Ayrı, yalnızca `kakeibo` DB'sinde `readWrite` yetkili kullanıcı |
|
||||
| `JWT_SECRET` | ✅ | `openssl rand -hex 32` çıktısı | En az 32 karakter. **Değişirse tüm oturumlar kapanır** |
|
||||
| `APP_URL` | ✅ | `https://kakeibo.<alan-adı>` | `https://` ile başlarsa cookie `Secure` olur |
|
||||
| `REGISTRATION` | – | `invite_only` | `invite_only`: ilk hane serbest, sonra yalnızca davet · `open`: herkes |
|
||||
| `RUST_LOG` | – | `kakeibo=info,tower_http=info` | Log seviyesi |
|
||||
| `PORT` | – | `8080` | İmajda ayarlı, değiştirmeyin |
|
||||
| `STATIC_DIR` | – | `/app/static` | İmajda ayarlı, değiştirmeyin |
|
||||
|
||||
## 3. Yerelde imajı deneme
|
||||
|
||||
```bash
|
||||
docker build -t kakeibo .
|
||||
# Yerel MongoDB (27020) ile, geliştirme sunucusuyla çakışmasın diye 8090'da:
|
||||
docker run --rm --network host --env-file .env -e PORT=8090 kakeibo
|
||||
# → http://localhost:8090
|
||||
```
|
||||
|
||||
Kontrol listesi (Faz 3 – Adım 1'de doğrulandı):
|
||||
- [x] `/` ve `/defter` → `index.html` (`Cache-Control: no-cache`)
|
||||
- [x] `/assets/*.js` → brotli/gzip, `max-age=31536000, immutable`
|
||||
- [x] `/api/health` → `db: ok`; bilinmeyen `/api/...` → JSON 404
|
||||
- [x] Docker `HEALTHCHECK` → healthy; süreç `kakeibo` kullanıcısıyla çalışır
|
||||
- [x] `docker stop` → düzgün kapanış logu
|
||||
|
||||
## 4. Canlı MongoDB
|
||||
|
||||
- Dokploy'da **ayrı** MongoDB servisi (Bütçem'in veritabanı paylaşılmaz): proje `Kakeibo` → Database → MongoDB `kakeibo-mongo`.
|
||||
- Servis (iç ağ) adı: **`kakeibo-kakeibomongo-fibzhc`**, imaj `mongo:7`, ağ `dokploy-network`, volume `kakeibo-kakeibomongo-fibzhc-data → /data/db`.
|
||||
- Replica set: `rs0`, üye `kakeibo-kakeibomongo-fibzhc:27017` (Dokploy "Replica Sets" açık).
|
||||
- Kullanıcılar: `kakeibo_root` (admin, `root`) · `kakeibo_app` (`kakeibo` DB, yalnızca `readWrite`). Şifreler 2026-11-12'de yenilendi (eskileri açığa çıkmıştı).
|
||||
- Dış erişim: `docker service inspect … .Endpoint.Ports` → `null` (port yayınlanmıyor) ✅. External Port alanı boş kalmalı.
|
||||
- Bağlantı:
|
||||
```
|
||||
mongodb://kakeibo_app:<APP_ŞİFRESİ>@kakeibo-kakeibomongo-fibzhc:27017/kakeibo?authSource=kakeibo&replicaSet=rs0
|
||||
```
|
||||
|
||||
### ⚠️ Açık konu: MongoDB kimlik doğrulaması kapalı
|
||||
Dokploy'un replica set betiği `mongod --replSet rs0 --bind_ip_all` ile başlatıyor (`--keyFile` yok) ve her deploy'da
|
||||
Advanced → Command/Args'ı yok sayıp kendi betiğini yazıyor. Sonuç: kullanıcılar tanımlı ama şifre **zorunlu değil**;
|
||||
`dokploy-network`'teki herhangi bir konteyner erişebilir. İnternetten erişim yok.
|
||||
Çözüm adayları (ileride): Dokploy'un MongoDB'si yerine Compose servisi (kendi komutumuzla `--keyFile`) ya da Dokploy'da keyFile desteği.
|
||||
Uygulama zaten kullanıcı/şifreyle bağlandığı için auth açıldığında uygulamada değişiklik gerekmez.
|
||||
|
||||
### Şifre girerken dikkat
|
||||
- `mongosh` içinde `passwordPrompt()`'a **yapıştırmayın** (terminal görünmez karakter ekleyebiliyor → `U_STRINGPREP_PROHIBITED_ERROR`).
|
||||
Bunun yerine: `const p = require('crypto').randomBytes(24).toString('hex'); db.changeUserPassword("kakeibo_app", p); print(p)`
|
||||
- Şifreyi komuta düz yazmayın; sonra `rm -f /root/.mongodb/mongosh/mongosh_repl_history`.
|
||||
- "Rebuild Database" veriyi siler ve Dokploy betiği yeni root şifresini servis ayarına düz metin yazar.
|
||||
|
||||
## 5. Dokploy kurulumu
|
||||
|
||||
> Faz 3 – Adım 3'te birlikte doldurulacak.
|
||||
|
||||
## 6. Yedekleme ve geri yükleme
|
||||
|
||||
> Faz 3 – Adım 5'te doldurulacak (günlük `mongodump`, son 14 yedek).
|
||||
+15
-1
@@ -1,7 +1,7 @@
|
||||
# 🧠 Proje Hafızası
|
||||
|
||||
## ▶️ Sıradaki Adım
|
||||
**Faz 3 – Başlangıç:** Faz 3 planını sun (canlıya alma: çok aşamalı Dockerfile, Axum'un frontend'i sunması + SPA yönlendirmesi, Dokploy, canlı MongoDB kontrolü — KAKEIBO_PROMPT §12.3 sorularını kullanıcıyla birlikte, domain + HTTPS, yedekleme). Sunucuya erişim/bilgi kullanıcıdan alınacak.
|
||||
**Faz 3 – Adım 3:** Dokploy Application (`kakeibo-app`, Gitea repo, Dockerfile), ortam değişkenleri, domain `kakeibo.ta1sph.website` (HTTPS), deploy, health kontrolü. Kullanıcı süreçten yoruldu → kısa ve net adımlar, gereksiz tur yok.
|
||||
|
||||
## 📌 Alınan Kararlar
|
||||
- **Yerel demo hesapları** (`kakeibo_dev`): `demo@ornek.com` / `demo12345` (owner, "Demo Hanesi") · `es@ornek.com` / `demo12345` (member, aynı hane). `npm run db:reset` silerse API ile yeniden oluştur (ileride `db:seed`).
|
||||
@@ -31,9 +31,15 @@
|
||||
- Canlıda SPA yönlendirmesi (Axum: bilinmeyen yol → index.html) Faz 3'te yapılmalı.
|
||||
- Kayıt ekranı: "Yeni hane kur" / "Davetle katıl"; davet bağlantısı `/kayit?davet=KOD`. Sunucu 409 → e-posta alanı, "Davet…" 400 → kod alanı.
|
||||
- Yönlendirme sonrası toast için `mutateAsync` + await kullanıldı (mutate() callback'leri bileşen kalkınca çalışmayabilir).
|
||||
- **Kayıt politikası (kullanıcı kararı, 2026-11-12): Seçenek 1** → `REGISTRATION=invite_only` varsayılan; sistemde hiç hane yokken ilk kayıt yeni hane kurar (sunucu sahibi), sonra yalnızca davetle. `open` isteğe bağlı. Canlıda ilk hesabı kullanıcı `/kayit`'tan açar; demo hesaplar canlıya taşınmaz.
|
||||
- Prettier ayarı eklendi (önceden yoktu → VS Code kaydedince çift tırnağa çevirebilirdi).
|
||||
- Canlı imaj: node:26-alpine → rust:1.99-slim-trixie → debian:trixie-slim (ca-certificates + curl, uid 10001 `kakeibo`), 174 MB. Docker katman önbelleği için sahte lib/main ile bağımlılık derlemesi; kalıntılar `*kakeibo*` + `.fingerprint` silinir, gerçek kaynak `touch`lanır (yoksa sahte lib kullanılıyordu).
|
||||
- Önbellek: HTML `no-cache`, `/assets/*` 1 yıl immutable, diğer statik 1 saat. Bilinmeyen `/api/*` → JSON 404.
|
||||
- Canlı: domain `kakeibo.ta1sph.website` (DNS → 159.146.61.151, ev dış IP'si olmalı), Gitea Dokploy'a bağlı. Canlı MongoDB = ayrı Dokploy servisi `kakeibo-kakeibomongo-fibzhc` (rs0), kullanıcı `kakeibo_app` (readWrite/kakeibo).
|
||||
- Sürümler: Rust 1.99.1 (edition 2024), Axum 0.8, React 19, Vite 8, TypeScript 7, Tailwind 4.3, shadcn 4.23, Vitest 5, react-router 8, TanStack Query 5.
|
||||
|
||||
## ❓ Açık Konular
|
||||
- **Canlı MongoDB auth kapalı** (Dokploy replica set betiği keyFile kullanmıyor, özel Command'ı yok sayıyor). İç ağda, port kapalı. Ayrıntı: `docs/DEPLOY.md` §4.
|
||||
- Üye çıkarma / rol değiştirme / sahipliği devretme henüz yok (Ayarlar ekranıyla birlikte, Faz 2+).
|
||||
- Kapalı dönem yeniden açılırken başka açık dönem varsa davranış (Faz 6).
|
||||
- Altın kuru için veri kaynağı seçimi (Faz 8).
|
||||
@@ -62,3 +68,11 @@
|
||||
- **Adım 3:** ts-rs eklendi (17 tip), health ve hata gövdesi struct'a çevrildi (`ApiErrorBody`). Frontend: @tanstack/react-query, `lib/api.ts` (+7 vitest), `lib/query.ts`, `lib/auth.ts`, main.tsx'te QueryClientProvider. Geçici oturum paneli. Tarayıcı e2e (CDP betiği): yanlış şifre mesajı, giriş, yenilemede kalıcılık, httpOnly (document.cookie'de yok), çıkış — hepsi geçti.
|
||||
- **Adım 4:** react-router 8 (createBrowserRouter), `RequireAuth`/`RequireGuest`/`Splash`, `AppLayout` (masaüstü kenar çubuğu + mobil üst çubuk + 5'li alt menü, safe-area), Diğer sayfası, 7 yer tutucu sayfa, 404, `lib/strings.ts`, `useDocumentTitle`, `lib/return-path.ts` (+10 test, açık yönlendirme koruması), ThemeToggle `compact`. Tasarım önizlemesi → `/dev/tasarim` (yalnızca DEV). CDP e2e (10 kontrol) iki hata buldu, düzeltildi: giriş sonrası `donus` yarışı, çıkışta `qc.clear()`. Masaüstü içerik boşluğu düzeltildi.
|
||||
- **Adım 5:** react-hook-form, zod 4, @hookform/resolvers, sonner; shadcn input/label/checkbox/card (44 px / 20 px'e büyütüldü). `components/form-field.tsx`, `components/toaster.tsx`, `features/auth/` (auth-shell, login-page, register-page, schemas + 8 vitest). Metinler `t.auth`. CDP e2e 11 kontrol geçti (boş form, yanlış şifre, giriş + toast, davet linki, kayıtlı e-posta → alan hatası, geçersiz davet → alan hatası, yeni hane). Frontend 47 test. **Faz 2 tamamlandı.**
|
||||
|
||||
### 2026-11-12 — Faz 3 öncesi
|
||||
- Kullanıcı `api.http`'deki kayıt örneğini (yeni@ornek.com) hesap sandı → dosya başlığına hazır hesaplar kutusu ve "bu bir KAYIT isteğidir" uyarısı eklendi.
|
||||
- Kayıt politikası: `config.rs` `RegistrationMode`, `AppError::RegistrationClosed` (403), `account::new_household_allowed`, `GET /api/auth/registration` (`RegistrationInfo` TS tipi). `tests/registration.rs` (2). Kayıt ekranı kapalıyken yalnızca davet modu + açıklama. `.env`/`.env.example`: `REGISTRATION=invite_only`. Prettier (`.prettierrc.json`, `.prettierignore`, `format` / `format:check`), tüm src biçimlendirildi (yalnızca boşluk).
|
||||
|
||||
### 2026-11-12 — Faz 3
|
||||
- **Adım 1:** tower-http (fs, compression gzip/br, trace). `routes::app()` + `routes/spa.rs` (ServeDir + index.html fallback + cache middleware), API fallback JSON 404, `STATIC_DIR` config, graceful shutdown (SIGTERM/Ctrl+C). `Dockerfile`, `.dockerignore`, `docs/DEPLOY.md` başlatıldı. İmaj yerelde çalıştırıldı (host network, 8090): 10 kontrol + tarayıcı ekran görüntüsü geçti. İlk derlemede Docker önbellek hilesi hatası (sahte lib.rs) bulundu, düzeltildi.
|
||||
- **Adım 2:** Mevcut tek MongoDB (butcem-mongo) Bütçem'e ait → kullanıcı ayrı MongoDB seçti. Dokploy MongoDB (replica set) kuruldu, `kakeibo_app` oluşturuldu, port yayını yok (iki servis de `null`). Sorunlar: kullanıcı şifreyi komuta düz yazıp paylaştı; Dokploy betiği root şifresini servis Args'ına düz yazıyor → iki şifre de mongosh içinde üretilerek yenilendi. passwordPrompt yapıştırma hatası (U_STRINGPREP). Auth açma denemesi: Dokploy özel Command'ı uygulamadı → auth kapalı kabul edildi (açık konu).
|
||||
|
||||
@@ -37,3 +37,10 @@
|
||||
- ✅ 3. `lib/api.ts`, TanStack Query, ts-rs tip üretimi, oturum durumu
|
||||
- ✅ 4. Router (react-router), korumalı sayfalar, layout: mobil alt menü / masaüstü sol menü
|
||||
- ✅ 5. Giriş / kayıt ekranları (react-hook-form + zod, beni hatırla, davet kodu), arayüz metinleri tek dosyada
|
||||
|
||||
## Faz 3 Adımları
|
||||
- ✅ 1. Dockerfile (çok aşamalı), Axum arayüzü sunar (SPA yönlendirmesi, önbellek başlıkları, sıkıştırma), graceful shutdown, yerelde imaj testi
|
||||
- ✅ 2. Canlı MongoDB kontrolü (§12.3): kurulum şekli, replica set, ayrı kullanıcı, dış erişim
|
||||
- 🔄 3. Dokploy: Gitea bağlantısı, ortam değişkenleri, alan adı + HTTPS, ilk yayın
|
||||
- ⏳ 4. Canlıda ilk hesap + kontrol listesi
|
||||
- ⏳ 5. Yedekleme (mongodump, 14 gün) + geri yükleme denemesi
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# ts-rs tarafından üretilir (npm run types)
|
||||
src/types/
|
||||
# shadcn/ui kaynakları (kendi biçimleriyle bırakılır)
|
||||
src/components/ui/
|
||||
dist/
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
Generated
+17
@@ -32,6 +32,7 @@
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.2",
|
||||
"oxlint": "^1.90.0",
|
||||
"prettier": "^3.10.0",
|
||||
"typescript": "~7.0.2",
|
||||
"vite": "^8.3.6",
|
||||
"vitest": "^5.1.0"
|
||||
@@ -5945,6 +5946,22 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.10.0",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.10.0.tgz",
|
||||
"integrity": "sha512-mBi4765LgjO1PFjlEs7pUeK0nb1bw71c+N4/ItfeUWNdY5cV2pRKaFpbbFRYmDJMO+5ZwzGxHRqHxFZna7iv5w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-ms": {
|
||||
"version": "9.3.1",
|
||||
"resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.1.tgz",
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
"test:watch": "vitest",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx,css}\"",
|
||||
"format:check": "prettier --check \"src/**/*.{ts,tsx,css}\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
@@ -36,6 +38,7 @@
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.2",
|
||||
"oxlint": "^1.90.0",
|
||||
"prettier": "^3.10.0",
|
||||
"typescript": "~7.0.2",
|
||||
"vite": "^8.3.6",
|
||||
"vitest": "^5.1.0"
|
||||
|
||||
@@ -22,7 +22,11 @@ export function FormField({
|
||||
hint?: string
|
||||
className?: string
|
||||
/** Alanı üreten fonksiyon: kimlik ve aria özelliklerini alır */
|
||||
children: (field: { id: string; 'aria-invalid': boolean; 'aria-describedby'?: string }) => ReactNode
|
||||
children: (field: {
|
||||
id: string
|
||||
'aria-invalid': boolean
|
||||
'aria-describedby'?: string
|
||||
}) => ReactNode
|
||||
}) {
|
||||
const id = useId()
|
||||
const messageId = `${id}-message`
|
||||
@@ -37,7 +41,10 @@ export function FormField({
|
||||
'aria-describedby': message ? messageId : undefined,
|
||||
})}
|
||||
{message && (
|
||||
<p id={messageId} className={cn('text-sm', error ? 'text-expense' : 'text-muted-foreground')}>
|
||||
<p
|
||||
id={messageId}
|
||||
className={cn('text-sm', error ? 'text-expense' : 'text-muted-foreground')}
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
@@ -50,7 +57,11 @@ export function PasswordInput(props: ComponentProps<typeof Input>) {
|
||||
const [visible, setVisible] = useState(false)
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input {...props} type={visible ? 'text' : 'password'} className={cn('pr-12', props.className)} />
|
||||
<Input
|
||||
{...props}
|
||||
type={visible ? 'text' : 'password'}
|
||||
className={cn('pr-12', props.className)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisible((v) => !v)}
|
||||
@@ -68,7 +79,10 @@ export function PasswordInput(props: ComponentProps<typeof Input>) {
|
||||
export function FormError({ message }: { message?: string }) {
|
||||
if (!message) return null
|
||||
return (
|
||||
<p role="alert" className="rounded-lg border border-expense/30 bg-expense/10 px-3 py-2.5 text-sm text-expense">
|
||||
<p
|
||||
role="alert"
|
||||
className="rounded-lg border border-expense/30 bg-expense/10 px-3 py-2.5 text-sm text-expense"
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
)
|
||||
|
||||
@@ -12,7 +12,13 @@ const OPTIONS: { value: Theme; label: string; icon: typeof Sun }[] = [
|
||||
* Aydınlık / Karanlık / Sistem seçici (radyo grubu gibi davranır, klavyeyle gezilebilir).
|
||||
* `compact`: yalnızca ikonlar (dar alanlar için; etiketler ekran okuyucuda kalır).
|
||||
*/
|
||||
export function ThemeToggle({ className, compact = false }: { className?: string; compact?: boolean }) {
|
||||
export function ThemeToggle({
|
||||
className,
|
||||
compact = false,
|
||||
}: {
|
||||
className?: string
|
||||
compact?: boolean
|
||||
}) {
|
||||
const { theme, setTheme } = useTheme()
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -43,7 +43,9 @@ export function LoginPage() {
|
||||
|
||||
// Kayıt sayfasına geçerken dönüş adresi kaybolmasın
|
||||
const donus = params.get('donus')
|
||||
const registerLink = donus ? `${paths.register}?donus=${encodeURIComponent(donus)}` : paths.register
|
||||
const registerLink = donus
|
||||
? `${paths.register}?donus=${encodeURIComponent(donus)}`
|
||||
: paths.register
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
@@ -52,7 +54,10 @@ export function LoginPage() {
|
||||
footer={
|
||||
<>
|
||||
{s.noAccount}{' '}
|
||||
<Link to={registerLink} className="font-medium text-primary underline-offset-4 hover:underline">
|
||||
<Link
|
||||
to={registerLink}
|
||||
className="font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
{s.toRegister}
|
||||
</Link>
|
||||
</>
|
||||
@@ -74,7 +79,9 @@ export function LoginPage() {
|
||||
</FormField>
|
||||
|
||||
<FormField label={s.password} error={errors.password}>
|
||||
{(f) => <PasswordInput {...f} {...register('password')} autoComplete="current-password" />}
|
||||
{(f) => (
|
||||
<PasswordInput {...f} {...register('password')} autoComplete="current-password" />
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<Controller
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Home, Loader2, UserPlus, Users } from 'lucide-react'
|
||||
import { useEffect } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Home, Info, Loader2, UserPlus, Users } from 'lucide-react'
|
||||
import { Controller, useForm, useWatch } from 'react-hook-form'
|
||||
import { Link, useSearchParams } from 'react-router'
|
||||
import { toast } from 'sonner'
|
||||
@@ -7,11 +9,13 @@ import { FormError, FormField, PasswordInput } from '@/components/form-field'
|
||||
import { paths } from '@/components/layout/nav-items'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ApiError } from '@/lib/api'
|
||||
import { api, ApiError } from '@/lib/api'
|
||||
import { useRegister } from '@/lib/auth'
|
||||
import { queryKeys } from '@/lib/query'
|
||||
import { t } from '@/lib/strings'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { AuthShell } from './auth-shell'
|
||||
import type { RegistrationInfo } from '@/types/RegistrationInfo'
|
||||
import { normalizeInviteCode, registerSchema, type RegisterForm } from './schemas'
|
||||
|
||||
const s = t.auth.register
|
||||
@@ -22,11 +26,20 @@ export function RegisterPage() {
|
||||
// Davet bağlantısı: /kayit?davet=ABCD-EFGH → "Davetle katıl" seçili ve kod dolu
|
||||
const invite = params.get('davet') ?? ''
|
||||
|
||||
// Sunucu politikası (REGISTRATION): yeni hane kurulabilir mi?
|
||||
const qc = useQueryClient()
|
||||
const info = useQuery({
|
||||
queryKey: queryKeys.registration,
|
||||
queryFn: () => api.get<RegistrationInfo>('/auth/registration'),
|
||||
})
|
||||
const newAllowed = info.data?.new_household_allowed ?? true
|
||||
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
setError,
|
||||
setValue,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<RegisterForm>({
|
||||
resolver: zodResolver(registerSchema),
|
||||
@@ -40,6 +53,11 @@ export function RegisterPage() {
|
||||
invite_code: invite,
|
||||
},
|
||||
})
|
||||
// Kayıt kapalıysa yalnızca "Davetle katıl"
|
||||
useEffect(() => {
|
||||
if (!newAllowed) setValue('mode', 'join')
|
||||
}, [newAllowed, setValue])
|
||||
|
||||
const mode = useWatch({ control, name: 'mode' })
|
||||
const name = useWatch({ control, name: 'name' }).trim()
|
||||
|
||||
@@ -58,7 +76,11 @@ export function RegisterPage() {
|
||||
if (!(e instanceof ApiError)) return setError('root', { message: String(e) })
|
||||
// Sunucu hatasını ilgili alana bağla
|
||||
if (e.code === 'conflict') setError('email', { message: e.message }, { shouldFocus: true })
|
||||
else if (v.mode === 'join' && e.code === 'bad_request' && e.message.includes('Davet'))
|
||||
else if (e.code === 'registration_closed') {
|
||||
// Bu arada başka biri ilk haneyi kurmuş olabilir → bilgiyi tazele, formu davete çevir
|
||||
await qc.invalidateQueries({ queryKey: queryKeys.registration })
|
||||
setError('root', { message: e.message })
|
||||
} else if (v.mode === 'join' && e.code === 'bad_request' && e.message.includes('Davet'))
|
||||
setError('invite_code', { message: e.message }, { shouldFocus: true })
|
||||
else setError('root', { message: e.message })
|
||||
}
|
||||
@@ -74,7 +96,10 @@ export function RegisterPage() {
|
||||
footer={
|
||||
<>
|
||||
{s.hasAccount}{' '}
|
||||
<Link to={loginLink} className="font-medium text-primary underline-offset-4 hover:underline">
|
||||
<Link
|
||||
to={loginLink}
|
||||
className="font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
{s.toLogin}
|
||||
</Link>
|
||||
</>
|
||||
@@ -105,39 +130,60 @@ export function RegisterPage() {
|
||||
{/* Yeni hane mi, davetle katılma mı? */}
|
||||
<fieldset className="space-y-3">
|
||||
<legend className="mb-2 text-sm font-medium">{s.modeLabel}</legend>
|
||||
<Controller
|
||||
control={control}
|
||||
name="mode"
|
||||
render={({ field }) => (
|
||||
<div role="radiogroup" className="grid grid-cols-2 gap-2 rounded-lg border bg-muted p-1">
|
||||
{(
|
||||
[
|
||||
['new', s.modeNew, Home],
|
||||
['join', s.modeJoin, Users],
|
||||
] as const
|
||||
).map(([value, label, Icon]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={field.value === value}
|
||||
onClick={() => field.onChange(value)}
|
||||
className={cn(
|
||||
'flex min-h-11 items-center justify-center gap-2 rounded-md text-sm font-medium text-muted-foreground transition-colors',
|
||||
field.value === value && 'bg-background text-foreground shadow-sm',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4" aria-hidden />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{!newAllowed && (
|
||||
<p className="flex gap-2 rounded-lg bg-muted px-3 py-2.5 text-sm text-muted-foreground">
|
||||
<Info className="mt-0.5 size-4 shrink-0" aria-hidden />
|
||||
{s.closedNote}
|
||||
</p>
|
||||
)}
|
||||
{newAllowed && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="mode"
|
||||
render={({ field }) => (
|
||||
<div
|
||||
role="radiogroup"
|
||||
className="grid grid-cols-2 gap-2 rounded-lg border bg-muted p-1"
|
||||
>
|
||||
{(
|
||||
[
|
||||
['new', s.modeNew, Home],
|
||||
['join', s.modeJoin, Users],
|
||||
] as const
|
||||
).map(([value, label, Icon]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={field.value === value}
|
||||
onClick={() => field.onChange(value)}
|
||||
className={cn(
|
||||
'flex min-h-11 items-center justify-center gap-2 rounded-md text-sm font-medium text-muted-foreground transition-colors',
|
||||
field.value === value && 'bg-background text-foreground shadow-sm',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4" aria-hidden />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{mode === 'new' ? (
|
||||
<FormField label={s.householdName} error={errors.household_name} hint={s.householdHint(name)}>
|
||||
{(f) => <Input {...f} {...register('household_name')} placeholder={name ? `${name} Hanesi` : ''} />}
|
||||
<FormField
|
||||
label={s.householdName}
|
||||
error={errors.household_name}
|
||||
hint={s.householdHint(name)}
|
||||
>
|
||||
{(f) => (
|
||||
<Input
|
||||
{...f}
|
||||
{...register('household_name')}
|
||||
placeholder={name ? `${name} Hanesi` : ''}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
) : (
|
||||
<FormField label={s.inviteCode} error={errors.invite_code} hint={s.inviteHint}>
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { loginSchema, normalizeInviteCode, registerSchema } from './schemas'
|
||||
|
||||
const base = { name: 'Suphi', email: 'a@b.co', password: '12345678', mode: 'new' as const, household_name: '', invite_code: '' }
|
||||
const issues = (r: { success: boolean; error?: { issues: { path: PropertyKey[]; message: string }[] } }) =>
|
||||
Object.fromEntries((r.error?.issues ?? []).map((i) => [i.path.join('.'), i.message]))
|
||||
const base = {
|
||||
name: 'Suphi',
|
||||
email: 'a@b.co',
|
||||
password: '12345678',
|
||||
mode: 'new' as const,
|
||||
household_name: '',
|
||||
invite_code: '',
|
||||
}
|
||||
const issues = (r: {
|
||||
success: boolean
|
||||
error?: { issues: { path: PropertyKey[]; message: string }[] }
|
||||
}) => Object.fromEntries((r.error?.issues ?? []).map((i) => [i.path.join('.'), i.message]))
|
||||
|
||||
describe('loginSchema', () => {
|
||||
it('boş alanlarda Türkçe mesaj', () => {
|
||||
@@ -13,9 +22,9 @@ describe('loginSchema', () => {
|
||||
})
|
||||
})
|
||||
it('geçersiz e-posta', () => {
|
||||
expect(issues(loginSchema.safeParse({ email: 'abc', password: 'x', remember_me: false })).email).toBe(
|
||||
'Geçerli bir e-posta adresi girin',
|
||||
)
|
||||
expect(
|
||||
issues(loginSchema.safeParse({ email: 'abc', password: 'x', remember_me: false })).email,
|
||||
).toBe('Geçerli bir e-posta adresi girin')
|
||||
})
|
||||
it('e-postadaki boşlukları kırpar', () => {
|
||||
const r = loginSchema.safeParse({ email: ' a@b.co ', password: 'x', remember_me: true })
|
||||
@@ -34,11 +43,15 @@ describe('registerSchema', () => {
|
||||
})
|
||||
})
|
||||
it('davet modunda kod zorunlu ve 8 karakter', () => {
|
||||
expect(issues(registerSchema.safeParse({ ...base, mode: 'join' })).invite_code).toBe('Davet kodunu yaz')
|
||||
expect(issues(registerSchema.safeParse({ ...base, mode: 'join', invite_code: 'ABC' })).invite_code).toBe(
|
||||
'Davet kodu 8 karakter olmalı',
|
||||
expect(issues(registerSchema.safeParse({ ...base, mode: 'join' })).invite_code).toBe(
|
||||
'Davet kodunu yaz',
|
||||
)
|
||||
expect(registerSchema.safeParse({ ...base, mode: 'join', invite_code: 'abcd-efgh' }).success).toBe(true)
|
||||
expect(
|
||||
issues(registerSchema.safeParse({ ...base, mode: 'join', invite_code: 'ABC' })).invite_code,
|
||||
).toBe('Davet kodu 8 karakter olmalı')
|
||||
expect(
|
||||
registerSchema.safeParse({ ...base, mode: 'join', invite_code: 'abcd-efgh' }).success,
|
||||
).toBe(true)
|
||||
})
|
||||
it('yeni hane modunda davet kodu yok sayılır', () => {
|
||||
expect(registerSchema.safeParse({ ...base, invite_code: 'x' }).success).toBe(true)
|
||||
|
||||
@@ -7,7 +7,11 @@ import { t } from '@/lib/strings'
|
||||
|
||||
const m = t.auth.validation
|
||||
|
||||
const email = z.string().trim().min(1, { error: m.emailRequired }).pipe(z.email({ error: m.emailInvalid }))
|
||||
const email = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, { error: m.emailRequired })
|
||||
.pipe(z.email({ error: m.emailInvalid }))
|
||||
|
||||
export const loginSchema = z.object({
|
||||
email,
|
||||
@@ -32,7 +36,8 @@ export const registerSchema = z
|
||||
if (v.mode !== 'join') return
|
||||
const code = normalizeInviteCode(v.invite_code)
|
||||
if (!code) ctx.addIssue({ code: 'custom', path: ['invite_code'], message: m.inviteRequired })
|
||||
else if (code.length !== 8) ctx.addIssue({ code: 'custom', path: ['invite_code'], message: m.inviteInvalid })
|
||||
else if (code.length !== 8)
|
||||
ctx.addIssue({ code: 'custom', path: ['invite_code'], message: m.inviteInvalid })
|
||||
})
|
||||
export type RegisterForm = z.infer<typeof registerSchema>
|
||||
|
||||
|
||||
@@ -32,7 +32,9 @@ export function DesignPreview() {
|
||||
|
||||
<p className="text-sm">
|
||||
{health.isError && <span className="text-expense">● {health.error.message}</span>}
|
||||
{health.isPending && <span className="text-muted-foreground">● Backend kontrol ediliyor…</span>}
|
||||
{health.isPending && (
|
||||
<span className="text-muted-foreground">● Backend kontrol ediliyor…</span>
|
||||
)}
|
||||
{health.data && (
|
||||
<span className="text-income">
|
||||
● Backend çalışıyor (v{health.data.version}, DB: {health.data.db})
|
||||
@@ -40,7 +42,6 @@ export function DesignPreview() {
|
||||
)}
|
||||
</p>
|
||||
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-xl font-semibold">Ana gruplar</h2>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
@@ -65,7 +66,7 @@ export function DesignPreview() {
|
||||
{[
|
||||
['Gelir', formatMoney(4_500_000, 'TRY', { signed: true }), 'text-income'],
|
||||
['Gider', formatMoney(-125_000), 'text-expense'],
|
||||
['Bütçenin %85\'i kullanıldı', formatMoney(85_000), 'text-warning'],
|
||||
["Bütçenin %85'i kullanıldı", formatMoney(85_000), 'text-warning'],
|
||||
['Euro', formatMoney(30_000, 'EUR'), ''],
|
||||
['Dolar', formatMoney(15_000, 'USD'), ''],
|
||||
['Gram altın', formatQuantity(12_500_000, 'gr'), ''],
|
||||
|
||||
+159
-159
@@ -1,184 +1,184 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
@import "@fontsource-variable/inter";
|
||||
@import 'tailwindcss';
|
||||
@import 'tw-animate-css';
|
||||
@import 'shadcn/tailwind.css';
|
||||
@import '@fontsource-variable/inter';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--font-heading: var(--font-sans);
|
||||
--font-sans: 'Inter Variable', system-ui, sans-serif;
|
||||
--font-heading: var(--font-sans);
|
||||
--font-sans: 'Inter Variable', system-ui, sans-serif;
|
||||
|
||||
/* Kakeibo: 4 ana grup + durum renkleri (aşağıdaki :root / .dark değişkenlerinden) */
|
||||
--color-needs: var(--needs);
|
||||
--color-needs-fg: var(--needs-fg);
|
||||
--color-needs-soft: var(--needs-soft);
|
||||
--color-wants: var(--wants);
|
||||
--color-wants-fg: var(--wants-fg);
|
||||
--color-wants-soft: var(--wants-soft);
|
||||
--color-culture: var(--culture);
|
||||
--color-culture-fg: var(--culture-fg);
|
||||
--color-culture-soft: var(--culture-soft);
|
||||
--color-unexpected: var(--unexpected);
|
||||
--color-unexpected-fg: var(--unexpected-fg);
|
||||
--color-unexpected-soft: var(--unexpected-soft);
|
||||
--color-income: var(--income);
|
||||
--color-expense: var(--expense);
|
||||
--color-warning: var(--warning);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: var(--background);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
/* Kakeibo: 4 ana grup + durum renkleri (aşağıdaki :root / .dark değişkenlerinden) */
|
||||
--color-needs: var(--needs);
|
||||
--color-needs-fg: var(--needs-fg);
|
||||
--color-needs-soft: var(--needs-soft);
|
||||
--color-wants: var(--wants);
|
||||
--color-wants-fg: var(--wants-fg);
|
||||
--color-wants-soft: var(--wants-soft);
|
||||
--color-culture: var(--culture);
|
||||
--color-culture-fg: var(--culture-fg);
|
||||
--color-culture-soft: var(--culture-soft);
|
||||
--color-unexpected: var(--unexpected);
|
||||
--color-unexpected-fg: var(--unexpected-fg);
|
||||
--color-unexpected-soft: var(--unexpected-soft);
|
||||
--color-income: var(--income);
|
||||
--color-expense: var(--expense);
|
||||
--color-warning: var(--warning);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: var(--background);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.42 0.13 268); /* ai-iro: Japon indigosu */
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.55 0.13 268);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.42 0.13 268); /* ai-iro: Japon indigosu */
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.55 0.13 268);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
|
||||
/* 4 ana grup — Okabe-Ito (renk körlüğüne uygun) paletinden.
|
||||
/* 4 ana grup — Okabe-Ito (renk körlüğüne uygun) paletinden.
|
||||
*: çubuk/dolgu · *-fg: yazı (WCAG AA ≥ 4.5:1) · *-soft: zemin */
|
||||
--needs: #0072b2;
|
||||
--needs-fg: #005a8c;
|
||||
--needs-soft: #e6f1f8;
|
||||
--wants: #cc79a7;
|
||||
--wants-fg: #9c3f72;
|
||||
--wants-soft: #f9eef4;
|
||||
--culture: #009e73;
|
||||
--culture-fg: #00714f;
|
||||
--culture-soft: #e5f5f0;
|
||||
--unexpected: #d55e00;
|
||||
--unexpected-fg: #a84a00;
|
||||
--unexpected-soft: #fbefe5;
|
||||
--needs: #0072b2;
|
||||
--needs-fg: #005a8c;
|
||||
--needs-soft: #e6f1f8;
|
||||
--wants: #cc79a7;
|
||||
--wants-fg: #9c3f72;
|
||||
--wants-soft: #f9eef4;
|
||||
--culture: #009e73;
|
||||
--culture-fg: #00714f;
|
||||
--culture-soft: #e5f5f0;
|
||||
--unexpected: #d55e00;
|
||||
--unexpected-fg: #a84a00;
|
||||
--unexpected-soft: #fbefe5;
|
||||
|
||||
/* Durum: gelir/olumlu · gider/aşım · uyarı (yazı olarak AA uyumlu) */
|
||||
--income: #15803d;
|
||||
--expense: #c62828;
|
||||
--warning: #b45309;
|
||||
/* Durum: gelir/olumlu · gider/aşım · uyarı (yazı olarak AA uyumlu) */
|
||||
--income: #15803d;
|
||||
--expense: #c62828;
|
||||
--warning: #b45309;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.72 0.12 268);
|
||||
--primary-foreground: oklch(0.18 0.03 268);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.62 0.12 268);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.72 0.12 268);
|
||||
--primary-foreground: oklch(0.18 0.03 268);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.62 0.12 268);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
|
||||
/* Koyu zeminde daha açık tonlar (kontrast için) */
|
||||
--needs: #3a9fdb;
|
||||
--needs-fg: #7cc3f0;
|
||||
--needs-soft: #10283a;
|
||||
--wants: #d98fb8;
|
||||
--wants-fg: #ecb6d4;
|
||||
--wants-soft: #35202c;
|
||||
--culture: #1fbf8f;
|
||||
--culture-fg: #6fdcb9;
|
||||
--culture-soft: #0c2e25;
|
||||
--unexpected: #f07a24;
|
||||
--unexpected-fg: #f8a96b;
|
||||
--unexpected-soft: #3a2010;
|
||||
/* Koyu zeminde daha açık tonlar (kontrast için) */
|
||||
--needs: #3a9fdb;
|
||||
--needs-fg: #7cc3f0;
|
||||
--needs-soft: #10283a;
|
||||
--wants: #d98fb8;
|
||||
--wants-fg: #ecb6d4;
|
||||
--wants-soft: #35202c;
|
||||
--culture: #1fbf8f;
|
||||
--culture-fg: #6fdcb9;
|
||||
--culture-soft: #0c2e25;
|
||||
--unexpected: #f07a24;
|
||||
--unexpected-fg: #f8a96b;
|
||||
--unexpected-soft: #3a2010;
|
||||
|
||||
--income: #4ade80;
|
||||
--expense: #f87171;
|
||||
--warning: #fbbf24;
|
||||
--income: #4ade80;
|
||||
--expense: #f87171;
|
||||
--warning: #fbbf24;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
font-size: 16px;
|
||||
@@ -206,4 +206,4 @@
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,10 @@ export function useRegister() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (body: RegisterRequest) => api.post<MeResponse>('/auth/register', body),
|
||||
onSuccess: (me) => qc.setQueryData(queryKeys.me, me),
|
||||
onSuccess: (me) => {
|
||||
qc.setQueryData(queryKeys.me, me)
|
||||
qc.removeQueries({ queryKey: queryKeys.registration })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ApiError } from './api'
|
||||
/** Önbellek anahtarları tek yerde (yazım hatası olmasın) */
|
||||
export const queryKeys = {
|
||||
me: ['me'] as const,
|
||||
registration: ['registration'] as const,
|
||||
health: ['health'] as const,
|
||||
household: ['household'] as const,
|
||||
categories: (includeInactive = false) => ['categories', { includeInactive }] as const,
|
||||
|
||||
@@ -6,10 +6,15 @@ describe('safeReturnPath', () => {
|
||||
expect(safeReturnPath(p)).toBe(p)
|
||||
})
|
||||
|
||||
it.each([null, '', 'defter', '//kotu.com', '/\\kotu.com', 'https://kotu.com', 'javascript:alert(1)'])(
|
||||
'güvensiz → panele: %s',
|
||||
(p) => {
|
||||
expect(safeReturnPath(p)).toBe('/')
|
||||
},
|
||||
)
|
||||
it.each([
|
||||
null,
|
||||
'',
|
||||
'defter',
|
||||
'//kotu.com',
|
||||
'/\\kotu.com',
|
||||
'https://kotu.com',
|
||||
'javascript:alert(1)',
|
||||
])('güvensiz → panele: %s', (p) => {
|
||||
expect(safeReturnPath(p)).toBe('/')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,9 +23,15 @@ export const t = {
|
||||
pages: {
|
||||
dashboard: { title: 'Panel', description: 'Gelir, harcama ve birikimin tek bakışta özeti.' },
|
||||
ledger: { title: 'Günlük Defter', description: 'Haftalık harcama girişleri.' },
|
||||
planning: { title: 'Aylık Plan', description: 'Gelir, sabit giderler, birikim hedefi ve bütçe dağıtımı.' },
|
||||
planning: {
|
||||
title: 'Aylık Plan',
|
||||
description: 'Gelir, sabit giderler, birikim hedefi ve bütçe dağıtımı.',
|
||||
},
|
||||
savings: { title: 'Birikimler', description: 'TL, altın ve döviz varlıkların, kâr/zarar.' },
|
||||
review: { title: 'Ay Sonu Değerlendirmesi', description: "Kakeibo'nun dört sorusu ve dönem kapanışı." },
|
||||
review: {
|
||||
title: 'Ay Sonu Değerlendirmesi',
|
||||
description: "Kakeibo'nun dört sorusu ve dönem kapanışı.",
|
||||
},
|
||||
reports: { title: 'Raporlar', description: 'Gelir–gider, kategori ve bütçe sapma raporları.' },
|
||||
settings: { title: 'Ayarlar', description: 'Kategoriler, ödeme türleri, hane ve hesap.' },
|
||||
more: { title: 'Diğer' },
|
||||
@@ -73,6 +79,8 @@ export const t = {
|
||||
householdHint: (name: string) => (name ? `Boş bırakırsan: "${name} Hanesi"` : 'İsteğe bağlı'),
|
||||
inviteCode: 'Davet kodu',
|
||||
inviteHint: 'Hane sahibinden aldığın 8 karakterlik kod (ör. ABCD-EFGH).',
|
||||
closedNote:
|
||||
'Bu sunucuda yeni hane kaydı kapalı. Katılmak için hane sahibinden davet bağlantısı ya da kodu iste.',
|
||||
submit: 'Hesabı oluştur',
|
||||
submitting: 'Oluşturuluyor…',
|
||||
hasAccount: 'Zaten hesabın var mı?',
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { cn } from "cn"
|
||||
export { cn } from 'cn'
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Kayıt ekranı için: yeni hane kurulabilir mi? (oturum gerektirmez)
|
||||
*/
|
||||
export type RegistrationInfo = {
|
||||
/**
|
||||
* `false` ise yalnızca davet koduyla kayıt yapılabilir
|
||||
*/
|
||||
new_household_allowed: boolean, };
|
||||
Reference in New Issue
Block a user