Compare commits
13 Commits
b0b765b8fa
...
feature/fr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc7b70e039 | ||
|
|
873b4a9d3a | ||
|
|
596eb8faea | ||
|
|
0cd6e837fc | ||
|
|
be63fcbc37 | ||
|
|
3f252a8abd | ||
|
|
0740072a60 | ||
|
|
ff752985c6 | ||
|
|
feb5122843 | ||
|
|
0260a03e1b | ||
|
|
a88e4d7274 | ||
|
|
7d99a4852b | ||
|
|
e59e7ca4c8 |
@@ -147,6 +147,7 @@ fn get_app_state(
|
|||||||
) -> AppState {
|
) -> AppState {
|
||||||
AppState {
|
AppState {
|
||||||
database_connection: db_connection.clone(),
|
database_connection: db_connection.clone(),
|
||||||
|
config: Arc::new(settings.clone()),
|
||||||
service: Arc::new(AppService {
|
service: Arc::new(AppService {
|
||||||
server_state: Arc::new(ServerStateService::new(db_connection.clone())),
|
server_state: Arc::new(ServerStateService::new(db_connection.clone())),
|
||||||
settings: Arc::new(SettingsService::new(db_connection.clone())),
|
settings: Arc::new(SettingsService::new(db_connection.clone())),
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ use tracing::{debug, error};
|
|||||||
pub trait FromConfig: Sized {
|
pub trait FromConfig: Sized {
|
||||||
fn from_config(config: &Config) -> Result<Self, String>;
|
fn from_config(config: &Config) -> Result<Self, String>;
|
||||||
fn validate(&self) -> Result<(), String>;
|
fn validate(&self) -> Result<(), String>;
|
||||||
|
#[cfg(test)]
|
||||||
|
fn mock() -> Self;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -40,6 +42,16 @@ impl FromConfig for ProgramSettings {
|
|||||||
self.auth.validate()?;
|
self.auth.validate()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn mock() -> Self {
|
||||||
|
ProgramSettings {
|
||||||
|
logging: logging::LoggingSettings::mock(),
|
||||||
|
database: database::DatabaseSettings::mock(),
|
||||||
|
server: server::ServerSettings::mock(),
|
||||||
|
auth: auth::AuthSettings::mock(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_program_settings() -> ProgramSettings {
|
pub fn get_program_settings() -> ProgramSettings {
|
||||||
|
|||||||
@@ -48,4 +48,13 @@ impl FromConfig for AuthSettings {
|
|||||||
fn validate(&self) -> Result<(), String> {
|
fn validate(&self) -> Result<(), String> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn mock() -> Self {
|
||||||
|
AuthSettings {
|
||||||
|
jwt_secret: Some("mock_jwt_secret".to_string()),
|
||||||
|
default_admin_username: Some("admin".to_string()),
|
||||||
|
default_admin_password: Some("password".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,4 +50,13 @@ impl FromConfig for DatabaseSettings {
|
|||||||
fn validate(&self) -> Result<(), String> {
|
fn validate(&self) -> Result<(), String> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn mock() -> Self {
|
||||||
|
DatabaseSettings {
|
||||||
|
url: "sqlite::memory:".to_string(),
|
||||||
|
max_connections: 5,
|
||||||
|
migrate_on_startup: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ pub(crate) const SERVER_ADDRESS_KEY: &str = "SERVER.ADDRESS";
|
|||||||
pub(crate) const SERVER_PORT_KEY: &str = "SERVER.PORT";
|
pub(crate) const SERVER_PORT_KEY: &str = "SERVER.PORT";
|
||||||
pub(crate) const SERVER_SERVE_OPENAPI_KEY: &str = "SERVER.SERVE_OPENAPI";
|
pub(crate) const SERVER_SERVE_OPENAPI_KEY: &str = "SERVER.SERVE_OPENAPI";
|
||||||
pub(crate) const SERVER_CORS_ALLOWED_ORIGINS_KEY: &str = "SERVER.CORS.ALLOWED_ORIGINS";
|
pub(crate) const SERVER_CORS_ALLOWED_ORIGINS_KEY: &str = "SERVER.CORS.ALLOWED_ORIGINS";
|
||||||
|
pub(crate) const SERVER_COOKIES_SECURE_KEY: &str = "SERVER.COOKIES.SECURE";
|
||||||
//
|
//
|
||||||
pub(crate) const DATABASE_URL_KEY: &str = "DATABASE.URL";
|
pub(crate) const DATABASE_URL_KEY: &str = "DATABASE.URL";
|
||||||
pub(crate) const DATABASE_MAX_CONNECTIONS_KEY: &str = "DATABASE.MAX_CONNECTIONS";
|
pub(crate) const DATABASE_MAX_CONNECTIONS_KEY: &str = "DATABASE.MAX_CONNECTIONS";
|
||||||
|
|||||||
@@ -49,4 +49,12 @@ impl FromConfig for LoggingSettings {
|
|||||||
fn validate(&self) -> Result<(), String> {
|
fn validate(&self) -> Result<(), String> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn mock() -> Self {
|
||||||
|
LoggingSettings {
|
||||||
|
level: Level::INFO,
|
||||||
|
utc: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ use std::net::IpAddr;
|
|||||||
use config::{Config, ConfigError};
|
use config::{Config, ConfigError};
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
use crate::configs::key::{SERVER_CORS_ALLOWED_ORIGINS_KEY, SERVER_SERVE_OPENAPI_KEY};
|
use crate::configs::key::{
|
||||||
|
SERVER_COOKIES_SECURE_KEY, SERVER_CORS_ALLOWED_ORIGINS_KEY, SERVER_SERVE_OPENAPI_KEY,
|
||||||
|
};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
FromConfig,
|
FromConfig,
|
||||||
@@ -16,6 +18,7 @@ pub struct ServerSettings {
|
|||||||
pub port: u16,
|
pub port: u16,
|
||||||
pub serve_openapi: bool,
|
pub serve_openapi: bool,
|
||||||
pub cors: CORSSettings,
|
pub cors: CORSSettings,
|
||||||
|
pub cookies: CookiesSettings,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -23,6 +26,11 @@ pub struct CORSSettings {
|
|||||||
pub allowed_origins: Vec<String>,
|
pub allowed_origins: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CookiesSettings {
|
||||||
|
pub secure: bool,
|
||||||
|
}
|
||||||
|
|
||||||
impl FromConfig for ServerSettings {
|
impl FromConfig for ServerSettings {
|
||||||
fn from_config(_config: &Config) -> Result<Self, String> {
|
fn from_config(_config: &Config) -> Result<Self, String> {
|
||||||
Ok(ServerSettings {
|
Ok(ServerSettings {
|
||||||
@@ -81,6 +89,24 @@ impl FromConfig for ServerSettings {
|
|||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
cookies: CookiesSettings {
|
||||||
|
secure: _config
|
||||||
|
.get_bool(SERVER_COOKIES_SECURE_KEY)
|
||||||
|
.inspect(|is_secure| {
|
||||||
|
if !*is_secure {
|
||||||
|
warn!("Cookie 'secure' flag is disabled; this is not recommended in production environments.");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|err| {
|
||||||
|
const DEFAULT_COOKIES_SECURE: bool = true;
|
||||||
|
warn!(
|
||||||
|
"{} not set or invalid in configuration, defaulting to {}. Error: {}",
|
||||||
|
SERVER_COOKIES_SECURE_KEY, DEFAULT_COOKIES_SECURE, err
|
||||||
|
);
|
||||||
|
DEFAULT_COOKIES_SECURE
|
||||||
|
}),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,4 +117,17 @@ impl FromConfig for ServerSettings {
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn mock() -> Self {
|
||||||
|
ServerSettings {
|
||||||
|
address: "0.0.0.0".parse().unwrap(),
|
||||||
|
port: 8080,
|
||||||
|
serve_openapi: false,
|
||||||
|
cors: CORSSettings {
|
||||||
|
allowed_origins: vec![],
|
||||||
|
},
|
||||||
|
cookies: CookiesSettings { secure: true },
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use axum::{
|
|||||||
response::Response,
|
response::Response,
|
||||||
};
|
};
|
||||||
use axum_extra::extract::cookie::CookieJar;
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
|
use tracing::debug;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -25,6 +26,7 @@ pub async fn require_auth(
|
|||||||
let token = if let Some(cookie) = cookies.get(JWT_COOKIE_NAME) {
|
let token = if let Some(cookie) = cookies.get(JWT_COOKIE_NAME) {
|
||||||
cookie.value().to_string()
|
cookie.value().to_string()
|
||||||
} else {
|
} else {
|
||||||
|
debug!("No JWT cookie found. cookies: {:?}", cookies);
|
||||||
return handle_unauthenticated().await;
|
return handle_unauthenticated().await;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use axum::{Extension, Router};
|
|||||||
use migration::sea_orm::DatabaseConnection;
|
use migration::sea_orm::DatabaseConnection;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
configs::server::CORSSettings,
|
configs::{ProgramSettings, server::CORSSettings},
|
||||||
middlewares,
|
middlewares,
|
||||||
services::{
|
services::{
|
||||||
auth::{
|
auth::{
|
||||||
@@ -23,10 +23,9 @@ use crate::{
|
|||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
#[allow(dead_code)]
|
|
||||||
pub database_connection: Arc<DatabaseConnection>,
|
pub database_connection: Arc<DatabaseConnection>,
|
||||||
#[allow(dead_code)]
|
|
||||||
pub service: Arc<AppService>,
|
pub service: Arc<AppService>,
|
||||||
|
pub config: Arc<ProgramSettings>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type ServiceState<T> = Arc<T>;
|
pub type ServiceState<T> = Arc<T>;
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ use serde::{Deserialize, Serialize};
|
|||||||
use serde_json::{Value, from_value};
|
use serde_json::{Value, from_value};
|
||||||
use tracing::{error, warn};
|
use tracing::{error, warn};
|
||||||
|
|
||||||
use crate::routes::{AppState, api::openapi::tag::AUTH_TAG};
|
use crate::{
|
||||||
|
helpers::constants::JWT_COOKIE_NAME,
|
||||||
|
routes::{AppState, api::openapi::tag::AUTH_TAG},
|
||||||
|
};
|
||||||
|
|
||||||
/// Login request payload
|
/// Login request payload
|
||||||
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
|
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
@@ -81,9 +84,15 @@ pub async fn login(State(state): State<Arc<AppState>>, Json(payload): Json<Value
|
|||||||
.header(
|
.header(
|
||||||
SET_COOKIE,
|
SET_COOKIE,
|
||||||
format!(
|
format!(
|
||||||
"token={}; HttpOnly; Path=/; Max-Age={}; SameSite=Strict;",
|
"{}={}; HttpOnly; Path=/; Max-Age={}; SameSite=Strict;{}",
|
||||||
|
JWT_COOKIE_NAME,
|
||||||
jwt,
|
jwt,
|
||||||
claims.exp - claims.iat
|
claims.exp - claims.iat,
|
||||||
|
if state.config.server.cookies.secure {
|
||||||
|
" Secure;"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.body(Body::from(()));
|
.body(Body::from(()));
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ pub async fn get_health_info(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod test {
|
mod test {
|
||||||
|
use crate::configs::FromConfig;
|
||||||
use crate::{
|
use crate::{
|
||||||
routes::{AppState, api::health::state::HealthState},
|
routes::{AppState, api::health::state::HealthState},
|
||||||
services::{
|
services::{
|
||||||
@@ -112,6 +113,7 @@ mod test {
|
|||||||
|
|
||||||
let app_state = Arc::new(AppState {
|
let app_state = Arc::new(AppState {
|
||||||
database_connection: db.clone(),
|
database_connection: db.clone(),
|
||||||
|
config: Arc::new(crate::configs::ProgramSettings::mock()),
|
||||||
service: Arc::new(crate::routes::AppService {
|
service: Arc::new(crate::routes::AppService {
|
||||||
settings: Arc::new(SettingsService::new(db.clone())),
|
settings: Arc::new(SettingsService::new(db.clone())),
|
||||||
auth_state: crate::routes::AuthState {
|
auth_state: crate::routes::AuthState {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ pub mod tag {
|
|||||||
/// Health tag constant
|
/// Health tag constant
|
||||||
pub const HEALTH_TAG: &str = "Health";
|
pub const HEALTH_TAG: &str = "Health";
|
||||||
pub const AUTH_TAG: &str = "Authentication";
|
pub const AUTH_TAG: &str = "Authentication";
|
||||||
|
pub const USER_TAG: &str = "User";
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(utoipa::OpenApi)]
|
#[derive(utoipa::OpenApi)]
|
||||||
@@ -11,16 +12,21 @@ pub mod tag {
|
|||||||
// Authentication paths
|
// Authentication paths
|
||||||
crate::routes::api::auth::login::login,
|
crate::routes::api::auth::login::login,
|
||||||
crate::routes::api::auth::init_admin::init_admin,
|
crate::routes::api::auth::init_admin::init_admin,
|
||||||
|
// User management paths
|
||||||
|
crate::routes::api::restricted::user::me::get_user_info,
|
||||||
),
|
),
|
||||||
components(
|
components(
|
||||||
schemas(crate::routes::api::health::info::HealthInfo),
|
schemas(crate::routes::api::health::info::HealthInfo),
|
||||||
// Authentication schemas
|
// Authentication schemas
|
||||||
schemas(crate::routes::api::auth::login::LoginRequest),
|
schemas(crate::routes::api::auth::login::LoginRequest),
|
||||||
schemas(crate::routes::api::auth::init_admin::AdminInitRequest),
|
schemas(crate::routes::api::auth::init_admin::AdminInitRequest),
|
||||||
|
// User management schemas
|
||||||
|
schemas(crate::routes::api::restricted::user::me::UserInfo),
|
||||||
),
|
),
|
||||||
tags(
|
tags(
|
||||||
(name = tag::HEALTH_TAG, description = "Health information API"),
|
(name = tag::HEALTH_TAG, description = "Health information API"),
|
||||||
(name = tag::AUTH_TAG, description = "Authentication API")
|
(name = tag::AUTH_TAG, description = "Authentication API"),
|
||||||
|
(name = tag::USER_TAG, description = "User management API")
|
||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
pub struct ApiDoc;
|
pub struct ApiDoc;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
pub mod user;
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
@@ -6,8 +8,7 @@ use crate::{middlewares::require_auth::require_auth, routes::AppState};
|
|||||||
|
|
||||||
pub fn get_restricted_router(state: Arc<AppState>) -> Router {
|
pub fn get_restricted_router(state: Arc<AppState>) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
//
|
.nest("/user", user::get_user_router(state.clone()))
|
||||||
//
|
|
||||||
.layer(axum::middleware::from_fn_with_state(
|
.layer(axum::middleware::from_fn_with_state(
|
||||||
state.clone(),
|
state.clone(),
|
||||||
require_auth,
|
require_auth,
|
||||||
|
|||||||
13
apps/api/src/routes/api/restricted/user.rs
Normal file
13
apps/api/src/routes/api/restricted/user.rs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
pub mod me;
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::Router;
|
||||||
|
|
||||||
|
use crate::routes::AppState;
|
||||||
|
|
||||||
|
pub fn get_user_router(state: Arc<AppState>) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/me", axum::routing::get(me::get_user_info))
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
64
apps/api/src/routes/api/restricted/user/me.rs
Normal file
64
apps/api/src/routes/api/restricted/user/me.rs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
Extension, Json,
|
||||||
|
extract::State,
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tracing::error;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
middlewares::request_info::RequestInfo,
|
||||||
|
routes::{AppState, api::openapi::tag::USER_TAG},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// System health information
|
||||||
|
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct UserInfo {
|
||||||
|
/// User ID
|
||||||
|
pub id: uuid::Uuid,
|
||||||
|
/// Username
|
||||||
|
pub username: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current user information
|
||||||
|
///
|
||||||
|
/// Returns the information of the currently authenticated user.
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/user/me",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "User information retrieved successfully", body = UserInfo),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
(status = 500, description = "Internal server error"),
|
||||||
|
),
|
||||||
|
tag = USER_TAG,
|
||||||
|
)]
|
||||||
|
pub async fn get_user_info(
|
||||||
|
State(app_state): State<Arc<AppState>>,
|
||||||
|
request_info: Extension<Arc<RequestInfo>>,
|
||||||
|
) -> Response {
|
||||||
|
let user_id = match request_info.user_id {
|
||||||
|
Some(id) => id,
|
||||||
|
None => {
|
||||||
|
error!("User ID not found in request info");
|
||||||
|
return (StatusCode::UNAUTHORIZED).into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match app_state.service.user.get_user_by_id(user_id, None).await {
|
||||||
|
Ok(user) => {
|
||||||
|
let user_info = UserInfo {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
};
|
||||||
|
(StatusCode::OK, Json(user_info)).into_response()
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
error!("Error fetching user info: {}", err);
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ use std::{collections::HashSet, sync::Arc};
|
|||||||
use argon2::password_hash::{SaltString, rand_core::OsRng};
|
use argon2::password_hash::{SaltString, rand_core::OsRng};
|
||||||
use jsonwebtoken::{
|
use jsonwebtoken::{
|
||||||
DecodingKey, EncodingKey, Header, Validation, decode, encode,
|
DecodingKey, EncodingKey, Header, Validation, decode, encode,
|
||||||
errors::ErrorKind::{ExpiredSignature, InvalidSubject, InvalidToken},
|
errors::ErrorKind::{ExpiredSignature, InvalidSignature, InvalidSubject, InvalidToken},
|
||||||
};
|
};
|
||||||
use sea_orm::prelude::Uuid;
|
use sea_orm::prelude::Uuid;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -124,7 +124,7 @@ impl AuthenticationService for AuthenticationServiceImpl {
|
|||||||
match decode::<Claims>(token, &decoding_key, &validation) {
|
match decode::<Claims>(token, &decoding_key, &validation) {
|
||||||
Ok(data) => Ok(Some(data.claims)),
|
Ok(data) => Ok(Some(data.claims)),
|
||||||
Err(err) => match *err.kind() {
|
Err(err) => match *err.kind() {
|
||||||
InvalidToken | InvalidSubject | ExpiredSignature => Ok(None),
|
InvalidToken | InvalidSubject | ExpiredSignature | InvalidSignature => Ok(None),
|
||||||
_ => Err(ServiceError::InternalError(format!(
|
_ => Err(ServiceError::InternalError(format!(
|
||||||
"JWT validation error: {}",
|
"JWT validation error: {}",
|
||||||
err
|
err
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ pub trait UserService: Send + Sync {
|
|||||||
|
|
||||||
pub struct User {
|
pub struct User {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
#[allow(dead_code)] // TODO: remove when used
|
|
||||||
pub username: String,
|
pub username: String,
|
||||||
#[allow(dead_code)] // TODO: remove when used
|
#[allow(dead_code)] // TODO: remove when used
|
||||||
pub is_admin: bool,
|
pub is_admin: bool,
|
||||||
|
|||||||
@@ -11,14 +11,11 @@ use crate::errors::service_error::ServiceError;
|
|||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
pub trait SettingsStore: Send + Sync {
|
pub trait SettingsStore: Send + Sync {
|
||||||
#[allow(dead_code)] // TODO: remove when used
|
|
||||||
async fn get_setting(&self, key: &str) -> Result<String, ServiceError>;
|
async fn get_setting(&self, key: &str) -> Result<String, ServiceError>;
|
||||||
#[allow(dead_code)] // TODO: remove when used
|
|
||||||
async fn set_setting(&self, key: &str, value: String) -> Result<(), ServiceError>;
|
async fn set_setting(&self, key: &str, value: String) -> Result<(), ServiceError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct SettingsService {
|
pub struct SettingsService {
|
||||||
#[allow(dead_code)] // TODO: remove when used
|
|
||||||
connection: Arc<DatabaseConnection>,
|
connection: Arc<DatabaseConnection>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -105,6 +105,34 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"/api/user/me": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"User"
|
||||||
|
],
|
||||||
|
"summary": "Get current user information",
|
||||||
|
"description": "Returns the information of the currently authenticated user.",
|
||||||
|
"operationId": "get_user_info",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "User information retrieved successfully",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/UserInfo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal server error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"components": {
|
"components": {
|
||||||
@@ -183,6 +211,25 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"UserInfo": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "System health information",
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"username"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"description": "User ID"
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Username"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -194,6 +241,10 @@
|
|||||||
{
|
{
|
||||||
"name": "Authentication",
|
"name": "Authentication",
|
||||||
"description": "Authentication API"
|
"description": "Authentication API"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "User",
|
||||||
|
"description": "User management API"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@ import type { AnyFieldMeta } from '@tanstack/react-form';
|
|||||||
import { LucideEye, LucideEyeClosed } from 'lucide-react';
|
import { LucideEye, LucideEyeClosed } from 'lucide-react';
|
||||||
import { useCallback, useId, useState } from 'react';
|
import { useCallback, useId, useState } from 'react';
|
||||||
import { InfoIcon, type InfoIconProps } from '../info';
|
import { InfoIcon, type InfoIconProps } from '../info';
|
||||||
|
import { Text } from '@radix-ui/themes';
|
||||||
|
|
||||||
export type TextFieldProps = {
|
export type TextFieldProps = {
|
||||||
label?: string;
|
label?: string;
|
||||||
@@ -32,6 +33,11 @@ export function TextField({ label, value, onChange, labelProps, labelDivProps, s
|
|||||||
{label && (
|
{label && (
|
||||||
<div style={{ fontSize: 12, color: 'var(--gray-9)', marginBottom: 6, display: 'flex', alignItems: 'center' }} {...labelDivProps}>
|
<div style={{ fontSize: 12, color: 'var(--gray-9)', marginBottom: 6, display: 'flex', alignItems: 'center' }} {...labelDivProps}>
|
||||||
{label}
|
{label}
|
||||||
|
{rest?.required && (
|
||||||
|
<Text size="3" style={{ color: 'var(--red-9)', marginLeft: 2 }}>
|
||||||
|
*
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
{infoIconProps && <InfoIcon {...infoIconProps} style={{ marginLeft: 4, verticalAlign: 'middle' }} />}
|
{infoIconProps && <InfoIcon {...infoIconProps} style={{ marginLeft: 4, verticalAlign: 'middle' }} />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
1
apps/frontend/app/empty-toastify.css
Normal file
1
apps/frontend/app/empty-toastify.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/* intentionally empty: used to stub react-toastify CSS in production builds */
|
||||||
@@ -9,6 +9,7 @@ export namespace Schemas {
|
|||||||
version: string;
|
version: string;
|
||||||
};
|
};
|
||||||
export type LoginRequest = { password: string; username: string };
|
export type LoginRequest = { password: string; username: string };
|
||||||
|
export type UserInfo = { id: string; username: string };
|
||||||
|
|
||||||
// </Schemas>
|
// </Schemas>
|
||||||
}
|
}
|
||||||
@@ -41,6 +42,13 @@ export namespace Endpoints {
|
|||||||
parameters: never;
|
parameters: never;
|
||||||
responses: { 200: Schemas.HealthInfo; 404: unknown };
|
responses: { 200: Schemas.HealthInfo; 404: unknown };
|
||||||
};
|
};
|
||||||
|
export type get_Get_user_info = {
|
||||||
|
method: "GET";
|
||||||
|
path: "/api/user/me";
|
||||||
|
requestFormat: "json";
|
||||||
|
parameters: never;
|
||||||
|
responses: { 200: Schemas.UserInfo; 401: unknown; 500: unknown };
|
||||||
|
};
|
||||||
|
|
||||||
// </Endpoints>
|
// </Endpoints>
|
||||||
}
|
}
|
||||||
@@ -53,6 +61,7 @@ export type EndpointByMethod = {
|
|||||||
};
|
};
|
||||||
get: {
|
get: {
|
||||||
"/api/health/info": Endpoints.get_Get_health_info;
|
"/api/health/info": Endpoints.get_Get_health_info;
|
||||||
|
"/api/user/me": Endpoints.get_Get_user_info;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Text } from '@radix-ui/themes';
|
|
||||||
import { AxiosError } from 'axios';
|
import { AxiosError } from 'axios';
|
||||||
import { useLocation, useNavigate } from 'react-router';
|
import { useLocation, useNavigate } from 'react-router';
|
||||||
import { toast } from 'react-toastify';
|
|
||||||
import { SearchParamKeys } from '../lib/constants';
|
import { SearchParamKeys } from '../lib/constants';
|
||||||
|
import { useQueryMessage } from './useQueryMessage';
|
||||||
|
import { QueryMessageCode, QueryMessageType } from '../lib/QueryMessages';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { displayForbiddenErrorToast, displayNetworkErrorToast, displayUnexpectedErrorToast } from '../lib/toasts';
|
||||||
|
|
||||||
export enum ResponseErrorToastId {
|
export enum ResponseErrorToastId {
|
||||||
NetworkError = 'network-error',
|
NetworkError = 'network-error',
|
||||||
@@ -19,26 +21,18 @@ export type DefaultResponseErrorHandlerOptions = {
|
|||||||
* @param err error value
|
* @param err error value
|
||||||
* @returns {boolean} true if the error was handled, false otherwise
|
* @returns {boolean} true if the error was handled, false otherwise
|
||||||
*/
|
*/
|
||||||
const defaultResponseErrorHandler =
|
|
||||||
(navigate: ReturnType<typeof useNavigate>, location: ReturnType<typeof useLocation>) =>
|
export function useResponseErrorHandler(): {
|
||||||
|
defaultResponseErrorHandler: typeof defaultResponseErrorHandler;
|
||||||
|
} {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const { toSearchParamQueryMessage } = useQueryMessage();
|
||||||
|
|
||||||
|
const defaultResponseErrorHandler = useCallback(
|
||||||
(err: unknown, options?: DefaultResponseErrorHandlerOptions): boolean => {
|
(err: unknown, options?: DefaultResponseErrorHandlerOptions): boolean => {
|
||||||
if (!(err instanceof AxiosError) && !options?.disableHandleUnexpectedErrors) {
|
if (!(err instanceof AxiosError) && !options?.disableHandleUnexpectedErrors) {
|
||||||
toast.error(
|
displayUnexpectedErrorToast();
|
||||||
<div>
|
|
||||||
<Text weight="bold">Unexpected Error:</Text>
|
|
||||||
<br /> An unexpected error occurred. Please try again later.
|
|
||||||
</div>,
|
|
||||||
{
|
|
||||||
position: 'top-center',
|
|
||||||
autoClose: false,
|
|
||||||
hideProgressBar: false,
|
|
||||||
closeOnClick: true,
|
|
||||||
pauseOnHover: true,
|
|
||||||
draggable: false,
|
|
||||||
progress: undefined,
|
|
||||||
theme: 'colored',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,23 +44,7 @@ const defaultResponseErrorHandler =
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (err.message === 'Network Error') {
|
if (err.message === 'Network Error') {
|
||||||
toast.error(
|
displayNetworkErrorToast();
|
||||||
<div>
|
|
||||||
<Text weight="bold">Network Error:</Text>
|
|
||||||
<br /> Unable to reach the server. Please check your internet connection and try again.
|
|
||||||
</div>,
|
|
||||||
{
|
|
||||||
toastId: ResponseErrorToastId.NetworkError,
|
|
||||||
position: 'top-center',
|
|
||||||
autoClose: false,
|
|
||||||
hideProgressBar: false,
|
|
||||||
closeOnClick: true,
|
|
||||||
pauseOnHover: true,
|
|
||||||
draggable: false,
|
|
||||||
progress: undefined,
|
|
||||||
theme: 'colored',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,36 +54,20 @@ const defaultResponseErrorHandler =
|
|||||||
const currentPath = location.pathname + location.search;
|
const currentPath = location.pathname + location.search;
|
||||||
const searchParam = new URLSearchParams();
|
const searchParam = new URLSearchParams();
|
||||||
searchParam.set(SearchParamKeys.Redirect, currentPath);
|
searchParam.set(SearchParamKeys.Redirect, currentPath);
|
||||||
searchParam.set(SearchParamKeys.Message, 'Session expired, please log in again');
|
searchParam.set(SearchParamKeys.Message, toSearchParamQueryMessage(QueryMessageCode.SessionExpired, QueryMessageType.Info));
|
||||||
navigate(`/login?${searchParam.toString()}`);
|
navigate(`/login?${searchParam.toString()}`);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (err.status === 403) {
|
if (err.status === 403) {
|
||||||
toast.error(
|
displayForbiddenErrorToast();
|
||||||
<div>
|
|
||||||
<Text weight="bold">Forbidden:</Text>
|
|
||||||
<br /> You do not have permission to perform this action.
|
|
||||||
</div>,
|
|
||||||
{
|
|
||||||
position: 'top-center',
|
|
||||||
autoClose: 5000,
|
|
||||||
hideProgressBar: false,
|
|
||||||
closeOnClick: true,
|
|
||||||
pauseOnHover: true,
|
|
||||||
draggable: false,
|
|
||||||
progress: undefined,
|
|
||||||
theme: 'colored',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
};
|
},
|
||||||
|
[location, navigate, toSearchParamQueryMessage]
|
||||||
|
);
|
||||||
|
|
||||||
export function useResponseErrorHandler() {
|
return { defaultResponseErrorHandler };
|
||||||
const navigate = useNavigate();
|
|
||||||
const location = useLocation();
|
|
||||||
return { defaultResponseErrorHandler: defaultResponseErrorHandler(navigate, location) };
|
|
||||||
}
|
}
|
||||||
|
|||||||
48
apps/frontend/app/hooks/ensureLoggedIn.tsx
Normal file
48
apps/frontend/app/hooks/ensureLoggedIn.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
import { useAuth } from '../providers/AuthProvider';
|
||||||
|
import { useApi } from '../providers/ApiProvider';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useResponseErrorHandler } from './ResponseHelper';
|
||||||
|
|
||||||
|
export type EnsureLoggedInResult = {
|
||||||
|
checking: boolean;
|
||||||
|
loggedIn: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useEnsureLoggedIn(): EnsureLoggedInResult {
|
||||||
|
const { user, setUser } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { tanstackApiClient } = useApi();
|
||||||
|
const { defaultResponseErrorHandler } = useResponseErrorHandler();
|
||||||
|
|
||||||
|
const { queryOptions: currentUserQuery } = tanstackApiClient.get('/api/user/me');
|
||||||
|
const { isFetched, isPending } = useQuery({
|
||||||
|
...currentUserQuery,
|
||||||
|
queryFn: async (...args) => {
|
||||||
|
try {
|
||||||
|
const data = await currentUserQuery.queryFn!(...args);
|
||||||
|
setUser({
|
||||||
|
id: data.id,
|
||||||
|
name: data.username,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
if (defaultResponseErrorHandler(error)) return {} as never;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
navigate('/', { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}, [user, setUser, navigate]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
checking: isPending,
|
||||||
|
loggedIn: isFetched && !!user,
|
||||||
|
};
|
||||||
|
}
|
||||||
111
apps/frontend/app/hooks/useQueryMessage.tsx
Normal file
111
apps/frontend/app/hooks/useQueryMessage.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { useCallback, useEffect, useRef, type ReactNode } from 'react';
|
||||||
|
import { useLocation, useSearchParams } from 'react-router';
|
||||||
|
import { toast } from 'react-toastify/unstyled';
|
||||||
|
import { SearchParamKeys } from '../lib/constants';
|
||||||
|
import { CODE_TO_MESSAGE_MAP, QueryMessageCode, QueryMessageType } from '../lib/QueryMessages';
|
||||||
|
|
||||||
|
type QueryMessageString = `${QueryMessageCode}__${QueryMessageType}`;
|
||||||
|
|
||||||
|
export type QueryMessage = {
|
||||||
|
type: QueryMessageType;
|
||||||
|
code: QueryMessageCode;
|
||||||
|
message: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UseQueryMessageOptions = {
|
||||||
|
displayMessages?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UseQueryMessageReturn = {
|
||||||
|
setQueryMessage: (messageCode: QueryMessageCode, messageType: QueryMessageType) => void;
|
||||||
|
clearQueryMessage: () => void;
|
||||||
|
toSearchParamQueryMessage: (message: QueryMessageCode, type: QueryMessageType) => QueryMessageString;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useQueryMessage(
|
||||||
|
{ displayMessages }: UseQueryMessageOptions = {
|
||||||
|
displayMessages: true,
|
||||||
|
}
|
||||||
|
): UseQueryMessageReturn {
|
||||||
|
const location = useLocation();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const messageStr = useRef<QueryMessageString | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Reset messageStr when location changes to allow re-displaying the same message on navigation
|
||||||
|
messageStr.current = null;
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const queryMessageStr = searchParams.get(SearchParamKeys.Message);
|
||||||
|
if (!(queryMessageStr && queryMessageStr !== messageStr.current)) return;
|
||||||
|
const [queryMessage, queryMessageString] = toQueryMessage(queryMessageStr) ?? [null, null];
|
||||||
|
if (!queryMessage) return;
|
||||||
|
|
||||||
|
messageStr.current = queryMessageString;
|
||||||
|
if (displayMessages) {
|
||||||
|
toast[queryMessage.type](queryMessage.message, {
|
||||||
|
position: 'top-center',
|
||||||
|
autoClose: 5000,
|
||||||
|
hideProgressBar: false,
|
||||||
|
closeOnClick: true,
|
||||||
|
pauseOnHover: true,
|
||||||
|
draggable: false,
|
||||||
|
progress: undefined,
|
||||||
|
theme: 'colored',
|
||||||
|
toastId: 'login-route-info-message',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [displayMessages, searchParams]);
|
||||||
|
|
||||||
|
const setQueryMessage = useCallback(
|
||||||
|
(messageCode: QueryMessageCode, messageType: QueryMessageType) => {
|
||||||
|
const queryMessageString: QueryMessageString = `${messageCode}__${messageType}`;
|
||||||
|
messageStr.current = queryMessageString;
|
||||||
|
setSearchParams((prev) => {
|
||||||
|
prev.set(SearchParamKeys.Message, queryMessageString);
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[setSearchParams]
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearQueryMessage = useCallback(() => {
|
||||||
|
messageStr.current = null;
|
||||||
|
setSearchParams((prev) => {
|
||||||
|
prev.delete(SearchParamKeys.Message);
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
}, [setSearchParams]);
|
||||||
|
|
||||||
|
const toSearchParamQueryMessage = useCallback((message: QueryMessageCode, type: QueryMessageType): QueryMessageString => {
|
||||||
|
return `${message}__${type}`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
setQueryMessage,
|
||||||
|
clearQueryMessage,
|
||||||
|
toSearchParamQueryMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidQueryMessageCode(code: string): code is QueryMessageCode {
|
||||||
|
return Object.values(QueryMessageCode).includes(code as QueryMessageCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidQueryMessageType(type: string): type is QueryMessageType {
|
||||||
|
return Object.values(QueryMessageType).includes(type as QueryMessageType);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toQueryMessage(value: string): [QueryMessage, QueryMessageString] | null {
|
||||||
|
const [code, type] = value.split('__');
|
||||||
|
if (!isValidQueryMessageCode(code) || !isValidQueryMessageType(type)) return null;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
code: code,
|
||||||
|
type: type,
|
||||||
|
message: CODE_TO_MESSAGE_MAP[code],
|
||||||
|
},
|
||||||
|
`${code}__${type}`,
|
||||||
|
];
|
||||||
|
}
|
||||||
20
apps/frontend/app/lib/QueryMessages.tsx
Normal file
20
apps/frontend/app/lib/QueryMessages.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export enum QueryMessageType {
|
||||||
|
Info = 'info',
|
||||||
|
Success = 'success',
|
||||||
|
Warning = 'warning',
|
||||||
|
Error = 'error',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum QueryMessageCode {
|
||||||
|
SessionExpired = 'SESSION_EXPIRED',
|
||||||
|
InitializationRequired = 'INITIALIZATION_REQUIRED',
|
||||||
|
InitializationSuccessful = 'INITIALIZATION_SUCCESSFUL',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CODE_TO_MESSAGE_MAP: Record<QueryMessageCode, ReactNode> = {
|
||||||
|
[QueryMessageCode.SessionExpired]: 'Your session has expired. Please log in again.',
|
||||||
|
[QueryMessageCode.InitializationRequired]: 'The application requires initialization. Please follow the setup instructions.',
|
||||||
|
[QueryMessageCode.InitializationSuccessful]: 'Initialization successful. Please log in.',
|
||||||
|
} as const;
|
||||||
64
apps/frontend/app/lib/toasts.tsx
Normal file
64
apps/frontend/app/lib/toasts.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { toast, type ToastOptions } from 'react-toastify/unstyled';
|
||||||
|
import { Text } from '@radix-ui/themes';
|
||||||
|
import { ResponseErrorToastId } from '../hooks/ResponseHelper';
|
||||||
|
|
||||||
|
export const displayUnexpectedErrorToast = (options: ToastOptions = {}) => {
|
||||||
|
toast.error(
|
||||||
|
<div>
|
||||||
|
<Text weight="bold">Unexpected Error:</Text>
|
||||||
|
<br /> An unexpected error occurred. Please try again later.
|
||||||
|
</div>,
|
||||||
|
{
|
||||||
|
position: 'top-center',
|
||||||
|
autoClose: false,
|
||||||
|
hideProgressBar: false,
|
||||||
|
closeOnClick: true,
|
||||||
|
pauseOnHover: true,
|
||||||
|
draggable: false,
|
||||||
|
progress: undefined,
|
||||||
|
theme: 'colored',
|
||||||
|
...options,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const displayNetworkErrorToast = (options: ToastOptions = {}) => {
|
||||||
|
toast.error(
|
||||||
|
<div>
|
||||||
|
<Text weight="bold">Network Error:</Text>
|
||||||
|
<br /> Unable to reach the server. Please check your internet connection and try again.
|
||||||
|
</div>,
|
||||||
|
{
|
||||||
|
toastId: ResponseErrorToastId.NetworkError,
|
||||||
|
position: 'top-center',
|
||||||
|
autoClose: false,
|
||||||
|
hideProgressBar: false,
|
||||||
|
closeOnClick: true,
|
||||||
|
pauseOnHover: true,
|
||||||
|
draggable: false,
|
||||||
|
progress: undefined,
|
||||||
|
theme: 'colored',
|
||||||
|
...options,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const displayForbiddenErrorToast = (options: ToastOptions = {}) => {
|
||||||
|
toast.error(
|
||||||
|
<div>
|
||||||
|
<Text weight="bold">Forbidden:</Text>
|
||||||
|
<br /> You do not have permission to perform this action.
|
||||||
|
</div>,
|
||||||
|
{
|
||||||
|
position: 'top-center',
|
||||||
|
autoClose: 5000,
|
||||||
|
hideProgressBar: false,
|
||||||
|
closeOnClick: true,
|
||||||
|
pauseOnHover: true,
|
||||||
|
draggable: false,
|
||||||
|
progress: undefined,
|
||||||
|
theme: 'colored',
|
||||||
|
...options,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
56
apps/frontend/app/providers/ApiHealthProvider.tsx
Normal file
56
apps/frontend/app/providers/ApiHealthProvider.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { createContext, use, type PropsWithChildren } from 'react';
|
||||||
|
import { useApi } from './ApiProvider';
|
||||||
|
import { useResponseErrorHandler } from '../hooks/ResponseHelper';
|
||||||
|
import type { Schemas } from '../generated/api-client/api-client';
|
||||||
|
|
||||||
|
export type HealthStatus = Schemas.HealthInfo;
|
||||||
|
|
||||||
|
export type ApiHealthProviderProps = PropsWithChildren<object>;
|
||||||
|
export type ApiHealthContextType = {
|
||||||
|
healthStatus: HealthStatus | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ApiHealthContext = createContext<ApiHealthContextType | null>(null);
|
||||||
|
|
||||||
|
export const ApiHealthProvider: React.FC<ApiHealthProviderProps> = ({ children }) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { tanstackApiClient } = useApi();
|
||||||
|
const { defaultResponseErrorHandler } = useResponseErrorHandler();
|
||||||
|
|
||||||
|
const { queryOptions: healthInfoQuery } = tanstackApiClient.get('/api/health/info');
|
||||||
|
const { data } = useQuery({
|
||||||
|
...healthInfoQuery,
|
||||||
|
queryFn: async (...args) => {
|
||||||
|
try {
|
||||||
|
const data = await healthInfoQuery.queryFn!(...args);
|
||||||
|
if (!data.is_initialized) {
|
||||||
|
navigate('/init');
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
if (defaultResponseErrorHandler(error)) return {} as never;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ApiHealthContext
|
||||||
|
value={{
|
||||||
|
healthStatus: data,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ApiHealthContext>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useApiHealth = (): ApiHealthContextType => {
|
||||||
|
const context = use(ApiHealthContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useApiHealth must be used within an ApiHealthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
@@ -2,7 +2,6 @@ import { createContext, use, type PropsWithChildren } from 'react';
|
|||||||
import { createTanstackApi, createApi } from '../lib/api';
|
import { createTanstackApi, createApi } from '../lib/api';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { useNavigate } from 'react-router';
|
|
||||||
|
|
||||||
type ApiProviderProps = PropsWithChildren<object>;
|
type ApiProviderProps = PropsWithChildren<object>;
|
||||||
type ApiContextType = {
|
type ApiContextType = {
|
||||||
|
|||||||
47
apps/frontend/app/providers/AuthProvider.tsx
Normal file
47
apps/frontend/app/providers/AuthProvider.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { createContext, use, useCallback, useState, type PropsWithChildren } from 'react';
|
||||||
|
|
||||||
|
export type User = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AuthProviderProps = PropsWithChildren<object>;
|
||||||
|
export type AuthContextType = {
|
||||||
|
setUser: (user: User) => void;
|
||||||
|
logOut: () => void;
|
||||||
|
user: User | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | null>(null);
|
||||||
|
|
||||||
|
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
||||||
|
const [user, setUserState] = useState<User | null>(null);
|
||||||
|
|
||||||
|
const setUser = useCallback((user: User) => {
|
||||||
|
setUserState(user);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
setUserState(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext
|
||||||
|
value={{
|
||||||
|
user: user,
|
||||||
|
logOut: logout,
|
||||||
|
setUser: setUser,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AuthContext>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = use(AuthContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAuth must be used within a AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -2,10 +2,17 @@ import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration }
|
|||||||
import type { Route } from './+types/root';
|
import type { Route } from './+types/root';
|
||||||
import '@radix-ui/themes/styles.css';
|
import '@radix-ui/themes/styles.css';
|
||||||
import './app.css';
|
import './app.css';
|
||||||
|
// start: react-toastify special import
|
||||||
|
// ! MUST use unstyled version for dev server build, styled version for production build is handled in vite.config.ts
|
||||||
|
import { ToastContainer } from 'react-toastify/unstyled';
|
||||||
|
import 'react-toastify/ReactToastify.css';
|
||||||
|
// end: react-toastify special import
|
||||||
import AppTheme from './components/theme';
|
import AppTheme from './components/theme';
|
||||||
import { ApiProvider } from './providers/ApiProvider';
|
import { ApiProvider } from './providers/ApiProvider';
|
||||||
import { LayoutProvider } from './providers/LayoutProvider';
|
import { LayoutProvider } from './providers/LayoutProvider';
|
||||||
import { Tooltip } from 'radix-ui';
|
import { Tooltip } from 'radix-ui';
|
||||||
|
import { AuthProvider } from './providers/AuthProvider';
|
||||||
|
import { ApiHealthProvider } from './providers/ApiHealthProvider';
|
||||||
|
|
||||||
export const links: Route.LinksFunction = () => [];
|
export const links: Route.LinksFunction = () => [];
|
||||||
|
|
||||||
@@ -29,15 +36,23 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<AppTheme>
|
<AppTheme>
|
||||||
<ApiProvider>
|
<ApiProvider>
|
||||||
<Tooltip.Provider delayDuration={250}>
|
<Tooltip.Provider delayDuration={250}>
|
||||||
<LayoutProvider>
|
<LayoutProvider>
|
||||||
|
<ApiHealthProvider>
|
||||||
|
<AuthProvider>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
</AuthProvider>
|
||||||
|
</ApiHealthProvider>
|
||||||
</LayoutProvider>
|
</LayoutProvider>
|
||||||
</Tooltip.Provider>
|
</Tooltip.Provider>
|
||||||
</ApiProvider>
|
</ApiProvider>
|
||||||
</AppTheme>
|
</AppTheme>
|
||||||
|
|
||||||
|
<ToastContainer />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { Box, Container, Flex, Heading } from '@radix-ui/themes';
|
import { Box, Container, Flex, Heading } from '@radix-ui/themes';
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import { useLocation, useNavigate } from 'react-router';
|
import { useLocation, useNavigate } from 'react-router';
|
||||||
import { Slide, toast, ToastContainer } from 'react-toastify';
|
import { toast } from 'react-toastify/unstyled';
|
||||||
import * as v from 'valibot';
|
import * as v from 'valibot';
|
||||||
import { useResponseErrorHandler } from '../../hooks/ResponseHelper';
|
import { useResponseErrorHandler } from '../../hooks/ResponseHelper';
|
||||||
import { useApi } from '../../providers/ApiProvider';
|
import { useApi } from '../../providers/ApiProvider';
|
||||||
import { formHook } from '../../providers/FormProvider';
|
import { formHook } from '../../providers/FormProvider';
|
||||||
import type { Route } from './+types/login';
|
import type { Route } from './+types/login';
|
||||||
import { SearchParamKeys } from '../../lib/constants';
|
import { SearchParamKeys } from '../../lib/constants';
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { AxiosError } from 'axios';
|
import { AxiosError } from 'axios';
|
||||||
|
import { useQueryMessage } from '../../hooks/useQueryMessage';
|
||||||
|
|
||||||
const loginFormSchema = v.object({
|
const loginFormSchema = v.object({
|
||||||
username: v.pipe(v.string(), v.trim(), v.minLength(1, 'Username is required')),
|
username: v.pipe(v.string(), v.trim(), v.minLength(1, 'Username is required')),
|
||||||
@@ -27,7 +27,7 @@ export default function LoginRoute() {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { tanstackApiClient } = useApi();
|
const { tanstackApiClient } = useApi();
|
||||||
const { defaultResponseErrorHandler } = useResponseErrorHandler();
|
const { defaultResponseErrorHandler } = useResponseErrorHandler();
|
||||||
const [previousSearchParamMessage, setPreviousSearchParamMessage] = useState<string>('');
|
useQueryMessage();
|
||||||
|
|
||||||
const { mutateAsync: login, isPending } = useMutation({
|
const { mutateAsync: login, isPending } = useMutation({
|
||||||
...tanstackApiClient.mutation('post', '/api/auth/login').mutationOptions,
|
...tanstackApiClient.mutation('post', '/api/auth/login').mutationOptions,
|
||||||
@@ -75,25 +75,6 @@ export default function LoginRoute() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const searchParams = new URLSearchParams(location.search);
|
|
||||||
const message = searchParams.get(SearchParamKeys.Message);
|
|
||||||
if (message && message !== previousSearchParamMessage) {
|
|
||||||
setPreviousSearchParamMessage(message);
|
|
||||||
toast.info(message, {
|
|
||||||
position: 'top-center',
|
|
||||||
autoClose: 5000,
|
|
||||||
hideProgressBar: false,
|
|
||||||
closeOnClick: true,
|
|
||||||
pauseOnHover: true,
|
|
||||||
draggable: false,
|
|
||||||
progress: undefined,
|
|
||||||
theme: 'colored',
|
|
||||||
toastId: 'login-route-info-message',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [location.search]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Flex align="center" justify="center" style={{ minHeight: 'calc(100vh - 64px)' }}>
|
<Flex align="center" justify="center" style={{ minHeight: 'calc(100vh - 64px)' }}>
|
||||||
@@ -167,17 +148,6 @@ export default function LoginRoute() {
|
|||||||
</Box>
|
</Box>
|
||||||
</Container>
|
</Container>
|
||||||
</Flex>
|
</Flex>
|
||||||
<ToastContainer
|
|
||||||
position="top-center"
|
|
||||||
autoClose={false}
|
|
||||||
newestOnTop={false}
|
|
||||||
closeOnClick
|
|
||||||
rtl={false}
|
|
||||||
pauseOnFocusLoss
|
|
||||||
draggable={false}
|
|
||||||
theme="colored"
|
|
||||||
transition={Slide}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Box, Button, Card, Flex, Grid, Heading, Text } from '@radix-ui/themes';
|
|||||||
import type { Route } from './+types/home';
|
import type { Route } from './+types/home';
|
||||||
import TablePlaceholder from '../components/home/TablePlaceholder';
|
import TablePlaceholder from '../components/home/TablePlaceholder';
|
||||||
import { useLayout } from '../providers/LayoutProvider';
|
import { useLayout } from '../providers/LayoutProvider';
|
||||||
|
import { useEnsureLoggedIn } from '../hooks/ensureLoggedIn';
|
||||||
|
|
||||||
// eslint-disable-next-line no-empty-pattern
|
// eslint-disable-next-line no-empty-pattern
|
||||||
export function meta({}: Route.MetaArgs) {
|
export function meta({}: Route.MetaArgs) {
|
||||||
@@ -9,6 +10,7 @@ export function meta({}: Route.MetaArgs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ProxyHostDemo() {
|
export default function ProxyHostDemo() {
|
||||||
|
useEnsureLoggedIn();
|
||||||
const { activeTab } = useLayout();
|
const { activeTab } = useLayout();
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { Box, Container, Flex, Heading, Text } from '@radix-ui/themes';
|
import { Box, Container, Flex, Heading, Text } from '@radix-ui/themes';
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { Slide, toast, ToastContainer } from 'react-toastify';
|
import { toast } from 'react-toastify/unstyled';
|
||||||
import * as v from 'valibot';
|
import * as v from 'valibot';
|
||||||
import { useResponseErrorHandler } from '../hooks/ResponseHelper';
|
import { useResponseErrorHandler } from '../hooks/ResponseHelper';
|
||||||
import { useApi } from '../providers/ApiProvider';
|
import { useApi } from '../providers/ApiProvider';
|
||||||
import { formHook } from '../providers/FormProvider';
|
import { formHook } from '../providers/FormProvider';
|
||||||
import { TooltipContentContainer } from '../components/info';
|
import { TooltipContentContainer } from '../components/info';
|
||||||
import { SearchParamKeys } from '../lib/constants';
|
import { SearchParamKeys } from '../lib/constants';
|
||||||
|
import { useQueryMessage } from '../hooks/useQueryMessage';
|
||||||
|
import { QueryMessageCode, QueryMessageType } from '../lib/QueryMessages';
|
||||||
|
|
||||||
const initFormSchema = v.object({
|
const initFormSchema = v.object({
|
||||||
username: v.pipe(v.string(), v.trim(), v.minLength(1, 'Username is required')),
|
username: v.pipe(v.string(), v.trim(), v.minLength(1, 'Username is required')),
|
||||||
@@ -19,12 +21,13 @@ export default function InitRoute() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { tanstackApiClient } = useApi();
|
const { tanstackApiClient } = useApi();
|
||||||
const { defaultResponseErrorHandler } = useResponseErrorHandler();
|
const { defaultResponseErrorHandler } = useResponseErrorHandler();
|
||||||
|
const { toSearchParamQueryMessage } = useQueryMessage();
|
||||||
|
|
||||||
const { mutateAsync: initAdmin, isPending } = useMutation({
|
const { mutateAsync: initAdmin, isPending } = useMutation({
|
||||||
...tanstackApiClient.mutation('post', '/api/auth/init_admin').mutationOptions,
|
...tanstackApiClient.mutation('post', '/api/auth/init_admin').mutationOptions,
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
searchParams.set(SearchParamKeys.Message, 'Initialization successful. Please log in.');
|
searchParams.set(SearchParamKeys.Message, toSearchParamQueryMessage(QueryMessageCode.InitializationSuccessful, QueryMessageType.Success));
|
||||||
navigate(`/login?${searchParams.toString()}`);
|
navigate(`/login?${searchParams.toString()}`);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@@ -40,7 +43,7 @@ export default function InitRoute() {
|
|||||||
try {
|
try {
|
||||||
const data = await healthInfoQuery.queryFn!(...args);
|
const data = await healthInfoQuery.queryFn!(...args);
|
||||||
if (data.is_initialized) {
|
if (data.is_initialized) {
|
||||||
navigate('/');
|
navigate('/login', { replace: true });
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
@@ -153,18 +156,6 @@ export default function InitRoute() {
|
|||||||
</Box>
|
</Box>
|
||||||
</Container>
|
</Container>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
<ToastContainer
|
|
||||||
position="top-center"
|
|
||||||
autoClose={false}
|
|
||||||
newestOnTop={false}
|
|
||||||
closeOnClick
|
|
||||||
rtl={false}
|
|
||||||
pauseOnFocusLoss
|
|
||||||
draggable={false}
|
|
||||||
theme="colored"
|
|
||||||
transition={Slide}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import js from '@eslint/js';
|
import js from '@eslint/js';
|
||||||
import globals from 'globals';
|
import globals from 'globals';
|
||||||
import tseslint from 'typescript-eslint';
|
import tseslint from 'typescript-eslint';
|
||||||
|
import pluginReact from 'eslint-plugin-react';
|
||||||
|
import pluginReactHooks from 'eslint-plugin-react-hooks';
|
||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{
|
{
|
||||||
// Ignore files and directories
|
// Ignore files and directories
|
||||||
ignores: ['dist', 'node_modules', 'app/generated'],
|
ignores: ['node_modules', 'app/generated', 'build', '.react-router'],
|
||||||
},
|
},
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
...tseslint.configs.recommended,
|
...tseslint.configs.recommended,
|
||||||
@@ -22,5 +24,19 @@ export default tseslint.config(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
rules: {},
|
rules: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...pluginReact.configs.flat.recommended, // Enables core React rules
|
||||||
|
...pluginReactHooks.configs.flat.recommended, // Enables React Hooks rules
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
ecmaFeatures: {
|
||||||
|
jsx: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
globals: {
|
||||||
|
...globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import tsconfigPaths from 'vite-tsconfig-paths';
|
|||||||
// @ts-expect-error vite-plugin-eslint has no types
|
// @ts-expect-error vite-plugin-eslint has no types
|
||||||
import eslint from 'vite-plugin-eslint';
|
import eslint from 'vite-plugin-eslint';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig(({ command }) => {
|
||||||
|
const isBuild = command === 'build';
|
||||||
|
|
||||||
|
return {
|
||||||
plugins: [
|
plugins: [
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
reactRouter(),
|
reactRouter(),
|
||||||
@@ -14,5 +17,22 @@ export default defineConfig({
|
|||||||
failOnError: false,
|
failOnError: false,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: isBuild
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
// replace unstyled import with styled for SPA build
|
||||||
|
find: 'react-toastify/unstyled',
|
||||||
|
replacement: 'react-toastify',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// point to the empty CSS file to stub out the import during build, SPA build does not require extra CSS imports
|
||||||
|
find: 'react-toastify/ReactToastify.css',
|
||||||
|
replacement: '~/empty-toastify.css',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
},
|
||||||
appType: 'spa',
|
appType: 'spa',
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user