WP-01: authentication, user management and application shell
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / ui (push) Has been cancelled

Argon2id password hashing, JWT access tokens with rotating HttpOnly refresh
cookies and reuse detection, login rate limiting, auth audit log, bootstrap
admin, admin-only user CRUD and password reset, OpenAPI spec. Vue login page,
auth store with automatic token refresh, route guards, sidebar shell with
toasts and placeholder pages, user management page.

All tests green: 40 backend, 12 Vitest, 4 Playwright.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-09-02 21:37:14 +02:00
parent 83aa500f5d
commit f1136fdf3d
32 changed files with 1899 additions and 48 deletions

View File

@ -1,6 +1,6 @@
# SoftVisor Infrastructure Monitoring System – Roadmap
Status: Draft v1 (2026-09-02)
Status: v1.1 (2026-09-02) – Milestone 1 delivered
This document is derived from `CLAUDE.md`. It breaks the project into work packages (WPs),
fixes the technical decisions that are not dictated by `CLAUDE.md`, and lists the open
@ -352,9 +352,9 @@ The server is reachable via `ssh softvisor` (as root). Findings from the inspect
| WP | Milestone | Status | Notes |
|----|-----------|--------|-------|
| WP-00 | M1 | todo | |
| WP-01 | M1 | todo | |
| WP-02 | M1 (shell) / M2 (rest) | todo | |
| WP-00 | M1 | done | 2026-09-02 |
| WP-01 | M1 | done | 2026-09-02 |
| WP-02 | M1 (shell) / M2 (rest) | shell done | shell, toasts, placeholders 2026-09-02; secrets/scheduler/SMTP in M2 |
| WP-10 | M2 | todo | |
| WP-11 | M2 | todo | |
| WP-12 | M2 | todo | |

View File

@ -0,0 +1,173 @@
//! /api/auth: login, refresh, logout, me.
use axum::extract::State;
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use domain::user::User;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use crate::error::{simple, ApiError};
use crate::extract::{AuthUser, ClientIp};
use crate::AppState;
pub const REFRESH_COOKIE: &str = "refresh_token";
pub fn router() -> Router<AppState> {
Router::new()
.route("/login", post(login))
.route("/refresh", post(refresh))
.route("/logout", post(logout))
.route("/me", get(me))
}
#[derive(Serialize, ToSchema)]
pub struct UserDto {
pub id: uuid::Uuid,
pub email: String,
pub display_name: String,
#[schema(value_type = String, example = "admin")]
pub role: domain::user::Role,
pub is_active: bool,
pub created_at: chrono::DateTime<chrono::Utc>,
}
impl From<User> for UserDto {
fn from(u: User) -> Self {
Self {
id: u.id,
email: u.email,
display_name: u.display_name,
role: u.role,
is_active: u.is_active,
created_at: u.created_at,
}
}
}
#[derive(Deserialize, ToSchema)]
pub struct LoginRequest {
pub email: String,
pub password: String,
}
#[derive(Serialize, ToSchema)]
pub struct TokenResponse {
pub access_token: String,
pub user: UserDto,
}
#[utoipa::path(post, path = "/api/auth/login", request_body = LoginRequest, tag = "auth",
responses((status = 200, body = TokenResponse), (status = 401), (status = 403), (status = 429)))]
async fn login(
State(state): State<AppState>,
ClientIp(ip): ClientIp,
Json(req): Json<LoginRequest>,
) -> Response {
if !state
.login_limiter
.check(ip.as_deref().unwrap_or("unknown"))
{
return simple(
StatusCode::TOO_MANY_REQUESTS,
"rate_limited",
"too many login attempts, try again later",
);
}
match state.auth.login(&req.email, &req.password, ip).await {
Ok(pair) => token_response(&state, pair).await,
Err(e) => ApiError(e).into_response(),
}
}
#[utoipa::path(post, path = "/api/auth/refresh", tag = "auth",
responses((status = 200, body = TokenResponse), (status = 401)))]
async fn refresh(
State(state): State<AppState>,
ClientIp(ip): ClientIp,
headers: HeaderMap,
) -> Response {
let Some(token) = cookie(&headers, REFRESH_COOKIE) else {
return simple(
StatusCode::UNAUTHORIZED,
"invalid_token",
"missing refresh cookie",
);
};
match state.auth.refresh(&token, ip).await {
Ok(pair) => token_response(&state, pair).await,
Err(e) => ApiError(e).into_response(),
}
}
#[utoipa::path(post, path = "/api/auth/logout", tag = "auth", responses((status = 204)))]
async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Result<Response, ApiError> {
if let Some(token) = cookie(&headers, REFRESH_COOKIE) {
state.auth.logout(&token).await?;
}
Ok((
[(header::SET_COOKIE, clear_cookie(state.cfg.cookie_secure))],
StatusCode::NO_CONTENT,
)
.into_response())
}
#[utoipa::path(get, path = "/api/auth/me", tag = "auth", security(("bearer" = [])),
responses((status = 200, body = UserDto), (status = 401)))]
async fn me(AuthUser(user): AuthUser) -> Json<UserDto> {
Json(user.into())
}
async fn token_response(state: &AppState, pair: domain::auth::TokenPair) -> Response {
let claims = state.auth.authenticate(&pair.access_token).await;
match claims {
Ok(user) => (
[(
header::SET_COOKIE,
set_cookie(&pair.refresh_token, state.cfg.cookie_secure),
)],
Json(TokenResponse {
access_token: pair.access_token,
user: user.into(),
}),
)
.into_response(),
Err(e) => ApiError(e).into_response(),
}
}
fn cookie(headers: &HeaderMap, name: &str) -> Option<String> {
headers
.get_all(header::COOKIE)
.iter()
.filter_map(|v| v.to_str().ok())
.flat_map(|v| v.split(';'))
.filter_map(|kv| kv.trim().split_once('='))
.find(|(k, _)| *k == name)
.map(|(_, v)| v.to_string())
}
fn cookie_attrs(secure: bool) -> String {
format!(
"Path=/api/auth; HttpOnly; SameSite=Strict{}",
if secure { "; Secure" } else { "" }
)
}
fn set_cookie(token: &str, secure: bool) -> HeaderValue {
let max_age = application::auth_service::REFRESH_TOKEN_TTL_DAYS * 24 * 3600;
HeaderValue::from_str(&format!(
"{REFRESH_COOKIE}={token}; Max-Age={max_age}; {}",
cookie_attrs(secure)
))
.unwrap()
}
fn clear_cookie(secure: bool) -> HeaderValue {
HeaderValue::from_str(&format!(
"{REFRESH_COOKIE}=; Max-Age=0; {}",
cookie_attrs(secure)
))
.unwrap()
}

View File

@ -0,0 +1,51 @@
//! Maps domain errors to HTTP responses of the shape `{"error": code, "message": text}`.
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use domain::DomainError;
#[derive(Debug)]
pub struct ApiError(pub DomainError);
impl From<DomainError> for ApiError {
fn from(e: DomainError) -> Self {
ApiError(e)
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
use DomainError::*;
let (status, code) = match &self.0 {
NotFound => (StatusCode::NOT_FOUND, "not_found"),
EmailTaken => (StatusCode::CONFLICT, "email_taken"),
LastAdmin => (StatusCode::CONFLICT, "last_admin"),
InvalidCredentials => (StatusCode::UNAUTHORIZED, "invalid_credentials"),
InvalidToken => (StatusCode::UNAUTHORIZED, "invalid_token"),
InactiveUser => (StatusCode::FORBIDDEN, "inactive_user"),
Validation(_) => (StatusCode::UNPROCESSABLE_ENTITY, "validation"),
Storage(msg) => {
tracing::error!("storage error: {msg}");
(StatusCode::INTERNAL_SERVER_ERROR, "internal")
}
};
let message = if code == "internal" {
"internal error".to_string()
} else {
self.0.to_string()
};
(
status,
Json(serde_json::json!({ "error": code, "message": message })),
)
.into_response()
}
}
pub fn simple(status: StatusCode, code: &str, message: &str) -> Response {
(
status,
Json(serde_json::json!({ "error": code, "message": message })),
)
.into_response()
}

View File

@ -0,0 +1,97 @@
//! Request extractors: authenticated user, admin user, client IP.
use axum::extract::{ConnectInfo, FromRequestParts};
use axum::http::request::Parts;
use axum::http::{header, StatusCode};
use axum::response::Response;
use domain::user::User;
use std::net::SocketAddr;
use crate::error::simple;
use crate::AppState;
pub struct AuthUser(pub User);
pub struct AdminUser(pub User);
impl FromRequestParts<AppState> for AuthUser {
type Rejection = Response;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let token = parts
.headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or_else(|| {
simple(
StatusCode::UNAUTHORIZED,
"unauthorized",
"missing bearer token",
)
})?;
state
.auth
.authenticate(token)
.await
.map(AuthUser)
.map_err(|e| crate::error::ApiError(e).into_response_401())
}
}
impl FromRequestParts<AppState> for AdminUser {
type Rejection = Response;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let AuthUser(user) = AuthUser::from_request_parts(parts, state).await?;
if !user.is_admin() {
return Err(simple(
StatusCode::FORBIDDEN,
"forbidden",
"admin role required",
));
}
Ok(AdminUser(user))
}
}
impl crate::error::ApiError {
/// Auth failures on protected routes are always reported as 401 (inactive users included).
fn into_response_401(self) -> Response {
simple(
StatusCode::UNAUTHORIZED,
"unauthorized",
&self.0.to_string(),
)
}
}
/// Best-effort client IP: `X-Forwarded-For` first hop, else the socket address.
pub fn client_ip(parts: &Parts) -> Option<String> {
parts
.headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.split(',').next())
.map(|s| s.trim().to_string())
.or_else(|| {
parts
.extensions
.get::<ConnectInfo<SocketAddr>>()
.map(|c| c.0.ip().to_string())
})
}
pub struct ClientIp(pub Option<String>);
impl<S: Send + Sync> FromRequestParts<S> for ClientIp {
type Rejection = std::convert::Infallible;
async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> {
Ok(ClientIp(client_ip(parts)))
}
}

View File

@ -1,8 +1,20 @@
//! HTTP API layer (axum). `build_app` is used by both the binary and the integration tests.
pub mod auth;
pub mod config;
pub mod error;
pub mod extract;
pub mod openapi;
pub mod rate_limit;
pub mod test_support;
pub mod users;
use std::sync::Arc;
use application::{AuthService, UserService};
use axum::{routing::get, Json, Router};
use infrastructure::{
Argon2Hasher, DbPool, JwtIssuer, SqliteAuditLog, SqliteRefreshTokens, SqliteUsers,
};
use tower_http::services::{ServeDir, ServeFile};
use tower_http::trace::TraceLayer;
@ -11,6 +23,42 @@ pub use config::Config;
#[derive(Clone)]
pub struct AppState {
pub cfg: Config,
pub auth: Arc<AuthService>,
pub users: Arc<UserService>,
pub login_limiter: Arc<rate_limit::RateLimiter>,
}
impl AppState {
/// Wire the services on top of a connected database.
pub fn new(cfg: Config, pool: DbPool) -> Self {
let users = Arc::new(SqliteUsers(pool.clone()));
let hasher = Arc::new(Argon2Hasher);
let auth = AuthService::new(
users.clone(),
Arc::new(SqliteRefreshTokens(pool.clone())),
Arc::new(SqliteAuditLog(pool)),
hasher.clone(),
Arc::new(JwtIssuer::new(&cfg.jwt_secret)),
);
Self {
cfg,
auth: Arc::new(auth),
users: Arc::new(UserService::new(users, hasher)),
login_limiter: Arc::new(rate_limit::RateLimiter::new(
10,
std::time::Duration::from_secs(60),
)),
}
}
pub async fn bootstrap(&self) -> anyhow::Result<()> {
if let Some((email, password)) = &self.cfg.bootstrap_admin {
if self.users.bootstrap_admin(email, password).await? {
tracing::info!("created bootstrap admin {email}");
}
}
Ok(())
}
}
pub fn build_app(state: AppState) -> Router {
@ -18,6 +66,9 @@ pub fn build_app(state: AppState) -> Router {
let spa = ServeDir::new(&state.cfg.frontend_dir).not_found_service(ServeFile::new(index));
Router::new()
.route("/healthz", get(healthz))
.route("/api/openapi.json", get(openapi::spec))
.nest("/api/auth", auth::router())
.nest("/api/users", users::router())
.fallback_service(spa)
.layer(TraceLayer::new_for_http())
.with_state(state)

View File

@ -8,8 +8,20 @@ async fn main() -> anyhow::Result<()> {
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
.init();
let cfg = Config::from_env()?;
if let Some(dir) = cfg
.database_url
.strip_prefix("sqlite://")
.and_then(|p| p.split('?').next())
.and_then(|p| std::path::Path::new(p).parent())
{
std::fs::create_dir_all(dir)?;
}
let pool = infrastructure::connect(&cfg.database_url).await?;
let state = AppState::new(cfg.clone(), pool);
state.bootstrap().await?;
let listener = tokio::net::TcpListener::bind(cfg.bind).await?;
tracing::info!("listening on http://{}", cfg.bind);
axum::serve(listener, build_app(AppState { cfg })).await?;
let app = build_app(state).into_make_service_with_connect_info::<std::net::SocketAddr>();
axum::serve(listener, app).await?;
Ok(())
}

View File

@ -0,0 +1,34 @@
use axum::Json;
use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
use utoipa::{Modify, OpenApi};
struct BearerAuth;
impl Modify for BearerAuth {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
let components = openapi.components.get_or_insert_with(Default::default);
components.add_security_scheme(
"bearer",
SecurityScheme::Http(
HttpBuilder::new()
.scheme(HttpAuthScheme::Bearer)
.bearer_format("JWT")
.build(),
),
);
}
}
#[derive(OpenApi)]
#[openapi(
info(title = "SoftVisor Monitoring API", version = "0.1.0"),
paths(
crate::auth::login, crate::auth::refresh, crate::auth::logout, crate::auth::me,
crate::users::list, crate::users::create, crate::users::get_one, crate::users::update, crate::users::reset_password,
),
modifiers(&BearerAuth)
)]
pub struct ApiDoc;
pub async fn spec() -> Json<utoipa::openapi::OpenApi> {
Json(ApiDoc::openapi())
}

View File

@ -0,0 +1,43 @@
//! Minimal fixed-window rate limiter keyed by client identifier (IP).
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};
pub struct RateLimiter {
max: u32,
window: Duration,
hits: Mutex<HashMap<String, (Instant, u32)>>,
}
impl RateLimiter {
pub fn new(max: u32, window: Duration) -> Self {
Self {
max,
window,
hits: Mutex::new(HashMap::new()),
}
}
/// Returns true if the request is allowed.
pub fn check(&self, key: &str) -> bool {
let mut hits = self.hits.lock().unwrap();
let now = Instant::now();
hits.retain(|_, (start, _)| now.duration_since(*start) < self.window);
let entry = hits.entry(key.to_string()).or_insert((now, 0));
entry.1 += 1;
entry.1 <= self.max
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn blocks_after_max_hits_per_key() {
let l = RateLimiter::new(3, Duration::from_secs(60));
assert!(l.check("a") && l.check("a") && l.check("a"));
assert!(!l.check("a"));
assert!(l.check("b"));
}
}

View File

@ -14,5 +14,22 @@ pub fn test_config() -> Config {
}
pub async fn build_test_app() -> Router {
build_app(AppState { cfg: test_config() })
build_test_app_with(test_config()).await
}
pub async fn build_test_app_with_admin(email: &str, password: &str) -> Router {
let cfg = Config {
bootstrap_admin: Some((email.into(), password.into())),
..test_config()
};
build_test_app_with(cfg).await
}
async fn build_test_app_with(cfg: Config) -> Router {
let pool = infrastructure::connect(&cfg.database_url)
.await
.expect("db");
let state = AppState::new(cfg, pool);
state.bootstrap().await.expect("bootstrap");
build_app(state)
}

View File

@ -0,0 +1,114 @@
//! /api/users: admin-only user management.
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::routing::{get, post};
use axum::{Json, Router};
use domain::user::{NewUser, Role, UserUpdate};
use serde::Deserialize;
use utoipa::ToSchema;
use uuid::Uuid;
use crate::auth::UserDto;
use crate::error::ApiError;
use crate::extract::AdminUser;
use crate::AppState;
pub fn router() -> Router<AppState> {
Router::new()
.route("/", get(list).post(create))
.route("/{id}", get(get_one).patch(update))
.route("/{id}/password", post(reset_password))
}
#[derive(Deserialize, ToSchema)]
pub struct CreateUserRequest {
pub email: String,
pub display_name: String,
pub password: String,
#[schema(value_type = String, example = "admin")]
pub role: Role,
}
#[derive(Deserialize, ToSchema)]
pub struct UpdateUserRequest {
pub display_name: Option<String>,
#[schema(value_type = String, example = "admin")]
pub role: Option<Role>,
pub is_active: Option<bool>,
}
#[derive(Deserialize, ToSchema)]
pub struct PasswordRequest {
pub password: String,
}
#[utoipa::path(get, path = "/api/users", tag = "users", security(("bearer" = [])),
responses((status = 200, body = Vec<UserDto>), (status = 401), (status = 403)))]
async fn list(State(state): State<AppState>, _: AdminUser) -> Result<Json<Vec<UserDto>>, ApiError> {
Ok(Json(
state
.users
.list()
.await?
.into_iter()
.map(Into::into)
.collect(),
))
}
#[utoipa::path(post, path = "/api/users", tag = "users", security(("bearer" = [])), request_body = CreateUserRequest,
responses((status = 201, body = UserDto), (status = 409), (status = 422)))]
async fn create(
State(state): State<AppState>,
_: AdminUser,
Json(req): Json<CreateUserRequest>,
) -> Result<(StatusCode, Json<UserDto>), ApiError> {
let user = state
.users
.create(NewUser {
email: req.email,
display_name: req.display_name,
password: req.password,
role: req.role,
})
.await?;
Ok((StatusCode::CREATED, Json(user.into())))
}
#[utoipa::path(get, path = "/api/users/{id}", tag = "users", security(("bearer" = [])),
responses((status = 200, body = UserDto), (status = 404)))]
async fn get_one(
State(state): State<AppState>,
_: AdminUser,
Path(id): Path<Uuid>,
) -> Result<Json<UserDto>, ApiError> {
Ok(Json(state.users.get(id).await?.into()))
}
#[utoipa::path(patch, path = "/api/users/{id}", tag = "users", security(("bearer" = [])), request_body = UpdateUserRequest,
responses((status = 200, body = UserDto), (status = 404), (status = 409)))]
async fn update(
State(state): State<AppState>,
_: AdminUser,
Path(id): Path<Uuid>,
Json(req): Json<UpdateUserRequest>,
) -> Result<Json<UserDto>, ApiError> {
let update = UserUpdate {
display_name: req.display_name,
role: req.role,
is_active: req.is_active,
};
Ok(Json(state.users.update(id, update).await?.into()))
}
#[utoipa::path(post, path = "/api/users/{id}/password", tag = "users", security(("bearer" = [])), request_body = PasswordRequest,
responses((status = 204), (status = 404), (status = 422)))]
async fn reset_password(
State(state): State<AppState>,
_: AdminUser,
Path(id): Path<Uuid>,
Json(req): Json<PasswordRequest>,
) -> Result<StatusCode, ApiError> {
state.users.reset_password(id, &req.password).await?;
Ok(StatusCode::NO_CONTENT)
}

View File

@ -1,22 +1,27 @@
use std::sync::Arc;
use chrono::Duration;
use domain::auth::TokenPair;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use chrono::{Duration, Utc};
use domain::auth::{AuthEvent, AuthEventKind, RefreshToken, TokenPair};
use domain::ports::{
AccessTokenIssuer, AuditLog, PasswordHasher, RefreshTokenRepository, UserRepository,
};
use domain::user::User;
use domain::DomainError;
use rand::RngCore;
use sha2::{Digest, Sha256};
use uuid::Uuid;
pub const REFRESH_TOKEN_TTL_DAYS: i64 = 30;
pub struct AuthService {
pub(crate) users: Arc<dyn UserRepository>,
pub(crate) refresh: Arc<dyn RefreshTokenRepository>,
pub(crate) audit: Arc<dyn AuditLog>,
pub(crate) hasher: Arc<dyn PasswordHasher>,
pub(crate) tokens: Arc<dyn AccessTokenIssuer>,
pub(crate) refresh_ttl: Duration,
users: Arc<dyn UserRepository>,
refresh: Arc<dyn RefreshTokenRepository>,
audit: Arc<dyn AuditLog>,
hasher: Arc<dyn PasswordHasher>,
tokens: Arc<dyn AccessTokenIssuer>,
refresh_ttl: Duration,
}
impl AuthService {
@ -39,27 +44,143 @@ impl AuthService {
pub async fn login(
&self,
_email: &str,
_password: &str,
_ip: Option<String>,
email: &str,
password: &str,
ip: Option<String>,
) -> Result<TokenPair, DomainError> {
todo!()
let email = email.trim().to_lowercase();
let user = self.users.find_by_email(&email).await?;
let valid = user
.as_ref()
.is_some_and(|u| self.hasher.verify(password, &u.password_hash));
let Some(user) = user.filter(|_| valid) else {
self.record(None, &email, AuthEventKind::LoginFailed, ip)
.await?;
return Err(DomainError::InvalidCredentials);
};
if !user.is_active {
self.record(Some(user.id), &email, AuthEventKind::LoginFailed, ip)
.await?;
return Err(DomainError::InactiveUser);
}
let pair = self.issue_pair(&user, Uuid::new_v4()).await?;
self.record(Some(user.id), &email, AuthEventKind::LoginSuccess, ip)
.await?;
Ok(pair)
}
pub async fn refresh(
&self,
_refresh_token: &str,
_ip: Option<String>,
refresh_token: &str,
ip: Option<String>,
) -> Result<TokenPair, DomainError> {
todo!()
let stored = self
.refresh
.find_by_hash(&hash_token(refresh_token))
.await?
.ok_or(DomainError::InvalidToken)?;
let user = self
.users
.find_by_id(stored.user_id)
.await?
.ok_or(DomainError::InvalidToken)?;
if stored.revoked {
// A revoked token is presented again: someone else may hold the rotated one.
self.refresh.revoke_family(stored.family).await?;
self.record(
Some(user.id),
&user.email,
AuthEventKind::RefreshReuseDetected,
ip,
)
.await?;
return Err(DomainError::InvalidToken);
}
if !stored.is_valid(Utc::now()) {
return Err(DomainError::InvalidToken);
}
if !user.is_active {
return Err(DomainError::InactiveUser);
}
self.refresh.revoke(stored.id).await?;
let pair = self.issue_pair(&user, stored.family).await?;
self.record(Some(user.id), &user.email, AuthEventKind::Refresh, ip)
.await?;
Ok(pair)
}
pub async fn logout(&self, _refresh_token: &str) -> Result<(), DomainError> {
todo!()
pub async fn logout(&self, refresh_token: &str) -> Result<(), DomainError> {
if let Some(stored) = self
.refresh
.find_by_hash(&hash_token(refresh_token))
.await?
{
self.refresh.revoke_family(stored.family).await?;
if let Some(user) = self.users.find_by_id(stored.user_id).await? {
self.record(Some(user.id), &user.email, AuthEventKind::Logout, None)
.await?;
}
}
Ok(())
}
/// Resolve the user behind an access token; fails for invalid tokens and inactive users.
pub async fn authenticate(&self, _access_token: &str) -> Result<User, DomainError> {
todo!()
pub async fn authenticate(&self, access_token: &str) -> Result<User, DomainError> {
let claims = self.tokens.verify(access_token)?;
let user = self
.users
.find_by_id(claims.sub)
.await?
.ok_or(DomainError::InvalidToken)?;
if !user.is_active {
return Err(DomainError::InactiveUser);
}
Ok(user)
}
async fn issue_pair(&self, user: &User, family: Uuid) -> Result<TokenPair, DomainError> {
let raw = random_token();
self.refresh
.insert(&RefreshToken {
id: Uuid::new_v4(),
user_id: user.id,
family,
token_hash: hash_token(&raw),
expires_at: Utc::now() + self.refresh_ttl,
revoked: false,
})
.await?;
Ok(TokenPair {
access_token: self.tokens.issue(user)?,
refresh_token: raw,
})
}
async fn record(
&self,
user_id: Option<Uuid>,
email: &str,
kind: AuthEventKind,
ip: Option<String>,
) -> Result<(), DomainError> {
self.audit
.record(&AuthEvent {
user_id,
email: email.into(),
kind,
ip,
at: Utc::now(),
})
.await
}
}
fn random_token() -> String {
let mut bytes = [0u8; 32];
rand::thread_rng().fill_bytes(&mut bytes);
URL_SAFE_NO_PAD.encode(bytes)
}
fn hash_token(raw: &str) -> String {
format!("{:x}", Sha256::digest(raw.as_bytes()))
}

View File

@ -1,13 +1,14 @@
use std::sync::Arc;
use chrono::Utc;
use domain::ports::{PasswordHasher, UserRepository};
use domain::user::{NewUser, User, UserUpdate};
use domain::user::{validate_email, validate_password, NewUser, Role, User, UserUpdate};
use domain::DomainError;
use uuid::Uuid;
pub struct UserService {
pub(crate) users: Arc<dyn UserRepository>,
pub(crate) hasher: Arc<dyn PasswordHasher>,
users: Arc<dyn UserRepository>,
hasher: Arc<dyn PasswordHasher>,
}
impl UserService {
@ -16,31 +17,67 @@ impl UserService {
}
pub async fn list(&self) -> Result<Vec<User>, DomainError> {
todo!()
self.users.list().await
}
pub async fn get(&self, _id: Uuid) -> Result<User, DomainError> {
todo!()
pub async fn get(&self, id: Uuid) -> Result<User, DomainError> {
self.users
.find_by_id(id)
.await?
.ok_or(DomainError::NotFound)
}
pub async fn create(&self, _new: NewUser) -> Result<User, DomainError> {
todo!()
pub async fn create(&self, new: NewUser) -> Result<User, DomainError> {
let email = new.email.trim().to_lowercase();
validate_email(&email)?;
validate_password(&new.password)?;
if self.users.find_by_email(&email).await?.is_some() {
return Err(DomainError::EmailTaken);
}
let user = User {
id: Uuid::new_v4(),
email,
display_name: new.display_name.trim().to_string(),
password_hash: self.hasher.hash(&new.password)?,
role: new.role,
is_active: true,
created_at: Utc::now(),
};
self.users.insert(&user).await?;
Ok(user)
}
pub async fn update(&self, _id: Uuid, _update: UserUpdate) -> Result<User, DomainError> {
todo!()
pub async fn update(&self, id: Uuid, update: UserUpdate) -> Result<User, DomainError> {
let current = self.get(id).await?;
let loses_admin = current.is_admin()
&& current.is_active
&& (update.role == Some(Role::User) || update.is_active == Some(false));
if loses_admin && self.users.count_active_admins().await? <= 1 {
return Err(DomainError::LastAdmin);
}
self.users.update(id, &update).await
}
pub async fn reset_password(&self, _id: Uuid, _password: &str) -> Result<(), DomainError> {
todo!()
pub async fn reset_password(&self, id: Uuid, password: &str) -> Result<(), DomainError> {
validate_password(password)?;
self.get(id).await?;
self.users
.set_password_hash(id, &self.hasher.hash(password)?)
.await
}
/// Create the initial admin if the user table is empty. Returns true if created.
pub async fn bootstrap_admin(
&self,
_email: &str,
_password: &str,
) -> Result<bool, DomainError> {
todo!()
pub async fn bootstrap_admin(&self, email: &str, password: &str) -> Result<bool, DomainError> {
if self.users.count().await? > 0 {
return Ok(false);
}
self.create(NewUser {
email: email.into(),
display_name: "Administrator".into(),
password: password.into(),
role: Role::Admin,
})
.await?;
Ok(true)
}
}

View File

@ -0,0 +1,28 @@
CREATE TABLE users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('admin', 'user')),
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL
);
CREATE TABLE refresh_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
family TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
revoked INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX refresh_tokens_family ON refresh_tokens(family);
CREATE TABLE auth_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT,
email TEXT NOT NULL,
kind TEXT NOT NULL,
ip TEXT,
at TEXT NOT NULL
);

View File

@ -0,0 +1,17 @@
use sqlx::sqlite::{SqlitePool, SqlitePoolOptions};
pub type DbPool = SqlitePool;
/// Connect and run migrations. In-memory URLs get a single connection so the schema persists.
pub async fn connect(url: &str) -> anyhow::Result<DbPool> {
let in_memory = url.contains(":memory:");
let pool = SqlitePoolOptions::new()
.max_connections(if in_memory { 1 } else { 5 })
.connect(url)
.await?;
sqlx::query("PRAGMA foreign_keys = ON")
.execute(&pool)
.await?;
sqlx::migrate!("./migrations").run(&pool).await?;
Ok(pool)
}

View File

@ -1 +1,10 @@
//! infrastructure layer
//! Infrastructure layer: SQLite repositories, Argon2 hashing, JWT issuing.
pub mod db;
pub mod password;
pub mod sqlite;
pub mod token;
pub use db::{connect, DbPool};
pub use password::Argon2Hasher;
pub use sqlite::{SqliteAuditLog, SqliteRefreshTokens, SqliteUsers};
pub use token::JwtIssuer;

View File

@ -0,0 +1,42 @@
use argon2::password_hash::{
rand_core::OsRng, PasswordHash, PasswordHasher as _, PasswordVerifier, SaltString,
};
use argon2::Argon2;
use domain::ports::PasswordHasher;
use domain::DomainError;
#[derive(Default)]
pub struct Argon2Hasher;
impl PasswordHasher for Argon2Hasher {
fn hash(&self, password: &str) -> Result<String, DomainError> {
let salt = SaltString::generate(&mut OsRng);
Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map(|h| h.to_string())
.map_err(|e| DomainError::Storage(e.to_string()))
}
fn verify(&self, password: &str, hash: &str) -> bool {
PasswordHash::new(hash).is_ok_and(|parsed| {
Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.is_ok()
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hash_roundtrip() {
let h = Argon2Hasher;
let hash = h.hash("correct-horse-battery").unwrap();
assert!(hash.starts_with("$argon2id$"));
assert!(h.verify("correct-horse-battery", &hash));
assert!(!h.verify("wrong", &hash));
assert!(!h.verify("x", "not-a-hash"));
}
}

View File

@ -0,0 +1,282 @@
//! SQLite implementations of the repository ports.
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use domain::auth::{AuthEvent, RefreshToken};
use domain::ports::{AuditLog, RefreshTokenRepository, UserRepository};
use domain::user::{Role, User, UserUpdate};
use domain::DomainError;
use sqlx::sqlite::SqliteRow;
use sqlx::Row;
use uuid::Uuid;
use crate::DbPool;
fn storage(e: sqlx::Error) -> DomainError {
DomainError::Storage(e.to_string())
}
fn parse_ts(s: &str) -> DateTime<Utc> {
DateTime::parse_from_rfc3339(s)
.map(|d| d.with_timezone(&Utc))
.unwrap_or_default()
}
fn user_from_row(r: &SqliteRow) -> User {
User {
id: r.get::<Uuid, _>("id"),
email: r.get("email"),
display_name: r.get("display_name"),
password_hash: r.get("password_hash"),
role: Role::parse(r.get::<String, _>("role").as_str()).unwrap_or(Role::User),
is_active: r.get::<bool, _>("is_active"),
created_at: parse_ts(r.get::<String, _>("created_at").as_str()),
}
}
const USER_COLS: &str = "id, email, display_name, password_hash, role, is_active, created_at";
pub struct SqliteUsers(pub DbPool);
#[async_trait]
impl UserRepository for SqliteUsers {
async fn find_by_id(&self, id: Uuid) -> Result<Option<User>, DomainError> {
sqlx::query(&format!("SELECT {USER_COLS} FROM users WHERE id = ?"))
.bind(id)
.fetch_optional(&self.0)
.await
.map(|r| r.as_ref().map(user_from_row))
.map_err(storage)
}
async fn find_by_email(&self, email: &str) -> Result<Option<User>, DomainError> {
sqlx::query(&format!("SELECT {USER_COLS} FROM users WHERE email = ?"))
.bind(email)
.fetch_optional(&self.0)
.await
.map(|r| r.as_ref().map(user_from_row))
.map_err(storage)
}
async fn list(&self) -> Result<Vec<User>, DomainError> {
sqlx::query(&format!("SELECT {USER_COLS} FROM users ORDER BY email"))
.fetch_all(&self.0)
.await
.map(|rows| rows.iter().map(user_from_row).collect())
.map_err(storage)
}
async fn count(&self) -> Result<u64, DomainError> {
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users")
.fetch_one(&self.0)
.await
.map_err(storage)?;
Ok(n as u64)
}
async fn count_active_admins(&self) -> Result<u64, DomainError> {
let n: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE role = 'admin' AND is_active = 1")
.fetch_one(&self.0)
.await
.map_err(storage)?;
Ok(n as u64)
}
async fn insert(&self, u: &User) -> Result<(), DomainError> {
sqlx::query(&format!(
"INSERT INTO users ({USER_COLS}) VALUES (?, ?, ?, ?, ?, ?, ?)"
))
.bind(u.id)
.bind(&u.email)
.bind(&u.display_name)
.bind(&u.password_hash)
.bind(u.role.as_str())
.bind(u.is_active)
.bind(u.created_at.to_rfc3339())
.execute(&self.0)
.await
.map(|_| ())
.map_err(storage)
}
async fn update(&self, id: Uuid, up: &UserUpdate) -> Result<User, DomainError> {
let res = sqlx::query(
"UPDATE users SET display_name = COALESCE(?, display_name), role = COALESCE(?, role), \
is_active = COALESCE(?, is_active) WHERE id = ?",
)
.bind(&up.display_name)
.bind(up.role.map(Role::as_str))
.bind(up.is_active)
.bind(id)
.execute(&self.0)
.await
.map_err(storage)?;
if res.rows_affected() == 0 {
return Err(DomainError::NotFound);
}
self.find_by_id(id).await?.ok_or(DomainError::NotFound)
}
async fn set_password_hash(&self, id: Uuid, hash: &str) -> Result<(), DomainError> {
let res = sqlx::query("UPDATE users SET password_hash = ? WHERE id = ?")
.bind(hash)
.bind(id)
.execute(&self.0)
.await
.map_err(storage)?;
(res.rows_affected() > 0)
.then_some(())
.ok_or(DomainError::NotFound)
}
}
pub struct SqliteRefreshTokens(pub DbPool);
#[async_trait]
impl RefreshTokenRepository for SqliteRefreshTokens {
async fn insert(&self, t: &RefreshToken) -> Result<(), DomainError> {
sqlx::query("INSERT INTO refresh_tokens (id, user_id, family, token_hash, expires_at, revoked) VALUES (?, ?, ?, ?, ?, ?)")
.bind(t.id)
.bind(t.user_id)
.bind(t.family)
.bind(&t.token_hash)
.bind(t.expires_at.to_rfc3339())
.bind(t.revoked)
.execute(&self.0)
.await
.map(|_| ())
.map_err(storage)
}
async fn find_by_hash(&self, hash: &str) -> Result<Option<RefreshToken>, DomainError> {
sqlx::query("SELECT id, user_id, family, token_hash, expires_at, revoked FROM refresh_tokens WHERE token_hash = ?")
.bind(hash)
.fetch_optional(&self.0)
.await
.map(|row| {
row.map(|r| RefreshToken {
id: r.get("id"),
user_id: r.get("user_id"),
family: r.get("family"),
token_hash: r.get("token_hash"),
expires_at: parse_ts(r.get::<String, _>("expires_at").as_str()),
revoked: r.get("revoked"),
})
})
.map_err(storage)
}
async fn revoke(&self, id: Uuid) -> Result<(), DomainError> {
sqlx::query("UPDATE refresh_tokens SET revoked = 1 WHERE id = ?")
.bind(id)
.execute(&self.0)
.await
.map(|_| ())
.map_err(storage)
}
async fn revoke_family(&self, family: Uuid) -> Result<(), DomainError> {
sqlx::query("UPDATE refresh_tokens SET revoked = 1 WHERE family = ?")
.bind(family)
.execute(&self.0)
.await
.map(|_| ())
.map_err(storage)
}
}
pub struct SqliteAuditLog(pub DbPool);
#[async_trait]
impl AuditLog for SqliteAuditLog {
async fn record(&self, e: &AuthEvent) -> Result<(), DomainError> {
sqlx::query("INSERT INTO auth_events (user_id, email, kind, ip, at) VALUES (?, ?, ?, ?, ?)")
.bind(e.user_id)
.bind(&e.email)
.bind(e.kind.as_str())
.bind(&e.ip)
.bind(e.at.to_rfc3339())
.execute(&self.0)
.await
.map(|_| ())
.map_err(storage)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn users_roundtrip_and_update() {
let pool = crate::connect("sqlite::memory:").await.unwrap();
let repo = SqliteUsers(pool);
let u = User {
id: Uuid::new_v4(),
email: "a@x.de".into(),
display_name: "A".into(),
password_hash: "h".into(),
role: Role::Admin,
is_active: true,
created_at: Utc::now(),
};
repo.insert(&u).await.unwrap();
assert_eq!(
repo.find_by_email("a@x.de").await.unwrap().unwrap().id,
u.id
);
assert_eq!(repo.count_active_admins().await.unwrap(), 1);
let updated = repo
.update(
u.id,
&UserUpdate {
is_active: Some(false),
..Default::default()
},
)
.await
.unwrap();
assert!(!updated.is_active);
assert_eq!(updated.display_name, "A");
assert_eq!(
repo.update(Uuid::new_v4(), &UserUpdate::default())
.await
.unwrap_err(),
DomainError::NotFound
);
}
#[tokio::test]
async fn refresh_tokens_revoke_by_family() {
let pool = crate::connect("sqlite::memory:").await.unwrap();
let users = SqliteUsers(pool.clone());
let u = User {
id: Uuid::new_v4(),
email: "a@x.de".into(),
display_name: "A".into(),
password_hash: "h".into(),
role: Role::User,
is_active: true,
created_at: Utc::now(),
};
users.insert(&u).await.unwrap();
let repo = SqliteRefreshTokens(pool);
let family = Uuid::new_v4();
for h in ["h1", "h2"] {
repo.insert(&RefreshToken {
id: Uuid::new_v4(),
user_id: u.id,
family,
token_hash: h.into(),
expires_at: Utc::now() + chrono::Duration::days(1),
revoked: false,
})
.await
.unwrap();
}
repo.revoke_family(family).await.unwrap();
assert!(repo.find_by_hash("h1").await.unwrap().unwrap().revoked);
assert!(repo.find_by_hash("h2").await.unwrap().unwrap().revoked);
assert!(repo.find_by_hash("nope").await.unwrap().is_none());
}
}

View File

@ -0,0 +1,89 @@
use chrono::{Duration, Utc};
use domain::auth::AccessClaims;
use domain::ports::AccessTokenIssuer;
use domain::user::User;
use domain::DomainError;
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation};
pub const ACCESS_TOKEN_TTL_MINUTES: i64 = 15;
pub struct JwtIssuer {
enc: EncodingKey,
dec: DecodingKey,
ttl: Duration,
}
impl JwtIssuer {
pub fn new(secret: &str) -> Self {
Self {
enc: EncodingKey::from_secret(secret.as_bytes()),
dec: DecodingKey::from_secret(secret.as_bytes()),
ttl: Duration::minutes(ACCESS_TOKEN_TTL_MINUTES),
}
}
}
impl AccessTokenIssuer for JwtIssuer {
fn issue(&self, user: &User) -> Result<String, DomainError> {
let claims = AccessClaims {
sub: user.id,
role: user.role,
exp: (Utc::now() + self.ttl).timestamp(),
};
jsonwebtoken::encode(&Header::default(), &claims, &self.enc)
.map_err(|e| DomainError::Storage(e.to_string()))
}
fn verify(&self, token: &str) -> Result<AccessClaims, DomainError> {
jsonwebtoken::decode::<AccessClaims>(token, &self.dec, &Validation::default())
.map(|d| d.claims)
.map_err(|_| DomainError::InvalidToken)
}
}
#[cfg(test)]
mod tests {
use super::*;
use domain::user::Role;
use uuid::Uuid;
fn user() -> User {
User {
id: Uuid::new_v4(),
email: "a@x.de".into(),
display_name: "A".into(),
password_hash: String::new(),
role: Role::Admin,
is_active: true,
created_at: Utc::now(),
}
}
#[test]
fn issue_and_verify_roundtrip() {
let issuer = JwtIssuer::new("0123456789012345678901234567890123456789");
let u = user();
let claims = issuer.verify(&issuer.issue(&u).unwrap()).unwrap();
assert_eq!(claims.sub, u.id);
assert_eq!(claims.role, Role::Admin);
}
#[test]
fn other_secret_and_expired_tokens_are_rejected() {
let a = JwtIssuer::new("0123456789012345678901234567890123456789");
let b = JwtIssuer::new("abcdefghijabcdefghijabcdefghijabcdefghij");
assert_eq!(
b.verify(&a.issue(&user()).unwrap()).unwrap_err(),
DomainError::InvalidToken
);
let expired = JwtIssuer {
ttl: Duration::minutes(-10),
..JwtIssuer::new("0123456789012345678901234567890123456789")
};
assert_eq!(
a.verify(&expired.issue(&user()).unwrap()).unwrap_err(),
DomainError::InvalidToken
);
assert_eq!(a.verify("garbage").unwrap_err(), DomainError::InvalidToken);
}
}

View File

@ -1,5 +1,8 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import Toast from './components/Toast.vue'
</script>
<template>
<Toast />
<RouterView />
</template>

View File

@ -0,0 +1,62 @@
import { useAuthStore } from '../stores/auth'
import type { TokenResponse } from './types'
export class ApiError extends Error {
status: number
code: string
constructor(status: number, code: string, message: string) {
super(message)
this.status = status
this.code = code
}
}
async function parse(res: Response): Promise<unknown> {
if (res.status === 204) return undefined
const text = await res.text()
return text ? JSON.parse(text) : undefined
}
async function toError(res: Response): Promise<ApiError> {
const body = (await parse(res).catch(() => undefined)) as
{ error?: string; message?: string } | undefined
return new ApiError(res.status, body?.error ?? 'unknown', body?.message ?? res.statusText)
}
/** Refresh the access token via the HttpOnly cookie. Returns false if it failed. */
export async function refreshAccessToken(): Promise<boolean> {
const auth = useAuthStore()
const res = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'same-origin' })
if (!res.ok) {
auth.clear()
return false
}
const data = (await parse(res)) as TokenResponse
auth.setSession(data)
return true
}
async function request<T>(method: string, path: string, body?: unknown, retry = true): Promise<T> {
const auth = useAuthStore()
const headers: Record<string, string> = {}
if (body !== undefined) headers['Content-Type'] = 'application/json'
if (auth.accessToken) headers['Authorization'] = `Bearer ${auth.accessToken}`
const res = await fetch(path, {
method,
headers,
credentials: 'same-origin',
body: body === undefined ? undefined : JSON.stringify(body),
})
if (res.status === 401 && retry && !path.startsWith('/api/auth/')) {
if (await refreshAccessToken()) return request<T>(method, path, body, false)
throw new ApiError(401, 'unauthorized', 'session expired')
}
if (!res.ok) throw await toError(res)
return (await parse(res)) as T
}
export const api = {
get: <T>(path: string) => request<T>('GET', path),
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
patch: <T>(path: string, body?: unknown) => request<T>('PATCH', path, body),
}

28
frontend/src/api/types.ts Normal file
View File

@ -0,0 +1,28 @@
export type Role = 'admin' | 'user'
export interface User {
id: string
email: string
display_name: string
role: Role
is_active: boolean
created_at?: string
}
export interface TokenResponse {
access_token: string
user: User
}
export interface CreateUserPayload {
email: string
display_name: string
password: string
role: Role
}
export interface UpdateUserPayload {
display_name?: string
role?: Role
is_active?: boolean
}

View File

@ -0,0 +1,49 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
const auth = useAuthStore()
const router = useRouter()
const nav = [
{ to: '/', label: 'Dashboard' },
{ to: '/updates', label: 'Updates' },
{ to: '/vulnerabilities', label: 'Vulnerabilities' },
{ to: '/backups', label: 'Backups' },
{ to: '/users', label: 'Users', admin: true },
{ to: '/settings', label: 'Settings' },
]
async function signOut() {
await auth.logout()
router.push('/login')
}
</script>
<template>
<div class="flex min-h-screen bg-gray-50 text-gray-900">
<aside class="flex w-56 flex-col border-r border-gray-200 bg-white">
<div class="px-5 py-4 text-lg font-semibold">SoftVisor Monitoring</div>
<nav class="flex-1 space-y-1 px-3">
<template v-for="item in nav" :key="item.to">
<RouterLink
v-if="!item.admin || auth.isAdmin"
:to="item.to"
class="block rounded-md px-3 py-2 text-sm hover:bg-gray-100"
active-class="bg-gray-100 font-medium"
:exact-active-class="item.to === '/' ? 'bg-gray-100 font-medium' : undefined"
>
{{ item.label }}
</RouterLink>
</template>
</nav>
<div class="border-t border-gray-200 px-5 py-4 text-sm">
<div class="truncate font-medium">{{ auth.user?.display_name }}</div>
<div class="truncate text-gray-500">{{ auth.user?.email }}</div>
<button class="mt-2 text-blue-600 hover:underline" @click="signOut">Sign out</button>
</div>
</aside>
<main class="flex-1 p-8">
<RouterView />
</main>
</div>
</template>

View File

@ -0,0 +1,18 @@
<script setup lang="ts">
import { useToastStore } from '../stores/toast'
const toasts = useToastStore()
</script>
<template>
<div class="fixed right-4 top-4 z-50 space-y-2">
<div
v-for="t in toasts.items"
:key="t.id"
role="status"
class="rounded-md px-4 py-2 text-sm text-white shadow"
:class="t.kind === 'error' ? 'bg-red-600' : 'bg-green-600'"
>
{{ t.message }}
</div>
</div>
</template>

View File

@ -0,0 +1,104 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { CreateUserPayload, Role, UpdateUserPayload, User } from '../api/types'
const props = defineProps<{ mode: 'create' | 'edit'; user?: User; busy?: boolean }>()
const emit = defineEmits<{ submit: [payload: CreateUserPayload | UpdateUserPayload]; cancel: [] }>()
const email = ref(props.user?.email ?? '')
const displayName = ref(props.user?.display_name ?? '')
const password = ref('')
const role = ref<Role>(props.user?.role ?? 'user')
const isActive = ref(props.user?.is_active ?? true)
const error = ref('')
function submit() {
error.value = ''
if (props.mode === 'create') {
if (password.value.length < 12) {
error.value = 'Password must have at least 12 characters'
return
}
emit('submit', {
email: email.value,
display_name: displayName.value,
password: password.value,
role: role.value,
})
} else {
emit('submit', { display_name: displayName.value, role: role.value, is_active: isActive.value })
}
}
</script>
<template>
<form class="space-y-4" @submit.prevent="submit">
<div v-if="mode === 'create'">
<label for="uf-email" class="block text-sm font-medium">Email</label>
<input
id="uf-email"
v-model="email"
name="email"
type="email"
required
class="mt-1 w-full rounded-md border border-gray-300 px-3 py-2"
/>
</div>
<div>
<label for="uf-name" class="block text-sm font-medium">Display name</label>
<input
id="uf-name"
v-model="displayName"
name="display_name"
type="text"
required
class="mt-1 w-full rounded-md border border-gray-300 px-3 py-2"
/>
</div>
<div v-if="mode === 'create'">
<label for="uf-password" class="block text-sm font-medium">Password</label>
<input
id="uf-password"
v-model="password"
name="password"
type="password"
required
autocomplete="new-password"
class="mt-1 w-full rounded-md border border-gray-300 px-3 py-2"
/>
</div>
<div>
<label for="uf-role" class="block text-sm font-medium">Role</label>
<select
id="uf-role"
v-model="role"
name="role"
class="mt-1 w-full rounded-md border border-gray-300 px-3 py-2"
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<label v-if="mode === 'edit'" class="flex items-center gap-2 text-sm">
<input v-model="isActive" name="is_active" type="checkbox" />
Active
</label>
<p v-if="error" role="alert" class="text-sm text-red-600">{{ error }}</p>
<div class="flex justify-end gap-2">
<button
type="button"
class="rounded-md border border-gray-300 px-4 py-2 text-sm"
@click="emit('cancel')"
>
Cancel
</button>
<button
type="submit"
:disabled="busy"
class="rounded-md bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
>
{{ mode === 'create' ? 'Create' : 'Save' }}
</button>
</div>
</form>
</template>

View File

@ -0,0 +1,6 @@
<template>
<h1 class="text-2xl font-semibold">Dashboard</h1>
<p class="mt-2 text-gray-600">
Server overview, update status, vulnerabilities and backups will appear here.
</p>
</template>

View File

@ -0,0 +1,73 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '../stores/auth'
const auth = useAuthStore()
const router = useRouter()
const route = useRoute()
const email = ref('')
const password = ref('')
const error = ref('')
const busy = ref(false)
async function submit() {
error.value = ''
busy.value = true
try {
await auth.login(email.value, password.value)
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/'
router.push(redirect)
} catch (e) {
const code = (e as { code?: string }).code
error.value =
code === 'invalid_credentials'
? 'Invalid email or password'
: code === 'inactive_user'
? 'This account is deactivated'
: code === 'rate_limited'
? 'Too many attempts, please wait a minute'
: 'Login failed, please try again'
} finally {
busy.value = false
}
}
</script>
<template>
<main class="flex min-h-screen items-center justify-center bg-gray-50">
<form class="w-full max-w-sm space-y-4 rounded-lg bg-white p-8 shadow" @submit.prevent="submit">
<h1 class="text-xl font-semibold">SoftVisor Monitoring</h1>
<div>
<label for="email" class="block text-sm font-medium">Email</label>
<input
id="email"
v-model="email"
type="email"
required
autocomplete="username"
class="mt-1 w-full rounded-md border border-gray-300 px-3 py-2"
/>
</div>
<div>
<label for="password" class="block text-sm font-medium">Password</label>
<input
id="password"
v-model="password"
type="password"
required
autocomplete="current-password"
class="mt-1 w-full rounded-md border border-gray-300 px-3 py-2"
/>
</div>
<p v-if="error" role="alert" class="text-sm text-red-600">{{ error }}</p>
<button
type="submit"
:disabled="busy"
class="w-full rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
>
Sign in
</button>
</form>
</main>
</template>

View File

@ -0,0 +1,8 @@
<script setup lang="ts">
defineProps<{ title: string }>()
</script>
<template>
<h1 class="text-2xl font-semibold">{{ title }}</h1>
<p class="mt-2 text-gray-600">Not implemented yet.</p>
</template>

View File

@ -0,0 +1,178 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { api, ApiError } from '../api/client'
import type { CreateUserPayload, UpdateUserPayload, User } from '../api/types'
import { useAuthStore } from '../stores/auth'
import { useToastStore } from '../stores/toast'
import UserForm from '../components/UserForm.vue'
import StatusBadge from '../components/StatusBadge.vue'
const auth = useAuthStore()
const toast = useToastStore()
const users = ref<User[]>([])
const loading = ref(true)
const busy = ref(false)
const dialog = ref<
{ mode: 'create' } | { mode: 'edit'; user: User } | { mode: 'password'; user: User } | null
>(null)
const newPassword = ref('')
async function load() {
loading.value = true
try {
users.value = await api.get<User[]>('/api/users')
} finally {
loading.value = false
}
}
onMounted(() => {
if (auth.isAdmin) load()
})
function fail(e: unknown) {
toast.error(e instanceof ApiError ? e.message : 'Request failed')
}
async function submit(payload: CreateUserPayload | UpdateUserPayload) {
if (!dialog.value || dialog.value.mode === 'password') return
busy.value = true
try {
if (dialog.value.mode === 'create') {
await api.post('/api/users', payload)
toast.success('User created')
} else {
await api.patch(`/api/users/${dialog.value.user.id}`, payload)
toast.success('User updated')
}
dialog.value = null
await load()
} catch (e) {
fail(e)
} finally {
busy.value = false
}
}
async function resetPassword() {
if (!dialog.value || dialog.value.mode !== 'password') return
busy.value = true
try {
await api.post(`/api/users/${dialog.value.user.id}/password`, { password: newPassword.value })
toast.success('Password reset')
dialog.value = null
newPassword.value = ''
} catch (e) {
fail(e)
} finally {
busy.value = false
}
}
</script>
<template>
<div v-if="!auth.isAdmin">
<h1 class="text-2xl font-semibold">Users</h1>
<p class="mt-2 text-gray-600">You do not have permission to manage users.</p>
</div>
<div v-else>
<div class="flex items-center justify-between">
<h1 class="text-2xl font-semibold">Users</h1>
<button
class="rounded-md bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700"
@click="dialog = { mode: 'create' }"
>
New user
</button>
</div>
<p v-if="loading" class="mt-6 text-gray-500">Loading…</p>
<table v-else class="mt-6 w-full text-left text-sm">
<thead class="border-b border-gray-200 text-gray-500">
<tr>
<th class="py-2">Email</th>
<th>Name</th>
<th>Role</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="u in users" :key="u.id" class="border-b border-gray-100">
<td class="py-2">{{ u.email }}</td>
<td>{{ u.display_name }}</td>
<td class="capitalize">{{ u.role }}</td>
<td>
<StatusBadge
:status="u.is_active ? 'ok' : 'error'"
:label="u.is_active ? 'active' : 'inactive'"
/>
</td>
<td class="space-x-3 text-right">
<button
class="text-blue-600 hover:underline"
@click="dialog = { mode: 'edit', user: u }"
>
Edit
</button>
<button
class="text-blue-600 hover:underline"
@click="dialog = { mode: 'password', user: u }"
>
Reset password
</button>
</td>
</tr>
</tbody>
</table>
<div v-if="dialog" class="fixed inset-0 flex items-center justify-center bg-black/30">
<div class="w-full max-w-md rounded-lg bg-white p-6 shadow-lg" role="dialog">
<template v-if="dialog.mode === 'password'">
<h2 class="mb-4 text-lg font-semibold">Reset password for {{ dialog.user.email }}</h2>
<form class="space-y-4" @submit.prevent="resetPassword">
<div>
<label for="np" class="block text-sm font-medium">New password</label>
<input
id="np"
v-model="newPassword"
type="password"
required
minlength="12"
class="mt-1 w-full rounded-md border border-gray-300 px-3 py-2"
/>
</div>
<div class="flex justify-end gap-2">
<button
type="button"
class="rounded-md border border-gray-300 px-4 py-2 text-sm"
@click="dialog = null"
>
Cancel
</button>
<button
type="submit"
:disabled="busy"
class="rounded-md bg-blue-600 px-4 py-2 text-sm text-white"
>
Reset
</button>
</div>
</form>
</template>
<template v-else>
<h2 class="mb-4 text-lg font-semibold">
{{ dialog.mode === 'create' ? 'New user' : 'Edit user' }}
</h2>
<UserForm
:key="dialog.mode === 'edit' ? dialog.user.id : 'new'"
:mode="dialog.mode"
:user="dialog.mode === 'edit' ? dialog.user : undefined"
:busy="busy"
@submit="submit"
@cancel="dialog = null"
/>
</template>
</div>
</div>
</div>
</template>

View File

@ -1,7 +1,44 @@
import { createRouter, createWebHistory } from 'vue-router'
import HealthPage from './pages/HealthPage.vue'
import { useAuthStore } from './stores/auth'
import { refreshAccessToken } from './api/client'
import AppShell from './components/AppShell.vue'
import LoginPage from './pages/LoginPage.vue'
import DashboardPage from './pages/DashboardPage.vue'
import UsersPage from './pages/UsersPage.vue'
import PlaceholderPage from './pages/PlaceholderPage.vue'
export const router = createRouter({
history: createWebHistory(),
routes: [{ path: '/', component: HealthPage }],
routes: [
{ path: '/login', component: LoginPage, meta: { public: true } },
{
path: '/',
component: AppShell,
children: [
{ path: '', name: 'dashboard', component: DashboardPage },
{ path: 'updates', component: PlaceholderPage, props: { title: 'Updates' } },
{
path: 'vulnerabilities',
component: PlaceholderPage,
props: { title: 'Vulnerabilities' },
},
{ path: 'backups', component: PlaceholderPage, props: { title: 'Backups' } },
{ path: 'users', component: UsersPage, meta: { admin: true } },
{ path: 'settings', component: PlaceholderPage, props: { title: 'Settings' } },
],
},
],
})
let sessionRestored = false
router.beforeEach(async (to) => {
const auth = useAuthStore()
if (!sessionRestored) {
sessionRestored = true
if (!auth.isAuthenticated) await refreshAccessToken().catch(() => false)
}
if (to.meta.public) return auth.isAuthenticated ? '/' : true
if (!auth.isAuthenticated) return { path: '/login', query: { redirect: to.fullPath } }
return true
})

View File

@ -2,7 +2,9 @@ import { setActivePinia, createPinia } from 'pinia'
import { useAuthStore } from './auth'
import { api } from '../api/client'
const admin = { id: '1', email: 'a@x.de', display_name: 'A', role: 'admin', is_active: true }
import type { User } from '../api/types'
const admin: User = { id: '1', email: 'a@x.de', display_name: 'A', role: 'admin', is_active: true }
function mockFetch(responses: Array<{ status: number; body?: unknown }>) {
const calls: Array<{ url: string; init: RequestInit }> = []

View File

@ -0,0 +1,43 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { api, ApiError } from '../api/client'
import type { TokenResponse, User } from '../api/types'
export const useAuthStore = defineStore('auth', () => {
const accessToken = ref<string | null>(null)
const user = ref<User | null>(null)
const isAuthenticated = computed(() => user.value !== null)
const isAdmin = computed(() => user.value?.role === 'admin')
function setSession(data: TokenResponse) {
accessToken.value = data.access_token
user.value = data.user
}
function clear() {
accessToken.value = null
user.value = null
}
async function login(email: string, password: string) {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ email, password }),
})
const body = await res.json().catch(() => ({}))
if (!res.ok) throw new ApiError(res.status, body.error ?? 'unknown', body.message ?? '')
setSession(body as TokenResponse)
}
async function logout() {
try {
await api.post('/api/auth/logout')
} finally {
clear()
}
}
return { accessToken, user, isAuthenticated, isAdmin, setSession, clear, login, logout }
})

View File

@ -0,0 +1,23 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export interface Toast {
id: number
kind: 'success' | 'error'
message: string
}
export const useToastStore = defineStore('toast', () => {
const items = ref<Toast[]>([])
let next = 1
function push(kind: Toast['kind'], message: string) {
const id = next++
items.value.push({ id, kind, message })
setTimeout(() => (items.value = items.value.filter((t) => t.id !== id)), 4000)
}
return {
items,
success: (m: string) => push('success', m),
error: (m: string) => push('error', m),
}
})