mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
Merge branch 'main' into fix/rename-id-to-message-id
This commit is contained in:
Generated
+1
-1
@@ -424,7 +424,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bichon"
|
||||
version = "0.1.3"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"async-imap",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "bichon"
|
||||
version = "0.1.4"
|
||||
version = "0.2.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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(¤t_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.
|
||||
|
||||
@@ -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 (20000–20999)
|
||||
PermissionDenied = 20000,
|
||||
AccountDisabled = 20010,
|
||||
Forbidden = 20020,
|
||||
OAuth2ItemDisabled = 20050,
|
||||
MissingRefreshToken = 20060,
|
||||
|
||||
// Resource errors (30000–30999)
|
||||
ResourceNotFound = 30000,
|
||||
TooManyRequest = 30020,
|
||||
AlreadyExists = 30030,
|
||||
|
||||
// Network connection errors (40000–40999)
|
||||
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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(|| {
|
||||
|
||||
@@ -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?))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?))
|
||||
}
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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!(),
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
@@ -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")]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
// }
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+1750
-42
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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">
|
||||
|
||||
@@ -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']),
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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])
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -55,4 +55,4 @@ const AlertDescription = React.forwardRef<
|
||||
))
|
||||
AlertDescription.displayName = 'AlertDescription'
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
+17
-17
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user