feat: support user appearance preferences with persisted theme and language #85

This commit is contained in:
rustmailer
2025-12-30 15:09:41 +08:00
parent 75cae51be9
commit 455e6b1a75
39 changed files with 1080 additions and 83 deletions
+3 -3
View File
@@ -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::<BichonUser>(uid)
.primary::<UserModel>(uid)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
+3 -3
View File
@@ -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::<BichonUser>(user_id)
.primary::<UserModel>(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)
+2 -2
View File
@@ -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<u64, BichonUser>) -> AccountResp {
pub fn from_model(account: AccountModel, user_map: &HashMap<u64, UserModel>) -> AccountResp {
let user = user_map.get(&account.created_by);
AccountResp {
id: account.id,
+2 -2
View File
@@ -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<E: Endpoint> Endpoint for ApiGuardEndpoint<E> {
#[derive(Clone, Debug)]
pub struct ClientContext {
pub ip_addr: Option<IpAddr>,
pub user: BichonUser,
pub user: UserModel,
}
impl ClientContext {
+3
View File
@@ -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::<AccountModel>()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw.migrate::<UserModel>()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
+2 -1
View File
@@ -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::<Proxy>();
self.register_model::<UserRole>();
self.register_model::<BichonUser>();
self.register_model::<BichonUserV2>();
self.register_model::<AccessTokenModel>();
}
}
+2 -2
View File
@@ -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<u64, BichonUser> = BichonUser::list_all()
let user_map: HashMap<u64, UserModel> = UserModel::list_all()
.await?
.into_iter()
.map(|u| (u.id, u))
+8 -8
View File
@@ -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<u64, UserRole> = 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<u64, UserRole> = 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<Json<UserView>> {
let roles = UserRole::list_all().await?;
let role_lookup: BTreeMap<u64, UserRole> = 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(
+2 -2
View File
@@ -16,7 +16,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
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<LoginPayload>) -> 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)
+5 -5
View File
@@ -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<BichonUser> {
pub async fn resolve_user_from_token(token: &str) -> BichonResult<UserModel> {
let token = token.to_string();
let token_option = async_find_impl::<AccessTokenModel>(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<Vec<AccessTokenResp>> {
let users = BichonUser::list_all().await?;
let users = UserModel::list_all().await?;
let mut all = list_all_impl::<AccessTokenModel>(DB_MANAGER.meta_db()).await?;
all.retain(|t| t.token_type == TokenType::Api);
let user_map: HashMap<u64, BichonUser> = users.into_iter().map(|u| (u.id, u)).collect();
let user_map: HashMap<u64, UserModel> = users.into_iter().map(|u| (u.id, u)).collect();
let resp = all
.into_iter()
+2 -2
View File
@@ -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
}
}
+126 -26
View File
@@ -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<String>,
pub access_token: Option<String>,
pub theme: Option<String>,
pub language: Option<String>,
}
pub const DEFAULT_ADMIN_USER_ID: u64 = 100000000000000;
@@ -92,9 +96,44 @@ pub struct BichonUser {
pub acl: Option<AccessControl>,
}
impl BichonUser {
pub async fn list_all() -> BichonResult<Vec<BichonUser>> {
Ok(list_all_impl::<BichonUser>(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<String>,
/// 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<u64, u64>,
pub description: Option<String>,
/// System Roles: Permissions that apply to the whole system
/// (e.g., system settings, creating new users).
pub global_roles: Vec<u64>,
pub avatar: Option<String>,
pub created_at: i64,
pub updated_at: i64,
/// Optional access control settings
pub acl: Option<AccessControl>,
pub theme: Option<String>,
pub language: Option<String>,
}
impl BichonUserV2 {
pub async fn list_all() -> BichonResult<Vec<UserModel>> {
Ok(list_all_impl::<UserModel>(DB_MANAGER.meta_db()).await?)
}
async fn get_all_permissions(&self) -> HashSet<String> {
@@ -111,7 +150,7 @@ impl BichonUser {
all_perms
}
pub fn to_current_user(self, role_lookup: &BTreeMap<u64, UserRole>) -> UserView {
pub fn to_view(self, role_lookup: &BTreeMap<u64, UserRole>) -> 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::<BichonUser>(DEFAULT_ADMIN_USER_ID)
.primary::<UserModel>(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<LoginResult> {
let user_option = secondary_find_impl::<BichonUser>(
let user_option = secondary_find_impl::<UserModel>(
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::<BichonUser>(
match secondary_find_impl::<UserModel>(
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<Option<BichonUser>> {
pub async fn find(user_id: u64) -> BichonResult<Option<UserModel>> {
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::<BichonUser>(
if secondary_find_impl::<UserModel>(
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::<BichonUser>(
if secondary_find_impl::<UserModel>(
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<BichonUser> {
pub async fn create(request: UserCreateRequest) -> BichonResult<UserModel> {
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::<BichonUser>(
let user_option = secondary_find_impl::<UserModel>(
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::<BichonUser>(
let user_option = secondary_find_impl::<UserModel>(
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::<BichonUser>(id)
.primary::<UserModel>(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<Vec<BichonUser>> {
async fn list_authorized_users(account_id: u64) -> BichonResult<Vec<UserModel>> {
let all = Self::list_all().await?;
let result: Vec<BichonUser> = all
let result: Vec<UserModel> = 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::<BichonUser>(user.id)
.primary::<UserModel>(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<BichonUserV2> 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<BichonUser> 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,
}
}
}
+48 -1
View File
@@ -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<String>,
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<String>) -> BichonResult<()> {
validate_option_in_set(theme, &allowed_themes(), "theme")
}
fn validate_language(language: &Option<String>) -> 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<AccessControl>,
pub avatar_base64: Option<String>,
pub description: Option<String>,
pub theme: Option<String>,
pub language: Option<String>,
}
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<u64, RoleType> =
all_roles.into_iter().map(|r| (r.id, r.role_type)).collect();
@@ -301,6 +343,8 @@ pub struct UserUpdateRequest {
pub account_access_map: Option<BTreeMap<u64, u64>>,
pub acl: Option<AccessControl>,
pub description: Option<String>,
pub theme: Option<String>,
pub language: Option<String>,
}
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<u64, RoleType> =
all_roles.into_iter().map(|r| (r.id, r.role_type)).collect();
+2
View File
@@ -49,4 +49,6 @@ pub struct UserView {
pub updated_at: i64,
/// Optional access control settings
pub acl: Option<AccessControl>,
pub theme: Option<String>,
pub language: Option<String>,
}