Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b671450a8 | |||
| c0df6d1c16 | |||
| 35f9512d94 | |||
| 9e39161fc7 | |||
| 7c58282e42 | |||
| e0d81f2733 | |||
| 1d23b3fe5e | |||
| a0d4d7a1ff | |||
| 2f1b61e883 | |||
| 4e62c90700 | |||
| 7afb496eb0 | |||
| 5809e3eebc | |||
| 2e9bbe6801 | |||
| dc0eec7c54 | |||
| a0605299f0 | |||
| db68437a0b | |||
| 77d8411ea9 | |||
| 0c1ab3db48 | |||
| 6cc6e94b91 | |||
| 37ae493fa7 | |||
| 33f7c5d88a | |||
| c6c8979772 | |||
| a00279f47d | |||
| 669d7ef242 | |||
| 97d2117e15 | |||
| 429b747710 | |||
| a06853835d | |||
| c4ff063865 | |||
| 70b0a3a394 | |||
| e7c07fda4e | |||
| 0a001bebcc |
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"Bash(npx vite *)",
|
|
||||||
"Bash(npx tsc *)"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
## Summary
|
||||||
|
|
||||||
|
<!-- What changed and why? -->
|
||||||
|
|
||||||
|
## Change Type
|
||||||
|
|
||||||
|
- [ ] Bug fix
|
||||||
|
- [ ] Feature
|
||||||
|
- [ ] Compatibility update
|
||||||
|
- [ ] Documentation
|
||||||
|
- [ ] Refactor
|
||||||
|
|
||||||
|
## Cross-File Checklist
|
||||||
|
|
||||||
|
- [ ] I read `CONTRIBUTING.md`.
|
||||||
|
- [ ] Schema changes, if any, updated both runtime schema and `migrations/0001_init.sql`.
|
||||||
|
- [ ] Persistent data changes, if any, updated backup export/import or documented why backup is not needed.
|
||||||
|
- [ ] User-facing text changes, if any, updated all locale files.
|
||||||
|
- [ ] Bitwarden client compatibility was considered for sync/API shape changes.
|
||||||
|
- [ ] No secrets, tokens, private deployment values, or real vault data are included.
|
||||||
|
|
||||||
|
## Checks
|
||||||
|
|
||||||
|
- [ ] `npx tsc -p tsconfig.json --noEmit`
|
||||||
|
- [ ] `npx tsc -p webapp/tsconfig.json --noEmit`
|
||||||
|
- [ ] `npm run i18n:validate`
|
||||||
|
- [ ] `npm run build`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
<!-- Anything reviewers should pay special attention to? -->
|
||||||
@@ -67,7 +67,7 @@ class SecurityReport {
|
|||||||
guideStep1: '1. **开发人员**:使用上方表格中的 **位置** 列找到确切的文件和行号。',
|
guideStep1: '1. **开发人员**:使用上方表格中的 **位置** 列找到确切的文件和行号。',
|
||||||
guideStep2: '2. **纠正**:遵循为每个规则提供的文档链接以提交修复。',
|
guideStep2: '2. **纠正**:遵循为每个规则提供的文档链接以提交修复。',
|
||||||
guideStep3: '3. **可追溯性**:完整的原始 `.sarif` 数据已附加到此分支。下载并将其导入您的 IDE(例如 VS Code SARIF 查看器)进行本地分析。',
|
guideStep3: '3. **可追溯性**:完整的原始 `.sarif` 数据已附加到此分支。下载并将其导入您的 IDE(例如 VS Code SARIF 查看器)进行本地分析。',
|
||||||
footer: '💡 *由 Antigravity AI 安全引擎生成。透明度是我们的承诺。*',
|
footer: '💡 *由 NodeWarden 安全工作流生成。透明度是我们的承诺。*',
|
||||||
auditedIcon: '✅ **已审计**',
|
auditedIcon: '✅ **已审计**',
|
||||||
noFiles: '未检索到文件。',
|
noFiles: '未检索到文件。',
|
||||||
trivyTitle: '🛡️ 容器配置安全 (Trivy)',
|
trivyTitle: '🛡️ 容器配置安全 (Trivy)',
|
||||||
@@ -119,7 +119,7 @@ class SecurityReport {
|
|||||||
guideStep1: '1. **Developers**: Use the **Location** column in the tables above to find the exact file and line number.',
|
guideStep1: '1. **Developers**: Use the **Location** column in the tables above to find the exact file and line number.',
|
||||||
guideStep2: '2. **Remediate**: Follow the documentation links provided for each rule to submit a fix.',
|
guideStep2: '2. **Remediate**: Follow the documentation links provided for each rule to submit a fix.',
|
||||||
guideStep3: '3. **Traceability**: Full raw `.sarif` data is attached to this branch. Download and import it into your IDE (e.g., VS Code SARIF Viewer) for local analysis.',
|
guideStep3: '3. **Traceability**: Full raw `.sarif` data is attached to this branch. Download and import it into your IDE (e.g., VS Code SARIF Viewer) for local analysis.',
|
||||||
footer: '💡 *Generated by Antigravity AI Security Engine. Transparency is our commitment.*',
|
footer: '💡 *Generated by the NodeWarden security workflow. Transparency is our commitment.*',
|
||||||
auditedIcon: '✅ **Audited**',
|
auditedIcon: '✅ **Audited**',
|
||||||
noFiles: 'No files found.',
|
noFiles: 'No files found.',
|
||||||
trivyTitle: '🛡️ Container Config Security (Trivy)',
|
trivyTitle: '🛡️ Container Config Security (Trivy)',
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
name: Sync Bitwarden global domains
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "17 4 * * 1"
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
bitwarden_ref:
|
||||||
|
description: "bitwarden/server ref to sync"
|
||||||
|
required: false
|
||||||
|
default: "main"
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sync-global-domains:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Sync generated Bitwarden domains
|
||||||
|
run: npm run domains:sync -- --ref "${{ inputs.bitwarden_ref || 'main' }}"
|
||||||
|
|
||||||
|
- name: Verify custom domains were not touched
|
||||||
|
run: git diff --exit-code -- src/static/global_domains.custom.json
|
||||||
|
|
||||||
|
- name: Create pull request
|
||||||
|
uses: peter-evans/create-pull-request@v6
|
||||||
|
with:
|
||||||
|
branch: chore/sync-bitwarden-global-domains
|
||||||
|
delete-branch: true
|
||||||
|
title: "chore: sync Bitwarden global domain rules"
|
||||||
|
commit-message: "chore: sync Bitwarden global domain rules"
|
||||||
|
body: |
|
||||||
|
Automated sync from bitwarden/server.
|
||||||
|
|
||||||
|
This PR only updates:
|
||||||
|
- `src/static/global_domains.bitwarden.json`
|
||||||
|
- `src/static/global_domains.bitwarden.meta.json`
|
||||||
|
|
||||||
|
`src/static/global_domains.custom.json` is intentionally left untouched.
|
||||||
|
add-paths: |
|
||||||
|
src/static/global_domains.bitwarden.json
|
||||||
|
src/static/global_domains.bitwarden.meta.json
|
||||||
@@ -42,5 +42,8 @@ tmp/
|
|||||||
.tmp/
|
.tmp/
|
||||||
|
|
||||||
nodewarden.wiki/
|
nodewarden.wiki/
|
||||||
|
wiki/
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
settings.json
|
settings.json
|
||||||
|
.claude/
|
||||||
|
NodeWarden-compat/
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# Contributing to NodeWarden
|
||||||
|
|
||||||
|
Thanks for taking the time to improve NodeWarden.
|
||||||
|
|
||||||
|
NodeWarden is a Bitwarden-compatible server with a custom web vault, Cloudflare
|
||||||
|
Workers/D1 storage, attachment storage, imports/exports, and scheduled backups.
|
||||||
|
Small changes can affect official clients, backups, migrations, or locale files,
|
||||||
|
so please keep changes focused and check the related parts of the project.
|
||||||
|
|
||||||
|
## Before Opening an Issue
|
||||||
|
|
||||||
|
For bug reports, include enough detail for someone else to reproduce the problem:
|
||||||
|
|
||||||
|
- The client or browser you used.
|
||||||
|
- The page, API route, or action that failed.
|
||||||
|
- Screenshots, logs, or the exact error message.
|
||||||
|
- Whether the problem happened after sync, import, export, restore, upgrade, or
|
||||||
|
a fresh deployment.
|
||||||
|
|
||||||
|
Please do not report NodeWarden-specific problems to the official Bitwarden
|
||||||
|
team. This project is independent from Bitwarden.
|
||||||
|
|
||||||
|
## Pull Request Guidelines
|
||||||
|
|
||||||
|
Keep pull requests small enough to review. A good PR should explain:
|
||||||
|
|
||||||
|
- What changed and why.
|
||||||
|
- What user-facing behavior changed.
|
||||||
|
- Which related areas were checked.
|
||||||
|
- Which commands were run before submitting.
|
||||||
|
|
||||||
|
Avoid mixing unrelated refactors with feature or bug-fix work. If a cleanup is
|
||||||
|
needed before the real fix, mention that clearly in the PR.
|
||||||
|
|
||||||
|
## Areas That Need Extra Care
|
||||||
|
|
||||||
|
Some parts of the codebase are deliberately connected. When changing one of
|
||||||
|
these areas, check the related files before calling the work complete.
|
||||||
|
|
||||||
|
### Database Changes
|
||||||
|
|
||||||
|
Runtime schema lives in `src/services/storage-schema.ts`. The initial D1 schema
|
||||||
|
lives in `migrations/0001_init.sql`.
|
||||||
|
|
||||||
|
If you add or change a table, column, or index:
|
||||||
|
|
||||||
|
- Update both schema files.
|
||||||
|
- Bump `STORAGE_SCHEMA_VERSION` in `src/services/storage.ts`.
|
||||||
|
- Decide whether the data should be included in instance backup.
|
||||||
|
|
||||||
|
### Backup And Restore
|
||||||
|
|
||||||
|
Backup export and restore are whitelist-based. This protects old backups from
|
||||||
|
breaking when fields are removed and prevents transient or secret runtime data
|
||||||
|
from being exported by accident.
|
||||||
|
|
||||||
|
When adding persistent data, check:
|
||||||
|
|
||||||
|
- `src/services/backup-archive.ts`
|
||||||
|
- `src/services/backup-import.ts`
|
||||||
|
- `webapp/src/lib/api/backup.ts`
|
||||||
|
|
||||||
|
Do not export runtime lock rows such as `backup.runner.lock.v1`. Do not import
|
||||||
|
retired sensitive fields such as `users.api_key`.
|
||||||
|
|
||||||
|
### Secrets And Provider Settings
|
||||||
|
|
||||||
|
Provider credentials must not be stored or exported as plain config JSON. Follow
|
||||||
|
the encrypted settings pattern in `src/services/backup-settings-crypto.ts`, or
|
||||||
|
document a replacement design before changing it.
|
||||||
|
|
||||||
|
### Bitwarden Client Compatibility
|
||||||
|
|
||||||
|
Official Bitwarden clients may send or expect fields that are not used directly
|
||||||
|
by the web vault. Cipher and sync changes should preserve unknown client fields
|
||||||
|
unless they are known-invalid or server-owned.
|
||||||
|
|
||||||
|
Check these files when changing vault item shape or sync behavior:
|
||||||
|
|
||||||
|
- `src/handlers/ciphers.ts`
|
||||||
|
- `src/handlers/sync.ts`
|
||||||
|
- `src/services/storage-cipher-repo.ts`
|
||||||
|
|
||||||
|
### Domain Rules
|
||||||
|
|
||||||
|
Equivalent-domain settings store both client/UI rule state and derived active
|
||||||
|
groups. Do not remove `equivalent_domains`, `custom_equivalent_domains`, or
|
||||||
|
`excluded_global_equivalent_domains` as duplicates without a migration and
|
||||||
|
compatibility plan.
|
||||||
|
|
||||||
|
### Accounts And Passwords
|
||||||
|
|
||||||
|
`users.master_password_hash` is for server-side login verification. It is not the
|
||||||
|
vault decryption key. Password changes, key material, `securityStamp`, and
|
||||||
|
refresh-token revocation must stay aligned.
|
||||||
|
|
||||||
|
Password hints are reminders, not recovery secrets. They must never contain the
|
||||||
|
master password, recovery codes, API keys, or anything that directly unlocks the
|
||||||
|
vault.
|
||||||
|
|
||||||
|
### i18n
|
||||||
|
|
||||||
|
Locale files are complete standalone bundles. When adding or changing user-facing
|
||||||
|
text, keep every locale in sync and run the validation script.
|
||||||
|
|
||||||
|
For new locales, update:
|
||||||
|
|
||||||
|
- `webapp/src/lib/i18n.ts`
|
||||||
|
- `webapp/src/lib/i18n/locales/*`
|
||||||
|
- `scripts/i18n-utils.cjs`
|
||||||
|
|
||||||
|
## Recommended Checks
|
||||||
|
|
||||||
|
For most backend or shared changes:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npx tsc -p tsconfig.json --noEmit
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
For webapp text or locale changes:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run i18n:validate
|
||||||
|
npx tsc -p webapp/tsconfig.json --noEmit
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
For documentation-only changes:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
@@ -14,16 +14,14 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="./RELEASE_NOTES.md">更新日志</a> |
|
|
||||||
<a href="https://github.com/shuaiplus/NodeWarden/issues/new/choose">提交问题</a> |
|
|
||||||
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest">最新发布</a><br />
|
|
||||||
<a href="./nodewarden.wiki/Home.md">文档首页</a> |
|
|
||||||
<a href="./nodewarden.wiki/快速开始.md">快速开始</a><br />
|
|
||||||
<a href="https://t.me/NodeWarden_News">Telegram 频道</a> |
|
<a href="https://t.me/NodeWarden_News">Telegram 频道</a> |
|
||||||
<a href="https://t.me/NodeWarden_Official">Telegram 群组</a><br />
|
<a href="https://t.me/NodeWarden_Official">Telegram 群组</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
English: <a href="./README_EN.md"><code>README_EN.md</code></a>
|
<p align="center">
|
||||||
|
<a href="./README_EN.md">English</a> |
|
||||||
|
<a href="./CONTRIBUTING.md">贡献指南</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
> **免责声明**
|
> **免责声明**
|
||||||
> 本项目仅供学习与交流使用,请定期备份你的密码库。
|
> 本项目仅供学习与交流使用,请定期备份你的密码库。
|
||||||
|
|||||||
@@ -15,15 +15,14 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="./RELEASE_NOTES.md">Release Notes</a> |
|
|
||||||
<a href="https://github.com/shuaiplus/NodeWarden/issues/new/choose">Report an Issue</a> |
|
|
||||||
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest">Latest Release</a><br />
|
|
||||||
<a href="https://t.me/NodeWarden_News">Telegram Channel</a> |
|
<a href="https://t.me/NodeWarden_News">Telegram Channel</a> |
|
||||||
<a href="https://t.me/NodeWarden_Official">Telegram Group</a><br />
|
<a href="https://t.me/NodeWarden_Official">Telegram Group</a>
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
中文说明:<a href="./README.md"><code>README.md</code></a>
|
<p align="center">
|
||||||
|
<a href="./README.md">中文说明</a> |
|
||||||
|
<a href="./CONTRIBUTING.md">Contributing</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
> **Disclaimer**
|
> **Disclaimer**
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
PRAGMA foreign_keys = ON;
|
PRAGMA foreign_keys = ON;
|
||||||
|
|
||||||
-- IMPORTANT:
|
-- IMPORTANT:
|
||||||
-- Keep this file in sync with src/services/storage.ts (SCHEMA_STATEMENTS).
|
-- This is the initial D1 schema. Keep it in sync with
|
||||||
|
-- src/services/storage-schema.ts (SCHEMA_STATEMENTS).
|
||||||
-- Any new table/column/index must be added to both places together.
|
-- Any new table/column/index must be added to both places together.
|
||||||
|
--
|
||||||
|
-- WHEN CHANGING THIS:
|
||||||
|
-- - Also bump STORAGE_SCHEMA_VERSION in src/services/storage.ts.
|
||||||
|
-- - If the new table stores persistent data, update backup export/import.
|
||||||
|
-- - Keep src/services/storage-schema.ts idempotent for existing installs.
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS config (
|
CREATE TABLE IF NOT EXISTS config (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
@@ -33,6 +39,15 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS domain_settings (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
equivalent_domains TEXT NOT NULL DEFAULT '[]',
|
||||||
|
custom_equivalent_domains TEXT NOT NULL DEFAULT '[]',
|
||||||
|
excluded_global_equivalent_domains TEXT NOT NULL DEFAULT '[]',
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
-- Per-user sync revision date
|
-- Per-user sync revision date
|
||||||
CREATE TABLE IF NOT EXISTS user_revisions (
|
CREATE TABLE IF NOT EXISTS user_revisions (
|
||||||
user_id TEXT PRIMARY KEY,
|
user_id TEXT PRIMARY KEY,
|
||||||
@@ -115,6 +130,8 @@ CREATE TABLE IF NOT EXISTS refresh_tokens (
|
|||||||
token TEXT PRIMARY KEY,
|
token TEXT PRIMARY KEY,
|
||||||
user_id TEXT NOT NULL,
|
user_id TEXT NOT NULL,
|
||||||
expires_at INTEGER NOT NULL,
|
expires_at INTEGER NOT NULL,
|
||||||
|
device_identifier TEXT,
|
||||||
|
device_session_stamp TEXT,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
|
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
|
||||||
@@ -155,6 +172,8 @@ CREATE TABLE IF NOT EXISTS devices (
|
|||||||
encrypted_user_key TEXT,
|
encrypted_user_key TEXT,
|
||||||
encrypted_public_key TEXT,
|
encrypted_public_key TEXT,
|
||||||
encrypted_private_key TEXT,
|
encrypted_private_key TEXT,
|
||||||
|
banned INTEGER NOT NULL DEFAULT 0,
|
||||||
|
banned_at TEXT,
|
||||||
device_note TEXT,
|
device_note TEXT,
|
||||||
last_seen_at TEXT,
|
last_seen_at TEXT,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "nodewarden",
|
"name": "nodewarden",
|
||||||
"version": "1.5.1",
|
"version": "1.5.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "nodewarden",
|
"name": "nodewarden",
|
||||||
"version": "1.5.1",
|
"version": "1.5.2",
|
||||||
"license": "LGPL-3.0",
|
"license": "LGPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
|
||||||
"@noble/hashes": "^2.0.1",
|
"@noble/hashes": "^2.0.1",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"@zip.js/zip.js": "^2.8.22",
|
"@zip.js/zip.js": "^2.8.22",
|
||||||
@@ -525,59 +522,6 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@dnd-kit/accessibility": {
|
|
||||||
"version": "3.1.1",
|
|
||||||
"resolved": "https://registry.npmmirror.com/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
|
||||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": ">=16.8.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@dnd-kit/core": {
|
|
||||||
"version": "6.3.1",
|
|
||||||
"resolved": "https://registry.npmmirror.com/@dnd-kit/core/-/core-6.3.1.tgz",
|
|
||||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@dnd-kit/accessibility": "^3.1.1",
|
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
|
||||||
"tslib": "^2.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": ">=16.8.0",
|
|
||||||
"react-dom": ">=16.8.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@dnd-kit/sortable": {
|
|
||||||
"version": "10.0.0",
|
|
||||||
"resolved": "https://registry.npmmirror.com/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
|
||||||
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
|
||||||
"tslib": "^2.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@dnd-kit/core": "^6.3.0",
|
|
||||||
"react": ">=16.8.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@dnd-kit/utilities": {
|
|
||||||
"version": "3.2.2",
|
|
||||||
"resolved": "https://registry.npmmirror.com/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
|
||||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": ">=16.8.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@emnapi/runtime": {
|
"node_modules/@emnapi/runtime": {
|
||||||
"version": "1.8.1",
|
"version": "1.8.1",
|
||||||
"resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.8.1.tgz",
|
"resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.8.1.tgz",
|
||||||
@@ -3518,19 +3462,6 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-dom": {
|
|
||||||
"version": "19.2.4",
|
|
||||||
"resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.4.tgz",
|
|
||||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"scheduler": "^0.27.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^19.2.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/read-cache": {
|
"node_modules/read-cache": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/read-cache/-/read-cache-1.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/read-cache/-/read-cache-1.0.0.tgz",
|
||||||
@@ -3688,13 +3619,6 @@
|
|||||||
"queue-microtask": "^1.2.2"
|
"queue-microtask": "^1.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/scheduler": {
|
|
||||||
"version": "0.27.0",
|
|
||||||
"resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz",
|
|
||||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true
|
|
||||||
},
|
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "7.7.4",
|
"version": "7.7.4",
|
||||||
"resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz",
|
"resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz",
|
||||||
@@ -3944,7 +3868,9 @@
|
|||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"license": "0BSD"
|
"dev": true,
|
||||||
|
"license": "0BSD",
|
||||||
|
"optional": true
|
||||||
},
|
},
|
||||||
"node_modules/tsx": {
|
"node_modules/tsx": {
|
||||||
"version": "4.21.0",
|
"version": "4.21.0",
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
{
|
{
|
||||||
"name": "nodewarden",
|
"name": "nodewarden",
|
||||||
"version": "1.5.1",
|
"version": "1.5.2",
|
||||||
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
|
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
|
||||||
"author": "shuaiplus",
|
"author": "shuaiplus",
|
||||||
"license": "LGPL-3.0",
|
"license": "LGPL-3.0",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "wrangler dev -c wrangler.toml",
|
"dev": "npm run build && wrangler dev -c wrangler.toml",
|
||||||
"dev:kv": "wrangler dev -c wrangler.kv.toml",
|
"dev:kv": "npm run build && wrangler dev -c wrangler.kv.toml",
|
||||||
"dev:demo": "vite --config webapp/vite.config.ts --mode demo --host 127.0.0.1 --port 5174",
|
"dev:demo": "vite --config webapp/vite.config.ts --mode demo --host 127.0.0.1 --port 5174",
|
||||||
"build": "vite build --config webapp/vite.config.ts",
|
"build": "vite build --config webapp/vite.config.ts",
|
||||||
"build:demo": "vite build --config webapp/vite.config.ts --mode demo && node scripts/pages-spa-redirects.cjs",
|
"build:demo": "vite build --config webapp/vite.config.ts --mode demo && node scripts/pages-spa-redirects.cjs",
|
||||||
|
"domains:sync": "node scripts/sync-global-domains.mjs",
|
||||||
"i18n": "node scripts/i18n-validate.cjs",
|
"i18n": "node scripts/i18n-validate.cjs",
|
||||||
"i18n:validate": "node scripts/i18n-validate.cjs",
|
"i18n:validate": "node scripts/i18n-validate.cjs",
|
||||||
"deploy": "wrangler deploy",
|
"deploy": "npm run build && wrangler deploy",
|
||||||
"deploy:kv": "wrangler deploy -c wrangler.kv.toml",
|
"deploy:kv": "npm run build && wrangler deploy -c wrangler.kv.toml",
|
||||||
"deploy:demo": "npm run build:demo && wrangler pages deploy dist --project-name nw-demo"
|
"deploy:demo": "npm run build:demo && wrangler pages deploy dist --project-name nw-demo"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -55,9 +56,6 @@
|
|||||||
"wrangler": "^4.71.0"
|
"wrangler": "^4.71.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
|
||||||
"@noble/hashes": "^2.0.1",
|
"@noble/hashes": "^2.0.1",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"@zip.js/zip.js": "^2.8.22",
|
"@zip.js/zip.js": "^2.8.22",
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const vm = require('vm');
|
const vm = require('vm');
|
||||||
|
|
||||||
|
// CONTRACT:
|
||||||
|
// This list is the script-side locale source of truth. Keep it in sync with
|
||||||
|
// webapp/src/lib/i18n.ts whenever adding/removing a locale.
|
||||||
const localeDir = path.join(__dirname, '..', 'webapp', 'src', 'lib', 'i18n', 'locales');
|
const localeDir = path.join(__dirname, '..', 'webapp', 'src', 'lib', 'i18n', 'locales');
|
||||||
|
|
||||||
const localeFiles = [
|
const localeFiles = [
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
const { localeFiles, readLocale } = require('./i18n-utils.cjs');
|
const { localeFiles, readLocale } = require('./i18n-utils.cjs');
|
||||||
|
|
||||||
|
// CONTRACT:
|
||||||
|
// This is the authoritative locale consistency gate. It checks key parity,
|
||||||
|
// placeholder parity, and accidental mostly-English locale files. Run after any
|
||||||
|
// user-facing text or locale-file change.
|
||||||
const locales = Object.fromEntries(
|
const locales = Object.fromEntries(
|
||||||
localeFiles.map(([locale, fileName, variableName]) => [locale, readLocale(fileName, variableName)])
|
localeFiles.map(([locale, fileName, variableName]) => [locale, readLocale(fileName, variableName)])
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const DEFAULT_REF = 'main';
|
||||||
|
const OUTPUT_DIR = path.join(process.cwd(), 'src', 'static');
|
||||||
|
const OUT_FILE = path.join(OUTPUT_DIR, 'global_domains.bitwarden.json');
|
||||||
|
const META_FILE = path.join(OUTPUT_DIR, 'global_domains.bitwarden.meta.json');
|
||||||
|
const ENUM_PATH = 'src/Core/Enums/GlobalEquivalentDomainsType.cs';
|
||||||
|
const STATIC_STORE_PATH = 'src/Core/Utilities/StaticStore.cs';
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const args = { ref: process.env.BITWARDEN_SERVER_REF || DEFAULT_REF };
|
||||||
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
|
const arg = argv[i];
|
||||||
|
if (arg === '--ref' && argv[i + 1]) {
|
||||||
|
args.ref = argv[i + 1];
|
||||||
|
i += 1;
|
||||||
|
} else if (arg.startsWith('--ref=')) {
|
||||||
|
args.ref = arg.slice('--ref='.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rawUrl(ref, filePath) {
|
||||||
|
return `https://raw.githubusercontent.com/bitwarden/server/${encodeURIComponent(ref)}/${filePath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchText(url) {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'NodeWarden global domains sync',
|
||||||
|
Accept: 'text/plain',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch ${url}: HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
return response.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEnumTypes(source) {
|
||||||
|
const map = new Map();
|
||||||
|
const enumMatch = source.match(/enum\s+GlobalEquivalentDomainsType\b[\s\S]*?\{([\s\S]*?)\}/);
|
||||||
|
if (!enumMatch) {
|
||||||
|
throw new Error('GlobalEquivalentDomainsType enum was not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = enumMatch[1].replace(/\/\/.*$/gm, '');
|
||||||
|
const entryRe = /\b([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(\d+)\b/g;
|
||||||
|
let match;
|
||||||
|
while ((match = entryRe.exec(body)) !== null) {
|
||||||
|
map.set(match[1], Number(match[2]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!map.size) {
|
||||||
|
throw new Error('No enum values were parsed from GlobalEquivalentDomainsType');
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStringList(source) {
|
||||||
|
const domains = [];
|
||||||
|
const stringRe = /"((?:\\.|[^"\\])*)"/g;
|
||||||
|
let match;
|
||||||
|
while ((match = stringRe.exec(source)) !== null) {
|
||||||
|
domains.push(match[1].replace(/\\"/g, '"').trim().toLowerCase());
|
||||||
|
}
|
||||||
|
return Array.from(new Set(domains.filter(Boolean)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGlobalDomains(source, enumTypes) {
|
||||||
|
const out = [];
|
||||||
|
const addRe = /GlobalDomains\.Add\s*\(\s*GlobalEquivalentDomainsType\.([A-Za-z_][A-Za-z0-9_]*)\s*,\s*new\s+List(?:<\s*string\s*>)?\s*\{([\s\S]*?)\}\s*\)\s*;/g;
|
||||||
|
let match;
|
||||||
|
while ((match = addRe.exec(source)) !== null) {
|
||||||
|
const name = match[1];
|
||||||
|
const type = enumTypes.get(name);
|
||||||
|
if (!Number.isInteger(type)) {
|
||||||
|
throw new Error(`GlobalDomains references unknown enum value ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const domains = parseStringList(match[2]);
|
||||||
|
if (domains.length < 2) {
|
||||||
|
throw new Error(`GlobalDomains.${name} has fewer than two domains`);
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push({
|
||||||
|
type,
|
||||||
|
domains,
|
||||||
|
excluded: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!out.length) {
|
||||||
|
throw new Error('No GlobalDomains.Add(...) rules were parsed from StaticStore.cs');
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRulesJson(rules) {
|
||||||
|
return `[\n${rules.map((rule) => ` ${JSON.stringify(rule)}`).join(',\n')}\n]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMetaJson(meta) {
|
||||||
|
return JSON.stringify(meta, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ref } = parseArgs(process.argv.slice(2));
|
||||||
|
const enumUrl = rawUrl(ref, ENUM_PATH);
|
||||||
|
const staticStoreUrl = rawUrl(ref, STATIC_STORE_PATH);
|
||||||
|
|
||||||
|
const [enumSource, staticStoreSource] = await Promise.all([
|
||||||
|
fetchText(enumUrl),
|
||||||
|
fetchText(staticStoreUrl),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const enumTypes = parseEnumTypes(enumSource);
|
||||||
|
const rules = parseGlobalDomains(staticStoreSource, enumTypes);
|
||||||
|
const domainsCount = rules.reduce((sum, rule) => sum + rule.domains.length, 0);
|
||||||
|
const rulesJson = formatRulesJson(rules);
|
||||||
|
|
||||||
|
async function readJsonFile(filePath) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(await readFile(filePath, 'utf8'));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingRules = await readJsonFile(OUT_FILE);
|
||||||
|
const existingMeta = await readJsonFile(META_FILE);
|
||||||
|
const unchangedRules = JSON.stringify(existingRules) === JSON.stringify(rules);
|
||||||
|
const unchangedRef = existingMeta?.ref === ref;
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
source: 'https://github.com/bitwarden/server',
|
||||||
|
ref,
|
||||||
|
generatedAt: unchangedRules && unchangedRef && existingMeta?.generatedAt
|
||||||
|
? existingMeta.generatedAt
|
||||||
|
: new Date().toISOString(),
|
||||||
|
rulesCount: rules.length,
|
||||||
|
domainsCount,
|
||||||
|
sourceFiles: [
|
||||||
|
ENUM_PATH,
|
||||||
|
STATIC_STORE_PATH,
|
||||||
|
],
|
||||||
|
sourceUrls: [
|
||||||
|
enumUrl,
|
||||||
|
staticStoreUrl,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
await mkdir(OUTPUT_DIR, { recursive: true });
|
||||||
|
await writeFile(OUT_FILE, `${rulesJson}\n`, 'utf8');
|
||||||
|
await writeFile(META_FILE, `${formatMetaJson(meta)}\n`, 'utf8');
|
||||||
|
|
||||||
|
console.log(`Wrote ${rules.length} global domain rules (${domainsCount} domains) from bitwarden/server@${ref}.`);
|
||||||
@@ -1 +1 @@
|
|||||||
export const APP_VERSION = '1.5.1';
|
export const APP_VERSION = '1.5.2';
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
|
// Shared backup settings types used by both Worker and webapp code.
|
||||||
|
//
|
||||||
|
// CONTRACT:
|
||||||
|
// Keep this file serializable and provider-neutral. Runtime state is operational
|
||||||
|
// metadata; destination fields can contain provider credentials and must be
|
||||||
|
// encrypted by src/services/backup-settings-crypto.ts before storage/export.
|
||||||
|
// User-facing provider names should use canonical values here. Legacy aliases
|
||||||
|
// belong in backend normalization, not in this shared type.
|
||||||
export const BACKUP_DEFAULT_TIMEZONE = 'UTC';
|
export const BACKUP_DEFAULT_TIMEZONE = 'UTC';
|
||||||
export const BACKUP_DEFAULT_RETENTION_COUNT = 30;
|
export const BACKUP_DEFAULT_RETENTION_COUNT = 30;
|
||||||
export const BACKUP_DEFAULT_S3_REGION = 'auto';
|
export const BACKUP_DEFAULT_S3_REGION = 'auto';
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
const MULTI_LABEL_PUBLIC_SUFFIXES = new Set([
|
||||||
|
'ac.cn',
|
||||||
|
'com.cn',
|
||||||
|
'edu.cn',
|
||||||
|
'gov.cn',
|
||||||
|
'net.cn',
|
||||||
|
'org.cn',
|
||||||
|
'ah.cn',
|
||||||
|
'bj.cn',
|
||||||
|
'cq.cn',
|
||||||
|
'fj.cn',
|
||||||
|
'gd.cn',
|
||||||
|
'gs.cn',
|
||||||
|
'gx.cn',
|
||||||
|
'gz.cn',
|
||||||
|
'ha.cn',
|
||||||
|
'hb.cn',
|
||||||
|
'he.cn',
|
||||||
|
'hi.cn',
|
||||||
|
'hk.cn',
|
||||||
|
'hl.cn',
|
||||||
|
'hn.cn',
|
||||||
|
'jl.cn',
|
||||||
|
'js.cn',
|
||||||
|
'jx.cn',
|
||||||
|
'ln.cn',
|
||||||
|
'mo.cn',
|
||||||
|
'nm.cn',
|
||||||
|
'nx.cn',
|
||||||
|
'qh.cn',
|
||||||
|
'sc.cn',
|
||||||
|
'sd.cn',
|
||||||
|
'sh.cn',
|
||||||
|
'sn.cn',
|
||||||
|
'sx.cn',
|
||||||
|
'tj.cn',
|
||||||
|
'tw.cn',
|
||||||
|
'xj.cn',
|
||||||
|
'xz.cn',
|
||||||
|
'yn.cn',
|
||||||
|
'zj.cn',
|
||||||
|
'co.uk',
|
||||||
|
'org.uk',
|
||||||
|
'net.uk',
|
||||||
|
'ac.uk',
|
||||||
|
'gov.uk',
|
||||||
|
'com.au',
|
||||||
|
'net.au',
|
||||||
|
'org.au',
|
||||||
|
'edu.au',
|
||||||
|
'gov.au',
|
||||||
|
'co.nz',
|
||||||
|
'org.nz',
|
||||||
|
'net.nz',
|
||||||
|
'com.br',
|
||||||
|
'com.mx',
|
||||||
|
'com.ar',
|
||||||
|
'com.tr',
|
||||||
|
'com.sg',
|
||||||
|
'com.my',
|
||||||
|
'com.hk',
|
||||||
|
'com.tw',
|
||||||
|
'co.jp',
|
||||||
|
'ne.jp',
|
||||||
|
'or.jp',
|
||||||
|
'co.kr',
|
||||||
|
'or.kr',
|
||||||
|
'co.in',
|
||||||
|
'firm.in',
|
||||||
|
'net.in',
|
||||||
|
'org.in',
|
||||||
|
'co.id',
|
||||||
|
'or.id',
|
||||||
|
'web.id',
|
||||||
|
'co.il',
|
||||||
|
'org.il',
|
||||||
|
'co.za',
|
||||||
|
'com.sa',
|
||||||
|
'com.ph',
|
||||||
|
'com.vn',
|
||||||
|
'com.pk',
|
||||||
|
'com.bd',
|
||||||
|
'com.ng',
|
||||||
|
'github.io',
|
||||||
|
'pages.dev',
|
||||||
|
'workers.dev',
|
||||||
|
'cloudflareaccess.com',
|
||||||
|
'vercel.app',
|
||||||
|
'netlify.app',
|
||||||
|
'web.app',
|
||||||
|
'firebaseapp.com',
|
||||||
|
'herokuapp.com',
|
||||||
|
'fly.dev',
|
||||||
|
'railway.app',
|
||||||
|
'render.com',
|
||||||
|
'onrender.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function extractHost(input: string): string {
|
||||||
|
let raw = input.trim().toLowerCase();
|
||||||
|
if (!raw) return '';
|
||||||
|
raw = raw.replace(/\\/g, '/');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const candidate = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`;
|
||||||
|
const parsed = new URL(candidate);
|
||||||
|
raw = parsed.hostname;
|
||||||
|
} catch {
|
||||||
|
raw = raw.split(/[/?#]/, 1)[0] || '';
|
||||||
|
const atIndex = raw.lastIndexOf('@');
|
||||||
|
if (atIndex >= 0) raw = raw.slice(atIndex + 1);
|
||||||
|
if (raw.startsWith('[')) return '';
|
||||||
|
const colonIndex = raw.lastIndexOf(':');
|
||||||
|
if (colonIndex > -1 && raw.indexOf(':') === colonIndex) raw = raw.slice(0, colonIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw
|
||||||
|
.replace(/^\*+\./, '')
|
||||||
|
.replace(/^\.+/, '')
|
||||||
|
.replace(/\.+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidHost(host: string): boolean {
|
||||||
|
if (!host || host.length > 253 || !host.includes('.')) return false;
|
||||||
|
if (host.includes('..') || /[:/\s]/.test(host)) return false;
|
||||||
|
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(host)) return false;
|
||||||
|
return host.split('.').every((label) => (
|
||||||
|
label.length > 0
|
||||||
|
&& label.length <= 63
|
||||||
|
&& /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeEquivalentDomain(value: unknown): string {
|
||||||
|
const host = extractHost(String(value || ''));
|
||||||
|
if (!isValidHost(host)) return '';
|
||||||
|
|
||||||
|
const labels = host.split('.');
|
||||||
|
for (let index = 0; index < labels.length; index += 1) {
|
||||||
|
const suffix = labels.slice(index).join('.');
|
||||||
|
if (!MULTI_LABEL_PUBLIC_SUFFIXES.has(suffix)) continue;
|
||||||
|
if (index === 0) return '';
|
||||||
|
return labels.slice(index - 1).join('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return labels.length >= 2 ? labels.slice(-2).join('.') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidEquivalentDomain(value: unknown): boolean {
|
||||||
|
return !!normalizeEquivalentDomain(value);
|
||||||
|
}
|
||||||
@@ -9,6 +9,11 @@ import { isTotpEnabled, verifyTotpToken } from '../utils/totp';
|
|||||||
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
|
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
|
||||||
import { buildAccountKeys } from '../utils/user-decryption';
|
import { buildAccountKeys } from '../utils/user-decryption';
|
||||||
|
|
||||||
|
// CONTRACT:
|
||||||
|
// users.master_password_hash is server-side login verification only. It does
|
||||||
|
// not decrypt vault data. Password changes must keep encrypted user key material,
|
||||||
|
// securityStamp, refresh-token invalidation, and client compatibility together.
|
||||||
|
// Password hints are non-secret reminders; never treat them as recovery secrets.
|
||||||
function looksLikeEncString(value: string): boolean {
|
function looksLikeEncString(value: string): boolean {
|
||||||
if (!value) return false;
|
if (!value) return false;
|
||||||
const firstDot = value.indexOf('.');
|
const firstDot = value.indexOf('.');
|
||||||
|
|||||||
@@ -85,6 +85,10 @@ const BACKUP_RUNNER_LOCK_KEY = 'backup.runner.lock.v1';
|
|||||||
const BACKUP_RUNNER_LEASE_MS = 10 * 60 * 1000;
|
const BACKUP_RUNNER_LEASE_MS = 10 * 60 * 1000;
|
||||||
const BACKUP_RUNNER_HEARTBEAT_MS = 30 * 1000;
|
const BACKUP_RUNNER_HEARTBEAT_MS = 30 * 1000;
|
||||||
|
|
||||||
|
// CONTRACT:
|
||||||
|
// The runner lock is a config-row lease, not a queue. It only prevents two
|
||||||
|
// backup/restore jobs from overlapping. Manual runs return conflict when the
|
||||||
|
// lease is held; scheduled runs skip quietly. Never export this row in backups.
|
||||||
interface BackupRunnerLease {
|
interface BackupRunnerLease {
|
||||||
token: string;
|
token: string;
|
||||||
touch: () => Promise<void>;
|
touch: () => Promise<void>;
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ import { deleteAllAttachmentsForCipher, deleteAllAttachmentsForCiphers } from '.
|
|||||||
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
|
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
|
||||||
import { readActingDeviceIdentifier } from '../utils/device';
|
import { readActingDeviceIdentifier } from '../utils/device';
|
||||||
|
|
||||||
|
// CONTRACT:
|
||||||
|
// Cipher JSON is the highest-risk Bitwarden compatibility surface. Preserve
|
||||||
|
// unknown/future client fields by default, then override only server-owned
|
||||||
|
// fields. Any change to cipher response shape must be checked against /api/sync,
|
||||||
|
// attachments, import/export, and current official clients.
|
||||||
function normalizeOptionalId(value: unknown): string | null {
|
function normalizeOptionalId(value: unknown): string | null {
|
||||||
if (value == null) return null;
|
if (value == null) return null;
|
||||||
const normalized = String(value).trim();
|
const normalized = String(value).trim();
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import type { Env } from '../types';
|
||||||
|
import { StorageService } from '../services/storage';
|
||||||
|
import {
|
||||||
|
buildDomainsResponse,
|
||||||
|
customRulesToActiveEquivalentDomains,
|
||||||
|
normalizeCustomEquivalentDomains,
|
||||||
|
normalizeEquivalentDomains,
|
||||||
|
normalizeExcludedGlobalTypes,
|
||||||
|
} from '../services/domain-rules';
|
||||||
|
import { errorResponse, jsonResponse } from '../utils/response';
|
||||||
|
|
||||||
|
// CONTRACT:
|
||||||
|
// This route accepts both camelCase and PascalCase Bitwarden-compatible payloads.
|
||||||
|
// It stores custom rules, then derives equivalentDomains from the non-excluded
|
||||||
|
// custom rules. Keep this behavior aligned with backup import/export and
|
||||||
|
// src/services/storage-domain-rules-repo.ts.
|
||||||
|
function firstPresent(payload: Record<string, unknown>, keys: string[]): unknown {
|
||||||
|
for (const key of keys) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(payload, key)) return payload[key];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readPayload(request: Request): Promise<Record<string, unknown>> {
|
||||||
|
try {
|
||||||
|
const parsed = await request.json();
|
||||||
|
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||||
|
? parsed as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleGetDomains(env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const settings = await storage.getUserDomainSettings(userId);
|
||||||
|
return jsonResponse(buildDomainsResponse(
|
||||||
|
settings.equivalentDomains,
|
||||||
|
settings.customEquivalentDomains,
|
||||||
|
settings.excludedGlobalEquivalentDomains
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleUpdateDomains(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const payload = await readPayload(request);
|
||||||
|
const current = await storage.getUserDomainSettings(userId);
|
||||||
|
const equivalentDomainsRaw = firstPresent(payload, [
|
||||||
|
'equivalentDomains',
|
||||||
|
'EquivalentDomains',
|
||||||
|
]);
|
||||||
|
const customEquivalentDomainsRaw = firstPresent(payload, [
|
||||||
|
'customEquivalentDomains',
|
||||||
|
'CustomEquivalentDomains',
|
||||||
|
]);
|
||||||
|
const excludedGlobalEquivalentDomainsRaw = firstPresent(payload, [
|
||||||
|
'excludedGlobalEquivalentDomains',
|
||||||
|
'ExcludedGlobalEquivalentDomains',
|
||||||
|
// Some older compatible clients send the excluded type list under this key.
|
||||||
|
'globalEquivalentDomains',
|
||||||
|
'GlobalEquivalentDomains',
|
||||||
|
]);
|
||||||
|
const customEquivalentDomains = customEquivalentDomainsRaw === undefined
|
||||||
|
? (equivalentDomainsRaw === undefined
|
||||||
|
? current.customEquivalentDomains
|
||||||
|
: normalizeCustomEquivalentDomains(normalizeEquivalentDomains(equivalentDomainsRaw)))
|
||||||
|
: normalizeCustomEquivalentDomains(customEquivalentDomainsRaw);
|
||||||
|
const equivalentDomains = customRulesToActiveEquivalentDomains(customEquivalentDomains);
|
||||||
|
const excludedGlobalEquivalentDomains = excludedGlobalEquivalentDomainsRaw === undefined
|
||||||
|
? current.excludedGlobalEquivalentDomains
|
||||||
|
: normalizeExcludedGlobalTypes(excludedGlobalEquivalentDomainsRaw);
|
||||||
|
|
||||||
|
await storage.saveUserDomainSettings(userId, equivalentDomains, customEquivalentDomains, excludedGlobalEquivalentDomains);
|
||||||
|
|
||||||
|
const settings = await storage.getUserDomainSettings(userId);
|
||||||
|
if (!settings) {
|
||||||
|
return errorResponse('Domain settings unavailable', 500);
|
||||||
|
}
|
||||||
|
return jsonResponse(buildDomainsResponse(
|
||||||
|
settings.equivalentDomains,
|
||||||
|
settings.customEquivalentDomains,
|
||||||
|
settings.excludedGlobalEquivalentDomains
|
||||||
|
));
|
||||||
|
}
|
||||||
@@ -9,7 +9,13 @@ import {
|
|||||||
buildUserDecryptionCompat,
|
buildUserDecryptionCompat,
|
||||||
buildUserDecryptionOptions,
|
buildUserDecryptionOptions,
|
||||||
} from '../utils/user-decryption';
|
} from '../utils/user-decryption';
|
||||||
|
import { buildDomainsResponse } from '../services/domain-rules';
|
||||||
|
|
||||||
|
// CONTRACT:
|
||||||
|
// /api/sync reuses cipherToResponse() as the single cipher response shaper.
|
||||||
|
// Filtering invalid cipher responses here protects clients from stored rows that
|
||||||
|
// would otherwise make official apps fail after an HTTP 200 sync.
|
||||||
|
// Keep this aligned with src/handlers/ciphers.ts when adding new vault fields.
|
||||||
function buildSyncCacheRequest(request: Request, userId: string, revisionDate: string, excludeDomains: boolean, excludeSends: boolean): Request {
|
function buildSyncCacheRequest(request: Request, userId: string, revisionDate: string, excludeDomains: boolean, excludeSends: boolean): Request {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const cacheUrl = new URL(
|
const cacheUrl = new URL(
|
||||||
@@ -50,11 +56,12 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
|||||||
return cachedResponse;
|
return cachedResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [ciphers, folders, sends, attachmentsByCipher] = await Promise.all([
|
const [ciphers, folders, sends, attachmentsByCipher, domainSettings] = await Promise.all([
|
||||||
storage.getAllCiphers(userId),
|
storage.getAllCiphers(userId),
|
||||||
storage.getAllFolders(userId),
|
storage.getAllFolders(userId),
|
||||||
excludeSends ? Promise.resolve([]) : storage.getAllSends(userId),
|
excludeSends ? Promise.resolve([]) : storage.getAllSends(userId),
|
||||||
storage.getAttachmentsByUserId(userId),
|
storage.getAttachmentsByUserId(userId),
|
||||||
|
excludeDomains ? Promise.resolve(null) : storage.getUserDomainSettings(userId),
|
||||||
]);
|
]);
|
||||||
const accountKeys = buildAccountKeys(user);
|
const accountKeys = buildAccountKeys(user);
|
||||||
const userDecryptionOptions = buildUserDecryptionOptions(user);
|
const userDecryptionOptions = buildUserDecryptionOptions(user);
|
||||||
@@ -111,11 +118,12 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
|||||||
ciphers: cipherResponses,
|
ciphers: cipherResponses,
|
||||||
domains: excludeDomains
|
domains: excludeDomains
|
||||||
? null
|
? null
|
||||||
: {
|
: buildDomainsResponse(
|
||||||
equivalentDomains: [],
|
domainSettings?.equivalentDomains || [],
|
||||||
globalEquivalentDomains: [],
|
domainSettings?.customEquivalentDomains || [],
|
||||||
object: 'domains',
|
domainSettings?.excludedGlobalEquivalentDomains || [],
|
||||||
},
|
{ omitExcludedGlobals: true }
|
||||||
|
),
|
||||||
policies: [],
|
policies: [],
|
||||||
sends: sendResponses,
|
sends: sendResponses,
|
||||||
UserDecryption: {
|
UserDecryption: {
|
||||||
|
|||||||
@@ -31,13 +31,33 @@ function isWorkerHandledPath(path: string): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addSearchIndexHeaders(request: Request, response: Response): Response {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const contentType = String(response.headers.get('Content-Type') || '').toLowerCase();
|
||||||
|
const shouldNoIndex =
|
||||||
|
url.pathname === '/robots.txt' ||
|
||||||
|
contentType.includes('text/html');
|
||||||
|
|
||||||
|
if (!shouldNoIndex) return response;
|
||||||
|
|
||||||
|
const headers = new Headers(response.headers);
|
||||||
|
headers.set('X-Robots-Tag', 'noindex, nofollow, noarchive, nosnippet');
|
||||||
|
|
||||||
|
return new Response(response.body, {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function maybeServeAsset(request: Request, env: Env): Promise<Response | null> {
|
async function maybeServeAsset(request: Request, env: Env): Promise<Response | null> {
|
||||||
if (!env.ASSETS) return null;
|
if (!env.ASSETS) return null;
|
||||||
if (request.method !== 'GET' && request.method !== 'HEAD') return null;
|
if (request.method !== 'GET' && request.method !== 'HEAD') return null;
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
if (isWorkerHandledPath(url.pathname)) return null;
|
if (isWorkerHandledPath(url.pathname)) return null;
|
||||||
|
|
||||||
return env.ASSETS.fetch(request);
|
const response = await env.ASSETS.fetch(request);
|
||||||
|
return addSearchIndexHeaders(request, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureDatabaseInitialized(env: Env): Promise<void> {
|
async function ensureDatabaseInitialized(env: Env): Promise<void> {
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ import {
|
|||||||
} from './handlers/attachments';
|
} from './handlers/attachments';
|
||||||
import { handleAuthenticatedDeviceRoute } from './router-devices';
|
import { handleAuthenticatedDeviceRoute } from './router-devices';
|
||||||
import { handleAdminRoute } from './router-admin';
|
import { handleAdminRoute } from './router-admin';
|
||||||
|
import { handleGetDomains, handleUpdateDomains } from './handlers/domains';
|
||||||
|
|
||||||
export async function handleAuthenticatedRoute(
|
export async function handleAuthenticatedRoute(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -297,14 +298,9 @@ export async function handleAuthenticatedRoute(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === '/api/settings/domains') {
|
if (path === '/api/settings/domains' || path === '/settings/domains') {
|
||||||
if (method === 'GET' || method === 'PUT' || method === 'POST') {
|
if (method === 'GET') return handleGetDomains(env, userId);
|
||||||
return jsonResponse({
|
if (method === 'PUT' || method === 'POST') return handleUpdateDomains(request, env, userId);
|
||||||
equivalentDomains: [],
|
|
||||||
globalEquivalentDomains: [],
|
|
||||||
object: 'domains',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
} from './handlers/notifications';
|
} from './handlers/notifications';
|
||||||
import { handlePublicUploadSendFile } from './handlers/sends';
|
import { handlePublicUploadSendFile } from './handlers/sends';
|
||||||
import { jsonResponse } from './utils/response';
|
import { jsonResponse } from './utils/response';
|
||||||
|
import { StorageService } from './services/storage';
|
||||||
import type { Env } from './types';
|
import type { Env } from './types';
|
||||||
|
|
||||||
type PublicRateLimiter = (category?: string, maxRequests?: number) => Promise<Response | null>;
|
type PublicRateLimiter = (category?: string, maxRequests?: number) => Promise<Response | null>;
|
||||||
@@ -31,6 +32,7 @@ export interface WebBootstrapResponse {
|
|||||||
defaultKdfIterations: number;
|
defaultKdfIterations: number;
|
||||||
jwtUnsafeReason: JwtUnsafeReason;
|
jwtUnsafeReason: JwtUnsafeReason;
|
||||||
jwtSecretMinLength: number;
|
jwtSecretMinLength: number;
|
||||||
|
registrationInviteRequired: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSameOriginWriteRequest(request: Request): boolean {
|
function isSameOriginWriteRequest(request: Request): boolean {
|
||||||
@@ -142,6 +144,17 @@ function normalizeIconHost(rawHost: string): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ICON_UPSTREAM_TIMEOUT_MS = 2500;
|
const ICON_UPSTREAM_TIMEOUT_MS = 2500;
|
||||||
|
const BITWARDEN_DEFAULT_GLOBE_ICON_BYTES = 500;
|
||||||
|
const BITWARDEN_DEFAULT_GLOBE_ICON_SHA256 = 'aaa64871332ad5b7d28fe8874efb19c2d9cc2f1e6de75d52b080b438225a0783';
|
||||||
|
|
||||||
|
type IconSource = {
|
||||||
|
url: string;
|
||||||
|
rejectImage?: {
|
||||||
|
byteLength: number;
|
||||||
|
sha256: string;
|
||||||
|
};
|
||||||
|
headers?: HeadersInit;
|
||||||
|
};
|
||||||
|
|
||||||
async function fetchIconSource(source: { url: string; headers?: HeadersInit }): Promise<Response> {
|
async function fetchIconSource(source: { url: string; headers?: HeadersInit }): Promise<Response> {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -161,51 +174,73 @@ async function fetchIconSource(source: { url: string; headers?: HeadersInit }):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function sha256Hex(bytes: ArrayBuffer): Promise<string> {
|
||||||
|
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
||||||
|
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function iconResponse(body: BodyInit | null, contentType: string | null): Response {
|
||||||
|
return new Response(body, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': contentType || 'image/png',
|
||||||
|
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}, immutable`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-found' = 'default'): Promise<Response> {
|
async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-found' = 'default'): Promise<Response> {
|
||||||
const normalizedHost = normalizeIconHost(host);
|
const normalizedHost = normalizeIconHost(host);
|
||||||
if (!normalizedHost) return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
|
if (!normalizedHost) return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
|
||||||
|
|
||||||
const encodedHost = encodeURIComponent(normalizedHost);
|
const encodedHost = encodeURIComponent(normalizedHost);
|
||||||
const requestHeaders = { 'User-Agent': 'NodeWarden/1.0' };
|
const requestHeaders = { 'User-Agent': 'NodeWarden/1.0' };
|
||||||
const upstreamSources: Array<{ url: string; headers?: HeadersInit }> = [
|
const upstreamSources: IconSource[] = [
|
||||||
|
{
|
||||||
|
url: `https://favicon.im/zh/${encodedHost}?larger=true&throw-error-on-404=true`,
|
||||||
|
headers: requestHeaders,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
url: `https://icons.bitwarden.net/${encodedHost}/icon.png`,
|
url: `https://icons.bitwarden.net/${encodedHost}/icon.png`,
|
||||||
headers: requestHeaders,
|
rejectImage: {
|
||||||
|
byteLength: BITWARDEN_DEFAULT_GLOBE_ICON_BYTES,
|
||||||
|
sha256: BITWARDEN_DEFAULT_GLOBE_ICON_SHA256,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
url: `https://favicon.im/${encodedHost}`,
|
|
||||||
headers: requestHeaders,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: `https://icons.duckduckgo.com/ip3/${encodedHost}.ico`,
|
|
||||||
headers: requestHeaders,
|
headers: requestHeaders,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
try {
|
|
||||||
for (const source of upstreamSources) {
|
for (const source of upstreamSources) {
|
||||||
|
try {
|
||||||
const resp = await fetchIconSource(source);
|
const resp = await fetchIconSource(source);
|
||||||
|
|
||||||
if (!resp.ok) continue;
|
if (!resp.ok) continue;
|
||||||
const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase();
|
const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase();
|
||||||
if (!contentType.startsWith('image/')) continue;
|
if (!contentType.startsWith('image/')) continue;
|
||||||
|
|
||||||
return new Response(resp.body, {
|
if (!source.rejectImage) {
|
||||||
status: 200,
|
return iconResponse(resp.body, resp.headers.get('Content-Type'));
|
||||||
headers: {
|
|
||||||
'Content-Type': resp.headers.get('Content-Type') || 'image/png',
|
|
||||||
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}, immutable`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
|
const contentLength = Number(resp.headers.get('Content-Length') || '');
|
||||||
|
if (Number.isFinite(contentLength) && contentLength > 0 && contentLength !== source.rejectImage.byteLength) {
|
||||||
|
return iconResponse(resp.body, resp.headers.get('Content-Type'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = await resp.arrayBuffer();
|
||||||
|
if (bytes.byteLength === 0) continue;
|
||||||
|
if (bytes.byteLength === source.rejectImage.byteLength && (await sha256Hex(bytes)) === source.rejectImage.sha256) continue;
|
||||||
|
|
||||||
|
return iconResponse(bytes, resp.headers.get('Content-Type'));
|
||||||
} catch {
|
} catch {
|
||||||
return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildWebBootstrapResponse(env: Env): WebBootstrapResponse {
|
return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildWebBootstrapResponse(env: Env): Promise<WebBootstrapResponse> {
|
||||||
const secret = (env.JWT_SECRET || '').trim();
|
const secret = (env.JWT_SECRET || '').trim();
|
||||||
const jwtUnsafeReason =
|
const jwtUnsafeReason =
|
||||||
!secret
|
!secret
|
||||||
@@ -215,11 +250,14 @@ export function buildWebBootstrapResponse(env: Env): WebBootstrapResponse {
|
|||||||
: secret.length < LIMITS.auth.jwtSecretMinLength
|
: secret.length < LIMITS.auth.jwtSecretMinLength
|
||||||
? 'too_short'
|
? 'too_short'
|
||||||
: null;
|
: null;
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const userCount = await storage.getUserCount();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
defaultKdfIterations: LIMITS.auth.defaultKdfIterations,
|
defaultKdfIterations: LIMITS.auth.defaultKdfIterations,
|
||||||
jwtUnsafeReason,
|
jwtUnsafeReason,
|
||||||
jwtSecretMinLength: LIMITS.auth.jwtSecretMinLength,
|
jwtSecretMinLength: LIMITS.auth.jwtSecretMinLength,
|
||||||
|
registrationInviteRequired: userCount > 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,7 +281,7 @@ export async function handlePublicRoute(
|
|||||||
if ((path === '/api/web-bootstrap' || path === '/web-bootstrap') && method === 'GET') {
|
if ((path === '/api/web-bootstrap' || path === '/web-bootstrap') && method === 'GET') {
|
||||||
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||||
if (blocked) return blocked;
|
if (blocked) return blocked;
|
||||||
return jsonResponse(buildWebBootstrapResponse(env));
|
return jsonResponse(await buildWebBootstrapResponse(env));
|
||||||
}
|
}
|
||||||
|
|
||||||
const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i);
|
const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i);
|
||||||
|
|||||||
@@ -1,14 +1,28 @@
|
|||||||
import { zipSync, unzipSync } from 'fflate';
|
import { zipSync, unzipSync } from 'fflate';
|
||||||
import type { Env } from '../types';
|
import type { Env } from '../types';
|
||||||
import { APP_VERSION } from '../../shared/app-version';
|
import { APP_VERSION } from '../../shared/app-version';
|
||||||
|
import { BACKUP_SETTINGS_CONFIG_KEY } from './backup-config';
|
||||||
|
import { exportPortableBackupSettingsEnvelope } from './backup-settings-crypto';
|
||||||
import {
|
import {
|
||||||
getAttachmentObjectKey,
|
getAttachmentObjectKey,
|
||||||
getBlobStorageKind,
|
getBlobStorageKind,
|
||||||
} from './blob-store';
|
} from './blob-store';
|
||||||
|
|
||||||
|
// CONTRACT:
|
||||||
|
// This file defines the exported instance-backup archive shape. Keep it in lock
|
||||||
|
// step with src/services/backup-import.ts and webapp/src/lib/api/backup.ts.
|
||||||
|
//
|
||||||
|
// WHEN CHANGING THIS:
|
||||||
|
// - Add persistent tables to BackupPayload, export SQL, manifest tableCounts,
|
||||||
|
// and validateBackupPayloadContents().
|
||||||
|
// - Keep secrets and transient runtime rows sanitized before writing db.json.
|
||||||
|
// - users.api_key is intentionally not exported.
|
||||||
|
// - backup.settings.v1 is exported as portable-only; the current server runtime
|
||||||
|
// envelope must not leave the instance.
|
||||||
type SqlRow = Record<string, string | number | null>;
|
type SqlRow = Record<string, string | number | null>;
|
||||||
|
|
||||||
const BACKUP_FORMAT_VERSION = 1;
|
const BACKUP_FORMAT_VERSION = 1;
|
||||||
|
const BACKUP_RUNNER_LOCK_CONFIG_KEY = 'backup.runner.lock.v1';
|
||||||
const BACKUP_FILE_HASH_PREFIX_LENGTH = 5;
|
const BACKUP_FILE_HASH_PREFIX_LENGTH = 5;
|
||||||
// Worker-side backup export must stay well below Cloudflare CPU limits.
|
// Worker-side backup export must stay well below Cloudflare CPU limits.
|
||||||
// Prefer store-only ZIP entries over heavier compression to keep exports reliable.
|
// Prefer store-only ZIP entries over heavier compression to keep exports reliable.
|
||||||
@@ -48,6 +62,7 @@ export interface BackupPayload {
|
|||||||
db: {
|
db: {
|
||||||
config: SqlRow[];
|
config: SqlRow[];
|
||||||
users: SqlRow[];
|
users: SqlRow[];
|
||||||
|
domain_settings: SqlRow[];
|
||||||
user_revisions: SqlRow[];
|
user_revisions: SqlRow[];
|
||||||
folders: SqlRow[];
|
folders: SqlRow[];
|
||||||
ciphers: SqlRow[];
|
ciphers: SqlRow[];
|
||||||
@@ -89,6 +104,23 @@ async function queryRows(db: D1Database, sql: string, ...values: unknown[]): Pro
|
|||||||
return (result.results || []).map((row) => ({ ...row }));
|
return (result.results || []).map((row) => ({ ...row }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeConfigRowsForExport(rows: SqlRow[]): SqlRow[] {
|
||||||
|
const sanitized: SqlRow[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const key = String(row.key || '').trim();
|
||||||
|
if (!key || key === BACKUP_RUNNER_LOCK_CONFIG_KEY) continue;
|
||||||
|
|
||||||
|
if (key === BACKUP_SETTINGS_CONFIG_KEY) {
|
||||||
|
const portableOnly = exportPortableBackupSettingsEnvelope(typeof row.value === 'string' ? row.value : null);
|
||||||
|
if (portableOnly) sanitized.push({ ...row, value: portableOnly });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
sanitized.push({ ...row });
|
||||||
|
}
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
async function sha256Hex(bytes: Uint8Array): Promise<string> {
|
async function sha256Hex(bytes: Uint8Array): Promise<string> {
|
||||||
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
||||||
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||||
@@ -264,6 +296,7 @@ export function validateBackupPayloadContents(
|
|||||||
const configRows = ensureRowArray(payload.db.config, 'config');
|
const configRows = ensureRowArray(payload.db.config, 'config');
|
||||||
const userRows = ensureRowArray(payload.db.users, 'users');
|
const userRows = ensureRowArray(payload.db.users, 'users');
|
||||||
const revisionRows = ensureRowArray(payload.db.user_revisions, 'user_revisions');
|
const revisionRows = ensureRowArray(payload.db.user_revisions, 'user_revisions');
|
||||||
|
const domainSettingsRows = ensureRowArray(payload.db.domain_settings || [], 'domain_settings');
|
||||||
const folderRows = ensureRowArray(payload.db.folders, 'folders');
|
const folderRows = ensureRowArray(payload.db.folders, 'folders');
|
||||||
const cipherRows = ensureRowArray(payload.db.ciphers, 'ciphers');
|
const cipherRows = ensureRowArray(payload.db.ciphers, 'ciphers');
|
||||||
const attachmentRows = ensureRowArray(payload.db.attachments, 'attachments');
|
const attachmentRows = ensureRowArray(payload.db.attachments, 'attachments');
|
||||||
@@ -294,6 +327,18 @@ export function validateBackupPayloadContents(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const domainSettingUserIds = new Set<string>();
|
||||||
|
for (const row of domainSettingsRows) {
|
||||||
|
const userId = String(row.user_id || '').trim();
|
||||||
|
if (!userId || !userIds.has(userId)) {
|
||||||
|
throw new Error(`Backup archive contains domain settings for an unknown user: ${userId || '(empty)'}`);
|
||||||
|
}
|
||||||
|
if (domainSettingUserIds.has(userId)) {
|
||||||
|
throw new Error(`Backup archive contains duplicate domain settings for user: ${userId}`);
|
||||||
|
}
|
||||||
|
domainSettingUserIds.add(userId);
|
||||||
|
}
|
||||||
|
|
||||||
const folderIds = new Set<string>();
|
const folderIds = new Set<string>();
|
||||||
for (const row of folderRows) {
|
for (const row of folderRows) {
|
||||||
const id = String(row.id || '').trim();
|
const id = String(row.id || '').trim();
|
||||||
@@ -345,14 +390,16 @@ export async function buildBackupArchive(
|
|||||||
includeAttachments,
|
includeAttachments,
|
||||||
});
|
});
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const [configRows, userRows, revisionRows, folderRows, cipherRows, attachmentRows] = await Promise.all([
|
const [configRows, userRows, domainSettingsRows, revisionRows, folderRows, cipherRows, attachmentRows] = await Promise.all([
|
||||||
queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'),
|
queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'),
|
||||||
queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, api_key, created_at, updated_at FROM users ORDER BY created_at ASC'),
|
queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'),
|
||||||
|
queryRows(env.DB, 'SELECT user_id, equivalent_domains, custom_equivalent_domains, excluded_global_equivalent_domains, updated_at FROM domain_settings ORDER BY user_id ASC'),
|
||||||
queryRows(env.DB, 'SELECT user_id, revision_date FROM user_revisions ORDER BY user_id ASC'),
|
queryRows(env.DB, 'SELECT user_id, revision_date FROM user_revisions ORDER BY user_id ASC'),
|
||||||
queryRows(env.DB, 'SELECT id, user_id, name, created_at, updated_at FROM folders ORDER BY created_at ASC'),
|
queryRows(env.DB, 'SELECT id, user_id, name, created_at, updated_at FROM folders ORDER BY created_at ASC'),
|
||||||
queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at FROM ciphers ORDER BY created_at ASC'),
|
queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at FROM ciphers ORDER BY created_at ASC'),
|
||||||
queryRows(env.DB, 'SELECT id, cipher_id, file_name, size, size_name, key FROM attachments ORDER BY cipher_id ASC, id ASC'),
|
queryRows(env.DB, 'SELECT id, cipher_id, file_name, size, size_name, key FROM attachments ORDER BY cipher_id ASC, id ASC'),
|
||||||
]);
|
]);
|
||||||
|
const exportedConfigRows = sanitizeConfigRowsForExport(configRows);
|
||||||
const exportedAttachmentRows = includeAttachments ? attachmentRows : [];
|
const exportedAttachmentRows = includeAttachments ? attachmentRows : [];
|
||||||
const attachmentBlobs: BackupManifestAttachmentBlob[] = exportedAttachmentRows.map((row) => {
|
const attachmentBlobs: BackupManifestAttachmentBlob[] = exportedAttachmentRows.map((row) => {
|
||||||
const cipherId = String(row.cipher_id || '').trim();
|
const cipherId = String(row.cipher_id || '').trim();
|
||||||
@@ -371,8 +418,9 @@ export async function buildBackupArchive(
|
|||||||
appVersion: APP_VERSION,
|
appVersion: APP_VERSION,
|
||||||
storageKind: getBlobStorageKind(env),
|
storageKind: getBlobStorageKind(env),
|
||||||
tableCounts: {
|
tableCounts: {
|
||||||
config: configRows.length,
|
config: exportedConfigRows.length,
|
||||||
users: userRows.length,
|
users: userRows.length,
|
||||||
|
domain_settings: domainSettingsRows.length,
|
||||||
user_revisions: revisionRows.length,
|
user_revisions: revisionRows.length,
|
||||||
folders: folderRows.length,
|
folders: folderRows.length,
|
||||||
ciphers: cipherRows.length,
|
ciphers: cipherRows.length,
|
||||||
@@ -392,8 +440,9 @@ export async function buildBackupArchive(
|
|||||||
const files: Record<string, Uint8Array> = {
|
const files: Record<string, Uint8Array> = {
|
||||||
'manifest.json': encoder.encode(JSON.stringify(manifestBase, null, BACKUP_JSON_INDENT)),
|
'manifest.json': encoder.encode(JSON.stringify(manifestBase, null, BACKUP_JSON_INDENT)),
|
||||||
'db.json': encoder.encode(JSON.stringify({
|
'db.json': encoder.encode(JSON.stringify({
|
||||||
config: configRows,
|
config: exportedConfigRows,
|
||||||
users: userRows,
|
users: userRows,
|
||||||
|
domain_settings: domainSettingsRows,
|
||||||
user_revisions: revisionRows,
|
user_revisions: revisionRows,
|
||||||
folders: folderRows,
|
folders: folderRows,
|
||||||
ciphers: cipherRows,
|
ciphers: cipherRows,
|
||||||
|
|||||||
@@ -8,10 +8,21 @@ import {
|
|||||||
validateBackupPayloadContents,
|
validateBackupPayloadContents,
|
||||||
} from './backup-archive';
|
} from './backup-archive';
|
||||||
|
|
||||||
|
// CONTRACT:
|
||||||
|
// Restore is intentionally whitelist-based. Old backups may contain retired
|
||||||
|
// fields, but only the columns listed here are imported. Keep this file in sync
|
||||||
|
// with src/services/backup-archive.ts whenever backup contents change.
|
||||||
|
//
|
||||||
|
// WHEN CHANGING THIS:
|
||||||
|
// - Update BackupTableName, BACKUP_TABLES, reset statements, prepared payloads,
|
||||||
|
// shadow-table count validation, insert column lists, and frontend import
|
||||||
|
// count types together.
|
||||||
|
// - Do not import users.api_key, even if an older backup contains it.
|
||||||
type SqlRow = Record<string, string | number | null>;
|
type SqlRow = Record<string, string | number | null>;
|
||||||
type BackupTableName =
|
type BackupTableName =
|
||||||
| 'config'
|
| 'config'
|
||||||
| 'users'
|
| 'users'
|
||||||
|
| 'domain_settings'
|
||||||
| 'user_revisions'
|
| 'user_revisions'
|
||||||
| 'folders'
|
| 'folders'
|
||||||
| 'ciphers'
|
| 'ciphers'
|
||||||
@@ -20,6 +31,7 @@ type BackupTableName =
|
|||||||
const BACKUP_TABLES: BackupTableName[] = [
|
const BACKUP_TABLES: BackupTableName[] = [
|
||||||
'config',
|
'config',
|
||||||
'users',
|
'users',
|
||||||
|
'domain_settings',
|
||||||
'user_revisions',
|
'user_revisions',
|
||||||
'folders',
|
'folders',
|
||||||
'ciphers',
|
'ciphers',
|
||||||
@@ -35,6 +47,7 @@ export interface BackupImportResultBody {
|
|||||||
imported: {
|
imported: {
|
||||||
config: number;
|
config: number;
|
||||||
users: number;
|
users: number;
|
||||||
|
domainSettings: number;
|
||||||
userRevisions: number;
|
userRevisions: number;
|
||||||
folders: number;
|
folders: number;
|
||||||
ciphers: number;
|
ciphers: number;
|
||||||
@@ -155,6 +168,7 @@ function buildResetImportTargetStatements(db: D1Database): D1PreparedStatement[]
|
|||||||
'DELETE FROM attachments',
|
'DELETE FROM attachments',
|
||||||
'DELETE FROM ciphers',
|
'DELETE FROM ciphers',
|
||||||
'DELETE FROM folders',
|
'DELETE FROM folders',
|
||||||
|
'DELETE FROM domain_settings',
|
||||||
'DELETE FROM user_revisions',
|
'DELETE FROM user_revisions',
|
||||||
'DELETE FROM users',
|
'DELETE FROM users',
|
||||||
'DELETE FROM config',
|
'DELETE FROM config',
|
||||||
@@ -276,6 +290,7 @@ async function importPreparedBackupRows(db: D1Database, payload: BackupPayload['
|
|||||||
...row,
|
...row,
|
||||||
verify_devices: row.verify_devices ?? 1,
|
verify_devices: row.verify_devices ?? 1,
|
||||||
})),
|
})),
|
||||||
|
domain_settings: cloneRows(payload.domain_settings || []),
|
||||||
user_revisions: cloneRows(payload.user_revisions || []),
|
user_revisions: cloneRows(payload.user_revisions || []),
|
||||||
folders: cloneRows(payload.folders || []),
|
folders: cloneRows(payload.folders || []),
|
||||||
ciphers: cloneRows(payload.ciphers || []).map((row) => ({
|
ciphers: cloneRows(payload.ciphers || []).map((row) => ({
|
||||||
@@ -594,7 +609,7 @@ async function importBackupRows(db: D1Database, payload: BackupPayload['db'], us
|
|||||||
buildInsertStatements(
|
buildInsertStatements(
|
||||||
db,
|
db,
|
||||||
tableName('users'),
|
tableName('users'),
|
||||||
['id', 'email', 'name', 'master_password_hint', 'master_password_hash', 'key', 'private_key', 'public_key', 'kdf_type', 'kdf_iterations', 'kdf_memory', 'kdf_parallelism', 'security_stamp', 'role', 'status', 'verify_devices', 'totp_secret', 'totp_recovery_code', 'api_key', 'created_at', 'updated_at'],
|
['id', 'email', 'name', 'master_password_hint', 'master_password_hash', 'key', 'private_key', 'public_key', 'kdf_type', 'kdf_iterations', 'kdf_memory', 'kdf_parallelism', 'security_stamp', 'role', 'status', 'verify_devices', 'totp_secret', 'totp_recovery_code', 'created_at', 'updated_at'],
|
||||||
payload.users || []
|
payload.users || []
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -603,6 +618,17 @@ async function importBackupRows(db: D1Database, payload: BackupPayload['db'], us
|
|||||||
tableName('user_revisions'),
|
tableName('user_revisions'),
|
||||||
buildInsertStatements(db, tableName('user_revisions'), ['user_id', 'revision_date'], payload.user_revisions || [], true)
|
buildInsertStatements(db, tableName('user_revisions'), ['user_id', 'revision_date'], payload.user_revisions || [], true)
|
||||||
);
|
);
|
||||||
|
await runInsertBatch(
|
||||||
|
db,
|
||||||
|
tableName('domain_settings'),
|
||||||
|
buildInsertStatements(
|
||||||
|
db,
|
||||||
|
tableName('domain_settings'),
|
||||||
|
['user_id', 'equivalent_domains', 'custom_equivalent_domains', 'excluded_global_equivalent_domains', 'updated_at'],
|
||||||
|
payload.domain_settings || [],
|
||||||
|
true
|
||||||
|
)
|
||||||
|
);
|
||||||
await runInsertBatch(
|
await runInsertBatch(
|
||||||
db,
|
db,
|
||||||
tableName('folders'),
|
tableName('folders'),
|
||||||
@@ -669,6 +695,7 @@ export async function importBackupArchiveBytes(
|
|||||||
await validateShadowTableCounts(env.DB, {
|
await validateShadowTableCounts(env.DB, {
|
||||||
config: (db.config || []).length,
|
config: (db.config || []).length,
|
||||||
users: (db.users || []).length,
|
users: (db.users || []).length,
|
||||||
|
domain_settings: (db.domain_settings || []).length,
|
||||||
user_revisions: (db.user_revisions || []).length,
|
user_revisions: (db.user_revisions || []).length,
|
||||||
folders: (db.folders || []).length,
|
folders: (db.folders || []).length,
|
||||||
ciphers: (db.ciphers || []).length,
|
ciphers: (db.ciphers || []).length,
|
||||||
@@ -690,6 +717,7 @@ export async function importBackupArchiveBytes(
|
|||||||
await validateShadowTableCounts(env.DB, {
|
await validateShadowTableCounts(env.DB, {
|
||||||
config: (db.config || []).length,
|
config: (db.config || []).length,
|
||||||
users: (db.users || []).length,
|
users: (db.users || []).length,
|
||||||
|
domain_settings: (db.domain_settings || []).length,
|
||||||
user_revisions: (db.user_revisions || []).length,
|
user_revisions: (db.user_revisions || []).length,
|
||||||
folders: (db.folders || []).length,
|
folders: (db.folders || []).length,
|
||||||
ciphers: (db.ciphers || []).length,
|
ciphers: (db.ciphers || []).length,
|
||||||
@@ -729,6 +757,7 @@ export async function importBackupArchiveBytes(
|
|||||||
imported: {
|
imported: {
|
||||||
config: (db.config || []).length,
|
config: (db.config || []).length,
|
||||||
users: (db.users || []).length,
|
users: (db.users || []).length,
|
||||||
|
domainSettings: (db.domain_settings || []).length,
|
||||||
userRevisions: (db.user_revisions || []).length,
|
userRevisions: (db.user_revisions || []).length,
|
||||||
folders: (db.folders || []).length,
|
folders: (db.folders || []).length,
|
||||||
ciphers: (db.ciphers || []).length,
|
ciphers: (db.ciphers || []).length,
|
||||||
@@ -804,6 +833,7 @@ export async function importRemoteBackupArchiveBytes(
|
|||||||
await validateShadowTableCounts(env.DB, {
|
await validateShadowTableCounts(env.DB, {
|
||||||
config: (db.config || []).length,
|
config: (db.config || []).length,
|
||||||
users: (db.users || []).length,
|
users: (db.users || []).length,
|
||||||
|
domain_settings: (db.domain_settings || []).length,
|
||||||
user_revisions: (db.user_revisions || []).length,
|
user_revisions: (db.user_revisions || []).length,
|
||||||
folders: (db.folders || []).length,
|
folders: (db.folders || []).length,
|
||||||
ciphers: (db.ciphers || []).length,
|
ciphers: (db.ciphers || []).length,
|
||||||
@@ -825,6 +855,7 @@ export async function importRemoteBackupArchiveBytes(
|
|||||||
await validateShadowTableCounts(env.DB, {
|
await validateShadowTableCounts(env.DB, {
|
||||||
config: (db.config || []).length,
|
config: (db.config || []).length,
|
||||||
users: (db.users || []).length,
|
users: (db.users || []).length,
|
||||||
|
domain_settings: (db.domain_settings || []).length,
|
||||||
user_revisions: (db.user_revisions || []).length,
|
user_revisions: (db.user_revisions || []).length,
|
||||||
folders: (db.folders || []).length,
|
folders: (db.folders || []).length,
|
||||||
ciphers: (db.ciphers || []).length,
|
ciphers: (db.ciphers || []).length,
|
||||||
@@ -870,6 +901,7 @@ export async function importRemoteBackupArchiveBytes(
|
|||||||
imported: {
|
imported: {
|
||||||
config: (db.config || []).length,
|
config: (db.config || []).length,
|
||||||
users: (db.users || []).length,
|
users: (db.users || []).length,
|
||||||
|
domainSettings: (db.domain_settings || []).length,
|
||||||
userRevisions: (db.user_revisions || []).length,
|
userRevisions: (db.user_revisions || []).length,
|
||||||
folders: (db.folders || []).length,
|
folders: (db.folders || []).length,
|
||||||
ciphers: (db.ciphers || []).length,
|
ciphers: (db.ciphers || []).length,
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
import type { Env, User } from '../types';
|
import type { Env, User } from '../types';
|
||||||
|
|
||||||
|
// CONTRACT:
|
||||||
|
// Backup settings contain provider credentials. They are stored as a v2 envelope:
|
||||||
|
// - runtime: AES-GCM encrypted with a key derived from JWT_SECRET for the current
|
||||||
|
// server's scheduled backup runner.
|
||||||
|
// - portable: AES-GCM encrypted with a random DEK; that DEK is RSA-wrapped for
|
||||||
|
// active admin public keys so settings can be repaired after restore/migration.
|
||||||
|
//
|
||||||
|
// New admin-entered provider secrets, such as mail API keys, should use this
|
||||||
|
// pattern or a deliberately documented replacement. Do not store provider
|
||||||
|
// secrets as plain config JSON.
|
||||||
const RUNTIME_SALT = 'nodewarden.backup-settings.runtime.v2';
|
const RUNTIME_SALT = 'nodewarden.backup-settings.runtime.v2';
|
||||||
const RUNTIME_INFO = 'runtime';
|
const RUNTIME_INFO = 'runtime';
|
||||||
const PORTABLE_ALGORITHM = 'RSA-OAEP';
|
const PORTABLE_ALGORITHM = 'RSA-OAEP';
|
||||||
@@ -155,6 +165,20 @@ export function parseBackupSettingsEnvelope(raw: string | null): BackupSettingsE
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function exportPortableBackupSettingsEnvelope(raw: string | null): string | null {
|
||||||
|
const envelope = parseBackupSettingsEnvelope(raw);
|
||||||
|
if (!envelope) return null;
|
||||||
|
return JSON.stringify({
|
||||||
|
version: 2,
|
||||||
|
portableOnly: true,
|
||||||
|
runtime: {
|
||||||
|
iv: '',
|
||||||
|
ciphertext: '',
|
||||||
|
},
|
||||||
|
portable: envelope.portable,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function encryptBackupSettingsEnvelope(
|
export async function encryptBackupSettingsEnvelope(
|
||||||
plaintext: string,
|
plaintext: string,
|
||||||
env: Env,
|
env: Env,
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import bitwardenGlobalDomainsRaw from '../static/global_domains.bitwarden.json';
|
||||||
|
import customGlobalDomainsRaw from '../static/global_domains.custom.json';
|
||||||
|
import type { CustomEquivalentDomain, DomainRulesResponse, GlobalEquivalentDomain } from '../types';
|
||||||
|
import { normalizeEquivalentDomain } from '../../shared/domain-normalize';
|
||||||
|
|
||||||
|
// CONTRACT:
|
||||||
|
// Equivalent domains are a Bitwarden compatibility surface. The DB stores both
|
||||||
|
// the full custom rule list and the derived active equivalent-domain groups:
|
||||||
|
// - custom_equivalent_domains: UI/client rules with id + excluded state.
|
||||||
|
// - equivalent_domains: active groups derived from non-excluded custom rules.
|
||||||
|
// - excluded_global_equivalent_domains: disabled global rule type ids.
|
||||||
|
// Do not treat equivalent_domains and custom_equivalent_domains as accidental
|
||||||
|
// duplicates without a migration and compatibility plan.
|
||||||
|
type RawGlobalDomain = Partial<GlobalEquivalentDomain> & {
|
||||||
|
Type?: unknown;
|
||||||
|
Domains?: unknown;
|
||||||
|
Excluded?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeDomain(value: unknown): string {
|
||||||
|
return normalizeEquivalentDomain(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGlobalDomain(entry: RawGlobalDomain): GlobalEquivalentDomain | null {
|
||||||
|
const type = Number(entry.type ?? entry.Type);
|
||||||
|
if (!Number.isInteger(type)) return null;
|
||||||
|
|
||||||
|
const rawDomains = entry.domains ?? entry.Domains;
|
||||||
|
if (!Array.isArray(rawDomains)) return null;
|
||||||
|
|
||||||
|
const domains = Array.from(new Set(rawDomains.map(normalizeDomain).filter(Boolean)));
|
||||||
|
if (domains.length < 2) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
domains,
|
||||||
|
excluded: Boolean(entry.excluded ?? entry.Excluded ?? false),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGlobalDomains(input: unknown): GlobalEquivalentDomain[] {
|
||||||
|
if (!Array.isArray(input)) return [];
|
||||||
|
|
||||||
|
const seen = new Set<number>();
|
||||||
|
const out: GlobalEquivalentDomain[] = [];
|
||||||
|
for (const entry of input) {
|
||||||
|
const normalized = normalizeGlobalDomain(entry as RawGlobalDomain);
|
||||||
|
if (!normalized || seen.has(normalized.type)) continue;
|
||||||
|
seen.add(normalized.type);
|
||||||
|
out.push(normalized);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bitwardenGlobalDomains = normalizeGlobalDomains(bitwardenGlobalDomainsRaw);
|
||||||
|
const customGlobalDomains = normalizeGlobalDomains(customGlobalDomainsRaw);
|
||||||
|
|
||||||
|
export const globalDomains: readonly GlobalEquivalentDomain[] = [
|
||||||
|
...bitwardenGlobalDomains,
|
||||||
|
...customGlobalDomains,
|
||||||
|
];
|
||||||
|
|
||||||
|
export function normalizeEquivalentDomains(input: unknown): string[][] {
|
||||||
|
if (!Array.isArray(input)) return [];
|
||||||
|
|
||||||
|
const groups: string[][] = [];
|
||||||
|
const seenGroups = new Set<string>();
|
||||||
|
for (const group of input) {
|
||||||
|
if (!Array.isArray(group)) continue;
|
||||||
|
const domains = Array.from(new Set(group.map(normalizeDomain).filter(Boolean)));
|
||||||
|
if (domains.length < 2) continue;
|
||||||
|
const key = domains.slice().sort().join('\n');
|
||||||
|
if (seenGroups.has(key)) continue;
|
||||||
|
seenGroups.add(key);
|
||||||
|
groups.push(domains);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeEquivalentDomainGroups(input: string[][]): string[][] {
|
||||||
|
const parent = new Map<string, string>();
|
||||||
|
|
||||||
|
function find(domain: string): string {
|
||||||
|
const current = parent.get(domain);
|
||||||
|
if (!current) {
|
||||||
|
parent.set(domain, domain);
|
||||||
|
return domain;
|
||||||
|
}
|
||||||
|
if (current === domain) return domain;
|
||||||
|
const root = find(current);
|
||||||
|
parent.set(domain, root);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function union(a: string, b: string): void {
|
||||||
|
const rootA = find(a);
|
||||||
|
const rootB = find(b);
|
||||||
|
if (rootA !== rootB) parent.set(rootB, rootA);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const group of normalizeEquivalentDomains(input)) {
|
||||||
|
if (group.length < 2) continue;
|
||||||
|
const [first, ...rest] = group;
|
||||||
|
find(first);
|
||||||
|
for (const domain of rest) union(first, domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
const components = new Map<string, string[]>();
|
||||||
|
for (const domain of parent.keys()) {
|
||||||
|
const root = find(domain);
|
||||||
|
const group = components.get(root) || [];
|
||||||
|
group.push(domain);
|
||||||
|
components.set(root, group);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(components.values())
|
||||||
|
.map((group) => group.sort())
|
||||||
|
.filter((group) => group.length >= 2)
|
||||||
|
.sort((a, b) => a[0].localeCompare(b[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expandCustomEquivalentDomainsWithGlobals(
|
||||||
|
customGroups: string[][],
|
||||||
|
activeGlobalGroups: string[][]
|
||||||
|
): string[][] {
|
||||||
|
const normalizedCustomGroups = normalizeEquivalentDomains(customGroups);
|
||||||
|
if (!normalizedCustomGroups.length) return [];
|
||||||
|
|
||||||
|
const customDomains = new Set(normalizedCustomGroups.flat());
|
||||||
|
return mergeEquivalentDomainGroups([
|
||||||
|
...activeGlobalGroups,
|
||||||
|
...normalizedCustomGroups,
|
||||||
|
]).filter((group) => group.some((domain) => customDomains.has(domain)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCustomDomainId(domains: string[], index: number): string {
|
||||||
|
return `custom:${domains.slice().sort().join('|')}:${index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeCustomEquivalentDomains(input: unknown): CustomEquivalentDomain[] {
|
||||||
|
if (!Array.isArray(input)) return [];
|
||||||
|
|
||||||
|
const rules: CustomEquivalentDomain[] = [];
|
||||||
|
const seenGroups = new Set<string>();
|
||||||
|
for (const [index, item] of input.entries()) {
|
||||||
|
const record = Array.isArray(item)
|
||||||
|
? { domains: item, excluded: false, id: '' }
|
||||||
|
: item && typeof item === 'object'
|
||||||
|
? item as Record<string, unknown>
|
||||||
|
: null;
|
||||||
|
if (!record) continue;
|
||||||
|
|
||||||
|
const domains = normalizeEquivalentDomains([record.domains ?? record.Domains])[0];
|
||||||
|
if (!domains) continue;
|
||||||
|
|
||||||
|
const key = domains.slice().sort().join('\n');
|
||||||
|
if (seenGroups.has(key)) continue;
|
||||||
|
seenGroups.add(key);
|
||||||
|
|
||||||
|
const rawId = String(record.id ?? record.Id ?? '').trim();
|
||||||
|
rules.push({
|
||||||
|
id: rawId || createCustomDomainId(domains, index),
|
||||||
|
domains,
|
||||||
|
excluded: Boolean(record.excluded ?? record.Excluded ?? false),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return rules;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function customRulesToActiveEquivalentDomains(rules: CustomEquivalentDomain[]): string[][] {
|
||||||
|
return mergeEquivalentDomainGroups(rules
|
||||||
|
.filter((rule) => !rule.excluded)
|
||||||
|
.map((rule) => rule.domains));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeExcludedGlobalTypes(input: unknown): number[] {
|
||||||
|
if (!Array.isArray(input)) return [];
|
||||||
|
|
||||||
|
const validTypes = new Set(globalDomains.map((entry) => entry.type));
|
||||||
|
const seen = new Set<number>();
|
||||||
|
const out: number[] = [];
|
||||||
|
for (const item of input) {
|
||||||
|
const type = Number(typeof item === 'object' && item !== null ? (item as Record<string, unknown>).type : item);
|
||||||
|
const excluded = typeof item === 'object' && item !== null
|
||||||
|
? Boolean((item as Record<string, unknown>).excluded)
|
||||||
|
: true;
|
||||||
|
if (!excluded || !Number.isInteger(type) || !validTypes.has(type) || seen.has(type)) continue;
|
||||||
|
seen.add(type);
|
||||||
|
out.push(type);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDomainsResponse(
|
||||||
|
equivalentDomains: string[][],
|
||||||
|
customEquivalentDomains: CustomEquivalentDomain[],
|
||||||
|
excludedGlobalEquivalentDomains: number[],
|
||||||
|
options: { omitExcludedGlobals?: boolean } = {}
|
||||||
|
): DomainRulesResponse {
|
||||||
|
const excluded = new Set(excludedGlobalEquivalentDomains);
|
||||||
|
const activeGlobalDomainGroups = globalDomains
|
||||||
|
.filter((entry) => !excluded.has(entry.type))
|
||||||
|
.map((entry) => entry.domains);
|
||||||
|
const mergedEquivalentDomains = expandCustomEquivalentDomainsWithGlobals(
|
||||||
|
equivalentDomains,
|
||||||
|
activeGlobalDomainGroups
|
||||||
|
);
|
||||||
|
const globals = globalDomains
|
||||||
|
.map((entry) => ({
|
||||||
|
type: entry.type,
|
||||||
|
domains: entry.domains,
|
||||||
|
excluded: excluded.has(entry.type),
|
||||||
|
}))
|
||||||
|
.filter((entry) => !options.omitExcludedGlobals || !entry.excluded);
|
||||||
|
|
||||||
|
return {
|
||||||
|
equivalentDomains: mergedEquivalentDomains,
|
||||||
|
customEquivalentDomains,
|
||||||
|
globalEquivalentDomains: globals,
|
||||||
|
object: 'domains',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import type { UserDomainSettings } from '../types';
|
||||||
|
import { normalizeCustomEquivalentDomains, normalizeEquivalentDomains } from './domain-rules';
|
||||||
|
|
||||||
|
// Storage adapter for the domain_settings table.
|
||||||
|
//
|
||||||
|
// CONTRACT:
|
||||||
|
// equivalent_domains is kept as the active derived groups for compatibility and
|
||||||
|
// fallback reads. custom_equivalent_domains is the full rule list that preserves
|
||||||
|
// UI/client state. Save both together through saveUserDomainSettings().
|
||||||
|
function parseJsonArray<T>(raw: string | null | undefined, fallback: T[]): T[] {
|
||||||
|
if (!raw) return fallback;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
return Array.isArray(parsed) ? parsed as T[] : fallback;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserDomainSettings(db: D1Database, userId: string): Promise<UserDomainSettings> {
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT equivalent_domains, custom_equivalent_domains, excluded_global_equivalent_domains, updated_at FROM domain_settings WHERE user_id = ?')
|
||||||
|
.bind(userId)
|
||||||
|
.first<{
|
||||||
|
equivalent_domains: string | null;
|
||||||
|
custom_equivalent_domains: string | null;
|
||||||
|
excluded_global_equivalent_domains: string | null;
|
||||||
|
updated_at: string | null;
|
||||||
|
}>();
|
||||||
|
const equivalentDomains = normalizeEquivalentDomains(parseJsonArray<string[]>(row?.equivalent_domains, []));
|
||||||
|
const storedCustomEquivalentDomains = row?.custom_equivalent_domains
|
||||||
|
? normalizeCustomEquivalentDomains(parseJsonArray<unknown>(row.custom_equivalent_domains, []))
|
||||||
|
: [];
|
||||||
|
const customEquivalentDomains = storedCustomEquivalentDomains.length
|
||||||
|
? storedCustomEquivalentDomains
|
||||||
|
: normalizeCustomEquivalentDomains(equivalentDomains);
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId,
|
||||||
|
equivalentDomains,
|
||||||
|
customEquivalentDomains,
|
||||||
|
excludedGlobalEquivalentDomains: parseJsonArray<number>(row?.excluded_global_equivalent_domains, []),
|
||||||
|
updatedAt: row?.updated_at || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveUserDomainSettings(
|
||||||
|
db: D1Database,
|
||||||
|
userId: string,
|
||||||
|
equivalentDomains: string[][],
|
||||||
|
customEquivalentDomains: UserDomainSettings['customEquivalentDomains'],
|
||||||
|
excludedGlobalEquivalentDomains: number[],
|
||||||
|
updatedAt: string
|
||||||
|
): Promise<void> {
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO domain_settings(user_id, equivalent_domains, custom_equivalent_domains, excluded_global_equivalent_domains, updated_at) ' +
|
||||||
|
'VALUES(?, ?, ?, ?, ?) ' +
|
||||||
|
'ON CONFLICT(user_id) DO UPDATE SET ' +
|
||||||
|
'equivalent_domains = excluded.equivalent_domains, ' +
|
||||||
|
'custom_equivalent_domains = excluded.custom_equivalent_domains, ' +
|
||||||
|
'excluded_global_equivalent_domains = excluded.excluded_global_equivalent_domains, ' +
|
||||||
|
'updated_at = excluded.updated_at'
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
userId,
|
||||||
|
JSON.stringify(equivalentDomains),
|
||||||
|
JSON.stringify(customEquivalentDomains),
|
||||||
|
JSON.stringify(excludedGlobalEquivalentDomains),
|
||||||
|
updatedAt
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
// IMPORTANT:
|
// IMPORTANT:
|
||||||
// Keep this schema list in sync with migrations/0001_init.sql.
|
// This is the runtime D1 schema bootstrap. Keep it in sync with
|
||||||
// Any new table/column/index must be added to both places together.
|
// migrations/0001_init.sql. Any new table/column/index must be added to both
|
||||||
|
// places together.
|
||||||
|
//
|
||||||
|
// WHEN CHANGING THIS:
|
||||||
|
// - Bump STORAGE_SCHEMA_VERSION in src/services/storage.ts so existing installs
|
||||||
|
// rerun these idempotent statements.
|
||||||
|
// - If the new table stores persistent data, update the backup export/import
|
||||||
|
// contract in src/services/backup-archive.ts and backup-import.ts.
|
||||||
|
// - Keep statements idempotent; D1 may execute them again on later requests.
|
||||||
const SCHEMA_STATEMENTS: readonly string[] = [
|
const SCHEMA_STATEMENTS: readonly string[] = [
|
||||||
'CREATE TABLE IF NOT EXISTS users (' +
|
'CREATE TABLE IF NOT EXISTS users (' +
|
||||||
'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hint TEXT, master_password_hash TEXT NOT NULL, ' +
|
'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hint TEXT, master_password_hash TEXT NOT NULL, ' +
|
||||||
@@ -15,6 +23,11 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
|||||||
'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT',
|
'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT',
|
||||||
'ALTER TABLE users ADD COLUMN api_key TEXT',
|
'ALTER TABLE users ADD COLUMN api_key TEXT',
|
||||||
|
|
||||||
|
'CREATE TABLE IF NOT EXISTS domain_settings (' +
|
||||||
|
'user_id TEXT PRIMARY KEY, equivalent_domains TEXT NOT NULL DEFAULT \'[]\', custom_equivalent_domains TEXT NOT NULL DEFAULT \'[]\', excluded_global_equivalent_domains TEXT NOT NULL DEFAULT \'[]\', updated_at TEXT NOT NULL, ' +
|
||||||
|
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||||
|
'ALTER TABLE domain_settings ADD COLUMN custom_equivalent_domains TEXT NOT NULL DEFAULT \'[]\'',
|
||||||
|
|
||||||
'CREATE TABLE IF NOT EXISTS user_revisions (' +
|
'CREATE TABLE IF NOT EXISTS user_revisions (' +
|
||||||
'user_id TEXT PRIMARY KEY, revision_date TEXT NOT NULL, ' +
|
'user_id TEXT PRIMARY KEY, revision_date TEXT NOT NULL, ' +
|
||||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, TrustedDeviceTokenSummary, RefreshTokenRecord } from '../types';
|
import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, TrustedDeviceTokenSummary, RefreshTokenRecord, CustomEquivalentDomain } from '../types';
|
||||||
import { LIMITS } from '../config/limits';
|
import { LIMITS } from '../config/limits';
|
||||||
import { ensureStorageSchema } from './storage-schema';
|
import { ensureStorageSchema } from './storage-schema';
|
||||||
import {
|
import {
|
||||||
@@ -105,10 +105,18 @@ import {
|
|||||||
getRevisionDate as getStoredRevisionDate,
|
getRevisionDate as getStoredRevisionDate,
|
||||||
updateRevisionDate as updateStoredRevisionDate,
|
updateRevisionDate as updateStoredRevisionDate,
|
||||||
} from './storage-revision-repo';
|
} from './storage-revision-repo';
|
||||||
|
import {
|
||||||
|
getUserDomainSettings as getStoredUserDomainSettings,
|
||||||
|
saveUserDomainSettings as saveStoredUserDomainSettings,
|
||||||
|
} from './storage-domain-rules-repo';
|
||||||
|
|
||||||
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
|
const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
|
||||||
const STORAGE_SCHEMA_VERSION = '2026-04-28';
|
// IMPORTANT:
|
||||||
|
// Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql
|
||||||
|
// changes. Existing D1 installs only rerun ensureStorageSchema() when this value
|
||||||
|
// differs from config.schema.version.
|
||||||
|
const STORAGE_SCHEMA_VERSION = '2026-05-05-domain-rules-v2';
|
||||||
|
|
||||||
// D1-backed storage.
|
// D1-backed storage.
|
||||||
// Contract:
|
// Contract:
|
||||||
@@ -270,6 +278,29 @@ export class StorageService {
|
|||||||
await createStoredAuditLog(this.db, log);
|
await createStoredAuditLog(this.db, log);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Domain rules ---
|
||||||
|
|
||||||
|
async getUserDomainSettings(userId: string) {
|
||||||
|
return getStoredUserDomainSettings(this.db, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveUserDomainSettings(
|
||||||
|
userId: string,
|
||||||
|
equivalentDomains: string[][],
|
||||||
|
customEquivalentDomains: CustomEquivalentDomain[],
|
||||||
|
excludedGlobalEquivalentDomains: number[]
|
||||||
|
): Promise<void> {
|
||||||
|
await saveStoredUserDomainSettings(
|
||||||
|
this.db,
|
||||||
|
userId,
|
||||||
|
equivalentDomains,
|
||||||
|
customEquivalentDomains,
|
||||||
|
excludedGlobalEquivalentDomains,
|
||||||
|
new Date().toISOString()
|
||||||
|
);
|
||||||
|
await this.updateRevisionDate(userId);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Ciphers ---
|
// --- Ciphers ---
|
||||||
|
|
||||||
async getCipher(id: string): Promise<Cipher | null> {
|
async getCipher(id: string): Promise<Cipher | null> {
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
[
|
||||||
|
{"type":2,"domains":["ameritrade.com","tdameritrade.com"],"excluded":false},
|
||||||
|
{"type":3,"domains":["bankofamerica.com","bofa.com","mbna.com","usecfo.com"],"excluded":false},
|
||||||
|
{"type":4,"domains":["sprint.com","sprintpcs.com","nextel.com"],"excluded":false},
|
||||||
|
{"type":0,"domains":["youtube.com","google.com","gmail.com"],"excluded":false},
|
||||||
|
{"type":1,"domains":["apple.com","icloud.com"],"excluded":false},
|
||||||
|
{"type":5,"domains":["wellsfargo.com","wf.com","wellsfargoadvisors.com"],"excluded":false},
|
||||||
|
{"type":6,"domains":["mymerrill.com","ml.com","merrilledge.com"],"excluded":false},
|
||||||
|
{"type":7,"domains":["accountonline.com","citi.com","citibank.com","citicards.com","citibankonline.com"],"excluded":false},
|
||||||
|
{"type":8,"domains":["cnet.com","cnettv.com","com.com","download.com","news.com","search.com","upload.com"],"excluded":false},
|
||||||
|
{"type":9,"domains":["bananarepublic.com","gap.com","oldnavy.com","piperlime.com"],"excluded":false},
|
||||||
|
{"type":10,"domains":["bing.com","hotmail.com","live.com","microsoft.com","msn.com","passport.net","windows.com","microsoftonline.com","office.com","office365.com","microsoftstore.com","xbox.com","azure.com","windowsazure.com","cloud.microsoft"],"excluded":false},
|
||||||
|
{"type":11,"domains":["ua2go.com","ual.com","united.com","unitedwifi.com"],"excluded":false},
|
||||||
|
{"type":12,"domains":["overture.com","yahoo.com"],"excluded":false},
|
||||||
|
{"type":13,"domains":["zonealarm.com","zonelabs.com"],"excluded":false},
|
||||||
|
{"type":14,"domains":["paypal.com","paypal-search.com"],"excluded":false},
|
||||||
|
{"type":15,"domains":["avon.com","youravon.com"],"excluded":false},
|
||||||
|
{"type":16,"domains":["diapers.com","soap.com","wag.com","yoyo.com","beautybar.com","casa.com","afterschool.com","vine.com","bookworm.com","look.com","vinemarket.com"],"excluded":false},
|
||||||
|
{"type":17,"domains":["1800contacts.com","800contacts.com"],"excluded":false},
|
||||||
|
{"type":18,"domains":["amazon.com","amazon.com.be","amazon.ae","amazon.ca","amazon.co.uk","amazon.com.au","amazon.com.br","amazon.com.mx","amazon.com.tr","amazon.de","amazon.es","amazon.fr","amazon.in","amazon.it","amazon.nl","amazon.pl","amazon.sa","amazon.se","amazon.sg"],"excluded":false},
|
||||||
|
{"type":19,"domains":["cox.com","cox.net","coxbusiness.com"],"excluded":false},
|
||||||
|
{"type":20,"domains":["mynortonaccount.com","norton.com"],"excluded":false},
|
||||||
|
{"type":21,"domains":["verizon.com","verizon.net"],"excluded":false},
|
||||||
|
{"type":22,"domains":["rakuten.com","buy.com"],"excluded":false},
|
||||||
|
{"type":23,"domains":["siriusxm.com","sirius.com"],"excluded":false},
|
||||||
|
{"type":24,"domains":["ea.com","origin.com","play4free.com","tiberiumalliance.com"],"excluded":false},
|
||||||
|
{"type":25,"domains":["37signals.com","basecamp.com","basecamphq.com","highrisehq.com"],"excluded":false},
|
||||||
|
{"type":26,"domains":["steampowered.com","steamcommunity.com","steamgames.com"],"excluded":false},
|
||||||
|
{"type":27,"domains":["chart.io","chartio.com"],"excluded":false},
|
||||||
|
{"type":28,"domains":["gotomeeting.com","citrixonline.com"],"excluded":false},
|
||||||
|
{"type":29,"domains":["gogoair.com","gogoinflight.com"],"excluded":false},
|
||||||
|
{"type":30,"domains":["mysql.com","oracle.com"],"excluded":false},
|
||||||
|
{"type":31,"domains":["discover.com","discovercard.com"],"excluded":false},
|
||||||
|
{"type":32,"domains":["dcu.org","dcu-online.org"],"excluded":false},
|
||||||
|
{"type":33,"domains":["healthcare.gov","cuidadodesalud.gov","cms.gov"],"excluded":false},
|
||||||
|
{"type":34,"domains":["pepco.com","pepcoholdings.com"],"excluded":false},
|
||||||
|
{"type":35,"domains":["century21.com","21online.com"],"excluded":false},
|
||||||
|
{"type":36,"domains":["comcast.com","comcast.net","xfinity.com"],"excluded":false},
|
||||||
|
{"type":37,"domains":["cricketwireless.com","aiowireless.com"],"excluded":false},
|
||||||
|
{"type":38,"domains":["mandtbank.com","mtb.com"],"excluded":false},
|
||||||
|
{"type":39,"domains":["dropbox.com","getdropbox.com"],"excluded":false},
|
||||||
|
{"type":40,"domains":["snapfish.com","snapfish.ca"],"excluded":false},
|
||||||
|
{"type":41,"domains":["alibaba.com","aliexpress.com","aliyun.com","net.cn"],"excluded":false},
|
||||||
|
{"type":42,"domains":["playstation.com","sonyentertainmentnetwork.com"],"excluded":false},
|
||||||
|
{"type":43,"domains":["mercadolivre.com","mercadolivre.com.br","mercadolibre.com","mercadolibre.com.ar","mercadolibre.com.mx"],"excluded":false},
|
||||||
|
{"type":44,"domains":["zendesk.com","zopim.com"],"excluded":false},
|
||||||
|
{"type":45,"domains":["autodesk.com","tinkercad.com"],"excluded":false},
|
||||||
|
{"type":46,"domains":["railnation.ru","railnation.de","rail-nation.com","railnation.gr","railnation.us","trucknation.de","traviangames.com"],"excluded":false},
|
||||||
|
{"type":47,"domains":["wpcu.coop","wpcuonline.com"],"excluded":false},
|
||||||
|
{"type":48,"domains":["mathletics.com","mathletics.com.au","mathletics.co.uk"],"excluded":false},
|
||||||
|
{"type":49,"domains":["discountbank.co.il","telebank.co.il"],"excluded":false},
|
||||||
|
{"type":50,"domains":["mi.com","xiaomi.com"],"excluded":false},
|
||||||
|
{"type":52,"domains":["postepay.it","poste.it"],"excluded":false},
|
||||||
|
{"type":51,"domains":["facebook.com","messenger.com"],"excluded":false},
|
||||||
|
{"type":53,"domains":["skysports.com","skybet.com","skyvegas.com"],"excluded":false},
|
||||||
|
{"type":54,"domains":["disneymoviesanywhere.com","go.com","disney.com","dadt.com","disneyplus.com"],"excluded":false},
|
||||||
|
{"type":55,"domains":["pokemon-gl.com","pokemon.com"],"excluded":false},
|
||||||
|
{"type":56,"domains":["myuv.com","uvvu.com"],"excluded":false},
|
||||||
|
{"type":58,"domains":["mdsol.com","imedidata.com"],"excluded":false},
|
||||||
|
{"type":57,"domains":["bank-yahav.co.il","bankhapoalim.co.il"],"excluded":false},
|
||||||
|
{"type":59,"domains":["sears.com","shld.net"],"excluded":false},
|
||||||
|
{"type":60,"domains":["xiami.com","alipay.com"],"excluded":false},
|
||||||
|
{"type":61,"domains":["belkin.com","seedonk.com"],"excluded":false},
|
||||||
|
{"type":62,"domains":["turbotax.com","intuit.com"],"excluded":false},
|
||||||
|
{"type":63,"domains":["shopify.com","myshopify.com"],"excluded":false},
|
||||||
|
{"type":64,"domains":["ebay.com","ebay.at","ebay.be","ebay.ca","ebay.ch","ebay.cn","ebay.co.jp","ebay.co.th","ebay.co.uk","ebay.com.au","ebay.com.hk","ebay.com.my","ebay.com.sg","ebay.com.tw","ebay.de","ebay.es","ebay.fr","ebay.ie","ebay.in","ebay.it","ebay.nl","ebay.ph","ebay.pl"],"excluded":false},
|
||||||
|
{"type":65,"domains":["techdata.com","techdata.ch"],"excluded":false},
|
||||||
|
{"type":66,"domains":["schwab.com","schwabplan.com"],"excluded":false},
|
||||||
|
{"type":68,"domains":["tesla.com","teslamotors.com"],"excluded":false},
|
||||||
|
{"type":69,"domains":["morganstanley.com","morganstanleyclientserv.com","stockplanconnect.com","ms.com"],"excluded":false},
|
||||||
|
{"type":70,"domains":["taxact.com","taxactonline.com"],"excluded":false},
|
||||||
|
{"type":71,"domains":["mediawiki.org","wikibooks.org","wikidata.org","wikimedia.org","wikinews.org","wikipedia.org","wikiquote.org","wikisource.org","wikiversity.org","wikivoyage.org","wiktionary.org"],"excluded":false},
|
||||||
|
{"type":72,"domains":["airbnb.at","airbnb.be","airbnb.ca","airbnb.ch","airbnb.cl","airbnb.co.cr","airbnb.co.id","airbnb.co.in","airbnb.co.kr","airbnb.co.nz","airbnb.co.uk","airbnb.co.ve","airbnb.com","airbnb.com.ar","airbnb.com.au","airbnb.com.bo","airbnb.com.br","airbnb.com.bz","airbnb.com.co","airbnb.com.ec","airbnb.com.gt","airbnb.com.hk","airbnb.com.hn","airbnb.com.mt","airbnb.com.my","airbnb.com.ni","airbnb.com.pa","airbnb.com.pe","airbnb.com.py","airbnb.com.sg","airbnb.com.sv","airbnb.com.tr","airbnb.com.tw","airbnb.cz","airbnb.de","airbnb.dk","airbnb.es","airbnb.fi","airbnb.fr","airbnb.gr","airbnb.gy","airbnb.hu","airbnb.ie","airbnb.is","airbnb.it","airbnb.jp","airbnb.mx","airbnb.nl","airbnb.no","airbnb.pl","airbnb.pt","airbnb.ru","airbnb.se"],"excluded":false},
|
||||||
|
{"type":73,"domains":["eventbrite.at","eventbrite.be","eventbrite.ca","eventbrite.ch","eventbrite.cl","eventbrite.co","eventbrite.co.nz","eventbrite.co.uk","eventbrite.com","eventbrite.com.ar","eventbrite.com.au","eventbrite.com.br","eventbrite.com.mx","eventbrite.com.pe","eventbrite.de","eventbrite.dk","eventbrite.es","eventbrite.fi","eventbrite.fr","eventbrite.hk","eventbrite.ie","eventbrite.it","eventbrite.nl","eventbrite.pt","eventbrite.se","eventbrite.sg"],"excluded":false},
|
||||||
|
{"type":74,"domains":["stackexchange.com","superuser.com","stackoverflow.com","serverfault.com","mathoverflow.net","askubuntu.com","stackapps.com"],"excluded":false},
|
||||||
|
{"type":75,"domains":["docusign.com","docusign.net"],"excluded":false},
|
||||||
|
{"type":76,"domains":["envato.com","themeforest.net","codecanyon.net","videohive.net","audiojungle.net","graphicriver.net","photodune.net","3docean.net"],"excluded":false},
|
||||||
|
{"type":77,"domains":["x10hosting.com","x10premium.com"],"excluded":false},
|
||||||
|
{"type":78,"domains":["dnsomatic.com","opendns.com","umbrella.com"],"excluded":false},
|
||||||
|
{"type":79,"domains":["cagreatamerica.com","canadaswonderland.com","carowinds.com","cedarfair.com","cedarpoint.com","dorneypark.com","kingsdominion.com","knotts.com","miadventure.com","schlitterbahn.com","valleyfair.com","visitkingsisland.com","worldsoffun.com"],"excluded":false},
|
||||||
|
{"type":80,"domains":["ubnt.com","ui.com"],"excluded":false},
|
||||||
|
{"type":81,"domains":["discordapp.com","discord.com"],"excluded":false},
|
||||||
|
{"type":82,"domains":["netcup.de","netcup.eu","customercontrolpanel.de"],"excluded":false},
|
||||||
|
{"type":83,"domains":["yandex.com","ya.ru","yandex.az","yandex.by","yandex.co.il","yandex.com.am","yandex.com.ge","yandex.com.tr","yandex.ee","yandex.fi","yandex.fr","yandex.kg","yandex.kz","yandex.lt","yandex.lv","yandex.md","yandex.pl","yandex.ru","yandex.tj","yandex.tm","yandex.ua","yandex.uz"],"excluded":false},
|
||||||
|
{"type":84,"domains":["sonyentertainmentnetwork.com","sony.com"],"excluded":false},
|
||||||
|
{"type":85,"domains":["proton.me","protonmail.com","protonvpn.com"],"excluded":false},
|
||||||
|
{"type":86,"domains":["ubisoft.com","ubi.com"],"excluded":false},
|
||||||
|
{"type":87,"domains":["transferwise.com","wise.com"],"excluded":false},
|
||||||
|
{"type":88,"domains":["takeaway.com","just-eat.dk","just-eat.no","just-eat.fr","just-eat.ch","lieferando.de","lieferando.at","thuisbezorgd.nl","pyszne.pl"],"excluded":false},
|
||||||
|
{"type":89,"domains":["atlassian.com","bitbucket.org","trello.com","statuspage.io","atlassian.net","jira.com"],"excluded":false},
|
||||||
|
{"type":90,"domains":["pinterest.com","pinterest.com.au","pinterest.cl","pinterest.de","pinterest.dk","pinterest.es","pinterest.fr","pinterest.co.uk","pinterest.jp","pinterest.co.kr","pinterest.nz","pinterest.pt","pinterest.se"],"excluded":false},
|
||||||
|
{"type":91,"domains":["twitter.com","x.com"],"excluded":false}
|
||||||
|
]
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"source": "https://github.com/bitwarden/server",
|
||||||
|
"ref": "main",
|
||||||
|
"generatedAt": "2026-05-05T00:00:00.000Z",
|
||||||
|
"rulesCount": 91,
|
||||||
|
"domainsCount": 436,
|
||||||
|
"sourceFiles": [
|
||||||
|
"src/Core/Enums/GlobalEquivalentDomainsType.cs",
|
||||||
|
"src/Core/Utilities/StaticStore.cs"
|
||||||
|
],
|
||||||
|
"sourceUrls": [
|
||||||
|
"https://raw.githubusercontent.com/bitwarden/server/main/src/Core/Enums/GlobalEquivalentDomainsType.cs",
|
||||||
|
"https://raw.githubusercontent.com/bitwarden/server/main/src/Core/Utilities/StaticStore.cs"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[
|
||||||
|
{"type":-10001,"domains":["nodewarden.example","nw.example"],"excluded":false,"source":"nodewarden"}
|
||||||
|
]
|
||||||
@@ -55,6 +55,34 @@ export interface User {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserDomainSettings {
|
||||||
|
userId: string;
|
||||||
|
equivalentDomains: string[][];
|
||||||
|
customEquivalentDomains: CustomEquivalentDomain[];
|
||||||
|
excludedGlobalEquivalentDomains: number[];
|
||||||
|
updatedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomEquivalentDomain {
|
||||||
|
id: string;
|
||||||
|
domains: string[];
|
||||||
|
excluded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GlobalEquivalentDomain {
|
||||||
|
type: number;
|
||||||
|
domains: string[];
|
||||||
|
excluded: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DomainRulesResponse {
|
||||||
|
equivalentDomains: string[][];
|
||||||
|
customEquivalentDomains: CustomEquivalentDomain[];
|
||||||
|
globalEquivalentDomains: GlobalEquivalentDomain[];
|
||||||
|
object: 'domains';
|
||||||
|
}
|
||||||
|
|
||||||
export interface Invite {
|
export interface Invite {
|
||||||
code: string;
|
code: string;
|
||||||
createdBy: string;
|
createdBy: string;
|
||||||
|
|||||||
@@ -0,0 +1,428 @@
|
|||||||
|
Attribution-ShareAlike 4.0 International
|
||||||
|
|
||||||
|
=======================================================================
|
||||||
|
|
||||||
|
Creative Commons Corporation ("Creative Commons") is not a law firm and
|
||||||
|
does not provide legal services or legal advice. Distribution of
|
||||||
|
Creative Commons public licenses does not create a lawyer-client or
|
||||||
|
other relationship. Creative Commons makes its licenses and related
|
||||||
|
information available on an "as-is" basis. Creative Commons gives no
|
||||||
|
warranties regarding its licenses, any material licensed under their
|
||||||
|
terms and conditions, or any related information. Creative Commons
|
||||||
|
disclaims all liability for damages resulting from their use to the
|
||||||
|
fullest extent possible.
|
||||||
|
|
||||||
|
Using Creative Commons Public Licenses
|
||||||
|
|
||||||
|
Creative Commons public licenses provide a standard set of terms and
|
||||||
|
conditions that creators and other rights holders may use to share
|
||||||
|
original works of authorship and other material subject to copyright
|
||||||
|
and certain other rights specified in the public license below. The
|
||||||
|
following considerations are for informational purposes only, are not
|
||||||
|
exhaustive, and do not form part of our licenses.
|
||||||
|
|
||||||
|
Considerations for licensors: Our public licenses are
|
||||||
|
intended for use by those authorized to give the public
|
||||||
|
permission to use material in ways otherwise restricted by
|
||||||
|
copyright and certain other rights. Our licenses are
|
||||||
|
irrevocable. Licensors should read and understand the terms
|
||||||
|
and conditions of the license they choose before applying it.
|
||||||
|
Licensors should also secure all rights necessary before
|
||||||
|
applying our licenses so that the public can reuse the
|
||||||
|
material as expected. Licensors should clearly mark any
|
||||||
|
material not subject to the license. This includes other CC-
|
||||||
|
licensed material, or material used under an exception or
|
||||||
|
limitation to copyright. More considerations for licensors:
|
||||||
|
wiki.creativecommons.org/Considerations_for_licensors
|
||||||
|
|
||||||
|
Considerations for the public: By using one of our public
|
||||||
|
licenses, a licensor grants the public permission to use the
|
||||||
|
licensed material under specified terms and conditions. If
|
||||||
|
the licensor's permission is not necessary for any reason--for
|
||||||
|
example, because of any applicable exception or limitation to
|
||||||
|
copyright--then that use is not regulated by the license. Our
|
||||||
|
licenses grant only permissions under copyright and certain
|
||||||
|
other rights that a licensor has authority to grant. Use of
|
||||||
|
the licensed material may still be restricted for other
|
||||||
|
reasons, including because others have copyright or other
|
||||||
|
rights in the material. A licensor may make special requests,
|
||||||
|
such as asking that all changes be marked or described.
|
||||||
|
Although not required by our licenses, you are encouraged to
|
||||||
|
respect those requests where reasonable. More_considerations
|
||||||
|
for the public:
|
||||||
|
wiki.creativecommons.org/Considerations_for_licensees
|
||||||
|
|
||||||
|
=======================================================================
|
||||||
|
|
||||||
|
Creative Commons Attribution-ShareAlike 4.0 International Public
|
||||||
|
License
|
||||||
|
|
||||||
|
By exercising the Licensed Rights (defined below), You accept and agree
|
||||||
|
to be bound by the terms and conditions of this Creative Commons
|
||||||
|
Attribution-ShareAlike 4.0 International Public License ("Public
|
||||||
|
License"). To the extent this Public License may be interpreted as a
|
||||||
|
contract, You are granted the Licensed Rights in consideration of Your
|
||||||
|
acceptance of these terms and conditions, and the Licensor grants You
|
||||||
|
such rights in consideration of benefits the Licensor receives from
|
||||||
|
making the Licensed Material available under these terms and
|
||||||
|
conditions.
|
||||||
|
|
||||||
|
|
||||||
|
Section 1 -- Definitions.
|
||||||
|
|
||||||
|
a. Adapted Material means material subject to Copyright and Similar
|
||||||
|
Rights that is derived from or based upon the Licensed Material
|
||||||
|
and in which the Licensed Material is translated, altered,
|
||||||
|
arranged, transformed, or otherwise modified in a manner requiring
|
||||||
|
permission under the Copyright and Similar Rights held by the
|
||||||
|
Licensor. For purposes of this Public License, where the Licensed
|
||||||
|
Material is a musical work, performance, or sound recording,
|
||||||
|
Adapted Material is always produced where the Licensed Material is
|
||||||
|
synched in timed relation with a moving image.
|
||||||
|
|
||||||
|
b. Adapter's License means the license You apply to Your Copyright
|
||||||
|
and Similar Rights in Your contributions to Adapted Material in
|
||||||
|
accordance with the terms and conditions of this Public License.
|
||||||
|
|
||||||
|
c. BY-SA Compatible License means a license listed at
|
||||||
|
creativecommons.org/compatiblelicenses, approved by Creative
|
||||||
|
Commons as essentially the equivalent of this Public License.
|
||||||
|
|
||||||
|
d. Copyright and Similar Rights means copyright and/or similar rights
|
||||||
|
closely related to copyright including, without limitation,
|
||||||
|
performance, broadcast, sound recording, and Sui Generis Database
|
||||||
|
Rights, without regard to how the rights are labeled or
|
||||||
|
categorized. For purposes of this Public License, the rights
|
||||||
|
specified in Section 2(b)(1)-(2) are not Copyright and Similar
|
||||||
|
Rights.
|
||||||
|
|
||||||
|
e. Effective Technological Measures means those measures that, in the
|
||||||
|
absence of proper authority, may not be circumvented under laws
|
||||||
|
fulfilling obligations under Article 11 of the WIPO Copyright
|
||||||
|
Treaty adopted on December 20, 1996, and/or similar international
|
||||||
|
agreements.
|
||||||
|
|
||||||
|
f. Exceptions and Limitations means fair use, fair dealing, and/or
|
||||||
|
any other exception or limitation to Copyright and Similar Rights
|
||||||
|
that applies to Your use of the Licensed Material.
|
||||||
|
|
||||||
|
g. License Elements means the license attributes listed in the name
|
||||||
|
of a Creative Commons Public License. The License Elements of this
|
||||||
|
Public License are Attribution and ShareAlike.
|
||||||
|
|
||||||
|
h. Licensed Material means the artistic or literary work, database,
|
||||||
|
or other material to which the Licensor applied this Public
|
||||||
|
License.
|
||||||
|
|
||||||
|
i. Licensed Rights means the rights granted to You subject to the
|
||||||
|
terms and conditions of this Public License, which are limited to
|
||||||
|
all Copyright and Similar Rights that apply to Your use of the
|
||||||
|
Licensed Material and that the Licensor has authority to license.
|
||||||
|
|
||||||
|
j. Licensor means the individual(s) or entity(ies) granting rights
|
||||||
|
under this Public License.
|
||||||
|
|
||||||
|
k. Share means to provide material to the public by any means or
|
||||||
|
process that requires permission under the Licensed Rights, such
|
||||||
|
as reproduction, public display, public performance, distribution,
|
||||||
|
dissemination, communication, or importation, and to make material
|
||||||
|
available to the public including in ways that members of the
|
||||||
|
public may access the material from a place and at a time
|
||||||
|
individually chosen by them.
|
||||||
|
|
||||||
|
l. Sui Generis Database Rights means rights other than copyright
|
||||||
|
resulting from Directive 96/9/EC of the European Parliament and of
|
||||||
|
the Council of 11 March 1996 on the legal protection of databases,
|
||||||
|
as amended and/or succeeded, as well as other essentially
|
||||||
|
equivalent rights anywhere in the world.
|
||||||
|
|
||||||
|
m. You means the individual or entity exercising the Licensed Rights
|
||||||
|
under this Public License. Your has a corresponding meaning.
|
||||||
|
|
||||||
|
|
||||||
|
Section 2 -- Scope.
|
||||||
|
|
||||||
|
a. License grant.
|
||||||
|
|
||||||
|
1. Subject to the terms and conditions of this Public License,
|
||||||
|
the Licensor hereby grants You a worldwide, royalty-free,
|
||||||
|
non-sublicensable, non-exclusive, irrevocable license to
|
||||||
|
exercise the Licensed Rights in the Licensed Material to:
|
||||||
|
|
||||||
|
a. reproduce and Share the Licensed Material, in whole or
|
||||||
|
in part; and
|
||||||
|
|
||||||
|
b. produce, reproduce, and Share Adapted Material.
|
||||||
|
|
||||||
|
2. Exceptions and Limitations. For the avoidance of doubt, where
|
||||||
|
Exceptions and Limitations apply to Your use, this Public
|
||||||
|
License does not apply, and You do not need to comply with
|
||||||
|
its terms and conditions.
|
||||||
|
|
||||||
|
3. Term. The term of this Public License is specified in Section
|
||||||
|
6(a).
|
||||||
|
|
||||||
|
4. Media and formats; technical modifications allowed. The
|
||||||
|
Licensor authorizes You to exercise the Licensed Rights in
|
||||||
|
all media and formats whether now known or hereafter created,
|
||||||
|
and to make technical modifications necessary to do so. The
|
||||||
|
Licensor waives and/or agrees not to assert any right or
|
||||||
|
authority to forbid You from making technical modifications
|
||||||
|
necessary to exercise the Licensed Rights, including
|
||||||
|
technical modifications necessary to circumvent Effective
|
||||||
|
Technological Measures. For purposes of this Public License,
|
||||||
|
simply making modifications authorized by this Section 2(a)
|
||||||
|
(4) never produces Adapted Material.
|
||||||
|
|
||||||
|
5. Downstream recipients.
|
||||||
|
|
||||||
|
a. Offer from the Licensor -- Licensed Material. Every
|
||||||
|
recipient of the Licensed Material automatically
|
||||||
|
receives an offer from the Licensor to exercise the
|
||||||
|
Licensed Rights under the terms and conditions of this
|
||||||
|
Public License.
|
||||||
|
|
||||||
|
b. Additional offer from the Licensor -- Adapted Material.
|
||||||
|
Every recipient of Adapted Material from You
|
||||||
|
automatically receives an offer from the Licensor to
|
||||||
|
exercise the Licensed Rights in the Adapted Material
|
||||||
|
under the conditions of the Adapter's License You apply.
|
||||||
|
|
||||||
|
c. No downstream restrictions. You may not offer or impose
|
||||||
|
any additional or different terms or conditions on, or
|
||||||
|
apply any Effective Technological Measures to, the
|
||||||
|
Licensed Material if doing so restricts exercise of the
|
||||||
|
Licensed Rights by any recipient of the Licensed
|
||||||
|
Material.
|
||||||
|
|
||||||
|
6. No endorsement. Nothing in this Public License constitutes or
|
||||||
|
may be construed as permission to assert or imply that You
|
||||||
|
are, or that Your use of the Licensed Material is, connected
|
||||||
|
with, or sponsored, endorsed, or granted official status by,
|
||||||
|
the Licensor or others designated to receive attribution as
|
||||||
|
provided in Section 3(a)(1)(A)(i).
|
||||||
|
|
||||||
|
b. Other rights.
|
||||||
|
|
||||||
|
1. Moral rights, such as the right of integrity, are not
|
||||||
|
licensed under this Public License, nor are publicity,
|
||||||
|
privacy, and/or other similar personality rights; however, to
|
||||||
|
the extent possible, the Licensor waives and/or agrees not to
|
||||||
|
assert any such rights held by the Licensor to the limited
|
||||||
|
extent necessary to allow You to exercise the Licensed
|
||||||
|
Rights, but not otherwise.
|
||||||
|
|
||||||
|
2. Patent and trademark rights are not licensed under this
|
||||||
|
Public License.
|
||||||
|
|
||||||
|
3. To the extent possible, the Licensor waives any right to
|
||||||
|
collect royalties from You for the exercise of the Licensed
|
||||||
|
Rights, whether directly or through a collecting society
|
||||||
|
under any voluntary or waivable statutory or compulsory
|
||||||
|
licensing scheme. In all other cases the Licensor expressly
|
||||||
|
reserves any right to collect such royalties.
|
||||||
|
|
||||||
|
|
||||||
|
Section 3 -- License Conditions.
|
||||||
|
|
||||||
|
Your exercise of the Licensed Rights is expressly made subject to the
|
||||||
|
following conditions.
|
||||||
|
|
||||||
|
a. Attribution.
|
||||||
|
|
||||||
|
1. If You Share the Licensed Material (including in modified
|
||||||
|
form), You must:
|
||||||
|
|
||||||
|
a. retain the following if it is supplied by the Licensor
|
||||||
|
with the Licensed Material:
|
||||||
|
|
||||||
|
i. identification of the creator(s) of the Licensed
|
||||||
|
Material and any others designated to receive
|
||||||
|
attribution, in any reasonable manner requested by
|
||||||
|
the Licensor (including by pseudonym if
|
||||||
|
designated);
|
||||||
|
|
||||||
|
ii. a copyright notice;
|
||||||
|
|
||||||
|
iii. a notice that refers to this Public License;
|
||||||
|
|
||||||
|
iv. a notice that refers to the disclaimer of
|
||||||
|
warranties;
|
||||||
|
|
||||||
|
v. a URI or hyperlink to the Licensed Material to the
|
||||||
|
extent reasonably practicable;
|
||||||
|
|
||||||
|
b. indicate if You modified the Licensed Material and
|
||||||
|
retain an indication of any previous modifications; and
|
||||||
|
|
||||||
|
c. indicate the Licensed Material is licensed under this
|
||||||
|
Public License, and include the text of, or the URI or
|
||||||
|
hyperlink to, this Public License.
|
||||||
|
|
||||||
|
2. You may satisfy the conditions in Section 3(a)(1) in any
|
||||||
|
reasonable manner based on the medium, means, and context in
|
||||||
|
which You Share the Licensed Material. For example, it may be
|
||||||
|
reasonable to satisfy the conditions by providing a URI or
|
||||||
|
hyperlink to a resource that includes the required
|
||||||
|
information.
|
||||||
|
|
||||||
|
3. If requested by the Licensor, You must remove any of the
|
||||||
|
information required by Section 3(a)(1)(A) to the extent
|
||||||
|
reasonably practicable.
|
||||||
|
|
||||||
|
b. ShareAlike.
|
||||||
|
|
||||||
|
In addition to the conditions in Section 3(a), if You Share
|
||||||
|
Adapted Material You produce, the following conditions also apply.
|
||||||
|
|
||||||
|
1. The Adapter's License You apply must be a Creative Commons
|
||||||
|
license with the same License Elements, this version or
|
||||||
|
later, or a BY-SA Compatible License.
|
||||||
|
|
||||||
|
2. You must include the text of, or the URI or hyperlink to, the
|
||||||
|
Adapter's License You apply. You may satisfy this condition
|
||||||
|
in any reasonable manner based on the medium, means, and
|
||||||
|
context in which You Share Adapted Material.
|
||||||
|
|
||||||
|
3. You may not offer or impose any additional or different terms
|
||||||
|
or conditions on, or apply any Effective Technological
|
||||||
|
Measures to, Adapted Material that restrict exercise of the
|
||||||
|
rights granted under the Adapter's License You apply.
|
||||||
|
|
||||||
|
|
||||||
|
Section 4 -- Sui Generis Database Rights.
|
||||||
|
|
||||||
|
Where the Licensed Rights include Sui Generis Database Rights that
|
||||||
|
apply to Your use of the Licensed Material:
|
||||||
|
|
||||||
|
a. for the avoidance of doubt, Section 2(a)(1) grants You the right
|
||||||
|
to extract, reuse, reproduce, and Share all or a substantial
|
||||||
|
portion of the contents of the database;
|
||||||
|
|
||||||
|
b. if You include all or a substantial portion of the database
|
||||||
|
contents in a database in which You have Sui Generis Database
|
||||||
|
Rights, then the database in which You have Sui Generis Database
|
||||||
|
Rights (but not its individual contents) is Adapted Material,
|
||||||
|
|
||||||
|
including for purposes of Section 3(b); and
|
||||||
|
c. You must comply with the conditions in Section 3(a) if You Share
|
||||||
|
all or a substantial portion of the contents of the database.
|
||||||
|
|
||||||
|
For the avoidance of doubt, this Section 4 supplements and does not
|
||||||
|
replace Your obligations under this Public License where the Licensed
|
||||||
|
Rights include other Copyright and Similar Rights.
|
||||||
|
|
||||||
|
|
||||||
|
Section 5 -- Disclaimer of Warranties and Limitation of Liability.
|
||||||
|
|
||||||
|
a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
|
||||||
|
EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
|
||||||
|
AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
|
||||||
|
ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
|
||||||
|
IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
|
||||||
|
WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
|
||||||
|
ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
|
||||||
|
KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
|
||||||
|
ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
|
||||||
|
|
||||||
|
b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
|
||||||
|
TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
|
||||||
|
NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
|
||||||
|
INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
|
||||||
|
COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
|
||||||
|
USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
|
||||||
|
ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
|
||||||
|
DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
|
||||||
|
IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
|
||||||
|
|
||||||
|
c. The disclaimer of warranties and limitation of liability provided
|
||||||
|
above shall be interpreted in a manner that, to the extent
|
||||||
|
possible, most closely approximates an absolute disclaimer and
|
||||||
|
waiver of all liability.
|
||||||
|
|
||||||
|
|
||||||
|
Section 6 -- Term and Termination.
|
||||||
|
|
||||||
|
a. This Public License applies for the term of the Copyright and
|
||||||
|
Similar Rights licensed here. However, if You fail to comply with
|
||||||
|
this Public License, then Your rights under this Public License
|
||||||
|
terminate automatically.
|
||||||
|
|
||||||
|
b. Where Your right to use the Licensed Material has terminated under
|
||||||
|
Section 6(a), it reinstates:
|
||||||
|
|
||||||
|
1. automatically as of the date the violation is cured, provided
|
||||||
|
it is cured within 30 days of Your discovery of the
|
||||||
|
violation; or
|
||||||
|
|
||||||
|
2. upon express reinstatement by the Licensor.
|
||||||
|
|
||||||
|
For the avoidance of doubt, this Section 6(b) does not affect any
|
||||||
|
right the Licensor may have to seek remedies for Your violations
|
||||||
|
of this Public License.
|
||||||
|
|
||||||
|
c. For the avoidance of doubt, the Licensor may also offer the
|
||||||
|
Licensed Material under separate terms or conditions or stop
|
||||||
|
distributing the Licensed Material at any time; however, doing so
|
||||||
|
will not terminate this Public License.
|
||||||
|
|
||||||
|
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
|
||||||
|
License.
|
||||||
|
|
||||||
|
|
||||||
|
Section 7 -- Other Terms and Conditions.
|
||||||
|
|
||||||
|
a. The Licensor shall not be bound by any additional or different
|
||||||
|
terms or conditions communicated by You unless expressly agreed.
|
||||||
|
|
||||||
|
b. Any arrangements, understandings, or agreements regarding the
|
||||||
|
Licensed Material not stated herein are separate from and
|
||||||
|
independent of the terms and conditions of this Public License.
|
||||||
|
|
||||||
|
|
||||||
|
Section 8 -- Interpretation.
|
||||||
|
|
||||||
|
a. For the avoidance of doubt, this Public License does not, and
|
||||||
|
shall not be interpreted to, reduce, limit, restrict, or impose
|
||||||
|
conditions on any use of the Licensed Material that could lawfully
|
||||||
|
be made without permission under this Public License.
|
||||||
|
|
||||||
|
b. To the extent possible, if any provision of this Public License is
|
||||||
|
deemed unenforceable, it shall be automatically reformed to the
|
||||||
|
minimum extent necessary to make it enforceable. If the provision
|
||||||
|
cannot be reformed, it shall be severed from this Public License
|
||||||
|
without affecting the enforceability of the remaining terms and
|
||||||
|
conditions.
|
||||||
|
|
||||||
|
c. No term or condition of this Public License will be waived and no
|
||||||
|
failure to comply consented to unless expressly agreed to by the
|
||||||
|
Licensor.
|
||||||
|
|
||||||
|
d. Nothing in this Public License constitutes or may be interpreted
|
||||||
|
as a limitation upon, or waiver of, any privileges and immunities
|
||||||
|
that apply to the Licensor or You, including from the legal
|
||||||
|
processes of any jurisdiction or authority.
|
||||||
|
|
||||||
|
|
||||||
|
=======================================================================
|
||||||
|
|
||||||
|
Creative Commons is not a party to its public
|
||||||
|
licenses. Notwithstanding, Creative Commons may elect to apply one of
|
||||||
|
its public licenses to material it publishes and in those instances
|
||||||
|
will be considered the “Licensor.” The text of the Creative Commons
|
||||||
|
public licenses is dedicated to the public domain under the CC0 Public
|
||||||
|
Domain Dedication. Except for the limited purpose of indicating that
|
||||||
|
material is shared under a Creative Commons public license or as
|
||||||
|
otherwise permitted by the Creative Commons policies published at
|
||||||
|
creativecommons.org/policies, Creative Commons does not authorize the
|
||||||
|
use of the trademark "Creative Commons" or any other trademark or logo
|
||||||
|
of Creative Commons without its prior written consent including,
|
||||||
|
without limitation, in connection with any unauthorized modifications
|
||||||
|
to any of its public licenses or any other arrangements,
|
||||||
|
understandings, or agreements concerning use of licensed material. For
|
||||||
|
the avoidance of doubt, this paragraph does not form part of the
|
||||||
|
public licenses.
|
||||||
|
|
||||||
|
Creative Commons may be contacted at creativecommons.org.
|
||||||
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
Payment logos in this directory are from datatrans/payment-logos.
|
||||||
|
|
||||||
|
Source: https://github.com/datatrans/payment-logos
|
||||||
|
License: CC-BY-SA-4.0
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="120" height="80" version="1.1" viewBox="0 0 120 80" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="40" width="80" height="80" rx="4" fill="#fff" fill-rule="evenodd" />
|
||||||
|
<path d="m120 76v-8.6763h-9.651l-4.969-5.4944-4.994 5.4944h-31.822v-25.607h-10.27l12.74-28.831h12.286l4.3857 9.877v-9.877h15.208l2.64 7.4429 2.658-7.4429h11.789v-8.8854c0-2.2091-1.7909-4-4-4h-112c-2.2091 4.4409e-16 -4 1.7909-4 4v72c4.4409e-16 2.2091 1.7909 4 4 4h112c2.2091 0 4-1.7909 4-4zm-8.026-11.882h8.026l-10.616-11.258 10.616-11.13h-7.898l-6.556 7.1645-6.4935-7.1645h-8.0275l10.554 11.194-10.554 11.194h7.8041l6.5889-7.2283 6.556 7.2283zm1.878-11.249 6.148 6.5406v-13.027l-6.148 6.4861zm-35.78 6.0675v-3.4864h12.633v-5.0534h-12.633v-3.4859h12.953l5e-4 -5.1815h-19.062v22.388h19.062l-5e-4 -5.1813h-12.953zm35.883-20.456h6.045v-22.388h-9.403l-5.022 13.944-4.989-13.944h-9.5631v22.388h6.0446v-15.672l5.7575 15.672h5.373l5.757-15.704v15.704zm-29.809 0h6.8765l-9.8824-22.388h-7.8682l-9.8833 22.388h6.7166l1.8554-4.4776h10.298l1.887 4.4776zm-3.9976-9.4992h-6.0773l3.0387-7.3242 3.0386 7.3242z" fill="#0690FF"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,11 @@
|
|||||||
|
<svg width="120" height="80" viewBox="0 0 120 80" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="120" height="80" rx="4" fill="url(#paint0_linear_804_2)"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M65.3997 64.8343C79.0213 64.8992 91.4542 53.7631 91.4542 40.2157C91.4542 25.4007 79.0213 15.1605 65.3997 15.1654H53.6768C39.8921 15.1605 28.5459 25.4038 28.5459 40.2157C28.5459 53.7661 39.8921 64.8993 53.6768 64.8343H65.3997Z" fill="#3477B9"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M53.6852 17.1522C41.0891 17.1561 30.8821 27.3313 30.8792 39.8896C30.8821 52.4456 41.089 62.6199 53.6852 62.6238C66.2843 62.6199 76.4934 52.4456 76.4952 39.8896C76.4933 27.3313 66.2843 17.1561 53.6852 17.1522ZM39.2291 39.8896C39.241 33.7529 43.0866 28.5199 48.5095 26.4404V53.3355C43.0866 51.2572 39.2409 46.0271 39.2291 39.8896ZM58.859 53.3415V26.4396C64.2838 28.514 68.1355 33.7499 68.1453 39.8896C68.1355 46.0311 64.2838 51.263 58.859 53.3415Z" fill="white"/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="paint0_linear_804_2" x1="1.68141e-06" y1="21" x2="120" y2="54" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#3479C0"/>
|
||||||
|
<stop offset="1" stop-color="#133362"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,22 @@
|
|||||||
|
<svg width="120" height="80" viewBox="0 0 120 80" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="120" height="80" rx="4" fill="white"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M29 80H116.002C118.21 80 120 78.211 120 75.9957V48C120 48 87.8616 70.1063 29 80Z" fill="#E7792B"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M113.088 33.8624C113.088 30.7125 110.888 28.8951 107.053 28.8951H102.12V45.7197H105.443V38.9609H105.877L110.481 45.7197H114.571L109.202 38.6314C111.708 38.129 113.088 36.4383 113.088 33.8624ZM106.414 36.6411H105.443V31.5451H106.467C108.538 31.5451 109.665 32.4018 109.665 34.0385C109.665 35.7305 108.538 36.6411 106.414 36.6411Z" fill="#1A1918"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M90.4839 45.7197H99.9176V42.8713H93.8077V38.3298H99.6923V35.4802H93.8077V31.746H99.9176V28.8951H90.4839V45.7197Z" fill="#1A1918"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M80.7677 40.1959L76.2205 28.8951H72.5864L79.8236 46.1512H81.613L88.9799 28.8951H85.3742L80.7677 40.1959Z" fill="#1A1918"/>
|
||||||
|
<path d="M64.6178 46.7197C69.7118 46.7197 73.8414 42.6454 73.8414 37.6197C73.8414 32.5939 69.7118 28.5197 64.6178 28.5197C59.5238 28.5197 55.3943 32.5939 55.3943 37.6197C55.3943 42.6454 59.5238 46.7197 64.6178 46.7197Z" fill="url(#paint0_radial_823_341)"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M41.2231 37.3191C41.2231 42.2643 45.159 46.0986 50.224 46.0986C51.6556 46.0986 52.8817 45.8211 54.3943 45.1184V41.2555C53.0642 42.5685 51.8869 43.0982 50.3788 43.0982C47.0287 43.0982 44.651 40.7017 44.651 37.2944C44.651 34.0645 47.1038 31.5165 50.224 31.5165C51.8104 31.5165 53.0115 32.0749 54.3943 33.4093V29.5483C52.9344 28.8177 51.7334 28.5148 50.3024 28.5148C45.2631 28.5148 41.2231 32.4272 41.2231 37.3191Z" fill="#1A1918"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M35.2687 35.3515C33.2725 34.6229 32.6868 34.1419 32.6868 33.2332C32.6868 32.173 33.731 31.3683 35.1646 31.3683C36.1614 31.3683 36.9803 31.772 37.8467 32.7307L39.5873 30.4824C38.157 29.248 36.446 28.6169 34.5763 28.6169C31.5589 28.6169 29.2576 30.6839 29.2576 33.4379C29.2576 35.7558 30.3295 36.9421 33.453 38.0516C34.7555 38.5047 35.4182 38.8063 35.7529 39.0097C36.417 39.4381 36.7497 40.0439 36.7497 40.7504C36.7497 42.1135 35.6515 43.1236 34.1671 43.1236C32.5807 43.1236 31.3032 42.341 30.537 40.8798L28.3879 42.9214C29.9204 45.1405 31.7611 46.124 34.2923 46.124C37.7485 46.124 40.1736 43.8568 40.1736 40.5996C40.1736 37.9268 39.0523 36.7165 35.2687 35.3515Z" fill="#1A1918"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M23.8091 28.8951H27.1355V45.7197H23.8091V28.8951Z" fill="#1A1918"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.1242 28.8951H8.2417V45.7197H13.0985C15.6811 45.7197 17.5456 45.1184 19.1828 43.7775C21.1283 42.1889 22.2786 39.7949 22.2786 37.319C22.2786 32.3537 18.5187 28.8951 13.1242 28.8951ZM17.01 41.5336C15.9644 42.4651 14.6073 42.8713 12.4582 42.8713H11.5655V31.746H12.4582C14.6073 31.746 15.9111 32.1249 17.01 33.1064C18.1603 34.1171 18.8521 35.683 18.8521 37.2943C18.8521 38.9096 18.1603 40.5235 17.01 41.5336Z" fill="#1A1918"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M115.21 29.5275C115.21 29.233 115.005 29.0712 114.643 29.0712H114.162V30.5499H114.52V29.9766L114.939 30.5499H115.376L114.883 29.9402C115.094 29.8843 115.21 29.7329 115.21 29.5275ZM114.58 29.7296H114.52V29.3429H114.584C114.761 29.3429 114.853 29.4059 114.853 29.5327C114.853 29.664 114.76 29.7296 114.58 29.7296Z" fill="#1A1918"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M114.715 28.5187C113.987 28.5187 113.41 29.092 113.41 29.8077C113.41 30.5233 113.994 31.0973 114.715 31.0973C115.424 31.0973 116.005 30.5175 116.005 29.8077C116.005 29.1018 115.424 28.5187 114.715 28.5187ZM114.71 30.8672C114.138 30.8672 113.669 30.3966 113.669 29.8096C113.669 29.2207 114.132 28.7508 114.71 28.7508C115.28 28.7508 115.745 29.2318 115.745 29.8096C115.745 30.3914 115.28 30.8672 114.71 30.8672Z" fill="#1A1918"/>
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="paint0_radial_823_341" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(71.5 44) rotate(-142.431) scale(16.4012 16.1816)">
|
||||||
|
<stop stop-color="#F59900"/>
|
||||||
|
<stop offset="0.210082" stop-color="#F39501"/>
|
||||||
|
<stop offset="0.908163" stop-color="#CE3C0B"/>
|
||||||
|
<stop offset="1" stop-color="#A4420A"/>
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,42 @@
|
|||||||
|
<svg width="120" height="80" viewBox="0 0 120 80" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="120" height="80" rx="4" fill="white"/>
|
||||||
|
<path d="M100.9 58.8C100.9 65.8 95.1996 71.5 88.1996 71.5H19.0996V21.2C19.0996 14.2 24.7996 8.5 31.7996 8.5H100.9V58.8Z" fill="white"/>
|
||||||
|
<path d="M78.3994 45.9H83.6494C83.7994 45.9 84.1494 45.85 84.2994 45.85C85.2994 45.65 86.1494 44.75 86.1494 43.5C86.1494 42.3 85.2994 41.4 84.2994 41.15C84.1494 41.1 83.8494 41.1 83.6494 41.1H78.3994V45.9Z" fill="url(#paint0_linear_833_6149)"/>
|
||||||
|
<path d="M83.0494 12.75C78.0494 12.75 73.9494 16.8 73.9494 21.85V31.3H86.7994C87.0994 31.3 87.4494 31.3 87.6994 31.35C90.5994 31.5 92.7494 33 92.7494 35.6C92.7494 37.65 91.2994 39.4 88.5994 39.75V39.85C91.5494 40.05 93.7994 41.7 93.7994 44.25C93.7994 47 91.2994 48.8 87.9994 48.8H73.8994V67.3H87.2494C92.2494 67.3 96.3494 63.25 96.3494 58.2V12.75H83.0494Z" fill="url(#paint1_linear_833_6149)"/>
|
||||||
|
<path d="M85.4994 36.2C85.4994 35 84.6494 34.2 83.6494 34.05C83.5494 34.05 83.2994 34 83.1494 34H78.3994V38.4H83.1494C83.2994 38.4 83.5994 38.4 83.6494 38.35C84.6494 38.2 85.4994 37.4 85.4994 36.2Z" fill="url(#paint2_linear_833_6149)"/>
|
||||||
|
<path d="M57.8988 12.75C52.8988 12.75 48.7988 16.8 48.7988 21.85V33.75C51.0988 31.8 55.0988 30.55 61.5488 30.85C64.9988 31 68.6988 31.95 68.6988 31.95V35.8C66.8488 34.85 64.6488 34 61.7988 33.8C56.8988 33.45 53.9488 35.85 53.9488 40.05C53.9488 44.3 56.8988 46.7 61.7988 46.3C64.6488 46.1 66.8488 45.2 68.6988 44.3V48.15C68.6988 48.15 65.0488 49.1 61.5488 49.25C55.0988 49.55 51.0988 48.3 48.7988 46.35V67.35H62.1488C67.1488 67.35 71.2488 63.3 71.2488 58.25V12.75H57.8988Z" fill="url(#paint3_linear_833_6149)"/>
|
||||||
|
<path d="M32.7496 12.75C27.7496 12.75 23.6496 16.8 23.6496 21.85V44.3C26.1996 45.55 28.8496 46.35 31.4996 46.35C34.6496 46.35 36.3496 44.45 36.3496 41.85V31.25H44.1496V41.8C44.1496 45.9 41.5996 49.25 32.9496 49.25C27.6996 49.25 23.5996 48.1 23.5996 48.1V67.25H36.9496C41.9496 67.25 46.0496 63.2 46.0496 58.15V12.75H32.7496Z" fill="url(#paint4_linear_833_6149)"/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="paint0_linear_833_6149" x1="60.9804" y1="40.0821" x2="126.075" y2="40.0821" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#007940"/>
|
||||||
|
<stop offset="0.2285" stop-color="#00873F"/>
|
||||||
|
<stop offset="0.7433" stop-color="#40A737"/>
|
||||||
|
<stop offset="1" stop-color="#5CB531"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint1_linear_833_6149" x1="73.9404" y1="40.0023" x2="96.4108" y2="40.0023" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#007940"/>
|
||||||
|
<stop offset="0.2285" stop-color="#00873F"/>
|
||||||
|
<stop offset="0.7433" stop-color="#40A737"/>
|
||||||
|
<stop offset="1" stop-color="#5CB531"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint2_linear_833_6149" x1="73.9396" y1="36.1925" x2="96.409" y2="36.1925" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#007940"/>
|
||||||
|
<stop offset="0.2285" stop-color="#00873F"/>
|
||||||
|
<stop offset="0.7433" stop-color="#40A737"/>
|
||||||
|
<stop offset="1" stop-color="#5CB531"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint3_linear_833_6149" x1="48.6689" y1="40.0023" x2="70.8287" y2="40.0023" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#6C2C2F"/>
|
||||||
|
<stop offset="0.1735" stop-color="#882730"/>
|
||||||
|
<stop offset="0.5731" stop-color="#BE1833"/>
|
||||||
|
<stop offset="0.8585" stop-color="#DC0436"/>
|
||||||
|
<stop offset="1" stop-color="#E60039"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint4_linear_833_6149" x1="23.6382" y1="40.0023" x2="46.4553" y2="40.0023" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#1F286F"/>
|
||||||
|
<stop offset="0.4751" stop-color="#004E94"/>
|
||||||
|
<stop offset="0.8261" stop-color="#0066B1"/>
|
||||||
|
<stop offset="1" stop-color="#006FBC"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.5 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
<svg width="120" height="80" viewBox="0 0 120 80" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="120" height="80" rx="4" fill="white"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M97.5288 54.6562V53.7384H97.289L97.0137 54.3698L96.7378 53.7384H96.498V54.6562H96.6675V53.9637L96.9257 54.5609H97.1011L97.36 53.9624V54.6562H97.5288ZM96.0111 54.6562V53.8947H96.318V53.7397H95.5361V53.8947H95.843V54.6562H96.0111Z" fill="#00A2E5"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M49.6521 58.595H70.3479V21.4044H49.6521V58.595Z" fill="#7375CF"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M98.2675 40.0003C98.2675 53.063 87.6791 63.652 74.6171 63.652C69.0996 63.652 64.0229 61.7624 60 58.5956C65.5011 54.2646 69.0339 47.5448 69.0339 40.0003C69.0339 32.4552 65.5011 25.7354 60 21.4044C64.0229 18.2376 69.0996 16.348 74.6171 16.348C87.6791 16.348 98.2675 26.937 98.2675 40.0003Z" fill="#00A2E5"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M50.966 40.0003C50.966 32.4552 54.4988 25.7354 59.9999 21.4044C55.977 18.2376 50.9003 16.348 45.3828 16.348C32.3208 16.348 21.7324 26.937 21.7324 40.0003C21.7324 53.063 32.3208 63.652 45.3828 63.652C50.9003 63.652 55.977 61.7624 59.9999 58.5956C54.4988 54.2646 50.966 47.5448 50.966 40.0003Z" fill="#EB001B"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
<svg width="120" height="80" viewBox="0 0 120 80" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="120" height="80" rx="4" fill="white"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M97.5288 54.6562V53.7384H97.289L97.0137 54.3698L96.7378 53.7384H96.498V54.6562H96.6675V53.9637L96.9257 54.5609H97.1011L97.36 53.9624V54.6562H97.5288ZM96.0111 54.6562V53.8947H96.318V53.7397H95.5361V53.8947H95.843V54.6562H96.0111Z" fill="#F79E1B"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M49.6521 58.595H70.3479V21.4044H49.6521V58.595Z" fill="#FF5F00"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M98.2675 40.0003C98.2675 53.063 87.6791 63.652 74.6171 63.652C69.0996 63.652 64.0229 61.7624 60 58.5956C65.5011 54.2646 69.0339 47.5448 69.0339 40.0003C69.0339 32.4552 65.5011 25.7354 60 21.4044C64.0229 18.2376 69.0996 16.348 74.6171 16.348C87.6791 16.348 98.2675 26.937 98.2675 40.0003Z" fill="#F79E1B"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M50.966 40.0003C50.966 32.4552 54.4988 25.7354 59.9999 21.4044C55.977 18.2376 50.9003 16.348 45.3828 16.348C32.3208 16.348 21.7324 26.937 21.7324 40.0003C21.7324 53.063 32.3208 63.652 45.3828 63.652C50.9003 63.652 55.977 61.7624 59.9999 58.5956C54.4988 54.2646 50.966 47.5448 50.966 40.0003Z" fill="#EB001B"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,16 @@
|
|||||||
|
<svg width="120" height="80" viewBox="0 0 120 80" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="120" height="80" rx="4" fill="white"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M76.5282 14.1788C73.7421 14.2654 70.3351 16.4596 69.7146 19.1653L60.2981 60.8371C59.6776 63.568 61.3656 65.7903 64.0813 65.8312H84.9996C87.6739 65.6989 90.2725 63.5298 90.8824 60.8549L100.299 19.1828C100.93 16.424 99.201 14.1839 96.4402 14.1839L76.5282 14.1788Z" fill="#01798A"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M60.2982 60.8371L69.7148 19.1653C70.3353 16.4596 73.7422 14.2654 76.4776 14.1815L68.5607 14.1764L54.2967 14.1737C51.5536 14.2298 48.1023 16.4394 47.482 19.1653L38.0627 60.8371C37.4399 63.568 39.1304 65.7903 41.8443 65.8312H64.0814C61.3657 65.7903 59.6777 63.568 60.2982 60.8371Z" fill="#024381"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.0627 60.8371L47.482 19.1653C48.1023 16.4394 51.5536 14.2298 54.2967 14.1737L36.0237 14.1689C33.2653 14.1689 29.7287 16.4039 29.0983 19.1653L19.6789 60.8371C19.6216 61.0914 19.5898 61.3406 19.5708 61.5845V62.3576C19.7552 64.3483 21.2754 65.798 23.4605 65.8312H41.8443C39.1304 65.7903 37.4399 63.568 38.0627 60.8371Z" fill="#DD0228"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M54.6818 44.5384H55.0276C55.3454 44.5384 55.5592 44.4318 55.6594 44.2206L56.558 42.8756H58.9644L58.4626 43.7603H61.3479L60.9819 45.1157H57.5486C57.1532 45.7107 56.6665 45.9904 56.0812 45.9572H54.2929L54.6818 44.5384ZM54.2867 46.4811H60.608L60.2051 47.9535H57.6629L57.275 49.3747H59.7488L59.3458 50.8469H56.872L56.2974 52.947C56.1551 53.298 56.3422 53.4559 56.8556 53.4201H58.8717L58.4982 54.7882H54.6274C53.8937 54.7882 53.642 54.3685 53.8722 53.527L54.6069 50.8469H53.0256L53.4273 49.3747H55.0088L55.3964 47.9535H53.8848L54.2867 46.4811ZM64.3762 42.8656L64.2766 43.7275C64.2766 43.7275 65.4691 42.8322 66.552 42.8322H70.5538L69.0234 48.3727C68.8965 49.0061 68.3523 49.3211 67.3911 49.3211H62.8554L61.7929 53.2116C61.7317 53.4201 61.8182 53.527 62.0471 53.527H62.9395L62.6115 54.7346H60.3426C59.4717 54.7346 59.1095 54.4727 59.2531 53.9466L62.2554 42.8656H64.3762ZM67.765 44.4318H64.1932L63.7659 45.9268C63.7659 45.9268 64.3608 45.4973 65.3548 45.4819C66.3461 45.4664 67.4776 45.4819 67.4776 45.4819L67.765 44.4318ZM66.471 47.8999C66.735 47.9357 66.8828 47.8312 66.9006 47.5845L67.1192 46.7964H63.5419L63.2419 47.8999H66.471ZM64.0581 49.6899H66.12L66.0817 50.5823H66.6307C66.9081 50.5823 67.0456 50.4935 67.0456 50.3181L67.2081 49.7408H68.9218L68.693 50.5823C68.4994 51.2842 67.9863 51.6503 67.1523 51.6861H66.054L66.0488 53.2116C66.0287 53.4559 66.2496 53.5804 66.7046 53.5804H67.7369L67.4037 54.7882H64.9276C64.2335 54.8212 63.8932 54.4905 63.9004 53.7889L64.0581 49.6899Z" fill="white"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M72.8218 44.5995L73.3 42.9164H75.7177L75.6133 43.534C75.6133 43.534 76.8488 42.9164 77.7385 42.9164H80.7282L80.253 44.5995H79.7827L77.5275 52.5378H77.9978L77.5504 54.1143H77.0801L76.8844 54.7983H74.543L74.7383 54.1143H70.1191L70.5693 52.5378H71.0321L73.2894 44.5995H72.8218ZM75.4303 44.5995L74.815 46.7479C74.815 46.7479 75.8678 46.3439 76.7753 46.2295C76.9758 45.4792 77.2378 44.5995 77.2378 44.5995H75.4303ZM74.53 47.755L73.9126 50.0053C73.9126 50.0053 75.0794 49.4307 75.8801 49.3823C76.1114 48.5126 76.3429 47.755 76.3429 47.755H74.53ZM74.9826 52.5378L75.4454 50.9055H73.6407L73.1755 52.5378H74.9826ZM80.8301 42.8122H83.1031L83.1995 43.651C83.1845 43.8645 83.3114 43.9665 83.5809 43.9665H83.9825L83.5762 45.3877H81.9055C81.2676 45.4207 80.9395 45.1768 80.9091 44.6503L80.8301 42.8122ZM87.5266 45.8608L87.0946 47.3865H84.7504L84.3485 48.805H86.6903L86.2555 50.3282H83.6473L83.0572 51.2209H84.3338L84.6287 53.0082C84.6639 53.1862 84.8216 53.2727 85.0911 53.2727H85.4876L85.071 54.7447H83.6675C82.9403 54.7805 82.5643 54.5363 82.5336 54.0101L82.1953 52.3777L81.0336 54.1143C80.7589 54.605 80.3368 54.834 79.7677 54.7983H77.6243L78.0413 53.3259H78.71C78.9847 53.3259 79.2132 53.2039 79.4191 52.9573L81.2371 50.3282H78.893L79.3274 48.805H81.87L82.2743 47.3865H79.7293L80.1641 45.8608H87.5266Z" fill="white"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M89.8554 40.9179C89.0926 42.5399 88.3657 43.4857 87.9388 43.9256C87.5113 44.3606 86.665 45.3726 84.626 45.2962L84.8015 44.058C86.5172 43.5291 87.4452 41.1464 87.9741 40.0913L87.3437 32.3209L88.6708 32.3031H89.7843L89.904 37.1775L91.9909 32.3031H94.1038L89.8554 40.9179Z" fill="white"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M83.9472 32.8904L83.1078 33.4677C82.2308 32.7813 81.43 32.3566 79.8846 33.0735C77.7792 34.0499 76.02 41.5382 81.8165 39.0717L82.147 39.4633L84.4275 39.5218L85.925 32.7175L83.9472 32.8904ZM82.6505 36.6104C82.2841 37.6911 81.4659 38.4055 80.8252 38.2022C80.1846 38.0038 79.9557 36.9612 80.3269 35.8781C80.6929 34.7949 81.5165 34.083 82.1521 34.2864C82.7928 34.4847 83.024 35.5272 82.6505 36.6104Z" fill="white"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M70.7625 27.8254H75.9591C76.9583 27.8254 77.731 28.0519 78.2622 28.4966C78.7911 28.9468 79.0558 29.5927 79.0558 30.4343V30.4596C79.0558 30.6197 79.0452 30.8003 79.0302 30.996C79.0045 31.1893 78.9713 31.385 78.9286 31.5885C78.6997 32.7023 78.1682 33.5973 77.3472 34.2762C76.5232 34.9525 75.5473 35.2933 74.4236 35.2933H71.6369L70.7752 39.5218H68.3623L70.7625 27.8254ZM72.0613 33.2592H74.3727C74.975 33.2592 75.4529 33.1192 75.8014 32.8422C76.1473 32.5625 76.3761 32.1354 76.503 31.5557C76.5232 31.4486 76.5358 31.3521 76.5512 31.2632C76.5591 31.1794 76.569 31.0952 76.569 31.0141C76.569 30.5995 76.4219 30.2995 76.1267 30.1113C75.8319 29.9204 75.3694 29.8291 74.7284 29.8291H72.7657L72.0613 33.2592Z" fill="white"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M61.9146 32.3006H63.7046L63.5015 33.3431L63.7553 33.0456C64.3354 32.4253 65.0421 32.1175 65.8709 32.1175C66.6235 32.1175 67.1673 32.3363 67.5053 32.776C67.8388 33.216 67.9353 33.8237 67.7724 34.6043L66.7911 39.5218H64.9504L65.8405 35.0646C65.9318 34.6043 65.9065 34.261 65.7666 34.0397C65.6216 33.8185 65.3595 33.7093 64.9761 33.7093C64.503 33.7093 64.1066 33.8566 63.7785 34.1492C63.4529 34.4441 63.237 34.8535 63.1355 35.3747L62.3118 39.5218H60.4722L61.9146 32.3006Z" fill="white"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M53.4452 38.97C52.9265 38.4742 52.6646 37.8055 52.6621 36.9563C52.6621 36.8112 52.6709 36.6461 52.6902 36.4654C52.7093 36.2823 52.7335 36.1043 52.7677 35.9392C53.0028 34.767 53.5038 33.8364 54.2753 33.1498C55.0456 32.4608 55.975 32.1149 57.0631 32.1149C57.9541 32.1149 58.6608 32.3642 59.1784 32.8625C59.6956 33.3635 59.9548 34.0397 59.9548 34.8994C59.9548 35.0466 59.9436 35.217 59.9244 35.4001C59.9015 35.5857 59.8738 35.7637 59.8416 35.9392C59.6118 37.0935 59.1124 38.014 58.3407 38.6879C57.569 39.3668 56.6423 39.7047 55.5618 39.7047C54.6669 39.7047 53.9626 39.4607 53.4452 38.97ZM57.2245 37.541C57.5741 37.1622 57.8245 36.5874 57.9771 35.8222C58 35.7028 58.0202 35.5781 58.0328 35.4535C58.0455 35.3314 58.0506 35.217 58.0506 35.1128C58.0506 34.6678 57.9374 34.3221 57.7099 34.0779C57.4838 33.8312 57.1623 33.7093 56.7467 33.7093C56.1973 33.7093 55.7499 33.9023 55.3993 34.2889C55.0456 34.6755 54.7952 35.2603 54.6375 36.0383C54.616 36.1578 54.5982 36.2774 54.5818 36.3943C54.5691 36.5138 54.5653 36.6257 54.5677 36.7273C54.5677 37.1698 54.681 37.5106 54.9084 37.752C55.1345 37.9937 55.4547 38.1131 55.8758 38.1131C56.4275 38.1131 56.8749 37.9224 57.2245 37.541Z" fill="white"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M49.7065 32.3031H51.6897L50.1362 39.5193H48.157L49.7065 32.3031ZM50.3308 29.6741H52.3316L51.9579 31.4257H49.9572L50.3308 29.6741Z" fill="white"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M41.4044 32.3006H43.1929L42.9884 33.3431L43.245 33.0456C43.8248 32.4253 44.529 32.1175 45.3603 32.1175C46.1129 32.1175 46.6556 32.3363 46.9964 32.776C47.3318 33.216 47.4234 33.8237 47.2647 34.6043L46.2794 39.5218H44.4413L45.3311 35.0646C45.4227 34.6043 45.3974 34.261 45.256 34.0397C45.1164 33.8185 44.8492 33.7093 44.4641 33.7093C43.9913 33.7093 43.5934 33.8566 43.2692 34.1492C42.9437 34.4441 42.729 34.8535 42.6235 35.3747L41.8036 39.5218H39.9617L41.4044 32.3006Z" fill="white"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M39.0721 35.484C38.7924 36.8546 38.1441 37.9072 37.1385 38.6548C36.142 39.3896 34.8567 39.7582 33.2831 39.7582C31.8022 39.7582 30.7165 39.3819 30.0237 38.6267C29.5432 38.0902 29.3042 37.4087 29.3042 36.5849C29.3042 36.2443 29.345 35.8781 29.4263 35.484L31.103 27.3984H33.6352L31.9814 35.3925C31.9305 35.6138 31.9102 35.8197 31.9128 36.0053C31.9102 36.4148 32.0118 36.7503 32.2177 37.0121C32.5177 37.4013 33.0046 37.5944 33.6822 37.5944C34.4613 37.5944 35.1033 37.4038 35.6016 37.0197C36.1 36.6384 36.4254 36.0969 36.5715 35.3925L38.2305 27.3984H40.7499L39.0721 35.484Z" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 8.6 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
<svg width="120" height="80" viewBox="0 0 120 80" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="120" height="80" rx="4" fill="white"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M86.6666 44.9375L90.3239 35.0625L92.3809 44.9375H86.6666ZM100.952 52.8375L95.8086 27.1625H88.7383C86.3525 27.1625 85.7723 29.0759 85.7723 29.0759L76.1904 52.8375H82.8868L84.2269 49.0244H92.3947L93.1479 52.8375H100.952Z" fill="#1434CB"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M77.1866 33.5711L78.0952 28.244C78.0952 28.244 75.2896 27.1625 72.3648 27.1625C69.2031 27.1625 61.6955 28.5638 61.6955 35.3738C61.6955 41.7825 70.5071 41.8621 70.5071 45.2266C70.5071 48.5912 62.6034 47.9901 59.9955 45.8676L59.0476 51.4362C59.0476 51.4362 61.8919 52.8375 66.2397 52.8375C70.5869 52.8375 77.1467 50.5544 77.1467 44.3455C77.1467 37.8964 68.2552 37.296 68.2552 34.4921C68.2552 31.6882 74.4602 32.0484 77.1866 33.5711Z" fill="#1434CB"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M54.6517 52.8375H47.6191L52.0144 27.1625H59.0477L54.6517 52.8375Z" fill="#1434CB"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M42.3113 27.1625L35.9217 44.8213L35.1663 41.0185L35.167 41.0199L32.9114 29.4749C32.9114 29.4749 32.6394 27.1625 29.7324 27.1625H19.1709L19.0476 27.5966C19.0476 27.5966 22.2782 28.2669 26.057 30.5326L31.8793 52.8375H38.8617L49.5238 27.1625H42.3113Z" fill="#1434CB"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -23,6 +23,7 @@ import {
|
|||||||
stripProfileSecrets,
|
stripProfileSecrets,
|
||||||
} from '@/lib/api/auth';
|
} from '@/lib/api/auth';
|
||||||
import { listAdminInvites, listAdminUsers } from '@/lib/api/admin';
|
import { listAdminInvites, listAdminUsers } from '@/lib/api/admin';
|
||||||
|
import { getDomainRules, saveDomainRules } from '@/lib/api/domains';
|
||||||
import { getSends } from '@/lib/api/send';
|
import { getSends } from '@/lib/api/send';
|
||||||
import { getCachedVaultCoreSnapshot, loadVaultCoreSyncSnapshot } from '@/lib/api/vault-sync';
|
import { getCachedVaultCoreSnapshot, loadVaultCoreSyncSnapshot } from '@/lib/api/vault-sync';
|
||||||
import { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair';
|
import { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair';
|
||||||
@@ -68,7 +69,7 @@ import {
|
|||||||
createDemoMainRoutesProps,
|
createDemoMainRoutesProps,
|
||||||
} from '@/lib/demo';
|
} from '@/lib/demo';
|
||||||
import type { AdminBackupSettings } from '@/lib/api/backup';
|
import type { AdminBackupSettings } from '@/lib/api/backup';
|
||||||
import type { AdminInvite, AdminUser, AppPhase, AuthorizedDevice, Cipher, Folder as VaultFolder, Profile, Send, SessionState } from '@/lib/types';
|
import type { AdminInvite, AdminUser, AppPhase, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SessionState } from '@/lib/types';
|
||||||
import type { VaultCoreSnapshot } from '@/lib/vault-cache';
|
import type { VaultCoreSnapshot } from '@/lib/vault-cache';
|
||||||
|
|
||||||
function isBackupProgressDetail(value: unknown): value is BackupProgressDetail {
|
function isBackupProgressDetail(value: unknown): value is BackupProgressDetail {
|
||||||
@@ -87,6 +88,7 @@ const IMPORT_ROUTE_PATHS = [IMPORT_ROUTE, '/tools/import', '/tools/import-export
|
|||||||
const IMPORT_ROUTE_ALIASES: ReadonlySet<string> = new Set(IMPORT_ROUTE_PATHS.filter((path) => path !== IMPORT_ROUTE));
|
const IMPORT_ROUTE_ALIASES: ReadonlySet<string> = new Set(IMPORT_ROUTE_PATHS.filter((path) => path !== IMPORT_ROUTE));
|
||||||
const SETTINGS_HOME_ROUTE = '/settings';
|
const SETTINGS_HOME_ROUTE = '/settings';
|
||||||
const SETTINGS_ACCOUNT_ROUTE = '/settings/account';
|
const SETTINGS_ACCOUNT_ROUTE = '/settings/account';
|
||||||
|
const SETTINGS_DOMAIN_RULES_ROUTE = '/settings/domain-rules';
|
||||||
const AUTH_ROUTE_PATHS = ['/', '/login', '/register', '/lock', '/recover-2fa'] as const;
|
const AUTH_ROUTE_PATHS = ['/', '/login', '/register', '/lock', '/recover-2fa'] as const;
|
||||||
const APP_ROUTE_PATHS = [
|
const APP_ROUTE_PATHS = [
|
||||||
'/',
|
'/',
|
||||||
@@ -98,6 +100,7 @@ const APP_ROUTE_PATHS = [
|
|||||||
'/backup',
|
'/backup',
|
||||||
'/settings',
|
'/settings',
|
||||||
SETTINGS_ACCOUNT_ROUTE,
|
SETTINGS_ACCOUNT_ROUTE,
|
||||||
|
SETTINGS_DOMAIN_RULES_ROUTE,
|
||||||
'/help',
|
'/help',
|
||||||
...IMPORT_ROUTE_PATHS,
|
...IMPORT_ROUTE_PATHS,
|
||||||
] as const;
|
] as const;
|
||||||
@@ -168,6 +171,7 @@ export default function App() {
|
|||||||
const [session, setSessionState] = useState<SessionState | null>(initialBootstrap.session);
|
const [session, setSessionState] = useState<SessionState | null>(initialBootstrap.session);
|
||||||
const [profile, setProfile] = useState<Profile | null>(initialProfileSnapshot);
|
const [profile, setProfile] = useState<Profile | null>(initialProfileSnapshot);
|
||||||
const [defaultKdfIterations, setDefaultKdfIterations] = useState(initialBootstrap.defaultKdfIterations);
|
const [defaultKdfIterations, setDefaultKdfIterations] = useState(initialBootstrap.defaultKdfIterations);
|
||||||
|
const [registrationInviteRequired, setRegistrationInviteRequired] = useState(initialBootstrap.registrationInviteRequired);
|
||||||
const [jwtWarning, setJwtWarning] = useState<{ reason: JwtUnsafeReason; minLength: number } | null>(initialBootstrap.jwtWarning);
|
const [jwtWarning, setJwtWarning] = useState<{ reason: JwtUnsafeReason; minLength: number } | null>(initialBootstrap.jwtWarning);
|
||||||
|
|
||||||
const [loginValues, setLoginValues] = useState({ email: '', password: '' });
|
const [loginValues, setLoginValues] = useState({ email: '', password: '' });
|
||||||
@@ -227,6 +231,9 @@ export default function App() {
|
|||||||
const pendingVaultCoreQueryRefreshRef = useRef<Promise<{ data?: VaultCoreSnapshot } | unknown> | null>(null);
|
const pendingVaultCoreQueryRefreshRef = useRef<Promise<{ data?: VaultCoreSnapshot } | unknown> | null>(null);
|
||||||
const pendingVaultCoreRefreshRef = useRef<Promise<unknown> | null>(null);
|
const pendingVaultCoreRefreshRef = useRef<Promise<unknown> | null>(null);
|
||||||
const notificationRefreshTimerRef = useRef<number | null>(null);
|
const notificationRefreshTimerRef = useRef<number | null>(null);
|
||||||
|
const domainRulesSaveSeqRef = useRef(0);
|
||||||
|
const loginEmailRef = useRef(loginValues.email);
|
||||||
|
const loginHintRequestSeqRef = useRef(0);
|
||||||
const { toasts, pushToast, removeToast } = useToastManager();
|
const { toasts, pushToast, removeToast } = useToastManager();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -259,6 +266,7 @@ export default function App() {
|
|||||||
}, [inviteCodeFromUrl]);
|
}, [inviteCodeFromUrl]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
loginEmailRef.current = loginValues.email;
|
||||||
const normalizedEmail = loginValues.email.trim().toLowerCase();
|
const normalizedEmail = loginValues.email.trim().toLowerCase();
|
||||||
setLoginHintState((prev) => (
|
setLoginHintState((prev) => (
|
||||||
prev.email && prev.email !== normalizedEmail
|
prev.email && prev.email !== normalizedEmail
|
||||||
@@ -406,6 +414,7 @@ export default function App() {
|
|||||||
const normalizedCurrentHashPath = currentHashPath.replace(/^\/+/, '').replace(/\/+$/, '');
|
const normalizedCurrentHashPath = currentHashPath.replace(/^\/+/, '').replace(/\/+$/, '');
|
||||||
const isDemoPublicSendRoute = /^send\/[^/]+(?:\/[^/]+)?$/i.test(normalizedCurrentHashPath);
|
const isDemoPublicSendRoute = /^send\/[^/]+(?:\/[^/]+)?$/i.test(normalizedCurrentHashPath);
|
||||||
setDefaultKdfIterations(initialBootstrap.defaultKdfIterations);
|
setDefaultKdfIterations(initialBootstrap.defaultKdfIterations);
|
||||||
|
setRegistrationInviteRequired(initialBootstrap.registrationInviteRequired);
|
||||||
setJwtWarning(null);
|
setJwtWarning(null);
|
||||||
setSession(null);
|
setSession(null);
|
||||||
setProfile(null);
|
setProfile(null);
|
||||||
@@ -420,6 +429,7 @@ export default function App() {
|
|||||||
const boot = await bootstrapAppSession(initialBootstrap);
|
const boot = await bootstrapAppSession(initialBootstrap);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setDefaultKdfIterations(boot.defaultKdfIterations);
|
setDefaultKdfIterations(boot.defaultKdfIterations);
|
||||||
|
setRegistrationInviteRequired(boot.registrationInviteRequired);
|
||||||
setJwtWarning(boot.jwtWarning);
|
setJwtWarning(boot.jwtWarning);
|
||||||
setSession(boot.session);
|
setSession(boot.session);
|
||||||
setProfile(boot.profile);
|
setProfile(boot.profile);
|
||||||
@@ -630,6 +640,7 @@ export default function App() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const requestSeq = ++loginHintRequestSeqRef.current;
|
||||||
setLoginHintState({
|
setLoginHintState({
|
||||||
email,
|
email,
|
||||||
loading: true,
|
loading: true,
|
||||||
@@ -638,6 +649,7 @@ export default function App() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await getPasswordHint(email);
|
const result = await getPasswordHint(email);
|
||||||
|
if (loginHintRequestSeqRef.current !== requestSeq || loginEmailRef.current.trim().toLowerCase() !== email) return;
|
||||||
openPasswordHintDialog(result.masterPasswordHint);
|
openPasswordHintDialog(result.masterPasswordHint);
|
||||||
setLoginHintState({
|
setLoginHintState({
|
||||||
email,
|
email,
|
||||||
@@ -645,6 +657,7 @@ export default function App() {
|
|||||||
hint: result.masterPasswordHint,
|
hint: result.masterPasswordHint,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (loginHintRequestSeqRef.current !== requestSeq || loginEmailRef.current.trim().toLowerCase() !== email) return;
|
||||||
setLoginHintState({
|
setLoginHintState({
|
||||||
email: '',
|
email: '',
|
||||||
loading: false,
|
loading: false,
|
||||||
@@ -953,6 +966,45 @@ export default function App() {
|
|||||||
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
|
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
|
const domainRulesQueryKey = useMemo(() => ['domain-rules', vaultCacheKey || session?.email] as const, [vaultCacheKey, session?.email]);
|
||||||
|
const domainRulesQuery = useQuery({
|
||||||
|
queryKey: domainRulesQueryKey,
|
||||||
|
queryFn: () => getDomainRules(authedFetch),
|
||||||
|
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
function handleSaveDomainRules(customEquivalentDomains: CustomEquivalentDomain[], excludedGlobalEquivalentDomains: number[]): Promise<void> {
|
||||||
|
const equivalentDomains = customEquivalentDomains.filter((rule) => !rule.excluded).map((rule) => rule.domains);
|
||||||
|
const excludedGlobalTypes = new Set(excludedGlobalEquivalentDomains);
|
||||||
|
const currentRules = queryClient.getQueryData<DomainRules>(domainRulesQueryKey) || domainRulesQuery.data;
|
||||||
|
const optimisticRules: DomainRules = {
|
||||||
|
object: 'domains',
|
||||||
|
equivalentDomains,
|
||||||
|
customEquivalentDomains,
|
||||||
|
globalEquivalentDomains: (currentRules?.globalEquivalentDomains || []).map((rule) => ({
|
||||||
|
...rule,
|
||||||
|
excluded: excludedGlobalTypes.has(rule.type),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
const saveSeq = ++domainRulesSaveSeqRef.current;
|
||||||
|
queryClient.setQueryData(domainRulesQueryKey, optimisticRules);
|
||||||
|
|
||||||
|
void saveDomainRules(authedFetch, {
|
||||||
|
customEquivalentDomains,
|
||||||
|
equivalentDomains,
|
||||||
|
excludedGlobalEquivalentDomains,
|
||||||
|
}).then((updated) => {
|
||||||
|
if (domainRulesSaveSeqRef.current !== saveSeq) return;
|
||||||
|
queryClient.setQueryData(domainRulesQueryKey, updated);
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['vault-core', vaultCacheKey] });
|
||||||
|
}).catch((error) => {
|
||||||
|
if (domainRulesSaveSeqRef.current !== saveSeq) return;
|
||||||
|
pushToast('error', error instanceof Error ? error.message : t('txt_domain_rules_save_failed'));
|
||||||
|
void domainRulesQuery.refetch();
|
||||||
|
});
|
||||||
|
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
useQuery({
|
useQuery({
|
||||||
queryKey: ['admin-backup-settings', vaultCacheKey],
|
queryKey: ['admin-backup-settings', vaultCacheKey],
|
||||||
queryFn: () => backupActions.loadSettings(),
|
queryFn: () => backupActions.loadSettings(),
|
||||||
@@ -1317,6 +1369,23 @@ export default function App() {
|
|||||||
const isImportRoute = routeLocation === IMPORT_ROUTE || IMPORT_ROUTE_ALIASES.has(routeLocation);
|
const isImportRoute = routeLocation === IMPORT_ROUTE || IMPORT_ROUTE_ALIASES.has(routeLocation);
|
||||||
const showSidebarToggle = mobileLayout && (location === '/vault' || location === '/sends');
|
const showSidebarToggle = mobileLayout && (location === '/vault' || location === '/sends');
|
||||||
const sidebarToggleTitle = location === '/vault' ? t('txt_folders') : t('txt_type');
|
const sidebarToggleTitle = location === '/vault' ? t('txt_folders') : t('txt_type');
|
||||||
|
const demoDomainRules = useMemo<DomainRules>(() => ({
|
||||||
|
equivalentDomains: [
|
||||||
|
['nodewarden.example', 'nw.example'],
|
||||||
|
['staging.nodewarden.example', 'preview.nodewarden.example'],
|
||||||
|
],
|
||||||
|
customEquivalentDomains: [
|
||||||
|
{ id: 'demo-custom-1', domains: ['nodewarden.example', 'nw.example'], excluded: false },
|
||||||
|
{ id: 'demo-custom-2', domains: ['staging.nodewarden.example', 'preview.nodewarden.example'], excluded: false },
|
||||||
|
],
|
||||||
|
globalEquivalentDomains: [
|
||||||
|
{ type: 0, domains: ['youtube.com', 'google.com', 'gmail.com'], excluded: false },
|
||||||
|
{ type: 1, domains: ['apple.com', 'icloud.com'], excluded: false },
|
||||||
|
{ type: 10, domains: ['microsoft.com', 'office.com', 'xbox.com'], excluded: true },
|
||||||
|
{ type: -10001, domains: ['nodewarden.example', 'nw.example'], excluded: false },
|
||||||
|
],
|
||||||
|
object: 'domains',
|
||||||
|
}), []);
|
||||||
const mobilePrimaryRoute =
|
const mobilePrimaryRoute =
|
||||||
location === '/sends'
|
location === '/sends'
|
||||||
? '/sends'
|
? '/sends'
|
||||||
@@ -1330,6 +1399,7 @@ export default function App() {
|
|||||||
if (location === '/sends') return t('nav_sends');
|
if (location === '/sends') return t('nav_sends');
|
||||||
if (location === '/admin') return t('nav_admin_panel');
|
if (location === '/admin') return t('nav_admin_panel');
|
||||||
if (location === '/security/devices') return t('nav_device_management');
|
if (location === '/security/devices') return t('nav_device_management');
|
||||||
|
if (location === SETTINGS_DOMAIN_RULES_ROUTE) return t('nav_domain_rules');
|
||||||
if (location === '/backup') return t('nav_backup_strategy');
|
if (location === '/backup') return t('nav_backup_strategy');
|
||||||
if (isImportRoute) return t('nav_import_export');
|
if (isImportRoute) return t('nav_import_export');
|
||||||
if (location === SETTINGS_ACCOUNT_ROUTE) return t('nav_account_settings');
|
if (location === SETTINGS_ACCOUNT_ROUTE) return t('nav_account_settings');
|
||||||
@@ -1341,6 +1411,12 @@ export default function App() {
|
|||||||
if (phase === 'app' && location === '/' && !isPublicSendRoute) navigate('/vault');
|
if (phase === 'app' && location === '/' && !isPublicSendRoute) navigate('/vault');
|
||||||
}, [phase, location, isPublicSendRoute, navigate]);
|
}, [phase, location, isPublicSendRoute, navigate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase === 'register' && (location === '/' || location === '/login') && !isPublicSendRoute) {
|
||||||
|
navigate('/register');
|
||||||
|
}
|
||||||
|
}, [phase, location, isPublicSendRoute, navigate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (phase === 'app' && isImportHashRoute && location !== IMPORT_ROUTE) {
|
if (phase === 'app' && isImportHashRoute && location !== IMPORT_ROUTE) {
|
||||||
navigate(IMPORT_ROUTE);
|
navigate(IMPORT_ROUTE);
|
||||||
@@ -1385,6 +1461,9 @@ export default function App() {
|
|||||||
authorizedDevices: authorizedDevicesQuery.data || [],
|
authorizedDevices: authorizedDevicesQuery.data || [],
|
||||||
authorizedDevicesLoading: authorizedDevicesQuery.isFetching,
|
authorizedDevicesLoading: authorizedDevicesQuery.isFetching,
|
||||||
authorizedDevicesError: authorizedDevicesQuery.isError && !authorizedDevicesQuery.data ? t('txt_load_devices_failed') : '',
|
authorizedDevicesError: authorizedDevicesQuery.isError && !authorizedDevicesQuery.data ? t('txt_load_devices_failed') : '',
|
||||||
|
domainRules: IS_DEMO_MODE ? demoDomainRules : domainRulesQuery.data || null,
|
||||||
|
domainRulesLoading: domainRulesQuery.isFetching && !domainRulesQuery.data,
|
||||||
|
domainRulesError: domainRulesQuery.isError && !domainRulesQuery.data ? t('txt_domain_rules_load_failed') : '',
|
||||||
onNavigate: navigate,
|
onNavigate: navigate,
|
||||||
onLogout: handleLogout,
|
onLogout: handleLogout,
|
||||||
onNotify: pushToast,
|
onNotify: pushToast,
|
||||||
@@ -1432,6 +1511,10 @@ export default function App() {
|
|||||||
onLockTimeoutChange: setLockTimeoutMinutes,
|
onLockTimeoutChange: setLockTimeoutMinutes,
|
||||||
onSessionTimeoutActionChange: setSessionTimeoutAction,
|
onSessionTimeoutActionChange: setSessionTimeoutAction,
|
||||||
onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices,
|
onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices,
|
||||||
|
onRefreshDomainRules: () => {
|
||||||
|
void domainRulesQuery.refetch();
|
||||||
|
},
|
||||||
|
onSaveDomainRules: handleSaveDomainRules,
|
||||||
onRenameAuthorizedDevice: accountSecurityActions.renameAuthorizedDevice,
|
onRenameAuthorizedDevice: accountSecurityActions.renameAuthorizedDevice,
|
||||||
onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust,
|
onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust,
|
||||||
onRemoveDevice: accountSecurityActions.openRemoveDevice,
|
onRemoveDevice: accountSecurityActions.openRemoveDevice,
|
||||||
@@ -1531,6 +1614,7 @@ export default function App() {
|
|||||||
unlockPreparing={unlockPreparing}
|
unlockPreparing={unlockPreparing}
|
||||||
loginValues={loginValues}
|
loginValues={loginValues}
|
||||||
registerValues={registerValues}
|
registerValues={registerValues}
|
||||||
|
registrationInviteRequired={registrationInviteRequired}
|
||||||
unlockPassword={unlockPassword}
|
unlockPassword={unlockPassword}
|
||||||
emailForLock={profile?.email || session?.email || ''}
|
emailForLock={profile?.email || session?.email || ''}
|
||||||
loginHintLoading={loginHintState.loading}
|
loginHintLoading={loginHintState.loading}
|
||||||
|
|||||||
@@ -129,15 +129,20 @@ export default function AdminPage(props: AdminPageProps) {
|
|||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="card">
|
<section className="card admin-invites-card">
|
||||||
<div className="section-head">
|
<div className="section-head admin-invites-head">
|
||||||
<h3>{t('txt_invites')}</h3>
|
<h3>{t('txt_invites')}</h3>
|
||||||
<button type="button" className="btn btn-secondary" disabled={props.loading} onClick={props.onRefresh}>
|
<div className="actions admin-invites-head-actions">
|
||||||
|
<button type="button" className="btn btn-secondary small" disabled={props.loading} onClick={props.onRefresh}>
|
||||||
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync')}
|
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync')}
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" className="btn btn-danger small" onClick={() => void props.onDeleteAllInvites()}>
|
||||||
|
<Trash2 size={14} className="btn-icon" /> {t('txt_delete_all')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="invite-toolbar">
|
<div className="invite-toolbar">
|
||||||
<div className="actions invite-create-group">
|
<div className="invite-create-group">
|
||||||
<label className="field invite-hours-field">
|
<label className="field invite-hours-field">
|
||||||
<span>{t('txt_invite_validity_hours')}</span>
|
<span>{t('txt_invite_validity_hours')}</span>
|
||||||
<input
|
<input
|
||||||
@@ -154,11 +159,8 @@ export default function AdminPage(props: AdminPageProps) {
|
|||||||
{t('txt_create_timed_invite')}
|
{t('txt_create_timed_invite')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="btn btn-danger" onClick={() => void props.onDeleteAllInvites()}>
|
|
||||||
<Trash2 size={14} className="btn-icon" /> {t('txt_delete_all')}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<table className="table">
|
<table className="table invite-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{t('txt_code')}</th>
|
<th>{t('txt_code')}</th>
|
||||||
@@ -207,7 +209,7 @@ export default function AdminPage(props: AdminPageProps) {
|
|||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div className="actions">
|
<div className="actions admin-pagination invite-pagination">
|
||||||
<button type="button" className="btn btn-secondary small" disabled={safePage <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
<button type="button" className="btn btn-secondary small" disabled={safePage <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
||||||
<ChevronLeft size={14} className="btn-icon" />
|
<ChevronLeft size={14} className="btn-icon" />
|
||||||
{t('txt_prev')}
|
{t('txt_prev')}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { ArrowUpDown, Cloud, Clock3, Folder as FolderIcon, KeyRound, Lock, LogOut, Send as SendIcon, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
|
import { ArrowUpDown, Check, ChevronDown, Clock3, Cloud, Folder as FolderIcon, Globe2, KeyRound, Lock, LogOut, MonitorSmartphone, Send as SendIcon, Settings as SettingsIcon, ShieldUser, SlidersHorizontal, Users } from 'lucide-preact';
|
||||||
|
import type { ComponentChildren } from 'preact';
|
||||||
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
import { Link } from 'wouter';
|
import { Link } from 'wouter';
|
||||||
import AppMainRoutes from '@/components/AppMainRoutes';
|
import AppMainRoutes from '@/components/AppMainRoutes';
|
||||||
import ThemeSwitch from '@/components/ThemeSwitch';
|
import ThemeSwitch from '@/components/ThemeSwitch';
|
||||||
@@ -25,6 +27,21 @@ interface AppAuthenticatedShellProps {
|
|||||||
mainRoutesProps: AppMainRoutesProps;
|
mainRoutesProps: AppMainRoutesProps;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type NavLayoutMode = 'flat' | 'grouped-expanded' | 'grouped-smart';
|
||||||
|
|
||||||
|
const NAV_LAYOUT_STORAGE_KEY = 'nodewarden.navLayoutMode';
|
||||||
|
|
||||||
|
function readNavLayoutMode(): NavLayoutMode {
|
||||||
|
if (typeof window === 'undefined') return 'flat';
|
||||||
|
try {
|
||||||
|
const saved = window.localStorage.getItem(NAV_LAYOUT_STORAGE_KEY);
|
||||||
|
if (saved === 'flat' || saved === 'grouped-expanded' || saved === 'grouped-smart') return saved;
|
||||||
|
} catch {
|
||||||
|
// Ignore local preference read failures.
|
||||||
|
}
|
||||||
|
return 'flat';
|
||||||
|
}
|
||||||
|
|
||||||
function isAdminProfile(profile: Profile | null): boolean {
|
function isAdminProfile(profile: Profile | null): boolean {
|
||||||
return String(profile?.role || '').toLowerCase() === 'admin';
|
return String(profile?.role || '').toLowerCase() === 'admin';
|
||||||
}
|
}
|
||||||
@@ -32,6 +49,179 @@ function isAdminProfile(profile: Profile | null): boolean {
|
|||||||
export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps) {
|
export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps) {
|
||||||
const routeAnimationKey = props.isImportRoute ? props.importRoute : props.location;
|
const routeAnimationKey = props.isImportRoute ? props.importRoute : props.location;
|
||||||
const isAdmin = isAdminProfile(props.profile);
|
const isAdmin = isAdminProfile(props.profile);
|
||||||
|
const vaultActive = props.location === '/vault' || props.location === '/vault/totp';
|
||||||
|
const settingsActive = props.location === props.settingsAccountRoute || props.location === '/settings/domain-rules';
|
||||||
|
const dataActive = props.location === '/backup' || props.isImportRoute;
|
||||||
|
const managementActive = props.location === '/admin' || props.location === '/security/devices';
|
||||||
|
const [navLayoutMode, setNavLayoutMode] = useState<NavLayoutMode>(readNavLayoutMode);
|
||||||
|
const [navLayoutPickerOpen, setNavLayoutPickerOpen] = useState(false);
|
||||||
|
const navLayoutPickerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [expandedGroups, setExpandedGroups] = useState({
|
||||||
|
vault: true,
|
||||||
|
settings: false,
|
||||||
|
data: false,
|
||||||
|
management: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onPointerDown = (event: Event) => {
|
||||||
|
if (!navLayoutPickerOpen) return;
|
||||||
|
const target = event.target as Node | null;
|
||||||
|
if (navLayoutPickerRef.current && target && !navLayoutPickerRef.current.contains(target)) {
|
||||||
|
setNavLayoutPickerOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') setNavLayoutPickerOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('pointerdown', onPointerDown);
|
||||||
|
document.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('pointerdown', onPointerDown);
|
||||||
|
document.removeEventListener('keydown', onKeyDown);
|
||||||
|
};
|
||||||
|
}, [navLayoutPickerOpen]);
|
||||||
|
|
||||||
|
function setNavMode(mode: NavLayoutMode): void {
|
||||||
|
setNavLayoutMode(mode);
|
||||||
|
setNavLayoutPickerOpen(false);
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(NAV_LAYOUT_STORAGE_KEY, mode);
|
||||||
|
} catch {
|
||||||
|
// Ignore local preference write failures.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleGroup(group: keyof typeof expandedGroups): void {
|
||||||
|
setExpandedGroups((current) => ({ ...current, [group]: !current[group] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupOpen(group: keyof typeof expandedGroups, active: boolean): boolean {
|
||||||
|
if (navLayoutMode === 'grouped-expanded') return true;
|
||||||
|
return expandedGroups[group] || active;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSideLink(href: string, active: boolean, icon: ComponentChildren, label: string) {
|
||||||
|
return (
|
||||||
|
<Link href={href} className={`side-link ${active ? 'active' : ''}`}>
|
||||||
|
{icon}
|
||||||
|
<span>{label}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSubLink(href: string, active: boolean, label: string) {
|
||||||
|
return (
|
||||||
|
<Link href={href} className={`side-sub-link ${active ? 'active' : ''}`}>
|
||||||
|
<span>{label}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNavGroup(
|
||||||
|
group: keyof typeof expandedGroups,
|
||||||
|
title: string,
|
||||||
|
icon: ComponentChildren,
|
||||||
|
active: boolean,
|
||||||
|
children: ComponentChildren
|
||||||
|
) {
|
||||||
|
const open = groupOpen(group, active);
|
||||||
|
return (
|
||||||
|
<div className={`side-nav-group ${open ? 'open' : ''}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`side-group-trigger ${active ? 'active' : ''}`}
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => toggleGroup(group)}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
<span>{title}</span>
|
||||||
|
<ChevronDown size={15} className="side-group-chevron" />
|
||||||
|
</button>
|
||||||
|
<div className={`side-subnav ${open ? 'open' : ''}`}>
|
||||||
|
<div className="side-subnav-inner">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const navLayoutOptions: Array<{ mode: NavLayoutMode; label: string }> = [
|
||||||
|
{
|
||||||
|
mode: 'flat',
|
||||||
|
label: t('txt_nav_layout_flat'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mode: 'grouped-expanded',
|
||||||
|
label: t('txt_nav_layout_grouped_expanded'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mode: 'grouped-smart',
|
||||||
|
label: t('txt_nav_layout_grouped_smart'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const navLayoutLabel = navLayoutOptions.find((option) => option.mode === navLayoutMode)?.label || t('txt_nav_layout_flat');
|
||||||
|
const flatNav = (
|
||||||
|
<>
|
||||||
|
{renderSideLink('/vault', props.location === '/vault', <KeyRound size={16} />, t('nav_vault_items'))}
|
||||||
|
{renderSideLink('/vault/totp', props.location === '/vault/totp', <Clock3 size={16} />, t('txt_verification_code'))}
|
||||||
|
{renderSideLink('/sends', props.location === '/sends', <SendIcon size={16} />, t('nav_sends'))}
|
||||||
|
{renderSideLink(props.settingsAccountRoute, props.location === props.settingsAccountRoute, <SettingsIcon size={16} />, t('nav_account_settings'))}
|
||||||
|
{renderSideLink('/settings/domain-rules', props.location === '/settings/domain-rules', <Globe2 size={16} />, t('nav_domain_rules'))}
|
||||||
|
{isAdmin && renderSideLink('/backup', props.location === '/backup', <Cloud size={16} />, t('nav_backup_strategy'))}
|
||||||
|
{renderSideLink(props.importRoute, props.isImportRoute, <ArrowUpDown size={16} />, t('nav_import_export'))}
|
||||||
|
{isAdmin && renderSideLink('/admin', props.location === '/admin', <Users size={16} />, t('nav_admin_panel'))}
|
||||||
|
{renderSideLink('/security/devices', props.location === '/security/devices', <MonitorSmartphone size={16} />, t('nav_device_management'))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const groupedNav = (
|
||||||
|
<>
|
||||||
|
{renderNavGroup(
|
||||||
|
'vault',
|
||||||
|
t('nav_my_vault'),
|
||||||
|
<KeyRound size={16} />,
|
||||||
|
vaultActive,
|
||||||
|
<>
|
||||||
|
{renderSubLink('/vault', props.location === '/vault', t('nav_vault_items'))}
|
||||||
|
{renderSubLink('/vault/totp', props.location === '/vault/totp', t('txt_verification_code'))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{renderSideLink('/sends', props.location === '/sends', <SendIcon size={16} />, t('nav_sends'))}
|
||||||
|
{renderNavGroup(
|
||||||
|
'settings',
|
||||||
|
t('txt_settings'),
|
||||||
|
<SettingsIcon size={16} />,
|
||||||
|
settingsActive,
|
||||||
|
<>
|
||||||
|
{renderSubLink(props.settingsAccountRoute, props.location === props.settingsAccountRoute, t('nav_account_settings'))}
|
||||||
|
{renderSubLink('/settings/domain-rules', props.location === '/settings/domain-rules', t('nav_domain_rules'))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{renderNavGroup(
|
||||||
|
'data',
|
||||||
|
t('nav_group_data_backup'),
|
||||||
|
<Cloud size={16} />,
|
||||||
|
dataActive,
|
||||||
|
<>
|
||||||
|
{isAdmin && renderSubLink('/backup', props.location === '/backup', t('nav_backup_strategy'))}
|
||||||
|
{renderSubLink(props.importRoute, props.isImportRoute, t('nav_import_export'))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{renderNavGroup(
|
||||||
|
'management',
|
||||||
|
t('nav_group_management'),
|
||||||
|
<ShieldUser size={16} />,
|
||||||
|
managementActive,
|
||||||
|
<>
|
||||||
|
{isAdmin && renderSubLink('/admin', props.location === '/admin', t('nav_admin_panel'))}
|
||||||
|
{renderSubLink('/security/devices', props.location === '/security/devices', t('nav_device_management'))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-page">
|
<div className="app-page">
|
||||||
@@ -76,45 +266,43 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
|||||||
|
|
||||||
<div className="app-main">
|
<div className="app-main">
|
||||||
<aside className="app-side">
|
<aside className="app-side">
|
||||||
<Link href="/vault" className={`side-link ${props.location === '/vault' ? 'active' : ''}`}>
|
<div className="side-nav-main">
|
||||||
<KeyRound size={16} />
|
{navLayoutMode === 'flat' ? flatNav : groupedNav}
|
||||||
<span>{t('nav_my_vault')}</span>
|
</div>
|
||||||
</Link>
|
<div className="nav-layout-control" ref={navLayoutPickerRef}>
|
||||||
<Link href="/vault/totp" className={`side-link ${props.location === '/vault/totp' ? 'active' : ''}`}>
|
{navLayoutPickerOpen && (
|
||||||
<Clock3 size={16} />
|
<div className="nav-layout-menu" role="menu">
|
||||||
<span>{t('txt_verification_code')}</span>
|
{navLayoutOptions.map((option) => (
|
||||||
</Link>
|
<button
|
||||||
<Link href="/sends" className={`side-link ${props.location === '/sends' ? 'active' : ''}`}>
|
key={option.mode}
|
||||||
<SendIcon size={16} />
|
type="button"
|
||||||
<span>{t('nav_sends')}</span>
|
className={`nav-layout-option ${navLayoutMode === option.mode ? 'active' : ''}`}
|
||||||
</Link>
|
onClick={() => setNavMode(option.mode)}
|
||||||
{isAdmin && (
|
role="menuitemradio"
|
||||||
<Link href="/admin" className={`side-link ${props.location === '/admin' ? 'active' : ''}`}>
|
aria-checked={navLayoutMode === option.mode}
|
||||||
<ShieldUser size={16} />
|
>
|
||||||
<span>{t('nav_admin_panel')}</span>
|
<span className="nav-layout-option-text">
|
||||||
</Link>
|
<strong>{option.label}</strong>
|
||||||
|
</span>
|
||||||
|
{navLayoutMode === option.mode && <Check size={15} className="nav-layout-check" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<Link href={props.settingsAccountRoute} className={`side-link ${props.location === props.settingsAccountRoute ? 'active' : ''}`}>
|
<button
|
||||||
<SettingsIcon size={16} />
|
type="button"
|
||||||
<span>{t('nav_account_settings')}</span>
|
className={`nav-layout-trigger ${navLayoutPickerOpen ? 'active' : ''}`}
|
||||||
</Link>
|
aria-haspopup="menu"
|
||||||
<Link href="/security/devices" className={`side-link ${props.location === '/security/devices' ? 'active' : ''}`}>
|
aria-expanded={navLayoutPickerOpen}
|
||||||
<Shield size={16} />
|
onClick={() => setNavLayoutPickerOpen((open) => !open)}
|
||||||
<span>{t('nav_device_management')}</span>
|
title={t('txt_nav_layout')}
|
||||||
</Link>
|
>
|
||||||
{isAdmin && (
|
<SlidersHorizontal size={15} />
|
||||||
<Link href="/backup" className={`side-link ${props.location === '/backup' ? 'active' : ''}`}>
|
</button>
|
||||||
<Cloud size={16} />
|
</div>
|
||||||
<span>{t('nav_backup_strategy')}</span>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
<Link href={props.importRoute} className={`side-link ${props.isImportRoute ? 'active' : ''}`}>
|
|
||||||
<ArrowUpDown size={14} />
|
|
||||||
<span>{t('nav_import_export')}</span>
|
|
||||||
</Link>
|
|
||||||
</aside>
|
</aside>
|
||||||
<main className="content">
|
<main className="content">
|
||||||
<div key={routeAnimationKey} className="route-stage">
|
<div key={routeAnimationKey} className={`route-stage ${props.location === '/settings/domain-rules' ? 'route-stage-fixed' : ''}`}>
|
||||||
<AppMainRoutes {...props.mainRoutesProps} />
|
<AppMainRoutes {...props.mainRoutesProps} />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
import { lazy, Suspense } from 'preact/compat';
|
import { lazy, Suspense } from 'preact/compat';
|
||||||
import { useEffect } from 'preact/hooks';
|
import { useEffect } from 'preact/hooks';
|
||||||
import { Link, Route, Switch } from 'wouter';
|
import { Link, Route, Switch } from 'wouter';
|
||||||
import { ArrowUpDown, Cloud, LogOut, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
|
import { ArrowUpDown, Cloud, Globe2, LogOut, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
|
||||||
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
|
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
|
||||||
import LoadingState from '@/components/LoadingState';
|
import LoadingState from '@/components/LoadingState';
|
||||||
import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup';
|
import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup';
|
||||||
import type { CiphersImportPayload } from '@/lib/api/vault';
|
import type { CiphersImportPayload } from '@/lib/api/vault';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import type { AdminInvite, AdminUser, AuthorizedDevice, Cipher, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft } from '@/lib/types';
|
import type { AdminInvite, AdminUser, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft } from '@/lib/types';
|
||||||
import type { ExportRequest } from '@/lib/export-formats';
|
import type { ExportRequest } from '@/lib/export-formats';
|
||||||
|
|
||||||
const VaultPage = lazy(() => import('@/components/VaultPage'));
|
const VaultPage = lazy(() => import('@/components/VaultPage'));
|
||||||
const SendsPage = lazy(() => import('@/components/SendsPage'));
|
const SendsPage = lazy(() => import('@/components/SendsPage'));
|
||||||
const TotpCodesPage = lazy(() => import('@/components/TotpCodesPage'));
|
const TotpCodesPage = lazy(() => import('@/components/TotpCodesPage'));
|
||||||
const SettingsPage = lazy(() => import('@/components/SettingsPage'));
|
const SettingsPage = lazy(() => import('@/components/SettingsPage'));
|
||||||
|
const DomainRulesPage = lazy(() => import('@/components/DomainRulesPage'));
|
||||||
const SecurityDevicesPage = lazy(() => import('@/components/SecurityDevicesPage'));
|
const SecurityDevicesPage = lazy(() => import('@/components/SecurityDevicesPage'));
|
||||||
const AdminPage = lazy(() => import('@/components/AdminPage'));
|
const AdminPage = lazy(() => import('@/components/AdminPage'));
|
||||||
const BackupCenterPage = lazy(() => import('@/components/BackupCenterPage'));
|
const BackupCenterPage = lazy(() => import('@/components/BackupCenterPage'));
|
||||||
@@ -56,6 +57,9 @@ export interface AppMainRoutesProps {
|
|||||||
authorizedDevices: AuthorizedDevice[];
|
authorizedDevices: AuthorizedDevice[];
|
||||||
authorizedDevicesLoading: boolean;
|
authorizedDevicesLoading: boolean;
|
||||||
authorizedDevicesError: string;
|
authorizedDevicesError: string;
|
||||||
|
domainRules: DomainRules | null;
|
||||||
|
domainRulesLoading: boolean;
|
||||||
|
domainRulesError: string;
|
||||||
onNavigate: (path: string) => void;
|
onNavigate: (path: string) => void;
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
|
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
|
||||||
@@ -108,6 +112,8 @@ export interface AppMainRoutesProps {
|
|||||||
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void;
|
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void;
|
||||||
onSessionTimeoutActionChange: (action: 'lock' | 'logout') => void;
|
onSessionTimeoutActionChange: (action: 'lock' | 'logout') => void;
|
||||||
onRefreshAuthorizedDevices: () => Promise<void>;
|
onRefreshAuthorizedDevices: () => Promise<void>;
|
||||||
|
onRefreshDomainRules: () => void;
|
||||||
|
onSaveDomainRules: (customEquivalentDomains: CustomEquivalentDomain[], excludedGlobalEquivalentDomains: number[]) => Promise<void>;
|
||||||
onRenameAuthorizedDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
|
onRenameAuthorizedDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
|
||||||
onRevokeDeviceTrust: (device: AuthorizedDevice) => void;
|
onRevokeDeviceTrust: (device: AuthorizedDevice) => void;
|
||||||
onRemoveDevice: (device: AuthorizedDevice) => void;
|
onRemoveDevice: (device: AuthorizedDevice) => void;
|
||||||
@@ -268,6 +274,10 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
<Shield size={18} />
|
<Shield size={18} />
|
||||||
<span>{t('nav_device_management')}</span>
|
<span>{t('nav_device_management')}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href="/settings/domain-rules" className="mobile-settings-link">
|
||||||
|
<Globe2 size={18} />
|
||||||
|
<span>{t('nav_domain_rules')}</span>
|
||||||
|
</Link>
|
||||||
<Link href={props.importRoute} className="mobile-settings-link">
|
<Link href={props.importRoute} className="mobile-settings-link">
|
||||||
<ArrowUpDown size={18} />
|
<ArrowUpDown size={18} />
|
||||||
<span>{t('nav_import_export')}</span>
|
<span>{t('nav_import_export')}</span>
|
||||||
@@ -319,6 +329,28 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
</Route>
|
</Route>
|
||||||
|
<Route path="/settings/domain-rules">
|
||||||
|
<div className="stack domain-rules-route">
|
||||||
|
{props.mobileLayout && (
|
||||||
|
<div className="mobile-settings-subhead">
|
||||||
|
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => props.onNavigate(props.settingsHomeRoute)}>
|
||||||
|
<span className="btn-icon" aria-hidden="true">{"<"}</span>
|
||||||
|
{t('txt_back')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Suspense fallback={<RouteContentFallback />}>
|
||||||
|
<DomainRulesPage
|
||||||
|
rules={props.domainRules}
|
||||||
|
loading={props.domainRulesLoading}
|
||||||
|
error={props.domainRulesError}
|
||||||
|
onRefresh={props.onRefreshDomainRules}
|
||||||
|
onSave={props.onSaveDomainRules}
|
||||||
|
onNotify={props.onNotify}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</Route>
|
||||||
<Route path="/admin">
|
<Route path="/admin">
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
{props.mobileLayout && (
|
{props.mobileLayout && (
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ interface AuthViewsProps {
|
|||||||
unlockPreparing: boolean;
|
unlockPreparing: boolean;
|
||||||
loginValues: LoginValues;
|
loginValues: LoginValues;
|
||||||
registerValues: RegisterValues;
|
registerValues: RegisterValues;
|
||||||
|
registrationInviteRequired?: boolean;
|
||||||
unlockPassword: string;
|
unlockPassword: string;
|
||||||
emailForLock: string;
|
emailForLock: string;
|
||||||
loginHintLoading: boolean;
|
loginHintLoading: boolean;
|
||||||
@@ -77,6 +78,7 @@ export default function AuthViews(props: AuthViewsProps) {
|
|||||||
const loginBusy = props.pendingAction === 'login';
|
const loginBusy = props.pendingAction === 'login';
|
||||||
const registerBusy = props.pendingAction === 'register';
|
const registerBusy = props.pendingAction === 'register';
|
||||||
const unlockBusy = props.pendingAction === 'unlock';
|
const unlockBusy = props.pendingAction === 'unlock';
|
||||||
|
const showInviteCodeField = props.registrationInviteRequired !== false || !!props.registerValues.inviteCode.trim();
|
||||||
|
|
||||||
if (props.mode === 'locked') {
|
if (props.mode === 'locked') {
|
||||||
return (
|
return (
|
||||||
@@ -184,8 +186,9 @@ export default function AuthViews(props: AuthViewsProps) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
{showInviteCodeField ? (
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t('txt_invite_code_optional')}</span>
|
<span>{t('txt_invite_code_required')}</span>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
value={props.registerValues.inviteCode}
|
value={props.registerValues.inviteCode}
|
||||||
@@ -195,6 +198,7 @@ export default function AuthViews(props: AuthViewsProps) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
) : null}
|
||||||
<button type="submit" className="btn btn-primary full" disabled={registerBusy}>
|
<button type="submit" className="btn btn-primary full" disabled={registerBusy}>
|
||||||
<UserPlus size={16} className="btn-icon" />
|
<UserPlus size={16} className="btn-icon" />
|
||||||
{registerBusy ? t('txt_registering') : t('txt_create_account')}
|
{registerBusy ? t('txt_registering') : t('txt_create_account')}
|
||||||
@@ -227,6 +231,7 @@ export default function AuthViews(props: AuthViewsProps) {
|
|||||||
value={props.loginValues.email}
|
value={props.loginValues.email}
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
placeholder={props.authPlaceholder}
|
placeholder={props.authPlaceholder}
|
||||||
|
autoFocus
|
||||||
onInput={(e) => props.onChangeLogin({ ...props.loginValues, email: (e.currentTarget as HTMLInputElement).value })}
|
onInput={(e) => props.onChangeLogin({ ...props.loginValues, email: (e.currentTarget as HTMLInputElement).value })}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -236,7 +241,6 @@ export default function AuthViews(props: AuthViewsProps) {
|
|||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
placeholder={props.authPlaceholder}
|
placeholder={props.authPlaceholder}
|
||||||
onInput={(v) => props.onChangeLogin({ ...props.loginValues, password: v })}
|
onInput={(v) => props.onChangeLogin({ ...props.loginValues, password: v })}
|
||||||
autoFocus
|
|
||||||
/>
|
/>
|
||||||
<div className="auth-support-row">
|
<div className="auth-support-row">
|
||||||
<span />
|
<span />
|
||||||
@@ -244,7 +248,7 @@ export default function AuthViews(props: AuthViewsProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
className="auth-link-btn"
|
className="auth-link-btn"
|
||||||
onClick={props.onTogglePasswordHint}
|
onClick={props.onTogglePasswordHint}
|
||||||
disabled={loginBusy || !props.loginValues.email.trim()}
|
disabled={loginBusy || props.loginHintLoading || !props.loginValues.email.trim()}
|
||||||
>
|
>
|
||||||
{props.loginHintLoading
|
{props.loginHintLoading
|
||||||
? t('txt_loading_password_hint')
|
? t('txt_loading_password_hint')
|
||||||
|
|||||||
@@ -0,0 +1,529 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
|
import { Check, ChevronDown, ChevronUp, ExternalLink, Pencil, Plus, RefreshCw, Save, Trash2, X } from 'lucide-preact';
|
||||||
|
import LoadingState from '@/components/LoadingState';
|
||||||
|
import { t } from '@/lib/i18n';
|
||||||
|
import type { CustomEquivalentDomain, DomainRules } from '@/lib/types';
|
||||||
|
import { normalizeEquivalentDomain } from '@shared/domain-normalize';
|
||||||
|
|
||||||
|
const CUSTOM_GLOBAL_DOMAINS_PR_URL = 'https://github.com/shuaiplus/nodewarden/edit/main/src/static/global_domains.custom.json';
|
||||||
|
|
||||||
|
interface DomainRulesPageProps {
|
||||||
|
rules: DomainRules | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string;
|
||||||
|
onRefresh: () => void;
|
||||||
|
onSave: (customEquivalentDomains: CustomEquivalentDomain[], excludedGlobalEquivalentDomains: number[]) => Promise<void>;
|
||||||
|
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DomainRuleSummaryProps {
|
||||||
|
text: string;
|
||||||
|
expanded: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDomain(value: string): string {
|
||||||
|
return normalizeEquivalentDomain(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDomainList(domains: string[]): string[] {
|
||||||
|
return Array.from(new Set(domains.map(normalizeDomain).filter(Boolean)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidDomainName(value: string): boolean {
|
||||||
|
return !!normalizeEquivalentDomain(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInvalidDomainIndexes(domains: string[]): Set<number> {
|
||||||
|
const invalid = new Set<number>();
|
||||||
|
domains.forEach((domain, index) => {
|
||||||
|
if (!isValidDomainName(domain)) invalid.add(index);
|
||||||
|
});
|
||||||
|
return invalid;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDraftId(): string {
|
||||||
|
return `custom-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEmptyDomains(): string[] {
|
||||||
|
return ['', ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
function DomainRuleSummary(props: DomainRuleSummaryProps) {
|
||||||
|
const textRef = useRef<HTMLSpanElement>(null);
|
||||||
|
const [canExpand, setCanExpand] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const node = textRef.current;
|
||||||
|
if (!node) return undefined;
|
||||||
|
|
||||||
|
const measure = () => {
|
||||||
|
const width = node.getBoundingClientRect().width;
|
||||||
|
if (!width || typeof document === 'undefined') {
|
||||||
|
setCanExpand(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const probe = document.createElement('span');
|
||||||
|
const styles = window.getComputedStyle(node);
|
||||||
|
probe.textContent = props.text;
|
||||||
|
probe.style.position = 'absolute';
|
||||||
|
probe.style.visibility = 'hidden';
|
||||||
|
probe.style.whiteSpace = 'nowrap';
|
||||||
|
probe.style.font = styles.font;
|
||||||
|
probe.style.letterSpacing = styles.letterSpacing;
|
||||||
|
probe.style.left = '-9999px';
|
||||||
|
probe.style.top = '-9999px';
|
||||||
|
document.body.appendChild(probe);
|
||||||
|
const fullWidth = probe.getBoundingClientRect().width;
|
||||||
|
probe.remove();
|
||||||
|
setCanExpand(fullWidth > width + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
measure();
|
||||||
|
if (typeof ResizeObserver === 'undefined') {
|
||||||
|
window.addEventListener('resize', measure);
|
||||||
|
return () => window.removeEventListener('resize', measure);
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(measure);
|
||||||
|
observer.observe(node);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [props.text]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
ref={textRef}
|
||||||
|
className={`domain-rule-domains${props.expanded ? ' domain-rule-domains-expanded' : ''}`}
|
||||||
|
>
|
||||||
|
{props.text}
|
||||||
|
</span>
|
||||||
|
{canExpand && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="domain-rule-expand-btn"
|
||||||
|
title={props.expanded ? t('txt_collapse') : t('txt_expand')}
|
||||||
|
aria-label={props.expanded ? t('txt_collapse') : t('txt_expand')}
|
||||||
|
onClick={props.onToggle}
|
||||||
|
>
|
||||||
|
{props.expanded ? <ChevronUp size={15} /> : <ChevronDown size={15} />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toEditableCustomRules(rules: DomainRules | null): CustomEquivalentDomain[] {
|
||||||
|
const source = rules?.customEquivalentDomains?.length
|
||||||
|
? rules.customEquivalentDomains
|
||||||
|
: (rules?.equivalentDomains || []).map((domains, index) => ({
|
||||||
|
id: `custom-${index}`,
|
||||||
|
domains,
|
||||||
|
excluded: false,
|
||||||
|
}));
|
||||||
|
return source.map((rule, index) => ({
|
||||||
|
id: String(rule.id || `custom-${index}`),
|
||||||
|
domains: rule.domains.length >= 2 ? [...rule.domains] : createEmptyDomains(),
|
||||||
|
excluded: !!rule.excluded,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DomainRulesPage(props: DomainRulesPageProps) {
|
||||||
|
const [customRules, setCustomRules] = useState<CustomEquivalentDomain[]>([]);
|
||||||
|
const [newRuleDomains, setNewRuleDomains] = useState<string[] | null>(null);
|
||||||
|
const [editingRuleId, setEditingRuleId] = useState<string | null>(null);
|
||||||
|
const [editingDomains, setEditingDomains] = useState<string[]>(createEmptyDomains);
|
||||||
|
const [newRuleInvalidIndexes, setNewRuleInvalidIndexes] = useState<Set<number>>(new Set());
|
||||||
|
const [editingInvalidIndexes, setEditingInvalidIndexes] = useState<Set<number>>(new Set());
|
||||||
|
const [excludedTypes, setExcludedTypes] = useState<Set<number>>(new Set());
|
||||||
|
const [expandedCustomRules, setExpandedCustomRules] = useState<Set<string>>(new Set());
|
||||||
|
const [expandedGlobalRules, setExpandedGlobalRules] = useState<Set<number>>(new Set());
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [filter, setFilter] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCustomRules(toEditableCustomRules(props.rules));
|
||||||
|
setNewRuleDomains(null);
|
||||||
|
setEditingRuleId(null);
|
||||||
|
setEditingDomains(createEmptyDomains());
|
||||||
|
setNewRuleInvalidIndexes(new Set());
|
||||||
|
setEditingInvalidIndexes(new Set());
|
||||||
|
setExpandedCustomRules(new Set());
|
||||||
|
setExpandedGlobalRules(new Set());
|
||||||
|
setExcludedTypes(new Set((props.rules?.globalEquivalentDomains || []).filter((entry) => entry.excluded).map((entry) => entry.type)));
|
||||||
|
}, [props.rules]);
|
||||||
|
|
||||||
|
const sortedGlobals = useMemo(() => {
|
||||||
|
return [...(props.rules?.globalEquivalentDomains || [])].sort((a, b) => {
|
||||||
|
const aKey = a.domains[0] || '';
|
||||||
|
const bKey = b.domains[0] || '';
|
||||||
|
return aKey.localeCompare(bKey, undefined, { sensitivity: 'base' });
|
||||||
|
});
|
||||||
|
}, [props.rules]);
|
||||||
|
|
||||||
|
const filteredGlobals = useMemo(() => {
|
||||||
|
const needle = filter.trim().toLowerCase();
|
||||||
|
if (!needle) return sortedGlobals;
|
||||||
|
return sortedGlobals.filter((entry) => entry.domains.some((domain) => domain.includes(needle)));
|
||||||
|
}, [filter, sortedGlobals]);
|
||||||
|
|
||||||
|
function setCustomRuleEnabled(index: number, enabled: boolean): void {
|
||||||
|
setCustomRules((rules) => rules.map((rule, ruleIndex) => ruleIndex === index ? { ...rule, excluded: !enabled } : rule));
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginEditCustomRule(rule: CustomEquivalentDomain): void {
|
||||||
|
setNewRuleDomains(null);
|
||||||
|
setEditingRuleId(rule.id);
|
||||||
|
setEditingDomains(rule.domains.length >= 2 ? [...rule.domains] : createEmptyDomains());
|
||||||
|
setEditingInvalidIndexes(new Set());
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmEditCustomRule(): void {
|
||||||
|
if (!editingRuleId) return;
|
||||||
|
const invalidIndexes = getInvalidDomainIndexes(editingDomains);
|
||||||
|
setEditingInvalidIndexes(invalidIndexes);
|
||||||
|
if (invalidIndexes.size) {
|
||||||
|
props.onNotify('warning', t('txt_domain_rule_invalid_domains'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const domains = normalizeDomainList(editingDomains);
|
||||||
|
if (domains.length < 2) {
|
||||||
|
props.onNotify('warning', t('txt_domain_rule_needs_two_domains'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCustomRules((rules) => rules.map((rule) => rule.id === editingRuleId ? { ...rule, domains } : rule));
|
||||||
|
setEditingRuleId(null);
|
||||||
|
setEditingDomains(createEmptyDomains());
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEditCustomRule(): void {
|
||||||
|
setEditingRuleId(null);
|
||||||
|
setEditingDomains(createEmptyDomains());
|
||||||
|
setEditingInvalidIndexes(new Set());
|
||||||
|
}
|
||||||
|
|
||||||
|
function addNewRule(): void {
|
||||||
|
const invalidIndexes = getInvalidDomainIndexes(newRuleDomains || []);
|
||||||
|
setNewRuleInvalidIndexes(invalidIndexes);
|
||||||
|
if (invalidIndexes.size) {
|
||||||
|
props.onNotify('warning', t('txt_domain_rule_invalid_domains'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const domains = normalizeDomainList(newRuleDomains || []);
|
||||||
|
if (domains.length < 2) {
|
||||||
|
props.onNotify('warning', t('txt_domain_rule_needs_two_domains'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCustomRules((rules) => [
|
||||||
|
{
|
||||||
|
id: createDraftId(),
|
||||||
|
domains,
|
||||||
|
excluded: false,
|
||||||
|
},
|
||||||
|
...rules,
|
||||||
|
]);
|
||||||
|
setNewRuleDomains(null);
|
||||||
|
setNewRuleInvalidIndexes(new Set());
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCustomRule(index: number): void {
|
||||||
|
setCustomRules((rules) => rules.filter((_, currentIndex) => currentIndex !== index));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleGlobal(type: number): void {
|
||||||
|
setExcludedTypes((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (next.has(type)) {
|
||||||
|
next.delete(type);
|
||||||
|
} else {
|
||||||
|
next.add(type);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleExpandedCustomRule(id: string): void {
|
||||||
|
setExpandedCustomRules((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleExpandedGlobalRule(type: number): void {
|
||||||
|
setExpandedGlobalRules((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (next.has(type)) next.delete(type);
|
||||||
|
else next.add(type);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(): Promise<void> {
|
||||||
|
const normalizedCustomRules = customRules.map((rule) => ({
|
||||||
|
...rule,
|
||||||
|
domains: normalizeDomainList(rule.domains),
|
||||||
|
}));
|
||||||
|
if (normalizedCustomRules.some((rule) => rule.domains.some((domain) => !isValidDomainName(domain)))) {
|
||||||
|
props.onNotify('warning', t('txt_domain_rule_invalid_domains'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (normalizedCustomRules.some((rule) => rule.domains.length < 2)) {
|
||||||
|
props.onNotify('warning', t('txt_domain_rule_needs_two_domains'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const excludedGlobalEquivalentDomains = (props.rules?.globalEquivalentDomains || [])
|
||||||
|
.filter((entry) => excludedTypes.has(entry.type))
|
||||||
|
.map((entry) => entry.type);
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await props.onSave(normalizedCustomRules, excludedGlobalEquivalentDomains);
|
||||||
|
props.onNotify('success', t('txt_domain_rules_saved'));
|
||||||
|
} catch (error) {
|
||||||
|
props.onNotify('error', error instanceof Error ? error.message : t('txt_domain_rules_save_failed'));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDomainInputs(domains: string[], invalidIndexes: Set<number>, onChange: (index: number, value: string) => void, onAdd: () => void, onRemove?: (index: number) => void) {
|
||||||
|
return (
|
||||||
|
<div className="domain-rule-inputs">
|
||||||
|
{domains.map((domain, index) => (
|
||||||
|
<div key={index} className="domain-rule-input-piece">
|
||||||
|
<input
|
||||||
|
className={`input domain-rule-inline-input${invalidIndexes.has(index) ? ' domain-rule-input-invalid' : ''}`}
|
||||||
|
value={domain}
|
||||||
|
placeholder="example.com"
|
||||||
|
aria-invalid={invalidIndexes.has(index)}
|
||||||
|
onInput={(event) => onChange(index, (event.currentTarget as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
{domains.length > 2 && onRemove && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="domain-rule-input-remove"
|
||||||
|
title={t('txt_remove_domain')}
|
||||||
|
aria-label={t('txt_remove_domain')}
|
||||||
|
onClick={() => onRemove(index)}
|
||||||
|
>
|
||||||
|
<X size={13} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{index < domains.length - 1 && <span className="domain-rule-operator">,</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary small domain-rule-mini-btn"
|
||||||
|
title={t('txt_add_domain')}
|
||||||
|
aria-label={t('txt_add_domain')}
|
||||||
|
onClick={onAdd}
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.loading && !props.rules) {
|
||||||
|
return <LoadingState card lines={6} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="domain-rules-page">
|
||||||
|
<div className="domain-rules-toolbar">
|
||||||
|
<div className="domain-rules-toolbar-copy">
|
||||||
|
<div className="domain-rules-toolbar-title">{t('nav_domain_rules')}</div>
|
||||||
|
<p>{t('txt_domain_rules_description')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="actions">
|
||||||
|
<button type="button" className="btn btn-primary" disabled={saving} onClick={() => void save()}>
|
||||||
|
<Save size={14} className="btn-icon" />
|
||||||
|
{saving ? t('txt_saving') : t('txt_save')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary" disabled={props.loading} onClick={props.onRefresh}>
|
||||||
|
<RefreshCw size={14} className="btn-icon" />
|
||||||
|
{t('txt_sync')}
|
||||||
|
</button>
|
||||||
|
<a className="btn btn-secondary" href={CUSTOM_GLOBAL_DOMAINS_PR_URL} target="_blank" rel="noreferrer">
|
||||||
|
<ExternalLink size={14} className="btn-icon" />
|
||||||
|
{t('txt_submit_pr')}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-modules-grid domain-rules-grid">
|
||||||
|
<section className="card settings-module domain-rules-custom">
|
||||||
|
<div className="section-heading-row">
|
||||||
|
<h3>{t('txt_custom_equivalent_domains')}</h3>
|
||||||
|
<button type="button" className="btn btn-secondary small" onClick={() => {
|
||||||
|
setEditingRuleId(null);
|
||||||
|
setEditingInvalidIndexes(new Set());
|
||||||
|
setNewRuleDomains((current) => current || createEmptyDomains());
|
||||||
|
setNewRuleInvalidIndexes(new Set());
|
||||||
|
}}>
|
||||||
|
<Plus size={14} className="btn-icon" />
|
||||||
|
{t('txt_add')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{props.error && <div className="status-error">{props.error}</div>}
|
||||||
|
|
||||||
|
{newRuleDomains && (
|
||||||
|
<div className="domain-rule-row domain-rule-editing-row domain-rule-new-row">
|
||||||
|
<div className="domain-rule-main">
|
||||||
|
{renderDomainInputs(
|
||||||
|
newRuleDomains,
|
||||||
|
newRuleInvalidIndexes,
|
||||||
|
(index, value) => {
|
||||||
|
setNewRuleDomains((domains) => (domains || createEmptyDomains()).map((domain, currentIndex) => currentIndex === index ? value : domain));
|
||||||
|
setNewRuleInvalidIndexes((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
next.delete(index);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
setNewRuleDomains((domains) => [...(domains || createEmptyDomains()), '']);
|
||||||
|
setNewRuleInvalidIndexes(new Set());
|
||||||
|
},
|
||||||
|
(index) => setNewRuleDomains((domains) => {
|
||||||
|
const current = domains || createEmptyDomains();
|
||||||
|
setNewRuleInvalidIndexes(new Set());
|
||||||
|
return current.length > 2 ? current.filter((_, currentIndex) => currentIndex !== index) : current;
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="domain-rule-row-actions">
|
||||||
|
<button type="button" className="btn btn-primary small" onClick={addNewRule}>
|
||||||
|
<Check size={14} className="btn-icon" />
|
||||||
|
{t('txt_confirm')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary small" onClick={() => {
|
||||||
|
setNewRuleDomains(null);
|
||||||
|
setNewRuleInvalidIndexes(new Set());
|
||||||
|
}}>
|
||||||
|
<X size={14} className="btn-icon" />
|
||||||
|
{t('txt_cancel')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="domain-rules-table">
|
||||||
|
{customRules.map((rule, ruleIndex) => (
|
||||||
|
editingRuleId === rule.id ? (
|
||||||
|
<div key={rule.id} className="domain-rule-row domain-rule-editing-row">
|
||||||
|
<div className="domain-rule-main">
|
||||||
|
{renderDomainInputs(
|
||||||
|
editingDomains,
|
||||||
|
editingInvalidIndexes,
|
||||||
|
(domainIndex, value) => {
|
||||||
|
setEditingDomains((domains) => domains.map((domain, currentIndex) => currentIndex === domainIndex ? value : domain));
|
||||||
|
setEditingInvalidIndexes((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
next.delete(domainIndex);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
setEditingDomains((domains) => [...domains, '']);
|
||||||
|
setEditingInvalidIndexes(new Set());
|
||||||
|
},
|
||||||
|
(domainIndex) => {
|
||||||
|
setEditingInvalidIndexes(new Set());
|
||||||
|
setEditingDomains((domains) => domains.length > 2 ? domains.filter((_, currentIndex) => currentIndex !== domainIndex) : domains);
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="domain-rule-row-actions">
|
||||||
|
<button type="button" className="btn btn-primary small" onClick={confirmEditCustomRule}>
|
||||||
|
<Check size={14} className="btn-icon" />
|
||||||
|
{t('txt_confirm')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary small" onClick={cancelEditCustomRule}>
|
||||||
|
<X size={14} className="btn-icon" />
|
||||||
|
{t('txt_cancel')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div key={rule.id} className={`domain-rule-row${expandedCustomRules.has(rule.id) ? ' domain-rule-row-expanded' : ''}`}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!rule.excluded}
|
||||||
|
aria-label={t('txt_enabled')}
|
||||||
|
onChange={(event) => setCustomRuleEnabled(ruleIndex, (event.currentTarget as HTMLInputElement).checked)}
|
||||||
|
/>
|
||||||
|
<DomainRuleSummary
|
||||||
|
text={rule.domains.join(', ')}
|
||||||
|
expanded={expandedCustomRules.has(rule.id)}
|
||||||
|
onToggle={() => toggleExpandedCustomRule(rule.id)}
|
||||||
|
/>
|
||||||
|
<div className="domain-rule-row-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary small domain-rule-icon-btn"
|
||||||
|
title={t('txt_edit')}
|
||||||
|
aria-label={t('txt_edit')}
|
||||||
|
onClick={() => beginEditCustomRule(rule)}
|
||||||
|
>
|
||||||
|
<Pencil size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary small domain-rule-icon-btn"
|
||||||
|
title={t('txt_delete')}
|
||||||
|
aria-label={t('txt_delete')}
|
||||||
|
onClick={() => removeCustomRule(ruleIndex)}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
{!customRules.length && !newRuleDomains && <div className="empty empty-comfortable">{t('txt_no_custom_domain_rules')}</div>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card settings-module domain-rules-global">
|
||||||
|
<div className="section-heading-row">
|
||||||
|
<h3>{t('txt_global_equivalent_domains')}</h3>
|
||||||
|
<div className="domain-rules-heading-actions">
|
||||||
|
<input
|
||||||
|
className="input domain-rules-filter"
|
||||||
|
value={filter}
|
||||||
|
placeholder={t('txt_search_domains')}
|
||||||
|
onInput={(event) => setFilter((event.currentTarget as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="domain-rules-table">
|
||||||
|
{filteredGlobals.map((entry) => (
|
||||||
|
<div key={entry.type} className={`domain-rule-row domain-rule-readonly-row${expandedGlobalRules.has(entry.type) ? ' domain-rule-row-expanded' : ''}`}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!excludedTypes.has(entry.type)}
|
||||||
|
onChange={() => toggleGlobal(entry.type)}
|
||||||
|
/>
|
||||||
|
<DomainRuleSummary
|
||||||
|
text={entry.domains.join(', ')}
|
||||||
|
expanded={expandedGlobalRules.has(entry.type)}
|
||||||
|
onToggle={() => toggleExpandedGlobalRule(entry.type)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!filteredGlobals.length && <div className="empty empty-comfortable">{t('txt_no_domain_rules_found')}</div>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -554,7 +554,7 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
<button type="button" className="btn btn-secondary small" onClick={() => copyAccessUrl(selectedSend)}>
|
<button type="button" className="btn btn-secondary small" onClick={() => copyAccessUrl(selectedSend)}>
|
||||||
<Copy size={14} className="btn-icon" /> {t('txt_copy_link')}
|
<Copy size={14} className="btn-icon" /> {t('txt_copy_link')}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="btn btn-secondary small" onClick={() => { setDraft(draftFromSend(selectedSend)); setIsCreating(false); setIsEditing(true); }}>
|
<button type="button" className="btn btn-secondary small" onClick={() => { setDraft(draftFromSend(selectedSend)); setIsCreating(false); setIsEditing(true); setShowPassword(false); }}>
|
||||||
<Pencil size={14} className="btn-icon" /> {t('txt_edit')}
|
<Pencil size={14} className="btn-icon" /> {t('txt_edit')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -338,8 +338,7 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="card settings-module">
|
<section className="settings-module sensitive-actions-module">
|
||||||
<h3>{t('txt_recovery_code_and_api_key')}</h3>
|
|
||||||
<div className="sensitive-actions-grid">
|
<div className="sensitive-actions-grid">
|
||||||
<div className="sensitive-action">
|
<div className="sensitive-action">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,22 +1,5 @@
|
|||||||
import type { JSX } from 'preact';
|
|
||||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
import { Clipboard, Globe, GripVertical } from 'lucide-preact';
|
import { Clipboard, Globe } from 'lucide-preact';
|
||||||
import {
|
|
||||||
closestCenter,
|
|
||||||
DndContext,
|
|
||||||
type DragEndEvent,
|
|
||||||
PointerSensor,
|
|
||||||
TouchSensor,
|
|
||||||
useSensor,
|
|
||||||
useSensors,
|
|
||||||
} from '@dnd-kit/core';
|
|
||||||
import {
|
|
||||||
arrayMove,
|
|
||||||
rectSortingStrategy,
|
|
||||||
SortableContext,
|
|
||||||
useSortable,
|
|
||||||
} from '@dnd-kit/sortable';
|
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
|
||||||
import { copyTextToClipboard as copyTextWithFeedback } from '@/lib/clipboard';
|
import { copyTextToClipboard as copyTextWithFeedback } from '@/lib/clipboard';
|
||||||
import { calcTotpNow } from '@/lib/crypto';
|
import { calcTotpNow } from '@/lib/crypto';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
@@ -34,7 +17,6 @@ interface TotpCodesPageProps {
|
|||||||
const TOTP_PERIOD_SECONDS = 30;
|
const TOTP_PERIOD_SECONDS = 30;
|
||||||
const TOTP_RING_RADIUS = 14;
|
const TOTP_RING_RADIUS = 14;
|
||||||
const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
|
const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
|
||||||
const TOTP_ORDER_STORAGE_KEY = 'nodewarden.totp-order';
|
|
||||||
const TOTP_REFRESH_BATCH_SIZE = 16;
|
const TOTP_REFRESH_BATCH_SIZE = 16;
|
||||||
function getTotpTimeState(): { windowId: number; remain: number } {
|
function getTotpTimeState(): { windowId: number; remain: number } {
|
||||||
const epoch = Math.floor(Date.now() / 1000);
|
const epoch = Math.floor(Date.now() / 1000);
|
||||||
@@ -55,39 +37,18 @@ function TotpListIcon({ cipher }: { cipher: Cipher }) {
|
|||||||
return <WebsiteIcon cipher={cipher} fallback={<Globe size={18} />} />;
|
return <WebsiteIcon cipher={cipher} fallback={<Globe size={18} />} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SortableTotpRowProps {
|
interface TotpRowProps {
|
||||||
cipher: Cipher;
|
cipher: Cipher;
|
||||||
live: { code: string; remain: number } | null;
|
live: { code: string; remain: number } | null;
|
||||||
onCopy: (value: string) => void;
|
onCopy: (value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SortableTotpRow(props: SortableTotpRowProps) {
|
function TotpRow(props: TotpRowProps) {
|
||||||
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
|
|
||||||
id: props.cipher.id,
|
|
||||||
});
|
|
||||||
const dragButtonAttributes = attributes as JSX.HTMLAttributes<HTMLButtonElement>;
|
|
||||||
|
|
||||||
const style = {
|
|
||||||
transform: CSS.Transform.toString(transform),
|
|
||||||
transition,
|
|
||||||
};
|
|
||||||
|
|
||||||
const name = props.cipher.decName || props.cipher.name || t('txt_no_name');
|
const name = props.cipher.decName || props.cipher.name || t('txt_no_name');
|
||||||
const username = props.cipher.login?.decUsername || '';
|
const username = props.cipher.login?.decUsername || '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={setNodeRef} style={style} className={`totp-code-row${isDragging ? ' is-dragging' : ''}`}>
|
<div className="totp-code-row">
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
ref={setActivatorNodeRef}
|
|
||||||
className="btn btn-secondary small totp-drag-btn"
|
|
||||||
title={t('txt_drag_to_reorder')}
|
|
||||||
aria-label={t('txt_drag_to_reorder')}
|
|
||||||
{...dragButtonAttributes}
|
|
||||||
{...listeners}
|
|
||||||
>
|
|
||||||
<GripVertical size={14} className="btn-icon" />
|
|
||||||
</button>
|
|
||||||
<div className="totp-code-info">
|
<div className="totp-code-info">
|
||||||
<div className="list-icon-wrap">
|
<div className="list-icon-wrap">
|
||||||
<TotpListIcon cipher={props.cipher} />
|
<TotpListIcon cipher={props.cipher} />
|
||||||
@@ -135,30 +96,7 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
|||||||
const [totpCodes, setTotpCodes] = useState<Record<string, string | null>>({});
|
const [totpCodes, setTotpCodes] = useState<Record<string, string | null>>({});
|
||||||
const [remainingSeconds, setRemainingSeconds] = useState(() => getTotpTimeState().remain);
|
const [remainingSeconds, setRemainingSeconds] = useState(() => getTotpTimeState().remain);
|
||||||
const [columnCount, setColumnCount] = useState(1);
|
const [columnCount, setColumnCount] = useState(1);
|
||||||
const [orderedIds, setOrderedIds] = useState<string[]>(() => {
|
|
||||||
if (typeof window === 'undefined') return [];
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(String(window.localStorage.getItem(TOTP_ORDER_STORAGE_KEY) || '[]'));
|
|
||||||
return Array.isArray(parsed) ? parsed.map((id) => String(id || '').trim()).filter(Boolean) : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const listRef = useRef<HTMLDivElement | null>(null);
|
const listRef = useRef<HTMLDivElement | null>(null);
|
||||||
const hasLoadedTotpItemsRef = useRef(false);
|
|
||||||
const sensors = useSensors(
|
|
||||||
useSensor(PointerSensor, {
|
|
||||||
activationConstraint: {
|
|
||||||
distance: 6,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
useSensor(TouchSensor, {
|
|
||||||
activationConstraint: {
|
|
||||||
delay: 120,
|
|
||||||
tolerance: 8,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
async function copyToClipboard(value: string): Promise<void> {
|
async function copyToClipboard(value: string): Promise<void> {
|
||||||
await copyTextWithFeedback(value, { successMessage: t('txt_code_copied') });
|
await copyTextWithFeedback(value, { successMessage: t('txt_code_copied') });
|
||||||
@@ -169,7 +107,7 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const baseTotpItems = useMemo(
|
const totpItems = useMemo(
|
||||||
() =>
|
() =>
|
||||||
props.ciphers
|
props.ciphers
|
||||||
.filter((cipher) => isCipherVisibleInNormalVault(cipher) && !!cipher.login?.decTotp)
|
.filter((cipher) => isCipherVisibleInNormalVault(cipher) && !!cipher.login?.decTotp)
|
||||||
@@ -181,46 +119,6 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
|||||||
[props.ciphers, nameCollator]
|
[props.ciphers, nameCollator]
|
||||||
);
|
);
|
||||||
|
|
||||||
const totpItems = useMemo(() => {
|
|
||||||
if (!baseTotpItems.length) return [];
|
|
||||||
const orderMap = new Map(orderedIds.map((id, index) => [id, index]));
|
|
||||||
return [...baseTotpItems].sort((a, b) => {
|
|
||||||
const orderA = orderMap.get(a.id);
|
|
||||||
const orderB = orderMap.get(b.id);
|
|
||||||
if (orderA != null && orderB != null) return orderA - orderB;
|
|
||||||
if (orderA != null) return -1;
|
|
||||||
if (orderB != null) return 1;
|
|
||||||
const nameA = (a.decName || a.name || '').trim();
|
|
||||||
const nameB = (b.decName || b.name || '').trim();
|
|
||||||
return nameCollator.compare(nameA, nameB);
|
|
||||||
});
|
|
||||||
}, [baseTotpItems, orderedIds, nameCollator]);
|
|
||||||
|
|
||||||
const sortableTotpItems = useMemo(() => totpItems.map((cipher) => cipher.id), [totpItems]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!baseTotpItems.length) return;
|
|
||||||
hasLoadedTotpItemsRef.current = true;
|
|
||||||
const validIds = new Set(baseTotpItems.map((cipher) => cipher.id));
|
|
||||||
setOrderedIds((prev) => {
|
|
||||||
const filtered = prev.filter((id) => validIds.has(id));
|
|
||||||
const missing = baseTotpItems.map((cipher) => cipher.id).filter((id) => !filtered.includes(id));
|
|
||||||
const next = [...filtered, ...missing];
|
|
||||||
if (next.length === prev.length && next.every((id, index) => id === prev[index])) return prev;
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, [baseTotpItems]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
if (!hasLoadedTotpItemsRef.current) return;
|
|
||||||
try {
|
|
||||||
window.localStorage.setItem(TOTP_ORDER_STORAGE_KEY, JSON.stringify(orderedIds));
|
|
||||||
} catch {
|
|
||||||
// ignore storage write failures
|
|
||||||
}
|
|
||||||
}, [orderedIds]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!totpItems.length) {
|
if (!totpItems.length) {
|
||||||
setTotpCodes({});
|
setTotpCodes({});
|
||||||
@@ -307,16 +205,6 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
|||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDragEnd = (event: DragEndEvent) => {
|
|
||||||
const activeId = String(event.active.id);
|
|
||||||
const overId = event.over ? String(event.over.id) : null;
|
|
||||||
if (!overId || activeId === overId) return;
|
|
||||||
const fromIndex = orderedIds.indexOf(activeId);
|
|
||||||
const toIndex = orderedIds.indexOf(overId);
|
|
||||||
if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) return;
|
|
||||||
setOrderedIds((prev) => arrayMove(prev, fromIndex, toIndex));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="totp-codes-page">
|
<div className="totp-codes-page">
|
||||||
<div className="card">
|
<div className="card">
|
||||||
@@ -330,18 +218,14 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
|||||||
>
|
>
|
||||||
{!totpItems.length && props.loading && <LoadingState lines={6} />}
|
{!totpItems.length && props.loading && <LoadingState lines={6} />}
|
||||||
{!totpItems.length && !props.loading && <div className="empty">{t('txt_no_verification_codes')}</div>}
|
{!totpItems.length && !props.loading && <div className="empty">{t('txt_no_verification_codes')}</div>}
|
||||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
|
||||||
<SortableContext items={sortableTotpItems} strategy={rectSortingStrategy}>
|
|
||||||
{totpItems.map((cipher) => (
|
{totpItems.map((cipher) => (
|
||||||
<SortableTotpRow
|
<TotpRow
|
||||||
key={cipher.id}
|
key={cipher.id}
|
||||||
cipher={cipher}
|
cipher={cipher}
|
||||||
live={totpCodes[cipher.id] ? { code: totpCodes[cipher.id] || '', remain: remainingSeconds } : null}
|
live={totpCodes[cipher.id] ? { code: totpCodes[cipher.id] || '', remain: remainingSeconds } : null}
|
||||||
onCopy={(value) => void copyToClipboard(value)}
|
onCopy={(value) => void copyToClipboard(value)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</SortableContext>
|
|
||||||
</DndContext>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
MOBILE_LAYOUT_QUERY,
|
MOBILE_LAYOUT_QUERY,
|
||||||
VAULT_LIST_OVERSCAN,
|
VAULT_LIST_OVERSCAN,
|
||||||
VAULT_LIST_ROW_HEIGHT,
|
VAULT_LIST_ROW_HEIGHT,
|
||||||
|
cardListSubtitle,
|
||||||
FOLDER_SORT_STORAGE_KEY,
|
FOLDER_SORT_STORAGE_KEY,
|
||||||
VAULT_SORT_STORAGE_KEY,
|
VAULT_SORT_STORAGE_KEY,
|
||||||
cipherTypeKey,
|
cipherTypeKey,
|
||||||
@@ -263,6 +264,8 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
setRepromptApprovedCipherId(null);
|
setRepromptApprovedCipherId(null);
|
||||||
setRepromptPassword('');
|
setRepromptPassword('');
|
||||||
setRepromptOpen(false);
|
setRepromptOpen(false);
|
||||||
|
setShowPassword(false);
|
||||||
|
setHiddenFieldVisibleMap({});
|
||||||
}, [selectedCipherId]);
|
}, [selectedCipherId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -499,6 +502,9 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
|||||||
if (Number(cipher.type || 1) === 1) {
|
if (Number(cipher.type || 1) === 1) {
|
||||||
return cipher.login?.decUsername || cipherMetaById.get(cipher.id)?.firstUri || '';
|
return cipher.login?.decUsername || cipherMetaById.get(cipher.id)?.firstUri || '';
|
||||||
}
|
}
|
||||||
|
if (Number(cipher.type || 1) === 3) {
|
||||||
|
return cardListSubtitle(cipher);
|
||||||
|
}
|
||||||
return cipherTypeLabel(Number(cipher.type || 1));
|
return cipherTypeLabel(Number(cipher.type || 1));
|
||||||
}, [cipherMetaById]);
|
}, [cipherMetaById]);
|
||||||
|
|
||||||
@@ -516,6 +522,7 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
|||||||
setCreateMenuOpen(false);
|
setCreateMenuOpen(false);
|
||||||
setSelectedCipherId('');
|
setSelectedCipherId('');
|
||||||
setShowPassword(false);
|
setShowPassword(false);
|
||||||
|
setHiddenFieldVisibleMap({});
|
||||||
setLocalError('');
|
setLocalError('');
|
||||||
setAttachmentQueue([]);
|
setAttachmentQueue([]);
|
||||||
setRemovedAttachmentIds({});
|
setRemovedAttachmentIds({});
|
||||||
@@ -530,6 +537,7 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
|||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
setShowPassword(false);
|
setShowPassword(false);
|
||||||
|
setHiddenFieldVisibleMap({});
|
||||||
setLocalError('');
|
setLocalError('');
|
||||||
setAttachmentQueue([]);
|
setAttachmentQueue([]);
|
||||||
setRemovedAttachmentIds({});
|
setRemovedAttachmentIds({});
|
||||||
@@ -542,6 +550,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
|||||||
setDraft(null);
|
setDraft(null);
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
|
setShowPassword(false);
|
||||||
|
setHiddenFieldVisibleMap({});
|
||||||
setLocalError('');
|
setLocalError('');
|
||||||
setAttachmentQueue([]);
|
setAttachmentQueue([]);
|
||||||
setRemovedAttachmentIds({});
|
setRemovedAttachmentIds({});
|
||||||
@@ -971,6 +981,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
|||||||
}
|
}
|
||||||
setSelectedCipherId(cipherId);
|
setSelectedCipherId(cipherId);
|
||||||
setRepromptApprovedCipherId(null);
|
setRepromptApprovedCipherId(null);
|
||||||
|
setShowPassword(false);
|
||||||
|
setHiddenFieldVisibleMap({});
|
||||||
if (isMobileLayout) setMobilePanel('detail');
|
if (isMobileLayout) setMobilePanel('detail');
|
||||||
setMobileSidebarOpen(false);
|
setMobileSidebarOpen(false);
|
||||||
}, [isEditing, isCreating, cancelEdit, isMobileLayout]);
|
}, [isEditing, isCreating, cancelEdit, isMobileLayout]);
|
||||||
|
|||||||
@@ -41,11 +41,16 @@ export function BackupOperationsSidebar(props: BackupOperationsSidebarProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="backup-divider" />
|
<details className="backup-recommendations-disclosure">
|
||||||
|
<summary className="backup-recommendations-summary">
|
||||||
|
<span>
|
||||||
|
<strong>{t('txt_backup_recommend_title')}</strong>
|
||||||
|
<small>{t('txt_backup_recommend_group_webdav')} · {t('txt_backup_recommend_group_s3')}</small>
|
||||||
|
</span>
|
||||||
|
<span className="backup-recommendations-summary-icon" aria-hidden="true" />
|
||||||
|
</summary>
|
||||||
|
|
||||||
<div className="section-head">
|
<div className="backup-recommendations-body">
|
||||||
<h3>{t('txt_backup_recommend_title')}</h3>
|
|
||||||
</div>
|
|
||||||
<div className="backup-recommendation-group">
|
<div className="backup-recommendation-group">
|
||||||
<h4 className="backup-recommendation-group-title">{t('txt_backup_recommend_group_webdav')}</h4>
|
<h4 className="backup-recommendation-group-title">{t('txt_backup_recommend_group_webdav')}</h4>
|
||||||
<div className="backup-recommendation-list">
|
<div className="backup-recommendation-list">
|
||||||
@@ -96,6 +101,8 @@ export function BackupOperationsSidebar(props: BackupOperationsSidebarProps) {
|
|||||||
<div className="backup-browser-empty">{t('txt_backup_recommend_empty')}</div>
|
<div className="backup-browser-empty">{t('txt_backup_recommend_empty')}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { createPortal } from 'preact/compat';
|
import { createPortal } from 'preact/compat';
|
||||||
import { useMemo, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||||
import { Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Folder, Paperclip, Pencil, RotateCcw, Trash2, X } from 'lucide-preact';
|
import { Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Folder, Paperclip, Pencil, RotateCcw, Trash2, X } from 'lucide-preact';
|
||||||
import { useDialogLifecycle } from '@/components/ConfirmDialog';
|
import { useDialogLifecycle } from '@/components/ConfirmDialog';
|
||||||
import type { Cipher } from '@/lib/types';
|
import type { Cipher } from '@/lib/types';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import {
|
import {
|
||||||
|
CardBrandIcon,
|
||||||
TOTP_PERIOD_SECONDS,
|
TOTP_PERIOD_SECONDS,
|
||||||
TOTP_RING_CIRCUMFERENCE,
|
TOTP_RING_CIRCUMFERENCE,
|
||||||
VaultListIcon,
|
VaultListIcon,
|
||||||
copyToClipboard,
|
copyToClipboard,
|
||||||
|
displayCardBrand,
|
||||||
formatAttachmentSize,
|
formatAttachmentSize,
|
||||||
formatHistoryTime,
|
formatHistoryTime,
|
||||||
formatTotp,
|
formatTotp,
|
||||||
@@ -92,6 +94,10 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
|||||||
.filter((entry) => entry.password.trim()),
|
.filter((entry) => entry.password.trim()),
|
||||||
[props.selectedCipher.passwordHistory]
|
[props.selectedCipher.passwordHistory]
|
||||||
);
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
setShowSshPrivateKey(false);
|
||||||
|
setPasswordHistoryOpen(false);
|
||||||
|
}, [props.selectedCipher.id]);
|
||||||
const formatDownloadLabel = (attachmentId: string) => {
|
const formatDownloadLabel = (attachmentId: string) => {
|
||||||
const downloadKey = `${props.selectedCipher.id}:${attachmentId}`;
|
const downloadKey = `${props.selectedCipher.id}:${attachmentId}`;
|
||||||
if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download');
|
if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download');
|
||||||
@@ -242,7 +248,13 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
|||||||
<h4>{t('txt_card_details')}</h4>
|
<h4>{t('txt_card_details')}</h4>
|
||||||
<div className="kv-line"><span>{t('txt_cardholder_name')}</span><strong>{props.selectedCipher.card.decCardholderName || ''}</strong></div>
|
<div className="kv-line"><span>{t('txt_cardholder_name')}</span><strong>{props.selectedCipher.card.decCardholderName || ''}</strong></div>
|
||||||
<div className="kv-line"><span>{t('txt_number')}</span><strong>{props.selectedCipher.card.decNumber || ''}</strong></div>
|
<div className="kv-line"><span>{t('txt_number')}</span><strong>{props.selectedCipher.card.decNumber || ''}</strong></div>
|
||||||
<div className="kv-line"><span>{t('txt_brand')}</span><strong>{props.selectedCipher.card.decBrand || ''}</strong></div>
|
<div className="kv-line">
|
||||||
|
<span>{t('txt_brand')}</span>
|
||||||
|
<strong className="card-brand-detail">
|
||||||
|
<CardBrandIcon brand={props.selectedCipher.card.decBrand} />
|
||||||
|
{displayCardBrand(props.selectedCipher.card.decBrand)}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
<div className="kv-line"><span>{t('txt_expiry')}</span><strong>{`${props.selectedCipher.card.decExpMonth || ''}/${props.selectedCipher.card.decExpYear || ''}`}</strong></div>
|
<div className="kv-line"><span>{t('txt_expiry')}</span><strong>{`${props.selectedCipher.card.decExpMonth || ''}/${props.selectedCipher.card.decExpYear || ''}`}</strong></div>
|
||||||
<div className="kv-line"><span>{t('txt_security_code')}</span><strong>{props.selectedCipher.card.decCode || ''}</strong></div>
|
<div className="kv-line"><span>{t('txt_security_code')}</span><strong>{props.selectedCipher.card.decCode || ''}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -357,7 +369,10 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
|||||||
<div className="custom-field-label" title={fieldName}>{fieldName}</div>
|
<div className="custom-field-label" title={fieldName}>{fieldName}</div>
|
||||||
<div className="custom-field-body">
|
<div className="custom-field-body">
|
||||||
<div className="custom-field-value">
|
<div className="custom-field-value">
|
||||||
<strong className="value-ellipsis" title={fieldType === 1 && !isHiddenVisible ? '' : rawValue}>
|
<strong
|
||||||
|
className={fieldType === 1 && !isHiddenVisible ? 'value-ellipsis' : 'custom-field-display'}
|
||||||
|
title={fieldType === 1 && !isHiddenVisible ? '' : rawValue}
|
||||||
|
>
|
||||||
{fieldType === 1 && !isHiddenVisible ? maskSecret(rawValue) : rawValue}
|
{fieldType === 1 && !isHiddenVisible ? maskSecret(rawValue) : rawValue}
|
||||||
</strong>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -99,7 +99,11 @@ export default function VaultDialogs(props: VaultDialogsProps) {
|
|||||||
) : (
|
) : (
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t('txt_field_value')}</span>
|
<span>{t('txt_field_value')}</span>
|
||||||
<input className="input" value={props.fieldValue} onInput={(e) => props.onFieldValueChange((e.currentTarget as HTMLInputElement).value)} />
|
<textarea
|
||||||
|
className="input textarea custom-field-textarea"
|
||||||
|
value={props.fieldValue}
|
||||||
|
onInput={(e) => props.onFieldValueChange((e.currentTarget as HTMLTextAreaElement).value)}
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
</ConfirmDialog>
|
</ConfirmDialog>
|
||||||
|
|||||||
@@ -1,34 +1,21 @@
|
|||||||
import type { JSX, RefObject } from 'preact';
|
import type { RefObject } from 'preact';
|
||||||
import { createPortal } from 'preact/compat';
|
import { createPortal } from 'preact/compat';
|
||||||
import { CheckCheck, Download, GripVertical, Paperclip, Plus, QrCode, RefreshCw, Star, StarOff, Trash2, Upload, X } from 'lucide-preact';
|
import { ArrowDown, ArrowUp, CheckCheck, Download, Paperclip, Plus, QrCode, RefreshCw, Star, StarOff, Trash2, Upload, X } from 'lucide-preact';
|
||||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
import { useDialogLifecycle } from '@/components/ConfirmDialog';
|
import { useDialogLifecycle } from '@/components/ConfirmDialog';
|
||||||
import {
|
|
||||||
closestCenter,
|
|
||||||
DndContext,
|
|
||||||
type DragEndEvent,
|
|
||||||
type DragStartEvent,
|
|
||||||
PointerSensor,
|
|
||||||
TouchSensor,
|
|
||||||
useSensor,
|
|
||||||
useSensors,
|
|
||||||
} from '@dnd-kit/core';
|
|
||||||
import {
|
|
||||||
SortableContext,
|
|
||||||
arrayMove,
|
|
||||||
useSortable,
|
|
||||||
verticalListSortingStrategy,
|
|
||||||
} from '@dnd-kit/sortable';
|
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
|
||||||
import type { Cipher, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
|
import type { Cipher, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
|
import { cardBrand } from '@/lib/import-format-shared';
|
||||||
import {
|
import {
|
||||||
|
CARD_BRAND_OPTIONS,
|
||||||
|
CardBrandIcon,
|
||||||
cipherTypeLabel,
|
cipherTypeLabel,
|
||||||
createEmptyLoginUri,
|
createEmptyLoginUri,
|
||||||
formatAttachmentSize,
|
formatAttachmentSize,
|
||||||
formatHistoryTime,
|
formatHistoryTime,
|
||||||
getCreateTypeOptions,
|
getCreateTypeOptions,
|
||||||
getWebsiteMatchOptions,
|
getWebsiteMatchOptions,
|
||||||
|
normalizeCardBrand,
|
||||||
toBooleanFieldValue,
|
toBooleanFieldValue,
|
||||||
} from '@/components/vault/vault-page-helpers';
|
} from '@/components/vault/vault-page-helpers';
|
||||||
|
|
||||||
@@ -67,46 +54,45 @@ interface VaultEditorProps {
|
|||||||
onDeleteSelected: () => void;
|
onDeleteSelected: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SortableWebsiteRowProps {
|
interface WebsiteRowProps {
|
||||||
id: string;
|
|
||||||
uriEntry: VaultDraft['loginUris'][number];
|
uriEntry: VaultDraft['loginUris'][number];
|
||||||
index: number;
|
index: number;
|
||||||
canRemove: boolean;
|
canRemove: boolean;
|
||||||
isDragging: boolean;
|
canMoveUp: boolean;
|
||||||
|
canMoveDown: boolean;
|
||||||
onUpdateUri: (index: number, value: string) => void;
|
onUpdateUri: (index: number, value: string) => void;
|
||||||
onUpdateMatch: (index: number, value: number | null) => void;
|
onUpdateMatch: (index: number, value: number | null) => void;
|
||||||
|
onMove: (fromIndex: number, toIndex: number) => void;
|
||||||
onRemove: (index: number) => void;
|
onRemove: (index: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SortableWebsiteRow(props: SortableWebsiteRowProps) {
|
function WebsiteRow(props: WebsiteRowProps) {
|
||||||
const websiteMatchOptions = getWebsiteMatchOptions();
|
const websiteMatchOptions = getWebsiteMatchOptions();
|
||||||
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
|
|
||||||
id: props.id,
|
|
||||||
});
|
|
||||||
const dragButtonAttributes = attributes as JSX.HTMLAttributes<HTMLButtonElement>;
|
|
||||||
|
|
||||||
const style = {
|
|
||||||
transform: CSS.Transform.toString(transform),
|
|
||||||
transition,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="website-row">
|
||||||
ref={setNodeRef}
|
<div className="website-order-actions">
|
||||||
style={style}
|
|
||||||
className={`website-row${isDragging || props.isDragging ? ' is-dragging' : ''}`}
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
ref={setActivatorNodeRef}
|
className="btn btn-secondary small website-order-btn"
|
||||||
className="btn btn-secondary small website-drag-btn"
|
title={t('txt_move_up')}
|
||||||
title={t('txt_drag_to_reorder')}
|
aria-label={t('txt_move_up')}
|
||||||
aria-label={t('txt_drag_to_reorder')}
|
disabled={!props.canMoveUp}
|
||||||
{...dragButtonAttributes}
|
onClick={() => props.onMove(props.index, props.index - 1)}
|
||||||
{...listeners}
|
|
||||||
>
|
>
|
||||||
<GripVertical size={14} className="btn-icon" />
|
<ArrowUp size={14} className="btn-icon" />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary small website-order-btn"
|
||||||
|
title={t('txt_move_down')}
|
||||||
|
aria-label={t('txt_move_down')}
|
||||||
|
disabled={!props.canMoveDown}
|
||||||
|
onClick={() => props.onMove(props.index, props.index + 1)}
|
||||||
|
>
|
||||||
|
<ArrowDown size={14} className="btn-icon" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
value={props.uriEntry.uri}
|
value={props.uriEntry.uri}
|
||||||
@@ -127,7 +113,13 @@ function SortableWebsiteRow(props: SortableWebsiteRowProps) {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{props.canRemove && (
|
{props.canRemove && (
|
||||||
<button type="button" className="btn btn-secondary small" onClick={() => props.onRemove(props.index)}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary small website-remove-btn"
|
||||||
|
title={t('txt_remove')}
|
||||||
|
aria-label={t('txt_remove')}
|
||||||
|
onClick={() => props.onRemove(props.index)}
|
||||||
|
>
|
||||||
<X size={14} className="btn-icon" />
|
<X size={14} className="btn-icon" />
|
||||||
{t('txt_remove')}
|
{t('txt_remove')}
|
||||||
</button>
|
</button>
|
||||||
@@ -138,32 +130,18 @@ function SortableWebsiteRow(props: SortableWebsiteRowProps) {
|
|||||||
|
|
||||||
export default function VaultEditor(props: VaultEditorProps) {
|
export default function VaultEditor(props: VaultEditorProps) {
|
||||||
const createTypeOptions = getCreateTypeOptions();
|
const createTypeOptions = getCreateTypeOptions();
|
||||||
const uriIdSeedRef = useRef(0);
|
const normalizedDraftCardBrand = normalizeCardBrand(props.draft.cardBrand);
|
||||||
|
const cardBrandOptions = normalizedDraftCardBrand && !CARD_BRAND_OPTIONS.includes(normalizedDraftCardBrand as any)
|
||||||
|
? [...CARD_BRAND_OPTIONS, normalizedDraftCardBrand]
|
||||||
|
: CARD_BRAND_OPTIONS;
|
||||||
const totpQrVideoRef = useRef<HTMLVideoElement | null>(null);
|
const totpQrVideoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
const totpQrFileRef = useRef<HTMLInputElement | null>(null);
|
const totpQrFileRef = useRef<HTMLInputElement | null>(null);
|
||||||
const totpQrStreamRef = useRef<MediaStream | null>(null);
|
const totpQrStreamRef = useRef<MediaStream | null>(null);
|
||||||
const totpQrFrameRef = useRef<number | null>(null);
|
const totpQrFrameRef = useRef<number | null>(null);
|
||||||
const [uriItemIds, setUriItemIds] = useState<string[]>([]);
|
|
||||||
const [activeUriId, setActiveUriId] = useState<string | null>(null);
|
|
||||||
const [totpQrOpen, setTotpQrOpen] = useState(false);
|
const [totpQrOpen, setTotpQrOpen] = useState(false);
|
||||||
const [totpQrStatus, setTotpQrStatus] = useState('');
|
const [totpQrStatus, setTotpQrStatus] = useState('');
|
||||||
const [totpQrBusy, setTotpQrBusy] = useState(false);
|
const [totpQrBusy, setTotpQrBusy] = useState(false);
|
||||||
useDialogLifecycle(totpQrOpen, () => setTotpQrOpen(false));
|
useDialogLifecycle(totpQrOpen, () => setTotpQrOpen(false));
|
||||||
const sensors = useSensors(
|
|
||||||
useSensor(PointerSensor, {
|
|
||||||
activationConstraint: {
|
|
||||||
distance: 6,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
useSensor(TouchSensor, {
|
|
||||||
activationConstraint: {
|
|
||||||
delay: 120,
|
|
||||||
tolerance: 8,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const createUriId = () => `login-uri-${uriIdSeedRef.current++}`;
|
|
||||||
|
|
||||||
const stopTotpQrScanner = () => {
|
const stopTotpQrScanner = () => {
|
||||||
if (totpQrFrameRef.current != null) {
|
if (totpQrFrameRef.current != null) {
|
||||||
@@ -222,21 +200,6 @@ export default function VaultEditor(props: VaultEditorProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setUriItemIds((prev) => {
|
|
||||||
if (prev.length === props.draft.loginUris.length) return prev;
|
|
||||||
if (prev.length < props.draft.loginUris.length) {
|
|
||||||
return [...prev, ...Array.from({ length: props.draft.loginUris.length - prev.length }, () => createUriId())];
|
|
||||||
}
|
|
||||||
return prev.slice(0, props.draft.loginUris.length);
|
|
||||||
});
|
|
||||||
}, [props.draft.loginUris.length]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setUriItemIds(props.draft.loginUris.map(() => createUriId()));
|
|
||||||
setActiveUriId(null);
|
|
||||||
}, [props.draft.id, props.isCreating]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!totpQrOpen) {
|
if (!totpQrOpen) {
|
||||||
stopTotpQrScanner();
|
stopTotpQrScanner();
|
||||||
@@ -324,28 +287,15 @@ export default function VaultEditor(props: VaultEditorProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const addLoginUri = () => {
|
const addLoginUri = () => {
|
||||||
setUriItemIds((prev) => [...prev, createUriId()]);
|
|
||||||
props.onUpdateDraft({ loginUris: [...props.draft.loginUris, createEmptyLoginUri()] });
|
props.onUpdateDraft({ loginUris: [...props.draft.loginUris, createEmptyLoginUri()] });
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeLoginUri = (index: number) => {
|
const removeLoginUri = (index: number) => {
|
||||||
setUriItemIds((prev) => prev.filter((_, itemIndex) => itemIndex !== index));
|
|
||||||
props.onUpdateDraft({ loginUris: props.draft.loginUris.filter((_, itemIndex) => itemIndex !== index) });
|
props.onUpdateDraft({ loginUris: props.draft.loginUris.filter((_, itemIndex) => itemIndex !== index) });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleWebsiteDragStart = (event: DragStartEvent) => {
|
const moveLoginUri = (fromIndex: number, toIndex: number) => {
|
||||||
setActiveUriId(String(event.active.id));
|
if (fromIndex < 0 || toIndex < 0 || fromIndex >= props.draft.loginUris.length || toIndex >= props.draft.loginUris.length || fromIndex === toIndex) return;
|
||||||
};
|
|
||||||
|
|
||||||
const handleWebsiteDragEnd = (event: DragEndEvent) => {
|
|
||||||
const activeId = String(event.active.id);
|
|
||||||
const overId = event.over ? String(event.over.id) : null;
|
|
||||||
setActiveUriId(null);
|
|
||||||
if (!overId || activeId === overId) return;
|
|
||||||
const fromIndex = uriItemIds.indexOf(activeId);
|
|
||||||
const toIndex = uriItemIds.indexOf(overId);
|
|
||||||
if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) return;
|
|
||||||
setUriItemIds((prev) => arrayMove(prev, fromIndex, toIndex));
|
|
||||||
props.onReorderDraftLoginUri(fromIndex, toIndex);
|
props.onReorderDraftLoginUri(fromIndex, toIndex);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -435,23 +385,20 @@ export default function VaultEditor(props: VaultEditorProps) {
|
|||||||
<Plus size={14} className="btn-icon" /> {t('txt_add_website')}
|
<Plus size={14} className="btn-icon" /> {t('txt_add_website')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragStart={handleWebsiteDragStart} onDragEnd={handleWebsiteDragEnd}>
|
|
||||||
<SortableContext items={uriItemIds} strategy={verticalListSortingStrategy}>
|
|
||||||
{props.draft.loginUris.map((uriEntry, index) => (
|
{props.draft.loginUris.map((uriEntry, index) => (
|
||||||
<SortableWebsiteRow
|
<WebsiteRow
|
||||||
key={uriItemIds[index] ?? `uri-${index}`}
|
key={`uri-${index}`}
|
||||||
id={uriItemIds[index] ?? `uri-fallback-${index}`}
|
|
||||||
uriEntry={uriEntry}
|
uriEntry={uriEntry}
|
||||||
index={index}
|
index={index}
|
||||||
|
canMoveUp={index > 0}
|
||||||
|
canMoveDown={index < props.draft.loginUris.length - 1}
|
||||||
canRemove={props.draft.loginUris.length > 1}
|
canRemove={props.draft.loginUris.length > 1}
|
||||||
isDragging={activeUriId === uriItemIds[index]}
|
|
||||||
onUpdateUri={props.onUpdateDraftLoginUri}
|
onUpdateUri={props.onUpdateDraftLoginUri}
|
||||||
onUpdateMatch={props.onUpdateDraftLoginUriMatch}
|
onUpdateMatch={props.onUpdateDraftLoginUriMatch}
|
||||||
|
onMove={moveLoginUri}
|
||||||
onRemove={removeLoginUri}
|
onRemove={removeLoginUri}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</SortableContext>
|
|
||||||
</DndContext>
|
|
||||||
{props.draft.loginFido2Credentials.length > 0 && (
|
{props.draft.loginFido2Credentials.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="section-head passkeys-section-head">
|
<div className="section-head passkeys-section-head">
|
||||||
@@ -496,8 +443,37 @@ export default function VaultEditor(props: VaultEditorProps) {
|
|||||||
<h4>{t('txt_card_details')}</h4>
|
<h4>{t('txt_card_details')}</h4>
|
||||||
<div className="field-grid">
|
<div className="field-grid">
|
||||||
<label className="field"><span>{t('txt_cardholder_name')}</span><input className="input" value={props.draft.cardholderName} onInput={(e) => props.onUpdateDraft({ cardholderName: (e.currentTarget as HTMLInputElement).value })} /></label>
|
<label className="field"><span>{t('txt_cardholder_name')}</span><input className="input" value={props.draft.cardholderName} onInput={(e) => props.onUpdateDraft({ cardholderName: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||||
<label className="field"><span>{t('txt_number')}</span><input className="input" value={props.draft.cardNumber} onInput={(e) => props.onUpdateDraft({ cardNumber: (e.currentTarget as HTMLInputElement).value })} /></label>
|
<label className="field">
|
||||||
<label className="field"><span>{t('txt_brand')}</span><input className="input" value={props.draft.cardBrand} onInput={(e) => props.onUpdateDraft({ cardBrand: (e.currentTarget as HTMLInputElement).value })} /></label>
|
<span>{t('txt_number')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={props.draft.cardNumber}
|
||||||
|
onInput={(e) => {
|
||||||
|
const value = (e.currentTarget as HTMLInputElement).value;
|
||||||
|
const detectedBrand = normalizeCardBrand(cardBrand(value) || '');
|
||||||
|
props.onUpdateDraft({
|
||||||
|
cardNumber: value,
|
||||||
|
...(props.draft.cardBrand ? {} : { cardBrand: detectedBrand }),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_brand')}</span>
|
||||||
|
<div className="card-brand-select-row">
|
||||||
|
<CardBrandIcon brand={normalizedDraftCardBrand} />
|
||||||
|
<select
|
||||||
|
className="input card-brand-select"
|
||||||
|
value={normalizedDraftCardBrand}
|
||||||
|
onInput={(e) => props.onUpdateDraft({ cardBrand: (e.currentTarget as HTMLSelectElement).value })}
|
||||||
|
>
|
||||||
|
<option value="">{t('txt_select')}</option>
|
||||||
|
{cardBrandOptions.map((brand) => (
|
||||||
|
<option key={brand} value={brand}>{brand}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
<label className="field"><span>{t('txt_security_code_cvv')}</span><input className="input" value={props.draft.cardCode} onInput={(e) => props.onUpdateDraft({ cardCode: (e.currentTarget as HTMLInputElement).value })} /></label>
|
<label className="field"><span>{t('txt_security_code_cvv')}</span><input className="input" value={props.draft.cardCode} onInput={(e) => props.onUpdateDraft({ cardCode: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||||
<label className="field"><span>{t('txt_expiry_month')}</span><input className="input" value={props.draft.cardExpMonth} onInput={(e) => props.onUpdateDraft({ cardExpMonth: (e.currentTarget as HTMLInputElement).value })} /></label>
|
<label className="field"><span>{t('txt_expiry_month')}</span><input className="input" value={props.draft.cardExpMonth} onInput={(e) => props.onUpdateDraft({ cardExpMonth: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||||
<label className="field"><span>{t('txt_expiry_year')}</span><input className="input" value={props.draft.cardExpYear} onInput={(e) => props.onUpdateDraft({ cardExpYear: (e.currentTarget as HTMLInputElement).value })} /></label>
|
<label className="field"><span>{t('txt_expiry_year')}</span><input className="input" value={props.draft.cardExpYear} onInput={(e) => props.onUpdateDraft({ cardExpYear: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||||
@@ -693,7 +669,11 @@ export default function VaultEditor(props: VaultEditorProps) {
|
|||||||
<span>{toBooleanFieldValue(field.value) ? t('txt_checked') : t('txt_unchecked')}</span>
|
<span>{toBooleanFieldValue(field.value) ? t('txt_checked') : t('txt_unchecked')}</span>
|
||||||
</label>
|
</label>
|
||||||
) : (
|
) : (
|
||||||
<input className="input" value={field.value} onInput={(e) => props.onPatchDraftCustomField(originalIndex, { value: (e.currentTarget as HTMLInputElement).value })} />
|
<textarea
|
||||||
|
className="input textarea custom-field-textarea"
|
||||||
|
value={field.value}
|
||||||
|
onInput={(e) => props.onPatchDraftCustomField(originalIndex, { value: (e.currentTarget as HTMLTextAreaElement).value })}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="btn btn-secondary small custom-field-remove" onClick={() => props.onUpdateDraftCustomFields(props.draft.customFields.filter((_, i) => i !== originalIndex))}>
|
<button type="button" className="btn btn-secondary small custom-field-remove" onClick={() => props.onUpdateDraftCustomFields(props.draft.customFields.filter((_, i) => i !== originalIndex))}>
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ const CipherListItem = memo(function CipherListItem(props: CipherListItemProps)
|
|||||||
onInput={(e) => props.onToggleSelected(props.cipher.id, (e.currentTarget as HTMLInputElement).checked)}
|
onInput={(e) => props.onToggleSelected(props.cipher.id, (e.currentTarget as HTMLInputElement).checked)}
|
||||||
/>
|
/>
|
||||||
<button type="button" className="row-main" onClick={() => props.onSelectCipher(props.cipher.id)}>
|
<button type="button" className="row-main" onClick={() => props.onSelectCipher(props.cipher.id)}>
|
||||||
<div className="list-icon-wrap">
|
<div className={`list-icon-wrap ${Number(props.cipher.type || 1) === 3 ? 'card-list-icon-wrap' : ''}`}>
|
||||||
<VaultListIcon cipher={props.cipher} />
|
<VaultListIcon cipher={props.cipher} />
|
||||||
</div>
|
</div>
|
||||||
<div className="list-text">
|
<div className="list-text">
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import type { ComponentChildren } from 'preact';
|
|||||||
import { Globe } from 'lucide-preact';
|
import { Globe } from 'lucide-preact';
|
||||||
import type { Cipher } from '@/lib/types';
|
import type { Cipher } from '@/lib/types';
|
||||||
import {
|
import {
|
||||||
|
beginWebsiteIconLoad,
|
||||||
getWebsiteIconImageUrl,
|
getWebsiteIconImageUrl,
|
||||||
getWebsiteIconStatus,
|
getWebsiteIconStatus,
|
||||||
preloadWebsiteIcon,
|
|
||||||
subscribeWebsiteIconStatus,
|
subscribeWebsiteIconStatus,
|
||||||
} from '@/lib/website-icon-cache';
|
} from '@/lib/website-icon-cache';
|
||||||
import { demoBrandIconUrl } from '@/lib/demo-brand-icons';
|
import { demoBrandIconUrl } from '@/lib/demo-brand-icons';
|
||||||
@@ -77,16 +77,8 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (SHOULD_LOAD_DEMO_BRAND_ICONS) return;
|
if (SHOULD_LOAD_DEMO_BRAND_ICONS) return;
|
||||||
if (demoIconUrl) return;
|
if (demoIconUrl) return;
|
||||||
if (!host || !src || !shouldLoad || status === 'loaded' || status === 'error') return;
|
if (!host || !src || !shouldLoad || status !== 'idle') return;
|
||||||
let disposed = false;
|
beginWebsiteIconLoad(host, src);
|
||||||
void preloadWebsiteIcon(host, src).then((nextStatus) => {
|
|
||||||
if (disposed) return;
|
|
||||||
setStatus(nextStatus);
|
|
||||||
setImageUrl(getWebsiteIconImageUrl(host));
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
disposed = true;
|
|
||||||
};
|
|
||||||
}, [demoIconUrl, host, src, shouldLoad, status]);
|
}, [demoIconUrl, host, src, shouldLoad, status]);
|
||||||
|
|
||||||
if (demoIconUrl) {
|
if (demoIconUrl) {
|
||||||
@@ -107,12 +99,14 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
|
|||||||
return <span className="list-icon-fallback">{props.fallback ?? <Globe size={18} />}</span>;
|
return <span className="list-icon-fallback">{props.fallback ?? <Globe size={18} />}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const shouldRenderIconImage = !!imageUrl && status === 'loaded';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="list-icon-stack" ref={nodeRef}>
|
<span className="list-icon-stack" ref={nodeRef}>
|
||||||
{status !== 'loaded' && <span className="list-icon-fallback">{props.fallback ?? <Globe size={18} />}</span>}
|
{status !== 'loaded' && <span className="list-icon-fallback">{props.fallback ?? <Globe size={18} />}</span>}
|
||||||
{status === 'loaded' && imageUrl && (
|
{shouldRenderIconImage && (
|
||||||
<img
|
<img
|
||||||
className="list-icon loaded"
|
className={`list-icon${status === 'loaded' ? ' loaded' : ''}`}
|
||||||
src={imageUrl}
|
src={imageUrl}
|
||||||
alt=""
|
alt=""
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
|||||||
@@ -28,6 +28,89 @@ interface TypeOption {
|
|||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const CARD_BRAND_OPTIONS = [
|
||||||
|
'Visa',
|
||||||
|
'Mastercard',
|
||||||
|
'American Express',
|
||||||
|
'Discover',
|
||||||
|
'Diners Club',
|
||||||
|
'JCB',
|
||||||
|
'Maestro',
|
||||||
|
'UnionPay',
|
||||||
|
'RuPay',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type CardBrand = typeof CARD_BRAND_OPTIONS[number];
|
||||||
|
|
||||||
|
const CARD_BRAND_ALIASES: Record<string, CardBrand> = {
|
||||||
|
amex: 'American Express',
|
||||||
|
'american express': 'American Express',
|
||||||
|
americanexpress: 'American Express',
|
||||||
|
discover: 'Discover',
|
||||||
|
diners: 'Diners Club',
|
||||||
|
'diners club': 'Diners Club',
|
||||||
|
dinersclub: 'Diners Club',
|
||||||
|
jcb: 'JCB',
|
||||||
|
maestro: 'Maestro',
|
||||||
|
mastercard: 'Mastercard',
|
||||||
|
master: 'Mastercard',
|
||||||
|
rupay: 'RuPay',
|
||||||
|
unionpay: 'UnionPay',
|
||||||
|
'union pay': 'UnionPay',
|
||||||
|
visa: 'Visa',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CARD_BRAND_LOGO_SLUGS: Partial<Record<CardBrand, string>> = {
|
||||||
|
'American Express': 'american-express',
|
||||||
|
'Diners Club': 'diners',
|
||||||
|
Discover: 'discover',
|
||||||
|
JCB: 'jcb',
|
||||||
|
Maestro: 'maestro',
|
||||||
|
Mastercard: 'mastercard',
|
||||||
|
UnionPay: 'unionpay',
|
||||||
|
Visa: 'visa',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function normalizeCardBrand(value: string | null | undefined): string {
|
||||||
|
const normalized = String(value || '').trim();
|
||||||
|
if (!normalized) return '';
|
||||||
|
return CARD_BRAND_ALIASES[normalized.toLowerCase().replace(/\s+/g, ' ')] || normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function displayCardBrand(value: string | null | undefined): string {
|
||||||
|
return normalizeCardBrand(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cardLast4(value: string | null | undefined): string {
|
||||||
|
const digits = String(value || '').replace(/\D/g, '');
|
||||||
|
return digits.length >= 4 ? digits.slice(-4) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cardListSubtitle(cipher: Cipher): string {
|
||||||
|
const brand = displayCardBrand(cipher.card?.decBrand ?? cipher.card?.brand);
|
||||||
|
const last4 = cardLast4(cipher.card?.decNumber ?? cipher.card?.number);
|
||||||
|
if (brand && last4) return `${brand}, *${last4}`;
|
||||||
|
if (brand) return brand;
|
||||||
|
if (last4) return `*${last4}`;
|
||||||
|
return cipherTypeLabel(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardBrandIcon({ brand }: { brand?: string | null }) {
|
||||||
|
const display = displayCardBrand(brand);
|
||||||
|
const key = display.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'generic';
|
||||||
|
const label = display || t('txt_card');
|
||||||
|
const logoSlug = CARD_BRAND_LOGO_SLUGS[display as CardBrand];
|
||||||
|
return (
|
||||||
|
<span className={`card-brand-icon card-brand-${key}`} aria-label={label} title={label}>
|
||||||
|
{logoSlug ? (
|
||||||
|
<img src={`/payment-logos/cards/${logoSlug}.svg`} alt="" loading="lazy" decoding="async" />
|
||||||
|
) : (
|
||||||
|
<CreditCard size={18} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function getCreateTypeOptions(): TypeOption[] {
|
export function getCreateTypeOptions(): TypeOption[] {
|
||||||
return [
|
return [
|
||||||
{ type: 1, label: t('txt_login') },
|
{ type: 1, label: t('txt_login') },
|
||||||
@@ -323,7 +406,7 @@ export function draftFromCipher(cipher: Cipher): VaultDraft {
|
|||||||
if (cipher.card) {
|
if (cipher.card) {
|
||||||
draft.cardholderName = cipher.card.decCardholderName || '';
|
draft.cardholderName = cipher.card.decCardholderName || '';
|
||||||
draft.cardNumber = cipher.card.decNumber || '';
|
draft.cardNumber = cipher.card.decNumber || '';
|
||||||
draft.cardBrand = cipher.card.decBrand || '';
|
draft.cardBrand = normalizeCardBrand(cipher.card.decBrand || '');
|
||||||
draft.cardExpMonth = cipher.card.decExpMonth || '';
|
draft.cardExpMonth = cipher.card.decExpMonth || '';
|
||||||
draft.cardExpYear = cipher.card.decExpYear || '';
|
draft.cardExpYear = cipher.card.decExpYear || '';
|
||||||
draft.cardCode = cipher.card.decCode || '';
|
draft.cardCode = cipher.card.decCode || '';
|
||||||
@@ -425,6 +508,9 @@ export function firstPasskeyCreationTime(cipher: Cipher | null): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function VaultListIcon({ cipher }: { cipher: Cipher }) {
|
export function VaultListIcon({ cipher }: { cipher: Cipher }) {
|
||||||
|
if (Number(cipher.type || 1) === 3) {
|
||||||
|
return <CardBrandIcon brand={cipher.card?.decBrand ?? cipher.card?.brand} />;
|
||||||
|
}
|
||||||
return <WebsiteIcon cipher={cipher} fallback={<TypeIcon type={Number(cipher.type || 1)} />} />;
|
return <WebsiteIcon cipher={cipher} fallback={<TypeIcon type={Number(cipher.type || 1)} />} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { bytesToBase64, decryptBw, encryptBw, hkdfExpand, pbkdf2 } from '../crypto';
|
import { bytesToBase64, decryptBw, encryptBw, hkdfExpand, pbkdf2 } from '../crypto';
|
||||||
import { t } from '../i18n';
|
import { t, translateServerError } from '../i18n';
|
||||||
import type { AuthorizedDevice } from '../types';
|
import type { AuthorizedDevice } from '../types';
|
||||||
import type {
|
import type {
|
||||||
Profile,
|
Profile,
|
||||||
@@ -297,12 +297,12 @@ export async function refreshAccessToken(session: SessionState): Promise<Refresh
|
|||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
transient: isTransientRefreshStatus(resp.status),
|
transient: isTransientRefreshStatus(resp.status),
|
||||||
error: json?.error_description || json?.error || 'Session refresh failed',
|
error: translateServerError(json?.error_description || json?.error, t('txt_session_refresh_failed')),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const json = await parseJson<TokenSuccess>(resp);
|
const json = await parseJson<TokenSuccess>(resp);
|
||||||
if (!json?.access_token) {
|
if (!json?.access_token) {
|
||||||
return { ok: false, transient: false, error: 'Session refresh failed' };
|
return { ok: false, transient: false, error: t('txt_session_refresh_failed') };
|
||||||
}
|
}
|
||||||
return { ok: true, token: json };
|
return { ok: true, token: json };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -400,11 +400,11 @@ export async function registerAccount(args: {
|
|||||||
|
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
const json = await parseJson<TokenError>(resp);
|
const json = await parseJson<TokenError>(resp);
|
||||||
return { ok: false, message: json?.error_description || json?.error || 'Register failed' };
|
return { ok: false, message: translateServerError(json?.error_description || json?.error, t('txt_register_failed')) };
|
||||||
}
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { ok: false, message: error instanceof Error ? error.message : 'Register failed' };
|
return { ok: false, message: error instanceof Error ? translateServerError(error.message, error.message) : t('txt_register_failed') };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,7 +416,7 @@ export async function getPasswordHint(email: string): Promise<{ masterPasswordHi
|
|||||||
});
|
});
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
const body = await parseJson<TokenError>(resp);
|
const body = await parseJson<TokenError>(resp);
|
||||||
throw new Error(body?.error_description || body?.error || 'Failed to load password hint');
|
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_password_hint_load_failed')));
|
||||||
}
|
}
|
||||||
const body = (await parseJson<{ masterPasswordHint?: string | null }>(resp)) || {};
|
const body = (await parseJson<{ masterPasswordHint?: string | null }>(resp)) || {};
|
||||||
return { masterPasswordHint: body.masterPasswordHint ?? null };
|
return { masterPasswordHint: body.masterPasswordHint ?? null };
|
||||||
@@ -469,10 +469,10 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
|
|||||||
const refreshed = await refreshAccessTokenOnce(refreshSource);
|
const refreshed = await refreshAccessTokenOnce(refreshSource);
|
||||||
if (!refreshed.ok) {
|
if (!refreshed.ok) {
|
||||||
if (refreshed.transient) {
|
if (refreshed.transient) {
|
||||||
throw new Error(refreshed.error || 'Session refresh temporarily unavailable');
|
throw new Error(refreshed.error || t('txt_session_refresh_failed'));
|
||||||
}
|
}
|
||||||
setSession(null);
|
setSession(null);
|
||||||
throw new Error('Session expired');
|
throw new Error(t('txt_session_refresh_failed'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextSession: SessionState = {
|
const nextSession: SessionState = {
|
||||||
@@ -512,7 +512,7 @@ export async function updateProfile(
|
|||||||
});
|
});
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
const body = await parseJson<TokenError>(resp);
|
const body = await parseJson<TokenError>(resp);
|
||||||
throw new Error(body?.error_description || body?.error || 'Save profile failed');
|
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_save_profile_failed')));
|
||||||
}
|
}
|
||||||
const body = await parseJson<Profile>(resp);
|
const body = await parseJson<Profile>(resp);
|
||||||
if (!body) throw new Error('Invalid profile');
|
if (!body) throw new Error('Invalid profile');
|
||||||
@@ -575,7 +575,7 @@ export async function setTotp(
|
|||||||
});
|
});
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
const body = await parseJson<TokenError>(resp);
|
const body = await parseJson<TokenError>(resp);
|
||||||
throw new Error(body?.error_description || body?.error || 'TOTP update failed');
|
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_totp_update_failed')));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -590,7 +590,7 @@ export async function verifyMasterPassword(
|
|||||||
});
|
});
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
const body = await parseJson<TokenError>(resp);
|
const body = await parseJson<TokenError>(resp);
|
||||||
throw new Error(body?.error_description || body?.error || 'Master password verify failed');
|
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_master_password_verify_failed')));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -625,7 +625,7 @@ export async function getTotpRecoveryCode(
|
|||||||
});
|
});
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
const body = await parseJson<TokenError>(resp);
|
const body = await parseJson<TokenError>(resp);
|
||||||
throw new Error(body?.error_description || body?.error || 'Failed to get recovery code');
|
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_get_recovery_code_failed')));
|
||||||
}
|
}
|
||||||
const body = (await parseJson<{ code?: string }>(resp)) || {};
|
const body = (await parseJson<{ code?: string }>(resp)) || {};
|
||||||
return String(body.code || '');
|
return String(body.code || '');
|
||||||
@@ -647,7 +647,7 @@ export async function recoverTwoFactor(
|
|||||||
});
|
});
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
const body = await parseJson<TokenError>(resp);
|
const body = await parseJson<TokenError>(resp);
|
||||||
throw new Error(body?.error_description || body?.error || 'Recover 2FA failed');
|
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_recover_2fa_failed')));
|
||||||
}
|
}
|
||||||
return (await parseJson<{ newRecoveryCode?: string }>(resp)) || {};
|
return (await parseJson<{ newRecoveryCode?: string }>(resp)) || {};
|
||||||
}
|
}
|
||||||
@@ -708,7 +708,7 @@ export async function getApiKey(authedFetch: AuthedFetch, masterPasswordHash: st
|
|||||||
});
|
});
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
const body = await parseJson<TokenError>(resp);
|
const body = await parseJson<TokenError>(resp);
|
||||||
throw new Error(body?.error_description || body?.error || 'Failed to get API key');
|
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_get_api_key_failed')));
|
||||||
}
|
}
|
||||||
const body = (await parseJson<{ apiKey?: string }>(resp)) || {};
|
const body = (await parseJson<{ apiKey?: string }>(resp)) || {};
|
||||||
return String(body.apiKey || '');
|
return String(body.apiKey || '');
|
||||||
@@ -722,7 +722,7 @@ export async function rotateApiKey(authedFetch: AuthedFetch, masterPasswordHash:
|
|||||||
});
|
});
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
const body = await parseJson<TokenError>(resp);
|
const body = await parseJson<TokenError>(resp);
|
||||||
throw new Error(body?.error_description || body?.error || 'Failed to rotate API key');
|
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_rotate_api_key_failed')));
|
||||||
}
|
}
|
||||||
const body = (await parseJson<{ apiKey?: string }>(resp)) || {};
|
const body = (await parseJson<{ apiKey?: string }>(resp)) || {};
|
||||||
return String(body.apiKey || '');
|
return String(body.apiKey || '');
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ export interface RemoteBackupBrowserResponse {
|
|||||||
export interface AdminBackupImportCounts {
|
export interface AdminBackupImportCounts {
|
||||||
config: number;
|
config: number;
|
||||||
users: number;
|
users: number;
|
||||||
|
domainSettings?: number;
|
||||||
userRevisions: number;
|
userRevisions: number;
|
||||||
folders: number;
|
folders: number;
|
||||||
ciphers: number;
|
ciphers: number;
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { t } from '@/lib/i18n';
|
||||||
|
import type { DomainRules } from '@/lib/types';
|
||||||
|
import { parseErrorMessage, parseJson, type AuthedFetch } from './shared';
|
||||||
|
|
||||||
|
function normalizeDomainsResponse(body: Partial<DomainRules> & Record<string, unknown>): DomainRules {
|
||||||
|
const equivalentDomains = Array.isArray(body.equivalentDomains)
|
||||||
|
? body.equivalentDomains
|
||||||
|
: Array.isArray(body.EquivalentDomains)
|
||||||
|
? body.EquivalentDomains as string[][]
|
||||||
|
: [];
|
||||||
|
const globalEquivalentDomains = Array.isArray(body.globalEquivalentDomains)
|
||||||
|
? body.globalEquivalentDomains
|
||||||
|
: Array.isArray(body.GlobalEquivalentDomains)
|
||||||
|
? body.GlobalEquivalentDomains as DomainRules['globalEquivalentDomains']
|
||||||
|
: [];
|
||||||
|
const customEquivalentDomains = Array.isArray(body.customEquivalentDomains)
|
||||||
|
? body.customEquivalentDomains as DomainRules['customEquivalentDomains']
|
||||||
|
: Array.isArray(body.CustomEquivalentDomains)
|
||||||
|
? body.CustomEquivalentDomains as DomainRules['customEquivalentDomains']
|
||||||
|
: equivalentDomains.map((domains, index) => ({
|
||||||
|
id: `custom:${index}`,
|
||||||
|
domains,
|
||||||
|
excluded: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
equivalentDomains,
|
||||||
|
customEquivalentDomains,
|
||||||
|
globalEquivalentDomains,
|
||||||
|
object: 'domains',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDomainRules(authedFetch: AuthedFetch): Promise<DomainRules> {
|
||||||
|
const resp = await authedFetch('/api/settings/domains');
|
||||||
|
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_domain_rules_load_failed')));
|
||||||
|
const body = await parseJson<Partial<DomainRules> & Record<string, unknown>>(resp);
|
||||||
|
if (!body) throw new Error(t('txt_domain_rules_invalid_response'));
|
||||||
|
return normalizeDomainsResponse(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveDomainRules(
|
||||||
|
authedFetch: AuthedFetch,
|
||||||
|
payload: {
|
||||||
|
customEquivalentDomains: DomainRules['customEquivalentDomains'];
|
||||||
|
equivalentDomains: string[][];
|
||||||
|
excludedGlobalEquivalentDomains: number[];
|
||||||
|
}
|
||||||
|
): Promise<DomainRules> {
|
||||||
|
const resp = await authedFetch('/api/settings/domains', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(await parseErrorMessage(resp, t('txt_domain_rules_save_failed')));
|
||||||
|
}
|
||||||
|
const body = await parseJson<Partial<DomainRules> & Record<string, unknown>>(resp);
|
||||||
|
if (!body) throw new Error(t('txt_domain_rules_invalid_response'));
|
||||||
|
return normalizeDomainsResponse(body);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { t } from '../i18n';
|
import { t, translateServerError } from '../i18n';
|
||||||
import type { SessionState, TokenError } from '../types';
|
import type { SessionState, TokenError } from '../types';
|
||||||
|
|
||||||
export type AuthedFetch = (input: string, init?: RequestInit) => Promise<Response>;
|
export type AuthedFetch = (input: string, init?: RequestInit) => Promise<Response>;
|
||||||
@@ -46,7 +46,7 @@ export function parseContentDispositionFileName(response: Response, fallback: st
|
|||||||
|
|
||||||
export async function parseErrorMessage(resp: Response, fallback: string): Promise<string> {
|
export async function parseErrorMessage(resp: Response, fallback: string): Promise<string> {
|
||||||
const body = await parseJson<TokenError>(resp);
|
const body = await parseJson<TokenError>(resp);
|
||||||
return body?.error_description || body?.error || fallback;
|
return translateServerError(body?.error_description || body?.error, fallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createApiError(message: string, status?: number): Error & { status?: number } {
|
export function createApiError(message: string, status?: number): Error & { status?: number } {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
unlockVaultKey,
|
unlockVaultKey,
|
||||||
} from '@/lib/api/auth';
|
} from '@/lib/api/auth';
|
||||||
import { readInviteCodeFromUrl } from '@/lib/app-support';
|
import { readInviteCodeFromUrl } from '@/lib/app-support';
|
||||||
|
import { t, translateServerError } from '@/lib/i18n';
|
||||||
import type { AppPhase, Profile, SessionState, TokenSuccess, WebBootstrapResponse } from '@/lib/types';
|
import type { AppPhase, Profile, SessionState, TokenSuccess, WebBootstrapResponse } from '@/lib/types';
|
||||||
|
|
||||||
export interface PendingTotp {
|
export interface PendingTotp {
|
||||||
@@ -23,6 +24,7 @@ export type JwtUnsafeReason = 'missing' | 'default' | 'too_short';
|
|||||||
|
|
||||||
export interface BootstrapAppResult {
|
export interface BootstrapAppResult {
|
||||||
defaultKdfIterations: number;
|
defaultKdfIterations: number;
|
||||||
|
registrationInviteRequired?: boolean;
|
||||||
jwtWarning: { reason: JwtUnsafeReason; minLength: number } | null;
|
jwtWarning: { reason: JwtUnsafeReason; minLength: number } | null;
|
||||||
session: SessionState | null;
|
session: SessionState | null;
|
||||||
profile: Profile | null;
|
profile: Profile | null;
|
||||||
@@ -32,6 +34,7 @@ export interface BootstrapAppResult {
|
|||||||
|
|
||||||
export interface InitialAppBootstrapState {
|
export interface InitialAppBootstrapState {
|
||||||
defaultKdfIterations: number;
|
defaultKdfIterations: number;
|
||||||
|
registrationInviteRequired?: boolean;
|
||||||
jwtWarning: { reason: JwtUnsafeReason; minLength: number } | null;
|
jwtWarning: { reason: JwtUnsafeReason; minLength: number } | null;
|
||||||
session: SessionState | null;
|
session: SessionState | null;
|
||||||
phase: AppPhase;
|
phase: AppPhase;
|
||||||
@@ -96,8 +99,10 @@ function readWindowBootstrap(): WebBootstrapResponse {
|
|||||||
return raw && typeof raw === 'object' ? raw : {};
|
return raw && typeof raw === 'object' ? raw : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeBootstrapResponse(boot: WebBootstrapResponse): Pick<InitialAppBootstrapState, 'defaultKdfIterations' | 'jwtWarning'> {
|
function normalizeBootstrapResponse(boot: WebBootstrapResponse): Pick<InitialAppBootstrapState, 'defaultKdfIterations' | 'registrationInviteRequired' | 'jwtWarning'> {
|
||||||
const defaultKdfIterations = Number(boot.defaultKdfIterations || 600000);
|
const defaultKdfIterations = Number(boot.defaultKdfIterations || 600000);
|
||||||
|
const registrationInviteRequired =
|
||||||
|
typeof boot.registrationInviteRequired === 'boolean' ? boot.registrationInviteRequired : undefined;
|
||||||
const jwtUnsafeReason = boot.jwtUnsafeReason || null;
|
const jwtUnsafeReason = boot.jwtUnsafeReason || null;
|
||||||
const jwtWarning = jwtUnsafeReason
|
const jwtWarning = jwtUnsafeReason
|
||||||
? {
|
? {
|
||||||
@@ -108,6 +113,7 @@ function normalizeBootstrapResponse(boot: WebBootstrapResponse): Pick<InitialApp
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
defaultKdfIterations,
|
defaultKdfIterations,
|
||||||
|
registrationInviteRequired,
|
||||||
jwtWarning,
|
jwtWarning,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -163,16 +169,22 @@ function buildTransientProfile(token: TokenSuccess, email: string, fallbackProfi
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveUnauthenticatedPhase(registrationInviteRequired: boolean | undefined, fallback: AppPhase): AppPhase {
|
||||||
|
return registrationInviteRequired === false ? 'register' : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
export function readInitialAppBootstrapState(): InitialAppBootstrapState {
|
export function readInitialAppBootstrapState(): InitialAppBootstrapState {
|
||||||
const { defaultKdfIterations, jwtWarning } = normalizeBootstrapResponse(readWindowBootstrap());
|
const { defaultKdfIterations, registrationInviteRequired, jwtWarning } = normalizeBootstrapResponse(readWindowBootstrap());
|
||||||
const session = loadSession();
|
const session = loadSession();
|
||||||
const hasInviteCode = !!readInviteCodeFromUrl();
|
const hasInviteCode = !!readInviteCodeFromUrl();
|
||||||
|
const unauthenticatedPhase = hasInviteCode ? 'register' : 'login';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
defaultKdfIterations,
|
defaultKdfIterations,
|
||||||
|
registrationInviteRequired,
|
||||||
jwtWarning,
|
jwtWarning,
|
||||||
session,
|
session,
|
||||||
phase: jwtWarning ? 'login' : session ? 'locked' : hasInviteCode ? 'register' : 'login',
|
phase: jwtWarning ? 'login' : session ? 'locked' : resolveUnauthenticatedPhase(registrationInviteRequired, unauthenticatedPhase),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,11 +192,13 @@ export async function bootstrapAppSession(initial: InitialAppBootstrapState = re
|
|||||||
const remoteBoot = await fetchBootstrapConfig();
|
const remoteBoot = await fetchBootstrapConfig();
|
||||||
const normalizedBoot = normalizeBootstrapResponse(remoteBoot);
|
const normalizedBoot = normalizeBootstrapResponse(remoteBoot);
|
||||||
const defaultKdfIterations = normalizedBoot.defaultKdfIterations || initial.defaultKdfIterations;
|
const defaultKdfIterations = normalizedBoot.defaultKdfIterations || initial.defaultKdfIterations;
|
||||||
|
const registrationInviteRequired = normalizedBoot.registrationInviteRequired ?? initial.registrationInviteRequired;
|
||||||
const jwtWarning = normalizedBoot.jwtWarning ?? initial.jwtWarning;
|
const jwtWarning = normalizedBoot.jwtWarning ?? initial.jwtWarning;
|
||||||
|
|
||||||
if (jwtWarning) {
|
if (jwtWarning) {
|
||||||
return {
|
return {
|
||||||
defaultKdfIterations,
|
defaultKdfIterations,
|
||||||
|
registrationInviteRequired,
|
||||||
jwtWarning,
|
jwtWarning,
|
||||||
session: null,
|
session: null,
|
||||||
profile: null,
|
profile: null,
|
||||||
@@ -196,10 +210,11 @@ export async function bootstrapAppSession(initial: InitialAppBootstrapState = re
|
|||||||
if (!loaded) {
|
if (!loaded) {
|
||||||
return {
|
return {
|
||||||
defaultKdfIterations,
|
defaultKdfIterations,
|
||||||
|
registrationInviteRequired,
|
||||||
jwtWarning: null,
|
jwtWarning: null,
|
||||||
session: null,
|
session: null,
|
||||||
profile: null,
|
profile: null,
|
||||||
phase: initial.phase,
|
phase: resolveUnauthenticatedPhase(registrationInviteRequired, initial.phase),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,6 +222,7 @@ export async function bootstrapAppSession(initial: InitialAppBootstrapState = re
|
|||||||
if (cachedProfile) {
|
if (cachedProfile) {
|
||||||
return {
|
return {
|
||||||
defaultKdfIterations,
|
defaultKdfIterations,
|
||||||
|
registrationInviteRequired,
|
||||||
jwtWarning: null,
|
jwtWarning: null,
|
||||||
session: loaded,
|
session: loaded,
|
||||||
profile: cachedProfile,
|
profile: cachedProfile,
|
||||||
@@ -217,6 +233,7 @@ export async function bootstrapAppSession(initial: InitialAppBootstrapState = re
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
defaultKdfIterations,
|
defaultKdfIterations,
|
||||||
|
registrationInviteRequired,
|
||||||
jwtWarning: null,
|
jwtWarning: null,
|
||||||
session: loaded,
|
session: loaded,
|
||||||
profile: null,
|
profile: null,
|
||||||
@@ -311,7 +328,7 @@ export async function performPasswordLogin(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
kind: 'error',
|
kind: 'error',
|
||||||
message: tokenError.error_description || tokenError.error || 'Login failed',
|
message: translateServerError(tokenError.error_description || tokenError.error, t('txt_login_failed')),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,7 +345,7 @@ export async function performTotpLogin(
|
|||||||
return completeLogin(token, pendingTotp.email, pendingTotp.masterKey);
|
return completeLogin(token, pendingTotp.email, pendingTotp.masterKey);
|
||||||
}
|
}
|
||||||
const tokenError = token as { error_description?: string; error?: string };
|
const tokenError = token as { error_description?: string; error?: string };
|
||||||
throw new Error(tokenError.error_description || tokenError.error || 'TOTP verify failed');
|
throw new Error(translateServerError(tokenError.error_description || tokenError.error, t('txt_totp_verify_failed')));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function performRecoverTwoFactorLogin(
|
export async function performRecoverTwoFactorLogin(
|
||||||
@@ -404,7 +421,7 @@ export async function performUnlock(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
kind: 'error',
|
kind: 'error',
|
||||||
message: tokenError.error_description || tokenError.error || 'Unlock failed',
|
message: translateServerError(tokenError.error_description || tokenError.error, t('txt_unlock_failed')),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export function preloadAuthenticatedWorkspace(isAdmin: boolean): Promise<unknown
|
|||||||
import('@/components/SendsPage'),
|
import('@/components/SendsPage'),
|
||||||
import('@/components/TotpCodesPage'),
|
import('@/components/TotpCodesPage'),
|
||||||
import('@/components/SettingsPage'),
|
import('@/components/SettingsPage'),
|
||||||
|
import('@/components/DomainRulesPage'),
|
||||||
import('@/components/SecurityDevicesPage'),
|
import('@/components/SecurityDevicesPage'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -41,6 +42,7 @@ export function preloadDemoExperience(): () => void {
|
|||||||
() => import('@/components/SendsPage'),
|
() => import('@/components/SendsPage'),
|
||||||
() => import('@/components/TotpCodesPage'),
|
() => import('@/components/TotpCodesPage'),
|
||||||
() => import('@/components/SettingsPage'),
|
() => import('@/components/SettingsPage'),
|
||||||
|
() => import('@/components/DomainRulesPage'),
|
||||||
() => import('@/components/SecurityDevicesPage'),
|
() => import('@/components/SecurityDevicesPage'),
|
||||||
() => import('@/components/AdminPage'),
|
() => import('@/components/AdminPage'),
|
||||||
() => import('@/components/BackupCenterPage'),
|
() => import('@/components/BackupCenterPage'),
|
||||||
|
|||||||
@@ -224,26 +224,71 @@ function parseSteamSecret(raw: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseTotpConfig(raw: string): { secret: string; steam: boolean } {
|
type TotpHashAlgorithm = 'SHA-1' | 'SHA-256' | 'SHA-512';
|
||||||
if (!raw) return { secret: '', steam: false };
|
|
||||||
|
interface TotpConfig {
|
||||||
|
secret: string;
|
||||||
|
steam: boolean;
|
||||||
|
algorithm: TotpHashAlgorithm;
|
||||||
|
digits: number;
|
||||||
|
period: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TOTP_CONFIG: Omit<TotpConfig, 'secret' | 'steam'> = {
|
||||||
|
algorithm: 'SHA-1',
|
||||||
|
digits: 6,
|
||||||
|
period: 30,
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseTotpPositiveInt(value: string | null, fallback: number, min: number, max: number): number {
|
||||||
|
if (!value) return fallback;
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isInteger(parsed) || parsed < min || parsed > max) return fallback;
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTotpHashAlgorithm(value: string | null): TotpHashAlgorithm {
|
||||||
|
const normalized = (value || '').trim().toUpperCase().replace(/[^A-Z0-9]/g, '');
|
||||||
|
if (normalized === 'SHA256') return 'SHA-256';
|
||||||
|
if (normalized === 'SHA512') return 'SHA-512';
|
||||||
|
return 'SHA-1';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTotpConfig(raw: string): TotpConfig {
|
||||||
|
if (!raw) return { secret: '', steam: false, ...DEFAULT_TOTP_CONFIG };
|
||||||
const s = raw.trim();
|
const s = raw.trim();
|
||||||
if (!s) return { secret: '', steam: false };
|
if (!s) return { secret: '', steam: false, ...DEFAULT_TOTP_CONFIG };
|
||||||
if (/^steam:\/\//i.test(s)) {
|
if (/^steam:\/\//i.test(s)) {
|
||||||
return { secret: parseSteamSecret(s), steam: true };
|
return {
|
||||||
|
secret: parseSteamSecret(s),
|
||||||
|
steam: true,
|
||||||
|
algorithm: 'SHA-1',
|
||||||
|
digits: 5,
|
||||||
|
period: 30,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (/^otpauth:\/\//i.test(s)) {
|
if (/^otpauth:\/\//i.test(s)) {
|
||||||
try {
|
try {
|
||||||
const u = new URL(s);
|
const u = new URL(s);
|
||||||
|
if (u.hostname.toLowerCase() !== 'totp') {
|
||||||
|
return { secret: '', steam: false, ...DEFAULT_TOTP_CONFIG };
|
||||||
|
}
|
||||||
const label = decodeURIComponent((u.pathname || '').replace(/^\/+/, '')).toLowerCase();
|
const label = decodeURIComponent((u.pathname || '').replace(/^\/+/, '')).toLowerCase();
|
||||||
const issuer = (u.searchParams.get('issuer') || '').trim().toLowerCase();
|
const issuer = (u.searchParams.get('issuer') || '').trim().toLowerCase();
|
||||||
const algorithm = (u.searchParams.get('algorithm') || '').trim().toLowerCase();
|
const algorithm = (u.searchParams.get('algorithm') || '').trim().toLowerCase();
|
||||||
const steam = issuer === 'steam' || label.startsWith('steam:') || algorithm === 'steam';
|
const steam = issuer === 'steam' || label.startsWith('steam:') || algorithm === 'steam';
|
||||||
return { secret: normalizeTotpSecret(u.searchParams.get('secret') || ''), steam };
|
return {
|
||||||
|
secret: normalizeTotpSecret(u.searchParams.get('secret') || ''),
|
||||||
|
steam,
|
||||||
|
algorithm: steam ? 'SHA-1' : parseTotpHashAlgorithm(u.searchParams.get('algorithm')),
|
||||||
|
digits: steam ? 5 : parseTotpPositiveInt(u.searchParams.get('digits'), DEFAULT_TOTP_CONFIG.digits, 1, 10),
|
||||||
|
period: parseTotpPositiveInt(u.searchParams.get('period'), DEFAULT_TOTP_CONFIG.period, 1, 3600),
|
||||||
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return { secret: '', steam: false };
|
return { secret: '', steam: false, ...DEFAULT_TOTP_CONFIG };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { secret: normalizeTotpSecret(s), steam: false };
|
return { secret: normalizeTotpSecret(s), steam: false, ...DEFAULT_TOTP_CONFIG };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extractTotpSecret(raw: string): string {
|
export function extractTotpSecret(raw: string): string {
|
||||||
@@ -269,15 +314,14 @@ function base32ToBytes(input: string): Uint8Array {
|
|||||||
return new Uint8Array(out);
|
return new Uint8Array(out);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function calcTotpNow(rawSecret: string): Promise<{ code: string; remain: number } | null> {
|
export async function calcTotpNow(rawSecret: string, nowMs: number = Date.now()): Promise<{ code: string; remain: number } | null> {
|
||||||
const { secret, steam } = parseTotpConfig(rawSecret);
|
const { secret, steam, algorithm, digits, period } = parseTotpConfig(rawSecret);
|
||||||
if (!secret) return null;
|
if (!secret) return null;
|
||||||
const keyBytes = base32ToBytes(secret);
|
const keyBytes = base32ToBytes(secret);
|
||||||
if (!keyBytes.length) return null;
|
if (!keyBytes.length) return null;
|
||||||
const step = 30;
|
const epoch = Math.floor(nowMs / 1000);
|
||||||
const epoch = Math.floor(Date.now() / 1000);
|
const counter = Math.floor(epoch / period);
|
||||||
const counter = Math.floor(epoch / step);
|
const remain = period - (epoch % period);
|
||||||
const remain = step - (epoch % step);
|
|
||||||
|
|
||||||
const message = new Uint8Array(8);
|
const message = new Uint8Array(8);
|
||||||
let c = counter;
|
let c = counter;
|
||||||
@@ -285,11 +329,11 @@ export async function calcTotpNow(rawSecret: string): Promise<{ code: string; re
|
|||||||
message[i] = c & 0xff;
|
message[i] = c & 0xff;
|
||||||
c = Math.floor(c / 256);
|
c = Math.floor(c / 256);
|
||||||
}
|
}
|
||||||
const key = await crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']);
|
const key = await crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'HMAC', hash: algorithm }, false, ['sign']);
|
||||||
const hs = new Uint8Array(await crypto.subtle.sign('HMAC', key, toBufferSource(message)));
|
const hs = new Uint8Array(await crypto.subtle.sign('HMAC', key, toBufferSource(message)));
|
||||||
const offset = hs[hs.length - 1] & 0x0f;
|
const offset = hs[hs.length - 1] & 0x0f;
|
||||||
const bin = ((hs[offset] & 0x7f) << 24) | ((hs[offset + 1] & 0xff) << 16) | ((hs[offset + 2] & 0xff) << 8) | (hs[offset + 3] & 0xff);
|
const bin = ((hs[offset] & 0x7f) << 24) | ((hs[offset + 1] & 0xff) << 16) | ((hs[offset + 2] & 0xff) << 8) | (hs[offset + 3] & 0xff);
|
||||||
let code = (bin % 1000000).toString().padStart(6, '0');
|
let code = (bin % (10 ** digits)).toString().padStart(digits, '0');
|
||||||
if (steam) {
|
if (steam) {
|
||||||
const chars = '23456789BCDFGHJKMNPQRTVWXY';
|
const chars = '23456789BCDFGHJKMNPQRTVWXY';
|
||||||
let value = bin;
|
let value = bin;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export function createDemoBackupSettings(): AdminBackupSettings {
|
|||||||
export function createDemoInitialBootstrapState(): InitialAppBootstrapState {
|
export function createDemoInitialBootstrapState(): InitialAppBootstrapState {
|
||||||
return {
|
return {
|
||||||
defaultKdfIterations: 600000,
|
defaultKdfIterations: 600000,
|
||||||
|
registrationInviteRequired: true,
|
||||||
jwtWarning: null,
|
jwtWarning: null,
|
||||||
session: null,
|
session: null,
|
||||||
phase: 'login',
|
phase: 'login',
|
||||||
|
|||||||
@@ -789,6 +789,7 @@ async function runDemoRemoteRestoreProgress(fileName: string): Promise<void> {
|
|||||||
export function createDemoInitialBootstrapState(): InitialAppBootstrapState {
|
export function createDemoInitialBootstrapState(): InitialAppBootstrapState {
|
||||||
return {
|
return {
|
||||||
defaultKdfIterations: 600000,
|
defaultKdfIterations: 600000,
|
||||||
|
registrationInviteRequired: true,
|
||||||
jwtWarning: null,
|
jwtWarning: null,
|
||||||
session: null,
|
session: null,
|
||||||
phase: 'login',
|
phase: 'login',
|
||||||
@@ -909,6 +910,8 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
|
|||||||
authorizedDevices: state.authorizedDevices,
|
authorizedDevices: state.authorizedDevices,
|
||||||
authorizedDevicesLoading: false,
|
authorizedDevicesLoading: false,
|
||||||
authorizedDevicesError: '',
|
authorizedDevicesError: '',
|
||||||
|
domainRulesLoading: false,
|
||||||
|
domainRulesError: '',
|
||||||
onImport: async () => {
|
onImport: async () => {
|
||||||
await readonly();
|
await readonly();
|
||||||
return createDemoImportResult();
|
return createDemoImportResult();
|
||||||
@@ -1055,6 +1058,10 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
|
|||||||
onRefreshAuthorizedDevices: async () => {
|
onRefreshAuthorizedDevices: async () => {
|
||||||
notify('success', t('txt_demo_devices_refreshed'));
|
notify('success', t('txt_demo_devices_refreshed'));
|
||||||
},
|
},
|
||||||
|
onRefreshDomainRules: () => {
|
||||||
|
notify('success', t('txt_domain_rules_refreshed'));
|
||||||
|
},
|
||||||
|
onSaveDomainRules: readonly,
|
||||||
onRenameAuthorizedDevice: async (device, name) => {
|
onRenameAuthorizedDevice: async (device, name) => {
|
||||||
const normalized = String(name || '').trim();
|
const normalized = String(name || '').trim();
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
// CONTRACT:
|
||||||
|
// Locale bundles are standalone and loaded on demand. Adding a locale requires
|
||||||
|
// updating Locale, AVAILABLE_LOCALES, browser-language detection, localeLoaders,
|
||||||
|
// scripts/i18n-utils.cjs, and the locale file itself.
|
||||||
|
//
|
||||||
|
// Do not call t() at module scope for exported arrays/constants; async init can
|
||||||
|
// otherwise leave raw txt_* keys in the rendered UI.
|
||||||
export type Locale =
|
export type Locale =
|
||||||
| 'en'
|
| 'en'
|
||||||
| 'zh-CN'
|
| 'zh-CN'
|
||||||
@@ -86,6 +93,41 @@ export function t(key: string, params?: I18nParams): string {
|
|||||||
return template.replace(/\{(\w+)\}/g, (_, name: string) => String(params[name] ?? ''));
|
return template.replace(/\{(\w+)\}/g, (_, name: string) => String(params[name] ?? ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function translateServerError(message: string | null | undefined, fallback: string): string {
|
||||||
|
const normalized = String(message || '').trim();
|
||||||
|
if (!normalized) return fallback;
|
||||||
|
|
||||||
|
const rateLimitMatch = normalized.match(/^Rate limit exceeded\. Try again in (\d+) seconds\.$/i);
|
||||||
|
if (rateLimitMatch) {
|
||||||
|
return t('txt_rate_limit_try_again_seconds', { seconds: rateLimitMatch[1] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = {
|
||||||
|
'Account is disabled': 'txt_server_error_account_disabled',
|
||||||
|
'Client IP is required': 'txt_server_error_client_ip_required',
|
||||||
|
'ClientId or clientSecret is incorrect. Try again': 'txt_server_error_client_credentials_incorrect',
|
||||||
|
'Email already registered': 'txt_server_error_email_already_registered',
|
||||||
|
'Email and password are required': 'txt_server_error_email_password_required',
|
||||||
|
'Email is required': 'txt_server_error_email_required',
|
||||||
|
'Invite code is invalid or expired': 'txt_server_error_invite_invalid_or_expired',
|
||||||
|
'Invite code is required': 'txt_server_error_invite_required',
|
||||||
|
'Invalid refresh token': 'txt_server_error_invalid_refresh_token',
|
||||||
|
'Invalid request payload': 'txt_server_error_invalid_request_payload',
|
||||||
|
'JWT_SECRET is not set': 'txt_server_error_jwt_secret_missing',
|
||||||
|
'JWT_SECRET is using the default/sample value. Please change it.': 'txt_server_error_jwt_secret_default',
|
||||||
|
'JWT_SECRET must be at least 32 characters': 'txt_server_error_jwt_secret_too_short',
|
||||||
|
'Parameter error': 'txt_server_error_parameter_error',
|
||||||
|
'Refresh token is required': 'txt_server_error_refresh_token_required',
|
||||||
|
'Registration is temporarily unavailable, retry once': 'txt_server_error_registration_retry',
|
||||||
|
'TOTP token is required': 'txt_server_error_totp_token_required',
|
||||||
|
'Two factor required.': 'txt_server_error_two_factor_required',
|
||||||
|
'Two-step token is invalid. Try again.': 'txt_server_error_two_factor_invalid',
|
||||||
|
'Username or password is incorrect. Try again': 'txt_server_error_username_password_incorrect',
|
||||||
|
}[normalized];
|
||||||
|
|
||||||
|
return key ? t(key) : normalized;
|
||||||
|
}
|
||||||
|
|
||||||
export function getLocale(): Locale {
|
export function getLocale(): Locale {
|
||||||
return locale;
|
return locale;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ const en: Record<string, string> = {
|
|||||||
"nav_admin_panel": "Admin Panel",
|
"nav_admin_panel": "Admin Panel",
|
||||||
"nav_device_management": "Device Management",
|
"nav_device_management": "Device Management",
|
||||||
"nav_my_vault": "My Vault",
|
"nav_my_vault": "My Vault",
|
||||||
|
"nav_vault_items": "Vault",
|
||||||
"nav_sends": "Sends",
|
"nav_sends": "Sends",
|
||||||
"nav_backup_strategy": "Cloud Backup",
|
"nav_backup_strategy": "Cloud Backup",
|
||||||
"nav_import_export": "Import & Export",
|
"nav_import_export": "Import & Export",
|
||||||
|
"nav_group_data_backup": "Data & Backup",
|
||||||
|
"nav_group_management": "Management",
|
||||||
"txt_page_not_found": "Page Not Found",
|
"txt_page_not_found": "Page Not Found",
|
||||||
"txt_page_not_found_hint": "The page may have been removed, expired, or the link is incomplete.",
|
"txt_page_not_found_hint": "The page may have been removed, expired, or the link is incomplete.",
|
||||||
"txt_back_to_home": "Back To Home",
|
"txt_back_to_home": "Back To Home",
|
||||||
@@ -346,6 +349,7 @@ const en: Record<string, string> = {
|
|||||||
"txt_create": "Create",
|
"txt_create": "Create",
|
||||||
"txt_create_account": "Create Account",
|
"txt_create_account": "Create Account",
|
||||||
"txt_registering": "Creating account...",
|
"txt_registering": "Creating account...",
|
||||||
|
"txt_register_failed": "Register failed",
|
||||||
"txt_create_folder": "Create Folder",
|
"txt_create_folder": "Create Folder",
|
||||||
"txt_create_folder_failed": "Create folder failed",
|
"txt_create_folder_failed": "Create folder failed",
|
||||||
"txt_create_item_failed": "Create item failed",
|
"txt_create_item_failed": "Create item failed",
|
||||||
@@ -405,6 +409,7 @@ const en: Record<string, string> = {
|
|||||||
"txt_disable_this_send": "Disable this send",
|
"txt_disable_this_send": "Disable this send",
|
||||||
"txt_disable_totp": "Disable TOTP",
|
"txt_disable_totp": "Disable TOTP",
|
||||||
"txt_disable_totp_failed": "Disable TOTP failed",
|
"txt_disable_totp_failed": "Disable TOTP failed",
|
||||||
|
"txt_totp_update_failed": "Update TOTP failed",
|
||||||
"txt_download": "Download",
|
"txt_download": "Download",
|
||||||
"txt_downloading": "Downloading...",
|
"txt_downloading": "Downloading...",
|
||||||
"txt_downloading_percent": "Downloading {percent}%",
|
"txt_downloading_percent": "Downloading {percent}%",
|
||||||
@@ -464,12 +469,33 @@ const en: Record<string, string> = {
|
|||||||
"txt_identity_details": "Identity Details",
|
"txt_identity_details": "Identity Details",
|
||||||
"txt_ie_browser": "IE Browser",
|
"txt_ie_browser": "IE Browser",
|
||||||
"txt_create_invite_failed": "Failed to create invite",
|
"txt_create_invite_failed": "Failed to create invite",
|
||||||
"txt_invite_code_optional": "Invite Code (Not required for the first account; required for all others)",
|
"txt_invite_code_required": "Invite Code (Required)",
|
||||||
"txt_invite_created": "Invite created",
|
"txt_invite_created": "Invite created",
|
||||||
"txt_invite_revoked": "Invite revoked",
|
"txt_invite_revoked": "Invite revoked",
|
||||||
"txt_revoke_invite_failed": "Failed to revoke invite",
|
"txt_revoke_invite_failed": "Failed to revoke invite",
|
||||||
"txt_invite_validity_hours": "Invite validity (hours)",
|
"txt_invite_validity_hours": "Invite validity (hours)",
|
||||||
"txt_invites": "Invites",
|
"txt_invites": "Invites",
|
||||||
|
"txt_rate_limit_try_again_seconds": "Too many requests. Try again in {seconds} seconds.",
|
||||||
|
"txt_server_error_account_disabled": "Account is disabled",
|
||||||
|
"txt_server_error_client_credentials_incorrect": "Client ID or client secret is incorrect. Try again.",
|
||||||
|
"txt_server_error_client_ip_required": "Client IP is required",
|
||||||
|
"txt_server_error_email_already_registered": "Email already registered",
|
||||||
|
"txt_server_error_email_password_required": "Email and password are required",
|
||||||
|
"txt_server_error_email_required": "Email is required",
|
||||||
|
"txt_server_error_invalid_refresh_token": "Session expired. Please sign in again.",
|
||||||
|
"txt_server_error_invalid_request_payload": "Invalid request payload",
|
||||||
|
"txt_server_error_invite_invalid_or_expired": "Invite code is invalid or expired",
|
||||||
|
"txt_server_error_invite_required": "Invite code is required",
|
||||||
|
"txt_server_error_jwt_secret_default": "JWT_SECRET is using the default/sample value. Please change it.",
|
||||||
|
"txt_server_error_jwt_secret_missing": "JWT_SECRET is not set",
|
||||||
|
"txt_server_error_jwt_secret_too_short": "JWT_SECRET must be at least 32 characters",
|
||||||
|
"txt_server_error_parameter_error": "Parameter error",
|
||||||
|
"txt_server_error_refresh_token_required": "Session is missing. Please sign in again.",
|
||||||
|
"txt_server_error_registration_retry": "Registration is temporarily unavailable. Please retry once.",
|
||||||
|
"txt_server_error_totp_token_required": "Two-step token is required",
|
||||||
|
"txt_server_error_two_factor_invalid": "Two-step token is invalid. Try again.",
|
||||||
|
"txt_server_error_two_factor_required": "Two factor required.",
|
||||||
|
"txt_server_error_username_password_incorrect": "Username or password is incorrect. Try again.",
|
||||||
"txt_ios": "iOS",
|
"txt_ios": "iOS",
|
||||||
"txt_item": "Item",
|
"txt_item": "Item",
|
||||||
"txt_item_created": "Item created",
|
"txt_item_created": "Item created",
|
||||||
@@ -543,12 +569,14 @@ const en: Record<string, string> = {
|
|||||||
"txt_master_password_is_required": "Master password is required",
|
"txt_master_password_is_required": "Master password is required",
|
||||||
"txt_master_password_is_required_2": "Master password is required.",
|
"txt_master_password_is_required_2": "Master password is required.",
|
||||||
"txt_master_password_must_be_at_least_12_chars": "Master password must be at least 12 chars",
|
"txt_master_password_must_be_at_least_12_chars": "Master password must be at least 12 chars",
|
||||||
|
"txt_master_password_verify_failed": "Master password verify failed",
|
||||||
"txt_master_password_reprompt": "Master password reprompt",
|
"txt_master_password_reprompt": "Master password reprompt",
|
||||||
"txt_master_password_reprompt_2": "Master Password Reprompt",
|
"txt_master_password_reprompt_2": "Master Password Reprompt",
|
||||||
"txt_max_access_count": "Max Access Count",
|
"txt_max_access_count": "Max Access Count",
|
||||||
"txt_middle_name": "Middle Name",
|
"txt_middle_name": "Middle Name",
|
||||||
"txt_drag_to_reorder": "Drag to reorder",
|
|
||||||
"txt_move": "Move",
|
"txt_move": "Move",
|
||||||
|
"txt_move_up": "Move up",
|
||||||
|
"txt_move_down": "Move down",
|
||||||
"txt_move_selected_items": "Move Selected Items",
|
"txt_move_selected_items": "Move Selected Items",
|
||||||
"txt_moved_selected_items": "Moved selected items",
|
"txt_moved_selected_items": "Moved selected items",
|
||||||
"txt_name": "Name",
|
"txt_name": "Name",
|
||||||
@@ -628,6 +656,9 @@ const en: Record<string, string> = {
|
|||||||
"txt_api_key_rotated": "API key rotated",
|
"txt_api_key_rotated": "API key rotated",
|
||||||
"txt_rotate_api_key_confirm": "Rotate API key? The current key will stop working immediately.",
|
"txt_rotate_api_key_confirm": "Rotate API key? The current key will stop working immediately.",
|
||||||
"txt_api_key_is_empty": "API key is empty",
|
"txt_api_key_is_empty": "API key is empty",
|
||||||
|
"txt_get_api_key_failed": "Failed to get API key",
|
||||||
|
"txt_get_recovery_code_failed": "Failed to get recovery code",
|
||||||
|
"txt_rotate_api_key_failed": "Failed to rotate API key",
|
||||||
"txt_api_key_dialog_intro": "Your API key can be used to authenticate with the Bitwarden CLI.",
|
"txt_api_key_dialog_intro": "Your API key can be used to authenticate with the Bitwarden CLI.",
|
||||||
"txt_api_key_warning_body": "Your API key is an alternative authentication mechanism. Keep it secret.",
|
"txt_api_key_warning_body": "Your API key is an alternative authentication mechanism. Keep it secret.",
|
||||||
"txt_oauth_client_credentials": "OAuth 2.0 Client Credentials",
|
"txt_oauth_client_credentials": "OAuth 2.0 Client Credentials",
|
||||||
@@ -665,6 +696,7 @@ const en: Record<string, string> = {
|
|||||||
"txt_save_profile": "Save Profile",
|
"txt_save_profile": "Save Profile",
|
||||||
"txt_save_profile_failed": "Save profile failed",
|
"txt_save_profile_failed": "Save profile failed",
|
||||||
"txt_search_sends": "Search sends...",
|
"txt_search_sends": "Search sends...",
|
||||||
|
"txt_session_refresh_failed": "Session refresh failed. Please sign in again.",
|
||||||
"txt_search_your_secure_vault": "Search your secure vault...",
|
"txt_search_your_secure_vault": "Search your secure vault...",
|
||||||
"txt_clear_search": "Clear search",
|
"txt_clear_search": "Clear search",
|
||||||
"txt_clear_search_esc": "Clear search (Esc)",
|
"txt_clear_search_esc": "Clear search (Esc)",
|
||||||
@@ -678,6 +710,7 @@ const en: Record<string, string> = {
|
|||||||
"txt_security_code": "Security Code",
|
"txt_security_code": "Security Code",
|
||||||
"txt_security_code_cvv": "Security Code (CVV)",
|
"txt_security_code_cvv": "Security Code (CVV)",
|
||||||
"txt_select_all": "Select All",
|
"txt_select_all": "Select All",
|
||||||
|
"txt_select": "Select",
|
||||||
"txt_select_duplicate_items": "Select Duplicates",
|
"txt_select_duplicate_items": "Select Duplicates",
|
||||||
"txt_select_an_item": "Select an item",
|
"txt_select_an_item": "Select an item",
|
||||||
"txt_send_created": "Send created",
|
"txt_send_created": "Send created",
|
||||||
@@ -762,13 +795,13 @@ const en: Record<string, string> = {
|
|||||||
"txt_user_deleted": "User deleted",
|
"txt_user_deleted": "User deleted",
|
||||||
"txt_user_status_updated": "User status updated",
|
"txt_user_status_updated": "User status updated",
|
||||||
"txt_username": "Username",
|
"txt_username": "Username",
|
||||||
"txt_uri_match_default_base_domain": "Default (Base Domain)",
|
"txt_uri_match_default_base_domain": "Default",
|
||||||
"txt_uri_match_base_domain": "Base Domain",
|
"txt_uri_match_base_domain": "Base Domain",
|
||||||
"txt_uri_match_host": "Host",
|
"txt_uri_match_host": "Host",
|
||||||
"txt_uri_match_exact": "Exact",
|
"txt_uri_match_exact": "Exact",
|
||||||
"txt_uri_match_never": "Never",
|
"txt_uri_match_never": "Never",
|
||||||
"txt_uri_match_starts_with": "Starts With",
|
"txt_uri_match_starts_with": "Starts With",
|
||||||
"txt_uri_match_regular_expression": "Regular Expression",
|
"txt_uri_match_regular_expression": "Regex",
|
||||||
"txt_users": "Users",
|
"txt_users": "Users",
|
||||||
"txt_vault_synced": "Vault synced",
|
"txt_vault_synced": "Vault synced",
|
||||||
"txt_verification_code": "Verification Code",
|
"txt_verification_code": "Verification Code",
|
||||||
@@ -874,7 +907,35 @@ const en: Record<string, string> = {
|
|||||||
"txt_status_inactive": "Inactive",
|
"txt_status_inactive": "Inactive",
|
||||||
"txt_language": "Language",
|
"txt_language": "Language",
|
||||||
"txt_display_language": "Display language",
|
"txt_display_language": "Display language",
|
||||||
"txt_language_saved_locally": "This preference is saved in this browser and used before the app loads next time."
|
"txt_language_saved_locally": "This preference is saved in this browser and used before the app loads next time.",
|
||||||
|
"nav_domain_rules": "Domain Rules",
|
||||||
|
"txt_domain_rules_description": "Mark sites that share one login as equivalent domains. Global rules come from the preset list; custom rules only affect your own matching.",
|
||||||
|
"txt_submit_pr": "Submit PR",
|
||||||
|
"txt_custom_equivalent_domains": "Custom equivalent domains",
|
||||||
|
"txt_global_equivalent_domains": "Global equivalent domains",
|
||||||
|
"txt_domain_group": "Domain group",
|
||||||
|
"txt_no_custom_domain_rules": "No custom domain rules",
|
||||||
|
"txt_no_domain_rules_found": "No domain rules found",
|
||||||
|
"txt_search_domains": "Search domains",
|
||||||
|
"txt_domain_rules_saved": "Domain rules saved",
|
||||||
|
"txt_domain_rules_save_failed": "Saving domain rules failed",
|
||||||
|
"txt_domain_rules_load_failed": "Loading domain rules failed",
|
||||||
|
"txt_domain_rules_invalid_response": "Invalid domain rules response",
|
||||||
|
"txt_domain_rules_refreshed": "Domain rules refreshed",
|
||||||
|
"txt_saving": "Saving...",
|
||||||
|
"txt_domain_rule_needs_two_domains": "Each domain rule needs at least two domains.",
|
||||||
|
"txt_domain_rule_invalid_domains": "Please enter valid domains, such as example.com.",
|
||||||
|
"txt_add_domain": "Add domain",
|
||||||
|
"txt_expand": "Expand",
|
||||||
|
"txt_collapse": "Collapse",
|
||||||
|
"txt_nav_layout": "Navigation style",
|
||||||
|
"txt_nav_layout_flat": "Flat",
|
||||||
|
"txt_nav_layout_flat_desc": "Show every page directly",
|
||||||
|
"txt_nav_layout_grouped_expanded": "Grouped",
|
||||||
|
"txt_nav_layout_grouped_expanded_desc": "Keep all groups expanded",
|
||||||
|
"txt_nav_layout_grouped_smart": "Smart groups",
|
||||||
|
"txt_nav_layout_grouped_smart_desc": "Open active groups as needed",
|
||||||
|
"txt_remove_domain": "Remove domain"
|
||||||
};
|
};
|
||||||
|
|
||||||
export default en;
|
export default en;
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ const es: Record<string, string> = {
|
|||||||
"nav_admin_panel": "Panel de administración",
|
"nav_admin_panel": "Panel de administración",
|
||||||
"nav_device_management": "Gestión de dispositivos",
|
"nav_device_management": "Gestión de dispositivos",
|
||||||
"nav_my_vault": "Mi bóveda",
|
"nav_my_vault": "Mi bóveda",
|
||||||
|
"nav_vault_items": "Bóveda",
|
||||||
"nav_sends": "Envíos",
|
"nav_sends": "Envíos",
|
||||||
"nav_backup_strategy": "Copia de seguridad en la nube",
|
"nav_backup_strategy": "Copia de seguridad en la nube",
|
||||||
"nav_import_export": "Importar y exportar",
|
"nav_import_export": "Importar y exportar",
|
||||||
|
"nav_group_data_backup": "Datos y copias",
|
||||||
|
"nav_group_management": "Gestión",
|
||||||
"txt_page_not_found": "Página no encontrada",
|
"txt_page_not_found": "Página no encontrada",
|
||||||
"txt_page_not_found_hint": "La página pudo haberse eliminado, expirado, o el enlace está incompleto.",
|
"txt_page_not_found_hint": "La página pudo haberse eliminado, expirado, o el enlace está incompleto.",
|
||||||
"txt_back_to_home": "Volver al inicio",
|
"txt_back_to_home": "Volver al inicio",
|
||||||
@@ -346,6 +349,7 @@ const es: Record<string, string> = {
|
|||||||
"txt_create": "Crear",
|
"txt_create": "Crear",
|
||||||
"txt_create_account": "Crear cuenta",
|
"txt_create_account": "Crear cuenta",
|
||||||
"txt_registering": "Creando cuenta...",
|
"txt_registering": "Creando cuenta...",
|
||||||
|
"txt_register_failed": "Error al registrarse",
|
||||||
"txt_create_folder": "Crear carpeta",
|
"txt_create_folder": "Crear carpeta",
|
||||||
"txt_create_folder_failed": "Error al crear carpeta",
|
"txt_create_folder_failed": "Error al crear carpeta",
|
||||||
"txt_create_item_failed": "Error al crear elemento",
|
"txt_create_item_failed": "Error al crear elemento",
|
||||||
@@ -405,6 +409,7 @@ const es: Record<string, string> = {
|
|||||||
"txt_disable_this_send": "Desactivar este envío",
|
"txt_disable_this_send": "Desactivar este envío",
|
||||||
"txt_disable_totp": "Desactivar TOTP",
|
"txt_disable_totp": "Desactivar TOTP",
|
||||||
"txt_disable_totp_failed": "Error al desactivar TOTP",
|
"txt_disable_totp_failed": "Error al desactivar TOTP",
|
||||||
|
"txt_totp_update_failed": "Error al actualizar TOTP",
|
||||||
"txt_download": "Descargar",
|
"txt_download": "Descargar",
|
||||||
"txt_downloading": "Descargando...",
|
"txt_downloading": "Descargando...",
|
||||||
"txt_downloading_percent": "Descargando {percent}%",
|
"txt_downloading_percent": "Descargando {percent}%",
|
||||||
@@ -464,12 +469,33 @@ const es: Record<string, string> = {
|
|||||||
"txt_identity_details": "Detalles de identidad",
|
"txt_identity_details": "Detalles de identidad",
|
||||||
"txt_ie_browser": "Navegador Internet Explorer",
|
"txt_ie_browser": "Navegador Internet Explorer",
|
||||||
"txt_create_invite_failed": "Error al crear invitación",
|
"txt_create_invite_failed": "Error al crear invitación",
|
||||||
"txt_invite_code_optional": "Código de invitación (No obligatorio para la primera cuenta; obligatorio para todas las demás)",
|
"txt_invite_code_required": "Código de invitación (obligatorio)",
|
||||||
"txt_invite_created": "Invitación creada",
|
"txt_invite_created": "Invitación creada",
|
||||||
"txt_invite_revoked": "Invitación revocada",
|
"txt_invite_revoked": "Invitación revocada",
|
||||||
"txt_revoke_invite_failed": "Error al revocar invitación",
|
"txt_revoke_invite_failed": "Error al revocar invitación",
|
||||||
"txt_invite_validity_hours": "Validez de la invitación en horas",
|
"txt_invite_validity_hours": "Validez de la invitación en horas",
|
||||||
"txt_invites": "Invitaciones",
|
"txt_invites": "Invitaciones",
|
||||||
|
"txt_rate_limit_try_again_seconds": "Demasiadas solicitudes. Inténtalo de nuevo en {seconds} segundos.",
|
||||||
|
"txt_server_error_account_disabled": "La cuenta está deshabilitada",
|
||||||
|
"txt_server_error_client_credentials_incorrect": "El ID de cliente o el secreto de cliente no son correctos. Inténtalo de nuevo.",
|
||||||
|
"txt_server_error_client_ip_required": "Se requiere la IP del cliente",
|
||||||
|
"txt_server_error_email_already_registered": "Este correo ya está registrado",
|
||||||
|
"txt_server_error_email_password_required": "Correo y contraseña son obligatorios",
|
||||||
|
"txt_server_error_email_required": "El correo es obligatorio",
|
||||||
|
"txt_server_error_invalid_refresh_token": "La sesión caducó. Inicia sesión de nuevo.",
|
||||||
|
"txt_server_error_invalid_request_payload": "Solicitud no válida",
|
||||||
|
"txt_server_error_invite_invalid_or_expired": "El código de invitación no es válido o ha caducado",
|
||||||
|
"txt_server_error_invite_required": "El código de invitación es obligatorio",
|
||||||
|
"txt_server_error_jwt_secret_default": "JWT_SECRET usa el valor predeterminado/de ejemplo. Cámbialo.",
|
||||||
|
"txt_server_error_jwt_secret_missing": "JWT_SECRET no está configurado",
|
||||||
|
"txt_server_error_jwt_secret_too_short": "JWT_SECRET debe tener al menos 32 caracteres",
|
||||||
|
"txt_server_error_parameter_error": "Error de parámetros",
|
||||||
|
"txt_server_error_refresh_token_required": "Falta la sesión. Inicia sesión de nuevo.",
|
||||||
|
"txt_server_error_registration_retry": "El registro no está disponible temporalmente. Inténtalo una vez más.",
|
||||||
|
"txt_server_error_totp_token_required": "El código de verificación en dos pasos es obligatorio",
|
||||||
|
"txt_server_error_two_factor_invalid": "El código de verificación en dos pasos no es válido. Inténtalo de nuevo.",
|
||||||
|
"txt_server_error_two_factor_required": "Se requiere verificación en dos pasos.",
|
||||||
|
"txt_server_error_username_password_incorrect": "Usuario o contraseña incorrectos. Inténtalo de nuevo.",
|
||||||
"txt_ios": "iOS",
|
"txt_ios": "iOS",
|
||||||
"txt_item": "Elemento",
|
"txt_item": "Elemento",
|
||||||
"txt_item_created": "Elemento creado",
|
"txt_item_created": "Elemento creado",
|
||||||
@@ -543,12 +569,14 @@ const es: Record<string, string> = {
|
|||||||
"txt_master_password_is_required": "La contraseña maestra es obligatoria",
|
"txt_master_password_is_required": "La contraseña maestra es obligatoria",
|
||||||
"txt_master_password_is_required_2": "La contraseña maestra es obligatoria.",
|
"txt_master_password_is_required_2": "La contraseña maestra es obligatoria.",
|
||||||
"txt_master_password_must_be_at_least_12_chars": "La contraseña maestra debe tener al menos 12 caracteres",
|
"txt_master_password_must_be_at_least_12_chars": "La contraseña maestra debe tener al menos 12 caracteres",
|
||||||
|
"txt_master_password_verify_failed": "Error al verificar la contraseña maestra",
|
||||||
"txt_master_password_reprompt": "Solicitar contraseña maestra de nuevo",
|
"txt_master_password_reprompt": "Solicitar contraseña maestra de nuevo",
|
||||||
"txt_master_password_reprompt_2": "Solicitar contraseña maestra de nuevo",
|
"txt_master_password_reprompt_2": "Solicitar contraseña maestra de nuevo",
|
||||||
"txt_max_access_count": "Número máximo de accesos",
|
"txt_max_access_count": "Número máximo de accesos",
|
||||||
"txt_middle_name": "Segundo nombre",
|
"txt_middle_name": "Segundo nombre",
|
||||||
"txt_drag_to_reorder": "Arrastre para reordenar",
|
|
||||||
"txt_move": "Mover",
|
"txt_move": "Mover",
|
||||||
|
"txt_move_up": "Mover arriba",
|
||||||
|
"txt_move_down": "Mover abajo",
|
||||||
"txt_move_selected_items": "Mover elementos seleccionados",
|
"txt_move_selected_items": "Mover elementos seleccionados",
|
||||||
"txt_moved_selected_items": "Elementos seleccionados movidos",
|
"txt_moved_selected_items": "Elementos seleccionados movidos",
|
||||||
"txt_name": "Nombre",
|
"txt_name": "Nombre",
|
||||||
@@ -628,6 +656,9 @@ const es: Record<string, string> = {
|
|||||||
"txt_api_key_rotated": "Clave API rotada",
|
"txt_api_key_rotated": "Clave API rotada",
|
||||||
"txt_rotate_api_key_confirm": "¿Rotar clave API? La clave actual dejará de funcionar inmediatamente.",
|
"txt_rotate_api_key_confirm": "¿Rotar clave API? La clave actual dejará de funcionar inmediatamente.",
|
||||||
"txt_api_key_is_empty": "La clave API está vacía",
|
"txt_api_key_is_empty": "La clave API está vacía",
|
||||||
|
"txt_get_api_key_failed": "Error al obtener la clave API",
|
||||||
|
"txt_get_recovery_code_failed": "Error al obtener el código de recuperación",
|
||||||
|
"txt_rotate_api_key_failed": "Error al rotar la clave API",
|
||||||
"txt_api_key_dialog_intro": "Su clave API puede usarse para autenticarse con la CLI de Bitwarden.",
|
"txt_api_key_dialog_intro": "Su clave API puede usarse para autenticarse con la CLI de Bitwarden.",
|
||||||
"txt_api_key_warning_body": "Su clave API es un mecanismo de autenticación alternativo. Manténgala secreta.",
|
"txt_api_key_warning_body": "Su clave API es un mecanismo de autenticación alternativo. Manténgala secreta.",
|
||||||
"txt_oauth_client_credentials": "Credenciales de cliente OAuth 2.0",
|
"txt_oauth_client_credentials": "Credenciales de cliente OAuth 2.0",
|
||||||
@@ -665,6 +696,7 @@ const es: Record<string, string> = {
|
|||||||
"txt_save_profile": "Guardar perfil",
|
"txt_save_profile": "Guardar perfil",
|
||||||
"txt_save_profile_failed": "Error al guardar perfil",
|
"txt_save_profile_failed": "Error al guardar perfil",
|
||||||
"txt_search_sends": "Buscar envíos...",
|
"txt_search_sends": "Buscar envíos...",
|
||||||
|
"txt_session_refresh_failed": "Error al actualizar la sesión. Inicia sesión de nuevo.",
|
||||||
"txt_search_your_secure_vault": "Buscar en su bóveda segura...",
|
"txt_search_your_secure_vault": "Buscar en su bóveda segura...",
|
||||||
"txt_clear_search": "Limpiar búsqueda",
|
"txt_clear_search": "Limpiar búsqueda",
|
||||||
"txt_clear_search_esc": "Limpiar búsqueda (Esc)",
|
"txt_clear_search_esc": "Limpiar búsqueda (Esc)",
|
||||||
@@ -678,6 +710,7 @@ const es: Record<string, string> = {
|
|||||||
"txt_security_code": "Código de seguridad",
|
"txt_security_code": "Código de seguridad",
|
||||||
"txt_security_code_cvv": "Código de seguridad (CVV)",
|
"txt_security_code_cvv": "Código de seguridad (CVV)",
|
||||||
"txt_select_all": "Seleccionar todo",
|
"txt_select_all": "Seleccionar todo",
|
||||||
|
"txt_select": "Seleccionar",
|
||||||
"txt_select_duplicate_items": "Seleccionar duplicados",
|
"txt_select_duplicate_items": "Seleccionar duplicados",
|
||||||
"txt_select_an_item": "Seleccione un elemento",
|
"txt_select_an_item": "Seleccione un elemento",
|
||||||
"txt_send_created": "Envío creado",
|
"txt_send_created": "Envío creado",
|
||||||
@@ -762,13 +795,13 @@ const es: Record<string, string> = {
|
|||||||
"txt_user_deleted": "Usuario eliminado",
|
"txt_user_deleted": "Usuario eliminado",
|
||||||
"txt_user_status_updated": "Estado del usuario actualizado",
|
"txt_user_status_updated": "Estado del usuario actualizado",
|
||||||
"txt_username": "Nombre de usuario",
|
"txt_username": "Nombre de usuario",
|
||||||
"txt_uri_match_default_base_domain": "Predeterminado (dominio base)",
|
"txt_uri_match_default_base_domain": "Predet.",
|
||||||
"txt_uri_match_base_domain": "Dominio base",
|
"txt_uri_match_base_domain": "Dominio base",
|
||||||
"txt_uri_match_host": "Host",
|
"txt_uri_match_host": "Host",
|
||||||
"txt_uri_match_exact": "Exacto",
|
"txt_uri_match_exact": "Exacto",
|
||||||
"txt_uri_match_never": "Nunca",
|
"txt_uri_match_never": "Nunca",
|
||||||
"txt_uri_match_starts_with": "Empieza con",
|
"txt_uri_match_starts_with": "Empieza con",
|
||||||
"txt_uri_match_regular_expression": "Expresión regular",
|
"txt_uri_match_regular_expression": "Regex",
|
||||||
"txt_users": "Usuarios",
|
"txt_users": "Usuarios",
|
||||||
"txt_vault_synced": "Bóveda sincronizada",
|
"txt_vault_synced": "Bóveda sincronizada",
|
||||||
"txt_verification_code": "Código de verificación",
|
"txt_verification_code": "Código de verificación",
|
||||||
@@ -874,7 +907,35 @@ const es: Record<string, string> = {
|
|||||||
"txt_status_inactive": "Inactivo",
|
"txt_status_inactive": "Inactivo",
|
||||||
"txt_language": "Idioma",
|
"txt_language": "Idioma",
|
||||||
"txt_display_language": "Idioma de visualización",
|
"txt_display_language": "Idioma de visualización",
|
||||||
"txt_language_saved_locally": "Esta preferencia se guarda en este navegador y se usa antes de que la aplicación cargue la próxima vez."
|
"txt_language_saved_locally": "Esta preferencia se guarda en este navegador y se usa antes de que la aplicación cargue la próxima vez.",
|
||||||
|
"nav_domain_rules": "Reglas de dominio",
|
||||||
|
"txt_domain_rules_description": "Marca los sitios que comparten un inicio de sesión como dominios equivalentes. Las reglas globales vienen de la lista predefinida; las personalizadas solo afectan tus coincidencias.",
|
||||||
|
"txt_submit_pr": "Enviar PR",
|
||||||
|
"txt_custom_equivalent_domains": "Dominios equivalentes personalizados",
|
||||||
|
"txt_global_equivalent_domains": "Dominios equivalentes globales",
|
||||||
|
"txt_domain_group": "Grupo de dominios",
|
||||||
|
"txt_no_custom_domain_rules": "No hay reglas de dominio personalizadas",
|
||||||
|
"txt_no_domain_rules_found": "No se encontraron reglas de dominio",
|
||||||
|
"txt_search_domains": "Buscar dominios",
|
||||||
|
"txt_domain_rules_saved": "Reglas de dominio guardadas",
|
||||||
|
"txt_domain_rules_save_failed": "No se pudieron guardar las reglas de dominio",
|
||||||
|
"txt_domain_rules_load_failed": "No se pudieron cargar las reglas de dominio",
|
||||||
|
"txt_domain_rules_invalid_response": "Respuesta de reglas de dominio no válida",
|
||||||
|
"txt_domain_rules_refreshed": "Reglas de dominio actualizadas",
|
||||||
|
"txt_saving": "Guardando...",
|
||||||
|
"txt_domain_rule_needs_two_domains": "Cada regla de dominio necesita al menos dos dominios.",
|
||||||
|
"txt_domain_rule_invalid_domains": "Introduce dominios válidos, como example.com.",
|
||||||
|
"txt_add_domain": "Añadir dominio",
|
||||||
|
"txt_expand": "Expandir",
|
||||||
|
"txt_collapse": "Contraer",
|
||||||
|
"txt_nav_layout": "Estilo de navegación",
|
||||||
|
"txt_nav_layout_flat": "Plano",
|
||||||
|
"txt_nav_layout_flat_desc": "Mostrar cada página directamente",
|
||||||
|
"txt_nav_layout_grouped_expanded": "Agrupado",
|
||||||
|
"txt_nav_layout_grouped_expanded_desc": "Mantener todos los grupos abiertos",
|
||||||
|
"txt_nav_layout_grouped_smart": "Grupos inteligentes",
|
||||||
|
"txt_nav_layout_grouped_smart_desc": "Abrir grupos activos cuando haga falta",
|
||||||
|
"txt_remove_domain": "Quitar dominio"
|
||||||
};
|
};
|
||||||
|
|
||||||
export default es;
|
export default es;
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ const ru: Record<string, string> = {
|
|||||||
"nav_admin_panel": "Панель администратора",
|
"nav_admin_panel": "Панель администратора",
|
||||||
"nav_device_management": "Управление устройствами",
|
"nav_device_management": "Управление устройствами",
|
||||||
"nav_my_vault": "Мое хранилище",
|
"nav_my_vault": "Мое хранилище",
|
||||||
|
"nav_vault_items": "Хранилище",
|
||||||
"nav_sends": "Отправляет",
|
"nav_sends": "Отправляет",
|
||||||
"nav_backup_strategy": "Облачное резервное копирование",
|
"nav_backup_strategy": "Облачное резервное копирование",
|
||||||
"nav_import_export": "Импорт и экспорт",
|
"nav_import_export": "Импорт и экспорт",
|
||||||
|
"nav_group_data_backup": "Данные и резервные копии",
|
||||||
|
"nav_group_management": "Управление",
|
||||||
"txt_page_not_found": "Страница не найдена",
|
"txt_page_not_found": "Страница не найдена",
|
||||||
"txt_page_not_found_hint": "Страница могла быть удалена, срок ее действия истек, или ссылка неполная.",
|
"txt_page_not_found_hint": "Страница могла быть удалена, срок ее действия истек, или ссылка неполная.",
|
||||||
"txt_back_to_home": "На главную",
|
"txt_back_to_home": "На главную",
|
||||||
@@ -346,6 +349,7 @@ const ru: Record<string, string> = {
|
|||||||
"txt_create": "Создать",
|
"txt_create": "Создать",
|
||||||
"txt_create_account": "Создать учетную запись",
|
"txt_create_account": "Создать учетную запись",
|
||||||
"txt_registering": "Создание учетной записи...",
|
"txt_registering": "Создание учетной записи...",
|
||||||
|
"txt_register_failed": "Не удалось зарегистрироваться",
|
||||||
"txt_create_folder": "Создать папку",
|
"txt_create_folder": "Создать папку",
|
||||||
"txt_create_folder_failed": "Создать папку не удалось",
|
"txt_create_folder_failed": "Создать папку не удалось",
|
||||||
"txt_create_item_failed": "Создать элемент не удалось",
|
"txt_create_item_failed": "Создать элемент не удалось",
|
||||||
@@ -405,6 +409,7 @@ const ru: Record<string, string> = {
|
|||||||
"txt_disable_this_send": "Отключить эту отправку",
|
"txt_disable_this_send": "Отключить эту отправку",
|
||||||
"txt_disable_totp": "Отключить TOTP",
|
"txt_disable_totp": "Отключить TOTP",
|
||||||
"txt_disable_totp_failed": "Отключить TOTP не удалось",
|
"txt_disable_totp_failed": "Отключить TOTP не удалось",
|
||||||
|
"txt_totp_update_failed": "Не удалось обновить TOTP",
|
||||||
"txt_download": "Скачать",
|
"txt_download": "Скачать",
|
||||||
"txt_downloading": "Загрузка...",
|
"txt_downloading": "Загрузка...",
|
||||||
"txt_downloading_percent": "Загрузка {percent}%",
|
"txt_downloading_percent": "Загрузка {percent}%",
|
||||||
@@ -464,12 +469,33 @@ const ru: Record<string, string> = {
|
|||||||
"txt_identity_details": "Данные личности",
|
"txt_identity_details": "Данные личности",
|
||||||
"txt_ie_browser": "IE-браузер",
|
"txt_ie_browser": "IE-браузер",
|
||||||
"txt_create_invite_failed": "Не удалось создать приглашение",
|
"txt_create_invite_failed": "Не удалось создать приглашение",
|
||||||
"txt_invite_code_optional": "Пригласительный код (не требуется для первой учетной записи; требуется для всех остальных)",
|
"txt_invite_code_required": "Пригласительный код (обязательно)",
|
||||||
"txt_invite_created": "Приглашение создано",
|
"txt_invite_created": "Приглашение создано",
|
||||||
"txt_invite_revoked": "Приглашение отозвано",
|
"txt_invite_revoked": "Приглашение отозвано",
|
||||||
"txt_revoke_invite_failed": "Не удалось отозвать приглашение",
|
"txt_revoke_invite_failed": "Не удалось отозвать приглашение",
|
||||||
"txt_invite_validity_hours": "Срок действия приглашения (часы)",
|
"txt_invite_validity_hours": "Срок действия приглашения (часы)",
|
||||||
"txt_invites": "Приглашает",
|
"txt_invites": "Приглашает",
|
||||||
|
"txt_rate_limit_try_again_seconds": "Слишком много запросов. Повторите попытку через {seconds} секунд.",
|
||||||
|
"txt_server_error_account_disabled": "Учетная запись отключена",
|
||||||
|
"txt_server_error_client_credentials_incorrect": "ID клиента или секрет клиента неверны. Повторите попытку.",
|
||||||
|
"txt_server_error_client_ip_required": "Требуется IP клиента",
|
||||||
|
"txt_server_error_email_already_registered": "Этот адрес электронной почты уже зарегистрирован",
|
||||||
|
"txt_server_error_email_password_required": "Требуются адрес электронной почты и пароль",
|
||||||
|
"txt_server_error_email_required": "Требуется адрес электронной почты",
|
||||||
|
"txt_server_error_invalid_refresh_token": "Сеанс истек. Войдите снова.",
|
||||||
|
"txt_server_error_invalid_request_payload": "Недопустимый запрос",
|
||||||
|
"txt_server_error_invite_invalid_or_expired": "Код приглашения недействителен или истек",
|
||||||
|
"txt_server_error_invite_required": "Требуется код приглашения",
|
||||||
|
"txt_server_error_jwt_secret_default": "JWT_SECRET использует значение по умолчанию/пример. Измените его.",
|
||||||
|
"txt_server_error_jwt_secret_missing": "JWT_SECRET не настроен",
|
||||||
|
"txt_server_error_jwt_secret_too_short": "JWT_SECRET должен содержать не менее 32 символов",
|
||||||
|
"txt_server_error_parameter_error": "Ошибка параметров",
|
||||||
|
"txt_server_error_refresh_token_required": "Сеанс отсутствует. Войдите снова.",
|
||||||
|
"txt_server_error_registration_retry": "Регистрация временно недоступна. Повторите попытку один раз.",
|
||||||
|
"txt_server_error_totp_token_required": "Требуется код двухэтапной проверки",
|
||||||
|
"txt_server_error_two_factor_invalid": "Код двухэтапной проверки недействителен. Повторите попытку.",
|
||||||
|
"txt_server_error_two_factor_required": "Требуется двухэтапная проверка.",
|
||||||
|
"txt_server_error_username_password_incorrect": "Имя пользователя или пароль неверны. Повторите попытку.",
|
||||||
"txt_ios": "iOS",
|
"txt_ios": "iOS",
|
||||||
"txt_item": "Товар",
|
"txt_item": "Товар",
|
||||||
"txt_item_created": "Объект создан",
|
"txt_item_created": "Объект создан",
|
||||||
@@ -543,12 +569,14 @@ const ru: Record<string, string> = {
|
|||||||
"txt_master_password_is_required": "Требуется мастер-пароль",
|
"txt_master_password_is_required": "Требуется мастер-пароль",
|
||||||
"txt_master_password_is_required_2": "Требуется мастер-пароль.",
|
"txt_master_password_is_required_2": "Требуется мастер-пароль.",
|
||||||
"txt_master_password_must_be_at_least_12_chars": "Мастер-пароль должен содержать не менее 12 символов.",
|
"txt_master_password_must_be_at_least_12_chars": "Мастер-пароль должен содержать не менее 12 символов.",
|
||||||
|
"txt_master_password_verify_failed": "Не удалось проверить мастер-пароль",
|
||||||
"txt_master_password_reprompt": "Повторный запрос мастер-пароля",
|
"txt_master_password_reprompt": "Повторный запрос мастер-пароля",
|
||||||
"txt_master_password_reprompt_2": "Повторный запрос мастер-пароля",
|
"txt_master_password_reprompt_2": "Повторный запрос мастер-пароля",
|
||||||
"txt_max_access_count": "Максимальное количество доступов",
|
"txt_max_access_count": "Максимальное количество доступов",
|
||||||
"txt_middle_name": "Второе имя",
|
"txt_middle_name": "Второе имя",
|
||||||
"txt_drag_to_reorder": "Перетащите, чтобы изменить порядок",
|
|
||||||
"txt_move": "Переместить",
|
"txt_move": "Переместить",
|
||||||
|
"txt_move_up": "Переместить вверх",
|
||||||
|
"txt_move_down": "Переместить вниз",
|
||||||
"txt_move_selected_items": "Переместить выбранные элементы",
|
"txt_move_selected_items": "Переместить выбранные элементы",
|
||||||
"txt_moved_selected_items": "Перемещены выбранные элементы",
|
"txt_moved_selected_items": "Перемещены выбранные элементы",
|
||||||
"txt_name": "Имя",
|
"txt_name": "Имя",
|
||||||
@@ -628,6 +656,9 @@ const ru: Record<string, string> = {
|
|||||||
"txt_api_key_rotated": "Ключ API поменян",
|
"txt_api_key_rotated": "Ключ API поменян",
|
||||||
"txt_rotate_api_key_confirm": "Поменять ключ API? Текущий ключ немедленно перестанет работать.",
|
"txt_rotate_api_key_confirm": "Поменять ключ API? Текущий ключ немедленно перестанет работать.",
|
||||||
"txt_api_key_is_empty": "Ключ API пуст",
|
"txt_api_key_is_empty": "Ключ API пуст",
|
||||||
|
"txt_get_api_key_failed": "Не удалось получить ключ API",
|
||||||
|
"txt_get_recovery_code_failed": "Не удалось получить код восстановления",
|
||||||
|
"txt_rotate_api_key_failed": "Не удалось сменить ключ API",
|
||||||
"txt_api_key_dialog_intro": "Ваш ключ API можно использовать для аутентификации с помощью Bitwarden CLI.",
|
"txt_api_key_dialog_intro": "Ваш ключ API можно использовать для аутентификации с помощью Bitwarden CLI.",
|
||||||
"txt_api_key_warning_body": "Ваш ключ API — это альтернативный механизм аутентификации. Держите это в секрете.",
|
"txt_api_key_warning_body": "Ваш ключ API — это альтернативный механизм аутентификации. Держите это в секрете.",
|
||||||
"txt_oauth_client_credentials": "Учетные данные клиента OAuth 2.0",
|
"txt_oauth_client_credentials": "Учетные данные клиента OAuth 2.0",
|
||||||
@@ -665,6 +696,7 @@ const ru: Record<string, string> = {
|
|||||||
"txt_save_profile": "Сохранить профиль",
|
"txt_save_profile": "Сохранить профиль",
|
||||||
"txt_save_profile_failed": "Сохранить профиль не удалось",
|
"txt_save_profile_failed": "Сохранить профиль не удалось",
|
||||||
"txt_search_sends": "Поиск отправляет...",
|
"txt_search_sends": "Поиск отправляет...",
|
||||||
|
"txt_session_refresh_failed": "Не удалось обновить сеанс. Войдите снова.",
|
||||||
"txt_search_your_secure_vault": "Найдите свое безопасное хранилище...",
|
"txt_search_your_secure_vault": "Найдите свое безопасное хранилище...",
|
||||||
"txt_clear_search": "Очистить поиск",
|
"txt_clear_search": "Очистить поиск",
|
||||||
"txt_clear_search_esc": "Очистить поиск (Esc)",
|
"txt_clear_search_esc": "Очистить поиск (Esc)",
|
||||||
@@ -678,6 +710,7 @@ const ru: Record<string, string> = {
|
|||||||
"txt_security_code": "Код безопасности",
|
"txt_security_code": "Код безопасности",
|
||||||
"txt_security_code_cvv": "Код безопасности (CVV)",
|
"txt_security_code_cvv": "Код безопасности (CVV)",
|
||||||
"txt_select_all": "Выбрать все",
|
"txt_select_all": "Выбрать все",
|
||||||
|
"txt_select": "Выбрать",
|
||||||
"txt_select_duplicate_items": "Выберите дубликаты",
|
"txt_select_duplicate_items": "Выберите дубликаты",
|
||||||
"txt_select_an_item": "Выберите элемент",
|
"txt_select_an_item": "Выберите элемент",
|
||||||
"txt_send_created": "Отправить создано",
|
"txt_send_created": "Отправить создано",
|
||||||
@@ -762,13 +795,13 @@ const ru: Record<string, string> = {
|
|||||||
"txt_user_deleted": "Пользователь удален",
|
"txt_user_deleted": "Пользователь удален",
|
||||||
"txt_user_status_updated": "Статус пользователя обновлен",
|
"txt_user_status_updated": "Статус пользователя обновлен",
|
||||||
"txt_username": "Имя пользователя",
|
"txt_username": "Имя пользователя",
|
||||||
"txt_uri_match_default_base_domain": "По умолчанию (базовый домен)",
|
"txt_uri_match_default_base_domain": "По умолч.",
|
||||||
"txt_uri_match_base_domain": "Базовый домен",
|
"txt_uri_match_base_domain": "Базовый домен",
|
||||||
"txt_uri_match_host": "Хост",
|
"txt_uri_match_host": "Хост",
|
||||||
"txt_uri_match_exact": "Точный",
|
"txt_uri_match_exact": "Точный",
|
||||||
"txt_uri_match_never": "Никогда",
|
"txt_uri_match_never": "Никогда",
|
||||||
"txt_uri_match_starts_with": "Начинается с",
|
"txt_uri_match_starts_with": "Начинается с",
|
||||||
"txt_uri_match_regular_expression": "Регулярное выражение",
|
"txt_uri_match_regular_expression": "Regex",
|
||||||
"txt_users": "Пользователи",
|
"txt_users": "Пользователи",
|
||||||
"txt_vault_synced": "Сейф синхронизирован",
|
"txt_vault_synced": "Сейф синхронизирован",
|
||||||
"txt_verification_code": "Код подтверждения",
|
"txt_verification_code": "Код подтверждения",
|
||||||
@@ -874,7 +907,35 @@ const ru: Record<string, string> = {
|
|||||||
"txt_status_inactive": "Неактивный",
|
"txt_status_inactive": "Неактивный",
|
||||||
"txt_language": "Язык",
|
"txt_language": "Язык",
|
||||||
"txt_display_language": "Язык дисплея",
|
"txt_display_language": "Язык дисплея",
|
||||||
"txt_language_saved_locally": "Этот выбор сохраняется в текущем браузере и применяется при следующей загрузке приложения."
|
"txt_language_saved_locally": "Этот выбор сохраняется в текущем браузере и применяется при следующей загрузке приложения.",
|
||||||
|
"nav_domain_rules": "Правила доменов",
|
||||||
|
"txt_domain_rules_description": "Отмечайте сайты с одним логином как эквивалентные домены. Глобальные правила берутся из готового списка, а пользовательские влияют только на ваши совпадения.",
|
||||||
|
"txt_submit_pr": "Отправить PR",
|
||||||
|
"txt_custom_equivalent_domains": "Пользовательские эквивалентные домены",
|
||||||
|
"txt_global_equivalent_domains": "Глобальные эквивалентные домены",
|
||||||
|
"txt_domain_group": "Группа доменов",
|
||||||
|
"txt_no_custom_domain_rules": "Нет пользовательских правил доменов",
|
||||||
|
"txt_no_domain_rules_found": "Правила доменов не найдены",
|
||||||
|
"txt_search_domains": "Поиск доменов",
|
||||||
|
"txt_domain_rules_saved": "Правила доменов сохранены",
|
||||||
|
"txt_domain_rules_save_failed": "Не удалось сохранить правила доменов",
|
||||||
|
"txt_domain_rules_load_failed": "Не удалось загрузить правила доменов",
|
||||||
|
"txt_domain_rules_invalid_response": "Недопустимый ответ правил доменов",
|
||||||
|
"txt_domain_rules_refreshed": "Правила доменов обновлены",
|
||||||
|
"txt_saving": "Сохранение...",
|
||||||
|
"txt_domain_rule_needs_two_domains": "В каждом правиле доменов должно быть не менее двух доменов.",
|
||||||
|
"txt_domain_rule_invalid_domains": "Введите корректные домены, например example.com.",
|
||||||
|
"txt_add_domain": "Добавить домен",
|
||||||
|
"txt_expand": "Развернуть",
|
||||||
|
"txt_collapse": "Свернуть",
|
||||||
|
"txt_nav_layout": "Стиль навигации",
|
||||||
|
"txt_nav_layout_flat": "Плоский",
|
||||||
|
"txt_nav_layout_flat_desc": "Показывать все страницы сразу",
|
||||||
|
"txt_nav_layout_grouped_expanded": "Группы",
|
||||||
|
"txt_nav_layout_grouped_expanded_desc": "Держать все группы открытыми",
|
||||||
|
"txt_nav_layout_grouped_smart": "Умные группы",
|
||||||
|
"txt_nav_layout_grouped_smart_desc": "Открывать активные группы по необходимости",
|
||||||
|
"txt_remove_domain": "Удалить домен"
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ru;
|
export default ru;
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ const zhCN: Record<string, string> = {
|
|||||||
"nav_admin_panel": "用户管理",
|
"nav_admin_panel": "用户管理",
|
||||||
"nav_device_management": "设备管理",
|
"nav_device_management": "设备管理",
|
||||||
"nav_my_vault": "我的密码库",
|
"nav_my_vault": "我的密码库",
|
||||||
|
"nav_vault_items": "密码库",
|
||||||
"nav_sends": "Send",
|
"nav_sends": "Send",
|
||||||
"nav_backup_strategy": "云端备份",
|
"nav_backup_strategy": "云端备份",
|
||||||
"nav_import_export": "导入导出",
|
"nav_import_export": "导入导出",
|
||||||
|
"nav_group_data_backup": "数据与备份",
|
||||||
|
"nav_group_management": "管理",
|
||||||
"txt_page_not_found": "页面不存在",
|
"txt_page_not_found": "页面不存在",
|
||||||
"txt_page_not_found_hint": "这个页面可能已经删除、过期,或者链接不完整。",
|
"txt_page_not_found_hint": "这个页面可能已经删除、过期,或者链接不完整。",
|
||||||
"txt_back_to_home": "回到首页",
|
"txt_back_to_home": "回到首页",
|
||||||
@@ -346,6 +349,7 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_create": "创建",
|
"txt_create": "创建",
|
||||||
"txt_create_account": "创建账户",
|
"txt_create_account": "创建账户",
|
||||||
"txt_registering": "正在注册...",
|
"txt_registering": "正在注册...",
|
||||||
|
"txt_register_failed": "注册失败",
|
||||||
"txt_create_folder": "创建文件夹",
|
"txt_create_folder": "创建文件夹",
|
||||||
"txt_create_folder_failed": "创建文件夹失败",
|
"txt_create_folder_failed": "创建文件夹失败",
|
||||||
"txt_create_item_failed": "创建项目失败",
|
"txt_create_item_failed": "创建项目失败",
|
||||||
@@ -405,6 +409,7 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_disable_this_send": "禁用此 Send",
|
"txt_disable_this_send": "禁用此 Send",
|
||||||
"txt_disable_totp": "停用 TOTP",
|
"txt_disable_totp": "停用 TOTP",
|
||||||
"txt_disable_totp_failed": "禁用 TOTP 失败",
|
"txt_disable_totp_failed": "禁用 TOTP 失败",
|
||||||
|
"txt_totp_update_failed": "更新 TOTP 失败",
|
||||||
"txt_download": "下载",
|
"txt_download": "下载",
|
||||||
"txt_downloading": "下载中...",
|
"txt_downloading": "下载中...",
|
||||||
"txt_downloading_percent": "下载中 {percent}%",
|
"txt_downloading_percent": "下载中 {percent}%",
|
||||||
@@ -464,12 +469,33 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_identity_details": "身份详情",
|
"txt_identity_details": "身份详情",
|
||||||
"txt_ie_browser": "IE 浏览器",
|
"txt_ie_browser": "IE 浏览器",
|
||||||
"txt_create_invite_failed": "创建邀请码失败",
|
"txt_create_invite_failed": "创建邀请码失败",
|
||||||
"txt_invite_code_optional": "邀请码(首位注册者无需填写,其他人必填)",
|
"txt_invite_code_required": "邀请码(必填)",
|
||||||
"txt_invite_created": "邀请码已创建",
|
"txt_invite_created": "邀请码已创建",
|
||||||
"txt_invite_revoked": "邀请码已撤销",
|
"txt_invite_revoked": "邀请码已撤销",
|
||||||
"txt_revoke_invite_failed": "撤销邀请码失败",
|
"txt_revoke_invite_failed": "撤销邀请码失败",
|
||||||
"txt_invite_validity_hours": "邀请码有效期(小时)",
|
"txt_invite_validity_hours": "邀请码有效期(小时)",
|
||||||
"txt_invites": "邀请码",
|
"txt_invites": "邀请码",
|
||||||
|
"txt_rate_limit_try_again_seconds": "请求过于频繁,请在 {seconds} 秒后重试",
|
||||||
|
"txt_server_error_account_disabled": "账号已被禁用",
|
||||||
|
"txt_server_error_client_credentials_incorrect": "客户端 ID 或客户端密钥不正确,请重试",
|
||||||
|
"txt_server_error_client_ip_required": "无法获取客户端 IP",
|
||||||
|
"txt_server_error_email_already_registered": "该邮箱已注册",
|
||||||
|
"txt_server_error_email_password_required": "邮箱和密码不能为空",
|
||||||
|
"txt_server_error_email_required": "邮箱不能为空",
|
||||||
|
"txt_server_error_invalid_refresh_token": "登录状态已失效,请重新登录",
|
||||||
|
"txt_server_error_invalid_request_payload": "请求内容无效",
|
||||||
|
"txt_server_error_invite_invalid_or_expired": "邀请码无效或已过期",
|
||||||
|
"txt_server_error_invite_required": "邀请码不能为空",
|
||||||
|
"txt_server_error_jwt_secret_default": "JWT_SECRET 正在使用默认示例值,请修改后再继续",
|
||||||
|
"txt_server_error_jwt_secret_missing": "JWT_SECRET 未设置",
|
||||||
|
"txt_server_error_jwt_secret_too_short": "JWT_SECRET 至少需要 32 个字符",
|
||||||
|
"txt_server_error_parameter_error": "请求参数错误",
|
||||||
|
"txt_server_error_refresh_token_required": "登录状态缺失,请重新登录",
|
||||||
|
"txt_server_error_registration_retry": "注册暂时不可用,请重试一次",
|
||||||
|
"txt_server_error_totp_token_required": "请输入两步验证码",
|
||||||
|
"txt_server_error_two_factor_invalid": "两步验证码无效,请重试",
|
||||||
|
"txt_server_error_two_factor_required": "需要两步验证",
|
||||||
|
"txt_server_error_username_password_incorrect": "用户名或密码不正确,请重试",
|
||||||
"txt_ios": "iOS",
|
"txt_ios": "iOS",
|
||||||
"txt_item": "项目",
|
"txt_item": "项目",
|
||||||
"txt_item_created": "项目已创建",
|
"txt_item_created": "项目已创建",
|
||||||
@@ -543,12 +569,14 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_master_password_is_required": "主密码不能为空",
|
"txt_master_password_is_required": "主密码不能为空",
|
||||||
"txt_master_password_is_required_2": "请输入主密码",
|
"txt_master_password_is_required_2": "请输入主密码",
|
||||||
"txt_master_password_must_be_at_least_12_chars": "主密码至少需要 12 个字符",
|
"txt_master_password_must_be_at_least_12_chars": "主密码至少需要 12 个字符",
|
||||||
|
"txt_master_password_verify_failed": "主密码验证失败",
|
||||||
"txt_master_password_reprompt": "主密码二次确认",
|
"txt_master_password_reprompt": "主密码二次确认",
|
||||||
"txt_master_password_reprompt_2": "主密码二次确认",
|
"txt_master_password_reprompt_2": "主密码二次确认",
|
||||||
"txt_max_access_count": "最大访问次数",
|
"txt_max_access_count": "最大访问次数",
|
||||||
"txt_middle_name": "中间名",
|
"txt_middle_name": "中间名",
|
||||||
"txt_drag_to_reorder": "拖动调整顺序",
|
|
||||||
"txt_move": "移动",
|
"txt_move": "移动",
|
||||||
|
"txt_move_up": "上移",
|
||||||
|
"txt_move_down": "下移",
|
||||||
"txt_move_selected_items": "移动所选项目",
|
"txt_move_selected_items": "移动所选项目",
|
||||||
"txt_moved_selected_items": "已移动所选项目",
|
"txt_moved_selected_items": "已移动所选项目",
|
||||||
"txt_name": "名称",
|
"txt_name": "名称",
|
||||||
@@ -574,7 +602,7 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_note": "笔记",
|
"txt_note": "笔记",
|
||||||
"txt_notes": "备注",
|
"txt_notes": "备注",
|
||||||
"txt_replace_device_name_with_note": "为这台设备设置自定义名称,不会改变系统识别到的设备类型。",
|
"txt_replace_device_name_with_note": "为这台设备设置自定义名称,不会改变系统识别到的设备类型。",
|
||||||
"txt_number": "数字",
|
"txt_number": "号码",
|
||||||
"txt_open": "打开",
|
"txt_open": "打开",
|
||||||
"txt_opera_browser": "Opera 浏览器",
|
"txt_opera_browser": "Opera 浏览器",
|
||||||
"txt_opera_extension": "Opera 扩展",
|
"txt_opera_extension": "Opera 扩展",
|
||||||
@@ -628,6 +656,9 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_api_key_rotated": "API 密钥已轮换",
|
"txt_api_key_rotated": "API 密钥已轮换",
|
||||||
"txt_rotate_api_key_confirm": "轮换 API 密钥?当前密钥将立即失效。",
|
"txt_rotate_api_key_confirm": "轮换 API 密钥?当前密钥将立即失效。",
|
||||||
"txt_api_key_is_empty": "API 密钥为空",
|
"txt_api_key_is_empty": "API 密钥为空",
|
||||||
|
"txt_get_api_key_failed": "获取 API 密钥失败",
|
||||||
|
"txt_get_recovery_code_failed": "获取恢复代码失败",
|
||||||
|
"txt_rotate_api_key_failed": "轮换 API 密钥失败",
|
||||||
"txt_api_key_dialog_intro": "您的 API 密钥可用于在 Bitwarden CLI 中进行身份验证。",
|
"txt_api_key_dialog_intro": "您的 API 密钥可用于在 Bitwarden CLI 中进行身份验证。",
|
||||||
"txt_api_key_warning_body": "您的 API 密钥是一种替代身份验证机制。请严格保密。",
|
"txt_api_key_warning_body": "您的 API 密钥是一种替代身份验证机制。请严格保密。",
|
||||||
"txt_oauth_client_credentials": "OAuth 2.0 客户端凭据",
|
"txt_oauth_client_credentials": "OAuth 2.0 客户端凭据",
|
||||||
@@ -665,6 +696,7 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_save_profile": "保存资料",
|
"txt_save_profile": "保存资料",
|
||||||
"txt_save_profile_failed": "保存资料失败",
|
"txt_save_profile_failed": "保存资料失败",
|
||||||
"txt_search_sends": "搜索 Send...",
|
"txt_search_sends": "搜索 Send...",
|
||||||
|
"txt_session_refresh_failed": "会话刷新失败,请重新登录",
|
||||||
"txt_search_your_secure_vault": "搜索你的密码库...",
|
"txt_search_your_secure_vault": "搜索你的密码库...",
|
||||||
"txt_clear_search": "清空搜索",
|
"txt_clear_search": "清空搜索",
|
||||||
"txt_clear_search_esc": "清空搜索(Esc)",
|
"txt_clear_search_esc": "清空搜索(Esc)",
|
||||||
@@ -678,6 +710,7 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_security_code": "安全码",
|
"txt_security_code": "安全码",
|
||||||
"txt_security_code_cvv": "安全码 (CVV)",
|
"txt_security_code_cvv": "安全码 (CVV)",
|
||||||
"txt_select_all": "全选",
|
"txt_select_all": "全选",
|
||||||
|
"txt_select": "请选择",
|
||||||
"txt_select_duplicate_items": "选择重复项",
|
"txt_select_duplicate_items": "选择重复项",
|
||||||
"txt_select_an_item": "请选择一个项目",
|
"txt_select_an_item": "请选择一个项目",
|
||||||
"txt_send_created": "Send 已创建",
|
"txt_send_created": "Send 已创建",
|
||||||
@@ -762,13 +795,13 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_user_deleted": "用户已删除",
|
"txt_user_deleted": "用户已删除",
|
||||||
"txt_user_status_updated": "用户状态已更新",
|
"txt_user_status_updated": "用户状态已更新",
|
||||||
"txt_username": "用户名",
|
"txt_username": "用户名",
|
||||||
"txt_uri_match_default_base_domain": "默认(基础域名)",
|
"txt_uri_match_default_base_domain": "默认",
|
||||||
"txt_uri_match_base_domain": "基础域名",
|
"txt_uri_match_base_domain": "基础域名",
|
||||||
"txt_uri_match_host": "主机",
|
"txt_uri_match_host": "主机",
|
||||||
"txt_uri_match_exact": "精确",
|
"txt_uri_match_exact": "精确",
|
||||||
"txt_uri_match_never": "从不",
|
"txt_uri_match_never": "从不",
|
||||||
"txt_uri_match_starts_with": "开始于",
|
"txt_uri_match_starts_with": "开始于",
|
||||||
"txt_uri_match_regular_expression": "正则表达式",
|
"txt_uri_match_regular_expression": "正则表达",
|
||||||
"txt_users": "用户",
|
"txt_users": "用户",
|
||||||
"txt_vault_synced": "密码库已同步",
|
"txt_vault_synced": "密码库已同步",
|
||||||
"txt_verification_code": "验证码",
|
"txt_verification_code": "验证码",
|
||||||
@@ -874,7 +907,35 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_status_inactive": "未激活",
|
"txt_status_inactive": "未激活",
|
||||||
"txt_language": "语言",
|
"txt_language": "语言",
|
||||||
"txt_display_language": "显示语言",
|
"txt_display_language": "显示语言",
|
||||||
"txt_language_saved_locally": "此偏好会保存在当前浏览器中,下次打开应用前就会生效。"
|
"txt_language_saved_locally": "此偏好会保存在当前浏览器中,下次打开应用前就会生效。",
|
||||||
|
"nav_domain_rules": "域名规则",
|
||||||
|
"txt_domain_rules_description": "多个网站共用同一登录信息时,可将它们设为等效域名;全局规则来自预置列表,自定义规则只影响你自己的匹配。",
|
||||||
|
"txt_submit_pr": "提交 PR",
|
||||||
|
"txt_custom_equivalent_domains": "自定义等效域名",
|
||||||
|
"txt_global_equivalent_domains": "全局等效域名",
|
||||||
|
"txt_domain_group": "域名组",
|
||||||
|
"txt_no_custom_domain_rules": "暂无自定义域名规则",
|
||||||
|
"txt_no_domain_rules_found": "未找到域名规则",
|
||||||
|
"txt_search_domains": "搜索域名",
|
||||||
|
"txt_domain_rules_saved": "域名规则已保存",
|
||||||
|
"txt_domain_rules_save_failed": "保存域名规则失败",
|
||||||
|
"txt_domain_rules_load_failed": "加载域名规则失败",
|
||||||
|
"txt_domain_rules_invalid_response": "域名规则响应无效",
|
||||||
|
"txt_domain_rules_refreshed": "域名规则已刷新",
|
||||||
|
"txt_saving": "保存中...",
|
||||||
|
"txt_domain_rule_needs_two_domains": "每条域名规则至少需要两个域名。",
|
||||||
|
"txt_domain_rule_invalid_domains": "请输入有效域名,例如 example.com。",
|
||||||
|
"txt_add_domain": "新增域名",
|
||||||
|
"txt_expand": "展开",
|
||||||
|
"txt_collapse": "收起",
|
||||||
|
"txt_nav_layout": "导航样式",
|
||||||
|
"txt_nav_layout_flat": "直接显示",
|
||||||
|
"txt_nav_layout_flat_desc": "所有页面直接列出来",
|
||||||
|
"txt_nav_layout_grouped_expanded": "分组展开",
|
||||||
|
"txt_nav_layout_grouped_expanded_desc": "父子菜单全部展开",
|
||||||
|
"txt_nav_layout_grouped_smart": "智能分组",
|
||||||
|
"txt_nav_layout_grouped_smart_desc": "当前相关分组自动展开",
|
||||||
|
"txt_remove_domain": "移除域名"
|
||||||
};
|
};
|
||||||
|
|
||||||
export default zhCN;
|
export default zhCN;
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ const zhTW: Record<string, string> = {
|
|||||||
"nav_admin_panel": "用戶管理",
|
"nav_admin_panel": "用戶管理",
|
||||||
"nav_device_management": "設備管理",
|
"nav_device_management": "設備管理",
|
||||||
"nav_my_vault": "我的密碼庫",
|
"nav_my_vault": "我的密碼庫",
|
||||||
|
"nav_vault_items": "密碼庫",
|
||||||
"nav_sends": "Send",
|
"nav_sends": "Send",
|
||||||
"nav_backup_strategy": "雲端備份",
|
"nav_backup_strategy": "雲端備份",
|
||||||
"nav_import_export": "導入導出",
|
"nav_import_export": "導入導出",
|
||||||
|
"nav_group_data_backup": "資料與備份",
|
||||||
|
"nav_group_management": "管理",
|
||||||
"txt_page_not_found": "頁面不存在",
|
"txt_page_not_found": "頁面不存在",
|
||||||
"txt_page_not_found_hint": "這個頁面可能已經刪除、過期,或者連結不完整。",
|
"txt_page_not_found_hint": "這個頁面可能已經刪除、過期,或者連結不完整。",
|
||||||
"txt_back_to_home": "回到首頁",
|
"txt_back_to_home": "回到首頁",
|
||||||
@@ -346,6 +349,7 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_create": "創建",
|
"txt_create": "創建",
|
||||||
"txt_create_account": "創建賬戶",
|
"txt_create_account": "創建賬戶",
|
||||||
"txt_registering": "正在註冊...",
|
"txt_registering": "正在註冊...",
|
||||||
|
"txt_register_failed": "註冊失敗",
|
||||||
"txt_create_folder": "創建文件夾",
|
"txt_create_folder": "創建文件夾",
|
||||||
"txt_create_folder_failed": "創建文件夾失敗",
|
"txt_create_folder_failed": "創建文件夾失敗",
|
||||||
"txt_create_item_failed": "創建項目失敗",
|
"txt_create_item_failed": "創建項目失敗",
|
||||||
@@ -405,6 +409,7 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_disable_this_send": "禁用此 Send",
|
"txt_disable_this_send": "禁用此 Send",
|
||||||
"txt_disable_totp": "停用 TOTP",
|
"txt_disable_totp": "停用 TOTP",
|
||||||
"txt_disable_totp_failed": "禁用 TOTP 失敗",
|
"txt_disable_totp_failed": "禁用 TOTP 失敗",
|
||||||
|
"txt_totp_update_failed": "更新 TOTP 失敗",
|
||||||
"txt_download": "下載",
|
"txt_download": "下載",
|
||||||
"txt_downloading": "下載中...",
|
"txt_downloading": "下載中...",
|
||||||
"txt_downloading_percent": "下載中 {percent}%",
|
"txt_downloading_percent": "下載中 {percent}%",
|
||||||
@@ -464,12 +469,33 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_identity_details": "身份詳情",
|
"txt_identity_details": "身份詳情",
|
||||||
"txt_ie_browser": "IE 瀏覽器",
|
"txt_ie_browser": "IE 瀏覽器",
|
||||||
"txt_create_invite_failed": "創建邀請碼失敗",
|
"txt_create_invite_failed": "創建邀請碼失敗",
|
||||||
"txt_invite_code_optional": "邀請碼(首位註冊者無需填寫,其他人必填)",
|
"txt_invite_code_required": "邀請碼(必填)",
|
||||||
"txt_invite_created": "邀請碼已創建",
|
"txt_invite_created": "邀請碼已創建",
|
||||||
"txt_invite_revoked": "邀請碼已撤銷",
|
"txt_invite_revoked": "邀請碼已撤銷",
|
||||||
"txt_revoke_invite_failed": "撤銷邀請碼失敗",
|
"txt_revoke_invite_failed": "撤銷邀請碼失敗",
|
||||||
"txt_invite_validity_hours": "邀請碼有效期(小時)",
|
"txt_invite_validity_hours": "邀請碼有效期(小時)",
|
||||||
"txt_invites": "邀請碼",
|
"txt_invites": "邀請碼",
|
||||||
|
"txt_rate_limit_try_again_seconds": "請求過於頻繁,請在 {seconds} 秒後重試",
|
||||||
|
"txt_server_error_account_disabled": "帳號已被禁用",
|
||||||
|
"txt_server_error_client_credentials_incorrect": "客戶端 ID 或客戶端密鑰不正確,請重試",
|
||||||
|
"txt_server_error_client_ip_required": "無法獲取客戶端 IP",
|
||||||
|
"txt_server_error_email_already_registered": "該郵箱已註冊",
|
||||||
|
"txt_server_error_email_password_required": "郵箱和密碼不能為空",
|
||||||
|
"txt_server_error_email_required": "郵箱不能為空",
|
||||||
|
"txt_server_error_invalid_refresh_token": "登入狀態已失效,請重新登入",
|
||||||
|
"txt_server_error_invalid_request_payload": "請求內容無效",
|
||||||
|
"txt_server_error_invite_invalid_or_expired": "邀請碼無效或已過期",
|
||||||
|
"txt_server_error_invite_required": "邀請碼不能為空",
|
||||||
|
"txt_server_error_jwt_secret_default": "JWT_SECRET 正在使用默認示例值,請修改後再繼續",
|
||||||
|
"txt_server_error_jwt_secret_missing": "JWT_SECRET 未設置",
|
||||||
|
"txt_server_error_jwt_secret_too_short": "JWT_SECRET 至少需要 32 個字符",
|
||||||
|
"txt_server_error_parameter_error": "請求參數錯誤",
|
||||||
|
"txt_server_error_refresh_token_required": "登入狀態缺失,請重新登入",
|
||||||
|
"txt_server_error_registration_retry": "註冊暫時不可用,請重試一次",
|
||||||
|
"txt_server_error_totp_token_required": "請輸入兩步驗證碼",
|
||||||
|
"txt_server_error_two_factor_invalid": "兩步驗證碼無效,請重試",
|
||||||
|
"txt_server_error_two_factor_required": "需要兩步驗證",
|
||||||
|
"txt_server_error_username_password_incorrect": "用戶名或密碼不正確,請重試",
|
||||||
"txt_ios": "iOS",
|
"txt_ios": "iOS",
|
||||||
"txt_item": "項目",
|
"txt_item": "項目",
|
||||||
"txt_item_created": "項目已創建",
|
"txt_item_created": "項目已創建",
|
||||||
@@ -543,12 +569,14 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_master_password_is_required": "主密碼不能為空",
|
"txt_master_password_is_required": "主密碼不能為空",
|
||||||
"txt_master_password_is_required_2": "請輸入主密碼",
|
"txt_master_password_is_required_2": "請輸入主密碼",
|
||||||
"txt_master_password_must_be_at_least_12_chars": "主密碼至少需要 12 個字符",
|
"txt_master_password_must_be_at_least_12_chars": "主密碼至少需要 12 個字符",
|
||||||
|
"txt_master_password_verify_failed": "主密碼驗證失敗",
|
||||||
"txt_master_password_reprompt": "主密碼二次確認",
|
"txt_master_password_reprompt": "主密碼二次確認",
|
||||||
"txt_master_password_reprompt_2": "主密碼二次確認",
|
"txt_master_password_reprompt_2": "主密碼二次確認",
|
||||||
"txt_max_access_count": "最大訪問次數",
|
"txt_max_access_count": "最大訪問次數",
|
||||||
"txt_middle_name": "中間名",
|
"txt_middle_name": "中間名",
|
||||||
"txt_drag_to_reorder": "拖動調整順序",
|
|
||||||
"txt_move": "移動",
|
"txt_move": "移動",
|
||||||
|
"txt_move_up": "上移",
|
||||||
|
"txt_move_down": "下移",
|
||||||
"txt_move_selected_items": "移動所選項目",
|
"txt_move_selected_items": "移動所選項目",
|
||||||
"txt_moved_selected_items": "已移動所選項目",
|
"txt_moved_selected_items": "已移動所選項目",
|
||||||
"txt_name": "名稱",
|
"txt_name": "名稱",
|
||||||
@@ -574,7 +602,7 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_note": "筆記",
|
"txt_note": "筆記",
|
||||||
"txt_notes": "備註",
|
"txt_notes": "備註",
|
||||||
"txt_replace_device_name_with_note": "為這臺設備設置自定義名稱,不會改變系統識別到的設備類型。",
|
"txt_replace_device_name_with_note": "為這臺設備設置自定義名稱,不會改變系統識別到的設備類型。",
|
||||||
"txt_number": "數字",
|
"txt_number": "號碼",
|
||||||
"txt_open": "打開",
|
"txt_open": "打開",
|
||||||
"txt_opera_browser": "Opera 瀏覽器",
|
"txt_opera_browser": "Opera 瀏覽器",
|
||||||
"txt_opera_extension": "Opera 擴展",
|
"txt_opera_extension": "Opera 擴展",
|
||||||
@@ -628,6 +656,9 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_api_key_rotated": "API 密鑰已輪換",
|
"txt_api_key_rotated": "API 密鑰已輪換",
|
||||||
"txt_rotate_api_key_confirm": "輪換 API 密鑰?當前密鑰將立即失效。",
|
"txt_rotate_api_key_confirm": "輪換 API 密鑰?當前密鑰將立即失效。",
|
||||||
"txt_api_key_is_empty": "API 密鑰為空",
|
"txt_api_key_is_empty": "API 密鑰為空",
|
||||||
|
"txt_get_api_key_failed": "獲取 API 密鑰失敗",
|
||||||
|
"txt_get_recovery_code_failed": "獲取恢復代碼失敗",
|
||||||
|
"txt_rotate_api_key_failed": "輪換 API 密鑰失敗",
|
||||||
"txt_api_key_dialog_intro": "您的 API 密鑰可用於在 Bitwarden CLI 中進行身份驗證。",
|
"txt_api_key_dialog_intro": "您的 API 密鑰可用於在 Bitwarden CLI 中進行身份驗證。",
|
||||||
"txt_api_key_warning_body": "您的 API 密鑰是一種替代身份驗證機制。請嚴格保密。",
|
"txt_api_key_warning_body": "您的 API 密鑰是一種替代身份驗證機制。請嚴格保密。",
|
||||||
"txt_oauth_client_credentials": "OAuth 2.0 客戶端憑據",
|
"txt_oauth_client_credentials": "OAuth 2.0 客戶端憑據",
|
||||||
@@ -665,6 +696,7 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_save_profile": "保存資料",
|
"txt_save_profile": "保存資料",
|
||||||
"txt_save_profile_failed": "保存資料失敗",
|
"txt_save_profile_failed": "保存資料失敗",
|
||||||
"txt_search_sends": "搜索 Send...",
|
"txt_search_sends": "搜索 Send...",
|
||||||
|
"txt_session_refresh_failed": "會話刷新失敗,請重新登入",
|
||||||
"txt_search_your_secure_vault": "搜索你的密碼庫...",
|
"txt_search_your_secure_vault": "搜索你的密碼庫...",
|
||||||
"txt_clear_search": "清空搜索",
|
"txt_clear_search": "清空搜索",
|
||||||
"txt_clear_search_esc": "清空搜索(Esc)",
|
"txt_clear_search_esc": "清空搜索(Esc)",
|
||||||
@@ -678,6 +710,7 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_security_code": "安全碼",
|
"txt_security_code": "安全碼",
|
||||||
"txt_security_code_cvv": "安全碼 (CVV)",
|
"txt_security_code_cvv": "安全碼 (CVV)",
|
||||||
"txt_select_all": "全選",
|
"txt_select_all": "全選",
|
||||||
|
"txt_select": "請選擇",
|
||||||
"txt_select_duplicate_items": "選擇重複項",
|
"txt_select_duplicate_items": "選擇重複項",
|
||||||
"txt_select_an_item": "請選擇一個項目",
|
"txt_select_an_item": "請選擇一個項目",
|
||||||
"txt_send_created": "Send 已創建",
|
"txt_send_created": "Send 已創建",
|
||||||
@@ -762,13 +795,13 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_user_deleted": "用戶已刪除",
|
"txt_user_deleted": "用戶已刪除",
|
||||||
"txt_user_status_updated": "用戶狀態已更新",
|
"txt_user_status_updated": "用戶狀態已更新",
|
||||||
"txt_username": "用戶名",
|
"txt_username": "用戶名",
|
||||||
"txt_uri_match_default_base_domain": "默認(基礎域名)",
|
"txt_uri_match_default_base_domain": "默認",
|
||||||
"txt_uri_match_base_domain": "基礎域名",
|
"txt_uri_match_base_domain": "基礎域名",
|
||||||
"txt_uri_match_host": "主機",
|
"txt_uri_match_host": "主機",
|
||||||
"txt_uri_match_exact": "精確",
|
"txt_uri_match_exact": "精確",
|
||||||
"txt_uri_match_never": "從不",
|
"txt_uri_match_never": "從不",
|
||||||
"txt_uri_match_starts_with": "開始於",
|
"txt_uri_match_starts_with": "開始於",
|
||||||
"txt_uri_match_regular_expression": "正則表達式",
|
"txt_uri_match_regular_expression": "正則表達",
|
||||||
"txt_users": "用戶",
|
"txt_users": "用戶",
|
||||||
"txt_vault_synced": "密碼庫已同步",
|
"txt_vault_synced": "密碼庫已同步",
|
||||||
"txt_verification_code": "驗證碼",
|
"txt_verification_code": "驗證碼",
|
||||||
@@ -874,7 +907,35 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_status_inactive": "未激活",
|
"txt_status_inactive": "未激活",
|
||||||
"txt_language": "語言",
|
"txt_language": "語言",
|
||||||
"txt_display_language": "顯示語言",
|
"txt_display_language": "顯示語言",
|
||||||
"txt_language_saved_locally": "此偏好會保存在當前瀏覽器中,下次打開應用前就會生效。"
|
"txt_language_saved_locally": "此偏好會保存在當前瀏覽器中,下次打開應用前就會生效。",
|
||||||
|
"nav_domain_rules": "域名規則",
|
||||||
|
"txt_domain_rules_description": "多個網站共用同一登入資訊時,可將它們設為等效域名;全局規則來自預置列表,自定義規則只影響你自己的匹配。",
|
||||||
|
"txt_submit_pr": "提交 PR",
|
||||||
|
"txt_custom_equivalent_domains": "自定義等效域名",
|
||||||
|
"txt_global_equivalent_domains": "全局等效域名",
|
||||||
|
"txt_domain_group": "域名組",
|
||||||
|
"txt_no_custom_domain_rules": "暫無自定義域名規則",
|
||||||
|
"txt_no_domain_rules_found": "未找到域名規則",
|
||||||
|
"txt_search_domains": "搜索域名",
|
||||||
|
"txt_domain_rules_saved": "域名規則已保存",
|
||||||
|
"txt_domain_rules_save_failed": "保存域名規則失敗",
|
||||||
|
"txt_domain_rules_load_failed": "加載域名規則失敗",
|
||||||
|
"txt_domain_rules_invalid_response": "域名規則響應無效",
|
||||||
|
"txt_domain_rules_refreshed": "域名規則已刷新",
|
||||||
|
"txt_saving": "保存中...",
|
||||||
|
"txt_domain_rule_needs_two_domains": "每條域名規則至少需要兩個域名。",
|
||||||
|
"txt_domain_rule_invalid_domains": "請輸入有效域名,例如 example.com。",
|
||||||
|
"txt_add_domain": "新增域名",
|
||||||
|
"txt_expand": "展開",
|
||||||
|
"txt_collapse": "收起",
|
||||||
|
"txt_nav_layout": "導航樣式",
|
||||||
|
"txt_nav_layout_flat": "直接顯示",
|
||||||
|
"txt_nav_layout_flat_desc": "所有頁面直接列出",
|
||||||
|
"txt_nav_layout_grouped_expanded": "分組展開",
|
||||||
|
"txt_nav_layout_grouped_expanded_desc": "父子選單全部展開",
|
||||||
|
"txt_nav_layout_grouped_smart": "智能分組",
|
||||||
|
"txt_nav_layout_grouped_smart_desc": "目前相關分組自動展開",
|
||||||
|
"txt_remove_domain": "移除域名"
|
||||||
};
|
};
|
||||||
|
|
||||||
export default zhTW;
|
export default zhTW;
|
||||||
|
|||||||
@@ -287,6 +287,7 @@ export interface WebBootstrapResponse {
|
|||||||
defaultKdfIterations?: number;
|
defaultKdfIterations?: number;
|
||||||
jwtUnsafeReason?: 'missing' | 'default' | 'too_short' | null;
|
jwtUnsafeReason?: 'missing' | 'default' | 'too_short' | null;
|
||||||
jwtSecretMinLength?: number;
|
jwtSecretMinLength?: number;
|
||||||
|
registrationInviteRequired?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TokenSuccess {
|
export interface TokenSuccess {
|
||||||
@@ -359,3 +360,22 @@ export interface AuthorizedDevice {
|
|||||||
trustedTokenCount: number;
|
trustedTokenCount: number;
|
||||||
trustedUntil: string | null;
|
trustedUntil: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GlobalEquivalentDomain {
|
||||||
|
type: number;
|
||||||
|
domains: string[];
|
||||||
|
excluded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomEquivalentDomain {
|
||||||
|
id: string;
|
||||||
|
domains: string[];
|
||||||
|
excluded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DomainRules {
|
||||||
|
equivalentDomains: string[][];
|
||||||
|
customEquivalentDomains: CustomEquivalentDomain[];
|
||||||
|
globalEquivalentDomains: GlobalEquivalentDomain[];
|
||||||
|
object: 'domains';
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
type WebsiteIconStatus = 'idle' | 'loading' | 'loaded' | 'error';
|
type WebsiteIconStatus = 'idle' | 'loading' | 'loaded' | 'error';
|
||||||
|
|
||||||
const ICON_LOAD_TIMEOUT_MS = 5000;
|
|
||||||
|
|
||||||
interface WebsiteIconRecord {
|
interface WebsiteIconRecord {
|
||||||
status: WebsiteIconStatus;
|
status: WebsiteIconStatus;
|
||||||
promise: Promise<WebsiteIconStatus> | null;
|
|
||||||
imageUrl: string | null;
|
imageUrl: string | null;
|
||||||
|
errorAt: number;
|
||||||
|
loadStartedAt: number;
|
||||||
|
loadToken: number;
|
||||||
|
loader: HTMLImageElement | null;
|
||||||
|
timeoutId: ReturnType<typeof setTimeout> | null;
|
||||||
listeners: Set<(status: WebsiteIconStatus) => void>;
|
listeners: Set<(status: WebsiteIconStatus) => void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const WEBSITE_ICON_ERROR_TTL_MS = 5 * 60 * 1000;
|
||||||
|
const WEBSITE_ICON_LOAD_TIMEOUT_MS = 15 * 1000;
|
||||||
|
|
||||||
const iconRecords = new Map<string, WebsiteIconRecord>();
|
const iconRecords = new Map<string, WebsiteIconRecord>();
|
||||||
|
|
||||||
function ensureRecord(host: string): WebsiteIconRecord {
|
function ensureRecord(host: string): WebsiteIconRecord {
|
||||||
@@ -16,8 +21,12 @@ function ensureRecord(host: string): WebsiteIconRecord {
|
|||||||
if (!record) {
|
if (!record) {
|
||||||
record = {
|
record = {
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
promise: null,
|
|
||||||
imageUrl: null,
|
imageUrl: null,
|
||||||
|
errorAt: 0,
|
||||||
|
loadStartedAt: 0,
|
||||||
|
loadToken: 0,
|
||||||
|
loader: null,
|
||||||
|
timeoutId: null,
|
||||||
listeners: new Set(),
|
listeners: new Set(),
|
||||||
};
|
};
|
||||||
iconRecords.set(host, record);
|
iconRecords.set(host, record);
|
||||||
@@ -25,6 +34,29 @@ function ensureRecord(host: string): WebsiteIconRecord {
|
|||||||
return record;
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearLoadTimer(record: WebsiteIconRecord): void {
|
||||||
|
if (record.timeoutId) {
|
||||||
|
clearTimeout(record.timeoutId);
|
||||||
|
record.timeoutId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function expireRecordIfNeeded(record: WebsiteIconRecord): void {
|
||||||
|
const now = Date.now();
|
||||||
|
if (record.status === 'error' && record.errorAt && now - record.errorAt >= WEBSITE_ICON_ERROR_TTL_MS) {
|
||||||
|
record.status = 'idle';
|
||||||
|
record.errorAt = 0;
|
||||||
|
record.imageUrl = null;
|
||||||
|
}
|
||||||
|
if (record.status === 'loading' && record.loadStartedAt && now - record.loadStartedAt >= WEBSITE_ICON_LOAD_TIMEOUT_MS) {
|
||||||
|
clearLoadTimer(record);
|
||||||
|
record.status = 'error';
|
||||||
|
record.errorAt = now;
|
||||||
|
record.imageUrl = null;
|
||||||
|
record.loader = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function notifyRecord(host: string, status: WebsiteIconStatus): void {
|
function notifyRecord(host: string, status: WebsiteIconStatus): void {
|
||||||
const record = ensureRecord(host);
|
const record = ensureRecord(host);
|
||||||
record.status = status;
|
record.status = status;
|
||||||
@@ -35,12 +67,16 @@ function notifyRecord(host: string, status: WebsiteIconStatus): void {
|
|||||||
|
|
||||||
export function getWebsiteIconStatus(host: string): WebsiteIconStatus {
|
export function getWebsiteIconStatus(host: string): WebsiteIconStatus {
|
||||||
if (!host) return 'idle';
|
if (!host) return 'idle';
|
||||||
return ensureRecord(host).status;
|
const record = ensureRecord(host);
|
||||||
|
expireRecordIfNeeded(record);
|
||||||
|
return record.status;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getWebsiteIconImageUrl(host: string): string {
|
export function getWebsiteIconImageUrl(host: string): string {
|
||||||
if (!host) return '';
|
if (!host) return '';
|
||||||
return ensureRecord(host).imageUrl || '';
|
const record = ensureRecord(host);
|
||||||
|
expireRecordIfNeeded(record);
|
||||||
|
return record.imageUrl || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function subscribeWebsiteIconStatus(host: string, listener: (status: WebsiteIconStatus) => void): () => void {
|
export function subscribeWebsiteIconStatus(host: string, listener: (status: WebsiteIconStatus) => void): () => void {
|
||||||
@@ -52,69 +88,72 @@ export function subscribeWebsiteIconStatus(host: string, listener: (status: Webs
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function markWebsiteIconLoaded(host: string, imageUrl?: string): void {
|
function markWebsiteIconLoaded(host: string, imageUrl?: string): void {
|
||||||
if (!host) return;
|
if (!host) return;
|
||||||
const record = ensureRecord(host);
|
const record = ensureRecord(host);
|
||||||
record.promise = null;
|
clearLoadTimer(record);
|
||||||
if (imageUrl) {
|
if (imageUrl) {
|
||||||
record.imageUrl = imageUrl;
|
record.imageUrl = imageUrl;
|
||||||
}
|
}
|
||||||
|
record.errorAt = 0;
|
||||||
|
record.loadStartedAt = 0;
|
||||||
|
record.loader = null;
|
||||||
notifyRecord(host, 'loaded');
|
notifyRecord(host, 'loaded');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function markWebsiteIconErrored(host: string): void {
|
function markWebsiteIconErrored(host: string): void {
|
||||||
if (!host) return;
|
if (!host) return;
|
||||||
const record = ensureRecord(host);
|
const record = ensureRecord(host);
|
||||||
record.promise = null;
|
clearLoadTimer(record);
|
||||||
record.imageUrl = null;
|
record.imageUrl = null;
|
||||||
|
record.errorAt = Date.now();
|
||||||
|
record.loadStartedAt = 0;
|
||||||
|
record.loader = null;
|
||||||
notifyRecord(host, 'error');
|
notifyRecord(host, 'error');
|
||||||
}
|
}
|
||||||
|
|
||||||
function blobToDataUrl(blob: Blob): Promise<string> {
|
export function beginWebsiteIconLoad(host: string, src: string): boolean {
|
||||||
return new Promise((resolve, reject) => {
|
if (!host || !src) return false;
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : '');
|
|
||||||
reader.onerror = () => reject(reader.error || new Error('Failed to read icon'));
|
|
||||||
reader.readAsDataURL(blob);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function preloadWebsiteIcon(host: string, src: string): Promise<WebsiteIconStatus> {
|
|
||||||
if (!host) return Promise.resolve('error');
|
|
||||||
|
|
||||||
const record = ensureRecord(host);
|
const record = ensureRecord(host);
|
||||||
if (record.status === 'loaded' || record.status === 'error') {
|
expireRecordIfNeeded(record);
|
||||||
return Promise.resolve(record.status);
|
if (record.status !== 'idle') return false;
|
||||||
}
|
|
||||||
if (record.promise) {
|
|
||||||
return record.promise;
|
|
||||||
}
|
|
||||||
|
|
||||||
notifyRecord(host, 'loading');
|
if (typeof Image !== 'function') {
|
||||||
record.promise = (async () => {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeout = window.setTimeout(() => controller.abort(), ICON_LOAD_TIMEOUT_MS);
|
|
||||||
try {
|
|
||||||
const resp = await fetch(src, {
|
|
||||||
cache: 'force-cache',
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
if (!resp.ok) throw new Error('Icon unavailable');
|
|
||||||
const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase();
|
|
||||||
if (!contentType.startsWith('image/')) throw new Error('Icon response is not an image');
|
|
||||||
const blob = await resp.blob();
|
|
||||||
if (!blob.size) throw new Error('Icon response is empty');
|
|
||||||
const imageUrl = await blobToDataUrl(blob);
|
|
||||||
if (!imageUrl) throw new Error('Icon response is empty');
|
|
||||||
markWebsiteIconLoaded(host, imageUrl);
|
|
||||||
return 'loaded';
|
|
||||||
} catch {
|
|
||||||
markWebsiteIconErrored(host);
|
markWebsiteIconErrored(host);
|
||||||
return 'error';
|
return false;
|
||||||
} finally {
|
|
||||||
window.clearTimeout(timeout);
|
|
||||||
}
|
}
|
||||||
})();
|
|
||||||
|
|
||||||
return record.promise;
|
const token = record.loadToken + 1;
|
||||||
|
const loader = new Image();
|
||||||
|
record.loadToken = token;
|
||||||
|
record.loader = loader;
|
||||||
|
record.imageUrl = src;
|
||||||
|
record.errorAt = 0;
|
||||||
|
record.loadStartedAt = Date.now();
|
||||||
|
notifyRecord(host, 'loading');
|
||||||
|
|
||||||
|
record.timeoutId = setTimeout(() => {
|
||||||
|
const current = ensureRecord(host);
|
||||||
|
if (current.loadToken !== token || current.status !== 'loading') return;
|
||||||
|
current.imageUrl = null;
|
||||||
|
current.errorAt = Date.now();
|
||||||
|
current.loadStartedAt = 0;
|
||||||
|
current.loader = null;
|
||||||
|
current.timeoutId = null;
|
||||||
|
notifyRecord(host, 'error');
|
||||||
|
}, WEBSITE_ICON_LOAD_TIMEOUT_MS);
|
||||||
|
|
||||||
|
loader.onload = () => {
|
||||||
|
const current = ensureRecord(host);
|
||||||
|
if (current.loadToken !== token) return;
|
||||||
|
markWebsiteIconLoaded(host, src);
|
||||||
|
};
|
||||||
|
loader.onerror = () => {
|
||||||
|
const current = ensureRecord(host);
|
||||||
|
if (current.loadToken !== token) return;
|
||||||
|
markWebsiteIconErrored(host);
|
||||||
|
};
|
||||||
|
loader.src = src;
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -240,3 +240,999 @@
|
|||||||
transform: translateX(100%);
|
transform: translateX(100%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Product refinement layer: calm, dense, app-like surfaces for the vault workflow. */
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, color-mix(in srgb, var(--panel-soft) 78%, var(--bg-accent)) 0%, var(--bg-accent) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4 {
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-page {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--panel);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
height: 56px;
|
||||||
|
padding-inline: 16px;
|
||||||
|
background: color-mix(in srgb, var(--panel) 92%, transparent);
|
||||||
|
border-color: var(--line-soft);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-logo {
|
||||||
|
height: 36px;
|
||||||
|
width: 45px;
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-wordmark,
|
||||||
|
.standalone-brand-wordmark,
|
||||||
|
.not-found-wordmark {
|
||||||
|
background: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-actions {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-chip {
|
||||||
|
height: 32px;
|
||||||
|
max-width: 280px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--panel-soft);
|
||||||
|
color: var(--muted-strong);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-chip span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
grid-template-columns: 212px minmax(0, 1fr);
|
||||||
|
background: var(--panel-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-side {
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--panel);
|
||||||
|
border-color: var(--line-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-nav-main {
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-link,
|
||||||
|
.side-group-trigger,
|
||||||
|
.side-sub-link,
|
||||||
|
.nav-layout-trigger,
|
||||||
|
.nav-layout-option {
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-link,
|
||||||
|
.side-group-trigger {
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
color: var(--muted-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-subnav-inner {
|
||||||
|
padding-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-sub-link {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-link:hover,
|
||||||
|
.side-group-trigger:hover,
|
||||||
|
.side-sub-link:hover,
|
||||||
|
.nav-layout-trigger:hover,
|
||||||
|
.nav-layout-trigger.active {
|
||||||
|
background: var(--panel-soft);
|
||||||
|
border-color: var(--line);
|
||||||
|
color: var(--text);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-link.active,
|
||||||
|
.side-group-trigger.active,
|
||||||
|
.side-sub-link.active,
|
||||||
|
.mobile-tab.active,
|
||||||
|
.tree-btn.active,
|
||||||
|
.list-item.active,
|
||||||
|
.sort-menu-item.active,
|
||||||
|
.backup-destination-item.active,
|
||||||
|
.backup-interval-preset.active,
|
||||||
|
.mobile-settings-link.active,
|
||||||
|
.nav-layout-option.active {
|
||||||
|
background: color-mix(in srgb, var(--primary) 10%, var(--panel));
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 34%, var(--line));
|
||||||
|
color: var(--primary-strong);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-layout-menu,
|
||||||
|
.sort-menu,
|
||||||
|
.create-menu {
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-stage {
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-page {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.standalone-shell {
|
||||||
|
max-width: 430px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.standalone-brand {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.standalone-brand-logo {
|
||||||
|
height: 48px;
|
||||||
|
width: 60px;
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card,
|
||||||
|
.dialog-card,
|
||||||
|
.card,
|
||||||
|
.list-panel,
|
||||||
|
.sidebar-block,
|
||||||
|
.settings-subcard,
|
||||||
|
.backup-operations-sidebar,
|
||||||
|
.backup-destination-sidebar,
|
||||||
|
.backup-detail-panel,
|
||||||
|
.restore-progress-card,
|
||||||
|
.backup-recommendation-card,
|
||||||
|
.backup-destination-item,
|
||||||
|
.backup-browser-list,
|
||||||
|
.backup-browser-path,
|
||||||
|
.totp-code-row,
|
||||||
|
.mobile-settings-link,
|
||||||
|
.table tr {
|
||||||
|
border-color: var(--line);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--panel);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card h1,
|
||||||
|
.standalone-title {
|
||||||
|
font-size: 26px;
|
||||||
|
line-height: 1.18;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted,
|
||||||
|
.standalone-muted,
|
||||||
|
.field-help,
|
||||||
|
.settings-field-note,
|
||||||
|
.backup-inline-note,
|
||||||
|
.detail-sub,
|
||||||
|
.list-sub,
|
||||||
|
.backup-destination-meta {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field > span {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
color: var(--muted-strong);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input,
|
||||||
|
.search-input {
|
||||||
|
height: 42px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border-color: var(--line);
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--text);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
min-height: 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:hover:not(:disabled),
|
||||||
|
.search-input:hover:not(:disabled) {
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 30%, var(--line));
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus,
|
||||||
|
.search-input:focus {
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 58%, var(--line));
|
||||||
|
background: var(--panel);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 14%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
height: 38px;
|
||||||
|
min-width: 38px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 14px;
|
||||||
|
box-shadow: none;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.small,
|
||||||
|
.topbar-actions .btn {
|
||||||
|
height: 32px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding-inline: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.full {
|
||||||
|
height: 44px;
|
||||||
|
margin-block: 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--primary);
|
||||||
|
border-color: transparent;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 8px 18px color-mix(in srgb, var(--primary) 20%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
box-shadow: 0 10px 22px color-mix(in srgb, var(--primary) 24%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--panel);
|
||||||
|
border-color: var(--line);
|
||||||
|
color: var(--primary-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
background: color-mix(in srgb, var(--primary) 6%, var(--panel));
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 26%, var(--line));
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: color-mix(in srgb, var(--danger) 5%, var(--panel));
|
||||||
|
border-color: color-mix(in srgb, var(--danger) 24%, var(--line));
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: color-mix(in srgb, var(--danger) 10%, var(--panel));
|
||||||
|
border-color: color-mix(in srgb, var(--danger) 38%, var(--line));
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active:not(:disabled) {
|
||||||
|
transform: translateY(0) scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.sidebar-block {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-title {
|
||||||
|
color: var(--muted-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-btn {
|
||||||
|
min-height: 36px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-delete-btn,
|
||||||
|
.folder-sort-btn,
|
||||||
|
.folder-add-btn,
|
||||||
|
.search-clear-btn,
|
||||||
|
.input-icon-btn,
|
||||||
|
.eye-btn,
|
||||||
|
.totp-copy-btn,
|
||||||
|
.domain-rule-expand-btn,
|
||||||
|
.domain-rule-mini-btn,
|
||||||
|
.domain-rule-icon-btn,
|
||||||
|
.password-history-close {
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-col {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-head {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-count {
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-panel,
|
||||||
|
.detail-col {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item {
|
||||||
|
min-height: 60px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--panel);
|
||||||
|
border-color: var(--line-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item:hover {
|
||||||
|
background: var(--panel-subtle);
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 28%, var(--line));
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item:hover .row-main,
|
||||||
|
.list-item.active .row-main,
|
||||||
|
.list-item:hover .list-text,
|
||||||
|
.list-item.active .list-text,
|
||||||
|
.list-item:hover .list-sub,
|
||||||
|
.list-item.active .list-sub {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item:hover .list-title,
|
||||||
|
.list-item.active .list-title {
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-title {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-badge {
|
||||||
|
background: var(--panel-muted);
|
||||||
|
color: var(--muted-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h4,
|
||||||
|
.settings-module h3,
|
||||||
|
.section-head h3,
|
||||||
|
.section-head h4 {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-title {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-line,
|
||||||
|
.kv-row,
|
||||||
|
.attachment-row,
|
||||||
|
.custom-field-card,
|
||||||
|
.table th,
|
||||||
|
.table td {
|
||||||
|
border-color: var(--line-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-label,
|
||||||
|
.kv-line > span,
|
||||||
|
.table th {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-modules-grid,
|
||||||
|
.import-export-panels,
|
||||||
|
.backup-grid,
|
||||||
|
.domain-rules-grid {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-module h3 {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sensitive-actions-module {
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sensitive-action,
|
||||||
|
.recovery-code-card,
|
||||||
|
.api-key-warning-panel,
|
||||||
|
.api-key-credentials-panel,
|
||||||
|
.backup-recommendation-dav-item,
|
||||||
|
.restore-progress-current,
|
||||||
|
.restore-progress-elapsed {
|
||||||
|
border-radius: .5rem;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: var(--line);
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-ok,
|
||||||
|
.device-status-pill.online {
|
||||||
|
color: var(--success);
|
||||||
|
background: color-mix(in srgb, var(--success) 11%, var(--panel));
|
||||||
|
}
|
||||||
|
|
||||||
|
.local-error,
|
||||||
|
.api-key-warning-title {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-grid {
|
||||||
|
grid-template-columns: 260px 280px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-operations-sidebar,
|
||||||
|
.backup-destination-sidebar,
|
||||||
|
.backup-detail-panel {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-recommendations-summary,
|
||||||
|
.backup-browser-path,
|
||||||
|
.backup-browser-empty,
|
||||||
|
.backup-destination-item {
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--panel-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-destination-item:hover {
|
||||||
|
background: var(--panel);
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 32%, var(--line));
|
||||||
|
}
|
||||||
|
|
||||||
|
.totp-code-row {
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.totp-code-name {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tr {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th,
|
||||||
|
.table td {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-item,
|
||||||
|
.dialog-card {
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] html,
|
||||||
|
:root[data-theme='dark'] body,
|
||||||
|
:root[data-theme='dark'] #root {
|
||||||
|
background: var(--bg-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .app-shell,
|
||||||
|
:root[data-theme='dark'] .topbar,
|
||||||
|
:root[data-theme='dark'] .app-side,
|
||||||
|
:root[data-theme='dark'] .mobile-tabbar,
|
||||||
|
:root[data-theme='dark'] .auth-card,
|
||||||
|
:root[data-theme='dark'] .dialog-card,
|
||||||
|
:root[data-theme='dark'] .card,
|
||||||
|
:root[data-theme='dark'] .list-panel,
|
||||||
|
:root[data-theme='dark'] .sidebar-block,
|
||||||
|
:root[data-theme='dark'] .settings-subcard,
|
||||||
|
:root[data-theme='dark'] .backup-operations-sidebar,
|
||||||
|
:root[data-theme='dark'] .backup-destination-sidebar,
|
||||||
|
:root[data-theme='dark'] .backup-detail-panel,
|
||||||
|
:root[data-theme='dark'] .backup-recommendation-card,
|
||||||
|
:root[data-theme='dark'] .backup-destination-item,
|
||||||
|
:root[data-theme='dark'] .backup-browser-list,
|
||||||
|
:root[data-theme='dark'] .backup-browser-path,
|
||||||
|
:root[data-theme='dark'] .totp-code-row,
|
||||||
|
:root[data-theme='dark'] .mobile-settings-link,
|
||||||
|
:root[data-theme='dark'] .table tr,
|
||||||
|
:root[data-theme='dark'] .list-item,
|
||||||
|
:root[data-theme='dark'] .input,
|
||||||
|
:root[data-theme='dark'] .search-input,
|
||||||
|
:root[data-theme='dark'] .create-menu,
|
||||||
|
:root[data-theme='dark'] .sort-menu,
|
||||||
|
:root[data-theme='dark'] .nav-layout-menu {
|
||||||
|
background: var(--panel);
|
||||||
|
border-color: var(--line);
|
||||||
|
color: var(--text);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .app-main,
|
||||||
|
:root[data-theme='dark'] .app-side,
|
||||||
|
:root[data-theme='dark'] .mobile-tabbar,
|
||||||
|
:root[data-theme='dark'] .backup-recommendations-summary,
|
||||||
|
:root[data-theme='dark'] .backup-browser-path,
|
||||||
|
:root[data-theme='dark'] .backup-browser-empty,
|
||||||
|
:root[data-theme='dark'] .backup-recommendation-dav-item {
|
||||||
|
background: var(--panel-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .list-item:hover,
|
||||||
|
:root[data-theme='dark'] .side-link:hover,
|
||||||
|
:root[data-theme='dark'] .side-group-trigger:hover,
|
||||||
|
:root[data-theme='dark'] .side-sub-link:hover,
|
||||||
|
:root[data-theme='dark'] .mobile-tab:hover,
|
||||||
|
:root[data-theme='dark'] .tree-btn:hover {
|
||||||
|
background: var(--panel-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .btn-primary {
|
||||||
|
color: #08111f;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1180px) {
|
||||||
|
.app-page {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
border-radius: 0;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
height: var(--mobile-topbar-height);
|
||||||
|
padding-inline: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-page-title {
|
||||||
|
max-width: min(60vw, 260px);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-tabbar {
|
||||||
|
min-height: var(--mobile-tabbar-height);
|
||||||
|
background: color-mix(in srgb, var(--panel) 96%, transparent);
|
||||||
|
border-color: var(--line-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-tab {
|
||||||
|
min-height: 48px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-tab:hover {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-tab.active {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-grid {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-detail-sheet {
|
||||||
|
background: var(--panel-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sidebar-sheet {
|
||||||
|
left: 8px;
|
||||||
|
right: 8px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border-color: var(--line);
|
||||||
|
background: var(--panel);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-head {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto auto auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-panel {
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item {
|
||||||
|
min-height: 58px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-detail-sheet .card,
|
||||||
|
.detail-col .card,
|
||||||
|
.import-export-panel,
|
||||||
|
.settings-subcard {
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-detail-sheet .card {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-actions .actions .btn,
|
||||||
|
.detail-delete-btn,
|
||||||
|
.import-export-panel .actions .btn,
|
||||||
|
.settings-subcard .actions .btn,
|
||||||
|
.section-head .actions .btn {
|
||||||
|
min-height: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-stack {
|
||||||
|
top: 10px;
|
||||||
|
left: 10px;
|
||||||
|
right: 10px;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
body {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-page {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
padding: 18px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.standalone-shell {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.standalone-brand-logo {
|
||||||
|
height: 42px;
|
||||||
|
width: 53px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.standalone-brand-wordmark {
|
||||||
|
width: clamp(176px, 58vw, 244px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
margin-bottom: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input,
|
||||||
|
.search-input {
|
||||||
|
height: 42px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-modules-grid,
|
||||||
|
.import-export-panels,
|
||||||
|
.backup-grid,
|
||||||
|
.domain-rules-grid {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-module,
|
||||||
|
.backup-operations-sidebar,
|
||||||
|
.backup-destination-sidebar,
|
||||||
|
.backup-detail-panel {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sensitive-actions-module {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-detail-panel > .section-head .actions .btn {
|
||||||
|
min-height: 38px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Final consistency pass for management controls and selected vault rows. */
|
||||||
|
.toolbar .btn.small,
|
||||||
|
.toolbar.actions .btn.small,
|
||||||
|
.actions .btn.small,
|
||||||
|
.topbar-actions .btn,
|
||||||
|
.list-icon-btn,
|
||||||
|
.sort-trigger,
|
||||||
|
.mobile-fab-trigger,
|
||||||
|
.website-remove-btn,
|
||||||
|
.kv-actions .btn.small,
|
||||||
|
.domain-rule-mini-btn,
|
||||||
|
.domain-rule-icon-btn,
|
||||||
|
.admin-pagination .btn,
|
||||||
|
.invite-row-actions .btn,
|
||||||
|
.admin-invites-head-actions .btn,
|
||||||
|
input[type='file'].input::file-selector-button {
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-fab-trigger,
|
||||||
|
.sort-trigger,
|
||||||
|
.totp-copy-btn,
|
||||||
|
.input-icon-btn,
|
||||||
|
.eye-btn,
|
||||||
|
.search-clear-btn,
|
||||||
|
.folder-delete-btn,
|
||||||
|
.folder-sort-btn,
|
||||||
|
.domain-rule-mini-btn,
|
||||||
|
.domain-rule-icon-btn {
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item.active {
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, color-mix(in srgb, var(--primary) 13%, var(--panel)) 0%, var(--panel) 72%);
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 46%, var(--line));
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item.active:hover {
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, color-mix(in srgb, var(--primary) 17%, var(--panel)) 0%, var(--panel-subtle) 72%);
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 58%, var(--line));
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item.active .list-title,
|
||||||
|
.list-item.active .list-icon-fallback {
|
||||||
|
color: var(--primary-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item.active .list-sub {
|
||||||
|
color: var(--muted-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-invites-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-invites-head {
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 1px solid var(--line-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-invites-head h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-invites-head-actions {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-toolbar {
|
||||||
|
display: grid;
|
||||||
|
align-items: end;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--line-soft);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--panel-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-create-group {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(120px, 150px) auto;
|
||||||
|
align-items: end;
|
||||||
|
justify-content: start;
|
||||||
|
gap: 10px;
|
||||||
|
width: fit-content;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-hours-field {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-hours-field > span {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-hours-field .input.small {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-table {
|
||||||
|
border-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-table th {
|
||||||
|
height: 42px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
background: var(--panel-soft);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-table td {
|
||||||
|
height: 48px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-table tr {
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-table tbody tr:hover {
|
||||||
|
background: var(--panel-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-table .empty {
|
||||||
|
min-height: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-row-actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-pagination {
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
margin-top: 0;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid var(--line-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .list-item.active {
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, color-mix(in srgb, var(--primary) 22%, var(--panel)) 0%, var(--panel) 72%);
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 62%, var(--line));
|
||||||
|
box-shadow:
|
||||||
|
inset 3px 0 0 var(--primary),
|
||||||
|
0 0 0 1px color-mix(in srgb, var(--primary) 22%, transparent),
|
||||||
|
0 10px 24px rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .list-item.active:hover {
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, color-mix(in srgb, var(--primary) 26%, var(--panel)) 0%, var(--panel-subtle) 72%);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .list-item.active .list-title,
|
||||||
|
:root[data-theme='dark'] .list-item.active .list-icon-fallback {
|
||||||
|
color: var(--primary-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .list-item.active .list-sub {
|
||||||
|
color: var(--muted-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .invite-toolbar,
|
||||||
|
:root[data-theme='dark'] .invite-table th {
|
||||||
|
background: var(--panel-soft);
|
||||||
|
border-color: var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .invite-table tbody tr:hover {
|
||||||
|
background: var(--panel-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.admin-invites-head {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-invites-head-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-invites-head-actions .btn {
|
||||||
|
min-height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-create-group {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-create-group .btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-table {
|
||||||
|
border-spacing: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-table tr {
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-row-actions {
|
||||||
|
justify-content: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-row-actions .btn {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-pagination {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,6 +47,9 @@
|
|||||||
:root[data-theme='dark'] .or,
|
:root[data-theme='dark'] .or,
|
||||||
:root[data-theme='dark'] .mobile-tab,
|
:root[data-theme='dark'] .mobile-tab,
|
||||||
:root[data-theme='dark'] .side-link,
|
:root[data-theme='dark'] .side-link,
|
||||||
|
:root[data-theme='dark'] .side-group-trigger,
|
||||||
|
:root[data-theme='dark'] .side-sub-link,
|
||||||
|
:root[data-theme='dark'] .nav-layout-trigger,
|
||||||
:root[data-theme='dark'] .user-chip,
|
:root[data-theme='dark'] .user-chip,
|
||||||
:root[data-theme='dark'] .list-count,
|
:root[data-theme='dark'] .list-count,
|
||||||
:root[data-theme='dark'] .totp-code-username,
|
:root[data-theme='dark'] .totp-code-username,
|
||||||
@@ -195,14 +198,38 @@
|
|||||||
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.24), 0 0 0 1px rgba(139, 184, 255, 0.12);
|
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.24), 0 0 0 1px rgba(139, 184, 255, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .card-brand-icon {
|
||||||
|
color: #bfdbfe;
|
||||||
|
background: linear-gradient(180deg, #1f2937 0%, #111827 100%);
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 30%, var(--line));
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .card-brand-icon:has(img) {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .card-brand-american-express {
|
||||||
|
color: #fff;
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .card-brand-mastercard,
|
||||||
|
:root[data-theme='dark'] .card-brand-maestro,
|
||||||
|
:root[data-theme='dark'] .card-brand-discover {
|
||||||
|
color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
:root[data-theme='dark'] .mobile-sidebar-sheet,
|
:root[data-theme='dark'] .mobile-sidebar-sheet,
|
||||||
:root[data-theme='dark'] .mobile-sidebar-close,
|
:root[data-theme='dark'] .mobile-sidebar-close,
|
||||||
|
:root[data-theme='dark'] .nav-layout-menu,
|
||||||
|
:root[data-theme='dark'] .nav-layout-trigger,
|
||||||
:root[data-theme='dark'] .table tr,
|
:root[data-theme='dark'] .table tr,
|
||||||
:root[data-theme='dark'] .settings-subcard,
|
:root[data-theme='dark'] .settings-subcard,
|
||||||
:root[data-theme='dark'] .import-summary-table-wrap,
|
:root[data-theme='dark'] .import-summary-table-wrap,
|
||||||
:root[data-theme='dark'] .backup-help-bubble,
|
:root[data-theme='dark'] .backup-help-bubble,
|
||||||
:root[data-theme='dark'] .backup-recommendation-card,
|
:root[data-theme='dark'] .backup-recommendation-card,
|
||||||
:root[data-theme='dark'] .backup-recommendation-dav-item,
|
:root[data-theme='dark'] .backup-recommendation-dav-item,
|
||||||
|
:root[data-theme='dark'] .backup-recommendations-summary,
|
||||||
:root[data-theme='dark'] .backup-browser-path,
|
:root[data-theme='dark'] .backup-browser-path,
|
||||||
:root[data-theme='dark'] .backup-browser-list,
|
:root[data-theme='dark'] .backup-browser-list,
|
||||||
:root[data-theme='dark'] .restore-progress-card,
|
:root[data-theme='dark'] .restore-progress-card,
|
||||||
@@ -229,6 +256,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme='dark'] .mobile-sidebar-close:hover,
|
:root[data-theme='dark'] .mobile-sidebar-close:hover,
|
||||||
|
:root[data-theme='dark'] .side-link:hover,
|
||||||
|
:root[data-theme='dark'] .side-group-trigger:hover,
|
||||||
|
:root[data-theme='dark'] .side-sub-link:hover,
|
||||||
|
:root[data-theme='dark'] .nav-layout-trigger:hover,
|
||||||
|
:root[data-theme='dark'] .nav-layout-trigger.active,
|
||||||
|
:root[data-theme='dark'] .nav-layout-option:hover,
|
||||||
|
:root[data-theme='dark'] .nav-layout-option.active,
|
||||||
:root[data-theme='dark'] .mobile-sidebar-sheet .tree-btn.active,
|
:root[data-theme='dark'] .mobile-sidebar-sheet .tree-btn.active,
|
||||||
:root[data-theme='dark'] .mobile-settings-link.active,
|
:root[data-theme='dark'] .mobile-settings-link.active,
|
||||||
:root[data-theme='dark'] .backup-destination-item.active,
|
:root[data-theme='dark'] .backup-destination-item.active,
|
||||||
@@ -247,7 +281,7 @@
|
|||||||
:root[data-theme='dark'] .restore-progress-card,
|
:root[data-theme='dark'] .restore-progress-card,
|
||||||
:root[data-theme='dark'] .restore-progress-current,
|
:root[data-theme='dark'] .restore-progress-current,
|
||||||
:root[data-theme='dark'] .restore-progress-elapsed {
|
:root[data-theme='dark'] .restore-progress-elapsed {
|
||||||
border-color: var(--line-soft);
|
border-color: var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme='dark'] .import-summary-table th {
|
:root[data-theme='dark'] .import-summary-table th {
|
||||||
|
|||||||
@@ -2,6 +2,13 @@
|
|||||||
@apply grid gap-3;
|
@apply grid gap-3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.domain-rules-route {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
grid-template-rows: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
.import-export-page {
|
.import-export-page {
|
||||||
@apply grid gap-3;
|
@apply grid gap-3;
|
||||||
}
|
}
|
||||||
@@ -19,15 +26,58 @@
|
|||||||
.backup-operations-sidebar,
|
.backup-operations-sidebar,
|
||||||
.backup-destination-sidebar,
|
.backup-destination-sidebar,
|
||||||
.backup-detail-panel {
|
.backup-detail-panel {
|
||||||
@apply min-w-0 rounded-xl bg-white p-3;
|
@apply min-w-0 rounded-2xl border bg-panel p-3 shadow-soft;
|
||||||
border: 1px solid #d8dee8;
|
border-color: var(--line);
|
||||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.backup-actions-stack {
|
.backup-actions-stack {
|
||||||
@apply grid gap-2.5;
|
@apply grid gap-2.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.backup-recommendations-disclosure {
|
||||||
|
@apply mt-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-recommendations-summary {
|
||||||
|
@apply flex cursor-pointer list-none items-center justify-between gap-3 rounded-xl border px-3 py-2.5;
|
||||||
|
border-color: var(--line);
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-recommendations-summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-recommendations-summary > span:first-child {
|
||||||
|
@apply grid min-w-0 gap-0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-recommendations-summary strong {
|
||||||
|
@apply text-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-recommendations-summary small {
|
||||||
|
@apply text-xs font-semibold;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-recommendations-summary-icon {
|
||||||
|
@apply h-2.5 w-2.5 shrink-0;
|
||||||
|
border-right: 2px solid #365fa8;
|
||||||
|
border-bottom: 2px solid #365fa8;
|
||||||
|
transform: rotate(45deg) translateY(-2px);
|
||||||
|
transition: transform var(--dur-fast) var(--ease-out-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-recommendations-disclosure[open] .backup-recommendations-summary-icon {
|
||||||
|
transform: rotate(225deg) translate(-1px, -1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-recommendations-body {
|
||||||
|
@apply pt-2.5;
|
||||||
|
}
|
||||||
|
|
||||||
.backup-option-field {
|
.backup-option-field {
|
||||||
@apply inline-flex items-center gap-2;
|
@apply inline-flex items-center gap-2;
|
||||||
}
|
}
|
||||||
@@ -305,7 +355,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.backup-browser-list {
|
.backup-browser-list {
|
||||||
@apply overflow-hidden rounded-xl bg-white;
|
@apply overflow-hidden rounded-xl border bg-white;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,6 +505,13 @@
|
|||||||
@apply min-w-0;
|
@apply min-w-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sensitive-actions-module {
|
||||||
|
@apply min-w-0 p-0;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-module h3 {
|
.settings-module h3 {
|
||||||
@apply mb-4 mt-0 text-base font-extrabold;
|
@apply mb-4 mt-0 text-base font-extrabold;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
@@ -483,9 +540,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sensitive-action {
|
.sensitive-action {
|
||||||
@apply rounded-lg border p-3.5;
|
@apply border;
|
||||||
border-color: var(--line-soft);
|
border-radius: .5rem;
|
||||||
background: color-mix(in srgb, var(--surface) 74%, transparent);
|
border-width: 1px;
|
||||||
|
padding: 23px .875rem;
|
||||||
|
border-color: var(--line);
|
||||||
|
background: var(--panel);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sensitive-action h4 {
|
.sensitive-action h4 {
|
||||||
@@ -494,9 +554,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.recovery-code-card {
|
.recovery-code-card {
|
||||||
@apply mb-0 mt-2.5 rounded-lg border p-3;
|
@apply mb-0 mt-2.5 border p-3;
|
||||||
border-color: var(--line-soft);
|
border-radius: .5rem;
|
||||||
background: color-mix(in srgb, var(--surface) 84%, transparent);
|
border-width: 1px;
|
||||||
|
border-color: var(--line);
|
||||||
|
background: var(--panel);
|
||||||
}
|
}
|
||||||
|
|
||||||
.recovery-code-value {
|
.recovery-code-value {
|
||||||
@@ -510,8 +572,8 @@
|
|||||||
|
|
||||||
.api-key-warning-panel {
|
.api-key-warning-panel {
|
||||||
@apply mb-3.5 mt-3;
|
@apply mb-3.5 mt-3;
|
||||||
border: 1px solid color-mix(in srgb, var(--danger) 24%, transparent);
|
border: 1px solid var(--line);
|
||||||
background: color-mix(in srgb, var(--danger) 7%, var(--surface));
|
background: var(--panel);
|
||||||
}
|
}
|
||||||
|
|
||||||
.api-key-warning-title {
|
.api-key-warning-title {
|
||||||
@@ -570,7 +632,7 @@
|
|||||||
|
|
||||||
.website-row {
|
.website-row {
|
||||||
@apply mb-2 grid items-center gap-2 rounded-[18px] border p-1.5;
|
@apply mb-2 grid items-center gap-2 rounded-[18px] border p-1.5;
|
||||||
grid-template-columns: auto minmax(0, 1fr) minmax(130px, 160px) auto;
|
grid-template-columns: auto minmax(0, 1fr) minmax(96px, 120px) auto;
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
background: color-mix(in srgb, var(--panel) 84%, transparent);
|
background: color-mix(in srgb, var(--panel) 84%, transparent);
|
||||||
transition:
|
transition:
|
||||||
@@ -581,50 +643,25 @@
|
|||||||
opacity var(--dur-fast) var(--ease-smooth);
|
opacity var(--dur-fast) var(--ease-smooth);
|
||||||
}
|
}
|
||||||
|
|
||||||
.website-row.is-dragging {
|
.website-order-actions {
|
||||||
@apply opacity-50;
|
@apply grid gap-1;
|
||||||
border-color: rgba(37, 99, 235, 0.24);
|
|
||||||
background: color-mix(in srgb, var(--panel-soft) 92%, white 8%);
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.website-drag-btn {
|
.website-order-btn {
|
||||||
@apply relative h-12 w-7 min-w-7 cursor-grab gap-0 overflow-visible rounded-[10px] p-0 opacity-[0.82];
|
@apply h-[22px] w-7 min-w-7 gap-0 rounded-[8px] p-0;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
border-color: transparent;
|
|
||||||
background: transparent;
|
|
||||||
box-shadow: none;
|
|
||||||
touch-action: none;
|
|
||||||
-webkit-user-select: none;
|
|
||||||
user-select: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.website-drag-btn:hover {
|
.website-order-btn:hover:not(:disabled) {
|
||||||
color: var(--primary-strong);
|
color: var(--primary-strong);
|
||||||
border-color: transparent;
|
|
||||||
background: transparent;
|
|
||||||
box-shadow: none;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.website-drag-btn:active {
|
.website-order-btn:disabled {
|
||||||
cursor: grabbing;
|
@apply opacity-35;
|
||||||
border-color: transparent;
|
|
||||||
background: transparent;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.website-drag-btn::before {
|
|
||||||
content: '';
|
|
||||||
@apply absolute -inset-2 rounded-xl;
|
|
||||||
}
|
|
||||||
|
|
||||||
.website-drag-btn .btn-icon {
|
|
||||||
@apply opacity-90;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.website-match-select {
|
.website-match-select {
|
||||||
@apply h-12 py-2.5 pr-[38px] text-[13px] leading-[1.2];
|
@apply h-12 py-2.5 pr-[30px] text-[13px] leading-[1.2];
|
||||||
}
|
}
|
||||||
|
|
||||||
.website-match-select option {
|
.website-match-select option {
|
||||||
@@ -637,30 +674,22 @@
|
|||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.website-row {
|
.website-row {
|
||||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
grid-template-columns: auto minmax(88px, 1fr) minmax(72px, 84px) auto;
|
||||||
@apply items-start;
|
@apply items-center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.website-row > :nth-child(1) {
|
.website-row .website-remove-btn {
|
||||||
grid-column: 1;
|
width: 30px;
|
||||||
grid-row: 1;
|
min-width: 30px;
|
||||||
align-self: center;
|
height: 30px;
|
||||||
|
padding: 0;
|
||||||
|
gap: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.website-row > :nth-child(2) {
|
.website-row .website-remove-btn .btn-icon {
|
||||||
grid-column: 2 / span 2;
|
margin: 0;
|
||||||
grid-row: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.website-row > :nth-child(3) {
|
|
||||||
grid-column: 1 / span 2;
|
|
||||||
grid-row: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.website-row > :nth-child(4) {
|
|
||||||
grid-column: 3;
|
|
||||||
grid-row: 2;
|
|
||||||
justify-self: start;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -743,10 +772,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.restore-progress-elapsed {
|
.restore-progress-elapsed {
|
||||||
@apply shrink-0 rounded-[10px] px-2 py-1.5 text-center text-[13px] font-semibold;
|
@apply shrink-0 px-2 py-1.5 text-center text-[13px] font-semibold;
|
||||||
min-width: 88px;
|
min-width: 88px;
|
||||||
background: #f8fbff;
|
border-radius: .5rem;
|
||||||
border: 1px solid #d7e2f1;
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
color: #475569;
|
color: #475569;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -762,9 +792,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.restore-progress-current {
|
.restore-progress-current {
|
||||||
@apply mt-3 rounded-[10px] px-3 py-2.5;
|
@apply mt-3 px-3 py-2.5;
|
||||||
background: #f8fbff;
|
border-radius: .5rem;
|
||||||
border: 1px solid #d7e2f1;
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.restore-progress-current strong {
|
.restore-progress-current strong {
|
||||||
@@ -858,6 +889,10 @@
|
|||||||
color: #5f6f85;
|
color: #5f6f85;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-pagination {
|
||||||
|
@apply mt-3 items-center;
|
||||||
|
}
|
||||||
|
|
||||||
.trusted-cell {
|
.trusted-cell {
|
||||||
@apply inline-flex items-center gap-1.5;
|
@apply inline-flex items-center gap-1.5;
|
||||||
}
|
}
|
||||||
@@ -875,3 +910,275 @@
|
|||||||
background: #e2e8f0;
|
background: #e2e8f0;
|
||||||
color: #475569;
|
color: #475569;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section-heading-row {
|
||||||
|
@apply mb-3.5 flex items-center justify-between gap-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading-row h3 {
|
||||||
|
@apply mb-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-rules-page {
|
||||||
|
@apply grid min-h-0 gap-3.5;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-rules-toolbar {
|
||||||
|
@apply flex flex-wrap items-start justify-between gap-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-rules-toolbar-copy {
|
||||||
|
max-width: 760px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-rules-toolbar-title {
|
||||||
|
@apply text-base font-bold;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-rules-toolbar-copy p {
|
||||||
|
@apply mt-1.5 text-sm leading-6;
|
||||||
|
color: var(--muted-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-rules-grid {
|
||||||
|
min-height: 0;
|
||||||
|
grid-template-columns: minmax(380px, 1fr) minmax(420px, 1.08fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-rules-custom,
|
||||||
|
.domain-rules-global {
|
||||||
|
@apply flex min-h-0 flex-col rounded-2xl border bg-panel shadow-soft;
|
||||||
|
border-color: var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-rules-heading-actions {
|
||||||
|
@apply flex flex-wrap items-center justify-end gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-rules-filter {
|
||||||
|
width: min(240px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-rules-table {
|
||||||
|
@apply grid min-h-0 flex-1 content-start gap-2 overflow-auto pr-0.5;
|
||||||
|
overflow-anchor: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-rule-row {
|
||||||
|
@apply grid items-center gap-2.5 rounded-md px-2.5 py-2.5;
|
||||||
|
grid-template-columns: 18px minmax(0, 1fr) auto auto;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-rule-row > input[type='checkbox'] {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-rule-readonly-row {
|
||||||
|
grid-template-columns: 18px minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-rule-editing-row {
|
||||||
|
@apply items-start;
|
||||||
|
grid-template-columns: minmax(360px, 1fr) auto;
|
||||||
|
column-gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-rule-domains {
|
||||||
|
display: block;
|
||||||
|
line-height: 20px;
|
||||||
|
min-width: 0;
|
||||||
|
max-height: 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition:
|
||||||
|
max-height 180ms var(--ease-out-soft),
|
||||||
|
opacity 140ms var(--ease-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-rule-row-expanded {
|
||||||
|
@apply items-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-rule-row-expanded > input[type='checkbox'],
|
||||||
|
.domain-rule-row-expanded .domain-rule-expand-btn,
|
||||||
|
.domain-rule-row-expanded .domain-rule-row-actions {
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-rule-row-expanded .domain-rule-domains {
|
||||||
|
overflow: visible;
|
||||||
|
text-overflow: clip;
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-rule-domains-expanded {
|
||||||
|
max-height: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-rule-expand-btn {
|
||||||
|
@apply flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-0 p-0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted-strong);
|
||||||
|
transition:
|
||||||
|
background-color var(--dur-fast) var(--ease-smooth),
|
||||||
|
color var(--dur-fast) var(--ease-smooth),
|
||||||
|
transform var(--dur-fast) var(--ease-out-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-rule-expand-btn:hover {
|
||||||
|
background: var(--panel-soft);
|
||||||
|
color: var(--primary);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-rule-main {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-rule-inputs {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 8px 18px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-rule-input-piece {
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
@apply flex items-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-rule-inline-input {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
padding-right: 52px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-rule-inline-input.domain-rule-input-invalid {
|
||||||
|
border-color: rgba(217, 45, 87, 0.78);
|
||||||
|
background: color-mix(in srgb, var(--danger) 5%, var(--panel));
|
||||||
|
box-shadow: 0 0 0 3px rgba(217, 45, 87, 0.10), inset 0 1px 0 rgba(255, 255, 255, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-rule-inline-input.domain-rule-input-invalid:focus {
|
||||||
|
border-color: rgba(217, 45, 87, 0.86);
|
||||||
|
box-shadow: 0 0 0 4px rgba(217, 45, 87, 0.14), inset 0 1px 0 rgba(255, 255, 255, 0.78);
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-rule-operator {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: -12px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 700;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-rule-input-piece:nth-child(even) .domain-rule-operator {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-rule-mini-btn,
|
||||||
|
.domain-rule-icon-btn {
|
||||||
|
@apply h-9 w-9 justify-center p-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-rule-input-remove {
|
||||||
|
@apply absolute top-1/2 flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-full border-0 p-0;
|
||||||
|
right: 1rem;
|
||||||
|
background: var(--panel-soft);
|
||||||
|
color: var(--primary);
|
||||||
|
transition:
|
||||||
|
background-color var(--dur-fast) var(--ease-smooth),
|
||||||
|
color var(--dur-fast) var(--ease-smooth),
|
||||||
|
transform var(--dur-fast) var(--ease-out-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-rule-input-remove:hover {
|
||||||
|
background: color-mix(in srgb, var(--danger) 12%, var(--panel));
|
||||||
|
color: var(--danger);
|
||||||
|
transform: translateY(-50%) scale(1.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-rule-row-actions {
|
||||||
|
@apply flex items-center self-center gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-rule-row-actions .btn {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-rule-editing-row .domain-rule-row-actions {
|
||||||
|
align-self: start;
|
||||||
|
padding-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-rule-new-row {
|
||||||
|
@apply mb-2;
|
||||||
|
background: var(--panel-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1180px) {
|
||||||
|
.route-stage-fixed {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-rules-route {
|
||||||
|
height: auto;
|
||||||
|
overflow: visible;
|
||||||
|
grid-template-rows: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-rules-page {
|
||||||
|
height: auto;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-rules-grid {
|
||||||
|
grid-auto-rows: minmax(320px, min(54vh, 560px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-rules-table {
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.domain-rules-page {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-rules-grid {
|
||||||
|
grid-auto-rows: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-rules-table {
|
||||||
|
max-height: 56vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-rule-editing-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-rule-inputs {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-rule-operator {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domain-rule-editing-row .domain-rule-row-actions {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -360,7 +360,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.list-item {
|
.list-item {
|
||||||
@apply rounded-[14px] p-3;
|
@apply rounded-[14px] py-2 px-3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row-check {
|
.row-check {
|
||||||
@@ -417,10 +417,178 @@
|
|||||||
@apply rounded-2xl;
|
@apply rounded-2xl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-col {
|
||||||
|
@apply overflow-visible rounded-2xl border-0 bg-transparent p-0 shadow-none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-detail-sheet.detail-col {
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
@apply p-3.5;
|
@apply p-3.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mobile-detail-sheet {
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-panel-head {
|
||||||
|
margin: 6px 8px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-panel-back {
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-detail-sheet > .detail-switch-stage,
|
||||||
|
.mobile-detail-sheet > .card,
|
||||||
|
.mobile-detail-sheet > .empty {
|
||||||
|
margin-left: 8px;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-detail-sheet .card {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-detail-sheet .card h4 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-detail-sheet .detail-title {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-detail-sheet .section-head {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-detail-sheet .section-head h3,
|
||||||
|
.mobile-detail-sheet .section-head h4 {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-detail-sheet .section-head > .btn.small {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-detail-sheet .field {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-detail-sheet .field > span {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-detail-sheet .field-grid {
|
||||||
|
gap: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-detail-sheet .input {
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 8px 11px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-detail-sheet select.input {
|
||||||
|
padding-right: 30px;
|
||||||
|
background-position:
|
||||||
|
calc(100% - 15px) calc(50% - 3px),
|
||||||
|
calc(100% - 9px) calc(50% - 3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-detail-sheet .textarea {
|
||||||
|
min-height: 82px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-detail-sheet .input-action-wrap .input {
|
||||||
|
padding-right: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-detail-sheet .input-icon-btn {
|
||||||
|
right: 6px;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-detail-sheet .website-row {
|
||||||
|
grid-template-columns: auto minmax(88px, 1fr) minmax(72px, 84px) auto;
|
||||||
|
gap: 5px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-detail-sheet .website-row > * {
|
||||||
|
grid-column: auto !important;
|
||||||
|
grid-row: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-detail-sheet .website-order-actions {
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-detail-sheet .website-order-btn {
|
||||||
|
width: 24px;
|
||||||
|
min-width: 24px;
|
||||||
|
height: 19px;
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-detail-sheet .website-match-select {
|
||||||
|
height: 40px;
|
||||||
|
min-width: 0;
|
||||||
|
padding-left: 8px;
|
||||||
|
padding-right: 24px;
|
||||||
|
font-size: 12px;
|
||||||
|
background-position:
|
||||||
|
calc(100% - 12px) calc(50% - 3px),
|
||||||
|
calc(100% - 7px) calc(50% - 3px);
|
||||||
|
background-size: 5px 5px, 5px 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-detail-sheet .website-remove-btn {
|
||||||
|
width: 30px;
|
||||||
|
min-width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0;
|
||||||
|
gap: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-detail-sheet .website-remove-btn .btn-icon {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-detail-sheet .attachment-row,
|
||||||
|
.mobile-detail-sheet .custom-field-card {
|
||||||
|
padding-top: 7px;
|
||||||
|
padding-bottom: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
.section-head {
|
.section-head {
|
||||||
@apply flex-col items-start gap-2.5;
|
@apply flex-col items-start gap-2.5;
|
||||||
}
|
}
|
||||||
@@ -477,6 +645,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.settings-modules-grid,
|
.settings-modules-grid,
|
||||||
|
.domain-rules-grid,
|
||||||
.password-settings-grid {
|
.password-settings-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@@ -654,34 +823,75 @@
|
|||||||
@media (max-width: 1180px) {
|
@media (max-width: 1180px) {
|
||||||
.backup-grid {
|
.backup-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.backup-operations-sidebar,
|
.backup-operations-sidebar,
|
||||||
.backup-destination-sidebar {
|
.backup-destination-sidebar,
|
||||||
|
.backup-detail-panel {
|
||||||
position: static;
|
position: static;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 14px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
|
.settings-modules-grid {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-module {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sensitive-actions-module {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-module h3 {
|
.settings-module h3 {
|
||||||
margin-bottom: 12px;
|
margin-bottom: 8px;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.25;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-module .field,
|
.settings-module .field,
|
||||||
.auth-card .field {
|
.auth-card .field {
|
||||||
margin-bottom: 12px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-module .field > span,
|
.settings-module .field > span,
|
||||||
.auth-card .field > span {
|
.auth-card .field > span {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-module .field-grid,
|
.settings-module .field-grid,
|
||||||
.auth-card .field-grid,
|
.auth-card .field-grid,
|
||||||
.session-timeout-fields {
|
.session-timeout-fields {
|
||||||
gap: 12px;
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-module .input {
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 8px 11px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-module select.input {
|
||||||
|
padding-right: 30px;
|
||||||
|
background-position:
|
||||||
|
calc(100% - 15px) calc(50% - 3px),
|
||||||
|
calc(100% - 9px) calc(50% - 3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-module .field-help {
|
||||||
|
margin-top: 5px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-module .btn,
|
.settings-module .btn,
|
||||||
@@ -689,6 +899,46 @@
|
|||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-module .actions {
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-module .totp-grid {
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-module .totp-qr {
|
||||||
|
min-height: 132px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-module .totp-qr svg,
|
||||||
|
.settings-module .totp-qr img {
|
||||||
|
width: 118px;
|
||||||
|
height: 118px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-module .sensitive-actions-grid {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-module .sensitive-action {
|
||||||
|
padding: 23px .875rem;
|
||||||
|
border-radius: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-module .sensitive-action h4 {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-field-note {
|
||||||
|
margin-bottom: 7px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
.dialog-mask.totp-scan-mask {
|
.dialog-mask.totp-scan-mask {
|
||||||
display: block;
|
display: block;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -708,6 +958,7 @@
|
|||||||
|
|
||||||
.backup-interval-row {
|
.backup-interval-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
gap: 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.backup-browser-row,
|
.backup-browser-row,
|
||||||
@@ -715,9 +966,149 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.backup-destination-top {
|
.backup-grid {
|
||||||
align-items: flex-start;
|
gap: 8px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-operations-sidebar,
|
||||||
|
.backup-destination-sidebar,
|
||||||
|
.backup-detail-panel {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-operations-sidebar .section-head,
|
||||||
|
.backup-destination-sidebar .section-head,
|
||||||
|
.backup-detail-panel .section-head {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-detail-panel > .section-head {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-detail-panel > .section-head .actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-detail-panel > .section-head .actions .btn {
|
||||||
|
height: 36px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-operations-sidebar .section-head h3,
|
||||||
|
.backup-destination-sidebar .section-head h3,
|
||||||
|
.backup-detail-panel .section-head h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-actions-stack,
|
||||||
|
.backup-destination-list,
|
||||||
|
.backup-recommendation-list {
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-actions-stack .btn,
|
||||||
|
.backup-destination-addbar .btn {
|
||||||
|
height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-recommendations-disclosure {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-recommendations-summary {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-recommendations-body {
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-recommendation-group + .backup-recommendation-group {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-recommendation-group-title {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-destination-item {
|
||||||
|
padding: 9px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-destination-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-name-row,
|
||||||
|
.backup-detail-schedule-grid {
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-detail-panel .field {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-detail-panel .field > span {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-detail-panel .input {
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 8px 11px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-detail-panel select.input {
|
||||||
|
padding-right: 30px;
|
||||||
|
background-position:
|
||||||
|
calc(100% - 15px) calc(50% - 3px),
|
||||||
|
calc(100% - 9px) calc(50% - 3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-schedule-attachments-row {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-option-label {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-option-label input[type='checkbox'] {
|
||||||
|
width: 19px;
|
||||||
|
height: 19px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-browser-path,
|
||||||
|
.backup-browser-empty {
|
||||||
|
padding: 9px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-destination-top {
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: row;
|
||||||
}
|
}
|
||||||
|
|
||||||
.backup-add-chooser {
|
.backup-add-chooser {
|
||||||
|
|||||||
@@ -163,24 +163,147 @@
|
|||||||
border-color: var(--line-soft);
|
border-color: var(--line-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.side-nav-main {
|
||||||
|
@apply flex min-h-0 flex-1 flex-col gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
.side-link {
|
.side-link {
|
||||||
@apply flex items-center gap-2.5 rounded-xl border border-transparent px-3 py-2.5 text-sm font-semibold text-muted-strong no-underline transition;
|
@apply flex items-center gap-2.5 rounded-xl border border-transparent px-3 py-2.5 text-sm font-semibold text-muted-strong no-underline transition;
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-link:hover {
|
.side-link span,
|
||||||
|
.side-group-trigger span,
|
||||||
|
.side-sub-link span {
|
||||||
|
@apply min-w-0 flex-1 truncate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-link svg,
|
||||||
|
.side-group-trigger svg,
|
||||||
|
.side-sub-link svg {
|
||||||
|
@apply shrink-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-nav-group {
|
||||||
|
@apply grid gap-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-group-trigger {
|
||||||
|
@apply flex w-full cursor-pointer items-center gap-2.5 rounded-xl border border-transparent px-3 py-2.5 text-left text-sm font-semibold text-muted-strong transition;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-link:hover,
|
||||||
|
.side-group-trigger:hover {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-color: rgba(128, 152, 192, 0.18);
|
border-color: rgba(128, 152, 192, 0.18);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
box-shadow: 0 10px 18px rgba(15, 23, 42, 0.04);
|
box-shadow: 0 10px 18px rgba(15, 23, 42, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-link.active {
|
.side-link.active,
|
||||||
|
.side-group-trigger.active {
|
||||||
background: rgba(37, 99, 235, 0.11);
|
background: rgba(37, 99, 235, 0.11);
|
||||||
border-color: rgba(37, 99, 235, 0.28);
|
border-color: rgba(37, 99, 235, 0.28);
|
||||||
color: var(--primary-strong);
|
color: var(--primary-strong);
|
||||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.58);
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.58);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.side-group-chevron {
|
||||||
|
@apply shrink-0;
|
||||||
|
transition: transform 190ms var(--ease-out-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-nav-group.open .side-group-chevron {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-subnav {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 0fr;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
transition:
|
||||||
|
grid-template-rows 220ms var(--ease-smooth),
|
||||||
|
opacity 170ms var(--ease-smooth),
|
||||||
|
transform 220ms var(--ease-out-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-subnav.open {
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-subnav-inner {
|
||||||
|
@apply grid gap-1 overflow-hidden pl-4 pr-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-sub-link {
|
||||||
|
@apply flex items-center gap-2 rounded-lg border border-transparent px-2.5 py-2 text-sm font-semibold text-muted no-underline transition;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-sub-link:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.78);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-sub-link.active {
|
||||||
|
background: rgba(37, 99, 235, 0.10);
|
||||||
|
border-color: rgba(37, 99, 235, 0.18);
|
||||||
|
color: var(--primary-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-layout-control {
|
||||||
|
@apply relative mt-auto pt-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-layout-trigger {
|
||||||
|
@apply flex h-10 w-10 cursor-pointer items-center justify-center rounded-xl border p-0 transition;
|
||||||
|
border-color: rgba(128, 152, 192, 0.18);
|
||||||
|
background: rgba(255, 255, 255, 0.46);
|
||||||
|
color: var(--muted-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-layout-trigger:hover,
|
||||||
|
.nav-layout-trigger.active {
|
||||||
|
border-color: rgba(37, 99, 235, 0.20);
|
||||||
|
background: #fff;
|
||||||
|
color: var(--primary-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-layout-menu {
|
||||||
|
@apply absolute bottom-[calc(100%+8px)] left-0 right-0 z-40 grid gap-1 rounded-2xl border p-1.5;
|
||||||
|
border-color: var(--line);
|
||||||
|
background: var(--panel);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
transform-origin: bottom center;
|
||||||
|
animation: menu-in 190ms var(--ease-out-strong) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-layout-option {
|
||||||
|
@apply flex w-full cursor-pointer items-center gap-2.5 rounded-xl border border-transparent bg-transparent px-3 py-2.5 text-left transition;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-layout-option:hover,
|
||||||
|
.nav-layout-option.active {
|
||||||
|
background: color-mix(in srgb, var(--primary) 9%, var(--panel));
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 18%, var(--line));
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-layout-option-text {
|
||||||
|
@apply min-w-0 flex-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-layout-option-text strong {
|
||||||
|
@apply overflow-hidden text-ellipsis whitespace-nowrap text-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-layout-check {
|
||||||
|
@apply shrink-0;
|
||||||
|
color: var(--primary-strong);
|
||||||
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
@apply min-h-0 overflow-hidden p-3.5;
|
@apply min-h-0 overflow-hidden p-3.5;
|
||||||
}
|
}
|
||||||
@@ -189,6 +312,10 @@
|
|||||||
@apply h-full min-h-0 overflow-auto;
|
@apply h-full min-h-0 overflow-auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.route-stage-fixed {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.mobile-sidebar-mask {
|
.mobile-sidebar-mask {
|
||||||
@apply pointer-events-none invisible fixed inset-0 opacity-0;
|
@apply pointer-events-none invisible fixed inset-0 opacity-0;
|
||||||
background: rgba(15, 23, 42, 0.36);
|
background: rgba(15, 23, 42, 0.36);
|
||||||
|
|||||||
@@ -1,28 +1,34 @@
|
|||||||
:root {
|
:root {
|
||||||
--bg-accent: #eef3fa;
|
--bg-accent: #f3f6f9;
|
||||||
--panel: #ffffff;
|
--panel: #ffffff;
|
||||||
--panel-soft: #f6f8fc;
|
--panel-soft: #f8fafc;
|
||||||
--panel-muted: #edf2f8;
|
--panel-muted: #edf2f7;
|
||||||
--panel-subtle: #f8fafc;
|
--panel-subtle: #fbfcfe;
|
||||||
--line: rgba(113, 132, 163, 0.28);
|
--surface: #ffffff;
|
||||||
--line-soft: rgba(113, 132, 163, 0.16);
|
--line: rgba(100, 116, 139, 0.24);
|
||||||
--text: #0b1730;
|
--line-soft: rgba(100, 116, 139, 0.14);
|
||||||
--muted: #5f6f85;
|
--text: #111827;
|
||||||
--muted-strong: #2f4058;
|
--text-muted: #64748b;
|
||||||
--primary: #2563eb;
|
--muted: #64748b;
|
||||||
--primary-hover: #1d4ed8;
|
--muted-strong: #334155;
|
||||||
--primary-strong: #0f3f98;
|
--primary: #2457c5;
|
||||||
--danger: #d92d57;
|
--primary-hover: #1d4aa7;
|
||||||
|
--primary-strong: #173f8f;
|
||||||
|
--brand: var(--primary);
|
||||||
|
--brand-strong: var(--primary-strong);
|
||||||
|
--accent: #0f766e;
|
||||||
|
--accent-soft: #e6f6f3;
|
||||||
|
--danger: #c92f4e;
|
||||||
--success: #0f766e;
|
--success: #0f766e;
|
||||||
--warning: #b45309;
|
--warning: #b7791f;
|
||||||
--overlay-strong: rgba(15, 23, 42, 0.56);
|
--overlay-strong: rgba(15, 23, 42, 0.58);
|
||||||
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.05);
|
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.045);
|
||||||
--shadow-md: 0 8px 24px rgba(15, 23, 42, 0.08);
|
--shadow-md: 0 8px 18px rgba(15, 23, 42, 0.075);
|
||||||
--shadow-lg: 0 14px 38px rgba(15, 23, 42, 0.10);
|
--shadow-lg: 0 18px 44px rgba(15, 23, 42, 0.105);
|
||||||
--radius-sm: 8px;
|
--radius-sm: 6px;
|
||||||
--radius-md: 10px;
|
--radius-md: 8px;
|
||||||
--radius-lg: 14px;
|
--radius-lg: 10px;
|
||||||
--radius-xl: 18px;
|
--radius-xl: 14px;
|
||||||
--ease-out-strong: cubic-bezier(0.22, 1, 0.36, 1);
|
--ease-out-strong: cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
--ease-out-soft: cubic-bezier(0.24, 0.8, 0.32, 1);
|
--ease-out-soft: cubic-bezier(0.24, 0.8, 0.32, 1);
|
||||||
--ease-smooth: cubic-bezier(0.4, 0, 0.2, 1);
|
--ease-smooth: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
@@ -37,19 +43,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme='dark'] {
|
:root[data-theme='dark'] {
|
||||||
--bg-accent: #0b1020;
|
--bg-accent: #101418;
|
||||||
--panel: #111827;
|
--panel: #171d25;
|
||||||
--panel-soft: #0f172a;
|
--panel-soft: #131922;
|
||||||
--panel-muted: #0b1324;
|
--panel-muted: #0f151d;
|
||||||
--panel-subtle: #151e2e;
|
--panel-subtle: #1c2430;
|
||||||
--line: rgba(148, 163, 184, 0.20);
|
--surface: #171d25;
|
||||||
--line-soft: rgba(148, 163, 184, 0.12);
|
--line: rgba(148, 163, 184, 0.18);
|
||||||
--text: #e5edf8;
|
--line-soft: rgba(148, 163, 184, 0.11);
|
||||||
--muted: #9aa8bb;
|
--text: #e8edf4;
|
||||||
--muted-strong: #c7d2e2;
|
--text-muted: #9aa8ba;
|
||||||
--primary: #8bb8ff;
|
--muted: #9aa8ba;
|
||||||
--primary-hover: #a9ccff;
|
--muted-strong: #c4cfdc;
|
||||||
--primary-strong: #dceaff;
|
--primary: #80b6ff;
|
||||||
|
--primary-hover: #a6cbff;
|
||||||
|
--primary-strong: #d7e8ff;
|
||||||
|
--brand: var(--primary);
|
||||||
|
--brand-strong: var(--primary-strong);
|
||||||
|
--accent: #5eead4;
|
||||||
|
--accent-soft: rgba(94, 234, 212, 0.12);
|
||||||
--danger: #fb7185;
|
--danger: #fb7185;
|
||||||
--success: #5eead4;
|
--success: #5eead4;
|
||||||
--warning: #fbbf24;
|
--warning: #fbbf24;
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
|
|
||||||
.sidebar,
|
.sidebar,
|
||||||
.list-panel,
|
.list-panel,
|
||||||
.card {
|
.card,
|
||||||
|
.detail-col {
|
||||||
@apply rounded-2xl border bg-panel shadow-soft;
|
@apply rounded-2xl border bg-panel shadow-soft;
|
||||||
border-color: var(--line);
|
border-color: var(--line);
|
||||||
}
|
}
|
||||||
@@ -378,6 +379,101 @@
|
|||||||
transition: transform 240ms var(--ease-out-soft), filter 240ms var(--ease-out-soft);
|
transition: transform 240ms var(--ease-out-soft), filter 240ms var(--ease-out-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-list-icon-wrap {
|
||||||
|
@apply w-9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-brand-icon {
|
||||||
|
@apply inline-grid h-6 w-9 shrink-0 place-items-center rounded border text-[7px] font-black uppercase leading-none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
color: #1e3a8a;
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #f4f7fb 100%);
|
||||||
|
border-color: rgba(96, 125, 169, 0.34);
|
||||||
|
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-brand-icon span {
|
||||||
|
@apply block max-w-full overflow-hidden text-ellipsis whitespace-nowrap px-0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-brand-icon img {
|
||||||
|
@apply block h-full w-full object-contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-brand-icon svg {
|
||||||
|
@apply h-[18px] w-[18px];
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-brand-icon:has(img) {
|
||||||
|
color: inherit;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-brand-visa {
|
||||||
|
color: #1a4db3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-brand-mastercard {
|
||||||
|
color: #111827;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 38% 50%, rgba(235, 0, 27, 0.88) 0 24%, transparent 25%),
|
||||||
|
radial-gradient(circle at 62% 50%, rgba(247, 158, 27, 0.88) 0 24%, transparent 25%),
|
||||||
|
#fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-brand-american-express {
|
||||||
|
color: #fff;
|
||||||
|
background: #2e77bb;
|
||||||
|
border-color: rgba(46, 119, 187, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-brand-discover {
|
||||||
|
color: #172554;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 72% 50%, rgba(245, 130, 32, 0.9) 0 26%, transparent 27%),
|
||||||
|
#fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-brand-diners-club {
|
||||||
|
color: #0f5fa8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-brand-jcb {
|
||||||
|
color: #0f766e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-brand-maestro {
|
||||||
|
color: #1e40af;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 38% 50%, rgba(0, 85, 164, 0.86) 0 24%, transparent 25%),
|
||||||
|
radial-gradient(circle at 62% 50%, rgba(237, 28, 36, 0.82) 0 24%, transparent 25%),
|
||||||
|
#fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-brand-unionpay {
|
||||||
|
color: #0f766e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-brand-rupay {
|
||||||
|
color: #1e3a8a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-brand-select-row {
|
||||||
|
@apply flex items-center gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-brand-select-row .card-brand-icon {
|
||||||
|
@apply h-8 w-11;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-brand-select {
|
||||||
|
@apply min-w-0 flex-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-brand-detail {
|
||||||
|
@apply inline-flex min-w-0 items-center gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
.list-icon {
|
.list-icon {
|
||||||
@apply h-6 w-6 rounded-md;
|
@apply h-6 w-6 rounded-md;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -483,7 +579,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.detail-col {
|
.detail-col {
|
||||||
@apply min-h-0 overflow-auto;
|
@apply min-h-0 overflow-auto p-2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-panel-head {
|
.mobile-panel-head {
|
||||||
@@ -627,6 +723,8 @@
|
|||||||
|
|
||||||
.kv-main > strong {
|
.kv-main > strong {
|
||||||
@apply min-w-0;
|
@apply min-w-0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.totp-inline {
|
.totp-inline {
|
||||||
@@ -776,7 +874,7 @@
|
|||||||
|
|
||||||
.totp-code-row {
|
.totp-code-row {
|
||||||
@apply grid w-full min-w-0 max-w-none items-center gap-2.5 rounded-xl border p-3;
|
@apply grid w-full min-w-0 max-w-none items-center gap-2.5 rounded-xl border p-3;
|
||||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
border-color: #e2e8f0;
|
border-color: #e2e8f0;
|
||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
transition:
|
transition:
|
||||||
@@ -787,52 +885,10 @@
|
|||||||
opacity var(--dur-fast) var(--ease-smooth);
|
opacity var(--dur-fast) var(--ease-smooth);
|
||||||
}
|
}
|
||||||
|
|
||||||
.totp-code-row.is-dragging {
|
|
||||||
@apply z-[2];
|
|
||||||
border-color: rgba(37, 99, 235, 0.3);
|
|
||||||
background: color-mix(in srgb, var(--panel) 88%, white 12%);
|
|
||||||
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.14);
|
|
||||||
}
|
|
||||||
|
|
||||||
.totp-code-info {
|
.totp-code-info {
|
||||||
@apply flex min-w-0 items-center gap-2.5;
|
@apply flex min-w-0 items-center gap-2.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.totp-drag-btn {
|
|
||||||
@apply relative h-[34px] w-6 min-w-6 cursor-grab self-center overflow-visible rounded-[10px] p-0 opacity-[0.82];
|
|
||||||
color: var(--muted);
|
|
||||||
touch-action: none;
|
|
||||||
-webkit-user-select: none;
|
|
||||||
user-select: none;
|
|
||||||
border-color: transparent;
|
|
||||||
background: transparent;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.totp-drag-btn:hover {
|
|
||||||
color: var(--primary-strong);
|
|
||||||
border-color: transparent;
|
|
||||||
background: transparent;
|
|
||||||
box-shadow: none;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.totp-drag-btn:active {
|
|
||||||
cursor: grabbing;
|
|
||||||
border-color: transparent;
|
|
||||||
background: transparent;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.totp-drag-btn::before {
|
|
||||||
content: '';
|
|
||||||
@apply absolute -inset-2.5 rounded-xl;
|
|
||||||
}
|
|
||||||
|
|
||||||
.totp-drag-btn .btn-icon {
|
|
||||||
@apply opacity-90;
|
|
||||||
}
|
|
||||||
|
|
||||||
.totp-code-main {
|
.totp-code-main {
|
||||||
@apply flex min-w-0 shrink-0 items-center gap-1.5;
|
@apply flex min-w-0 shrink-0 items-center gap-1.5;
|
||||||
}
|
}
|
||||||
@@ -960,6 +1016,15 @@
|
|||||||
@apply w-full;
|
@apply w-full;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.custom-field-textarea {
|
||||||
|
@apply min-h-20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-field-display {
|
||||||
|
@apply block max-w-full whitespace-pre-wrap break-words text-sm font-semibold;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
.custom-field-check {
|
.custom-field-check {
|
||||||
@apply mb-0 inline-flex items-center gap-2;
|
@apply mb-0 inline-flex items-center gap-2;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,65 @@
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import preact from '@preact/preset-vite';
|
import preact from '@preact/preset-vite';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig, type Plugin } from 'vite';
|
||||||
|
|
||||||
const rootDir = fileURLToPath(new URL('.', import.meta.url));
|
const rootDir = fileURLToPath(new URL('.', import.meta.url));
|
||||||
|
|
||||||
|
function searchIndexPolicyPlugin(isDemo: boolean): Plugin {
|
||||||
|
return {
|
||||||
|
name: 'nodewarden-search-index-policy',
|
||||||
|
transformIndexHtml(html: string) {
|
||||||
|
if (isDemo) return html;
|
||||||
|
return html.replace(
|
||||||
|
'<meta name="viewport" content="width=device-width, initial-scale=1.0" />',
|
||||||
|
'<meta name="viewport" content="width=device-width, initial-scale=1.0" />\n <meta name="robots" content="noindex, nofollow, noarchive, nosnippet" />'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
generateBundle() {
|
||||||
|
this.emitFile({
|
||||||
|
type: 'asset',
|
||||||
|
fileName: 'robots.txt',
|
||||||
|
source: isDemo
|
||||||
|
? 'User-agent: *\nAllow: /\n'
|
||||||
|
: 'User-agent: *\nDisallow: /\n',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resourcePriorityPlugin(isDemo: boolean): Plugin {
|
||||||
|
return {
|
||||||
|
name: 'nodewarden-resource-priority',
|
||||||
|
enforce: 'post' as const,
|
||||||
|
transformIndexHtml(html: string) {
|
||||||
|
if (isDemo || !html.includes('/assets/app-suite-')) return html;
|
||||||
|
|
||||||
|
const scriptMatch = html.match(/^\s*<script type="module" crossorigin src="\/assets\/index-[^"]+\.js"><\/script>\s*$/m);
|
||||||
|
const appSuiteMatch = html.match(/^\s*<link rel="modulepreload" crossorigin href="\/assets\/app-suite-[^"]+\.js">\s*$/m);
|
||||||
|
const stylesheetMatch = html.match(/^\s*<link rel="stylesheet" crossorigin href="\/assets\/index-[^"]+\.css">\s*$/m);
|
||||||
|
|
||||||
|
if (!scriptMatch || !appSuiteMatch || !stylesheetMatch) return html;
|
||||||
|
|
||||||
|
const prioritizedTags = [
|
||||||
|
stylesheetMatch[0].replace('rel="stylesheet"', 'rel="stylesheet" fetchpriority="high"'),
|
||||||
|
appSuiteMatch[0].replace('rel="modulepreload"', 'rel="modulepreload" fetchpriority="high"'),
|
||||||
|
scriptMatch[0].replace('type="module"', 'type="module" fetchpriority="high"'),
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
return html
|
||||||
|
.replace(scriptMatch[0], '')
|
||||||
|
.replace(appSuiteMatch[0], '')
|
||||||
|
.replace(stylesheetMatch[0], prioritizedTags);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig(({ mode }) => {
|
||||||
const isDemo = mode === 'demo';
|
const isDemo = mode === 'demo';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
root: rootDir,
|
root: rootDir,
|
||||||
plugins: [preact()],
|
plugins: [preact(), searchIndexPolicyPlugin(isDemo), resourcePriorityPlugin(isDemo)],
|
||||||
define: {
|
define: {
|
||||||
__NODEWARDEN_DEMO__: JSON.stringify(isDemo),
|
__NODEWARDEN_DEMO__: JSON.stringify(isDemo),
|
||||||
},
|
},
|
||||||
@@ -30,13 +79,13 @@ export default defineConfig(({ mode }) => {
|
|||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
sourcemap: false,
|
sourcemap: false,
|
||||||
target: 'esnext',
|
target: 'esnext',
|
||||||
|
chunkSizeWarningLimit: 800,
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
|
treeshake: {
|
||||||
|
preset: 'smallest',
|
||||||
|
},
|
||||||
output: {
|
output: {
|
||||||
manualChunks(id) {
|
manualChunks(id) {
|
||||||
if (id.includes('/node_modules/')) {
|
|
||||||
return 'vendor';
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalized = id.replace(/\\/g, '/');
|
const normalized = id.replace(/\\/g, '/');
|
||||||
|
|
||||||
const localeMatch = normalized.match(/\/src\/lib\/i18n\/locales\/(.+)\.ts$/);
|
const localeMatch = normalized.match(/\/src\/lib\/i18n\/locales\/(.+)\.ts$/);
|
||||||
@@ -45,28 +94,16 @@ export default defineConfig(({ mode }) => {
|
|||||||
return `i18n-${localeMatch[1]}`;
|
return `i18n-${localeMatch[1]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalized.includes('/src/lib/i18n.ts')) {
|
|
||||||
return 'i18n-core';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
normalized.includes('/src/components/AuthViews.tsx') ||
|
|
||||||
normalized.includes('/src/components/PublicSendPage.tsx') ||
|
|
||||||
normalized.includes('/src/components/RecoverTwoFactorPage.tsx') ||
|
|
||||||
normalized.includes('/src/components/JwtWarningPage.tsx') ||
|
|
||||||
normalized.includes('/src/lib/app-auth.ts')
|
|
||||||
) {
|
|
||||||
return 'auth-suite';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!isDemo &&
|
!isDemo &&
|
||||||
(
|
(
|
||||||
|
normalized.includes('/src/components/VaultPage.tsx') ||
|
||||||
normalized.includes('/src/components/ImportPage.tsx') ||
|
normalized.includes('/src/components/ImportPage.tsx') ||
|
||||||
normalized.includes('/src/lib/import-') ||
|
normalized.includes('/src/lib/import-') ||
|
||||||
normalized.includes('/src/lib/export-formats.ts') ||
|
normalized.includes('/src/lib/export-formats.ts') ||
|
||||||
normalized.includes('/src/components/SendsPage.tsx') ||
|
normalized.includes('/src/components/SendsPage.tsx') ||
|
||||||
normalized.includes('/src/components/TotpCodesPage.tsx') ||
|
normalized.includes('/src/components/TotpCodesPage.tsx') ||
|
||||||
|
normalized.includes('/src/components/DomainRulesPage.tsx') ||
|
||||||
normalized.includes('/src/components/BackupCenterPage.tsx') ||
|
normalized.includes('/src/components/BackupCenterPage.tsx') ||
|
||||||
normalized.includes('/src/components/backup-center/') ||
|
normalized.includes('/src/components/backup-center/') ||
|
||||||
normalized.includes('/src/components/SettingsPage.tsx') ||
|
normalized.includes('/src/components/SettingsPage.tsx') ||
|
||||||
@@ -74,7 +111,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
normalized.includes('/src/components/AdminPage.tsx')
|
normalized.includes('/src/components/AdminPage.tsx')
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
return 'workspace-suite';
|
return 'app-suite';
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
@@ -2,27 +2,12 @@ name = "nodewarden"
|
|||||||
main = "src/index.ts"
|
main = "src/index.ts"
|
||||||
compatibility_date = "2024-01-01"
|
compatibility_date = "2024-01-01"
|
||||||
|
|
||||||
[observability]
|
|
||||||
enabled = false
|
|
||||||
|
|
||||||
[observability.logs]
|
|
||||||
enabled = true
|
|
||||||
head_sampling_rate = 1
|
|
||||||
persist = true
|
|
||||||
invocation_logs = true
|
|
||||||
|
|
||||||
[observability.traces]
|
|
||||||
enabled = false
|
|
||||||
|
|
||||||
[assets]
|
[assets]
|
||||||
binding = "ASSETS"
|
binding = "ASSETS"
|
||||||
directory = "./dist"
|
directory = "./dist"
|
||||||
not_found_handling = "single-page-application"
|
not_found_handling = "single-page-application"
|
||||||
run_worker_first = false
|
run_worker_first = false
|
||||||
|
|
||||||
[build]
|
|
||||||
command = "npm run build"
|
|
||||||
|
|
||||||
[triggers]
|
[triggers]
|
||||||
crons = [ "*/5 * * * *" ]
|
crons = [ "*/5 * * * *" ]
|
||||||
|
|
||||||
|
|||||||
@@ -2,27 +2,12 @@ name = "nodewarden"
|
|||||||
main = "src/index.ts"
|
main = "src/index.ts"
|
||||||
compatibility_date = "2024-01-01"
|
compatibility_date = "2024-01-01"
|
||||||
|
|
||||||
[observability]
|
|
||||||
enabled = false
|
|
||||||
|
|
||||||
[observability.logs]
|
|
||||||
enabled = true
|
|
||||||
head_sampling_rate = 1
|
|
||||||
persist = true
|
|
||||||
invocation_logs = true
|
|
||||||
|
|
||||||
[observability.traces]
|
|
||||||
enabled = false
|
|
||||||
|
|
||||||
[assets]
|
[assets]
|
||||||
binding = "ASSETS"
|
binding = "ASSETS"
|
||||||
directory = "./dist"
|
directory = "./dist"
|
||||||
not_found_handling = "single-page-application"
|
not_found_handling = "single-page-application"
|
||||||
run_worker_first = false
|
run_worker_first = false
|
||||||
|
|
||||||
[build]
|
|
||||||
command = "npm run build"
|
|
||||||
|
|
||||||
[triggers]
|
[triggers]
|
||||||
crons = [ "*/5 * * * *" ]
|
crons = [ "*/5 * * * *" ]
|
||||||
|
|
||||||
|
|||||||