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:
2026-06-19 01:39:42 +02:00
parent 4139330234
commit adda731dc6
41 changed files with 1331 additions and 153 deletions

View File

@@ -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
View File

@@ -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"

View File

@@ -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" }

View 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"] }

View 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())
}
}

View File

@@ -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)
}
}

View File

@@ -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(())
}
}

View File

@@ -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)

View File

@@ -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
}
}

View 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)
}
}

View File

@@ -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)?,
})
}

View 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))
}
}

View File

@@ -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?;

View 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,
}))
}

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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>),
)
}

View File

@@ -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

View File

@@ -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)>

View File

@@ -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

View File

@@ -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);
}

View 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"] }

View 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")
}
}

View 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(())
}

View File

@@ -1,3 +1,4 @@
pub mod auth_service;
mod config_service;
mod data_projection;

View File

@@ -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 {

View File

@@ -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

View File

@@ -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 {

View File

@@ -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};

View 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,
}

View File

@@ -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,

View 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;
}

View File

@@ -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;
}

View File

@@ -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;

View 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
View 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")
}

View File

@@ -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}`)

View File

@@ -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
View 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>
)
}

View File

@@ -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 })