diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 84413d3..c62921b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,6 +34,22 @@ jobs: - name: Checkout code uses: actions/checkout@v4 + - name: Verify Cargo.toml version matches git tag + shell: bash + run: | + TAG_VERSION="${GITHUB_REF_NAME}" + + CARGO_VERSION=$(grep '^version' Cargo.toml | head -n1 | cut -d '"' -f2) + + echo "Git tag version: $TAG_VERSION" + echo "Cargo.toml version: $CARGO_VERSION" + + if [ "$TAG_VERSION" != "$CARGO_VERSION" ]; then + echo "::error::Version mismatch! Git tag ($TAG_VERSION) does not match Cargo.toml version ($CARGO_VERSION)" + exit 1 + fi + + - name: Install Rust uses: actions-rs/toolchain@v1 with: diff --git a/Cargo.lock b/Cargo.lock index 6c7b3b2..4b6d98a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -424,7 +424,7 @@ dependencies = [ [[package]] name = "bichon" -version = "0.1.4" +version = "0.2.1" dependencies = [ "ahash", "async-imap", diff --git a/Cargo.toml b/Cargo.toml index ca347e7..b84b7e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bichon" -version = "0.1.4" +version = "0.2.1" edition = "2021" [[bin]] diff --git a/README.md b/README.md index 3344cde..7e5c79b 100644 --- a/README.md +++ b/README.md @@ -52,49 +52,20 @@ Built in Rust, it requires no external dependencies and provides fast, efficient ## 🚀 Features -### ⚡ Lightweight & Standalone -- Pure Rust, single-machine application. -- No external database required. -- Includes **WebUI** for intuitive management. +* **Lightweight & Standalone** — Pure Rust, no external database, with built-in WebUI +* **Multi-Account Sync** — Download and manage emails from multiple accounts +* **Flexible Fetching** — Sync by date range, email count, or specific mailboxes +* **IMAP & OAuth2 Auth** — Password or OAuth2 login with automatic token refresh +* **Proxy & Auto Config** — Supports network proxies and automatic IMAP discovery +* **Unified Search** — Search across all accounts by sender, subject, body, date, size, attachments, and more +* **Tags & Facets** — Organize emails using Tantivy facet-based tags +* **Compressed Storage** — Transparent compression and deduplication for efficient storage +* **Email Management** — Browse, view threads, bulk clean up, export EML or attachments +* **Dashboard & Analytics** — Visual insights into email volume, trends, and top senders +* **Internationalized WebUI** — Frontend available in 18 languages +* **OpenAPI Access** — OpenAPI docs with access-token authentication +* **Multi-User & Role-Based Access Control (RBAC)** — Supports multiple users with fine-grained, role-based permissions -### 📬 Multi-Account Management -- Synchronize and download emails from multiple accounts. -- Flexible selection: by **date range**, **number of emails**, or **specific mailboxes**. - -### 🔑 IMAP & OAuth2 Authentication -- Supports **IMAP password** or **OAuth2** login. -- Built-in WebUI for **OAuth2 authorization**, including **automatic token refresh** (e.g., Gmail, Outlook). -- Supports **network proxy** for IMAP and OAuth2. -- Automatic IMAP server discovery and configuration. - -### 🔍 Unified Multi-Account Search -- Powerful search across all accounts: - **account**, **mailbox**, **sender**, **attachment name**, **has attachments**, **size**, **date**, **subject**, **body**. - -### 🏷️ Tags & Facets -- Organize archived emails using **tags** backed by Tantivy **facets**. -- Efficiently filter and locate emails based on these facet-based tags. - -### 💾 Compressed & Deduplicated Storage -- Store emails efficiently with **transparent compression** and **deduplication**—emails can be read directly without any extra steps. - -### 📂 Email Management & Viewing -- Bulk cleanup of local archives. -- Download emails as **EML** or **attachments separately**. -- View and browse emails directly. -- View the full **conversation thread** of any email. - -### 📊 Dashboard & Analytics -- Visualize email statistics: **counts**, **time distribution**, **top senders**, **largest emails**, **account rankings**. - -### 🌐 Internationalization (i18n) -* WebUI fully supports **17 languages** for all interface elements. -* Backend responses (e.g., system messages, API data) are **not yet internationalized**. -* Frontend is ready to support more languages in the future with minimal effort. - -### 🛠️ OpenAPI Support -- Provides **OpenAPI documentation**. -- **Access token authentication** for programmatic access. ## 🐾 Why Create Bichon? @@ -285,79 +256,53 @@ Extract and run: * If you are accessing Bichon from a proxy domain **mydomain** argument --bichon-cors-origins="https://mydomain" is required. -## Setting the Bichon Encryption Password +## 🔐 Setting the Bichon Encryption Password -Bichon uses an encryption password to secure sensitive data. **You must set it before first use**, when no data exists. +Please refer to the following documentation for detailed instructions on how to set the Bichon encryption password: -Once set, it **cannot be changed**. Changing it later will make all encrypted data unreadable. To start over, you would need to **reinitialize Bichon and clear all emails and metadata**. +👉 [https://github.com/rustmailer/bichon/wiki/Setting-the-Bichon-Encryption-Password](https://github.com/rustmailer/bichon/wiki/Setting-the-Bichon-Encryption-Password) -### How to Set the Password +All configuration methods, including command-line options, environment variables, and password file support (v0.2.0+), are documented there. -You can set the password **via command-line or environment variable**: +## 🔑 User Authentication & Admin Account -### Command-Line +Starting from **Bichon v0.2.0**, the authentication model has been updated. -```bash -bichon --bichon-encrypt-password "your-strong-password" -``` +### Built-in Admin User (v0.2.0+) -### Environment Variable +* Bichon no longer uses the legacy single-account `root / root` login. +* The system now ships with a built-in **admin** user by default. +* **Default credentials:** -```bash -export BICHON_ENCRYPT_PASSWORD="your-strong-password" -bichon -``` + * **Username:** `admin` + * **Password:** `admin@bichon` -**Tip:** Use a strong, secure password and keep it safe, as it cannot be changed later. +> The legacy `root` account and the `root / root` default credentials **no longer exist**. -## 🔑 Root User Login Information -**Bichon currently supports a single Root user login for system access and management.** +### Mandatory Access Token Authentication -### First Login and Enabling Access +* From **v0.2.0 onward**, **access-token–based authentication is always enabled**. +* The startup flag and environment variable + `--bichon-enable-access-token` / `BICHON_ENABLE_ACCESS_TOKEN` + are **deprecated and no longer used**. +* No additional configuration is required to enable authentication. -To enable the login feature, you must specify a command-line argument or set an environment variable when starting Bichon. -#### 1\. Command-Line Argument +### Managing Account Information -Add the `--bichon-enable-access-token` flag to your startup command: +After logging in, the admin user can manage their profile directly in the WebUI: -```bash -# Linux/macOS Binary Deployment Example -./bichon --bichon-root-dir /tmp/bichon-data --bichon-enable-access-token -``` +1. Log in to the WebUI using the default admin credentials. +2. Navigate to **Settings → Profile**. +3. Update: -#### 2\. Environment Variable (Recommended for Docker) - -Set the environment variable `BICHON_ENABLE_ACCESS_TOKEN` to `true`: - -```bash -# Docker Deployment Example -docker run -d \ - --name bichon \ - -p 15630:15630 \ - -v $(pwd)/bichon-data:/data \ - -e BICHON_LOG_LEVEL=info \ - -e BICHON_ROOT_DIR=/data \ - -e BICHON_ENABLE_ACCESS_TOKEN=true \ - rustmailer/bichon:latest -``` - -### Default Credentials - - * **Initial Login Account:** `root` - * **Initial Password:** `root` - -### Changing the Password - -**It is strongly recommended that you change the default password immediately after your first login.** - -You can change the password via the WebUI: - -1. Log in to the WebUI. -2. Navigate to the **Settings** page. -3. Use the **Reset Root Password** option to modify your password. + * Username + * Password + * Avatar and other profile information +⚠️ **Security Notice:** +For security reasons, you should **change the default admin password immediately after the first login**. ## 📖 Documentation @@ -386,6 +331,40 @@ 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 +428,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 diff --git a/src/main.rs b/src/main.rs index 7a6da72..ef9765a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,7 +16,6 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - 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(); diff --git a/src/modules/account/grant.rs b/src/modules/account/grant.rs new file mode 100644 index 0000000..8452d66 --- /dev/null +++ b/src/modules/account/grant.rs @@ -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 . + +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}, + UserModel, + }, + }, + raise_error, utc_now, +}; + +#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)] +pub struct BatchAccountRoleRequest { + pub account_ids: Vec, + pub user_ids: Vec, + 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 = UserModel::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, + user_ids: Vec, + 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::(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 + } +} diff --git a/src/modules/account/migration.rs b/src/modules/account/migration.rs index 64b4d37..e9089dd 100644 --- a/src/modules/account/migration.rs +++ b/src/modules/account/migration.rs @@ -27,11 +27,16 @@ use tracing::info; use crate::{ encrypt, modules::{ - account::{entity::ImapConfig, since::DateSince, state::AccountRunningState}, + account::{ + entity::ImapConfig, + since::{DateSince, RelativeDate}, + 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, UserModel, DEFAULT_ADMIN_USER_ID}, }, utc_now, }; @@ -52,10 +57,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 +125,42 @@ impl AccountV2 { fn pk(&self) -> String { format!("{}_{}", self.created_at, self.id) } +} - pub fn new(request: AccountCreateRequest) -> BichonResult { +#[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, + pub enabled: bool, + #[oai(validator(custom = "crate::modules::common::validator::EmailValidator"))] + pub email: String, + pub name: Option, + pub capabilities: Option>, + pub date_since: Option, + pub date_before: Option, + pub folder_limit: Option, + pub sync_folders: Option>, + pub account_type: AccountType, + pub sync_interval_min: Option, + pub sync_batch_size: Option, + pub known_folders: Option>, + pub created_at: i64, + pub updated_at: i64, + pub created_by: u64, //user id + pub use_proxy: Option, + pub use_dangerous: bool, + pub pgp_key: Option, +} + +impl AccountV3 { + fn pk(&self) -> String { + format!("{}_{}", self.created_at, self.id) + } + + pub fn new(user_id: u64, request: AccountCreateRequest) -> BichonResult { Ok(Self { id: id!(64), email: request.email, @@ -141,12 +179,15 @@ impl AccountV2 { folder_limit: request.folder_limit, use_dangerous: request.use_dangerous, pgp_key: request.pgp_key, + created_by: user_id, + sync_batch_size: request.sync_batch_size, + date_before: request.date_before, }) } pub async fn check_account_exists(account_id: u64) -> BichonResult { let account = - secondary_find_impl::(DB_MANAGER.meta_db(), AccountV2Key::id, account_id) + secondary_find_impl::(DB_MANAGER.meta_db(), AccountV3Key::id, account_id) .await? .ok_or_else(|| { raise_error!( @@ -176,24 +217,53 @@ impl AccountV2 { } pub async fn find(account_id: u64) -> BichonResult> { - secondary_find_impl::(DB_MANAGER.meta_db(), AccountV2Key::id, account_id) + secondary_find_impl::(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 { - 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 { + 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::(entity) + .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; + let user = rw + .get() + .primary::(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 +300,7 @@ impl AccountV2 { async fn delete_account(account_id: u64) -> BichonResult<()> { delete_impl(DB_MANAGER.meta_db(), move|rw|{ - rw.get().secondary::(AccountV2Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))? + rw.get().secondary::(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 +312,7 @@ impl AccountV2 { MAIL_CONTEXT.clean_account(account.id).await?; } OAuth2AccessToken::try_delete(account.id).await?; - AccessToken::cleanup_account(account.id).await?; + UserModel::cleanup_account(account.id).await?; MailBox::clean(account.id).await?; ENVELOPE_INDEX_MANAGER .delete_account_envelopes(account.id) @@ -260,7 +330,7 @@ impl AccountV2 { sync_folders: Vec, ) -> BichonResult<()> { update_impl(DB_MANAGER.meta_db(), move |rw| { - rw.get().secondary::(AccountV2Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))? + rw.get().secondary::(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 +345,7 @@ impl AccountV2 { known_folders: BTreeSet, ) -> BichonResult<()> { update_impl(DB_MANAGER.meta_db(), move |rw| { - rw.get().secondary::(AccountV2Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))? + rw.get().secondary::(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 +360,7 @@ impl AccountV2 { capabilities: Vec, ) -> BichonResult<()> { update_impl(DB_MANAGER.meta_db(), move |rw| { - rw.get().secondary::(AccountV2Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))? + rw.get().secondary::(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 +389,7 @@ impl AccountV2 { } pub async fn count() -> BichonResult { - count_by_unique_secondary_key_impl::(DB_MANAGER.meta_db(), AccountV2Key::id) + count_by_unique_secondary_key_impl::(DB_MANAGER.meta_db(), AccountV3Key::id) .await } @@ -342,6 +412,12 @@ impl AccountV2 { if let Some(date_since) = request.date_since { new.date_since = Some(date_since); + new.date_before = None; + } + + if let Some(date_before) = request.date_before { + new.date_before = Some(date_before); + new.date_since = None; } if let Some(folder_limit) = request.folder_limit { @@ -377,6 +453,11 @@ impl AccountV2 { if let Some(sync_interval_min) = &request.sync_interval_min { new.sync_interval_min = Some(*sync_interval_min); } + + if let Some(sync_batch_size) = &request.sync_batch_size { + new.sync_batch_size = Some(*sync_batch_size); + } + if let Some(use_proxy) = request.use_proxy { new.use_proxy = Some(use_proxy); } @@ -450,3 +531,54 @@ impl From for AccountV1 { } } } + +impl From 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 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, + sync_batch_size: None, + date_before: None, + } + } +} diff --git a/src/modules/account/mod.rs b/src/modules/account/mod.rs index 5d879a4..c49ea1c 100644 --- a/src/modules/account/mod.rs +++ b/src/modules/account/mod.rs @@ -16,10 +16,11 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - 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; diff --git a/src/modules/account/payload.rs b/src/modules/account/payload.rs index adc2f2c..5be9c83 100644 --- a/src/modules/account/payload.rs +++ b/src/modules/account/payload.rs @@ -16,14 +16,11 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -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::account::since::{DateSince, RelativeDate}; 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}; @@ -36,21 +33,37 @@ pub struct AccountCreateRequest { pub imap: Option, pub enabled: bool, pub date_since: Option, + pub date_before: Option, pub account_type: AccountType, #[oai(validator(minimum(value = "100")))] pub folder_limit: Option, #[oai(validator(minimum(value = "10"), maximum(value = "480")))] pub sync_interval_min: Option, + #[oai(validator(minimum(value = "30"), maximum(value = "200")))] + pub sync_batch_size: Option, pub use_proxy: Option, pub use_dangerous: bool, pub pgp_key: Option, } impl AccountCreateRequest { - pub fn create_entity(self) -> BichonResult { + pub fn create_entity(self, user_id: u64) -> BichonResult { + if self.date_before.is_some() && self.date_since.is_some() { + return Err(raise_error!( + "date_before and date_since are mutually exclusive; specify only one time boundary" + .into(), + ErrorCode::InvalidParameter + )); + } + if let Some(date_since) = self.date_since.as_ref() { date_since.validate()?; } + + if let Some(date_before) = self.date_before.as_ref() { + date_before.validate_date()?; + } + match self.account_type { AccountType::IMAP => { match &self.imap { @@ -71,7 +84,7 @@ impl AccountCreateRequest { } AccountType::NoSync => {} } - Ok(AccountModel::new(self)?) + Ok(AccountModel::new(user_id, self)?) } fn validate_request(imap: &ImapConfig, email: &str) -> BichonResult<()> { @@ -107,6 +120,7 @@ pub struct AccountUpdateRequest { /// - First-time sync optimization for large accounts /// - Reducing server load during resyncs pub date_since: Option, + pub date_before: Option, /// Max emails to sync for this folder. /// If not set, sync all emails. /// otherwise sync up to `n` most recent emails (min 10). @@ -129,6 +143,8 @@ pub struct AccountUpdateRequest { /// Incremental sync interval (seconds) #[oai(validator(minimum(value = "10"), maximum(value = "480")))] pub sync_interval_min: Option, + #[oai(validator(minimum(value = "30"), maximum(value = "200")))] + pub sync_batch_size: Option, /// Optional proxy ID for establishing the connection to external APIs (e.g., Gmail, Outlook). /// - If `None` or not provided, the client will connect directly to the API server. /// - If `Some(proxy_id)`, the client will use the pre-configured proxy with the given ID for API requests. @@ -141,9 +157,22 @@ pub struct AccountUpdateRequest { impl AccountUpdateRequest { pub fn validate_update_request(&self, account: &AccountModel) -> BichonResult<()> { + if self.date_before.is_some() && self.date_since.is_some() { + return Err(raise_error!( + "date_before and date_since are mutually exclusive; specify only one time boundary" + .into(), + ErrorCode::InvalidParameter + )); + } + if let Some(date_since) = self.date_since.as_ref() { date_since.validate()?; } + + if let Some(date_before) = self.date_before.as_ref() { + date_before.validate_date()?; + } + if matches!(account.account_type, AccountType::IMAP) { if let Some(mailboxes) = self.sync_folders.as_ref() { if mailboxes.is_empty() { @@ -167,11 +196,11 @@ pub struct MinimalAccount { pub fn filter_accessible_accounts<'a>( all_accounts: &'a [MinimalAccount], - allowed: &BTreeSet, + allowed: &Vec, ) -> Vec { all_accounts .iter() - .filter(|acct| allowed.iter().any(|a| a.id == acct.id)) + .filter(|acct| allowed.contains(&acct.id)) .cloned() .collect() } diff --git a/src/modules/account/since.rs b/src/modules/account/since.rs index 5ae340f..1336769 100644 --- a/src/modules/account/since.rs +++ b/src/modules/account/since.rs @@ -16,7 +16,6 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - use crate::{ modules::error::{code::ErrorCode, BichonResult}, raise_error, diff --git a/src/modules/account/view.rs b/src/modules/account/view.rs new file mode 100644 index 0000000..b967fb1 --- /dev/null +++ b/src/modules/account/view.rs @@ -0,0 +1,91 @@ +// +// 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 . + +use std::collections::{BTreeSet, HashMap}; + +use poem_openapi::Object; +use serde::{Deserialize, Serialize}; + +use crate::modules::{ + account::{ + entity::ImapConfig, + migration::{AccountModel, AccountType}, + since::{DateSince, RelativeDate}, + }, + users::UserModel, +}; + +#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)] +pub struct AccountResp { + pub id: u64, + pub imap: Option, + pub enabled: bool, + pub email: String, + pub name: Option, + pub capabilities: Option>, + pub date_since: Option, + pub date_before: Option, + pub folder_limit: Option, + pub sync_folders: Option>, + pub account_type: AccountType, + pub sync_interval_min: Option, + pub sync_batch_size: Option, + pub known_folders: Option>, + 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, + pub use_dangerous: bool, + pub pgp_key: Option, +} + +impl AccountResp { + pub fn from_model(account: AccountModel, user_map: &HashMap) -> 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, + date_before: account.date_before, + folder_limit: account.folder_limit, + sync_folders: account.sync_folders, + account_type: account.account_type, + sync_interval_min: account.sync_interval_min, + sync_batch_size: account.sync_batch_size, + 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, + } + } +} diff --git a/src/modules/cache/imap/sync/flow.rs b/src/modules/cache/imap/sync/flow.rs index b52d6f7..fd16c69 100644 --- a/src/modules/cache/imap/sync/flow.rs +++ b/src/modules/cache/imap/sync/flow.rs @@ -16,7 +16,6 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - use crate::{ modules::{ account::{migration::AccountModel, state::AccountRunningState}, @@ -24,7 +23,7 @@ use crate::{ imap::{ find_intersecting_mailboxes, find_missing_mailboxes, mailbox::MailBox, - sync::rebuild::{rebuild_mailbox_cache, rebuild_mailbox_cache_since_date}, + sync::rebuild::{rebuild_mailbox_cache, rebuild_mailbox_cache_by_date}, }, SEMAPHORE, }, @@ -37,17 +36,30 @@ use crate::{ use std::time::Instant; use tracing::{debug, error, info, warn}; -pub const BATCH_SIZE: u32 = 50; +pub const DEFAULT_BATCH_SIZE: u32 = 50; -pub async fn fetch_and_save_since_date( +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum FetchDirection { + Since, + Before, +} + +pub async fn fetch_and_save_by_date( account: &AccountModel, date: &str, mailbox: &MailBox, + direction: FetchDirection, ) -> BichonResult { let account_id = account.id; let executor = MAIL_CONTEXT.imap(account_id).await?; + + let search_criteria = match direction { + FetchDirection::Since => format!("SINCE {date}"), + FetchDirection::Before => format!("BEFORE {date}"), + }; + let uid_list = executor - .uid_search(&mailbox.encoded_name(), format!("SINCE {date}").as_str()) + .uid_search(&mailbox.encoded_name(), &search_criteria) .await?; let len = uid_list.len(); @@ -63,13 +75,23 @@ pub async fn fetch_and_save_since_date( if let Some(limit) = folder_limit { let limit = limit.max(100) as usize; if len > limit { - uid_vec = uid_vec.split_off(len - limit as usize); + uid_vec = match direction { + FetchDirection::Since => uid_vec.split_off(len - limit), + FetchDirection::Before => { + uid_vec.truncate(limit); + uid_vec + } + }; } } // let semaphore = Arc::new(Semaphore::new(5)); - let uid_batches = generate_uid_sequence_hashset(uid_vec, BATCH_SIZE as usize, false); + let uid_batches = generate_uid_sequence_hashset( + uid_vec, + account.sync_batch_size.unwrap_or(DEFAULT_BATCH_SIZE) as usize, + false, + ); AccountRunningState::set_initial_current_syncing_folder( account_id, mailbox.name.clone(), @@ -105,9 +127,11 @@ pub async fn fetch_and_save_full_mailbox( _ => total, }; let page_size = if let Some(limit) = folder_limit { - limit.max(100).min(BATCH_SIZE as u32) + limit + .max(100) + .min(account.sync_batch_size.unwrap_or(DEFAULT_BATCH_SIZE)) } else { - BATCH_SIZE as u32 + account.sync_batch_size.unwrap_or(DEFAULT_BATCH_SIZE) }; let total_batches = total_to_fetch.div_ceil(page_size); @@ -251,17 +275,30 @@ pub async fn reconcile_mailboxes( match &account.date_since { Some(date_since) => { - rebuild_mailbox_cache_since_date( + rebuild_mailbox_cache_by_date( account, local_mailbox.id, - date_since, + &date_since.since_date()?, remote_mailbox, + FetchDirection::Since, ) .await?; } - None => { - rebuild_mailbox_cache(account, local_mailbox, remote_mailbox).await?; - } + None => match &account.date_before { + Some(r) => { + rebuild_mailbox_cache_by_date( + account, + local_mailbox.id, + &r.calculate_date()?, + remote_mailbox, + FetchDirection::Before, + ) + .await?; + } + None => { + rebuild_mailbox_cache(account, local_mailbox, remote_mailbox).await? + } + }, } } else { perform_incremental_sync(account, local_mailbox, remote_mailbox).await?; @@ -305,14 +342,31 @@ pub async fn reconcile_mailboxes( let _permit = permit; match &account.date_since { Some(date_since) => { - rebuild_mailbox_cache_since_date( - &account, mailbox.id, date_since, &mailbox, + rebuild_mailbox_cache_by_date( + &account, + mailbox.id, + &date_since.since_date()?, + &mailbox, + FetchDirection::Since, ) .await } - None => { - rebuild_mailbox_cache(&account, &mailbox, &mailbox).await - } + None => match &account.date_before { + Some(r) => { + rebuild_mailbox_cache_by_date( + &account, + mailbox.id, + &r.calculate_date()?, + &mailbox, + FetchDirection::Before, + ) + .await + } + None => { + rebuild_mailbox_cache(&account, &mailbox, &mailbox) + .await + } + }, } }); handles.push(handle); @@ -348,8 +402,14 @@ async fn perform_incremental_sync( match local_max_uid { Some(max_uid) => { let executor = MAIL_CONTEXT.imap(account.id).await?; + let before_date = account + .date_before + .as_ref() + .map(|r| r.calculate_date()) + .transpose()?; + executor - .fetch_new_mail(account.id, local_mailbox, max_uid + 1) + .fetch_new_mail(account, local_mailbox, max_uid + 1, before_date.as_deref()) .await?; } None => { @@ -359,10 +419,11 @@ async fn perform_incremental_sync( match &account.date_since { Some(date_since) => { - fetch_and_save_since_date( + fetch_and_save_by_date( account, date_since.since_date()?.as_str(), remote_mailbox, + FetchDirection::Since, ) .await?; } diff --git a/src/modules/cache/imap/sync/mod.rs b/src/modules/cache/imap/sync/mod.rs index 76473e8..d1873a7 100644 --- a/src/modules/cache/imap/sync/mod.rs +++ b/src/modules/cache/imap/sync/mod.rs @@ -23,13 +23,13 @@ use crate::{ migration::{AccountModel, AccountType}, state::AccountRunningState, }, - cache::imap::mailbox::MailBox, + cache::imap::{mailbox::MailBox, sync::flow::FetchDirection}, error::BichonResult, }, utc_now, }; use flow::reconcile_mailboxes; -use rebuild::{rebuild_cache, rebuild_cache_since_date}; +use rebuild::{rebuild_cache, rebuild_cache_by_date}; use std::time::Instant; use sync_folders::get_sync_folders; use sync_type::{determine_sync_type, SyncType}; @@ -54,9 +54,26 @@ pub async fn execute_imap_sync(account: &AccountModel) -> BichonResult<()> { // AccountRunningState::set_initial_sync_start(account_id).await?; let result = match &account.date_since { Some(date_since) => { - rebuild_cache_since_date(account, &remote_mailboxes, date_since).await + rebuild_cache_by_date( + account, + &remote_mailboxes, + &date_since.since_date()?, + FetchDirection::Since, + ) + .await } - None => rebuild_cache(account, &remote_mailboxes).await, + None => match &account.date_before { + Some(r) => { + rebuild_cache_by_date( + account, + &remote_mailboxes, + &r.calculate_date()?, + FetchDirection::Before, + ) + .await + } + None => rebuild_cache(account, &remote_mailboxes).await, + }, }; match result { Ok(_) => { diff --git a/src/modules/cache/imap/sync/rebuild.rs b/src/modules/cache/imap/sync/rebuild.rs index 77ec860..00a3c60 100644 --- a/src/modules/cache/imap/sync/rebuild.rs +++ b/src/modules/cache/imap/sync/rebuild.rs @@ -16,14 +16,13 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - use crate::{ modules::{ - account::{migration::AccountModel, since::DateSince}, + account::migration::AccountModel, cache::{ imap::{ mailbox::MailBox, - sync::flow::{fetch_and_save_full_mailbox, fetch_and_save_since_date}, + sync::flow::{fetch_and_save_by_date, fetch_and_save_full_mailbox, FetchDirection}, }, SEMAPHORE, }, @@ -86,14 +85,14 @@ pub async fn rebuild_cache( Ok(()) } -pub async fn rebuild_cache_since_date( +pub async fn rebuild_cache_by_date( account: &AccountModel, remote_mailboxes: &[MailBox], - date_since: &DateSince, + date: &str, + direction: FetchDirection, ) -> BichonResult<()> { let start_time = Instant::now(); let mut total_inserted = 0; - let date = date_since.since_date()?; MailBox::batch_insert(remote_mailboxes).await?; let mut handles = Vec::new(); @@ -107,13 +106,14 @@ pub async fn rebuild_cache_since_date( } let account = account.clone(); let mailbox = mailbox.clone(); - let date = date.clone(); + let date = date.to_string(); + let direction = direction.clone(); match SEMAPHORE.clone().acquire_owned().await { Ok(permit) => { let handle: tokio::task::JoinHandle> = tokio::spawn(async move { let _permit = permit; // Ensure permit is released when task finishes - fetch_and_save_since_date(&account, date.as_str(), &mailbox).await + fetch_and_save_by_date(&account, date.as_str(), &mailbox, direction).await }); handles.push(handle); } @@ -132,10 +132,14 @@ pub async fn rebuild_cache_since_date( } } let elapsed_time = start_time.elapsed().as_secs(); + let direction_desc = match direction { + FetchDirection::Since => "starting from the specified date", + FetchDirection::Before => "ending before the specified date", + }; info!( "Rebuild account cache completed: {} envelopes inserted. {} secs elapsed. \ - Data fetched from server starting from the specified date: {}.", - total_inserted, elapsed_time, date + Data fetched from server {}: {}.", + total_inserted, elapsed_time, direction_desc, date ); Ok(()) } @@ -169,11 +173,12 @@ pub async fn rebuild_mailbox_cache( Ok(()) } -pub async fn rebuild_mailbox_cache_since_date( +pub async fn rebuild_mailbox_cache_by_date( account: &AccountModel, local_mailbox_id: u64, - date_since: &DateSince, + date: &str, remote: &MailBox, + direction: FetchDirection, ) -> BichonResult<()> { ENVELOPE_INDEX_MANAGER .delete_mailbox_envelopes(account.id, vec![local_mailbox_id]) @@ -190,8 +195,7 @@ pub async fn rebuild_mailbox_cache_since_date( return Ok(()); // Skip if the mailbox has no emails } - let count = - fetch_and_save_since_date(account, date_since.since_date()?.as_str(), remote).await?; + let count = fetch_and_save_by_date(account, date, remote, direction).await?; info!( "Account {}: Successfully rebuild mailbox cache, inserted {} envelopes for mailbox '{}'.", account.id, count, &remote.name diff --git a/src/modules/common/auth.rs b/src/modules/common/auth.rs index 84eb091..66d2d2b 100644 --- a/src/modules/common/auth.rs +++ b/src/modules/common/auth.rs @@ -16,12 +16,11 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - 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, UserModel}, 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 Endpoint for ApiGuardEndpoint { } } -#[derive(Clone, Debug, Default)] +#[derive(Clone, Debug)] pub struct ClientContext { pub ip_addr: Option, - pub access_token: Option, - pub is_root: bool, + pub user: UserModel, } 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>> { - 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, &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, 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, 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, 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, + 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 { - 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::>() - .map(|auth| auth.0.token().to_string()) - .or_else(|| req.params::().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::>() + .map(|auth| auth.0.token().to_string()) + .or_else(|| req.params::().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 { 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) diff --git a/src/modules/dashboard/mod.rs b/src/modules/dashboard/mod.rs index 66b787b..63f9996 100644 --- a/src/modules/dashboard/mod.rs +++ b/src/modules/dashboard/mod.rs @@ -16,14 +16,17 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +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 { - 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 { + let has_all_accounts = context + .has_permission(None, Permission::ACCOUNT_MANAGE_ALL) + .await; + + let authorized_ids: Option> = 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) } } diff --git a/src/modules/database/manager.rs b/src/modules/database/manager.rs index af1bf9c..8fe15e0 100644 --- a/src/modules/database/manager.rs +++ b/src/modules/database/manager.rs @@ -21,6 +21,7 @@ use crate::modules::cache::imap::MAILBOX_MODELS; use crate::modules::error::{code::ErrorCode, BichonError}; use crate::modules::settings::cli::SETTINGS; use crate::modules::settings::dir::DATA_DIR_MANAGER; +use crate::modules::users::UserModel; use crate::modules::{database::META_MODELS, error::BichonResult}; use crate::raise_error; use native_db::{Builder, Database}; @@ -73,6 +74,8 @@ impl DatabaseManager { .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; rw.migrate::() .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; + rw.migrate::() + .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; rw.commit() .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; diff --git a/src/modules/database/mod.rs b/src/modules/database/mod.rs index 3ed0ea9..02e2ed0 100644 --- a/src/modules/database/mod.rs +++ b/src/modules/database/mod.rs @@ -16,7 +16,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use crate::modules::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, BichonUserV2}; use crate::raise_error; use db_type::{KeyOptions, ToKeyDefinition}; use itertools::Itertools; @@ -58,15 +60,21 @@ impl ModelsAdapter { } pub fn register_metadata_models(&mut self) { - self.register_model::(); + //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::(); self.register_model::(); self.register_model::(); self.register_model::(); self.register_model::(); + self.register_model::(); self.register_model::(); self.register_model::(); self.register_model::(); self.register_model::(); + self.register_model::(); + self.register_model::(); + self.register_model::(); + self.register_model::(); } } @@ -170,11 +178,11 @@ pub async fn update_impl( .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 +231,20 @@ pub async fn async_find_impl( .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))? } -pub fn find_impl( - database: &Arc>, - key: &str, -) -> BichonResult> { - let db = database.clone(); - let r_transaction = db - .r_transaction() - .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; - let entity: Option = r_transaction - .get() - .primary(key) - .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; - Ok(entity) -} +// pub fn find_impl( +// database: &Arc>, +// key: &str, +// ) -> BichonResult> { +// let db = database.clone(); +// let r_transaction = db +// .r_transaction() +// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; +// let entity: Option = r_transaction +// .get() +// .primary(key) +// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; +// Ok(entity) +// } pub async fn delete_impl( database: &Arc>, @@ -307,6 +315,25 @@ pub async fn list_all_impl( .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))? } +pub async fn with_transaction( + database: &Arc>, + f: impl FnOnce(&RwTransaction) -> BichonResult<()> + Send + 'static, +) -> BichonResult<()> { + let db: Arc> = 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. diff --git a/src/modules/envelope/extractor.rs b/src/modules/envelope/extractor.rs index 23f1a1a..339ce9d 100644 --- a/src/modules/envelope/extractor.rs +++ b/src/modules/envelope/extractor.rs @@ -17,13 +17,14 @@ // along with this program. If not, see . use crate::modules::common::AddrVec; +use crate::modules::envelope::utils::normalize_subject; use crate::modules::error::code::ErrorCode; use crate::modules::error::BichonResult; use crate::modules::utils::create_hash; use crate::{calculate_hash, raise_error, utc_now}; use crate::{id, modules::indexer::envelope::Envelope}; use async_imap::types::Fetch; -use mail_parser::{Message, MessageParser, MimeHeaders}; +use mail_parser::{HeaderName, Message, MessageParser, MimeHeaders}; pub fn extract_envelope(fetch: &Fetch, account_id: u64, mailbox_id: u64) -> BichonResult { let internal_date = fetch @@ -62,7 +63,13 @@ pub fn extract_envelope(fetch: &Fetch, account_id: u64, mailbox_id: u64) -> Bich let in_reply_to = message.in_reply_to().as_text().map(String::from); let references = extract_references(&message); let thread_id = compute_thread_id(in_reply_to, references, &message_id); - let subject = message.subject().map(String::from).unwrap_or("".into()); + + let mut subject = message.subject().map(String::from).unwrap_or_default(); + + if subject.contains('\u{FFFD}') { + subject = normalize_subject(message.header_raw(HeaderName::Subject)); + } + let date = message.date().map(|d| d.to_timestamp() * 1000).unwrap_or(0); let bcc: Option> = message.bcc().map(|addr| { AddrVec::from(addr) @@ -114,6 +121,8 @@ pub fn extract_envelope(fetch: &Fetch, account_id: u64, mailbox_id: u64) -> Bich thread_id, attachments, tags: None, + account_email: None, + mailbox_name: None, }; Ok(envelope) } @@ -150,7 +159,12 @@ pub fn extract_envelope_from_eml( let in_reply_to = message.in_reply_to().as_text().map(String::from); let references = extract_references(&message); let thread_id = compute_thread_id(in_reply_to, references, &message_id); - let subject = message.subject().map(String::from).unwrap_or("".into()); + + let mut subject = message.subject().map(String::from).unwrap_or_default(); + if subject.contains('\u{FFFD}') { + subject = normalize_subject(message.header_raw(HeaderName::Subject)); + } + let date = message.date().map(|d| d.to_timestamp() * 1000).unwrap_or(0); let bcc: Option> = message.bcc().map(|addr| { AddrVec::from(addr) @@ -202,6 +216,8 @@ pub fn extract_envelope_from_eml( thread_id, attachments, tags: None, + account_email: None, + mailbox_name: None, }; Ok(envelope) } diff --git a/src/modules/envelope/mod.rs b/src/modules/envelope/mod.rs index 872957e..87f2ec9 100644 --- a/src/modules/envelope/mod.rs +++ b/src/modules/envelope/mod.rs @@ -16,5 +16,5 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - pub mod extractor; +pub mod utils; diff --git a/src/modules/envelope/utils.rs b/src/modules/envelope/utils.rs new file mode 100644 index 0000000..1fe281d --- /dev/null +++ b/src/modules/envelope/utils.rs @@ -0,0 +1,114 @@ +use mail_parser::parsers::MessageStream; +use regex::{Captures, Regex}; + +fn merge_contiguous_encoded_words(input: &str) -> String { + let block_re = + Regex::new(r"(?:=\?[^?]+\?[bBqQ]\?[^?]+\?=)(?:\s+(?:=\?[^?]+\?[bBqQ]\?[^?]+\?=))+") + .unwrap(); + + let word_re = Regex::new(r"=\?([^?]+)\?([bBqQ])\?([^?]+)\?=").unwrap(); + + block_re + .replace_all(input, |caps: &Captures| { + let whole = caps.get(0).unwrap().as_str(); + + let mut charset: Option = None; + let mut encoding: Option = None; + let mut combined = String::new(); + let mut ok = true; + + for cap in word_re.captures_iter(whole) { + let cs = &cap[1]; + let enc = cap[2].to_ascii_uppercase(); + let text = &cap[3]; + + if let Some(ref c) = charset { + if c != cs { + ok = false; + break; + } + } else { + charset = Some(cs.to_string()); + } + + if let Some(ref e) = encoding { + if e != &enc { + ok = false; + break; + } + } else { + encoding = Some(enc); + } + + combined.push_str(text); + } + + if ok { + format!( + "=?{}?{}?{}?=", + charset.unwrap(), + encoding.unwrap(), + combined + ) + } else { + whole.to_string() + } + }) + .to_string() +} + +pub fn normalize_subject(raw_subject: Option<&str>) -> String { + let subject = match raw_subject { + Some(subject) => merge_contiguous_encoded_words(subject), + None => return String::new(), + }; + + MessageStream::new(subject.as_bytes()) + .parse_unstructured() + .as_text() + .map(String::from) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use crate::modules::envelope::utils::merge_contiguous_encoded_words; + + + #[tokio::test] + async fn test3() { + let s = "Hello =?UTF-8?B?SGVsbG8=?= =?UTF-8?B?V29ybGQ=?= !!!"; + assert_eq!( + merge_contiguous_encoded_words(s), + "Hello =?UTF-8?B?SGVsbG8=V29ybGQ=?= !!!" + ); + + let s = "=?UTF-8?B?QQ==?= =?UTF-8?B?Qg==?= =?UTF-8?B?Qw==?="; + assert_eq!( + merge_contiguous_encoded_words(s), + "=?UTF-8?B?QQ==Qg==Qw==?=" + ); + + let s = "=?UTF-8?B?QQ==?= =?UTF-8?B?Qg==?= test =?UTF-8?B?Qw==?= =?UTF-8?B?RA==?="; + assert_eq!( + merge_contiguous_encoded_words(s), + "=?UTF-8?B?QQ==Qg==?= test =?UTF-8?B?Qw==RA==?=" + ); + + let s = "=?UTF-8?B?QQ==?= =?GBK?B?Qg==?="; + assert_eq!(merge_contiguous_encoded_words(s), s); + let s = "=?UTF-8?B?QQ==?= =?UTF-8?Q?Qg?="; + assert_eq!(merge_contiguous_encoded_words(s), s); + + let s = "=?UTF-8?b?QQ==?= =?UTF-8?B?Qg==?="; + assert_eq!(merge_contiguous_encoded_words(s), "=?UTF-8?B?QQ==Qg==?="); + let s = "Hello =?UTF-8?B?SGVsbG8=?= !!!"; + assert_eq!(merge_contiguous_encoded_words(s), s); + let s = "=?UTF-8?B?QQ==?= =?UTF-8?B?Qg==?="; + assert_eq!(merge_contiguous_encoded_words(s), "=?UTF-8?B?QQ==Qg==?="); + let s = "Just a normal subject line"; + assert_eq!(merge_contiguous_encoded_words(s), s); + let s = "=?UTF-8?Q?Hello_?= =?UTF-8?Q?World?="; + assert_eq!(merge_contiguous_encoded_words(s), "=?UTF-8?Q?Hello_World?="); + } +} diff --git a/src/modules/error/code.rs b/src/modules/error/code.rs index 38057af..662b84f 100644 --- a/src/modules/error/code.rs +++ b/src/modules/error/code.rs @@ -16,7 +16,6 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - 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 diff --git a/src/modules/imap/executor.rs b/src/modules/imap/executor.rs index 13f1320..95200d5 100644 --- a/src/modules/imap/executor.rs +++ b/src/modules/imap/executor.rs @@ -16,9 +16,10 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +use crate::modules::account::migration::AccountModel; use crate::modules::account::state::AccountRunningState; use crate::modules::cache::imap::mailbox::MailBox; -use crate::modules::cache::imap::sync::flow::{generate_uid_sequence_hashset, BATCH_SIZE}; +use crate::modules::cache::imap::sync::flow::{generate_uid_sequence_hashset, DEFAULT_BATCH_SIZE}; use crate::modules::envelope::extractor::extract_envelope; use crate::modules::error::code::ErrorCode; use crate::modules::indexer::manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER}; @@ -80,15 +81,22 @@ impl ImapExecutor { pub async fn fetch_new_mail( &self, - account_id: u64, + account: &AccountModel, mailbox: &MailBox, start_uid: u64, + before: Option<&str> ) -> BichonResult<()> { assert!(start_uid > 0, "start_uid must be greater than 0"); + + let query = match before { + Some(date) => format!("UID {start_uid}:* BEFORE {date}"), + None => format!("UID {start_uid}:*"), + }; + let uid_list = self .uid_search( &mailbox.encoded_name(), - format!("UID {start_uid}:*").as_str(), + &query, ) .await?; @@ -98,17 +106,21 @@ impl ImapExecutor { } info!( "[account {}][mailbox {}] {} envelopes need to be fetched", - account_id, mailbox.name, len + account.id, mailbox.name, len ); let mut uid_vec: Vec = uid_list.into_iter().collect(); uid_vec.sort(); - let uid_batches = generate_uid_sequence_hashset(uid_vec, BATCH_SIZE as usize, false); + let uid_batches = generate_uid_sequence_hashset( + uid_vec, + account.sync_batch_size.unwrap_or(DEFAULT_BATCH_SIZE) as usize, + false, + ); - let too_many = len as u32 > 10 * BATCH_SIZE; + let too_many = len as u32 > 5 * account.sync_batch_size.unwrap_or(DEFAULT_BATCH_SIZE); if too_many { AccountRunningState::set_initial_current_syncing_folder( - account_id, + account.id, mailbox.name.clone(), uid_batches.len() as u32, ) @@ -118,13 +130,13 @@ impl ImapExecutor { for (index, batch) in uid_batches.into_iter().enumerate() { if too_many { AccountRunningState::set_current_sync_batch_number( - account_id, + account.id, mailbox.name.clone(), (index + 1) as u32, ) .await?; } - self.uid_batch_retrieve_emails(account_id, mailbox.id, &batch, &mailbox.encoded_name()) + self.uid_batch_retrieve_emails(account.id, mailbox.id, &batch, &mailbox.encoded_name()) .await?; } Ok(()) diff --git a/src/modules/imap/manager.rs b/src/modules/imap/manager.rs index cf92b01..4a5f158 100644 --- a/src/modules/imap/manager.rs +++ b/src/modules/imap/manager.rs @@ -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 + }) } } } diff --git a/src/modules/imap/pool.rs b/src/modules/imap/pool.rs index f444b4a..218ee45 100644 --- a/src/modules/imap/pool.rs +++ b/src/modules/imap/pool.rs @@ -16,7 +16,6 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - use crate::modules::error::code::ErrorCode; use crate::modules::error::{BichonError, BichonResult}; use crate::modules::imap::{manager::ImapConnectionManager, session::SessionStream}; diff --git a/src/modules/imap/tests.rs b/src/modules/imap/tests.rs index f403cb7..67b390d 100644 --- a/src/modules/imap/tests.rs +++ b/src/modules/imap/tests.rs @@ -16,11 +16,13 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use mail_parser::MessageParser; +use mail_parser::{parsers::MessageStream, HeaderName, MessageParser}; use crate::{ base64_encode_url_safe, - modules::{account::entity::Encryption, imap::client::Client}, + modules::{ + account::entity::Encryption, envelope::utils::normalize_subject, imap::client::Client, + }, }; #[tokio::test] @@ -48,3 +50,72 @@ async fn test1() { println!("{}", part.is_multipart()); } } + +#[tokio::test] +async fn test2() { + const MESSAGE: &str = r#"From: Art Vandelay (Vandelay Industries) +To: "Colleagues": "James Smythe" ; Friends: + jane@example.com, =?UTF-8?Q?John_Sm=C3=AEth?= ; +Date: Sat, 20 Nov 2021 14:22:01 -0800 +Subject: =?utf-8?B?SnVzdCAxNSBkYXlzIGxlZnQgdG8gdmlzaXQgTkFSTklBISDinYTvuI/wn462?= +Content-Type: multipart/mixed; boundary="festivus"; + +--festivus +Content-Type: text/html; charset="us-ascii" +Content-Transfer-Encoding: base64 + +PGh0bWw+PHA+SSB3YXMgdGhpbmtpbmcgYWJvdXQgcXVpdHRpbmcgdGhlICZsZHF1bztle +HBvcnRpbmcmcmRxdW87IHRvIGZvY3VzIGp1c3Qgb24gdGhlICZsZHF1bztpbXBvcnRpbm +cmcmRxdW87LDwvcD48cD5idXQgdGhlbiBJIHRob3VnaHQsIHdoeSBub3QgZG8gYm90aD8 +gJiN4MjYzQTs8L3A+PC9odG1sPg== +--festivus +Content-Type: message/rfc822 + +From: "Cosmo Kramer" +Subject: Exporting my book about coffee tables +Content-Type: multipart/mixed; boundary="giddyup"; + +--giddyup +Content-Type: text/plain; charset="utf-16" +Content-Transfer-Encoding: quoted-printable + +=FF=FE=0C!5=D8"=DD5=D8)=DD5=D8-=DD =005=D8*=DD5=D8"=DD =005=D8"= +=DD5=D85=DD5=D8-=DD5=D8,=DD5=D8/=DD5=D81=DD =005=D8*=DD5=D86=DD = +=005=D8=1F=DD5=D8,=DD5=D8,=DD5=D8(=DD =005=D8-=DD5=D8)=DD5=D8"= +=DD5=D8=1E=DD5=D80=DD5=D8"=DD!=00 +--giddyup +Content-Type: image/gif; name*1="about "; name*0="Book "; + name*2*=utf-8''%e2%98%95 tables.gif +Content-Transfer-Encoding: Base64 +Content-Disposition: attachment + +R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7 +--giddyup-- +--festivus-- +"#; + + let message = MessageParser::default().parse(MESSAGE).unwrap(); + let raw_subject = message.header_raw("Subject").unwrap().as_bytes(); + + let data = MessageStream::new(raw_subject) + .parse_unstructured() + .unwrap_text() + .to_string(); + + println!("{}", data); + // RFC2047 support for encoded text in message readers + println!("{}", message.subject().unwrap()); +} + +#[tokio::test] +async fn test44() { + let path = r"C:\Users\polly\Downloads\test222.eml"; + let input = std::fs::read(path).unwrap(); + let message = MessageParser::default().parse(&input).unwrap(); + let subject = message.subject().unwrap(); + println!("Subject: {}", subject); + if subject.contains('\u{FFFD}') { + let subject = normalize_subject(message.header_raw(HeaderName::Subject)); + println!("Subject: {}", subject); + } +} diff --git a/src/modules/indexer/envelope.rs b/src/modules/indexer/envelope.rs index 4007fbf..e89c65d 100644 --- a/src/modules/indexer/envelope.rs +++ b/src/modules/indexer/envelope.rs @@ -16,7 +16,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - +use crate::modules::account::migration::AccountModel; +use crate::modules::cache::imap::mailbox::MailBox; use crate::modules::error::code::ErrorCode; use crate::modules::utils::create_hash; use crate::modules::{error::BichonResult, indexer::schema::SchemaTools}; @@ -31,7 +32,9 @@ pub struct Envelope { pub id: u64, pub message_id: String, pub account_id: u64, + pub account_email: Option, pub mailbox_id: u64, + pub mailbox_name: Option, pub uid: u32, pub subject: String, pub text: String, @@ -169,11 +172,20 @@ impl Envelope { }) .flatten() .collect(); + let account_email = AccountModel::find(account_id).await?.map(|a| a.email); + + let mailboxes = MailBox::list_all(account_id).await?; + let mailbox_name = mailboxes + .iter() + .find(|m| m.id == mailbox_id) + .map(|m| m.name.clone()); let envelope = Envelope { id, account_id, + account_email, mailbox_id, + mailbox_name, message_id: extract_string_field(doc, fields.f_message_id)?, uid: extract_u64_field(doc, fields.f_uid)? as u32, subject: extract_string_field(doc, fields.f_subject)?, diff --git a/src/modules/indexer/manager.rs b/src/modules/indexer/manager.rs index 1e648fe..c54cb52 100644 --- a/src/modules/indexer/manager.rs +++ b/src/modules/indexer/manager.rs @@ -16,7 +16,6 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - use std::{ collections::{HashMap, HashSet}, ops::Bound, @@ -35,8 +34,8 @@ use crate::{ indexer::{ envelope::Envelope, fields::{ - F_ACCOUNT_ID, F_FROM, F_HAS_ATTACHMENT, F_INTERNAL_DATE, F_MAILBOX_ID, F_SIZE, - F_TAGS, F_THREAD_ID, F_UID, + F_ACCOUNT_ID, F_DATE, F_FROM, F_HAS_ATTACHMENT, F_MAILBOX_ID, F_SIZE, F_TAGS, + F_THREAD_ID, F_UID, }, schema::SchemaTools, }, @@ -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 { + pub fn total_emails(&self, accounts: &Option>) -> BichonResult { 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, + )); + } + let query = Box::new(BooleanQuery::new(subqueries)) as Box; + 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 { @@ -223,12 +242,36 @@ impl EnvelopeIndexManager { fn filter_query( &self, + accounts: Option>, filter: SearchFilter, parser: QueryParser, ) -> BichonResult> { let f = SchemaTools::envelope_fields(); let mut subqueries: Vec<(Occur, Box)> = 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, + )); + } + subqueries.push(( + Occur::Must, + Box::new(BooleanQuery::new(account_must_queries)), + )); + } + } + if let Some(ref text) = filter.text { let query = parser .parse_query(text) @@ -292,13 +335,13 @@ impl EnvelopeIndexManager { } let start_bound = if let Some(from) = filter.since { - Bound::Included(Term::from_field_i64(f.f_internal_date, from)) + Bound::Included(Term::from_field_i64(f.f_date, from)) } else { Bound::Unbounded }; let end_bound = if let Some(to) = filter.before { - Bound::Included(Term::from_field_i64(f.f_internal_date, to)) + Bound::Included(Term::from_field_i64(f.f_date, to)) } else { Bound::Unbounded }; @@ -426,14 +469,16 @@ impl EnvelopeIndexManager { } fn collect_facets_recursive( + query: &dyn Query, searcher: &Searcher, parent_facet: &str, all_facets: &mut Vec, ) -> 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> { + pub async fn get_all_tags( + &self, + accounts: Option>, + ) -> BichonResult> { let searcher = self.reader.searcher(); + + let query: Box = 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, + )); + } + 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>, filter: SearchFilter, page: u64, page_size: u64, @@ -557,7 +624,7 @@ impl EnvelopeIndexManager { ) -> BichonResult> { 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) @@ -591,7 +658,7 @@ impl EnvelopeIndexManager { &query, &TopDocs::with_limit(page_size as usize) .and_offset(offset as usize) - .order_by_fast_field(F_INTERNAL_DATE, order), + .order_by_fast_field(F_DATE, order), ) .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; let mut result = Vec::new(); @@ -654,7 +721,7 @@ impl EnvelopeIndexManager { query.as_ref(), &TopDocs::with_limit(page_size as usize) .and_offset(offset as usize) - .order_by_fast_field(F_INTERNAL_DATE, order), + .order_by_fast_field(F_DATE, order), ) .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; let mut result = Vec::new(); @@ -719,7 +786,7 @@ impl EnvelopeIndexManager { query.as_ref(), &TopDocs::with_limit(page_size as usize) .and_offset(offset as usize) - .order_by_fast_field(F_INTERNAL_DATE, order), + .order_by_fast_field(F_DATE, order), ) .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; let mut result = Vec::new(); @@ -782,15 +849,36 @@ impl EnvelopeIndexManager { } } - pub async fn top_10_largest_emails(&self) -> BichonResult> { + + pub async fn top_10_largest_emails( + &self, + accounts: &Option>, + ) -> BichonResult> { self.reader .reload() .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; let searcher = self.reader.searcher(); + let query: Box = 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, + )); + } + 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))?; @@ -946,7 +1034,10 @@ impl EnvelopeIndexManager { Ok(self.reader.searcher()) } - pub async fn get_dashboard_stats(&self) -> BichonResult { + pub async fn get_dashboard_stats( + &self, + accounts: &Option>, + ) -> BichonResult { 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(); @@ -957,7 +1048,7 @@ impl EnvelopeIndexManager { }, "recent_30d_histogram": { "histogram": { - "field": F_INTERNAL_DATE, + "field": F_DATE, "interval": 86400000, "hard_bounds": { "min": week_ago_ms, @@ -985,7 +1076,23 @@ impl EnvelopeIndexManager { })) .unwrap(); - let query = AllQuery; + let query: Box = 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, + )); + } + 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) diff --git a/src/modules/mailbox/list.rs b/src/modules/mailbox/list.rs index 7d2a218..3f0e420 100644 --- a/src/modules/mailbox/list.rs +++ b/src/modules/mailbox/list.rs @@ -16,7 +16,6 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - use crate::modules::account::migration::{AccountModel, AccountType}; use crate::modules::cache::imap::mailbox::{Attribute, AttributeEnum, MailBox}; use crate::modules::context::executors::MAIL_CONTEXT; @@ -64,8 +63,15 @@ pub async fn convert_names_to_mailboxes( for name in names.into_iter() { // Convert the name into a MailBox structure let mailbox_name = name.name().to_string(); + let mut mailbox: MailBox = name.into(); + tracing::debug!( + raw = &mailbox_name, + decoded = &mailbox.name, + "mailbox name comparison" + ); + if contains_no_select(&mailbox.attributes) { continue; } diff --git a/src/modules/message/search.rs b/src/modules/message/search.rs index c762eec..7a78c19 100644 --- a/src/modules/message/search.rs +++ b/src/modules/message/search.rs @@ -16,6 +16,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +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> { +pub async fn search_messages_impl( + accounts: Option>, + request: SearchRequest, +) -> BichonResult> { 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 } diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 1264760..c551325 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -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; diff --git a/src/modules/oauth2/entity.rs b/src/modules/oauth2/entity.rs index bf23663..697573d 100644 --- a/src/modules/oauth2/entity.rs +++ b/src/modules/oauth2/entity.rs @@ -16,7 +16,6 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - 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(()) diff --git a/src/modules/rest/api/access_token.rs b/src/modules/rest/api/access_token.rs index 231a430..0867f50 100644 --- a/src/modules/rest/api/access_token.rs +++ b/src/modules/rest/api/access_token.rs @@ -16,16 +16,12 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - 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>> { - context.require_root()?; - Ok(Json(AccessToken::list_all().await?)) + ) -> ApiResult>> { + 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, - context: ClientContext, - ) -> ApiResult>> { - 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, 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, ) -> ApiResult> { - 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, - /// The request payload. - payload: Json, - ) -> 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> { - 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, - 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)) } } diff --git a/src/modules/rest/api/account.rs b/src/modules/rest/api/account.rs index 2ff1dc6..1b14711 100644 --- a/src/modules/rest/api/account.rs +++ b/src/modules/rest/api/account.rs @@ -16,23 +16,25 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -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::UserModel; use crate::raise_error; -use poem::web::Path; -use poem_openapi::param::Query; +use poem_openapi::param::{Path, Query}; use poem_openapi::payload::Json; use poem_openapi::OpenApi; @@ -53,7 +55,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_READ_DETAILS) + .await?; Ok(Json(AccountModel::get(account_id).await?)) } @@ -70,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?) } @@ -82,14 +88,10 @@ impl AccountApi { payload: Json, context: ClientContext, ) -> ApiResult> { - 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)) } @@ -108,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?) } @@ -123,35 +127,61 @@ impl AccountApi { /// Optional. Whether to sort the list in descending order. desc: Query>, context: ClientContext, - ) -> ApiResult>> { - let accessible_accounts = context.accessible_accounts()?; + ) -> ApiResult>> { + 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 = - accessible_accounts.unwrap().iter().map(|a| a.id).collect(); - - let mut filtered_accounts: Vec = all_accounts + let user_map: HashMap = UserModel::list_all() + .await? .into_iter() - .filter(|acct| allowed_ids.contains(&acct.id)) + .map(|u| (u.id, u)) + .collect(); + let page_data: DataPage = if is_admin { + AccountModel::paginate_list(page.0, page_size.0, desc.0).await? + } else { + let authorized_ids: HashSet = + 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::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 @@ -168,7 +198,9 @@ impl AccountApi { ) -> ApiResult> { 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(), @@ -191,13 +223,25 @@ impl AccountApi { &self, context: ClientContext, ) -> ApiResult>> { - 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 = 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, + context: ClientContext, + ) -> ApiResult<()> { + req.validate_existence().await?; + req.0.do_assign(&context).await?; + Ok(()) + } } diff --git a/src/modules/rest/api/auto_config.rs b/src/modules/rest/api/auto_config.rs index 299c703..f209181 100644 --- a/src/modules/rest/api/auto_config.rs +++ b/src/modules/rest/api/auto_config.rs @@ -16,14 +16,15 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - 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::web::Path; +use poem_openapi::param::Path; use poem_openapi::payload::Json; use poem_openapi::OpenApi; @@ -40,8 +41,13 @@ impl AutoConfigApi { async fn autoconfig( &self, /// The email address to lookup configuration for - email_address: Path + email_address: Path, + context: ClientContext, ) -> ApiResult> { + context + .require_permission(None, Permission::ACCOUNT_CREATE) + .await?; + let result = resolve_autoconfig(email_address.0.trim()) .await? .ok_or_else(|| { diff --git a/src/modules/rest/api/import.rs b/src/modules/rest/api/import.rs index ede9650..94e07ae 100644 --- a/src/modules/rest/api/import.rs +++ b/src/modules/rest/api/import.rs @@ -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, context: ClientContext, ) -> ApiResult> { - context.require_root()?; + context + .require_permission(Some(payload.0.account_id), Permission::DATA_IMPORT_BATCH) + .await?; Ok(Json(ImportEmls::do_import(payload.0).await?)) } } diff --git a/src/modules/rest/api/mailbox.rs b/src/modules/rest/api/mailbox.rs index 155ec95..077f9e2 100644 --- a/src/modules/rest/api/mailbox.rs +++ b/src/modules/rest/api/mailbox.rs @@ -16,14 +16,13 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - use crate::modules::cache::imap::mailbox::MailBox; use crate::modules::common::auth::ClientContext; use crate::modules::mailbox::list::get_account_mailboxes; use crate::modules::rest::api::ApiTags; use crate::modules::rest::ApiResult; -use poem::web::Path; -use poem_openapi::param::Query; +use crate::modules::users::permissions::Permission; +use poem_openapi::param::{Path, Query}; use poem_openapi::payload::Json; use poem_openapi::OpenApi; @@ -53,7 +52,9 @@ impl MailBoxApi { context: ClientContext, ) -> ApiResult>> { 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?)) } diff --git a/src/modules/rest/api/message.rs b/src/modules/rest/api/message.rs index 97d3556..736b43d 100644 --- a/src/modules/rest/api/message.rs +++ b/src/modules/rest/api/message.rs @@ -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?) } @@ -80,7 +84,9 @@ impl MessageApi { ) -> ApiResult>> { 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?, )) @@ -98,8 +104,15 @@ impl MessageApi { payload: Json, context: ClientContext, ) -> ApiResult>> { - context.require_root()?; - Ok(Json(search_messages_impl(payload.0).await?)) + let authorized_ids: Option> = 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?)) } /// Retrieves all messages belonging to a specific thread. Requires `thread_id`, `page`, and `page_size` query parameters. @@ -122,7 +135,9 @@ impl MessageApi { ) -> ApiResult>> { 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?, )) @@ -143,7 +158,9 @@ impl MessageApi { context: ClientContext, ) -> ApiResult> { 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?)) } @@ -194,9 +211,10 @@ impl MessageApi { ) -> ApiResult> { let account_id = account_id.0; AccountModel::check_account_exists(account_id).await?; - context.require_account_access(account_id)?; - let message_id = message_id.0; - let reader = EML_INDEX_MANAGER.get_reader(account_id, message_id).await?; + context + .require_permission(Some(account_id), Permission::DATA_RAW_DOWNLOAD) + .await?; + let reader = EML_INDEX_MANAGER.get_reader(account_id, message_id.0).await?; let body = Body::from_async_read(reader); let attachment = Attachment::new(body) .attachment_type(AttachmentType::Attachment) @@ -222,11 +240,12 @@ impl MessageApi { ) -> ApiResult> { let account_id = account_id.0; AccountModel::check_account_exists(account_id).await?; - context.require_account_access(account_id)?; - let message_id = message_id.0; + context + .require_permission(Some(account_id), Permission::DATA_READ) + .await?; let name = name.0.trim(); let reader = EML_INDEX_MANAGER - .get_attachment(account_id, message_id, name) + .get_attachment(account_id, message_id.0, name) .await?; let body = Body::from_async_read(reader); let attachment = Attachment::new(body) @@ -236,8 +255,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>> { - Ok(Json(ENVELOPE_INDEX_MANAGER.get_all_tags().await?)) + async fn get_all_tags(&self, context: ClientContext) -> ApiResult>> { + let authorized_ids: Option> = 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. @@ -246,12 +275,23 @@ impl MessageApi { method = "post", operation_id = "update_envelope_tags" )] - async fn update_envelope_tags(&self, req: Json) -> ApiResult<()> { + async fn update_envelope_tags( + &self, + req: Json, + 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?; diff --git a/src/modules/rest/api/mod.rs b/src/modules/rest/api/mod.rs index 5248db4..087fffa 100644 --- a/src/modules/rest/api/mod.rs +++ b/src/modules/rest/api/mod.rs @@ -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 { @@ -70,6 +76,7 @@ pub fn create_openapi_service() -> OpenApiService { OAuth2Api, MessageApi, ImportApi, + UsersApi, ), "BichonApi", bichon_version!(), diff --git a/src/modules/rest/api/oauth2.rs b/src/modules/rest/api/oauth2.rs index 2606cfa..6bd66d0 100644 --- a/src/modules/rest/api/oauth2.rs +++ b/src/modules/rest/api/oauth2.rs @@ -16,7 +16,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - +use crate::modules::account::migration::AccountModel; use crate::modules::common::auth::ClientContext; use crate::modules::error::code::ErrorCode; use crate::modules::oauth2::entity::{OAuth2, OAuth2CreateRequest, OAuth2UpdateRequest}; @@ -25,9 +25,9 @@ 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::web::Path; -use poem_openapi::param::Query; +use poem_openapi::param::{Path, Query}; use poem_openapi::payload::{Json, PlainText}; use poem_openapi::OpenApi; @@ -50,14 +50,26 @@ impl OAuth2Api { id: Path, context: ClientContext, ) -> ApiResult> { - 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. @@ -75,7 +87,9 @@ impl OAuth2Api { id: Path, context: ClientContext, ) -> ApiResult<()> { - context.require_root()?; + context + .require_permission(None, Permission::ROOT) + .await?; Ok(OAuth2::delete(id.0).await?) } @@ -94,7 +108,9 @@ impl OAuth2Api { request: Json, context: ClientContext, ) -> ApiResult<()> { - context.require_root()?; + context + .require_permission(None, Permission::ROOT) + .await?; let entity = OAuth2::new(request.0)?; Ok(entity.save().await?) } @@ -116,7 +132,9 @@ impl OAuth2Api { payload: Json, context: ClientContext, ) -> ApiResult<()> { - context.require_root()?; + context + .require_permission(None, Permission::ROOT) + .await?; Ok(OAuth2::update(id.0, payload.0).await?) } @@ -139,10 +157,23 @@ impl OAuth2Api { desc: Query>, context: ClientContext, ) -> ApiResult>> { - 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. @@ -160,8 +191,14 @@ impl OAuth2Api { request: Json, context: ClientContext, ) -> ApiResult> { - 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?)) } @@ -181,7 +218,9 @@ impl OAuth2Api { context: ClientContext, ) -> ApiResult> { 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!( @@ -219,10 +258,13 @@ impl OAuth2Api { request: Json, 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(()) } } diff --git a/src/modules/rest/api/system.rs b/src/modules/rest/api/system.rs index 3384372..84ebde6 100644 --- a/src/modules/rest/api/system.rs +++ b/src/modules/rest/api/system.rs @@ -16,13 +16,15 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - 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> { - let stats = DashboardStats::get().await?; + async fn get_dashboard_stats(&self, context: ClientContext) -> ApiResult> { + 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>> { + async fn list_proxy(&self, context: ClientContext) -> ApiResult>> { + 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, 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, context: ClientContext, ) -> ApiResult> { - 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, 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, 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> { + context + .require_permission(None, Permission::ROOT) + .await?; + let config: SystemConfigurations = SystemConfigurations::from(&*SETTINGS); + Ok(Json(config)) + } } diff --git a/src/modules/rest/api/users.rs b/src/modules/rest/api/users.rs new file mode 100644 index 0000000..84a0397 --- /dev/null +++ b/src/modules/rest/api/users.rs @@ -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 . + +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::UserModel; +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>> { + 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, + 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, + context: ClientContext, + ) -> ApiResult> { + 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, + /// Role update request payload + payload: Json, + 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>> { + context + .require_permission(None, Permission::USER_MANAGE) + .await?; + let roles = UserRole::list_all().await?; + let role_lookup: BTreeMap = roles.into_iter().map(|r| (r.id, r)).collect(); + let users = UserModel::list_all().await?; + let users = users + .into_iter() + .map(|u| u.to_view(&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, + context: ClientContext, + ) -> ApiResult>> { + 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, + context: ClientContext, + ) -> ApiResult<()> { + let id = id.0; + context + .require_permission(None, Permission::USER_MANAGE) + .await?; + Ok(UserModel::remove(id).await?) + } + + #[oai(path = "/users", method = "post", operation_id = "create_user")] + async fn create_user( + &self, + payload: Json, + context: ClientContext, + ) -> ApiResult> { + context + .require_permission(None, Permission::USER_MANAGE) + .await?; + let user = UserModel::create(payload.0).await?; + let roles = UserRole::list_all().await?; + let role_lookup: BTreeMap = roles.into_iter().map(|r| (r.id, r)).collect(); + Ok(Json(user.to_view(&role_lookup))) + } + + #[oai(path = "/users/:id", method = "post", operation_id = "update_user")] + async fn update_user( + &self, + id: Path, + payload: Json, + 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(UserModel::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> { + let roles = UserRole::list_all().await?; + let role_lookup: BTreeMap = roles.into_iter().map(|r| (r.id, r)).collect(); + Ok(Json(context.user.to_view(&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>> { + 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)) + } +} diff --git a/src/modules/rest/mod.rs b/src/modules/rest/mod.rs index 2b498eb..541ff3d 100644 --- a/src/modules/rest/mod.rs +++ b/src/modules/rest/mod.rs @@ -78,8 +78,7 @@ pub async fn start_http_server() -> BichonResult<()> { .with(Timeout) .with(Tracing); - let cors_origins: Option> = - SETTINGS.bichon_cors_origins.clone(); + let cors_origins: Option> = SETTINGS.bichon_cors_origins.clone(); let cors_origins: Vec = cors_origins.unwrap_or_default().into_iter().collect(); diff --git a/src/modules/rest/public/login.rs b/src/modules/rest/public/login.rs index 1487fb6..67afb46 100644 --- a/src/modules/rest/public/login.rs +++ b/src/modules/rest/public/login.rs @@ -16,29 +16,41 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - -use crate::modules::token::root::check_root_password; -use poem::{handler, IntoResponse, Response}; +use crate::modules::users::UserModel; +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) -> Response { + let payload = payload.0; + match UserModel::authenticate_user(payload.username, payload.password).await { + Ok(result) => match serde_json::to_string(&result) { + Ok(json_string) => Response::builder() + .status(http::StatusCode::OK) + .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() } } diff --git a/src/modules/settings/cli.rs b/src/modules/settings/cli.rs index 0f99b18..fbc58f7 100644 --- a/src/modules/settings/cli.rs +++ b/src/modules/settings/cli.rs @@ -19,7 +19,7 @@ use clap::{builder::ValueParser, Parser, ValueEnum}; use std::{collections::HashSet, env, fmt, path::PathBuf, sync::LazyLock}; -pub static SETTINGS: LazyLock = LazyLock::new(Settings::parse); +pub static SETTINGS: LazyLock = 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, + + #[clap( + long, + env, + help = "The file containing the encryption password. An alternative to --bichon-encrypt-password." + )] + pub bichon_encrypt_password_file: Option, + + /// 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, - /// 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, } +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")] diff --git a/src/modules/settings/mod.rs b/src/modules/settings/mod.rs index e36694e..2fe55d5 100644 --- a/src/modules/settings/mod.rs +++ b/src/modules/settings/mod.rs @@ -16,8 +16,68 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +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, + pub bichon_public_url: String, + + pub bichon_cors_origins: Option>, + 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, + pub bichon_envelope_cache_size: Option, + + pub bichon_enable_rest_https: bool, + pub bichon_http_compression_enabled: bool, + pub bichon_sync_concurrency: Option, +} + +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, + } + } +} diff --git a/src/modules/settings/proxy.rs b/src/modules/settings/proxy.rs index df3845a..89e42b8 100644 --- a/src/modules/settings/proxy.rs +++ b/src/modules/settings/proxy.rs @@ -16,7 +16,6 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - 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()); diff --git a/src/modules/settings/system.rs b/src/modules/settings/system.rs index 4369bec..04247f2 100644 --- a/src/modules/settings/system.rs +++ b/src/modules/settings/system.rs @@ -17,10 +17,10 @@ // along with this program. If not, see . -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> { - find_impl(DB_MANAGER.meta_db(), key) - } + // pub fn get(key: &str) -> BichonResult> { + // find_impl(DB_MANAGER.meta_db(), key) + // } // pub async fn list() -> RustMailerResult> { // list_all_impl(DB_MANAGER.metadata_db()).await // } - pub fn get_existing_value(key: &str) -> BichonResult> { - let setting = Self::get(key)?; - Ok(setting.map(|s| s.value)) - } + // pub fn get_existing_value(key: &str) -> BichonResult> { + // 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 + // } } diff --git a/src/modules/token/mod.rs b/src/modules/token/mod.rs index 353044c..7e0f3dd 100644 --- a/src/modules/token/mod.rs +++ b/src/modules/token/mod.rs @@ -16,12 +16,17 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +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::UserModel; 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, +// /// 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, +// /// The timestamp (in milliseconds since epoch) when the token was last used. +// pub last_access_at: i64, +// /// Optional access control settings +// pub acl: Option, +// } + +#[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, + /// An optional name of the token. + pub name: Option, + /// 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, + /// 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, /// The timestamp (in milliseconds since epoch) when the token was last used. pub last_access_at: i64, - /// Optional access control settings - pub acl: Option, } -#[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 { - 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>, - /// An optional rate limit configuration for the access token. - pub rate_limit: Option, -} - -impl AccessControl { - pub fn validate(&self) -> BichonResult<()> { - if let Some(ip_whitelist) = &self.ip_whitelist { - for ip in ip_whitelist { - if ip.parse::().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, - description: Option, - acl: Option, + user_id: u64, + name: Option, + expire_at: Option, ) -> 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 { - let token = token.to_string(); - update_impl( - DB_MANAGER.meta_db(), - |rw| { - rw.get() - .primary::(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 { + 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> { + let tokens = filter_by_secondary_key_impl::( DB_MANAGER.meta_db(), - move |rw| { - rw.get() - .primary::(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> { + let tokens = filter_by_secondary_key_impl::( + 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 { + let token = token.to_string(); + let token_option = async_find_impl::(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::(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 { - // 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::(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 = UserModel::find(token.user_id) + .await? + .ok_or_else(|| raise_error!("The user associated with this access token does not exist or may have been deleted.".into(), ErrorCode::ResourceNotFound))?; + Ok(user) + } + + pub async fn create_api_token( + user_id: u64, + request: AccessTokenCreateRequest, + ) -> BichonResult { + // 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::(token.clone()) + .primary::(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> { - list_all_impl(DB_MANAGER.meta_db()).await + pub async fn get_token(token: &str) -> BichonResult { + 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> { - let all = AccessToken::list_all().await?; - let result: Vec = all + pub async fn list_all_api_tokens() -> BichonResult> { + let users = UserModel::list_all().await?; + let mut all = list_all_impl::(DB_MANAGER.meta_db()).await?; + + all.retain(|t| t.token_type == TokenType::Api); + let user_map: HashMap = users.into_iter().map(|u| (u.id, u)).collect(); + + let 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::(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) } } diff --git a/src/modules/token/payload.rs b/src/modules/token/payload.rs index fe7965d..7f9e1fd 100644 --- a/src/modules/token/payload.rs +++ b/src/modules/token/payload.rs @@ -16,15 +16,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - -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, /// An optional description of the token's purpose or usage. - #[oai(validator(max_length = "255"))] - pub description: Option, - /// Optional access control settings - pub acl: Option, + #[oai(validator(max_length = "32"))] + pub name: Option, + /// 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, + /// 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, } 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::>().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>, - /// An optional description of the token's purpose or usage. - #[oai(validator(max_length = "255"))] - pub description: Option, - /// Optional access control settings - pub acl: Option, -} - -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::>().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() - } -} diff --git a/src/modules/token/root.rs b/src/modules/token/root.rs index 95ddb9e..d1e0ce5 100644 --- a/src/modules/token/root.rs +++ b/src/modules/token/root.rs @@ -16,110 +16,109 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +// 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 { +// 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 { - 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 { +// 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 { - 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 { +// 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 { - 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 +// } diff --git a/src/modules/token/view.rs b/src/modules/token/view.rs new file mode 100644 index 0000000..bb4a7b1 --- /dev/null +++ b/src/modules/token/view.rs @@ -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 . + + +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, + /// 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, + /// 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, +} diff --git a/src/modules/users/acl.rs b/src/modules/users/acl.rs new file mode 100644 index 0000000..3efae87 --- /dev/null +++ b/src/modules/users/acl.rs @@ -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 . + +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>, + /// An optional rate limit configuration for the access token. + pub rate_limit: Option, +} + +impl AccessControl { + pub fn validate(&self) -> BichonResult<()> { + if let Some(ip_whitelist) = &self.ip_whitelist { + for ip in ip_whitelist { + if ip.parse::().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(()) + } +} diff --git a/web/src/features/access-tokens/data/schema.ts b/src/modules/users/manager.rs similarity index 63% rename from web/src/features/access-tokens/data/schema.ts rename to src/modules/users/manager.rs index 4bc64cb..9e4fa50 100644 --- a/web/src/features/access-tokens/data/schema.ts +++ b/src/modules/users/manager.rs @@ -16,30 +16,17 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +use crate::modules::{ + context::Initialize, + error::BichonResult, + users::{role::UserRole, UserModel}, +}; -interface AccountInfo { - id: number; - email: string; +pub struct UserManager; + +impl Initialize for UserManager { + async fn initialize() -> BichonResult<()> { + UserRole::ensure_default_roles_exists().await?; + UserModel::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 }; \ No newline at end of file diff --git a/src/modules/users/minimal.rs b/src/modules/users/minimal.rs new file mode 100644 index 0000000..c1b0a99 --- /dev/null +++ b/src/modules/users/minimal.rs @@ -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 . + +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> { + let all_users = list_all_impl::(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) + } +} diff --git a/src/modules/users/mod.rs b/src/modules/users/mod.rs new file mode 100644 index 0000000..39e0653 --- /dev/null +++ b/src/modules/users/mod.rs @@ -0,0 +1,692 @@ +// +// 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 . + +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; + +pub type UserModel = BichonUserV2; + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Object)] +pub struct LoginResult { + pub success: bool, + pub error_message: Option, + pub access_token: Option, + pub theme: Option, + pub language: Option, +} + +pub const DEFAULT_ADMIN_USER_ID: u64 = 100000000000000; + +#[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, + + /// Scoped Access: Defines per-account permissions. + /// Example: + /// { account_id: 1, role_id: role_manager_id } -> Manager on Account 1 + /// { account_id: 2, role_id: role_viewer_id } -> Viewer on Account 2 + pub account_access_map: BTreeMap, + + pub description: Option, + + /// System Roles: Permissions that apply to the whole system + /// (e.g., system settings, creating new users). + pub global_roles: Vec, + + pub avatar: Option, + pub created_at: i64, + pub updated_at: i64, + /// Optional access control settings + pub acl: Option, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Object)] +#[native_model(id = 10, version = 2, from = BichonUser)] +#[native_db] +pub struct BichonUserV2 { + #[primary_key] + pub id: u64, + #[secondary_key(unique)] + pub username: String, + #[secondary_key(unique)] + pub email: String, + + pub password: Option, + + /// Scoped Access: Defines per-account permissions. + /// Example: + /// { account_id: 1, role_id: role_manager_id } -> Manager on Account 1 + /// { account_id: 2, role_id: role_viewer_id } -> Viewer on Account 2 + pub account_access_map: BTreeMap, + + pub description: Option, + + /// System Roles: Permissions that apply to the whole system + /// (e.g., system settings, creating new users). + pub global_roles: Vec, + + pub avatar: Option, + pub created_at: i64, + pub updated_at: i64, + /// Optional access control settings + pub acl: Option, + + pub theme: Option, + pub language: Option, +} + +impl BichonUserV2 { + pub async fn list_all() -> BichonResult> { + Ok(list_all_impl::(DB_MANAGER.meta_db()).await?) + } + + async fn get_all_permissions(&self) -> HashSet { + 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_view(self, role_lookup: &BTreeMap) -> 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> = 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, + theme: self.theme, + language: self.language, + } + } + + 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::(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(UserModel { + 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, + theme: None, + language: 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 { + let user_option = secondary_find_impl::( + DB_MANAGER.meta_db(), + BichonUserV2Key::username, + username.clone(), + ) + .await?; + + let user = match user_option { + Some(u) => u, + None => { + match secondary_find_impl::( + DB_MANAGER.meta_db(), + BichonUserV2Key::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, + theme: None, + language: 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), + theme: user.theme, + language: user.language, + }) + } else { + warn!( + "Login failed: Incorrect password for user '{}'.", + user.username + ); + Ok(LoginResult { + success: false, + error_message: Some("Incorrect password.".to_string()), + access_token: None, + theme: None, + language: 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, + theme: None, + language: None, + }) + } + } + } + + pub async fn find(user_id: u64) -> BichonResult> { + async_find_impl(DB_MANAGER.meta_db(), user_id).await + } + + pub async fn check_username_conflict(username: &str) -> BichonResult<()> { + // Check username duplicate + if secondary_find_impl::( + DB_MANAGER.meta_db(), + BichonUserV2Key::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::( + DB_MANAGER.meta_db(), + BichonUserV2Key::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 { + 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 = UserModel { + 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, + theme: request.theme, + language: request.language, + }; + + 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::(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 = rw + .scan() + .secondary::(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?; + let password_changed = request.password.is_some(); + + 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::( + DB_MANAGER.meta_db(), + BichonUserV2Key::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::( + DB_MANAGER.meta_db(), + BichonUserV2Key::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::(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); + } + + if let Some(theme) = request.theme { + updated.theme = Some(theme); + } + + if let Some(language) = request.language { + updated.language = Some(language); + } + + updated.updated_at = utc_now!(); + + Ok(updated) + }, + ) + .await?; + + if password_changed { + AccessTokenModel::reset_webui_token(id).await?; + } + + Ok(()) + } + + async fn list_authorized_users(account_id: u64) -> BichonResult> { + let all = Self::list_all().await?; + let result: Vec = 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::(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(()) + } +} + +impl From for BichonUser { + fn from(value: BichonUserV2) -> Self { + BichonUser { + id: value.id, + username: value.username, + email: value.email, + password: value.password, + account_access_map: value.account_access_map, + description: value.description, + global_roles: value.global_roles, + avatar: value.avatar, + created_at: value.created_at, + updated_at: value.updated_at, + acl: value.acl, + } + } +} + +impl From for BichonUserV2 { + fn from(value: BichonUser) -> Self { + BichonUserV2 { + id: value.id, + username: value.username, + email: value.email, + password: value.password, + account_access_map: value.account_access_map, + description: value.description, + global_roles: value.global_roles, + avatar: value.avatar, + created_at: value.created_at, + updated_at: value.updated_at, + acl: value.acl, + theme: None, + language: None, + } + } +} diff --git a/src/modules/users/payload.rs b/src/modules/users/payload.rs new file mode 100644 index 0000000..df4ae3c --- /dev/null +++ b/src/modules/users/payload.rs @@ -0,0 +1,453 @@ +// +// 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 . + +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, HashSet}; + +fn allowed_themes() -> HashSet<&'static str> { + ["light", "dark"].into_iter().collect() +} + +fn allowed_languages() -> HashSet<&'static str> { + [ + "ar", "da", "de", "en", "es", "fi", "fr", "it", "jp", "ko", "nl", "no", "pl", "pt", "ru", + "sv", "zh", "zh-tw", + ] + .into_iter() + .collect() +} + +fn validate_option_in_set( + value: &Option, + allowed: &std::collections::HashSet<&'static str>, + field_name: &str, +) -> BichonResult<()> { + if let Some(v) = value { + if !allowed.contains(v.as_str()) { + return Err(raise_error!( + format!("invalid {} value: '{}'", field_name, v), + ErrorCode::InvalidParameter + )); + } + } + Ok(()) +} + +fn validate_theme(theme: &Option) -> BichonResult<()> { + validate_option_in_set(theme, &allowed_themes(), "theme") +} + +fn validate_language(language: &Option) -> BichonResult<()> { + validate_option_in_set(language, &allowed_languages(), "language") +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)] +pub struct RoleCreateRequest { + pub name: String, + pub role_type: RoleType, + pub description: Option, + pub permissions: BTreeSet, +} + +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, + pub description: Option, + pub permissions: Option>, +} + +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, + + /// Scoped Access: List of accounts paired with specific roles. + /// This allows different permissions per account. + pub account_access_map: BTreeMap, + + pub acl: Option, + pub avatar_base64: Option, + pub description: Option, + pub theme: Option, + pub language: Option, +} + +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 > 256 { + return Err(raise_error!( + "Password cannot exceed 256 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 + )); + } + + validate_theme(&self.theme)?; + validate_language(&self.language)?; + + let all_roles = UserRole::list_all().await?; + let role_type_map: HashMap = + all_roles.into_iter().map(|r| (r.id, r.role_type)).collect(); + + 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, + #[oai(validator(custom = "crate::modules::common::validator::EmailValidator"))] + pub email: Option, + pub password: Option, + pub avatar_base64: Option, + pub global_roles: Option>, + /// Scoped Access + pub account_access_map: Option>, + pub acl: Option, + pub description: Option, + pub theme: Option, + pub language: Option, +} + +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 > 256 { + return Err(raise_error!( + "Password must be 8-256 characters.".into(), + ErrorCode::InvalidParameter + )); + } + } + + validate_theme(&self.theme)?; + validate_language(&self.language)?; + + let all_roles = UserRole::list_all().await?; + let role_type_map: HashMap = + all_roles.into_iter().map(|r| (r.id, r.role_type)).collect(); + + 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(()) + } +} diff --git a/src/modules/users/permissions.rs b/src/modules/users/permissions.rs new file mode 100644 index 0000000..b55fbb2 --- /dev/null +++ b/src/modules/users/permissions.rs @@ -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 . + +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> = 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, + ) -> 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(()) + } +} diff --git a/src/modules/users/role.rs b/src/modules/users/role.rs new file mode 100644 index 0000000..7d6ea3b --- /dev/null +++ b/src/modules/users/role.rs @@ -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 . + +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, + pub permissions: BTreeSet, + 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::(role_id) + .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))? + .is_some(); + + if !exists { + let permissions: BTreeSet = 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> { + list_all_impl(DB_MANAGER.meta_db()).await + } + + pub async fn find(role_id: u64) -> BichonResult> { + async_find_impl(DB_MANAGER.meta_db(), role_id).await + } + + pub async fn create(request: RoleCreateRequest) -> BichonResult { + 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::(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::(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 + } +} diff --git a/src/modules/users/view.rs b/src/modules/users/view.rs new file mode 100644 index 0000000..755529a --- /dev/null +++ b/src/modules/users/view.rs @@ -0,0 +1,54 @@ +// +// 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 . + +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, + + /// Scoped Access: Defines per-account permissions. + /// Example: + /// { account_id: 1, role_id: role_manager_id } -> Manager on Account 1 + /// { account_id: 2, role_id: role_viewer_id } -> Viewer on Account 2 + pub account_access_map: BTreeMap, + pub account_roles_summary: BTreeMap, + pub account_permissions: BTreeMap>, + pub description: Option, + /// Global Roles: Permissions that apply to the whole system + /// (e.g., system settings, creating new users). + pub global_roles: Vec, + pub global_roles_names: Vec, + pub global_permissions: Vec, + pub avatar: Option, + pub created_at: i64, + pub updated_at: i64, + /// Optional access control settings + pub acl: Option, + pub theme: Option, + pub language: Option, +} diff --git a/src/modules/utils/encrypt.rs b/src/modules/utils/encrypt.rs index 61e0267..e39faab 100644 --- a/src/modules/utils/encrypt.rs +++ b/src/modules/utils/encrypt.rs @@ -16,18 +16,34 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - 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 = 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 { - 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 { - 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 diff --git a/src/modules/utils/mod.rs b/src/modules/utils/mod.rs index c35659c..68b3017 100644 --- a/src/modules/utils/mod.rs +++ b/src/modules/utils/mod.rs @@ -16,9 +16,10 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - 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 { Ok(total_size) } + +const MAX_AVATAR_BYTES: usize = 128 * 1024; + +pub fn decode_avatar_bytes(base64_str: &str) -> BichonResult> { + 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) +} diff --git a/src/modules/utils/rate_limit/mod.rs b/src/modules/utils/rate_limit/mod.rs index cf6dadf..84f991e 100644 --- a/src/modules/utils/rate_limit/mod.rs +++ b/src/modules/utils/rate_limit/mod.rs @@ -16,7 +16,6 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - 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 = LazyLock::new(TokenRateLimiter::new); +pub static RATE_LIMITER_MANAGER: LazyLock = LazyLock::new(UserRateLimiter::new); -pub struct TokenRateLimiter { +pub struct UserRateLimiter { limiters: Arc< DashMap< - String, + u64, ( Arc>, 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> { - 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> { 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() } -} \ No newline at end of file +} diff --git a/web/components.json b/web/components.json index 299ea38..b2b9bc6 100644 --- a/web/components.json +++ b/web/components.json @@ -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" -} \ No newline at end of file + "registries": { + "@reui": "https://reui.io/r/{name}.json" + } +} diff --git a/web/package.json b/web/package.json index 934993f..4b50984 100644 --- a/web/package.json +++ b/web/package.json @@ -58,9 +58,10 @@ "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", + "react-day-picker": "9.13.0", "react-dom": "^18.3.1", "react-hook-form": "^7.54.0", "react-i18next": "^16.3.5", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 93b8184..cf62009 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -143,6 +143,9 @@ importers: lucide-react: specifier: ^0.468.0 version: 0.468.0(react@18.3.1) + radix-ui: + specifier: ^1.4.3 + version: 1.4.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: specifier: ^18.3.1 version: 18.3.1 @@ -150,8 +153,8 @@ importers: specifier: ^13.0.0 version: 13.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-day-picker: - specifier: 8.10.1 - version: 8.10.1(date-fns@3.6.0)(react@18.3.1) + specifier: 9.13.0 + version: 9.13.0(react@18.3.1) react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) @@ -399,6 +402,9 @@ packages: '@types/react': optional: true + '@date-fns/tz@1.4.1': + resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==} + '@emotion/babel-plugin@11.13.5': resolution: {integrity: sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==} @@ -832,6 +838,9 @@ packages: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + '@jridgewell/gen-mapping@0.3.5': resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} engines: {node: '>=6.0.0'} @@ -844,15 +853,21 @@ packages: resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} engines: {node: '>=6.0.0'} - '@jridgewell/source-map@0.3.10': - resolution: {integrity: sha512-0pPkgz9dY+bijgistcTTJ5mR+ocqRXLuhXHYdzoMmmoJ2C9S46RCm2GMUbatPEUK9Yjy26IrAy8D/M00lLkv+Q==} + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} '@jridgewell/sourcemap-codec@1.5.0': resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@mui/core-downloads-tracker@7.3.5': resolution: {integrity: sha512-kOLwlcDPnVz2QMhiBv0OQ8le8hTCqKM9cRXlfVPL91l3RGeOsxrIhNRsUt3Xb8wb+pTVUolW+JXKym93vRKxCw==} @@ -1065,12 +1080,44 @@ packages: '@radix-ui/number@1.1.0': resolution: {integrity: sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==} + '@radix-ui/number@1.1.1': + resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} + '@radix-ui/primitive@1.1.0': resolution: {integrity: sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==} '@radix-ui/primitive@1.1.1': resolution: {integrity: sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==} + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/react-accessible-icon@1.1.7': + resolution: {integrity: sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-accordion@1.2.12': + resolution: {integrity: sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-accordion@1.2.2': resolution: {integrity: sha512-b1oh54x4DMCdGsB4/7ahiSrViXxaBwRPotiZNnYXjLha9vfuURSAZErki6qjDoSIV0eXx5v57XnTGVtGwnfp2g==} peerDependencies: @@ -1084,6 +1131,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-alert-dialog@1.1.15': + resolution: {integrity: sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-alert-dialog@1.1.2': resolution: {integrity: sha512-eGSlLzPhKO+TErxkiGcCZGuvbVMnLA1MTnyBksGOeGRGkxHiiJUujsjmNTdWTm4iHVSRaUao9/4Ur671auMghQ==} peerDependencies: @@ -1123,6 +1183,32 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-arrow@1.1.7': + resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-aspect-ratio@1.1.7': + resolution: {integrity: sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-avatar@1.1.1': resolution: {integrity: sha512-eoOtThOmxeoizxpX6RiEsQZ2wj5r4+zoeqAwO0cBaFQGjJwIH3dIX0OCxNrCyrrdxG+vBweMETh3VziQG7c1kw==} peerDependencies: @@ -1136,6 +1222,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-avatar@1.1.10': + resolution: {integrity: sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-checkbox@1.1.2': resolution: {integrity: sha512-/i0fl686zaJbDQLNKrkCbMyDm6FQMt4jg323k7HuqitoANm9sE23Ql8yOK3Wusk34HSLKDChhMux05FnP6KUkw==} peerDependencies: @@ -1149,6 +1248,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-checkbox@1.3.3': + resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-collapsible@1.1.1': resolution: {integrity: sha512-1///SnrfQHJEofLokyczERxQbWfCGQlQ2XsCZMucVs6it+lq9iw4vXy+uDn1edlb58cOZOWSldnfPAYcT4O/Yg==} peerDependencies: @@ -1162,6 +1274,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-collapsible@1.1.12': + resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-collapsible@1.1.2': resolution: {integrity: sha512-PliMB63vxz7vggcyq0IxNYk8vGDrLXVWw4+W4B8YnwI1s18x7YZYqlG9PLX7XxAJUi0g2DxP4XKJMFHh/iVh9A==} peerDependencies: @@ -1214,6 +1339,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-collection@1.1.7': + resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-compose-refs@1.1.0': resolution: {integrity: sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==} peerDependencies: @@ -1241,6 +1379,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-context-menu@2.2.16': + resolution: {integrity: sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-context@1.1.0': resolution: {integrity: sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==} peerDependencies: @@ -1259,6 +1410,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-context@1.1.2': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-context@1.1.3': resolution: {integrity: sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==} peerDependencies: @@ -1268,6 +1428,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-dialog@1.1.15': + resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-dialog@1.1.2': resolution: {integrity: sha512-Yj4dZtqa2o+kG61fzB0H2qUvmwBA2oyQroGLyNtBj1beo1khoQ3q1a2AO8rrQYjd8256CO9+N8L9tvsS+bnIyA==} peerDependencies: @@ -1290,6 +1463,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-direction@1.1.1': + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-dismissable-layer@1.1.1': resolution: {integrity: sha512-QSxg29lfr/xcev6kSz7MAlmDnzbP1eI/Dwn3Tp1ip0KT5CUELsxkekFEMVBEoykI3oV39hKT4TKZzBNMbcTZYQ==} peerDependencies: @@ -1303,6 +1485,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-dismissable-layer@1.1.11': + resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-dismissable-layer@1.1.3': resolution: {integrity: sha512-onrWn/72lQoEucDmJnr8uczSNTujT0vJnA/X5+3AkChVPowr8n1yvIKIabhWyMQeMvvmdpsvcyDqx3X1LEXCPg==} peerDependencies: @@ -1329,6 +1524,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-dropdown-menu@2.1.16': + resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-dropdown-menu@2.1.2': resolution: {integrity: sha512-GVZMR+eqK8/Kes0a36Qrv+i20bAPXSn8rCBTHx30w+3ECnR5o3xixAlqcVaYvLeyKUsm0aqyhWfmUcqufM8nYA==} peerDependencies: @@ -1351,6 +1559,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-focus-guards@1.1.3': + resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-focus-scope@1.1.0': resolution: {integrity: sha512-200UD8zylvEyL8Bx+z76RJnASR2gRMuxlgFCPAe/Q/679a/r0eK3MBVYMb7vZODZcffZBdob1EGnky78xmVvcA==} peerDependencies: @@ -1364,6 +1581,45 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-focus-scope@1.1.7': + resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-form@0.1.8': + resolution: {integrity: sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-hover-card@1.1.15': + resolution: {integrity: sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-hover-card@1.1.4': resolution: {integrity: sha512-QSUUnRA3PQ2UhvoCv3eYvMnCAgGQW+sTu86QPuNb+ZMi+ZENd6UWpiXbcWDQ4AEaKF9KKpCHBeaJz9Rw6lRlaQ==} peerDependencies: @@ -1391,6 +1647,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-id@1.1.1': + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-label@2.1.0': resolution: {integrity: sha512-peLblDlFw/ngk3UWq0VnYaOLy6agTZZ+MUO/WhVfm14vJGML+xH4FAl2XQGLqdefjNb7ApRg6Yn7U42ZhmYXdw==} peerDependencies: @@ -1404,6 +1669,32 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-label@2.1.7': + resolution: {integrity: sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menu@2.1.16': + resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-menu@2.1.2': resolution: {integrity: sha512-lZ0R4qR2Al6fZ4yCCZzu/ReTFrylHFxIqy7OezIpWF4bL0o9biKo0pFIvkaew3TyZ9Fy5gYVrR5zCGZBVbO1zg==} peerDependencies: @@ -1417,6 +1708,32 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-menubar@1.1.16': + resolution: {integrity: sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-navigation-menu@1.2.14': + resolution: {integrity: sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-navigation-menu@1.2.5': resolution: {integrity: sha512-myMHHQUZ3ZLTi8W381/Vu43Ia0NqakkQZ2vzynMmTUtQQ9kNkjzhOwkZC9TAM5R07OZUVIQyHC06f/9JZJpvvA==} peerDependencies: @@ -1430,6 +1747,45 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-one-time-password-field@0.1.8': + resolution: {integrity: sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-password-toggle-field@0.1.3': + resolution: {integrity: sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popover@1.1.15': + resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-popover@1.1.2': resolution: {integrity: sha512-u2HRUyWW+lOiA2g0Le0tMmT55FGOEWHwPFt1EPfbLly7uXQExFo5duNKqG2DzmFXIdqOeNd+TpE8baHWJCyP9w==} peerDependencies: @@ -1469,6 +1825,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-popper@1.2.8': + resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-portal@1.1.2': resolution: {integrity: sha512-WeDYLGPxJb/5EGBoedyJbT0MpoULmwnIPMJMSldkuiMsBAv7N1cRdsTWZWht9vpPOiN3qyiGAtbK2is47/uMFg==} peerDependencies: @@ -1495,6 +1864,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-portal@1.1.9': + resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-presence@1.1.1': resolution: {integrity: sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==} peerDependencies: @@ -1521,6 +1903,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-presence@1.1.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-primitive@2.0.0': resolution: {integrity: sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==} peerDependencies: @@ -1560,6 +1955,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-primitive@2.1.4': resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==} peerDependencies: @@ -1573,6 +1981,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-progress@1.1.7': + resolution: {integrity: sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-progress@1.1.8': resolution: {integrity: sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA==} peerDependencies: @@ -1599,6 +2020,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-radio-group@1.3.8': + resolution: {integrity: sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-roving-focus@1.1.0': resolution: {integrity: sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==} peerDependencies: @@ -1612,6 +2046,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-roving-focus@1.1.11': + resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-scroll-area@1.2.1': resolution: {integrity: sha512-FnM1fHfCtEZ1JkyfH/1oMiTcFBQvHKl4vD9WnpwkLgtF+UmnXMCad6ECPTaAjcDjam+ndOEJWgHyKDGNteWSHw==} peerDependencies: @@ -1625,6 +2072,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-scroll-area@1.2.10': + resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-select@2.1.2': resolution: {integrity: sha512-rZJtWmorC7dFRi0owDmoijm6nSJH1tVw64QGiNIZ9PNLyBDtG+iAq+XGsya052At4BfarzY/Dhv9wrrUr6IMZA==} peerDependencies: @@ -1638,6 +2098,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-select@2.2.6': + resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-separator@1.1.0': resolution: {integrity: sha512-3uBAs+egzvJBDZAzvb/n4NxxOYpnspmWxO2u5NbZ8Y6FM/NdrGSF9bop3Cf6F6C71z1rTSn8KV0Fo2ZVd79lGA==} peerDependencies: @@ -1651,6 +2124,32 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-separator@1.1.7': + resolution: {integrity: sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slider@1.3.6': + resolution: {integrity: sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-slot@1.1.0': resolution: {integrity: sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==} peerDependencies: @@ -1678,6 +2177,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-slot@1.2.4': resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} peerDependencies: @@ -1700,6 +2208,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-switch@1.2.6': + resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-tabs@1.1.1': resolution: {integrity: sha512-3GBUDmP2DvzmtYLMsHmpA1GtR46ZDZ+OreXM/N+kkQJOPIgytFWWTfDQmBQKBvaFS0Vno0FktdbVzN28KGrMdw==} peerDependencies: @@ -1713,6 +2234,32 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-tabs@1.1.13': + resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toast@1.2.15': + resolution: {integrity: sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-toast@1.2.2': resolution: {integrity: sha512-Z6pqSzmAP/bFJoqMAston4eSNa+ud44NSZTiZUmUen+IOZ5nBY8kzuU5WDBVyFXPtcW6yUalOHsxM/BP6Sv8ww==} peerDependencies: @@ -1726,6 +2273,45 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-toggle-group@1.1.11': + resolution: {integrity: sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toggle@1.1.10': + resolution: {integrity: sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toolbar@1.1.11': + resolution: {integrity: sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-tooltip@1.1.4': resolution: {integrity: sha512-QpObUH/ZlpaO4YgHSaYzrLO2VuO+ZBFFgGzjMUPwtiYnAzzNNDPJeEGRrT7qNOrWm/Jr08M1vlp+vTHtnSQ0Uw==} peerDependencies: @@ -1739,6 +2325,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-tooltip@1.2.8': + resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-use-callback-ref@1.1.0': resolution: {integrity: sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==} peerDependencies: @@ -1748,6 +2347,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-callback-ref@1.1.1': + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-controllable-state@1.1.0': resolution: {integrity: sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==} peerDependencies: @@ -1757,6 +2365,24 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-escape-keydown@1.1.0': resolution: {integrity: sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==} peerDependencies: @@ -1766,6 +2392,24 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-escape-keydown@1.1.1': + resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-is-hydrated@0.1.0': + resolution: {integrity: sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-layout-effect@1.1.0': resolution: {integrity: sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==} peerDependencies: @@ -1775,6 +2419,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-previous@1.1.0': resolution: {integrity: sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og==} peerDependencies: @@ -1784,6 +2437,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-previous@1.1.1': + resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-rect@1.1.0': resolution: {integrity: sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==} peerDependencies: @@ -1793,6 +2455,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-rect@1.1.1': + resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-size@1.1.0': resolution: {integrity: sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==} peerDependencies: @@ -1802,6 +2473,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-size@1.1.1': + resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-visually-hidden@1.1.0': resolution: {integrity: sha512-N8MDZqtgCgG5S3aV60INAB475osJousYpZ4cTJ2cFbMpdHS5Y6loLTH8LPtkj2QN0x93J30HT/M3qJXM0+lyeQ==} peerDependencies: @@ -1828,9 +2508,25 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-visually-hidden@1.2.3': + resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/rect@1.1.0': resolution: {integrity: sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==} + '@radix-ui/rect@1.1.1': + resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@react-spring/animated@10.0.3': resolution: {integrity: sha512-7MrxADV3vaUADn2V9iYhaIL6iOWRx9nCJjYrsk2AHD2kwPr6fg7Pt0v+deX5RnCDmCKNnD6W5fasiyM8D+wzJQ==} peerDependencies: @@ -2209,6 +2905,9 @@ packages: '@types/estree@1.0.6': resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/file-saver@2.0.7': resolution: {integrity: sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==} @@ -2230,6 +2929,9 @@ packages: '@types/node@22.10.1': resolution: {integrity: sha512-qKgsUwfHZV2WCWLAnVP1JqnpE6Im6h3Y0+fYgMTasNQ7V++CBX5OT1as0g0f+OyubbFqhf6XVNIsmN4IIhEgGQ==} + '@types/node@22.19.2': + resolution: {integrity: sha512-LPM2G3Syo1GLzXLGJAKdqoU35XvrWzGJ21/7sgZTUpbkBaOasTj8tjwn6w+hCkqaa1TfJ/w67rJSwYItlJ2mYw==} + '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} @@ -2392,6 +3094,11 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + aggregate-error@3.1.0: resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} engines: {node: '>=8'} @@ -2474,6 +3181,10 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + baseline-browser-mapping@2.9.6: + resolution: {integrity: sha512-v9BVVpOTLB59C9E7aSnmIF8h7qRsFpx+A2nugVMTszEOMcfjlZMsXRm4LF23I3Z9AJxc8ANpIvzbzONoX9VJlg==} + hasBin: true + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -2493,6 +3204,11 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -2504,8 +3220,8 @@ packages: resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} engines: {node: '>= 6'} - caniuse-lite@1.0.30001707: - resolution: {integrity: sha512-3qtRjw/HQSMlDWf+X79N206fepf4SOOU6SQLMaq/0KkZLmSjPxAkBOQQ+FxbHKfHmYLZFfdWsO3KA90ceHPSnw==} + caniuse-lite@1.0.30001760: + resolution: {integrity: sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==} ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -2649,9 +3365,15 @@ packages: resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} engines: {node: '>=12'} + date-fns-jalali@4.1.0-0: + resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==} + date-fns@3.6.0: resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==} + date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + debug@4.3.7: resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} engines: {node: '>=6.0'} @@ -2710,6 +3432,9 @@ packages: easy-table@1.2.0: resolution: {integrity: sha512-OFzVOv03YpvtcWGe5AayU5G2hgybsg3iqA6drU8UaoZyB9jLGMTrz9+asnLp/E+6qPh88yEI1gvyZFZ41dmgww==} + electron-to-chromium@1.5.267: + resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} + electron-to-chromium@1.5.57: resolution: {integrity: sha512-xS65H/tqgOwUBa5UmOuNSLuslDo7zho0y/lgQw35pnrqiZh7UOWHCeL/Bt6noJATbA6tpQJGCifsFsIRZj1Fqg==} @@ -2723,6 +3448,10 @@ packages: resolution: {integrity: sha512-0/r0MySGYG8YqlayBZ6MuCfECmHFdJ5qyPh8s8wa5Hnm6SaFLSK1VYCbj+NKp090Nm1caZhD+QTnmxO7esYGyQ==} engines: {node: '>=10.13.0'} + enhanced-resolve@5.18.3: + resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} + engines: {node: '>=10.13.0'} + error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -2842,8 +3571,8 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fast-uri@3.0.6: - resolution: {integrity: sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==} + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} fastq@1.17.1: resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} @@ -2990,8 +3719,8 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} - immutable@5.1.3: - resolution: {integrity: sha512-+chQdDfvscSF1SJqv2gn4SRO2ZyS3xL3r7IW/wWEEzrzLisnOlKiQu5ytC/BVNcS15C39WT2Hg/bjKjDMcu+zg==} + immutable@5.1.4: + resolution: {integrity: sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==} import-fresh@3.3.0: resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} @@ -3142,8 +3871,8 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - loader-runner@4.3.0: - resolution: {integrity: sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==} + loader-runner@4.3.1: + resolution: {integrity: sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==} engines: {node: '>=6.11.5'} locate-path@6.0.0: @@ -3368,6 +4097,9 @@ packages: node-releases@2.0.18: resolution: {integrity: sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==} + node-releases@2.0.27: + resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -3581,6 +4313,19 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + radix-ui@1.4.3: + resolution: {integrity: sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + randombytes@2.1.0: resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} @@ -3590,11 +4335,11 @@ packages: react: ^0.13.0 || ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 react-dom: ^0.13.0 || ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 - react-day-picker@8.10.1: - resolution: {integrity: sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==} + react-day-picker@9.13.0: + resolution: {integrity: sha512-euzj5Hlq+lOHqI53NiuNhCP8HWgsPf/bBAVijR50hNaY1XwjKjShAnIe8jm8RD2W9IJUvihDIZ+KrmqfFzNhFQ==} + engines: {node: '>=18'} peerDependencies: - date-fns: ^2.28.0 || ^3.0.0 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react: '>=16.8.0' react-dom@18.3.1: resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} @@ -3648,6 +4393,16 @@ packages: '@types/react': optional: true + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + react-remove-scroll@2.6.0: resolution: {integrity: sha512-I2U4JVEsQenxDAKaVa3VZ/JeJZe0/2DxPWL8Tj8yLKctQJQiZM52pn/GWFpSp8dftjM3pSAHVJZscAnC/y+ySQ==} engines: {node: '>=10'} @@ -3658,6 +4413,16 @@ packages: '@types/react': optional: true + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + react-resizable-panels@2.1.7: resolution: {integrity: sha512-JtT6gI+nURzhMYQYsx8DKkx6bSoOGFp7A3CwMrOb8y5jFHFyqwo9m68UhmXRw57fRVJksFn1TSlm3ywEQ9vMgA==} peerDependencies: @@ -3680,6 +4445,16 @@ packages: '@types/react': optional: true + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + react-transition-group@4.4.5: resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} peerDependencies: @@ -3764,8 +4539,8 @@ packages: scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} - schema-utils@4.3.2: - resolution: {integrity: sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==} + schema-utils@4.3.3: + resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==} engines: {node: '>= 10.13.0'} semver@6.3.1: @@ -3887,8 +4662,12 @@ packages: resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} engines: {node: '>=6'} - terser-webpack-plugin@5.3.14: - resolution: {integrity: sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==} + tapable@2.3.0: + resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} + engines: {node: '>=6'} + + terser-webpack-plugin@5.3.15: + resolution: {integrity: sha512-PGkOdpRFK+rb1TzVz+msVhw4YMRT9txLF4kRqvJhGhCM324xuR3REBSHALN+l+sAhKUmz0aotnjp5D+P83mLhQ==} engines: {node: '>= 10.13.0'} peerDependencies: '@swc/core': '*' @@ -3979,6 +4758,9 @@ packages: undici-types@6.20.0: resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -4007,6 +4789,12 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + update-browserslist-db@1.2.2: + resolution: {integrity: sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -4020,6 +4808,16 @@ packages: '@types/react': optional: true + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + use-sidecar@1.1.2: resolution: {integrity: sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==} engines: {node: '>=10'} @@ -4030,6 +4828,16 @@ packages: '@types/react': optional: true + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + use-sync-external-store@1.2.2: resolution: {integrity: sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw==} peerDependencies: @@ -4367,6 +5175,8 @@ snapshots: optionalDependencies: '@types/react': 18.3.18 + '@date-fns/tz@1.4.1': {} + '@emotion/babel-plugin@11.13.5': dependencies: '@babel/helper-module-imports': 7.25.9 @@ -4685,6 +5495,12 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + optional: true + '@jridgewell/gen-mapping@0.3.5': dependencies: '@jridgewell/set-array': 1.2.1 @@ -4695,19 +5511,28 @@ snapshots: '@jridgewell/set-array@1.2.1': {} - '@jridgewell/source-map@0.3.10': + '@jridgewell/source-map@0.3.11': dependencies: - '@jridgewell/gen-mapping': 0.3.5 - '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 optional: true '@jridgewell/sourcemap-codec@1.5.0': {} + '@jridgewell/sourcemap-codec@1.5.5': + optional: true + '@jridgewell/trace-mapping@0.3.25': dependencies: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + optional: true + '@mui/core-downloads-tracker@7.3.5': {} '@mui/material@7.3.5(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': @@ -4897,10 +5722,40 @@ snapshots: '@radix-ui/number@1.1.0': {} + '@radix-ui/number@1.1.1': {} + '@radix-ui/primitive@1.1.0': {} '@radix-ui/primitive@1.1.1': {} + '@radix-ui/primitive@1.1.3': {} + + '@radix-ui/react-accessible-icon@1.1.7(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + + '@radix-ui/react-accordion@1.2.12(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-accordion@1.2.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.1 @@ -4918,6 +5773,20 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-alert-dialog@1.1.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.0 @@ -4950,6 +5819,24 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-arrow@1.1.7(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + + '@radix-ui/react-aspect-ratio@1.1.7(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-avatar@1.1.1(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-context': 1.1.1(@types/react@18.3.18)(react@18.3.1) @@ -4962,6 +5849,19 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-avatar@1.1.10(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-context': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-checkbox@1.1.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.0 @@ -4978,6 +5878,22 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-checkbox@1.3.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-collapsible@1.1.1(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.0 @@ -4994,6 +5910,22 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-collapsible@1.1.12(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-collapsible@1.1.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.1 @@ -5046,6 +5978,18 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-collection@1.1.7(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-compose-refs@1.1.0(@types/react@18.3.18)(react@18.3.1)': dependencies: react: 18.3.1 @@ -5064,6 +6008,20 @@ snapshots: optionalDependencies: '@types/react': 18.3.18 + '@radix-ui/react-context-menu@2.2.16(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-context@1.1.0(@types/react@18.3.18)(react@18.3.1)': dependencies: react: 18.3.1 @@ -5076,12 +6034,40 @@ snapshots: optionalDependencies: '@types/react': 18.3.18 + '@radix-ui/react-context@1.1.2(@types/react@18.3.18)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.18 + '@radix-ui/react-context@1.1.3(@types/react@18.3.18)(react@18.3.1)': dependencies: react: 18.3.1 optionalDependencies: '@types/react': 18.3.18 + '@radix-ui/react-dialog@1.1.15(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.18)(react@18.3.1) + aria-hidden: 1.2.4 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.7.2(@types/react@18.3.18)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-dialog@1.1.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.0 @@ -5110,6 +6096,12 @@ snapshots: optionalDependencies: '@types/react': 18.3.18 + '@radix-ui/react-direction@1.1.1(@types/react@18.3.18)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.18 + '@radix-ui/react-dismissable-layer@1.1.1(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.0 @@ -5123,6 +6115,19 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-dismissable-layer@1.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.1 @@ -5149,6 +6154,21 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-dropdown-menu@2.1.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.0 @@ -5170,6 +6190,12 @@ snapshots: optionalDependencies: '@types/react': 18.3.18 + '@radix-ui/react-focus-guards@1.1.3(@types/react@18.3.18)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.18 + '@radix-ui/react-focus-scope@1.1.0(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.18)(react@18.3.1) @@ -5181,6 +6207,48 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + + '@radix-ui/react-form@0.1.8(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-label': 2.1.7(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + + '@radix-ui/react-hover-card@1.1.15(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-hover-card@1.1.4(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.1 @@ -5209,6 +6277,13 @@ snapshots: optionalDependencies: '@types/react': 18.3.18 + '@radix-ui/react-id@1.1.1(@types/react@18.3.18)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.18 + '@radix-ui/react-label@2.1.0(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -5218,6 +6293,41 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-label@2.1.7(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + + '@radix-ui/react-menu@2.1.16(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.18)(react@18.3.1) + aria-hidden: 1.2.4 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.7.2(@types/react@18.3.18)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-menu@2.1.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.0 @@ -5244,6 +6354,46 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-menubar@1.1.16(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + + '@radix-ui/react-navigation-menu@1.2.14(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-navigation-menu@1.2.5(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.1 @@ -5266,6 +6416,65 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-one-time-password-field@0.1.8(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + + '@radix-ui/react-password-toggle-field@0.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + + '@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.18)(react@18.3.1) + aria-hidden: 1.2.4 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.7.2(@types/react@18.3.18)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-popover@1.1.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.0 @@ -5325,6 +6534,24 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-popper@1.2.8(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@floating-ui/react-dom': 2.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-rect': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/rect': 1.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-portal@1.1.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -5345,6 +6572,16 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-portal@1.1.9(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-presence@1.1.1(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.18)(react@18.3.1) @@ -5365,6 +6602,16 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-presence@1.1.5(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-primitive@2.0.0(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-slot': 1.1.0(@types/react@18.3.18)(react@18.3.1) @@ -5392,6 +6639,15 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-primitive@2.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-primitive@2.1.4(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-slot': 1.2.4(@types/react@18.3.18)(react@18.3.1) @@ -5401,6 +6657,16 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-progress@1.1.7(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-context': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-progress@1.1.8(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-context': 1.1.3(@types/react@18.3.18)(react@18.3.1) @@ -5429,6 +6695,24 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-radio-group@1.3.8(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-roving-focus@1.1.0(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.0 @@ -5446,6 +6730,23 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-scroll-area@1.2.1(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/number': 1.1.0 @@ -5463,6 +6764,23 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-select@2.1.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/number': 1.1.0 @@ -5492,6 +6810,35 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-select@2.2.6(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + aria-hidden: 1.2.4 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.7.2(@types/react@18.3.18)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-separator@1.1.0(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -5501,6 +6848,34 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-separator@1.1.7(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + + '@radix-ui/react-slider@1.3.6(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-slot@1.1.0(@types/react@18.3.18)(react@18.3.1)': dependencies: '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.18)(react@18.3.1) @@ -5522,6 +6897,13 @@ snapshots: optionalDependencies: '@types/react': 18.3.18 + '@radix-ui/react-slot@1.2.3(@types/react@18.3.18)(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.18 + '@radix-ui/react-slot@1.2.4(@types/react@18.3.18)(react@18.3.1)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.18)(react@18.3.1) @@ -5544,6 +6926,21 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-switch@1.2.6(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-tabs@1.1.1(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.0 @@ -5560,6 +6957,42 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-tabs@1.1.13(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + + '@radix-ui/react-toast@1.2.15(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-toast@1.2.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.0 @@ -5580,6 +7013,47 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-toggle-group@1.1.11(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-toggle': 1.1.10(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + + '@radix-ui/react-toggle@1.1.10(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + + '@radix-ui/react-toolbar@1.1.11(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-separator': 1.1.7(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-tooltip@1.1.4(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.0 @@ -5600,12 +7074,38 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-tooltip@1.2.8(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-use-callback-ref@1.1.0(@types/react@18.3.18)(react@18.3.1)': dependencies: react: 18.3.1 optionalDependencies: '@types/react': 18.3.18 + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@18.3.18)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.18 + '@radix-ui/react-use-controllable-state@1.1.0(@types/react@18.3.18)(react@18.3.1)': dependencies: '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.18)(react@18.3.1) @@ -5613,6 +7113,21 @@ snapshots: optionalDependencies: '@types/react': 18.3.18 + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@18.3.18)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.18 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@18.3.18)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.18 + '@radix-ui/react-use-escape-keydown@1.1.0(@types/react@18.3.18)(react@18.3.1)': dependencies: '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.18)(react@18.3.1) @@ -5620,18 +7135,44 @@ snapshots: optionalDependencies: '@types/react': 18.3.18 + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@18.3.18)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.18 + + '@radix-ui/react-use-is-hydrated@0.1.0(@types/react@18.3.18)(react@18.3.1)': + dependencies: + react: 18.3.1 + use-sync-external-store: 1.6.0(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@radix-ui/react-use-layout-effect@1.1.0(@types/react@18.3.18)(react@18.3.1)': dependencies: react: 18.3.1 optionalDependencies: '@types/react': 18.3.18 + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@18.3.18)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.18 + '@radix-ui/react-use-previous@1.1.0(@types/react@18.3.18)(react@18.3.1)': dependencies: react: 18.3.1 optionalDependencies: '@types/react': 18.3.18 + '@radix-ui/react-use-previous@1.1.1(@types/react@18.3.18)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.18 + '@radix-ui/react-use-rect@1.1.0(@types/react@18.3.18)(react@18.3.1)': dependencies: '@radix-ui/rect': 1.1.0 @@ -5639,6 +7180,13 @@ snapshots: optionalDependencies: '@types/react': 18.3.18 + '@radix-ui/react-use-rect@1.1.1(@types/react@18.3.18)(react@18.3.1)': + dependencies: + '@radix-ui/rect': 1.1.1 + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.18 + '@radix-ui/react-use-size@1.1.0(@types/react@18.3.18)(react@18.3.1)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.18)(react@18.3.1) @@ -5646,6 +7194,13 @@ snapshots: optionalDependencies: '@types/react': 18.3.18 + '@radix-ui/react-use-size@1.1.1(@types/react@18.3.18)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.18 + '@radix-ui/react-visually-hidden@1.1.0(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -5664,8 +7219,19 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/rect@1.1.0': {} + '@radix-ui/rect@1.1.1': {} + '@react-spring/animated@10.0.3(react@18.3.1)': dependencies: '@react-spring/shared': 10.0.3(react@18.3.1) @@ -5988,12 +7554,12 @@ snapshots: '@types/eslint-scope@3.7.7': dependencies: '@types/eslint': 9.6.1 - '@types/estree': 1.0.6 + '@types/estree': 1.0.8 optional: true '@types/eslint@9.6.1': dependencies: - '@types/estree': 1.0.6 + '@types/estree': 1.0.8 '@types/json-schema': 7.0.15 optional: true @@ -6003,6 +7569,9 @@ snapshots: '@types/estree@1.0.6': {} + '@types/estree@1.0.8': + optional: true + '@types/file-saver@2.0.7': {} '@types/hast@3.0.4': @@ -6023,6 +7592,11 @@ snapshots: dependencies: undici-types: 6.20.0 + '@types/node@22.19.2': + dependencies: + undici-types: 6.21.0 + optional: true + '@types/parse-json@4.0.2': {} '@types/prop-types@15.7.14': {} @@ -6242,6 +7816,9 @@ snapshots: acorn@8.14.0: {} + acorn@8.15.0: + optional: true + aggregate-error@3.1.0: dependencies: clean-stack: 2.2.0 @@ -6268,7 +7845,7 @@ snapshots: ajv@8.17.1: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.0.6 + fast-uri: 3.1.0 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 optional: true @@ -6303,7 +7880,7 @@ snapshots: autoprefixer@10.4.20(postcss@8.4.49): dependencies: browserslist: 4.24.2 - caniuse-lite: 1.0.30001707 + caniuse-lite: 1.0.30001760 fraction.js: 4.3.7 normalize-range: 0.1.2 picocolors: 1.1.1 @@ -6337,6 +7914,9 @@ snapshots: balanced-match@1.0.2: {} + baseline-browser-mapping@2.9.6: + optional: true + binary-extensions@2.3.0: {} brace-expansion@1.1.11: @@ -6354,11 +7934,20 @@ snapshots: browserslist@4.24.2: dependencies: - caniuse-lite: 1.0.30001707 + caniuse-lite: 1.0.30001760 electron-to-chromium: 1.5.57 node-releases: 2.0.18 update-browserslist-db: 1.1.1(browserslist@4.24.2) + browserslist@4.28.1: + dependencies: + baseline-browser-mapping: 2.9.6 + caniuse-lite: 1.0.30001760 + electron-to-chromium: 1.5.267 + node-releases: 2.0.27 + update-browserslist-db: 1.2.2(browserslist@4.28.1) + optional: true + buffer-from@1.1.2: optional: true @@ -6366,7 +7955,7 @@ snapshots: camelcase-css@2.0.1: {} - caniuse-lite@1.0.30001707: {} + caniuse-lite@1.0.30001760: {} ccount@2.0.1: {} @@ -6505,8 +8094,12 @@ snapshots: d3-timer@3.0.1: {} + date-fns-jalali@4.1.0-0: {} + date-fns@3.6.0: {} + date-fns@4.1.0: {} + debug@4.3.7: dependencies: ms: 2.1.3 @@ -6556,6 +8149,9 @@ snapshots: optionalDependencies: wcwidth: 1.0.1 + electron-to-chromium@1.5.267: + optional: true + electron-to-chromium@1.5.57: {} emoji-regex@8.0.0: {} @@ -6567,6 +8163,12 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.2.1 + enhanced-resolve@5.18.3: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.0 + optional: true + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -6745,7 +8347,7 @@ snapshots: fast-levenshtein@2.0.6: {} - fast-uri@3.0.6: + fast-uri@3.1.0: optional: true fastq@1.17.1: @@ -6895,7 +8497,7 @@ snapshots: ignore@5.3.2: {} - immutable@5.1.3: + immutable@5.1.4: optional: true import-fresh@3.3.0: @@ -6960,7 +8562,7 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 22.10.1 + '@types/node': 22.19.2 merge-stream: 2.0.0 supports-color: 8.1.1 optional: true @@ -7028,7 +8630,7 @@ snapshots: lines-and-columns@1.2.4: {} - loader-runner@4.3.0: + loader-runner@4.3.1: optional: true locate-path@6.0.0: @@ -7452,6 +9054,9 @@ snapshots: node-releases@2.0.18: {} + node-releases@2.0.27: + optional: true + normalize-path@3.0.0: {} normalize-range@0.1.2: {} @@ -7594,6 +9199,69 @@ snapshots: queue-microtask@1.2.3: {} + radix-ui@1.4.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-accessible-icon': 1.1.7(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-accordion': 1.2.12(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-alert-dialog': 1.1.15(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-aspect-ratio': 1.1.7(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-avatar': 1.1.10(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-checkbox': 1.3.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-context-menu': 2.2.16(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-dropdown-menu': 2.1.16(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-form': 0.1.8(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-hover-card': 1.1.15(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-label': 2.1.7(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-menubar': 1.1.16(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-navigation-menu': 1.2.14(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-one-time-password-field': 0.1.8(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-password-toggle-field': 0.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-popover': 1.1.15(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-progress': 1.1.7(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-radio-group': 1.3.8(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-scroll-area': 1.2.10(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-select': 2.2.6(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-separator': 1.1.7(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slider': 1.3.6(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-switch': 1.2.6(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-tabs': 1.1.13(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-toast': 1.2.15(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-toggle': 1.1.10(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-toolbar': 1.1.11(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-tooltip': 1.2.8(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + randombytes@2.1.0: dependencies: safe-buffer: 5.2.1 @@ -7609,9 +9277,11 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - react-day-picker@8.10.1(date-fns@3.6.0)(react@18.3.1): + react-day-picker@9.13.0(react@18.3.1): dependencies: - date-fns: 3.6.0 + '@date-fns/tz': 1.4.1 + date-fns: 4.1.0 + date-fns-jalali: 4.1.0-0 react: 18.3.1 react-dom@18.3.1(react@18.3.1): @@ -7667,6 +9337,14 @@ snapshots: optionalDependencies: '@types/react': 18.3.18 + react-remove-scroll-bar@2.3.8(@types/react@18.3.18)(react@18.3.1): + dependencies: + react: 18.3.1 + react-style-singleton: 2.2.3(@types/react@18.3.18)(react@18.3.1) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.18 + react-remove-scroll@2.6.0(@types/react@18.3.18)(react@18.3.1): dependencies: react: 18.3.1 @@ -7678,6 +9356,17 @@ snapshots: optionalDependencies: '@types/react': 18.3.18 + react-remove-scroll@2.7.2(@types/react@18.3.18)(react@18.3.1): + dependencies: + react: 18.3.1 + react-remove-scroll-bar: 2.3.8(@types/react@18.3.18)(react@18.3.1) + react-style-singleton: 2.2.3(@types/react@18.3.18)(react@18.3.1) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@18.3.18)(react@18.3.1) + use-sidecar: 1.1.3(@types/react@18.3.18)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + react-resizable-panels@2.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 @@ -7700,6 +9389,14 @@ snapshots: optionalDependencies: '@types/react': 18.3.18 + react-style-singleton@2.2.3(@types/react@18.3.18)(react@18.3.1): + dependencies: + get-nonce: 1.0.1 + react: 18.3.1 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.18 + react-transition-group@4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@babel/runtime': 7.28.4 @@ -7826,7 +9523,7 @@ snapshots: sass@1.89.2: dependencies: chokidar: 4.0.3 - immutable: 5.1.3 + immutable: 5.1.4 source-map-js: 1.2.1 optionalDependencies: '@parcel/watcher': 2.5.1 @@ -7836,7 +9533,7 @@ snapshots: dependencies: loose-envify: 1.4.0 - schema-utils@4.3.2: + schema-utils@4.3.3: dependencies: '@types/json-schema': 7.0.15 ajv: 8.17.1 @@ -7974,11 +9671,14 @@ snapshots: tapable@2.2.1: {} - terser-webpack-plugin@5.3.14(webpack@5.99.9): + tapable@2.3.0: + optional: true + + terser-webpack-plugin@5.3.15(webpack@5.99.9): dependencies: - '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 - schema-utils: 4.3.2 + schema-utils: 4.3.3 serialize-javascript: 6.0.2 terser: 5.43.1 webpack: 5.99.9 @@ -7986,8 +9686,8 @@ snapshots: terser@5.43.1: dependencies: - '@jridgewell/source-map': 0.3.10 - acorn: 8.14.0 + '@jridgewell/source-map': 0.3.11 + acorn: 8.15.0 commander: 2.20.3 source-map-support: 0.5.21 optional: true @@ -8051,6 +9751,9 @@ snapshots: undici-types@6.20.0: {} + undici-types@6.21.0: + optional: true + unified@11.0.5: dependencies: '@types/unist': 3.0.3 @@ -8095,6 +9798,13 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + update-browserslist-db@1.2.2(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 + optional: true + uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -8106,6 +9816,13 @@ snapshots: optionalDependencies: '@types/react': 18.3.18 + use-callback-ref@1.3.3(@types/react@18.3.18)(react@18.3.1): + dependencies: + react: 18.3.1 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.18 + use-sidecar@1.1.2(@types/react@18.3.18)(react@18.3.1): dependencies: detect-node-es: 1.1.0 @@ -8114,6 +9831,14 @@ snapshots: optionalDependencies: '@types/react': 18.3.18 + use-sidecar@1.1.3(@types/react@18.3.18)(react@18.3.1): + dependencies: + detect-node-es: 1.1.0 + react: 18.3.1 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.18 + use-sync-external-store@1.2.2(react@18.3.1): dependencies: react: 18.3.1 @@ -8195,27 +9920,27 @@ snapshots: webpack@5.99.9: dependencies: '@types/eslint-scope': 3.7.7 - '@types/estree': 1.0.6 + '@types/estree': 1.0.8 '@types/json-schema': 7.0.15 '@webassemblyjs/ast': 1.14.1 '@webassemblyjs/wasm-edit': 1.14.1 '@webassemblyjs/wasm-parser': 1.14.1 - acorn: 8.14.0 - browserslist: 4.24.2 + acorn: 8.15.0 + browserslist: 4.28.1 chrome-trace-event: 1.0.4 - enhanced-resolve: 5.18.0 + enhanced-resolve: 5.18.3 es-module-lexer: 1.7.0 eslint-scope: 5.1.1 events: 3.3.0 glob-to-regexp: 0.4.1 graceful-fs: 4.2.11 json-parse-even-better-errors: 2.3.1 - loader-runner: 4.3.0 + loader-runner: 4.3.1 mime-types: 2.1.35 neo-async: 2.6.2 - schema-utils: 4.3.2 - tapable: 2.2.1 - terser-webpack-plugin: 5.3.14(webpack@5.99.9) + schema-utils: 4.3.3 + tapable: 2.3.0 + terser-webpack-plugin: 5.3.15(webpack@5.99.9) watchpack: 2.4.4 webpack-sources: 3.3.3 transitivePeerDependencies: diff --git a/web/src/api/access-tokens/api.ts b/web/src/api/access-tokens/api.ts deleted file mode 100644 index d1cf99a..0000000 --- a/web/src/api/access-tokens/api.ts +++ /dev/null @@ -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 . - - -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("/api/v1/access-token-list"); - return response.data; -}; - -export const create_access_token = async (data: Record) => { - const response = await axiosInstance.post("/api/v1/access-token", data); - return response.data; -} - -export const update_access_token = async (token: string, data: Record) => { - 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; -} \ No newline at end of file diff --git a/web/src/api/account/api.ts b/web/src/api/account/api.ts index 9d4170c..6468d02 100644 --- a/web/src/api/account/api.ts +++ b/web/src/api/account/api.ts @@ -18,7 +18,6 @@ import axiosInstance from "@/api/axiosInstance"; -import { AccountModel } from "@/features/accounts/data/schema"; import { PaginatedResponse } from ".."; export interface MinimalAccount { @@ -56,6 +55,59 @@ export interface MailboxBatchProgress { current_batch: number; } + + +type Encryption = 'Ssl' | 'StartTls' | 'None'; +type AuthType = 'Password' | 'OAuth2'; +type Unit = 'Days' | 'Months' | 'Years'; +type AccountType = 'IMAP' | 'NoSync'; +// Interface definitions +interface AuthConfig { + auth_type: AuthType; + password?: string; +} + +export interface ImapConfig { + host: string; + port: number; // integer, 0-65535 + encryption: Encryption; + auth: AuthConfig; + use_proxy?: number; +} + +interface RelativeDate { + unit: Unit; + value: number; // integer, minimum 1 +} + +interface DateSelection { + fixed?: string; // format: "YYYY-MM-DD" + relative?: RelativeDate; +} + +export interface AccountModel { + id: number; + account_type: AccountType; + imap?: ImapConfig; + enabled: boolean; + name?: string, + email: string; + capabilities?: string[]; + date_since?: DateSelection; + date_before?: RelativeDate; + folder_limit?: number, + sync_folders: string[]; + sync_interval_min?: number; + sync_batch_size?: number; + created_by: number; + created_user_name: string; + created_user_email: string; + created_at: number; + updated_at: number; + use_proxy?: number + use_dangerous: boolean +} + export const account_state = async (account_id: number) => { const response = await axiosInstance.get(`/api/v1/account-state/${account_id}`); return response.data; @@ -103,3 +155,8 @@ export const autoconfig = async (email: string) => { const response = await axiosInstance.get(`/api/v1/autoconfig/${email}`); return response.data; }; + +export const access_assign = async (data: Record) => { + const response = await axiosInstance.post("/api/v1/accounts/access/assignments", data); + return response.data; +}; \ No newline at end of file diff --git a/web/src/api/axiosInstance.ts b/web/src/api/axiosInstance.ts index 49570d2..6b13610 100644 --- a/web/src/api/axiosInstance.ts +++ b/web/src/api/axiosInstance.ts @@ -17,7 +17,7 @@ // along with this program. If not, see . -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; }, diff --git a/web/src/api/index.ts b/web/src/api/index.ts index db468d7..b0e6a6c 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -30,6 +30,8 @@ export interface EmailEnvelope { id: number; message_id: string; account_id: number; + account_email?: string; + mailbox_name?: string; uid: number; subject: string; text: string; diff --git a/web/src/api/system/api.ts b/web/src/api/system/api.ts index 1360987..e5ef4ab 100644 --- a/web/src/api/system/api.ts +++ b/web/src/api/system/api.ts @@ -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(`/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(`/api/v1/system-configurations`); + return response.data; }; \ No newline at end of file diff --git a/web/src/api/users/api.ts b/web/src/api/users/api.ts new file mode 100644 index 0000000..20afd00 --- /dev/null +++ b/web/src/api/users/api.ts @@ -0,0 +1,208 @@ +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; + account_roles_summary: Record; + global_permissions: string[] + account_permissions: Record + created_at: number; + updated_at: number; +} + +type Theme = 'dark' | 'light' + + +export interface LoginResult { + success: boolean; + error_message?: string | null; + access_token?: string | null; + theme?: Theme, + language?: string, +} + + +export interface MinimalUser { + id: number; + username: string; + email: string; +} + + +export const login = async (data: Record) => { + const response = await axiosInstance.post(`/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("/api/v1/access-token-list"); + return response.data; +}; + +export const create_access_token = async (data: Record) => { + const response = await axiosInstance.post("/api/v1/access-token", data); + return response.data; +} + +export const update_access_token = async (token: string, data: Record) => { + 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("/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) => { + const response = await axiosInstance.post("/api/v1/roles", data); + return response.data; +}; + + +export const update_role = async (id: number, data: Record) => { + const response = await axiosInstance.post(`/api/v1/roles/${id}`, data); + return response.data; +}; + + +export const list_users = async () => { + const response = await axiosInstance.get("/api/v1/list-users"); + return response.data; +}; + + +export const list_minimal_users = async () => { + const response = await axiosInstance.get("/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) => { + const response = await axiosInstance.post("/api/v1/users", data); + return response.data; +}; + + +export const update_user = async (id: number, data: Record) => { + 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(`/api/v1/user-tokens/${id}`); + return response.data; +}; + +export const get_current_user = async () => { + const response = await axiosInstance.get("/api/v1/current-user"); + return response.data; +}; diff --git a/web/src/components/api-docs.tsx b/web/src/components/api-docs.tsx index f246679..f14883d 100644 --- a/web/src/components/api-docs.tsx +++ b/web/src/components/api-docs.tsx @@ -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() {

+
diff --git a/web/src/components/date-picker.tsx b/web/src/components/date-picker.tsx index d3ed612..625dfdb 100644 --- a/web/src/components/date-picker.tsx +++ b/web/src/components/date-picker.tsx @@ -7,6 +7,9 @@ import { PopoverContent, PopoverTrigger, } from '@/components/ui/popover' +import i18n from '@/i18n' +import { dateFnsLocaleMap } from '@/lib/utils' +import { enUS } from 'date-fns/locale' type DatePickerProps = { selected: Date | undefined @@ -19,6 +22,10 @@ export function DatePicker({ onSelect, placeholder = 'Pick a date', }: DatePickerProps) { + + const currentLang = i18n.language.toLowerCase().replace('_', '-'); + const dateLocale = dateFnsLocaleMap[currentLang] || enUS; + return ( @@ -28,7 +35,7 @@ export function DatePicker({ className='data-[empty=true]:text-muted-foreground w-[240px] justify-start text-start font-normal' > {selected ? ( - format(selected, 'MMM d, yyyy') + format(selected, 'PPP', { locale: dateLocale }) ) : ( {placeholder} )} diff --git a/web/src/components/layout/data/sidebar-data.ts b/web/src/components/layout/data/sidebar-data.ts index 65fdcc5..eb23ebf 100644 --- a/web/src/components/layout/data/sidebar-data.ts +++ b/web/src/components/layout/data/sidebar-data.ts @@ -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']), } ] }, diff --git a/web/src/components/layout/nav-group.tsx b/web/src/components/layout/nav-group.tsx index c50a45e..01cd647 100644 --- a/web/src/components/layout/nav-group.tsx +++ b/web/src/components/layout/nav-group.tsx @@ -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 . - - 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 ( {title} - {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 ( - {item.items.map((subItem) => ( + {visibleSubItems.map((subItem) => ( { + const visibleSubItems = item.items.filter(sub => sub.visible !== false) + + if (visibleSubItems.length === 0) return null + return ( @@ -167,7 +161,7 @@ const SidebarMenuCollapsedDropdown = ({ {item.title} {item.badge ? `(${item.badge})` : ''} - {item.items.map((sub) => ( + {visibleSubItems.map((sub) => ( 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]) ) -} +} \ No newline at end of file diff --git a/web/src/components/layout/types.ts b/web/src/components/layout/types.ts index e27d4ce..551cf49 100644 --- a/web/src/components/layout/types.ts +++ b/web/src/components/layout/types.ts @@ -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 } diff --git a/web/src/components/pagination.tsx b/web/src/components/pagination.tsx index c959667..04b9298 100644 --- a/web/src/components/pagination.tsx +++ b/web/src/components/pagination.tsx @@ -20,6 +20,8 @@ import { ChevronLeftIcon, ChevronRightIcon, + DoubleArrowLeftIcon, + DoubleArrowRightIcon, } from '@radix-ui/react-icons' import { Button } from '@/components/ui/button' import { @@ -30,6 +32,7 @@ import { SelectValue, } from '@/components/ui/select' import { useTranslation } from 'react-i18next' +import { showNumbers } from '@/lib/utils' interface PaginationProps { totalItems: number @@ -66,6 +69,9 @@ export function EnvelopeListPagination({ setPageIndex(newPageIndex) } + const currentPage = pageIndex + 1; + const pageNumbers = showNumbers(currentPage, pageCount) + return (
@@ -94,6 +100,14 @@ export function EnvelopeListPagination({ {t("table.page")} {pageIndex + 1} {t("table.of")} {pageCount}
+ + + {pageNumbers.map((pageNumber, index) => ( +
+ {pageNumber === '...' ? ( + ... + ) : ( + + )} +
+ ))} +
diff --git a/web/src/components/profile-dropdown.tsx b/web/src/components/profile-dropdown.tsx index 2610495..d7d4e5e 100644 --- a/web/src/components/profile-dropdown.tsx +++ b/web/src/components/profile-dropdown.tsx @@ -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 ( <> - - setIsLogoutDialogOpen(true)}> - {t('auth.logout')} - ⇧⌘Q + +
+

{user?.username}

+

+ {user?.email} +

+
+
+ + + + + {t('profile.menu.profile')} + + + {t('profile.menu.settings')} + + + + setOpen(true)}> + {t('profile.menu.sign_out')}
- + ) } diff --git a/web/src/components/sign-out-dialog.tsx b/web/src/components/sign-out-dialog.tsx new file mode 100644 index 0000000..a325373 --- /dev/null +++ b/web/src/components/sign-out-dialog.tsx @@ -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 ( + + ) +} diff --git a/web/src/components/ui/alert.tsx b/web/src/components/ui/alert.tsx index d0a903d..3047c0d 100644 --- a/web/src/components/ui/alert.tsx +++ b/web/src/components/ui/alert.tsx @@ -55,4 +55,4 @@ const AlertDescription = React.forwardRef< )) AlertDescription.displayName = 'AlertDescription' -export { Alert, AlertTitle, AlertDescription } +export { Alert, AlertTitle, AlertDescription } \ No newline at end of file diff --git a/web/src/components/ui/calendar.tsx b/web/src/components/ui/calendar.tsx index 91c684a..84c26bf 100644 --- a/web/src/components/ui/calendar.tsx +++ b/web/src/components/ui/calendar.tsx @@ -1,69 +1,210 @@ -import * as React from 'react' -import { ChevronLeft, ChevronRight } from 'lucide-react' -import { DayPicker } from 'react-day-picker' -import { cn } from '@/lib/utils' -import { buttonVariants } from '@/components/ui/button' +import * as React from "react" +import { + ChevronDownIcon, + ChevronLeftIcon, + ChevronRightIcon, +} from "lucide-react" +import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker" -export type CalendarProps = React.ComponentProps +import { cn } from "@/lib/utils" +import { Button, buttonVariants } from "@/components/ui/button" function Calendar({ className, classNames, showOutsideDays = true, + captionLayout = "label", + buttonVariant = "ghost", + formatters, + components, ...props -}: CalendarProps) { +}: React.ComponentProps & { + buttonVariant?: React.ComponentProps["variant"] +}) { + const defaultClassNames = getDefaultClassNames() return ( svg]:rotate-180`, + String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, + className + )} + captionLayout={captionLayout} + formatters={{ + formatMonthDropdown: (date) => + date.toLocaleString("default", { month: "short" }), + ...formatters, + }} classNames={{ - months: 'flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0', - month: 'space-y-4', - caption: 'flex justify-center pt-1 relative items-center', - caption_label: 'text-sm font-medium', - nav: 'space-x-1 flex items-center', - nav_button: cn( - buttonVariants({ variant: 'outline' }), - 'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100' + root: cn("w-fit", defaultClassNames.root), + months: cn( + "relative flex flex-col gap-4 md:flex-row", + defaultClassNames.months ), - nav_button_previous: 'absolute left-1', - nav_button_next: 'absolute right-1', - table: 'w-full border-collapse space-y-1', - head_row: 'flex', - head_cell: - 'text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]', - row: 'flex w-full mt-2', - cell: cn( - 'relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected].day-range-end)]:rounded-r-md', - props.mode === 'range' - ? '[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md' - : '[&:has([aria-selected])]:rounded-md' + month: cn("flex w-full flex-col gap-4", defaultClassNames.month), + nav: cn( + "absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", + defaultClassNames.nav + ), + button_previous: cn( + buttonVariants({ variant: buttonVariant }), + "h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50", + defaultClassNames.button_previous + ), + button_next: cn( + buttonVariants({ variant: buttonVariant }), + "h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50", + defaultClassNames.button_next + ), + month_caption: cn( + "flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]", + defaultClassNames.month_caption + ), + dropdowns: cn( + "flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium", + defaultClassNames.dropdowns + ), + dropdown_root: cn( + "has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border", + defaultClassNames.dropdown_root + ), + dropdown: cn( + "bg-popover absolute inset-0 opacity-0", + defaultClassNames.dropdown + ), + caption_label: cn( + "select-none font-medium", + captionLayout === "label" + ? "text-sm" + : "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5", + defaultClassNames.caption_label + ), + table: "w-full border-collapse", + weekdays: cn("flex", defaultClassNames.weekdays), + weekday: cn( + "text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal", + defaultClassNames.weekday + ), + week: cn("mt-2 flex w-full", defaultClassNames.week), + week_number_header: cn( + "w-[--cell-size] select-none", + defaultClassNames.week_number_header + ), + week_number: cn( + "text-muted-foreground select-none text-[0.8rem]", + defaultClassNames.week_number ), day: cn( - buttonVariants({ variant: 'ghost' }), - 'h-8 w-8 p-0 font-normal aria-selected:opacity-100' + "group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md", + defaultClassNames.day ), - day_range_start: 'day-range-start', - day_range_end: 'day-range-end', - day_selected: - 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground', - day_today: 'bg-accent text-accent-foreground', - day_outside: - 'day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground', - day_disabled: 'text-muted-foreground opacity-50', - day_range_middle: - 'aria-selected:bg-accent aria-selected:text-accent-foreground', - day_hidden: 'invisible', + range_start: cn( + "bg-accent rounded-l-md", + defaultClassNames.range_start + ), + range_middle: cn("rounded-none", defaultClassNames.range_middle), + range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end), + today: cn( + "bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none", + defaultClassNames.today + ), + outside: cn( + "text-muted-foreground aria-selected:text-muted-foreground", + defaultClassNames.outside + ), + disabled: cn( + "text-muted-foreground opacity-50", + defaultClassNames.disabled + ), + hidden: cn("invisible", defaultClassNames.hidden), ...classNames, }} components={{ - IconLeft: () => , - IconRight: () => , + Root: ({ className, rootRef, ...props }) => { + return ( +
+ ) + }, + Chevron: ({ className, orientation, ...props }) => { + if (orientation === "left") { + return ( + + ) + } + + if (orientation === "right") { + return ( + + ) + } + + return ( + + ) + }, + DayButton: CalendarDayButton, + WeekNumber: ({ children, ...props }) => { + return ( + +
+ {children} +
+ + ) + }, + ...components, }} {...props} /> ) } -Calendar.displayName = 'Calendar' -export { Calendar } +function CalendarDayButton({ + className, + day, + modifiers, + ...props +}: React.ComponentProps) { + const defaultClassNames = getDefaultClassNames() + + const ref = React.useRef(null) + React.useEffect(() => { + if (modifiers.focused) ref.current?.focus() + }, [modifiers.focused]) + + return ( + - - - - - ) -} diff --git a/web/src/features/access-tokens/components/acl-detail-dialog.tsx b/web/src/features/access-tokens/components/acl-detail-dialog.tsx deleted file mode 100644 index 6bc1c61..0000000 --- a/web/src/features/access-tokens/components/acl-detail-dialog.tsx +++ /dev/null @@ -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 . - - -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 ( - { - onOpenChange(state) - }} - > - - - {t('settings.acl')} - - {t('accessTokens.aclRulesForAccessTokens')} - - - -
- {/* IP Whitelist */} -
- -