add auth system: users, login, JWT, protected routes
Domain: User entity, AuthPort/PasswordHashPort/SecretStore ports. Adapters: auth (argon2 hashing, JWT tokens), secret-store (env-based), config-sqlite user repository, http-api auth routes + extractors. Application: auth_service. SPA: login page, auth client, protected router.
This commit is contained in:
10
.env.example
10
.env.example
@@ -4,5 +4,15 @@ KFRAME_TCP_ADDR=0.0.0.0:2699
|
||||
KFRAME_HTTP_ADDR=0.0.0.0:3000
|
||||
KFRAME_POLL_INTERVAL_SECS=5
|
||||
|
||||
# Auth (required)
|
||||
JWT_SECRET=change-me-to-a-random-secret
|
||||
JWT_TTL_SECONDS=3600
|
||||
|
||||
# Encryption at rest (required, generate with: openssl rand -hex 32)
|
||||
KFRAME_ENCRYPTION_KEY=change-me-generate-with-openssl-rand-hex-32
|
||||
|
||||
# SPA static files (optional, omit for dev mode with Vite proxy)
|
||||
# KFRAME_SPA_DIR=spa/dist
|
||||
|
||||
# Logging (tracing-subscriber)
|
||||
RUST_LOG=info,sqlx=warn
|
||||
|
||||
255
Cargo.lock
generated
255
Cargo.lock
generated
@@ -2,6 +2,41 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "aead"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes-gcm"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"aes",
|
||||
"cipher",
|
||||
"ctr",
|
||||
"ghash",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
@@ -40,6 +75,18 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"blake2",
|
||||
"cpufeatures",
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atoi"
|
||||
version = "2.0.0"
|
||||
@@ -146,6 +193,15 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@@ -166,8 +222,10 @@ dependencies = [
|
||||
"dotenvy",
|
||||
"http-api",
|
||||
"http-json",
|
||||
"kframe-auth",
|
||||
"media-adapter",
|
||||
"rss-adapter",
|
||||
"secret-store",
|
||||
"tcp-server",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -208,6 +266,16 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "client-application"
|
||||
version = "0.1.0"
|
||||
@@ -352,9 +420,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"rand_core",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctr"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
@@ -366,6 +444,12 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
@@ -621,8 +705,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -636,6 +722,16 @@ dependencies = [
|
||||
"r-efi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ghash"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
|
||||
dependencies = [
|
||||
"opaque-debug",
|
||||
"polyval",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.15"
|
||||
@@ -991,6 +1087,15 @@ dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.12.0"
|
||||
@@ -1014,6 +1119,32 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonwebtoken"
|
||||
version = "9.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"js-sys",
|
||||
"pem",
|
||||
"ring",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"simple_asn1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kframe-auth"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"domain",
|
||||
"jsonwebtoken",
|
||||
"rand_core",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
@@ -1189,6 +1320,16 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint-dig"
|
||||
version = "0.8.6"
|
||||
@@ -1205,6 +1346,12 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.46"
|
||||
@@ -1241,6 +1388,12 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "opaque-debug"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.81"
|
||||
@@ -1313,6 +1466,27 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "password-hash"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"rand_core",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pem"
|
||||
version = "3.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pem-rfc7468"
|
||||
version = "0.7.0"
|
||||
@@ -1367,6 +1541,18 @@ version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||
|
||||
[[package]]
|
||||
name = "polyval"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"opaque-debug",
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "postcard"
|
||||
version = "1.1.3"
|
||||
@@ -1388,6 +1574,12 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "powerfmt"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
@@ -1666,6 +1858,17 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "secret-store"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"base64",
|
||||
"domain",
|
||||
"hex",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
@@ -1802,6 +2005,18 @@ dependencies = [
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simple_asn1"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"thiserror",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
@@ -2174,6 +2389,36 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.49"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"num-conv",
|
||||
"powerfmt",
|
||||
"serde_core",
|
||||
"time-core",
|
||||
"time-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time-core"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
||||
|
||||
[[package]]
|
||||
name = "time-macros"
|
||||
version = "0.2.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d"
|
||||
dependencies = [
|
||||
"num-conv",
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.3"
|
||||
@@ -2431,6 +2676,16 @@ version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
|
||||
|
||||
[[package]]
|
||||
name = "universal-hash"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
|
||||
@@ -14,6 +14,8 @@ members = [
|
||||
"crates/adapters/http-json",
|
||||
"crates/adapters/rss",
|
||||
"crates/adapters/media",
|
||||
"crates/adapters/auth",
|
||||
"crates/adapters/secret-store",
|
||||
"crates/api-types",
|
||||
"crates/bootstrap",
|
||||
"crates/client-desktop",
|
||||
@@ -38,6 +40,8 @@ http-json = { path = "crates/adapters/http-json" }
|
||||
http-api = { path = "crates/adapters/http-api" }
|
||||
media-adapter = { path = "crates/adapters/media" }
|
||||
rss-adapter = { path = "crates/adapters/rss" }
|
||||
kframe-auth = { path = "crates/adapters/auth" }
|
||||
secret-store = { path = "crates/adapters/secret-store" }
|
||||
axum = { version = "0.8", features = ["macros"] }
|
||||
tower-http = { version = "0.6", features = ["cors", "fs"] }
|
||||
api-types = { path = "crates/api-types" }
|
||||
|
||||
11
crates/adapters/auth/Cargo.toml
Normal file
11
crates/adapters/auth/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "kframe-auth"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
domain.workspace = true
|
||||
jsonwebtoken = "9"
|
||||
argon2 = { version = "0.5", features = ["std"] }
|
||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
90
crates/adapters/auth/src/lib.rs
Normal file
90
crates/adapters/auth/src/lib.rs
Normal file
@@ -0,0 +1,90 @@
|
||||
use argon2::{
|
||||
Argon2,
|
||||
password_hash::{PasswordHasher, PasswordVerifier, SaltString},
|
||||
};
|
||||
use domain::{AuthPort, PasswordHashPort, UserId};
|
||||
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
|
||||
use rand_core::OsRng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub struct AuthConfig {
|
||||
pub secret: String,
|
||||
pub ttl_seconds: u64,
|
||||
}
|
||||
|
||||
impl AuthConfig {
|
||||
pub fn from_env() -> Result<Self, String> {
|
||||
let secret = std::env::var("JWT_SECRET")
|
||||
.map_err(|_| "JWT_SECRET env var is required".to_string())?;
|
||||
if secret.is_empty() {
|
||||
return Err("JWT_SECRET must not be empty".into());
|
||||
}
|
||||
let ttl_seconds = std::env::var("JWT_TTL_SECONDS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(3600u64);
|
||||
Ok(Self {
|
||||
secret,
|
||||
ttl_seconds,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Claims {
|
||||
sub: u32,
|
||||
exp: u64,
|
||||
}
|
||||
|
||||
pub struct JwtAuthService {
|
||||
config: AuthConfig,
|
||||
}
|
||||
|
||||
impl JwtAuthService {
|
||||
pub fn new(config: AuthConfig) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthPort for JwtAuthService {
|
||||
fn generate_token(&self, user_id: UserId) -> String {
|
||||
let exp = jsonwebtoken::get_current_timestamp() + self.config.ttl_seconds;
|
||||
let claims = Claims { sub: user_id, exp };
|
||||
encode(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(self.config.secret.as_bytes()),
|
||||
)
|
||||
.expect("JWT encoding should not fail")
|
||||
}
|
||||
|
||||
fn validate_token(&self, token: &str) -> Option<UserId> {
|
||||
let data = decode::<Claims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(self.config.secret.as_bytes()),
|
||||
&Validation::default(),
|
||||
)
|
||||
.ok()?;
|
||||
Some(data.claims.sub)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Argon2Hasher;
|
||||
|
||||
impl PasswordHashPort for Argon2Hasher {
|
||||
async fn hash(&self, plain: &str) -> Result<String, String> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let hash = Argon2::default()
|
||||
.hash_password(plain.as_bytes(), &salt)
|
||||
.map_err(|e| e.to_string())?
|
||||
.to_string();
|
||||
Ok(hash)
|
||||
}
|
||||
|
||||
async fn verify(&self, plain: &str, hash: &str) -> Result<bool, String> {
|
||||
let parsed = argon2::password_hash::PasswordHash::new(hash).map_err(|e| e.to_string())?;
|
||||
Ok(Argon2::default()
|
||||
.verify_password(plain.as_bytes(), &parsed)
|
||||
.is_ok())
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use domain::{
|
||||
ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, WidgetConfig,
|
||||
WidgetId,
|
||||
ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, User,
|
||||
WidgetConfig, WidgetId,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::RwLock;
|
||||
@@ -16,6 +16,7 @@ pub struct MemoryConfigStore {
|
||||
data_sources: RwLock<HashMap<DataSourceId, DataSource>>,
|
||||
layout: RwLock<Option<Layout>>,
|
||||
presets: RwLock<HashMap<LayoutPresetId, LayoutPreset>>,
|
||||
users: RwLock<Vec<User>>,
|
||||
}
|
||||
|
||||
impl Default for MemoryConfigStore {
|
||||
@@ -25,6 +26,7 @@ impl Default for MemoryConfigStore {
|
||||
data_sources: RwLock::new(HashMap::new()),
|
||||
layout: RwLock::new(None),
|
||||
presets: RwLock::new(HashMap::new()),
|
||||
users: RwLock::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -156,4 +158,30 @@ impl ConfigRepository for MemoryConfigStore {
|
||||
guard.remove(&id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_user_by_username(&self, username: &str) -> Result<Option<User>, Self::Error> {
|
||||
let guard = self
|
||||
.users
|
||||
.read()
|
||||
.map_err(|_| MemoryConfigError::LockPoisoned)?;
|
||||
Ok(guard.iter().find(|u| u.username == username).cloned())
|
||||
}
|
||||
|
||||
async fn save_user(&self, user: &User) -> Result<(), Self::Error> {
|
||||
let mut guard = self
|
||||
.users
|
||||
.write()
|
||||
.map_err(|_| MemoryConfigError::LockPoisoned)?;
|
||||
guard.retain(|u| u.id != user.id);
|
||||
guard.push(user.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn count_users(&self) -> Result<u32, Self::Error> {
|
||||
let guard = self
|
||||
.users
|
||||
.read()
|
||||
.map_err(|_| MemoryConfigError::LockPoisoned)?;
|
||||
Ok(guard.len() as u32)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,22 +2,36 @@ pub mod error;
|
||||
mod repository;
|
||||
mod serialization;
|
||||
|
||||
use domain::SecretStore;
|
||||
use sqlx::SqlitePool;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub use error::SqliteConfigError;
|
||||
|
||||
pub struct SqliteConfigStore {
|
||||
pool: SqlitePool,
|
||||
secrets: Option<Arc<dyn SecretStore + Send + Sync>>,
|
||||
}
|
||||
|
||||
impl SqliteConfigStore {
|
||||
pub async fn new(database_url: &str) -> Result<Self, sqlx::Error> {
|
||||
Self::with_secrets(database_url, None).await
|
||||
}
|
||||
|
||||
pub async fn with_secrets(
|
||||
database_url: &str,
|
||||
secrets: Option<Arc<dyn SecretStore + Send + Sync>>,
|
||||
) -> Result<Self, sqlx::Error> {
|
||||
let pool = SqlitePool::connect(database_url).await?;
|
||||
let store = Self { pool };
|
||||
let store = Self { pool, secrets };
|
||||
store.migrate().await?;
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
pub(crate) fn secrets(&self) -> Option<&(dyn SecretStore + Send + Sync)> {
|
||||
self.secrets.as_deref()
|
||||
}
|
||||
|
||||
async fn migrate(&self) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS widgets (
|
||||
@@ -63,6 +77,16 @@ impl SqliteConfigStore {
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL
|
||||
)",
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ impl SqliteConfigStore {
|
||||
|
||||
match row {
|
||||
None => Ok(None),
|
||||
Some(row) => Ok(Some(ser::data_source_from_row(&row)?)),
|
||||
Some(row) => Ok(Some(ser::data_source_from_row(&row, self.secrets())?)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,19 +28,22 @@ impl SqliteConfigStore {
|
||||
.await
|
||||
.map_err(SqliteConfigError::Sql)?;
|
||||
|
||||
rows.iter().map(ser::data_source_from_row).collect()
|
||||
let secrets = self.secrets();
|
||||
rows.iter()
|
||||
.map(|r| ser::data_source_from_row(r, secrets))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) async fn save_data_source_impl(
|
||||
&self,
|
||||
source: &DataSource,
|
||||
) -> Result<(), SqliteConfigError> {
|
||||
let config_json = ser::data_source_config_to_json(&source.config)?;
|
||||
let config_json = ser::data_source_config_to_json(&source.config, self.secrets())?;
|
||||
let type_str = ser::data_source_type_to_str(&source.source_type);
|
||||
|
||||
sqlx::query(
|
||||
"INSERT OR REPLACE INTO data_sources (id, name, source_type, poll_interval_secs, config)
|
||||
VALUES (?, ?, ?, ?, ?)"
|
||||
VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(source.id as i64)
|
||||
.bind(&source.name)
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
mod data_sources;
|
||||
mod layout;
|
||||
mod presets;
|
||||
mod users;
|
||||
mod widgets;
|
||||
|
||||
use crate::SqliteConfigStore;
|
||||
use crate::error::SqliteConfigError;
|
||||
use domain::{
|
||||
ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, WidgetConfig,
|
||||
WidgetId,
|
||||
ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, User,
|
||||
WidgetConfig, WidgetId,
|
||||
};
|
||||
|
||||
impl ConfigRepository for SqliteConfigStore {
|
||||
@@ -68,4 +69,16 @@ impl ConfigRepository for SqliteConfigStore {
|
||||
async fn delete_preset(&self, id: LayoutPresetId) -> Result<(), Self::Error> {
|
||||
self.delete_preset_impl(id).await
|
||||
}
|
||||
|
||||
async fn get_user_by_username(&self, username: &str) -> Result<Option<User>, Self::Error> {
|
||||
self.get_user_by_username_impl(username).await
|
||||
}
|
||||
|
||||
async fn save_user(&self, user: &User) -> Result<(), Self::Error> {
|
||||
self.save_user_impl(user).await
|
||||
}
|
||||
|
||||
async fn count_users(&self) -> Result<u32, Self::Error> {
|
||||
self.count_users_impl().await
|
||||
}
|
||||
}
|
||||
|
||||
48
crates/adapters/config-sqlite/src/repository/users.rs
Normal file
48
crates/adapters/config-sqlite/src/repository/users.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use crate::SqliteConfigStore;
|
||||
use crate::error::SqliteConfigError;
|
||||
use domain::User;
|
||||
use sqlx::Row;
|
||||
|
||||
impl SqliteConfigStore {
|
||||
pub(crate) async fn get_user_by_username_impl(
|
||||
&self,
|
||||
username: &str,
|
||||
) -> Result<Option<User>, SqliteConfigError> {
|
||||
let row = sqlx::query("SELECT id, username, password_hash FROM users WHERE username = ?")
|
||||
.bind(username)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(SqliteConfigError::Sql)?;
|
||||
|
||||
Ok(row.map(|r| {
|
||||
let id: i64 = r.get("id");
|
||||
User {
|
||||
id: id as u32,
|
||||
username: r.get("username"),
|
||||
password_hash: r.get("password_hash"),
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) async fn save_user_impl(&self, user: &User) -> Result<(), SqliteConfigError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO users (username, password_hash) VALUES (?, ?)
|
||||
ON CONFLICT(username) DO UPDATE SET password_hash = excluded.password_hash",
|
||||
)
|
||||
.bind(&user.username)
|
||||
.bind(&user.password_hash)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(SqliteConfigError::Sql)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn count_users_impl(&self) -> Result<u32, SqliteConfigError> {
|
||||
let row = sqlx::query("SELECT COUNT(*) as cnt FROM users")
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(SqliteConfigError::Sql)?;
|
||||
let count: i64 = row.get("cnt");
|
||||
Ok(count as u32)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,16 @@
|
||||
use crate::error::SqliteConfigError;
|
||||
use domain::{DataSource, DataSourceConfig, DataSourceType};
|
||||
use domain::{DataSource, DataSourceConfig, DataSourceType, SecretStore};
|
||||
use sqlx::Row;
|
||||
use sqlx::sqlite::SqliteRow;
|
||||
use std::time::Duration;
|
||||
|
||||
const SENSITIVE_KEYS: &[&str] = &["password", "secret", "token", "api_key", "apikey"];
|
||||
|
||||
fn is_sensitive_key(key: &str) -> bool {
|
||||
let lower = key.to_lowercase();
|
||||
SENSITIVE_KEYS.iter().any(|s| lower.contains(s))
|
||||
}
|
||||
|
||||
pub fn data_source_type_to_str(t: &DataSourceType) -> &'static str {
|
||||
match t {
|
||||
DataSourceType::Weather => "weather",
|
||||
@@ -27,27 +34,78 @@ fn data_source_type_from_str(s: &str) -> Result<DataSourceType, SqliteConfigErro
|
||||
}
|
||||
}
|
||||
|
||||
pub fn data_source_config_to_json(config: &DataSourceConfig) -> Result<String, SqliteConfigError> {
|
||||
pub fn data_source_config_to_json(
|
||||
config: &DataSourceConfig,
|
||||
secrets: Option<&(dyn SecretStore + Send + Sync)>,
|
||||
) -> Result<String, SqliteConfigError> {
|
||||
let api_key = config.api_key.as_ref().map(|k| match secrets {
|
||||
Some(s) => s.encrypt(k),
|
||||
None => k.clone(),
|
||||
});
|
||||
|
||||
let headers: Vec<(String, String)> = config
|
||||
.headers
|
||||
.iter()
|
||||
.map(|(k, v)| {
|
||||
let val = if is_sensitive_key(k) {
|
||||
match secrets {
|
||||
Some(s) => s.encrypt(v),
|
||||
None => v.clone(),
|
||||
}
|
||||
} else {
|
||||
v.clone()
|
||||
};
|
||||
(k.clone(), val)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let v = serde_json::json!({
|
||||
"url": config.url,
|
||||
"headers": config.headers,
|
||||
"api_key": config.api_key,
|
||||
"headers": headers,
|
||||
"api_key": api_key,
|
||||
"encrypted": secrets.is_some(),
|
||||
});
|
||||
serde_json::to_string(&v).map_err(|e| SqliteConfigError::Serialization(e.to_string()))
|
||||
}
|
||||
|
||||
fn data_source_config_from_json(json: &str) -> Result<DataSourceConfig, SqliteConfigError> {
|
||||
fn data_source_config_from_json(
|
||||
json: &str,
|
||||
secrets: Option<&(dyn SecretStore + Send + Sync)>,
|
||||
) -> Result<DataSourceConfig, SqliteConfigError> {
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_str(json).map_err(|e| SqliteConfigError::Serialization(e.to_string()))?;
|
||||
|
||||
let encrypted = v["encrypted"].as_bool().unwrap_or(false);
|
||||
|
||||
let url = v["url"].as_str().map(String::from);
|
||||
let api_key = v["api_key"].as_str().map(String::from);
|
||||
|
||||
let api_key = v["api_key"].as_str().map(|k| {
|
||||
if encrypted {
|
||||
match secrets {
|
||||
Some(s) => s.decrypt(k),
|
||||
None => k.to_string(),
|
||||
}
|
||||
} else {
|
||||
k.to_string()
|
||||
}
|
||||
});
|
||||
|
||||
let headers = match v["headers"].as_array() {
|
||||
Some(arr) => arr
|
||||
.iter()
|
||||
.filter_map(|h| {
|
||||
let pair = h.as_array()?;
|
||||
Some((pair[0].as_str()?.into(), pair[1].as_str()?.into()))
|
||||
let key: String = pair[0].as_str()?.into();
|
||||
let raw_val: &str = pair[1].as_str()?;
|
||||
let val = if encrypted && is_sensitive_key(&key) {
|
||||
match secrets {
|
||||
Some(s) => s.decrypt(raw_val),
|
||||
None => raw_val.to_string(),
|
||||
}
|
||||
} else {
|
||||
raw_val.to_string()
|
||||
};
|
||||
Some((key, val))
|
||||
})
|
||||
.collect(),
|
||||
None => vec![],
|
||||
@@ -60,7 +118,10 @@ fn data_source_config_from_json(json: &str) -> Result<DataSourceConfig, SqliteCo
|
||||
})
|
||||
}
|
||||
|
||||
pub fn data_source_from_row(row: &SqliteRow) -> Result<DataSource, SqliteConfigError> {
|
||||
pub fn data_source_from_row(
|
||||
row: &SqliteRow,
|
||||
secrets: Option<&(dyn SecretStore + Send + Sync)>,
|
||||
) -> Result<DataSource, SqliteConfigError> {
|
||||
let id: i64 = row.get("id");
|
||||
let name: String = row.get("name");
|
||||
let type_str: String = row.get("source_type");
|
||||
@@ -72,6 +133,6 @@ pub fn data_source_from_row(row: &SqliteRow) -> Result<DataSource, SqliteConfigE
|
||||
name,
|
||||
source_type: data_source_type_from_str(&type_str)?,
|
||||
poll_interval: Duration::from_secs(interval_secs as u64),
|
||||
config: data_source_config_from_json(&config_json)?,
|
||||
config: data_source_config_from_json(&config_json, secrets)?,
|
||||
})
|
||||
}
|
||||
|
||||
42
crates/adapters/http-api/src/extractors.rs
Normal file
42
crates/adapters/http-api/src/extractors.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
use axum::{
|
||||
extract::FromRequestParts,
|
||||
http::{StatusCode, request::Parts},
|
||||
};
|
||||
use domain::{AuthPort, UserId};
|
||||
|
||||
pub struct AuthUser(pub UserId);
|
||||
|
||||
impl<C, E, W, B, R, A, H> FromRequestParts<crate::AppState<C, E, W, B, R, A, H>> for AuthUser
|
||||
where
|
||||
A: AuthPort + Send + Sync + 'static,
|
||||
C: Send + Sync + 'static,
|
||||
E: Send + Sync + 'static,
|
||||
W: Send + Sync + 'static,
|
||||
B: Send + Sync + 'static,
|
||||
R: Send + Sync + 'static,
|
||||
H: Send + Sync + 'static,
|
||||
{
|
||||
type Rejection = StatusCode;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &crate::AppState<C, E, W, B, R, A, H>,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
let header = parts
|
||||
.headers
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let token = header
|
||||
.strip_prefix("Bearer ")
|
||||
.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let user_id = state
|
||||
.auth
|
||||
.validate_token(token)
|
||||
.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
Ok(AuthUser(user_id))
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,27 @@
|
||||
pub mod extractors;
|
||||
mod routes;
|
||||
|
||||
use axum::Router;
|
||||
use domain::{BroadcastPort, ClientRegistry, ConfigRepository, EventPublisher, WidgetStateReader};
|
||||
use domain::{
|
||||
AuthPort, BroadcastPort, ClientRegistry, ConfigRepository, EventPublisher, PasswordHashPort,
|
||||
WidgetStateReader,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tower_http::cors::CorsLayer;
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
|
||||
pub struct AppState<C, E, W, B, R> {
|
||||
pub struct AppState<C, E, W, B, R, A, H> {
|
||||
pub config: Arc<C>,
|
||||
pub events: Arc<E>,
|
||||
pub widget_states: Arc<W>,
|
||||
pub broadcaster: Arc<B>,
|
||||
pub clients: Arc<R>,
|
||||
pub auth: Arc<A>,
|
||||
pub hasher: Arc<H>,
|
||||
pub spa_dir: Option<String>,
|
||||
}
|
||||
|
||||
impl<C, E, W, B, R> Clone for AppState<C, E, W, B, R> {
|
||||
impl<C, E, W, B, R, A, H> Clone for AppState<C, E, W, B, R, A, H> {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
config: self.config.clone(),
|
||||
@@ -23,12 +29,14 @@ impl<C, E, W, B, R> Clone for AppState<C, E, W, B, R> {
|
||||
widget_states: self.widget_states.clone(),
|
||||
broadcaster: self.broadcaster.clone(),
|
||||
clients: self.clients.clone(),
|
||||
auth: self.auth.clone(),
|
||||
hasher: self.hasher.clone(),
|
||||
spa_dir: self.spa_dir.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn router<C, E, W, B, R>(state: AppState<C, E, W, B, R>) -> Router
|
||||
pub fn router<C, E, W, B, R, A, H>(state: AppState<C, E, W, B, R, A, H>) -> Router
|
||||
where
|
||||
C: ConfigRepository + Send + Sync + 'static,
|
||||
C::Error: std::fmt::Debug + Send,
|
||||
@@ -38,6 +46,8 @@ where
|
||||
B: BroadcastPort + Send + Sync + 'static,
|
||||
B::Error: std::fmt::Debug + Send,
|
||||
R: ClientRegistry + Send + Sync + 'static,
|
||||
A: AuthPort + Send + Sync + 'static,
|
||||
H: PasswordHashPort + Send + Sync + 'static,
|
||||
{
|
||||
let spa_dir = state.spa_dir.clone();
|
||||
|
||||
@@ -54,9 +64,9 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn serve<C, E, W, B, R>(
|
||||
pub async fn serve<C, E, W, B, R, A, H>(
|
||||
addr: &str,
|
||||
state: AppState<C, E, W, B, R>,
|
||||
state: AppState<C, E, W, B, R, A, H>,
|
||||
) -> Result<(), std::io::Error>
|
||||
where
|
||||
C: ConfigRepository + Send + Sync + 'static,
|
||||
@@ -67,6 +77,8 @@ where
|
||||
B: BroadcastPort + Send + Sync + 'static,
|
||||
B::Error: std::fmt::Debug + Send,
|
||||
R: ClientRegistry + Send + Sync + 'static,
|
||||
A: AuthPort + Send + Sync + 'static,
|
||||
H: PasswordHashPort + Send + Sync + 'static,
|
||||
{
|
||||
let app = router(state);
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
|
||||
85
crates/adapters/http-api/src/routes/auth.rs
Normal file
85
crates/adapters/http-api/src/routes/auth.rs
Normal file
@@ -0,0 +1,85 @@
|
||||
use crate::AppState;
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::Json;
|
||||
use domain::{AuthPort, ConfigRepository, PasswordHashPort};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
type S<C, E, W, B, R, A, H> = State<AppState<C, E, W, B, R, A, H>>;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct LoginResponse {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct StatusResponse {
|
||||
pub needs_setup: bool,
|
||||
}
|
||||
|
||||
pub async fn login<C, E, W, B, R, A, H>(
|
||||
State(state): S<C, E, W, B, R, A, H>,
|
||||
Json(body): Json<LoginRequest>,
|
||||
) -> Result<Json<LoginResponse>, (StatusCode, String)>
|
||||
where
|
||||
C: ConfigRepository,
|
||||
C::Error: std::fmt::Debug,
|
||||
A: AuthPort,
|
||||
H: PasswordHashPort,
|
||||
{
|
||||
let token = application::auth_service::login(
|
||||
state.config.as_ref(),
|
||||
state.auth.as_ref(),
|
||||
state.hasher.as_ref(),
|
||||
&body.username,
|
||||
&body.password,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string()))?;
|
||||
|
||||
Ok(Json(LoginResponse { token }))
|
||||
}
|
||||
|
||||
pub async fn register<C, E, W, B, R, A, H>(
|
||||
State(state): S<C, E, W, B, R, A, H>,
|
||||
Json(body): Json<LoginRequest>,
|
||||
) -> Result<StatusCode, (StatusCode, String)>
|
||||
where
|
||||
C: ConfigRepository,
|
||||
C::Error: std::fmt::Debug,
|
||||
H: PasswordHashPort,
|
||||
{
|
||||
application::auth_service::register(
|
||||
state.config.as_ref(),
|
||||
state.hasher.as_ref(),
|
||||
&body.username,
|
||||
&body.password,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
|
||||
|
||||
Ok(StatusCode::CREATED)
|
||||
}
|
||||
|
||||
pub async fn auth_status<C, E, W, B, R, A, H>(
|
||||
State(state): S<C, E, W, B, R, A, H>,
|
||||
) -> Result<Json<StatusResponse>, StatusCode>
|
||||
where
|
||||
C: ConfigRepository,
|
||||
C::Error: std::fmt::Debug,
|
||||
{
|
||||
let count = state
|
||||
.config
|
||||
.count_users()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
Ok(Json(StatusResponse {
|
||||
needs_setup: count == 0,
|
||||
}))
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
use crate::AppState;
|
||||
use crate::extractors::AuthUser;
|
||||
use api_types::ClientDto;
|
||||
use axum::extract::State;
|
||||
use axum::response::Json;
|
||||
use domain::{ClientRegistry, ConfigRepository, EventPublisher};
|
||||
|
||||
type S<C, E, W, B, R> = State<AppState<C, E, W, B, R>>;
|
||||
type S<C, E, W, B, R, A, H> = State<AppState<C, E, W, B, R, A, H>>;
|
||||
|
||||
pub async fn list_clients<C, E, W, B, R>(State(state): S<C, E, W, B, R>) -> Json<Vec<ClientDto>>
|
||||
pub async fn list_clients<C, E, W, B, R, A, H>(
|
||||
_auth: AuthUser,
|
||||
State(state): S<C, E, W, B, R, A, H>,
|
||||
) -> Json<Vec<ClientDto>>
|
||||
where
|
||||
C: ConfigRepository,
|
||||
C::Error: std::fmt::Debug,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::AppState;
|
||||
use crate::extractors::AuthUser;
|
||||
use api_types::DataSourceDto;
|
||||
use application::ConfigService;
|
||||
use axum::{
|
||||
@@ -8,10 +9,11 @@ use axum::{
|
||||
};
|
||||
use domain::{ConfigRepository, EventPublisher};
|
||||
|
||||
type S<C, E, W, B, R> = State<AppState<C, E, W, B, R>>;
|
||||
type S<C, E, W, B, R, A, H> = State<AppState<C, E, W, B, R, A, H>>;
|
||||
|
||||
pub async fn list_data_sources<C, E, W, B, R>(
|
||||
State(state): S<C, E, W, B, R>,
|
||||
pub async fn list_data_sources<C, E, W, B, R, A, H>(
|
||||
_auth: AuthUser,
|
||||
State(state): S<C, E, W, B, R, A, H>,
|
||||
) -> Result<Json<Vec<DataSourceDto>>, StatusCode>
|
||||
where
|
||||
C: ConfigRepository,
|
||||
@@ -27,8 +29,9 @@ where
|
||||
Ok(Json(sources.iter().map(DataSourceDto::from).collect()))
|
||||
}
|
||||
|
||||
pub async fn get_data_source<C, E, W, B, R>(
|
||||
State(state): S<C, E, W, B, R>,
|
||||
pub async fn get_data_source<C, E, W, B, R, A, H>(
|
||||
_auth: AuthUser,
|
||||
State(state): S<C, E, W, B, R, A, H>,
|
||||
Path(id): Path<u16>,
|
||||
) -> Result<Json<DataSourceDto>, StatusCode>
|
||||
where
|
||||
@@ -48,8 +51,9 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_data_source<C, E, W, B, R>(
|
||||
State(state): S<C, E, W, B, R>,
|
||||
pub async fn create_data_source<C, E, W, B, R, A, H>(
|
||||
_auth: AuthUser,
|
||||
State(state): S<C, E, W, B, R, A, H>,
|
||||
Json(body): Json<DataSourceDto>,
|
||||
) -> Result<StatusCode, (StatusCode, String)>
|
||||
where
|
||||
@@ -68,8 +72,9 @@ where
|
||||
Ok(StatusCode::CREATED)
|
||||
}
|
||||
|
||||
pub async fn update_data_source<C, E, W, B, R>(
|
||||
State(state): S<C, E, W, B, R>,
|
||||
pub async fn update_data_source<C, E, W, B, R, A, H>(
|
||||
_auth: AuthUser,
|
||||
State(state): S<C, E, W, B, R, A, H>,
|
||||
Path(_id): Path<u16>,
|
||||
Json(body): Json<DataSourceDto>,
|
||||
) -> Result<StatusCode, (StatusCode, String)>
|
||||
@@ -89,8 +94,9 @@ where
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
pub async fn delete_data_source<C, E, W, B, R>(
|
||||
State(state): S<C, E, W, B, R>,
|
||||
pub async fn delete_data_source<C, E, W, B, R, A, H>(
|
||||
_auth: AuthUser,
|
||||
State(state): S<C, E, W, B, R, A, H>,
|
||||
Path(id): Path<u16>,
|
||||
) -> Result<StatusCode, StatusCode>
|
||||
where
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
use crate::AppState;
|
||||
use crate::extractors::AuthUser;
|
||||
use api_types::LayoutDto;
|
||||
use application::ConfigService;
|
||||
use axum::{extract::State, http::StatusCode, response::Json};
|
||||
use domain::{ConfigRepository, EventPublisher};
|
||||
|
||||
type S<C, E, W, B, R> = State<AppState<C, E, W, B, R>>;
|
||||
type S<C, E, W, B, R, A, H> = State<AppState<C, E, W, B, R, A, H>>;
|
||||
|
||||
pub async fn get_layout<C, E, W, B, R>(
|
||||
State(state): S<C, E, W, B, R>,
|
||||
pub async fn get_layout<C, E, W, B, R, A, H>(
|
||||
_auth: AuthUser,
|
||||
State(state): S<C, E, W, B, R, A, H>,
|
||||
) -> Result<Json<Option<LayoutDto>>, StatusCode>
|
||||
where
|
||||
C: ConfigRepository,
|
||||
@@ -23,8 +25,9 @@ where
|
||||
Ok(Json(layout.as_ref().map(LayoutDto::from)))
|
||||
}
|
||||
|
||||
pub async fn update_layout<C, E, W, B, R>(
|
||||
State(state): S<C, E, W, B, R>,
|
||||
pub async fn update_layout<C, E, W, B, R, A, H>(
|
||||
_auth: AuthUser,
|
||||
State(state): S<C, E, W, B, R, A, H>,
|
||||
Json(body): Json<LayoutDto>,
|
||||
) -> Result<StatusCode, (StatusCode, String)>
|
||||
where
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
mod auth;
|
||||
mod clients;
|
||||
mod data_sources;
|
||||
mod layout;
|
||||
@@ -8,9 +9,12 @@ mod widgets;
|
||||
use crate::AppState;
|
||||
use axum::Router;
|
||||
use axum::routing::{get, post};
|
||||
use domain::{BroadcastPort, ClientRegistry, ConfigRepository, EventPublisher, WidgetStateReader};
|
||||
use domain::{
|
||||
AuthPort, BroadcastPort, ClientRegistry, ConfigRepository, EventPublisher, PasswordHashPort,
|
||||
WidgetStateReader,
|
||||
};
|
||||
|
||||
pub fn api_routes<C, E, W, B, R>() -> Router<AppState<C, E, W, B, R>>
|
||||
pub fn api_routes<C, E, W, B, R, A, H>() -> Router<AppState<C, E, W, B, R, A, H>>
|
||||
where
|
||||
C: ConfigRepository + Send + Sync + 'static,
|
||||
C::Error: std::fmt::Debug + Send,
|
||||
@@ -20,55 +24,72 @@ where
|
||||
B: BroadcastPort + Send + Sync + 'static,
|
||||
B::Error: std::fmt::Debug + Send,
|
||||
R: ClientRegistry + Send + Sync + 'static,
|
||||
A: AuthPort + Send + Sync + 'static,
|
||||
H: PasswordHashPort + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
// Public auth routes
|
||||
.route(
|
||||
"/auth/status",
|
||||
get(auth::auth_status::<C, E, W, B, R, A, H>),
|
||||
)
|
||||
.route("/auth/login", post(auth::login::<C, E, W, B, R, A, H>))
|
||||
.route(
|
||||
"/auth/register",
|
||||
post(auth::register::<C, E, W, B, R, A, H>),
|
||||
)
|
||||
// Protected routes
|
||||
.route(
|
||||
"/widgets",
|
||||
get(widgets::list_widgets::<C, E, W, B, R>)
|
||||
.post(widgets::create_widget::<C, E, W, B, R>),
|
||||
get(widgets::list_widgets::<C, E, W, B, R, A, H>)
|
||||
.post(widgets::create_widget::<C, E, W, B, R, A, H>),
|
||||
)
|
||||
.route(
|
||||
"/widgets/{id}",
|
||||
get(widgets::get_widget::<C, E, W, B, R>)
|
||||
.put(widgets::update_widget::<C, E, W, B, R>)
|
||||
.delete(widgets::delete_widget::<C, E, W, B, R>),
|
||||
get(widgets::get_widget::<C, E, W, B, R, A, H>)
|
||||
.put(widgets::update_widget::<C, E, W, B, R, A, H>)
|
||||
.delete(widgets::delete_widget::<C, E, W, B, R, A, H>),
|
||||
)
|
||||
.route(
|
||||
"/widgets/{id}/preview",
|
||||
get(widgets::preview_widget::<C, E, W, B, R>),
|
||||
get(widgets::preview_widget::<C, E, W, B, R, A, H>),
|
||||
)
|
||||
.route(
|
||||
"/data-sources",
|
||||
get(data_sources::list_data_sources::<C, E, W, B, R>)
|
||||
.post(data_sources::create_data_source::<C, E, W, B, R>),
|
||||
get(data_sources::list_data_sources::<C, E, W, B, R, A, H>)
|
||||
.post(data_sources::create_data_source::<C, E, W, B, R, A, H>),
|
||||
)
|
||||
.route(
|
||||
"/data-sources/{id}",
|
||||
get(data_sources::get_data_source::<C, E, W, B, R>)
|
||||
.put(data_sources::update_data_source::<C, E, W, B, R>)
|
||||
.delete(data_sources::delete_data_source::<C, E, W, B, R>),
|
||||
get(data_sources::get_data_source::<C, E, W, B, R, A, H>)
|
||||
.put(data_sources::update_data_source::<C, E, W, B, R, A, H>)
|
||||
.delete(data_sources::delete_data_source::<C, E, W, B, R, A, H>),
|
||||
)
|
||||
.route(
|
||||
"/layout",
|
||||
get(layout::get_layout::<C, E, W, B, R>).put(layout::update_layout::<C, E, W, B, R>),
|
||||
get(layout::get_layout::<C, E, W, B, R, A, H>)
|
||||
.put(layout::update_layout::<C, E, W, B, R, A, H>),
|
||||
)
|
||||
.route(
|
||||
"/presets",
|
||||
get(presets::list_presets::<C, E, W, B, R>)
|
||||
.post(presets::create_preset::<C, E, W, B, R>),
|
||||
get(presets::list_presets::<C, E, W, B, R, A, H>)
|
||||
.post(presets::create_preset::<C, E, W, B, R, A, H>),
|
||||
)
|
||||
.route(
|
||||
"/presets/{id}",
|
||||
get(presets::get_preset::<C, E, W, B, R>)
|
||||
.delete(presets::delete_preset::<C, E, W, B, R>),
|
||||
get(presets::get_preset::<C, E, W, B, R, A, H>)
|
||||
.delete(presets::delete_preset::<C, E, W, B, R, A, H>),
|
||||
)
|
||||
.route(
|
||||
"/presets/{id}/load",
|
||||
post(presets::load_preset::<C, E, W, B, R>),
|
||||
post(presets::load_preset::<C, E, W, B, R, A, H>),
|
||||
)
|
||||
.route(
|
||||
"/clients",
|
||||
get(clients::list_clients::<C, E, W, B, R, A, H>),
|
||||
)
|
||||
.route("/clients", get(clients::list_clients::<C, E, W, B, R>))
|
||||
.route(
|
||||
"/webhook/{source_id}",
|
||||
post(webhook::receive_webhook::<C, E, W, B, R>),
|
||||
post(webhook::receive_webhook::<C, E, W, B, R, A, H>),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::AppState;
|
||||
use crate::extractors::AuthUser;
|
||||
use api_types::{CreatePresetDto, PresetDto};
|
||||
use application::ConfigService;
|
||||
use axum::{
|
||||
@@ -8,10 +9,11 @@ use axum::{
|
||||
};
|
||||
use domain::{ConfigRepository, EventPublisher};
|
||||
|
||||
type S<C, E, W, B, R> = State<AppState<C, E, W, B, R>>;
|
||||
type S<C, E, W, B, R, A, H> = State<AppState<C, E, W, B, R, A, H>>;
|
||||
|
||||
pub async fn list_presets<C, E, W, B, R>(
|
||||
State(state): S<C, E, W, B, R>,
|
||||
pub async fn list_presets<C, E, W, B, R, A, H>(
|
||||
_auth: AuthUser,
|
||||
State(state): S<C, E, W, B, R, A, H>,
|
||||
) -> Result<Json<Vec<PresetDto>>, StatusCode>
|
||||
where
|
||||
C: ConfigRepository,
|
||||
@@ -27,8 +29,9 @@ where
|
||||
Ok(Json(presets.iter().map(PresetDto::from).collect()))
|
||||
}
|
||||
|
||||
pub async fn get_preset<C, E, W, B, R>(
|
||||
State(state): S<C, E, W, B, R>,
|
||||
pub async fn get_preset<C, E, W, B, R, A, H>(
|
||||
_auth: AuthUser,
|
||||
State(state): S<C, E, W, B, R, A, H>,
|
||||
Path(id): Path<u16>,
|
||||
) -> Result<Json<PresetDto>, StatusCode>
|
||||
where
|
||||
@@ -48,8 +51,9 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_preset<C, E, W, B, R>(
|
||||
State(state): S<C, E, W, B, R>,
|
||||
pub async fn create_preset<C, E, W, B, R, A, H>(
|
||||
_auth: AuthUser,
|
||||
State(state): S<C, E, W, B, R, A, H>,
|
||||
Json(body): Json<CreatePresetDto>,
|
||||
) -> Result<StatusCode, (StatusCode, String)>
|
||||
where
|
||||
@@ -68,8 +72,9 @@ where
|
||||
Ok(StatusCode::CREATED)
|
||||
}
|
||||
|
||||
pub async fn delete_preset<C, E, W, B, R>(
|
||||
State(state): S<C, E, W, B, R>,
|
||||
pub async fn delete_preset<C, E, W, B, R, A, H>(
|
||||
_auth: AuthUser,
|
||||
State(state): S<C, E, W, B, R, A, H>,
|
||||
Path(id): Path<u16>,
|
||||
) -> Result<StatusCode, StatusCode>
|
||||
where
|
||||
@@ -85,8 +90,9 @@ where
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn load_preset<C, E, W, B, R>(
|
||||
State(state): S<C, E, W, B, R>,
|
||||
pub async fn load_preset<C, E, W, B, R, A, H>(
|
||||
_auth: AuthUser,
|
||||
State(state): S<C, E, W, B, R, A, H>,
|
||||
Path(id): Path<u16>,
|
||||
) -> Result<StatusCode, (StatusCode, String)>
|
||||
where
|
||||
|
||||
@@ -4,10 +4,10 @@ use axum::http::StatusCode;
|
||||
use axum::response::Json;
|
||||
use domain::{BroadcastPort, ConfigRepository, EventPublisher, WidgetStateReader};
|
||||
|
||||
type S<C, E, W, B, R> = State<AppState<C, E, W, B, R>>;
|
||||
type S<C, E, W, B, R, A, H> = State<AppState<C, E, W, B, R, A, H>>;
|
||||
|
||||
pub async fn receive_webhook<C, E, W, B, R>(
|
||||
State(state): S<C, E, W, B, R>,
|
||||
pub async fn receive_webhook<C, E, W, B, R, A, H>(
|
||||
State(state): S<C, E, W, B, R, A, H>,
|
||||
Path(source_id): Path<u16>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> Result<StatusCode, (StatusCode, String)>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::AppState;
|
||||
use crate::extractors::AuthUser;
|
||||
use api_types::{CreateWidgetDto, WidgetDto};
|
||||
use application::ConfigService;
|
||||
use axum::{
|
||||
@@ -8,10 +9,11 @@ use axum::{
|
||||
};
|
||||
use domain::{ConfigRepository, EventPublisher, WidgetStateReader};
|
||||
|
||||
type S<C, E, W, B, R> = State<AppState<C, E, W, B, R>>;
|
||||
type S<C, E, W, B, R, A, H> = State<AppState<C, E, W, B, R, A, H>>;
|
||||
|
||||
pub async fn list_widgets<C, E, W, B, R>(
|
||||
State(state): S<C, E, W, B, R>,
|
||||
pub async fn list_widgets<C, E, W, B, R, A, H>(
|
||||
_auth: AuthUser,
|
||||
State(state): S<C, E, W, B, R, A, H>,
|
||||
) -> Result<Json<Vec<WidgetDto>>, StatusCode>
|
||||
where
|
||||
C: ConfigRepository,
|
||||
@@ -27,8 +29,9 @@ where
|
||||
Ok(Json(widgets.iter().map(WidgetDto::from).collect()))
|
||||
}
|
||||
|
||||
pub async fn get_widget<C, E, W, B, R>(
|
||||
State(state): S<C, E, W, B, R>,
|
||||
pub async fn get_widget<C, E, W, B, R, A, H>(
|
||||
_auth: AuthUser,
|
||||
State(state): S<C, E, W, B, R, A, H>,
|
||||
Path(id): Path<u16>,
|
||||
) -> Result<Json<WidgetDto>, StatusCode>
|
||||
where
|
||||
@@ -48,8 +51,9 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_widget<C, E, W, B, R>(
|
||||
State(state): S<C, E, W, B, R>,
|
||||
pub async fn create_widget<C, E, W, B, R, A, H>(
|
||||
_auth: AuthUser,
|
||||
State(state): S<C, E, W, B, R, A, H>,
|
||||
Json(body): Json<CreateWidgetDto>,
|
||||
) -> Result<StatusCode, (StatusCode, String)>
|
||||
where
|
||||
@@ -68,8 +72,9 @@ where
|
||||
Ok(StatusCode::CREATED)
|
||||
}
|
||||
|
||||
pub async fn update_widget<C, E, W, B, R>(
|
||||
State(state): S<C, E, W, B, R>,
|
||||
pub async fn update_widget<C, E, W, B, R, A, H>(
|
||||
_auth: AuthUser,
|
||||
State(state): S<C, E, W, B, R, A, H>,
|
||||
Path(_id): Path<u16>,
|
||||
Json(body): Json<CreateWidgetDto>,
|
||||
) -> Result<StatusCode, (StatusCode, String)>
|
||||
@@ -89,8 +94,9 @@ where
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
pub async fn delete_widget<C, E, W, B, R>(
|
||||
State(state): S<C, E, W, B, R>,
|
||||
pub async fn delete_widget<C, E, W, B, R, A, H>(
|
||||
_auth: AuthUser,
|
||||
State(state): S<C, E, W, B, R, A, H>,
|
||||
Path(id): Path<u16>,
|
||||
) -> Result<StatusCode, StatusCode>
|
||||
where
|
||||
@@ -106,8 +112,9 @@ where
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn preview_widget<C, E, W, B, R>(
|
||||
State(state): S<C, E, W, B, R>,
|
||||
pub async fn preview_widget<C, E, W, B, R, A, H>(
|
||||
_auth: AuthUser,
|
||||
State(state): S<C, E, W, B, R, A, H>,
|
||||
Path(id): Path<u16>,
|
||||
) -> Result<Json<serde_json::Value>, StatusCode>
|
||||
where
|
||||
|
||||
@@ -2,11 +2,32 @@ use application::DataProjection;
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use config_memory::MemoryConfigStore;
|
||||
use domain::{AuthPort, PasswordHashPort, UserId};
|
||||
use http_api::{AppState, router};
|
||||
use std::sync::Arc;
|
||||
use tcp_server::{ClientTracker, TcpBroadcaster, TcpEventBus};
|
||||
use tower::ServiceExt;
|
||||
|
||||
struct TestAuth;
|
||||
impl AuthPort for TestAuth {
|
||||
fn generate_token(&self, _user_id: UserId) -> String {
|
||||
"test-token".into()
|
||||
}
|
||||
fn validate_token(&self, token: &str) -> Option<UserId> {
|
||||
if token == "test-token" { Some(1) } else { None }
|
||||
}
|
||||
}
|
||||
|
||||
struct TestHasher;
|
||||
impl PasswordHashPort for TestHasher {
|
||||
async fn hash(&self, _plain: &str) -> Result<String, String> {
|
||||
Ok("hashed".into())
|
||||
}
|
||||
async fn verify(&self, _plain: &str, _hash: &str) -> Result<bool, String> {
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
fn test_app() -> axum::Router {
|
||||
let state = AppState {
|
||||
config: Arc::new(MemoryConfigStore::new()),
|
||||
@@ -14,13 +35,29 @@ fn test_app() -> axum::Router {
|
||||
widget_states: Arc::new(DataProjection::new()),
|
||||
broadcaster: Arc::new(TcpBroadcaster::new(16)),
|
||||
clients: Arc::new(ClientTracker::new()),
|
||||
auth: Arc::new(TestAuth),
|
||||
hasher: Arc::new(TestHasher),
|
||||
spa_dir: None,
|
||||
};
|
||||
router(state)
|
||||
}
|
||||
|
||||
fn authed_json_request(method: &str, uri: &str, body: Option<&str>) -> Request<Body> {
|
||||
let builder = Request::builder()
|
||||
.method(method)
|
||||
.uri(uri)
|
||||
.header("content-type", "application/json")
|
||||
.header("authorization", "Bearer test-token");
|
||||
|
||||
if let Some(b) = body {
|
||||
builder.body(Body::from(b.to_string())).unwrap()
|
||||
} else {
|
||||
builder.body(Body::empty()).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
fn json_request(method: &str, uri: &str, body: Option<&str>) -> Request<Body> {
|
||||
let mut builder = Request::builder()
|
||||
let builder = Request::builder()
|
||||
.method(method)
|
||||
.uri(uri)
|
||||
.header("content-type", "application/json");
|
||||
@@ -32,6 +69,16 @@ fn json_request(method: &str, uri: &str, body: Option<&str>) -> Request<Body> {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unauthenticated_request_returns_401() {
|
||||
let app = test_app();
|
||||
let resp = app
|
||||
.oneshot(json_request("GET", "/api/widgets", None))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_and_get_widget() {
|
||||
let app = test_app();
|
||||
@@ -46,13 +93,13 @@ async fn create_and_get_widget() {
|
||||
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_request("POST", "/api/widgets", Some(body)))
|
||||
.oneshot(authed_json_request("POST", "/api/widgets", Some(body)))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
|
||||
let resp = app
|
||||
.oneshot(json_request("GET", "/api/widgets/1", None))
|
||||
.oneshot(authed_json_request("GET", "/api/widgets/1", None))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
@@ -62,8 +109,6 @@ async fn create_and_get_widget() {
|
||||
.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(json["name"], "weather");
|
||||
assert_eq!(json["display_hint"], "icon_value");
|
||||
assert_eq!(json["data_source_id"], 10);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -74,16 +119,16 @@ async fn list_widgets() {
|
||||
let w2 = r#"{"id":2,"name":"b","display_hint":"key_value","data_source_id":2,"mappings":[]}"#;
|
||||
|
||||
app.clone()
|
||||
.oneshot(json_request("POST", "/api/widgets", Some(w1)))
|
||||
.oneshot(authed_json_request("POST", "/api/widgets", Some(w1)))
|
||||
.await
|
||||
.unwrap();
|
||||
app.clone()
|
||||
.oneshot(json_request("POST", "/api/widgets", Some(w2)))
|
||||
.oneshot(authed_json_request("POST", "/api/widgets", Some(w2)))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let resp = app
|
||||
.oneshot(json_request("GET", "/api/widgets", None))
|
||||
.oneshot(authed_json_request("GET", "/api/widgets", None))
|
||||
.await
|
||||
.unwrap();
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
@@ -100,19 +145,19 @@ async fn delete_widget() {
|
||||
let body =
|
||||
r#"{"id":1,"name":"a","display_hint":"icon_value","data_source_id":1,"mappings":[]}"#;
|
||||
app.clone()
|
||||
.oneshot(json_request("POST", "/api/widgets", Some(body)))
|
||||
.oneshot(authed_json_request("POST", "/api/widgets", Some(body)))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_request("DELETE", "/api/widgets/1", None))
|
||||
.oneshot(authed_json_request("DELETE", "/api/widgets/1", None))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
let resp = app
|
||||
.oneshot(json_request("GET", "/api/widgets/1", None))
|
||||
.oneshot(authed_json_request("GET", "/api/widgets/1", None))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
@@ -134,23 +179,16 @@ async fn create_and_get_data_source() {
|
||||
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_request("POST", "/api/data-sources", Some(body)))
|
||||
.oneshot(authed_json_request("POST", "/api/data-sources", Some(body)))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
|
||||
let resp = app
|
||||
.oneshot(json_request("GET", "/api/data-sources/10", None))
|
||||
.oneshot(authed_json_request("GET", "/api/data-sources/10", None))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(json["name"], "weather_api");
|
||||
assert_eq!(json["poll_interval_secs"], 300);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -172,24 +210,16 @@ async fn update_and_get_layout() {
|
||||
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_request("PUT", "/api/layout", Some(body)))
|
||||
.oneshot(authed_json_request("PUT", "/api/layout", Some(body)))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let resp = app
|
||||
.oneshot(json_request("GET", "/api/layout", None))
|
||||
.oneshot(authed_json_request("GET", "/api/layout", None))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(json["root"]["type"], "container");
|
||||
assert_eq!(json["root"]["direction"], "row");
|
||||
assert_eq!(json["root"]["children"].as_array().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -198,14 +228,23 @@ async fn get_nonexistent_returns_404() {
|
||||
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(json_request("GET", "/api/widgets/99", None))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
|
||||
let resp = app
|
||||
.oneshot(json_request("GET", "/api/data-sources/99", None))
|
||||
.oneshot(authed_json_request("GET", "/api/widgets/99", None))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_status_returns_needs_setup() {
|
||||
let app = test_app();
|
||||
let resp = app
|
||||
.oneshot(json_request("GET", "/api/auth/status", None))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(json["needs_setup"], true);
|
||||
}
|
||||
|
||||
11
crates/adapters/secret-store/Cargo.toml
Normal file
11
crates/adapters/secret-store/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "secret-store"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
domain.workspace = true
|
||||
aes-gcm = "0.10"
|
||||
base64 = "0.22"
|
||||
hex = "0.4"
|
||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||
56
crates/adapters/secret-store/src/lib.rs
Normal file
56
crates/adapters/secret-store/src/lib.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
use aes_gcm::{Aes256Gcm, Key, KeyInit, Nonce, aead::Aead};
|
||||
use base64::{Engine, engine::general_purpose::STANDARD as B64};
|
||||
use domain::SecretStore;
|
||||
use rand_core::{OsRng, RngCore};
|
||||
|
||||
pub struct AesSecretStore {
|
||||
key: Key<Aes256Gcm>,
|
||||
}
|
||||
|
||||
impl AesSecretStore {
|
||||
pub fn from_env() -> Result<Self, String> {
|
||||
let hex_key = std::env::var("KFRAME_ENCRYPTION_KEY")
|
||||
.map_err(|_| "KFRAME_ENCRYPTION_KEY env var is required".to_string())?;
|
||||
let bytes = hex::decode(&hex_key)
|
||||
.map_err(|e| format!("KFRAME_ENCRYPTION_KEY must be 64 hex chars: {e}"))?;
|
||||
if bytes.len() != 32 {
|
||||
return Err(format!(
|
||||
"KFRAME_ENCRYPTION_KEY must be 32 bytes (64 hex chars), got {}",
|
||||
bytes.len()
|
||||
));
|
||||
}
|
||||
let key = Key::<Aes256Gcm>::from_slice(&bytes);
|
||||
Ok(Self { key: *key })
|
||||
}
|
||||
}
|
||||
|
||||
impl SecretStore for AesSecretStore {
|
||||
fn encrypt(&self, plaintext: &str) -> String {
|
||||
let cipher = Aes256Gcm::new(&self.key);
|
||||
let mut nonce_bytes = [0u8; 12];
|
||||
OsRng.fill_bytes(&mut nonce_bytes);
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
let ciphertext = cipher
|
||||
.encrypt(nonce, plaintext.as_bytes())
|
||||
.expect("AES-GCM encryption should not fail");
|
||||
let mut combined = nonce_bytes.to_vec();
|
||||
combined.extend(ciphertext);
|
||||
B64.encode(combined)
|
||||
}
|
||||
|
||||
fn decrypt(&self, ciphertext: &str) -> String {
|
||||
let combined = B64
|
||||
.decode(ciphertext)
|
||||
.expect("invalid base64 in encrypted field");
|
||||
if combined.len() < 12 {
|
||||
panic!("encrypted data too short");
|
||||
}
|
||||
let (nonce_bytes, ct) = combined.split_at(12);
|
||||
let cipher = Aes256Gcm::new(&self.key);
|
||||
let nonce = Nonce::from_slice(nonce_bytes);
|
||||
let plaintext = cipher
|
||||
.decrypt(nonce, ct)
|
||||
.expect("AES-GCM decryption failed — wrong key or corrupted data");
|
||||
String::from_utf8(plaintext).expect("decrypted data is not valid UTF-8")
|
||||
}
|
||||
}
|
||||
79
crates/application/src/auth_service.rs
Normal file
79
crates/application/src/auth_service.rs
Normal file
@@ -0,0 +1,79 @@
|
||||
use domain::{AuthPort, ConfigRepository, PasswordHashPort, User};
|
||||
|
||||
pub enum AuthError<E> {
|
||||
InvalidCredentials,
|
||||
RegistrationClosed,
|
||||
Repository(E),
|
||||
Hash(String),
|
||||
}
|
||||
|
||||
impl<E: std::fmt::Debug> std::fmt::Display for AuthError<E> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::InvalidCredentials => write!(f, "invalid credentials"),
|
||||
Self::RegistrationClosed => write!(f, "registration closed (users already exist)"),
|
||||
Self::Repository(e) => write!(f, "repository error: {e:?}"),
|
||||
Self::Hash(e) => write!(f, "hash error: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn login<C, A, H>(
|
||||
config: &C,
|
||||
auth: &A,
|
||||
hasher: &H,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<String, AuthError<C::Error>>
|
||||
where
|
||||
C: ConfigRepository,
|
||||
A: AuthPort,
|
||||
H: PasswordHashPort,
|
||||
{
|
||||
let user = config
|
||||
.get_user_by_username(username)
|
||||
.await
|
||||
.map_err(AuthError::Repository)?
|
||||
.ok_or(AuthError::InvalidCredentials)?;
|
||||
|
||||
let valid = hasher
|
||||
.verify(password, &user.password_hash)
|
||||
.await
|
||||
.map_err(AuthError::Hash)?;
|
||||
|
||||
if !valid {
|
||||
return Err(AuthError::InvalidCredentials);
|
||||
}
|
||||
|
||||
Ok(auth.generate_token(user.id))
|
||||
}
|
||||
|
||||
pub async fn register<C, H>(
|
||||
config: &C,
|
||||
hasher: &H,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<(), AuthError<C::Error>>
|
||||
where
|
||||
C: ConfigRepository,
|
||||
H: PasswordHashPort,
|
||||
{
|
||||
let count = config.count_users().await.map_err(AuthError::Repository)?;
|
||||
if count > 0 {
|
||||
return Err(AuthError::RegistrationClosed);
|
||||
}
|
||||
|
||||
let hash = hasher.hash(password).await.map_err(AuthError::Hash)?;
|
||||
|
||||
let user = User {
|
||||
id: 0,
|
||||
username: username.to_string(),
|
||||
password_hash: hash,
|
||||
};
|
||||
|
||||
config
|
||||
.save_user(&user)
|
||||
.await
|
||||
.map_err(AuthError::Repository)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod auth_service;
|
||||
mod config_service;
|
||||
mod data_projection;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use domain::{
|
||||
ConfigRepository, DataSource, DataSourceId, DomainEvent, EventPublisher, Layout, LayoutPreset,
|
||||
LayoutPresetId, WidgetConfig, WidgetId,
|
||||
LayoutPresetId, User, WidgetConfig, WidgetId,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
@@ -112,6 +112,18 @@ impl ConfigRepository for InMemoryConfigRepository {
|
||||
self.presets.lock().unwrap().remove(&id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_user_by_username(&self, _username: &str) -> Result<Option<User>, Self::Error> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn save_user(&self, _user: &User) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn count_users(&self) -> Result<u32, Self::Error> {
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct InMemoryEventPublisher {
|
||||
|
||||
@@ -12,6 +12,8 @@ http-api.workspace = true
|
||||
http-json.workspace = true
|
||||
media-adapter.workspace = true
|
||||
rss-adapter.workspace = true
|
||||
kframe-auth.workspace = true
|
||||
secret-store.workspace = true
|
||||
tokio.workspace = true
|
||||
anyhow.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
@@ -6,6 +6,8 @@ use anyhow::Result;
|
||||
use application::DataProjection;
|
||||
use config_sqlite::SqliteConfigStore;
|
||||
use http_api::AppState;
|
||||
use kframe_auth::{Argon2Hasher, AuthConfig, JwtAuthService};
|
||||
use secret_store::AesSecretStore;
|
||||
use std::sync::Arc;
|
||||
use tcp_server::{ClientTracker, TcpBroadcaster, TcpEventBus, run_tcp_server};
|
||||
use tracing::{error, info};
|
||||
@@ -23,13 +25,20 @@ async fn main() -> Result<()> {
|
||||
|
||||
let cfg = config::ServerConfig::from_env();
|
||||
|
||||
let auth_config = AuthConfig::from_env().map_err(|e| anyhow::anyhow!(e))?;
|
||||
let secrets = AesSecretStore::from_env().map_err(|e| anyhow::anyhow!(e))?;
|
||||
|
||||
info!(db = %cfg.database_url, "connecting to database");
|
||||
let config_store = Arc::new(SqliteConfigStore::new(&cfg.database_url).await?);
|
||||
let secrets = Arc::new(secrets);
|
||||
let config_store =
|
||||
Arc::new(SqliteConfigStore::with_secrets(&cfg.database_url, Some(secrets.clone())).await?);
|
||||
|
||||
let event_bus = Arc::new(TcpEventBus::new(64));
|
||||
let broadcaster = Arc::new(TcpBroadcaster::new(64));
|
||||
let projection = Arc::new(DataProjection::new());
|
||||
let tracker = Arc::new(ClientTracker::new());
|
||||
let auth = Arc::new(JwtAuthService::new(auth_config));
|
||||
let hasher = Arc::new(Argon2Hasher);
|
||||
|
||||
let tcp_addr = cfg.tcp_addr.clone();
|
||||
let tcp_bc = broadcaster.clone();
|
||||
@@ -50,6 +59,8 @@ async fn main() -> Result<()> {
|
||||
widget_states: projection.clone(),
|
||||
broadcaster: broadcaster.clone(),
|
||||
clients: tracker.clone(),
|
||||
auth: auth.clone(),
|
||||
hasher: hasher.clone(),
|
||||
spa_dir: cfg.spa_dir,
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
mod data_source;
|
||||
mod layout_preset;
|
||||
mod user;
|
||||
mod widget_config;
|
||||
|
||||
pub use data_source::{
|
||||
DataSource, DataSourceConfig, DataSourceId, DataSourceType, DataSourceValidationError,
|
||||
};
|
||||
pub use layout_preset::{LayoutPreset, LayoutPresetId};
|
||||
pub use user::{User, UserId};
|
||||
pub use widget_config::{WidgetConfig, WidgetId};
|
||||
|
||||
8
crates/domain/src/entities/user.rs
Normal file
8
crates/domain/src/entities/user.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
pub type UserId = u32;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct User {
|
||||
pub id: UserId,
|
||||
pub username: String,
|
||||
pub password_hash: String,
|
||||
}
|
||||
@@ -7,12 +7,12 @@ pub mod value_objects;
|
||||
|
||||
pub use entities::{
|
||||
DataSource, DataSourceConfig, DataSourceId, DataSourceType, DataSourceValidationError,
|
||||
LayoutPreset, LayoutPresetId, WidgetConfig, WidgetId,
|
||||
LayoutPreset, LayoutPresetId, User, UserId, WidgetConfig, WidgetId,
|
||||
};
|
||||
pub use events::DomainEvent;
|
||||
pub use ports::{
|
||||
BroadcastPort, ClientRegistry, ConfigRepository, ConnectedClient, DataSourcePort,
|
||||
EventPublisher, WidgetStateReader,
|
||||
AuthPort, BroadcastPort, ClientRegistry, ConfigRepository, ConnectedClient, DataSourcePort,
|
||||
EventPublisher, PasswordHashPort, SecretStore, WidgetStateReader,
|
||||
};
|
||||
pub use value_objects::{
|
||||
ContainerNode, Direction, DisplayHint, KeyMapping, Layout, LayoutChild, LayoutNode,
|
||||
|
||||
12
crates/domain/src/ports/auth.rs
Normal file
12
crates/domain/src/ports/auth.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
use crate::entities::UserId;
|
||||
use std::future::Future;
|
||||
|
||||
pub trait AuthPort {
|
||||
fn generate_token(&self, user_id: UserId) -> String;
|
||||
fn validate_token(&self, token: &str) -> Option<UserId>;
|
||||
}
|
||||
|
||||
pub trait PasswordHashPort {
|
||||
fn hash(&self, plain: &str) -> impl Future<Output = Result<String, String>> + Send;
|
||||
fn verify(&self, plain: &str, hash: &str) -> impl Future<Output = Result<bool, String>> + Send;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::entities::{
|
||||
DataSource, DataSourceId, LayoutPreset, LayoutPresetId, WidgetConfig, WidgetId,
|
||||
DataSource, DataSourceId, LayoutPreset, LayoutPresetId, User, WidgetConfig, WidgetId,
|
||||
};
|
||||
use crate::value_objects::Layout;
|
||||
use std::future::Future;
|
||||
@@ -50,4 +50,11 @@ pub trait ConfigRepository {
|
||||
&self,
|
||||
id: LayoutPresetId,
|
||||
) -> impl Future<Output = Result<(), Self::Error>> + Send;
|
||||
|
||||
fn get_user_by_username(
|
||||
&self,
|
||||
username: &str,
|
||||
) -> impl Future<Output = Result<Option<User>, Self::Error>> + Send;
|
||||
fn save_user(&self, user: &User) -> impl Future<Output = Result<(), Self::Error>> + Send;
|
||||
fn count_users(&self) -> impl Future<Output = Result<u32, Self::Error>> + Send;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
mod auth;
|
||||
mod broadcast;
|
||||
mod client_registry;
|
||||
mod config_repository;
|
||||
mod data_source_port;
|
||||
mod event;
|
||||
mod secret_store;
|
||||
mod widget_state_reader;
|
||||
|
||||
pub use auth::{AuthPort, PasswordHashPort};
|
||||
pub use broadcast::BroadcastPort;
|
||||
pub use client_registry::{ClientRegistry, ConnectedClient};
|
||||
pub use config_repository::ConfigRepository;
|
||||
pub use data_source_port::DataSourcePort;
|
||||
pub use event::EventPublisher;
|
||||
pub use secret_store::SecretStore;
|
||||
pub use widget_state_reader::WidgetStateReader;
|
||||
|
||||
4
crates/domain/src/ports/secret_store.rs
Normal file
4
crates/domain/src/ports/secret_store.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub trait SecretStore {
|
||||
fn encrypt(&self, plaintext: &str) -> String;
|
||||
fn decrypt(&self, ciphertext: &str) -> String;
|
||||
}
|
||||
59
spa/src/api/auth.ts
Normal file
59
spa/src/api/auth.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
|
||||
const BASE = "/api"
|
||||
|
||||
export function useAuthStatus() {
|
||||
return useQuery({
|
||||
queryKey: ["auth-status"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`${BASE}/auth/status`)
|
||||
return res.json() as Promise<{ needs_setup: boolean }>
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useLogin() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (creds: { username: string; password: string }) => {
|
||||
const res = await fetch(`${BASE}/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(creds),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => res.statusText)
|
||||
throw new Error(text)
|
||||
}
|
||||
return res.json() as Promise<{ token: string }>
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
localStorage.setItem("kframe_token", data.token)
|
||||
qc.invalidateQueries()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useRegister() {
|
||||
return useMutation({
|
||||
mutationFn: async (creds: { username: string; password: string }) => {
|
||||
const res = await fetch(`${BASE}/auth/register`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(creds),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => res.statusText)
|
||||
throw new Error(text)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function getToken(): string | null {
|
||||
return localStorage.getItem("kframe_token")
|
||||
}
|
||||
|
||||
export function clearToken() {
|
||||
localStorage.removeItem("kframe_token")
|
||||
}
|
||||
@@ -1,13 +1,25 @@
|
||||
import { getToken, clearToken } from "./auth"
|
||||
|
||||
const BASE = "/api"
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...init?.headers,
|
||||
},
|
||||
})
|
||||
const token = getToken()
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...(init?.headers as Record<string, string>),
|
||||
}
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
const res = await fetch(`${BASE}${path}`, { ...init, headers })
|
||||
|
||||
if (res.status === 401) {
|
||||
clearToken()
|
||||
window.location.href = "/login"
|
||||
throw new Error("Unauthorized")
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => res.statusText)
|
||||
throw new Error(`${res.status}: ${text}`)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Link, useRouterState } from "@tanstack/react-router"
|
||||
import { Link, useNavigate, useRouterState } from "@tanstack/react-router"
|
||||
import {
|
||||
SidebarProvider,
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuItem,
|
||||
@@ -12,6 +13,8 @@ import {
|
||||
} from "@/components/ui/sidebar"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Toaster } from "@/components/ui/sonner"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { clearToken } from "@/api/auth"
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Database,
|
||||
@@ -19,6 +22,7 @@ import {
|
||||
Layers,
|
||||
Save,
|
||||
BookOpen,
|
||||
LogOut,
|
||||
} from "lucide-react"
|
||||
|
||||
const NAV = [
|
||||
@@ -32,6 +36,12 @@ const NAV = [
|
||||
|
||||
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
const { location } = useRouterState()
|
||||
const navigate = useNavigate()
|
||||
|
||||
function logout() {
|
||||
clearToken()
|
||||
navigate({ to: "/login" })
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
@@ -59,6 +69,12 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarContent>
|
||||
<SidebarFooter className="p-2">
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start" onClick={logout}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Sign Out
|
||||
</Button>
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
<SidebarInset>
|
||||
<header className="flex h-12 items-center gap-2 border-b px-4">
|
||||
|
||||
79
spa/src/pages/login.tsx
Normal file
79
spa/src/pages/login.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useState } from "react"
|
||||
import { useNavigate } from "@tanstack/react-router"
|
||||
import { useLogin, useRegister, useAuthStatus } from "@/api/auth"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate()
|
||||
const { data: status } = useAuthStatus()
|
||||
const login = useLogin()
|
||||
const register = useRegister()
|
||||
|
||||
const [username, setUsername] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
|
||||
const isSetup = status?.needs_setup ?? false
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
try {
|
||||
if (isSetup) {
|
||||
await register.mutateAsync({ username, password })
|
||||
toast.success("Account created")
|
||||
await login.mutateAsync({ username, password })
|
||||
} else {
|
||||
await login.mutateAsync({ username, password })
|
||||
}
|
||||
navigate({ to: "/" })
|
||||
} catch (err) {
|
||||
toast.error(String(err))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-svh items-center justify-center p-4">
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-center text-xl">
|
||||
{isSetup ? "K-Frame Setup" : "K-Frame Login"}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="grid gap-4">
|
||||
{isSetup && (
|
||||
<p className="text-muted-foreground text-center text-sm">
|
||||
Create your admin account to get started.
|
||||
</p>
|
||||
)}
|
||||
<div className="grid gap-2">
|
||||
<Label>Username</Label>
|
||||
<Input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Password</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!username || !password || login.isPending || register.isPending}
|
||||
>
|
||||
{isSetup ? "Create Account" : "Sign In"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
createRoute,
|
||||
createRouter,
|
||||
Outlet,
|
||||
redirect,
|
||||
} from "@tanstack/react-router"
|
||||
import { AppShell } from "@/components/app-shell"
|
||||
import { DashboardPage } from "@/pages/dashboard"
|
||||
@@ -11,8 +12,35 @@ import { WidgetsPage } from "@/pages/widgets"
|
||||
import { LayoutBuilderPage } from "@/pages/layout-builder"
|
||||
import { PresetsPage } from "@/pages/presets"
|
||||
import { GuidePage } from "@/pages/guide"
|
||||
import { LoginPage } from "@/pages/login"
|
||||
import { getToken } from "@/api/auth"
|
||||
import { Toaster } from "@/components/ui/sonner"
|
||||
|
||||
function requireAuth() {
|
||||
if (!getToken()) {
|
||||
throw redirect({ to: "/login" })
|
||||
}
|
||||
}
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
component: () => <Outlet />,
|
||||
})
|
||||
|
||||
const loginRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/login",
|
||||
component: () => (
|
||||
<>
|
||||
<LoginPage />
|
||||
<Toaster />
|
||||
</>
|
||||
),
|
||||
})
|
||||
|
||||
const authenticatedRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
id: "authenticated",
|
||||
beforeLoad: requireAuth,
|
||||
component: () => (
|
||||
<AppShell>
|
||||
<Outlet />
|
||||
@@ -21,48 +49,51 @@ const rootRoute = createRootRoute({
|
||||
})
|
||||
|
||||
const indexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
getParentRoute: () => authenticatedRoute,
|
||||
path: "/",
|
||||
component: DashboardPage,
|
||||
})
|
||||
|
||||
const dataSourcesRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
getParentRoute: () => authenticatedRoute,
|
||||
path: "/data-sources",
|
||||
component: DataSourcesPage,
|
||||
})
|
||||
|
||||
const widgetsRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
getParentRoute: () => authenticatedRoute,
|
||||
path: "/widgets",
|
||||
component: WidgetsPage,
|
||||
})
|
||||
|
||||
const layoutRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
getParentRoute: () => authenticatedRoute,
|
||||
path: "/layout",
|
||||
component: LayoutBuilderPage,
|
||||
})
|
||||
|
||||
const presetsRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
getParentRoute: () => authenticatedRoute,
|
||||
path: "/presets",
|
||||
component: PresetsPage,
|
||||
})
|
||||
|
||||
const guideRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
getParentRoute: () => authenticatedRoute,
|
||||
path: "/guide",
|
||||
component: GuidePage,
|
||||
})
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
indexRoute,
|
||||
dataSourcesRoute,
|
||||
widgetsRoute,
|
||||
layoutRoute,
|
||||
presetsRoute,
|
||||
guideRoute,
|
||||
loginRoute,
|
||||
authenticatedRoute.addChildren([
|
||||
indexRoute,
|
||||
dataSourcesRoute,
|
||||
widgetsRoute,
|
||||
layoutRoute,
|
||||
presetsRoute,
|
||||
guideRoute,
|
||||
]),
|
||||
])
|
||||
|
||||
export const router = createRouter({ routeTree })
|
||||
|
||||
Reference in New Issue
Block a user