From 455e6b1a75aa28026080909b964e70db2163d68e Mon Sep 17 00:00:00 2001 From: rustmailer Date: Tue, 30 Dec 2025 15:09:41 +0800 Subject: [PATCH] feat: support user appearance preferences with persisted theme and language #85 --- src/modules/account/grant.rs | 6 +- src/modules/account/migration.rs | 6 +- src/modules/account/view.rs | 4 +- src/modules/common/auth.rs | 4 +- src/modules/database/manager.rs | 3 + src/modules/database/mod.rs | 3 +- src/modules/rest/api/account.rs | 4 +- src/modules/rest/api/users.rs | 16 +- src/modules/rest/public/login.rs | 4 +- src/modules/token/mod.rs | 10 +- src/modules/users/manager.rs | 4 +- src/modules/users/mod.rs | 152 +++++++++-- src/modules/users/payload.rs | 49 +++- src/modules/users/view.rs | 2 + web/src/api/users/api.ts | 56 ++-- .../sign-in/components/user-auth-form.tsx | 11 + .../settings/appearance/appearance-form.tsx | 239 ++++++++++++++++++ .../features/settings/appearance/index.tsx | 7 + web/src/features/settings/index.tsx | 7 +- web/src/locales/ar.json | 25 ++ web/src/locales/da.json | 33 +++ web/src/locales/de.json | 25 ++ web/src/locales/en.json | 33 +++ web/src/locales/es.json | 25 ++ web/src/locales/fi.json | 33 +++ web/src/locales/fr.json | 25 ++ web/src/locales/it.json | 25 ++ web/src/locales/jp.json | 25 ++ web/src/locales/ko.json | 25 ++ web/src/locales/nl.json | 33 +++ web/src/locales/no.json | 33 +++ web/src/locales/pl.json | 25 ++ web/src/locales/pt.json | 25 ++ web/src/locales/ru.json | 25 ++ web/src/locales/sv.json | 33 +++ web/src/locales/zh-tw.json | 33 +++ web/src/locales/zh.json | 33 +++ web/src/routeTree.gen.ts | 35 +++ .../settings/appearance.lazy.tsx | 27 ++ 39 files changed, 1080 insertions(+), 83 deletions(-) create mode 100644 web/src/features/settings/appearance/appearance-form.tsx create mode 100644 web/src/features/settings/appearance/index.tsx create mode 100644 web/src/routes/_authenticated/settings/appearance.lazy.tsx diff --git a/src/modules/account/grant.rs b/src/modules/account/grant.rs index f7e2a47..8452d66 100644 --- a/src/modules/account/grant.rs +++ b/src/modules/account/grant.rs @@ -28,7 +28,7 @@ use crate::{ users::{ permissions::Permission, role::{RoleType, UserRole}, - BichonUser, + UserModel, }, }, raise_error, utc_now, @@ -68,7 +68,7 @@ impl BatchAccountRoleRequest { } for id in &self.user_ids { - let exists = BichonUser::find(*id).await?; // Assuming an exists helper + let exists = UserModel::find(*id).await?; // Assuming an exists helper if exists.is_none() { return Err(raise_error!( format!("User ID {} not found", id), @@ -90,7 +90,7 @@ impl BatchAccountRoleRequest { // Fetch the current user record from the database let user = rw .get() - .primary::(uid) + .primary::(uid) .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))? .ok_or_else(|| { raise_error!( diff --git a/src/modules/account/migration.rs b/src/modules/account/migration.rs index 8d41934..e9089dd 100644 --- a/src/modules/account/migration.rs +++ b/src/modules/account/migration.rs @@ -36,7 +36,7 @@ use crate::{ database::{list_all_impl, with_transaction}, error::BichonResult, indexer::manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER}, - users::{role::DEFAULT_ACCOUNT_MANAGER_ROLE_ID, BichonUser, DEFAULT_ADMIN_USER_ID}, + users::{role::DEFAULT_ACCOUNT_MANAGER_ROLE_ID, UserModel, DEFAULT_ADMIN_USER_ID}, }, utc_now, }; @@ -238,7 +238,7 @@ impl AccountV3 { .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; let user = rw .get() - .primary::(user_id) + .primary::(user_id) .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))? .ok_or_else(|| { raise_error!( @@ -312,7 +312,7 @@ impl AccountV3 { MAIL_CONTEXT.clean_account(account.id).await?; } OAuth2AccessToken::try_delete(account.id).await?; - BichonUser::cleanup_account(account.id).await?; + UserModel::cleanup_account(account.id).await?; MailBox::clean(account.id).await?; ENVELOPE_INDEX_MANAGER .delete_account_envelopes(account.id) diff --git a/src/modules/account/view.rs b/src/modules/account/view.rs index 447222b..b967fb1 100644 --- a/src/modules/account/view.rs +++ b/src/modules/account/view.rs @@ -27,7 +27,7 @@ use crate::modules::{ migration::{AccountModel, AccountType}, since::{DateSince, RelativeDate}, }, - users::BichonUser, + users::UserModel, }; #[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)] @@ -57,7 +57,7 @@ pub struct AccountResp { } impl AccountResp { - pub fn from_model(account: AccountModel, user_map: &HashMap) -> AccountResp { + pub fn from_model(account: AccountModel, user_map: &HashMap) -> AccountResp { let user = user_map.get(&account.created_by); AccountResp { id: account.id, diff --git a/src/modules/common/auth.rs b/src/modules/common/auth.rs index 045404c..66d2d2b 100644 --- a/src/modules/common/auth.rs +++ b/src/modules/common/auth.rs @@ -20,7 +20,7 @@ use crate::{ modules::{ error::{code::ErrorCode, BichonResult}, token::AccessTokenModel, - users::{permissions::Permission, role::UserRole, BichonUser}, + users::{permissions::Permission, role::UserRole, UserModel}, utils::rate_limit::RATE_LIMITER_MANAGER, }, raise_error, @@ -74,7 +74,7 @@ impl Endpoint for ApiGuardEndpoint { #[derive(Clone, Debug)] pub struct ClientContext { pub ip_addr: Option, - pub user: BichonUser, + pub user: UserModel, } impl ClientContext { diff --git a/src/modules/database/manager.rs b/src/modules/database/manager.rs index af1bf9c..8fe15e0 100644 --- a/src/modules/database/manager.rs +++ b/src/modules/database/manager.rs @@ -21,6 +21,7 @@ use crate::modules::cache::imap::MAILBOX_MODELS; use crate::modules::error::{code::ErrorCode, BichonError}; use crate::modules::settings::cli::SETTINGS; use crate::modules::settings::dir::DATA_DIR_MANAGER; +use crate::modules::users::UserModel; use crate::modules::{database::META_MODELS, error::BichonResult}; use crate::raise_error; use native_db::{Builder, Database}; @@ -73,6 +74,8 @@ impl DatabaseManager { .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; rw.migrate::() .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; + rw.migrate::() + .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; rw.commit() .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; diff --git a/src/modules/database/mod.rs b/src/modules/database/mod.rs index 806212e..02e2ed0 100644 --- a/src/modules/database/mod.rs +++ b/src/modules/database/mod.rs @@ -27,7 +27,7 @@ use crate::modules::settings::proxy::Proxy; use crate::modules::settings::system::SystemSetting; use crate::modules::token::AccessTokenModel; use crate::modules::users::role::UserRole; -use crate::modules::users::BichonUser; +use crate::modules::users::{BichonUser, BichonUserV2}; use crate::raise_error; use db_type::{KeyOptions, ToKeyDefinition}; use itertools::Itertools; @@ -73,6 +73,7 @@ impl ModelsAdapter { self.register_model::(); self.register_model::(); self.register_model::(); + self.register_model::(); self.register_model::(); } } diff --git a/src/modules/rest/api/account.rs b/src/modules/rest/api/account.rs index cc4752a..1b14711 100644 --- a/src/modules/rest/api/account.rs +++ b/src/modules/rest/api/account.rs @@ -32,7 +32,7 @@ use crate::modules::rest::api::ApiTags; use crate::modules::rest::response::DataPage; use crate::modules::rest::ApiResult; use crate::modules::users::permissions::Permission; -use crate::modules::users::BichonUser; +use crate::modules::users::UserModel; use crate::raise_error; use poem_openapi::param::{Path, Query}; use poem_openapi::payload::Json; @@ -131,7 +131,7 @@ impl AccountApi { let is_admin = context.user.is_admin().await; let sort_desc = desc.0.unwrap_or(true); - let user_map: HashMap = BichonUser::list_all() + let user_map: HashMap = UserModel::list_all() .await? .into_iter() .map(|u| (u.id, u)) diff --git a/src/modules/rest/api/users.rs b/src/modules/rest/api/users.rs index d12e71a..84a0397 100644 --- a/src/modules/rest/api/users.rs +++ b/src/modules/rest/api/users.rs @@ -29,7 +29,7 @@ use crate::modules::users::payload::{ use crate::modules::users::permissions::Permission; use crate::modules::users::role::UserRole; use crate::modules::users::view::UserView; -use crate::modules::users::BichonUser; +use crate::modules::users::UserModel; use poem::web::Path; use poem_openapi::payload::Json; use poem_openapi::OpenApi; @@ -100,10 +100,10 @@ impl UsersApi { .await?; let roles = UserRole::list_all().await?; let role_lookup: BTreeMap = roles.into_iter().map(|r| (r.id, r)).collect(); - let users = BichonUser::list_all().await?; + let users = UserModel::list_all().await?; let users = users .into_iter() - .map(|u| u.to_current_user(&role_lookup)) + .map(|u| u.to_view(&role_lookup)) .collect(); Ok(Json(users)) } @@ -140,7 +140,7 @@ impl UsersApi { context .require_permission(None, Permission::USER_MANAGE) .await?; - Ok(BichonUser::remove(id).await?) + Ok(UserModel::remove(id).await?) } #[oai(path = "/users", method = "post", operation_id = "create_user")] @@ -152,10 +152,10 @@ impl UsersApi { context .require_permission(None, Permission::USER_MANAGE) .await?; - let user = BichonUser::create(payload.0).await?; + let user = UserModel::create(payload.0).await?; let roles = UserRole::list_all().await?; let role_lookup: BTreeMap = roles.into_iter().map(|r| (r.id, r)).collect(); - Ok(Json(user.to_current_user(&role_lookup))) + Ok(Json(user.to_view(&role_lookup))) } #[oai(path = "/users/:id", method = "post", operation_id = "update_user")] @@ -180,7 +180,7 @@ impl UsersApi { update_data.account_access_map = None; update_data.acl = None; } - Ok(BichonUser::update(target_id, update_data).await?) + Ok(UserModel::update(target_id, update_data).await?) } #[oai( @@ -191,7 +191,7 @@ impl UsersApi { async fn get_current_user(&self, context: ClientContext) -> ApiResult> { let roles = UserRole::list_all().await?; let role_lookup: BTreeMap = roles.into_iter().map(|r| (r.id, r)).collect(); - Ok(Json(context.user.to_current_user(&role_lookup))) + Ok(Json(context.user.to_view(&role_lookup))) } #[oai( diff --git a/src/modules/rest/public/login.rs b/src/modules/rest/public/login.rs index 8884c35..67afb46 100644 --- a/src/modules/rest/public/login.rs +++ b/src/modules/rest/public/login.rs @@ -16,7 +16,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use crate::modules::users::BichonUser; +use crate::modules::users::UserModel; use poem::{handler, web::Json, IntoResponse, Response}; use serde::Deserialize; use tracing::error; @@ -34,7 +34,7 @@ pub struct LoginPayload { #[handler] pub async fn login(payload: Json) -> Response { let payload = payload.0; - match BichonUser::authenticate_user(payload.username, payload.password).await { + match UserModel::authenticate_user(payload.username, payload.password).await { Ok(result) => match serde_json::to_string(&result) { Ok(json_string) => Response::builder() .status(http::StatusCode::OK) diff --git a/src/modules/token/mod.rs b/src/modules/token/mod.rs index 4cad71b..7e0f3dd 100644 --- a/src/modules/token/mod.rs +++ b/src/modules/token/mod.rs @@ -26,7 +26,7 @@ use crate::modules::database::{ use crate::modules::database::{insert_impl, list_all_impl, update_impl}; use crate::modules::settings::cli::SETTINGS; use crate::modules::token::view::AccessTokenResp; -use crate::modules::users::BichonUser; +use crate::modules::users::UserModel; use crate::raise_error; use crate::{ generate_token, modules::error::BichonResult, @@ -180,7 +180,7 @@ impl AccessTokenModel { .collect()) } - pub async fn resolve_user_from_token(token: &str) -> BichonResult { + pub async fn resolve_user_from_token(token: &str) -> BichonResult { let token = token.to_string(); let token_option = async_find_impl::(DB_MANAGER.meta_db(), token).await?; let token = match token_option { @@ -237,7 +237,7 @@ impl AccessTokenModel { .await?; } - let user = BichonUser::find(token.user_id) + let user = UserModel::find(token.user_id) .await? .ok_or_else(|| raise_error!("The user associated with this access token does not exist or may have been deleted.".into(), ErrorCode::ResourceNotFound))?; Ok(user) @@ -287,11 +287,11 @@ impl AccessTokenModel { } pub async fn list_all_api_tokens() -> BichonResult> { - let users = BichonUser::list_all().await?; + let users = UserModel::list_all().await?; let mut all = list_all_impl::(DB_MANAGER.meta_db()).await?; all.retain(|t| t.token_type == TokenType::Api); - let user_map: HashMap = users.into_iter().map(|u| (u.id, u)).collect(); + let user_map: HashMap = users.into_iter().map(|u| (u.id, u)).collect(); let resp = all .into_iter() diff --git a/src/modules/users/manager.rs b/src/modules/users/manager.rs index 0649f90..9e4fa50 100644 --- a/src/modules/users/manager.rs +++ b/src/modules/users/manager.rs @@ -19,7 +19,7 @@ use crate::modules::{ context::Initialize, error::BichonResult, - users::{role::UserRole, BichonUser}, + users::{role::UserRole, UserModel}, }; pub struct UserManager; @@ -27,6 +27,6 @@ pub struct UserManager; impl Initialize for UserManager { async fn initialize() -> BichonResult<()> { UserRole::ensure_default_roles_exists().await?; - BichonUser::ensure_default_admin_exists().await + UserModel::ensure_default_admin_exists().await } } diff --git a/src/modules/users/mod.rs b/src/modules/users/mod.rs index 331ffdc..39e0653 100644 --- a/src/modules/users/mod.rs +++ b/src/modules/users/mod.rs @@ -51,11 +51,15 @@ pub mod permissions; pub mod role; pub mod view; +pub type UserModel = BichonUserV2; + #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Object)] pub struct LoginResult { pub success: bool, pub error_message: Option, pub access_token: Option, + pub theme: Option, + pub language: Option, } pub const DEFAULT_ADMIN_USER_ID: u64 = 100000000000000; @@ -92,9 +96,44 @@ pub struct BichonUser { pub acl: Option, } -impl BichonUser { - pub async fn list_all() -> BichonResult> { - Ok(list_all_impl::(DB_MANAGER.meta_db()).await?) +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Object)] +#[native_model(id = 10, version = 2, from = BichonUser)] +#[native_db] +pub struct BichonUserV2 { + #[primary_key] + pub id: u64, + #[secondary_key(unique)] + pub username: String, + #[secondary_key(unique)] + pub email: String, + + pub password: Option, + + /// Scoped Access: Defines per-account permissions. + /// Example: + /// { account_id: 1, role_id: role_manager_id } -> Manager on Account 1 + /// { account_id: 2, role_id: role_viewer_id } -> Viewer on Account 2 + pub account_access_map: BTreeMap, + + pub description: Option, + + /// System Roles: Permissions that apply to the whole system + /// (e.g., system settings, creating new users). + pub global_roles: Vec, + + pub avatar: Option, + pub created_at: i64, + pub updated_at: i64, + /// Optional access control settings + pub acl: Option, + + pub theme: Option, + pub language: Option, +} + +impl BichonUserV2 { + pub async fn list_all() -> BichonResult> { + Ok(list_all_impl::(DB_MANAGER.meta_db()).await?) } async fn get_all_permissions(&self) -> HashSet { @@ -111,7 +150,7 @@ impl BichonUser { all_perms } - pub fn to_current_user(self, role_lookup: &BTreeMap) -> UserView { + pub fn to_view(self, role_lookup: &BTreeMap) -> UserView { let global_roles_names = self .global_roles .iter() @@ -173,6 +212,8 @@ impl BichonUser { acl: self.acl, account_permissions, global_permissions, + theme: self.theme, + language: self.language, } } @@ -187,12 +228,12 @@ impl BichonUser { // 1. Try to get the existing admin user let admin = rw .get() - .primary::(DEFAULT_ADMIN_USER_ID) + .primary::(DEFAULT_ADMIN_USER_ID) .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; if admin.is_none() { // 2. Insert the BichonUser with the updated schema - rw.insert(BichonUser { + rw.insert(UserModel { id: DEFAULT_ADMIN_USER_ID, username: "admin".into(), email: "placeholder@example.com".into(), @@ -209,6 +250,8 @@ impl BichonUser { updated_at: now, description: Some("System default administrator".into()), acl: None, + theme: None, + language: None, }) .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; @@ -239,9 +282,9 @@ impl BichonUser { username: String, password: String, ) -> BichonResult { - let user_option = secondary_find_impl::( + let user_option = secondary_find_impl::( DB_MANAGER.meta_db(), - BichonUserKey::username, + BichonUserV2Key::username, username.clone(), ) .await?; @@ -249,9 +292,9 @@ impl BichonUser { let user = match user_option { Some(u) => u, None => { - match secondary_find_impl::( + match secondary_find_impl::( DB_MANAGER.meta_db(), - BichonUserKey::email, + BichonUserV2Key::email, username, ) .await? @@ -262,6 +305,8 @@ impl BichonUser { success: false, error_message: Some("User or email not found.".to_string()), access_token: None, + theme: None, + language: None, }); } } @@ -277,6 +322,8 @@ impl BichonUser { success: true, error_message: None, access_token: Some(new_token), + theme: user.theme, + language: user.language, }) } else { warn!( @@ -287,6 +334,8 @@ impl BichonUser { success: false, error_message: Some("Incorrect password.".to_string()), access_token: None, + theme: None, + language: None, }) } } @@ -304,20 +353,22 @@ impl BichonUser { ) ), access_token: None, + theme: None, + language: None, }) } } } - pub async fn find(user_id: u64) -> BichonResult> { + pub async fn find(user_id: u64) -> BichonResult> { async_find_impl(DB_MANAGER.meta_db(), user_id).await } pub async fn check_username_conflict(username: &str) -> BichonResult<()> { // Check username duplicate - if secondary_find_impl::( + if secondary_find_impl::( DB_MANAGER.meta_db(), - BichonUserKey::username, + BichonUserV2Key::username, username.to_string(), ) .await? @@ -334,9 +385,9 @@ impl BichonUser { pub async fn check_email_conflict(email: &str) -> BichonResult<()> { // Check email duplicate - if secondary_find_impl::( + if secondary_find_impl::( DB_MANAGER.meta_db(), - BichonUserKey::email, + BichonUserV2Key::email, email.to_string(), ) .await? @@ -351,7 +402,7 @@ impl BichonUser { Ok(()) } - pub async fn create(request: UserCreateRequest) -> BichonResult { + pub async fn create(request: UserCreateRequest) -> BichonResult { request.validate().await?; Self::check_username_conflict(&request.username).await?; Self::check_email_conflict(&request.email).await?; @@ -359,7 +410,7 @@ impl BichonUser { let password_hash = Some(encrypt!(&request.password)?); let now = utc_now!(); - let user = BichonUser { + let user = UserModel { id: id!(96), username: request.username, email: request.email, @@ -371,6 +422,8 @@ impl BichonUser { created_at: now, updated_at: now, account_access_map: request.account_access_map, + theme: request.theme, + language: request.language, }; let user_clone = user.clone(); @@ -454,9 +507,9 @@ impl BichonUser { } if let Some(username) = &request.username { - let user_option = secondary_find_impl::( + let user_option = secondary_find_impl::( DB_MANAGER.meta_db(), - BichonUserKey::username, + BichonUserV2Key::username, username.to_string(), ) .await?; @@ -472,9 +525,9 @@ impl BichonUser { } if let Some(email) = &request.email { - let user_option = secondary_find_impl::( + let user_option = secondary_find_impl::( DB_MANAGER.meta_db(), - BichonUserKey::email, + BichonUserV2Key::email, email.to_string(), ) .await?; @@ -493,7 +546,7 @@ impl BichonUser { DB_MANAGER.meta_db(), move |rw| { rw.get() - .primary::(id) + .primary::(id) .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))? .ok_or_else(|| { raise_error!( @@ -532,6 +585,15 @@ impl BichonUser { if let Some(avatar_base64) = request.avatar_base64 { updated.avatar = Some(avatar_base64); } + + if let Some(theme) = request.theme { + updated.theme = Some(theme); + } + + if let Some(language) = request.language { + updated.language = Some(language); + } + updated.updated_at = utc_now!(); Ok(updated) @@ -542,13 +604,13 @@ impl BichonUser { if password_changed { AccessTokenModel::reset_webui_token(id).await?; } - + Ok(()) } - async fn list_authorized_users(account_id: u64) -> BichonResult> { + async fn list_authorized_users(account_id: u64) -> BichonResult> { let all = Self::list_all().await?; - let result: Vec = all + let result: Vec = all .into_iter() .filter(|e| e.account_access_map.contains_key(&account_id)) .collect(); @@ -566,7 +628,7 @@ impl BichonUser { for user in users { let current = rw .get() - .primary::(user.id) + .primary::(user.id) .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))? .ok_or_else(|| { raise_error!( @@ -590,3 +652,41 @@ impl BichonUser { Ok(()) } } + +impl From for BichonUser { + fn from(value: BichonUserV2) -> Self { + BichonUser { + id: value.id, + username: value.username, + email: value.email, + password: value.password, + account_access_map: value.account_access_map, + description: value.description, + global_roles: value.global_roles, + avatar: value.avatar, + created_at: value.created_at, + updated_at: value.updated_at, + acl: value.acl, + } + } +} + +impl From for BichonUserV2 { + fn from(value: BichonUser) -> Self { + BichonUserV2 { + id: value.id, + username: value.username, + email: value.email, + password: value.password, + account_access_map: value.account_access_map, + description: value.description, + global_roles: value.global_roles, + avatar: value.avatar, + created_at: value.created_at, + updated_at: value.updated_at, + acl: value.acl, + theme: None, + language: None, + } + } +} diff --git a/src/modules/users/payload.rs b/src/modules/users/payload.rs index 02eb22c..df4ae3c 100644 --- a/src/modules/users/payload.rs +++ b/src/modules/users/payload.rs @@ -31,7 +31,44 @@ use crate::{ }; use poem_openapi::Object; use serde::{Deserialize, Serialize}; -use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; + +fn allowed_themes() -> HashSet<&'static str> { + ["light", "dark"].into_iter().collect() +} + +fn allowed_languages() -> HashSet<&'static str> { + [ + "ar", "da", "de", "en", "es", "fi", "fr", "it", "jp", "ko", "nl", "no", "pl", "pt", "ru", + "sv", "zh", "zh-tw", + ] + .into_iter() + .collect() +} + +fn validate_option_in_set( + value: &Option, + allowed: &std::collections::HashSet<&'static str>, + field_name: &str, +) -> BichonResult<()> { + if let Some(v) = value { + if !allowed.contains(v.as_str()) { + return Err(raise_error!( + format!("invalid {} value: '{}'", field_name, v), + ErrorCode::InvalidParameter + )); + } + } + Ok(()) +} + +fn validate_theme(theme: &Option) -> BichonResult<()> { + validate_option_in_set(theme, &allowed_themes(), "theme") +} + +fn validate_language(language: &Option) -> BichonResult<()> { + validate_option_in_set(language, &allowed_languages(), "language") +} #[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)] pub struct RoleCreateRequest { @@ -176,6 +213,8 @@ pub struct UserCreateRequest { pub acl: Option, pub avatar_base64: Option, pub description: Option, + pub theme: Option, + pub language: Option, } impl UserCreateRequest { @@ -219,6 +258,9 @@ impl UserCreateRequest { )); } + validate_theme(&self.theme)?; + validate_language(&self.language)?; + let all_roles = UserRole::list_all().await?; let role_type_map: HashMap = all_roles.into_iter().map(|r| (r.id, r.role_type)).collect(); @@ -301,6 +343,8 @@ pub struct UserUpdateRequest { pub account_access_map: Option>, pub acl: Option, pub description: Option, + pub theme: Option, + pub language: Option, } impl UserUpdateRequest { @@ -325,6 +369,9 @@ impl UserUpdateRequest { } } + validate_theme(&self.theme)?; + validate_language(&self.language)?; + let all_roles = UserRole::list_all().await?; let role_type_map: HashMap = all_roles.into_iter().map(|r| (r.id, r.role_type)).collect(); diff --git a/src/modules/users/view.rs b/src/modules/users/view.rs index d67df01..755529a 100644 --- a/src/modules/users/view.rs +++ b/src/modules/users/view.rs @@ -49,4 +49,6 @@ pub struct UserView { pub updated_at: i64, /// Optional access control settings pub acl: Option, + pub theme: Option, + pub language: Option, } diff --git a/web/src/api/users/api.ts b/web/src/api/users/api.ts index 8d4cc9d..20afd00 100644 --- a/web/src/api/users/api.ts +++ b/web/src/api/users/api.ts @@ -14,33 +14,33 @@ export interface UserRole { } export function getPermissions(t: (key: string) => string) { - return [ - // 1. Global Management - { label: t('permission.system.access'), value: 'system:access' }, - { label: t('permission.system.root'), value: 'system:root' }, - { label: t('permission.user.manage'), value: 'user:manage' }, - { label: t('permission.user.view'), value: 'user:view' }, - { label: t('permission.token.manage'), value: 'token:manage' }, - { label: t('permission.account.create'), value: 'account:create' }, + return [ + // 1. Global Management + { label: t('permission.system.access'), value: 'system:access' }, + { label: t('permission.system.root'), value: 'system:root' }, + { label: t('permission.user.manage'), value: 'user:manage' }, + { label: t('permission.user.view'), value: 'user:view' }, + { label: t('permission.token.manage'), value: 'token:manage' }, + { label: t('permission.account.create'), value: 'account:create' }, - // 2. Global "ALL" Scoped (Admin) - { label: t('permission.account.manage_all'), value: 'account:manage:all' }, - { label: t('permission.data.read_all'), value: 'data:read:all' }, - { label: t('permission.data.manage_all'), value: 'data:manage:all' }, - { label: t('permission.data.raw_download_all'), value: 'data:raw:download:all' }, - { label: t('permission.data.delete_all'), value: 'data:delete:all' }, - { label: t('permission.data.export_batch_all'), value: 'data:export:batch:all' }, + // 2. Global "ALL" Scoped (Admin) + { label: t('permission.account.manage_all'), value: 'account:manage:all' }, + { label: t('permission.data.read_all'), value: 'data:read:all' }, + { label: t('permission.data.manage_all'), value: 'data:manage:all' }, + { label: t('permission.data.raw_download_all'), value: 'data:raw:download:all' }, + { label: t('permission.data.delete_all'), value: 'data:delete:all' }, + { label: t('permission.data.export_batch_all'), value: 'data:export:batch:all' }, - // 3. Scoped / Limited - { label: t('permission.account.manage'), value: 'account:manage' }, - { label: t('permission.account.read_details'), value: 'account:read_details' }, - { label: t('permission.data.read'), value: 'data:read' }, - { label: t('permission.data.manage'), value: 'data:manage' }, - { label: t('permission.data.raw_download'), value: 'data:raw:download' }, - { label: t('permission.data.delete'), value: 'data:delete' }, - { label: t('permission.data.export_batch'), value: 'data:export:batch' }, - { label: t('permission.data.import_batch'), value: 'data:import:batch' }, - ] + // 3. Scoped / Limited + { label: t('permission.account.manage'), value: 'account:manage' }, + { label: t('permission.account.read_details'), value: 'account:read_details' }, + { label: t('permission.data.read'), value: 'data:read' }, + { label: t('permission.data.manage'), value: 'data:manage' }, + { label: t('permission.data.raw_download'), value: 'data:raw:download' }, + { label: t('permission.data.delete'), value: 'data:delete' }, + { label: t('permission.data.export_batch'), value: 'data:export:batch' }, + { label: t('permission.data.import_batch'), value: 'data:import:batch' }, + ] } export interface RateLimit { @@ -85,10 +85,16 @@ export interface User { created_at: number; updated_at: number; } + +type Theme = 'dark' | 'light' + + export interface LoginResult { success: boolean; error_message?: string | null; access_token?: string | null; + theme?: Theme, + language?: string, } diff --git a/web/src/features/auth/sign-in/components/user-auth-form.tsx b/web/src/features/auth/sign-in/components/user-auth-form.tsx index aaf1ffd..318787d 100644 --- a/web/src/features/auth/sign-in/components/user-auth-form.tsx +++ b/web/src/features/auth/sign-in/components/user-auth-form.tsx @@ -43,6 +43,7 @@ import { useTranslation } from 'react-i18next' import i18n from '@/i18n' import { Loader2, LogIn } from 'lucide-react' import { login } from '@/api/users/api' +import { useTheme } from '@/context/theme-context' type UserAuthFormProps = HTMLAttributes @@ -59,6 +60,7 @@ const getFormSchema = (t: (key: string, options?: Record) => string export function UserAuthForm({ className, ...props }: UserAuthFormProps) { const [isLoading, setIsLoading] = useState(false) + const { setTheme } = useTheme(); const navigate = useNavigate() const { t } = useTranslation() @@ -86,6 +88,15 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) { onSuccess: (result) => { if (result.success) { setToken(result); + + if (result.theme) { + setTheme(result.theme); + } + + if (result.language) { + i18n.changeLanguage(result.language); + } + navigate({ to: redirect }); } else { toast({ diff --git a/web/src/features/settings/appearance/appearance-form.tsx b/web/src/features/settings/appearance/appearance-form.tsx new file mode 100644 index 0000000..3839d55 --- /dev/null +++ b/web/src/features/settings/appearance/appearance-form.tsx @@ -0,0 +1,239 @@ +import { z } from 'zod' +import { useForm } from 'react-hook-form' +import { CaretSortIcon, CheckIcon } from '@radix-ui/react-icons' +import { zodResolver } from '@hookform/resolvers/zod' +import { cn } from '@/lib/utils' +import { Button } from '@/components/ui/button' +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' +import { useTheme } from '@/context/theme-context' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command' +import { useTranslation } from 'react-i18next' +import { useCurrentUser } from '@/hooks/use-current-user' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { update_user } from '@/api/users/api' +import { toast } from '@/hooks/use-toast' +import { AxiosError } from 'axios' + + +const languages = [ + { value: 'ar', label: 'العربية' }, + { value: 'da', label: 'Dansk' }, + { value: 'de', label: 'Deutsch' }, + { value: 'en', label: 'English' }, + { value: 'es', label: 'Español' }, + { value: 'fi', label: 'Suomi' }, + { value: 'fr', label: 'Français' }, + { value: 'it', label: 'Italiano' }, + { value: 'jp', label: '日本語' }, + { value: 'ko', label: '한국어' }, + { value: 'nl', label: 'Nederlands' }, + { value: 'no', label: 'Norsk' }, + { value: 'pl', label: 'Polski' }, + { value: 'pt', label: 'Português' }, + { value: 'ru', label: 'Русский' }, + { value: 'sv', label: 'Svenska' }, + { value: 'zh', label: '中文' }, + { value: 'zh-tw', label: '繁體中文' }, +] + +const appearanceSchema = (t: (key: string) => string) => z.object({ + theme: z.enum(['light', 'dark'], { + required_error: t('settings.appearance.validation.theme.required'), + }), + language: z.string({ + required_error: t('settings.appearance.validation.language.required'), + }) +}) + +type AppearanceFormValues = z.infer> + + +export function AppearanceForm() { + const { data: user } = useCurrentUser(); + const queryClient = useQueryClient(); + const { t, i18n } = useTranslation(); + const { theme, setTheme } = useTheme(); + + + const form = useForm({ + resolver: zodResolver(appearanceSchema(t)), + mode: 'onChange', + defaultValues: { + theme: (theme as 'light' | 'dark') || 'light', + language: i18n.language || 'en', + }, + }) + + const mutation = useMutation({ + mutationFn: async (values: AppearanceFormValues) => { + return update_user(user!.id, values) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['current-user'] }) + toast({ title: t('settings.profile.toast.updated') }) + }, + onError: (err: AxiosError) => { + toast({ + variant: 'destructive', + title: t('settings.profile.toast.update_failed'), + description: (err.response?.data as any)?.message || err.message, + }) + }, + }); + + function onSubmit(data: AppearanceFormValues) { + i18n.changeLanguage(data.language); + setTheme(data.theme); + mutation.mutate(data); + } + + return ( +
+
+ + ( + + {t('settings.appearance.field.language')} + + + + + + + + + + {t('settings.appearance.command.no_results')} + + + {languages.map((language) => ( + { + form.setValue('language', language.value) + }} + > + + {language.label} + + ))} + + + + + + + {t('settings.appearance.description.language')} + + + + )} + /> + ( + + {t('settings.appearance.field.theme')} + + {t('settings.appearance.description.theme')} + + + + + + + + +
+
+
+
+
+
+
+
+
+
+
+
+ + {t('settings.appearance.theme.light')} + + + + + + + + +
+
+
+
+
+
+
+
+
+
+
+
+ + {t('settings.appearance.theme.dark')} + + + + + + )} + /> + +
+ +
+ + +
+ ) +} \ No newline at end of file diff --git a/web/src/features/settings/appearance/index.tsx b/web/src/features/settings/appearance/index.tsx new file mode 100644 index 0000000..208a340 --- /dev/null +++ b/web/src/features/settings/appearance/index.tsx @@ -0,0 +1,7 @@ +import { AppearanceForm } from './appearance-form' + +export function SettingsAppearance() { + return ( + + ) +} \ No newline at end of file diff --git a/web/src/features/settings/index.tsx b/web/src/features/settings/index.tsx index b7d5e92..9ca401a 100644 --- a/web/src/features/settings/index.tsx +++ b/web/src/features/settings/index.tsx @@ -20,7 +20,7 @@ import { Outlet } from '@tanstack/react-router' import { Main } from '@/components/layout/main' import SidebarNav from './components/sidebar-nav' -import { KeyRound, SettingsIcon, UserCog, Waypoints } from 'lucide-react' +import { KeyRound, Palette, SettingsIcon, UserCog, Waypoints } from 'lucide-react' import { FixedHeader } from '@/components/layout/fixed-header' import { useCurrentUser } from '@/hooks/use-current-user' import { useTranslation } from 'react-i18next' @@ -36,6 +36,11 @@ export default function Settings() { href: '/settings/profile', icon: , }, + { + title: t('settings.appearance.title'), + href: '/settings/appearance', + icon: , + }, { title: t('settings.sidebar.apiTokens'), href: '/settings/api-tokens', diff --git a/web/src/locales/ar.json b/web/src/locales/ar.json index 00b357d..11f759c 100644 --- a/web/src/locales/ar.json +++ b/web/src/locales/ar.json @@ -718,6 +718,31 @@ "interval": "الفاصل الزمني" }, "settings": { + "appearance": { + "title": "المظهر", + "field": { + "language": "لغة الواجهة", + "theme": "سمة الواجهة" + }, + "description": { + "language": "اختر اللغة المستخدمة في واجهة الويب.", + "theme": "اختر بين الوضع الفاتح أو الداكن." + }, + "placeholder": { + "select_language": "اختر اللغة" + }, + "command": { + "search": "بحث عن لغة...", + "no_results": "لم يتم العثور على لغة." + }, + "theme": { + "light": "فاتح", + "dark": "داكن" + }, + "button": { + "update": "تحديث التفضيلات" + } + }, "title": "الإعدادات", "general": "عام", "proxy": "وكيل", diff --git a/web/src/locales/da.json b/web/src/locales/da.json index 9e899c9..276a224 100644 --- a/web/src/locales/da.json +++ b/web/src/locales/da.json @@ -718,6 +718,39 @@ "interval": "Interval" }, "settings": { + "appearance": { + "title": "Udseende", + "field": { + "language": "Grænsefladesprog", + "theme": "Grænsefladetema" + }, + "description": { + "language": "Vælg det sprog, der skal bruges i webbrugerfladen.", + "theme": "Vælg mellem lys eller mørk tilstand for brugerfladen." + }, + "placeholder": { + "select_language": "Vælg sprog" + }, + "command": { + "search": "Søg efter sprog...", + "no_results": "Intet sprog fundet." + }, + "theme": { + "light": "Lys", + "dark": "Mørk" + }, + "button": { + "update": "Opdater indstillinger" + }, + "validation": { + "language": { + "required": "Vælg venligst et sprog." + }, + "theme": { + "required": "Vælg venligst et tema." + } + } + }, "title": "Indstillinger", "general": "Generelt", "proxy": "Proxy", diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 2c20b7e..a645ff0 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -718,6 +718,31 @@ "interval": "Intervall" }, "settings": { + "appearance": { + "title": "Erscheinungsbild", + "field": { + "language": "Sprache der Benutzeroberfläche", + "theme": "Design" + }, + "description": { + "language": "Wählen Sie die Sprache für die Web-Oberfläche.", + "theme": "Wählen Sie zwischen hellem oder dunklem Modus." + }, + "placeholder": { + "select_language": "Sprache wählen" + }, + "command": { + "search": "Sprache suchen...", + "no_results": "Keine Sprache gefunden." + }, + "theme": { + "light": "Hell", + "dark": "Dunkel" + }, + "button": { + "update": "Einstellungen aktualisieren" + } + }, "title": "Einstellungen", "general": "Allgemein", "proxy": "Proxy", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 5c328b5..44ec391 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -718,6 +718,39 @@ "interval": "Interval" }, "settings": { + "appearance": { + "title": "Appearance", + "field": { + "language": "Language", + "theme": "Interface Theme" + }, + "description": { + "language": "Select the language used in the Web UI.", + "theme": "Choose between light or dark mode for the interface." + }, + "placeholder": { + "select_language": "Select language" + }, + "command": { + "search": "Search language...", + "no_results": "No language found." + }, + "theme": { + "light": "Light", + "dark": "Dark" + }, + "button": { + "update": "Update preferences" + }, + "validation": { + "language": { + "required": "Please select a language." + }, + "theme": { + "required": "Please select a theme." + } + } + }, "title": "Settings", "general": "General", "proxy": "Proxy", diff --git a/web/src/locales/es.json b/web/src/locales/es.json index 1e87a64..ee5f057 100644 --- a/web/src/locales/es.json +++ b/web/src/locales/es.json @@ -718,6 +718,31 @@ "interval": "Intervalo" }, "settings": { + "appearance": { + "title": "Apariencia", + "field": { + "language": "Idioma de la interfaz", + "theme": "Tema de la interfaz" + }, + "description": { + "language": "Seleccione el idioma de la interfaz web.", + "theme": "Elija entre el modo claro u oscuro." + }, + "placeholder": { + "select_language": "Seleccionar idioma" + }, + "command": { + "search": "Buscar idioma...", + "no_results": "No se encontró el idioma." + }, + "theme": { + "light": "Claro", + "dark": "Oscuro" + }, + "button": { + "update": "Actualizar preferencias" + } + }, "title": "Configuración", "general": "General", "proxy": "Proxy", diff --git a/web/src/locales/fi.json b/web/src/locales/fi.json index fe1ecdd..06a194e 100644 --- a/web/src/locales/fi.json +++ b/web/src/locales/fi.json @@ -718,6 +718,39 @@ "interval": "Väli" }, "settings": { + "appearance": { + "title": "Ulkoasu", + "field": { + "language": "Käyttöliittymän kieli", + "theme": "Käyttöliittymän teema" + }, + "description": { + "language": "Valitse Web-käyttöliittymässä käytettävä kieli.", + "theme": "Valitse käyttöliittymän vaalea tai tumma tila." + }, + "placeholder": { + "select_language": "Valitse kieli" + }, + "command": { + "search": "Hae kieltä...", + "no_results": "Kieltä ei löytynyt." + }, + "theme": { + "light": "Vaalea", + "dark": "Tumma" + }, + "button": { + "update": "Päivitä asetukset" + }, + "validation": { + "language": { + "required": "Valitse kieli." + }, + "theme": { + "required": "Valitse teema." + } + } + }, "title": "Asetukset", "general": "Yleinen", "proxy": "Välityspalvelin", diff --git a/web/src/locales/fr.json b/web/src/locales/fr.json index 45b9630..0917ca5 100644 --- a/web/src/locales/fr.json +++ b/web/src/locales/fr.json @@ -718,6 +718,31 @@ "interval": "Intervalle" }, "settings": { + "appearance": { + "title": "Apparence", + "field": { + "language": "Langue de l'interface", + "theme": "Thème de l'interface" + }, + "description": { + "language": "Choisissez la langue de l'interface web.", + "theme": "Choisissez entre le mode clair ou sombre." + }, + "placeholder": { + "select_language": "Choisir une langue" + }, + "command": { + "search": "Rechercher une langue...", + "no_results": "Aucune langue trouvée." + }, + "theme": { + "light": "Clair", + "dark": "Sombre" + }, + "button": { + "update": "Mettre à jour les préférences" + } + }, "title": "Paramètres", "general": "Général", "proxy": "Proxy", diff --git a/web/src/locales/it.json b/web/src/locales/it.json index ee5ebd0..3ebbf9d 100644 --- a/web/src/locales/it.json +++ b/web/src/locales/it.json @@ -718,6 +718,31 @@ "interval": "Intervallo" }, "settings": { + "appearance": { + "title": "Aspetto", + "field": { + "language": "Lingua dell'interfaccia", + "theme": "Tema dell'interfaccia" + }, + "description": { + "language": "Seleziona la lingua per l'interfaccia web.", + "theme": "Scegli tra la modalità chiara o scura." + }, + "placeholder": { + "select_language": "Seleziona lingua" + }, + "command": { + "search": "Cerca lingua...", + "no_results": "Nessuna lingua trovata." + }, + "theme": { + "light": "Chiaro", + "dark": "Scuro" + }, + "button": { + "update": "Aggiorna preferenze" + } + }, "title": "Impostazioni", "general": "Generale", "proxy": "Proxy", diff --git a/web/src/locales/jp.json b/web/src/locales/jp.json index 0a01d50..751bc94 100644 --- a/web/src/locales/jp.json +++ b/web/src/locales/jp.json @@ -718,6 +718,31 @@ "interval": "間隔" }, "settings": { + "appearance": { + "title": "外観", + "field": { + "language": "表示言語", + "theme": "インターフェーステーマ" + }, + "description": { + "language": "Web UIで使用する言語を選択します。", + "theme": "ライトモードまたはダークモードを選択します。" + }, + "placeholder": { + "select_language": "言語を選択" + }, + "command": { + "search": "言語を検索...", + "no_results": "言語が見つかりません。" + }, + "theme": { + "light": "ライト", + "dark": "ダーク" + }, + "button": { + "update": "設定を更新" + } + }, "title": "設定", "general": "一般", "proxy": "プロキシ", diff --git a/web/src/locales/ko.json b/web/src/locales/ko.json index 78eef93..531848a 100644 --- a/web/src/locales/ko.json +++ b/web/src/locales/ko.json @@ -718,6 +718,31 @@ "interval": "간격" }, "settings": { + "appearance": { + "title": "외관", + "field": { + "language": "인터페이스 언어", + "theme": "인터페이스 테마" + }, + "description": { + "language": "웹 UI에서 사용할 언어를 선택하세요.", + "theme": "라이트 모드와 다크 모드 중에서 선택하세요." + }, + "placeholder": { + "select_language": "언어 선택" + }, + "command": { + "search": "언어 검색...", + "no_results": "언어를 찾을 수 없습니다." + }, + "theme": { + "light": "라이트", + "dark": "다크" + }, + "button": { + "update": "설정 업데이트" + } + }, "title": "설정", "general": "일반", "proxy": "프록시", diff --git a/web/src/locales/nl.json b/web/src/locales/nl.json index 00775f4..2f916b8 100644 --- a/web/src/locales/nl.json +++ b/web/src/locales/nl.json @@ -718,6 +718,39 @@ "interval": "Interval" }, "settings": { + "appearance": { + "title": "Uiterlijk", + "field": { + "language": "Interfacetaal", + "theme": "Interfacethema" + }, + "description": { + "language": "Selecteer de taal voor de webinterface.", + "theme": "Kies tussen de lichte of donkere modus voor de interface." + }, + "placeholder": { + "select_language": "Selecteer taal" + }, + "command": { + "search": "Taal zoeken...", + "no_results": "Geen taal gevonden." + }, + "theme": { + "light": "Licht", + "dark": "Donker" + }, + "button": { + "update": "Voorkeuren bijwerken" + }, + "validation": { + "language": { + "required": "Selecteer een taal." + }, + "theme": { + "required": "Selecteer een thema." + } + } + }, "title": "Instellingen", "general": "Algemeen", "proxy": "Proxy", diff --git a/web/src/locales/no.json b/web/src/locales/no.json index 890d065..87465e4 100644 --- a/web/src/locales/no.json +++ b/web/src/locales/no.json @@ -718,6 +718,39 @@ "interval": "Intervall" }, "settings": { + "appearance": { + "title": "Utseende", + "field": { + "language": "Grensesnittspråk", + "theme": "Grensesnittema" + }, + "description": { + "language": "Velg språket som skal brukes i webgrensesnittet.", + "theme": "Velg mellom lys eller mørk modus for grensesnittet." + }, + "placeholder": { + "select_language": "Velg språk" + }, + "command": { + "search": "Søk etter språk...", + "no_results": "Fant ingen språk." + }, + "theme": { + "light": "Lys", + "dark": "Mørk" + }, + "button": { + "update": "Oppdater innstillinger" + }, + "validation": { + "language": { + "required": "Vennligst velg et språk." + }, + "theme": { + "required": "Vennligst velg et tema." + } + } + }, "title": "Innstillinger", "general": "Generelt", "proxy": "Proxy", diff --git a/web/src/locales/pl.json b/web/src/locales/pl.json index 36e3422..ed75c9e 100644 --- a/web/src/locales/pl.json +++ b/web/src/locales/pl.json @@ -718,6 +718,31 @@ "interval": "Interval" }, "settings": { + "appearance": { + "title": "Wygląd", + "field": { + "language": "Język interfejsu", + "theme": "Motyw interfejsu" + }, + "description": { + "language": "Wybierz język interfejsu webowego.", + "theme": "Wybierz tryb jasny lub ciemny." + }, + "placeholder": { + "select_language": "Wybierz język" + }, + "command": { + "search": "Szukaj języka...", + "no_results": "Nie znaleziono języka." + }, + "theme": { + "light": "Jasny", + "dark": "Ciemny" + }, + "button": { + "update": "Aktualizuj preferencje" + } + }, "title": "Ustawienia", "general": "Ogólne", "proxy": "Proxy", diff --git a/web/src/locales/pt.json b/web/src/locales/pt.json index 03f8b66..9cf58fa 100644 --- a/web/src/locales/pt.json +++ b/web/src/locales/pt.json @@ -718,6 +718,31 @@ "interval": "Intervalo" }, "settings": { + "appearance": { + "title": "Aparência", + "field": { + "language": "Idioma da interface", + "theme": "Tema da interface" + }, + "description": { + "language": "Selecione o idioma da interface web.", + "theme": "Escolha entre o modo claro ou escuro." + }, + "placeholder": { + "select_language": "Selecionar idioma" + }, + "command": { + "search": "Pesquisar idioma...", + "no_results": "Nenhum idioma encontrado." + }, + "theme": { + "light": "Claro", + "dark": "Escuro" + }, + "button": { + "update": "Atualizar preferências" + } + }, "title": "Configurações", "general": "Geral", "proxy": "Proxy", diff --git a/web/src/locales/ru.json b/web/src/locales/ru.json index 121a98c..a151aa9 100644 --- a/web/src/locales/ru.json +++ b/web/src/locales/ru.json @@ -718,6 +718,31 @@ "interval": "Интервал" }, "settings": { + "appearance": { + "title": "Внешний вид", + "field": { + "language": "Язык интерфейса", + "theme": "Тема интерфейса" + }, + "description": { + "language": "Выберите язык веб-интерфейса.", + "theme": "Выберите светлый или темный режим." + }, + "placeholder": { + "select_language": "Выберите язык" + }, + "command": { + "search": "Поиск языка...", + "no_results": "Язык не найден." + }, + "theme": { + "light": "Светлая", + "dark": "Темная" + }, + "button": { + "update": "Обновить настройки" + } + }, "title": "Настройки", "general": "Общие", "proxy": "Прокси", diff --git a/web/src/locales/sv.json b/web/src/locales/sv.json index 11acb2e..369f9de 100644 --- a/web/src/locales/sv.json +++ b/web/src/locales/sv.json @@ -718,6 +718,39 @@ "interval": "Intervall" }, "settings": { + "appearance": { + "title": "Utseende", + "field": { + "language": "Gränssnittsspråk", + "theme": "Gränssnittstema" + }, + "description": { + "language": "Välj språk för webbanvändargränssnittet.", + "theme": "Välj mellan ljust eller mörkt läge för gränssnittet." + }, + "placeholder": { + "select_language": "Välj språk" + }, + "command": { + "search": "Sök språk...", + "no_results": "Inget språk hittades." + }, + "theme": { + "light": "Ljust", + "dark": "Mörkt" + }, + "button": { + "update": "Uppdatera inställningar" + }, + "validation": { + "language": { + "required": "Välj ett språk." + }, + "theme": { + "required": "Välj ett tema." + } + } + }, "title": "Inställningar", "general": "Allmänt", "proxy": "Proxy", diff --git a/web/src/locales/zh-tw.json b/web/src/locales/zh-tw.json index 59466d2..7a80520 100644 --- a/web/src/locales/zh-tw.json +++ b/web/src/locales/zh-tw.json @@ -711,6 +711,39 @@ "interval": "間隔" }, "settings": { + "appearance": { + "title": "外觀設置", + "field": { + "language": "介面语言", + "theme": "介面主題" + }, + "description": { + "language": "選擇網頁介面顯示的语言。", + "theme": "為介面選擇淺色或深色顯示模式。" + }, + "placeholder": { + "select_language": "選擇語言" + }, + "command": { + "search": "搜尋語言...", + "no_results": "未找到相關語言。" + }, + "theme": { + "light": "淺色", + "dark": "深色" + }, + "button": { + "update": "更新偏好設置" + }, + "validation": { + "language": { + "required": "請選擇一種語言。" + }, + "theme": { + "required": "請選擇一個主題。" + } + } + }, "title": "設定", "general": "一般", "proxy": "代理", diff --git a/web/src/locales/zh.json b/web/src/locales/zh.json index df26a25..03041ce 100644 --- a/web/src/locales/zh.json +++ b/web/src/locales/zh.json @@ -718,6 +718,39 @@ "interval": "间隔" }, "settings": { + "appearance": { + "title": "外观设置", + "field": { + "language": "语言", + "theme": "界面主题" + }, + "description": { + "language": "选择 Web 界面显示的语言。", + "theme": "为界面选择浅色或深色显示模式。" + }, + "placeholder": { + "select_language": "选择语言" + }, + "command": { + "search": "搜索语言...", + "no_results": "未找到相关语言。" + }, + "theme": { + "light": "浅色", + "dark": "深色" + }, + "button": { + "update": "更新偏好设置" + }, + "validation": { + "language": { + "required": "请选择一种语言。" + }, + "theme": { + "required": "请选择一个主题。" + } + } + }, "title": "设置", "general": "常规", "proxy": "网络代理", diff --git a/web/src/routeTree.gen.ts b/web/src/routeTree.gen.ts index de6a5b8..813f51b 100644 --- a/web/src/routeTree.gen.ts +++ b/web/src/routeTree.gen.ts @@ -70,6 +70,9 @@ const AuthenticatedSettingsProfileLazyImport = createFileRoute( const AuthenticatedSettingsConfigurationsLazyImport = createFileRoute( '/_authenticated/settings/configurations', )() +const AuthenticatedSettingsAppearanceLazyImport = createFileRoute( + '/_authenticated/settings/appearance', +)() const AuthenticatedSettingsApiTokensLazyImport = createFileRoute( '/_authenticated/settings/api-tokens', )() @@ -282,6 +285,17 @@ const AuthenticatedSettingsConfigurationsLazyRoute = ), ) +const AuthenticatedSettingsAppearanceLazyRoute = + AuthenticatedSettingsAppearanceLazyImport.update({ + id: '/appearance', + path: '/appearance', + getParentRoute: () => AuthenticatedSettingsRouteLazyRoute, + } as any).lazy(() => + import('./routes/_authenticated/settings/appearance.lazy').then( + (d) => d.Route, + ), + ) + const AuthenticatedSettingsApiTokensLazyRoute = AuthenticatedSettingsApiTokensLazyImport.update({ id: '/api-tokens', @@ -381,6 +395,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedSettingsApiTokensLazyImport parentRoute: typeof AuthenticatedSettingsRouteLazyImport } + '/_authenticated/settings/appearance': { + id: '/_authenticated/settings/appearance' + path: '/appearance' + fullPath: '/settings/appearance' + preLoaderRoute: typeof AuthenticatedSettingsAppearanceLazyImport + parentRoute: typeof AuthenticatedSettingsRouteLazyImport + } '/_authenticated/settings/configurations': { id: '/_authenticated/settings/configurations' path: '/configurations' @@ -479,6 +500,7 @@ declare module '@tanstack/react-router' { interface AuthenticatedSettingsRouteLazyRouteChildren { AuthenticatedSettingsApiTokensLazyRoute: typeof AuthenticatedSettingsApiTokensLazyRoute + AuthenticatedSettingsAppearanceLazyRoute: typeof AuthenticatedSettingsAppearanceLazyRoute AuthenticatedSettingsConfigurationsLazyRoute: typeof AuthenticatedSettingsConfigurationsLazyRoute AuthenticatedSettingsProfileLazyRoute: typeof AuthenticatedSettingsProfileLazyRoute AuthenticatedSettingsProxyLazyRoute: typeof AuthenticatedSettingsProxyLazyRoute @@ -489,6 +511,8 @@ const AuthenticatedSettingsRouteLazyRouteChildren: AuthenticatedSettingsRouteLaz { AuthenticatedSettingsApiTokensLazyRoute: AuthenticatedSettingsApiTokensLazyRoute, + AuthenticatedSettingsAppearanceLazyRoute: + AuthenticatedSettingsAppearanceLazyRoute, AuthenticatedSettingsConfigurationsLazyRoute: AuthenticatedSettingsConfigurationsLazyRoute, AuthenticatedSettingsProfileLazyRoute: @@ -562,6 +586,7 @@ export interface FileRoutesByFullPath { '/503': typeof errors503LazyRoute '/': typeof AuthenticatedIndexRoute '/settings/api-tokens': typeof AuthenticatedSettingsApiTokensLazyRoute + '/settings/appearance': typeof AuthenticatedSettingsAppearanceLazyRoute '/settings/configurations': typeof AuthenticatedSettingsConfigurationsLazyRoute '/settings/profile': typeof AuthenticatedSettingsProfileLazyRoute '/settings/proxy': typeof AuthenticatedSettingsProxyLazyRoute @@ -586,6 +611,7 @@ export interface FileRoutesByTo { '/503': typeof errors503LazyRoute '/': typeof AuthenticatedIndexRoute '/settings/api-tokens': typeof AuthenticatedSettingsApiTokensLazyRoute + '/settings/appearance': typeof AuthenticatedSettingsAppearanceLazyRoute '/settings/configurations': typeof AuthenticatedSettingsConfigurationsLazyRoute '/settings/profile': typeof AuthenticatedSettingsProfileLazyRoute '/settings/proxy': typeof AuthenticatedSettingsProxyLazyRoute @@ -615,6 +641,7 @@ export interface FileRoutesById { '/(errors)/503': typeof errors503LazyRoute '/_authenticated/': typeof AuthenticatedIndexRoute '/_authenticated/settings/api-tokens': typeof AuthenticatedSettingsApiTokensLazyRoute + '/_authenticated/settings/appearance': typeof AuthenticatedSettingsAppearanceLazyRoute '/_authenticated/settings/configurations': typeof AuthenticatedSettingsConfigurationsLazyRoute '/_authenticated/settings/profile': typeof AuthenticatedSettingsProfileLazyRoute '/_authenticated/settings/proxy': typeof AuthenticatedSettingsProxyLazyRoute @@ -644,6 +671,7 @@ export interface FileRouteTypes { | '/503' | '/' | '/settings/api-tokens' + | '/settings/appearance' | '/settings/configurations' | '/settings/profile' | '/settings/proxy' @@ -667,6 +695,7 @@ export interface FileRouteTypes { | '/503' | '/' | '/settings/api-tokens' + | '/settings/appearance' | '/settings/configurations' | '/settings/profile' | '/settings/proxy' @@ -694,6 +723,7 @@ export interface FileRouteTypes { | '/(errors)/503' | '/_authenticated/' | '/_authenticated/settings/api-tokens' + | '/_authenticated/settings/appearance' | '/_authenticated/settings/configurations' | '/_authenticated/settings/profile' | '/_authenticated/settings/proxy' @@ -777,6 +807,7 @@ export const routeTree = rootRoute "parent": "/_authenticated", "children": [ "/_authenticated/settings/api-tokens", + "/_authenticated/settings/appearance", "/_authenticated/settings/configurations", "/_authenticated/settings/profile", "/_authenticated/settings/proxy", @@ -815,6 +846,10 @@ export const routeTree = rootRoute "filePath": "_authenticated/settings/api-tokens.lazy.tsx", "parent": "/_authenticated/settings" }, + "/_authenticated/settings/appearance": { + "filePath": "_authenticated/settings/appearance.lazy.tsx", + "parent": "/_authenticated/settings" + }, "/_authenticated/settings/configurations": { "filePath": "_authenticated/settings/configurations.lazy.tsx", "parent": "/_authenticated/settings" diff --git a/web/src/routes/_authenticated/settings/appearance.lazy.tsx b/web/src/routes/_authenticated/settings/appearance.lazy.tsx new file mode 100644 index 0000000..43ae434 --- /dev/null +++ b/web/src/routes/_authenticated/settings/appearance.lazy.tsx @@ -0,0 +1,27 @@ +// +// Copyright (c) 2025 rustmailer.com (https://rustmailer.com) +// +// This file is part of the Bichon Email Archiving Project +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + + +import { createLazyFileRoute } from '@tanstack/react-router' +import { SettingsAppearance } from '@/features/settings/appearance' + +export const Route = createLazyFileRoute('/_authenticated/settings/appearance')( + { + component: SettingsAppearance, + }, +)