mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
docs: update README files for clarity on deployment steps and features
This commit is contained in:
@@ -32,9 +32,9 @@ English:[`README_EN.md`](./README_EN.md)
|
|||||||
| 附件上传/下载 | ✅ | ✅ | 基于 Cloudflare R2 |
|
| 附件上传/下载 | ✅ | ✅ | 基于 Cloudflare R2 |
|
||||||
| 导入功能 | ✅ | ✅ | 覆盖常见导入路径 |
|
| 导入功能 | ✅ | ✅ | 覆盖常见导入路径 |
|
||||||
| 网站图标代理 | ✅ | ✅ | 通过 `/icons/{hostname}/icon.png` |
|
| 网站图标代理 | ✅ | ✅ | 通过 `/icons/{hostname}/icon.png` |
|
||||||
| passkey、TOTP | ❌ | ✅ |官方需要会员,我们的不需要 |
|
| passkey、TOTP字段 | ❌ | ✅ |官方需要会员,我们的不需要 |
|
||||||
| Send | ✅ | ✅ | 已支持文本 Send 与文件 Send |
|
| Send | ✅ | ✅ | 已支持文本 Send 与文件 Send |
|
||||||
| 多用户 | ✅ | ✅ | 完整的用户管理 |
|
| 多用户 | ✅ | ✅ | 完整的用户管理,邀请机制 |
|
||||||
| 组织/集合/成员权限 | ✅ | ❌ | 没必要实现 |
|
| 组织/集合/成员权限 | ✅ | ❌ | 没必要实现 |
|
||||||
| 登录 2FA(TOTP/WebAuthn/Duo/Email) | ✅ | ⚠️ 部分支持 | 仅支持 TOTP(通过 `TOTP_SECRET`) |
|
| 登录 2FA(TOTP/WebAuthn/Duo/Email) | ✅ | ⚠️ 部分支持 | 仅支持 TOTP(通过 `TOTP_SECRET`) |
|
||||||
| SSO / SCIM / 企业目录 | ✅ | ❌ | 没必要实现 |
|
| SSO / SCIM / 企业目录 | ✅ | ❌ | 没必要实现 |
|
||||||
@@ -47,8 +47,8 @@ English:[`README_EN.md`](./README_EN.md)
|
|||||||
- ✅ Windows 客户端(v2026.1.0)
|
- ✅ Windows 客户端(v2026.1.0)
|
||||||
- ✅ 手机 App(v2026.1.0)
|
- ✅ 手机 App(v2026.1.0)
|
||||||
- ✅ 浏览器扩展(v2026.1.0)
|
- ✅ 浏览器扩展(v2026.1.0)
|
||||||
|
- ✅ Linux 客户端(v2026.1.0)
|
||||||
- ⬜ macOS 客户端(未测试)
|
- ⬜ macOS 客户端(未测试)
|
||||||
- ⬜ Linux 客户端(未测试)
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 快速开始
|
# 快速开始
|
||||||
@@ -57,9 +57,15 @@ English:[`README_EN.md`](./README_EN.md)
|
|||||||
|
|
||||||
**部署步骤:**
|
**部署步骤:**
|
||||||
|
|
||||||
1. 先在右上角fork此项目(若后续不需要更新,可不fork)
|
1. 首先Fork本仓库,命名为**NodeWarden**
|
||||||
2. [](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden)
|
2. 点击下面的一键部署按钮,修改项目名称为**NodeWarden2**,修改**JWT_SECRET**成32为随机字符串
|
||||||
3. 打开部署后生成的链接,并根据网页提示完成后续操作。
|
3. [](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden)
|
||||||
|
4. 部署完成后,同一页面打开workers设置,将**Git存储库**断开连接
|
||||||
|
5. 同一位置,**Git存储库**链接至第一步Fork的仓库
|
||||||
|
|
||||||
|
**同步上游(更新):**
|
||||||
|
- 手动:Github打开你Fork的私人仓库,看到顶部同步提示时,点击 “Sync fork”。
|
||||||
|
- 自动:进入你的 Fork 仓库 → Actions,点击 “I understand my workflows, go ahead and enable them”,每天凌晨三点自动同步至上游
|
||||||
|
|
||||||
### CLI 部署
|
### CLI 部署
|
||||||
|
|
||||||
@@ -80,6 +86,11 @@ npx wrangler r2 bucket create nodewarden-attachments
|
|||||||
|
|
||||||
# 部署
|
# 部署
|
||||||
npx wrangler deploy
|
npx wrangler deploy
|
||||||
|
|
||||||
|
# 需更新时重新拉取仓库,重新部署即可,无需创建云资源
|
||||||
|
git clone https://github.com/shuaiplus/NodeWarden.git
|
||||||
|
cd NodeWarden
|
||||||
|
npx wrangler deploy
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -102,7 +113,7 @@ A: 在客户端中选择「导出密码库」,保存 JSON 文件。
|
|||||||
A: 无法恢复,这是端到端加密的特性。建议妥善保管主密码。
|
A: 无法恢复,这是端到端加密的特性。建议妥善保管主密码。
|
||||||
|
|
||||||
**Q: 可以多人使用吗?**
|
**Q: 可以多人使用吗?**
|
||||||
A: 不建议。本项目为单用户设计,多人使用请选择 Vaultwarden。
|
A: 支持。第一个注册的用户自动成为管理员,管理员可在管理页面生成邀请码,其他用户凭邀请码注册。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+25
-15
@@ -16,10 +16,9 @@
|
|||||||
|
|
||||||
中文文档:[`README.md`](./README.md)
|
中文文档:[`README.md`](./README.md)
|
||||||
|
|
||||||
> Disclaimer
|
> **Disclaimer**
|
||||||
> - This project is for learning and communication only.
|
> This project is for learning and communication purposes only. We are not responsible for any data loss; regular vault backups are strongly recommended.
|
||||||
> - We are not responsible for any data loss. Regular vault backups are strongly recommended.
|
> This project is not affiliated with Bitwarden. Please do not report issues to the official Bitwarden team.
|
||||||
> - This project is not affiliated with Bitwarden. Please do not report issues to the official Bitwarden team.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -27,14 +26,14 @@
|
|||||||
|
|
||||||
| Capability | Bitwarden | NodeWarden | Notes |
|
| Capability | Bitwarden | NodeWarden | Notes |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Single-user vault (logins/notes/cards/identities) | ✅ | ✅ | Core vault model supported |
|
| Single-user vault (logins/notes/cards/identities) | ✅ | ✅ | Based on Cloudflare D1 |
|
||||||
| Folders / Favorites | ✅ | ✅ | Common vault organization supported |
|
| Folders / Favorites | ✅ | ✅ | Common vault organization supported |
|
||||||
| Full sync `/api/sync` | ✅ | ✅ | Compatibility-focused implementation |
|
| Full sync `/api/sync` | ✅ | ✅ | Compatibility and performance optimized |
|
||||||
| Attachment upload/download | ✅ | ✅ | Backed by Cloudflare R2 |
|
| Attachment upload/download | ✅ | ✅ | Backed by Cloudflare R2 |
|
||||||
| Import flow (common clients) | ✅ | ✅ | Common import paths covered |
|
| Import flow (common clients) | ✅ | ✅ | Common import paths covered |
|
||||||
| Website icon proxy | ✅ | ✅ | Via `/icons/{hostname}/icon.png` |
|
| Website icon proxy | ✅ | ✅ | Via `/icons/{hostname}/icon.png` |
|
||||||
| passkey、TOTP | ❌ | ✅ | Official service requires premium; NodeWarden does not |
|
| passkey、TOTP fields | ❌ | ✅ | Official service requires premium; NodeWarden does not |
|
||||||
| Multi-user | ✅ | ✅ | Full User Management |
|
| Multi-user | ✅ | ✅ | Full user management with invitation mechanism |
|
||||||
| Send | ✅ | ✅ | Text Send and File Send are supported |
|
| Send | ✅ | ✅ | Text Send and File Send are supported |
|
||||||
| Organizations / Collections / Member roles | ✅ | ❌ | Not necessary to implement |
|
| Organizations / Collections / Member roles | ✅ | ❌ | Not necessary to implement |
|
||||||
| Login 2FA (TOTP/WebAuthn/Duo/Email) | ✅ | ⚠️ Partial | TOTP-only via `TOTP_SECRET` |
|
| Login 2FA (TOTP/WebAuthn/Duo/Email) | ✅ | ⚠️ Partial | TOTP-only via `TOTP_SECRET` |
|
||||||
@@ -47,10 +46,10 @@
|
|||||||
## Tested clients / platforms
|
## Tested clients / platforms
|
||||||
|
|
||||||
- ✅ Windows desktop client (v2026.1.0)
|
- ✅ Windows desktop client (v2026.1.0)
|
||||||
- ✅ Android app (v2026.1.0)
|
- ✅ Mobile app (v2026.1.0)
|
||||||
- ✅ Browser extension (v2026.1.0)
|
- ✅ Browser extension (v2026.1.0)
|
||||||
|
- ✅ Linux desktop client (v2026.1.0)
|
||||||
- ⬜ macOS desktop client (not tested)
|
- ⬜ macOS desktop client (not tested)
|
||||||
- ⬜ Linux desktop client (not tested)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -60,9 +59,15 @@
|
|||||||
|
|
||||||
**Deploy steps:**
|
**Deploy steps:**
|
||||||
|
|
||||||
1. Fork this project (you don't need to fork it if you don't need to update it later).
|
1. Fork this repository and name it **NodeWarden**.
|
||||||
2. [](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden)
|
2. Click the deploy button below, rename the project to **NodeWarden2**, and set **JWT_SECRET** to a 32-character random string.
|
||||||
3. Open the generated service URL and follow the on-page instructions.
|
3. [](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden)
|
||||||
|
4. After deployment, open the Workers settings on the same page and disconnect the **Git repository**.
|
||||||
|
5. From the same location, reconnect the **Git repository** to the fork you created in step 1.
|
||||||
|
|
||||||
|
**Sync upstream (update):**
|
||||||
|
- Manual: Open your forked repository on GitHub and click **Sync fork** when the sync prompt appears at the top.
|
||||||
|
- Automatic: Go to your fork → Actions, click "I understand my workflows, go ahead and enable them". The repository will auto-sync with upstream every day at 3 AM.
|
||||||
|
|
||||||
### CLI deploy
|
### CLI deploy
|
||||||
|
|
||||||
@@ -83,9 +88,14 @@ npx wrangler r2 bucket create nodewarden-attachments
|
|||||||
|
|
||||||
# Deploy
|
# Deploy
|
||||||
npx wrangler deploy
|
npx wrangler deploy
|
||||||
|
|
||||||
|
# To update later: re-clone and re-deploy — no need to recreate cloud resources
|
||||||
|
git clone https://github.com/shuaiplus/NodeWarden.git
|
||||||
|
cd NodeWarden
|
||||||
|
npx wrangler deploy
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
## Local development
|
## Local development
|
||||||
|
|
||||||
This repo is a Cloudflare Workers TypeScript project (Wrangler).
|
This repo is a Cloudflare Workers TypeScript project (Wrangler).
|
||||||
@@ -105,7 +115,7 @@ A: Use **Export vault** in your client and save the JSON file.
|
|||||||
A: It can’t be recovered (end-to-end encryption). Keep it safe.
|
A: It can’t be recovered (end-to-end encryption). Keep it safe.
|
||||||
|
|
||||||
**Q: Can multiple people use it?**
|
**Q: Can multiple people use it?**
|
||||||
A: Not recommended. This project is designed for single-user usage. For multi-user usage, choose Vaultwarden.
|
A: Yes. The first registered user becomes the admin. The admin can generate invite codes from the admin panel, and other users register with those codes.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,9 @@
|
|||||||
// /api/devices/knowndevice probe budget per IP per minute.
|
// /api/devices/knowndevice probe budget per IP per minute.
|
||||||
// /api/devices/knowndevice 每 IP 每分钟探测配额。
|
// /api/devices/knowndevice 每 IP 每分钟探测配额。
|
||||||
knownDeviceRequestsPerMinute: 10,
|
knownDeviceRequestsPerMinute: 10,
|
||||||
|
// Public Send access budget per IP per minute.
|
||||||
|
// 公共 Send 访问接口每 IP 每分钟配额。
|
||||||
|
publicSendRequestsPerMinute: 60,
|
||||||
// Fixed window size for API rate limiting in seconds.
|
// Fixed window size for API rate limiting in seconds.
|
||||||
// API 限流固定窗口大小(秒)。
|
// API 限流固定窗口大小(秒)。
|
||||||
apiWindowSeconds: 60,
|
apiWindowSeconds: 60,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Env, User, ProfileResponse, DEFAULT_DEV_SECRET } from '../types';
|
import { Env, User, ProfileResponse, DEFAULT_DEV_SECRET } from '../types';
|
||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
import { AuthService } from '../services/auth';
|
import { AuthService } from '../services/auth';
|
||||||
|
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
|
||||||
import { jsonResponse, errorResponse } from '../utils/response';
|
import { jsonResponse, errorResponse } from '../utils/response';
|
||||||
import { generateUUID } from '../utils/uuid';
|
import { generateUUID } from '../utils/uuid';
|
||||||
import { LIMITS } from '../config/limits';
|
import { LIMITS } from '../config/limits';
|
||||||
@@ -449,6 +450,7 @@ export async function handleGetTotpRecoveryCode(request: Request, env: Env, user
|
|||||||
export async function handleRecoverTwoFactor(request: Request, env: Env): Promise<Response> {
|
export async function handleRecoverTwoFactor(request: Request, env: Env): Promise<Response> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const auth = new AuthService(env);
|
const auth = new AuthService(env);
|
||||||
|
const rateLimit = new RateLimitService(env.DB);
|
||||||
|
|
||||||
let body: Record<string, string | undefined>;
|
let body: Record<string, string | undefined>;
|
||||||
try {
|
try {
|
||||||
@@ -466,20 +468,35 @@ export async function handleRecoverTwoFactor(request: Request, env: Env): Promis
|
|||||||
const email = String(body.email || body.username || '').trim().toLowerCase();
|
const email = String(body.email || body.username || '').trim().toLowerCase();
|
||||||
const masterPasswordHash = String(body.masterPasswordHash || body.password || '').trim();
|
const masterPasswordHash = String(body.masterPasswordHash || body.password || '').trim();
|
||||||
const recoveryCode = normalizeRecoveryCodeInput(String(body.recoveryCode || body.twoFactorToken || body.recovery_code || ''));
|
const recoveryCode = normalizeRecoveryCodeInput(String(body.recoveryCode || body.twoFactorToken || body.recovery_code || ''));
|
||||||
|
const recoverLimitKey = `${getClientIdentifier(request)}:recover-2fa:${email || 'unknown'}`;
|
||||||
|
|
||||||
|
const recoverAttemptCheck = await rateLimit.checkLoginAttempt(recoverLimitKey);
|
||||||
|
if (!recoverAttemptCheck.allowed) {
|
||||||
|
return errorResponse(
|
||||||
|
`Too many failed recovery attempts. Try again in ${Math.ceil((recoverAttemptCheck.retryAfterSeconds || 60) / 60)} minutes.`,
|
||||||
|
429
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!email || !masterPasswordHash || !recoveryCode) {
|
if (!email || !masterPasswordHash || !recoveryCode) {
|
||||||
return errorResponse('Email, masterPasswordHash and recoveryCode are required', 400);
|
return errorResponse('Email, masterPasswordHash and recoveryCode are required', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await storage.getUser(email);
|
const user = await storage.getUser(email);
|
||||||
if (!user) return errorResponse('Invalid credentials', 400);
|
if (!user || user.status !== 'active') {
|
||||||
if (user.status !== 'active') return errorResponse('Account is disabled', 403);
|
await rateLimit.recordFailedLogin(recoverLimitKey);
|
||||||
|
return errorResponse('Invalid credentials or recovery code', 400);
|
||||||
|
}
|
||||||
|
|
||||||
const validPassword = await auth.verifyPassword(masterPasswordHash, user.masterPasswordHash);
|
const validPassword = await auth.verifyPassword(masterPasswordHash, user.masterPasswordHash);
|
||||||
if (!validPassword) return errorResponse('Invalid credentials', 400);
|
if (!validPassword) {
|
||||||
|
await rateLimit.recordFailedLogin(recoverLimitKey);
|
||||||
|
return errorResponse('Invalid credentials or recovery code', 400);
|
||||||
|
}
|
||||||
|
|
||||||
if (!recoveryCodeEquals(recoveryCode, user.totpRecoveryCode)) {
|
if (!recoveryCodeEquals(recoveryCode, user.totpRecoveryCode)) {
|
||||||
return errorResponse('Recovery code is incorrect. Try again.', 400);
|
await rateLimit.recordFailedLogin(recoverLimitKey);
|
||||||
|
return errorResponse('Invalid credentials or recovery code', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
user.totpSecret = null;
|
user.totpSecret = null;
|
||||||
@@ -488,6 +505,7 @@ export async function handleRecoverTwoFactor(request: Request, env: Env): Promis
|
|||||||
user.updatedAt = new Date().toISOString();
|
user.updatedAt = new Date().toISOString();
|
||||||
await storage.saveUser(user);
|
await storage.saveUser(user);
|
||||||
await storage.deleteRefreshTokensByUserId(user.id);
|
await storage.deleteRefreshTokensByUserId(user.id);
|
||||||
|
await rateLimit.clearLoginAttempts(recoverLimitKey);
|
||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const grantType = body.grant_type;
|
const grantType = body.grant_type;
|
||||||
|
const clientIdentifier = getClientIdentifier(request);
|
||||||
|
|
||||||
if (grantType === 'password') {
|
if (grantType === 'password') {
|
||||||
// Login with password
|
// Login with password
|
||||||
@@ -113,7 +114,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
const twoFactorToken = body.twoFactorToken;
|
const twoFactorToken = body.twoFactorToken;
|
||||||
const twoFactorProvider = body.twoFactorProvider;
|
const twoFactorProvider = body.twoFactorProvider;
|
||||||
const twoFactorRemember = body.twoFactorRemember;
|
const twoFactorRemember = body.twoFactorRemember;
|
||||||
const loginIdentifier = getClientIdentifier(request);
|
const loginIdentifier = clientIdentifier;
|
||||||
const deviceInfo = readAuthRequestDeviceInfo(body, request);
|
const deviceInfo = readAuthRequestDeviceInfo(body, request);
|
||||||
|
|
||||||
if (!email || !passwordHash) {
|
if (!email || !passwordHash) {
|
||||||
@@ -266,6 +267,15 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
return jsonResponse(response);
|
return jsonResponse(response);
|
||||||
|
|
||||||
} else if (grantType === 'send_access') {
|
} else if (grantType === 'send_access') {
|
||||||
|
const sendAccessLimit = await rateLimit.consumePublicSendAccessBudget(`${clientIdentifier}:public-send-oauth`);
|
||||||
|
if (!sendAccessLimit.allowed) {
|
||||||
|
return identityErrorResponse(
|
||||||
|
`Too many public Send requests. Try again in ${sendAccessLimit.retryAfterSeconds} seconds.`,
|
||||||
|
'TooManyRequests',
|
||||||
|
429
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const sendId = String(body.send_id || body.sendId || '').trim();
|
const sendId = String(body.send_id || body.sendId || '').trim();
|
||||||
if (!sendId) {
|
if (!sendId) {
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
|
|||||||
+18
-2
@@ -322,6 +322,14 @@ function hasEmailAuth(send: Send): boolean {
|
|||||||
return send.authType === SendAuthType.Email;
|
return send.authType === SendAuthType.Email;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSafeJwtSecret(env: Env): { ok: true; secret: string } | { ok: false; response: Response } {
|
||||||
|
const secret = (env.JWT_SECRET || '').trim();
|
||||||
|
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) {
|
||||||
|
return { ok: false, response: errorResponse('Server configuration error', 500) };
|
||||||
|
}
|
||||||
|
return { ok: true, secret };
|
||||||
|
}
|
||||||
|
|
||||||
function extractBearerToken(request: Request): string | null {
|
function extractBearerToken(request: Request): string | null {
|
||||||
const authHeader = request.headers.get('Authorization');
|
const authHeader = request.headers.get('Authorization');
|
||||||
if (!authHeader) return null;
|
if (!authHeader) return null;
|
||||||
@@ -1078,12 +1086,15 @@ export async function handleAccessSendFile(
|
|||||||
|
|
||||||
// POST /api/sends/access (v2 bearer)
|
// POST /api/sends/access (v2 bearer)
|
||||||
export async function handleAccessSendV2(request: Request, env: Env): Promise<Response> {
|
export async function handleAccessSendV2(request: Request, env: Env): Promise<Response> {
|
||||||
|
const jwt = getSafeJwtSecret(env);
|
||||||
|
if (!jwt.ok) return jwt.response;
|
||||||
|
|
||||||
const token = extractBearerToken(request);
|
const token = extractBearerToken(request);
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return errorResponse('Unauthorized', 401);
|
return errorResponse('Unauthorized', 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
const claims = await verifySendAccessToken(token, env.JWT_SECRET);
|
const claims = await verifySendAccessToken(token, jwt.secret);
|
||||||
if (!claims) {
|
if (!claims) {
|
||||||
return errorResponse('Unauthorized', 401);
|
return errorResponse('Unauthorized', 401);
|
||||||
}
|
}
|
||||||
@@ -1196,6 +1207,11 @@ export async function issueSendAccessToken(
|
|||||||
passwordHashB64?: string | null,
|
passwordHashB64?: string | null,
|
||||||
password?: string | null
|
password?: string | null
|
||||||
): Promise<{ token: string } | { error: Response }> {
|
): Promise<{ token: string } | { error: Response }> {
|
||||||
|
const jwt = getSafeJwtSecret(env);
|
||||||
|
if (!jwt.ok) {
|
||||||
|
return { error: jwt.response };
|
||||||
|
}
|
||||||
|
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const send = await resolveSendFromIdOrAccessId(storage, sendIdOrAccessId);
|
const send = await resolveSendFromIdOrAccessId(storage, sendIdOrAccessId);
|
||||||
|
|
||||||
@@ -1260,7 +1276,7 @@ export async function issueSendAccessToken(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = await createSendAccessToken(send.id, env.JWT_SECRET);
|
const token = await createSendAccessToken(send.id, jwt.secret);
|
||||||
return { token };
|
return { token };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+28
-6
@@ -12,7 +12,6 @@ import { handleToken, handlePrelogin, handleRevocation } from './handlers/identi
|
|||||||
import {
|
import {
|
||||||
handleRegister,
|
handleRegister,
|
||||||
handleGetProfile,
|
handleGetProfile,
|
||||||
handleUpdateProfile,
|
|
||||||
handleSetKeys,
|
handleSetKeys,
|
||||||
handleGetRevisionDate,
|
handleGetRevisionDate,
|
||||||
handleVerifyPassword,
|
handleVerifyPassword,
|
||||||
@@ -214,6 +213,24 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
|||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const path = url.pathname;
|
const path = url.pathname;
|
||||||
const method = request.method;
|
const method = request.method;
|
||||||
|
const clientId = getClientIdentifier(request);
|
||||||
|
|
||||||
|
async function enforcePublicSendRateLimit(): Promise<Response | null> {
|
||||||
|
const rateLimit = new RateLimitService(env.DB);
|
||||||
|
const check = await rateLimit.consumePublicSendAccessBudget(`${clientId}:public-send`);
|
||||||
|
if (check.allowed) return null;
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
error: 'Too many requests',
|
||||||
|
error_description: `Too many public Send requests. Try again in ${check.retryAfterSeconds} seconds.`,
|
||||||
|
}), {
|
||||||
|
status: 429,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Retry-After': String(check.retryAfterSeconds || 60),
|
||||||
|
'X-RateLimit-Remaining': '0',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Handle CORS preflight
|
// Handle CORS preflight
|
||||||
if (method === 'OPTIONS') {
|
if (method === 'OPTIONS') {
|
||||||
@@ -272,23 +289,31 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
|||||||
// Public Send access endpoints
|
// Public Send access endpoints
|
||||||
const sendAccessMatch = path.match(/^\/api\/sends\/access\/([^/]+)$/i);
|
const sendAccessMatch = path.match(/^\/api\/sends\/access\/([^/]+)$/i);
|
||||||
if (sendAccessMatch && method === 'POST') {
|
if (sendAccessMatch && method === 'POST') {
|
||||||
|
const blocked = await enforcePublicSendRateLimit();
|
||||||
|
if (blocked) return blocked;
|
||||||
const accessId = sendAccessMatch[1];
|
const accessId = sendAccessMatch[1];
|
||||||
return handleAccessSend(request, env, accessId);
|
return handleAccessSend(request, env, accessId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sendAccessV2Match = path === '/api/sends/access';
|
const sendAccessV2Match = path === '/api/sends/access';
|
||||||
if (sendAccessV2Match && method === 'POST') {
|
if (sendAccessV2Match && method === 'POST') {
|
||||||
|
const blocked = await enforcePublicSendRateLimit();
|
||||||
|
if (blocked) return blocked;
|
||||||
return handleAccessSendV2(request, env);
|
return handleAccessSendV2(request, env);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sendAccessFileV2Match = path.match(/^\/api\/sends\/access\/file\/([a-f0-9-]+)$/i);
|
const sendAccessFileV2Match = path.match(/^\/api\/sends\/access\/file\/([a-f0-9-]+)$/i);
|
||||||
if (sendAccessFileV2Match && method === 'POST') {
|
if (sendAccessFileV2Match && method === 'POST') {
|
||||||
|
const blocked = await enforcePublicSendRateLimit();
|
||||||
|
if (blocked) return blocked;
|
||||||
const fileId = sendAccessFileV2Match[1];
|
const fileId = sendAccessFileV2Match[1];
|
||||||
return handleAccessSendFileV2(request, env, fileId);
|
return handleAccessSendFileV2(request, env, fileId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sendAccessFileMatch = path.match(/^\/api\/sends\/([^/]+)\/access\/file\/([a-f0-9-]+)$/i);
|
const sendAccessFileMatch = path.match(/^\/api\/sends\/([^/]+)\/access\/file\/([a-f0-9-]+)$/i);
|
||||||
if (sendAccessFileMatch && method === 'POST') {
|
if (sendAccessFileMatch && method === 'POST') {
|
||||||
|
const blocked = await enforcePublicSendRateLimit();
|
||||||
|
if (blocked) return blocked;
|
||||||
const idOrAccessId = sendAccessFileMatch[1];
|
const idOrAccessId = sendAccessFileMatch[1];
|
||||||
const fileId = sendAccessFileMatch[2];
|
const fileId = sendAccessFileMatch[2];
|
||||||
return handleAccessSendFile(request, env, idOrAccessId, fileId);
|
return handleAccessSendFile(request, env, idOrAccessId, fileId);
|
||||||
@@ -309,8 +334,7 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
|||||||
// Known device check (no auth required)
|
// Known device check (no auth required)
|
||||||
if (path === '/api/devices/knowndevice' && method === 'GET') {
|
if (path === '/api/devices/knowndevice' && method === 'GET') {
|
||||||
const rateLimit = new RateLimitService(env.DB);
|
const rateLimit = new RateLimitService(env.DB);
|
||||||
const clientIp = getClientIdentifier(request);
|
const probeLimit = await rateLimit.consumeKnownDeviceProbeBudget(clientId + ':known-device');
|
||||||
const probeLimit = await rateLimit.consumeKnownDeviceProbeBudget(clientIp + ':known-device');
|
|
||||||
if (!probeLimit.allowed) {
|
if (!probeLimit.allowed) {
|
||||||
// Keep compatibility simple: do not error, just answer "unknown device".
|
// Keep compatibility simple: do not error, just answer "unknown device".
|
||||||
return jsonResponse(false);
|
return jsonResponse(false);
|
||||||
@@ -417,8 +441,6 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
|||||||
if (currentUser.status !== 'active') {
|
if (currentUser.status !== 'active') {
|
||||||
return errorResponse('Account is disabled', 403);
|
return errorResponse('Account is disabled', 403);
|
||||||
}
|
}
|
||||||
const clientId = getClientIdentifier(request);
|
|
||||||
|
|
||||||
// Dedicated read rate limiting for heavy sync endpoint.
|
// Dedicated read rate limiting for heavy sync endpoint.
|
||||||
if (path === '/api/sync' && method === 'GET') {
|
if (path === '/api/sync' && method === 'GET') {
|
||||||
const rateLimit = new RateLimitService(env.DB);
|
const rateLimit = new RateLimitService(env.DB);
|
||||||
@@ -476,7 +498,7 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
|||||||
// Account endpoints
|
// Account endpoints
|
||||||
if (path === '/api/accounts/profile') {
|
if (path === '/api/accounts/profile') {
|
||||||
if (method === 'GET') return handleGetProfile(request, env, userId);
|
if (method === 'GET') return handleGetProfile(request, env, userId);
|
||||||
if (method === 'PUT') return handleUpdateProfile(request, env, userId);
|
return errorResponse('Method not allowed', 405);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((path === '/api/accounts/password' || path === '/api/accounts/change-password') && (method === 'POST' || method === 'PUT')) {
|
if ((path === '/api/accounts/password' || path === '/api/accounts/change-password') && (method === 'POST' || method === 'PUT')) {
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ const CONFIG = {
|
|||||||
SYNC_READ_REQUESTS_PER_MINUTE: LIMITS.rateLimit.syncReadRequestsPerMinute,
|
SYNC_READ_REQUESTS_PER_MINUTE: LIMITS.rateLimit.syncReadRequestsPerMinute,
|
||||||
// Dedicated budget for GET /api/devices/knowndevice probes.
|
// Dedicated budget for GET /api/devices/knowndevice probes.
|
||||||
KNOWN_DEVICE_REQUESTS_PER_MINUTE: LIMITS.rateLimit.knownDeviceRequestsPerMinute,
|
KNOWN_DEVICE_REQUESTS_PER_MINUTE: LIMITS.rateLimit.knownDeviceRequestsPerMinute,
|
||||||
|
// Dedicated budget for unauthenticated public Send access endpoints.
|
||||||
|
PUBLIC_SEND_REQUESTS_PER_MINUTE: LIMITS.rateLimit.publicSendRequestsPerMinute,
|
||||||
API_WINDOW_SECONDS: LIMITS.rateLimit.apiWindowSeconds,
|
API_WINDOW_SECONDS: LIMITS.rateLimit.apiWindowSeconds,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -233,6 +235,15 @@ export class RateLimitService {
|
|||||||
CONFIG.API_WINDOW_SECONDS
|
CONFIG.API_WINDOW_SECONDS
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Budget for unauthenticated public Send access endpoints.
|
||||||
|
async consumePublicSendAccessBudget(identifier: string): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
|
||||||
|
return this.consumeFixedWindowBudget(
|
||||||
|
identifier,
|
||||||
|
CONFIG.PUBLIC_SEND_REQUESTS_PER_MINUTE,
|
||||||
|
CONFIG.API_WINDOW_SECONDS
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getClientIdentifier(request: Request): string {
|
export function getClientIdentifier(request: Request): string {
|
||||||
|
|||||||
+4
-13
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||||
import { Link, Route, Switch, useLocation } from 'wouter';
|
import { Link, Route, Switch, useLocation } from 'wouter';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { CircleHelp, LogOut, Send as SendIcon, Settings as SettingsIcon, Shield, ShieldUser, Vault } from 'lucide-preact';
|
import { CircleHelp, Lock, LogOut, Send as SendIcon, Settings as SettingsIcon, Shield, ShieldUser, Vault } from 'lucide-preact';
|
||||||
import AuthViews from '@/components/AuthViews';
|
import AuthViews from '@/components/AuthViews';
|
||||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||||
import ToastHost from '@/components/ToastHost';
|
import ToastHost from '@/components/ToastHost';
|
||||||
@@ -53,7 +53,6 @@ import {
|
|||||||
updateSend,
|
updateSend,
|
||||||
buildSendShareKey,
|
buildSendShareKey,
|
||||||
unlockVaultKey,
|
unlockVaultKey,
|
||||||
updateProfile,
|
|
||||||
verifyMasterPassword,
|
verifyMasterPassword,
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
import { base64ToBytes, decryptBw, decryptStr, hkdf } from '@/lib/crypto';
|
import { base64ToBytes, decryptBw, decryptStr, hkdf } from '@/lib/crypto';
|
||||||
@@ -580,16 +579,6 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
}, [session?.symEncKey, session?.symMacKey, foldersQuery.data, ciphersQuery.data, sendsQuery.data]);
|
}, [session?.symEncKey, session?.symMacKey, foldersQuery.data, ciphersQuery.data, sendsQuery.data]);
|
||||||
|
|
||||||
async function saveProfileAction(name: string, email: string) {
|
|
||||||
try {
|
|
||||||
const updated = await updateProfile(authedFetch, { name: name.trim(), email: email.trim().toLowerCase() });
|
|
||||||
setProfile(updated);
|
|
||||||
pushToast('success', t('txt_profile_updated'));
|
|
||||||
} catch (error) {
|
|
||||||
pushToast('error', error instanceof Error ? error.message : t('txt_save_profile_failed'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function changePasswordAction(currentPassword: string, nextPassword: string, nextPassword2: string) {
|
async function changePasswordAction(currentPassword: string, nextPassword: string, nextPassword2: string) {
|
||||||
if (!profile) return;
|
if (!profile) return;
|
||||||
if (!currentPassword || !nextPassword) {
|
if (!currentPassword || !nextPassword) {
|
||||||
@@ -955,6 +944,9 @@ export default function App() {
|
|||||||
<ShieldUser size={16} />
|
<ShieldUser size={16} />
|
||||||
<span>{profile?.email}</span>
|
<span>{profile?.email}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<button type="button" className="btn btn-secondary small" onClick={handleLock}>
|
||||||
|
<Lock size={14} className="btn-icon" /> {t('txt_lock')}
|
||||||
|
</button>
|
||||||
<button type="button" className="btn btn-secondary small" onClick={handleLogout}>
|
<button type="button" className="btn btn-secondary small" onClick={handleLogout}>
|
||||||
<LogOut size={14} className="btn-icon" /> {t('txt_sign_out')}
|
<LogOut size={14} className="btn-icon" /> {t('txt_sign_out')}
|
||||||
</button>
|
</button>
|
||||||
@@ -1026,7 +1018,6 @@ export default function App() {
|
|||||||
<SettingsPage
|
<SettingsPage
|
||||||
profile={profile}
|
profile={profile}
|
||||||
totpEnabled={!!totpStatusQuery.data?.enabled}
|
totpEnabled={!!totpStatusQuery.data?.enabled}
|
||||||
onSaveProfile={saveProfileAction}
|
|
||||||
onChangePassword={changePasswordAction}
|
onChangePassword={changePasswordAction}
|
||||||
onEnableTotp={async (secret, token) => {
|
onEnableTotp={async (secret, token) => {
|
||||||
await enableTotpAction(secret, token);
|
await enableTotpAction(secret, token);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||||
import { Clipboard, KeyRound, RefreshCw, Save, ShieldCheck, ShieldOff } from 'lucide-preact';
|
import { Clipboard, KeyRound, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-preact';
|
||||||
import qrcode from 'qrcode-generator';
|
import qrcode from 'qrcode-generator';
|
||||||
import type { Profile } from '@/lib/types';
|
import type { Profile } from '@/lib/types';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
@@ -7,7 +7,6 @@ import { t } from '@/lib/i18n';
|
|||||||
interface SettingsPageProps {
|
interface SettingsPageProps {
|
||||||
profile: Profile;
|
profile: Profile;
|
||||||
totpEnabled: boolean;
|
totpEnabled: boolean;
|
||||||
onSaveProfile: (name: string, email: string) => Promise<void>;
|
|
||||||
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
|
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
|
||||||
onEnableTotp: (secret: string, token: string) => Promise<void>;
|
onEnableTotp: (secret: string, token: string) => Promise<void>;
|
||||||
onOpenDisableTotp: () => void;
|
onOpenDisableTotp: () => void;
|
||||||
@@ -30,8 +29,6 @@ function buildOtpUri(email: string, secret: string): string {
|
|||||||
|
|
||||||
export default function SettingsPage(props: SettingsPageProps) {
|
export default function SettingsPage(props: SettingsPageProps) {
|
||||||
const totpSecretStorageKey = `nodewarden.totp.secret.${props.profile.id}`;
|
const totpSecretStorageKey = `nodewarden.totp.secret.${props.profile.id}`;
|
||||||
const [name, setName] = useState(props.profile.name || '');
|
|
||||||
const [email, setEmail] = useState(props.profile.email || '');
|
|
||||||
const [currentPassword, setCurrentPassword] = useState('');
|
const [currentPassword, setCurrentPassword] = useState('');
|
||||||
const [newPassword, setNewPassword] = useState('');
|
const [newPassword, setNewPassword] = useState('');
|
||||||
const [newPassword2, setNewPassword2] = useState('');
|
const [newPassword2, setNewPassword2] = useState('');
|
||||||
@@ -49,12 +46,13 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
setTotpLocked(true);
|
setTotpLocked(true);
|
||||||
}, [props.totpEnabled]);
|
}, [props.totpEnabled]);
|
||||||
|
|
||||||
const qrSvg = useMemo(() => {
|
const qrDataUrl = useMemo(() => {
|
||||||
const qr = qrcode(0, 'M');
|
const qr = qrcode(0, 'M');
|
||||||
qr.addData(buildOtpUri(email || props.profile.email, secret));
|
qr.addData(buildOtpUri(props.profile.email, secret));
|
||||||
qr.make();
|
qr.make();
|
||||||
return qr.createSvgTag({ scalable: true, margin: 0 });
|
const svg = qr.createSvgTag({ scalable: true, margin: 0 });
|
||||||
}, [email, props.profile.email, secret]);
|
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
||||||
|
}, [props.profile.email, secret]);
|
||||||
|
|
||||||
async function enableTotp(): Promise<void> {
|
async function enableTotp(): Promise<void> {
|
||||||
await props.onEnableTotp(secret, token);
|
await props.onEnableTotp(secret, token);
|
||||||
@@ -70,29 +68,6 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
<section className="card">
|
|
||||||
<h3>{t('txt_profile')}</h3>
|
|
||||||
<div className="field-grid">
|
|
||||||
<label className="field">
|
|
||||||
<span>{t('txt_name')}</span>
|
|
||||||
<input className="input" value={name} onInput={(e) => setName((e.currentTarget as HTMLInputElement).value)} />
|
|
||||||
</label>
|
|
||||||
<label className="field">
|
|
||||||
<span>{t('txt_email')}</span>
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onInput={(e) => setEmail((e.currentTarget as HTMLInputElement).value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<button type="button" className="btn btn-primary" onClick={() => void props.onSaveProfile(name, email)}>
|
|
||||||
<Save size={14} className="btn-icon" />
|
|
||||||
{t('txt_save_profile')}
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<h3>{t('txt_change_master_password')}</h3>
|
<h3>{t('txt_change_master_password')}</h3>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
@@ -130,7 +105,9 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
<h3>{t('txt_totp')}</h3>
|
<h3>{t('txt_totp')}</h3>
|
||||||
{totpLocked && <div className="status-ok">{t('txt_totp_is_enabled_for_this_account')}</div>}
|
{totpLocked && <div className="status-ok">{t('txt_totp_is_enabled_for_this_account')}</div>}
|
||||||
<div className="totp-grid">
|
<div className="totp-grid">
|
||||||
<div className="totp-qr" dangerouslySetInnerHTML={{ __html: qrSvg }} />
|
<div className="totp-qr">
|
||||||
|
<img src={qrDataUrl} alt="TOTP QR" />
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div>
|
<div>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
|
|||||||
+5
-18
@@ -30,7 +30,11 @@ export function loadSession(): SessionState | null {
|
|||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
const parsed = JSON.parse(raw) as SessionState;
|
const parsed = JSON.parse(raw) as SessionState;
|
||||||
if (!parsed.accessToken || !parsed.refreshToken) return null;
|
if (!parsed.accessToken || !parsed.refreshToken) return null;
|
||||||
return parsed;
|
return {
|
||||||
|
accessToken: parsed.accessToken,
|
||||||
|
refreshToken: parsed.refreshToken,
|
||||||
|
email: parsed.email,
|
||||||
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -45,8 +49,6 @@ export function saveSession(session: SessionState | null): void {
|
|||||||
accessToken: session.accessToken,
|
accessToken: session.accessToken,
|
||||||
refreshToken: session.refreshToken,
|
refreshToken: session.refreshToken,
|
||||||
email: session.email,
|
email: session.email,
|
||||||
symEncKey: session.symEncKey,
|
|
||||||
symMacKey: session.symMacKey,
|
|
||||||
};
|
};
|
||||||
localStorage.setItem(SESSION_KEY, JSON.stringify(persisted));
|
localStorage.setItem(SESSION_KEY, JSON.stringify(persisted));
|
||||||
}
|
}
|
||||||
@@ -328,21 +330,6 @@ export async function getSends(authedFetch: (input: string, init?: RequestInit)
|
|||||||
return body?.data || [];
|
return body?.data || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateProfile(
|
|
||||||
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
|
|
||||||
payload: { name: string; email: string }
|
|
||||||
): Promise<Profile> {
|
|
||||||
const resp = await authedFetch('/api/accounts/profile', {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
if (!resp.ok) throw new Error('Save profile failed');
|
|
||||||
const body = await parseJson<Profile>(resp);
|
|
||||||
if (!body) throw new Error('Invalid profile');
|
|
||||||
return body;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function changeMasterPassword(
|
export async function changeMasterPassword(
|
||||||
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
|
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
|
||||||
args: {
|
args: {
|
||||||
|
|||||||
@@ -196,6 +196,7 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
txt_copied: "Copied",
|
txt_copied: "Copied",
|
||||||
txt_log_in: "Log In",
|
txt_log_in: "Log In",
|
||||||
txt_log_out: "Log Out",
|
txt_log_out: "Log Out",
|
||||||
|
txt_lock: "Lock",
|
||||||
txt_login: "Login",
|
txt_login: "Login",
|
||||||
txt_login_credentials: "Login Credentials",
|
txt_login_credentials: "Login Credentials",
|
||||||
txt_login_failed: "Login failed",
|
txt_login_failed: "Login failed",
|
||||||
@@ -368,7 +369,7 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
nav_device_management: '设备管理',
|
nav_device_management: '设备管理',
|
||||||
nav_support_center: '支持中心',
|
nav_support_center: '支持中心',
|
||||||
support_title: '支持中心',
|
support_title: '支持中心',
|
||||||
support_under_construction: '正在搭建中。',
|
support_under_construction: '正在搭建中',
|
||||||
txt_sign_out: '退出登录',
|
txt_sign_out: '退出登录',
|
||||||
txt_log_in: '登录',
|
txt_log_in: '登录',
|
||||||
txt_log_out: '退出',
|
txt_log_out: '退出',
|
||||||
@@ -392,7 +393,7 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
txt_search_your_secure_vault: '搜索你的保险库...',
|
txt_search_your_secure_vault: '搜索你的保险库...',
|
||||||
txt_refresh: '刷新',
|
txt_refresh: '刷新',
|
||||||
txt_sync: '同步',
|
txt_sync: '同步',
|
||||||
txt_sync_vault: '同步保险库',
|
txt_sync_vault: '同步',
|
||||||
txt_add: '新增',
|
txt_add: '新增',
|
||||||
txt_edit: '编辑',
|
txt_edit: '编辑',
|
||||||
txt_delete: '删除',
|
txt_delete: '删除',
|
||||||
@@ -720,6 +721,8 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
txt_copied: '已复制',
|
txt_copied: '已复制',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
zhCNOverrides.txt_lock = '\u9501\u5b9a';
|
||||||
|
|
||||||
messages['zh-CN'] = { ...messages.en, ...zhCNOverrides };
|
messages['zh-CN'] = { ...messages.en, ...zhCNOverrides };
|
||||||
|
|
||||||
function resolveInitialLocale(): Locale {
|
function resolveInitialLocale(): Locale {
|
||||||
|
|||||||
@@ -965,6 +965,11 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
height: 180px;
|
height: 180px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.totp-qr img {
|
||||||
|
width: 180px;
|
||||||
|
height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
.section-head {
|
.section-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user