Merge branch 'main' into fix/rename-id-to-message-id

This commit is contained in:
rustmailer
2025-12-26 19:59:25 +08:00
committed by GitHub
184 changed files with 23973 additions and 3193 deletions
Generated
+1 -1
View File
@@ -424,7 +424,7 @@ dependencies = [
[[package]]
name = "bichon"
version = "0.1.3"
version = "0.2.0"
dependencies = [
"ahash",
"async-imap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "bichon"
version = "0.1.4"
version = "0.2.0"
edition = "2021"
[[bin]]
+38
View File
@@ -386,6 +386,41 @@ A special thank you to **[@rallisf1](https://github.com/rallisf1)** for sharing
This data is provided solely as a **reference** for real-world usage. We encourage more users to share their Bichon usage screenshots and metrics (e.g., ingestion volume, compression ratio, search speed, etc.) to help the community conduct a more comprehensive assessment of Bichon's suitability and performance.
---
## Roadmap
* [ ] Multi-user support with account/password login
* System-level roles (admin / user)
* Per-mail-account permissions
* [ ] `bichon-cli` command-line tool
* Import emails from `eml`, `mbox`, `msg`, `pst`
* [ ] Manual sync controls
* Sync on demand
* Sync a single folder
* Verify completeness by comparing with the mail server
* [ ] Post-sync server cleanup
* Clean up server-side emails after successful sync
* Free up mailbox space (e.g. Gmail)
* [ ] Email export
* Export by folder
* Export by entire account
* [ ] Account-to-account email sync
* Sync emails to a specified target account
* Support mailbox migration
---
## 🛠️ Tech Stack
@@ -449,9 +484,12 @@ cargo build
Or run directly:
```bash
export BICHON_ENCRYPT_PASSWORD=dummy-password-for-testing
cargo run -- --bichon-root-dir e:\bichon-data
```
`--bichon-root-dir` specifies the directory where **all Bichon data** will be stored.
`BICHON_ENCRYPT_PASSWORD` is the password used to encrypt the sensitive data (see `cargo run -- --help` for alternative ways to specify this).
### WebUI Access
+4 -4
View File
@@ -16,7 +16,6 @@
// 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 mimalloc::MiMalloc;
use modules::{
common::rustls::RustMailerTls,
@@ -25,11 +24,12 @@ use modules::{
logger,
rest::start_http_server,
tasks::PeriodicTasks,
token::root::ensure_root_token,
};
use tracing::info;
use crate::modules::{common::signal::SignalManager, settings::dir::DataDirManager};
use crate::modules::{
common::signal::SignalManager, settings::dir::DataDirManager, users::manager::UserManager,
};
mod modules;
@@ -68,7 +68,7 @@ async fn initialize() -> BichonResult<()> {
// SETTINGS.validate()?;
SignalManager::initialize().await?;
DataDirManager::initialize().await?;
ensure_root_token().await?;
UserManager::initialize().await?;
RustMailerTls::initialize().await?;
EmailClientExecutors::initialize().await?;
PeriodicTasks::start_background_tasks();
+160
View File
@@ -0,0 +1,160 @@
//
// 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 <http://www.gnu.org/licenses/>.
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use crate::{
modules::{
account::migration::AccountModel,
common::auth::ClientContext,
database::{manager::DB_MANAGER, with_transaction},
error::{code::ErrorCode, BichonResult},
users::{
permissions::Permission,
role::{RoleType, UserRole},
BichonUser,
},
},
raise_error, utc_now,
};
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct BatchAccountRoleRequest {
pub account_ids: Vec<u64>,
pub user_ids: Vec<u64>,
pub role_id: u64,
}
impl BatchAccountRoleRequest {
pub async fn validate_existence(&self) -> BichonResult<()> {
let role = UserRole::find(self.role_id).await?.ok_or_else(|| {
raise_error!(
format!("Role ID {} not found", self.role_id),
ErrorCode::ResourceNotFound
)
})?;
if !matches!(role.role_type, RoleType::Account) {
return Err(raise_error!(
"Only Account roles can be assigned to individual account".into(),
ErrorCode::InvalidParameter
));
}
for id in &self.account_ids {
let exists = AccountModel::find(*id).await?; // Assuming an exists helper
if exists.is_none() {
return Err(raise_error!(
format!("Account ID {} not found", id),
ErrorCode::ResourceNotFound
));
}
}
for id in &self.user_ids {
let exists = BichonUser::find(*id).await?; // Assuming an exists helper
if exists.is_none() {
return Err(raise_error!(
format!("User ID {} not found", id),
ErrorCode::ResourceNotFound
));
}
}
Ok(())
}
async fn grant_batch_account_access(
account_ids: Vec<u64>,
user_ids: Vec<u64>,
role_id: u64,
) -> BichonResult<()> {
with_transaction(DB_MANAGER.meta_db(), move |rw| {
for &uid in &user_ids {
// Fetch the current user record from the database
let user = rw
.get()
.primary::<BichonUser>(uid)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!("User with id={} not found.", uid),
ErrorCode::ResourceNotFound
)
})?;
let mut updated_user = user.clone();
// Apply the role to each specified account_id
for &aid in &account_ids {
updated_user.account_access_map.insert(aid, role_id);
}
updated_user.updated_at = utc_now!();
// Save the updated user back to the database within the transaction
rw.update(user, updated_user)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
}
Ok(())
})
.await
}
pub async fn do_assign(self, context: &ClientContext) -> BichonResult<()> {
for account_id in &self.account_ids {
// Get the user's specific access for this account
let assigned_role_id =
context
.user
.account_access_map
.get(account_id)
.ok_or_else(|| {
raise_error!(
format!("No access to account {}", account_id),
ErrorCode::Forbidden
)
})?;
// Fetch the role definition from the database
let user_scoped_role = UserRole::find(*assigned_role_id).await?.ok_or_else(|| {
raise_error!(
"Assigned account role no longer exists".into(),
ErrorCode::InternalError
)
})?;
// Critical Check: Does this role grant management/sharing rights?
if !user_scoped_role
.permissions
.contains(Permission::ACCOUNT_MANAGE)
{
return Err(raise_error!(
format!("Your role on account {} does not allow sharing", account_id),
ErrorCode::Forbidden
));
}
// Optional: Ensure manager isn't giving away perms they don't have
// This is where you'd compare target_role.permissions vs manager's perms
}
Self::grant_batch_account_access(self.account_ids, self.user_ids, self.role_id).await
}
}
+133 -22
View File
@@ -29,9 +29,10 @@ use crate::{
modules::{
account::{entity::ImapConfig, since::DateSince, state::AccountRunningState},
cache::imap::mailbox::MailBox,
database::{insert_impl, list_all_impl},
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},
},
utc_now,
};
@@ -52,10 +53,9 @@ use crate::modules::database::{
use crate::modules::error::code::ErrorCode;
use crate::modules::oauth2::token::OAuth2AccessToken;
use crate::modules::rest::response::DataPage;
use crate::modules::token::AccessToken;
use crate::raise_error;
pub type AccountModel = AccountV2;
pub type AccountModel = AccountV3;
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Enum)]
pub enum AccountType {
@@ -121,8 +121,40 @@ impl AccountV2 {
fn pk(&self) -> String {
format!("{}_{}", self.created_at, self.id)
}
}
pub fn new(request: AccountCreateRequest) -> BichonResult<Self> {
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
#[native_model(id = 4, version = 3, from = AccountV2)]
#[native_db(primary_key(pk -> String))]
pub struct AccountV3 {
#[secondary_key(unique)]
pub id: u64,
pub imap: Option<ImapConfig>,
pub enabled: bool,
#[oai(validator(custom = "crate::modules::common::validator::EmailValidator"))]
pub email: String,
pub name: Option<String>,
pub capabilities: Option<Vec<String>>,
pub date_since: Option<DateSince>,
pub folder_limit: Option<u32>,
pub sync_folders: Option<Vec<String>>,
pub account_type: AccountType,
pub sync_interval_min: Option<i64>,
pub known_folders: Option<BTreeSet<String>>,
pub created_at: i64,
pub updated_at: i64,
pub created_by: u64, //user id
pub use_proxy: Option<u64>,
pub use_dangerous: bool,
pub pgp_key: Option<String>,
}
impl AccountV3 {
fn pk(&self) -> String {
format!("{}_{}", self.created_at, self.id)
}
pub fn new(user_id: u64, request: AccountCreateRequest) -> BichonResult<Self> {
Ok(Self {
id: id!(64),
email: request.email,
@@ -141,12 +173,13 @@ impl AccountV2 {
folder_limit: request.folder_limit,
use_dangerous: request.use_dangerous,
pgp_key: request.pgp_key,
created_by: user_id,
})
}
pub async fn check_account_exists(account_id: u64) -> BichonResult<AccountModel> {
let account =
secondary_find_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV2Key::id, account_id)
secondary_find_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV3Key::id, account_id)
.await?
.ok_or_else(|| {
raise_error!(
@@ -176,24 +209,53 @@ impl AccountV2 {
}
pub async fn find(account_id: u64) -> BichonResult<Option<AccountModel>> {
secondary_find_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV2Key::id, account_id)
secondary_find_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV3Key::id, account_id)
.await
}
/// Saves the current `AccountEntity` by persisting it to storage.
pub async fn save(&self) -> BichonResult<()> {
insert_impl(DB_MANAGER.meta_db(), self.to_owned()).await
}
// /// Saves the current `AccountEntity` by persisting it to storage.
// pub async fn save(&self) -> BichonResult<()> {
// insert_impl(DB_MANAGER.meta_db(), self.to_owned()).await
// }
pub async fn create_account(request: AccountCreateRequest) -> BichonResult<AccountModel> {
let entity = request.create_entity()?;
entity.save().await?;
if matches!(entity.account_type, AccountType::IMAP) {
pub async fn create_account(
user_id: u64,
request: AccountCreateRequest,
) -> BichonResult<AccountModel> {
let entity = request.create_entity(user_id)?;
let cloned = entity.clone();
with_transaction(DB_MANAGER.meta_db(), move |rw| {
let account_id = entity.id;
rw.insert::<AccountModel>(entity)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let user = rw
.get()
.primary::<BichonUser>(user_id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!("User with id={} not found.", user_id),
ErrorCode::ResourceNotFound
)
})?;
let mut updated = user.clone();
updated
.account_access_map
.insert(account_id, DEFAULT_ACCOUNT_MANAGER_ROLE_ID);
updated.updated_at = utc_now!();
rw.update(user, updated)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(())
})
.await?;
if matches!(cloned.account_type, AccountType::IMAP) {
SYNC_CONTROLLER
.trigger_start(entity.id, entity.email.clone())
.trigger_start(cloned.id, cloned.email.clone())
.await;
}
Ok(entity)
Ok(cloned)
}
pub async fn update(
@@ -230,7 +292,7 @@ impl AccountV2 {
async fn delete_account(account_id: u64) -> BichonResult<()> {
delete_impl(DB_MANAGER.meta_db(), move|rw|{
rw.get().secondary::<AccountModel>(AccountV2Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
rw.get().secondary::<AccountModel>(AccountV3Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(||raise_error!(format!("The account entity with id={account_id} that you want to delete was not found."), ErrorCode::ResourceNotFound))
}).await
}
@@ -242,7 +304,7 @@ impl AccountV2 {
MAIL_CONTEXT.clean_account(account.id).await?;
}
OAuth2AccessToken::try_delete(account.id).await?;
AccessToken::cleanup_account(account.id).await?;
BichonUser::cleanup_account(account.id).await?;
MailBox::clean(account.id).await?;
ENVELOPE_INDEX_MANAGER
.delete_account_envelopes(account.id)
@@ -260,7 +322,7 @@ impl AccountV2 {
sync_folders: Vec<String>,
) -> BichonResult<()> {
update_impl(DB_MANAGER.meta_db(), move |rw| {
rw.get().secondary::<AccountModel>(AccountV2Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
rw.get().secondary::<AccountModel>(AccountV3Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| raise_error!(format!("When trying to update account sync_folders, the corresponding record was not found. account_id={}", account_id), ErrorCode::ResourceNotFound))
}, |current|{
let mut updated = current.clone();
@@ -275,7 +337,7 @@ impl AccountV2 {
known_folders: BTreeSet<String>,
) -> BichonResult<()> {
update_impl(DB_MANAGER.meta_db(), move |rw| {
rw.get().secondary::<AccountModel>(AccountV2Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
rw.get().secondary::<AccountModel>(AccountV3Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| raise_error!(format!("When trying to update account known_folders, the corresponding record was not found. account_id={}", account_id), ErrorCode::ResourceNotFound))
}, |current|{
let mut updated = current.clone();
@@ -290,7 +352,7 @@ impl AccountV2 {
capabilities: Vec<String>,
) -> BichonResult<()> {
update_impl(DB_MANAGER.meta_db(), move |rw| {
rw.get().secondary::<AccountModel>(AccountV2Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
rw.get().secondary::<AccountModel>(AccountV3Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| raise_error!(format!("When trying to update account capabilities, the corresponding record was not found. account_id={}", account_id), ErrorCode::ResourceNotFound))
}, |current|{
let mut updated = current.clone();
@@ -319,7 +381,7 @@ impl AccountV2 {
}
pub async fn count() -> BichonResult<usize> {
count_by_unique_secondary_key_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV2Key::id)
count_by_unique_secondary_key_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV3Key::id)
.await
}
@@ -450,3 +512,52 @@ impl From<AccountV2> for AccountV1 {
}
}
}
impl From<AccountV3> for AccountV2 {
fn from(value: AccountV3) -> Self {
Self {
id: value.id,
imap: value.imap,
enabled: value.enabled,
email: value.email,
name: value.name,
capabilities: value.capabilities,
date_since: value.date_since,
folder_limit: value.folder_limit,
sync_folders: value.sync_folders,
account_type: value.account_type,
sync_interval_min: value.sync_interval_min,
known_folders: value.known_folders,
created_at: value.created_at,
updated_at: value.updated_at,
use_proxy: value.use_proxy,
use_dangerous: value.use_dangerous,
pgp_key: value.pgp_key,
}
}
}
impl From<AccountV2> for AccountV3 {
fn from(value: AccountV2) -> Self {
Self {
id: value.id,
imap: value.imap,
enabled: value.enabled,
email: value.email,
name: value.name,
capabilities: value.capabilities,
date_since: value.date_since,
folder_limit: value.folder_limit,
sync_folders: value.sync_folders,
account_type: value.account_type,
sync_interval_min: value.sync_interval_min,
known_folders: value.known_folders,
created_at: value.created_at,
updated_at: value.updated_at,
created_by: DEFAULT_ADMIN_USER_ID,
use_proxy: value.use_proxy,
use_dangerous: value.use_dangerous,
pgp_key: value.pgp_key,
}
}
}
+3 -2
View File
@@ -16,10 +16,11 @@
// 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/>.
pub mod dispatcher;
pub mod entity;
pub mod grant;
pub mod migration;
pub mod payload;
pub mod since;
pub mod state;
pub mod migration;
pub mod view;
+4 -7
View File
@@ -16,14 +16,11 @@
// 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 std::collections::BTreeSet;
use crate::modules::account::entity::ImapConfig;
use crate::modules::account::migration::{AccountModel, AccountType};
use crate::modules::account::since::DateSince;
use crate::modules::error::code::ErrorCode;
use crate::modules::error::BichonResult;
use crate::modules::token::AccountInfo;
use crate::{raise_error, validate_email};
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
@@ -47,7 +44,7 @@ pub struct AccountCreateRequest {
}
impl AccountCreateRequest {
pub fn create_entity(self) -> BichonResult<AccountModel> {
pub fn create_entity(self, user_id: u64) -> BichonResult<AccountModel> {
if let Some(date_since) = self.date_since.as_ref() {
date_since.validate()?;
}
@@ -71,7 +68,7 @@ impl AccountCreateRequest {
}
AccountType::NoSync => {}
}
Ok(AccountModel::new(self)?)
Ok(AccountModel::new(user_id, self)?)
}
fn validate_request(imap: &ImapConfig, email: &str) -> BichonResult<()> {
@@ -167,11 +164,11 @@ pub struct MinimalAccount {
pub fn filter_accessible_accounts<'a>(
all_accounts: &'a [MinimalAccount],
allowed: &BTreeSet<AccountInfo>,
allowed: &Vec<u64>,
) -> Vec<MinimalAccount> {
all_accounts
.iter()
.filter(|acct| allowed.iter().any(|a| a.id == acct.id))
.filter(|acct| allowed.contains(&acct.id))
.cloned()
.collect()
}
+87
View File
@@ -0,0 +1,87 @@
//
// 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 <http://www.gnu.org/licenses/>.
use std::collections::{BTreeSet, HashMap};
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use crate::modules::{
account::{
entity::ImapConfig,
migration::{AccountModel, AccountType},
since::DateSince,
},
users::BichonUser,
};
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct AccountResp {
pub id: u64,
pub imap: Option<ImapConfig>,
pub enabled: bool,
pub email: String,
pub name: Option<String>,
pub capabilities: Option<Vec<String>>,
pub date_since: Option<DateSince>,
pub folder_limit: Option<u32>,
pub sync_folders: Option<Vec<String>>,
pub account_type: AccountType,
pub sync_interval_min: Option<i64>,
pub known_folders: Option<BTreeSet<String>>,
pub created_at: i64,
pub updated_at: i64,
pub created_by: u64, //user id
pub created_user_name: String,
pub created_user_email: String,
pub use_proxy: Option<u64>,
pub use_dangerous: bool,
pub pgp_key: Option<String>,
}
impl AccountResp {
pub fn from_model(account: AccountModel, user_map: &HashMap<u64, BichonUser>) -> AccountResp {
let user = user_map.get(&account.created_by);
AccountResp {
id: account.id,
imap: account.imap,
enabled: account.enabled,
email: account.email,
name: account.name,
capabilities: account.capabilities,
date_since: account.date_since,
folder_limit: account.folder_limit,
sync_folders: account.sync_folders,
account_type: account.account_type,
sync_interval_min: account.sync_interval_min,
known_folders: account.known_folders,
created_at: account.created_at,
updated_at: account.updated_at,
created_by: account.created_by,
created_user_name: user
.map(|u| u.username.clone())
.unwrap_or_else(|| "Unknown".to_string()),
created_user_email: user
.map(|u| u.email.clone())
.unwrap_or_else(|| "N/A".to_string()),
use_proxy: account.use_proxy,
use_dangerous: account.use_dangerous,
pgp_key: account.pgp_key,
}
}
}
+153 -133
View File
@@ -16,12 +16,11 @@
// 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::{
error::{code::ErrorCode, BichonResult},
settings::{cli::SETTINGS, system::SystemSetting},
token::{root::ROOT_TOKEN, AccessToken, AccountInfo},
token::AccessTokenModel,
users::{permissions::Permission, role::UserRole, BichonUser},
utils::rate_limit::RATE_LIMITER_MANAGER,
},
raise_error,
@@ -35,7 +34,11 @@ use poem::{
Endpoint, FromRequest, Middleware, Request, RequestBody, Result,
};
use serde::Deserialize;
use std::{collections::BTreeSet, net::IpAddr, sync::Arc};
use std::{
collections::{BTreeSet, HashSet},
net::IpAddr,
sync::Arc,
};
use super::create_api_error_response;
@@ -68,62 +71,101 @@ impl<E: Endpoint> Endpoint for ApiGuardEndpoint<E> {
}
}
#[derive(Clone, Debug, Default)]
#[derive(Clone, Debug)]
pub struct ClientContext {
pub ip_addr: Option<IpAddr>,
pub access_token: Option<AccessToken>,
pub is_root: bool,
pub user: BichonUser,
}
impl ClientContext {
pub fn require_root(&self) -> BichonResult<()> {
if !SETTINGS.bichon_enable_access_token || self.is_root {
Ok(())
} else {
Err(raise_error!(
"Root access required".into(),
ErrorCode::PermissionDenied
))
}
}
pub fn require_authorized(&self) -> BichonResult<()> {
if !SETTINGS.bichon_enable_access_token || self.is_root || self.access_token.is_some() {
Ok(())
} else {
Err(raise_error!(
"Authorization required".into(),
ErrorCode::PermissionDenied
))
}
}
pub fn require_account_access(&self, account_id: u64) -> BichonResult<()> {
if !SETTINGS.bichon_enable_access_token || self.is_root {
return Ok(());
}
match &self.access_token {
Some(token) if token.can_access_account(account_id) => Ok(()),
_ => Err(raise_error!(format!(
"You do not have permission to access the requested email account (ID: {}). Please check your access rights or contact the administrator.",
account_id
), ErrorCode::PermissionDenied)),
}
}
pub fn accessible_accounts(&self) -> BichonResult<Option<&BTreeSet<AccountInfo>>> {
if !SETTINGS.bichon_enable_access_token || self.is_root {
Ok(None) // All accounts are accessible
} else {
match &self.access_token {
Some(token) => Ok(Some(&token.accounts)),
None => Err(raise_error!(
"Missing access token".into(),
ErrorCode::PermissionDenied
)),
pub async fn require_any_permission(
&self,
requirements: Vec<(Option<u64>, &str)>,
) -> BichonResult<()> {
for (account_id, permission) in requirements {
if self.has_permission(account_id, permission).await {
return Ok(());
}
}
Err(raise_error!(
"Access denied: Insufficient permissions to perform this action.".into(),
ErrorCode::Forbidden
))
}
pub async fn has_permission(&self, account_id: Option<u64>, permission: &str) -> bool {
if self.user.is_admin().await {
return true;
}
let mut global_perms = HashSet::new();
for rid in &self.user.global_roles {
if let Some(role) = UserRole::find(*rid).await.ok().flatten() {
global_perms.extend(role.permissions);
}
}
if self.check_global_logic(&global_perms, permission) {
return true;
}
if let Some(aid) = account_id {
if let Some(role_id) = self.user.account_access_map.get(&aid) {
if let Some(role) = UserRole::find(*role_id).await.ok().flatten() {
if role.permissions.contains(&permission.to_string())
|| self.check_account_logic(&role.permissions, permission)
{
return true;
}
}
}
}
false
}
fn check_global_logic(&self, global: &HashSet<String>, perm: &str) -> bool {
if global.contains(perm) {
return true;
}
match perm {
Permission::DATA_READ => global.contains(Permission::DATA_READ_ALL),
Permission::DATA_DELETE => global.contains(Permission::DATA_DELETE_ALL),
Permission::DATA_RAW_DOWNLOAD => global.contains(Permission::DATA_RAW_DOWNLOAD_ALL),
Permission::DATA_EXPORT_BATCH => global.contains(Permission::DATA_EXPORT_BATCH_ALL),
Permission::ACCOUNT_MANAGE | Permission::ACCOUNT_READ_DETAILS => {
global.contains(Permission::ACCOUNT_MANAGE_ALL)
}
_ => false,
}
}
fn check_account_logic(&self, scoped_perms: &BTreeSet<String>, perm: &str) -> bool {
if scoped_perms.contains(perm) {
return true;
}
match perm {
Permission::DATA_READ | Permission::ACCOUNT_READ_DETAILS => {
scoped_perms.contains(Permission::ACCOUNT_MANAGE)
}
_ => false,
}
}
pub async fn require_permission(
&self,
account_id: Option<u64>,
permission: &str,
) -> BichonResult<()> {
if self.has_permission(account_id, permission).await {
Ok(())
} else {
Err(raise_error!(
format!("Access Denied: Missing permission '{}'", permission),
ErrorCode::Forbidden
))
}
}
}
@@ -134,98 +176,76 @@ impl<'a> FromRequest<'a> for ClientContext {
}
pub async fn extract_client_context(req: &Request) -> Result<ClientContext> {
if SETTINGS.bichon_enable_access_token {
let ip_addr = RealIp::from_request_without_body(req)
.await
.map_err(|_| {
create_api_error_response(
"Failed to parse client IP address",
ErrorCode::InvalidParameter,
)
})?
.0
.ok_or_else(|| {
create_api_error_response(
"Failed to parse client IP address",
ErrorCode::InvalidParameter,
)
})?;
// Extract access token from Bearer header or query params
let bearer = req
.headers()
.typed_get::<Authorization<Bearer>>()
.map(|auth| auth.0.token().to_string())
.or_else(|| req.params::<Param>().ok().map(|param| param.access_token));
let ip_addr = RealIp::from_request_without_body(req)
.await
.map_err(|_| {
create_api_error_response(
"Failed to parse client IP address",
ErrorCode::InvalidParameter,
)
})?
.0
.ok_or_else(|| {
create_api_error_response(
"Failed to parse client IP address",
ErrorCode::InvalidParameter,
)
})?;
// Extract access token from Bearer header or query params
let bearer = req
.headers()
.typed_get::<Authorization<Bearer>>()
.map(|auth| auth.0.token().to_string())
.or_else(|| req.params::<Param>().ok().map(|param| param.access_token));
let token = bearer.ok_or_else(|| {
create_api_error_response("Valid access token not found", ErrorCode::PermissionDenied)
let token = bearer.ok_or_else(|| {
create_api_error_response("Valid access token not found", ErrorCode::PermissionDenied)
})?;
// Validate and update access token
let user = AccessTokenModel::resolve_user_from_token(&token)
.await
.map_err(|e| {
create_api_error_response(&format!("{:#?}", e), ErrorCode::PermissionDenied)
})?;
// Check for root token
if let Ok(Some(root)) = SystemSetting::get(ROOT_TOKEN) {
if root.value == token {
return Ok(ClientContext {
ip_addr: Some(ip_addr),
access_token: None,
is_root: true,
});
}
}
// Validate and update access token
let validated_token = AccessToken::try_update_access_timestamp(&token)
.await
.map_err(|_| {
create_api_error_response("Invalid access token", ErrorCode::PermissionDenied)
})?;
return Ok(ClientContext {
ip_addr: Some(ip_addr),
access_token: Some(validated_token),
is_root: false,
});
}
Ok(Default::default())
return Ok(ClientContext {
ip_addr: Some(ip_addr),
user,
});
}
pub async fn authorize_access(req: &Request) -> Result<ClientContext, poem::Error> {
let context = extract_client_context(&req).await?;
context.require_authorized().map_err(|error| {
create_api_error_response(&error.to_string(), ErrorCode::PermissionDenied)
})?;
if let Some(access_token) = &context.access_token {
if let Some(access_control) = &access_token.acl {
if let Some(ip_addr) = context.ip_addr {
if let Some(whitelist) = &access_control.ip_whitelist {
if !whitelist.contains(&ip_addr.to_string()) {
return Err(create_api_error_response(
&format!("IP {} not in whitelist", ip_addr),
ErrorCode::PermissionDenied,
));
}
}
}
if let Some(rate_limit) = &access_control.rate_limit {
if let Err(not_until) = RATE_LIMITER_MANAGER
.check(&access_token.token, rate_limit.clone())
.await
{
let wait_duration = not_until.wait_time_from(QuantaClock::default().now());
if let Some(access_control) = &context.user.acl {
if let Some(ip_addr) = context.ip_addr {
if let Some(whitelist) = &access_control.ip_whitelist {
if !whitelist.contains(&ip_addr.to_string()) {
return Err(create_api_error_response(
&format!(
"Rate limit: {}/{}s. Retry after {}s",
rate_limit.quota,
rate_limit.interval,
wait_duration.as_secs()
),
ErrorCode::TooManyRequest,
&format!("IP {} not in whitelist", ip_addr),
ErrorCode::Forbidden,
));
}
}
}
if let Some(rate_limit) = &access_control.rate_limit {
if let Err(not_until) = RATE_LIMITER_MANAGER
.check(context.user.id, rate_limit.clone())
.await
{
let wait_duration = not_until.wait_time_from(QuantaClock::default().now());
return Err(create_api_error_response(
&format!(
"Rate limit: {}/{}s. Retry after {}s",
rate_limit.quota,
rate_limit.interval,
wait_duration.as_secs()
),
ErrorCode::TooManyRequest,
));
}
}
}
Ok(context)
+42 -9
View File
@@ -16,14 +16,17 @@
// 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::permissions::Permission;
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use tantivy::{schema::Value, TantivyDocument};
use crate::{
bichon_version,
modules::{
account::migration::AccountModel,
common::auth::ClientContext,
error::{code::ErrorCode, BichonResult},
indexer::{manager::ENVELOPE_INDEX_MANAGER, schema::SchemaTools},
settings::dir::DATA_DIR_MANAGER,
@@ -50,17 +53,47 @@ pub struct DashboardStats {
}
impl DashboardStats {
pub async fn get() -> BichonResult<Self> {
let mut stat = ENVELOPE_INDEX_MANAGER.get_dashboard_stats().await?;
stat.top_largest_emails = ENVELOPE_INDEX_MANAGER.top_10_largest_emails().await?;
stat.email_count = ENVELOPE_INDEX_MANAGER.total_emails()?;
stat.account_count = AccountModel::count().await?;
stat.storage_usage_bytes = get_total_size(&DATA_DIR_MANAGER.eml_dir)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
stat.index_usage_bytes = get_total_size(&DATA_DIR_MANAGER.envelope_dir)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
pub async fn get(context: ClientContext) -> BichonResult<Self> {
let has_all_accounts = context
.has_permission(None, Permission::ACCOUNT_MANAGE_ALL)
.await;
let authorized_ids: Option<HashSet<u64>> = if has_all_accounts {
None
} else {
Some(context.user.account_access_map.keys().cloned().collect())
};
let mut stat = ENVELOPE_INDEX_MANAGER
.get_dashboard_stats(&authorized_ids)
.await?;
stat.top_largest_emails = ENVELOPE_INDEX_MANAGER
.top_10_largest_emails(&authorized_ids)
.await?;
stat.account_count = if has_all_accounts {
AccountModel::count().await?
} else {
authorized_ids.as_ref().map(|ids| ids.len()).unwrap_or(0)
};
stat.email_count = ENVELOPE_INDEX_MANAGER.total_emails(&authorized_ids)?;
if has_all_accounts {
stat.storage_usage_bytes = get_total_size(&DATA_DIR_MANAGER.eml_dir)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
stat.index_usage_bytes = get_total_size(&DATA_DIR_MANAGER.envelope_dir)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
} else {
stat.storage_usage_bytes = 0;
stat.index_usage_bytes = 0;
}
stat.system_version = bichon_version!().to_string();
stat.commit_hash = env!("GIT_HASH").to_string();
Ok(stat)
}
}
+45 -19
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::account::migration::{AccountV1, AccountV2};
use crate::modules::account::migration::{AccountV1, AccountV2, AccountV3};
use crate::modules::autoconfig::CachedMailSettings;
use crate::modules::error::code::ErrorCode;
use crate::modules::error::BichonResult;
@@ -25,7 +25,9 @@ use crate::modules::oauth2::pending::OAuth2PendingEntity;
use crate::modules::oauth2::token::OAuth2AccessToken;
use crate::modules::settings::proxy::Proxy;
use crate::modules::settings::system::SystemSetting;
use crate::modules::token::AccessToken;
use crate::modules::token::AccessTokenModel;
use crate::modules::users::role::UserRole;
use crate::modules::users::BichonUser;
use crate::raise_error;
use db_type::{KeyOptions, ToKeyDefinition};
use itertools::Itertools;
@@ -58,15 +60,20 @@ impl ModelsAdapter {
}
pub fn register_metadata_models(&mut self) {
self.register_model::<AccessToken>();
//Starting from version 0.2.0, `AccessToken` is deprecated/no longer used, but its ID must not be reused, otherwise it may cause model errors.
//self.register_model::<AccessToken>();
self.register_model::<SystemSetting>();
self.register_model::<CachedMailSettings>();
self.register_model::<AccountV1>();
self.register_model::<AccountV2>();
self.register_model::<AccountV3>();
self.register_model::<OAuth2>();
self.register_model::<OAuth2PendingEntity>();
self.register_model::<OAuth2AccessToken>();
self.register_model::<Proxy>();
self.register_model::<UserRole>();
self.register_model::<BichonUser>();
self.register_model::<AccessTokenModel>();
}
}
@@ -170,11 +177,11 @@ pub async fn update_impl<T: ToInput + Clone + std::fmt::Debug + Send + 'static>(
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let current_item = current(&rw)?;
let updated_item = updated(&current_item)?;
rw.update(current_item.clone(), updated_item)
rw.update(current_item, updated_item.clone())
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(current_item)
Ok(updated_item)
})
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
@@ -223,20 +230,20 @@ pub async fn async_find_impl<T: ToInput + Clone + Send + 'static>(
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
}
pub fn find_impl<T: ToInput + Clone + Send + 'static>(
database: &Arc<Database<'static>>,
key: &str,
) -> BichonResult<Option<T>> {
let db = database.clone();
let r_transaction = db
.r_transaction()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let entity: Option<T> = r_transaction
.get()
.primary(key)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(entity)
}
// pub fn find_impl<T: ToInput + Clone + Send + 'static>(
// database: &Arc<Database<'static>>,
// key: &str,
// ) -> BichonResult<Option<T>> {
// let db = database.clone();
// let r_transaction = db
// .r_transaction()
// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
// let entity: Option<T> = r_transaction
// .get()
// .primary(key)
// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
// Ok(entity)
// }
pub async fn delete_impl<T: ToInput + Clone + Send + 'static>(
database: &Arc<Database<'static>>,
@@ -307,6 +314,25 @@ pub async fn list_all_impl<T: ToInput + Clone + Send + 'static>(
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
}
pub async fn with_transaction(
database: &Arc<Database<'static>>,
f: impl FnOnce(&RwTransaction) -> BichonResult<()> + Send + 'static,
) -> BichonResult<()> {
let db: Arc<Database<'_>> = database.clone();
tokio::task::spawn_blocking(move || {
let rw_transaction = db
.rw_transaction()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
f(&rw_transaction)?;
rw_transaction
.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(())
})
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
}
// For tables with a creation timestamp, place the creation time at the front of the primary key.
// This allows sorting by time, as the data is stored in dictionary order based on the primary key.
// If reverse sorting by time is needed, the iterator can be reversed.
+6 -2
View File
@@ -16,7 +16,6 @@
// 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 poem::http::StatusCode;
use poem_openapi::Enum;
@@ -34,12 +33,14 @@ pub enum ErrorCode {
// Authentication and authorization errors (2000020999)
PermissionDenied = 20000,
AccountDisabled = 20010,
Forbidden = 20020,
OAuth2ItemDisabled = 20050,
MissingRefreshToken = 20060,
// Resource errors (3000030999)
ResourceNotFound = 30000,
TooManyRequest = 30020,
AlreadyExists = 30030,
// Network connection errors (4000040999)
NetworkError = 40000,
@@ -64,11 +65,14 @@ impl ErrorCode {
| ErrorCode::MissingConfiguration
| ErrorCode::Incompatible => StatusCode::BAD_REQUEST,
ErrorCode::PermissionDenied => StatusCode::UNAUTHORIZED,
ErrorCode::AccountDisabled | ErrorCode::OAuth2ItemDisabled => StatusCode::FORBIDDEN,
ErrorCode::AccountDisabled | ErrorCode::OAuth2ItemDisabled | ErrorCode::Forbidden => {
StatusCode::FORBIDDEN
}
ErrorCode::ResourceNotFound => StatusCode::NOT_FOUND,
ErrorCode::RequestTimeout => StatusCode::REQUEST_TIMEOUT,
ErrorCode::PayloadTooLarge => StatusCode::PAYLOAD_TOO_LARGE,
ErrorCode::TooManyRequest => StatusCode::TOO_MANY_REQUESTS,
ErrorCode::AlreadyExists => StatusCode::CONFLICT,
ErrorCode::InternalError
| ErrorCode::AutoconfigFetchFailed
| ErrorCode::ImapCommandFailed
+12 -2
View File
@@ -78,7 +78,13 @@ impl ImapConnectionManager {
})?;
let password = decrypt!(&password)?;
client.login(&username, &password).await
client.login(&username, &password).await.map_err(|e| {
error!(
"IMAP password auth failed for username '{}': {}",
username, e
);
e
})
}
AuthType::OAuth2 => {
let record = OAuth2AccessToken::get(self.account_id).await?;
@@ -90,8 +96,12 @@ impl ImapConnectionManager {
)
})?;
client
.authenticate(OAuth2::new(username, access_token))
.authenticate(OAuth2::new(username.clone(), access_token))
.await
.map_err(|e| {
error!("IMAP OAuth2 auth failed for username '{}': {}", username, e);
e
})
}
}
}
-1
View File
@@ -16,7 +16,6 @@
// 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::error::code::ErrorCode;
use crate::modules::error::{BichonError, BichonResult};
use crate::modules::imap::{manager::ImapConnectionManager, session::SessionStream};
+119 -13
View File
@@ -16,7 +16,6 @@
// 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 std::{
collections::{HashMap, HashSet},
ops::Bound,
@@ -58,7 +57,7 @@ use tantivy::{
AggregationCollector, Key,
},
collector::{Count, FacetCollector, TopDocs},
query::{AllQuery, BooleanQuery, Occur, Query, QueryParser, RangeQuery, TermQuery},
query::{AllQuery, BooleanQuery, EmptyQuery, Occur, Query, QueryParser, RangeQuery, TermQuery},
schema::{Facet, IndexRecordOption, Value},
store::{Compressor, ZstdCompressor},
DocAddress, Index, IndexBuilder, IndexReader, IndexSettings, IndexWriter, Order,
@@ -194,9 +193,29 @@ impl EnvelopeIndexManager {
}
}
pub fn total_emails(&self) -> BichonResult<u64> {
pub fn total_emails(&self, accounts: &Option<HashSet<u64>>) -> BichonResult<u64> {
let searcher = self.create_searcher()?;
Ok(searcher.num_docs())
match accounts {
Some(ref ids) if !ids.is_empty() => {
let mut subqueries = Vec::new();
for &id in ids {
let term =
Term::from_field_u64(SchemaTools::envelope_fields().f_account_id, id);
subqueries.push((
Occur::Should,
Box::new(TermQuery::new(term, IndexRecordOption::Basic)) as Box<dyn Query>,
));
}
let query = Box::new(BooleanQuery::new(subqueries)) as Box<dyn Query>;
let count = searcher
.search(&query, &Count)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(count as u64)
}
Some(_) => Ok(0),
None => Ok(searcher.num_docs()),
}
}
fn account_query(&self, account_id: u64) -> Box<TermQuery> {
@@ -223,12 +242,36 @@ impl EnvelopeIndexManager {
fn filter_query(
&self,
accounts: Option<HashSet<u64>>,
filter: SearchFilter,
parser: QueryParser,
) -> BichonResult<Box<dyn Query>> {
let f = SchemaTools::envelope_fields();
let mut subqueries: Vec<(Occur, Box<dyn Query>)> = Vec::new();
if let Some(authorized_ids) = accounts {
if authorized_ids.is_empty() {
let term = Term::from_field_u64(f.f_account_id, u64::MAX);
subqueries.push((
Occur::Must,
Box::new(TermQuery::new(term, IndexRecordOption::Basic)),
));
} else {
let mut account_must_queries = Vec::new();
for id in authorized_ids {
let term = Term::from_field_u64(f.f_account_id, id);
account_must_queries.push((
Occur::Should,
Box::new(TermQuery::new(term, IndexRecordOption::Basic)) as Box<dyn Query>,
));
}
subqueries.push((
Occur::Must,
Box::new(BooleanQuery::new(account_must_queries)),
));
}
}
if let Some(ref text) = filter.text {
let query = parser
.parse_query(text)
@@ -426,14 +469,16 @@ impl EnvelopeIndexManager {
}
fn collect_facets_recursive(
query: &dyn Query,
searcher: &Searcher,
parent_facet: &str,
all_facets: &mut Vec<TagCount>,
) -> BichonResult<()> {
let mut facet_collector = FacetCollector::for_field(F_TAGS);
facet_collector.add_facet(parent_facet);
let facet_counts = searcher
.search(&AllQuery, &facet_collector)
.search(query, &facet_collector)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
for (facet, count) in facet_counts.get(parent_facet) {
@@ -441,16 +486,37 @@ impl EnvelopeIndexManager {
tag: facet.to_string(),
count,
});
Self::collect_facets_recursive(searcher, &facet.to_string(), all_facets)?;
Self::collect_facets_recursive(query, searcher, &facet.to_string(), all_facets)?;
}
Ok(())
}
pub async fn get_all_tags(&self) -> BichonResult<Vec<TagCount>> {
pub async fn get_all_tags(
&self,
accounts: Option<HashSet<u64>>,
) -> BichonResult<Vec<TagCount>> {
let searcher = self.reader.searcher();
let query: Box<dyn Query> = match accounts {
Some(ref ids) if !ids.is_empty() => {
let mut subqueries = Vec::new();
for &id in ids {
let term =
Term::from_field_u64(SchemaTools::envelope_fields().f_account_id, id);
subqueries.push((
Occur::Should,
Box::new(TermQuery::new(term, IndexRecordOption::Basic)) as Box<dyn Query>,
));
}
Box::new(BooleanQuery::new(subqueries))
}
Some(_) => Box::new(EmptyQuery),
None => Box::new(AllQuery),
};
let mut all_facets = Vec::new();
Self::collect_facets_recursive(&searcher, "/", &mut all_facets)?;
Self::collect_facets_recursive(&query, &searcher, "/", &mut all_facets)?;
Ok(all_facets)
}
@@ -550,6 +616,7 @@ impl EnvelopeIndexManager {
pub async fn search(
&self,
accounts: Option<HashSet<u64>>,
filter: SearchFilter,
page: u64,
page_size: u64,
@@ -557,7 +624,7 @@ impl EnvelopeIndexManager {
) -> BichonResult<DataPage<Envelope>> {
assert!(page > 0, "Page number must be greater than 0");
assert!(page_size > 0, "Page size must be greater than 0");
let query = self.filter_query(filter, self.query_parser.clone())?;
let query = self.filter_query(accounts, filter, self.query_parser.clone())?;
let searcher = self.create_searcher()?;
let total = searcher
.search(&query, &Count)
@@ -741,15 +808,35 @@ impl EnvelopeIndexManager {
})
}
pub async fn top_10_largest_emails(&self) -> BichonResult<Vec<LargestEmail>> {
pub async fn top_10_largest_emails(
&self,
accounts: &Option<HashSet<u64>>,
) -> BichonResult<Vec<LargestEmail>> {
self.reader
.reload()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let searcher = self.reader.searcher();
let query: Box<dyn Query> = match accounts {
Some(ref ids) if !ids.is_empty() => {
let mut subqueries = Vec::new();
for &id in ids {
let term =
Term::from_field_u64(SchemaTools::envelope_fields().f_account_id, id);
subqueries.push((
Occur::Should,
Box::new(TermQuery::new(term, IndexRecordOption::Basic)) as Box<dyn Query>,
));
}
Box::new(BooleanQuery::new(subqueries))
}
Some(_) => Box::new(EmptyQuery),
None => Box::new(AllQuery),
};
let mailbox_docs: Vec<(u64, DocAddress)> = searcher
.search(
&AllQuery,
&query,
&TopDocs::with_limit(10).order_by_fast_field(F_SIZE, Order::Desc),
)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
@@ -905,7 +992,10 @@ impl EnvelopeIndexManager {
Ok(self.reader.searcher())
}
pub async fn get_dashboard_stats(&self) -> BichonResult<DashboardStats> {
pub async fn get_dashboard_stats(
&self,
accounts: &Option<HashSet<u64>>,
) -> BichonResult<DashboardStats> {
let searcher = self.create_searcher()?;
let now_ms = utc_now!();
let week_ago_ms = (Utc::now() - Duration::from_secs(60 * 60 * 24 * 30)).timestamp_millis();
@@ -944,7 +1034,23 @@ impl EnvelopeIndexManager {
}))
.unwrap();
let query = AllQuery;
let query: Box<dyn Query> = match accounts {
Some(ref ids) if !ids.is_empty() => {
let mut subqueries = Vec::new();
for &id in ids {
let term =
Term::from_field_u64(SchemaTools::envelope_fields().f_account_id, id);
subqueries.push((
Occur::Should,
Box::new(TermQuery::new(term, IndexRecordOption::Basic)) as Box<dyn Query>,
));
}
Box::new(BooleanQuery::new(subqueries))
}
Some(_) => Box::new(EmptyQuery),
None => Box::new(AllQuery),
};
let agg_collector = AggregationCollector::from_aggs(aggregations, Default::default());
let agg_results = searcher
.search(&query, &agg_collector)
+12 -2
View File
@@ -16,6 +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 std::collections::HashSet;
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
@@ -72,9 +73,18 @@ impl SearchRequest {
}
}
pub async fn search_messages_impl(request: SearchRequest) -> BichonResult<DataPage<Envelope>> {
pub async fn search_messages_impl(
accounts: Option<HashSet<u64>>,
request: SearchRequest,
) -> BichonResult<DataPage<Envelope>> {
request.validate()?;
ENVELOPE_INDEX_MANAGER
.search(request.filter, request.page, request.page_size, true)
.search(
accounts,
request.filter,
request.page,
request.page_size,
true,
)
.await
}
+1
View File
@@ -36,5 +36,6 @@ pub mod rest;
pub mod settings;
pub mod tasks;
pub mod token;
pub mod users;
pub mod utils;
pub mod version;
+21 -1
View File
@@ -16,7 +16,6 @@
// 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::{
encrypt, id,
modules::{
@@ -97,6 +96,27 @@ impl OAuth2 {
})
}
pub fn scrub_sensitive_fields(&mut self) {
let mask = "********";
let notice =
" [REDACTED: You do not have permission to view sensitive configuration details]";
let original_desc = self
.description
.clone()
.unwrap_or_else(|| "OAuth2 Config".to_string());
self.description = Some(format!("{}{}", original_desc, notice));
self.client_id = mask.to_string();
self.client_secret = mask.to_string();
self.auth_url = mask.to_string();
self.token_url = mask.to_string();
self.redirect_uri = mask.to_string();
self.scopes = None;
self.extra_params = None;
}
pub async fn save(&self) -> BichonResult<()> {
insert_impl(DB_MANAGER.meta_db(), self.to_owned()).await?;
Ok(())
+28 -90
View File
@@ -16,16 +16,12 @@
// 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::common::auth::ClientContext;
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::ApiResult;
use crate::modules::token::payload::AccessTokenUpdateRequest;
use crate::modules::token::root::set_root_password;
use crate::modules::{
token::payload::AccessTokenCreateRequest,
token::{root::reset_root_token, AccessToken},
};
use crate::modules::token::view::AccessTokenResp;
use crate::modules::users::permissions::Permission;
use crate::modules::{token::payload::AccessTokenCreateRequest, token::AccessTokenModel};
use poem_openapi::payload::PlainText;
use poem_openapi::{param::Path, payload::Json, OpenApi};
@@ -33,9 +29,6 @@ pub struct AccessTokenApi;
#[OpenApi(prefix_path = "/api/v1", tag = "ApiTags::AccessToken")]
impl AccessTokenApi {
/// Lists all access tokens in the system.
///
/// Requires root privileges.
#[oai(
path = "/access-token-list",
method = "get",
@@ -44,31 +37,15 @@ impl AccessTokenApi {
async fn list_access_tokens(
&self,
context: ClientContext,
) -> ApiResult<Json<Vec<AccessToken>>> {
context.require_root()?;
Ok(Json(AccessToken::list_all().await?))
) -> ApiResult<Json<Vec<AccessTokenResp>>> {
context
.require_permission(None, Permission::TOKEN_MANAGE)
.await?;
Ok(Json(AccessTokenModel::list_all_api_tokens().await?))
}
/// Lists access tokens for a specific account.
///
/// Requires root privileges.
#[oai(
path = "/access-token-list/:account_id",
method = "get",
operation_id = "list_account_access_tokens"
)]
async fn list_account_access_tokens(
&self,
/// The ID of the account whose tokens are to be retrieved.
account_id: Path<u64>,
context: ClientContext,
) -> ApiResult<Json<Vec<AccessToken>>> {
context.require_root()?;
Ok(Json(AccessToken::list_account_tokens(account_id.0).await?))
}
/// Deletes a specific access token.
///
/// Requires root privileges.
#[oai(
path = "/access-token/:token",
method = "delete",
@@ -80,13 +57,18 @@ impl AccessTokenApi {
token: Path<String>,
context: ClientContext,
) -> ApiResult<()> {
context.require_root()?;
Ok(AccessToken::delete(token.0.trim()).await?)
let token = token.0.trim();
let token = AccessTokenModel::get_token(token).await?;
if context.user.id != token.user_id {
context
.require_permission(None, Permission::TOKEN_MANAGE)
.await?;
}
Ok(AccessTokenModel::delete(&token.token).await?)
}
/// Creates a new access token.
///
/// Requires root privileges.
/// Creates a new api token.
#[oai(
path = "/access-token",
method = "post",
@@ -98,59 +80,15 @@ impl AccessTokenApi {
/// The request payload
payload: Json<AccessTokenCreateRequest>,
) -> ApiResult<PlainText<String>> {
context.require_root()?;
Ok(PlainText(AccessToken::create(payload.0).await?))
}
let current_user_id = context.user.id;
let target_user_id = payload.0.user_id.unwrap_or(current_user_id);
if target_user_id != current_user_id {
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
}
/// Updates an existing access token.
///
/// Requires root privileges.
#[oai(
path = "/access-token/:token",
method = "post",
operation_id = "update_access_token"
)]
async fn update_access_token(
&self,
context: ClientContext,
/// The access token to be updated.
token: Path<String>,
/// The request payload.
payload: Json<AccessTokenUpdateRequest>,
) -> ApiResult<()> {
context.require_root()?;
Ok(AccessToken::update(token.0.trim(), payload.0).await?)
}
/// Regenerates the root access token.
///
/// Requires root privileges.
#[oai(
path = "/reset-root-token",
method = "post",
operation_id = "regenerate_root_token"
)]
async fn regenerate_root_token(&self, context: ClientContext) -> ApiResult<PlainText<String>> {
context.require_root()?;
Ok(PlainText(reset_root_token().await?))
}
/// Reset the Root user's password.
///
/// Only callable by an already authenticated Root user.
/// This endpoint updates the Root password to `password_str`
/// and regenerates the `root_token`, invalidating any previous token.
#[oai(
path = "/reset-root-password",
method = "post",
operation_id = "reset_root_password"
)]
async fn reset_root_password(
&self,
password_str: PlainText<String>,
context: ClientContext,
) -> ApiResult<()> {
context.require_root()?;
Ok(set_root_password(password_str.0.trim()).await?)
let token_string = AccessTokenModel::create_api_token(target_user_id, payload.0).await?;
Ok(PlainText(token_string))
}
}
+90 -45
View File
@@ -16,20 +16,23 @@
// 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 std::collections::BTreeSet;
use std::collections::{HashMap, HashSet};
use crate::modules::account::grant::BatchAccountRoleRequest;
use crate::modules::account::migration::AccountModel;
use crate::modules::account::payload::{
filter_accessible_accounts, AccountCreateRequest, AccountUpdateRequest, MinimalAccount,
};
use crate::modules::account::state::AccountRunningState;
use crate::modules::account::view::AccountResp;
use crate::modules::common::auth::ClientContext;
use crate::modules::common::paginated::paginate_vec;
use crate::modules::error::code::ErrorCode;
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::response::DataPage;
use crate::modules::rest::ApiResult;
use crate::modules::token::{AccessToken, AccountInfo};
use crate::modules::users::permissions::Permission;
use crate::modules::users::BichonUser;
use crate::raise_error;
use poem_openapi::param::{Path, Query};
use poem_openapi::payload::Json;
@@ -52,7 +55,9 @@ impl AccountApi {
context: ClientContext,
) -> ApiResult<Json<AccountModel>> {
let account_id = account_id.0;
context.require_account_access(account_id)?;
context
.require_permission(Some(account_id), Permission::ACCOUNT_READ_DETAILS)
.await?;
Ok(Json(AccountModel::get(account_id).await?))
}
@@ -69,7 +74,9 @@ impl AccountApi {
context: ClientContext,
) -> ApiResult<()> {
let account_id = account_id.0;
context.require_account_access(account_id)?;
context
.require_permission(Some(account_id), Permission::ACCOUNT_MANAGE)
.await?;
Ok(AccountModel::delete(account_id).await?)
}
@@ -81,14 +88,10 @@ impl AccountApi {
payload: Json<AccountCreateRequest>,
context: ClientContext,
) -> ApiResult<Json<AccountModel>> {
let account = AccountModel::create_account(payload.0).await?;
if let Some(access_token) = &context.access_token {
let account_info = AccountInfo {
id: account.id,
email: account.email.clone(),
};
AccessToken::grant_account_access(&access_token.token, account_info).await?;
}
context
.require_permission(None, Permission::ACCOUNT_CREATE)
.await?;
let account = AccountModel::create_account(context.user.id, payload.0).await?;
Ok(Json(account))
}
@@ -107,7 +110,9 @@ impl AccountApi {
context: ClientContext,
) -> ApiResult<()> {
let account_id = account_id.0;
context.require_account_access(account_id)?;
context
.require_permission(Some(account_id), Permission::ACCOUNT_MANAGE)
.await?;
Ok(AccountModel::update(account_id, payload.0, true).await?)
}
@@ -122,35 +127,61 @@ impl AccountApi {
/// Optional. Whether to sort the list in descending order.
desc: Query<Option<bool>>,
context: ClientContext,
) -> ApiResult<Json<DataPage<AccountModel>>> {
let accessible_accounts = context.accessible_accounts()?;
) -> ApiResult<Json<DataPage<AccountResp>>> {
let is_admin = context.user.is_admin().await;
let sort_desc = desc.0.unwrap_or(true);
if accessible_accounts.is_none() {
return Ok(Json(
AccountModel::paginate_list(page.0, page_size.0, desc.0).await?,
));
}
let all_accounts = AccountModel::list_all().await?;
let allowed_ids: BTreeSet<u64> =
accessible_accounts.unwrap().iter().map(|a| a.id).collect();
let mut filtered_accounts: Vec<AccountModel> = all_accounts
let user_map: HashMap<u64, BichonUser> = BichonUser::list_all()
.await?
.into_iter()
.filter(|acct| allowed_ids.contains(&acct.id))
.map(|u| (u.id, u))
.collect();
let page_data: DataPage<AccountModel> = if is_admin {
AccountModel::paginate_list(page.0, page_size.0, desc.0).await?
} else {
let authorized_ids: HashSet<u64> =
context.user.account_access_map.keys().cloned().collect();
if authorized_ids.is_empty() {
return Ok(Json(DataPage {
current_page: page.0,
page_size: page_size.0,
total_items: 0,
items: vec![],
total_pages: Some(0),
}));
}
let mut accounts: Vec<AccountModel> = AccountModel::list_all()
.await?
.into_iter()
.filter(|acct| authorized_ids.contains(&acct.id))
.collect();
accounts.sort_by(|a, b| {
if sort_desc {
b.created_at.cmp(&a.created_at)
} else {
a.created_at.cmp(&b.created_at)
}
});
paginate_vec(&accounts, page.0, page_size.0).map(DataPage::from)?
};
let items = page_data
.items
.into_iter()
.map(|account| AccountResp::from_model(account, &user_map))
.collect();
let sort_desc = desc.0.unwrap_or(true);
filtered_accounts.sort_by(|a, b| {
if sort_desc {
b.created_at.cmp(&a.created_at)
} else {
a.created_at.cmp(&b.created_at)
}
});
let page_data =
paginate_vec(&filtered_accounts, page.0, page_size.0).map(DataPage::from)?;
Ok(Json(page_data))
Ok(Json(DataPage {
current_page: page_data.current_page,
page_size: page_data.page_size,
total_items: page_data.total_items,
total_pages: page_data.total_pages,
items,
}))
}
/// Get the running state of an account
@@ -167,7 +198,9 @@ impl AccountApi {
) -> ApiResult<Json<AccountRunningState>> {
let account_id = account_id.0;
AccountModel::check_account_exists(account_id).await?;
context.require_account_access(account_id)?;
context
.require_permission(Some(account_id), Permission::ACCOUNT_READ_DETAILS)
.await?;
let state = AccountRunningState::get(account_id).await?.ok_or_else(|| {
raise_error!(
"account running state is not found".into(),
@@ -190,13 +223,25 @@ impl AccountApi {
&self,
context: ClientContext,
) -> ApiResult<Json<Vec<MinimalAccount>>> {
let accessible_accounts = context.accessible_accounts()?;
let is_admin = context.user.is_admin().await;
let minimal_list = AccountModel::minimal_list().await?;
let result = match accessible_accounts {
Some(set) => filter_accessible_accounts(&minimal_list, set),
None => minimal_list,
};
if is_admin {
return Ok(Json(minimal_list));
}
let authorized_ids: Vec<u64> = context.user.account_access_map.keys().cloned().collect();
let result = filter_accessible_accounts(&minimal_list, &authorized_ids);
Ok(Json(result))
}
#[oai(path = "/accounts/access/assignments", method = "post")]
async fn batch_assign_account_role(
&self,
req: Json<BatchAccountRoleRequest>,
context: ClientContext,
) -> ApiResult<()> {
req.validate_existence().await?;
req.0.do_assign(&context).await?;
Ok(())
}
}
+8 -2
View File
@@ -16,12 +16,13 @@
// 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::autoconfig::entity::MailServerConfig;
use crate::modules::autoconfig::load::resolve_autoconfig;
use crate::modules::common::auth::ClientContext;
use crate::modules::error::code::ErrorCode;
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::ApiResult;
use crate::modules::users::permissions::Permission;
use crate::raise_error;
use poem_openapi::param::Path;
use poem_openapi::payload::Json;
@@ -40,8 +41,13 @@ impl AutoConfigApi {
async fn autoconfig(
&self,
/// The email address to lookup configuration for
email_address: Path<String>
email_address: Path<String>,
context: ClientContext,
) -> ApiResult<Json<MailServerConfig>> {
context
.require_permission(None, Permission::ACCOUNT_CREATE)
.await?;
let result = resolve_autoconfig(email_address.0.trim())
.await?
.ok_or_else(|| {
+4 -1
View File
@@ -21,6 +21,7 @@ use crate::modules::import::BatchEmlResult;
use crate::modules::import::{BatchEmlRequest, ImportEmls};
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::ApiResult;
use crate::modules::users::permissions::Permission;
use poem_openapi::payload::Json;
use poem_openapi::OpenApi;
@@ -43,7 +44,9 @@ impl ImportApi {
payload: Json<BatchEmlRequest>,
context: ClientContext,
) -> ApiResult<Json<BatchEmlResult>> {
context.require_root()?;
context
.require_permission(Some(payload.0.account_id), Permission::DATA_IMPORT_BATCH)
.await?;
Ok(Json(ImportEmls::do_import(payload.0).await?))
}
}
+3 -2
View File
@@ -16,7 +16,6 @@
// 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::cache::imap::mailbox::MailBox;
use crate::modules::common::auth::ClientContext;
use crate::modules::mailbox::list::get_account_mailboxes;
@@ -52,7 +51,9 @@ impl MailBoxApi {
context: ClientContext,
) -> ApiResult<Json<Vec<MailBox>>> {
let account_id = account_id.0;
context.require_account_access(account_id)?;
context
.require_permission(Some(account_id), Permission::ACCOUNT_READ_DETAILS)
.await?;
let remote = remote.0.unwrap_or(false);
Ok(Json(get_account_mailboxes(account_id, remote).await?))
}
+53 -11
View File
@@ -31,12 +31,14 @@ use crate::modules::rest::api::ApiTags;
use crate::modules::rest::response::DataPage;
use crate::modules::rest::ApiResult;
use crate::modules::rest::ErrorCode;
use crate::modules::users::permissions::Permission;
use crate::raise_error;
use poem::Body;
use poem_openapi::param::{Path, Query};
use poem_openapi::payload::{Attachment, AttachmentType, Json};
use poem_openapi::OpenApi;
use std::collections::HashMap;
use std::collections::HashSet;
use tantivy::schema::Facet;
pub struct MessageApi;
@@ -57,7 +59,9 @@ impl MessageApi {
) -> ApiResult<()> {
let request = payload.0;
for account_id in request.keys() {
context.require_account_access(*account_id)?;
context
.require_permission(Some(*account_id), Permission::DATA_DELETE)
.await?;
}
Ok(delete_messages_impl(request).await?)
}
@@ -78,7 +82,9 @@ impl MessageApi {
) -> ApiResult<Json<DataPage<Envelope>>> {
let account_id = account_id.0;
let mailbox_id = mailbox_id.0;
context.require_account_access(account_id)?;
context
.require_permission(Some(account_id), Permission::DATA_READ)
.await?;
Ok(Json(
list_messages_impl(account_id, mailbox_id, page.0, page_size.0).await?,
))
@@ -95,8 +101,15 @@ impl MessageApi {
payload: Json<SearchRequest>,
context: ClientContext,
) -> ApiResult<Json<DataPage<Envelope>>> {
context.require_root()?;
Ok(Json(search_messages_impl(payload.0).await?))
let authorized_ids: Option<HashSet<u64>> = if context
.has_permission(None, Permission::DATA_READ_ALL)
.await
{
None
} else {
Some(context.user.account_access_map.keys().cloned().collect())
};
Ok(Json(search_messages_impl(authorized_ids, payload.0).await?))
}
/// Get thread's envelopes in a specified mailbox for the given account.
@@ -119,7 +132,9 @@ impl MessageApi {
) -> ApiResult<Json<DataPage<Envelope>>> {
let account_id = account_id.0;
let thread_id = thread_id.0;
context.require_account_access(account_id)?;
context
.require_permission(Some(account_id), Permission::DATA_READ)
.await?;
Ok(Json(
get_thread_messages(account_id, thread_id, page.0, page_size.0).await?,
))
@@ -140,7 +155,9 @@ impl MessageApi {
context: ClientContext,
) -> ApiResult<Json<FullMessageContent>> {
let account_id = account_id.0;
context.require_account_access(account_id)?;
context
.require_permission(Some(account_id), Permission::DATA_READ)
.await?;
Ok(Json(retrieve_email_content(account_id, message_id.0).await?))
}
@@ -160,7 +177,9 @@ impl MessageApi {
) -> ApiResult<Attachment<Body>> {
let account_id = account_id.0;
AccountModel::check_account_exists(account_id).await?;
context.require_account_access(account_id)?;
context
.require_permission(Some(account_id), Permission::DATA_RAW_DOWNLOAD)
.await?;
let message_id = message_id.0;
let reader = EML_INDEX_MANAGER.get_reader(account_id, message_id).await?;
let body = Body::from_async_read(reader);
@@ -188,7 +207,9 @@ impl MessageApi {
) -> ApiResult<Attachment<Body>> {
let account_id = account_id.0;
AccountModel::check_account_exists(account_id).await?;
context.require_account_access(account_id)?;
context
.require_permission(Some(account_id), Permission::DATA_READ)
.await?;
let message_id = message_id.0;
let name = name.0.trim();
let reader = EML_INDEX_MANAGER
@@ -202,8 +223,18 @@ impl MessageApi {
}
/// Returns all facets in the index along with their document counts.
#[oai(path = "/all-tags", method = "get", operation_id = "get_all_tags")]
async fn get_all_tags(&self) -> ApiResult<Json<Vec<TagCount>>> {
Ok(Json(ENVELOPE_INDEX_MANAGER.get_all_tags().await?))
async fn get_all_tags(&self, context: ClientContext) -> ApiResult<Json<Vec<TagCount>>> {
let authorized_ids: Option<HashSet<u64>> = if context
.has_permission(None, Permission::DATA_READ_ALL)
.await
{
None
} else {
Some(context.user.account_access_map.keys().cloned().collect())
};
Ok(Json(
ENVELOPE_INDEX_MANAGER.get_all_tags(authorized_ids).await?,
))
}
/// Adds or removes facet tags for multiple emails across accounts.
@@ -212,12 +243,23 @@ impl MessageApi {
method = "post",
operation_id = "update_envelope_tags"
)]
async fn update_envelope_tags(&self, req: Json<UpdateTagsRequest>) -> ApiResult<()> {
async fn update_envelope_tags(
&self,
req: Json<UpdateTagsRequest>,
context: ClientContext,
) -> ApiResult<()> {
let req = req.0;
for tag in &req.tags {
Facet::from_text(tag)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InvalidParameter))?;
}
for account_id in req.updates.keys() {
context
.require_permission(Some(*account_id), Permission::DATA_MANAGE)
.await?;
}
ENVELOPE_INDEX_MANAGER
.update_envelope_tags(req.updates, req.tags)
.await?;
+8 -1
View File
@@ -25,7 +25,10 @@ use oauth2::OAuth2Api;
use poem_openapi::{OpenApiService, Tags};
use system::SystemApi;
use crate::{bichon_version, modules::rest::api::import::ImportApi};
use crate::{
bichon_version,
modules::rest::api::{import::ImportApi, users::UsersApi},
};
pub mod access_token;
pub mod account;
@@ -35,6 +38,7 @@ pub mod mailbox;
pub mod message;
pub mod oauth2;
pub mod system;
pub mod users;
#[derive(Tags)]
pub enum ApiTags {
@@ -46,6 +50,7 @@ pub enum ApiTags {
Message,
System,
Import,
Users,
}
type RustMailOpenApi = (
@@ -57,6 +62,7 @@ type RustMailOpenApi = (
OAuth2Api,
MessageApi,
ImportApi,
UsersApi,
);
pub fn create_openapi_service() -> OpenApiService<RustMailOpenApi, ()> {
@@ -70,6 +76,7 @@ pub fn create_openapi_service() -> OpenApiService<RustMailOpenApi, ()> {
OAuth2Api,
MessageApi,
ImportApi,
UsersApi,
),
"BichonApi",
bichon_version!(),
+59 -16
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::account::migration::AccountModel;
use crate::modules::common::auth::ClientContext;
use crate::modules::error::code::ErrorCode;
use crate::modules::oauth2::entity::{OAuth2, OAuth2CreateRequest, OAuth2UpdateRequest};
@@ -25,6 +25,7 @@ use crate::modules::oauth2::token::{ExternalOAuth2Request, OAuth2AccessToken};
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::raise_error;
use poem_openapi::param::{Path, Query};
use poem_openapi::payload::{Json, PlainText};
@@ -49,14 +50,26 @@ impl OAuth2Api {
id: Path<u64>,
context: ClientContext,
) -> ApiResult<Json<OAuth2>> {
context.require_root()?;
let id = id.0;
Ok(Json(OAuth2::get(id).await?.ok_or_else(|| {
let mut oauth2 = OAuth2::get(id).await?.ok_or_else(|| {
raise_error!(
format!("OAuth2 configuration id='{id}' not found"),
ErrorCode::ResourceNotFound
)
})?))
})?;
if context
.has_permission(None, Permission::ROOT)
.await
{
return Ok(Json(oauth2));
}
context
.require_permission(None, Permission::ACCOUNT_CREATE)
.await?;
oauth2.scrub_sensitive_fields();
Ok(Json(oauth2))
}
/// Deletes an OAuth2 configuration by name.
@@ -74,7 +87,9 @@ impl OAuth2Api {
id: Path<u64>,
context: ClientContext,
) -> ApiResult<()> {
context.require_root()?;
context
.require_permission(None, Permission::ROOT)
.await?;
Ok(OAuth2::delete(id.0).await?)
}
@@ -93,7 +108,9 @@ impl OAuth2Api {
request: Json<OAuth2CreateRequest>,
context: ClientContext,
) -> ApiResult<()> {
context.require_root()?;
context
.require_permission(None, Permission::ROOT)
.await?;
let entity = OAuth2::new(request.0)?;
Ok(entity.save().await?)
}
@@ -115,7 +132,9 @@ impl OAuth2Api {
payload: Json<OAuth2UpdateRequest>,
context: ClientContext,
) -> ApiResult<()> {
context.require_root()?;
context
.require_permission(None, Permission::ROOT)
.await?;
Ok(OAuth2::update(id.0, payload.0).await?)
}
@@ -138,10 +157,23 @@ impl OAuth2Api {
desc: Query<Option<bool>>,
context: ClientContext,
) -> ApiResult<Json<DataPage<OAuth2>>> {
context.require_root()?;
Ok(Json(
OAuth2::paginate_list(page.0, page_size.0, desc.0).await?,
))
let mut list = OAuth2::paginate_list(page.0, page_size.0, desc.0).await?;
if context
.has_permission(None, Permission::ROOT)
.await
{
return Ok(Json(list));
}
context
.require_permission(None, Permission::ACCOUNT_CREATE)
.await?;
for item in &mut list.items {
item.scrub_sensitive_fields();
}
Ok(Json(list))
}
/// Generates an OAuth2 authorization URL for a specific account.
@@ -159,8 +191,14 @@ impl OAuth2Api {
request: Json<AuthorizeUrlRequest>,
context: ClientContext,
) -> ApiResult<PlainText<String>> {
context.require_root()?;
let request = request.0;
context
.require_any_permission(vec![
(None, Permission::ACCOUNT_CREATE),
(Some(request.account_id), Permission::ACCOUNT_MANAGE),
])
.await?;
let flow = OAuth2Flow::new(request.oauth2_id);
Ok(PlainText(flow.authorize_url(request.account_id).await?))
}
@@ -180,7 +218,9 @@ impl OAuth2Api {
context: ClientContext,
) -> ApiResult<Json<OAuth2AccessToken>> {
let account = account_id.0;
context.require_account_access(account)?;
context
.require_permission(Some(account), Permission::ACCOUNT_MANAGE)
.await?;
Ok(Json(OAuth2AccessToken::get(account).await?.ok_or_else(
|| {
raise_error!(
@@ -218,10 +258,13 @@ impl OAuth2Api {
request: Json<ExternalOAuth2Request>,
context: ClientContext,
) -> ApiResult<()> {
let account = account_id.0;
let account_id = account_id.0;
AccountModel::check_account_exists(account_id).await?;
// Check account access permissions
context.require_account_access(account)?;
OAuth2AccessToken::upsert_external_oauth_token(account, request.0).await?;
context
.require_permission(Some(account_id), Permission::ACCOUNT_MANAGE)
.await?;
OAuth2AccessToken::upsert_external_oauth_token(account_id, request.0).await?;
Ok(())
}
}
+43 -8
View File
@@ -16,13 +16,15 @@
// 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::common::auth::ClientContext;
use crate::modules::dashboard::DashboardStats;
use crate::modules::error::code::ErrorCode;
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::ApiResult;
use crate::modules::settings::cli::SETTINGS;
use crate::modules::settings::proxy::Proxy;
use crate::modules::settings::SystemConfigurations;
use crate::modules::users::permissions::Permission;
use crate::modules::version::{fetch_notifications, Notifications};
use crate::raise_error;
use poem_openapi::param::Path;
@@ -60,14 +62,20 @@ impl SystemApi {
path = "/dashboard-stats",
operation_id = "get_dashboard_stats"
)]
async fn get_dashboard_stats(&self) -> ApiResult<Json<DashboardStats>> {
let stats = DashboardStats::get().await?;
async fn get_dashboard_stats(&self, context: ClientContext) -> ApiResult<Json<DashboardStats>> {
let stats = DashboardStats::get(context).await?;
Ok(Json(stats))
}
/// Get the full list of SOCKS5 proxy configurations.
#[oai(method = "get", path = "/list-proxy", operation_id = "list_proxy")]
async fn list_proxy(&self) -> ApiResult<Json<Vec<Proxy>>> {
async fn list_proxy(&self, context: ClientContext) -> ApiResult<Json<Vec<Proxy>>> {
context
.require_any_permission(vec![
(None, Permission::ACCOUNT_CREATE),
(None, Permission::ROOT),
])
.await?;
let proxies = Proxy::list_all()
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
@@ -82,7 +90,9 @@ impl SystemApi {
id: Path<u64>,
context: ClientContext,
) -> ApiResult<()> {
context.require_root()?;
context
.require_permission(None, Permission::ROOT)
.await?;
Ok(Proxy::delete(id.0).await?)
}
@@ -94,14 +104,18 @@ impl SystemApi {
id: Path<u64>,
context: ClientContext,
) -> ApiResult<Json<Proxy>> {
context.require_root()?;
context
.require_permission(None, Permission::ROOT)
.await?;
Ok(Json(Proxy::get(id.0).await?))
}
/// Create a new proxy configuration. Requires root permission.
#[oai(path = "/proxy", method = "post", operation_id = "create_proxy")]
async fn create_proxy(&self, url: PlainText<String>, context: ClientContext) -> ApiResult<()> {
context.require_root()?;
context
.require_permission(None, Permission::ROOT)
.await?;
let entity = Proxy::new(url.0);
Ok(entity.save().await?)
}
@@ -114,7 +128,28 @@ impl SystemApi {
url: PlainText<String>,
context: ClientContext,
) -> ApiResult<()> {
context.require_root()?;
context
.require_permission(None, Permission::ROOT)
.await?;
Ok(Proxy::update(id.0, url.0).await?)
}
/// Get system configurations.
///
/// Returns a read-only snapshot of the server configuration
/// resolved at startup. Sensitive values are not exposed.
#[oai(
method = "get",
path = "/system-configurations",
operation_id = "get_system_configurations"
)]
async fn get_system_configurations(
&self,
context: ClientContext,
) -> ApiResult<Json<SystemConfigurations>> {
context
.require_permission(None, Permission::ROOT)
.await?;
let config: SystemConfigurations = SystemConfigurations::from(&*SETTINGS);
Ok(Json(config))
}
}
+217
View File
@@ -0,0 +1,217 @@
//
// 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 <http://www.gnu.org/licenses/>.
use std::collections::BTreeMap;
use crate::modules::common::auth::ClientContext;
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::ApiResult;
use crate::modules::token::AccessTokenModel;
use crate::modules::users::minimal::MinimalUser;
use crate::modules::users::payload::{
RoleCreateRequest, RoleUpdateRequest, UserCreateRequest, UserUpdateRequest,
};
use crate::modules::users::permissions::Permission;
use crate::modules::users::role::UserRole;
use crate::modules::users::view::UserView;
use crate::modules::users::BichonUser;
use poem::web::Path;
use poem_openapi::payload::Json;
use poem_openapi::OpenApi;
pub struct UsersApi;
#[OpenApi(prefix_path = "/api/v1", tag = "ApiTags::Users")]
impl UsersApi {
#[oai(path = "/list-roles", method = "get", operation_id = "list_roles")]
async fn list_roles(&self, context: ClientContext) -> ApiResult<Json<Vec<UserRole>>> {
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
Ok(Json(UserRole::list_all().await?))
}
#[oai(path = "/roles/:id", method = "delete", operation_id = "remove_role")]
async fn remove_role(
&self,
/// The Role ID to delete
id: Path<u64>,
context: ClientContext,
) -> ApiResult<()> {
let id = id.0;
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
Ok(UserRole::delete(id).await?)
}
/// Create a new account
#[oai(path = "/roles", method = "post", operation_id = "create_role")]
async fn create_role(
&self,
/// Role creation request payload
payload: Json<RoleCreateRequest>,
context: ClientContext,
) -> ApiResult<Json<UserRole>> {
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
let role = UserRole::create(payload.0).await?;
Ok(Json(role))
}
/// Update an existing account
#[oai(path = "/roles/:id", method = "post", operation_id = "update_role")]
async fn update_role(
&self,
/// The Role ID to update
id: Path<u64>,
/// Role update request payload
payload: Json<RoleUpdateRequest>,
context: ClientContext,
) -> ApiResult<()> {
let id = id.0;
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
Ok(UserRole::update(id, payload.0).await?)
}
#[oai(path = "/list-users", method = "get", operation_id = "list_users")]
async fn list_users(&self, context: ClientContext) -> ApiResult<Json<Vec<UserView>>> {
context
.require_permission(None, Permission::USER_MANAGE)
.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 = users
.into_iter()
.map(|u| u.to_current_user(&role_lookup))
.collect();
Ok(Json(users))
}
#[oai(
path = "/user-tokens/:id",
method = "get",
operation_id = "get_user_tokens"
)]
async fn get_user_tokens(
&self,
id: Path<u64>,
context: ClientContext,
) -> ApiResult<Json<Vec<AccessTokenModel>>> {
let target_user_id = id.0;
let tokens = AccessTokenModel::get_user_api_tokens(target_user_id).await?;
if context.user.id == target_user_id {
return Ok(Json(tokens));
}
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
Ok(Json(tokens))
}
#[oai(path = "/users/:id", method = "delete", operation_id = "remove_user")]
async fn remove_user(
&self,
/// The User ID to delete
id: Path<u64>,
context: ClientContext,
) -> ApiResult<()> {
let id = id.0;
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
Ok(BichonUser::remove(id).await?)
}
#[oai(path = "/users", method = "post", operation_id = "create_user")]
async fn create_user(
&self,
payload: Json<UserCreateRequest>,
context: ClientContext,
) -> ApiResult<Json<UserView>> {
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
let user = BichonUser::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)))
}
#[oai(path = "/users/:id", method = "post", operation_id = "update_user")]
async fn update_user(
&self,
id: Path<u64>,
payload: Json<UserUpdateRequest>,
context: ClientContext,
) -> ApiResult<()> {
let target_id = id.0;
let current_user_id = context.user.id;
if current_user_id != target_id {
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
}
let mut update_data = payload.0;
if current_user_id == target_id
&& !context.has_permission(None, Permission::USER_MANAGE).await
{
update_data.global_roles = None;
update_data.account_access_map = None;
update_data.acl = None;
}
Ok(BichonUser::update(target_id, update_data).await?)
}
#[oai(
path = "/current-user",
method = "get",
operation_id = "get_current_user"
)]
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)))
}
#[oai(
path = "/minimal-user-list",
method = "get",
operation_id = "get_minimal_user_list"
)]
async fn get_minimal_user_list(
&self,
context: ClientContext,
) -> ApiResult<Json<Vec<MinimalUser>>> {
let is_admin = context.user.is_admin().await;
let minimal_list = MinimalUser::list_all().await?;
if is_admin {
return Ok(Json(minimal_list));
}
context
.require_permission(None, Permission::USER_VIEW)
.await?;
Ok(Json(minimal_list))
}
}
+1 -2
View File
@@ -78,8 +78,7 @@ pub async fn start_http_server() -> BichonResult<()> {
.with(Timeout)
.with(Tracing);
let cors_origins: Option<HashSet<String>> =
SETTINGS.bichon_cors_origins.clone();
let cors_origins: Option<HashSet<String>> = SETTINGS.bichon_cors_origins.clone();
let cors_origins: Vec<String> = cors_origins.unwrap_or_default().into_iter().collect();
+27 -15
View File
@@ -16,29 +16,41 @@
// 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::token::root::check_root_password;
use poem::{handler, IntoResponse, Response};
use crate::modules::users::BichonUser;
use poem::{handler, web::Json, IntoResponse, Response};
use serde::Deserialize;
use tracing::error;
/// Login endpoint for Root user
#[derive(Deserialize)]
pub struct LoginPayload {
pub username: String,
pub password: String,
}
/// Login endpoint
///
/// Accepts a plain text password and returns the `root_token`
/// on successful authentication.
#[handler]
pub async fn login(password: String) -> Response {
match check_root_password(&password) {
Ok(root_token) => Response::builder()
.status(http::StatusCode::OK)
.content_type("text/plain")
.body(root_token)
.into_response(),
pub async fn login(payload: Json<LoginPayload>) -> Response {
let payload = payload.0;
match BichonUser::authenticate_user(payload.username, payload.password).await {
Ok(result) => match serde_json::to_string(&result) {
Ok(json_string) => Response::builder()
.status(http::StatusCode::OK)
.content_type("application/json")
.body(json_string)
.into_response(),
Err(_) => Response::builder()
.status(http::StatusCode::INTERNAL_SERVER_ERROR)
.body("Internal server error during response serialization.")
.into_response(),
},
Err(e) => {
error!("Root login failed: {:?}", e);
error!("Authentication failed with system error: {:?}", e);
Response::builder()
.status(http::StatusCode::UNAUTHORIZED)
.content_type("text/plain")
.body(e.to_string())
.status(http::StatusCode::INTERNAL_SERVER_ERROR)
.body("Authentication system failed.".to_string())
.into_response()
}
}
+32 -17
View File
@@ -19,7 +19,7 @@
use clap::{builder::ValueParser, Parser, ValueEnum};
use std::{collections::HashSet, env, fmt, path::PathBuf, sync::LazyLock};
pub static SETTINGS: LazyLock<Settings> = LazyLock::new(Settings::parse);
pub static SETTINGS: LazyLock<Settings> = LazyLock::new(Settings::init);
#[derive(Debug, Parser)]
#[clap(
@@ -132,11 +132,27 @@ pub struct Settings {
/// bichon encryption password
#[clap(
long,
default_value = "change-this-default-password-now",
env,
help = "Set the encryption password for bichon. ⚠️ Change this default in production!"
default_value = "change-this-default-password-now",
help = "Set the encryption password for bichon. Alternatively, you can use --bichon-encrypt-password-file. If both are set, this parameter takes precedence over the file."
)]
pub bichon_encrypt_password: String,
pub bichon_encrypt_password: Option<String>,
#[clap(
long,
env,
help = "The file containing the encryption password. An alternative to --bichon-encrypt-password."
)]
pub bichon_encrypt_password_file: Option<String>,
/// WebUI token expiration time in seconds (default: 7 days)
#[clap(
long,
default_value = "168",
env,
help = "Set the WebUI token expiration time in hours"
)]
pub bichon_webui_token_expiration_hours: u32,
#[clap(
long,
@@ -174,19 +190,6 @@ pub struct Settings {
)]
pub bichon_envelope_cache_size: Option<usize>,
/// Enables or disables the access token mechanism for HTTP endpoints.
///
/// When set to `true`, HTTP requests will be subject to access token validation.
/// If the `Authorization` header is missing or the token is invalid, the service will return a 401 Unauthorized response.
/// When set to `false`, access token validation will be skipped.
#[clap(
long,
default_value = "false",
env,
help = "Enables or disables the access token mechanism for HTTP endpoints."
)]
pub bichon_enable_access_token: bool,
/// Enables or disables HTTPS for REST API endpoints.
///
/// When set to `true`, the REST API will use HTTPS with a valid SSL/TLS certificate for secure communication.
@@ -217,6 +220,18 @@ pub struct Settings {
pub bichon_sync_concurrency: Option<u16>,
}
impl Settings {
pub fn init() -> Self {
let s = Self::parse();
if s.bichon_encrypt_password.is_none() && s.bichon_encrypt_password_file.is_none() {
panic!(
"One of --bichon_encrypt_password or --bichon_encrypt_password_file has to be set"
);
}
s
}
}
#[derive(Clone, Copy, Debug, PartialEq, ValueEnum)]
pub enum CompressionAlgorithm {
#[clap(name = "none")]
+60
View File
@@ -16,8 +16,68 @@
// 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 poem_openapi::Object;
use serde::{Deserialize, Serialize};
use crate::modules::settings::cli::Settings;
pub mod cli;
pub mod dir;
pub mod proxy;
pub mod system;
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct SystemConfigurations {
pub bichon_log_level: String,
pub bichon_http_port: i32,
pub bichon_bind_ip: Option<String>,
pub bichon_public_url: String,
pub bichon_cors_origins: Option<Vec<String>>,
pub bichon_cors_max_age: i32,
pub bichon_ansi_logs: bool,
pub bichon_log_to_file: bool,
pub bichon_json_logs: bool,
pub bichon_max_server_log_files: usize,
pub bichon_encrypt_password_set: bool,
pub bichon_webui_token_expiration_hours: u32,
pub bichon_root_dir: String,
pub bichon_metadata_cache_size: Option<usize>,
pub bichon_envelope_cache_size: Option<usize>,
pub bichon_enable_rest_https: bool,
pub bichon_http_compression_enabled: bool,
pub bichon_sync_concurrency: Option<u16>,
}
impl From<&Settings> for SystemConfigurations {
fn from(s: &Settings) -> Self {
Self {
bichon_log_level: s.bichon_log_level.clone(),
bichon_http_port: s.bichon_http_port,
bichon_bind_ip: s.bichon_bind_ip.clone(),
bichon_public_url: s.bichon_public_url.clone(),
bichon_cors_origins: s
.bichon_cors_origins
.as_ref()
.map(|set| set.iter().cloned().collect()),
bichon_cors_max_age: s.bichon_cors_max_age,
bichon_ansi_logs: s.bichon_ansi_logs,
bichon_log_to_file: s.bichon_log_to_file,
bichon_json_logs: s.bichon_json_logs,
bichon_max_server_log_files: s.bichon_max_server_log_files,
bichon_encrypt_password_set: s.bichon_encrypt_password.is_some()
|| s.bichon_encrypt_password_file.is_some(),
bichon_webui_token_expiration_hours: s.bichon_webui_token_expiration_hours,
bichon_root_dir: s.bichon_root_dir.clone(),
bichon_metadata_cache_size: s.bichon_metadata_cache_size,
bichon_envelope_cache_size: s.bichon_envelope_cache_size,
bichon_enable_rest_https: s.bichon_enable_rest_https,
bichon_http_compression_enabled: s.bichon_http_compression_enabled,
bichon_sync_concurrency: s.bichon_sync_concurrency,
}
}
}
+1 -5
View File
@@ -16,7 +16,6 @@
// 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 native_db::*;
use native_model::{native_model, Model};
use poem_openapi::Object;
@@ -132,10 +131,7 @@ mod tests {
#[test]
fn test_valid_proxy_urls() {
let urls = vec![
"socks5://127.0.0.1:1080",
"http://127.0.0.1:8080",
];
let urls = vec!["socks5://127.0.0.1:1080", "http://127.0.0.1:8080"];
for url in urls {
let proxy = Proxy::new(url.to_string());
+26 -26
View File
@@ -17,10 +17,10 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::database::manager::DB_MANAGER;
use crate::modules::database::{find_impl, upsert_impl};
use crate::modules::error::BichonResult;
use crate::utc_now;
// use crate::modules::database::manager::DB_MANAGER;
// use crate::modules::database::{find_impl, upsert_impl};
// use crate::modules::error::BichonResult;
// use crate::utc_now;
use native_db::*;
use native_model::{native_model, Model};
use serde::{Deserialize, Serialize};
@@ -37,34 +37,34 @@ pub struct SystemSetting {
}
impl SystemSetting {
pub fn new(key: String, value: String) -> Self {
Self {
key,
value,
created_at: utc_now!(),
updated_at: utc_now!(),
}
}
// pub fn new(key: String, value: String) -> Self {
// Self {
// key,
// value,
// created_at: utc_now!(),
// updated_at: utc_now!(),
// }
// }
//overwrite
pub async fn set(&self) -> BichonResult<()> {
upsert_impl(DB_MANAGER.meta_db(), self.to_owned()).await
}
// pub async fn set(&self) -> BichonResult<()> {
// upsert_impl(DB_MANAGER.meta_db(), self.to_owned()).await
// }
pub fn get(key: &str) -> BichonResult<Option<SystemSetting>> {
find_impl(DB_MANAGER.meta_db(), key)
}
// pub fn get(key: &str) -> BichonResult<Option<SystemSetting>> {
// find_impl(DB_MANAGER.meta_db(), key)
// }
// pub async fn list() -> RustMailerResult<Vec<SystemSetting>> {
// list_all_impl(DB_MANAGER.metadata_db()).await
// }
pub fn get_existing_value(key: &str) -> BichonResult<Option<String>> {
let setting = Self::get(key)?;
Ok(setting.map(|s| s.value))
}
// pub fn get_existing_value(key: &str) -> BichonResult<Option<String>> {
// let setting = Self::get(key)?;
// Ok(setting.map(|s| s.value))
// }
pub async fn set_value(key: &str, value: String) -> BichonResult<()> {
let setting = Self::new(key.to_string(), value);
setting.set().await
}
// pub async fn set_value(key: &str, value: String) -> BichonResult<()> {
// let setting = Self::new(key.to_string(), value);
// setting.set().await
// }
}
+223 -259
View File
@@ -16,12 +16,17 @@
// 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 std::collections::HashMap;
use crate::modules::account::migration::AccountModel;
use crate::modules::database::delete_impl;
use super::error::code::ErrorCode;
use crate::modules::database::manager::DB_MANAGER;
use crate::modules::database::{
async_find_impl, delete_impl, filter_by_secondary_key_impl, with_transaction,
};
use crate::modules::database::{insert_impl, list_all_impl, update_impl};
use crate::modules::token::payload::AccessTokenUpdateRequest;
use crate::modules::settings::cli::SETTINGS;
use crate::modules::token::view::AccessTokenResp;
use crate::modules::users::BichonUser;
use crate::raise_error;
use crate::{
generate_token, modules::error::BichonResult,
@@ -29,259 +34,227 @@ use crate::{
};
use native_db::*;
use native_model::{native_model, Model};
use poem_openapi::Object;
use poem_openapi::{Enum, Object};
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use std::net::IpAddr;
use super::error::code::ErrorCode;
pub mod payload;
pub mod root;
pub mod view;
// Starting from version 0.2.0, this model is deprecated/no longer used
// #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Object)]
// #[native_model(id = 1, version = 1)]
// #[native_db]
// pub struct AccessToken {
// /// The unique token string used for authentication
// #[primary_key]
// pub token: String,
// /// A set of account information associated with the token.
// pub accounts: BTreeSet<AccountInfo>,
// /// The timestamp (in milliseconds since epoch) when the token was created.
// pub created_at: i64,
// /// The timestamp (in milliseconds since epoch) when the token was last updated.
// pub updated_at: i64,
// /// An optional description of the token's purpose or usage.
// pub description: Option<String>,
// /// The timestamp (in milliseconds since epoch) when the token was last used.
// pub last_access_at: i64,
// /// Optional access control settings
// pub acl: Option<AccessControl>,
// }
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Enum)]
pub enum TokenType {
WebUI,
Api,
}
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Object)]
#[native_model(id = 1, version = 1)]
#[native_model(id = 11, version = 1)]
#[native_db]
pub struct AccessToken {
pub struct AccessTokenModel {
/// The ID of the user who owns this token
#[secondary_key]
pub user_id: u64,
/// The unique token string used for authentication
#[primary_key]
pub token: String,
/// A set of account information associated with the token.
pub accounts: BTreeSet<AccountInfo>,
/// An optional name of the token.
pub name: Option<String>,
/// Token type: WebUI or API
pub token_type: TokenType,
/// The timestamp (in milliseconds since epoch) when the token was created.
pub created_at: i64,
/// The timestamp (in milliseconds since epoch) when the token was last updated.
pub updated_at: i64,
/// An optional description of the token's purpose or usage.
pub description: Option<String>,
/// The timestamp (in milliseconds since epoch) when the token expires.
/// None means the token does not expire (this applies only to API tokens).
pub expire_at: Option<i64>,
/// The timestamp (in milliseconds since epoch) when the token was last used.
pub last_access_at: i64,
/// Optional access control settings
pub acl: Option<AccessControl>,
}
#[derive(Clone, Debug, Hash, PartialEq, Eq, Deserialize, Serialize, Object)]
pub struct AccountInfo {
/// The unique identifier for the account.
pub id: u64,
/// The email address associated with the account.
pub email: String,
}
impl Ord for AccountInfo {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.id.cmp(&other.id)
}
}
impl PartialOrd for AccountInfo {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Object)]
pub struct AccessControl {
/// An optional set of valid IPv4 or IPv6 addresses allowed to use the access token.
pub ip_whitelist: Option<BTreeSet<String>>,
/// An optional rate limit configuration for the access token.
pub rate_limit: Option<RateLimit>,
}
impl AccessControl {
pub fn validate(&self) -> BichonResult<()> {
if let Some(ip_whitelist) = &self.ip_whitelist {
for ip in ip_whitelist {
if ip.parse::<IpAddr>().is_err() {
return Err(raise_error!(
format!("Invalid IP address: {}", ip),
ErrorCode::InvalidParameter
));
}
}
}
// Validate rate limit
if let Some(rate_limit) = &self.rate_limit {
if rate_limit.interval < 1 {
return Err(raise_error!(
"Rate limit interval must be at least 1 second".into(),
ErrorCode::InvalidParameter
));
}
if rate_limit.quota < 1 {
return Err(raise_error!(
"Rate limit quota must be at least 1".into(),
ErrorCode::InvalidParameter
));
}
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Object)]
pub struct RateLimit {
/// The time window in seconds for the rate limit.
pub interval: u64,
/// The maximum number of allowed requests within the time window.
pub quota: u32,
}
impl AccessToken {
pub fn new(
impl AccessTokenModel {
pub fn new_api_token(
token: String,
accounts: BTreeSet<AccountInfo>,
description: Option<String>,
acl: Option<AccessControl>,
user_id: u64,
name: Option<String>,
expire_at: Option<i64>,
) -> Self {
Self {
token,
accounts,
created_at: utc_now!(),
updated_at: utc_now!(),
description,
last_access_at: Default::default(),
acl,
name,
user_id,
token_type: TokenType::Api,
expire_at,
}
}
pub async fn try_update_access_timestamp(token: &str) -> BichonResult<AccessToken> {
let token = token.to_string();
update_impl(
DB_MANAGER.meta_db(),
|rw| {
rw.get()
.primary::<AccessToken>(token)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!("Token not exist.".into(), ErrorCode::ResourceNotFound)
})
},
|current| {
let mut updated = current.clone();
updated.last_access_at = utc_now!();
Ok(updated)
},
)
.await
pub fn new_webui_token(user_id: u64) -> AccessTokenModel {
let now = utc_now!();
AccessTokenModel {
token: generate_token!(128),
created_at: now,
updated_at: now,
last_access_at: Default::default(),
name: None,
user_id,
token_type: TokenType::WebUI,
expire_at: None,
}
}
pub async fn grant_account_access(token: &str, account: AccountInfo) -> BichonResult<()> {
let token = token.to_string();
update_impl(
pub async fn reset_webui_token(user_id: u64) -> BichonResult<String> {
let old_token = Self::get_user_webui_token(user_id).await?;
let new_token = Self::new_webui_token(user_id);
let new_token_str = new_token.token.clone();
match old_token {
Some(old) => {
with_transaction(DB_MANAGER.meta_db(), move |rw| {
rw.remove(old)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw.insert(new_token)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(())
})
.await?;
}
None => {
insert_impl(DB_MANAGER.meta_db(), new_token).await?;
}
}
Ok(new_token_str)
}
pub async fn get_user_webui_token(user_id: u64) -> BichonResult<Option<AccessTokenModel>> {
let tokens = filter_by_secondary_key_impl::<AccessTokenModel>(
DB_MANAGER.meta_db(),
move |rw| {
rw.get()
.primary::<AccessToken>(token.clone())
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!(
"The access token with token={} that you want to modify was not found.",
token
),
ErrorCode::ResourceNotFound
)
})
},
|current| {
let mut updated = current.clone();
updated.accounts.insert(account);
updated.updated_at = utc_now!();
Ok(updated)
},
AccessTokenModelKey::user_id,
user_id,
)
.await?;
Ok(())
Ok(tokens
.into_iter()
.find(|t| t.token_type == TokenType::WebUI))
}
pub async fn update(token: &str, request: AccessTokenUpdateRequest) -> BichonResult<()> {
if request.should_skip_update() {
return Err(raise_error!(
"No changes detected in access scopes, description, or accounts. \
Please modify at least one of these fields to perform an update."
.into(),
ErrorCode::InvalidParameter
));
}
request.validate().await?;
pub async fn get_user_api_tokens(user_id: u64) -> BichonResult<Vec<AccessTokenModel>> {
let tokens = filter_by_secondary_key_impl::<AccessTokenModel>(
DB_MANAGER.meta_db(),
AccessTokenModelKey::user_id,
user_id,
)
.await?;
let account_infos = if let Some(accounts) = &request.accounts {
let mut account_infos = BTreeSet::new();
for account_id in accounts {
let account = AccountModel::get(*account_id).await?;
account_infos.insert(AccountInfo {
id: *account_id,
email: account.email,
});
Ok(tokens
.into_iter()
.filter(|t| t.token_type == TokenType::Api)
.collect())
}
pub async fn resolve_user_from_token(token: &str) -> BichonResult<BichonUser> {
let token = token.to_string();
let token_option = async_find_impl::<AccessTokenModel>(DB_MANAGER.meta_db(), token).await?;
let token = match token_option {
Some(token) => token,
None => {
return Err(raise_error!(
"Permission denied: no valid access token provided.".into(),
ErrorCode::PermissionDenied
))
}
account_infos
} else {
BTreeSet::new()
};
let token = token.to_string();
update_impl(
DB_MANAGER.meta_db(),
move |rw| {
rw.get()
.primary::<AccessToken>(token.clone())
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!(
"The access token with token={} that you want to modify was not found.",
token
),
ErrorCode::ResourceNotFound
)
})
},
move |current| {
let mut updated = current.clone();
if let Some(description) = request.description {
updated.description = Some(description);
}
if matches!(token.token_type, TokenType::WebUI) {
let life = utc_now!() - token.created_at;
let max_life = SETTINGS.bichon_webui_token_expiration_hours * 60 * 60 * 1000;
if request.accounts.is_some() {
updated.accounts = account_infos;
}
if let Some(acl) = request.acl {
updated.acl = Some(acl);
}
updated.updated_at = utc_now!();
Ok(updated)
},
)
.await?;
Ok(())
}
pub async fn create(request: AccessTokenCreateRequest) -> BichonResult<String> {
// Validate request parameters first
request.validate().await?;
let AccessTokenCreateRequest {
accounts,
description,
acl,
} = request;
let mut account_infos = BTreeSet::new();
for &account_id in &accounts {
let account = AccountModel::get(account_id).await?;
account_infos.insert(AccountInfo {
id: account_id,
email: account.email,
});
if life > (max_life as i64) {
return Err(raise_error!(
"Permission denied: the WebUI token has expired.".into(),
ErrorCode::PermissionDenied
));
}
}
if matches!(token.token_type, TokenType::Api) {
if let Some(expire_at) = token.expire_at {
if utc_now!() > expire_at {
return Err(raise_error!(
"Your API token has expired and is no longer valid.".into(),
ErrorCode::PermissionDenied
));
}
}
let token = token.token.clone();
update_impl(
DB_MANAGER.meta_db(),
|rw| {
rw.get()
.primary::<AccessTokenModel>(token)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
"The access token does not exist or has been reset.".into(),
ErrorCode::ResourceNotFound
)
})
},
|current| {
let mut updated = current.clone();
updated.last_access_at = utc_now!();
Ok(updated)
},
)
.await?;
}
let user = BichonUser::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)
}
pub async fn create_api_token(
user_id: u64,
request: AccessTokenCreateRequest,
) -> BichonResult<String> {
// Validate request parameters first
request.validate().await?;
let expire_at = request
.expire_in
.map(|hours| utc_now!() + (hours as i64) * 60 * 60 * 1000);
let token = generate_token!(128);
let access_token = AccessToken::new(token.clone(), account_infos, description, acl);
let access_token =
AccessTokenModel::new_api_token(token.clone(), user_id, request.name, expire_at);
insert_impl(DB_MANAGER.meta_db(), access_token).await?;
Ok(token)
}
@@ -290,7 +263,7 @@ impl AccessToken {
let token = token.to_string();
delete_impl(DB_MANAGER.meta_db(), move |rw| {
rw.get()
.primary::<AccessToken>(token.clone())
.primary::<AccessTokenModel>(token.clone())
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
@@ -302,56 +275,47 @@ impl AccessToken {
.await
}
pub async fn list_all() -> BichonResult<Vec<AccessToken>> {
list_all_impl(DB_MANAGER.meta_db()).await
pub async fn get_token(token: &str) -> BichonResult<AccessTokenModel> {
async_find_impl(DB_MANAGER.meta_db(), token.to_string())
.await?
.ok_or_else(|| {
raise_error!(
format!("Access token '{}' not found", token),
ErrorCode::ResourceNotFound
)
})
}
pub async fn list_account_tokens(account_id: u64) -> BichonResult<Vec<AccessToken>> {
let all = AccessToken::list_all().await?;
let result: Vec<AccessToken> = all
pub async fn list_all_api_tokens() -> BichonResult<Vec<AccessTokenResp>> {
let users = BichonUser::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 resp = all
.into_iter()
.filter(|e| {
e.accounts
.iter()
.any(|account_info| account_info.id == account_id)
.map(|token| {
let user = user_map.get(&token.user_id);
AccessTokenResp {
user_name: user
.map(|u| u.username.clone())
.unwrap_or_else(|| "Unknown".to_string()),
user_email: user
.map(|u| u.email.clone())
.unwrap_or_else(|| "N/A".to_string()),
user_id: token.user_id,
name: token.name,
token: token.token,
token_type: token.token_type,
created_at: token.created_at,
updated_at: token.updated_at,
expire_at: token.expire_at,
last_access_at: token.last_access_at,
}
})
.collect();
Ok(result)
}
pub async fn cleanup_account(account_id: u64) -> BichonResult<()> {
let tokens = Self::list_account_tokens(account_id).await?;
if tokens.is_empty() {
return Ok(());
}
for token in tokens {
update_impl(
DB_MANAGER.meta_db(),
move |rw| {
rw.get()
.primary::<AccessToken>(token.token.clone())
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!("Cannot find access token, {}", token.token),
ErrorCode::ResourceNotFound
)
})
},
move |current| {
let mut updated = current.clone();
updated.updated_at = utc_now!();
updated.accounts.retain(|account| account.id != account_id);
Ok(updated)
},
)
.await?;
}
Ok(())
}
pub fn can_access_account(&self, account_id: u64) -> bool {
self.accounts.iter().any(|account| account.id == account_id)
Ok(resp)
}
}
+13 -83
View File
@@ -16,15 +16,8 @@
// 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 std::collections::BTreeSet;
use crate::{
modules::{
account::migration::AccountModel,
error::{code::ErrorCode, BichonResult},
token::AccessControl,
},
modules::error::{code::ErrorCode, BichonResult},
raise_error,
};
use poem_openapi::Object;
@@ -32,91 +25,28 @@ use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize, Object)]
pub struct AccessTokenCreateRequest {
/// A set of account information associated with the token.
pub accounts: BTreeSet<u64>,
/// An optional description of the token's purpose or usage.
#[oai(validator(max_length = "255"))]
pub description: Option<String>,
/// Optional access control settings
pub acl: Option<AccessControl>,
#[oai(validator(max_length = "32"))]
pub name: Option<String>,
/// The expiration interval for this token, in hours.
/// None means the token does not expire (this applies only to API tokens).
pub expire_in: Option<u64>,
/// The ID of the user for whom the token is being created.
/// If not specified, the token will be created for the current authenticated user.
/// Accessing this for another user typically requires `USER_MANAGE` permissions.
pub user_id: Option<u64>,
}
impl AccessTokenCreateRequest {
pub async fn validate(&self) -> BichonResult<()> {
if let Some(acl) = &self.acl {
acl.validate()?;
}
if self.accounts.is_empty() {
return Err(raise_error!(
"Account list cannot be empty. Please provide at least one valid account ID."
.into(),
ErrorCode::InvalidParameter
));
}
let mut not_found = Vec::new();
for account_id in &self.accounts {
if AccountModel::find(*account_id).await?.is_none() {
not_found.push(*account_id);
}
}
if !not_found.is_empty() {
return Err(raise_error!(
format!("The following account IDs were not found: {}. Please provide valid account IDs.", not_found.iter().map(u64::to_string).collect::<Vec<_>>().join(", ")).into(),
ErrorCode::InvalidParameter
));
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, Object)]
pub struct AccessTokenUpdateRequest {
/// A set of account information associated with the token.
pub accounts: Option<BTreeSet<u64>>,
/// An optional description of the token's purpose or usage.
#[oai(validator(max_length = "255"))]
pub description: Option<String>,
/// Optional access control settings
pub acl: Option<AccessControl>,
}
impl AccessTokenUpdateRequest {
pub async fn validate(&self) -> BichonResult<()> {
if let Some(acl) = &self.acl {
acl.validate()?;
}
if let Some(accounts) = &self.accounts {
if accounts.is_empty() {
if let Some(expire_in) = self.expire_in {
if expire_in == 0 {
return Err(raise_error!(
"Account list cannot be empty. Please provide at least one valid account ID."
.into(),
"expire_in must be a positive duration in hours; zero is not allowed.".into(),
ErrorCode::InvalidParameter
));
}
let mut not_found = Vec::new();
for account_id in accounts {
if AccountModel::find(*account_id).await?.is_none() {
not_found.push(*account_id);
}
}
if !not_found.is_empty() {
return Err(raise_error!(
format!("The following account IDs were not found: {}. Please provide valid account IDs.", not_found.iter().map(u64::to_string).collect::<Vec<_>>().join(", ")).into(),
ErrorCode::InvalidParameter
));
}
}
Ok(())
}
}
impl AccessTokenUpdateRequest {
pub fn should_skip_update(&self) -> bool {
self.description.is_none() && self.accounts.is_none() && self.acl.is_none()
}
}
+94 -95
View File
@@ -16,110 +16,109 @@
// 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::{
// decrypt, encrypt, generate_token,
// modules::{
// error::{code::ErrorCode, BichonResult},
// settings::{dir::DATA_DIR_MANAGER, system::SystemSetting},
// },
// raise_error,
// };
// use std::fs::File;
// use std::io::Write;
use crate::{
decrypt, encrypt, generate_token,
modules::{
error::{code::ErrorCode, BichonResult},
settings::{dir::DATA_DIR_MANAGER, system::SystemSetting},
},
raise_error,
};
use std::fs::File;
use std::io::Write;
// pub const ROOT_TOKEN: &str = "root-token";
// pub const ROOT_PASSWORD: &str = "root-password";
// pub const DEFAULT_ROOT_PASSWORD: &str = "root";
// pub const ROOT_TOKEN_FILE: &str = "root";
pub const ROOT_TOKEN: &str = "root-token";
pub const ROOT_PASSWORD: &str = "root-password";
pub const DEFAULT_ROOT_PASSWORD: &str = "root";
pub const ROOT_TOKEN_FILE: &str = "root";
// async fn get_or_generate(
// key: &str,
// generate: impl Fn() -> String,
// save_file_name: Option<&str>,
// force: bool,
// ) -> BichonResult<String> {
// if let Some(existing_value) = SystemSetting::get_existing_value(key)? {
// if force {
// // If force is true, write the existing value to the file
// if let Some(filename) = save_file_name {
// save_to_file(&existing_value.to_string(), filename).await?;
// }
// }
// Ok(existing_value)
// } else {
// // If no value exists, generate a new value
// let new_value = generate();
// SystemSetting::set_value(key, new_value.clone()).await?;
async fn get_or_generate(
key: &str,
generate: impl Fn() -> String,
save_file_name: Option<&str>,
force: bool,
) -> BichonResult<String> {
if let Some(existing_value) = SystemSetting::get_existing_value(key)? {
if force {
// If force is true, write the existing value to the file
if let Some(filename) = save_file_name {
save_to_file(&existing_value.to_string(), filename).await?;
}
}
Ok(existing_value)
} else {
// If no value exists, generate a new value
let new_value = generate();
SystemSetting::set_value(key, new_value.clone()).await?;
// // Write the new value to the file, if specified
// if let Some(filename) = save_file_name {
// save_to_file(&new_value.to_string(), filename).await?;
// }
// Ok(new_value)
// }
// }
// Write the new value to the file, if specified
if let Some(filename) = save_file_name {
save_to_file(&new_value.to_string(), filename).await?;
}
Ok(new_value)
}
}
// pub async fn ensure_root_token() -> BichonResult<()> {
// get_or_generate(
// ROOT_TOKEN,
// || generate_token!(128),
// Some(ROOT_TOKEN_FILE),
// true,
// )
// .await?;
// Ok(())
// }
pub async fn ensure_root_token() -> BichonResult<()> {
get_or_generate(
ROOT_TOKEN,
|| generate_token!(128),
Some(ROOT_TOKEN_FILE),
true,
)
.await?;
Ok(())
}
// pub async fn reset_root_token() -> BichonResult<String> {
// let new_token = generate_token!(128);
// save_new_token(&new_token).await?;
// save_to_file(&new_token, ROOT_TOKEN_FILE).await?;
// Ok(new_token)
// }
pub async fn reset_root_token() -> BichonResult<String> {
let new_token = generate_token!(128);
save_new_token(&new_token).await?;
save_to_file(&new_token, ROOT_TOKEN_FILE).await?;
Ok(new_token)
}
// async fn save_new_token(token: &str) -> BichonResult<()> {
// let setting = SystemSetting::new(ROOT_TOKEN.to_string(), token.to_string());
// setting.set().await
// }
async fn save_new_token(token: &str) -> BichonResult<()> {
let setting = SystemSetting::new(ROOT_TOKEN.to_string(), token.to_string());
setting.set().await
}
// async fn save_to_file(content: &str, filename: &str) -> BichonResult<()> {
// let file_path = DATA_DIR_MANAGER.root_dir.join(filename);
// let mut file = File::create(&file_path)
// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
// writeln!(file, "{}", content)
// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
// Ok(())
// }
async fn save_to_file(content: &str, filename: &str) -> BichonResult<()> {
let file_path = DATA_DIR_MANAGER.root_dir.join(filename);
let mut file = File::create(&file_path)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
writeln!(file, "{}", content)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(())
}
// pub fn check_root_password(password: &str) -> BichonResult<String> {
// let stored_encrypted_password = SystemSetting::get_existing_value(ROOT_PASSWORD)?;
// let matched = match stored_encrypted_password {
// Some(ref stored) => {
// let decrypted = decrypt!(stored)?;
// decrypted == password
// }
// None => DEFAULT_ROOT_PASSWORD == password,
// };
pub fn check_root_password(password: &str) -> BichonResult<String> {
let stored_encrypted_password = SystemSetting::get_existing_value(ROOT_PASSWORD)?;
let matched = match stored_encrypted_password {
Some(ref stored) => {
let decrypted = decrypt!(stored)?;
decrypted == password
}
None => DEFAULT_ROOT_PASSWORD == password,
};
// if !matched {
// return Err(raise_error!(
// "Invalid password".into(),
// ErrorCode::PermissionDenied
// ));
// }
if !matched {
return Err(raise_error!(
"Invalid password".into(),
ErrorCode::PermissionDenied
));
}
// let root_token = SystemSetting::get_existing_value(ROOT_TOKEN)?.ok_or_else(|| {
// raise_error!(
// "Root token not found — this should never happen".into(),
// ErrorCode::InternalError
// )
// })?;
let root_token = SystemSetting::get_existing_value(ROOT_TOKEN)?.ok_or_else(|| {
raise_error!(
"Root token not found — this should never happen".into(),
ErrorCode::InternalError
)
})?;
// Ok(root_token)
// }
Ok(root_token)
}
pub async fn set_root_password(new_password: &str) -> BichonResult<()> {
let encrypted_password = encrypt!(new_password)?;
SystemSetting::set_value(ROOT_PASSWORD, encrypted_password).await
}
// pub async fn set_root_password(new_password: &str) -> BichonResult<()> {
// let encrypted_password = encrypt!(new_password)?;
// SystemSetting::set_value(ROOT_PASSWORD, encrypted_password).await
// }
+44
View File
@@ -0,0 +1,44 @@
//
// 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 <http://www.gnu.org/licenses/>.
use crate::modules::token::TokenType;
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Object)]
pub struct AccessTokenResp {
pub user_id: u64,
pub token: String,
/// An optional name of the token.
pub name: Option<String>,
/// Token type: WebUI or API
pub token_type: TokenType,
/// The timestamp (in milliseconds since epoch) when the token was created.
pub created_at: i64,
/// The timestamp (in milliseconds since epoch) when the token was last updated.
pub updated_at: i64,
/// The timestamp (in milliseconds since epoch) when the token expires.
/// None means the token does not expire (this applies only to API tokens).
pub expire_at: Option<i64>,
/// The timestamp (in milliseconds since epoch) when the token was last used.
pub last_access_at: i64,
pub user_name: String,
pub user_email: String,
}
+73
View File
@@ -0,0 +1,73 @@
//
// 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 <http://www.gnu.org/licenses/>.
use std::{collections::BTreeSet, net::IpAddr};
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use crate::{modules::error::{BichonResult, code::ErrorCode}, raise_error};
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Object)]
pub struct RateLimit {
/// The time window in seconds for the rate limit.
pub interval: u64,
/// The maximum number of allowed requests within the time window.
pub quota: u32,
}
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Object)]
pub struct AccessControl {
/// An optional set of valid IPv4 or IPv6 addresses allowed to use the access token.
pub ip_whitelist: Option<BTreeSet<String>>,
/// An optional rate limit configuration for the access token.
pub rate_limit: Option<RateLimit>,
}
impl AccessControl {
pub fn validate(&self) -> BichonResult<()> {
if let Some(ip_whitelist) = &self.ip_whitelist {
for ip in ip_whitelist {
if ip.parse::<IpAddr>().is_err() {
return Err(raise_error!(
format!("Invalid IP address: {}", ip),
ErrorCode::InvalidParameter
));
}
}
}
// Validate rate limit
if let Some(rate_limit) = &self.rate_limit {
if rate_limit.interval < 1 {
return Err(raise_error!(
"Rate limit interval must be at least 1 second".into(),
ErrorCode::InvalidParameter
));
}
if rate_limit.quota < 1 {
return Err(raise_error!(
"Rate limit quota must be at least 1".into(),
ErrorCode::InvalidParameter
));
}
}
Ok(())
}
}
@@ -16,30 +16,17 @@
// 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::{
context::Initialize,
error::BichonResult,
users::{role::UserRole, BichonUser},
};
interface AccountInfo {
id: number;
email: string;
pub struct UserManager;
impl Initialize for UserManager {
async fn initialize() -> BichonResult<()> {
UserRole::ensure_default_roles_exists().await?;
BichonUser::ensure_default_admin_exists().await
}
}
interface RateLimit {
quota: number;
interval: number;
}
interface AccessControl {
ip_whitelist?: string[];
rate_limit?: RateLimit;
}
interface AccessToken {
token: string;
accounts: AccountInfo[];
created_at: number;
updated_at: number;
description?: string;
last_access_at: number;
acl?: AccessControl;
}
export type { AccessToken, AccountInfo, AccessControl, RateLimit };
+49
View File
@@ -0,0 +1,49 @@
//
// 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 <http://www.gnu.org/licenses/>.
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use crate::modules::{
database::{list_all_impl, manager::DB_MANAGER},
error::BichonResult,
users::BichonUser,
};
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
pub struct MinimalUser {
pub id: u64,
pub username: String,
pub email: String,
}
impl MinimalUser {
pub async fn list_all() -> BichonResult<Vec<MinimalUser>> {
let all_users = list_all_impl::<BichonUser>(DB_MANAGER.meta_db()).await?;
let minimal_list = all_users
.into_iter()
.map(|user| MinimalUser {
id: user.id,
username: user.username,
email: user.email,
})
.collect();
Ok(minimal_list)
}
}
+586
View File
@@ -0,0 +1,586 @@
//
// 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 <http://www.gnu.org/licenses/>.
use crate::{
decrypt, encrypt, generate_token, id,
modules::{
database::{
async_find_impl, batch_delete_impl, delete_impl, list_all_impl, manager::DB_MANAGER,
secondary_find_impl, update_impl, with_transaction,
},
error::{code::ErrorCode, BichonResult},
token::{AccessTokenModel, AccessTokenModelKey, TokenType},
users::{
acl::AccessControl,
payload::{UserCreateRequest, UserUpdateRequest},
permissions::Permission,
role::{UserRole, DEFAULT_ADMIN_ROLE_ID},
view::UserView,
},
},
raise_error, utc_now,
};
use itertools::Itertools;
use native_db::*;
use native_model::{native_model, Model};
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet, HashSet};
use tracing::warn;
pub mod acl;
pub mod manager;
pub mod minimal;
pub mod payload;
pub mod permissions;
pub mod role;
pub mod view;
#[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 const DEFAULT_ADMIN_USER_ID: u64 = 100000000000000;
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
#[native_model(id = 10, version = 1)]
#[native_db]
pub struct BichonUser {
#[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>,
}
impl BichonUser {
pub async fn list_all() -> BichonResult<Vec<BichonUser>> {
Ok(list_all_impl::<BichonUser>(DB_MANAGER.meta_db()).await?)
}
async fn get_all_permissions(&self) -> HashSet<String> {
let mut all_perms = HashSet::new();
for &role_id in &self.global_roles {
if let Ok(Some(role)) = UserRole::find(role_id).await {
for perm in role.permissions {
all_perms.insert(perm);
}
}
}
all_perms
}
pub fn to_current_user(self, role_lookup: &BTreeMap<u64, UserRole>) -> UserView {
let global_roles_names = self
.global_roles
.iter()
.filter_map(|role_id| role_lookup.get(role_id))
.map(|role| role.name.clone())
.collect();
let account_roles_summary = self
.account_access_map
.iter()
.map(|(acc_id, role_id)| {
let role_name = role_lookup
.get(role_id)
.map(|r| r.name.clone())
.unwrap_or_else(|| "Unknown Role".to_string());
(*acc_id, role_name)
})
.collect();
let global_permissions = {
let mut perms = BTreeSet::new();
for role_id in &self.global_roles {
if let Some(role) = role_lookup.get(role_id) {
perms.extend(role.permissions.iter().cloned());
}
}
perms.into_iter().collect()
};
let account_permissions = {
let mut map: BTreeMap<u64, BTreeSet<String>> = BTreeMap::new();
for (account_id, role_id) in &self.account_access_map {
if let Some(role) = role_lookup.get(role_id) {
let entry = map.entry(*account_id).or_default();
entry.extend(role.permissions.iter().cloned());
}
}
map.into_iter()
.map(|(acc_id, perms)| (acc_id, perms.into_iter().collect()))
.collect()
};
UserView {
id: self.id,
username: self.username,
email: self.email,
password: self.password.map(|_| "************".to_string()),
account_access_map: self.account_access_map,
account_roles_summary,
description: self.description,
global_roles: self.global_roles,
global_roles_names,
avatar: self.avatar,
created_at: self.created_at,
updated_at: self.updated_at,
acl: self.acl,
account_permissions,
global_permissions,
}
}
pub async fn is_admin(&self) -> bool {
self.get_all_permissions().await.contains(Permission::ROOT)
}
pub async fn ensure_default_admin_exists() -> BichonResult<()> {
with_transaction(DB_MANAGER.meta_db(), move |rw| {
let now = utc_now!();
// 1. Try to get the existing admin user
let admin = rw
.get()
.primary::<BichonUser>(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 {
id: DEFAULT_ADMIN_USER_ID,
username: "admin".into(),
email: "placeholder@example.com".into(),
password: Some(encrypt!("admin@bichon")?),
// Use global_roles as defined in our new schema
global_roles: vec![DEFAULT_ADMIN_ROLE_ID],
// Admin usually doesn't need specific scoped access
account_access_map: BTreeMap::new(),
avatar: None,
created_at: now,
updated_at: now,
description: Some("System default administrator".into()),
acl: None,
})
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
// 3. Generate and insert an initial access token for the first-time setup
let access_token = AccessTokenModel {
token: generate_token!(128),
created_at: now,
updated_at: now,
last_access_at: Default::default(),
name: Some("Initial Setup Token".into()),
user_id: DEFAULT_ADMIN_USER_ID,
token_type: TokenType::WebUI,
expire_at: None, // Admin setup token usually persistent until changed
};
rw.upsert(access_token)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
}
Ok(())
})
.await?;
Ok(())
}
pub async fn authenticate_user(
username: String,
password: String,
) -> BichonResult<LoginResult> {
let user_option = secondary_find_impl::<BichonUser>(
DB_MANAGER.meta_db(),
BichonUserKey::username,
username.clone(),
)
.await?;
let user = match user_option {
Some(u) => u,
None => {
match secondary_find_impl::<BichonUser>(
DB_MANAGER.meta_db(),
BichonUserKey::email,
username,
)
.await?
{
Some(u) => u,
None => {
return Ok(LoginResult {
success: false,
error_message: Some("User or email not found.".to_string()),
access_token: None,
});
}
}
}
};
match user.password.as_ref() {
Some(encrypted_password) => {
let decrypted = decrypt!(encrypted_password)?;
if password == decrypted {
let new_token = AccessTokenModel::reset_webui_token(user.id).await?;
Ok(LoginResult {
success: true,
error_message: None,
access_token: Some(new_token),
})
} else {
warn!(
"Login failed: Incorrect password for user '{}'.",
user.username
);
Ok(LoginResult {
success: false,
error_message: Some("Incorrect password.".to_string()),
access_token: None,
})
}
}
None => {
warn!(
"Login failed: User '{}' has no password set.",
user.username
);
Ok(LoginResult {
success: false,
error_message: Some(
format!(
"User '{}' has no password set. Please try logging in with an alternative method (e.g., OAuth/SSO).",
user.username
)
),
access_token: None,
})
}
}
}
pub async fn find(user_id: u64) -> BichonResult<Option<BichonUser>> {
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>(
DB_MANAGER.meta_db(),
BichonUserKey::username,
username.to_string(),
)
.await?
.is_some()
{
return Err(raise_error!(
format!("Username '{}' is already taken.", username).into(),
ErrorCode::AlreadyExists
));
}
Ok(())
}
pub async fn check_email_conflict(email: &str) -> BichonResult<()> {
// Check email duplicate
if secondary_find_impl::<BichonUser>(
DB_MANAGER.meta_db(),
BichonUserKey::email,
email.to_string(),
)
.await?
.is_some()
{
return Err(raise_error!(
format!("Email '{}' is already registered.", email).into(),
ErrorCode::AlreadyExists
));
}
Ok(())
}
pub async fn create(request: UserCreateRequest) -> BichonResult<BichonUser> {
request.validate().await?;
Self::check_username_conflict(&request.username).await?;
Self::check_email_conflict(&request.email).await?;
let password_hash = Some(encrypt!(&request.password)?);
let now = utc_now!();
let user = BichonUser {
id: id!(96),
username: request.username,
email: request.email,
password: password_hash,
global_roles: request.global_roles,
avatar: request.avatar_base64,
description: request.description,
acl: request.acl,
created_at: now,
updated_at: now,
account_access_map: request.account_access_map,
};
let user_clone = user.clone();
// 4. Atomic transaction for User and Initial Token
with_transaction(DB_MANAGER.meta_db(), move |rw| {
let user_id = user.id;
// Insert User
rw.insert(user)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
// Create initial WebUI access token
let access_token = AccessTokenModel {
token: generate_token!(128),
created_at: now,
updated_at: now,
last_access_at: Default::default(),
name: Some("Default WebUI Token".into()),
user_id,
token_type: TokenType::WebUI,
expire_at: None,
};
rw.insert(access_token)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(())
})
.await?;
Ok(user_clone)
}
//delete user
pub async fn remove(id: u64) -> BichonResult<()> {
if DEFAULT_ADMIN_USER_ID == id {
return Err(raise_error!(
format!("The default admin user (id={}) cannot be removed", id),
ErrorCode::PermissionDenied
));
}
delete_impl(DB_MANAGER.meta_db(), move |rw| {
rw.get()
.primary::<BichonUser>(id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!("The User with id={id} that you want to delete was not found."),
ErrorCode::ResourceNotFound
)
})
})
.await?;
batch_delete_impl(DB_MANAGER.meta_db(), move |rw| {
let tokens: Vec<AccessTokenModel> = rw
.scan()
.secondary::<AccessTokenModel>(AccessTokenModelKey::user_id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.start_with(id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.try_collect()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(tokens)
})
.await?;
Ok(())
}
pub async fn update(id: u64, request: UserUpdateRequest) -> BichonResult<()> {
let _ = &request.validate().await?;
if DEFAULT_ADMIN_USER_ID == id && request.global_roles.is_some() {
return Err(raise_error!(
format!("The role assignments for default admin (id={}) are immutable to ensure system accessibility.", id),
ErrorCode::Forbidden
));
}
if let Some(username) = &request.username {
let user_option = secondary_find_impl::<BichonUser>(
DB_MANAGER.meta_db(),
BichonUserKey::username,
username.to_string(),
)
.await?;
if let Some(u) = user_option {
if u.id != id {
return Err(raise_error!(
format!("Username '{}' is already taken.", username).into(),
ErrorCode::AlreadyExists
));
}
}
}
if let Some(email) = &request.email {
let user_option = secondary_find_impl::<BichonUser>(
DB_MANAGER.meta_db(),
BichonUserKey::email,
email.to_string(),
)
.await?;
if let Some(u) = user_option {
if u.id != id {
return Err(raise_error!(
format!("Email '{}' is already registered.", email).into(),
ErrorCode::AlreadyExists
));
}
}
}
update_impl(
DB_MANAGER.meta_db(),
move |rw| {
rw.get()
.primary::<BichonUser>(id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!("User with id={} not found", id),
ErrorCode::ResourceNotFound
)
})
},
move |current| {
let mut updated = current.clone();
if let Some(username) = request.username {
updated.username = username;
}
if let Some(email) = request.email {
updated.email = email;
}
if let Some(desc) = request.description {
updated.description = Some(desc);
}
if let Some(password) = request.password {
updated.password = Some(encrypt!(&password)?);
}
if let Some(global_roles) = request.global_roles {
updated.global_roles = global_roles;
}
if let Some(acl) = request.acl {
updated.acl = Some(acl);
}
if let Some(account_access_map) = request.account_access_map {
updated.account_access_map = account_access_map;
}
if let Some(avatar_base64) = request.avatar_base64 {
updated.avatar = Some(avatar_base64);
}
updated.updated_at = utc_now!();
Ok(updated)
},
)
.await?;
Ok(())
}
async fn list_authorized_users(account_id: u64) -> BichonResult<Vec<BichonUser>> {
let all = Self::list_all().await?;
let result: Vec<BichonUser> = all
.into_iter()
.filter(|e| e.account_access_map.contains_key(&account_id))
.collect();
Ok(result)
}
pub async fn cleanup_account(account_id: u64) -> BichonResult<()> {
let users = Self::list_authorized_users(account_id).await?;
if users.is_empty() {
return Ok(());
}
with_transaction(DB_MANAGER.meta_db(), move |rw| {
let now = utc_now!();
for user in users {
let current = rw
.get()
.primary::<BichonUser>(user.id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!("User {} not found", user.id),
ErrorCode::ResourceNotFound
)
})?;
let mut updated = current.clone();
if updated.account_access_map.remove(&account_id).is_some() {
updated.updated_at = now;
rw.update(current, updated)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
}
}
Ok(())
})
.await?;
Ok(())
}
}
+406
View File
@@ -0,0 +1,406 @@
//
// 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 <http://www.gnu.org/licenses/>.
use crate::{
modules::{
account::migration::AccountModel,
error::{code::ErrorCode, BichonResult},
users::{
acl::AccessControl,
permissions::{Permission, VALID_PERMISSION_SET},
role::{RoleType, UserRole},
},
utils::decode_avatar_bytes,
},
raise_error,
};
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet, HashMap};
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct RoleCreateRequest {
pub name: String,
pub role_type: RoleType,
pub description: Option<String>,
pub permissions: BTreeSet<String>,
}
impl RoleCreateRequest {
pub async fn validate(&self) -> BichonResult<()> {
let trimmed_name = self.name.trim();
if trimmed_name.is_empty() {
return Err(raise_error!(
"Role name cannot be empty or consist only of whitespace.".into(),
ErrorCode::InvalidParameter
));
}
let name_lower = trimmed_name.to_lowercase();
if name_lower == "admin" || name_lower == "manager" || name_lower == "viewer" {
return Err(raise_error!(
format!(
"The name '{}' is reserved for system builtin roles.",
trimmed_name
),
ErrorCode::InvalidParameter
));
}
if self.permissions.is_empty() {
return Err(raise_error!(
"Role must be assigned at least one permission.".into(),
ErrorCode::InvalidParameter
));
}
for permission in &self.permissions {
if !VALID_PERMISSION_SET.contains(permission.as_str()) {
return Err(raise_error!(
format!(
"Invalid permission '{}' specified in the request.",
permission
),
ErrorCode::InvalidParameter
));
}
}
Permission::validate_role_permissions(&self.role_type, &self.permissions)?;
Ok(())
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct RoleUpdateRequest {
pub name: Option<String>,
pub description: Option<String>,
pub permissions: Option<BTreeSet<String>>,
}
impl RoleUpdateRequest {
pub async fn validate(&self) -> BichonResult<()> {
// 1. Ensure at least one field is provided for the update
if self.name.is_none() && self.description.is_none() && self.permissions.is_none() {
return Err(raise_error!(
"Update request must contain at least one field to modify (name, description, or permissions).".into(),
ErrorCode::InvalidParameter
));
}
// 2. Validate Name if present
if let Some(name) = &self.name {
let trimmed_name = name.trim();
if trimmed_name.is_empty() {
return Err(raise_error!(
"Role name cannot be set to an empty string or consist only of whitespace."
.into(),
ErrorCode::InvalidParameter
));
}
// Prevent renaming to reserved system names
let name_lower = trimmed_name.to_lowercase();
if name_lower == "admin" || name_lower == "manager" || name_lower == "viewer" {
return Err(raise_error!(
format!(
"The name '{}' is reserved for system builtin roles.",
trimmed_name
),
ErrorCode::InvalidParameter
));
}
}
// 3. Validate Permissions if present
if let Some(permissions) = &self.permissions {
// Ensure the role doesn't end up with zero permissions
if permissions.is_empty() {
return Err(raise_error!(
"Permissions list cannot be empty. A role must have at least one permission."
.into(),
ErrorCode::InvalidParameter
));
}
// Check for invalid permission strings using a functional approach
if let Some(invalid_permission) = permissions
.iter()
.find(|p| !VALID_PERMISSION_SET.contains(p.as_str()))
{
return Err(raise_error!(
format!(
"Invalid permission '{}' specified in the update request.",
invalid_permission
),
ErrorCode::InvalidParameter
));
}
}
Ok(())
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct UserCreateRequest {
pub username: String,
#[oai(validator(custom = "crate::modules::common::validator::EmailValidator"))]
pub email: String,
pub password: String,
/// Global Roles: System-wide permissions (e.g., Admin, User Manager).
pub global_roles: Vec<u64>,
/// Scoped Access: List of accounts paired with specific roles.
/// This allows different permissions per account.
pub account_access_map: BTreeMap<u64, u64>,
pub acl: Option<AccessControl>,
pub avatar_base64: Option<String>,
pub description: Option<String>,
}
impl UserCreateRequest {
pub async fn validate(&self) -> BichonResult<()> {
let username_len = self.username.len();
// 1. Username constraints
if username_len < 5 {
return Err(raise_error!(
"Username must be at least 5 characters long.".into(),
ErrorCode::InvalidParameter
));
}
if username_len > 32 {
return Err(raise_error!(
"Username cannot exceed 32 characters.".into(),
ErrorCode::InvalidParameter
));
}
// 2. Password constraints
let password_len = self.password.len();
if password_len < 8 {
return Err(raise_error!(
"Password must be at least 8 characters long.".into(),
ErrorCode::InvalidParameter
));
}
if password_len > 32 {
return Err(raise_error!(
"Password cannot exceed 32 characters.".into(),
ErrorCode::InvalidParameter
));
}
// 3. Global Roles validation
if self.global_roles.is_empty() {
return Err(raise_error!(
"Global roles list cannot be empty. At least one role must be selected.".into(),
ErrorCode::InvalidParameter
));
}
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();
for rid in &self.global_roles {
match role_type_map.get(rid) {
Some(RoleType::Global) => {}
Some(_) => {
return Err(raise_error!(
format!("Role {} is not a System role", rid),
ErrorCode::InvalidParameter
))
}
None => {
return Err(raise_error!(
format!("System Role {} not found", rid),
ErrorCode::InvalidParameter
))
}
}
}
for (aid, rid) in &self.account_access_map {
if AccountModel::find(*aid).await?.is_none() {
return Err(raise_error!(
format!("Account {} not found", aid),
ErrorCode::InvalidParameter
));
}
match role_type_map.get(rid) {
Some(RoleType::Account) => {}
Some(_) => {
return Err(raise_error!(
format!(
"Role {} assigned to account {} must be an Account role",
rid, aid
),
ErrorCode::InvalidParameter
))
}
None => {
return Err(raise_error!(
format!("Role {} for account {} not found", rid, aid),
ErrorCode::InvalidParameter
))
}
}
}
if let Some(acl) = &self.acl {
acl.validate()?;
}
if let Some(desc) = &self.description {
if desc.len() > 256 {
return Err(raise_error!(
"Description cannot exceed 256 characters.".into(),
ErrorCode::InvalidParameter
));
}
}
if let Some(avatar_base64) = &self.avatar_base64 {
decode_avatar_bytes(&avatar_base64)?;
}
Ok(())
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct UserUpdateRequest {
pub username: Option<String>,
#[oai(validator(custom = "crate::modules::common::validator::EmailValidator"))]
pub email: Option<String>,
pub password: Option<String>,
pub avatar_base64: Option<String>,
pub global_roles: Option<Vec<u64>>,
/// Scoped Access
pub account_access_map: Option<BTreeMap<u64, u64>>,
pub acl: Option<AccessControl>,
pub description: Option<String>,
}
impl UserUpdateRequest {
pub async fn validate(&self) -> BichonResult<()> {
if let Some(username) = &self.username {
let len = username.len();
if len < 5 || len > 32 {
return Err(raise_error!(
"Username must be 5-32 characters.".into(),
ErrorCode::InvalidParameter
));
}
}
if let Some(password) = &self.password {
let len = password.len();
if len < 8 || len > 32 {
return Err(raise_error!(
"Password must be 8-32 characters.".into(),
ErrorCode::InvalidParameter
));
}
}
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();
if let Some(roles) = &self.global_roles {
if roles.is_empty() {
return Err(raise_error!(
"Roles list cannot be empty.".into(),
ErrorCode::InvalidParameter
));
}
for role_id in roles {
match role_type_map.get(role_id) {
Some(RoleType::Global) => {}
Some(_) => {
return Err(raise_error!(
format!("Role {} is not a System role", role_id),
ErrorCode::InvalidParameter
))
}
None => {
return Err(raise_error!(
format!("System Role {} not found", role_id),
ErrorCode::InvalidParameter
))
}
}
}
}
if let Some(account_access_map) = &self.account_access_map {
for (aid, rid) in account_access_map {
if AccountModel::find(*aid).await?.is_none() {
return Err(raise_error!(
format!("Account {} not found", aid),
ErrorCode::InvalidParameter
));
}
match role_type_map.get(rid) {
Some(RoleType::Account) => {}
Some(_) => {
return Err(raise_error!(
format!(
"Role {} assigned to account {} must be an Account role",
rid, aid
),
ErrorCode::InvalidParameter
))
}
None => {
return Err(raise_error!(
format!("Role {} for account {} not found", rid, aid),
ErrorCode::InvalidParameter
))
}
}
}
}
if let Some(desc) = &self.description {
if desc.len() > 256 {
return Err(raise_error!(
"Description too long.".into(),
ErrorCode::InvalidParameter
));
}
}
if let Some(acl) = &self.acl {
acl.validate()?;
}
if let Some(avatar) = &self.avatar_base64 {
decode_avatar_bytes(avatar)?;
}
Ok(())
}
}
+239
View File
@@ -0,0 +1,239 @@
//
// 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 <http://www.gnu.org/licenses/>.
use std::{
collections::{BTreeSet, HashSet},
sync::LazyLock,
};
use crate::{
modules::{
error::{code::ErrorCode, BichonResult},
users::role::RoleType,
},
raise_error,
};
pub static VALID_PERMISSION_SET: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
Permission::all_permissions()
.into_iter()
.map(|(key, _)| key)
.collect()
});
pub struct Permission;
impl Permission {
// ----------------------------------------------------------------------
// 1. Global Management Permissions (System, Users, Tokens)
// ----------------------------------------------------------------------
/// Basic platform access. Required for any user to log in and access the dashboard.
/// This provides no administrative powers.
pub const SYSTEM_ACCESS: &str = "system:access";
/// Manage core system configurations (OAuth Client ID/Secret, Proxy settings).
pub const ROOT: &str = "system:root";
/// Create, modify, and delete all users and their roles (Admin only).
pub const USER_MANAGE: &str = "user:manage";
/// View the minimal user list and basic profiles (Managers and Admins).
pub const USER_VIEW: &str = "user:view";
/// View and revoke all access tokens in the system.
pub const TOKEN_MANAGE: &str = "token:manage";
/// Create new email account connections.
pub const ACCOUNT_CREATE: &str = "account:create";
// ----------------------------------------------------------------------
// 2. Global "ALL" Scoped Permissions (Reserved for Admin)
// ----------------------------------------------------------------------
/// Manage configuration for all accounts (Global control).
pub const ACCOUNT_MANAGE_ALL: &str = "account:manage:all";
/// Read mail data from all accounts (Search, view messages).
pub const DATA_READ_ALL: &str = "data:read:all";
/// Download raw EML/MIME files from all accounts.
pub const DATA_RAW_DOWNLOAD_ALL: &str = "data:raw:download:all";
/// Delete messages from all accounts.
pub const DATA_DELETE_ALL: &str = "data:delete:all";
/// Manage metadata (e.g., tags, categories, notes) for messages in ALL email accounts.
pub const DATA_MANAGE_ALL: &str = "data:manage:all";
/// Export messages in batches from all accounts.
pub const DATA_EXPORT_BATCH_ALL: &str = "data:export:batch:all";
// ----------------------------------------------------------------------
// 3. Scoped/Limited Permissions (Manager & Viewer)
// Authorization requires checking the user's Account Access List (ACL)
// ----------------------------------------------------------------------
/// Manage (modify/delete/sync) configuration for a specific set of accounts.
pub const ACCOUNT_MANAGE: &str = "account:manage";
/// Read details and sync status for a specific set of accounts.
pub const ACCOUNT_READ_DETAILS: &str = "account:read_details";
/// Manage mail data metadata (e.g., updating tags, adding notes)
/// for specific accounts.
pub const DATA_MANAGE: &str = "data:manage";
/// Read mail data (Search, view) from a specific set of accounts.
pub const DATA_READ: &str = "data:read";
/// Download raw EML/MIME files from a specific set of accounts.
pub const DATA_RAW_DOWNLOAD: &str = "data:raw:download";
/// Delete messages from a specific set of accounts.
pub const DATA_DELETE: &str = "data:delete";
/// Export messages in batches from a specific set of accounts.
pub const DATA_EXPORT_BATCH: &str = "data:export:batch";
/// Import EML/PST data into a SPECIFIC account.
/// Authorization requires checking access to the target account_id.
pub const DATA_IMPORT_BATCH: &str = "data:import:batch";
pub fn global_permissions() -> Vec<(&'static str, &'static str)> {
vec![
(
Self::SYSTEM_ACCESS,
"Basic platform access for dashboard and personal settings.",
),
(Self::ROOT, "Full system access and configuration."),
(Self::USER_MANAGE, "Create, update, and delete users."),
(
Self::USER_VIEW,
"Read-only access to user list and profiles.",
),
(Self::TOKEN_MANAGE, "View and revoke all active API tokens."),
(
Self::ACCOUNT_CREATE,
"Connect new email accounts to the system.",
),
(
Self::ACCOUNT_MANAGE_ALL,
"Manage configurations for all email accounts.",
),
(
Self::DATA_READ_ALL,
"Search and read messages across all accounts.",
),
(
Self::DATA_MANAGE_ALL,
"Manage metadata and tags for all accounts.",
),
(
Self::DATA_RAW_DOWNLOAD_ALL,
"Download raw EML data from any account.",
),
(
Self::DATA_DELETE_ALL,
"Permanently delete messages from any account.",
),
(
Self::DATA_EXPORT_BATCH_ALL,
"Export bulk message data from all accounts.",
),
]
}
pub fn account_permissions() -> Vec<(&'static str, &'static str)> {
vec![
(
Self::ACCOUNT_MANAGE,
"Update or sync settings for authorized accounts.",
),
(
Self::ACCOUNT_READ_DETAILS,
"View status and details of authorized accounts.",
),
(
Self::DATA_READ,
"Read messages from authorized email accounts.",
),
(
Self::DATA_MANAGE,
"Manage tags and metadata for authorized accounts.",
),
(
Self::DATA_RAW_DOWNLOAD,
"Download raw EML files from authorized accounts.",
),
(
Self::DATA_DELETE,
"Delete messages from authorized email accounts.",
),
(
Self::DATA_EXPORT_BATCH,
"Export messages from authorized accounts.",
),
(
Self::DATA_IMPORT_BATCH,
"Import external EML/PST data into authorized accounts.",
),
]
}
pub fn all_permissions() -> Vec<(&'static str, &'static str)> {
let mut all = Self::global_permissions();
all.extend(Self::account_permissions());
all
}
fn is_account_permission(perm: &str) -> bool {
Self::account_permissions().iter().any(|(p, _)| *p == perm)
}
fn is_global_permission(perm: &str) -> bool {
Self::global_permissions().iter().any(|(p, _)| *p == perm)
}
pub fn validate_role_permissions(
role_type: &RoleType,
permissions: &BTreeSet<String>,
) -> BichonResult<()> {
for p in permissions {
match role_type {
RoleType::Global => {
if !Self::is_global_permission(p) {
return Err(raise_error!(
format!("Permission '{}' is not a valid Global permission", p),
ErrorCode::InvalidParameter
));
}
}
RoleType::Account => {
if !Self::is_account_permission(p) {
return Err(raise_error!(
format!("Permission '{}' is not a valid Account permission", p),
ErrorCode::InvalidParameter
));
}
}
}
}
Ok(())
}
}
+359
View File
@@ -0,0 +1,359 @@
//
// 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 <http://www.gnu.org/licenses/>.
use std::{
collections::{BTreeSet, HashSet},
fmt::{self, Display},
};
use native_db::*;
use native_model::{native_model, Model};
use poem_openapi::{Enum, Object};
use serde::{Deserialize, Serialize};
use crate::{
id,
modules::{
database::{
async_find_impl, delete_impl, insert_impl, list_all_impl, manager::DB_MANAGER,
update_impl, with_transaction,
},
error::{code::ErrorCode, BichonResult},
users::{
payload::{RoleCreateRequest, RoleUpdateRequest},
permissions::*,
},
},
raise_error, utc_now,
};
/// Enumerates the built-in roles in the Bichon system.
#[derive(Debug, PartialEq, Eq, Hash)]
pub enum BuiltinRole {
Admin,
Manager,
Member,
AccountManager,
AccountViewer,
}
impl Display for BuiltinRole {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
BuiltinRole::Admin => "admin",
BuiltinRole::Manager => "manager",
BuiltinRole::Member => "member",
BuiltinRole::AccountManager => "account_manager",
BuiltinRole::AccountViewer => "account_viewer",
};
write!(f, "{}", s)
}
}
impl BuiltinRole {
pub fn description(&self) -> &'static str {
match self {
BuiltinRole::Admin => {
"Full system administrator with unrestricted access to all accounts, user management, and system configurations."
}
BuiltinRole::Manager => {
"Standard operational manager. Can manage users, create accounts, and perform data operations on authorized email accounts."
}
BuiltinRole::Member => {
"Regular platform member. Provides basic login access to the system without any administrative or global management privileges."
}
BuiltinRole::AccountManager => {
"Specific account manager. Has full administrative control over a particular email account, including configuration and data deletion."
}
BuiltinRole::AccountViewer => {
"Specific account observer. Has read-only access to messages and metadata for a particular email account."
}
}
}
/// Retrieves the set of static permissions associated with the role.
pub fn get_permissions(&self) -> HashSet<&'static str> {
match self {
BuiltinRole::Admin => Self::admin_permissions(),
BuiltinRole::Manager => Self::manager_permissions(),
BuiltinRole::Member => Self::member_permissions(),
BuiltinRole::AccountManager => Self::account_owner_permissions(),
BuiltinRole::AccountViewer => Self::account_viewer_permissions(),
}
}
/// Admin Role: Full control over the system and all data.
fn admin_permissions() -> HashSet<&'static str> {
[
// System-Wide
Permission::ROOT,
Permission::USER_MANAGE,
Permission::USER_VIEW,
Permission::TOKEN_MANAGE,
// Account Configuration
Permission::ACCOUNT_CREATE,
Permission::ACCOUNT_MANAGE_ALL, // Global account management
// Data Access (Global ALL)
Permission::DATA_READ_ALL,
Permission::DATA_MANAGE_ALL,
Permission::DATA_RAW_DOWNLOAD_ALL,
Permission::DATA_DELETE_ALL,
Permission::DATA_EXPORT_BATCH_ALL,
]
.into_iter()
.collect()
}
/// Manager Role: Data and account configuration management, limited user management.
/// ALL data/account access must be scoped by the user's ACL.
fn manager_permissions() -> HashSet<&'static str> {
[Permission::USER_VIEW, Permission::ACCOUNT_CREATE]
.into_iter()
.collect()
}
fn member_permissions() -> HashSet<&'static str> {
[Permission::SYSTEM_ACCESS].into_iter().collect()
}
fn account_owner_permissions() -> HashSet<&'static str> {
[
Permission::ACCOUNT_MANAGE,
Permission::ACCOUNT_READ_DETAILS,
Permission::DATA_READ,
Permission::DATA_MANAGE,
Permission::DATA_RAW_DOWNLOAD,
Permission::DATA_DELETE,
Permission::DATA_EXPORT_BATCH,
Permission::DATA_IMPORT_BATCH,
]
.into_iter()
.collect()
}
fn account_viewer_permissions() -> HashSet<&'static str> {
[Permission::ACCOUNT_READ_DETAILS, Permission::DATA_READ]
.into_iter()
.collect()
}
}
// Global Roles (Starting with 1)
pub const DEFAULT_ADMIN_ROLE_ID: u64 = 100_000_000_000_000; // System Admin
pub const DEFAULT_MANAGER_ROLE_ID: u64 = 100_100_000_000_000; // System Manager
pub const DEFAULT_MEMBER_ROLE_ID: u64 = 100_200_000_000_000; // Regular Member (system:access)
// Account-specific Roles (Starting with 2)
pub const DEFAULT_ACCOUNT_MANAGER_ROLE_ID: u64 = 200_100_000_000_000;
pub const DEFAULT_ACCOUNT_VIEWER_ROLE_ID: u64 = 200_200_000_000_000;
fn is_builtin(id: u64) -> bool {
matches!(
id,
DEFAULT_ADMIN_ROLE_ID
| DEFAULT_MANAGER_ROLE_ID
| DEFAULT_MEMBER_ROLE_ID
| DEFAULT_ACCOUNT_MANAGER_ROLE_ID
| DEFAULT_ACCOUNT_VIEWER_ROLE_ID
)
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Enum)]
pub enum RoleType {
#[default]
Global,
Account,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
#[native_model(id = 9, version = 1)]
#[native_db]
pub struct UserRole {
#[primary_key]
pub id: u64,
pub name: String,
pub description: Option<String>,
pub permissions: BTreeSet<String>,
pub is_builtin: bool,
pub created_at: i64,
pub role_type: RoleType,
pub updated_at: i64,
}
impl UserRole {
pub async fn ensure_default_roles_exists() -> BichonResult<()> {
let builtin_roles = vec![
(BuiltinRole::Admin, DEFAULT_ADMIN_ROLE_ID, RoleType::Global),
(
BuiltinRole::Manager,
DEFAULT_MANAGER_ROLE_ID,
RoleType::Global,
),
(
BuiltinRole::Member,
DEFAULT_MEMBER_ROLE_ID,
RoleType::Global,
),
(
BuiltinRole::AccountManager,
DEFAULT_ACCOUNT_MANAGER_ROLE_ID,
RoleType::Account,
),
(
BuiltinRole::AccountViewer,
DEFAULT_ACCOUNT_VIEWER_ROLE_ID,
RoleType::Account,
),
];
with_transaction(DB_MANAGER.meta_db(), move |rw| {
let now = utc_now!();
for (role, role_id, role_type) in builtin_roles {
let exists = rw
.get()
.primary::<UserRole>(role_id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.is_some();
if !exists {
let permissions: BTreeSet<String> = role
.get_permissions()
.into_iter()
.map(|s| s.to_string())
.collect();
rw.insert(UserRole {
id: role_id,
name: role.to_string(),
description: Some(role.description().to_string()),
permissions,
created_at: now,
updated_at: now,
is_builtin: true,
role_type,
})
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
}
}
Ok(())
})
.await?;
Ok(())
}
pub async fn list_all() -> BichonResult<Vec<UserRole>> {
list_all_impl(DB_MANAGER.meta_db()).await
}
pub async fn find(role_id: u64) -> BichonResult<Option<UserRole>> {
async_find_impl(DB_MANAGER.meta_db(), role_id).await
}
pub async fn create(request: RoleCreateRequest) -> BichonResult<UserRole> {
let _ = &request.validate().await?;
let now = utc_now!();
let new_role = UserRole {
id: id!(64),
name: request.name,
description: request.description,
permissions: request.permissions,
created_at: now,
updated_at: now,
is_builtin: false,
role_type: request.role_type,
};
insert_impl(DB_MANAGER.meta_db(), new_role.clone()).await?;
Ok(new_role)
}
pub async fn update(id: u64, request: RoleUpdateRequest) -> BichonResult<()> {
if is_builtin(id) && request.permissions.is_some() {
return Err(raise_error!(
"The permissions of a builtin role are immutable. Please create a custom role instead.".into(),
ErrorCode::Forbidden
));
}
let _ = &request.validate().await?;
if let Some(permissions) = &request.permissions {
let role = Self::find(id).await?.ok_or_else(|| {
raise_error!(
format!("UserRole with id={} not found", id),
ErrorCode::ResourceNotFound
)
})?;
Permission::validate_role_permissions(&role.role_type, permissions)?;
}
update_impl(
DB_MANAGER.meta_db(),
move |rw| {
rw.get()
.primary::<UserRole>(id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!("UserRole with id={} not found", id),
ErrorCode::ResourceNotFound
)
})
},
move |current| {
let mut updated = current.clone();
if let Some(name) = request.name {
updated.name = name;
}
if let Some(desc) = request.description {
updated.description = Some(desc);
}
if let Some(permissions) = request.permissions {
updated.permissions = permissions;
}
updated.updated_at = utc_now!();
Ok(updated)
},
)
.await?;
Ok(())
}
pub async fn delete(id: u64) -> BichonResult<()> {
if is_builtin(id) {
return Err(raise_error!(
format!("Cannot delete a default system role (ID: {}).", id),
ErrorCode::InvalidParameter
));
}
delete_impl(DB_MANAGER.meta_db(), move |rw| {
rw.get()
.primary::<UserRole>(id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!("UserRole '{}' not found during deletion process.", id),
ErrorCode::ResourceNotFound
)
})
})
.await
}
}
+52
View File
@@ -0,0 +1,52 @@
//
// 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 <http://www.gnu.org/licenses/>.
use std::collections::BTreeMap;
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use crate::modules::users::acl::AccessControl;
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
pub struct UserView {
pub id: u64,
pub username: String,
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 account_roles_summary: BTreeMap<u64, String>,
pub account_permissions: BTreeMap<u64, Vec<String>>,
pub description: Option<String>,
/// Global Roles: Permissions that apply to the whole system
/// (e.g., system settings, creating new users).
pub global_roles: Vec<u64>,
pub global_roles_names: Vec<String>,
pub global_permissions: Vec<String>,
pub avatar: Option<String>,
pub created_at: i64,
pub updated_at: i64,
/// Optional access control settings
pub acl: Option<AccessControl>,
}
+19 -3
View File
@@ -16,18 +16,34 @@
// 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 base64::{engine::general_purpose, Engine as _};
use ring::aead::{Aad, BoundKey, Nonce, NonceSequence, OpeningKey, SealingKey, AES_256_GCM};
use ring::pbkdf2::{self, derive};
use ring::rand::{SecureRandom, SystemRandom};
use std::fs;
use std::num::NonZeroU32;
use std::sync::LazyLock;
use crate::modules::error::code::ErrorCode;
use crate::modules::error::BichonResult;
use crate::modules::settings::cli::SETTINGS;
use crate::raise_error;
static ENCRYPT_PASSWORD: LazyLock<String> = LazyLock::new(|| {
if let Some(file_path) = &SETTINGS.bichon_encrypt_password_file {
return fs::read_to_string(file_path)
.expect("failed to read the file with the encrypt password")
.trim()
.to_string();
}
if let Some(p) = &SETTINGS.bichon_encrypt_password {
return p.clone();
}
panic!("Neither encrypt_password nor encrypt_password_file is set. This should have been validated by SETTINGS.");
});
struct SingleNonceSequence([u8; 12]);
impl SingleNonceSequence {
@@ -43,12 +59,12 @@ impl NonceSequence for SingleNonceSequence {
}
pub fn encrypt_string(plaintext: &str) -> BichonResult<String> {
internal_encrypt_string(&SETTINGS.bichon_encrypt_password, plaintext)
internal_encrypt_string(&ENCRYPT_PASSWORD, plaintext)
.map_err(|_| raise_error!("Failed to encrypt string.".into(), ErrorCode::InternalError))
}
pub fn decrypt_string(data: &str) -> BichonResult<String> {
internal_decrypt_string(&SETTINGS.bichon_encrypt_password, data).map_err(|_| {
internal_decrypt_string(&ENCRYPT_PASSWORD, data).map_err(|_| {
raise_error!(
"Decryption failed, likely due to incorrect encryption key or corrupted data".into(),
ErrorCode::InternalError
+25 -1
View File
@@ -16,9 +16,10 @@
// 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 std::{fs, io, path::PathBuf};
use crate::modules::error::BichonResult;
use base64::engine::general_purpose::STANDARD;
use base64::{engine::general_purpose, Engine};
use rand::{rng, Rng};
@@ -310,3 +311,26 @@ pub fn get_total_size(path: &PathBuf) -> io::Result<u64> {
Ok(total_size)
}
const MAX_AVATAR_BYTES: usize = 128 * 1024;
pub fn decode_avatar_bytes(base64_str: &str) -> BichonResult<Vec<u8>> {
let bytes = STANDARD.decode(base64_str).map_err(|e| {
raise_error!(
format!("Invalid avatar base64 encoding: {}", e),
ErrorCode::InvalidParameter
)
})?;
if bytes.len() > MAX_AVATAR_BYTES {
return Err(raise_error!(
format!(
"Avatar image exceeds maximum size ({} KB).",
MAX_AVATAR_BYTES / 1024
),
ErrorCode::InvalidParameter
));
}
Ok(bytes)
}
+11 -12
View File
@@ -16,7 +16,6 @@
// 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 dashmap::DashMap;
use governor::{
clock::{QuantaClock, QuantaInstant},
@@ -30,14 +29,14 @@ use std::{
time::Duration,
};
use crate::modules::token::RateLimit;
use crate::modules::users::acl::RateLimit;
pub static RATE_LIMITER_MANAGER: LazyLock<TokenRateLimiter> = LazyLock::new(TokenRateLimiter::new);
pub static RATE_LIMITER_MANAGER: LazyLock<UserRateLimiter> = LazyLock::new(UserRateLimiter::new);
pub struct TokenRateLimiter {
pub struct UserRateLimiter {
limiters: Arc<
DashMap<
String,
u64,
(
Arc<RateLimiter<NotKeyed, InMemoryState, QuantaClock, NoOpMiddleware>>,
RateLimit,
@@ -46,29 +45,29 @@ pub struct TokenRateLimiter {
>,
}
impl TokenRateLimiter {
impl UserRateLimiter {
pub fn new() -> Self {
TokenRateLimiter {
UserRateLimiter {
limiters: Arc::new(DashMap::new()),
}
}
pub async fn check(
&self,
token: &str,
user_id: u64,
limit: RateLimit,
) -> Result<(), NotUntil<QuantaInstant>> {
let limiter = self.get_or_update_limiter(token, limit).await;
let limiter = self.get_or_update_limiter(user_id, limit).await;
limiter.check()
}
async fn get_or_update_limiter(
&self,
token: &str,
user_id: u64,
limit: RateLimit,
) -> Arc<RateLimiter<NotKeyed, InMemoryState, QuantaClock, NoOpMiddleware>> {
self.limiters
.entry(token.to_string())
.entry(user_id)
.and_modify(|(existing_limiter, current_limit)| {
if current_limit.interval != limit.interval || current_limit.quota != limit.quota {
let quota = Quota::with_period(Duration::from_secs(limit.interval))
@@ -100,4 +99,4 @@ impl TokenRateLimiter {
.0
.clone()
}
}
}
+5 -2
View File
@@ -10,6 +10,7 @@
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
@@ -17,5 +18,7 @@
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
"registries": {
"@reui": "https://reui.io/r/{name}.json"
}
}
+1
View File
@@ -58,6 +58,7 @@
"i18next": "^25.6.3",
"js-cookie": "^3.0.5",
"lucide-react": "^0.468.0",
"radix-ui": "^1.4.3",
"react": "^18.3.1",
"react-ace": "^13.0.0",
"react-day-picker": "8.10.1",
+1750 -42
View File
File diff suppressed because it is too large Load Diff
-64
View File
@@ -1,64 +0,0 @@
//
// 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 <http://www.gnu.org/licenses/>.
import axiosInstance from "@/api/axiosInstance";
import { AccessToken } from "@/features/access-tokens/data/schema";
export const login = async (password: string) => {
const response = await axiosInstance.post(`/api/login`, password, {
headers: {
"Content-Type": "text/plain",
},
});
return response.data;
};
export const reset_root_token = async () => {
const response = await axiosInstance.post("/api/v1/reset-root-token");
return response.data;
};
export const reset_root_password = async (password: string) => {
const response = await axiosInstance.post("/api/v1/reset-root-password", password, {
headers: {
"Content-Type": "text/plain",
},
});
return response.data;
};
export const list_access_tokens = async () => {
const response = await axiosInstance.get<AccessToken[]>("/api/v1/access-token-list");
return response.data;
};
export const create_access_token = async (data: Record<string, any>) => {
const response = await axiosInstance.post("/api/v1/access-token", data);
return response.data;
}
export const update_access_token = async (token: string, data: Record<string, any>) => {
const response = await axiosInstance.post(`/api/v1/access-token/${token}`, data);
return response.data;
}
export const delete_access_token = async (token: string) => {
const response = await axiosInstance.delete(`/api/v1/access-token/${token}`);
return response.data;
}
+5
View File
@@ -103,3 +103,8 @@ export const autoconfig = async (email: string) => {
const response = await axiosInstance.get<AutoConfigResult>(`/api/v1/autoconfig/${email}`);
return response.data;
};
export const access_assign = async (data: Record<string, any>) => {
const response = await axiosInstance.post("/api/v1/accounts/access/assignments", data);
return response.data;
};
+4 -4
View File
@@ -17,7 +17,7 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { getAccessToken } from "@/stores/authStore";
import { getToken } from "@/stores/authStore";
import axios from "axios";
// Create an Axios instance
@@ -36,9 +36,9 @@ const axiosInstance = axios.create({
// Add a request interceptor to include the access token in headers
axiosInstance.interceptors.request.use(
(config) => {
const accessToken = getAccessToken(); // Retrieve access token from localStorage
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
const stored = getToken(); // Retrieve access token from localStorage
if (stored) {
config.headers.Authorization = `Bearer ${stored.accessToken}`;
}
return config;
},
+34 -1
View File
@@ -18,7 +18,6 @@
import axiosInstance from "@/api/axiosInstance";
import { Proxy } from "@/features/settings/proxy/data/schema";
export interface Release {
tag_name: string;
@@ -73,6 +72,34 @@ export interface LargestEmail {
size_bytes: number; // Email size in bytes
}
export interface Proxy {
id: number;
url: string;
created_at: number;
updated_at: number;
}
export type ServerConfigurations = {
bichon_log_level: string
bichon_http_port: number
bichon_bind_ip?: string | null
bichon_public_url: string
bichon_cors_origins?: string[] | null
bichon_cors_max_age: number
bichon_ansi_logs: boolean
bichon_log_to_file: boolean
bichon_json_logs: boolean
bichon_max_server_log_files: number
bichon_encrypt_password_set: boolean
bichon_webui_token_expiration_hours: number
bichon_root_dir: string
bichon_metadata_cache_size?: number | null
bichon_envelope_cache_size?: number | null
bichon_enable_rest_https: boolean
bichon_http_compression_enabled: boolean
bichon_sync_concurrency?: number | null
}
export const get_dashboard_stats = async () => {
const response = await axiosInstance.get<DashboardStats>(`/api/v1/dashboard-stats`);
return response.data;
@@ -104,4 +131,10 @@ export const add_proxy = async (url: string) => {
},
});
return response.data;
};
export const get_system_configurations = async () => {
const response = await axiosInstance.get<ServerConfigurations>(`/api/v1/system-configurations`);
return response.data;
};
+202
View File
@@ -0,0 +1,202 @@
import axiosInstance from "@/api/axiosInstance";
export type RoleType = 'Global' | 'Account';
export interface UserRole {
id: number;
name: string;
description?: string | null;
permissions: string[];
is_builtin: boolean;
role_type: RoleType;
created_at: number;
updated_at: number;
}
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' },
// 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' },
]
}
export interface RateLimit {
quota: number;
interval: number;
}
export interface AccessControl {
ip_whitelist?: string[];
rate_limit?: RateLimit;
}
export type TokenType = "WebUI" | "Api";
export interface AccessToken {
user_id: number;
user_name: string,
user_email: string,
token: string;
created_at: number;
updated_at: number;
name?: string;
last_access_at: number;
expire_at?: number | null;
token_type: TokenType;
}
export interface User {
id: number;
username: string;
email: string;
password?: string | null;
description?: string | null;
global_roles: number[];
global_roles_names: string[];
avatar?: string;
acl?: AccessControl;
account_access_map: Record<number, number>;
account_roles_summary: Record<number, string>;
global_permissions: string[]
account_permissions: Record<number, string[]>
created_at: number;
updated_at: number;
}
export interface LoginResult {
success: boolean;
error_message?: string | null;
access_token?: string | null;
}
export interface MinimalUser {
id: number;
username: string;
email: string;
}
export const login = async (data: Record<string, any>) => {
const response = await axiosInstance.post<LoginResult>(`/api/login`, data);
return response.data;
};
export const reset_admin_token = async () => {
const response = await axiosInstance.post("/api/v1/reset-admin-token");
return response.data;
};
export const reset_admin_password = async (password: string) => {
const response = await axiosInstance.post("/api/v1/reset-admin-password", password, {
headers: {
"Content-Type": "text/plain",
},
});
return response.data;
};
export const list_access_tokens = async () => {
const response = await axiosInstance.get<AccessToken[]>("/api/v1/access-token-list");
return response.data;
};
export const create_access_token = async (data: Record<string, any>) => {
const response = await axiosInstance.post("/api/v1/access-token", data);
return response.data;
}
export const update_access_token = async (token: string, data: Record<string, any>) => {
const response = await axiosInstance.post(`/api/v1/access-token/${token}`, data);
return response.data;
}
export const remove_access_token = async (token: string) => {
const response = await axiosInstance.delete(`/api/v1/access-token/${token}`);
return response.data;
}
export const list_roles = async () => {
const response = await axiosInstance.get<UserRole[]>("/api/v1/list-roles");
return response.data;
};
export const remove_role = async (id: number) => {
const response = await axiosInstance.delete(`/api/v1/roles/${id}`);
return response.data;
};
export const create_role = async (data: Record<string, any>) => {
const response = await axiosInstance.post("/api/v1/roles", data);
return response.data;
};
export const update_role = async (id: number, data: Record<string, any>) => {
const response = await axiosInstance.post(`/api/v1/roles/${id}`, data);
return response.data;
};
export const list_users = async () => {
const response = await axiosInstance.get<User[]>("/api/v1/list-users");
return response.data;
};
export const list_minimal_users = async () => {
const response = await axiosInstance.get<MinimalUser[]>("/api/v1/minimal-user-list");
return response.data;
};
export const remove_user = async (id: number) => {
const response = await axiosInstance.delete(`/api/v1/users/${id}`);
return response.data;
};
export const create_user = async (data: Record<string, any>) => {
const response = await axiosInstance.post("/api/v1/users", data);
return response.data;
};
export const update_user = async (id: number, data: Record<string, any>) => {
const response = await axiosInstance.post(`/api/v1/users/${id}`, data);
return response.data;
};
export const get_user_tokens = async (id: number) => {
const response = await axiosInstance.get<AccessToken[]>(`/api/v1/user-tokens/${id}`);
return response.data;
};
export const get_current_user = async () => {
const response = await axiosInstance.get<User>("/api/v1/current-user");
return response.data;
};
+3 -1
View File
@@ -22,10 +22,11 @@ import { FixedHeader } from "./layout/fixed-header";
import { Main } from "./layout/main";
import Logo from '@/assets/logo.svg'
import { useTranslation } from 'react-i18next'
import { Separator } from "./ui/separator";
export default function APIDocs() {
const { t } = useTranslation()
const docsOptions = [
{ name: t('apiDocs.swaggerUI'), path: "/api-docs/swagger" },
{ name: t('apiDocs.reDoc'), path: "/api-docs/redoc" },
@@ -51,6 +52,7 @@ export default function APIDocs() {
</p>
</div>
</div>
<Separator className='mt-2 mb-4 lg:mt-3 lg:mb-6' />
<div className='-mx-4 flex-1 overflow-auto px-4 py-1 flex-row lg:space-x-12 space-y-0'>
<div className='m-auto flex h-full w-full flex-col items-center justify-center gap-6 p-4'>
<div className="grid w-full gap-4 sm:grid-cols-1 md:grid-cols-2 xl:max-w-4xl">
+16 -7
View File
@@ -20,16 +20,18 @@
import {
IconHelp,
IconLayoutDashboard,
IconLockAccess,
IconSettings
} from '@tabler/icons-react'
import { IdCard, Inbox, Mailbox, Search } from 'lucide-react'
import { IdCard, Inbox, Mailbox, Search, Users2 } from 'lucide-react'
import { type SidebarData } from '../types'
import { useTranslation } from 'react-i18next'
import { useCurrentUser } from '@/hooks/use-current-user'
export function useSidebarData(): SidebarData {
const { t } = useTranslation()
const { require_any_permission } = useCurrentUser()
return {
navGroups: [
{
@@ -69,11 +71,18 @@ export function useSidebarData(): SidebarData {
title: t('navigation.oauth2'),
url: '/oauth2',
icon: IdCard,
},
visible: require_any_permission(['system:root', 'account:create']),
}
]
},
{
title: t('navigation.users'),
items: [
{
title: t('navigation.accessTokens'),
url: '/access-tokens',
icon: IconLockAccess,
title: t('navigation.users'),
url: '/users',
icon: Users2,
visible: require_any_permission(['system:root', 'user:manage']),
}
]
},
+20 -26
View File
@@ -1,22 +1,3 @@
//
// 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 <http://www.gnu.org/licenses/>.
import { ReactNode } from 'react'
import { Link, useLocation } from '@tanstack/react-router'
import { ChevronRight } from 'lucide-react'
@@ -50,11 +31,16 @@ import { NavCollapsible, NavItem, NavLink, type NavGroup } from './types'
export function NavGroup({ title, items }: NavGroup) {
const { state } = useSidebar()
const href = useLocation({ select: (location) => location.href })
const visibleItems = items.filter(item => item.visible !== false)
if (visibleItems.length === 0) return null
return (
<SidebarGroup>
<SidebarGroupLabel>{title}</SidebarGroupLabel>
<SidebarMenu>
{items.map((item) => {
{visibleItems.map((item) => {
const key = `${item.title}-${item.url}`
if (!item.items)
@@ -103,6 +89,10 @@ const SidebarMenuCollapsible = ({
href: string
}) => {
const { setOpenMobile } = useSidebar()
const visibleSubItems = item.items.filter(sub => sub.visible !== false)
if (visibleSubItems.length === 0) return null
return (
<Collapsible
asChild
@@ -120,7 +110,7 @@ const SidebarMenuCollapsible = ({
</CollapsibleTrigger>
<CollapsibleContent className='CollapsibleContent'>
<SidebarMenuSub>
{item.items.map((subItem) => (
{visibleSubItems.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton
asChild
@@ -148,6 +138,10 @@ const SidebarMenuCollapsedDropdown = ({
item: NavCollapsible
href: string
}) => {
const visibleSubItems = item.items.filter(sub => sub.visible !== false)
if (visibleSubItems.length === 0) return null
return (
<SidebarMenuItem>
<DropdownMenu>
@@ -167,7 +161,7 @@ const SidebarMenuCollapsedDropdown = ({
{item.title} {item.badge ? `(${item.badge})` : ''}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{item.items.map((sub) => (
{visibleSubItems.map((sub) => (
<DropdownMenuItem key={`${sub.title}-${sub.url}`} asChild>
<Link
to={sub.url}
@@ -189,11 +183,11 @@ const SidebarMenuCollapsedDropdown = ({
function checkIsActive(href: string, item: NavItem, mainNav = false) {
return (
href === item.url || // /endpint?search=param
href.split('?')[0] === item.url || // endpoint
!!item?.items?.filter((i) => i.url === href).length || // if child nav is active
href === item.url ||
href.split('?')[0] === item.url ||
!!item?.items?.filter((i) => i.url === href).length ||
(mainNav &&
href.split('/')[1] !== '' &&
href.split('/')[1] === item?.url?.split('/')[1])
)
}
}
+2 -1
View File
@@ -23,6 +23,7 @@ interface BaseNavItem {
title: string
badge?: string
icon?: React.ElementType
visible?: boolean
}
type NavLink = BaseNavItem & {
@@ -31,7 +32,7 @@ type NavLink = BaseNavItem & {
}
type NavCollapsible = BaseNavItem & {
items: (BaseNavItem & { url: LinkProps['to'] })[]
items: (BaseNavItem & { url: LinkProps['to']; visible?: boolean })[]
url?: never
}
+49 -22
View File
@@ -22,48 +22,75 @@ import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuShortcut,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { LogoutConfirmDialog } from '@/features/auth/sign-in/components/logout';
import { resetAccessToken } from '@/stores/authStore';
import { useNavigate } from '@tanstack/react-router';
import { useState } from 'react';
import { useCurrentUser } from '@/hooks/use-current-user';
import useDialogState from '@/hooks/use-dialog-state';
import { useMemo } from 'react';
import { SignOutDialog } from './sign-out-dialog';
import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
export function ProfileDropdown() {
const navigate = useNavigate()
const [open, setOpen] = useDialogState()
const { t } = useTranslation()
const [isLogoutDialogOpen, setIsLogoutDialogOpen] = useState(false)
const handleLogout = () => {
resetAccessToken()
navigate({ to: '/sign-in' })
}
const { data: user } = useCurrentUser()
const avatarSrc = useMemo(() => {
const base64 = user?.avatar;
if (!base64 || base64.length === 0) return null;
return `data:image/png;base64,${base64}`;
}, [user]);
const fallbackName = user?.username ? user.username.charAt(0).toUpperCase() : 'U';
return (
<>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant='ghost' className='relative h-8 w-8 rounded-full'>
<Avatar className='h-8 w-8'>
<AvatarFallback className='text-xs'>root</AvatarFallback>
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
<Avatar className="h-8 w-8">
{avatarSrc ? (
<img src={avatarSrc} alt={t('profile.avatar_alt')} className="h-full w-full object-cover" />
) : (
<AvatarFallback className="text-xs">{fallbackName}</AvatarFallback>
)}
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className='w-56' align='end' forceMount>
<DropdownMenuItem onClick={() => setIsLogoutDialogOpen(true)}>
{t('auth.logout')}
<DropdownMenuShortcut>Q</DropdownMenuShortcut>
<DropdownMenuLabel className='font-normal'>
<div className='flex flex-col gap-1.5'>
<p className='text-sm leading-none font-medium'>{user?.username}</p>
<p className='text-muted-foreground text-xs leading-none'>
{user?.email}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem asChild>
<Link to='/settings/profile'>{t('profile.menu.profile')}</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to='/settings'>{t('profile.menu.settings')}</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setOpen(true)}>
{t('profile.menu.sign_out')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<LogoutConfirmDialog
open={isLogoutDialogOpen}
onOpenChange={setIsLogoutDialogOpen}
handleConfirm={handleLogout}
/>
<SignOutDialog open={!!open} onOpenChange={setOpen} />
</>
)
}
+36
View File
@@ -0,0 +1,36 @@
import { useNavigate, useLocation } from '@tanstack/react-router'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { resetToken } from '@/stores/authStore'
interface SignOutDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
export function SignOutDialog({ open, onOpenChange }: SignOutDialogProps) {
const navigate = useNavigate()
const location = useLocation()
const handleSignOut = () => {
resetToken()
const currentPath = location.href
navigate({
to: '/sign-in',
search: { redirect: currentPath },
replace: true,
})
}
return (
<ConfirmDialog
open={open}
onOpenChange={onOpenChange}
title='Sign out'
desc='Are you sure you want to sign out? You will need to sign in again to access your account.'
confirmText='Sign out'
destructive
handleConfirm={handleSignOut}
className='sm:max-w-sm'
/>
)
}
+1 -1
View File
@@ -55,4 +55,4 @@ const AlertDescription = React.forwardRef<
))
AlertDescription.displayName = 'AlertDescription'
export { Alert, AlertTitle, AlertDescription }
export { Alert, AlertTitle, AlertDescription }
+1 -2
View File
@@ -273,8 +273,7 @@ export function VirtualizedSelect({
.filter(Boolean);
if (selectedLabels.length === 0) return placeholder;
if (selectedLabels.length <= 3) return selectedLabels.join(', ');
return `${selectedLabels[0]}, ${selectedLabels[1]} +${selectedLabels.length - 2} more`;
return `${selectedLabels[0]} +${selectedLabels.length - 1} more`;
};
return (
@@ -1,50 +0,0 @@
//
// 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 <http://www.gnu.org/licenses/>.
import { ColumnDef } from '@tanstack/react-table'
import LongText from '@/components/long-text'
import { AccountInfo } from '../data/schema'
import { DataTableColumnHeader } from './data-table-column-header'
export const getColumns = (t: (key: string) => string): ColumnDef<AccountInfo>[] => [
{
accessorKey: 'id',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('accessTokens.accountId')} />
),
cell: ({ row }) => (
<LongText className='max-w-80'>{row.original.id}</LongText>
),
meta: { className: 'w-80' },
enableHiding: false,
enableSorting: false
},
{
accessorKey: 'email',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('accounts.email')} />
),
cell: ({ row }) => (
<LongText className='max-w-80'>{row.getValue('email')}</LongText>
),
meta: { className: 'w-80' },
enableHiding: true,
enableSorting: false
},
]
@@ -1,69 +0,0 @@
//
// 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 <http://www.gnu.org/licenses/>.
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { AccessToken } from '../data/schema'
import { Button } from '@/components/ui/button'
import { AccountsDetailTable } from './accounts-detail-table'
import { getColumns } from './accounts-detail-columns'
import { useTranslation } from 'react-i18next'
interface Props {
currentRow: AccessToken
open: boolean
onOpenChange: (open: boolean) => void
}
export function AccountDetailDialog({ currentRow, open, onOpenChange }: Props) {
const { t } = useTranslation()
const columns = getColumns(t)
return (
<Dialog
open={open}
onOpenChange={(state) => {
onOpenChange(state)
}}
>
<DialogContent className='sm:max-w-4xl'>
<DialogHeader className='text-left'>
<DialogTitle>{t('settings.accounts')}</DialogTitle>
<DialogDescription>
{t('accessTokens.theListOfAccountsThatCanBeQueried')}
</DialogDescription>
</DialogHeader>
<div className="h-[33rem] overflow-x-auto overflow-y-auto">
<AccountsDetailTable data={currentRow.accounts} columns={columns} />
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant='outline' className="px-2 py-1 text-sm h-auto">{t('common.close')}</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -1,99 +0,0 @@
//
// 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 <http://www.gnu.org/licenses/>.
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { AccessToken } from '../data/schema'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { useTranslation } from 'react-i18next'
interface Props {
currentRow: AccessToken
open: boolean
onOpenChange: (open: boolean) => void
}
export function AclDetailDialog({ currentRow, open, onOpenChange }: Props) {
const { t } = useTranslation()
return (
<Dialog
open={open}
onOpenChange={(state) => {
onOpenChange(state)
}}
>
<DialogContent className='sm:max-w-xl'>
<DialogHeader className='text-left'>
<DialogTitle>{t('settings.acl')}</DialogTitle>
<DialogDescription>
{t('accessTokens.aclRulesForAccessTokens')}
</DialogDescription>
</DialogHeader>
<ScrollArea className='h-[33rem] w-full pr-4 -mr-4 py-1'>
<div className="space-y-4">
{/* IP Whitelist */}
<div className="grid w-full items-center">
<Label className="mb-2">IP Whitelist</Label>
<Textarea
className="col-span-5 max-h-[240px] min-h-[300px]"
value={currentRow.acl?.ip_whitelist?.join('\n')}
/>
</div>
{/* Quota */}
<div className="grid w-full items-center">
<Label className="mb-2">Quota</Label>
<Input
type="number"
value={currentRow.acl?.rate_limit?.quota}
className="col-span-5"
/>
</div>
{/* Interval (seconds) */}
<div className="grid w-full items-center">
<Label className="mb-2">Interval (seconds)</Label>
<Input
type="number"
className="col-span-5"
value={currentRow.acl?.rate_limit?.interval}
/>
</div>
</div>
</ScrollArea>
<DialogFooter>
<DialogClose asChild>
<Button variant='outline' className="px-2 py-1 text-sm h-auto">Close</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -1,404 +0,0 @@
//
// 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 <http://www.gnu.org/licenses/>.
import { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { toast } from '@/hooks/use-toast'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
// import { MultiSelect } from '@/components/multi-select'
import { Textarea } from '@/components/ui/textarea'
import { AccessToken } from '../data/schema'
import useMinimalAccountList from '@/hooks/use-minimal-account-list'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { create_access_token, update_access_token } from '@/api/access-tokens/api'
import { ToastAction } from '@/components/ui/toast'
import { AxiosError } from 'axios'
import { VirtualizedSelect } from '@/components/virtualized-select'
import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
const isValidIP = (ip: string) => {
const ipv4Regex = /^(?:(?:\d{1,3}\.){3}\d{1,3})$/;
const ipv6Regex = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/;
return ipv4Regex.test(ip) || ipv6Regex.test(ip);
};
const RateLimitBaseSchema = z.object({
quota: z.optional(z.number()),
interval: z.optional(z.number()),
});
const AccessControlBaseSchema = z.object({
ip_whitelist: z.string().optional(),
rate_limit: z.optional(RateLimitBaseSchema),
});
const AccessTokenBaseSchema = z.object({
accounts: z.array(z.number()),
description: z.optional(z.string()),
acl: z.optional(AccessControlBaseSchema),
});
export type AccessTokenForm = z.infer<typeof AccessTokenBaseSchema>;
const getRateLimitSchema = (t: (key: string) => string) => z.object({
quota: z.optional(z.number().int().positive({ message: t('accessTokens.quotaMustBeAPositiveInteger') })),
interval: z.optional(z.number().int().positive({ message: t('accessTokens.intervalMustBeAPositiveInteger') })),
});
const getAccessControlSchema = (t: (key: string) => string) => AccessControlBaseSchema.extend({
rate_limit: getRateLimitSchema(t).optional(),
}).transform((data) => {
if (data.ip_whitelist) {
const ips = data.ip_whitelist
.split('\n')
.map((ip) => ip.trim())
.filter((ip) => ip !== '');
return {
...data,
ip_whitelist: ips.join('\n'),
};
}
return data;
}).refine(
(data) => {
if (data.ip_whitelist) {
const ips = data.ip_whitelist.split('\n');
const invalidIPs = ips.filter((ip) => !isValidIP(ip));
return invalidIPs.length === 0;
}
return true;
},
{
message: t('accessTokens.invalidIpAddressesFound'),
path: ['ip_whitelist'],
}
).transform((data) => {
if (data.rate_limit && !data.rate_limit.interval && !data.rate_limit.quota) {
return {
...data,
rate_limit: undefined,
};
}
return data;
})
.transform((data) => {
if (!data.ip_whitelist && !data.rate_limit) {
return undefined;
}
return data;
});
const getAccessTokenFormSchema = (t: (key: string) => string) => AccessTokenBaseSchema.extend({
accounts: z
.array(z.number())
.min(1, { message: t('accessTokens.atLeastOneAccountIsRequired') }),
description: z
.optional(z.string().max(255, { message: t('accessTokens.descriptionMustNotExceed255Characters') })),
acl: z.optional(getAccessControlSchema(t)),
});
interface Props {
currentRow?: AccessToken
open: boolean
onOpenChange: (open: boolean) => void
}
const defaultValues = {
accounts: [],
description: undefined,
access_scopes: [],
acl: undefined,
};
export function TokensActionDialog({ currentRow, open, onOpenChange }: Props) {
const { t } = useTranslation()
const isEdit = !!currentRow
const queryClient = useQueryClient();
const form = useForm<AccessTokenForm>({
resolver: zodResolver(getAccessTokenFormSchema(t)),
defaultValues: isEdit
? {
accounts: currentRow.accounts.map(value => value.id),
description: currentRow.description ?? undefined,
acl: currentRow.acl
? {
ip_whitelist: currentRow.acl.ip_whitelist
? currentRow.acl.ip_whitelist.join('\n')
: undefined,
rate_limit: currentRow.acl.rate_limit ? currentRow.acl.rate_limit : undefined
}
: undefined,
}
: defaultValues,
});
const createMutation = useMutation({
mutationFn: create_access_token,
onSuccess: handleSuccess,
onError: handleError
});
const updateMutation = useMutation({
mutationFn: (data: Record<string, any>) => update_access_token(currentRow?.token ?? '', data),
onSuccess: handleSuccess,
onError: handleError
})
function handleSuccess() {
toast({
title: `${t('accessTokens.title')} ${isEdit ? t('accessTokens.updated') : t('accessTokens.created')}`,
description: t('accessTokens.yourAccessTokenHasBeenSuccessfully', { action: isEdit ? t('accessTokens.updated').toLowerCase() : t('accessTokens.created').toLowerCase() }),
action: <ToastAction altText={t('common.close')}>{t('common.close')}</ToastAction>,
});
queryClient.invalidateQueries({ queryKey: ['access-tokens'] });
form.reset();
onOpenChange(false);
}
function handleError(error: AxiosError) {
const errorMessage = (error.response?.data as { message?: string })?.message ||
error.message ||
t('accessTokens.updateOrCreationFailed', { action: isEdit ? t('accessTokens.updateFailed') : t('accessTokens.creationFailed') });
toast({
variant: "destructive",
title: `${t('accessTokens.title')} ${isEdit ? t('accessTokens.updateFailed') : t('accessTokens.creationFailed')}`,
description: errorMessage as string,
action: <ToastAction altText={t('common.tryAgain')}>{t('common.tryAgain')}</ToastAction>,
});
console.error(error);
}
const { accountsOptions, isLoading } = useMinimalAccountList();
const onSubmit = (values: AccessTokenForm) => {
const payload = {
accounts: values.accounts,
description: values.description,
acl: values.acl
? {
...values.acl,
ip_whitelist: values.acl.ip_whitelist
? (() => {
const ipSet = new Set(
values.acl.ip_whitelist
.split('\n')
.map(ip => ip.trim())
.filter(ip => ip !== ''),
);
return ipSet.size > 0 ? Array.from(ipSet) : undefined;
})()
: undefined,
}
: undefined,
};
if (isEdit) {
updateMutation.mutate(payload);
} else {
createMutation.mutate(payload);
}
}
return (
<Dialog
open={open}
onOpenChange={(state) => {
form.reset()
onOpenChange(state)
}}
>
<DialogContent className='max-w-4xl'>
<DialogHeader className='text-left mb-4'>
<DialogTitle>{isEdit ? t('accessTokens.editToken') : t('accessTokens.addNew')}</DialogTitle>
<DialogDescription>
{isEdit ? t('accessTokens.updateTheAccessTokenHere') : t('accessTokens.createNewAccessTokenHere')}
{t('accounts.clickSaveWhenDone')}
</DialogDescription>
</DialogHeader>
<ScrollArea className='h-[28rem] w-full pr-4 -mr-4 py-1'>
<Form {...form}>
<form
id='token-form'
onSubmit={form.handleSubmit(onSubmit)}
className='space-y-4 p-0.5'
>
<FormField
control={form.control}
name='accounts'
render={({ field }) => (
<FormItem className='flex flex-col gap-y-1 space-y-0'>
<FormLabel className='mb-1'>{t('accessTokens.accounts')}:</FormLabel>
<FormControl>
<VirtualizedSelect
multiple
options={accountsOptions}
className='w-full'
isLoading={isLoading}
onSelectOption={(options) => {
const numberArray = options.map((v) => parseInt(v, 10));
return field.onChange(numberArray);
}}
value={field.value.map(String)}
placeholder={t('accessTokens.selectAccounts')}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t('accessTokens.selectMultipleAccountsForTheAccessToken')}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="acl.ip_whitelist"
render={({ field }) => (
<FormItem className="flex flex-col gap-y-1 space-y-0">
<FormLabel className='mb-1'>{t('settings.acl')}:</FormLabel>
<FormControl>
<Textarea
placeholder={t('accessTokens.enterOneIpAddressPerLine')}
{...field}
className="max-h-[500px] min-h-[180px]"
/>
</FormControl>
<FormDescription>
{t('accessTokens.aListOfIpAddressesAllowed')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-4">
<FormField
control={form.control}
name="acl.rate_limit.quota"
render={({ field }) => (
<FormItem className="flex flex-col gap-y-1 space-y-0 w-1/2">
<FormLabel className='mb-1'>{t('accessTokens.quota')}:</FormLabel>
<FormControl>
<Input
type="number"
placeholder={t('accessTokens.enterQuota')}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10))}
/>
</FormControl>
<FormDescription>
{t('accessTokens.theMaximumNumberOfRequestsAllowed')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="acl.rate_limit.interval"
render={({ field }) => (
<FormItem className="flex flex-col gap-y-1 space-y-0 w-1/2">
<FormLabel className='mb-1'>{t('accessTokens.interval')}:</FormLabel>
<FormControl>
<Input
type="number"
placeholder={t('accessTokens.enterIntervalInSeconds')}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10))}
/>
</FormControl>
<FormDescription>
{t('accessTokens.theTimeWindowForTheRateLimit')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name='description'
render={({ field }) => (
<FormItem className='flex flex-col gap-y-1 space-y-0'>
<FormLabel className='mb-1'>{t('settings.description')}:</FormLabel>
<FormControl>
<Textarea
placeholder={t('accessTokens.describeThePurposeOfTheAccessToken')}
{...field}
className="max-h-[240px] min-h-[80px]"
/>
</FormControl>
<FormDescription>{t('oauth2.optional')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<DialogFooter>
<Button
type="submit"
form="token-form"
disabled={isEdit ? updateMutation.isPending : createMutation.isPending}
className="min-w-[100px] relative transition-all"
>
<span className="inline-flex items-center justify-center gap-2">
{(isEdit ? updateMutation.isPending : createMutation.isPending) && (
<Loader2 className="h-4 w-4 animate-spin" />
)}
<span>
{isEdit
? updateMutation.isPending
? t('accessTokens.updating')
: t('accessTokens.saveChanges')
: createMutation.isPending
? t('accessTokens.creating')
: t('accessTokens.save')}
</span>
</span>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -1,127 +0,0 @@
//
// 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 <http://www.gnu.org/licenses/>.
import { ColumnDef } from '@tanstack/react-table'
import LongText from '@/components/long-text'
import { AccessToken } from '../data/schema'
import { DataTableColumnHeader } from './data-table-column-header'
import { DataTableRowActions } from './data-table-row-actions'
import { format, formatDistanceToNow, Locale } from 'date-fns'
import { AccountCellAction } from './account-action'
import { AclCellAction } from './acl-action'
export const getColumns = (t: (key: string) => string, locale: Locale): ColumnDef<AccessToken>[] => [
{
accessorKey: 'token',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('settings.token')} />
),
cell: ({ row }) => {
return <LongText className='w-40'>{row.original.token}</LongText>
},
meta: { className: 'w-40' },
enableHiding: false,
enableSorting: false,
},
{
accessorKey: 'accounts',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('settings.accounts')} />
),
cell: AccountCellAction,
meta: { className: 'w-10 text-center' },
filterFn: (row, columnId, filterValue) => {
const accounts = row.getValue(columnId) as { account_id: number; email: string }[];
if (!filterValue) return true;
return accounts.some(
(account) =>
`${account.account_id}`.includes(filterValue) ||
account.email.includes(filterValue)
);
},
},
{
id: 'acl',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('settings.acl')} />
),
cell: AclCellAction,
meta: { className: 'w-8 text-center' },
enableSorting: false
},
{
accessorKey: 'description',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('settings.description')} />
),
cell: ({ row }) => (
<LongText className='max-w-80'>{row.original.description}</LongText>
),
meta: { className: 'w-80' },
enableHiding: true,
enableSorting: false
},
{
accessorKey: 'created_at',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('settings.createdAt')} />
),
cell: ({ row }) => {
const created_at = row.original.created_at;
const date = format(new Date(created_at), 'yyyy-MM-dd HH:mm:ss');
return <LongText className='max-w-36'>{date}</LongText>;
},
meta: { className: 'w-36' },
enableHiding: false,
},
{
accessorKey: 'updated_at',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('settings.updatedAt')} />
),
cell: ({ row }) => {
const updated_at = row.original.updated_at;
const date = format(new Date(updated_at), 'yyyy-MM-dd HH:mm:ss');
return <LongText className='max-w-36'>{date}</LongText>;
},
meta: { className: 'w-36' },
enableHiding: false,
},
{
accessorKey: 'last_access_at',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('settings.lastAccess')} />
),
cell: ({ row }) => {
const last_access_at = row.original.last_access_at;
if (last_access_at === 0) {
return <LongText className='max-w-40'>{t('accessTokens.notUsedYet')}</LongText>;
}
const result = formatDistanceToNow(new Date(last_access_at), { addSuffix: true, locale });
return <LongText className='max-w-40'>{result}</LongText>;
},
meta: { className: 'w-40' },
enableHiding: false,
},
{
id: 'actions',
cell: DataTableRowActions,
},
]
@@ -1,103 +0,0 @@
//
// 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 <http://www.gnu.org/licenses/>.
import {
ChevronLeftIcon,
ChevronRightIcon,
} from '@radix-ui/react-icons'
import { Table } from '@tanstack/react-table'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useTranslation } from 'react-i18next'
interface DataTablePaginationProps<TData> {
table: Table<TData>
showSelected: boolean,
showPageSizeSelector: boolean
}
export function DataTablePagination<TData>({
table,
showSelected = true,
showPageSizeSelector = true
}: DataTablePaginationProps<TData>) {
const { t } = useTranslation();
return (
<div className='flex items-center justify-between overflow-auto px-2'>
{showSelected && <div className='hidden flex-1 text-sm text-muted-foreground sm:block'>
{table.getFilteredRowModel().rows.length} {t("table.results")}
</div>}
{!showPageSizeSelector && <div className='hidden flex-1 text-sm text-muted-foreground sm:block'>
10 {t("table.rowsPerPage")}.
</div>}
<div className='flex items-center sm:space-x-6 lg:space-x-8 ml-auto'>
{showPageSizeSelector && <div className='flex items-center space-x-2'>
<p className='hidden text-sm font-medium sm:block'>{t("table.rowsPerPage")}</p>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value))
}}
>
<SelectTrigger className='h-8 w-[70px]'>
<SelectValue placeholder={table.getState().pagination.pageSize} />
</SelectTrigger>
<SelectContent side='top'>
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>}
<div className='flex w-[100px] items-center justify-center text-sm font-medium'>
{t("table.page")} {table.getState().pagination.pageIndex + 1}{" "}
{t("table.of")} {table.getPageCount()}
</div>
<div className='flex items-center space-x-2'>
<Button
variant='outline'
className='h-8 w-8 p-0'
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className='sr-only'>{t("table.prevPage")}</span>
<ChevronLeftIcon className='h-4 w-4' />
</Button>
<Button
variant='outline'
className='h-8 w-8 p-0'
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className='sr-only'>{t("table.nextPage")}</span>
<ChevronRightIcon className='h-4 w-4' />
</Button>
</div>
</div>
</div>
)
}
-160
View File
@@ -1,160 +0,0 @@
//
// 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 <http://www.gnu.org/licenses/>.
import { useState } from 'react'
import useDialogState from '@/hooks/use-dialog-state'
import { Button } from '@/components/ui/button'
import { Main } from '@/components/layout/main'
import { TokensActionDialog } from './components/action-dialog'
import { getColumns } from './components/columns'
import { TokenDeleteDialog } from './components/delete-dialog'
import { AccessTokensTable } from './components/access-token-table'
import AccessTokensProvider, {
type AccessTokensDialogType,
} from './context'
import Logo from '@/assets/logo.svg'
import { Plus } from 'lucide-react'
import { AccessToken } from './data/schema'
import { AccountDetailDialog } from './components/accounts-detail-dialog'
import { AclDetailDialog } from './components/acl-detail-dialog'
import { useQuery } from '@tanstack/react-query'
import { list_access_tokens } from '@/api/access-tokens/api'
import { TableSkeleton } from '@/components/table-skeleton'
import { FixedHeader } from '@/components/layout/fixed-header'
import { useTranslation } from 'react-i18next'
import { dateFnsLocaleMap } from '@/lib/utils'
import { enUS } from 'date-fns/locale'
export default function AccessTokens() {
const { t, i18n } = useTranslation()
const locale = dateFnsLocaleMap[i18n.language.toLowerCase()] ?? enUS;
// Dialog states
const [currentRow, setCurrentRow] = useState<AccessToken | null>(null)
const [open, setOpen] = useDialogState<AccessTokensDialogType>(null)
const { data: accessTokens, isLoading } = useQuery({
queryKey: ['access-tokens'],
queryFn: list_access_tokens,
})
const columns = getColumns(t, locale)
return (
<AccessTokensProvider value={{ open, setOpen, currentRow, setCurrentRow }}>
{/* ===== Top Heading ===== */}
<FixedHeader />
<Main>
<div className="mx-auto mb-2 flex max-w-5xl flex-wrap items-center justify-between gap-x-4 gap-y-2 px-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">{t('accessTokens.title')}</h2>
<p className="text-muted-foreground">
{t('accessTokens.description')}
</p>
</div>
<div className="flex gap-2">
<Button className="space-x-1" onClick={() => setOpen('add')}>
<span>{t('common.add')}</span> <Plus size={18} />
</Button>
</div>
</div>
<div className="mx-auto flex-1 overflow-auto px-4 py-1 flex-row lg:space-x-12 space-y-0 max-w-5xl">
{isLoading ? (
<TableSkeleton columns={columns.length} rows={10} />
) : accessTokens?.length ? (
<AccessTokensTable data={accessTokens} columns={columns} />
) : (
<div className="flex h-[450px] shrink-0 items-center justify-center rounded-md border border-dashed">
<div className="mx-auto flex max-w-[420px] flex-col items-center justify-center text-center">
<img
src={Logo}
className="max-h-[100px] w-auto opacity-20 saturate-0 transition-all duration-300 hover:opacity-100 hover:saturate-100 object-contain"
alt="Bichon Logo"
/>
<h3 className="mt-4 text-lg font-semibold">{t('accessTokens.noTokens')}</h3>
<p className="mb-4 mt-2 text-sm text-muted-foreground">
{t('accessTokens.noTokensDesc')}
</p>
<Button onClick={() => setOpen('add')}>{t('accessTokens.create')}</Button>
</div>
</div>
)}
</div>
</Main>
<TokensActionDialog
key='token-add'
open={open === 'add'}
onOpenChange={() => setOpen('add')}
/>
{currentRow && (
<>
<TokensActionDialog
key={`token-edit-${currentRow.token}`}
open={open === 'edit'}
onOpenChange={() => {
setOpen('edit')
setTimeout(() => {
setCurrentRow(null)
}, 500)
}}
currentRow={currentRow}
/>
<TokenDeleteDialog
key={`token-delete-${currentRow.token}`}
open={open === 'delete'}
onOpenChange={() => {
setOpen('delete')
setTimeout(() => {
setCurrentRow(null)
}, 500)
}}
currentRow={currentRow}
/>
<AccountDetailDialog
key={`accounts-detail-${currentRow.token}`}
currentRow={currentRow}
open={open === 'account-detail'}
onOpenChange={() => {
setOpen('account-detail')
setTimeout(() => {
setCurrentRow(null)
}, 500)
}} />
<AclDetailDialog
key={`acl-detail-${currentRow.token}`}
currentRow={currentRow}
open={open === 'acl-detail'}
onOpenChange={() => {
setOpen('acl-detail')
setTimeout(() => {
setCurrentRow(null)
}, 500)
}} />
</>
)}
</AccessTokensProvider>
)
}
@@ -0,0 +1,277 @@
//
// 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 <http://www.gnu.org/licenses/>.
import React from 'react'
import { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Loader2, ShieldCheck, Users, Search } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Checkbox } from '@/components/ui/checkbox'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Input } from '@/components/ui/input'
import { useToast } from '@/hooks/use-toast'
import { AccountModel } from '../data/schema'
import { useRoles } from '@/hooks/use-roles'
import { useMinimalUsers } from '@/hooks/use-minimal-users'
import { access_assign } from '@/api/account/api'
interface Props {
currentRow: AccountModel
open: boolean
onOpenChange: (open: boolean) => void
}
export function AccountAccessAssignmentDialog({
currentRow,
open,
onOpenChange,
}: Props) {
const { t } = useTranslation()
const { toast } = useToast()
const queryClient = useQueryClient()
const { accountRoles, isLoading: isLoadingRoles } = useRoles()
const { users, isLoading: isLoadingUsers } = useMinimalUsers()
const [keyword, setKeyword] = React.useState('')
// 1. 定义校验 Schema (集成国际化错误提示)
const assignmentSchema = z.object({
account_ids: z.array(z.number()),
user_ids: z.array(z.number()).min(1, {
message: t('accounts.access_control.validation.user_required'),
}),
role_id: z.number({
required_error: t('accounts.access_control.validation.role_required'),
}),
})
type AssignmentFormValues = z.infer<typeof assignmentSchema>
const form = useForm<AssignmentFormValues>({
resolver: zodResolver(assignmentSchema),
defaultValues: {
account_ids: [currentRow.id],
user_ids: [],
role_id: undefined as any,
},
})
const filteredUsers = React.useMemo(() => {
if (!keyword.trim()) return users
const lowerKeyword = keyword.toLowerCase()
return users.filter(
(user) =>
user.username.toLowerCase().includes(lowerKeyword) ||
user.email.toLowerCase().includes(lowerKeyword)
)
}, [users, keyword])
const { mutate, isPending } = useMutation({
mutationFn: access_assign,
onSuccess: () => {
toast({
title: t('accounts.access_control.toast.success_title'),
description: t('accounts.access_control.toast.success_desc', { email: currentRow.email }),
})
queryClient.invalidateQueries({ queryKey: ['account-access-list'] })
onOpenChange(false)
},
onError: (error: any) => {
toast({
variant: 'destructive',
title: t('accounts.access_control.toast.failed_title'),
description: error.response?.data?.message || error.message,
})
},
})
const onSubmit = (data: AssignmentFormValues) => {
mutate(data)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md gap-0 p-0 overflow-hidden">
<DialogHeader className="px-6 pt-6 pb-4">
<DialogTitle className="flex items-center gap-2">
<ShieldCheck className="w-5 h-5 text-blue-600" />
{t('accounts.access_control.title')}
</DialogTitle>
<DialogDescription>
{t('accounts.access_control.description', { email: currentRow.email })}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div className="px-6 space-y-6">
<FormField
control={form.control}
name="role_id"
render={({ field }) => (
<FormItem>
<FormLabel>{t('accounts.access_control.role_label')}</FormLabel>
<Select
disabled={isLoadingRoles}
onValueChange={(value) => field.onChange(Number(value))}
value={field.value?.toString()}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={
isLoadingRoles
? t('accounts.access_control.role_loading')
: t('accounts.access_control.role_placeholder')
}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{accountRoles.map((role) => (
<SelectItem key={role.id} value={role.id.toString()}>
{role.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-3">
<FormLabel className="flex items-center gap-2">
<Users className="w-4 h-4" />
{t('accounts.access_control.user_label')}
</FormLabel>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t('accounts.access_control.user_search_placeholder')}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
className="pl-10"
/>
</div>
<div className="border rounded-md">
<ScrollArea className="h-64">
{isLoadingUsers ? (
<div className="flex justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : (
<div className="p-3 space-y-1">
{filteredUsers.length === 0 ? (
<div className="text-center py-8 text-sm text-muted-foreground">
{t('accounts.access_control.user_empty')}
</div>
) : (
filteredUsers.map((user) => (
<FormField
key={user.id}
control={form.control}
name="user_ids"
render={({ field }) => (
<FormItem className="flex flex-row items-center space-x-3 space-y-0 rounded-md hover:bg-accent/50 px-2 py-2 transition-colors">
<FormControl>
<Checkbox
checked={field.value?.includes(user.id) ?? false}
onCheckedChange={(checked) => {
if (checked) {
field.onChange([...(field.value ?? []), user.id])
} else {
field.onChange(
field.value?.filter((id: number) => id !== user.id) ?? []
)
}
}}
/>
</FormControl>
<label className="flex-1 cursor-pointer select-none space-y-1">
<div className="font-medium text-sm">{user.username}</div>
<div className="text-xs text-muted-foreground">
{user.email}
</div>
</label>
</FormItem>
)}
/>
))
)}
</div>
)}
</ScrollArea>
</div>
<FormMessage>{form.formState.errors.user_ids?.message}</FormMessage>
{form.watch('user_ids')?.length > 0 && (
<div className="text-sm text-muted-foreground">
{t('accounts.access_control.user_selected_count', { count: form.watch('user_ids').length })}
</div>
)}
</div>
</div>
<DialogFooter className="bg-muted/50 px-6 py-4">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
{t('accounts.access_control.buttons.cancel')}
</Button>
<Button type="submit" disabled={isPending}>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('accounts.access_control.buttons.save')}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
)
}
@@ -47,7 +47,7 @@ export function useColumns(): ColumnDef<AccountModel>[] {
{
accessorKey: "email",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('accounts.email')} />
<DataTableColumnHeader column={column} title={t('accounts.email')} className="justify-center" />
),
cell: ({ row }) => {
return <LongText>{row.original.email}</LongText>
@@ -57,7 +57,7 @@ export function useColumns(): ColumnDef<AccountModel>[] {
{
accessorKey: "enabled",
header: ({ column }) => (
<DataTableColumnHeader className="text-center" column={column} title={t('accounts.enabled')} />
<DataTableColumnHeader className="justify-center" column={column} title={t('accounts.enabled')} />
),
cell: EnableAction,
meta: { className: 'w-18 text-center' },
@@ -69,7 +69,7 @@ export function useColumns(): ColumnDef<AccountModel>[] {
<DataTableColumnHeader column={column} title={t('accounts.auth')} />
),
cell: OAuth2Action,
meta: { className: 'w-18 text-center' },
meta: { className: 'text-center' },
enableHiding: false,
enableSorting: false
},
@@ -88,14 +88,14 @@ export function useColumns(): ColumnDef<AccountModel>[] {
{
accessorKey: "sync_interval_sec",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('accounts.incSync')} />
<DataTableColumnHeader column={column} title={t('accounts.incSync')} className="justify-center" />
),
cell: ({ row }) => {
let account_type = row.original.account_type;
if (account_type === "NoSync") {
return <LongText>n/a</LongText>
return <LongText className="text-center">n/a</LongText>
}
return <LongText>{row.original.sync_interval_min} min</LongText>
return <LongText className="text-center">{row.original.sync_interval_min} min</LongText>
},
//meta: { className: 'w-18 text-center' },
enableHiding: false,
@@ -106,7 +106,28 @@ export function useColumns(): ColumnDef<AccountModel>[] {
<DataTableColumnHeader column={column} title={t('accounts.state')} />
),
cell: RunningStateCellAction,
meta: { className: 'w-36' },
meta: { className: 'text-center' },
enableHiding: false,
},
{
accessorKey: 'created_by',
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Owner" className="justify-center" />
),
cell: ({ row }) => {
const { created_user_name, created_user_email } = row.original;
return (
<div className="flex flex-col py-1 text-center">
<span className="text-sm font-medium text-foreground">
{created_user_name}
</span>
<span className="text-[11px] text-muted-foreground font-mono">
{created_user_email}
</span>
</div>
);
},
meta: { className: 'w-60 text-center' },
enableHiding: false,
},
{
@@ -32,51 +32,66 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useTranslation } from 'react-i18next'
interface DataTablePaginationProps<TData> {
table: Table<TData>
showSelected?: boolean,
showSelected?: boolean
showPageSizeSelector?: boolean
}
export function DataTablePagination<TData>({
table,
showSelected = true,
showSelected = false,
showPageSizeSelector = true
}: DataTablePaginationProps<TData>) {
const { t } = useTranslation()
return (
<div className='flex items-center justify-between overflow-auto px-2'>
{showSelected && <div className='hidden flex-1 text-sm text-muted-foreground sm:block'>
{table.getFilteredSelectedRowModel().rows.length} of{' '}
{table.getFilteredRowModel().rows.length} row(s) selected.
</div>}
{!showPageSizeSelector && <div className='hidden flex-1 text-sm text-muted-foreground sm:block'>
10 rows per page.
</div>}
{showSelected && (
<div className='hidden flex-1 text-sm text-muted-foreground sm:block'>
{t('table.pagination.selected', {
selected: table.getFilteredSelectedRowModel().rows.length,
total: table.getFilteredRowModel().rows.length,
})}
</div>
)}
{!showPageSizeSelector && (
<div className='hidden flex-1 text-sm text-muted-foreground sm:block'>
{t('table.pagination.fixed_page_size', { size: 10 })}
</div>
)}
<div className='flex items-center sm:space-x-6 lg:space-x-8 ml-auto'>
{showPageSizeSelector && <div className='flex items-center space-x-2'>
<p className='hidden text-sm font-medium sm:block'>Rows per page</p>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value))
}}
>
<SelectTrigger className='h-8 w-[70px]'>
<SelectValue placeholder={table.getState().pagination.pageSize} />
</SelectTrigger>
<SelectContent side='top'>
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>}
<div className='flex w-[100px] items-center justify-center text-sm font-medium'>
Page {table.getState().pagination.pageIndex + 1} of{' '}
{table.getPageCount()}
{showPageSizeSelector && (
<div className='flex items-center space-x-2'>
<p className='hidden text-sm font-medium sm:block'>
{t('table.pagination.rows_per_page')}
</p>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value))
}}
>
<SelectTrigger className='h-8 w-[70px]'>
<SelectValue placeholder={table.getState().pagination.pageSize} />
</SelectTrigger>
<SelectContent side='top'>
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className='flex w-[130px] items-center justify-center text-sm font-medium'>
{t('table.pagination.page_info', {
page: table.getState().pagination.pageIndex + 1,
total: table.getPageCount(),
})}
</div>
<div className='flex items-center space-x-2'>
<Button
@@ -85,7 +100,7 @@ export function DataTablePagination<TData>({
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className='sr-only'>Go to first page</span>
<span className='sr-only'>{t('table.pagination.first')}</span>
<DoubleArrowLeftIcon className='h-4 w-4' />
</Button>
<Button
@@ -94,7 +109,7 @@ export function DataTablePagination<TData>({
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className='sr-only'>Go to previous page</span>
<span className='sr-only'>{t('table.pagination.previous')}</span>
<ChevronLeftIcon className='h-4 w-4' />
</Button>
<Button
@@ -103,7 +118,7 @@ export function DataTablePagination<TData>({
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className='sr-only'>Go to next page</span>
<span className='sr-only'>{t('table.pagination.next')}</span>
<ChevronRightIcon className='h-4 w-4' />
</Button>
<Button
@@ -112,7 +127,7 @@ export function DataTablePagination<TData>({
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className='sr-only'>Go to last page</span>
<span className='sr-only'>{t('table.pagination.last')}</span>
<DoubleArrowRightIcon className='h-4 w-4' />
</Button>
</div>
@@ -19,7 +19,7 @@
import { DotsHorizontalIcon } from '@radix-ui/react-icons'
import { Row } from '@tanstack/react-table'
import { IconEdit, IconTrash } from '@tabler/icons-react'
import { IconEdit, IconShieldLock, IconTrash } from '@tabler/icons-react'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
@@ -33,6 +33,7 @@ import { useAccountContext } from '../context'
import { AccountModel } from '../data/schema'
import { Mailbox, MessageSquareMore } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useCurrentUser } from '@/hooks/use-current-user'
interface DataTableRowActionsProps {
row: Row<AccountModel>
@@ -43,11 +44,21 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
const { setOpen, setCurrentRow } = useAccountContext()
const account_type = row.original.account_type;
const { require_any_permission } = useCurrentUser()
const hasPermission = require_any_permission(['system:root', 'account:manage'], row.original.id);
const hasReadPermission = require_any_permission(['system:root', 'account:read_details'], row.original.id);
const canShowAnyAction =
(hasPermission) ||
(account_type === 'IMAP' && hasPermission) ||
(account_type === 'IMAP' && hasReadPermission);
return (
<>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<DropdownMenuTrigger asChild disabled={!canShowAnyAction}>
<Button
variant='ghost'
className='flex h-8 w-8 p-0 data-[state=open]:bg-muted'
@@ -57,7 +68,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-[160px]'>
<DropdownMenuItem
{hasPermission && <DropdownMenuItem
onClick={() => {
setCurrentRow(row.original)
if (account_type === "IMAP") {
@@ -72,8 +83,8 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
<DropdownMenuShortcut>
<IconEdit size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
{account_type === "IMAP" && <DropdownMenuItem
</DropdownMenuItem>}
{account_type === "IMAP" && hasPermission && <DropdownMenuItem
onClick={() => {
setCurrentRow(row.original)
setOpen('sync-folders')
@@ -84,7 +95,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
<Mailbox size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>}
{account_type === "IMAP" && <DropdownMenuItem
{account_type === "IMAP" && hasReadPermission && <DropdownMenuItem
onClick={() => {
setCurrentRow(row.original)
setOpen('detail')
@@ -95,8 +106,20 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
<MessageSquareMore size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>}
<DropdownMenuSeparator />
<DropdownMenuItem
{hasPermission && <DropdownMenuSeparator />}
{hasPermission && <DropdownMenuItem
onClick={() => {
setCurrentRow(row.original)
setOpen('access-assign')
}}
>
<span>Access Control</span>
<DropdownMenuShortcut>
<IconShieldLock size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>}
{hasPermission && <DropdownMenuSeparator />}
{hasPermission && <DropdownMenuItem
onClick={() => {
setCurrentRow(row.original)
setOpen('delete')
@@ -107,7 +130,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
<DropdownMenuShortcut>
<IconTrash size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuItem>}
</DropdownMenuContent>
</DropdownMenu>
</>
@@ -54,7 +54,7 @@ export function AccountDeleteDialog({ open, onOpenChange, currentRow }: Props) {
}
function handleError(error: AxiosError) {
const errorMessage = error.response?.data ||
const errorMessage = (error.response?.data as { message?: string })?.message ||
error.message ||
t('dialogs.deleteFailed');
@@ -28,6 +28,7 @@ import { update_account } from '@/api/account/api'
import { toast } from '@/hooks/use-toast'
import { AxiosError } from 'axios'
import { useTranslation } from 'react-i18next'
import { useCurrentUser } from '@/hooks/use-current-user'
interface DataTableRowActionsProps {
row: Row<AccountModel>
@@ -37,7 +38,10 @@ export function EnableAction({ row }: DataTableRowActionsProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false);
const queryClient = useQueryClient();
const { require_any_permission } = useCurrentUser()
const hasPermission = require_any_permission(['system:root', 'account:manage'], row.original.id);
const updateMutation = useMutation({
mutationFn: (enabled: boolean) =>
update_account(row.original.id, { enabled }),
@@ -74,7 +78,7 @@ export function EnableAction({ row }: DataTableRowActionsProps) {
<Switch
checked={row.original.enabled}
onCheckedChange={() => setOpen(true)}
disabled={updateMutation.isPending}
disabled={!hasPermission || updateMutation.isPending}
/>
<ConfirmDialog
open={open}
@@ -22,6 +22,9 @@ import { Button } from '@/components/ui/button'
import { useAccountContext } from '../context'
import { AccountModel } from '../data/schema'
import { useTranslation } from 'react-i18next'
import { useCurrentUser } from '@/hooks/use-current-user'
import { toast } from '@/hooks/use-toast'
import { ToastAction } from '@/components/ui/toast'
interface DataTableRowActionsProps {
row: Row<AccountModel>
@@ -29,8 +32,10 @@ interface DataTableRowActionsProps {
export function OAuth2Action({ row }: DataTableRowActionsProps) {
const { t } = useTranslation()
const { setOpen, setCurrentRow } = useAccountContext()
const { require_any_permission } = useCurrentUser()
const mailer = row.original
const account_type = mailer.account_type;
const hasPermission = require_any_permission(['system:root', 'account:manage'], row.original.id)
if (account_type === "NoSync") {
return <Button variant={"ghost"} className="text-xs text-muted-foreground">n/a</Button>
@@ -45,8 +50,21 @@ export function OAuth2Action({ row }: DataTableRowActionsProps) {
size="sm"
className="text-xs text-blue-500 hover:text-blue-700 underline"
onClick={() => {
setCurrentRow(mailer)
setOpen("oauth2")
if (hasPermission) {
setCurrentRow(mailer)
setOpen("oauth2")
} else {
toast({
variant: 'destructive',
title: 'Forbidden',
description: 'You do not have permission to view oauth2 tokens.',
action: (
<ToastAction altText="Close">
Close
</ToastAction>
),
})
}
}}
>
OAuth2
@@ -22,6 +22,9 @@ import { Button } from '@/components/ui/button'
import { AccountModel } from '../data/schema';
import { useAccountContext } from '../context';
import { useTranslation } from 'react-i18next';
import { useCurrentUser } from '@/hooks/use-current-user';
import { toast } from '@/hooks/use-toast';
import { ToastAction } from '@/components/ui/toast';
interface Props {
row: Row<AccountModel>
@@ -30,16 +33,31 @@ interface Props {
export function RunningStateCellAction({ row }: Props) {
const { t } = useTranslation()
const { setOpen, setCurrentRow } = useAccountContext()
const { require_any_permission } = useCurrentUser()
let account_type = row.original.account_type;
if (account_type === "NoSync") {
return <span className="text-xs text-muted-foreground">n/a</span>
}
const hasPermission = require_any_permission(['system:root', 'account:read_details'], row.original.id)
return (
<Button variant='ghost' className="h-auto p-1" onClick={() => {
setCurrentRow(row.original)
setOpen('running-state')
if (hasPermission) {
setCurrentRow(row.original)
setOpen('running-state')
} else {
toast({
variant: 'destructive',
title: 'Forbidden',
description: 'You do not have permission to view this account.',
action: (
<ToastAction altText="Close">
Close
</ToastAction>
),
})
}
}}>
<span className="text-xs text-blue-500 cursor-pointer underline hover:text-blue-700">{t('accounts.viewDetails')}</span>
</Button>
@@ -52,6 +52,7 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
retry: 0,
refetchOnWindowFocus: false,
refetchInterval: 5000,
enabled: open && !!currentRow.id && currentRow.account_type != "NoSync",
})
const calculateDuration = (start?: number, end?: number) => {
@@ -251,7 +251,12 @@ export function SyncFoldersDialog({ currentRow, open, onOpenChange }: Props) {
<TreeItemIconContainer {...getIconContainerProps()}>
<TreeItemIcon status={status} />
</TreeItemIconContainer>
<TreeItemCheckbox {...getCheckboxProps()} />
<TreeItemCheckbox {...getCheckboxProps()} sx={{
color: 'hsl(var(--muted-foreground) / 0.4)',
'&.Mui-checked': {
color: 'hsl(var(--primary))',
},
}} />
<CustomLabel
{...getLabelProps({
exists: item.exists,
+11 -1
View File
@@ -20,7 +20,17 @@
import React from 'react'
import { AccountModel } from '../data/schema'
export type AccountDialogType = 'add-imap' | 'add-nosync' | 'edit-imap' | 'edit-nosync' | 'delete' | 'detail' | 'oauth2' | 'running-state' | 'sync-folders'
export type AccountDialogType =
| 'add-imap'
| 'add-nosync'
| 'edit-imap'
| 'edit-nosync'
| 'delete'
| 'detail'
| 'oauth2'
| 'running-state'
| 'sync-folders'
| 'access-assign';
interface AccountContextType {
open: AccountDialogType | null
+3
View File
@@ -57,6 +57,9 @@ export interface AccountModel {
folder_limit?: number,
sync_folders: string[];
sync_interval_min?: number;
created_by: number;
created_user_name: string;
created_user_email: string;
created_at: number;
updated_at: number;
use_proxy?: number
+22 -9
View File
@@ -42,6 +42,8 @@ import { SyncFoldersDialog } from './components/sync-folders'
import { NoSyncAccountDialog } from './components/nosync-dialog'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { useTranslation } from 'react-i18next'
import { AccountAccessAssignmentDialog } from './components/access-assignment-dialog'
import { useCurrentUser } from '@/hooks/use-current-user'
export default function Accounts() {
const { t } = useTranslation()
@@ -49,6 +51,7 @@ export default function Accounts() {
// Dialog states
const [currentRow, setCurrentRow] = useState<AccountModel | null>(null)
const [open, setOpen] = useDialogState<AccountDialogType>(null)
const { require_any_permission } = useCurrentUser()
const { data: accountList, isLoading } = useQuery({
queryKey: ['account-list'],
@@ -63,7 +66,6 @@ export default function Accounts() {
<Main>
<div className="mx-auto w-full max-w-[88rem] px-4">
{/* Header Section */}
<div className='mb-2 flex items-center justify-between flex-wrap gap-x-4 gap-y-2'>
<div>
<h2 className='text-2xl font-bold tracking-tight'>{t('accounts.title')}</h2>
@@ -71,7 +73,7 @@ export default function Accounts() {
{t('accounts.description')}
</p>
</div>
<div className="flex gap-2">
{require_any_permission(['system:root', 'account:create']) && <div className="flex gap-2">
<div className="flex rounded-md shadow-sm">
<Button
onClick={() => setOpen("add-imap")}
@@ -98,10 +100,9 @@ export default function Accounts() {
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>}
</div>
{/* Table / Empty State Section */}
<div className='flex-1 overflow-auto py-1 flex-row lg:space-x-12 space-y-0'>
{isLoading ? (
<TableSkeleton columns={columns.length} rows={10} />
@@ -168,7 +169,8 @@ export default function Accounts() {
}}
currentRow={currentRow}
/>
<RunningStateDialog
{require_any_permission(['system:root', 'account:read_details'], currentRow.id) && <RunningStateDialog
key='running-state'
open={open === 'running-state'}
onOpenChange={() => {
@@ -178,7 +180,8 @@ export default function Accounts() {
}, 500)
}}
currentRow={currentRow}
/>
/>}
<AccountDeleteDialog
key={`account-delete-${currentRow.id}`}
open={open === 'delete'}
@@ -201,17 +204,27 @@ export default function Accounts() {
}}
currentRow={currentRow}
/>
{require_any_permission(['system:root', 'account:manage'], currentRow.id) && <AccountAccessAssignmentDialog
key={`access-assign-${currentRow.id}`}
open={open === 'access-assign'}
onOpenChange={() => {
setOpen('access-assign')
setTimeout(() => {
setCurrentRow(null)
}, 500)
}}
currentRow={currentRow}
/>}
<AccountDetailDrawer
open={open === 'detail'}
onOpenChange={() => setOpen('detail')}
currentRow={currentRow}
/>
<OAuth2TokensDialog open={open === 'oauth2'}
{require_any_permission(['system:root', 'account:manage'], currentRow.id) && <OAuth2TokensDialog open={open === 'oauth2'}
onOpenChange={() => setOpen('oauth2')}
currentRow={currentRow}
/>
/>}
</>
)}
</AccountProvider>
@@ -16,26 +16,26 @@
// 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/>.
import Logo from '@/assets/logo.svg'
import { Row } from '@tanstack/react-table'
import { Button } from '@/components/ui/button'
import { useAccessTokensContext } from '../context'
import { AccessToken } from '../data/schema'
interface DataTableRowActionsProps {
row: Row<AccessToken>
type AuthLayoutProps = {
children: React.ReactNode
}
export function AccountCellAction({ row }: DataTableRowActionsProps) {
const { setOpen, setCurrentRow } = useAccessTokensContext()
const accounts = row.original.accounts;
export function AuthLayout({ children }: AuthLayoutProps) {
return (
<Button variant='ghost' onClick={() => {
setCurrentRow(row.original)
setOpen('account-detail')
}}>
<span>{accounts.length}</span>
</Button>
<div className='container grid h-svh max-w-none items-center justify-center'>
<div className='mx-auto flex w-full flex-col justify-center space-y-2 py-8 sm:w-[480px] sm:p-8'>
<div className='mb-4 flex items-center justify-center'>
<img
src={Logo}
width={150}
height={150}
alt='Bichon Logo'
/>
</div>
{children}
</div>
</div>
)
}
@@ -33,8 +33,7 @@ import {
import { Input } from '@/components/ui/input'
import { PasswordInput } from '@/components/password-input'
import { useMutation } from '@tanstack/react-query'
import { login } from '@/api/access-tokens/api'
import { setAccessToken } from '@/stores/authStore'
import { setToken } from '@/stores/authStore'
import { toast } from '@/hooks/use-toast'
import { AxiosError } from 'axios'
import { ToastAction } from '@/components/ui/toast'
@@ -42,17 +41,21 @@ import { useLocation, useNavigate } from '@tanstack/react-router'
import { Button } from '@/components/button'
import { useTranslation } from 'react-i18next'
import i18n from '@/i18n'
import { Loader2, LogIn } from 'lucide-react'
import { login } from '@/api/users/api'
type UserAuthFormProps = HTMLAttributes<HTMLDivElement>
const getFormSchema = (t: (key: string, options?: Record<string, any>) => string) => z.object({
username: z
.string(),
password: z
.string()
.min(1, { message: t('validation.pleaseEnterPassword') })
.min(4, { message: t('validation.passwordMinLength', { min: 4 }) }),
});
const getFormSchema = (t: (key: string, options?: Record<string, any>) => string) =>
z.object({
username: z
.string()
.min(1, { message: t('validation.pleaseEnterUsernameOrEmail') }),
password: z
.string()
.min(1, { message: t('validation.pleaseEnterPassword') })
.min(4, { message: t('validation.passwordMinLength', { min: 4 }) }),
});
export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
const [isLoading, setIsLoading] = useState(false)
@@ -66,24 +69,33 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
username: 'root',
username: '',
password: '',
},
})
const mutation = useMutation({
mutationFn: (password: string) => login(password),
mutationFn: (data: Record<string, any>) => login(data),
retry: 0,
});
async function onSubmit(data: z.infer<typeof formSchema>) {
setIsLoading(true)
mutation.mutate(data.password, {
onSuccess: (rootToken) => {
setAccessToken(rootToken);
mutation.mutate(data, {
onSuccess: (result) => {
if (result.success) {
setToken(result);
navigate({ to: redirect });
} else {
toast({
variant: "destructive",
title: t('auth.loginFailed'),
description: `${result.error_message!}`,
action: <ToastAction altText={t('common.tryAgain')}>{t('common.tryAgain')}</ToastAction>,
})
}
setIsLoading(false);
navigate({ to: redirect });
},
onError: (error) => {
const { t } = i18n
@@ -119,7 +131,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
<FormItem className='space-y-1'>
<FormLabel>{t('auth.username')}</FormLabel>
<FormControl>
<Input disabled {...field} value={"root"} />
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -140,7 +152,8 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
</FormItem>
)}
/>
<Button className='mt-2' loading={isLoading}>
<Button className='mt-2' disabled={isLoading}>
{isLoading ? <Loader2 className='animate-spin' /> : <LogIn size={16} className='mr-2' />}
{t('auth.login')}
</Button>
</div>
+29 -18
View File
@@ -16,30 +16,41 @@
// 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/>.
import Logo from '@/assets/logo.svg'
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { UserAuthForm } from './components/user-auth-form'
import { useTranslation } from 'react-i18next'
import { AuthLayout } from './auth-layout'
export default function SignIn() {
const { t } = useTranslation()
return (
<div className='container relative flex h-svh flex-col items-center justify-center'>
<div className='p-8 flex flex-col items-center'>
<img
src={Logo}
className='mb-6'
width={350}
height={350}
alt='Bichon Logo'
/>
<h2 className='mb-4 text-lg font-medium text-muted-foreground'>
{t('auth.welcome')}
</h2>
<div className='mx-auto flex w-full flex-col justify-center space-y-2 sm:w-[350px]'>
<AuthLayout>
<Card className='gap-4'>
<CardHeader>
<CardTitle className='text-lg tracking-tight'>{t('auth.welcome')}</CardTitle>
</CardHeader>
<CardContent>
<UserAuthForm />
</div>
</div>
</div>
</CardContent>
<CardFooter>
<p className="text-muted-foreground px-8 text-center text-sm">
{t('common.project_description')}
<a
href="https://github.com/rustmailer/bichon"
className="hover:text-primary underline underline-offset-4 ml-1"
>
{t('common.view_on_github_button')}
</a>
.
</p>
</CardFooter>
</Card>
</AuthLayout>
)
}
+1 -1
View File
@@ -164,7 +164,7 @@ export default function MailArchiveDashboard() {
return (
<>
<FixedHeader />
<Main higher>
<Main>
<div className="flex-1 space-y-6 p-6 md:p-8">
<div className="flex items-center justify-between">
<div>
@@ -330,18 +330,6 @@ export function Mail({
))}
</div>
) : (
// <TreeView
// data={buildTree(mailboxes ?? [])}
// clickRowToSelect={true}
// onSelectChange={(item) => {
// if (item) {
// setSelectedMailbox(mailboxes?.find(m => m.id === parseInt(item.id, 10)))
// setPage(0);
// } else {
// setSelectedMailbox(undefined)
// }
// }}
// />
<RichTreeView
//checkboxSelection
items={tree}
+1 -1
View File
@@ -34,7 +34,7 @@ export default function Mailboxes() {
<>
{/* ===== Top Heading ===== */}
<FixedHeader />
<Main higher>
<Main>
<Mail
defaultLayout={defaultLayout}
defaultCollapsed={defaultCollapsed}
@@ -36,48 +36,64 @@ import { useTranslation } from 'react-i18next'
interface DataTablePaginationProps<TData> {
table: Table<TData>
showSelected: boolean,
showPageSizeSelector: boolean
showSelected?: boolean
showPageSizeSelector?: boolean
}
export function DataTablePagination<TData>({
table,
showSelected = true,
showPageSizeSelector = true
showSelected = false,
showPageSizeSelector = true,
}: DataTablePaginationProps<TData>) {
const { t } = useTranslation();
const { t } = useTranslation()
return (
<div className='flex items-center justify-between overflow-auto px-2'>
{showSelected && <div className='hidden flex-1 text-sm text-muted-foreground sm:block'>
{table.getFilteredRowModel().rows.length} {t("table.results")}
</div>}
{!showPageSizeSelector && <div className='hidden flex-1 text-sm text-muted-foreground sm:block'>
10 {t("table.rowsPerPage")}.
</div>}
{showSelected && (
<div className='hidden flex-1 text-sm text-muted-foreground sm:block'>
{t('table.pagination.selected', {
selected: table.getFilteredSelectedRowModel().rows.length,
total: table.getFilteredRowModel().rows.length,
})}
</div>
)}
{!showPageSizeSelector && (
<div className='hidden flex-1 text-sm text-muted-foreground sm:block'>
{t('table.pagination.fixed_page_size', { size: 10 })}
</div>
)}
<div className='flex items-center sm:space-x-6 lg:space-x-8 ml-auto'>
{showPageSizeSelector && <div className='flex items-center space-x-2'>
<p className='hidden text-sm font-medium sm:block'>{t("table.rowsPerPage")}</p>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value))
}}
>
<SelectTrigger className='h-8 w-[70px]'>
<SelectValue placeholder={table.getState().pagination.pageSize} />
</SelectTrigger>
<SelectContent side='top'>
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>}
<div className='flex w-[100px] items-center justify-center text-sm font-medium'>
{t("table.page")} {table.getState().pagination.pageIndex + 1}{" "}
{t("table.of")} {table.getPageCount()}
{showPageSizeSelector && (
<div className='flex items-center space-x-2'>
<p className='hidden text-sm font-medium sm:block'>
{t('table.pagination.rows_per_page')}
</p>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value))
}}
>
<SelectTrigger className='h-8 w-[70px]'>
<SelectValue
placeholder={table.getState().pagination.pageSize}
/>
</SelectTrigger>
<SelectContent side='top'>
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className='flex w-[130px] items-center justify-center text-sm font-medium'>
{t('table.pagination.page_info', {
page: table.getState().pagination.pageIndex + 1,
total: table.getPageCount(),
})}
</div>
<div className='flex items-center space-x-2'>
<Button
@@ -86,7 +102,7 @@ export function DataTablePagination<TData>({
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className='sr-only'>{t("table.firstPage")}</span>
<span className='sr-only'>{t('table.pagination.first')}</span>
<DoubleArrowLeftIcon className='h-4 w-4' />
</Button>
<Button
@@ -95,7 +111,7 @@ export function DataTablePagination<TData>({
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className='sr-only'>{t("table.prevPage")}</span>
<span className='sr-only'>{t('table.pagination.previous')}</span>
<ChevronLeftIcon className='h-4 w-4' />
</Button>
<Button
@@ -104,7 +120,7 @@ export function DataTablePagination<TData>({
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className='sr-only'>{t("table.nextPage")}</span>
<span className='sr-only'>{t('table.pagination.next')}</span>
<ChevronRightIcon className='h-4 w-4' />
</Button>
<Button
@@ -113,11 +129,11 @@ export function DataTablePagination<TData>({
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className='sr-only'>{t("table.lastPage")}</span>
<span className='sr-only'>{t('table.pagination.last')}</span>
<DoubleArrowRightIcon className='h-4 w-4' />
</Button>
</div>
</div>
</div>
)
}
}
+26 -1
View File
@@ -28,6 +28,7 @@ import { toast } from '@/hooks/use-toast';
import { EmailEnvelope } from '@/api';
import { validateTag } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
import { useQueryClient } from '@tanstack/react-query';
interface Props {
open: boolean
@@ -37,6 +38,7 @@ interface Props {
export function EditTagsDialog({ open, onOpenChange, currentEnvelope }: Props) {
const { tags: availableTags } = useAvailableTags();
const queryClient = useQueryClient();
const { mutate, isPending } = useUpdateTags();
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [inputValue, setInputValue] = useState('');
@@ -74,6 +76,26 @@ export function EditTagsDialog({ open, onOpenChange, currentEnvelope }: Props) {
};
const handleSave = () => {
if (inputValue.trim()) {
const normalized = inputValue.toLowerCase().trim();
const result = validateTag(normalized);
if (!result.valid) {
toast({
title: t('search.addTags.invalidTitle'),
description: result.error,
variant: 'destructive',
});
return;
}
if (!selectedTags.includes(normalized)) {
setSelectedTags(prev => [...prev, normalized]);
}
setInputValue('');
}
const updates = {
[currentEnvelope.account_id]: [currentEnvelope.id],
};
@@ -81,7 +103,9 @@ export function EditTagsDialog({ open, onOpenChange, currentEnvelope }: Props) {
mutate(
{
updates,
tags: selectedTags
tags: inputValue.trim()
? [...selectedTags, inputValue.toLowerCase().trim()]
: selectedTags,
},
{
onSuccess: () => {
@@ -94,6 +118,7 @@ export function EditTagsDialog({ open, onOpenChange, currentEnvelope }: Props) {
</div>
),
});
queryClient.invalidateQueries({ queryKey: ['all-tags'] });
onOpenChange(false);
},
onError: (error: any) => {
-3
View File
@@ -146,9 +146,6 @@ export function MailBulkActions({ children }: MailBulkActionsProps) {
<span className="sr-only">{t('search.bulkActions.clear')}</span>
</Button>
</TooltipTrigger>
<TooltipContent>
{t('search.bulkActions.clearWithKey', { key: 'Escape' })}
</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="h-5" />
+14 -9
View File
@@ -37,6 +37,7 @@ import { Button } from '@/components/ui/button';
import { EnvelopeTags } from './tag-facet';
import { EditTagsDialog } from './add-tag-dialog';
import { useTranslation } from 'react-i18next';
import Logo from '@/assets/logo.svg'
export default function Search() {
const { t } = useTranslation()
@@ -134,16 +135,20 @@ export default function Search() {
</Card>
)}
{total === 0 && <div className="text-center py-12 space-y-4">
<div className="bg-muted/50 border-2 border-dashed rounded-xl w-24 h-24 mx-auto flex items-center justify-center">
<SearchIcon className="w-10 h-10 text-muted-foreground" />
{total === 0 && <div className="flex h-[750px] shrink-0 items-center justify-center rounded-md border border-dashed">
<div className="mx-auto flex max-w-[420px] flex-col items-center justify-center text-center">
<img
src={Logo}
className="max-h-[100px] w-auto opacity-20 saturate-0 transition-all duration-300 hover:opacity-100 hover:saturate-100 object-contain"
alt="Bichon Logo"
/>
<h3 className="mt-4 text-lg font-semibold">{t('search.noEmailsFound')}</h3>
<p className="text-sm text-muted-foreground max-w-md mx-auto">
{Object.keys(filter).length === 0
? t('search.startSearching')
: t('search.adjustSearch')}
</p>
</div>
<h3 className="text-lg font-medium">{t('search.noEmailsFound')}</h3>
<p className="text-sm text-muted-foreground max-w-md mx-auto">
{Object.keys(filter).length === 0
? t('search.startSearching')
: t('search.adjustSearch')}
</p>
</div>}
{total > 0 && <ScrollArea className='h-[40rem] w-full pr-4 -mr-4 py-1'>
<MailList
@@ -0,0 +1,286 @@
//
// 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 <http://www.gnu.org/licenses/>.
import { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
import { toast } from '@/hooks/use-toast'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Input } from '@/components/ui/input'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { ToastAction } from '@/components/ui/toast'
import { AxiosError } from 'axios'
import { Loader2, Clock } from 'lucide-react'
import { AccessToken, create_access_token, update_access_token } from '@/api/users/api'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
const getAccessTokenSchema = (t: any) => z.object({
name: z
.string()
.max(32, t('apiTokens.form.errorMax'))
.optional()
.or(z.literal('')),
expire_in: z
.number({
invalid_type_error: t('apiTokens.form.errorNumber'),
})
.int(t('apiTokens.form.errorInt'))
.positive(t('apiTokens.form.errorPositive'))
.optional(),
})
export type AccessTokenForm = z.infer<ReturnType<typeof getAccessTokenSchema>>
interface Props {
currentRow?: AccessToken
open: boolean
userId: number
onOpenChange: (open: boolean) => void
}
const defaultValues: AccessTokenForm = {
name: '',
expire_in: undefined,
}
export function TokensActionDialog({
currentRow,
open,
onOpenChange,
userId,
}: Props) {
const { t } = useTranslation()
const isEdit = !!currentRow
const queryClient = useQueryClient()
const form = useForm<AccessTokenForm>({
resolver: zodResolver(getAccessTokenSchema(t)),
defaultValues: isEdit
? {
name: currentRow.name ?? '',
expire_in: undefined,
}
: defaultValues,
})
const createMutation = useMutation({
mutationFn: create_access_token,
onSuccess: handleSuccess,
onError: handleError,
})
const updateMutation = useMutation({
mutationFn: (data: Record<string, any>) =>
update_access_token(currentRow?.token ?? '', data),
onSuccess: handleSuccess,
onError: handleError,
})
function handleSuccess() {
toast({
title: isEdit
? t('apiTokens.notifications.updateSuccess')
: t('apiTokens.notifications.createSuccess'),
description: isEdit
? t('apiTokens.notifications.updateSuccessDesc')
: t('apiTokens.notifications.createSuccessDesc'),
action: (
<ToastAction altText={t('apiTokens.notifications.close')}>
{t('apiTokens.notifications.close')}
</ToastAction>
),
})
queryClient.invalidateQueries({ queryKey: ['access-tokens'] })
queryClient.invalidateQueries({ queryKey: ['user-tokens', userId] })
form.reset(defaultValues)
onOpenChange(false)
}
function handleError(error: AxiosError) {
const errorMessage =
(error.response?.data as { message?: string })?.message ||
error.message ||
t('apiTokens.notifications.genericError')
toast({
variant: 'destructive',
title: isEdit
? t('apiTokens.notifications.updateFailed')
: t('apiTokens.notifications.createFailed'),
description: errorMessage,
action: (
<ToastAction altText={t('apiTokens.notifications.tryAgain')}>
{t('apiTokens.notifications.tryAgain')}
</ToastAction>
),
})
console.error(error)
}
const onSubmit = (values: AccessTokenForm) => {
const payload = {
user_id: userId,
name: values.name?.trim() || undefined,
expire_in: values.expire_in || undefined,
}
isEdit
? updateMutation.mutate(payload)
: createMutation.mutate(payload)
}
const isPending = isEdit
? updateMutation.isPending
: createMutation.isPending
return (
<Dialog
open={open}
onOpenChange={(state) => {
form.reset(defaultValues)
onOpenChange(state)
}}
>
<DialogContent className="max-w-xl">
<DialogHeader className="text-left mb-4">
<DialogTitle>
{isEdit ? t('apiTokens.dialog.editTitle') : t('apiTokens.dialog.createTitle')}
</DialogTitle>
<DialogDescription>
{t('apiTokens.dialog.description')}
</DialogDescription>
</DialogHeader>
<ScrollArea className="h-[14rem] w-full pr-4 -mr-4">
<Form {...form}>
<form
id="token-form"
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6"
>
{/* name */}
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t('apiTokens.form.nameLabel')}</FormLabel>
<FormControl>
<Input
{...field}
maxLength={32}
placeholder={t('apiTokens.form.namePlaceholder')}
/>
</FormControl>
<FormDescription>
{t('apiTokens.form.nameDescription')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* expire_in */}
<FormField
control={form.control}
name="expire_in"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2">
<Clock className="h-4 w-4 text-muted-foreground" />
{t('apiTokens.form.expirationLabel')}
</FormLabel>
<Select
value={field.value?.toString() ?? 'never'}
onValueChange={(value) => {
field.onChange(
value === 'never' ? undefined : Number(value)
)
}}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('apiTokens.form.expirationPlaceholder')} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="never">{t('apiTokens.form.never')}</SelectItem>
<SelectItem value="24">{t('apiTokens.form.1day')}</SelectItem>
<SelectItem value="168">{t('apiTokens.form.7days')}</SelectItem>
<SelectItem value="720">{t('apiTokens.form.30days')}</SelectItem>
</SelectContent>
</Select>
<FormDescription>
{t('apiTokens.form.expirationDescription')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<DialogFooter>
<Button
type="submit"
form="token-form"
disabled={isPending}
className="min-w-[120px]"
>
{isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{isEdit ? t('apiTokens.dialog.saveChanges') : t('apiTokens.dialog.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,108 @@
//
// 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 <http://www.gnu.org/licenses/>.
import { useCurrentUser } from '@/hooks/use-current-user'
import { Loader2, Plus } from 'lucide-react'
import { useQuery } from '@tanstack/react-query'
import { get_user_tokens } from '@/api/users/api'
import { Skeleton } from '@/components/ui/skeleton'
import Logo from '@/assets/logo.svg'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { TokenCardList } from './token-list'
import { TokensActionDialog } from './access-token-action'
import { useTranslation } from 'react-i18next'
export function APITokens() {
const { t } = useTranslation()
const { data: user, isLoading, error } = useCurrentUser()
const [addOpen, setAddOpen] = useState(false)
const { data: tokens = [], isLoading: tokensLoading } = useQuery({
queryKey: ['user-tokens', user?.id!],
queryFn: () => get_user_tokens(user?.id!),
enabled: !!user?.id,
})
if (isLoading) {
return (
<div className="flex justify-center items-center h-64">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
)
}
if (error || !user) {
return (
<div className="p-6 text-red-600">
{t('apiTokens.page.loadError')}
</div>
)
}
return (
<div className="w-full max-w-3xl px-4 sm:px-6 lg:px-8">
{tokensLoading ? (
<div className="flex flex-col gap-4 mt-4">
<Skeleton className="h-16 w-full rounded-lg" />
<Skeleton className="h-16 w-full rounded-lg" />
<Skeleton className="h-16 w-full rounded-lg" />
</div>
) : tokens.length === 0 ? (
<div className="flex h-[450px] items-center justify-center rounded-md border border-dashed mt-4">
<div className="mx-auto flex max-w-[420px] flex-col items-center justify-center text-center px-4">
<img
src={Logo}
className="max-h-[100px] w-auto opacity-20 saturate-0 transition-all duration-300 hover:opacity-100 hover:saturate-100 object-contain"
alt="Bichon Logo"
/>
<h3 className="mt-4 text-lg font-semibold">{t('apiTokens.page.emptyTitle')}</h3>
<p className="mb-4 mt-2 text-sm text-muted-foreground">
{t('apiTokens.page.emptyDescription')}
</p>
<Button onClick={() => setAddOpen(true)}>
<span>{t('apiTokens.page.addBtn')}</span>
<Plus size={18} className="ml-2" />
</Button>
</div>
</div>
) : (
<>
<div className="flex justify-end mb-4">
<Button onClick={() => setAddOpen(true)}>
<span>{t('apiTokens.page.addBtn')}</span>
<Plus size={18} className="ml-2" />
</Button>
</div>
<ScrollArea className="h-[40rem] w-full pr-4 -mr-4 py-1">
<TokenCardList tokens={tokens} userId={user.id} />
</ScrollArea>
</>
)}
<TokensActionDialog
key="api-token-add"
open={addOpen}
userId={user.id}
onOpenChange={setAddOpen}
/>
</div>
)
}
@@ -0,0 +1,229 @@
//
// 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 <http://www.gnu.org/licenses/>.
import React from "react";
import {
Accordion,
AccordionItem,
AccordionTrigger,
AccordionContent,
} from "@/components/ui/accordion";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Copy, Trash2 } from "lucide-react";
import { Separator } from "@/components/ui/separator";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { format } from "date-fns";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { AccessToken, remove_access_token } from "@/api/users/api";
import { useTranslation } from "react-i18next";
import { toast } from "@/hooks/use-toast";
interface Props {
tokens: AccessToken[];
userId: number;
}
const isTokenExpired = (expireAt: number | null | undefined): boolean => {
if (!expireAt) return false;
return new Date(expireAt) < new Date();
};
export const TokenCardList: React.FC<Props> = ({ tokens, userId }) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [deleteTarget, setDeleteTarget] =
React.useState<AccessToken | null>(null);
const deleteMutation = useMutation({
mutationFn: (token: string) => remove_access_token(token),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['user-tokens', userId] });
setDeleteTarget(null);
toast({
description: t('apiTokens.notifications.deleteSuccess'),
});
},
onError: () => {
toast({
variant: "destructive",
description: t('apiTokens.notifications.deleteFailed'),
});
}
});
const handleCopy = (text: string) => {
navigator.clipboard.writeText(text);
toast({
description: t('apiTokens.notifications.copied'),
});
};
return (
<>
<Accordion type="multiple" className="w-full space-y-4">
{tokens.map((token) => {
const expired = isTokenExpired(token.expire_at);
const itemValue = token.token;
return (
<AccordionItem
key={itemValue}
value={itemValue}
className={`border rounded-lg shadow-md transition-all duration-300 ${expired
? "border-red-400 bg-red-50/50"
: "border-gray-200"
}`}
>
<AccordionTrigger className="px-5 py-4 hover:no-underline">
<div className="flex items-center justify-between w-full gap-4">
<div className="flex flex-col gap-1 min-w-0">
<h3 className="text-sm font-semibold truncate">
{token.name || t('apiTokens.list.unnamedToken')}
</h3>
<div className="flex items-center gap-2">
<Badge
variant={expired ? "destructive" : "secondary"}
className="text-xs"
>
{expired ? t('apiTokens.list.statusExpired') : t('apiTokens.list.statusActive')}
</Badge>
</div>
</div>
<div className="text-xs text-muted-foreground whitespace-nowrap">
{token.expire_at
? t('apiTokens.list.expiresOnShort', {
date: format(new Date(token.expire_at), "yyyy-MM-dd")
})
: t('apiTokens.list.neverExpires')}
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-5 pb-5 pt-2 bg-muted/40">
<Separator className="mb-4" />
<div className="space-y-4">
<div>
<div className="text-xs font-medium text-muted-foreground mb-1">
{t('apiTokens.list.tokenLabel')}
</div>
<div className="flex items-center gap-2 bg-background border rounded-md px-3 py-2">
<code className="font-mono text-xs truncate flex-1">
{token.token}
</code>
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={() => handleCopy(token.token)}
>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
{/* Meta */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs text-muted-foreground">
<div>
<span className="block font-medium text-foreground">
{t('apiTokens.list.createdLabel')}
</span>
{format(new Date(token.created_at), "yyyy-MM-dd HH:mm")}
</div>
<div>
<span className="block font-medium text-foreground">
{t('apiTokens.list.lastUsedLabel')}
</span>
{token.last_access_at > 0
? format(new Date(token.last_access_at), "yyyy-MM-dd HH:mm")
: t('apiTokens.list.neverUsed')}
</div>
<div>
<span className="block font-medium text-foreground">
{t('apiTokens.list.expiresOnLabel')}
</span>
{token.expire_at
? format(new Date(token.expire_at), "yyyy-MM-dd HH:mm")
: t('apiTokens.list.neverLabel')}
</div>
</div>
<div className="pt-2 flex justify-end">
<Button
size="sm"
variant="destructive"
onClick={() => setDeleteTarget(token)}
>
<Trash2 className="h-4 w-4 mr-2" />
{t('apiTokens.list.deleteBtn')}
</Button>
</div>
</div>
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
<Dialog
open={!!deleteTarget}
onOpenChange={(open) => !open && setDeleteTarget(null)}
>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{t('apiTokens.deleteDialog.title')}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
{t('apiTokens.deleteDialog.description')}
</p>
<DialogFooter className="mt-4">
<Button
variant="outline"
onClick={() => setDeleteTarget(null)}
>
{t('apiTokens.deleteDialog.cancel')}
</Button>
<Button
variant="destructive"
disabled={deleteMutation.isPending}
onClick={() => {
if (!deleteTarget) return;
deleteMutation.mutate(deleteTarget.token);
}}
>
{deleteMutation.isPending
? t('apiTokens.deleteDialog.deleting')
: t('apiTokens.deleteDialog.confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
@@ -1,50 +0,0 @@
//
// 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 <http://www.gnu.org/licenses/>.
import { ScrollArea } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator'
interface ContentSectionProps {
title: string
desc: string
children: React.JSX.Element,
showHeader?: boolean
}
export default function ContentSection({
title,
desc,
children,
showHeader = true
}: ContentSectionProps) {
return (
<div className='flex flex-1 flex-col'>
{showHeader && <div className='flex-none'>
<h3 className='text-lg font-medium'>{title}</h3>
<p className='text-sm text-muted-foreground'>{desc}</p>
</div>}
{showHeader && <Separator className='my-4 flex-none' />}
<ScrollArea className='faded-bottom -mx-4 flex-1 scroll-smooth px-4 md:pb-16'>
<div className='lg:max-w-2xl -mx-1 px-1.5'>{children}</div>
</ScrollArea>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More