mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-31 01:52:30 +00:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1453f3cda | ||
|
|
9e656b1f91 | ||
|
|
afac192280 | ||
|
|
c00ffe8d11 | ||
|
|
c90a2d552d | ||
|
|
a4627a63cb | ||
|
|
684b9765fe | ||
|
|
b0ce7671a8 | ||
|
|
4b13bf14c4 | ||
|
|
0015192b4d | ||
|
|
2f3acdd759 | ||
|
|
0fc83b693e | ||
|
|
9ba56ca5bb | ||
|
|
8f7244ccb9 |
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -424,7 +424,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bichon"
|
||||
version = "0.1.2"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"async-imap",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "bichon"
|
||||
version = "0.1.2"
|
||||
version = "0.1.4"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
|
||||
140
README.md
140
README.md
@@ -22,7 +22,7 @@
|
||||
<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/evFnSpdpaE">
|
||||
<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">
|
||||
@@ -151,41 +151,116 @@ docker run -d \
|
||||
rustmailer/bichon:latest
|
||||
```
|
||||
|
||||
## CORS Configuration (Important for Browser Access)
|
||||
|
||||
* **Accessing Bichon from a browser:**
|
||||
You need to add the exact address you use in your browser to `BICHON_CORS_ORIGINS`.
|
||||
Starting from **v0.1.4**, Bichon changes how `BICHON_CORS_ORIGINS` works:
|
||||
|
||||
* If you access via **IP**, add `IP:port`, e.g.:
|
||||
### **🔄 New Behavior in v0.1.4**
|
||||
|
||||
```
|
||||
http://192.168.1.16:15630
|
||||
```
|
||||
* If you access via **hostname**, add `hostname:port`, e.g.:
|
||||
* 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.
|
||||
|
||||
```
|
||||
http://myserver.local:15630
|
||||
```
|
||||
* If you access via **domain name**, add the domain, e.g.:
|
||||
#### How CORS Matching Works
|
||||
|
||||
```
|
||||
http://mydomain.com
|
||||
```
|
||||
* **If Bichon is running on port 80**, you **do not need to include the port**.
|
||||
* If you want to access Bichon in **multiple ways**, include all of them separated by commas.
|
||||
When a browser accesses Bichon, it will send an `Origin` header.
|
||||
|
||||
Example Docker run:
|
||||
* **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
|
||||
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_CORS_ORIGINS="http://192.168.1.16:15630,http://myserver.local:15630,http://mydomain.com" \
|
||||
rustmailer/bichon:latest
|
||||
-e BICHON_CORS_ORIGINS="http://localhost:15630,http://myserver.local:15630"
|
||||
```
|
||||
> **Tip:** Do not add a trailing `/`. Using `*` allows all addresses, but is **not recommended** for security.
|
||||
|
||||
* 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
|
||||
|
||||
@@ -289,6 +364,13 @@ You can change the password via the WebUI:
|
||||
> 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
|
||||
@@ -304,7 +386,7 @@ You can change the password via the WebUI:
|
||||
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/evFnSpdpaE">
|
||||
<a href="https://discord.gg/Bq4M2cDmF4">
|
||||
<img src="https://img.shields.io/badge/Discord-Join%20Server-7289DA?logo=discord&logoColor=white" alt="Discord">
|
||||
</a>
|
||||
|
||||
@@ -370,7 +452,7 @@ This project is licensed under [AGPLv3](LICENSE).
|
||||
|
||||
- [Docker Hub](https://hub.docker.com/r/rustmailer/bichon)
|
||||
- [Issue Tracker](https://github.com/rustmailer/bichon/issues)
|
||||
- [Discord](https://discord.gg/evFnSpdpaE)
|
||||
- [Discord](https://discord.gg/Bq4M2cDmF4)
|
||||
|
||||
|
||||
## 💖 Support & Promotion
|
||||
|
||||
@@ -349,7 +349,11 @@ impl AccountV2 {
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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, *",
|
||||
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(
|
||||
|
||||
@@ -23,11 +23,11 @@ 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 getColumns = (t: (key: string) => string): ColumnDef<AccessToken>[] => [
|
||||
export const getColumns = (t: (key: string) => string, locale: Locale): ColumnDef<AccessToken>[] => [
|
||||
{
|
||||
accessorKey: 'token',
|
||||
header: ({ column }) => (
|
||||
@@ -114,7 +114,7 @@ export const getColumns = (t: (key: string) => string): ColumnDef<AccessToken>[]
|
||||
if (last_access_at === 0) {
|
||||
return <LongText className='max-w-40'>{t('accessTokens.notUsedYet')}</LongText>;
|
||||
}
|
||||
const result = formatDistanceToNow(new Date(last_access_at), { addSuffix: true });
|
||||
const result = formatDistanceToNow(new Date(last_access_at), { addSuffix: true, locale });
|
||||
return <LongText className='max-w-40'>{result}</LongText>;
|
||||
},
|
||||
meta: { className: 'w-40' },
|
||||
|
||||
@@ -38,9 +38,12 @@ 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 } = useTranslation()
|
||||
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)
|
||||
@@ -50,7 +53,7 @@ export default function AccessTokens() {
|
||||
queryFn: list_access_tokens,
|
||||
})
|
||||
|
||||
const columns = getColumns(t)
|
||||
const columns = getColumns(t, locale)
|
||||
|
||||
return (
|
||||
<AccessTokensProvider value={{ open, setOpen, currentRow, setCurrentRow }}>
|
||||
@@ -58,43 +61,43 @@ export default function AccessTokens() {
|
||||
<FixedHeader />
|
||||
|
||||
<Main>
|
||||
<div className="mx-auto mb-2 flex max-w-5xl flex-wrap items-center justify-between gap-x-4 gap-y-2 px-2">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">{t('accessTokens.title')}</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{t('accessTokens.description')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button className="space-x-1" onClick={() => setOpen('add')}>
|
||||
<span>{t('common.add')}</span> <Plus size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto flex-1 overflow-auto px-4 py-1 flex-row lg:space-x-12 space-y-0 max-w-5xl">
|
||||
{isLoading ? (
|
||||
<TableSkeleton columns={columns.length} rows={10} />
|
||||
) : accessTokens?.length ? (
|
||||
<AccessTokensTable data={accessTokens} columns={columns} />
|
||||
) : (
|
||||
<div className="flex h-[450px] shrink-0 items-center justify-center rounded-md border border-dashed">
|
||||
<div className="mx-auto flex max-w-[420px] flex-col items-center justify-center text-center">
|
||||
<img
|
||||
src={Logo}
|
||||
className="max-h-[100px] w-auto opacity-20 saturate-0 transition-all duration-300 hover:opacity-100 hover:saturate-100 object-contain"
|
||||
alt="Bichon Logo"
|
||||
/>
|
||||
<h3 className="mt-4 text-lg font-semibold">{t('accessTokens.noTokens')}</h3>
|
||||
<p className="mb-4 mt-2 text-sm text-muted-foreground">
|
||||
{t('accessTokens.noTokensDesc')}
|
||||
</p>
|
||||
<Button onClick={() => setOpen('add')}>{t('accessTokens.create')}</Button>
|
||||
<div 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
|
||||
|
||||
@@ -120,6 +120,10 @@ export function AccountDetailDrawer({ open, onOpenChange, currentRow }: Props) {
|
||||
<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">{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" ? (
|
||||
|
||||
@@ -139,7 +139,7 @@ export type Steps = [
|
||||
|
||||
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"] },
|
||||
{ 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: [] },
|
||||
];
|
||||
|
||||
@@ -42,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
|
||||
@@ -50,7 +52,8 @@ interface Props {
|
||||
}
|
||||
|
||||
export function OAuth2TokensDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const { t, i18n } = useTranslation()
|
||||
const locale = dateFnsLocaleMap[i18n.language.toLowerCase()] ?? enUS;
|
||||
const navigate = useNavigate()
|
||||
const { data: oauth2Tokens, isLoading } = useQuery({
|
||||
queryKey: ['oauth2-tokens', currentRow.id],
|
||||
@@ -151,7 +154,7 @@ export function OAuth2TokensDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
<TableRow>
|
||||
<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>
|
||||
|
||||
@@ -34,6 +34,8 @@ 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,7 +44,8 @@ interface Props {
|
||||
}
|
||||
|
||||
export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
const { t } = useTranslation()
|
||||
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),
|
||||
@@ -124,7 +127,7 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
<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">
|
||||
@@ -143,7 +146,7 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
<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">
|
||||
@@ -192,7 +195,7 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
<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">{t('accounts.runningState.notStarted')}</span>
|
||||
)}
|
||||
@@ -202,7 +205,7 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
<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">{t('accounts.runningState.inProgress')}</span>
|
||||
) : (
|
||||
@@ -243,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>
|
||||
|
||||
@@ -66,22 +66,6 @@ export default function Step1({ isEdit }: StepProps) {
|
||||
</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>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -128,6 +128,22 @@ export default function Step2({ isEdit }: StepProps) {
|
||||
</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"
|
||||
|
||||
@@ -58,6 +58,10 @@ export default function Step4() {
|
||||
<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>
|
||||
|
||||
@@ -35,18 +35,31 @@ interface DailyActivity {
|
||||
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
|
||||
@@ -94,7 +107,10 @@ export default function MailArchiveDashboard() {
|
||||
queryFn: get_dashboard_stats,
|
||||
});
|
||||
|
||||
const { t } = useTranslation();
|
||||
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;
|
||||
|
||||
@@ -253,12 +269,25 @@ export default function MailArchiveDashboard() {
|
||||
<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))',
|
||||
|
||||
@@ -22,6 +22,7 @@ import useMinimalAccountList from "@/hooks/use-minimal-account-list";
|
||||
import { VirtualizedSelect } from "@/components/virtualized-select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
|
||||
interface AccountSwitcherProps {
|
||||
@@ -35,7 +36,7 @@ export function AccountSwitcher({
|
||||
}: AccountSwitcherProps) {
|
||||
const { accountsOptions, isLoading } = useMinimalAccountList();
|
||||
const navigate = useNavigate()
|
||||
|
||||
const { t } = useTranslation();
|
||||
if (isLoading) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
@@ -47,7 +48,7 @@ export function AccountSwitcher({
|
||||
options={accountsOptions}
|
||||
defaultValue={`${defaultAccountId}`}
|
||||
onSelectOption={(values) => onAccountSelect(parseInt(values[0], 10))}
|
||||
placeholder="Select an account"
|
||||
placeholder={t('oauth2.selectAnAccount')}
|
||||
noItemsComponent={<div className='space-y-2'>
|
||||
<p>No active email account.</p>
|
||||
<Button variant={'outline'} className="py-1 px-3 text-xs" onClick={() => navigate({ to: '/accounts' })}>Add Email Account</Button>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import { cn, formatBytes } from "@/lib/utils"
|
||||
import { cn, dateFnsLocaleMap, formatBytes } from "@/lib/utils"
|
||||
import { formatDistanceToNow } from "date-fns"
|
||||
import { MailIcon, Paperclip, Trash2 } from "lucide-react"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
@@ -27,6 +27,7 @@ import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { MailBulkActions } from "./bulk-actions"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { enUS } from "date-fns/locale"
|
||||
|
||||
interface MailListProps {
|
||||
items: EmailEnvelope[]
|
||||
@@ -37,8 +38,9 @@ export function MailList({
|
||||
items,
|
||||
isLoading,
|
||||
}: MailListProps) {
|
||||
const { t } = useTranslation()
|
||||
const { t, i18n } = useTranslation()
|
||||
const { currentEnvelope, setCurrentEnvelope, setDeleteIds, setOpen, selected, setSelected } = useMailboxContext()
|
||||
const locale = dateFnsLocaleMap[i18n.language.toLowerCase()] ?? enUS;
|
||||
|
||||
const handleDelete = (envelope: EmailEnvelope) => {
|
||||
setDeleteIds(new Set([envelope.id]))
|
||||
@@ -177,7 +179,7 @@ export function MailList({
|
||||
<span className={cn(
|
||||
isSelected ? "text-foreground font-medium" : "text-muted-foreground"
|
||||
)}>
|
||||
{item.date && formatDistanceToNow(new Date(item.date), { addSuffix: true })}
|
||||
{item.date && formatDistanceToNow(new Date(item.date), { addSuffix: true, locale })}
|
||||
</span>
|
||||
|
||||
<button
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import { cn, formatBytes } from "@/lib/utils"
|
||||
import { cn, dateFnsLocaleMap, formatBytes } from "@/lib/utils"
|
||||
import { formatDistanceToNow } from "date-fns"
|
||||
import { MailIcon, MoreVertical, Paperclip, TagIcon, Trash2 } from "lucide-react"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
@@ -29,6 +29,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { enUS } from "date-fns/locale"
|
||||
|
||||
interface MailListProps {
|
||||
items: EmailEnvelope[]
|
||||
@@ -41,7 +42,9 @@ export function MailList({
|
||||
isLoading,
|
||||
onEnvelopeChanged
|
||||
}: MailListProps) {
|
||||
const { t } = useTranslation()
|
||||
const { t, i18n } = useTranslation()
|
||||
|
||||
const locale = dateFnsLocaleMap[i18n.language.toLowerCase()] ?? enUS;
|
||||
const { setOpen, currentEnvelope, setCurrentEnvelope, selected, setSelected, setToDelete } = useSearchContext()
|
||||
|
||||
const handleToggleAll = () => {
|
||||
@@ -212,7 +215,7 @@ export function MailList({
|
||||
<span className="hidden md:inline">{formatBytes(item.size)}</span>
|
||||
|
||||
<span className={cn(isSelectedRow ? "text-foreground font-medium" : "text-muted-foreground")}>
|
||||
{item.date && formatDistanceToNow(new Date(item.date), { addSuffix: true })}
|
||||
{item.date && formatDistanceToNow(new Date(item.date), { addSuffix: true, locale })}
|
||||
</span>
|
||||
|
||||
<DropdownMenu>
|
||||
|
||||
@@ -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/>.
|
||||
|
||||
|
||||
import { enUS, zhCN, zhTW, arSA, de, es, fi, fr, it, ja, ko, nl, ptBR, ru, da, sv, nb, Locale } from 'date-fns/locale';
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
@@ -70,8 +70,12 @@ export function mapToRecordOfArrays(
|
||||
);
|
||||
}
|
||||
|
||||
export function formatNumber(num: number) {
|
||||
return new Intl.NumberFormat('en-US').format(num);
|
||||
export function formatNumber(num: number): string {
|
||||
const userLocale = navigator.language;
|
||||
|
||||
return new Intl.NumberFormat(userLocale, {
|
||||
maximumFractionDigits: 2,
|
||||
}).format(num);
|
||||
}
|
||||
|
||||
|
||||
@@ -126,4 +130,45 @@ export function formatTimestamp(milliseconds: number): string {
|
||||
const offsetHours = String(Math.floor(Math.abs(timezoneOffset) / 60)).padStart(2, '0');
|
||||
const offsetMinutes = String(Math.abs(timezoneOffset) % 60).padStart(2, '0');
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}${offsetSign}${offsetHours}:${offsetMinutes}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// i18n.language -> date-fns locale
|
||||
export const dateFnsLocaleMap: Record<string, Locale> = {
|
||||
en: enUS,
|
||||
'en-us': enUS,
|
||||
zh: zhCN,
|
||||
'zh-cn': zhCN,
|
||||
'zh-tw': zhTW,
|
||||
'zh_hk': zhTW,
|
||||
ar: arSA,
|
||||
'ar-sa': arSA,
|
||||
de: de,
|
||||
'de-de': de,
|
||||
es: es,
|
||||
'es-es': es,
|
||||
fi: fi,
|
||||
'fi-fi': fi,
|
||||
fr: fr,
|
||||
'fr-fr': fr,
|
||||
it: it,
|
||||
'it-it': it,
|
||||
jp: ja,
|
||||
ja: ja,
|
||||
'ja-jp': ja,
|
||||
ko: ko,
|
||||
'ko-kr': ko,
|
||||
nl: nl,
|
||||
'nl-nl': nl,
|
||||
pt: ptBR,
|
||||
'pt-br': ptBR,
|
||||
ru: ru,
|
||||
'ru-ru': ru,
|
||||
da: da,
|
||||
'da-dk': da,
|
||||
sv: sv,
|
||||
'sv-se': sv,
|
||||
no: nb,
|
||||
'no-no': nb,
|
||||
};
|
||||
@@ -138,7 +138,7 @@
|
||||
"noAccountConfigurations": "لا توجد تكوينات للحساب",
|
||||
"noAccountConfigurationsDesc": "لم تقم بإضافة أي تكوينات للحساب بعد. أضف واحدة لبدء استخدام ميزات الحساب.",
|
||||
"addConfiguration": "إضافة تكوين",
|
||||
"name": "الاسم",
|
||||
"name": "اسم الدخول",
|
||||
"email": "البريد الإلكتروني",
|
||||
"status": "الحالة",
|
||||
"type": "النوع",
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
"noAccountConfigurations": "Ingen kontokonfigurationer",
|
||||
"noAccountConfigurationsDesc": "Du har ikke tilføjet nogen kontokonfigurationer endnu. Tilføj en for at begynde at bruge kontofunktioner.",
|
||||
"addConfiguration": "Tilføj konfiguration",
|
||||
"name": "Navn",
|
||||
"name": "Logindnavn",
|
||||
"email": "E-mail",
|
||||
"status": "Status",
|
||||
"type": "Type",
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
"noAccountConfigurations": "Keine Kontokonfigurationen",
|
||||
"noAccountConfigurationsDesc": "Sie haben noch keine Kontokonfigurationen hinzugefügt. Fügen Sie eine hinzu, um mit der Nutzung der Kontofunktionen zu beginnen.",
|
||||
"addConfiguration": "Konfiguration hinzufügen",
|
||||
"name": "Name",
|
||||
"name": "Anmeldename",
|
||||
"email": "E-Mail",
|
||||
"status": "Status",
|
||||
"type": "Typ",
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
"noAccountConfigurations": "No Account Configurations",
|
||||
"noAccountConfigurationsDesc": "You haven't added any Account configurations yet. Add one to start using Account features.",
|
||||
"addConfiguration": "Add Configuration",
|
||||
"name": "Name",
|
||||
"name": "Login Name",
|
||||
"email": "Email",
|
||||
"status": "Status",
|
||||
"type": "Type",
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
"noAccountConfigurations": "Sin configuraciones de cuenta",
|
||||
"noAccountConfigurationsDesc": "Aún no has añadido ninguna configuración de cuenta. Añade una para empezar a usar las funcionalidades de la cuenta.",
|
||||
"addConfiguration": "Añadir configuración",
|
||||
"name": "Nombre",
|
||||
"name": "Nombre de usuario",
|
||||
"email": "Correo electrónico",
|
||||
"status": "Estado",
|
||||
"type": "Tipo",
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
"noAccountConfigurations": "Ei tilimäärityksiä",
|
||||
"noAccountConfigurationsDesc": "Et ole vielä lisännyt tilimäärityksiä. Lisää yksi aloittaaksesi tilitoimintojen käytön.",
|
||||
"addConfiguration": "Lisää määritys",
|
||||
"name": "Nimi",
|
||||
"name": "Kirjautumisnimi",
|
||||
"email": "Sähköposti",
|
||||
"status": "Tila",
|
||||
"type": "Tyyppi",
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
"noAccountConfigurations": "Aucune Configuration de Compte",
|
||||
"noAccountConfigurationsDesc": "Vous n'avez pas encore ajouté de Configuration de Compte. Veuillez en ajouter une pour commencer à utiliser les fonctionnalités du Compte.",
|
||||
"addConfiguration": "Ajouter Configuration",
|
||||
"name": "Nom",
|
||||
"name": "Nom de connexion",
|
||||
"email": "E-mail",
|
||||
"status": "Statut",
|
||||
"type": "Type",
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
"noAccountConfigurations": "Nessuna Configurazione Account",
|
||||
"noAccountConfigurationsDesc": "Non hai ancora aggiunto alcuna Configurazione Account. Aggiungine una per iniziare a usare le funzionalità Account.",
|
||||
"addConfiguration": "Aggiungi Configurazione",
|
||||
"name": "Nome",
|
||||
"name": "Nome di accesso",
|
||||
"email": "Email",
|
||||
"status": "Stato",
|
||||
"type": "Tipo",
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
"noAccountConfigurations": "アカウント設定がありません",
|
||||
"noAccountConfigurationsDesc": "まだアカウント設定を追加していません。機能を利用するには設定を追加してください。",
|
||||
"addConfiguration": "設定を追加",
|
||||
"name": "名前",
|
||||
"name": "ログイン名",
|
||||
"email": "メールアドレス",
|
||||
"status": "ステータス",
|
||||
"type": "タイプ",
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
"noAccountConfigurations": "계정 구성 없음",
|
||||
"noAccountConfigurationsDesc": "아직 계정 구성을 추가하지 않았습니다. 기능을 사용하려면 추가하십시오.",
|
||||
"addConfiguration": "구성 추가",
|
||||
"name": "이름",
|
||||
"name": "로그인 이름",
|
||||
"email": "이메일",
|
||||
"status": "상태",
|
||||
"type": "유형",
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
"noAccountConfigurations": "Geen Accountconfiguraties",
|
||||
"noAccountConfigurationsDesc": "U heeft nog geen Accountconfiguraties toegevoegd. Voeg er een toe om de Accountfuncties te gebruiken.",
|
||||
"addConfiguration": "Configuratie Toevoegen",
|
||||
"name": "Naam",
|
||||
"name": "Inlognaam",
|
||||
"email": "E-mail",
|
||||
"status": "Status",
|
||||
"type": "Type",
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
"noAccountConfigurations": "Ingen kontokonfigurasjoner",
|
||||
"noAccountConfigurationsDesc": "Du har ikke lagt til noen kontokonfigurasjoner ennå. Legg til en for å begynne å bruke kontofunksjoner.",
|
||||
"addConfiguration": "Legg til konfigurasjon",
|
||||
"name": "Navn",
|
||||
"name": "Påloggingsnavn",
|
||||
"email": "E-post",
|
||||
"status": "Status",
|
||||
"type": "Type",
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
"noAccountConfigurations": "Nenhuma Configuração de Conta",
|
||||
"noAccountConfigurationsDesc": "Você ainda não adicionou nenhuma configuração de conta. Adicione uma para utilizar a funcionalidade.",
|
||||
"addConfiguration": "Adicionar Configuração",
|
||||
"name": "Nome",
|
||||
"name": "Nome de login",
|
||||
"email": "Email",
|
||||
"status": "Status",
|
||||
"type": "Tipo",
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
"noAccountConfigurations": "Нет настроек учетных записей",
|
||||
"noAccountConfigurationsDesc": "Вы еще не добавили ни одной конфигурации учетной записи. Добавьте одну, чтобы начать использовать функции аккаунта.",
|
||||
"addConfiguration": "Добавить конфигурацию",
|
||||
"name": "Имя",
|
||||
"name": "Имя для входа",
|
||||
"email": "Email",
|
||||
"status": "Статус",
|
||||
"type": "Тип",
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
"noAccountConfigurations": "Inga kontokonfigurationer",
|
||||
"noAccountConfigurationsDesc": "Du har inte lagt till några kontokonfigurationer ännu. Lägg till en för att börja använda kontofunktioner.",
|
||||
"addConfiguration": "Lägg till konfiguration",
|
||||
"name": "Namn",
|
||||
"name": "Inloggningsnamn",
|
||||
"email": "E-post",
|
||||
"status": "Status",
|
||||
"type": "Typ",
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
"noAccountConfigurations": "沒有帳號設定",
|
||||
"noAccountConfigurationsDesc": "您尚未新增任何帳號設定。請新增設定以使用功能。",
|
||||
"addConfiguration": "新增設定",
|
||||
"name": "名稱",
|
||||
"name": "登入名稱",
|
||||
"email": "電子郵件",
|
||||
"status": "狀態",
|
||||
"type": "類型",
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
"noAccountConfigurations": "无账户配置",
|
||||
"noAccountConfigurationsDesc": "您尚未添加任何账户配置。添加一个以开始使用账户功能。",
|
||||
"addConfiguration": "添加配置",
|
||||
"name": "名称",
|
||||
"name": "登录名",
|
||||
"email": "邮箱",
|
||||
"status": "状态",
|
||||
"type": "类型",
|
||||
|
||||
Reference in New Issue
Block a user