mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-31 01:52:30 +00:00
Compare commits
85 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1453f3cda | ||
|
|
9e656b1f91 | ||
|
|
afac192280 | ||
|
|
c00ffe8d11 | ||
|
|
c90a2d552d | ||
|
|
a4627a63cb | ||
|
|
684b9765fe | ||
|
|
b0ce7671a8 | ||
|
|
4b13bf14c4 | ||
|
|
0015192b4d | ||
|
|
2f3acdd759 | ||
|
|
0fc83b693e | ||
|
|
9ba56ca5bb | ||
|
|
8f7244ccb9 | ||
|
|
454950374f | ||
|
|
3a2b42f5c3 | ||
|
|
34ec3a7d5b | ||
|
|
82397ab0cd | ||
|
|
1cfc12324f | ||
|
|
dffdac3eb6 | ||
|
|
7d02e58e4e | ||
|
|
847cc6825a | ||
|
|
6ce1420714 | ||
|
|
9a72ce9154 | ||
|
|
9f713ef044 | ||
|
|
275cde180b | ||
|
|
c82fb4f301 | ||
|
|
736270b08c | ||
|
|
855c9a1ffc | ||
|
|
b845eda58a | ||
|
|
6571966228 | ||
|
|
c727460251 | ||
|
|
7c7e353114 | ||
|
|
78b33f8994 | ||
|
|
324051bdf0 | ||
|
|
33a16c1af9 | ||
|
|
1bfecc2ff1 | ||
|
|
a8282a95f3 | ||
|
|
9fe617aed7 | ||
|
|
22e12b68a8 | ||
|
|
c6ee92aa3d | ||
|
|
089b6885a7 | ||
|
|
e41b26ba0b | ||
|
|
ea81d0298b | ||
|
|
5ebc7394d0 | ||
|
|
b97f2666a0 | ||
|
|
b36e63f530 | ||
|
|
d4dfee8649 | ||
|
|
1042fad6d0 | ||
|
|
1e9a5fde51 | ||
|
|
c586bab84e | ||
|
|
6a5706e846 | ||
|
|
901615e031 | ||
|
|
f988c0df54 | ||
|
|
7db800005c | ||
|
|
a62130fb5c | ||
|
|
80a2667ae1 | ||
|
|
b6847be9f4 | ||
|
|
e8368a5776 | ||
|
|
7b2280f460 | ||
|
|
ef06cd2310 | ||
|
|
ede685ada8 | ||
|
|
d1be6aa6f2 | ||
|
|
f97e6958d5 | ||
|
|
227da127ae | ||
|
|
11619f8918 | ||
|
|
0bf308438f | ||
|
|
d62d4d3b47 | ||
|
|
b05bb4f48e | ||
|
|
1f77ed91a7 | ||
|
|
57b0ad946c | ||
|
|
ad02c76849 | ||
|
|
e18b01dafb | ||
|
|
152478b767 | ||
|
|
77aae07167 | ||
|
|
b607ee473a | ||
|
|
4f4fe6bbbc | ||
|
|
750c56c5e8 | ||
|
|
d2575862ad | ||
|
|
7c7c7ee7f3 | ||
|
|
c1ca64d606 | ||
|
|
8f2469cb36 | ||
|
|
4e790f1512 | ||
|
|
5fd05fb154 | ||
|
|
3ab32f6a38 |
15
.github/FUNDING.yml
vendored
Normal file
15
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: rustmailer
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
21
.github/ISSUE_TEMPLATE/bug.md.md
vendored
Normal file
21
.github/ISSUE_TEMPLATE/bug.md.md
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Report a problem you encountered
|
||||
title: "[BUG] "
|
||||
labels: ["bug"]
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
> **Please write and communicate in English.**
|
||||
|
||||
### Version
|
||||
Which version are you using?
|
||||
|
||||
### Steps to Reproduce
|
||||
Describe the steps to reproduce the issue clearly.
|
||||
|
||||
### Issue Description
|
||||
What is the problem you encountered?
|
||||
|
||||
### Screenshots or Logs (optional)
|
||||
Attach any screenshots or logs if available.
|
||||
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
18
.github/ISSUE_TEMPLATE/feature.md
vendored
Normal file
18
.github/ISSUE_TEMPLATE/feature.md
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Suggest a new feature or improvement
|
||||
title: "[FEATURE] "
|
||||
labels: ["enhancement"]
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
> **Please write and communicate in English.**
|
||||
|
||||
### Description
|
||||
What feature would you like to see?
|
||||
|
||||
### Purpose / Use Case
|
||||
Why is this feature needed? What problem does it solve?
|
||||
|
||||
### Additional Information (optional)
|
||||
Any extra ideas or context.
|
||||
15
.github/ISSUE_TEMPLATE/other.md
vendored
Normal file
15
.github/ISSUE_TEMPLATE/other.md
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
name: Other Issue
|
||||
about: Any other question or topic
|
||||
title: ""
|
||||
labels: ["question"]
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
> **Please write and communicate in English.**
|
||||
|
||||
### Description
|
||||
Describe your question or topic.
|
||||
|
||||
### Additional Information (optional)
|
||||
Provide any additional context if needed.
|
||||
41
.github/workflows/release.yml
vendored
41
.github/workflows/release.yml
vendored
@@ -25,6 +25,10 @@ jobs:
|
||||
os: macos-latest
|
||||
- target: x86_64-pc-windows-msvc
|
||||
os: windows-latest
|
||||
- target: aarch64-unknown-linux-gnu
|
||||
os: ubuntu-latest
|
||||
- target: aarch64-apple-darwin
|
||||
os: macos-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -56,11 +60,19 @@ jobs:
|
||||
if: matrix.target == 'x86_64-unknown-linux-musl'
|
||||
run: sudo apt-get update && sudo apt-get install -y musl-tools
|
||||
|
||||
- name: Build aarch64 Rust backend
|
||||
if: matrix.target == 'aarch64-unknown-linux-gnu'
|
||||
run: |
|
||||
cargo install cross --force
|
||||
cross build --release --features vendored-openssl --target=${{ matrix.target }}
|
||||
|
||||
- name: Build Rust backend
|
||||
run: cargo build --release --features vendored-openssl --target=${{ matrix.target }}
|
||||
if: matrix.target != 'aarch64-unknown-linux-gnu'
|
||||
run: |
|
||||
cargo build --release --features vendored-openssl --target=${{ matrix.target }}
|
||||
|
||||
- name: Strip binary (Linux and macOS)
|
||||
if: matrix.os != 'windows-latest'
|
||||
if: matrix.os != 'windows-latest' && matrix.target != 'aarch64-unknown-linux-gnu'
|
||||
run: |
|
||||
strip target/${{ matrix.target }}/release/${{ env.BINARY_NAME }}
|
||||
|
||||
@@ -144,15 +156,31 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download Linux musl artifact
|
||||
- name: Download Linux x86_64 artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: x86_64-unknown-linux-musl
|
||||
name: x86_64-unknown-linux-gnu
|
||||
path: artifacts
|
||||
|
||||
- name: Download Linux aarch64 artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: aarch64-unknown-linux-gnu
|
||||
path: artifacts
|
||||
|
||||
- name: Prepare Docker context
|
||||
- name: Extract amd64 binary
|
||||
run: |
|
||||
tar -xzf artifacts/${{ env.BINARY_NAME }}-*.tar.gz -C docker
|
||||
mkdir -p docker/amd64
|
||||
AMD64_FILE=$(ls artifacts | grep x86_64-unknown-linux-gnu.tar.gz)
|
||||
echo "Extracting $AMD64_FILE"
|
||||
tar -xzf artifacts/$AMD64_FILE -C docker/amd64
|
||||
|
||||
- name: Extract arm64 binary
|
||||
run: |
|
||||
mkdir -p docker/arm64
|
||||
ARM64_FILE=$(ls artifacts | grep aarch64-unknown-linux-gnu.tar.gz)
|
||||
echo "Extracting $ARM64_FILE"
|
||||
tar -xzf artifacts/$ARM64_FILE -C docker/arm64
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
@@ -168,6 +196,7 @@ jobs:
|
||||
with:
|
||||
context: ./docker
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
rustmailer/bichon:${{ github.ref_name }}
|
||||
rustmailer/bichon:latest
|
||||
|
||||
2391
Cargo.lock
generated
2391
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
22
Cargo.toml
22
Cargo.toml
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "bichon"
|
||||
version = "0.0.1"
|
||||
version = "0.1.4"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
@@ -20,7 +20,7 @@ codegen-units = 1
|
||||
|
||||
[dependencies]
|
||||
chrono = "0.4.42"
|
||||
clap = { version = "4.5.51", features = ["derive", "env"] }
|
||||
clap = { version = "4.5.53", features = ["derive", "env"] }
|
||||
mimalloc = "0.1.48"
|
||||
native_db = "0.8.2"
|
||||
itertools = "0.14.0"
|
||||
@@ -39,28 +39,28 @@ ring = { version = "0.17.14", features = ["std"] }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.145"
|
||||
tokio = { version = "1.48.0", features = ["full"] }
|
||||
tracing = "0.1.41"
|
||||
tracing = "0.1.43"
|
||||
tracing-appender = "0.2.3"
|
||||
tracing-subscriber = { version = "0.3.20", features = ["env-filter", "json"] }
|
||||
tracing-subscriber = { version = "0.3.22", features = ["env-filter", "json"] }
|
||||
base64 = "0.22.1"
|
||||
snafu = "0.8.9"
|
||||
reqwest = { version = "0.12.24", default-features = false, features = [
|
||||
"json",
|
||||
"stream",
|
||||
"native-tls",
|
||||
# "rustls-tls",
|
||||
#"native-tls",
|
||||
"rustls-tls",
|
||||
"blocking",
|
||||
"socks",
|
||||
] }
|
||||
tokio-socks = "0.5.2"
|
||||
http = "1.3.1"
|
||||
http = "1.4.0"
|
||||
regex = "1.12.2"
|
||||
email_address = "0.2.9"
|
||||
futures = "0.3.31"
|
||||
utf7-imap = "0.3.2"
|
||||
imap-proto = "0.16.6"
|
||||
mail-parser = { version = '0.11.1', features = ["serde"] }
|
||||
mail-send = "0.5.2"
|
||||
# mail-send = "0.5.2"
|
||||
tokio-rustls = { version = "0.26.4", default-features = false, features = [
|
||||
"ring",
|
||||
"tls12",
|
||||
@@ -83,9 +83,9 @@ async-imap = { version = "0.11.1", default-features = false, features = [
|
||||
] }
|
||||
webpki-roots = "1.0.4"
|
||||
rustls = { version = "0.23.35", default-features = false, features = ["ring"] }
|
||||
rustls-pki-types = "1.13.0"
|
||||
rustls-pki-types = "1.13.1"
|
||||
tokio-io-timeout = "1.2.1"
|
||||
bb8 = "0.9.0"
|
||||
bb8 = "0.9.1"
|
||||
semver = "1.0.27"
|
||||
governor = "0.10.2"
|
||||
lru = "0.16.2"
|
||||
@@ -106,7 +106,7 @@ openssl-sys = { version = "0.9.111", optional = true, features = ["vendored"] }
|
||||
gethostname = "1.1.0"
|
||||
tantivy = { version = "0.25.0", features = ["quickwit", "zstd-compression"] }
|
||||
itoa = "1.0.15"
|
||||
html2text = "0.16.2"
|
||||
html2text = "0.16.4"
|
||||
bytes = "1.11.0"
|
||||
[dev-dependencies]
|
||||
#bincode = "1.3.3"
|
||||
|
||||
465
README.md
465
README.md
@@ -0,0 +1,465 @@
|
||||
<div align="center">
|
||||
|
||||
<h1 align="center">
|
||||
<img width="200" height="175" alt="image" src="https://github.com/user-attachments/assets/06dc3b67-7d55-4a93-a3de-8b90951c575b" />
|
||||
<br>
|
||||
Bichon
|
||||
<br>
|
||||
</h1>
|
||||
|
||||
<h3 align="center">
|
||||
A lightweight, high-performance Rust email archiver with WebUI
|
||||
</h3>
|
||||
|
||||
<p style="display: flex; gap: 10px; justify-content: center; flex-wrap: wrap;">
|
||||
<a href="https://github.com/rustmailer/bichon/releases">
|
||||
<img src="https://img.shields.io/github/v/release/rustmailer/bichon" alt="Release">
|
||||
</a>
|
||||
<a href="https://hub.docker.com/r/rustmailer/bichon">
|
||||
<img src="https://img.shields.io/docker/v/rustmailer/bichon?label=docker" alt="Docker">
|
||||
</a>
|
||||
<a href="LICENSE">
|
||||
<img src="https://img.shields.io/badge/license-AGPLv3-blue.svg" alt="License">
|
||||
</a>
|
||||
<a href="https://deepwiki.com/rustmailer/bichon"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki"></a>
|
||||
<a href="https://discord.gg/Bq4M2cDmF4">
|
||||
<img src="https://img.shields.io/badge/Discord-Join%20Server-7289DA?logo=discord&logoColor=white" alt="Discord">
|
||||
</a>
|
||||
<a href="https://x.com/rustmailer">
|
||||
<img src="https://img.shields.io/twitter/follow/rustmailer?style=social" alt="Follow on X">
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
Bichon is an open-source email archiving system that **synchronizes emails from IMAP servers**, **indexes them for full-text search**, and provides a **REST API** for programmatic access.
|
||||
**Unlike email clients**, Bichon is designed for **archiving and searching** rather than sending/receiving emails. It runs as a **standalone server application** that continuously synchronizes configured email accounts and maintains a **searchable local archive**.
|
||||
Built in Rust, it requires no external dependencies and provides fast, efficient email archiving, management, and search through a built-in WebUI. Its name is inspired by the puppy my daughter adopted last month.
|
||||
|
||||
## Key Differences from Email Clients
|
||||
|
||||
### Core Comparison
|
||||
|
||||
| Feature | Email Clients | Bichon |
|
||||
|---------|---------------|--------|
|
||||
| **Primary Purpose** | Send/receive emails, real-time communication | Archive, search, manage historical emails |
|
||||
| **Sending Capability** | ✅ Supports sending emails | ❌ No email sending support |
|
||||
| **Runtime Mode** | Desktop/mobile applications | Server-side application |
|
||||
| **Data Storage** | Local cache + server | Local archive store |
|
||||
| **Search Capability** | Basic search | Full-text indexing, advanced search |
|
||||
| **API Interface** | Typically not provided | Complete REST API |
|
||||
| **Multi-account Management** | Limited | Supports unified search across accounts |
|
||||
|
||||
|
||||
## 🚀 Features
|
||||
|
||||
### ⚡ Lightweight & Standalone
|
||||
- Pure Rust, single-machine application.
|
||||
- No external database required.
|
||||
- Includes **WebUI** for intuitive management.
|
||||
|
||||
### 📬 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?
|
||||
|
||||
A few months ago, I released **rustmailer**, an email API middleware:
|
||||
https://github.com/rustmailer/rustmailer
|
||||
|
||||
Since then, I’ve received many emails asking whether it could also archive emails, perform unified search, and support full-text indexing—not just querying recipients.
|
||||
But rustmailer was designed as a middleware focused on providing API services.
|
||||
Adding archiving and full-text search would complicate its core purpose and go far beyond its original scope.
|
||||
|
||||
Meanwhile, I realized that email archiving itself only requires a small portion of rustmailer’s functionality, plus a search engine.
|
||||
With that combination, building a dedicated, efficient archiver becomes much simpler.
|
||||
|
||||
Using the experience gained from rustmailer, I designed and built **Bichon** in less than two weeks, followed by another two weeks of testing and optimization.
|
||||
It has now reached a stable, usable state—and I decided to release it publicly.
|
||||
|
||||
**Bichon is completely free**.
|
||||
You can download and use it however you like.
|
||||
It’s not perfect, but I hope it brings you value.
|
||||
## 📸 Snapshot
|
||||
<img width="1914" height="904" alt="image" src="https://github.com/user-attachments/assets/3a456999-e4eb-441e-9052-3a727dea66a0" />
|
||||
<img width="1900" height="907" alt="image" src="https://github.com/user-attachments/assets/95db0a05-4b55-4e18-b418-9d40361d6fea" />
|
||||
<img width="1912" height="904" alt="image" src="https://github.com/user-attachments/assets/96b0ebc2-4778-452b-891f-dc9acf8e381f" />
|
||||
<img width="1909" height="904" alt="image" src="https://github.com/user-attachments/assets/ab4bf6ae-faa6-4b49-ae39-705eb9d4487f" />
|
||||
<img width="1910" height="910" alt="image" src="https://github.com/user-attachments/assets/bcf9cca2-d690-4e7b-b2c9-c52a31c7b999" />
|
||||
<img width="1915" height="903" alt="image" src="https://github.com/user-attachments/assets/242817d7-3e12-4cbb-afb0-c5ef7366178d" />
|
||||
<img width="1920" height="910" alt="image" src="https://github.com/user-attachments/assets/14561b74-ed53-4017-9c5b-a64920ec3526" />
|
||||
<img width="1913" height="909" alt="image" src="https://github.com/user-attachments/assets/6fd54cb0-c86f-4ceb-a955-c81107614fc4" />
|
||||
<img width="1916" height="814" alt="image" src="https://github.com/user-attachments/assets/6a079d98-ff6c-46f4-9ec6-e76d320bff5d" />
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#rustmailer/bichon&type=date&legend=top-left)
|
||||
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Docker Deployment (Recommended)
|
||||
|
||||
```bash
|
||||
# Pull the image
|
||||
docker pull rustmailer/bichon:latest
|
||||
|
||||
# Create data directory
|
||||
mkdir -p ./bichon-data
|
||||
|
||||
# Run container
|
||||
docker run -d \
|
||||
--name bichon \
|
||||
-p 15630:15630 \
|
||||
-v $(pwd)/bichon-data:/data \
|
||||
-e BICHON_LOG_LEVEL=info \
|
||||
-e BICHON_ROOT_DIR=/data \
|
||||
rustmailer/bichon:latest
|
||||
```
|
||||
|
||||
## CORS Configuration (Important for Browser Access)
|
||||
|
||||
Starting from **v0.1.4**, Bichon changes how `BICHON_CORS_ORIGINS` works:
|
||||
|
||||
### **🔄 New Behavior in v0.1.4**
|
||||
|
||||
* If **`BICHON_CORS_ORIGINS` is not set**, Bichon now **allows all origins**.
|
||||
This makes local testing and simple deployments much easier.
|
||||
* If you **do set** `BICHON_CORS_ORIGINS`, then **you must explicitly list each allowed origin**.
|
||||
* `*` is **not supported** and will **not work** — you must provide exact URLs.
|
||||
|
||||
#### How CORS Matching Works
|
||||
|
||||
When a browser accesses Bichon, it will send an `Origin` header.
|
||||
|
||||
* **Incoming Origin** = the exact address the browser is using
|
||||
* **Configured origins** = the list you passed to `BICHON_CORS_ORIGINS`
|
||||
|
||||
If Configured origins does not contain the Incoming Origin exactly as a full string match, the browser request will be rejected.
|
||||
|
||||
Example debug log:
|
||||
|
||||
```
|
||||
2025-12-06T23:56:30.422+08:00 DEBUG bichon::modules::rest: CORS: Incoming Origin = "http://localhost:15630"
|
||||
2025-12-06T23:56:30.422+08:00 DEBUG bichon::modules::rest: CORS: Configured origins = ["http://192.168.3.2:15630"]
|
||||
```
|
||||
|
||||
In this example:
|
||||
|
||||
* Browser is using `http://localhost:15630`
|
||||
* But the configured origin is `http://192.168.3.2:15630`
|
||||
|
||||
→ **CORS will fail**, and you can immediately see why.
|
||||
|
||||
#### When Should You Configure CORS?
|
||||
|
||||
It is strongly recommended to configure CORS in production environments to ensure that only trusted browser origins can access Bichon.
|
||||
If you want to access Bichon from a browser:
|
||||
|
||||
* Add the exact **IP** with port
|
||||
* Or the exact **hostname** with port
|
||||
* Or the **domain** (port optional if it's 80)
|
||||
|
||||
Examples:
|
||||
|
||||
```
|
||||
http://192.168.1.16:15630
|
||||
http://myserver.local:15630
|
||||
http://mydomain.com
|
||||
```
|
||||
|
||||
If you access Bichon in **multiple different ways**, list all of them:
|
||||
|
||||
```
|
||||
-e BICHON_CORS_ORIGINS="http://192.168.1.16:15630,http://myserver.local:15630,http://mydomain.com"
|
||||
```
|
||||
|
||||
> **Do not add a trailing slash**
|
||||
> (`http://192.168.1.16:15630/` will not match)
|
||||
>
|
||||
> **Do not use `*`**, it is not supported.
|
||||
|
||||
#### How to Enable Debug Logs (Highly Recommended for CORS Issues)
|
||||
|
||||
Set environment variable:
|
||||
|
||||
```
|
||||
BICHON_LOG_LEVEL=debug
|
||||
```
|
||||
|
||||
Or via command-line:
|
||||
|
||||
```
|
||||
--bichon-log-level debug
|
||||
```
|
||||
|
||||
Default is `info`, so CORS logs will not appear unless debug logging is enabled.
|
||||
|
||||
---
|
||||
|
||||
#### ⚠️ Note on Running Bichon in a Container
|
||||
|
||||
> ⚠️ **Note:** If you are running Bichon in a container (via **Docker Compose** or **docker run**), be careful with **quotes in environment variable values**.
|
||||
|
||||
For example, **do not** write:
|
||||
|
||||
```bash
|
||||
-e BICHON_CORS_ORIGINS="http://localhost:15630,http://myserver.local:15630"
|
||||
```
|
||||
|
||||
* The outer quotes (`"`) will be passed literally into the container and may cause CORS misconfiguration.
|
||||
|
||||
**Correct way:**
|
||||
|
||||
```bash
|
||||
-e BICHON_CORS_ORIGINS=http://localhost:15630,http://myserver.local:15630
|
||||
```
|
||||
|
||||
Or using YAML literal style for Docker Compose:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
BICHON_CORS_ORIGINS: |
|
||||
http://localhost:15630,http://myserver.local:15630
|
||||
```
|
||||
|
||||
This ensures that the configured origins are interpreted correctly inside the container.
|
||||
|
||||
> ⚠️ **Note:** This fucking problem I actually didn’t know about myself; thanks to [gall-1](https://github.com/gall-1) for pointing it out.
|
||||
|
||||
|
||||
### Binary Deployment
|
||||
|
||||
Download the appropriate binary for your platform from the [Releases](https://github.com/rustmailer/bichon/releases) page:
|
||||
|
||||
- Linux (GNU): `bichon-x.x.x-x86_64-unknown-linux-gnu.tar.gz`
|
||||
- Linux (MUSL): `bichon-x.x.x-x86_64-unknown-linux-musl.tar.gz`
|
||||
- macOS: `bichon-x.x.x-x86_64-apple-darwin.tar.gz`
|
||||
- Windows: `bichon-x.x.x-x86_64-pc-windows-msvc.zip`
|
||||
|
||||
Extract and run:
|
||||
|
||||
```bash
|
||||
# Linux/macOS
|
||||
./bichon --bichon-root-dir /tmp/bichon-data
|
||||
|
||||
# Windows
|
||||
.\bichon.exe --bichon-root-dir e:\bichon-data
|
||||
```
|
||||
|
||||
* --bichon-root-dir argument is required and must be an absolute path.
|
||||
|
||||
* If you are accessing Bichon from a proxy domain **mydomain** argument --bichon-cors-origins="https://mydomain" is required.
|
||||
|
||||
## 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.
|
||||
|
||||
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**.
|
||||
|
||||
### How to Set the Password
|
||||
|
||||
You can set the password **via command-line or environment variable**:
|
||||
|
||||
### Command-Line
|
||||
|
||||
```bash
|
||||
bichon --bichon-encrypt-password "your-strong-password"
|
||||
```
|
||||
|
||||
### Environment Variable
|
||||
|
||||
```bash
|
||||
export BICHON_ENCRYPT_PASSWORD="your-strong-password"
|
||||
bichon
|
||||
```
|
||||
|
||||
**Tip:** Use a strong, secure password and keep it safe, as it cannot be changed later.
|
||||
|
||||
## 🔑 Root User Login Information
|
||||
|
||||
**Bichon currently supports a single Root user login for system access and management.**
|
||||
|
||||
### First Login and Enabling Access
|
||||
|
||||
To enable the login feature, you must specify a command-line argument or set an environment variable when starting Bichon.
|
||||
|
||||
#### 1\. Command-Line Argument
|
||||
|
||||
Add the `--bichon-enable-access-token` flag to your startup command:
|
||||
|
||||
```bash
|
||||
# Linux/macOS Binary Deployment Example
|
||||
./bichon --bichon-root-dir /tmp/bichon-data --bichon-enable-access-token
|
||||
```
|
||||
|
||||
#### 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.
|
||||
|
||||
|
||||
## 📖 Documentation
|
||||
|
||||
> Under construction. Documentation will be available soon.
|
||||
[Bichon Wiki](https://github.com/rustmailer/bichon/wiki).
|
||||
|
||||
## FAQ
|
||||
|
||||
please see the FAQ in the project Wiki:
|
||||
|
||||
👉 [https://github.com/rustmailer/bichon/wiki/FAQ](https://github.com/rustmailer/bichon/wiki/FAQ-(Frequently-Asked-Questions))
|
||||
|
||||
|
||||
## 🛠️ Tech Stack
|
||||
|
||||
- **Backend**: Rust + Poem
|
||||
- **Frontend**: React + TypeScript + Vite + ShadCN UI
|
||||
- **Core Engine (Storage & Search)**: Tantivy
|
||||
- Acts as both the primary storage for email content and the full-text search index. This unified approach ensures high performance and eliminates data redundancy.
|
||||
- **Metadata Storage**: Native_DB
|
||||
- Used exclusively for lightweight configuration and account metadata.
|
||||
- **Email Protocols**: IMAP (Supports standard Password & OAuth2)
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions of all kinds are welcome!
|
||||
Whether you’d like to submit code, report a bug, or share practical suggestions that can help improve the project, your input is highly appreciated.
|
||||
Feel free to open an Issue or a Pull Request anytime. You can also reach out on Discord if you’d like to discuss ideas or improvements.
|
||||
<a href="https://discord.gg/Bq4M2cDmF4">
|
||||
<img src="https://img.shields.io/badge/Discord-Join%20Server-7289DA?logo=discord&logoColor=white" alt="Discord">
|
||||
</a>
|
||||
|
||||
## 🧑💻 Developer Guide
|
||||
|
||||
To build or contribute to Bichon, the following environment is recommended:
|
||||
|
||||
### Prerequisites
|
||||
- **Rust**: Use the latest stable toolchain for best compatibility and performance.
|
||||
- **Node.js**: Version **20+** is required.
|
||||
- **pnpm**: Recommended package manager for the WebUI.
|
||||
|
||||
### Steps
|
||||
|
||||
#### 1. Clone the repository
|
||||
```bash
|
||||
git clone https://github.com/rustmailer/bichon.git
|
||||
cd bichon
|
||||
```
|
||||
|
||||
#### 2. Build the WebUI
|
||||
|
||||
```bash
|
||||
cd web
|
||||
pnpm install
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
Run the WebUI in development mode if needed:
|
||||
|
||||
```bash
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
#### 3. Build or Run the Backend
|
||||
|
||||
After the WebUI is built, return to the project root:
|
||||
|
||||
```bash
|
||||
cd ..
|
||||
cargo build
|
||||
```
|
||||
|
||||
Or run directly:
|
||||
|
||||
```bash
|
||||
cargo run -- --bichon-root-dir e:\bichon-data
|
||||
```
|
||||
`--bichon-root-dir` specifies the directory where **all Bichon data** will be stored.
|
||||
|
||||
### WebUI Access
|
||||
|
||||
* The WebUI runs on **[http://localhost:15630](http://localhost:15630)** by default.
|
||||
* **HTTPS is not enabled** in development or default builds.
|
||||
|
||||
<cite/>
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is licensed under [AGPLv3](LICENSE).
|
||||
|
||||
## 🔗 Links
|
||||
|
||||
- [Docker Hub](https://hub.docker.com/r/rustmailer/bichon)
|
||||
- [Issue Tracker](https://github.com/rustmailer/bichon/issues)
|
||||
- [Discord](https://discord.gg/Bq4M2cDmF4)
|
||||
|
||||
|
||||
## 💖 Support & Promotion
|
||||
|
||||
If this project has been helpful to you and you’d like to support its development, you can consider making a small donation or helping spread the word.
|
||||
Financial support is optional but deeply appreciated — it helps me dedicate more time and resources to building new features and improving the overall experience.
|
||||
|
||||
You can also support the project by sharing it with others, writing about your experience, or recommending it within relevant communities. Every bit of visibility helps more people benefit from the tool!
|
||||
|
||||
[](https://buymeacoffee.com/rustmailer)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Use Debian bullseye-slim as the base image for newer OpenSSL
|
||||
FROM ubuntu:24.04
|
||||
ARG CRATE_VERSION
|
||||
ARG TARGETARCH
|
||||
|
||||
# Set maintainer metadata
|
||||
LABEL maintainer="rustmailer <rustmailer.git@gmail.com>"
|
||||
@@ -11,14 +12,13 @@ LABEL description="Dockerized Bichon service"
|
||||
WORKDIR /opt/bichon
|
||||
|
||||
# Copy compiled binary (ensure it's statically linked or compatible with bullseye)
|
||||
COPY bichon /opt/bichon/bichon
|
||||
COPY LICENSE /opt/bichon/
|
||||
COPY ${TARGETARCH}/bichon /opt/bichon/bichon
|
||||
COPY ${TARGETARCH}/LICENSE /opt/bichon/
|
||||
# Set proper permissions
|
||||
RUN chmod +x /opt/bichon/bichon
|
||||
|
||||
# Install ca-certificates to ensure HTTPS certificate verification works correctly
|
||||
RUN apt update && apt install -y ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN apt update && apt install -y ca-certificates curl && rm -rf /var/lib/apt/lists/*
|
||||
# Create data directory
|
||||
RUN mkdir -p /data
|
||||
|
||||
|
||||
32
env/rustmailer.env
vendored
32
env/rustmailer.env
vendored
@@ -1,44 +1,44 @@
|
||||
# Log level: trace, debug, info, warn, error
|
||||
RUSTMAILER_LOG_LEVEL=info
|
||||
BICHON_LOG_LEVEL=info
|
||||
|
||||
# HTTP server port
|
||||
RUSTMAILER_HTTP_PORT=15630
|
||||
BICHON_HTTP_PORT=15630
|
||||
|
||||
# Public URL for this RustMailer instance
|
||||
RUSTMAILER_PUBLIC_URL=http://localhost:15630
|
||||
# Public URL for this BICHON instance
|
||||
BICHON_PUBLIC_URL=http://localhost:15630
|
||||
|
||||
# Enable ANSI-colored logs (for terminal output)
|
||||
RUSTMAILER_ANSI_LOGS=false
|
||||
BICHON_ANSI_LOGS=false
|
||||
|
||||
# Enable JSON-formatted logs (for machine parsing)
|
||||
RUSTMAILER_JSON_LOGS=false
|
||||
BICHON_JSON_LOGS=false
|
||||
|
||||
# Persist logs to files
|
||||
RUSTMAILER_LOG_TO_FILE=false
|
||||
BICHON_LOG_TO_FILE=false
|
||||
|
||||
# Max number of server log files to retain
|
||||
RUSTMAILER_MAX_SERVER_LOG_FILES=5
|
||||
BICHON_MAX_SERVER_LOG_FILES=5
|
||||
|
||||
# Encryption password for stored secrets (change this in production!)
|
||||
RUSTMAILER_ENCRYPT_PASSWORD=change-this-default-password-now
|
||||
BICHON_ENCRYPT_PASSWORD=change-this-default-password-now
|
||||
|
||||
# Root directory for persistent storage (adjust to your environment)
|
||||
RUSTMAILER_ROOT_DIR=/data/bichon-data
|
||||
BICHON_ROOT_DIR=/data/bichon-data
|
||||
|
||||
# Enable API access token validation
|
||||
RUSTMAILER_ENABLE_ACCESS_TOKEN=false
|
||||
BICHON_ENABLE_ACCESS_TOKEN=false
|
||||
|
||||
# IP address to bind the HTTP and gRPC servers to (default: 0.0.0.0)
|
||||
RUSTMAILER_BIND_IP=
|
||||
BICHON_BIND_IP=
|
||||
|
||||
# Comma-separated list of allowed CORS origins (e.g. https://app.example.com)
|
||||
RUSTMAILER_CORS_ORIGINS=
|
||||
BICHON_CORS_ORIGINS=
|
||||
|
||||
# Cache duration (in seconds) for CORS preflight responses
|
||||
RUSTMAILER_CORS_MAX_AGE=86400
|
||||
BICHON_CORS_MAX_AGE=86400
|
||||
|
||||
# Enable HTTPS for REST API server
|
||||
RUSTMAILER_ENABLE_REST_HTTPS=false
|
||||
BICHON_ENABLE_REST_HTTPS=false
|
||||
|
||||
# Enable HTTP response compression (gzip or brotli)
|
||||
RUSTMAILER_HTTP_COMPRESSION_ENABLED=true
|
||||
BICHON_HTTP_COMPRESSION_ENABLED=true
|
||||
|
||||
@@ -70,7 +70,6 @@ pub struct AuthConfig {
|
||||
///
|
||||
/// Users should provide a plaintext password (1 to 256 characters).
|
||||
/// The server will encrypt the password using AES-256-GCM and securely store it.
|
||||
/// The plaintext password is never stored, so users must remember it for authentication.
|
||||
#[oai(validator(max_length = 256, min_length = 1))]
|
||||
pub password: Option<String>,
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use native_db::*;
|
||||
use native_model::{native_model, Model};
|
||||
|
||||
@@ -56,7 +55,7 @@ use crate::modules::rest::response::DataPage;
|
||||
use crate::modules::token::AccessToken;
|
||||
use crate::raise_error;
|
||||
|
||||
pub type AccountModel = AccountV1;
|
||||
pub type AccountModel = AccountV2;
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Enum)]
|
||||
pub enum AccountType {
|
||||
@@ -87,11 +86,41 @@ pub struct AccountV1 {
|
||||
pub updated_at: i64,
|
||||
pub use_proxy: Option<u64>,
|
||||
}
|
||||
|
||||
impl AccountV1 {
|
||||
fn pk(&self) -> String {
|
||||
format!("{}_{}", self.created_at, self.id)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
|
||||
#[native_model(id = 4, version = 2, from = AccountV1)]
|
||||
#[native_db(primary_key(pk -> String))]
|
||||
pub struct AccountV2 {
|
||||
#[secondary_key(unique)]
|
||||
pub id: u64,
|
||||
pub imap: Option<ImapConfig>,
|
||||
pub enabled: bool,
|
||||
#[oai(validator(custom = "crate::modules::common::validator::EmailValidator"))]
|
||||
pub email: String,
|
||||
pub name: Option<String>,
|
||||
pub capabilities: Option<Vec<String>>,
|
||||
pub date_since: Option<DateSince>,
|
||||
pub folder_limit: Option<u32>,
|
||||
pub sync_folders: Option<Vec<String>>,
|
||||
pub account_type: AccountType,
|
||||
pub sync_interval_min: Option<i64>,
|
||||
pub known_folders: Option<BTreeSet<String>>,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
pub use_proxy: Option<u64>,
|
||||
pub use_dangerous: bool,
|
||||
pub pgp_key: Option<String>,
|
||||
}
|
||||
|
||||
impl AccountV2 {
|
||||
fn pk(&self) -> String {
|
||||
format!("{}_{}", self.created_at, self.id)
|
||||
}
|
||||
|
||||
pub fn new(request: AccountCreateRequest) -> BichonResult<Self> {
|
||||
Ok(Self {
|
||||
@@ -110,12 +139,14 @@ impl AccountV1 {
|
||||
updated_at: utc_now!(),
|
||||
use_proxy: request.use_proxy,
|
||||
folder_limit: request.folder_limit,
|
||||
use_dangerous: request.use_dangerous,
|
||||
pgp_key: request.pgp_key,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn check_account_active(account_id: u64) -> BichonResult<AccountModel> {
|
||||
pub async fn check_account_exists(account_id: u64) -> BichonResult<AccountModel> {
|
||||
let account =
|
||||
secondary_find_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV1Key::id, account_id)
|
||||
secondary_find_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV2Key::id, account_id)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
@@ -124,12 +155,12 @@ impl AccountV1 {
|
||||
)
|
||||
})?;
|
||||
|
||||
if !account.enabled {
|
||||
return Err(raise_error!(
|
||||
format!("Account id='{account_id}' is disabled"),
|
||||
ErrorCode::AccountDisabled
|
||||
));
|
||||
}
|
||||
// if !account.enabled {
|
||||
// return Err(raise_error!(
|
||||
// format!("Account id='{account_id}' is disabled"),
|
||||
// ErrorCode::AccountDisabled
|
||||
// ));
|
||||
// }
|
||||
Ok(account)
|
||||
}
|
||||
|
||||
@@ -145,7 +176,7 @@ impl AccountV1 {
|
||||
}
|
||||
|
||||
pub async fn find(account_id: u64) -> BichonResult<Option<AccountModel>> {
|
||||
secondary_find_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV1Key::id, account_id)
|
||||
secondary_find_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV2Key::id, account_id)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -199,7 +230,7 @@ impl AccountV1 {
|
||||
|
||||
async fn delete_account(account_id: u64) -> BichonResult<()> {
|
||||
delete_impl(DB_MANAGER.meta_db(), move|rw|{
|
||||
rw.get().secondary::<AccountModel>(AccountV1Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
rw.get().secondary::<AccountModel>(AccountV2Key::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
|
||||
}
|
||||
@@ -229,7 +260,7 @@ impl AccountV1 {
|
||||
sync_folders: Vec<String>,
|
||||
) -> BichonResult<()> {
|
||||
update_impl(DB_MANAGER.meta_db(), move |rw| {
|
||||
rw.get().secondary::<AccountModel>(AccountV1Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
rw.get().secondary::<AccountModel>(AccountV2Key::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();
|
||||
@@ -244,7 +275,7 @@ impl AccountV1 {
|
||||
known_folders: BTreeSet<String>,
|
||||
) -> BichonResult<()> {
|
||||
update_impl(DB_MANAGER.meta_db(), move |rw| {
|
||||
rw.get().secondary::<AccountModel>(AccountV1Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
rw.get().secondary::<AccountModel>(AccountV2Key::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();
|
||||
@@ -259,7 +290,7 @@ impl AccountV1 {
|
||||
capabilities: Vec<String>,
|
||||
) -> BichonResult<()> {
|
||||
update_impl(DB_MANAGER.meta_db(), move |rw| {
|
||||
rw.get().secondary::<AccountModel>(AccountV1Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
rw.get().secondary::<AccountModel>(AccountV2Key::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();
|
||||
@@ -278,7 +309,7 @@ impl AccountV1 {
|
||||
let result = list_all_impl(DB_MANAGER.meta_db())
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|a: &AccountModel| a.enabled)
|
||||
//.filter(|a: &AccountModel| a.enabled)
|
||||
.map(|account: AccountModel| MinimalAccount {
|
||||
id: account.id,
|
||||
email: account.email,
|
||||
@@ -288,7 +319,7 @@ impl AccountV1 {
|
||||
}
|
||||
|
||||
pub async fn count() -> BichonResult<usize> {
|
||||
count_by_unique_secondary_key_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV1Key::id)
|
||||
count_by_unique_secondary_key_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV2Key::id)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -318,17 +349,26 @@ impl AccountV1 {
|
||||
}
|
||||
|
||||
if let Some(name) = &request.name {
|
||||
new.name = Some(name.clone());
|
||||
if name.trim().is_empty() {
|
||||
new.name = None;
|
||||
} else {
|
||||
new.name = Some(name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if matches!(old.account_type, AccountType::IMAP) {
|
||||
if let Some(imap) = &request.imap {
|
||||
let mut new_imap = imap.clone();
|
||||
if let Some(password) = &new_imap.auth.password {
|
||||
let encrypted_password = encrypt!(password)?;
|
||||
new_imap.auth.password = Some(encrypted_password);
|
||||
if let Some(current_imap) = &mut new.imap {
|
||||
current_imap.host = imap.host.clone();
|
||||
current_imap.port = imap.port.clone();
|
||||
current_imap.encryption = imap.encryption.clone();
|
||||
current_imap.auth.auth_type = imap.auth.auth_type.clone();
|
||||
if let Some(password) = &imap.auth.password {
|
||||
let encrypted_password = encrypt!(password)?;
|
||||
current_imap.auth.password = Some(encrypted_password);
|
||||
}
|
||||
current_imap.use_proxy = imap.use_proxy;
|
||||
}
|
||||
new.imap = Some(new_imap);
|
||||
}
|
||||
|
||||
if let Some(folder_names) = request.sync_folders {
|
||||
@@ -351,7 +391,62 @@ impl AccountV1 {
|
||||
if let Some(enabled) = request.enabled {
|
||||
new.enabled = enabled;
|
||||
}
|
||||
|
||||
if let Some(use_dangerous) = request.use_dangerous {
|
||||
new.use_dangerous = use_dangerous;
|
||||
}
|
||||
|
||||
if let Some(pgp_key) = request.pgp_key {
|
||||
new.pgp_key = Some(pgp_key);
|
||||
}
|
||||
|
||||
new.updated_at = utc_now!();
|
||||
Ok(new)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AccountV1> for AccountV2 {
|
||||
fn from(value: AccountV1) -> 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: false,
|
||||
pgp_key: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AccountV2> for AccountV1 {
|
||||
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,
|
||||
use_proxy: value.use_proxy,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use crate::modules::account::entity::ImapConfig;
|
||||
@@ -43,6 +42,8 @@ pub struct AccountCreateRequest {
|
||||
#[oai(validator(minimum(value = "10"), maximum(value = "480")))]
|
||||
pub sync_interval_min: Option<i64>,
|
||||
pub use_proxy: Option<u64>,
|
||||
pub use_dangerous: bool,
|
||||
pub pgp_key: Option<String>,
|
||||
}
|
||||
|
||||
impl AccountCreateRequest {
|
||||
@@ -132,6 +133,10 @@ pub struct AccountUpdateRequest {
|
||||
/// - 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.
|
||||
pub use_proxy: Option<u64>,
|
||||
|
||||
pub use_dangerous: Option<bool>,
|
||||
|
||||
pub pgp_key: Option<String>,
|
||||
}
|
||||
|
||||
impl AccountUpdateRequest {
|
||||
|
||||
@@ -69,7 +69,7 @@ impl AccountRunningState {
|
||||
errors: vec![],
|
||||
is_initial_sync_completed: false,
|
||||
progress: None,
|
||||
initial_sync_start_time: None,
|
||||
initial_sync_start_time: Some(utc_now!()),
|
||||
initial_sync_end_time: None,
|
||||
initial_sync_failed_time: None,
|
||||
};
|
||||
@@ -125,14 +125,14 @@ impl AccountRunningState {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn set_initial_sync_start(account_id: u64) -> BichonResult<()> {
|
||||
Self::update_account_running_state(account_id, move |current| {
|
||||
let mut updated = current.clone();
|
||||
updated.initial_sync_start_time = Some(utc_now!());
|
||||
Ok(updated)
|
||||
})
|
||||
.await
|
||||
}
|
||||
// pub async fn set_initial_sync_start(account_id: u64) -> BichonResult<()> {
|
||||
// Self::update_account_running_state(account_id, move |current| {
|
||||
// let mut updated = current.clone();
|
||||
// updated.initial_sync_start_time = Some(utc_now!());
|
||||
// Ok(updated)
|
||||
// })
|
||||
// .await
|
||||
// }
|
||||
|
||||
pub async fn set_initial_sync_completed(account_id: u64) -> BichonResult<()> {
|
||||
Self::update_account_running_state(account_id, move |current| {
|
||||
|
||||
5
src/modules/cache/imap/sync/mod.rs
vendored
5
src/modules/cache/imap/sync/mod.rs
vendored
@@ -16,7 +16,6 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use crate::{
|
||||
modules::{
|
||||
account::{
|
||||
@@ -46,13 +45,13 @@ pub async fn execute_imap_sync(account: &AccountModel) -> BichonResult<()> {
|
||||
let start_time = Instant::now();
|
||||
let account_id = account.id;
|
||||
let sync_type = determine_sync_type(account).await?;
|
||||
|
||||
if matches!(sync_type, SyncType::SkipSync) {
|
||||
return Ok(());
|
||||
}
|
||||
let remote_mailboxes = get_sync_folders(account).await?;
|
||||
if matches!(sync_type, SyncType::InitialSync) {
|
||||
AccountRunningState::set_initial_sync_start(account_id).await?;
|
||||
AccountRunningState::add(account.id).await?;
|
||||
// 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
|
||||
|
||||
6
src/modules/cache/imap/sync/sync_type.rs
vendored
6
src/modules/cache/imap/sync/sync_type.rs
vendored
@@ -16,7 +16,6 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use crate::{
|
||||
modules::{
|
||||
account::{migration::AccountModel, state::AccountRunningState},
|
||||
@@ -51,10 +50,7 @@ pub async fn determine_sync_type(account: &AccountModel) -> BichonResult<SyncTyp
|
||||
SyncType::SkipSync
|
||||
}
|
||||
}
|
||||
None => {
|
||||
AccountRunningState::add(account.id).await?;
|
||||
SyncType::InitialSync
|
||||
}
|
||||
None => SyncType::InitialSync,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -16,19 +16,14 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use super::error::code::ErrorCode;
|
||||
use super::error::BichonError;
|
||||
use mail_parser::{Addr as ImapAddr, Address as ImapAddress};
|
||||
use mail_send::mail_builder::headers::address::Address as SmtpAddress;
|
||||
use mail_send::mail_builder::headers::address::EmailAddress as SmtpEmailAddress;
|
||||
use poem::error::ResponseError;
|
||||
use poem::Body;
|
||||
use poem::{http::StatusCode, Error, Response};
|
||||
use poem_openapi::Object;
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::borrow::Cow;
|
||||
use std::ops::Deref;
|
||||
use tracing::error;
|
||||
|
||||
@@ -53,38 +48,6 @@ pub struct Addr {
|
||||
pub address: Option<String>,
|
||||
}
|
||||
|
||||
impl Addr {
|
||||
pub fn parse(s: &str) -> Self {
|
||||
let re = Regex::new(r#"(?:(?P<name>.*)\s*)?<(?P<email>[^<>]+)>"#).unwrap();
|
||||
if let Some(caps) = re.captures(s) {
|
||||
let name: Option<String> = caps.name("name").map(|m| m.as_str().trim().into());
|
||||
let email: Option<String> = caps.name("email").map(|m| m.as_str().trim().into());
|
||||
Addr {
|
||||
name: if let Some(n) = name {
|
||||
if n.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(n)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
},
|
||||
address: email,
|
||||
}
|
||||
} else {
|
||||
let s_trimmed = s.trim();
|
||||
Addr {
|
||||
name: None,
|
||||
address: if s_trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(s_trimmed.into())
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Addr {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match (&self.name, &self.address) {
|
||||
@@ -129,50 +92,6 @@ impl<'x> From<&ImapAddress<'x>> for AddrVec {
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> From<SmtpEmailAddress<'x>> for Addr {
|
||||
fn from(email: SmtpEmailAddress<'x>) -> Self {
|
||||
Addr {
|
||||
name: email.name.map(|n| n.into_owned()),
|
||||
address: Some(email.email.into_owned()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> From<&SmtpAddress<'x>> for AddrVec {
|
||||
fn from(address: &SmtpAddress<'x>) -> Self {
|
||||
fn collect_addresses<'x>(address: &SmtpAddress<'x>, result: &mut Vec<Addr>) {
|
||||
match address {
|
||||
SmtpAddress::Address(email) => {
|
||||
let addr = Addr::from(email.clone());
|
||||
result.push(addr);
|
||||
}
|
||||
SmtpAddress::Group(group) => {
|
||||
for addr in &group.addresses {
|
||||
collect_addresses(addr, result);
|
||||
}
|
||||
}
|
||||
SmtpAddress::List(list) => {
|
||||
for addr in list {
|
||||
collect_addresses(addr, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut addresses = Vec::new();
|
||||
collect_addresses(address, &mut addresses);
|
||||
AddrVec(addresses)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> From<Addr> for SmtpAddress<'x> {
|
||||
fn from(addr: Addr) -> Self {
|
||||
SmtpAddress::Address(SmtpEmailAddress {
|
||||
name: addr.name.map(Cow::Owned),
|
||||
email: Cow::Owned(addr.address.unwrap_or_default()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// #[derive(Serialize)]
|
||||
// pub struct ErrorResponse {
|
||||
// pub message: String,
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use crate::modules::account::migration::AccountType;
|
||||
use crate::modules::context::Initialize;
|
||||
use crate::modules::error::code::ErrorCode;
|
||||
use crate::raise_error;
|
||||
@@ -33,8 +33,7 @@ use dashmap::DashMap;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use tracing::info;
|
||||
|
||||
pub static MAIL_CONTEXT: LazyLock<EmailClientExecutors> =
|
||||
LazyLock::new(EmailClientExecutors::new);
|
||||
pub static MAIL_CONTEXT: LazyLock<EmailClientExecutors> = LazyLock::new(EmailClientExecutors::new);
|
||||
|
||||
pub struct EmailClientExecutors {
|
||||
start_at: i64,
|
||||
@@ -88,15 +87,17 @@ impl EmailClientExecutors {
|
||||
|
||||
pub async fn start_account_syncers(&self) -> BichonResult<()> {
|
||||
let accounts = AccountModel::list_all().await?;
|
||||
let active_accounts: Vec<AccountModel> =
|
||||
accounts.into_iter().filter(|a| a.enabled).collect();
|
||||
let active_accounts: Vec<AccountModel> = accounts
|
||||
.into_iter()
|
||||
.filter(|a| a.enabled && matches!(a.account_type, AccountType::IMAP))
|
||||
.collect();
|
||||
|
||||
if active_accounts.is_empty() {
|
||||
info!("No active accounts found for account initialization.");
|
||||
return Ok(());
|
||||
}
|
||||
info!(
|
||||
"System has {} active accounts to initialize.",
|
||||
"System has {} active IMAP accounts to initialize.",
|
||||
active_accounts.len()
|
||||
);
|
||||
for account in active_accounts {
|
||||
|
||||
@@ -16,12 +16,12 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use poem_openapi::Object;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tantivy::{schema::Value, TantivyDocument};
|
||||
|
||||
use crate::{
|
||||
bichon_version,
|
||||
modules::{
|
||||
account::migration::AccountModel,
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
@@ -45,6 +45,8 @@ pub struct DashboardStats {
|
||||
pub with_attachment_count: u64, // Emails with attachments
|
||||
pub without_attachment_count: u64, // Emails without attachments
|
||||
pub top_largest_emails: Vec<LargestEmail>, // Top 10 largest emails
|
||||
pub system_version: String, // The semantic version string of the currently running backend service
|
||||
pub commit_hash: String, // Git commit hash used to build this system version
|
||||
}
|
||||
|
||||
impl DashboardStats {
|
||||
@@ -57,6 +59,8 @@ impl DashboardStats {
|
||||
.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))?;
|
||||
stat.system_version = bichon_version!().to_string();
|
||||
stat.commit_hash = env!("GIT_HASH").to_string();
|
||||
Ok(stat)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use crate::modules::account::migration::AccountModel;
|
||||
use crate::modules::cache::imap::MAILBOX_MODELS;
|
||||
use crate::modules::error::{code::ErrorCode, BichonError};
|
||||
use crate::modules::settings::cli::SETTINGS;
|
||||
@@ -71,6 +71,8 @@ impl DatabaseManager {
|
||||
let rw = database
|
||||
.rw_transaction()
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
rw.migrate::<AccountModel>()
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
rw.commit()
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
|
||||
|
||||
@@ -16,8 +16,7 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use crate::modules::account::migration::AccountV1;
|
||||
use crate::modules::account::migration::{AccountV1, AccountV2};
|
||||
use crate::modules::autoconfig::CachedMailSettings;
|
||||
use crate::modules::error::code::ErrorCode;
|
||||
use crate::modules::error::BichonResult;
|
||||
@@ -63,6 +62,7 @@ impl ModelsAdapter {
|
||||
self.register_model::<SystemSetting>();
|
||||
self.register_model::<CachedMailSettings>();
|
||||
self.register_model::<AccountV1>();
|
||||
self.register_model::<AccountV2>();
|
||||
self.register_model::<OAuth2>();
|
||||
self.register_model::<OAuth2PendingEntity>();
|
||||
self.register_model::<OAuth2AccessToken>();
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use crate::modules::common::AddrVec;
|
||||
use crate::modules::error::code::ErrorCode;
|
||||
use crate::modules::error::BichonResult;
|
||||
@@ -33,12 +32,12 @@ pub fn extract_envelope(fetch: &Fetch, account_id: u64, mailbox_id: u64) -> Bich
|
||||
.map(|d| d.timestamp_millis())
|
||||
.unwrap_or(0);
|
||||
let uid = fetch.uid.unwrap_or(0);
|
||||
let size = fetch.size.unwrap_or(0);
|
||||
|
||||
let body = fetch
|
||||
.body()
|
||||
.ok_or_else(|| raise_error!("No body available".into(), ErrorCode::InternalError))?;
|
||||
|
||||
let size = fetch.size.unwrap_or(body.len() as u32);
|
||||
let message = MessageParser::new().parse(body).ok_or_else(|| {
|
||||
raise_error!(
|
||||
"Email header parse result is not available".into(),
|
||||
@@ -118,6 +117,92 @@ pub fn extract_envelope(fetch: &Fetch, account_id: u64, mailbox_id: u64) -> Bich
|
||||
Ok(envelope)
|
||||
}
|
||||
|
||||
pub fn extract_envelope_from_eml(
|
||||
body: &[u8],
|
||||
account_id: u64,
|
||||
mailbox_id: u64,
|
||||
) -> BichonResult<Envelope> {
|
||||
let uid = 0;
|
||||
let size = body.len() as u32;
|
||||
let message = MessageParser::new().parse(body).ok_or_else(|| {
|
||||
raise_error!(
|
||||
"Email header parse result is not available".into(),
|
||||
ErrorCode::InternalError
|
||||
)
|
||||
})?;
|
||||
|
||||
let text = if let Some(text) = message.body_text(0).map(|cow| cow.into_owned()) {
|
||||
text
|
||||
} else if let Some(html) = message.body_html(0).map(|cow| cow.into_owned()) {
|
||||
from_read(html.as_bytes(), 0)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let message_id = message
|
||||
.message_id()
|
||||
.map(String::from)
|
||||
.unwrap_or(generate_message_id());
|
||||
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 date = message.date().map(|d| d.to_timestamp() * 1000).unwrap_or(0);
|
||||
let bcc: Option<Vec<String>> = message.bcc().map(|addr| {
|
||||
AddrVec::from(addr)
|
||||
.0
|
||||
.into_iter()
|
||||
.filter_map(|a| a.address)
|
||||
.collect()
|
||||
});
|
||||
let cc: Option<Vec<String>> = message.cc().map(|addr| {
|
||||
AddrVec::from(addr)
|
||||
.0
|
||||
.into_iter()
|
||||
.filter_map(|a| a.address)
|
||||
.collect()
|
||||
});
|
||||
let to: Option<Vec<String>> = message.to().map(|addr| {
|
||||
AddrVec::from(addr)
|
||||
.0
|
||||
.into_iter()
|
||||
.filter_map(|a| a.address)
|
||||
.collect()
|
||||
});
|
||||
let from = message
|
||||
.from()
|
||||
.and_then(|addr| AddrVec::from(addr).0.into_iter().next())
|
||||
.and_then(|add| add.address)
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
let attachments: Vec<String> = message
|
||||
.attachments()
|
||||
.filter_map(|att| att.attachment_name())
|
||||
.map(|name| name.to_string())
|
||||
.collect();
|
||||
let envelope = Envelope {
|
||||
id: create_hash(account_id, &message_id),
|
||||
message_id,
|
||||
account_id,
|
||||
mailbox_id,
|
||||
uid,
|
||||
subject,
|
||||
text,
|
||||
from,
|
||||
to: to.unwrap_or_default(),
|
||||
cc: cc.unwrap_or_default(),
|
||||
bcc: bcc.unwrap_or_default(),
|
||||
date,
|
||||
internal_date: date,
|
||||
size,
|
||||
thread_id,
|
||||
attachments,
|
||||
tags: None,
|
||||
};
|
||||
Ok(envelope)
|
||||
}
|
||||
|
||||
pub fn compute_thread_id(
|
||||
in_reply_to: Option<String>,
|
||||
references: Option<Vec<String>>,
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use crate::modules::error::code::ErrorCode;
|
||||
use crate::modules::imap::session::SessionStream;
|
||||
use crate::{modules::error::BichonResult, raise_error};
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use crate::modules::account::entity::Encryption;
|
||||
use crate::modules::error::code::ErrorCode;
|
||||
use crate::modules::error::BichonResult;
|
||||
@@ -100,15 +99,17 @@ impl Client {
|
||||
encryption: &Encryption,
|
||||
port: u16,
|
||||
use_proxy: Option<u64>,
|
||||
dangerous: bool,
|
||||
) -> BichonResult<Self> {
|
||||
let resolved_addr = Self::resolve_to_socket_addr(domain, port)?;
|
||||
debug!("Attempting IMAP connection to {domain} ({resolved_addr}).");
|
||||
match encryption {
|
||||
Encryption::Ssl => {
|
||||
Self::establish_secure_connection(resolved_addr, domain, use_proxy).await
|
||||
Self::establish_secure_connection(resolved_addr, domain, use_proxy, dangerous).await
|
||||
}
|
||||
Encryption::StartTls => {
|
||||
Self::establish_starttls_connection(resolved_addr, domain, use_proxy).await
|
||||
Self::establish_starttls_connection(resolved_addr, domain, use_proxy, dangerous)
|
||||
.await
|
||||
}
|
||||
Encryption::None => Self::establish_insecure_connection(resolved_addr, use_proxy).await,
|
||||
}
|
||||
@@ -118,11 +119,17 @@ impl Client {
|
||||
address: SocketAddr,
|
||||
server_hostname: &str,
|
||||
use_proxy: Option<u64>,
|
||||
dangerous: bool,
|
||||
) -> BichonResult<Self> {
|
||||
// Establish the TLS connection with the specified parameters
|
||||
let tls_stream =
|
||||
establish_tls_connection(address, server_hostname, alpn(address.port()), use_proxy)
|
||||
.await?;
|
||||
let tls_stream = establish_tls_connection(
|
||||
address,
|
||||
server_hostname,
|
||||
alpn(address.port()),
|
||||
use_proxy,
|
||||
dangerous,
|
||||
)
|
||||
.await?;
|
||||
let stats_stream = StatsWrapper::new(tls_stream);
|
||||
// Wrap the TLS stream in a buffered writer for efficient IO
|
||||
let buffered_stream = BufWriter::new(stats_stream);
|
||||
@@ -137,7 +144,7 @@ impl Client {
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
"failed to read greeting".into(),
|
||||
"Failed to read IMAP greeting — this usually indicates an incorrect encryption setting (SSL vs. STARTTLS). Your current setting is SSL.".into(),
|
||||
ErrorCode::ImapCommandFailed
|
||||
)
|
||||
})?;
|
||||
@@ -180,6 +187,7 @@ impl Client {
|
||||
address: SocketAddr,
|
||||
server_hostname: &str,
|
||||
use_proxy: Option<u64>,
|
||||
dangerous: bool,
|
||||
) -> BichonResult<Self> {
|
||||
// Establish the initial TCP connection
|
||||
let tcp_stream = establish_tcp_connection_with_timeout(address, use_proxy).await?;
|
||||
@@ -197,7 +205,7 @@ impl Client {
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
"failed to read greeting".into(),
|
||||
"Failed to read IMAP greeting — this usually indicates an incorrect encryption setting (SSL vs. STARTTLS). Your current setting is STARTTLS.".into(),
|
||||
ErrorCode::ImapCommandFailed
|
||||
)
|
||||
})?;
|
||||
@@ -217,7 +225,7 @@ impl Client {
|
||||
let buffered_tcp_stream = client.into_inner();
|
||||
let tcp_stream = buffered_tcp_stream.into_inner();
|
||||
// Wrap the TCP stream in TLS encryption
|
||||
let tls_stream = establish_tls_stream(server_hostname, &[], tcp_stream).await?;
|
||||
let tls_stream = establish_tls_stream(server_hostname, &[], tcp_stream, dangerous).await?;
|
||||
// Wrap the TLS stream in a buffered writer
|
||||
let buffered_stream = BufWriter::new(tls_stream);
|
||||
// Create a SessionStream trait object for further communication
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use crate::modules::account::dispatcher::STATUS_DISPATCHER;
|
||||
use crate::modules::account::entity::AuthType;
|
||||
use crate::modules::account::migration::{AccountModel, AccountType};
|
||||
@@ -29,7 +28,7 @@ use crate::modules::imap::client::Client;
|
||||
use crate::modules::imap::oauth2::OAuth2;
|
||||
use crate::modules::imap::session::SessionStream;
|
||||
use crate::modules::oauth2::token::OAuth2AccessToken;
|
||||
use crate::{decrypt, raise_error};
|
||||
use crate::{bichon_version, decrypt, raise_error};
|
||||
use async_imap::Session;
|
||||
use tracing::error;
|
||||
|
||||
@@ -51,7 +50,14 @@ impl ImapConnectionManager {
|
||||
async fn create_client(&self, account: &AccountModel) -> BichonResult<Client> {
|
||||
assert_eq!(account.account_type, AccountType::IMAP);
|
||||
let imap = account.imap.as_ref().unwrap();
|
||||
Client::connection(&imap.host, &imap.encryption, imap.port, imap.use_proxy).await
|
||||
Client::connection(
|
||||
&imap.host,
|
||||
&imap.encryption,
|
||||
imap.port,
|
||||
imap.use_proxy,
|
||||
account.use_dangerous,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn authenticate(
|
||||
@@ -61,6 +67,7 @@ impl ImapConnectionManager {
|
||||
) -> BichonResult<Session<Box<dyn SessionStream>>> {
|
||||
assert_eq!(account.account_type, AccountType::IMAP);
|
||||
let imap = account.imap.as_ref().unwrap();
|
||||
let username = account.name.clone().unwrap_or(account.email.clone());
|
||||
match &imap.auth.auth_type {
|
||||
AuthType::Password => {
|
||||
let password = &imap.auth.password.clone().ok_or_else(|| {
|
||||
@@ -71,7 +78,7 @@ impl ImapConnectionManager {
|
||||
})?;
|
||||
|
||||
let password = decrypt!(&password)?;
|
||||
client.login(&account.email, &password).await
|
||||
client.login(&username, &password).await
|
||||
}
|
||||
AuthType::OAuth2 => {
|
||||
let record = OAuth2AccessToken::get(self.account_id).await?;
|
||||
@@ -83,7 +90,7 @@ impl ImapConnectionManager {
|
||||
)
|
||||
})?;
|
||||
client
|
||||
.authenticate(OAuth2::new(account.email.clone(), access_token))
|
||||
.authenticate(OAuth2::new(username, access_token))
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -137,6 +144,19 @@ impl ImapConnectionManager {
|
||||
.await;
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
if capabilities.has_str("ID") || capabilities.has_str("id") {
|
||||
session
|
||||
.id([
|
||||
("name", Some("bichon")),
|
||||
("version", Some(bichon_version!())),
|
||||
("vendor", Some("rustmailer")),
|
||||
])
|
||||
.await
|
||||
.map_err(|e| {
|
||||
raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
error!("Failed to fetch IMAP capabilities: {:#?}", error);
|
||||
|
||||
@@ -16,19 +16,21 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use mail_parser::MessageParser;
|
||||
|
||||
use crate::{base64_encode_url_safe, modules::{account::entity::Encryption, imap::client::Client}};
|
||||
use crate::{
|
||||
base64_encode_url_safe,
|
||||
modules::{account::entity::Encryption, imap::client::Client},
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn testxx() {
|
||||
rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider())
|
||||
.unwrap();
|
||||
let client = Client::connection("imap.zoho.com".into(), &Encryption::Ssl, 993, None)
|
||||
let client = Client::connection("imap.zoho.com".into(), &Encryption::Ssl, 993, None, false)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut session = client.login("pollybase@zohomail.com", "xxx").await.unwrap();
|
||||
let mut session = client.login("xx@zohomail.com", "xxx").await.unwrap();
|
||||
session.select("INBOX").await.unwrap();
|
||||
let result = session.uid_search("LARGER 1024").await.unwrap();
|
||||
println!("{:#?}", result);
|
||||
|
||||
162
src/modules/import/mod.rs
Normal file
162
src/modules/import/mod.rs
Normal file
@@ -0,0 +1,162 @@
|
||||
use poem_openapi::Object;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tantivy::doc;
|
||||
|
||||
use crate::{
|
||||
base64_decode_url_safe,
|
||||
modules::{
|
||||
account::migration::{AccountModel, AccountType},
|
||||
cache::imap::mailbox::{Attribute, AttributeEnum, MailBox},
|
||||
envelope::extractor::extract_envelope_from_eml,
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
indexer::{
|
||||
manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER},
|
||||
schema::SchemaTools,
|
||||
},
|
||||
utils::create_hash,
|
||||
},
|
||||
raise_error,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
|
||||
pub struct BatchEmlRequest {
|
||||
pub account_id: u64,
|
||||
pub mail_folder: String,
|
||||
/// A list of emails in base64-encoded format. Each element represents one .eml file.
|
||||
pub emls: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
|
||||
pub struct FailedEmlDetail {
|
||||
/// The 0-based index of the failed EML in the request list
|
||||
pub index: usize,
|
||||
/// The error message that caused the import to fail
|
||||
pub error_message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
|
||||
pub struct BatchEmlResult {
|
||||
/// Total number of emails processed
|
||||
pub total: usize,
|
||||
/// Number of emails successfully imported
|
||||
pub success: usize,
|
||||
/// Number of emails failed to import
|
||||
pub failed: usize,
|
||||
/// A list of details for failed imports
|
||||
pub failed_details: Vec<FailedEmlDetail>,
|
||||
}
|
||||
|
||||
pub struct ImportEmls;
|
||||
|
||||
impl ImportEmls {
|
||||
pub async fn do_import(request: BatchEmlRequest) -> BichonResult<BatchEmlResult> {
|
||||
let account = AccountModel::check_account_exists(request.account_id).await?;
|
||||
|
||||
if !account.enabled {
|
||||
return Err(raise_error!("The account is disabled and cannot be used for this operation.".into(), ErrorCode::InvalidParameter));
|
||||
}
|
||||
|
||||
let mailbox_id = match account.account_type {
|
||||
AccountType::IMAP => {
|
||||
let all_mailboxes = MailBox::list_all(account.id).await?;
|
||||
let mailbox = all_mailboxes.into_iter().find(|m| m.name == request.mail_folder);
|
||||
|
||||
match mailbox {
|
||||
Some(mailbox) => mailbox.id,
|
||||
None => return Err(raise_error!(
|
||||
format!("Mail folder '{}' not found for account ID {}. The target folder must exist before importing.",
|
||||
request.mail_folder,
|
||||
request.account_id).into(),
|
||||
ErrorCode::ResourceNotFound
|
||||
)),
|
||||
}
|
||||
},
|
||||
AccountType::NoSync => {
|
||||
let mailbox = MailBox {
|
||||
id: create_hash(request.account_id, &request.mail_folder),
|
||||
account_id: request.account_id,
|
||||
name: request.mail_folder.clone(),
|
||||
delimiter: Some("/".to_string()),
|
||||
attributes: vec![Attribute {
|
||||
attr: AttributeEnum::Extension,
|
||||
extension: Some("CreatedByBichon".into()),
|
||||
}],
|
||||
exists: 0,
|
||||
unseen: None,
|
||||
uid_next: None,
|
||||
uid_validity: None,
|
||||
};
|
||||
let mailbox_id = mailbox.id;
|
||||
// Upsert the mailbox, creating it if it doesn't exist
|
||||
MailBox::batch_upsert(&[mailbox]).await?;
|
||||
mailbox_id
|
||||
},
|
||||
};
|
||||
|
||||
let fields = SchemaTools::eml_fields();
|
||||
let account_id = account.id;
|
||||
let mut success_count = 0;
|
||||
let mut failed_details: Vec<FailedEmlDetail> = Vec::new(); // Store failure details
|
||||
|
||||
let total = request.emls.len();
|
||||
for (index, eml_base64) in request.emls.into_iter().enumerate() {
|
||||
// 1. Decode Base64
|
||||
let decoded = match base64_decode_url_safe!(eml_base64.as_bytes()) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => {
|
||||
let error_msg =
|
||||
format!("Failed to decode base64 EML at index {}: {:?}", index, e);
|
||||
tracing::error!("{}", error_msg);
|
||||
failed_details.push(FailedEmlDetail {
|
||||
index,
|
||||
error_message: error_msg,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let envelope = match extract_envelope_from_eml(&decoded, account_id, mailbox_id) {
|
||||
Ok(env) => env,
|
||||
Err(e) => {
|
||||
let error_msg = format!(
|
||||
"Failed to extract envelope from EML at index {}: {:?}",
|
||||
index, e
|
||||
);
|
||||
tracing::error!("{}", error_msg);
|
||||
failed_details.push(FailedEmlDetail {
|
||||
index,
|
||||
error_message: error_msg,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
ENVELOPE_INDEX_MANAGER
|
||||
.add_document(envelope.id, envelope.to_document(mailbox_id).unwrap())
|
||||
.await;
|
||||
|
||||
EML_INDEX_MANAGER
|
||||
.add_document(
|
||||
envelope.id,
|
||||
doc!(
|
||||
fields.f_id => envelope.id,
|
||||
fields.f_account_id => account_id,
|
||||
fields.f_mailbox_id => mailbox_id,
|
||||
fields.f_eml => decoded
|
||||
),
|
||||
)
|
||||
.await;
|
||||
|
||||
success_count += 1;
|
||||
}
|
||||
|
||||
let failed_count = failed_details.len();
|
||||
|
||||
Ok(BatchEmlResult {
|
||||
total,
|
||||
success: success_count,
|
||||
failed: failed_count,
|
||||
failed_details, // Return the list of failure details
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,6 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use std::{path::PathBuf, time::Duration};
|
||||
|
||||
use chrono::Utc;
|
||||
@@ -145,20 +144,20 @@ async fn test2() {
|
||||
let delete_term3 = Term::from_field_u64(a, 3u64);
|
||||
|
||||
let operations = vec![
|
||||
//UserOperation::Delete(delete_term1),
|
||||
UserOperation::Delete(delete_term1),
|
||||
UserOperation::Add(doc!(
|
||||
a => 1u64,
|
||||
b => "test1"
|
||||
b => "v1"
|
||||
)),
|
||||
//UserOperation::Delete(delete_term2),
|
||||
UserOperation::Delete(delete_term2),
|
||||
UserOperation::Add(doc!(
|
||||
a => 2u64,
|
||||
b => "test1"
|
||||
b => "v1"
|
||||
)),
|
||||
//UserOperation::Delete(delete_term3),
|
||||
UserOperation::Delete(delete_term3),
|
||||
UserOperation::Add(doc!(
|
||||
a => 3u64,
|
||||
b => "test1"
|
||||
b => "v1"
|
||||
)),
|
||||
];
|
||||
|
||||
@@ -166,13 +165,10 @@ async fn test2() {
|
||||
index_writer.commit().unwrap();
|
||||
|
||||
let reader = index.reader().unwrap();
|
||||
|
||||
let searcher = reader.searcher();
|
||||
|
||||
let tq = TermQuery::new(Term::from_field_u64(a, 3), IndexRecordOption::Basic);
|
||||
|
||||
let docs = searcher.search(&tq, &TopDocs::with_limit(1)).unwrap();
|
||||
|
||||
assert!(docs.first().is_some());
|
||||
if let Some((_, doc_address)) = docs.first() {
|
||||
let old_doc: TantivyDocument = searcher.doc_async(*doc_address).await.unwrap();
|
||||
|
||||
@@ -181,12 +177,14 @@ async fn test2() {
|
||||
if field == a {
|
||||
new_doc.add_field_value(a, value);
|
||||
}
|
||||
if field == b {
|
||||
assert_eq!(Some("v1"), value.as_str())
|
||||
}
|
||||
}
|
||||
new_doc.add_text(b, "test2");
|
||||
new_doc.add_text(b, "v2");
|
||||
|
||||
let delete_term = Term::from_field_u64(a, 3);
|
||||
index_writer.delete_term(delete_term);
|
||||
index_writer.commit().unwrap();
|
||||
index_writer.add_document(new_doc).unwrap();
|
||||
index_writer.commit().unwrap();
|
||||
}
|
||||
@@ -194,17 +192,14 @@ async fn test2() {
|
||||
reader.reload().unwrap();
|
||||
let searcher = reader.searcher();
|
||||
let docs = searcher.search(&tq, &TopDocs::with_limit(1)).unwrap();
|
||||
|
||||
assert!(docs.first().is_some());
|
||||
if let Some((_, doc_address)) = docs.first() {
|
||||
let doc: TantivyDocument = searcher.doc_async(*doc_address).await.unwrap();
|
||||
for (field, value) in doc.field_values() {
|
||||
if field == b {
|
||||
let value = value.as_str();
|
||||
println!("{:#?}", value);
|
||||
assert_eq!(Some("v2"), value.as_str())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("not found")
|
||||
}
|
||||
|
||||
let delete_term = Term::from_field_u64(a, 3);
|
||||
@@ -214,7 +209,7 @@ async fn test2() {
|
||||
reader.reload().unwrap();
|
||||
let searcher = reader.searcher();
|
||||
let docs = searcher.search(&tq, &TopDocs::with_limit(1)).unwrap();
|
||||
|
||||
assert!(docs.first().is_none());
|
||||
if let Some((_, doc_address)) = docs.first() {
|
||||
let doc: TantivyDocument = searcher.doc_async(*doc_address).await.unwrap();
|
||||
for (field, value) in doc.field_values() {
|
||||
@@ -223,7 +218,5 @@ async fn test2() {
|
||||
println!("{:#?}", value);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("not found")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ use crate::raise_error;
|
||||
use async_imap::types::Name;
|
||||
|
||||
pub async fn get_account_mailboxes(account_id: u64, remote: bool) -> BichonResult<Vec<MailBox>> {
|
||||
let account = AccountModel::check_account_active(account_id).await?;
|
||||
let account = AccountModel::check_account_exists(account_id).await?;
|
||||
if remote {
|
||||
if matches!(account.account_type, AccountType::IMAP) {
|
||||
request_imap_all_mailbox_list(account_id).await
|
||||
|
||||
@@ -68,7 +68,7 @@ pub async fn retrieve_email_content(
|
||||
account_id: u64,
|
||||
id: u64,
|
||||
) -> BichonResult<FullMessageContent> {
|
||||
AccountModel::check_account_active(account_id).await?;
|
||||
AccountModel::check_account_exists(account_id).await?;
|
||||
let eml = EML_INDEX_MANAGER
|
||||
.get(account_id, id)
|
||||
.await?
|
||||
|
||||
@@ -33,7 +33,7 @@ pub async fn list_messages_impl(
|
||||
page: u64,
|
||||
page_size: u64,
|
||||
) -> BichonResult<DataPage<Envelope>> {
|
||||
AccountModel::check_account_active(account_id).await?;
|
||||
AccountModel::check_account_exists(account_id).await?;
|
||||
validate_pagination_params(page, page_size)?;
|
||||
ENVELOPE_INDEX_MANAGER
|
||||
.list_mailbox_envelopes(account_id, mailbox_id, page, page_size, true)
|
||||
@@ -62,7 +62,7 @@ pub async fn get_thread_messages(
|
||||
page: u64,
|
||||
page_size: u64,
|
||||
) -> BichonResult<DataPage<Envelope>> {
|
||||
AccountModel::check_account_active(account_id).await?;
|
||||
AccountModel::check_account_exists(account_id).await?;
|
||||
ENVELOPE_INDEX_MANAGER
|
||||
.list_thread_envelopes(account_id, thread_id, page, page_size, true)
|
||||
.await
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
pub mod account;
|
||||
pub mod autoconfig;
|
||||
pub mod cache;
|
||||
@@ -27,6 +26,7 @@ pub mod database;
|
||||
pub mod envelope;
|
||||
pub mod error;
|
||||
pub mod imap;
|
||||
pub mod import;
|
||||
pub mod indexer;
|
||||
pub mod logger;
|
||||
pub mod mailbox;
|
||||
|
||||
@@ -16,14 +16,13 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
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::migration::AccountModel;
|
||||
use crate::modules::common::auth::ClientContext;
|
||||
use crate::modules::common::paginated::paginate_vec;
|
||||
use crate::modules::error::code::ErrorCode;
|
||||
@@ -114,11 +113,7 @@ impl AccountApi {
|
||||
}
|
||||
|
||||
/// List accounts with optional pagination parameters
|
||||
#[oai(
|
||||
path = "/accounts",
|
||||
method = "get",
|
||||
operation_id = "list_accounts"
|
||||
)]
|
||||
#[oai(path = "/accounts", method = "get", operation_id = "list_accounts")]
|
||||
async fn list_accounts(
|
||||
&self,
|
||||
/// Optional. The page number to retrieve (starting from 1).
|
||||
@@ -172,6 +167,7 @@ impl AccountApi {
|
||||
context: ClientContext,
|
||||
) -> ApiResult<Json<AccountRunningState>> {
|
||||
let account_id = account_id.0;
|
||||
AccountModel::check_account_exists(account_id).await?;
|
||||
context.require_account_access(account_id)?;
|
||||
let state = AccountRunningState::get(account_id).await?.ok_or_else(|| {
|
||||
raise_error!(
|
||||
|
||||
49
src/modules/rest/api/import.rs
Normal file
49
src/modules/rest/api/import.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
//
|
||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use crate::modules::common::auth::ClientContext;
|
||||
use crate::modules::import::BatchEmlResult;
|
||||
use crate::modules::import::{BatchEmlRequest, ImportEmls};
|
||||
use crate::modules::rest::api::ApiTags;
|
||||
use crate::modules::rest::ApiResult;
|
||||
use poem_openapi::payload::Json;
|
||||
use poem_openapi::OpenApi;
|
||||
|
||||
pub struct ImportApi;
|
||||
|
||||
#[OpenApi(prefix_path = "/api/v1", tag = "ApiTags::Import")]
|
||||
impl ImportApi {
|
||||
/// Batch import one or more EML files into a specified account and mail folder.
|
||||
///
|
||||
/// This endpoint accepts a JSON payload containing:
|
||||
/// - `account_id`: the target account to import emails into
|
||||
/// - `mail_folder`: the mailbox/folder name
|
||||
/// - `emls`: a list of base64-encoded .eml files
|
||||
///
|
||||
/// Returns a summary of the import result, including total processed, successful, and failed emails.
|
||||
#[oai(path = "/import", method = "post", operation_id = "do_batch_import")]
|
||||
async fn do_batch_import(
|
||||
&self,
|
||||
/// JSON payload with account info and EML files to import
|
||||
payload: Json<BatchEmlRequest>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<Json<BatchEmlResult>> {
|
||||
context.require_root()?;
|
||||
Ok(Json(ImportEmls::do_import(payload.0).await?))
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use crate::modules::account::migration::AccountModel;
|
||||
use crate::modules::common::auth::ClientContext;
|
||||
use crate::modules::indexer::envelope::Envelope;
|
||||
use crate::modules::indexer::manager::EML_INDEX_MANAGER;
|
||||
@@ -156,6 +156,7 @@ impl MessageApi {
|
||||
context: ClientContext,
|
||||
) -> ApiResult<Attachment<Body>> {
|
||||
let account_id = account_id.0;
|
||||
AccountModel::check_account_exists(account_id).await?;
|
||||
context.require_account_access(account_id)?;
|
||||
let id = id.0;
|
||||
let reader = EML_INDEX_MANAGER.get_reader(account_id, id).await?;
|
||||
@@ -180,6 +181,7 @@ impl MessageApi {
|
||||
context: ClientContext,
|
||||
) -> ApiResult<Attachment<Body>> {
|
||||
let account_id = account_id.0;
|
||||
AccountModel::check_account_exists(account_id).await?;
|
||||
context.require_account_access(account_id)?;
|
||||
let email_id = id.0;
|
||||
let name = name.0.trim();
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use access_token::AccessTokenApi;
|
||||
use account::AccountApi;
|
||||
use auto_config::AutoConfigApi;
|
||||
@@ -26,11 +25,12 @@ use oauth2::OAuth2Api;
|
||||
use poem_openapi::{OpenApiService, Tags};
|
||||
use system::SystemApi;
|
||||
|
||||
use crate::bichon_version;
|
||||
use crate::{bichon_version, modules::rest::api::import::ImportApi};
|
||||
|
||||
pub mod access_token;
|
||||
pub mod account;
|
||||
pub mod auto_config;
|
||||
pub mod import;
|
||||
pub mod mailbox;
|
||||
pub mod message;
|
||||
pub mod oauth2;
|
||||
@@ -45,6 +45,7 @@ pub enum ApiTags {
|
||||
OAuth2,
|
||||
Message,
|
||||
System,
|
||||
Import,
|
||||
}
|
||||
|
||||
type RustMailOpenApi = (
|
||||
@@ -55,6 +56,7 @@ type RustMailOpenApi = (
|
||||
MailBoxApi,
|
||||
OAuth2Api,
|
||||
MessageApi,
|
||||
ImportApi,
|
||||
);
|
||||
|
||||
pub fn create_openapi_service() -> OpenApiService<RustMailOpenApi, ()> {
|
||||
@@ -67,6 +69,7 @@ pub fn create_openapi_service() -> OpenApiService<RustMailOpenApi, ()> {
|
||||
MailBoxApi,
|
||||
OAuth2Api,
|
||||
MessageApi,
|
||||
ImportApi,
|
||||
),
|
||||
"BichonApi",
|
||||
bichon_version!(),
|
||||
|
||||
@@ -195,19 +195,19 @@ impl OAuth2Api {
|
||||
/// Configures an external OAuth2 token for a specified account.
|
||||
///
|
||||
/// This endpoint allows two usage modes:
|
||||
/// 1. If only an `access_token` is provided, RustMailer will store it directly.
|
||||
/// - In this mode, RustMailer **cannot refresh** the token, since it has no
|
||||
/// 1. If only an `access_token` is provided, Bichon will store it directly.
|
||||
/// - In this mode, Bichon **cannot refresh** the token, since it has no
|
||||
/// associated OAuth2 configuration or refresh token.
|
||||
/// - The caller is responsible for periodically updating the access token
|
||||
/// by calling this endpoint again.
|
||||
/// 2. If both `oauth2_id` and `refresh_token` are provided, it means the external
|
||||
/// OAuth2 authorization flow has been completed outside RustMailer.
|
||||
/// OAuth2 authorization flow has been completed outside Bichon.
|
||||
/// - Since the OAuth2 configuration (including client_id and client_secret)
|
||||
/// is already stored in RustMailer, the service can use the refresh token
|
||||
/// is already stored in Bichon, the service can use the refresh token
|
||||
/// to obtain new access tokens automatically.
|
||||
///
|
||||
/// Note: The `oauth2_id` must reference a valid OAuth2 configuration
|
||||
/// already created in RustMailer.
|
||||
/// already created in Bichon.
|
||||
#[oai(
|
||||
path = "/store-external-oauth2-token/:account_id",
|
||||
method = "post",
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use crate::modules::common::error::ErrorCapture;
|
||||
use crate::modules::common::log::Tracing;
|
||||
use crate::modules::common::tls::rustls_config;
|
||||
@@ -33,13 +32,14 @@ use crate::modules::common::timeout::{Timeout, TIMEOUT_HEADER};
|
||||
use crate::raise_error;
|
||||
use api::create_openapi_service;
|
||||
use assets::FrontEndAssets;
|
||||
use http::HeaderValue;
|
||||
use http::{HeaderValue, Method};
|
||||
use poem::endpoint::EmbeddedFilesEndpoint;
|
||||
use poem::listener::{Listener, TcpListener};
|
||||
use poem::middleware::{CatchPanic, Compression, SetHeader};
|
||||
use poem::{endpoint::EmbeddedFileEndpoint, middleware::Cors, EndpointExt, Route, Server};
|
||||
use poem::{get, post};
|
||||
use public::oauth2::oauth2_callback;
|
||||
use std::collections::HashSet;
|
||||
use std::time::Duration;
|
||||
|
||||
pub mod api;
|
||||
@@ -62,7 +62,7 @@ pub async fn start_http_server() -> BichonResult<()> {
|
||||
};
|
||||
|
||||
let api_service = create_openapi_service()
|
||||
.summary("A self-hosted IMAP/SMTP middleware designed for developers");
|
||||
.summary("A lightweight, high-performance Rust email archiver with WebUI");
|
||||
|
||||
let swagger = api_service.swagger_ui();
|
||||
let redoc = api_service.redoc();
|
||||
@@ -78,10 +78,35 @@ pub async fn start_http_server() -> BichonResult<()> {
|
||||
.with(Timeout)
|
||||
.with(Tracing);
|
||||
|
||||
let mut cors_origins = SETTINGS.bichon_cors_origins.clone();
|
||||
if cors_origins.is_empty() {
|
||||
cors_origins = ["*".to_string()].into_iter().collect();
|
||||
}
|
||||
let cors_origins: Option<HashSet<String>> =
|
||||
SETTINGS.bichon_cors_origins.clone();
|
||||
|
||||
let cors_origins: Vec<String> = cors_origins.unwrap_or_default().into_iter().collect();
|
||||
|
||||
let cors = Cors::new()
|
||||
.allow_origins_fn(move |origin| {
|
||||
tracing::debug!("CORS: Incoming Origin = {:?}", origin);
|
||||
tracing::debug!("CORS: Configured origins = {:?}", cors_origins);
|
||||
if cors_origins.is_empty() {
|
||||
tracing::debug!("CORS: No origins configured, allowing all");
|
||||
return true;
|
||||
}
|
||||
cors_origins.iter().any(|o| o == origin)
|
||||
})
|
||||
//.allow_origins(cors_origins)
|
||||
.allow_credentials(true)
|
||||
.allow_methods(&[
|
||||
Method::GET,
|
||||
Method::POST,
|
||||
Method::PUT,
|
||||
Method::DELETE,
|
||||
Method::OPTIONS,
|
||||
Method::HEAD,
|
||||
Method::PATCH,
|
||||
])
|
||||
.allow_headers(vec!["Content-Type", "Authorization", TIMEOUT_HEADER])
|
||||
.expose_headers(vec!["Accept"])
|
||||
.max_age(SETTINGS.bichon_cors_max_age);
|
||||
|
||||
let cache_static = || {
|
||||
SetHeader::new().overriding(
|
||||
@@ -90,14 +115,6 @@ pub async fn start_http_server() -> BichonResult<()> {
|
||||
)
|
||||
};
|
||||
|
||||
let cors = Cors::new()
|
||||
.allow_origins(cors_origins)
|
||||
.allow_credentials(true)
|
||||
.allow_methods(vec!["GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD"])
|
||||
.allow_headers(vec!["Content-Type", "Authorization", TIMEOUT_HEADER])
|
||||
.expose_headers(vec!["Accept"])
|
||||
.max_age(SETTINGS.bichon_cors_max_age);
|
||||
|
||||
let route = Route::new()
|
||||
.nest("/api-docs/swagger", swagger)
|
||||
.nest("/api-docs/redoc", redoc)
|
||||
@@ -122,7 +139,7 @@ pub async fn start_http_server() -> BichonResult<()> {
|
||||
.with(CatchPanic::new());
|
||||
|
||||
let server = Server::new(listener)
|
||||
.name("RustMailer API Service")
|
||||
.name("Bichon Service")
|
||||
.idle_timeout(Duration::from_secs(60))
|
||||
.run_with_graceful_shutdown(
|
||||
route.catch_all_error(error_handler),
|
||||
@@ -130,7 +147,7 @@ pub async fn start_http_server() -> BichonResult<()> {
|
||||
Some(Duration::from_secs(5)),
|
||||
);
|
||||
println!(
|
||||
"RustMailer API Service is now running on port {}.",
|
||||
"Bichon Service is now running on port {}.",
|
||||
SETTINGS.bichon_http_port
|
||||
);
|
||||
server
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use clap::{builder::ValueParser, Parser, ValueEnum};
|
||||
use std::{collections::HashSet, env, fmt, path::PathBuf, sync::LazyLock};
|
||||
|
||||
@@ -77,7 +76,6 @@ pub struct Settings {
|
||||
/// CORS allowed origins (default: "*")
|
||||
#[clap(
|
||||
long,
|
||||
default_value = "http://localhost:5173, http://localhost:15630, http://192.168.3.2:15630, *",
|
||||
env,
|
||||
help = "Set the allowed CORS origins (comma-separated list, e.g., \"https://example.com, https://another.com\")",
|
||||
value_parser = ValueParser::new(|s: &str| -> Result<HashSet<String>, String> {
|
||||
@@ -88,7 +86,7 @@ pub struct Settings {
|
||||
Ok(set)
|
||||
})
|
||||
)]
|
||||
pub bichon_cors_origins: HashSet<String>,
|
||||
pub bichon_cors_origins: Option<HashSet<String>>,
|
||||
|
||||
/// CORS max age in seconds (default: 86400)
|
||||
#[clap(
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use crate::modules::error::code::ErrorCode;
|
||||
use crate::modules::settings::proxy::Proxy;
|
||||
use crate::modules::utils::tls::establish_tls_stream;
|
||||
@@ -56,17 +55,19 @@ pub(crate) async fn establish_tcp_connection_with_timeout(
|
||||
Ok(Box::pin(timeout_stream))
|
||||
}
|
||||
|
||||
pub(crate) async fn establish_tls_connection(
|
||||
pub async fn establish_tls_connection(
|
||||
address: SocketAddr,
|
||||
server_hostname: &str,
|
||||
alpn_protocols: &[&str],
|
||||
use_proxy: Option<u64>,
|
||||
dangerous: bool,
|
||||
) -> BichonResult<impl SessionStream> {
|
||||
// Establish the TCP connection with timeout
|
||||
let tcp_stream = establish_tcp_connection_with_timeout(address, use_proxy).await?;
|
||||
|
||||
// Wrap the TCP stream with TLS encryption
|
||||
let tls_stream = establish_tls_stream(server_hostname, alpn_protocols, tcp_stream).await?;
|
||||
let tls_stream =
|
||||
establish_tls_stream(server_hostname, alpn_protocols, tcp_stream, dangerous).await?;
|
||||
|
||||
// Return the TLS stream wrapped in a SessionStream
|
||||
Ok(tls_stream)
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
use crate::{
|
||||
modules::{
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
@@ -24,38 +23,44 @@ use crate::{
|
||||
},
|
||||
raise_error,
|
||||
};
|
||||
use rustls::RootCertStore;
|
||||
use rustls::{
|
||||
client::danger::{ServerCertVerified, ServerCertVerifier},
|
||||
RootCertStore,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub async fn establish_tls_stream(
|
||||
server_hostname: &str,
|
||||
alpn_protocols: &[&str],
|
||||
stream: impl SessionStream + 'static,
|
||||
dangerous: bool,
|
||||
) -> BichonResult<impl SessionStream> {
|
||||
let tls_stream = establish_rustls_stream(server_hostname, alpn_protocols, stream).await?;
|
||||
let tls_stream =
|
||||
establish_rustls_stream(server_hostname, alpn_protocols, stream, dangerous).await?;
|
||||
let boxed_stream: Box<dyn SessionStream> = Box::new(tls_stream);
|
||||
Ok(boxed_stream)
|
||||
}
|
||||
|
||||
pub async fn establish_rustls_stream(
|
||||
async fn establish_rustls_stream(
|
||||
server_hostname: &str,
|
||||
alpn_protocols: &[&str],
|
||||
stream: impl SessionStream,
|
||||
dangerous: bool,
|
||||
) -> BichonResult<impl SessionStream> {
|
||||
// Create a root certificate store and add default trusted roots
|
||||
let root_store = RootCertStore {
|
||||
roots: webpki_roots::TLS_SERVER_ROOTS.into(),
|
||||
};
|
||||
|
||||
// Configure the Rustls client with the root certs and no client authentication
|
||||
let mut config = rustls::ClientConfig::builder()
|
||||
//builder_with_provider(
|
||||
// rustls::crypto::ring::default_provider().into(),
|
||||
// )
|
||||
// .with_protocol_versions(&[&rustls::version::TLS13])
|
||||
// .unwrap()
|
||||
.with_root_certificates(root_store)
|
||||
.with_no_client_auth();
|
||||
let mut config = if dangerous {
|
||||
rustls::ClientConfig::builder()
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(Arc::new(SkipCertVerification))
|
||||
.with_no_client_auth()
|
||||
} else {
|
||||
rustls::ClientConfig::builder()
|
||||
.with_root_certificates(root_store)
|
||||
.with_no_client_auth()
|
||||
};
|
||||
|
||||
// Set the ALPN protocols
|
||||
config.alpn_protocols = alpn_protocols
|
||||
@@ -76,3 +81,56 @@ pub async fn establish_rustls_stream(
|
||||
|
||||
Ok(tls_stream)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct SkipCertVerification;
|
||||
|
||||
impl ServerCertVerifier for SkipCertVerification {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
_end_entity: &rustls::pki_types::CertificateDer<'_>,
|
||||
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
|
||||
_server_name: &rustls::pki_types::ServerName<'_>,
|
||||
_ocsp_response: &[u8],
|
||||
_now: rustls::pki_types::UnixTime,
|
||||
) -> Result<ServerCertVerified, rustls::Error> {
|
||||
// Always return a valid certificate verification result
|
||||
Ok(ServerCertVerified::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &rustls::pki_types::CertificateDer<'_>,
|
||||
_dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &rustls::pki_types::CertificateDer<'_>,
|
||||
_dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||
vec![
|
||||
rustls::SignatureScheme::RSA_PKCS1_SHA1,
|
||||
rustls::SignatureScheme::ECDSA_SHA1_Legacy,
|
||||
rustls::SignatureScheme::RSA_PKCS1_SHA256,
|
||||
rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
|
||||
rustls::SignatureScheme::RSA_PKCS1_SHA384,
|
||||
rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
|
||||
rustls::SignatureScheme::RSA_PKCS1_SHA512,
|
||||
rustls::SignatureScheme::ECDSA_NISTP521_SHA512,
|
||||
rustls::SignatureScheme::RSA_PSS_SHA256,
|
||||
rustls::SignatureScheme::RSA_PSS_SHA384,
|
||||
rustls::SignatureScheme::RSA_PSS_SHA512,
|
||||
rustls::SignatureScheme::ED25519,
|
||||
rustls::SignatureScheme::ED448,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,11 @@
|
||||
"knip": "knip"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@mui/material": "^7.3.5",
|
||||
"@mui/x-tree-view": "^8.19.0",
|
||||
"@radix-ui/react-accordion": "^1.2.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.2",
|
||||
"@radix-ui/react-avatar": "^1.1.1",
|
||||
@@ -37,6 +41,7 @@
|
||||
"@radix-ui/react-toast": "^1.2.2",
|
||||
"@radix-ui/react-tooltip": "^1.1.4",
|
||||
"@radix-ui/react-visually-hidden": "^1.1.0",
|
||||
"@react-spring/web": "^10.0.3",
|
||||
"@tabler/icons-react": "^3.24.0",
|
||||
"@tanstack/react-query": "^5.62.3",
|
||||
"@tanstack/react-router": "^1.86.1",
|
||||
@@ -50,6 +55,7 @@
|
||||
"date-fns": "^3.6.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"handlebars": "^4.7.8",
|
||||
"i18next": "^25.6.3",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^18.3.1",
|
||||
@@ -57,6 +63,7 @@
|
||||
"react-day-picker": "8.10.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.54.0",
|
||||
"react-i18next": "^16.3.5",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"recharts": "^2.14.1",
|
||||
|
||||
656
web/pnpm-lock.yaml
generated
656
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -51,7 +51,7 @@ export interface OAuth2Tokens {
|
||||
access_token: string;
|
||||
account_id: string;
|
||||
created_at: number;
|
||||
oauth2_name: string;
|
||||
oauth2_id: number;
|
||||
refresh_token: string;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
@@ -54,6 +54,8 @@ export interface DashboardStats {
|
||||
with_attachment_count: number; // Emails with attachments
|
||||
without_attachment_count: number; // Emails without attachments
|
||||
top_largest_emails: LargestEmail[]; // Top 10 largest emails
|
||||
system_version: string, //The semantic version string of the currently running backend service
|
||||
commit_hash: string //Git commit hash used to build this system version
|
||||
}
|
||||
|
||||
export interface TimeBucket {
|
||||
|
||||
@@ -21,16 +21,18 @@ import { Card } from "@/components/ui/card";
|
||||
import { FixedHeader } from "./layout/fixed-header";
|
||||
import { Main } from "./layout/main";
|
||||
import Logo from '@/assets/logo.svg'
|
||||
|
||||
const docsOptions = [
|
||||
{ name: "Swagger UI", path: "/api-docs/swagger" },
|
||||
{ name: "ReDoc", path: "/api-docs/redoc" },
|
||||
{ name: "OpenAPI Explorer", path: "/api-docs/explorer" },
|
||||
{ name: "Scalar", path: "/api-docs/scalar" },
|
||||
{ name: "Download Spec YAML", path: "/api-docs/spec.yaml" }
|
||||
];
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
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" },
|
||||
{ name: t('apiDocs.openAPIExplorer'), path: "/api-docs/explorer" },
|
||||
{ name: t('apiDocs.scalar'), path: "/api-docs/scalar" },
|
||||
{ name: t('apiDocs.downloadSpecYAML'), path: "/api-docs/spec.yaml" }
|
||||
];
|
||||
const handleCardClick = (path: string) => {
|
||||
// Open in new tab
|
||||
window.open(path, '_blank', 'noopener,noreferrer');
|
||||
@@ -43,9 +45,9 @@ export default function APIDocs() {
|
||||
<Main>
|
||||
<div className='mb-2 flex items-center justify-between space-y-2 flex-wrap gap-x-4'>
|
||||
<div>
|
||||
<h2 className='text-2xl font-bold tracking-tight'>API Documentation</h2>
|
||||
<h2 className='text-2xl font-bold tracking-tight'>{t('navigation.apiDocs')}</h2>
|
||||
<p className='text-muted-foreground'>
|
||||
Choose your preferred API documentation type
|
||||
{t('apiDocs.choosePreferredType')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -62,7 +64,7 @@ export default function APIDocs() {
|
||||
<img
|
||||
src={Logo}
|
||||
className="max-h-[66px] w-auto opacity-20 saturate-0 transition-all duration-300 hover:opacity-100 hover:saturate-100 object-contain"
|
||||
alt="RustMailer Logo"
|
||||
alt="Bichon Logo"
|
||||
/>
|
||||
<h3 className="text-sm font-medium">{option.name}</h3>
|
||||
</div>
|
||||
|
||||
@@ -35,13 +35,16 @@ import {
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from '@/components/ui/command'
|
||||
import { sidebarData } from './layout/data/sidebar-data'
|
||||
import { useSidebarData } from './layout/data/sidebar-data'
|
||||
import { ScrollArea } from './ui/scroll-area'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export function CommandMenu() {
|
||||
const navigate = useNavigate()
|
||||
const { setTheme } = useTheme()
|
||||
const { open, setOpen } = useSearch()
|
||||
const sidebarData = useSidebarData()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const runCommand = React.useCallback(
|
||||
(command: () => unknown) => {
|
||||
@@ -53,10 +56,10 @@ export function CommandMenu() {
|
||||
|
||||
return (
|
||||
<CommandDialog modal open={open} onOpenChange={setOpen}>
|
||||
<CommandInput placeholder='Type a command or search...' />
|
||||
<CommandInput placeholder={t('command.typeCommandOrSearch')} />
|
||||
<CommandList>
|
||||
<ScrollArea type='hover' className='h-72 pr-1'>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
<CommandEmpty>{t('command.noResultsFound')}</CommandEmpty>
|
||||
{sidebarData.navGroups.map((group) => (
|
||||
<CommandGroup key={group.title} heading={group.title}>
|
||||
{group.items.map((navItem, i) => {
|
||||
@@ -94,13 +97,13 @@ export function CommandMenu() {
|
||||
</CommandGroup>
|
||||
))}
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading='Theme'>
|
||||
<CommandGroup heading={t('command.theme')}>
|
||||
<CommandItem onSelect={() => runCommand(() => setTheme('light'))}>
|
||||
<IconSun /> <span>Light</span>
|
||||
<IconSun /> <span>{t('command.light')}</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={() => runCommand(() => setTheme('dark'))}>
|
||||
<IconMoon className='scale-90' />
|
||||
<span>Dark</span>
|
||||
<span>{t('command.dark')}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean
|
||||
@@ -45,6 +46,7 @@ interface ConfirmDialogProps {
|
||||
}
|
||||
|
||||
export function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
title,
|
||||
desc,
|
||||
@@ -70,14 +72,14 @@ export function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
{children}
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isLoading}>
|
||||
{cancelBtnText ?? 'Cancel'}
|
||||
{cancelBtnText ?? t('common.cancel')}
|
||||
</AlertDialogCancel>
|
||||
<Button
|
||||
variant={destructive ? 'destructive' : 'default'}
|
||||
onClick={handleConfirm}
|
||||
disabled={disabled || isLoading}
|
||||
>
|
||||
{confirmText ?? 'Continue'}
|
||||
{confirmText ?? t('dialogs.continue')}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
77
web/src/components/language-switch.tsx
Normal file
77
web/src/components/language-switch.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
//
|
||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Languages } from 'lucide-react'
|
||||
|
||||
const LANGUAGES = [
|
||||
{ code: 'ar', label: 'العربية' },
|
||||
{ code: 'da', label: 'Dansk' },
|
||||
{ code: 'de', label: 'Deutsch' },
|
||||
{ code: 'en', label: 'English' },
|
||||
{ code: 'es', label: 'Español' },
|
||||
{ code: 'fi', label: 'Suomi' },
|
||||
{ code: 'fr', label: 'Français' },
|
||||
{ code: 'it', label: 'Italiano' },
|
||||
{ code: 'jp', label: '日本語' },
|
||||
{ code: 'ko', label: '한국어' },
|
||||
{ code: 'nl', label: 'Nederlands' },
|
||||
{ code: 'no', label: 'Norsk' },
|
||||
{ code: 'pt', label: 'Português' },
|
||||
{ code: 'ru', label: 'Русский' },
|
||||
{ code: 'sv', label: 'Svenska' },
|
||||
{ code: 'zh', label: '中文' },
|
||||
{ code: 'zh-tw', label: '繁體中文' },
|
||||
]
|
||||
|
||||
|
||||
export function LanguageSwitch() {
|
||||
const { i18n } = useTranslation()
|
||||
|
||||
const changeLanguage = (lng: string) => {
|
||||
i18n.changeLanguage(lng)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Languages className="h-4 w-4" />
|
||||
<span className="sr-only">Change language</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{LANGUAGES.map((lang) => (
|
||||
<DropdownMenuItem
|
||||
key={lang.code}
|
||||
onClick={() => changeLanguage(lang.code)}
|
||||
>
|
||||
{lang.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -26,10 +26,11 @@ import {
|
||||
} from '@/components/ui/sidebar'
|
||||
import { NavGroup } from '@/components/layout/nav-group'
|
||||
import Logo from '@/assets/logo.svg'
|
||||
import { sidebarData } from './data/sidebar-data'
|
||||
import { useSidebarData } from './data/sidebar-data'
|
||||
|
||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
const { open } = useSidebar();
|
||||
const sidebarData = useSidebarData();
|
||||
return (
|
||||
<Sidebar collapsible='icon' variant='sidebar' {...props}>
|
||||
<SidebarHeader>
|
||||
|
||||
@@ -25,68 +25,73 @@ import {
|
||||
} from '@tabler/icons-react'
|
||||
import { IdCard, Inbox, Mailbox, Search } from 'lucide-react'
|
||||
import { type SidebarData } from '../types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export const sidebarData: SidebarData = {
|
||||
navGroups: [
|
||||
{
|
||||
title: 'General',
|
||||
items: [
|
||||
{
|
||||
title: 'Dashboard',
|
||||
url: '/',
|
||||
icon: IconLayoutDashboard,
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Accounts',
|
||||
items: [
|
||||
{
|
||||
title: 'Accounts',
|
||||
url: '/accounts',
|
||||
icon: Inbox,
|
||||
},
|
||||
{
|
||||
title: 'MailBox',
|
||||
url: '/mailboxes',
|
||||
icon: Mailbox,
|
||||
},
|
||||
{
|
||||
title: 'Search',
|
||||
url: '/search',
|
||||
icon: Search,
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Auth',
|
||||
items: [
|
||||
{
|
||||
title: 'OAuth2',
|
||||
url: '/oauth2',
|
||||
icon: IdCard,
|
||||
},
|
||||
{
|
||||
title: 'Access Tokens',
|
||||
url: '/access-tokens',
|
||||
icon: IconLockAccess,
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Other',
|
||||
items: [
|
||||
{
|
||||
title: 'Settings',
|
||||
url: '/settings',
|
||||
icon: IconSettings,
|
||||
},
|
||||
{
|
||||
title: 'API Documentation',
|
||||
url: '/api-docs',
|
||||
icon: IconHelp,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
export function useSidebarData(): SidebarData {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return {
|
||||
navGroups: [
|
||||
{
|
||||
title: t('navigation.general'),
|
||||
items: [
|
||||
{
|
||||
title: t('navigation.dashboard'),
|
||||
url: '/',
|
||||
icon: IconLayoutDashboard,
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('navigation.accounts'),
|
||||
items: [
|
||||
{
|
||||
title: t('navigation.accounts'),
|
||||
url: '/accounts',
|
||||
icon: Inbox,
|
||||
},
|
||||
{
|
||||
title: t('navigation.mailbox'),
|
||||
url: '/mailboxes',
|
||||
icon: Mailbox,
|
||||
},
|
||||
{
|
||||
title: t('common.search'),
|
||||
url: '/search',
|
||||
icon: Search,
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('navigation.auth'),
|
||||
items: [
|
||||
{
|
||||
title: t('navigation.oauth2'),
|
||||
url: '/oauth2',
|
||||
icon: IdCard,
|
||||
},
|
||||
{
|
||||
title: t('navigation.accessTokens'),
|
||||
url: '/access-tokens',
|
||||
icon: IconLockAccess,
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t('navigation.other'),
|
||||
items: [
|
||||
{
|
||||
title: t('navigation.settings'),
|
||||
url: '/settings',
|
||||
icon: IconSettings,
|
||||
},
|
||||
{
|
||||
title: t('navigation.apiDocs'),
|
||||
url: '/api-docs',
|
||||
icon: IconHelp,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { ProfileDropdown } from "../profile-dropdown";
|
||||
import { Header } from "./header";
|
||||
import { NotificationPopover } from "./notification";
|
||||
import { GithubLinkButton } from "./github";
|
||||
import { LanguageSwitch } from "../language-switch";
|
||||
|
||||
export const FixedHeader = () => {
|
||||
return (
|
||||
@@ -30,6 +31,7 @@ export const FixedHeader = () => {
|
||||
<div className='ml-auto flex items-center space-x-4'>
|
||||
<NotificationPopover />
|
||||
<GithubLinkButton />
|
||||
<LanguageSwitch />
|
||||
<ThemeSwitch />
|
||||
<ProfileDropdown />
|
||||
</div>
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from "@/components/ui/command";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
/**
|
||||
* Variants for the multi-select component to handle different styles.
|
||||
@@ -75,7 +76,7 @@ const multiSelectVariants = cva(
|
||||
*/
|
||||
interface MultiSelectProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof multiSelectVariants> {
|
||||
VariantProps<typeof multiSelectVariants> {
|
||||
/**
|
||||
* An array of option objects to be displayed in the multi-select component.
|
||||
* Each option object has a label, value, and an optional icon.
|
||||
@@ -156,6 +157,7 @@ export const MultiSelect = React.forwardRef<
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const { t } = useTranslation()
|
||||
const [selectedValues, setSelectedValues] =
|
||||
React.useState<string[]>(defaultValue);
|
||||
const [isPopoverOpen, setIsPopoverOpen] = React.useState(false);
|
||||
@@ -308,7 +310,7 @@ export const MultiSelect = React.forwardRef<
|
||||
onKeyDown={handleInputKeyDown}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
<CommandEmpty>{t('common.table.noResults')}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
key="all"
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface PaginationProps {
|
||||
totalItems: number
|
||||
@@ -47,6 +48,7 @@ export function EnvelopeListPagination({
|
||||
setPageIndex,
|
||||
setPageSize,
|
||||
}: PaginationProps) {
|
||||
const { t } = useTranslation()
|
||||
const pageCount = Math.ceil(totalItems / pageSize)
|
||||
|
||||
const handlePageSizeChange = (value: string) => {
|
||||
@@ -67,11 +69,11 @@ export function EnvelopeListPagination({
|
||||
return (
|
||||
<div className='flex items-center justify-between space-x-2 overflow-auto px-2'>
|
||||
<div className='hidden flex-1 text-sm text-muted-foreground sm:block'>
|
||||
{totalItems} total items
|
||||
{totalItems} {t("table.results")}
|
||||
</div>
|
||||
<div className='flex items-center sm:space-x-6 lg:space-x-4'>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<p className='hidden text-sm font-medium sm:block'>Rows per page</p>
|
||||
<p className='hidden text-sm font-medium sm:block'>{t("table.rowsPerPage")}</p>
|
||||
<Select
|
||||
value={`${pageSize}`}
|
||||
onValueChange={handlePageSizeChange}
|
||||
@@ -80,7 +82,7 @@ export function EnvelopeListPagination({
|
||||
<SelectValue placeholder={pageSize} />
|
||||
</SelectTrigger>
|
||||
<SelectContent side='top'>
|
||||
{[10, 20, 30, 40, 50].map((size) => (
|
||||
{[10, 20, 30, 40, 50, 100].map((size) => (
|
||||
<SelectItem key={size} value={`${size}`}>
|
||||
{size}
|
||||
</SelectItem>
|
||||
@@ -89,7 +91,7 @@ export function EnvelopeListPagination({
|
||||
</Select>
|
||||
</div>
|
||||
<div className='flex items-center justify-center text-sm font-medium'>
|
||||
Page {pageIndex + 1} of {pageCount}
|
||||
{t("table.page")} {pageIndex + 1} {t("table.of")} {pageCount}
|
||||
</div>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<Button
|
||||
@@ -98,7 +100,7 @@ export function EnvelopeListPagination({
|
||||
onClick={goToPreviousPage}
|
||||
disabled={pageIndex === 0}
|
||||
>
|
||||
<span className='sr-only'>Go to previous page</span>
|
||||
<span className='sr-only'>{t("table.prevPage")}</span>
|
||||
<ChevronLeftIcon className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
@@ -107,7 +109,7 @@ export function EnvelopeListPagination({
|
||||
onClick={goToNextPage}
|
||||
disabled={!hasNextPage()}
|
||||
>
|
||||
<span className='sr-only'>Go to next page</span>
|
||||
<span className='sr-only'>{t("table.nextPage")}</span>
|
||||
<ChevronRightIcon className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -30,9 +30,11 @@ 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 { useTranslation } from 'react-i18next';
|
||||
|
||||
export function ProfileDropdown() {
|
||||
const navigate = useNavigate()
|
||||
const { t } = useTranslation()
|
||||
const [isLogoutDialogOpen, setIsLogoutDialogOpen] = useState(false)
|
||||
|
||||
const handleLogout = () => {
|
||||
@@ -52,7 +54,7 @@ export function ProfileDropdown() {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className='w-56' align='end' forceMount>
|
||||
<DropdownMenuItem onClick={() => setIsLogoutDialogOpen(true)}>
|
||||
Log out
|
||||
{t('auth.logout')}
|
||||
<DropdownMenuShortcut>⇧⌘Q</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -1,529 +0,0 @@
|
||||
//
|
||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import React from 'react'
|
||||
import * as AccordionPrimitive from '@radix-ui/react-accordion'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { cva } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const treeVariants = cva(
|
||||
'group hover:before:opacity-100 before:absolute before:rounded-lg before:left-0 px-2 before:w-full before:opacity-0 before:bg-accent/70 before:h-[2rem] before:-z-10'
|
||||
)
|
||||
|
||||
const selectedTreeVariants = cva(
|
||||
'before:opacity-100 before:bg-accent/70 text-accent-foreground'
|
||||
)
|
||||
|
||||
interface TreeDataItem {
|
||||
id: string
|
||||
name: string
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
openIcon?: React.ComponentType<{ className?: string }>
|
||||
children?: TreeDataItem[]
|
||||
badge?: React.ReactNode,
|
||||
attributes?: React.ReactNode,
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
type TreeProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||
data: TreeDataItem[] | TreeDataItem
|
||||
onSelectChange?: (item: TreeDataItem | undefined) => void
|
||||
onSelectItemsChange?: (items: TreeDataItem[]) => void
|
||||
expandAll?: boolean
|
||||
multiple?: boolean
|
||||
clickRowToSelect?: boolean
|
||||
initialSelectedItemIds?: string[]
|
||||
defaultNodeIcon?: React.ComponentType<{ className?: string }>
|
||||
defaultLeafIcon?: React.ComponentType<{ className?: string }>
|
||||
}
|
||||
|
||||
const TreeView = React.forwardRef<HTMLDivElement, TreeProps>(
|
||||
(
|
||||
{
|
||||
data,
|
||||
onSelectChange,
|
||||
onSelectItemsChange,
|
||||
expandAll,
|
||||
defaultLeafIcon,
|
||||
defaultNodeIcon,
|
||||
clickRowToSelect = true,
|
||||
className,
|
||||
multiple,
|
||||
initialSelectedItemIds = [],
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [selectedItemIds, setSelectedItemIds] = React.useState<Set<string>>(
|
||||
new Set(initialSelectedItemIds)
|
||||
);
|
||||
|
||||
const callbacksRef = React.useRef({
|
||||
onSelectChange,
|
||||
onSelectItemsChange
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
callbacksRef.current = {
|
||||
onSelectChange,
|
||||
onSelectItemsChange
|
||||
};
|
||||
}, [onSelectChange, onSelectItemsChange]);
|
||||
|
||||
|
||||
const handleSelectChange = React.useCallback(
|
||||
(item: TreeDataItem | undefined) => {
|
||||
if (!item) return;
|
||||
|
||||
setSelectedItemIds(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(item.id)) {
|
||||
newSet.delete(item.id);
|
||||
} else {
|
||||
if (!multiple) {
|
||||
newSet.clear();
|
||||
}
|
||||
newSet.add(item.id);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (callbacksRef.current.onSelectChange) {
|
||||
callbacksRef.current.onSelectChange(newSet.has(item.id) ? item : undefined);
|
||||
}
|
||||
if (callbacksRef.current.onSelectItemsChange) {
|
||||
const selectedItems = Array.from(newSet)
|
||||
.map(id => findItemById(data, id))
|
||||
.filter(Boolean) as TreeDataItem[];
|
||||
callbacksRef.current.onSelectItemsChange(selectedItems);
|
||||
}
|
||||
}, 0);
|
||||
return newSet;
|
||||
});
|
||||
},
|
||||
[multiple, onSelectChange, onSelectItemsChange, data]
|
||||
);
|
||||
|
||||
const expandedItemIds = React.useMemo(() => {
|
||||
if (!initialSelectedItemIds || initialSelectedItemIds.length === 0) {
|
||||
return [] as string[]
|
||||
}
|
||||
|
||||
const ids: string[] = []
|
||||
|
||||
function walkTreeItems(
|
||||
items: TreeDataItem[] | TreeDataItem,
|
||||
targetIds: string[]
|
||||
) {
|
||||
if (Array.isArray(items)) {
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
ids.push(items[i]!.id)
|
||||
if (walkTreeItems(items[i]!, targetIds) && !expandAll) {
|
||||
return true
|
||||
}
|
||||
if (!expandAll) ids.pop()
|
||||
}
|
||||
} else if (!expandAll && targetIds.includes(items.id)) {
|
||||
return true
|
||||
} else if (items.children) {
|
||||
return walkTreeItems(items.children, targetIds)
|
||||
}
|
||||
}
|
||||
|
||||
walkTreeItems(data, initialSelectedItemIds)
|
||||
return ids
|
||||
}, [data, expandAll, initialSelectedItemIds])
|
||||
|
||||
return (
|
||||
<div className={cn('overflow-hidden relative p-2', className)}>
|
||||
<TreeItem
|
||||
data={data}
|
||||
ref={ref}
|
||||
clickRowToSelect={clickRowToSelect}
|
||||
selectedItemIds={selectedItemIds}
|
||||
handleSelectChange={handleSelectChange}
|
||||
expandedItemIds={expandedItemIds}
|
||||
defaultLeafIcon={defaultLeafIcon}
|
||||
defaultNodeIcon={defaultNodeIcon}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
TreeView.displayName = 'TreeView'
|
||||
|
||||
// Helper function to find item by ID in tree
|
||||
function findItemById(items: TreeDataItem[] | TreeDataItem, id: string): TreeDataItem | undefined {
|
||||
if (Array.isArray(items)) {
|
||||
for (const item of items) {
|
||||
const found = findItemById(item, id);
|
||||
if (found) return found;
|
||||
}
|
||||
} else {
|
||||
if (items.id === id) return items;
|
||||
if (items.children) {
|
||||
return findItemById(items.children, id);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
type TreeItemProps = TreeProps & {
|
||||
selectedItemIds: Set<string>
|
||||
handleSelectChange: (item: TreeDataItem | undefined) => void
|
||||
expandedItemIds: string[]
|
||||
clickRowToSelect?: boolean
|
||||
defaultNodeIcon?: React.ComponentType<{ className?: string }>
|
||||
defaultLeafIcon?: React.ComponentType<{ className?: string }>
|
||||
}
|
||||
|
||||
const TreeItem = React.forwardRef<HTMLDivElement, TreeItemProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
data,
|
||||
selectedItemIds,
|
||||
handleSelectChange,
|
||||
clickRowToSelect,
|
||||
expandedItemIds,
|
||||
defaultNodeIcon,
|
||||
defaultLeafIcon,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
if (!Array.isArray(data)) {
|
||||
data = [data]
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} role="tree" className={className} {...props}>
|
||||
<ul>
|
||||
{data.map((item) => (
|
||||
<li key={item.id}>
|
||||
{item.children ? (
|
||||
<TreeNode
|
||||
item={item}
|
||||
selectedItemIds={selectedItemIds}
|
||||
expandedItemIds={expandedItemIds}
|
||||
clickRowToSelect={clickRowToSelect}
|
||||
handleSelectChange={handleSelectChange}
|
||||
defaultNodeIcon={defaultNodeIcon}
|
||||
defaultLeafIcon={defaultLeafIcon}
|
||||
/>
|
||||
) : (
|
||||
<TreeLeaf
|
||||
item={item}
|
||||
clickRowToSelect={clickRowToSelect}
|
||||
selectedItemIds={selectedItemIds}
|
||||
handleSelectChange={handleSelectChange}
|
||||
defaultLeafIcon={defaultLeafIcon}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
TreeItem.displayName = 'TreeItem'
|
||||
|
||||
interface TreeNodeProps {
|
||||
item: TreeDataItem
|
||||
handleSelectChange: (item: TreeDataItem | undefined) => void
|
||||
expandedItemIds: string[]
|
||||
clickRowToSelect?: boolean
|
||||
selectedItemIds: Set<string>
|
||||
defaultNodeIcon?: React.ComponentType<{ className?: string }>
|
||||
defaultLeafIcon?: React.ComponentType<{ className?: string }>
|
||||
}
|
||||
|
||||
const TreeNode = ({
|
||||
item,
|
||||
handleSelectChange,
|
||||
expandedItemIds,
|
||||
clickRowToSelect,
|
||||
selectedItemIds,
|
||||
defaultNodeIcon,
|
||||
defaultLeafIcon
|
||||
}: TreeNodeProps) => {
|
||||
const [value, setValue] = React.useState(
|
||||
expandedItemIds.includes(item.id) ? [item.id] : []
|
||||
)
|
||||
|
||||
const isSelected = selectedItemIds.has(item.id);
|
||||
|
||||
return (
|
||||
<AccordionPrimitive.Root
|
||||
type="multiple"
|
||||
value={value}
|
||||
onValueChange={(s) => setValue(s)}
|
||||
>
|
||||
<AccordionPrimitive.Item value={item.id}>
|
||||
<AccordionTrigger
|
||||
className={cn(
|
||||
"flex items-center w-full py-2",
|
||||
treeVariants(),
|
||||
isSelected && selectedTreeVariants()
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (clickRowToSelect) {
|
||||
handleSelectChange(item);
|
||||
}
|
||||
item.onClick?.();
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center min-w-0 flex-shrink-0">
|
||||
<TreeIcon
|
||||
item={item}
|
||||
isSelected={isSelected}
|
||||
isOpen={value.includes(item.id)}
|
||||
default={defaultNodeIcon}
|
||||
onCheck={() => { handleSelectChange(item) }}
|
||||
/>
|
||||
<span className="ml-2 text-sm truncate">
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{item.attributes && (
|
||||
<span className="mx-auto text-sm text-muted-foreground whitespace-nowrap">
|
||||
{item.attributes}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{item.badge && (
|
||||
<TreeBadge isSelected={isSelected}>
|
||||
{item.badge}
|
||||
</TreeBadge>
|
||||
)}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="ml-4 pl-1 border-l">
|
||||
<TreeItem
|
||||
data={item.children ? item.children : item}
|
||||
selectedItemIds={selectedItemIds}
|
||||
clickRowToSelect={clickRowToSelect}
|
||||
handleSelectChange={handleSelectChange}
|
||||
expandedItemIds={expandedItemIds}
|
||||
defaultLeafIcon={defaultLeafIcon}
|
||||
defaultNodeIcon={defaultNodeIcon}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionPrimitive.Item>
|
||||
</AccordionPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
// function hasSelectedChildrenRecursive(item: TreeDataItem, selectedItemIds: Set<string>): boolean {
|
||||
// if (!item.children) return false;
|
||||
// return item.children.some(child =>
|
||||
// selectedItemIds.has(child.id) ||
|
||||
// (child.children && hasSelectedChildrenRecursive(child, selectedItemIds)));
|
||||
// }
|
||||
|
||||
interface TreeLeafProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
item: TreeDataItem
|
||||
selectedItemIds: Set<string>
|
||||
clickRowToSelect?: boolean
|
||||
handleSelectChange: (item: TreeDataItem | undefined) => void
|
||||
defaultLeafIcon?: React.ComponentType<{ className?: string }>
|
||||
}
|
||||
|
||||
const TreeLeaf = React.forwardRef<HTMLDivElement, TreeLeafProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
item,
|
||||
clickRowToSelect,
|
||||
selectedItemIds,
|
||||
handleSelectChange,
|
||||
defaultLeafIcon,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"ml-5 flex items-center py-2 cursor-pointer before:right-1",
|
||||
treeVariants(),
|
||||
className,
|
||||
selectedItemIds.has(item.id) && selectedTreeVariants()
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (clickRowToSelect) {
|
||||
handleSelectChange(item);
|
||||
}
|
||||
item.onClick?.();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex items-center min-w-0 flex-shrink-0">
|
||||
<TreeIcon
|
||||
item={item}
|
||||
isSelected={selectedItemIds.has(item.id)}
|
||||
default={defaultLeafIcon}
|
||||
onCheck={() => { handleSelectChange(item) }}
|
||||
/>
|
||||
<span className="ml-2 text-sm truncate">
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{item.attributes && (
|
||||
<span className="mx-auto text-sm text-muted-foreground whitespace-nowrap">
|
||||
{item.attributes}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{item.badge && (
|
||||
<TreeBadge isSelected={selectedItemIds.has(item.id)}>
|
||||
{item.badge}
|
||||
</TreeBadge>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
TreeLeaf.displayName = 'TreeLeaf'
|
||||
|
||||
const AccordionTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Header>
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex flex-1 w-full items-center py-2 transition-all first:[&[data-state=open]>svg]:rotate-90',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (props.onClick) {
|
||||
props.onClick(e)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4 shrink-0 transition-transform duration-200 text-accent-foreground/50 mr-1" />
|
||||
{children}
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
))
|
||||
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
|
||||
|
||||
const AccordionContent = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="pb-1 pt-0">{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
))
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName
|
||||
|
||||
interface TreeIconProps {
|
||||
item: TreeDataItem;
|
||||
isOpen?: boolean;
|
||||
isSelected?: boolean;
|
||||
default?: React.ComponentType<{ className?: string }>;
|
||||
onCheck?: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
const TreeIcon = ({
|
||||
item,
|
||||
isOpen,
|
||||
isSelected,
|
||||
default: defaultIcon,
|
||||
onCheck,
|
||||
}: TreeIconProps) => {
|
||||
let Icon = defaultIcon;
|
||||
if (isOpen && item.openIcon) {
|
||||
Icon = item.openIcon;
|
||||
} else if (item.icon) {
|
||||
Icon = item.icon;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => onCheck?.(!isSelected)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
"h-4 w-4 rounded border border-primary dark:border-white shadow transition-all duration-200",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
isSelected
|
||||
? "bg-black dark:bg-white text-primary-foreground"
|
||||
: "bg-transparent",
|
||||
"appearance-none cursor-pointer flex items-center justify-center relative",
|
||||
"after:content-[''] after:w-1.5 after:h-2",
|
||||
"after:border-r-2 after:border-b-2 after:rotate-45 after:mt-[-2px]",
|
||||
isSelected
|
||||
? "after:block after:border-white dark:after:border-black after:z-10"
|
||||
: "after:hidden"
|
||||
)}
|
||||
/>
|
||||
{Icon && <Icon className="h-4 w-4 shrink-0" />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface TreeBadgeProps {
|
||||
children: React.ReactNode
|
||||
isSelected: boolean
|
||||
showOnSelectedOnly?: boolean
|
||||
}
|
||||
|
||||
const TreeBadge = ({
|
||||
children,
|
||||
isSelected,
|
||||
showOnSelectedOnly = false
|
||||
}: TreeBadgeProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
showOnSelectedOnly
|
||||
? isSelected
|
||||
? 'block'
|
||||
: 'hidden'
|
||||
: 'block',
|
||||
'absolute right-3 group-hover:block'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { TreeView, type TreeDataItem }
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
import { AccessToken } from '../data/schema'
|
||||
import { DataTablePagination } from './data-table-pagination'
|
||||
import { DataTableToolbar } from './data-table-toolbar'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
declare module '@tanstack/react-table' {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
@@ -62,7 +63,7 @@ export function AccessTokensTable({ columns, data }: DataTableProps) {
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||
const [sorting, setSorting] = useState<SortingState>([])
|
||||
|
||||
const { t } = useTranslation()
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
@@ -139,7 +140,7 @@ export function AccessTokensTable({ columns, data }: DataTableProps) {
|
||||
colSpan={columns.length}
|
||||
className='h-24 text-center'
|
||||
>
|
||||
No results.
|
||||
{t('common.table.noResults')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import { Table } from '@tanstack/react-table'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface DataTableToolbarProps<TData> {
|
||||
table: Table<TData>
|
||||
@@ -27,11 +28,12 @@ interface DataTableToolbarProps<TData> {
|
||||
export function AccountDetailTableToolbar<TData>({
|
||||
table,
|
||||
}: DataTableToolbarProps<TData>) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex flex-1 flex-col-reverse items-start gap-y-2 sm:flex-row sm:items-center sm:space-x-2'>
|
||||
<Input
|
||||
placeholder='Filter by email address or account ID...'
|
||||
placeholder={t('accessTokens.filterByEmailAddressOrAccountId')}
|
||||
value={(table.getState().globalFilter as string) ?? ''}
|
||||
onChange={(event) => {
|
||||
table.setGlobalFilter(event.target.value);
|
||||
|
||||
@@ -22,11 +22,11 @@ import LongText from '@/components/long-text'
|
||||
import { AccountInfo } from '../data/schema'
|
||||
import { DataTableColumnHeader } from './data-table-column-header'
|
||||
|
||||
export const columns: ColumnDef<AccountInfo>[] = [
|
||||
export const getColumns = (t: (key: string) => string): ColumnDef<AccountInfo>[] => [
|
||||
{
|
||||
accessorKey: 'id',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title='Account_Id' />
|
||||
<DataTableColumnHeader column={column} title={t('accessTokens.accountId')} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<LongText className='max-w-80'>{row.original.id}</LongText>
|
||||
@@ -38,7 +38,7 @@ export const columns: ColumnDef<AccountInfo>[] = [
|
||||
{
|
||||
accessorKey: 'email',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title='Email' />
|
||||
<DataTableColumnHeader column={column} title={t('accounts.email')} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<LongText className='max-w-80'>{row.getValue('email')}</LongText>
|
||||
|
||||
@@ -29,7 +29,8 @@ import {
|
||||
import { AccessToken } from '../data/schema'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AccountsDetailTable } from './accounts-detail-table'
|
||||
import { columns } from './accounts-detail-columns'
|
||||
import { getColumns } from './accounts-detail-columns'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface Props {
|
||||
currentRow: AccessToken
|
||||
@@ -38,6 +39,8 @@ interface Props {
|
||||
}
|
||||
|
||||
export function AccountDetailDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const columns = getColumns(t)
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
@@ -47,9 +50,9 @@ export function AccountDetailDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
>
|
||||
<DialogContent className='sm:max-w-4xl'>
|
||||
<DialogHeader className='text-left'>
|
||||
<DialogTitle>Accounts</DialogTitle>
|
||||
<DialogTitle>{t('settings.accounts')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
The list of accounts that can be queried using an access token.
|
||||
{t('accessTokens.theListOfAccountsThatCanBeQueried')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="h-[33rem] overflow-x-auto overflow-y-auto">
|
||||
@@ -57,7 +60,7 @@ export function AccountDetailDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant='outline' className="px-2 py-1 text-sm h-auto">Close</Button>
|
||||
<Button variant='outline' className="px-2 py-1 text-sm h-auto">{t('common.close')}</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
import { AccountInfo } from '../data/schema'
|
||||
import { DataTablePagination } from './data-table-pagination'
|
||||
import { AccountDetailTableToolbar } from './account-detail-table-toolbar'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
declare module '@tanstack/react-table' {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
@@ -57,6 +58,7 @@ interface DataTableProps {
|
||||
}
|
||||
|
||||
export function AccountsDetailTable({ columns, data }: DataTableProps) {
|
||||
const { t } = useTranslation()
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||
const [sorting, setSorting] = useState<SortingState>([])
|
||||
|
||||
@@ -132,7 +134,7 @@ export function AccountsDetailTable({ columns, data }: DataTableProps) {
|
||||
colSpan={columns.length}
|
||||
className='h-24 text-center'
|
||||
>
|
||||
No results.
|
||||
{t('common.table.noResults')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
@@ -32,6 +32,7 @@ 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
|
||||
@@ -40,6 +41,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export function AclDetailDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
@@ -49,9 +51,9 @@ export function AclDetailDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
>
|
||||
<DialogContent className='sm:max-w-xl'>
|
||||
<DialogHeader className='text-left'>
|
||||
<DialogTitle>Acl</DialogTitle>
|
||||
<DialogTitle>{t('settings.acl')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
ACL rules for access tokens include IP whitelist verification and rate limit enforcement.
|
||||
{t('accessTokens.aclRulesForAccessTokens')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ScrollArea className='h-[33rem] w-full pr-4 -mr-4 py-1'>
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
//
|
||||
// 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
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import { z } from 'zod'
|
||||
@@ -51,6 +51,7 @@ import { ToastAction } from '@/components/ui/toast'
|
||||
import { AxiosError } from 'axios'
|
||||
import { VirtualizedSelect } from '@/components/virtualized-select'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const isValidIP = (ip: string) => {
|
||||
const ipv4Regex = /^(?:(?:\d{1,3}\.){3}\d{1,3})$/;
|
||||
@@ -58,14 +59,31 @@ const isValidIP = (ip: string) => {
|
||||
return ipv4Regex.test(ip) || ipv6Regex.test(ip);
|
||||
};
|
||||
|
||||
const rateLimitSchema = z.object({
|
||||
quota: z.optional(z.number().int().positive({ message: "Quota must be a positive integer." })),
|
||||
interval: z.optional(z.number().int().positive({ message: "Interval must be a positive integer." })),
|
||||
const RateLimitBaseSchema = z.object({
|
||||
quota: z.optional(z.number()),
|
||||
interval: z.optional(z.number()),
|
||||
});
|
||||
|
||||
const accessControlSchema = z.object({
|
||||
const AccessControlBaseSchema = z.object({
|
||||
ip_whitelist: z.string().optional(),
|
||||
rate_limit: rateLimitSchema.optional(),
|
||||
rate_limit: z.optional(RateLimitBaseSchema),
|
||||
});
|
||||
|
||||
const AccessTokenBaseSchema = z.object({
|
||||
accounts: z.array(z.number()),
|
||||
description: z.optional(z.string()),
|
||||
acl: z.optional(AccessControlBaseSchema),
|
||||
});
|
||||
|
||||
export type AccessTokenForm = z.infer<typeof AccessTokenBaseSchema>;
|
||||
|
||||
const getRateLimitSchema = (t: (key: string) => string) => z.object({
|
||||
quota: z.optional(z.number().int().positive({ message: t('accessTokens.quotaMustBeAPositiveInteger') })),
|
||||
interval: z.optional(z.number().int().positive({ message: t('accessTokens.intervalMustBeAPositiveInteger') })),
|
||||
});
|
||||
|
||||
const getAccessControlSchema = (t: (key: string) => string) => AccessControlBaseSchema.extend({
|
||||
rate_limit: getRateLimitSchema(t).optional(),
|
||||
}).transform((data) => {
|
||||
if (data.ip_whitelist) {
|
||||
const ips = data.ip_whitelist
|
||||
@@ -89,7 +107,7 @@ const accessControlSchema = z.object({
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: 'Invalid IP addresses found. Please enter valid IPv4 or IPv6 addresses.',
|
||||
message: t('accessTokens.invalidIpAddressesFound'),
|
||||
path: ['ip_whitelist'],
|
||||
}
|
||||
).transform((data) => {
|
||||
@@ -108,17 +126,15 @@ const accessControlSchema = z.object({
|
||||
return data;
|
||||
});
|
||||
|
||||
const accessTokenFormSchema = z.object({
|
||||
const getAccessTokenFormSchema = (t: (key: string) => string) => AccessTokenBaseSchema.extend({
|
||||
accounts: z
|
||||
.array(z.number())
|
||||
.min(1, { message: "At least one account is required." }),
|
||||
.min(1, { message: t('accessTokens.atLeastOneAccountIsRequired') }),
|
||||
description: z
|
||||
.optional(z.string().max(255, { message: "Description must not exceed 255 characters." })),
|
||||
acl: z.optional(accessControlSchema),
|
||||
.optional(z.string().max(255, { message: t('accessTokens.descriptionMustNotExceed255Characters') })),
|
||||
acl: z.optional(getAccessControlSchema(t)),
|
||||
});
|
||||
|
||||
export type AccessTokenForm = z.infer<typeof accessTokenFormSchema>;
|
||||
|
||||
interface Props {
|
||||
currentRow?: AccessToken
|
||||
open: boolean
|
||||
@@ -134,14 +150,15 @@ const defaultValues = {
|
||||
|
||||
|
||||
export function TokensActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const isEdit = !!currentRow
|
||||
const queryClient = useQueryClient();
|
||||
const form = useForm<AccessTokenForm>({
|
||||
resolver: zodResolver(accessTokenFormSchema),
|
||||
resolver: zodResolver(getAccessTokenFormSchema(t)),
|
||||
defaultValues: isEdit
|
||||
? {
|
||||
accounts: currentRow.accounts.map(value => value.id),
|
||||
description: currentRow.description,
|
||||
description: currentRow.description ?? undefined,
|
||||
acl: currentRow.acl
|
||||
? {
|
||||
ip_whitelist: currentRow.acl.ip_whitelist
|
||||
@@ -168,9 +185,9 @@ export function TokensActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
|
||||
function handleSuccess() {
|
||||
toast({
|
||||
title: `Access token ${isEdit ? 'Updated' : 'Created'}`,
|
||||
description: `Your access token has been successfully ${isEdit ? 'updated' : 'created'}.`,
|
||||
action: <ToastAction altText="Close">Close</ToastAction>,
|
||||
title: `${t('accessTokens.title')} ${isEdit ? t('accessTokens.updated') : t('accessTokens.created')}`,
|
||||
description: t('accessTokens.yourAccessTokenHasBeenSuccessfully', { action: isEdit ? t('accessTokens.updated').toLowerCase() : t('accessTokens.created').toLowerCase() }),
|
||||
action: <ToastAction altText={t('common.close')}>{t('common.close')}</ToastAction>,
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['access-tokens'] });
|
||||
@@ -181,13 +198,13 @@ export function TokensActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
function handleError(error: AxiosError) {
|
||||
const errorMessage = (error.response?.data as { message?: string })?.message ||
|
||||
error.message ||
|
||||
`${isEdit ? 'Update' : 'Creation'} failed, please try again later`;
|
||||
t('accessTokens.updateOrCreationFailed', { action: isEdit ? t('accessTokens.updateFailed') : t('accessTokens.creationFailed') });
|
||||
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: `Access token ${isEdit ? 'Update' : 'Creation'} Failed`,
|
||||
title: `${t('accessTokens.title')} ${isEdit ? t('accessTokens.updateFailed') : t('accessTokens.creationFailed')}`,
|
||||
description: errorMessage as string,
|
||||
action: <ToastAction altText="Try again">Try again</ToastAction>,
|
||||
action: <ToastAction altText={t('common.tryAgain')}>{t('common.tryAgain')}</ToastAction>,
|
||||
});
|
||||
console.error(error);
|
||||
}
|
||||
@@ -233,10 +250,10 @@ export function TokensActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
>
|
||||
<DialogContent className='max-w-4xl'>
|
||||
<DialogHeader className='text-left mb-4'>
|
||||
<DialogTitle>{isEdit ? 'Edit Token' : 'Add New'}</DialogTitle>
|
||||
<DialogTitle>{isEdit ? t('accessTokens.editToken') : t('accessTokens.addNew')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit ? 'Update the access token here. ' : 'Create new access token here. '}
|
||||
Click save when you're done.
|
||||
{isEdit ? t('accessTokens.updateTheAccessTokenHere') : t('accessTokens.createNewAccessTokenHere')}
|
||||
{t('accounts.clickSaveWhenDone')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ScrollArea className='h-[28rem] w-full pr-4 -mr-4 py-1'>
|
||||
@@ -251,7 +268,7 @@ export function TokensActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
name='accounts'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-col gap-y-1 space-y-0'>
|
||||
<FormLabel className='mb-1'>Accounts:</FormLabel>
|
||||
<FormLabel className='mb-1'>{t('accessTokens.accounts')}:</FormLabel>
|
||||
<FormControl>
|
||||
<VirtualizedSelect
|
||||
multiple
|
||||
@@ -262,13 +279,13 @@ export function TokensActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
const numberArray = options.map((v) => parseInt(v, 10));
|
||||
return field.onChange(numberArray);
|
||||
}}
|
||||
defaultValue={`${field.value}`}
|
||||
placeholder="Select accounts"
|
||||
value={field.value.map(String)}
|
||||
placeholder={t('accessTokens.selectAccounts')}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
Select multiple accounts for the access token's authorization scope.
|
||||
{t('accessTokens.selectMultipleAccountsForTheAccessToken')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -278,16 +295,16 @@ export function TokensActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
name="acl.ip_whitelist"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col gap-y-1 space-y-0">
|
||||
<FormLabel className='mb-1'>IP Whitelist:</FormLabel>
|
||||
<FormLabel className='mb-1'>{t('settings.acl')}:</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Enter one IP address per line, e.g.:\n192.168.1.1\n192.168.1.2"
|
||||
placeholder={t('accessTokens.enterOneIpAddressPerLine')}
|
||||
{...field}
|
||||
className="max-h-[500px] min-h-[180px]"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A list of IP addresses allowed to access the resource. Enter one IP address per line. (Optional)
|
||||
{t('accessTokens.aListOfIpAddressesAllowed')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -299,17 +316,17 @@ export function TokensActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
name="acl.rate_limit.quota"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col gap-y-1 space-y-0 w-1/2">
|
||||
<FormLabel className='mb-1'>Quota:</FormLabel>
|
||||
<FormLabel className='mb-1'>{t('accessTokens.quota')}:</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Enter quota, e.g., 100"
|
||||
placeholder={t('accessTokens.enterQuota')}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The maximum number of requests allowed within the interval. (Optional)
|
||||
{t('accessTokens.theMaximumNumberOfRequestsAllowed')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -320,17 +337,17 @@ export function TokensActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
name="acl.rate_limit.interval"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col gap-y-1 space-y-0 w-1/2">
|
||||
<FormLabel className='mb-1'>Interval (seconds):</FormLabel>
|
||||
<FormLabel className='mb-1'>{t('accessTokens.interval')}:</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Enter interval in seconds, e.g., 60"
|
||||
placeholder={t('accessTokens.enterIntervalInSeconds')}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The time window (in seconds) for the rate limit. (Optional)
|
||||
{t('accessTokens.theTimeWindowForTheRateLimit')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -342,15 +359,15 @@ export function TokensActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
name='description'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-col gap-y-1 space-y-0'>
|
||||
<FormLabel className='mb-1'>Description:</FormLabel>
|
||||
<FormLabel className='mb-1'>{t('settings.description')}:</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder='Describe the purpose of the access token'
|
||||
placeholder={t('accessTokens.describeThePurposeOfTheAccessToken')}
|
||||
{...field}
|
||||
className="max-h-[240px] min-h-[80px]"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>(Optional)</FormDescription>
|
||||
<FormDescription>{t('oauth2.optional')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -372,11 +389,11 @@ export function TokensActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
<span>
|
||||
{isEdit
|
||||
? updateMutation.isPending
|
||||
? "Updating..."
|
||||
: "Save changes"
|
||||
? t('accessTokens.updating')
|
||||
: t('accessTokens.saveChanges')
|
||||
: createMutation.isPending
|
||||
? "Creating..."
|
||||
: "Save"}
|
||||
? t('accessTokens.creating')
|
||||
: t('accessTokens.save')}
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
@@ -384,4 +401,4 @@ export function TokensActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -23,15 +23,15 @@ import LongText from '@/components/long-text'
|
||||
import { AccessToken } from '../data/schema'
|
||||
import { DataTableColumnHeader } from './data-table-column-header'
|
||||
import { DataTableRowActions } from './data-table-row-actions'
|
||||
import { format, formatDistanceToNow } from 'date-fns'
|
||||
import { format, formatDistanceToNow, Locale } from 'date-fns'
|
||||
import { AccountCellAction } from './account-action'
|
||||
import { AclCellAction } from './acl-action'
|
||||
|
||||
export const columns: ColumnDef<AccessToken>[] = [
|
||||
export const getColumns = (t: (key: string) => string, locale: Locale): ColumnDef<AccessToken>[] => [
|
||||
{
|
||||
accessorKey: 'token',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title='Token' />
|
||||
<DataTableColumnHeader column={column} title={t('settings.token')} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return <LongText className='w-40'>{row.original.token}</LongText>
|
||||
@@ -43,7 +43,7 @@ export const columns: ColumnDef<AccessToken>[] = [
|
||||
{
|
||||
accessorKey: 'accounts',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title='Accounts' />
|
||||
<DataTableColumnHeader column={column} title={t('settings.accounts')} />
|
||||
),
|
||||
cell: AccountCellAction,
|
||||
meta: { className: 'w-10 text-center' },
|
||||
@@ -60,7 +60,7 @@ export const columns: ColumnDef<AccessToken>[] = [
|
||||
{
|
||||
id: 'acl',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title='Acl' />
|
||||
<DataTableColumnHeader column={column} title={t('settings.acl')} />
|
||||
),
|
||||
cell: AclCellAction,
|
||||
meta: { className: 'w-8 text-center' },
|
||||
@@ -69,7 +69,7 @@ export const columns: ColumnDef<AccessToken>[] = [
|
||||
{
|
||||
accessorKey: 'description',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title='Description' />
|
||||
<DataTableColumnHeader column={column} title={t('settings.description')} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<LongText className='max-w-80'>{row.original.description}</LongText>
|
||||
@@ -81,7 +81,7 @@ export const columns: ColumnDef<AccessToken>[] = [
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title='Created At' />
|
||||
<DataTableColumnHeader column={column} title={t('settings.createdAt')} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const created_at = row.original.created_at;
|
||||
@@ -94,7 +94,7 @@ export const columns: ColumnDef<AccessToken>[] = [
|
||||
{
|
||||
accessorKey: 'updated_at',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title='Updated At' />
|
||||
<DataTableColumnHeader column={column} title={t('settings.updatedAt')} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const updated_at = row.original.updated_at;
|
||||
@@ -107,14 +107,14 @@ export const columns: ColumnDef<AccessToken>[] = [
|
||||
{
|
||||
accessorKey: 'last_access_at',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title='Last Access' />
|
||||
<DataTableColumnHeader column={column} title={t('settings.lastAccess')} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const last_access_at = row.original.last_access_at;
|
||||
if (last_access_at === 0) {
|
||||
return <LongText className='max-w-40'>Not used yet</LongText>;
|
||||
return <LongText className='max-w-40'>{t('accessTokens.notUsedYet')}</LongText>;
|
||||
}
|
||||
const result = formatDistanceToNow(new Date(last_access_at), { addSuffix: true });
|
||||
const result = formatDistanceToNow(new Date(last_access_at), { addSuffix: true, locale });
|
||||
return <LongText className='max-w-40'>{result}</LongText>;
|
||||
},
|
||||
meta: { className: 'w-40' },
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface DataTableColumnHeaderProps<TData, TValue>
|
||||
extends React.HTMLAttributes<HTMLDivElement> {
|
||||
@@ -48,7 +49,7 @@ export function DataTableColumnHeader<TData, TValue>({
|
||||
if (!column.getCanSort()) {
|
||||
return <div className={cn(className)}>{title}</div>
|
||||
}
|
||||
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className={cn('flex items-center space-x-2', className)}>
|
||||
<DropdownMenu>
|
||||
@@ -71,16 +72,16 @@ export function DataTableColumnHeader<TData, TValue>({
|
||||
<DropdownMenuContent align='start'>
|
||||
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
|
||||
<ArrowUpIcon className='mr-2 h-3.5 w-3.5 text-muted-foreground/70' />
|
||||
Asc
|
||||
{t('table.asc')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
|
||||
<ArrowDownIcon className='mr-2 h-3.5 w-3.5 text-muted-foreground/70' />
|
||||
Desc
|
||||
{t('table.desc')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
|
||||
<EyeNoneIcon className='mr-2 h-3.5 w-3.5 text-muted-foreground/70' />
|
||||
Hide
|
||||
{t('table.hide')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -20,8 +20,6 @@
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
DoubleArrowLeftIcon,
|
||||
DoubleArrowRightIcon,
|
||||
} from '@radix-ui/react-icons'
|
||||
import { Table } from '@tanstack/react-table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -32,6 +30,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface DataTablePaginationProps<TData> {
|
||||
table: Table<TData>
|
||||
@@ -44,18 +43,18 @@ export function DataTablePagination<TData>({
|
||||
showSelected = true,
|
||||
showPageSizeSelector = true
|
||||
}: DataTablePaginationProps<TData>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className='flex items-center justify-between overflow-auto px-2'>
|
||||
{showSelected && <div className='hidden flex-1 text-sm text-muted-foreground sm:block'>
|
||||
{table.getFilteredSelectedRowModel().rows.length} of{' '}
|
||||
{table.getFilteredRowModel().rows.length} row(s) selected.
|
||||
{table.getFilteredRowModel().rows.length} {t("table.results")}
|
||||
</div>}
|
||||
{!showPageSizeSelector && <div className='hidden flex-1 text-sm text-muted-foreground sm:block'>
|
||||
10 rows per page.
|
||||
10 {t("table.rowsPerPage")}.
|
||||
</div>}
|
||||
<div className='flex items-center sm:space-x-6 lg:space-x-8 ml-auto'>
|
||||
{showPageSizeSelector && <div className='flex items-center space-x-2'>
|
||||
<p className='hidden text-sm font-medium sm:block'>Rows per page</p>
|
||||
<p className='hidden text-sm font-medium sm:block'>{t("table.rowsPerPage")}</p>
|
||||
<Select
|
||||
value={`${table.getState().pagination.pageSize}`}
|
||||
onValueChange={(value) => {
|
||||
@@ -75,26 +74,17 @@ export function DataTablePagination<TData>({
|
||||
</Select>
|
||||
</div>}
|
||||
<div className='flex w-[100px] items-center justify-center text-sm font-medium'>
|
||||
Page {table.getState().pagination.pageIndex + 1} of{' '}
|
||||
{table.getPageCount()}
|
||||
{t("table.page")} {table.getState().pagination.pageIndex + 1}{" "}
|
||||
{t("table.of")} {table.getPageCount()}
|
||||
</div>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='hidden h-8 w-8 p-0 lg:flex'
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className='sr-only'>Go to first page</span>
|
||||
<DoubleArrowLeftIcon className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='h-8 w-8 p-0'
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className='sr-only'>Go to previous page</span>
|
||||
<span className='sr-only'>{t("table.prevPage")}</span>
|
||||
<ChevronLeftIcon className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
@@ -103,18 +93,9 @@ export function DataTablePagination<TData>({
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className='sr-only'>Go to next page</span>
|
||||
<span className='sr-only'>{t("table.nextPage")}</span>
|
||||
<ChevronRightIcon className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='hidden h-8 w-8 p-0 lg:flex'
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className='sr-only'>Go to last page</span>
|
||||
<DoubleArrowRightIcon className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -31,12 +31,14 @@ import {
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useAccessTokensContext } from '../context'
|
||||
import { AccessToken } from '../data/schema'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface DataTableRowActionsProps {
|
||||
row: Row<AccessToken>
|
||||
}
|
||||
|
||||
export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
const { t } = useTranslation()
|
||||
const { setOpen, setCurrentRow } = useAccessTokensContext()
|
||||
return (
|
||||
<>
|
||||
@@ -47,7 +49,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
className='flex h-8 w-8 p-0 data-[state=open]:bg-muted'
|
||||
>
|
||||
<DotsHorizontalIcon className='h-4 w-4' />
|
||||
<span className='sr-only'>Open menu</span>
|
||||
<span className='sr-only'>{t('accessTokens.actions.openMenu')}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-[160px]'>
|
||||
@@ -57,7 +59,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
setOpen('edit')
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
{t('accessTokens.actions.edit')}
|
||||
<DropdownMenuShortcut>
|
||||
<IconEdit size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
@@ -70,7 +72,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
}}
|
||||
className='!text-red-500'
|
||||
>
|
||||
Delete
|
||||
{t('accessTokens.actions.delete')}
|
||||
<DropdownMenuShortcut>
|
||||
<IconTrash size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import { Table } from '@tanstack/react-table'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface DataTableToolbarProps<TData> {
|
||||
table: Table<TData>
|
||||
@@ -27,11 +28,12 @@ interface DataTableToolbarProps<TData> {
|
||||
export function DataTableToolbar<TData>({
|
||||
table,
|
||||
}: DataTableToolbarProps<TData>) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex flex-1 flex-col-reverse items-start gap-y-2 sm:flex-row sm:items-center sm:space-x-2'>
|
||||
<Input
|
||||
placeholder='Filter access token...'
|
||||
placeholder={t('settings.filterAccessToken')}
|
||||
value={(table.getState().globalFilter as string) ?? ''}
|
||||
onChange={(event) => {
|
||||
table.setGlobalFilter(event.target.value);
|
||||
|
||||
@@ -29,6 +29,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { delete_access_token } from '@/api/access-tokens/api'
|
||||
import { ToastAction } from '@/components/ui/toast'
|
||||
import { AxiosError } from 'axios'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
@@ -37,6 +38,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export function TokenDeleteDialog({ open, onOpenChange, currentRow }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const [value, setValue] = useState('')
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -49,9 +51,9 @@ export function TokenDeleteDialog({ open, onOpenChange, currentRow }: Props) {
|
||||
|
||||
function handleSuccess() {
|
||||
toast({
|
||||
title: 'Access Token Deleted',
|
||||
description: 'Your access token has been successfully deleted.',
|
||||
action: <ToastAction altText="Close">Close</ToastAction>,
|
||||
title: t('accessTokens.accessTokenDeleted'),
|
||||
description: t('accessTokens.yourAccessTokenHasBeenSuccessfullyDeleted'),
|
||||
action: <ToastAction altText={t('common.close')}>{t('common.close')}</ToastAction>,
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['access-tokens'] });
|
||||
@@ -61,13 +63,13 @@ export function TokenDeleteDialog({ open, onOpenChange, currentRow }: Props) {
|
||||
function handleError(error: AxiosError) {
|
||||
const errorMessage = (error.response?.data as { message?: string })?.message ||
|
||||
error.message ||
|
||||
'Delete failed, please try again later';
|
||||
t('dialogs.deleteFailed');
|
||||
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: `Access token delete Failed`,
|
||||
title: t('accessTokens.accessTokenDeleteFailed'),
|
||||
description: errorMessage as string,
|
||||
action: <ToastAction altText="Try again">Try again</ToastAction>,
|
||||
action: <ToastAction altText={t('common.tryAgain')}>{t('common.tryAgain')}</ToastAction>,
|
||||
});
|
||||
console.error(error);
|
||||
}
|
||||
@@ -91,37 +93,37 @@ export function TokenDeleteDialog({ open, onOpenChange, currentRow }: Props) {
|
||||
className='mr-1 inline-block stroke-destructive'
|
||||
size={18}
|
||||
/>{' '}
|
||||
Delete Token
|
||||
{t('accessTokens.deleteToken')}
|
||||
</span>
|
||||
}
|
||||
desc={
|
||||
<div className='space-y-4'>
|
||||
<p className='mb-2'>
|
||||
Are you sure you want to delete{' '}
|
||||
{t('dialogs.confirmDeleteDesc')}
|
||||
<span className='font-bold'>{currentRow.token}</span>?
|
||||
<br />
|
||||
This action will permanently remove the token from the system. This cannot be undone.
|
||||
{t('accessTokens.thisActionWillPermanentlyRemoveTheToken')}
|
||||
</p>
|
||||
|
||||
<Label className='my-2'>
|
||||
Token:
|
||||
{t('settings.token')}:
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder='Enter token to confirm deletion.'
|
||||
placeholder={t('accessTokens.enterTokenToConfirmDeletion')}
|
||||
className="mt-2"
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Alert variant='destructive'>
|
||||
<AlertTitle>Warning!</AlertTitle>
|
||||
<AlertTitle>{t('oauth2.warning')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
Please be carefull, this operation can not be rolled back.
|
||||
{t('oauth2.pleaseBeCareful')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
}
|
||||
confirmText='Delete'
|
||||
confirmText={t('common.delete')}
|
||||
destructive
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -22,7 +22,7 @@ import useDialogState from '@/hooks/use-dialog-state'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Main } from '@/components/layout/main'
|
||||
import { TokensActionDialog } from './components/action-dialog'
|
||||
import { columns } from './components/columns'
|
||||
import { getColumns } from './components/columns'
|
||||
import { TokenDeleteDialog } from './components/delete-dialog'
|
||||
import { AccessTokensTable } from './components/access-token-table'
|
||||
import AccessTokensProvider, {
|
||||
@@ -37,8 +37,13 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { list_access_tokens } from '@/api/access-tokens/api'
|
||||
import { TableSkeleton } from '@/components/table-skeleton'
|
||||
import { FixedHeader } from '@/components/layout/fixed-header'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { dateFnsLocaleMap } from '@/lib/utils'
|
||||
import { enUS } from 'date-fns/locale'
|
||||
|
||||
export default function AccessTokens() {
|
||||
const { t, i18n } = useTranslation()
|
||||
const locale = dateFnsLocaleMap[i18n.language.toLowerCase()] ?? enUS;
|
||||
// Dialog states
|
||||
const [currentRow, setCurrentRow] = useState<AccessToken | null>(null)
|
||||
const [open, setOpen] = useDialogState<AccessTokensDialogType>(null)
|
||||
@@ -48,49 +53,51 @@ export default function AccessTokens() {
|
||||
queryFn: list_access_tokens,
|
||||
})
|
||||
|
||||
const columns = getColumns(t, locale)
|
||||
|
||||
return (
|
||||
<AccessTokensProvider value={{ open, setOpen, currentRow, setCurrentRow }}>
|
||||
{/* ===== Top Heading ===== */}
|
||||
<FixedHeader />
|
||||
|
||||
<Main>
|
||||
<div className="mx-auto mb-2 flex max-w-5xl flex-wrap items-center justify-between gap-x-4 gap-y-2 px-2">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Access Token</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Manage your access tokens, their access controls, IP whitelists, and rate-limiting settings here.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button className="space-x-1" onClick={() => setOpen('add')}>
|
||||
<span>Add</span> <Plus size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto flex-1 overflow-auto px-4 py-1 flex-row lg:space-x-12 space-y-0 max-w-5xl">
|
||||
{isLoading ? (
|
||||
<TableSkeleton columns={columns.length} rows={10} />
|
||||
) : accessTokens?.length ? (
|
||||
<AccessTokensTable data={accessTokens} columns={columns} />
|
||||
) : (
|
||||
<div className="flex h-[450px] shrink-0 items-center justify-center rounded-md border border-dashed">
|
||||
<div className="mx-auto flex max-w-[420px] flex-col items-center justify-center text-center">
|
||||
<img
|
||||
src={Logo}
|
||||
className="max-h-[100px] w-auto opacity-20 saturate-0 transition-all duration-300 hover:opacity-100 hover:saturate-100 object-contain"
|
||||
alt="RustMailer Logo"
|
||||
/>
|
||||
<h3 className="mt-4 text-lg font-semibold">No Access Tokens</h3>
|
||||
<p className="mb-4 mt-2 text-sm text-muted-foreground">
|
||||
You haven't created any access tokens yet. Create one to start accessing the API securely.
|
||||
</p>
|
||||
<Button onClick={() => setOpen('add')}>Create Token</Button>
|
||||
<div className="mx-auto mb-2 flex max-w-5xl flex-wrap items-center justify-between gap-x-4 gap-y-2 px-2">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">{t('accessTokens.title')}</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{t('accessTokens.description')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button className="space-x-1" onClick={() => setOpen('add')}>
|
||||
<span>{t('common.add')}</span> <Plus size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Main>
|
||||
|
||||
<div className="mx-auto flex-1 overflow-auto px-4 py-1 flex-row lg:space-x-12 space-y-0 max-w-5xl">
|
||||
{isLoading ? (
|
||||
<TableSkeleton columns={columns.length} rows={10} />
|
||||
) : accessTokens?.length ? (
|
||||
<AccessTokensTable data={accessTokens} columns={columns} />
|
||||
) : (
|
||||
<div className="flex h-[450px] shrink-0 items-center justify-center rounded-md border border-dashed">
|
||||
<div className="mx-auto flex max-w-[420px] flex-col items-center justify-center text-center">
|
||||
<img
|
||||
src={Logo}
|
||||
className="max-h-[100px] w-auto opacity-20 saturate-0 transition-all duration-300 hover:opacity-100 hover:saturate-100 object-contain"
|
||||
alt="Bichon Logo"
|
||||
/>
|
||||
<h3 className="mt-4 text-lg font-semibold">{t('accessTokens.noTokens')}</h3>
|
||||
<p className="mb-4 mt-2 text-sm text-muted-foreground">
|
||||
{t('accessTokens.noTokensDesc')}
|
||||
</p>
|
||||
<Button onClick={() => setOpen('add')}>{t('accessTokens.create')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Main>
|
||||
|
||||
|
||||
<TokensActionDialog
|
||||
|
||||
@@ -24,6 +24,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
@@ -32,6 +33,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export function AccountDetailDrawer({ open, onOpenChange, currentRow }: Props) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
@@ -46,7 +48,7 @@ export function AccountDetailDrawer({ open, onOpenChange, currentRow }: Props) {
|
||||
<ScrollArea className="h-[35rem] w-full pr-4 -mr-4 py-1">
|
||||
<Tabs defaultValue="account" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-1">
|
||||
<TabsTrigger value="account">Account Details</TabsTrigger>
|
||||
<TabsTrigger value="account">{t('accounts.accountDetails')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="account">
|
||||
@@ -56,44 +58,44 @@ export function AccountDetailDrawer({ open, onOpenChange, currentRow }: Props) {
|
||||
<CardContent className="mt-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground">ID:</span>
|
||||
<span className="text-muted-foreground">{t('accounts.id')}:</span>
|
||||
<span>{currentRow.id}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground">Email:</span>
|
||||
<span className="text-muted-foreground">{t('accounts.email')}:</span>
|
||||
<span>{currentRow.email}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground">Name:</span>
|
||||
<span>{currentRow.name ?? "n/a"}</span>
|
||||
<span className="text-muted-foreground">{t('accounts.name')}:</span>
|
||||
<span>{currentRow.name ?? t('accounts.notAvailable')}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground">Enabled:</span>
|
||||
<span className="text-muted-foreground">{t('accounts.enabled')}:</span>
|
||||
<Checkbox checked={currentRow.enabled} disabled />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground">Incremental Sync Interval:</span>
|
||||
<span>every {currentRow.sync_interval_min} minutes</span>
|
||||
<span className="text-muted-foreground">{t('accounts.incrementalSyncInterval')}:</span>
|
||||
<span>{t('accounts.everyMinutes', { minutes: currentRow.sync_interval_min })}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-muted-foreground">Capabilities:</span>
|
||||
<span className="text-muted-foreground">{t('accounts.capabilities')}:</span>
|
||||
<code className="rounded-md bg-muted/50 px-2 py-1 text-sm border overflow-x-auto inline-block">
|
||||
{currentRow.capabilities ? currentRow.capabilities.join(", ") : "n/a"}
|
||||
{currentRow.capabilities ? currentRow.capabilities.join(", ") : t('accounts.notAvailable')}
|
||||
</code>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground">Date Selection:</span>
|
||||
<span className="text-muted-foreground">{t('accounts.dateSelection')}:</span>
|
||||
<span>
|
||||
{currentRow.date_since?.fixed
|
||||
? currentRow.date_since.fixed
|
||||
? t('accounts.since') + ' ' + currentRow.date_since.fixed
|
||||
: currentRow.date_since?.relative
|
||||
? `recent ${currentRow.date_since.relative.value} ${currentRow.date_since.relative.unit}`
|
||||
: "n/a"}
|
||||
? t('accounts.recent') + ' ' + currentRow.date_since.relative.value + ' ' + currentRow.date_since.relative.unit
|
||||
: t('accounts.notAvailable')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground">Folder Limit:</span>
|
||||
<span>{currentRow.folder_limit ? currentRow.folder_limit : "n/a"}</span>
|
||||
<span className="text-muted-foreground">{t('accounts.folderLimit')}:</span>
|
||||
<span>{currentRow.folder_limit ? currentRow.folder_limit : t('accounts.notAvailable')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -102,24 +104,28 @@ export function AccountDetailDrawer({ open, onOpenChange, currentRow }: Props) {
|
||||
{/* Server Configuration Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Server Configuration (IMAP)</CardTitle>
|
||||
<CardTitle>{t('accounts.serverConfiguration')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground">Host:</span>
|
||||
<span className="text-muted-foreground">{t('accounts.host')}:</span>
|
||||
<span>{currentRow.imap?.host}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground">Port:</span>
|
||||
<span className="text-muted-foreground">{t('accounts.port')}:</span>
|
||||
<span>{currentRow.imap?.port}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground">Encryption:</span>
|
||||
<span className="text-muted-foreground">{t('accounts.encryption')}:</span>
|
||||
<span>{currentRow.imap?.encryption}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground">Auth:</span>
|
||||
<span className="text-muted-foreground">{t('accounts.useDangerous')}:</span>
|
||||
<span>{`${currentRow.use_dangerous}`}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground">{t('accounts.auth')}:</span>
|
||||
{currentRow.imap?.auth.auth_type === "OAuth2" ? (
|
||||
<Badge variant="outline" className="bg-blue-100 text-blue-800">
|
||||
OAuth2
|
||||
@@ -131,7 +137,7 @@ export function AccountDetailDrawer({ open, onOpenChange, currentRow }: Props) {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground">Use Proxy:</span>
|
||||
<span className="text-muted-foreground">{t('accounts.useProxyField')}:</span>
|
||||
<span>{currentRow.imap?.use_proxy ? "true" : "false"}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -141,13 +147,13 @@ export function AccountDetailDrawer({ open, onOpenChange, currentRow }: Props) {
|
||||
{/* Sync Folders Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sync Folders</CardTitle>
|
||||
<CardTitle>{t('accounts.syncFoldersTitle')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{currentRow.sync_folders?.length ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm mt-2 text-muted-foreground">
|
||||
{currentRow.sync_folders.length} folder(s) configured for sync
|
||||
{t('accounts.foldersConfiguredForSync', { count: currentRow.sync_folders.length })}
|
||||
</div>
|
||||
<ScrollArea className="h-[300px] rounded-md border">
|
||||
<div className="p-2">
|
||||
|
||||
@@ -31,11 +31,11 @@ import Step1 from './step1';
|
||||
import Step2 from './step2';
|
||||
import Step3 from './step3';
|
||||
import Step4 from './step4';
|
||||
import CompleteStep from './complete-step';
|
||||
import { create_account, autoconfig, update_account } from '@/api/account/api';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { ToastAction } from '@/components/ui/toast';
|
||||
import { AxiosError } from 'axios';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const encryptionSchema = z.union([
|
||||
z.literal('Ssl'),
|
||||
@@ -48,48 +48,43 @@ const authTypeSchema = z.union([
|
||||
z.literal('OAuth2'),
|
||||
]);
|
||||
|
||||
const authConfigSchema = (isEdit: boolean) =>
|
||||
const getAuthConfigSchema = (isEdit: boolean, t: (key: string) => string) =>
|
||||
z.object({
|
||||
auth_type: authTypeSchema,
|
||||
password: z.string().optional(), // Always optional at base level
|
||||
password: z.string().optional(),
|
||||
}).refine(
|
||||
(data) => {
|
||||
// Only validate password when:
|
||||
// 1. Auth type is Password
|
||||
// 2. In create mode (not edit)
|
||||
if (data.auth_type === 'Password' && !isEdit) {
|
||||
return !!data.password?.trim();
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: 'Password is required when auth method is Password',
|
||||
message: t('validation.passwordRequired'),
|
||||
path: ['password'],
|
||||
}
|
||||
);
|
||||
|
||||
const imapConfigSchema = (isEdit: boolean) =>
|
||||
const getImapConfigSchema = (isEdit: boolean, t: (key: string) => string) =>
|
||||
z.object({
|
||||
host: z.string({ required_error: 'IMAP host is required' }).min(1, { message: 'IMAP host cannot be empty' }),
|
||||
port: z.number().int().min(0, { message: 'IMAP port must be a positive integer' }).max(65535, { message: 'IMAP port must be less than 65536' }),
|
||||
host: z.string({ required_error: t('validation.imapHostRequired') }).min(1, { message: t('validation.imapHostCannotBeEmpty') }),
|
||||
port: z.number().int().min(0, { message: t('validation.imapPortMustBePositive') }).max(65535, { message: t('validation.imapPortMustBeLessThan65536') }),
|
||||
encryption: encryptionSchema,
|
||||
auth: authConfigSchema(isEdit),
|
||||
auth: getAuthConfigSchema(isEdit, t),
|
||||
use_proxy: z.number().optional(),
|
||||
});
|
||||
|
||||
|
||||
const relativeDateSchema = z.object({
|
||||
unit: z.enum(["Days", "Months", "Years"], { message: "Please select a unit" }),
|
||||
value: z.number({ message: 'Please enter a value' }).int().min(1, "Must be at least 1"),
|
||||
const getRelativeDateSchema = (t: (key: string) => string) => z.object({
|
||||
unit: z.enum(["Days", "Months", "Years"], { message: t('accounts.selectUnit') }),
|
||||
value: z.number({ message: t('accounts.enterValue') }).int().min(1, t('accounts.mustBeAtLeast1')),
|
||||
});
|
||||
|
||||
const dateSelectionSchema = z.union([
|
||||
z.object({ fixed: z.string({ message: "Please select a date" }) },),
|
||||
z.object({ relative: relativeDateSchema }),
|
||||
const getDateSelectionSchema = (t: (key: string) => string) => z.union([
|
||||
z.object({ fixed: z.string({ message: t('accounts.selectDate') }) },),
|
||||
z.object({ relative: getRelativeDateSchema(t) }),
|
||||
z.undefined(),
|
||||
]);
|
||||
|
||||
// Define static Account type to avoid z.infer issue with dynamic schema
|
||||
export type Account = {
|
||||
name?: string;
|
||||
email: string;
|
||||
@@ -104,6 +99,7 @@ export type Account = {
|
||||
use_proxy?: number;
|
||||
};
|
||||
enabled: boolean;
|
||||
use_dangerous: boolean;
|
||||
date_since?: {
|
||||
fixed?: string;
|
||||
relative?: {
|
||||
@@ -115,19 +111,20 @@ export type Account = {
|
||||
sync_interval_min: number;
|
||||
};
|
||||
|
||||
const accountSchema = (isEdit: boolean) =>
|
||||
const getAccountSchema = (isEdit: boolean, t: (key: string) => string) =>
|
||||
z.object({
|
||||
name: z.string().optional(),
|
||||
email: z.string({ required_error: 'Email is required' }).email({ message: 'Invalid email address' }),
|
||||
imap: imapConfigSchema(isEdit),
|
||||
email: z.string({ required_error: t('validation.emailRequired') }).email({ message: t('validation.invalidEmail') }),
|
||||
imap: getImapConfigSchema(isEdit, t),
|
||||
enabled: z.boolean(),
|
||||
date_since: dateSelectionSchema.optional(),
|
||||
use_dangerous: z.boolean(),
|
||||
date_since: getDateSelectionSchema(t).optional(),
|
||||
folder_limit: z
|
||||
.number({ invalid_type_error: 'Folder limit must be a number' })
|
||||
.number({ invalid_type_error: t('validation.folderLimitMustBeNumber') })
|
||||
.int()
|
||||
.min(100, { message: 'Folder limit must be at least 100' })
|
||||
.min(100, { message: t('validation.folderLimitMustBeAtLeast100') })
|
||||
.optional(),
|
||||
sync_interval_min: z.number({ invalid_type_error: 'Incremental sync interval must be a number' }).int().min(10, { message: 'Incremental sync interval must be at least 10 minutes' }),
|
||||
sync_interval_min: z.number({ invalid_type_error: t('validation.incrementalSyncMustBeNumber') }).int().min(10, { message: t('validation.incrementalSyncMustBeAtLeast10') }),
|
||||
});
|
||||
|
||||
type Step = {
|
||||
@@ -137,28 +134,17 @@ type Step = {
|
||||
};
|
||||
|
||||
export type Steps = [
|
||||
{ id: "complete"; name: "Complete"; fields: [] },
|
||||
...Step[]
|
||||
];
|
||||
|
||||
const steps: Steps = [
|
||||
{ id: "complete", name: "Complete", fields: [] },
|
||||
{
|
||||
id: "step-1",
|
||||
name: "Email Address",
|
||||
fields: ["email"],
|
||||
},
|
||||
{
|
||||
id: "step-2",
|
||||
name: "IMAP",
|
||||
fields: ["imap"],
|
||||
},
|
||||
{ id: "step-3", name: "Sync Preferences", fields: ["enabled", "date_since", "folder_limit", "sync_interval_min"] },
|
||||
{ id: "step-4", name: "Summary", fields: [] },
|
||||
const getSteps = (t: (key: string) => string): Steps => [
|
||||
{ id: "step-1", name: t('accounts.steps.emailAddress'), fields: ["email"] },
|
||||
{ id: "step-2", name: t('accounts.steps.imap'), fields: ["imap", "use_dangerous", "name"] },
|
||||
{ id: "step-3", name: t('accounts.steps.syncPreferences'), fields: ["enabled", "date_since", "folder_limit", "sync_interval_min"] },
|
||||
{ id: "step-4", name: t('accounts.steps.summary'), fields: [] },
|
||||
];
|
||||
|
||||
const LAST_STEP = steps.length - 1;
|
||||
const COMPLETE_STEP = 0;
|
||||
const LAST_STEP = 4;
|
||||
|
||||
interface Props {
|
||||
currentRow?: AccountModel;
|
||||
@@ -180,6 +166,7 @@ const defaultValues: Account = {
|
||||
use_proxy: undefined
|
||||
},
|
||||
enabled: true,
|
||||
use_dangerous: false,
|
||||
date_since: undefined,
|
||||
folder_limit: undefined,
|
||||
sync_interval_min: 10,
|
||||
@@ -195,35 +182,36 @@ const emptyImap: ImapConfig = {
|
||||
|
||||
const mapCurrentRowToFormValues = (currentRow: AccountModel): Account => {
|
||||
const imap = { ...(currentRow.imap ?? emptyImap) };
|
||||
// Handle password and use_proxy conversion
|
||||
imap.auth = { ...imap.auth, password: undefined };
|
||||
if (imap.use_proxy === null) {
|
||||
imap.use_proxy = undefined;
|
||||
}
|
||||
|
||||
let account = {
|
||||
name: currentRow.name === null ? undefined : currentRow.name,
|
||||
return {
|
||||
name: currentRow.name ?? undefined,
|
||||
email: currentRow.email,
|
||||
imap,
|
||||
enabled: currentRow.enabled,
|
||||
use_dangerous: currentRow.use_dangerous,
|
||||
date_since: currentRow.date_since ?? undefined,
|
||||
folder_limit: currentRow.folder_limit ?? undefined,
|
||||
sync_interval_min: currentRow.sync_interval_min ?? 10,
|
||||
};
|
||||
|
||||
return account;
|
||||
};
|
||||
|
||||
export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const steps = getSteps(t);
|
||||
const isEdit = !!currentRow;
|
||||
const [currentStep, setCurrentStep] = React.useState(1);
|
||||
const { toast } = useToast();
|
||||
const [autoConfigLoading, setAutoConfigLoading] = React.useState(false);
|
||||
|
||||
const accountSchema = getAccountSchema(isEdit, t);
|
||||
const form = useForm<Account>({
|
||||
mode: "all",
|
||||
defaultValues: isEdit ? mapCurrentRowToFormValues(currentRow) : defaultValues,
|
||||
resolver: zodResolver(accountSchema(isEdit)),
|
||||
resolver: zodResolver(accountSchema),
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
@@ -242,9 +230,9 @@ export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
|
||||
function handleSuccess() {
|
||||
toast({
|
||||
title: `Account ${isEdit ? 'Updated' : 'Created'}`,
|
||||
description: `Your account has been successfully ${isEdit ? 'updated' : 'created'}.`,
|
||||
action: <ToastAction altText="Close">Close</ToastAction>,
|
||||
title: isEdit ? t('accounts.accountUpdated') : t('accounts.accountCreated'),
|
||||
description: isEdit ? t('accounts.accountUpdatedDesc') : t('accounts.accountCreatedDesc'),
|
||||
action: <ToastAction altText={t('common.close')}>{t('common.close')}</ToastAction>,
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['account-list'] });
|
||||
@@ -256,13 +244,13 @@ export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
const errorMessage =
|
||||
(error.response?.data as { message?: string })?.message ||
|
||||
error.message ||
|
||||
`${isEdit ? 'Update' : 'Creation'} failed, please try again later`;
|
||||
(isEdit ? t('accounts.updateFailed') : t('accounts.creationFailed'));
|
||||
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: `Account ${isEdit ? 'Update' : 'Creation'} Failed`,
|
||||
title: isEdit ? t('accounts.accountUpdateFailed') : t('accounts.accountCreationFailed'),
|
||||
description: errorMessage as string,
|
||||
action: <ToastAction altText="Try again">Try again</ToastAction>,
|
||||
action: <ToastAction altText={t('common.tryAgain')}>{t('common.tryAgain')}</ToastAction>,
|
||||
});
|
||||
console.error(error);
|
||||
}
|
||||
@@ -282,6 +270,7 @@ export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
},
|
||||
},
|
||||
enabled: data.enabled,
|
||||
use_dangerous: data.use_dangerous,
|
||||
date_since: data.date_since,
|
||||
folder_limit: data.folder_limit,
|
||||
sync_interval_min: data.sync_interval_min,
|
||||
@@ -289,11 +278,7 @@ export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
if (isEdit) {
|
||||
updateMutation.mutate(commonData);
|
||||
} else {
|
||||
const payload = {
|
||||
...commonData,
|
||||
account_type: "IMAP",
|
||||
};
|
||||
createMutation.mutate(payload);
|
||||
createMutation.mutate({ ...commonData, account_type: "IMAP" });
|
||||
}
|
||||
},
|
||||
[isEdit, updateMutation, createMutation]
|
||||
@@ -302,30 +287,21 @@ export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
const handleNav = async (index: number) => {
|
||||
let isValid = true;
|
||||
let failedStep = currentStep;
|
||||
for (let i = currentStep; i < index && isValid; i++) {
|
||||
for (let i = currentStep - 1; i < index - 1 && isValid; i++) {
|
||||
isValid = await form.trigger(steps[i].fields);
|
||||
if (!isValid) {
|
||||
failedStep = i;
|
||||
}
|
||||
}
|
||||
if (isValid) {
|
||||
setCurrentStep(index);
|
||||
} else {
|
||||
setCurrentStep(failedStep);
|
||||
if (!isValid) failedStep = i;
|
||||
}
|
||||
if (isValid) setCurrentStep(index);
|
||||
else setCurrentStep(failedStep);
|
||||
};
|
||||
|
||||
async function handleContinue() {
|
||||
const isValid = await form.trigger(steps[currentStep].fields);
|
||||
if (!isValid) {
|
||||
return;
|
||||
}
|
||||
const isValid = await form.trigger(steps[currentStep - 1].fields);
|
||||
if (!isValid) return;
|
||||
|
||||
if (currentStep === 1) {
|
||||
let allValues = form.getValues();
|
||||
if (
|
||||
allValues.imap.host.trim() !== "" &&
|
||||
allValues.imap.port > 0
|
||||
) {
|
||||
if (allValues.imap.host.trim() !== "" && allValues.imap.port > 0) {
|
||||
handleNav(currentStep + 1);
|
||||
return;
|
||||
}
|
||||
@@ -338,15 +314,12 @@ export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
form.setValue('imap.host', result.imap.host);
|
||||
form.setValue('imap.port', result.imap.port);
|
||||
form.setValue('imap.encryption', result.imap.encryption);
|
||||
if (result.oauth2) {
|
||||
form.setValue('imap.auth.auth_type', 'OAuth2');
|
||||
}
|
||||
if (result.oauth2) form.setValue('imap.auth.auth_type', 'OAuth2');
|
||||
}
|
||||
setAutoConfigLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Auto-configuration failed:', error);
|
||||
setAutoConfigLoading(false);
|
||||
}
|
||||
setAutoConfigLoading(false);
|
||||
handleNav(currentStep + 1);
|
||||
} else {
|
||||
handleNav(currentStep + 1);
|
||||
@@ -364,75 +337,58 @@ export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
>
|
||||
<DialogContent className='max-w-5xl'>
|
||||
<DialogHeader className='text-left mb-4'>
|
||||
<DialogTitle>{isEdit ? "Update Account" : "Add Account"}</DialogTitle>
|
||||
<DialogTitle>{isEdit ? t('accounts.updateAccount') : t('accounts.addAccount')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit ? 'Update the email account here. ' : 'Add new email account here. '}
|
||||
Click save when you're done.
|
||||
{isEdit ? t('accounts.updateAccount') : t('accounts.addAccount')}
|
||||
{t('accounts.clickSaveWhenDone')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="h-[38rem] w-full pr-4 -mr-4 py-1">
|
||||
<>
|
||||
{/* Mobile Steps (hidden on desktop) */}
|
||||
{currentStep !== COMPLETE_STEP && (
|
||||
<div className="flex my-5 space-x-4 md:hidden">
|
||||
{steps.map(
|
||||
(step, index) =>
|
||||
index !== COMPLETE_STEP && (
|
||||
<div className="z-20 my-3 ml-2 flex items-center" key={step.id}>
|
||||
<Button
|
||||
className={`size-9 rounded-full border font-bold ${`step-${currentStep}` === step.id ? "" : "bg-gray-200 text-black"
|
||||
}`}
|
||||
disabled={`step-${currentStep}` === step.id || currentStep === COMPLETE_STEP}
|
||||
onClick={() => handleNav(index)}
|
||||
>
|
||||
{index}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex my-5 space-x-4 md:hidden">
|
||||
{steps.map((step, index) => (
|
||||
<Button
|
||||
key={step.id}
|
||||
className={`size-9 rounded-full border font-bold ${currentStep === index + 1 ? "bg-primary text-white" : "bg-gray-200 text-black"
|
||||
}`}
|
||||
disabled={currentStep === index + 1}
|
||||
onClick={() => setCurrentStep(index + 1)}
|
||||
>
|
||||
{index + 1}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="w-full max-w-full p-4">
|
||||
<div className="flex md:h-min rounded-xl md:rounded-2xl p-4">
|
||||
{currentStep !== COMPLETE_STEP && (
|
||||
<div className="hidden md:block w-[260px] flex-shrink-0 rounded-xl p-5 pt-7 fixed">
|
||||
{steps.map(
|
||||
(step, index) =>
|
||||
index !== COMPLETE_STEP && (
|
||||
<div className="my-3 ml-2 flex items-center" key={step.id}>
|
||||
<Button
|
||||
className={`size-8 border rounded-full text-sm font-bold ${`step-${currentStep}` === step.id
|
||||
? "bg-primary text-white"
|
||||
: "bg-gray-200 text-black"
|
||||
}`}
|
||||
disabled={`step-${currentStep}` === step.id || currentStep === COMPLETE_STEP}
|
||||
onClick={() => handleNav(index)}
|
||||
>
|
||||
{index}
|
||||
</Button>
|
||||
<div className="flex flex-col items-baseline uppercase ml-5">
|
||||
<span className="text-xs">Step {index}</span>
|
||||
<span className="font-bold text-sm tracking-wider">{step.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="hidden md:block w-[260px] flex-shrink-0 rounded-xl p-5 pt-7 fixed">
|
||||
{steps.map((step, index) => (
|
||||
<div className="my-3 ml-2 flex items-center" key={step.id}>
|
||||
<Button
|
||||
className={`size-8 border rounded-full text-sm font-bold ${currentStep === index + 1 ? "bg-primary text-white" : "bg-gray-200 text-black"
|
||||
}`}
|
||||
disabled={currentStep === index + 1}
|
||||
onClick={() => setCurrentStep(index + 1)}
|
||||
>
|
||||
{index + 1}
|
||||
</Button>
|
||||
<div className="flex flex-col items-baseline uppercase ml-5">
|
||||
<span className="text-xs">{t('accounts.step', { index: index + 1 })}</span>
|
||||
<span className="font-bold text-sm tracking-wider">{step.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="account-register-form"
|
||||
className={`flex-grow flex flex-col px-4 md:px-8 lg:px-12 ${currentStep !== COMPLETE_STEP ? 'ml-[240px]' : ''
|
||||
}`}
|
||||
className="flex-grow flex flex-col px-4 md:px-8 lg:px-12 ml-[240px]"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
{currentStep === 1 && <Step1 isEdit={isEdit} />}
|
||||
{currentStep === 2 && <Step2 isEdit={isEdit} />}
|
||||
{currentStep === 3 && <Step3 />}
|
||||
{currentStep === 4 && <Step4 />}
|
||||
{currentStep === COMPLETE_STEP && <CompleteStep />}
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
@@ -440,60 +396,35 @@ export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
</>
|
||||
</ScrollArea>
|
||||
<DialogFooter className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
disabled={currentStep === 1 || currentStep === COMPLETE_STEP}
|
||||
type="button"
|
||||
className="flex-grow sm:flex-grow-0 shadow-none text-nowrap text-sm disabled:invisible"
|
||||
onClick={() => {
|
||||
handleNav(currentStep - 1);
|
||||
}}
|
||||
>
|
||||
Go Back
|
||||
</Button>
|
||||
<Button
|
||||
disabled={currentStep === LAST_STEP || currentStep === COMPLETE_STEP}
|
||||
type="button"
|
||||
className="flex-grow sm:flex-grow-0 rounded-md md:rounded-lg px-6 disabled:hidden text-sm"
|
||||
onClick={handleContinue}
|
||||
>
|
||||
{autoConfigLoading ? (
|
||||
<>
|
||||
<svg
|
||||
className="animate-spin h-5 w-5 mr-3 text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<span>Auto-configuring...</span>
|
||||
</>
|
||||
) : (
|
||||
"Continue"
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={currentStep !== LAST_STEP}
|
||||
type="submit"
|
||||
form="account-register-form"
|
||||
className="flex-grow sm:flex-grow-0 rounded-md text-sm px-7 disabled:hidden md:rounded-lg"
|
||||
>
|
||||
{isEdit ? "Save changes" : "Submit"}
|
||||
</Button>
|
||||
{currentStep > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
className="flex-grow sm:flex-grow-0 shadow-none text-nowrap text-sm"
|
||||
onClick={() => setCurrentStep(currentStep - 1)}
|
||||
>
|
||||
{t('accounts.goBack')}
|
||||
</Button>
|
||||
)}
|
||||
{currentStep < LAST_STEP && (
|
||||
<Button
|
||||
type="button"
|
||||
className="flex-grow sm:flex-grow-0 rounded-md md:rounded-lg px-6 text-sm"
|
||||
onClick={handleContinue}
|
||||
>
|
||||
{autoConfigLoading ? t('accounts.autoConfiguring') : t('accounts.continue')}
|
||||
</Button>
|
||||
)}
|
||||
{currentStep === LAST_STEP && (
|
||||
<Button
|
||||
type="submit"
|
||||
form="account-register-form"
|
||||
className="flex-grow sm:flex-grow-0 rounded-md text-sm px-7 md:rounded-lg"
|
||||
>
|
||||
{isEdit ? t('accounts.saveChanges') : t('accounts.submit')}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,12 +27,16 @@ import { format } from 'date-fns'
|
||||
import { OAuth2Action } from './oauth2-action'
|
||||
import { RunningStateCellAction } from './running-state-action'
|
||||
import { EnableAction } from './enable-action'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export const columns: ColumnDef<AccountModel>[] = [
|
||||
export function useColumns(): ColumnDef<AccountModel>[] {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return [
|
||||
{
|
||||
accessorKey: "id",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title='ID' />
|
||||
<DataTableColumnHeader column={column} title={t('accounts.id')} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return <LongText>{row.original.id}</LongText>
|
||||
@@ -43,7 +47,7 @@ export const columns: ColumnDef<AccountModel>[] = [
|
||||
{
|
||||
accessorKey: "email",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title='Email' />
|
||||
<DataTableColumnHeader column={column} title={t('accounts.email')} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return <LongText>{row.original.email}</LongText>
|
||||
@@ -53,7 +57,7 @@ export const columns: ColumnDef<AccountModel>[] = [
|
||||
{
|
||||
accessorKey: "enabled",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader className="ml-4" column={column} title='Enabled' />
|
||||
<DataTableColumnHeader className="ml-4" column={column} title={t('accounts.enabled')} />
|
||||
),
|
||||
cell: EnableAction,
|
||||
meta: { className: 'w-8 text-center' },
|
||||
@@ -62,7 +66,7 @@ export const columns: ColumnDef<AccountModel>[] = [
|
||||
{
|
||||
id: 'auth_type',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title='Auth' />
|
||||
<DataTableColumnHeader column={column} title={t('accounts.auth')} />
|
||||
),
|
||||
cell: OAuth2Action,
|
||||
meta: { className: 'w-8' },
|
||||
@@ -72,7 +76,7 @@ export const columns: ColumnDef<AccountModel>[] = [
|
||||
{
|
||||
id: 'account_type',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title='Type' />
|
||||
<DataTableColumnHeader column={column} title={t('accounts.type')} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return <LongText>{row.original.account_type}</LongText>
|
||||
@@ -84,7 +88,7 @@ export const columns: ColumnDef<AccountModel>[] = [
|
||||
{
|
||||
accessorKey: "sync_interval_sec",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title='Inc Sync' />
|
||||
<DataTableColumnHeader column={column} title={t('accounts.incSync')} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
let account_type = row.original.account_type;
|
||||
@@ -99,7 +103,7 @@ export const columns: ColumnDef<AccountModel>[] = [
|
||||
{
|
||||
id: 'running_state',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title='State' />
|
||||
<DataTableColumnHeader column={column} title={t('accounts.state')} />
|
||||
),
|
||||
cell: RunningStateCellAction,
|
||||
meta: { className: 'w-36' },
|
||||
@@ -108,7 +112,7 @@ export const columns: ColumnDef<AccountModel>[] = [
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title='Created At' />
|
||||
<DataTableColumnHeader column={column} title={t('accounts.createdAt')} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const created_at = row.original.created_at;
|
||||
@@ -121,7 +125,7 @@ export const columns: ColumnDef<AccountModel>[] = [
|
||||
{
|
||||
accessorKey: 'updated_at',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title='Updated At' />
|
||||
<DataTableColumnHeader column={column} title={t('accounts.updatedAt')} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const updated_at = row.original.updated_at;
|
||||
@@ -135,4 +139,5 @@ export const columns: ColumnDef<AccountModel>[] = [
|
||||
id: 'actions',
|
||||
cell: DataTableRowActions,
|
||||
},
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
//
|
||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import { CheckCircle } from "lucide-react"; // Using Lucide icon library
|
||||
import { Button } from "@/components/ui/button"; // Using custom button component
|
||||
|
||||
export default function CompleteStep() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-[25rem] p-6">
|
||||
{/* Success Icon */}
|
||||
<div className="mb-6 text-green-500">
|
||||
<CheckCircle className="w-16 h-16" />
|
||||
</div>
|
||||
|
||||
{/* Success Message */}
|
||||
<h1 className="text-3xl font-bold mb-4">Registration Successful!</h1>
|
||||
<p className="text-lg text-gray-600 mb-8 text-center">
|
||||
Your email account has been successfully added.
|
||||
</p>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="mt-8 flex gap-4">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
// Redirect to home page
|
||||
window.location.href = "/";
|
||||
}}
|
||||
>
|
||||
Authorize via OAuth2
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
// View details
|
||||
window.location.href = "/accounts";
|
||||
}}
|
||||
>
|
||||
View Details
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface DataTableColumnHeaderProps<TData, TValue>
|
||||
extends React.HTMLAttributes<HTMLDivElement> {
|
||||
@@ -48,7 +49,7 @@ export function DataTableColumnHeader<TData, TValue>({
|
||||
if (!column.getCanSort()) {
|
||||
return <div className={cn(className)}>{title}</div>
|
||||
}
|
||||
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className={cn('flex items-center space-x-2', className)}>
|
||||
<DropdownMenu>
|
||||
@@ -71,16 +72,16 @@ export function DataTableColumnHeader<TData, TValue>({
|
||||
<DropdownMenuContent align='start'>
|
||||
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
|
||||
<ArrowUpIcon className='mr-2 h-3.5 w-3.5 text-muted-foreground/70' />
|
||||
Asc
|
||||
{t('table.asc')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
|
||||
<ArrowDownIcon className='mr-2 h-3.5 w-3.5 text-muted-foreground/70' />
|
||||
Desc
|
||||
{t('table.desc')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
|
||||
<EyeNoneIcon className='mr-2 h-3.5 w-3.5 text-muted-foreground/70' />
|
||||
Hide
|
||||
{t('table.hide')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -32,12 +32,14 @@ import {
|
||||
import { useAccountContext } from '../context'
|
||||
import { AccountModel } from '../data/schema'
|
||||
import { Mailbox, MessageSquareMore } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface DataTableRowActionsProps {
|
||||
row: Row<AccountModel>
|
||||
}
|
||||
|
||||
export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
const { t } = useTranslation()
|
||||
const { setOpen, setCurrentRow } = useAccountContext()
|
||||
|
||||
const account_type = row.original.account_type;
|
||||
@@ -51,7 +53,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
className='flex h-8 w-8 p-0 data-[state=open]:bg-muted'
|
||||
>
|
||||
<DotsHorizontalIcon className='h-4 w-4' />
|
||||
<span className='sr-only'>Open menu</span>
|
||||
<span className='sr-only'>{t('accounts.openMenu')}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-[160px]'>
|
||||
@@ -66,7 +68,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
}
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
{t('accounts.edit')}
|
||||
<DropdownMenuShortcut>
|
||||
<IconEdit size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
@@ -77,7 +79,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
setOpen('sync-folders')
|
||||
}}
|
||||
>
|
||||
Sync Folders
|
||||
{t('accounts.syncFolders')}
|
||||
<DropdownMenuShortcut>
|
||||
<Mailbox size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
@@ -88,7 +90,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
setOpen('detail')
|
||||
}}
|
||||
>
|
||||
Detail
|
||||
{t('accounts.detail')}
|
||||
<DropdownMenuShortcut>
|
||||
<MessageSquareMore size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
@@ -101,7 +103,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
}}
|
||||
className='!text-red-500'
|
||||
>
|
||||
Delete
|
||||
{t('accounts.delete')}
|
||||
<DropdownMenuShortcut>
|
||||
<IconTrash size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import { Table } from '@tanstack/react-table'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface DataTableToolbarProps<TData> {
|
||||
table: Table<TData>
|
||||
@@ -27,11 +28,12 @@ interface DataTableToolbarProps<TData> {
|
||||
export function DataTableToolbar<TData>({
|
||||
table,
|
||||
}: DataTableToolbarProps<TData>) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex flex-1 flex-col-reverse items-start gap-y-2 sm:flex-row sm:items-center sm:space-x-2'>
|
||||
<Input
|
||||
placeholder='Filter account...'
|
||||
placeholder={t('settings.filterAccount')}
|
||||
value={(table.getState().globalFilter as string) ?? ''}
|
||||
onChange={(event) => {
|
||||
table.setGlobalFilter(event.target.value);
|
||||
|
||||
@@ -29,6 +29,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { ToastAction } from '@/components/ui/toast'
|
||||
import { AxiosError } from 'axios'
|
||||
import { remove_account } from '@/api/account/api'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
@@ -37,14 +38,15 @@ interface Props {
|
||||
}
|
||||
|
||||
export function AccountDeleteDialog({ open, onOpenChange, currentRow }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const [value, setValue] = useState('')
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
function handleSuccess() {
|
||||
toast({
|
||||
title: 'Account Deleted',
|
||||
description: 'Your account has been successfully deleted.',
|
||||
action: <ToastAction altText="Close">Close</ToastAction>,
|
||||
title: t('dialogs.accountDeleted'),
|
||||
description: t('dialogs.accountDeletedDesc'),
|
||||
action: <ToastAction altText={t('common.close')}>{t('common.close')}</ToastAction>,
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['account-list'] });
|
||||
@@ -54,13 +56,13 @@ export function AccountDeleteDialog({ open, onOpenChange, currentRow }: Props) {
|
||||
function handleError(error: AxiosError) {
|
||||
const errorMessage = error.response?.data ||
|
||||
error.message ||
|
||||
`Delete failed, please try again later`;
|
||||
t('dialogs.deleteFailed');
|
||||
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: `Account delete Failed`,
|
||||
title: t('dialogs.accountDeleteFailed'),
|
||||
description: errorMessage as string,
|
||||
action: <ToastAction altText="Try again">Try again</ToastAction>,
|
||||
action: <ToastAction altText={t('common.tryAgain')}>{t('common.tryAgain')}</ToastAction>,
|
||||
});
|
||||
console.error(error);
|
||||
}
|
||||
@@ -89,29 +91,28 @@ export function AccountDeleteDialog({ open, onOpenChange, currentRow }: Props) {
|
||||
className='mr-1 inline-block stroke-destructive'
|
||||
size={18}
|
||||
/>{' '}
|
||||
Delete Account Permanently
|
||||
{t('dialogs.deleteAccountPermanently')}
|
||||
</span>
|
||||
}
|
||||
desc={
|
||||
<div className='space-y-4'>
|
||||
<p className='mb-2'>
|
||||
You are deleting <span className='font-bold'>{currentRow.email}</span>.
|
||||
This will permanently remove:
|
||||
{t('dialogs.youAreDeleting', { email: currentRow.email })}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 space-y-1 text-sm text-muted-foreground">
|
||||
<li>Account credentials and settings</li>
|
||||
<li>Local cached metadata and sync status</li>
|
||||
<li>IMAP synchronization data</li>
|
||||
<li>OAuth tokens and API credentials</li>
|
||||
<li>{t('dialogs.accountCredentials')}</li>
|
||||
<li>{t('dialogs.localCachedMetadata')}</li>
|
||||
<li>{t('dialogs.imapSyncData')}</li>
|
||||
<li>{t('dialogs.oauthTokens')}</li>
|
||||
</ul>
|
||||
|
||||
<div className="pt-2">
|
||||
<Label>
|
||||
Type the account email to confirm:
|
||||
{t('dialogs.typeEmailToConfirm')}
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder={`Type "${currentRow.email}" to confirm`}
|
||||
placeholder={t('dialogs.typeToConfirm', { email: currentRow.email })}
|
||||
className="mt-2"
|
||||
/>
|
||||
</Label>
|
||||
@@ -119,15 +120,15 @@ export function AccountDeleteDialog({ open, onOpenChange, currentRow }: Props) {
|
||||
|
||||
<Alert variant='destructive'>
|
||||
<IconAlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>This action cannot be undone!</AlertTitle>
|
||||
<AlertTitle>{t('dialogs.cannotBeUndone')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
All related resources will be permanently erased.
|
||||
{t('dialogs.allResourcesErased')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
}
|
||||
confirmText={
|
||||
deleteMutation.isPending ? 'Deleting...' : 'Permanently Delete Account'
|
||||
deleteMutation.isPending ? t('dialogs.deleting') : t('dialogs.permanentlyDeleteAccount')
|
||||
}
|
||||
isLoading={deleteMutation.isPending}
|
||||
destructive
|
||||
|
||||
@@ -27,12 +27,14 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { update_account } from '@/api/account/api'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
import { AxiosError } from 'axios'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface DataTableRowActionsProps {
|
||||
row: Row<AccountModel>
|
||||
}
|
||||
|
||||
export function EnableAction({ row }: DataTableRowActionsProps) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -42,9 +44,9 @@ export function EnableAction({ row }: DataTableRowActionsProps) {
|
||||
onSuccess: () => {
|
||||
setOpen(false);
|
||||
toast({
|
||||
title: 'Account Updated',
|
||||
description: `Account has been successfully ${row.original.enabled ? 'disabled' : 'enabled'}.`,
|
||||
action: <ToastAction altText="Close">Close</ToastAction>,
|
||||
title: t('accounts.accountUpdated'),
|
||||
description: t('accounts.accountHasBeenSuccessfully', { action: row.original.enabled ? t('accounts.disabled').toLowerCase() : t('accounts.enabled').toLowerCase() }),
|
||||
action: <ToastAction altText={t('common.close')}>{t('common.close')}</ToastAction>,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ['account-list'] })
|
||||
},
|
||||
@@ -56,9 +58,9 @@ export function EnableAction({ row }: DataTableRowActionsProps) {
|
||||
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: 'Update Failed',
|
||||
title: t('accounts.accountUpdateFailed'),
|
||||
description: errorMessage,
|
||||
action: <ToastAction altText="Try again">Try again</ToastAction>,
|
||||
action: <ToastAction altText={t('common.tryAgain')}>{t('common.tryAgain')}</ToastAction>,
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -77,13 +79,13 @@ export function EnableAction({ row }: DataTableRowActionsProps) {
|
||||
<ConfirmDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title={`${row.original.enabled ? 'Disable' : 'Enable'} Account`}
|
||||
title={row.original.enabled ? t('accounts.disableAccount') : t('accounts.enableAccount')}
|
||||
desc={
|
||||
`Are you sure you want to ${row.original.enabled ? 'disable' : 'enable'} this account?` +
|
||||
(row.original.enabled ? ' This will prevent the account from being used.' : '')
|
||||
t('accounts.areYouSureYouWantTo', { action: row.original.enabled ? t('accounts.disable').toLowerCase() : t('accounts.enable').toLowerCase() }) +
|
||||
(row.original.enabled ? ' ' + t('accounts.thisWillPreventTheAccountFromBeingUsed') : '')
|
||||
}
|
||||
destructive={row.original.enabled}
|
||||
confirmText={row.original.enabled ? 'Disable' : 'Enable'}
|
||||
confirmText={row.original.enabled ? t('accounts.disable') : t('accounts.enable')}
|
||||
isLoading={updateMutation.isPending}
|
||||
handleConfirm={handleConfirm}
|
||||
/>
|
||||
|
||||
@@ -16,50 +16,53 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import { ProgressMap } from "@/api/account/api";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface Props {
|
||||
progressMap: ProgressMap | undefined | null;
|
||||
progressMap: ProgressMap | undefined | null;
|
||||
}
|
||||
|
||||
export function FolderSyncProgress({ progressMap }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!progressMap || Object.keys(progressMap).length === 0) {
|
||||
return <div className="text-muted-foreground text-sm">No Data</div>;
|
||||
}
|
||||
if (!progressMap || Object.keys(progressMap).length === 0) {
|
||||
return <div className="text-muted-foreground text-sm">{t('accounts.folderSync.noData')}</div>;
|
||||
}
|
||||
|
||||
const folderNames = Object.keys(progressMap);
|
||||
const folderNames = Object.keys(progressMap);
|
||||
|
||||
if (folderNames.length === 0) {
|
||||
return <div className="text-muted-foreground text-sm">No folders to sync</div>;
|
||||
}
|
||||
if (folderNames.length === 0) {
|
||||
return <div className="text-muted-foreground text-sm">{t('accounts.folderSync.noFolders')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{folderNames.map((folder) => {
|
||||
const progress = progressMap[folder];
|
||||
const percentage =
|
||||
progress.total_batches > 0
|
||||
? Math.min((progress.current_batch / progress.total_batches) * 100, 100)
|
||||
: 0;
|
||||
const isComplete = progress.current_batch >= progress.total_batches;
|
||||
const textColor = isComplete ? "text-green-800" : "text-blue-800";
|
||||
const progressColor = isComplete ? "bg-gray-200 [&>div]:bg-green-800 [&>div]:rounded-full h-1.5" : "bg-gray-200 [&>div]:bg-blue-800 [&>div]:rounded-full h-1.5";
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{folderNames.map((folder) => {
|
||||
const progress = progressMap[folder];
|
||||
const percentage =
|
||||
progress.total_batches > 0
|
||||
? Math.min((progress.current_batch / progress.total_batches) * 100, 100)
|
||||
: 0;
|
||||
const isComplete = progress.current_batch >= progress.total_batches;
|
||||
const textColor = isComplete ? "text-green-800" : "text-blue-800";
|
||||
const progressColor = isComplete
|
||||
? "bg-gray-200 [&>div]:bg-green-800 [&>div]:rounded-full h-1.5"
|
||||
: "bg-gray-200 [&>div]:bg-blue-800 [&>div]:rounded-full h-1.5";
|
||||
|
||||
return (
|
||||
<div key={folder} className="space-y-1">
|
||||
<div className={`flex justify-between text-sm ${textColor}`}>
|
||||
<span className="truncate max-w-[70%]">{folder}</span>
|
||||
<span className="text-sm font-medium">
|
||||
{progress.current_batch}/{progress.total_batches} batches
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={percentage} className={progressColor} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div key={folder} className="space-y-1">
|
||||
<div className={`flex justify-between text-sm ${textColor}`}>
|
||||
<span className="truncate max-w-[70%]">{folder}</span>
|
||||
<span className="text-sm font-medium">
|
||||
{progress.current_batch}/{progress.total_batches} {t('accounts.folderSync.batches')}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={percentage} className={progressColor} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,12 +34,13 @@ import { Input } from '@/components/ui/input';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { AccountModel } from '../data/schema';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
|
||||
const accountSchema = () =>
|
||||
const accountSchema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
name: z.string().optional(),
|
||||
email: z.string({ required_error: 'Email is required' }).email({ message: 'Invalid email address' }),
|
||||
email: z.string({ required_error: t('validation.emailRequired') }).email({ message: t('validation.invalidEmail') }),
|
||||
enabled: z.boolean()
|
||||
});
|
||||
|
||||
@@ -77,13 +78,14 @@ const mapCurrentRowToFormValues = (currentRow: AccountModel): NoSyncAccount => {
|
||||
|
||||
|
||||
export function NoSyncAccountDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const isEdit = !!currentRow;
|
||||
const { toast } = useToast();
|
||||
|
||||
const form = useForm<NoSyncAccount>({
|
||||
mode: "all",
|
||||
defaultValues: isEdit ? mapCurrentRowToFormValues(currentRow) : defaultValues,
|
||||
resolver: zodResolver(accountSchema()),
|
||||
resolver: zodResolver(accountSchema(t)),
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
@@ -102,9 +104,9 @@ export function NoSyncAccountDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
|
||||
function handleSuccess() {
|
||||
toast({
|
||||
title: `Account ${isEdit ? 'Updated' : 'Created'}`,
|
||||
description: `Your account has been successfully ${isEdit ? 'updated' : 'created'}.`,
|
||||
action: <ToastAction altText="Close">Close</ToastAction>,
|
||||
title: isEdit ? t('accounts.accountUpdated') : t('accounts.accountCreated'),
|
||||
description: isEdit ? t('accounts.accountUpdatedDesc') : t('accounts.accountCreatedDesc'),
|
||||
action: <ToastAction altText={t('common.close')}>{t('common.close')}</ToastAction>,
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['account-list'] });
|
||||
@@ -116,13 +118,13 @@ export function NoSyncAccountDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
const errorMessage =
|
||||
(error.response?.data as { message?: string })?.message ||
|
||||
error.message ||
|
||||
`${isEdit ? 'Update' : 'Creation'} failed, please try again later`;
|
||||
(isEdit ? t('accounts.updateFailed') : t('accounts.creationFailed'));
|
||||
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: `Account ${isEdit ? 'Update' : 'Creation'} Failed`,
|
||||
title: isEdit ? t('accounts.accountUpdateFailed') : t('accounts.accountCreationFailed'),
|
||||
description: errorMessage as string,
|
||||
action: <ToastAction altText="Try again">Try again</ToastAction>,
|
||||
action: <ToastAction altText={t('common.tryAgain')}>{t('common.tryAgain')}</ToastAction>,
|
||||
});
|
||||
console.error(error);
|
||||
}
|
||||
@@ -132,7 +134,8 @@ export function NoSyncAccountDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
const commonData = {
|
||||
email: data.email,
|
||||
name: data.name,
|
||||
enabled: data.enabled
|
||||
enabled: data.enabled,
|
||||
use_dangerous: false
|
||||
};
|
||||
if (isEdit) {
|
||||
updateMutation.mutate(commonData);
|
||||
@@ -156,10 +159,10 @@ export function NoSyncAccountDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
>
|
||||
<DialogContent className='max-w-2xl'>
|
||||
<DialogHeader className='text-left mb-4'>
|
||||
<DialogTitle>{isEdit ? "Update Account" : "Add Account"}</DialogTitle>
|
||||
<DialogTitle>{isEdit ? t('accounts.updateAccount') : t('accounts.addAccount')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit ? 'Update the email account here. ' : 'Add new email account here. '}
|
||||
Click save when you're done.
|
||||
{isEdit ? t('accounts.updateTheEmailAccountHere') : t('accounts.addNewEmailAccountHere')}
|
||||
{t('accounts.clickSaveWhenDone')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ScrollArea className='h-[23rem] w-full pr-4 -mr-4 py-1'>
|
||||
@@ -175,40 +178,40 @@ export function NoSyncAccountDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center justify-between">
|
||||
Email Address:
|
||||
{t('accounts.emailAddress')}:
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g john.doe@gmail.com" {...field} />
|
||||
<Input placeholder={t('accounts.emailPlaceholder')} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
This account is used for identification purposes only and does not require syncing with an email server. It helps with importing email data.
|
||||
{t('accounts.thisAccountIsUsedForIdentificationPurposesOnly')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
{/* <FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center justify-between">
|
||||
Name:
|
||||
{t('accounts.name')}:
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g john.doe" {...field} />
|
||||
<Input placeholder={t('accounts.namePlaceholder')} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Optional</FormDescription>
|
||||
<FormDescription>{t('accounts.optional')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
/> */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='enabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-col items-start gap-y-1'>
|
||||
<FormLabel>Enabled:</FormLabel>
|
||||
<FormLabel>{t('accounts.enabled')}:</FormLabel>
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
@@ -216,7 +219,7 @@ export function NoSyncAccountDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Determines whether this account is active. If disabled, the account will not be able to import data or perform queries.
|
||||
{t('accounts.determinesWhetherThisAccountIsActiveNoSync')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -234,19 +237,19 @@ export function NoSyncAccountDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
updateMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Saving...
|
||||
{t('oauth2.saving')}
|
||||
</>
|
||||
) : (
|
||||
"Save changes"
|
||||
t('accounts.saveChanges')
|
||||
)
|
||||
) : (
|
||||
createMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Creating...
|
||||
{t('oauth2.creating')}
|
||||
</>
|
||||
) : (
|
||||
"Create"
|
||||
t('common.create')
|
||||
)
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -21,17 +21,19 @@ import { Row } from '@tanstack/react-table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useAccountContext } from '../context'
|
||||
import { AccountModel } from '../data/schema'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface DataTableRowActionsProps {
|
||||
row: Row<AccountModel>
|
||||
}
|
||||
export function OAuth2Action({ row }: DataTableRowActionsProps) {
|
||||
const { t } = useTranslation()
|
||||
const { setOpen, setCurrentRow } = useAccountContext()
|
||||
const mailer = row.original
|
||||
const account_type = mailer.account_type;
|
||||
|
||||
if (account_type === "NoSync") {
|
||||
return <span className="text-xs text-muted-foreground">n/a</span>
|
||||
return <Button variant={"ghost"} className="text-xs text-muted-foreground">n/a</Button>
|
||||
}
|
||||
|
||||
const isOAuth2 = mailer.imap?.auth.auth_type === "OAuth2"
|
||||
@@ -52,5 +54,5 @@ export function OAuth2Action({ row }: DataTableRowActionsProps) {
|
||||
)
|
||||
}
|
||||
|
||||
return <span className="text-xs text-muted-foreground">Password</span>
|
||||
return <Button variant={"ghost"} className="text-xs text-muted-foreground">{t('settings.password')}</Button>
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { TableSkeleton } from '@/components/table-skeleton'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FileIcon } from 'lucide-react'
|
||||
import { format, formatDistanceToNow } from 'date-fns'
|
||||
import LongText from '@/components/long-text'
|
||||
@@ -41,6 +42,8 @@ import { IconCopy } from '@tabler/icons-react'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
import { ToastAction } from '@/components/ui/toast'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { dateFnsLocaleMap } from '@/lib/utils'
|
||||
import { enUS } from 'date-fns/locale'
|
||||
|
||||
interface Props {
|
||||
currentRow: AccountModel
|
||||
@@ -49,6 +52,8 @@ interface Props {
|
||||
}
|
||||
|
||||
export function OAuth2TokensDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const locale = dateFnsLocaleMap[i18n.language.toLowerCase()] ?? enUS;
|
||||
const navigate = useNavigate()
|
||||
const { data: oauth2Tokens, isLoading } = useQuery({
|
||||
queryKey: ['oauth2-tokens', currentRow.id],
|
||||
@@ -65,21 +70,21 @@ export function OAuth2TokensDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
await navigator.clipboard.writeText(token);
|
||||
if (access) {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Access token copied to clipboard",
|
||||
title: t('common.ok'),
|
||||
description: t('accounts.accessTokenCopiedToClipboard'),
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Refresh token copied to clipboard",
|
||||
title: t('common.ok'),
|
||||
description: t('accounts.refreshTokenCopiedToClipboard'),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to copy text",
|
||||
title: t('settings.failedToCopyText'),
|
||||
description: (err as Error).message,
|
||||
action: <ToastAction altText="Try again">Try again</ToastAction>,
|
||||
action: <ToastAction altText={t('common.tryAgain')}>{t('common.tryAgain')}</ToastAction>,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
@@ -93,9 +98,9 @@ export function OAuth2TokensDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
>
|
||||
<DialogContent className='sm:max-w-3xl'>
|
||||
<DialogHeader className='text-left'>
|
||||
<DialogTitle>OAuth2 Tokens</DialogTitle>
|
||||
<DialogTitle>{t('accounts.oauth2Tokens')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Details of the OAuth2 tokens for the account.
|
||||
{t('accounts.detailsOfTheOAuth2TokensForTheAccount')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Card>
|
||||
@@ -106,20 +111,20 @@ export function OAuth2TokensDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
<Table className='w-full'>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Field</TableHead>
|
||||
<TableHead>Value</TableHead>
|
||||
<TableHead>Action</TableHead>
|
||||
<TableHead>{t('accounts.field')}</TableHead>
|
||||
<TableHead>{t('accounts.value')}</TableHead>
|
||||
<TableHead>{t('common.actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell className='max-w-80'>OAuth2 Name</TableCell>
|
||||
<TableCell className='max-w-80'>{t('oauth2.id')}</TableCell>
|
||||
<TableCell>
|
||||
<LongText className='max-w-[240px] sm:max-w-[430px]'>{oauth2Tokens.oauth2_name}</LongText>
|
||||
<LongText className='max-w-[240px] sm:max-w-[430px]'>{oauth2Tokens.oauth2_id}</LongText>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className='max-w-80'>Access Token</TableCell>
|
||||
<TableCell className='max-w-80'>{t('accessTokens.token')}</TableCell>
|
||||
<TableCell>
|
||||
<LongText className='max-w-[240px] sm:max-w-[430px]'>{oauth2Tokens.access_token}</LongText>
|
||||
</TableCell>
|
||||
@@ -130,7 +135,7 @@ export function OAuth2TokensDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className='max-w-80'>Refresh Token</TableCell>
|
||||
<TableCell className='max-w-80'>{t('accounts.refreshToken')}</TableCell>
|
||||
<TableCell>
|
||||
<LongText className='max-w-[240px] sm:max-w-[430px]'>{oauth2Tokens.refresh_token}</LongText>
|
||||
</TableCell>
|
||||
@@ -141,15 +146,15 @@ export function OAuth2TokensDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className='max-w-80'>Created At</TableCell>
|
||||
<TableCell className='max-w-80'>{t('settings.createdAt')}</TableCell>
|
||||
<TableCell>
|
||||
{format(new Date(oauth2Tokens.created_at), 'yyyy-MM-dd HH:mm:ss')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className='max-w-80'>Updated At</TableCell>
|
||||
<TableCell className='max-w-80'>{t('settings.updatedAt')}</TableCell>
|
||||
<TableCell>
|
||||
{formatDistanceToNow(new Date(oauth2Tokens.updated_at), { addSuffix: true })}
|
||||
{formatDistanceToNow(new Date(oauth2Tokens.updated_at), { addSuffix: true, locale })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
@@ -158,10 +163,11 @@ export function OAuth2TokensDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
<div className="flex h-[250px] mt-4 shrink-0 items-center justify-center rounded-md border border-dashed">
|
||||
<div className="mx-auto flex max-w-[420px] flex-col items-center justify-center text-center">
|
||||
<FileIcon className="h-10 w-10 text-muted-foreground" />
|
||||
<h3 className="mt-4 text-lg font-semibold">No OAuth2 Tokens</h3>
|
||||
<h3 className="mt-4 text-lg font-semibold">{t('accounts.noOAuth2Tokens')}</h3>
|
||||
<p className="mb-4 mt-2 text-sm text-muted-foreground">
|
||||
The account has not completed the authorization process. Please
|
||||
<a onClick={() => navigate({ to: '/oauth2' })} className="ml-1 text-blue-500 underline cursor-pointer">click here</a> to authorize the account.
|
||||
{t('accounts.theAccountHasNotCompletedTheAuthorizationProcess')}
|
||||
<a onClick={() => navigate({ to: '/oauth2' })} className="ml-1 text-blue-500 underline cursor-pointer">{t('accounts.clickHere')}</a>
|
||||
{t('accounts.toAuthorizeTheAccount')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -170,7 +176,7 @@ export function OAuth2TokensDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
</Card>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant='outline' className="px-2 py-1 text-sm h-auto">Close</Button>
|
||||
<Button variant='outline' className="px-2 py-1 text-sm h-auto">{t('common.close')}</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -21,14 +21,16 @@ import { Row } from '@tanstack/react-table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AccountModel } from '../data/schema';
|
||||
import { useAccountContext } from '../context';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Props {
|
||||
row: Row<AccountModel>
|
||||
}
|
||||
|
||||
export function RunningStateCellAction({ row }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const { setOpen, setCurrentRow } = useAccountContext()
|
||||
|
||||
|
||||
let account_type = row.original.account_type;
|
||||
if (account_type === "NoSync") {
|
||||
return <span className="text-xs text-muted-foreground">n/a</span>
|
||||
@@ -39,7 +41,7 @@ export function RunningStateCellAction({ row }: Props) {
|
||||
setCurrentRow(row.original)
|
||||
setOpen('running-state')
|
||||
}}>
|
||||
<span className="text-xs text-blue-500 cursor-pointer underline hover:text-blue-700">view details</span>
|
||||
<span className="text-xs text-blue-500 cursor-pointer underline hover:text-blue-700">{t('accounts.viewDetails')}</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
@@ -34,6 +33,9 @@ import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { CheckCircle, Clock, Loader2, PlayCircle, FolderSync, FolderCheck } from 'lucide-react'
|
||||
import { FolderSyncProgress } from './folder-sync-progress'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { dateFnsLocaleMap } from '@/lib/utils'
|
||||
import { enUS } from 'date-fns/locale'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
@@ -42,6 +44,8 @@ interface Props {
|
||||
}
|
||||
|
||||
export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const locale = dateFnsLocaleMap[i18n.language.toLowerCase()] ?? enUS;
|
||||
const { data: state, isLoading } = useQuery({
|
||||
queryKey: ['running-state', currentRow.id],
|
||||
queryFn: () => account_state(currentRow.id),
|
||||
@@ -50,28 +54,27 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
refetchInterval: 5000,
|
||||
})
|
||||
|
||||
// Helper function to calculate duration
|
||||
const calculateDuration = (start?: number, end?: number) => {
|
||||
if (!start) {
|
||||
return (
|
||||
<span className="text-yellow-600 flex items-center gap-1">
|
||||
<Clock className="w-4 h-4" /> Not Started
|
||||
<Clock className="w-4 h-4" /> {t('accounts.runningState.notStarted')}
|
||||
</span>
|
||||
);
|
||||
)
|
||||
}
|
||||
if (!end) {
|
||||
return (
|
||||
<span className="text-blue-600 flex items-center gap-1">
|
||||
<PlayCircle className="w-4 h-4" /> In Progress
|
||||
<PlayCircle className="w-4 h-4" /> {t('accounts.runningState.inProgress')}
|
||||
</span>
|
||||
);
|
||||
)
|
||||
}
|
||||
const duration = intervalToDuration({ start: new Date(start), end: new Date(end) })
|
||||
return (
|
||||
<span className="text-green-600 flex items-center gap-1">
|
||||
<CheckCircle className="w-4 h-4" /> {formatDuration(duration, { format: ['hours', 'minutes', 'seconds'] })}
|
||||
</span>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -105,26 +108,26 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
) : (
|
||||
<FolderSync className="w-5 h-5 text-blue-500" />
|
||||
)}
|
||||
Initial Sync
|
||||
{t('accounts.runningState.initialSync')}
|
||||
</h3>
|
||||
{state.is_initial_sync_completed ? (
|
||||
<span className="text-xs bg-green-100 text-green-800 px-2 py-1 rounded-full">
|
||||
Completed
|
||||
{t('accounts.runningState.completed')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded-full">
|
||||
In Progress
|
||||
{t('accounts.runningState.inProgress')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-6 w-full">
|
||||
{/* Start Time */}
|
||||
<div className="flex flex-col text-sm">
|
||||
<span className="text-muted-foreground">Start Time:</span>
|
||||
<span className="text-muted-foreground">{t('accounts.runningState.startTime')}</span>
|
||||
<span className="font-medium">
|
||||
{state.initial_sync_start_time ? (
|
||||
<span className="text-green-600">
|
||||
{formatDistanceToNow(new Date(state.initial_sync_start_time), { addSuffix: true })}
|
||||
{formatDistanceToNow(new Date(state.initial_sync_start_time), { addSuffix: true, locale })}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-yellow-600">
|
||||
@@ -132,47 +135,40 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-yellow-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-yellow-500"></span>
|
||||
</span>
|
||||
Not Started
|
||||
{t('accounts.runningState.notStarted')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{/* End Time */}
|
||||
<div className="flex flex-col text-sm">
|
||||
<span className="text-muted-foreground">End Time:</span>
|
||||
<span className="text-muted-foreground">{t('accounts.runningState.endTime')}</span>
|
||||
<span className="font-medium">
|
||||
{state.initial_sync_end_time ? (
|
||||
<span className="text-green-600">
|
||||
{formatDistanceToNow(new Date(state.initial_sync_end_time), { addSuffix: true })}
|
||||
{formatDistanceToNow(new Date(state.initial_sync_end_time), { addSuffix: true, locale })}
|
||||
</span>
|
||||
) : state.initial_sync_start_time ? (
|
||||
<span className="flex items-center gap-1 text-blue-600">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500"></span>
|
||||
</span>
|
||||
In Progress
|
||||
{t('accounts.runningState.inProgress')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-yellow-600">Not Started</span>
|
||||
<span className="text-yellow-600">{t('accounts.runningState.notStarted')}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Duration */}
|
||||
<div className="flex flex-col text-sm">
|
||||
<span className="text-muted-foreground">Duration:</span>
|
||||
<span className="text-muted-foreground">{t('accounts.runningState.duration')}</span>
|
||||
<span className="font-medium">
|
||||
{(() => {
|
||||
if (!state.initial_sync_start_time)
|
||||
return <span className="text-yellow-600">Not Started</span>;
|
||||
if (!state.initial_sync_end_time)
|
||||
return <span className="text-blue-600 animate-pulse">Calculating...</span>;
|
||||
|
||||
const diff = new Date(state.initial_sync_end_time).getTime() - new Date(state.initial_sync_start_time).getTime();
|
||||
const h = Math.floor(diff / 3600000);
|
||||
const m = Math.floor((diff % 3600000) / 60000);
|
||||
const s = Math.floor((diff % 60000) / 1000);
|
||||
return <span className="text-foreground">{h}h {m}m {s}s</span>;
|
||||
if (!state.initial_sync_start_time) return <span className="text-yellow-600">{t('accounts.runningState.notStarted')}</span>
|
||||
if (!state.initial_sync_end_time) return <span className="text-blue-600 animate-pulse">{t('accounts.runningState.calculating')}</span>
|
||||
const diff = new Date(state.initial_sync_end_time).getTime() - new Date(state.initial_sync_start_time).getTime()
|
||||
const h = Math.floor(diff / 3600000)
|
||||
const m = Math.floor((diff % 3600000) / 60000)
|
||||
const s = Math.floor((diff % 60000) / 1000)
|
||||
return <span className="text-foreground">{h}h {m}m {s}s</span>
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
@@ -182,11 +178,9 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
<div className="p-4 border rounded-lg bg-card">
|
||||
{state && (
|
||||
<div>
|
||||
<h3 className="text-base sm:text-lg font-semibold mb-3">Sync Progres</h3>
|
||||
<h3 className="text-base sm:text-lg font-semibold mb-3">{t('accounts.runningState.syncProgress')}</h3>
|
||||
<ScrollArea className="h-70 sm:h-70 border rounded-md p-2">
|
||||
<div className='space-y-4'>
|
||||
<FolderSyncProgress progressMap={state.progress} />
|
||||
</div>
|
||||
<FolderSyncProgress progressMap={state.progress} />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
@@ -194,51 +188,52 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
|
||||
{/* Incremental Sync */}
|
||||
<div className="p-4 border rounded-lg bg-card">
|
||||
<h3 className="text-base sm:text-lg font-semibold mb-3">Incremental Sync</h3>
|
||||
<h3 className="text-base sm:text-lg font-semibold mb-3">{t('accounts.runningState.incrementalSync')}</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Start Time:</span>
|
||||
<span className="text-muted-foreground">{t('accounts.runningState.startTime')}</span>
|
||||
<span className="font-medium">
|
||||
{state.last_incremental_sync_start ? (
|
||||
formatDistanceToNow(new Date(state.last_incremental_sync_start), { addSuffix: true })
|
||||
formatDistanceToNow(new Date(state.last_incremental_sync_start), { addSuffix: true, locale })
|
||||
) : (
|
||||
<span className="text-yellow-600">Not Started</span>
|
||||
<span className="text-yellow-600">{t('accounts.runningState.notStarted')}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">End Time:</span>
|
||||
<span className="text-muted-foreground">{t('accounts.runningState.endTime')}</span>
|
||||
<span className="font-medium">
|
||||
{state.last_incremental_sync_end ? (
|
||||
formatDistanceToNow(new Date(state.last_incremental_sync_end), { addSuffix: true })
|
||||
formatDistanceToNow(new Date(state.last_incremental_sync_end), { addSuffix: true, locale })
|
||||
) : state.last_incremental_sync_start ? (
|
||||
<span className="text-blue-600">In Progress</span>
|
||||
<span className="text-blue-600">{t('accounts.runningState.inProgress')}</span>
|
||||
) : (
|
||||
<span className="text-yellow-600">Not Started</span>
|
||||
<span className="text-yellow-600">{t('accounts.runningState.notStarted')}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Duration:</span>
|
||||
<span className="text-muted-foreground">{t('accounts.runningState.duration')}</span>
|
||||
<span className="font-medium">
|
||||
{calculateDuration(state.last_incremental_sync_start, state.last_incremental_sync_end)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Sync Interval:</span>
|
||||
<span className="text-muted-foreground">{t('accounts.runningState.syncInterval')}</span>
|
||||
<span className="font-medium">
|
||||
Every {currentRow.sync_interval_min} minutes
|
||||
{t('accounts.runningState.everyMinutes', { minutes: currentRow.sync_interval_min })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border rounded-lg bg-card">
|
||||
<h3 className="text-base sm:text-lg font-semibold mb-3">Error Logs</h3>
|
||||
<h3 className="text-base sm:text-lg font-semibold mb-3">{t('accounts.runningState.errorLogs')}</h3>
|
||||
<ScrollArea className="h-[20rem] sm:h-[32rem]">
|
||||
<div className="space-y-3">
|
||||
{state.errors.length ? (
|
||||
@@ -251,7 +246,7 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
>
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(item.at), { addSuffix: true })}
|
||||
{formatDistanceToNow(new Date(item.at), { addSuffix: true, locale })}
|
||||
</div>
|
||||
<div className="font-medium break-words">{item.error}</div>
|
||||
</div>
|
||||
@@ -259,7 +254,7 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
))
|
||||
) : (
|
||||
<div className="h-full flex justify-center items-center py-8">
|
||||
<p className="text-sm text-muted-foreground">No error logs available.</p>
|
||||
<p className="text-sm text-muted-foreground">{t('accounts.runningState.noErrorLogs')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -270,22 +265,18 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
|
||||
{!isLoading && !state && (
|
||||
<div className="h-full flex flex-col justify-center items-center py-8 text-center space-y-2">
|
||||
<p className="text-sm text-muted-foreground">No active state available</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Account synchronization may not have started yet
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{t('accounts.runningState.noActiveState')}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('accounts.runningState.accountNotStarted')}</p>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter className="pt-4 px-4 sm:px-6 pb-4 border-t">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" className="w-full sm:w-auto">
|
||||
Close
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full sm:w-auto">{t('accounts.runningState.close')}</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,19 +29,21 @@ import {
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Account } from "./action-dialog";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface StepProps {
|
||||
isEdit: boolean;
|
||||
}
|
||||
|
||||
export default function Step1({ isEdit }: StepProps) {
|
||||
const { t } = useTranslation()
|
||||
const { control } = useFormContext<Account>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="my-3 md:mt-8">Email Account Registration</h1>
|
||||
<h1 className="my-3 md:mt-8">{t('accounts.emailAccountRegistration')}</h1>
|
||||
<p className="mb-5 md:mb-8">
|
||||
Please provide your email address. In the next steps, you will configure the IMAP/SMTP details. Using this email address, we will attempt to automatically retrieve the SMTP/IMAP server addresses.
|
||||
{t('accounts.emailAccountRegistrationDesc')}
|
||||
</p>
|
||||
<div className="space-y-8">
|
||||
<FormField
|
||||
@@ -50,36 +52,20 @@ export default function Step1({ isEdit }: StepProps) {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center justify-between">
|
||||
Email Address:
|
||||
{t('accounts.emailAddress')}:
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g john.doe@example.com" readOnly={isEdit} {...field} />
|
||||
<Input placeholder={t('accounts.emailPlaceholder')} readOnly={isEdit} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
{isEdit && (
|
||||
<FormDescription>
|
||||
The email account address cannot be modified when editing.
|
||||
{t('accounts.emailCannotBeModified')}
|
||||
</FormDescription>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center justify-between">
|
||||
Name:
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g john.doe" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Optional</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -37,12 +37,15 @@ import { useFormContext, useWatch } from "react-hook-form";
|
||||
import { Account } from "./action-dialog";
|
||||
import { PasswordInput } from "@/components/password-input";
|
||||
import useProxyList from "@/hooks/use-proxy";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
|
||||
interface StepProps {
|
||||
isEdit: boolean;
|
||||
}
|
||||
|
||||
export default function Step2({ isEdit }: StepProps) {
|
||||
const { t } = useTranslation()
|
||||
const { control } = useFormContext<Account>();
|
||||
const { proxyOptions } = useProxyList();
|
||||
const imapAuthMethod = useWatch({
|
||||
@@ -59,10 +62,10 @@ export default function Step2({ isEdit }: StepProps) {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center justify-between">
|
||||
IMAP Host:
|
||||
{t('accounts.imapHost')}:
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g imap.example.com" {...field} />
|
||||
<Input placeholder={t('accounts.imapHostPlaceholder')} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -74,10 +77,10 @@ export default function Step2({ isEdit }: StepProps) {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center justify-between">
|
||||
IMAP Port:
|
||||
{t('accounts.imapPort')}:
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="e.g 993" {...field} onChange={(e) => field.onChange(parseInt(e.target.value, 10))} />
|
||||
<Input type="number" placeholder={t('accounts.imapPortPlaceholder')} {...field} onChange={(e) => field.onChange(parseInt(e.target.value, 10))} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -88,11 +91,11 @@ export default function Step2({ isEdit }: StepProps) {
|
||||
name="imap.encryption"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>IMAP Auth Method:</FormLabel>
|
||||
<FormLabel>{t('accounts.imapEncryption')}:</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select an authentication method" />
|
||||
<SelectValue placeholder={t('accounts.selectEncryptionMethod')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
@@ -102,22 +105,55 @@ export default function Step2({ isEdit }: StepProps) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Choose the authentication method for IMAP.
|
||||
{t('accounts.chooseEncryptionMethod')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name="use_dangerous"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col items-start gap-y-1">
|
||||
<FormLabel>{t('accounts.useDangerous')}:</FormLabel>
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
className="mt-2"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('accounts.useDangerousDescription')}</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center justify-between">
|
||||
{t('accounts.name')}:
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('accounts.namePlaceholder')} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>{t('accounts.nameDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name="imap.auth.auth_type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>IMAP Auth Method:</FormLabel>
|
||||
<FormLabel>{t('accounts.imapAuthMethod')}:</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select an authentication method" />
|
||||
<SelectValue placeholder={t('accounts.selectAuthMethod')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
@@ -126,7 +162,7 @@ export default function Step2({ isEdit }: StepProps) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Choose the authentication method for IMAP.
|
||||
{t('accounts.chooseAuthMethod')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -139,15 +175,15 @@ export default function Step2({ isEdit }: StepProps) {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center justify-between">
|
||||
IMAP Password:
|
||||
{t('accounts.imapPassword')}:
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput placeholder={isEdit ? "Leave empty to keep current password" : "Enter your password"} {...field} />
|
||||
<PasswordInput placeholder={isEdit ? t('accounts.leaveEmptyToKeepPassword') : t('accounts.enterPassword')} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
{isEdit && (
|
||||
<FormDescription>
|
||||
Leave empty to keep the existing password, or enter a new password to update it.
|
||||
{t('accounts.leaveEmptyToKeepExisting')}
|
||||
</FormDescription>
|
||||
)}
|
||||
</FormItem>
|
||||
@@ -159,7 +195,7 @@ export default function Step2({ isEdit }: StepProps) {
|
||||
name='imap.use_proxy'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center justify-between">Use Proxy(optional):</FormLabel>
|
||||
<FormLabel className="flex items-center justify-between">{t('accounts.useProxy')} ({t('accounts.optional')}):</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
onValueChange={(val) => field.onChange(Number(val))}
|
||||
@@ -167,7 +203,7 @@ export default function Step2({ isEdit }: StepProps) {
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a proxy" />
|
||||
<SelectValue placeholder={t('accounts.selectProxy')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
@@ -178,13 +214,13 @@ export default function Step2({ isEdit }: StepProps) {
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<SelectItem disabled value="__none__">No proxy available</SelectItem>
|
||||
<SelectItem disabled value="__none__">{t('settings.noProxies')}</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription className='flex-1'>
|
||||
Use a SOCKS5 proxy for IMAP connections.
|
||||
{t('accounts.imapProxy')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import {
|
||||
FormField,
|
||||
FormItem,
|
||||
@@ -44,85 +43,95 @@ import { cn } from "@/lib/utils";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { useState } from "react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function Step3() {
|
||||
const { t } = useTranslation();
|
||||
const { control, getValues, setValue } = useFormContext<Account>();
|
||||
const current = getValues();
|
||||
const [rangeType, setRangeType] = useState<'none' | 'fixed' | 'relative'>(current.date_since ? (current.date_since.fixed ? 'fixed' : 'relative') : 'none')
|
||||
const [rangeType, setRangeType] = useState<'none' | 'fixed' | 'relative'>(
|
||||
current.date_since ? (current.date_since.fixed ? 'fixed' : 'relative') : 'none'
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-8">
|
||||
<FormField
|
||||
control={control}
|
||||
name="sync_interval_min"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center justify-between">
|
||||
Incremental Sync(minutes):
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="e.g 300" {...field} onChange={(e) => field.onChange(parseInt(e.target.value, 10))} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name='enabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-col items-start gap-y-1'>
|
||||
<FormLabel>Enabled:</FormLabel>
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
className='mt-2'
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Determines whether this account is active. If disabled, related syncs will not run.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormLabel className="flex items-center justify-between">
|
||||
Date Since:
|
||||
</FormLabel>
|
||||
<RadioGroup
|
||||
defaultValue={rangeType}
|
||||
onValueChange={(value: 'fixed' | 'relative' | 'none') => {
|
||||
setRangeType(value);
|
||||
if (value === 'none') {
|
||||
setValue("date_since", undefined, { shouldValidate: true });
|
||||
}
|
||||
<div className="space-y-8">
|
||||
<FormField
|
||||
control={control}
|
||||
name="sync_interval_min"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center justify-between">
|
||||
{t('accounts.incrementalSync')}:
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={t('accounts.incrementalSyncPlaceholder')}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
if (value === 'fixed') {
|
||||
setValue("date_since", { fixed: undefined }, { shouldValidate: true });
|
||||
}
|
||||
<FormField
|
||||
control={control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col items-start gap-y-1">
|
||||
<FormLabel>{t('accounts.enabled')}:</FormLabel>
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
className="mt-2"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('accounts.enabledDescription')}</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormLabel className="flex items-center justify-between">{t('accounts.dateSince')}:</FormLabel>
|
||||
<RadioGroup
|
||||
defaultValue={rangeType}
|
||||
onValueChange={(value: 'fixed' | 'relative' | 'none') => {
|
||||
setRangeType(value);
|
||||
if (value === 'none') {
|
||||
setValue("date_since", undefined, { shouldValidate: true });
|
||||
}
|
||||
if (value === 'fixed') {
|
||||
setValue("date_since", { fixed: undefined }, { shouldValidate: true });
|
||||
}
|
||||
if (value === 'relative') {
|
||||
setValue("date_since", { relative: { value: undefined, unit: undefined } }, { shouldValidate: true });
|
||||
}
|
||||
}}
|
||||
className="flex flex-row space-x-4"
|
||||
>
|
||||
<FormItem className="flex items-center space-x-3">
|
||||
<RadioGroupItem value="none" />
|
||||
<FormLabel className="font-normal">{t('accounts.none')}</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem className="flex items-center space-x-3">
|
||||
<RadioGroupItem value="fixed" />
|
||||
<FormLabel className="font-normal">{t('accounts.fixed')}</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem className="flex items-center space-x-3">
|
||||
<RadioGroupItem value="relative" />
|
||||
<FormLabel className="font-normal">{t('accounts.relative')}</FormLabel>
|
||||
</FormItem>
|
||||
</RadioGroup>
|
||||
|
||||
if (value === 'relative') {
|
||||
setValue("date_since", { relative: { value: undefined, unit: undefined } }, { shouldValidate: true });
|
||||
}
|
||||
}}
|
||||
className='flex flex-row space-x-4'
|
||||
>
|
||||
<FormItem className='flex items-center space-x-3'>
|
||||
<RadioGroupItem value='none' />
|
||||
<FormLabel className='font-normal'>None</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem className='flex items-center space-x-3'>
|
||||
<RadioGroupItem value='fixed' />
|
||||
<FormLabel className='font-normal'>Fixed</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem className='flex items-center space-x-3'>
|
||||
<RadioGroupItem value='relative' />
|
||||
<FormLabel className='font-normal'>Relative</FormLabel>
|
||||
</FormItem>
|
||||
</RadioGroup>
|
||||
<FormDescription>defines the sync start date—either specific or relative to now. Preceding emails are excluded,{rangeType === 'fixed' ? " syncs data after a set date" : " shifts the sync date over time, syncing only recent data."}</FormDescription>
|
||||
{rangeType === 'fixed' && <FormField
|
||||
<FormDescription>
|
||||
{t('accounts.syncStartDateDescription', {
|
||||
fixedPart: rangeType === 'fixed' ? t('accounts.syncAfterDate') : t('accounts.syncRecentData'),
|
||||
})}
|
||||
</FormDescription>
|
||||
|
||||
{rangeType === 'fixed' && (
|
||||
<FormField
|
||||
control={control}
|
||||
name="date_since.fixed"
|
||||
render={({ field }) => (
|
||||
@@ -131,17 +140,13 @@ export default function Step3() {
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-[240px] pl-3 text-left font-normal text-sm text-brand-marine-blue",
|
||||
"w-[240px] pl-3 text-left font-normal text-brand-marine-blue",
|
||||
!field.value && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{field.value ? (
|
||||
format(field.value, "PPP")
|
||||
) : (
|
||||
<span>Pick a date</span>
|
||||
)}
|
||||
{field.value ? format(field.value, "PPP") : <span>{t('accounts.selectDate')}</span>}
|
||||
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
@@ -152,15 +157,13 @@ export default function Step3() {
|
||||
selected={field.value ? new Date(new Date(field.value).setHours(0, 0, 0, 0)) : undefined}
|
||||
onSelect={(value) => {
|
||||
if (value) {
|
||||
const formattedDate = value.toLocaleDateString('en-CA')
|
||||
field.onChange(formattedDate)
|
||||
const formattedDate = value.toLocaleDateString('en-CA');
|
||||
field.onChange(formattedDate);
|
||||
} else {
|
||||
field.onChange(null)
|
||||
field.onChange(null);
|
||||
}
|
||||
}}
|
||||
disabled={(date) =>
|
||||
date > new Date() || date < new Date("1900-01-01")
|
||||
}
|
||||
disabled={(date) => date > new Date() || date < new Date("1900-01-01")}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
@@ -168,8 +171,11 @@ export default function Step3() {
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>}
|
||||
{rangeType === 'relative' && <div className="flex flex-row gap-4">
|
||||
/>
|
||||
)}
|
||||
|
||||
{rangeType === 'relative' && (
|
||||
<div className="flex flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<FormField
|
||||
control={control}
|
||||
@@ -177,7 +183,7 @@ export default function Step3() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="e.g 1" {...field} onChange={(e) => field.onChange(parseInt(e.target.value, 10))} />
|
||||
<Input type="number" placeholder="e.g. 1" {...field} onChange={(e) => field.onChange(parseInt(e.target.value, 10))} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -193,13 +199,13 @@ export default function Step3() {
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select unit" />
|
||||
<SelectValue placeholder={t('accounts.selectUnit')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="Days">Days</SelectItem>
|
||||
<SelectItem value="Months">Months</SelectItem>
|
||||
<SelectItem value="Years">Years</SelectItem>
|
||||
<SelectItem value="Days">{t('accounts.days')}</SelectItem>
|
||||
<SelectItem value="Months">{t('accounts.months')}</SelectItem>
|
||||
<SelectItem value="Years">{t('accounts.years')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
@@ -207,33 +213,28 @@ export default function Step3() {
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>}
|
||||
<FormField
|
||||
control={control}
|
||||
name="folder_limit"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center justify-between">
|
||||
Folder Sync Limit:
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
Limit the number of emails to sync per folder (minimum 100). Leave empty for no limit.
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="e.g. 1000"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(e.target.value ? parseInt(e.target.value, 10) : undefined)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="folder_limit"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center justify-between">{t('accounts.folderLimit')}:</FormLabel>
|
||||
<FormDescription>{t('accounts.folderLimitDescription')}</FormDescription>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={t('accounts.folderLimitPlaceholder')}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(e.target.value ? parseInt(e.target.value, 10) : undefined)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,102 +20,89 @@
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import { Account } from "./action-dialog";
|
||||
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "@/components/ui/accordion";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function Step4() {
|
||||
const { t } = useTranslation();
|
||||
const { getValues } = useFormContext<Account>();
|
||||
const summaryData = getValues();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="p-5 rounded-xl">
|
||||
<Accordion type="multiple" defaultValue={['email', 'name', 'minimal_sync', 'isolated_index', 'imap', 'smtp', 'date_since', 'folder_limit', 'sync_folders', 'language', 'sync_interval']}>
|
||||
<AccordionItem key="email" value="email">
|
||||
<AccordionTrigger className="font-medium capitalize text-gray-600">
|
||||
Email:
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
{summaryData.email}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem key="name" value="name">
|
||||
<AccordionTrigger className="font-medium capitalize text-gray-600">
|
||||
Name:
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
{summaryData.name ?? "n/a"}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem key="imap" value='imap'>
|
||||
<AccordionTrigger className="font-medium capitalize text-gray-600">
|
||||
Imap:
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y">
|
||||
<tbody className="divide-y">
|
||||
<div className="p-5 rounded-xl">
|
||||
<Accordion type="multiple" defaultValue={['email', 'name', 'imap', 'date_since', 'folder_limit', 'sync_interval']}>
|
||||
<AccordionItem key="email" value="email">
|
||||
<AccordionTrigger className="font-medium capitalize text-gray-600">{t('accounts.email')}:</AccordionTrigger>
|
||||
<AccordionContent>{summaryData.email}</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem key="name" value="name">
|
||||
<AccordionTrigger className="font-medium capitalize text-gray-600">{t('accounts.name')}:</AccordionTrigger>
|
||||
<AccordionContent>{summaryData.name ?? t('accounts.notAvailable')}</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem key="imap" value="imap">
|
||||
<AccordionTrigger className="font-medium capitalize text-gray-600">{t('accounts.imap')}:</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y">
|
||||
<tbody className="divide-y">
|
||||
<tr>
|
||||
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">{t('accounts.host')}:</td>
|
||||
<td className="px-6 py-2 whitespace-nowrap text-sm">{summaryData.imap.host}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">{t('accounts.port')}:</td>
|
||||
<td className="px-6 py-2 whitespace-nowrap text-sm">{summaryData.imap.port}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">{t('accounts.encryption')}:</td>
|
||||
<td className="px-6 py-2 whitespace-nowrap text-sm">{summaryData.imap.encryption}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">{t('accounts.useDangerous')}:</td>
|
||||
<td className="px-6 py-2 whitespace-nowrap text-sm">{`${summaryData.use_dangerous}`}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">{t('accounts.authType')}:</td>
|
||||
<td className="px-6 py-2 whitespace-nowrap text-sm">{summaryData.imap.auth.auth_type}</td>
|
||||
</tr>
|
||||
{summaryData.imap.auth.auth_type === 'Password' && (
|
||||
<tr>
|
||||
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">host:</td>
|
||||
<td className="px-6 py-2 whitespace-nowrap text-sm">{summaryData.imap.host}</td>
|
||||
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">{t('accounts.password')}:</td>
|
||||
<td className="px-6 py-2 whitespace-nowrap text-sm break-words">{summaryData.imap.auth.password}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">port:</td>
|
||||
<td className="px-6 py-2 whitespace-nowrap text-sm">{summaryData.imap.port}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">encryption:</td>
|
||||
<td className="px-6 py-2 whitespace-nowrap text-sm">{summaryData.imap.encryption}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">auth_type:</td>
|
||||
<td className="px-6 py-2 whitespace-nowrap text-sm">{summaryData.imap.auth.auth_type}</td>
|
||||
</tr>
|
||||
{summaryData.imap.auth.auth_type === 'Password' && (
|
||||
<tr>
|
||||
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">password:</td>
|
||||
<td className="px-6 py-2 whitespace-nowrap text-sm break-words">
|
||||
{summaryData.imap.auth.password}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">use proxy:</td>
|
||||
<td className="px-6 py-2 whitespace-nowrap text-sm">{summaryData.imap.use_proxy ? "true" : "false"}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem key="date_since" value='date_since'>
|
||||
<AccordionTrigger className="font-medium capitalize text-gray-600">
|
||||
Date Selection:
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
{summaryData.date_since?.fixed ?
|
||||
'since ' + summaryData.date_since.fixed
|
||||
: summaryData.date_since?.relative && summaryData.date_since.relative.value && summaryData.date_since.relative.unit ?
|
||||
'recent ' + summaryData.date_since.relative.value + ' ' + summaryData.date_since.relative.unit
|
||||
: 'n/a'}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem key="folder_limit" value='folder_limit'>
|
||||
<AccordionTrigger className="font-medium capitalize text-gray-600">
|
||||
Folder Sync Limit:
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
{summaryData.folder_limit ? summaryData.folder_limit : 'n/a'}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem key="sync_interval" value='sync_interval'>
|
||||
<AccordionTrigger className="font-medium capitalize text-gray-600">
|
||||
Incremental Sync Interval:
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
{summaryData.sync_interval_min} minutes
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<tr>
|
||||
<td className="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-600">{t('accounts.useProxyField')}:</td>
|
||||
<td className="px-6 py-2 whitespace-nowrap text-sm">{summaryData.imap.use_proxy ? t('common.yes') : t('common.no')}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem key="date_since" value="date_since">
|
||||
<AccordionTrigger className="font-medium capitalize text-gray-600">{t('accounts.dateSelection')}:</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
{summaryData.date_since?.fixed
|
||||
? t('accounts.since') + ' ' + summaryData.date_since.fixed
|
||||
: summaryData.date_since?.relative && summaryData.date_since.relative.value && summaryData.date_since.relative.unit
|
||||
? t('accounts.recent') + ' ' + summaryData.date_since.relative.value + ' ' + summaryData.date_since.relative.unit
|
||||
: t('accounts.notAvailable')}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem key="folder_limit" value="folder_limit">
|
||||
<AccordionTrigger className="font-medium capitalize text-gray-600">{t('accounts.folderLimit')}:</AccordionTrigger>
|
||||
<AccordionContent>{summaryData.folder_limit ?? t('accounts.notAvailable')}</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem key="sync_interval" value="sync_interval">
|
||||
<AccordionTrigger className="font-medium capitalize text-gray-600">{t('accounts.incrementalSync')}:</AccordionTrigger>
|
||||
<AccordionContent>{summaryData.sync_interval_min} {t('accounts.minutes')}</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,18 +26,119 @@ import {
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Loader2, CheckSquare, Square } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { AccountModel } from '../data/schema'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
import { list_mailboxes } from '@/api/mailbox/api'
|
||||
import { buildTree } from '@/lib/build-tree'
|
||||
import { TreeDataItem, TreeView } from '@/components/tree-view'
|
||||
import { list_mailboxes, MailboxData } from '@/api/mailbox/api'
|
||||
import { buildTree, ExtendedTreeItemProps } from '@/lib/build-tree'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { update_account } from '@/api/account/api'
|
||||
import { ToastAction } from '@/components/ui/toast'
|
||||
import { AxiosError } from 'axios'
|
||||
import axios, { AxiosError } from 'axios'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RichTreeView } from '@mui/x-tree-view/RichTreeView';
|
||||
import { useTheme } from '@/context/theme-context'
|
||||
import React from 'react'
|
||||
import Collapse from '@mui/material/Collapse';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import { TreeItemCheckbox, TreeItemContent, TreeItemIconContainer, TreeItemLabel, TreeItemRoot } from '@mui/x-tree-view/TreeItem'
|
||||
import { TreeItemDragAndDropOverlay, TreeItemIcon, TreeItemProvider, TreeViewBaseItem, TreeViewSelectionPropagation, useTreeItem, useTreeItemModel, UseTreeItemParameters } from '@mui/x-tree-view'
|
||||
import { animated, useSpring } from '@react-spring/web';
|
||||
import { TransitionProps } from '@mui/material/transitions'
|
||||
|
||||
|
||||
function getParentIds(tree: TreeViewBaseItem[]): string[] {
|
||||
const result: string[] = [];
|
||||
|
||||
function traverse(nodes: TreeViewBaseItem[]) {
|
||||
for (const node of nodes) {
|
||||
if (node.children && node.children.length > 0) {
|
||||
result.push(node.id);
|
||||
traverse(node.children);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
traverse(tree);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
interface CustomLabelProps {
|
||||
exists?: number;
|
||||
attributes?: { attr: string; extension: string | null }[],
|
||||
children: React.ReactNode;
|
||||
icon?: React.ElementType;
|
||||
expandable?: boolean;
|
||||
}
|
||||
|
||||
function CustomLabel({
|
||||
expandable,
|
||||
exists,
|
||||
attributes,
|
||||
children,
|
||||
...other
|
||||
}: CustomLabelProps) {
|
||||
return (
|
||||
<TreeItemLabel
|
||||
{...other}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<span className="font-medium text-sm text-inherit">
|
||||
{children}
|
||||
</span>
|
||||
<div className="flex gap-2 ml-auto mr-3 opacity-70 text-xs">
|
||||
{attributes?.map((attr) => {
|
||||
const text =
|
||||
attr.attr === 'Extension'
|
||||
? attr.extension
|
||||
: attr.attr;
|
||||
|
||||
return (
|
||||
<span key={attr.attr} className="text-inherit">
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{exists !== undefined && (
|
||||
<span
|
||||
className="text-sm opacity-60 min-w-[40px] text-right text-inherit"
|
||||
>
|
||||
{exists}
|
||||
</span>
|
||||
)}
|
||||
</TreeItemLabel>
|
||||
);
|
||||
}
|
||||
|
||||
const CustomCollapse = styled(Collapse)({
|
||||
padding: 0,
|
||||
});
|
||||
|
||||
const AnimatedCollapse = animated(CustomCollapse);
|
||||
|
||||
function TransitionComponent(props: TransitionProps) {
|
||||
const style = useSpring({
|
||||
to: {
|
||||
opacity: props.in ? 1 : 0,
|
||||
transform: `translate3d(0,${props.in ? 0 : 20}px,0)`,
|
||||
},
|
||||
});
|
||||
|
||||
return <AnimatedCollapse style={style} {...props} />;
|
||||
}
|
||||
|
||||
interface CustomTreeItemProps
|
||||
extends Omit<UseTreeItemParameters, 'rootRef'>,
|
||||
Omit<React.HTMLAttributes<HTMLLIElement>, 'onFocus'> { }
|
||||
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
@@ -46,36 +147,152 @@ interface Props {
|
||||
}
|
||||
|
||||
export function SyncFoldersDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
const [selectedFolders, setSelectedFolders] = useState<string[]>(currentRow.sync_folders || []);
|
||||
const [selectedItems, setSelectedItems] = React.useState<string[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const [allIds, setAllIds] = useState<string[]>([]);
|
||||
const [expandedItems, setExpandedItems] = useState<string[]>([]);
|
||||
const [itemsWithChildren, setItemsWithChildren] = useState<string[]>([]);
|
||||
const [mailboxes, setMailboxes] = useState<MailboxData[]>([]);
|
||||
const [selectionPropagation, setSelectionPropagation] =
|
||||
React.useState<TreeViewSelectionPropagation>({
|
||||
parents: false,
|
||||
descendants: true,
|
||||
});
|
||||
const [treeData, setTreeData] = useState<TreeViewBaseItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
const queryClient = useQueryClient();
|
||||
const { data: mailboxes, isLoading } = useQuery({
|
||||
queryKey: ['account-mailboxes', currentRow.id],
|
||||
queryFn: () => list_mailboxes(currentRow.id, true),
|
||||
enabled: open,
|
||||
});
|
||||
const { t } = useTranslation()
|
||||
const { theme } = useTheme()
|
||||
|
||||
// Convert mailbox names to IDs for initial selection
|
||||
const initialSelectedItemIds = useMemo(() => {
|
||||
if (!mailboxes) return [];
|
||||
return mailboxes
|
||||
.filter(mailbox => selectedFolders.includes(mailbox.name))
|
||||
.map(mailbox => mailbox.id.toString());
|
||||
}, [mailboxes, selectedFolders]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
const fetchMailboxes = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await list_mailboxes(currentRow.id, true);
|
||||
if (!cancelled) {
|
||||
setMailboxes(data);
|
||||
const allIds = data.map(mailbox => String(mailbox.id));
|
||||
setAllIds(allIds);
|
||||
|
||||
// Convert data to tree structure
|
||||
const treeData = useMemo(() => {
|
||||
if (!mailboxes) return [];
|
||||
return buildTree(mailboxes, undefined, true, true);
|
||||
}, [mailboxes]);
|
||||
const tree = buildTree(data);
|
||||
setTreeData(tree);
|
||||
const itemsWithChildren = getParentIds(tree);
|
||||
setItemsWithChildren(itemsWithChildren);
|
||||
setExpandedItems(itemsWithChildren);
|
||||
const sync_folders = data
|
||||
.filter(mailbox => currentRow.sync_folders.includes(mailbox.name))
|
||||
.map(mailbox => mailbox.id.toString());
|
||||
setSelectedItems(sync_folders);
|
||||
setError(undefined);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (axios.isAxiosError(err)) {
|
||||
const resData = err.response?.data;
|
||||
if (resData) {
|
||||
setError(`Error ${resData.code || ''}: ${resData.message || ''}`);
|
||||
} else {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setIsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchMailboxes();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentRow, open]);
|
||||
|
||||
const handleSelectItems = useCallback((selectedItems: TreeDataItem[]) => {
|
||||
const selected = selectedItems
|
||||
.map(item => mailboxes?.find(m => m.id === parseInt(item.id, 10))?.name)
|
||||
.filter(Boolean) as string[];
|
||||
|
||||
setSelectedFolders(selected);
|
||||
}, [mailboxes]);
|
||||
|
||||
const handleExpandedItemsChange = (
|
||||
_event: React.SyntheticEvent | null,
|
||||
itemIds: string[],
|
||||
) => {
|
||||
setExpandedItems(itemIds);
|
||||
};
|
||||
|
||||
const handleExpandClick = () => {
|
||||
setExpandedItems((oldExpanded) =>
|
||||
oldExpanded.length === 0 ? itemsWithChildren : [],
|
||||
);
|
||||
};
|
||||
|
||||
const CustomTreeItem = useMemo(() => {
|
||||
return React.forwardRef(function CustomTreeItem(
|
||||
props: CustomTreeItemProps,
|
||||
ref: React.Ref<HTMLLIElement>,
|
||||
) {
|
||||
const { id, itemId, label, disabled, children, ...other } = props;
|
||||
|
||||
const {
|
||||
getContextProviderProps,
|
||||
getRootProps,
|
||||
getContentProps,
|
||||
getIconContainerProps,
|
||||
getCheckboxProps,
|
||||
getLabelProps,
|
||||
getGroupTransitionProps,
|
||||
getDragAndDropOverlayProps,
|
||||
status,
|
||||
} = useTreeItem({ id, itemId, children, label, disabled, rootRef: ref });
|
||||
|
||||
const item = useTreeItemModel<ExtendedTreeItemProps>(itemId)!;
|
||||
|
||||
return (
|
||||
<TreeItemProvider {...getContextProviderProps()}>
|
||||
<TreeItemRoot {...getRootProps(other)}>
|
||||
<TreeItemContent {...getContentProps()}>
|
||||
<TreeItemIconContainer {...getIconContainerProps()}>
|
||||
<TreeItemIcon status={status} />
|
||||
</TreeItemIconContainer>
|
||||
<TreeItemCheckbox {...getCheckboxProps()} />
|
||||
<CustomLabel
|
||||
{...getLabelProps({
|
||||
exists: item.exists,
|
||||
attributes: item.attributes,
|
||||
expandable: status.expandable && status.expanded,
|
||||
})}
|
||||
/>
|
||||
<TreeItemDragAndDropOverlay {...getDragAndDropOverlayProps()} />
|
||||
</TreeItemContent>
|
||||
{children && <TransitionComponent {...getGroupTransitionProps()} />}
|
||||
</TreeItemRoot>
|
||||
</TreeItemProvider>
|
||||
);
|
||||
});
|
||||
}, [theme]);
|
||||
|
||||
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
const selectedSet = new Set(allIds);
|
||||
|
||||
const selectedAllMailbox = mailboxes.find((mb) =>
|
||||
selectedSet.has(String(mb.id)) &&
|
||||
mb.attributes?.some(a => a.attr === 'All')
|
||||
);
|
||||
|
||||
if (selectedAllMailbox) {
|
||||
toast({
|
||||
title: t('accounts.allMailFolderSelected'),
|
||||
description: t('accounts.allMailFolderSelectedDesc'),
|
||||
action: <ToastAction altText={t('common.ok')}>{t('common.ok')}</ToastAction>,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
setSelectedItems(allIds);
|
||||
}, [allIds]);
|
||||
|
||||
const handleDeselectAll = useCallback(() => {
|
||||
setSelectedItems([]);
|
||||
}, []);
|
||||
|
||||
|
||||
const updateMutation = useMutation({
|
||||
@@ -86,79 +303,188 @@ export function SyncFoldersDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
|
||||
function handleSuccess() {
|
||||
toast({
|
||||
title: 'Account Sync Folders Updated',
|
||||
description: 'Account has been successfully updated.',
|
||||
action: <ToastAction altText="Close">Close</ToastAction>,
|
||||
title: t('accounts.accountSyncFoldersUpdated'),
|
||||
description: t('accounts.accountUpdatedDesc'),
|
||||
action: <ToastAction altText={t('common.close')}>{t('common.close')}</ToastAction>,
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['account-list'] });
|
||||
setIsSubmitting(false);
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
function handleError(error: AxiosError) {
|
||||
const errorMessage = (error.response?.data as { message?: string })?.message ||
|
||||
error.message ||
|
||||
'Update failed, please try again later';
|
||||
t('accounts.updateFailed');
|
||||
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: 'Account Sync Folders Update Failed',
|
||||
title: t('accounts.accountSyncFoldersUpdateFailed'),
|
||||
description: errorMessage as string,
|
||||
action: <ToastAction altText="Try again">Try again</ToastAction>,
|
||||
action: <ToastAction altText={t('common.tryAgain')}>{t('common.tryAgain')}</ToastAction>,
|
||||
});
|
||||
setIsSubmitting(false);
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
const handleSelectedItemsChange = (
|
||||
_event: React.SyntheticEvent | null,
|
||||
newSelectedItems: string[],
|
||||
) => {
|
||||
const selectedSet = new Set(newSelectedItems);
|
||||
|
||||
const selectedAllMailbox = mailboxes.find((mb) =>
|
||||
selectedSet.has(String(mb.id)) &&
|
||||
mb.attributes?.some(a => a.attr === 'All')
|
||||
);
|
||||
|
||||
if (selectedAllMailbox) {
|
||||
toast({
|
||||
title: t('accounts.allMailFolderSelected'),
|
||||
description: t('accounts.allMailFolderSelectedDesc'),
|
||||
action: <ToastAction altText={t('common.ok')}>{t('common.ok')}</ToastAction>,
|
||||
});
|
||||
}
|
||||
|
||||
setSelectedItems(newSelectedItems);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (selectedFolders.length === 0) {
|
||||
if (selectedItems.length === 0) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Please select at least one folder',
|
||||
title: t('common.error'),
|
||||
description: t('accounts.selectAtLeastOneFolder'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
setIsSubmitting(true);
|
||||
|
||||
const selectedNames: string[] = [];
|
||||
const idSet = new Set(selectedItems);
|
||||
for (const mailbox of mailboxes) {
|
||||
if (idSet.has(String(mailbox.id))) {
|
||||
selectedNames.push(mailbox.name);
|
||||
}
|
||||
}
|
||||
|
||||
updateMutation.mutate({
|
||||
sync_folders: selectedFolders,
|
||||
sync_folders: selectedNames,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogContent className="sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Select Sync Folders</DialogTitle>
|
||||
<DialogTitle>{t('accounts.selectSyncFolders')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose folders to sync for {currentRow.email}, Newly added folders will begin downloading during the next sync cycle.
|
||||
{t('accounts.chooseFoldersToSync', { "email": currentRow.email })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-end">
|
||||
<div className="flex flex-col pt-2 gap-2">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSelectAll}
|
||||
disabled={isLoading || !allIds || allIds.length === 0}
|
||||
className="h-8"
|
||||
>
|
||||
<CheckSquare className="w-4 h-4 mr-2" />
|
||||
{t('common.selectAll')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDeselectAll}
|
||||
disabled={isLoading || allIds.length === 0}
|
||||
className="h-8"
|
||||
>
|
||||
<Square className="w-4 h-4 mr-2" />
|
||||
{t('common.deselectAll')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setSelectionPropagation(prev => ({
|
||||
...prev,
|
||||
descendants: !prev.descendants,
|
||||
}))
|
||||
}
|
||||
disabled={isLoading}
|
||||
className="h-8"
|
||||
>
|
||||
{selectionPropagation.descendants ? <CheckSquare className="w-4 h-4 mr-2" /> : <Square className="w-4 h-4 mr-2" />}
|
||||
{t('accounts.folderSync.autoSelectDescendants')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setSelectionPropagation(prev => ({
|
||||
...prev,
|
||||
parents: !prev.parents,
|
||||
}))
|
||||
}
|
||||
disabled={isLoading}
|
||||
className="h-8"
|
||||
>
|
||||
{selectionPropagation.parents ? <CheckSquare className="w-4 h-4 mr-2" /> : <Square className="w-4 h-4 mr-2" />}
|
||||
{t('accounts.folderSync.autoSelectParents')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleExpandClick}
|
||||
disabled={isLoading}>
|
||||
{expandedItems.length === 0 ? t('accounts.folderSync.expandAll') : t('accounts.folderSync.collapseAll')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{selectedFolders.length} folder(s) selected
|
||||
{t('accounts.foldersSelected', { count: selectedItems.length })}
|
||||
</div>
|
||||
</div>
|
||||
{isLoading && (
|
||||
<div className="space-y-2">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-8 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && (
|
||||
<TreeView
|
||||
data={treeData}
|
||||
multiple
|
||||
expandAll
|
||||
clickRowToSelect={false}
|
||||
initialSelectedItemIds={initialSelectedItemIds}
|
||||
onSelectItemsChange={handleSelectItems}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ScrollArea className="h-[35rem] w-full pr-4 -mr-4 py-1">
|
||||
{isLoading && (
|
||||
<div className="p-8 space-y-8">
|
||||
<div className="flex flex-col items-center gap-3 text-muted-foreground">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
<span className="text-sm font-medium">{t('accounts.folderSync.loadingMailboxFolders')}</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-8 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && (
|
||||
<RichTreeView
|
||||
multiSelect
|
||||
checkboxSelection
|
||||
items={treeData}
|
||||
expandedItems={expandedItems}
|
||||
onExpandedItemsChange={handleExpandedItemsChange}
|
||||
selectionPropagation={selectionPropagation}
|
||||
selectedItems={selectedItems}
|
||||
onSelectedItemsChange={handleSelectedItemsChange}
|
||||
slots={{ item: CustomTreeItem }}
|
||||
/>
|
||||
)}
|
||||
{error && (
|
||||
<div className="mt-auto p-2 text-red-600 text-sm font-medium">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
@@ -167,14 +493,14 @@ export function SyncFoldersDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || isLoading}
|
||||
disabled={isSubmitting || isLoading || !!error}
|
||||
>
|
||||
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Save Changes
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
import { AccountModel } from '../data/schema'
|
||||
import { DataTablePagination } from './data-table-pagination'
|
||||
import { DataTableToolbar } from './data-table-toolbar'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
declare module '@tanstack/react-table' {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
@@ -59,6 +60,7 @@ interface DataTableProps {
|
||||
|
||||
|
||||
export function AccountTable({ columns, data }: DataTableProps) {
|
||||
const { t } = useTranslation()
|
||||
const [rowSelection, setRowSelection] = useState({})
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||
@@ -140,7 +142,7 @@ export function AccountTable({ columns, data }: DataTableProps) {
|
||||
colSpan={columns.length}
|
||||
className='h-24 text-center'
|
||||
>
|
||||
No results.
|
||||
{t('common.table.noResults')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
@@ -60,4 +60,5 @@ export interface AccountModel {
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
use_proxy?: number
|
||||
use_dangerous: boolean
|
||||
}
|
||||
@@ -22,7 +22,7 @@ import useDialogState from '@/hooks/use-dialog-state'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Main } from '@/components/layout/main'
|
||||
import { AccountActionDialog } from './components/action-dialog'
|
||||
import { columns } from './components/columns'
|
||||
import { useColumns } from './components/columns'
|
||||
import { AccountDeleteDialog } from './components/delete-dialog'
|
||||
import { AccountTable } from './components/table'
|
||||
import AccountProvider, {
|
||||
@@ -41,8 +41,11 @@ import { FixedHeader } from '@/components/layout/fixed-header'
|
||||
import { SyncFoldersDialog } from './components/sync-folders'
|
||||
import { NoSyncAccountDialog } from './components/nosync-dialog'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function Accounts() {
|
||||
const { t } = useTranslation()
|
||||
const columns = useColumns()
|
||||
// Dialog states
|
||||
const [currentRow, setCurrentRow] = useState<AccountModel | null>(null)
|
||||
const [open, setOpen] = useDialogState<AccountDialogType>(null)
|
||||
@@ -63,9 +66,9 @@ export default function Accounts() {
|
||||
{/* Header Section */}
|
||||
<div className='mb-2 flex items-center justify-between flex-wrap gap-x-4 gap-y-2'>
|
||||
<div>
|
||||
<h2 className='text-2xl font-bold tracking-tight'>Email Accounts</h2>
|
||||
<h2 className='text-2xl font-bold tracking-tight'>{t('accounts.title')}</h2>
|
||||
<p className='text-muted-foreground'>
|
||||
Manage and configure your email accounts.
|
||||
{t('accounts.description')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
@@ -75,7 +78,7 @@ export default function Accounts() {
|
||||
className="rounded-r-none border-r-0"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add IMAP
|
||||
{t('accounts.addImap')}
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -84,13 +87,13 @@ export default function Accounts() {
|
||||
className="h-9 w-9 rounded-l-none border-l-0"
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
<span className="sr-only">More account types</span>
|
||||
<span className="sr-only">{t('accounts.moreAccountTypes')}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setOpen("add-nosync")}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add NoSync
|
||||
{t('accounts.addNoSync')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -110,15 +113,15 @@ export default function Accounts() {
|
||||
<img
|
||||
src={Logo}
|
||||
className="max-h-[100px] w-auto opacity-20 saturate-0 transition-all duration-300 hover:opacity-100 hover:saturate-100 object-contain"
|
||||
alt="RustMailer Logo"
|
||||
alt="Bichon Logo"
|
||||
/>
|
||||
<h3 className="mt-4 text-lg font-semibold">No Account Configurations</h3>
|
||||
<h3 className="mt-4 text-lg font-semibold">{t('accounts.noAccountConfigurations')}</h3>
|
||||
<p className="mb-4 mt-2 text-sm text-muted-foreground">
|
||||
You haven't added any Account configurations yet. Add one to start using Account features.
|
||||
{t('accounts.noAccountConfigurationsDesc')}
|
||||
</p>
|
||||
<div className="mt-4 flex flex-col items-center gap-3 sm:flex-row sm:flex-wrap sm:justify-center sm:gap-4">
|
||||
<Button variant="default" className="w-64" onClick={() => setOpen("add-imap")}>
|
||||
Add Configuration
|
||||
{t('accounts.addConfiguration')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import { IconLogout } from '@tabler/icons-react'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
@@ -27,6 +28,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export function LogoutConfirmDialog({ open, onOpenChange, handleConfirm }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const handleLogout = () => {
|
||||
handleConfirm();
|
||||
onOpenChange(false);
|
||||
@@ -44,18 +46,18 @@ export function LogoutConfirmDialog({ open, onOpenChange, handleConfirm }: Props
|
||||
className='mr-1 inline-block stroke-destructive'
|
||||
size={18}
|
||||
/>{' '}
|
||||
Log out
|
||||
{t('auth.logout')}
|
||||
</span>
|
||||
}
|
||||
desc={
|
||||
<p>
|
||||
Are you sure you want to log out?
|
||||
{t('auth.areYouSureYouWantToLogOut')}
|
||||
<br />
|
||||
You will need to log in again to access your account.
|
||||
{t('auth.youWillNeedToLogInAgain')}
|
||||
</p>
|
||||
}
|
||||
confirmText='Log out'
|
||||
cancelBtnText='Cancel'
|
||||
confirmText={t('auth.logout')}
|
||||
cancelBtnText={t('common.cancel')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -40,25 +40,29 @@ import { AxiosError } from 'axios'
|
||||
import { ToastAction } from '@/components/ui/toast'
|
||||
import { useLocation, useNavigate } from '@tanstack/react-router'
|
||||
import { Button } from '@/components/button'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import i18n from '@/i18n'
|
||||
|
||||
type UserAuthFormProps = HTMLAttributes<HTMLDivElement>
|
||||
|
||||
const formSchema = z.object({
|
||||
const getFormSchema = (t: (key: string, options?: Record<string, any>) => string) => z.object({
|
||||
username: z
|
||||
.string(),
|
||||
password: z
|
||||
.string()
|
||||
.min(1, { message: 'Please enter your password' })
|
||||
.min(4, { message: 'Password must be at least 4 characters long' }),
|
||||
.min(1, { message: t('validation.pleaseEnterPassword') })
|
||||
.min(4, { message: t('validation.passwordMinLength', { min: 4 }) }),
|
||||
});
|
||||
|
||||
export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { search } = useLocation();
|
||||
const redirect = new URLSearchParams(search).get('redirect') || '/';
|
||||
|
||||
const formSchema = getFormSchema(t)
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
@@ -82,19 +86,20 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
|
||||
navigate({ to: redirect });
|
||||
},
|
||||
onError: (error) => {
|
||||
const { t } = i18n
|
||||
if (error instanceof AxiosError && error.response && error.response.status === 401) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Login Failed",
|
||||
description: "Invalid password. Please try again.",
|
||||
action: <ToastAction altText="Try again">Try again</ToastAction>,
|
||||
title: t('auth.loginFailed'),
|
||||
description: t('auth.invalidPassword'),
|
||||
action: <ToastAction altText={t('common.tryAgain')}>{t('common.tryAgain')}</ToastAction>,
|
||||
})
|
||||
} else {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Something went wrong",
|
||||
title: t('auth.somethingWentWrong'),
|
||||
description: (error as Error).message,
|
||||
action: <ToastAction altText="Try again">Try again</ToastAction>,
|
||||
action: <ToastAction altText={t('common.tryAgain')}>{t('common.tryAgain')}</ToastAction>,
|
||||
})
|
||||
}
|
||||
setIsLoading(false)
|
||||
@@ -112,7 +117,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
|
||||
name='username'
|
||||
render={({ field }) => (
|
||||
<FormItem className='space-y-1'>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormLabel>{t('auth.username')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input disabled {...field} value={"root"} />
|
||||
</FormControl>
|
||||
@@ -126,7 +131,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
|
||||
render={({ field }) => (
|
||||
<FormItem className='space-y-1'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormLabel>{t('auth.password')}</FormLabel>
|
||||
</div>
|
||||
<FormControl>
|
||||
<PasswordInput placeholder='********' {...field} />
|
||||
@@ -136,7 +141,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
|
||||
)}
|
||||
/>
|
||||
<Button className='mt-2' loading={isLoading}>
|
||||
Login
|
||||
{t('auth.login')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -19,8 +19,10 @@
|
||||
|
||||
import Logo from '@/assets/logo.svg'
|
||||
import { UserAuthForm } from './components/user-auth-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function SignIn() {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className='container relative flex h-svh flex-col items-center justify-center'>
|
||||
<div className='p-8 flex flex-col items-center'>
|
||||
@@ -32,7 +34,7 @@ export default function SignIn() {
|
||||
alt='Bichon Logo'
|
||||
/>
|
||||
<h2 className='mb-4 text-lg font-medium text-muted-foreground'>
|
||||
Welcome to Bichon
|
||||
{t('auth.welcome')}
|
||||
</h2>
|
||||
<div className='mx-auto flex w-full flex-col justify-center space-y-2 sm:w-[350px]'>
|
||||
<UserAuthForm />
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
//
|
||||
// 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
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -22,30 +22,44 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, BarChart, Bar } from 'recharts';
|
||||
import { Mail, HardDrive, Database, Users, Inbox } from 'lucide-react';
|
||||
import { Mail, HardDrive, Database, Users, Inbox, Info } from 'lucide-react';
|
||||
import { formatBytes, formatNumber } from '@/lib/utils';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { get_dashboard_stats, TimeBucket } from '@/api/system/api';
|
||||
import { Main } from '@/components/layout/main';
|
||||
import { FixedHeader } from '@/components/layout/fixed-header';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface DailyActivity {
|
||||
date: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
function convertRecentActivity(timeBuckets: TimeBucket[]): DailyActivity[] {
|
||||
function convertRecentActivity(timeBuckets: TimeBucket[], locale: string): DailyActivity[] {
|
||||
const dateFormatter = new Intl.DateTimeFormat(locale, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
return timeBuckets.map(bucket => {
|
||||
const date = new Date(bucket.timestamp_ms);
|
||||
const mm = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(date.getDate()).padStart(2, '0');
|
||||
return {
|
||||
date: `${mm}-${dd}`,
|
||||
date: dateFormatter.format(date),
|
||||
count: bucket.count,
|
||||
timestamp_ms: bucket.timestamp_ms,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const formatTooltipDate = (timestamp_ms: number, locale: string): string => {
|
||||
const date = new Date(timestamp_ms);
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
const COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444'];
|
||||
|
||||
// Skeleton Components
|
||||
@@ -93,17 +107,18 @@ export default function MailArchiveDashboard() {
|
||||
queryFn: get_dashboard_stats,
|
||||
});
|
||||
|
||||
// 计算附件比例
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const currentLocale = i18n.resolvedLanguage || i18n.language || navigator.language;
|
||||
|
||||
const totalAttachments = (stats?.with_attachment_count ?? 0) + (stats?.without_attachment_count ?? 0);
|
||||
const attachmentRatio = totalAttachments > 0 ? (stats?.with_attachment_count ?? 0) / totalAttachments : 0;
|
||||
|
||||
// 空状态判断
|
||||
const hasRecentActivity = stats?.recent_activity && stats.recent_activity.length > 0;
|
||||
const hasTopSenders = stats?.top_senders && stats.top_senders.length > 0;
|
||||
const hasTopEmails = stats?.top_largest_emails && stats.top_largest_emails.length > 0;
|
||||
const hasTopAccounts = stats?.top_accounts && stats.top_accounts.length > 0;
|
||||
|
||||
// 附件饼图数据(空时显示“无数据”)
|
||||
const attachmentData = totalAttachments > 0
|
||||
? [
|
||||
{ name: 'With Attachments', value: attachmentRatio, fill: COLORS[1] },
|
||||
@@ -124,8 +139,8 @@ export default function MailArchiveDashboard() {
|
||||
<Skeleton className="h-7 w-28 rounded-full" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-6">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<MetricCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
@@ -151,95 +166,128 @@ export default function MailArchiveDashboard() {
|
||||
<FixedHeader />
|
||||
<Main higher>
|
||||
<div className="flex-1 space-y-6 p-6 md:p-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight">Dashboard</h2>
|
||||
<h2 className="text-3xl font-bold tracking-tight">{t('dashboard.title')}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Core Metric Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Mail Accounts</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">{t('dashboard.mailAccounts')}</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{formatNumber(stats!.account_count)}</div>
|
||||
<p className="text-xs text-muted-foreground">Connected</p>
|
||||
<p className="text-xs text-muted-foreground">{t('dashboard.connected')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Emails</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">{t('dashboard.totalEmails')}</CardTitle>
|
||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{formatNumber(stats!.email_count)}</div>
|
||||
<p className="text-xs text-muted-foreground">Synced locally</p>
|
||||
<p className="text-xs text-muted-foreground">{t('dashboard.syncedLocally')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Email Size</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">{t('dashboard.totalEmailSize')}</CardTitle>
|
||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{formatBytes(stats!.total_size_bytes)}</div>
|
||||
<p className="text-xs text-muted-foreground">Logical volume</p>
|
||||
<p className="text-xs text-muted-foreground">{t('dashboard.logicalVolume')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Local Data Files</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">{t('dashboard.localDataFiles')}</CardTitle>
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{formatBytes(stats!.storage_usage_bytes)}</div>
|
||||
<p className="text-xs text-muted-foreground">Actual disk usage</p>
|
||||
<p className="text-xs text-muted-foreground">{t('dashboard.actualDiskUsage')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Index Size</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">{t('dashboard.indexSize')}</CardTitle>
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{formatBytes(stats!.index_usage_bytes)}</div>
|
||||
<p className="text-xs text-muted-foreground">Tantivy index</p>
|
||||
<p className="text-xs text-muted-foreground">{t('dashboard.tantivyIndex')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{t('dashboard.systemVersion')}</CardTitle>
|
||||
<Info className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='text-2xl font-bold'>
|
||||
{stats!.system_version ? (
|
||||
<a
|
||||
href={`https://github.com/rustmailer/bichon/releases/tag/${stats!.system_version}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline"
|
||||
>
|
||||
{stats!.system_version}
|
||||
</a>
|
||||
) : 'N/A'}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{stats!.commit_hash ?? 'N/A'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue="trend" className="space-y-4">
|
||||
<TabsList className="grid w-full grid-cols-3 lg:w-auto">
|
||||
<TabsTrigger value="trend">30-Day Trend</TabsTrigger>
|
||||
<TabsTrigger value="attachment">Attachments</TabsTrigger>
|
||||
<TabsTrigger value="top">Top Lists</TabsTrigger>
|
||||
<TabsTrigger value="trend">{t('dashboard.dayTrend')}</TabsTrigger>
|
||||
<TabsTrigger value="attachment">{t('dashboard.attachments')}</TabsTrigger>
|
||||
<TabsTrigger value="top">{t('dashboard.topLists')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 30-Day Trend */}
|
||||
<TabsContent value="trend" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>New Emails</CardTitle>
|
||||
<CardDescription>Message distribution</CardDescription>
|
||||
<CardTitle>{t('dashboard.newEmails')}</CardTitle>
|
||||
<CardDescription>{t('dashboard.messageDistribution')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="h-80">
|
||||
{hasRecentActivity ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={convertRecentActivity(stats!.recent_activity)} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||
<BarChart data={convertRecentActivity(stats!.recent_activity, currentLocale)} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 12 }} />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 12 }} interval="preserveStart" tickCount={10} />
|
||||
<YAxis tick={{ fontSize: 12 }} />
|
||||
<Tooltip
|
||||
formatter={(v) => formatNumber(v as number)}
|
||||
content={({ payload }) => {
|
||||
if (payload && payload.length) {
|
||||
const dataPoint = payload[0].payload;
|
||||
const fullDate = formatTooltipDate(dataPoint.timestamp_ms, currentLocale);
|
||||
return (
|
||||
<div className="p-2 border rounded-lg shadow-md bg-white dark:bg-gray-800">
|
||||
<p className="font-semibold text-sm mb-1">{fullDate}</p>
|
||||
<p className="text-xs">{t('dashboard.emails')}: {formatNumber(dataPoint.count)}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--background))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
@@ -250,7 +298,7 @@ export default function MailArchiveDashboard() {
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<EmptyChart title="No recent activity" />
|
||||
<EmptyChart title={t('dashboard.noRecentActivity')} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -260,11 +308,11 @@ export default function MailArchiveDashboard() {
|
||||
<TabsContent value="attachment" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Attachment Ratio</CardTitle>
|
||||
<CardTitle>{t('dashboard.attachmentRatio')}</CardTitle>
|
||||
<CardDescription>
|
||||
{totalAttachments > 0
|
||||
? `${(attachmentRatio * 100).toFixed(1)}% of emails have attachments`
|
||||
: 'No emails synced yet'}
|
||||
? t('dashboard.attachmentRatioDesc', { percent: (attachmentRatio * 100).toFixed(1) })
|
||||
: t('dashboard.noEmailsSynced')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-center h-80">
|
||||
@@ -292,18 +340,18 @@ export default function MailArchiveDashboard() {
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-3 w-3 rounded-full bg-green-500"></div>
|
||||
<span className="text-sm">
|
||||
With Attachments ({(attachmentRatio * 100).toFixed(1)}%)
|
||||
{t('dashboard.withAttachments')} ({(attachmentRatio * 100).toFixed(1)}%)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-3 w-3 rounded-full bg-gray-200"></div>
|
||||
<span className="text-sm">No Attachments</span>
|
||||
<span className="text-sm">{t('dashboard.noAttachments')}</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-3 w-3 rounded-full bg-gray-200"></div>
|
||||
<span className="text-sm">No Data</span>
|
||||
<span className="text-sm">{t('common.noData')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -311,23 +359,20 @@ export default function MailArchiveDashboard() {
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Top Lists */}
|
||||
<TabsContent value="top" className="space-y-6">
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
|
||||
{/* Top 10 Senders */}
|
||||
<Card className="md:col-span-1">
|
||||
<CardHeader>
|
||||
<CardTitle>Top 10 Senders</CardTitle>
|
||||
<CardDescription>By message count</CardDescription>
|
||||
<CardTitle>{t('dashboard.top10Senders')}</CardTitle>
|
||||
<CardDescription>{t('dashboard.byMessageCount')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{hasTopSenders ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Sender</TableHead>
|
||||
<TableHead className="text-right">Count</TableHead>
|
||||
<TableHead>{t('dashboard.sender')}</TableHead>
|
||||
<TableHead className="text-right">{t('dashboard.count')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -342,24 +387,22 @@ export default function MailArchiveDashboard() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<EmptyTable title="No senders data" />
|
||||
<EmptyTable title={t('dashboard.noSendersData')} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Top 10 Largest Emails */}
|
||||
<Card className="md:col-span-1">
|
||||
<CardHeader>
|
||||
<CardTitle>Top 10 Largest Emails</CardTitle>
|
||||
<CardDescription>By message size</CardDescription>
|
||||
<CardTitle>{t('dashboard.top10LargestEmails')}</CardTitle>
|
||||
<CardDescription>{t('dashboard.byMessageSize')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{hasTopEmails ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Subject</TableHead>
|
||||
<TableHead className="text-right">Size</TableHead>
|
||||
<TableHead>{t('dashboard.subject')}</TableHead>
|
||||
<TableHead className="text-right">{t('dashboard.size')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -369,7 +412,7 @@ export default function MailArchiveDashboard() {
|
||||
className="max-w-[180px] truncate font-medium"
|
||||
title={m.subject}
|
||||
>
|
||||
{m.subject || '(No Subject)'}
|
||||
{m.subject || t('dashboard.noSubject')}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{formatBytes(m.size_bytes)}</TableCell>
|
||||
</TableRow>
|
||||
@@ -377,24 +420,22 @@ export default function MailArchiveDashboard() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<EmptyTable title="No large emails" />
|
||||
<EmptyTable title={t('dashboard.noLargeEmails')} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Top 10 Accounts */}
|
||||
<Card className="md:col-span-1">
|
||||
<CardHeader>
|
||||
<CardTitle>Top 10 Accounts</CardTitle>
|
||||
<CardDescription>By message count</CardDescription>
|
||||
<CardTitle>{t('dashboard.top10Accounts')}</CardTitle>
|
||||
<CardDescription>{t('dashboard.byMessageCount')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{hasTopAccounts ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Account</TableHead>
|
||||
<TableHead className="text-right">Emails</TableHead>
|
||||
<TableHead>{t('dashboard.account')}</TableHead>
|
||||
<TableHead className="text-right">{t('dashboard.emails')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -409,7 +450,7 @@ export default function MailArchiveDashboard() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<EmptyTable title="No account data" />
|
||||
<EmptyTable title={t('dashboard.noAccountData')} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -417,6 +458,11 @@ export default function MailArchiveDashboard() {
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Footer / Copyright - New Addition */}
|
||||
<div className="p-6 md:p-8 pt-0 text-center text-xs text-muted-foreground">
|
||||
© 2025 <a href="https://rustmailer.com" target="_blank" rel="noopener noreferrer" className="hover:underline">rustmailer.com</a> - Bichon Email Archiving Project
|
||||
</div>
|
||||
</Main>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -19,24 +19,25 @@
|
||||
|
||||
import { useNavigate, useRouter } from '@tanstack/react-router'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function ForbiddenError() {
|
||||
const navigate = useNavigate()
|
||||
const { history } = useRouter()
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className='h-svh'>
|
||||
<div className='m-auto flex h-full w-full flex-col items-center justify-center gap-2'>
|
||||
<h1 className='text-[7rem] font-bold leading-tight'>403</h1>
|
||||
<span className='font-medium'>Access Forbidden</span>
|
||||
<span className='font-medium'>{t('errors.forbiddenTitle')}</span>
|
||||
<p className='text-center text-muted-foreground'>
|
||||
You don't have necessary permission <br />
|
||||
to view this resource.
|
||||
{t('errors.forbiddenDesc')}
|
||||
</p>
|
||||
<div className='mt-6 flex gap-4'>
|
||||
<Button variant='outline' onClick={() => history.go(-1)}>
|
||||
Go Back
|
||||
{t('common.goBack')}
|
||||
</Button>
|
||||
<Button onClick={() => navigate({ to: '/' })}>Back to Home</Button>
|
||||
<Button onClick={() => navigate({ to: '/' })}>{t('common.backToHome')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import { useNavigate, useRouter } from '@tanstack/react-router'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface GeneralErrorProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
minimal?: boolean
|
||||
@@ -31,22 +32,23 @@ export default function GeneralError({
|
||||
}: GeneralErrorProps) {
|
||||
const navigate = useNavigate()
|
||||
const { history } = useRouter()
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className={cn('h-svh w-full', className)}>
|
||||
<div className='m-auto flex h-full w-full flex-col items-center justify-center gap-2'>
|
||||
{!minimal && (
|
||||
<h1 className='text-[7rem] font-bold leading-tight'>500</h1>
|
||||
)}
|
||||
<span className='font-medium'>Oops! Something went wrong {`:')`}</span>
|
||||
<span className='font-medium'>{t('errors.somethingWentWrong')}</span>
|
||||
<p className='text-center text-muted-foreground'>
|
||||
We apologize for the inconvenience. <br /> Please try again later.
|
||||
{t('errors.somethingWentWrongDesc')}
|
||||
</p>
|
||||
{!minimal && (
|
||||
<div className='mt-6 flex gap-4'>
|
||||
<Button variant='outline' onClick={() => history.go(-1)}>
|
||||
Go Back
|
||||
{t('common.goBack')}
|
||||
</Button>
|
||||
<Button onClick={() => navigate({ to: '/' })}>Back to Home</Button>
|
||||
<Button onClick={() => navigate({ to: '/' })}>{t('common.backToHome')}</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user