Compare commits

...
10 Commits
Author SHA1 Message Date
shuaiplus ff85698edb feat: add Tigris backup provider support with recommendations and localization updates 2026-06-25 21:11:57 +08:00
shuaiplus c3dc53bac1 feat: add Cloudflare R2 support with detailed backup recommendations and localization updates 2026-06-25 19:45:09 +08:00
shuaiplus 1acc31eda0 feat: add Backblaze B2 support with recommendations and styling updates 2026-06-25 18:42:34 +08:00
shuaiplusandClaude c694f1bfce refactor: consolidate security devices UI and remove pending auth requests from settings
- Merge device management and authorized devices sections into a single card in SecurityDevicesPage
- Remove PendingAuthRequestsPanel from SettingsPage and its related props
- Clean up unused auth request prop drilling in AppMainRoutes

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-25 01:49:17 +08:00
shuaiplus bf51309fbb fix: update version to v1.7.1 2026-06-24 01:55:09 +08:00
shuaiplusandShuai 23b23f39b9 fix: require reauthentication for auth request approval 2026-06-24 01:44:50 +08:00
shuaiplusandShuai 0daad46591 chore: add package overrides for undici, @babel/core, and esbuild 2026-06-24 01:44:50 +08:00
shuaiplusandShuai a2a8f1c7b6 fix:Harden authentication and sensitive file handling 2026-06-24 01:44:50 +08:00
shuaiplusandShuai 850fe0f044 fix: two-phase invite consumption to prevent registration race condition 2026-06-24 01:44:50 +08:00
shuaiplusandShuai 7279668955 fix: address security issue 2026-06-24 01:44:50 +08:00
44 changed files with 1825 additions and 651 deletions
+10
View File
@@ -228,6 +228,16 @@ CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (
CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device
ON trusted_two_factor_device_tokens(user_id, device_identifier); ON trusted_two_factor_device_tokens(user_id, device_identifier);
CREATE TABLE IF NOT EXISTS totp_login_replays (
user_id TEXT NOT NULL,
time_counter INTEGER NOT NULL,
consumed_at INTEGER NOT NULL,
PRIMARY KEY (user_id, time_counter),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_totp_login_replays_consumed_at
ON totp_login_replays(consumed_at);
CREATE TABLE IF NOT EXISTS webauthn_credentials ( CREATE TABLE IF NOT EXISTS webauthn_credentials (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
+639 -426
View File
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "nodewarden", "name": "nodewarden",
"version": "1.7.0", "version": "1.7.1",
"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",
@@ -42,6 +42,11 @@
} }
} }
}, },
"overrides": {
"undici": ">=7.28.0",
"@babel/core": ">=7.29.6",
"esbuild": ">=0.28.1"
},
"devDependencies": { "devDependencies": {
"@cloudflare/workers-types": "^4.20260131.0", "@cloudflare/workers-types": "^4.20260131.0",
"@preact/preset-vite": "^2.10.3", "@preact/preset-vite": "^2.10.3",
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = '1.7.0'; export const APP_VERSION = '1.7.1';
+4 -3
View File
@@ -9,7 +9,8 @@
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';
export const BACKUP_DEFAULT_REMOTE_PATH = 'nodewarden'; export const BACKUP_DEFAULT_S3_ROOT_PATH = '';
export const BACKUP_DEFAULT_WEBDAV_REMOTE_PATH = 'nodewarden';
export const BACKUP_DEFAULT_INTERVAL_HOURS = 24; export const BACKUP_DEFAULT_INTERVAL_HOURS = 24;
export const BACKUP_DEFAULT_START_TIME = '03:00'; export const BACKUP_DEFAULT_START_TIME = '03:00';
@@ -109,14 +110,14 @@ export function createDefaultBackupDestinationConfig(type: BackupDestinationType
region: BACKUP_DEFAULT_S3_REGION, region: BACKUP_DEFAULT_S3_REGION,
accessKeyId: '', accessKeyId: '',
secretAccessKey: '', secretAccessKey: '',
rootPath: BACKUP_DEFAULT_REMOTE_PATH, rootPath: BACKUP_DEFAULT_S3_ROOT_PATH,
}; };
} }
return { return {
baseUrl: '', baseUrl: '',
username: '', username: '',
password: '', password: '',
remotePath: BACKUP_DEFAULT_REMOTE_PATH, remotePath: BACKUP_DEFAULT_WEBDAV_REMOTE_PATH,
}; };
} }
+35 -6
View File
@@ -353,20 +353,31 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
return errorResponse('Invite code is required', 403); return errorResponse('Invite code is required', 403);
} }
const inviteMarked = await storage.markInviteUsed(inviteCode, user.id);
if (!inviteMarked) {
return errorResponse('Invite code is invalid or expired', 403);
}
try { try {
await storage.createUser(user); await storage.createUser(user);
} catch (error) { } catch (error) {
await storage.revertInviteUsed(inviteCode, user.id);
const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase(); const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
if (msg.includes('unique') || msg.includes('constraint')) { if (msg.includes('unique') || msg.includes('constraint')) {
return errorResponse('Email already registered', 409); return errorResponse('Email already registered', 409);
} }
console.error('Registration failed after invite reservation:', error);
throw error; throw error;
} }
const inviteMarked = await storage.markInviteUsed(inviteCode, user.id); try {
if (!inviteMarked) { const assigned = await storage.assignInviteUsedBy(inviteCode, user.id);
await storage.deleteUserById(user.id); if (!assigned) {
return errorResponse('Invite code is invalid or expired', 403); console.warn('Invite used_by was not assigned after registration', { inviteCode, userId: user.id });
}
} catch (error) {
// The invite is already consumed. Do not reactivate it after the user row exists.
console.error('Invite used_by assignment failed after registration:', error);
} }
await writeAuditEvent(storage, { await writeAuditEvent(storage, {
@@ -891,7 +902,7 @@ export async function handleDisableTwoFactorProvider(request: Request, env: Env,
} }
// PUT /api/accounts/totp // PUT /api/accounts/totp
// enable: { enabled: true, secret: "...", token: "123456" } // enable: { enabled: true, secret: "...", token: "123456", masterPasswordHash?: "...", userVerificationToken?: "..." }
// disable: { enabled: false, masterPasswordHash: "..." } // disable: { enabled: false, masterPasswordHash: "..." }
export async function handleSetTotpStatus(request: Request, env: Env, userId: string): Promise<Response> { export async function handleSetTotpStatus(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
@@ -899,7 +910,13 @@ export async function handleSetTotpStatus(request: Request, env: Env, userId: st
const user = await storage.getUserById(userId); const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404); if (!user) return errorResponse('User not found', 404);
let body: { enabled?: boolean; secret?: string; token?: string; masterPasswordHash?: string }; let body: {
enabled?: boolean;
secret?: string;
token?: string;
masterPasswordHash?: string;
userVerificationToken?: string;
};
try { try {
body = await request.json(); body = await request.json();
} catch { } catch {
@@ -908,12 +925,24 @@ export async function handleSetTotpStatus(request: Request, env: Env, userId: st
if (body.enabled === true) { if (body.enabled === true) {
const normalizedSecret = normalizeTotpSecret(body.secret || ''); const normalizedSecret = normalizeTotpSecret(body.secret || '');
const masterPasswordHash = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash']);
const userVerificationToken = readBodyString(body, ['userVerificationToken', 'UserVerificationToken']);
if (!isTotpEnabled(normalizedSecret)) { if (!isTotpEnabled(normalizedSecret)) {
return errorResponse('Invalid TOTP secret', 400); return errorResponse('Invalid TOTP secret', 400);
} }
if (!body.token) { if (!body.token) {
return errorResponse('TOTP token is required', 400); return errorResponse('TOTP token is required', 400);
} }
let verifiedUser = false;
if (userVerificationToken) {
verifiedUser = await verifyTotpUserVerificationToken(env, user, normalizedSecret, userVerificationToken);
}
if (!verifiedUser && masterPasswordHash) {
verifiedUser = await auth.verifyPassword(masterPasswordHash, user.masterPasswordHash, user.email);
}
if (!verifiedUser) {
return errorResponse('User verification failed.', 400);
}
const verified = await verifyTotpToken(normalizedSecret, body.token); const verified = await verifyTotpToken(normalizedSecret, body.token);
if (!verified) { if (!verified) {
return errorResponse('Invalid TOTP token', 400); return errorResponse('Invalid TOTP token', 400);
+2 -1
View File
@@ -4,6 +4,7 @@ import { StorageService } from '../services/storage';
import { jsonResponse, errorResponse } from '../utils/response'; import { jsonResponse, errorResponse } from '../utils/response';
import { buildDirectUploadUrl, getSafeJwtSecret, parseDirectUploadPayload } from '../utils/direct-upload'; import { buildDirectUploadUrl, getSafeJwtSecret, parseDirectUploadPayload } from '../utils/direct-upload';
import { generateUUID } from '../utils/uuid'; import { generateUUID } from '../utils/uuid';
import { sanitizeDownloadContentType } from '../utils/content-type';
import { import {
createAttachmentUploadToken, createAttachmentUploadToken,
createFileDownloadToken, createFileDownloadToken,
@@ -449,7 +450,7 @@ export async function handlePublicDownloadAttachment(
return new Response(object.body, { return new Response(object.body, {
headers: { headers: {
'Content-Type': object.contentType || 'application/octet-stream', 'Content-Type': sanitizeDownloadContentType(object.contentType),
'Content-Length': String(object.size), 'Content-Length': String(object.size),
'Content-Disposition': contentDispositionAttachment(attachment.fileName), 'Content-Disposition': contentDispositionAttachment(attachment.fileName),
'Cache-Control': 'private, no-cache', 'Cache-Control': 'private, no-cache',
+16
View File
@@ -14,6 +14,19 @@ function normalizeText(value: unknown, maxLength: number): string {
return String(value ?? '').trim().slice(0, maxLength); return String(value ?? '').trim().slice(0, maxLength);
} }
function isSerializedEncString(value: unknown): value is string {
const text = String(value || '').trim();
if (!text) return false;
const parts = text.split('.');
if (parts.length !== 2) return false;
const type = Number(parts[0]);
const bodyParts = parts[1].split('|');
if (type === 2) return bodyParts.length === 3 && bodyParts.every(Boolean);
if (type === 3 || type === 4) return bodyParts.length === 1 && !!bodyParts[0];
if (type === 5 || type === 6) return bodyParts.length === 2 && bodyParts.every(Boolean);
return false;
}
function getClientIp(request: Request): string | null { function getClientIp(request: Request): string | null {
return ( return (
request.headers.get('CF-Connecting-IP') || request.headers.get('CF-Connecting-IP') ||
@@ -251,6 +264,9 @@ export async function handleUpdateAuthRequest(request: Request, env: Env, userId
if (approved && !key) { if (approved && !key) {
return errorResponse('Encrypted key is required to approve the request.', 400); return errorResponse('Encrypted key is required to approve the request.', 400);
} }
if (approved && !isSerializedEncString(key)) {
return errorResponse('Encrypted key is not a valid encrypted string.', 400);
}
const updated = await storage.updateAuthRequestResponse(id, userId, { const updated = await storage.updateAuthRequestResponse(id, userId, {
approved, approved,
+80 -12
View File
@@ -40,15 +40,51 @@ import {
uploadBackupArchive, uploadBackupArchive,
} from '../services/backup-uploader'; } from '../services/backup-uploader';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { AuthService } from '../services/auth';
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events'; import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
import { getBlobObject } from '../services/blob-store'; import { getBlobObject } from '../services/blob-store';
import { notifyUserBackupProgress, notifyUserBackupRestoreProgress } from '../durable/notifications-hub'; import { notifyUserBackupProgress, notifyUserBackupRestoreProgress } from '../durable/notifications-hub';
import { verifyPasskeyUserVerificationToken } from '../utils/user-verification-token';
import { unzipSync } from 'fflate'; import { unzipSync } from 'fflate';
function isAdmin(user: User): boolean { function isAdmin(user: User): boolean {
return user.role === 'admin' && user.status === 'active'; return user.role === 'admin' && user.status === 'active';
} }
async function requireBackupUserVerification(actorUser: User, masterPasswordHash: string, env: Env): Promise<Response | null> {
const normalized = String(masterPasswordHash || '').trim();
if (!normalized) {
return errorResponse('masterPasswordHash is required', 400);
}
const auth = new AuthService(env);
const valid = await auth.verifyPassword(normalized, actorUser.masterPasswordHash, actorUser.email);
if (!valid) {
return errorResponse('Invalid password', 400);
}
return null;
}
async function requireBackupRepairVerification(
actorUser: User,
body: { masterPasswordHash?: string; userVerificationToken?: string },
env: Env
): Promise<Response | null> {
const masterPasswordHash = String(body.masterPasswordHash || '').trim();
if (masterPasswordHash) {
return requireBackupUserVerification(actorUser, masterPasswordHash, env);
}
const userVerificationToken = String(body.userVerificationToken || '').trim();
if (!userVerificationToken) {
return errorResponse('masterPasswordHash or userVerificationToken is required', 400);
}
const valid = await verifyPasskeyUserVerificationToken(env, userVerificationToken, actorUser.id, 'backup.settings.repair');
if (!valid) {
return errorResponse('Invalid user verification token', 400);
}
return null;
}
async function writeAuditLog( async function writeAuditLog(
storage: StorageService, storage: StorageService,
actorUserId: string | null, actorUserId: string | null,
@@ -787,13 +823,16 @@ export async function handleGetAdminBackupSettings(request: Request, env: Env, a
export async function handleUpdateAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> { export async function handleUpdateAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403); if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
let body: BackupSettingsInput; let body: BackupSettingsInput & { masterPasswordHash?: string };
try { try {
body = await request.json<BackupSettingsInput>(); body = await request.json<BackupSettingsInput & { masterPasswordHash?: string }>();
} catch { } catch {
return errorResponse('Backup settings payload is invalid', 400); return errorResponse('Backup settings payload is invalid', 400);
} }
const verificationError = await requireBackupUserVerification(actorUser, String(body.masterPasswordHash || ''), env);
if (verificationError) return verificationError;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
let previous; let previous;
try { try {
@@ -837,13 +876,16 @@ export async function handleGetAdminBackupSettingsRepairState(request: Request,
export async function handleRepairAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> { export async function handleRepairAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403); if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
let body: BackupSettingsInput; let body: BackupSettingsInput & { masterPasswordHash?: string; userVerificationToken?: string };
try { try {
body = await request.json<BackupSettingsInput>(); body = await request.json<BackupSettingsInput & { masterPasswordHash?: string; userVerificationToken?: string }>();
} catch { } catch {
return errorResponse('Backup settings repair payload is invalid', 400); return errorResponse('Backup settings repair payload is invalid', 400);
} }
const verificationError = await requireBackupRepairVerification(actorUser, body, env);
if (verificationError) return verificationError;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
let previous; let previous;
try { try {
@@ -871,15 +913,18 @@ export async function handleRunAdminConfiguredBackup(request: Request, env: Env,
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403); if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
try { try {
let body: { destinationId?: string } | null = null; let body: { destinationId?: string; masterPasswordHash?: string } | null = null;
try { try {
if ((request.headers.get('Content-Type') || '').includes('application/json')) { if ((request.headers.get('Content-Type') || '').includes('application/json')) {
body = await request.json<{ destinationId?: string }>(); body = await request.json<{ destinationId?: string; masterPasswordHash?: string }>();
} }
} catch { } catch {
return errorResponse('Backup run payload is invalid', 400); return errorResponse('Backup run payload is invalid', 400);
} }
const verificationError = await requireBackupUserVerification(actorUser, String(body?.masterPasswordHash || ''), env);
if (verificationError) return verificationError;
const outcome = await runConfiguredBackupInDurableObject(env, { const outcome = await runConfiguredBackupInDurableObject(env, {
actorUserId: actorUser.id, actorUserId: actorUser.id,
auditMetadata: auditRequestMetadata(request), auditMetadata: auditRequestMetadata(request),
@@ -928,12 +973,21 @@ export async function handleListAdminRemoteBackups(request: Request, env: Env, a
export async function handleDownloadAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> { export async function handleDownloadAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403); if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
let body: { destinationId?: string; path?: string; masterPasswordHash?: string };
try {
body = await request.json<{ destinationId?: string; path?: string; masterPasswordHash?: string }>();
} catch {
return errorResponse('Remote backup download payload is invalid', 400);
}
const verificationError = await requireBackupUserVerification(actorUser, String(body.masterPasswordHash || ''), env);
if (verificationError) return verificationError;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
try { try {
const settings = await loadBackupSettings(storage, env, 'UTC'); const settings = await loadBackupSettings(storage, env, 'UTC');
const url = new URL(request.url); const path = ensureRemoteRestoreCandidate(String(body.path || ''));
const path = ensureRemoteRestoreCandidate(url.searchParams.get('path') || ''); const destination = requireBackupDestination(settings, body.destinationId || null);
const destination = requireBackupDestination(settings, url.searchParams.get('destinationId') || null);
const remoteFile = await downloadRemoteBackupFile(destination, path); const remoteFile = await downloadRemoteBackupFile(destination, path);
return new Response(remoteFile.bytes, { return new Response(remoteFile.bytes, {
status: 200, status: 200,
@@ -994,13 +1048,22 @@ export async function handleDeleteAdminRemoteBackup(request: Request, env: Env,
export async function handleRestoreAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> { export async function handleRestoreAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403); if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
let body: { destinationId?: string; path?: string; replaceExisting?: boolean; allowChecksumMismatch?: boolean }; let body: {
destinationId?: string;
path?: string;
replaceExisting?: boolean;
allowChecksumMismatch?: boolean;
masterPasswordHash?: string;
};
try { try {
body = await request.json<{ destinationId?: string; path?: string; replaceExisting?: boolean }>(); body = await request.json<{ destinationId?: string; path?: string; replaceExisting?: boolean }>();
} catch { } catch {
return errorResponse('Remote restore payload is invalid', 400); return errorResponse('Remote restore payload is invalid', 400);
} }
const verificationError = await requireBackupUserVerification(actorUser, String(body.masterPasswordHash || ''), env);
if (verificationError) return verificationError;
try { try {
const path = ensureRemoteRestoreCandidate(String(body.path || '')); const path = ensureRemoteRestoreCandidate(String(body.path || ''));
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null; const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
@@ -1028,14 +1091,16 @@ export async function handleAdminExportBackup(request: Request, env: Env, actorU
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null; const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
let body: { includeAttachments?: boolean } | null = null; let body: { includeAttachments?: boolean; masterPasswordHash?: string } | null = null;
try { try {
if ((request.headers.get('Content-Type') || '').includes('application/json')) { if ((request.headers.get('Content-Type') || '').includes('application/json')) {
body = await request.json<{ includeAttachments?: boolean }>(); body = await request.json<{ includeAttachments?: boolean; masterPasswordHash?: string }>();
} }
} catch { } catch {
return errorResponse('Backup export payload is invalid', 400); return errorResponse('Backup export payload is invalid', 400);
} }
const verificationError = await requireBackupUserVerification(actorUser, String(body?.masterPasswordHash || ''), env);
if (verificationError) return verificationError;
let archive: BackupArchiveBundle; let archive: BackupArchiveBundle;
try { try {
const progress = async (event: { const progress = async (event: {
@@ -1140,6 +1205,9 @@ export async function handleAdminImportBackup(request: Request, env: Env, actorU
return errorResponse('Backup file is required', 400); return errorResponse('Backup file is required', 400);
} }
const verificationError = await requireBackupUserVerification(actorUser, String(formData.get('masterPasswordHash') || ''), env);
if (verificationError) return verificationError;
const replaceExisting = String(formData.get('replaceExisting') || '').trim() === '1'; const replaceExisting = String(formData.get('replaceExisting') || '').trim() === '1';
const allowChecksumMismatch = String(formData.get('allowChecksumMismatch') || '').trim() === '1'; const allowChecksumMismatch = String(formData.get('allowChecksumMismatch') || '').trim() === '1';
let archiveBytes: Uint8Array; let archiveBytes: Uint8Array;
+15 -4
View File
@@ -4,7 +4,7 @@ import { AuthService } from '../services/auth';
import { RateLimitService, getClientIdentifier } from '../services/ratelimit'; import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/response'; import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/response';
import { LIMITS } from '../config/limits'; import { LIMITS } from '../config/limits';
import { isTotpEnabled, verifyTotpToken } from '../utils/totp'; import { findMatchingTotpCounter, isTotpEnabled } from '../utils/totp';
import { createRefreshToken } from '../utils/jwt'; import { createRefreshToken } from '../utils/jwt';
import { readAuthRequestDeviceInfo } from '../utils/device'; import { readAuthRequestDeviceInfo } from '../utils/device';
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code'; import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
@@ -21,6 +21,7 @@ import {
buildAccountPasskeyTokenUserDecryptionOption, buildAccountPasskeyTokenUserDecryptionOption,
} from './account-passkeys'; } from './account-passkeys';
import { isAuthRequestExpired } from '../services/storage-auth-request-repo'; import { isAuthRequestExpired } from '../services/storage-auth-request-repo';
import { createPasskeyUserVerificationToken } from '../utils/user-verification-token';
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000; const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0; const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
@@ -336,6 +337,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
} }
let validatedAuthRequestId: string | null = null; let validatedAuthRequestId: string | null = null;
let authRequestLoginKey: string | null = null;
let valid = false; let valid = false;
const normalizedAuthRequestId = String(authRequestId || '').trim(); const normalizedAuthRequestId = String(authRequestId || '').trim();
if (normalizedAuthRequestId) { if (normalizedAuthRequestId) {
@@ -348,10 +350,12 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
authRequest.responseDate && authRequest.responseDate &&
!authRequest.authenticationDate && !authRequest.authenticationDate &&
!isAuthRequestExpired(authRequest) && !isAuthRequestExpired(authRequest) &&
!!authRequest.key &&
constantTimeEquals(authRequest.accessCode, passwordHash) constantTimeEquals(authRequest.accessCode, passwordHash)
); );
if (valid) { if (valid) {
validatedAuthRequestId = authRequest!.id; validatedAuthRequestId = authRequest!.id;
authRequestLoginKey = authRequest!.key;
} }
} else { } else {
valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash, user.email); valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash, user.email);
@@ -408,8 +412,12 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
return twoFactorRequiredResponse('Two factor required.'); return twoFactorRequiredResponse('Two factor required.');
} }
} else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)) { } else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)) {
const totpOk = await verifyTotpToken(effectiveTotpSecret, normalizedTwoFactorToken); const matchedCounter = await findMatchingTotpCounter(effectiveTotpSecret, normalizedTwoFactorToken);
if (!totpOk) { if (matchedCounter == null) {
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
}
const consumed = await storage.consumeTotpLoginCounter(user.id, matchedCounter);
if (!consumed) {
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier); return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
} }
} else if ( } else if (
@@ -488,7 +496,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
token_type: 'Bearer', token_type: 'Bearer',
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }), ...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }),
...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}), ...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}),
Key: user.key, Key: authRequestLoginKey || user.key,
PrivateKey: user.privateKey, PrivateKey: user.privateKey,
AccountKeys: accountKeys, AccountKeys: accountKeys,
accountKeys: accountKeys, accountKeys: accountKeys,
@@ -583,6 +591,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
const accessToken = await auth.generateAccessToken(user, deviceSession); const accessToken = await auth.generateAccessToken(user, deviceSession);
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession); const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
const userVerificationToken = await createPasskeyUserVerificationToken(env, user.id, 'backup.settings.repair');
const accountKeys = buildAccountKeys(user); const accountKeys = buildAccountKeys(user);
const webAuthnPrfOption = buildAccountPasskeyTokenUserDecryptionOption(credential); const webAuthnPrfOption = buildAccountPasskeyTokenUserDecryptionOption(credential);
const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOption); const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOption);
@@ -621,6 +630,8 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
ApiUseKeyConnector: false, ApiUseKeyConnector: false,
scope: 'api offline_access', scope: 'api offline_access',
unofficialServer: true, unofficialServer: true,
UserVerificationToken: userVerificationToken,
userVerificationToken,
UserDecryptionOptions: userDecryptionOptions, UserDecryptionOptions: userDecryptionOptions,
userDecryptionOptions: userDecryptionOptions, userDecryptionOptions: userDecryptionOptions,
}; };
+2 -1
View File
@@ -2,6 +2,7 @@ import { Env, SendType } from '../types';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { RateLimitService, getClientIdentifier } from '../services/ratelimit'; import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
import { jsonResponse, errorResponse } from '../utils/response'; import { jsonResponse, errorResponse } from '../utils/response';
import { sanitizeDownloadContentType } from '../utils/content-type';
import { LIMITS } from '../config/limits'; import { LIMITS } from '../config/limits';
import { import {
createSendAccessToken, createSendAccessToken,
@@ -306,7 +307,7 @@ export async function handleDownloadSendFile(
return new Response(object.body, { return new Response(object.body, {
headers: { headers: {
'Content-Type': object.contentType || 'application/octet-stream', 'Content-Type': sanitizeDownloadContentType(object.contentType),
'Content-Length': String(object.size), 'Content-Length': String(object.size),
'Content-Disposition': contentDispositionAttachment(fileName), 'Content-Disposition': contentDispositionAttachment(fileName),
'Cache-Control': 'private, no-cache', 'Cache-Control': 'private, no-cache',
+1 -1
View File
@@ -50,7 +50,7 @@ export async function handleAdminBackupRoute(
return handleListAdminRemoteBackups(request, env, actorUser); return handleListAdminRemoteBackups(request, env, actorUser);
} }
if (path === '/api/admin/backup/remote/download' && method === 'GET') { if (path === '/api/admin/backup/remote/download' && method === 'POST') {
return handleDownloadAdminRemoteBackup(request, env, actorUser); return handleDownloadAdminRemoteBackup(request, env, actorUser);
} }
+3 -1
View File
@@ -27,6 +27,7 @@ import {
handleNotificationsNegotiate, handleNotificationsNegotiate,
} from './handlers/notifications'; } from './handlers/notifications';
import { handlePublicUploadSendFile } from './handlers/sends'; import { handlePublicUploadSendFile } from './handlers/sends';
import { isSafeWebsiteIconContentType } from './utils/content-type';
import { jsonResponse } from './utils/response'; import { jsonResponse } from './utils/response';
import { StorageService } from './services/storage'; import { StorageService } from './services/storage';
import type { Env } from './types'; import type { Env } from './types';
@@ -241,6 +242,7 @@ function iconResponse(body: BodyInit | null, contentType: string | null): Respon
headers: { headers: {
'Content-Type': contentType || 'image/png', 'Content-Type': contentType || 'image/png',
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}, immutable`, 'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}, immutable`,
'Content-Security-Policy': "default-src 'none'; img-src 'self' data:; sandbox",
}, },
}); });
} }
@@ -272,7 +274,7 @@ async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-fo
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 (!isSafeWebsiteIconContentType(contentType)) continue;
const contentLength = getPositiveContentLength(resp.headers); const contentLength = getPositiveContentLength(resp.headers);
if (contentLength !== null && contentLength > ICON_MAX_BUFFER_BYTES) continue; if (contentLength !== null && contentLength > ICON_MAX_BUFFER_BYTES) continue;
+26 -2
View File
@@ -117,12 +117,36 @@ export async function listInvites(db: D1Database, includeInactive: boolean = fal
} }
export async function markInviteUsed(db: D1Database, code: string, userId: string): Promise<boolean> { export async function markInviteUsed(db: D1Database, code: string, userId: string): Promise<boolean> {
void userId;
const now = new Date().toISOString(); const now = new Date().toISOString();
const result = await db const result = await db
.prepare( .prepare(
"UPDATE invites SET status = 'used', used_by = ?, updated_at = ? WHERE code = ? AND status = 'active' AND expires_at > ?" "UPDATE invites SET status = 'used', used_by = NULL, updated_at = ? WHERE code = ? AND status = 'active' AND expires_at > ?"
) )
.bind(userId, now, code, now) .bind(now, code, now)
.run();
return (result.meta.changes ?? 0) > 0;
}
export async function assignInviteUsedBy(db: D1Database, code: string, userId: string): Promise<boolean> {
const now = new Date().toISOString();
const result = await db
.prepare(
"UPDATE invites SET used_by = ?, updated_at = ? WHERE code = ? AND status = 'used' AND used_by IS NULL"
)
.bind(userId, now, code)
.run();
return (result.meta.changes ?? 0) > 0;
}
export async function revertInviteUsed(db: D1Database, code: string, userId: string): Promise<boolean> {
void userId;
const now = new Date().toISOString();
const result = await db
.prepare(
"UPDATE invites SET status = 'active', used_by = NULL, updated_at = ? WHERE code = ? AND status = 'used' AND used_by IS NULL"
)
.bind(now, code)
.run(); .run();
return (result.meta.changes ?? 0) > 0; return (result.meta.changes ?? 0) > 0;
} }
+7
View File
@@ -78,6 +78,7 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'code TEXT PRIMARY KEY, created_by TEXT NOT NULL, used_by TEXT, expires_at TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' + 'code TEXT PRIMARY KEY, created_by TEXT NOT NULL, used_by TEXT, expires_at TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
'FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE, ' + 'FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE, ' +
'FOREIGN KEY (used_by) REFERENCES users(id) ON DELETE SET NULL)', 'FOREIGN KEY (used_by) REFERENCES users(id) ON DELETE SET NULL)',
'ALTER TABLE invites ADD COLUMN used_by TEXT',
'CREATE INDEX IF NOT EXISTS idx_invites_status_expires ON invites(status, expires_at)', 'CREATE INDEX IF NOT EXISTS idx_invites_status_expires ON invites(status, expires_at)',
'CREATE INDEX IF NOT EXISTS idx_invites_created_by ON invites(created_by, created_at)', 'CREATE INDEX IF NOT EXISTS idx_invites_created_by ON invites(created_by, created_at)',
@@ -126,6 +127,12 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'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_trusted_two_factor_device_tokens_user_device ON trusted_two_factor_device_tokens(user_id, device_identifier)', 'CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device ON trusted_two_factor_device_tokens(user_id, device_identifier)',
'CREATE TABLE IF NOT EXISTS totp_login_replays (' +
'user_id TEXT NOT NULL, time_counter INTEGER NOT NULL, consumed_at INTEGER NOT NULL, ' +
'PRIMARY KEY (user_id, time_counter), ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'CREATE INDEX IF NOT EXISTS idx_totp_login_replays_consumed_at ON totp_login_replays(consumed_at)',
'CREATE TABLE IF NOT EXISTS webauthn_credentials (' + 'CREATE TABLE IF NOT EXISTS webauthn_credentials (' +
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, ' + 'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, ' +
'type TEXT, aa_guid TEXT, transports TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, supports_prf INTEGER NOT NULL DEFAULT 0, ' + 'type TEXT, aa_guid TEXT, transports TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, supports_prf INTEGER NOT NULL DEFAULT 0, ' +
+35
View File
@@ -0,0 +1,35 @@
type ShouldRunPeriodicCleanup = (lastRunAt: number, intervalMs: number) => boolean;
export async function consumeTotpLoginCounter(
db: D1Database,
shouldRunPeriodicCleanup: ShouldRunPeriodicCleanup,
lastCleanupAt: number,
cleanupIntervalMs: number,
userId: string,
timeCounter: number,
consumedAtMs: number,
markerTtlMs: number
): Promise<{ consumed: boolean; cleanedUpAt: number | null }> {
let cleanedUpAt: number | null = null;
if (shouldRunPeriodicCleanup(lastCleanupAt, cleanupIntervalMs)) {
await db
.prepare('DELETE FROM totp_login_replays WHERE consumed_at < ?')
.bind(consumedAtMs - markerTtlMs)
.run();
cleanedUpAt = consumedAtMs;
}
const result = await db
.prepare(
'INSERT INTO totp_login_replays(user_id, time_counter, consumed_at) VALUES(?, ?, ?) ' +
'ON CONFLICT(user_id, time_counter) DO NOTHING'
)
.bind(userId, timeCounter, consumedAtMs)
.run();
return {
consumed: (result.meta.changes ?? 0) > 0,
cleanedUpAt,
};
}
+36 -2
View File
@@ -22,6 +22,7 @@ import {
type AuditLogListOptions, type AuditLogListOptions,
createAuditLog as createStoredAuditLog, createAuditLog as createStoredAuditLog,
clearAuditLogs as clearStoredAuditLogs, clearAuditLogs as clearStoredAuditLogs,
assignInviteUsedBy as assignStoredInviteUsedBy,
createInvite as createStoredInvite, createInvite as createStoredInvite,
deleteAllInvites as deleteStoredInvites, deleteAllInvites as deleteStoredInvites,
getInvite as findStoredInvite, getInvite as findStoredInvite,
@@ -30,6 +31,7 @@ import {
markInviteUsed as markStoredInviteUsed, markInviteUsed as markStoredInviteUsed,
pruneAuditLogs as pruneStoredAuditLogs, pruneAuditLogs as pruneStoredAuditLogs,
pruneAuditLogsToMax as pruneStoredAuditLogsToMax, pruneAuditLogsToMax as pruneStoredAuditLogsToMax,
revertInviteUsed as revertStoredInviteUsed,
revokeInvite as revokeStoredInvite, revokeInvite as revokeStoredInvite,
} from './storage-admin-repo'; } from './storage-admin-repo';
import { import {
@@ -121,6 +123,9 @@ import {
ensureUsedAttachmentDownloadTokenTable as ensureStoredAttachmentTokenTable, ensureUsedAttachmentDownloadTokenTable as ensureStoredAttachmentTokenTable,
consumeAttachmentDownloadToken as consumeStoredAttachmentDownloadToken, consumeAttachmentDownloadToken as consumeStoredAttachmentDownloadToken,
} from './storage-attachment-token-repo'; } from './storage-attachment-token-repo';
import {
consumeTotpLoginCounter as consumeStoredTotpLoginCounter,
} from './storage-totp-replay-repo';
import { import {
getRevisionDate as getStoredRevisionDate, getRevisionDate as getStoredRevisionDate,
updateRevisionDate as updateStoredRevisionDate, updateRevisionDate as updateStoredRevisionDate,
@@ -148,8 +153,8 @@ const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
// Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql // Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql
// changes. Existing D1 installs only rerun ensureStorageSchema() when this value // changes. Existing D1 installs only rerun ensureStorageSchema() when this value
// differs from config.schema.version. // differs from config.schema.version.
const STORAGE_SCHEMA_VERSION = '2026-06-22-push-notifications'; const STORAGE_SCHEMA_VERSION = '2026-06-23-totp-login-replay';
const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests'] as const; const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests', 'totp_login_replays'] as const;
// D1-backed storage. // D1-backed storage.
// Contract: // Contract:
@@ -162,10 +167,13 @@ export class StorageService {
private static schemaVerified = false; private static schemaVerified = false;
private static lastRefreshTokenCleanupAt = 0; private static lastRefreshTokenCleanupAt = 0;
private static lastAttachmentTokenCleanupAt = 0; private static lastAttachmentTokenCleanupAt = 0;
private static lastTotpReplayCleanupAt = 0;
private static readonly MAX_D1_SQL_VARIABLES = 100; private static readonly MAX_D1_SQL_VARIABLES = 100;
private static readonly REFRESH_TOKEN_CLEANUP_INTERVAL_MS = LIMITS.cleanup.refreshTokenCleanupIntervalMs; private static readonly REFRESH_TOKEN_CLEANUP_INTERVAL_MS = LIMITS.cleanup.refreshTokenCleanupIntervalMs;
private static readonly ATTACHMENT_TOKEN_CLEANUP_INTERVAL_MS = LIMITS.cleanup.attachmentTokenCleanupIntervalMs; private static readonly ATTACHMENT_TOKEN_CLEANUP_INTERVAL_MS = LIMITS.cleanup.attachmentTokenCleanupIntervalMs;
private static readonly TOTP_REPLAY_CLEANUP_INTERVAL_MS = 10 * 60 * 1000;
private static readonly TOTP_REPLAY_MARKER_TTL_MS = 5 * 60 * 1000;
private static readonly PERIODIC_CLEANUP_PROBABILITY = LIMITS.cleanup.cleanupProbability; private static readonly PERIODIC_CLEANUP_PROBABILITY = LIMITS.cleanup.cleanupProbability;
constructor(private db: D1Database) {} constructor(private db: D1Database) {}
@@ -313,6 +321,14 @@ export class StorageService {
return markStoredInviteUsed(this.db, code, userId); return markStoredInviteUsed(this.db, code, userId);
} }
async assignInviteUsedBy(code: string, userId: string): Promise<boolean> {
return assignStoredInviteUsedBy(this.db, code, userId);
}
async revertInviteUsed(code: string, userId: string): Promise<boolean> {
return revertStoredInviteUsed(this.db, code, userId);
}
async revokeInvite(code: string): Promise<boolean> { async revokeInvite(code: string): Promise<boolean> {
return revokeStoredInvite(this.db, code); return revokeStoredInvite(this.db, code);
} }
@@ -823,6 +839,24 @@ export class StorageService {
return findStoredTrustedTokenUserId(this.db, this.trustedTwoFactorTokenKey.bind(this), token, deviceIdentifier); return findStoredTrustedTokenUserId(this.db, this.trustedTwoFactorTokenKey.bind(this), token, deviceIdentifier);
} }
async consumeTotpLoginCounter(userId: string, timeCounter: number, consumedAtMs: number = Date.now()): Promise<boolean> {
if (!Number.isSafeInteger(timeCounter) || timeCounter < 0) return false;
const result = await consumeStoredTotpLoginCounter(
this.db,
this.shouldRunPeriodicCleanup.bind(this),
StorageService.lastTotpReplayCleanupAt,
StorageService.TOTP_REPLAY_CLEANUP_INTERVAL_MS,
userId,
timeCounter,
consumedAtMs,
StorageService.TOTP_REPLAY_MARKER_TTL_MS
);
if (result.cleanedUpAt !== null) {
StorageService.lastTotpReplayCleanupAt = result.cleanedUpAt;
}
return result.consumed;
}
// --- Revision dates --- // --- Revision dates ---
async getRevisionDate(userId: string): Promise<string> { async getRevisionDate(userId: string): Promise<string> {
+2
View File
@@ -466,6 +466,8 @@ export interface TokenResponse {
ResetMasterPassword: boolean; ResetMasterPassword: boolean;
scope: string; scope: string;
unofficialServer: boolean; unofficialServer: boolean;
UserVerificationToken?: string;
userVerificationToken?: string;
MasterPasswordPolicy?: { MasterPasswordPolicy?: {
minComplexity: number; minComplexity: number;
minLength: number; minLength: number;
+38
View File
@@ -0,0 +1,38 @@
const ACTIVE_DOWNLOAD_MEDIA_TYPES = new Set([
'application/xhtml+xml',
'application/xml',
'image/svg+xml',
'text/html',
'text/xml',
]);
const SAFE_ICON_MEDIA_TYPES = new Set([
'image/avif',
'image/bmp',
'image/gif',
'image/jpeg',
'image/png',
'image/vnd.microsoft.icon',
'image/webp',
'image/x-icon',
]);
function normalizeMediaType(contentType: string | null | undefined): string {
return String(contentType || '')
.split(';', 1)[0]
.trim()
.toLowerCase();
}
export function isSafeWebsiteIconContentType(contentType: string | null | undefined): boolean {
return SAFE_ICON_MEDIA_TYPES.has(normalizeMediaType(contentType));
}
export function sanitizeDownloadContentType(contentType: string | null | undefined): string {
const mediaType = normalizeMediaType(contentType);
if (!mediaType) return 'application/octet-stream';
if (ACTIVE_DOWNLOAD_MEDIA_TYPES.has(mediaType)) {
return 'application/octet-stream';
}
return contentType || mediaType;
}
+3 -1
View File
@@ -100,7 +100,9 @@ export function applyCors(
headers.set('X-Frame-Options', 'DENY'); headers.set('X-Frame-Options', 'DENY');
headers.set('X-Content-Type-Options', 'nosniff'); headers.set('X-Content-Type-Options', 'nosniff');
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
headers.set('Content-Security-Policy', "frame-ancestors 'none'; img-src 'self' data:"); if (!headers.has('Content-Security-Policy')) {
headers.set('Content-Security-Policy', "frame-ancestors 'none'; img-src 'self' data:");
}
return new Response(response.body, { return new Response(response.body, {
status: response.status, status: response.status,
statusText: response.statusText, statusText: response.statusText,
+16 -7
View File
@@ -70,17 +70,22 @@ function normalizeToken(token: string): string {
return token.replace(/\s+/g, ''); return token.replace(/\s+/g, '');
} }
export async function verifyTotpToken(secretRaw: string, tokenRaw: string, nowMs: number = Date.now()): Promise<boolean> { export async function findMatchingTotpCounter(
secretRaw: string,
tokenRaw: string,
nowMs: number = Date.now()
): Promise<number | null> {
const token = normalizeToken(tokenRaw); const token = normalizeToken(tokenRaw);
if (!/^\d{6}$/.test(token)) return false; if (!/^\d{6}$/.test(token)) return null;
const secret = base32Decode(secretRaw); const secret = base32Decode(secretRaw);
if (!secret) return false; if (!secret) return null;
const currentCounter = Math.floor(nowMs / 1000 / TOTP_STEP_SECONDS); const currentCounter = Math.floor(nowMs / 1000 / TOTP_STEP_SECONDS);
let matched = false; let matchedCounter: number | null = null;
for (let delta = -TOTP_WINDOW; delta <= TOTP_WINDOW; delta++) { for (let delta = -TOTP_WINDOW; delta <= TOTP_WINDOW; delta++) {
const expected = await hotp(secret, currentCounter + delta); const candidateCounter = currentCounter + delta;
const expected = await hotp(secret, candidateCounter);
// Constant-time comparison: always check all windows, never short-circuit. // Constant-time comparison: always check all windows, never short-circuit.
const a = new TextEncoder().encode(expected); const a = new TextEncoder().encode(expected);
const b = new TextEncoder().encode(token); const b = new TextEncoder().encode(token);
@@ -88,9 +93,13 @@ export async function verifyTotpToken(secretRaw: string, tokenRaw: string, nowMs
for (let i = 0; i < a.length && i < b.length; i++) { for (let i = 0; i < a.length && i < b.length; i++) {
diff |= a[i] ^ b[i]; diff |= a[i] ^ b[i];
} }
if (diff === 0) matched = true; if (diff === 0 && matchedCounter == null) matchedCounter = candidateCounter;
} }
return matched; return matchedCounter;
}
export async function verifyTotpToken(secretRaw: string, tokenRaw: string, nowMs: number = Date.now()): Promise<boolean> {
return (await findMatchingTotpCounter(secretRaw, tokenRaw, nowMs)) != null;
} }
export function isTotpEnabled(secretRaw: string | undefined | null): boolean { export function isTotpEnabled(secretRaw: string | undefined | null): boolean {
+89
View File
@@ -0,0 +1,89 @@
import type { Env } from '../types';
import { base64UrlToBytes, bytesToBase64Url } from './passkey';
const USER_VERIFICATION_TOKEN_TYPE = 'nodewarden.user-verification.v1';
const USER_VERIFICATION_TOKEN_TTL_MS = 5 * 60 * 1000;
export type UserVerificationPurpose = 'backup.settings.repair';
interface UserVerificationTokenPayload {
typ: typeof USER_VERIFICATION_TOKEN_TYPE;
userId: string;
method: 'passkey';
purpose: UserVerificationPurpose;
iat: number;
exp: number;
}
function textBytes(value: string): Uint8Array {
return new TextEncoder().encode(value);
}
async function importHmacKey(secret: string): Promise<CryptoKey> {
return crypto.subtle.importKey('raw', textBytes(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);
}
async function hmacSha256(secret: string, data: string): Promise<Uint8Array> {
const key = await importHmacKey(secret);
return new Uint8Array(await crypto.subtle.sign('HMAC', key, textBytes(data)));
}
function encodeJson(value: unknown): string {
return bytesToBase64Url(textBytes(JSON.stringify(value)));
}
function decodeJson<T>(value: string): T | null {
try {
return JSON.parse(new TextDecoder().decode(base64UrlToBytes(value))) as T;
} catch {
return null;
}
}
export async function createPasskeyUserVerificationToken(
env: Env,
userId: string,
purpose: UserVerificationPurpose
): Promise<string> {
const now = Date.now();
const payload: UserVerificationTokenPayload = {
typ: USER_VERIFICATION_TOKEN_TYPE,
userId,
method: 'passkey',
purpose,
iat: now,
exp: now + USER_VERIFICATION_TOKEN_TTL_MS,
};
const header = { alg: 'HS256', typ: 'JWT' };
const data = `${encodeJson(header)}.${encodeJson(payload)}`;
const signature = bytesToBase64Url(await hmacSha256(env.JWT_SECRET, data));
return `${data}.${signature}`;
}
export async function verifyPasskeyUserVerificationToken(
env: Env,
token: string,
userId: string,
purpose: UserVerificationPurpose
): Promise<boolean> {
try {
const parts = String(token || '').split('.');
if (parts.length !== 3) return false;
const data = `${parts[0]}.${parts[1]}`;
const expected = await hmacSha256(env.JWT_SECRET, data);
const actual = base64UrlToBytes(parts[2]);
if (actual.length !== expected.length) return false;
let diff = 0;
for (let i = 0; i < actual.length; i += 1) diff |= actual[i] ^ expected[i];
if (diff !== 0) return false;
const payload = decodeJson<UserVerificationTokenPayload>(parts[1]);
if (!payload || payload.typ !== USER_VERIFICATION_TOKEN_TYPE) return false;
if (payload.userId !== userId || payload.purpose !== purpose || payload.method !== 'passkey') return false;
if (!Number.isFinite(payload.exp) || payload.exp < Date.now()) return false;
return true;
} catch {
return false;
}
}
+101 -19
View File
@@ -11,6 +11,7 @@ import RecoverTwoFactorPage from '@/components/RecoverTwoFactorPage';
import JwtWarningPage from '@/components/JwtWarningPage'; import JwtWarningPage from '@/components/JwtWarningPage';
import { import {
createAuthedFetch, createAuthedFetch,
deriveLoginHash,
getAuthorizedDevices, getAuthorizedDevices,
clearProfileSnapshot, clearProfileSnapshot,
getCurrentDeviceIdentifier, getCurrentDeviceIdentifier,
@@ -237,6 +238,7 @@ export default function App() {
const [disableTotpPassword, setDisableTotpPassword] = useState(''); const [disableTotpPassword, setDisableTotpPassword] = useState('');
const [disableTotpSubmitting, setDisableTotpSubmitting] = useState(false); const [disableTotpSubmitting, setDisableTotpSubmitting] = useState(false);
const [authRequestDialogDismissedId, setAuthRequestDialogDismissedId] = useState<string | null>(null); const [authRequestDialogDismissedId, setAuthRequestDialogDismissedId] = useState<string | null>(null);
const [authRequestDialogSelectedId, setAuthRequestDialogSelectedId] = useState<string | null>(null);
const [authRequestSubmittingId, setAuthRequestSubmittingId] = useState<string | null>(null); const [authRequestSubmittingId, setAuthRequestSubmittingId] = useState<string | null>(null);
const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' }); const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' });
const [themePreference, setThemePreference] = useState<ThemePreference>(() => readThemePreference()); const [themePreference, setThemePreference] = useState<ThemePreference>(() => readThemePreference());
@@ -264,6 +266,11 @@ export default function App() {
const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {}); const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {});
const refreshPendingAuthRequestsRef = useRef<() => Promise<void>>(async () => {}); const refreshPendingAuthRequestsRef = useRef<() => Promise<void>>(async () => {});
const repairAttemptRef = useRef<string>(''); const repairAttemptRef = useRef<string>('');
const loginScopedBackupRepairAuthRef = useRef<{
accessToken: string;
masterPasswordHash?: string | null;
userVerificationToken?: string | null;
} | null>(null);
const uriChecksumRepairAttemptRef = useRef<string>(''); const uriChecksumRepairAttemptRef = useRef<string>('');
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);
@@ -506,6 +513,14 @@ export default function App() {
}, [phase, session?.email, location, navigate]); }, [phase, session?.email, location, navigate]);
async function finalizeLogin(login: CompletedLogin) { async function finalizeLogin(login: CompletedLogin) {
loginScopedBackupRepairAuthRef.current =
login.session.accessToken && (login.freshMasterPasswordHash || login.freshUserVerificationToken)
? {
accessToken: login.session.accessToken,
masterPasswordHash: login.freshMasterPasswordHash || null,
userVerificationToken: login.freshUserVerificationToken || null,
}
: null;
setSession(login.session); setSession(login.session);
setProfile(login.profile); setProfile(login.profile);
setUnlockPreparing(false); setUnlockPreparing(false);
@@ -1085,6 +1100,15 @@ 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,
}); });
async function deriveCurrentMasterPasswordHash(masterPassword: string): Promise<string> {
const email = String(profile?.email || session?.email || '').trim().toLowerCase();
if (!email) throw new Error(t('txt_profile_unavailable'));
const normalizedPassword = String(masterPassword || '');
if (!normalizedPassword) throw new Error(t('txt_master_password_is_required'));
const derived = await deriveLoginHash(email, normalizedPassword, defaultKdfIterations);
return derived.hash;
}
const pendingAuthRequestsQueryKey = useMemo(() => ['auth-requests-pending', vaultCacheKey || session?.email] as const, [vaultCacheKey, session?.email]); const pendingAuthRequestsQueryKey = useMemo(() => ['auth-requests-pending', vaultCacheKey || session?.email] as const, [vaultCacheKey, session?.email]);
const pendingAuthRequestsQuery = useQuery({ const pendingAuthRequestsQuery = useQuery({
queryKey: pendingAuthRequestsQueryKey, queryKey: pendingAuthRequestsQueryKey,
@@ -1096,7 +1120,20 @@ export default function App() {
}); });
const pendingAuthRequests = (pendingAuthRequestsQuery.data || []).filter(isPendingAuthRequest); const pendingAuthRequests = (pendingAuthRequestsQuery.data || []).filter(isPendingAuthRequest);
const latestPendingAuthRequest = pendingAuthRequests[0] || null; const latestPendingAuthRequest = pendingAuthRequests[0] || null;
const authRequestDialogOpen = !!latestPendingAuthRequest && latestPendingAuthRequest.id !== authRequestDialogDismissedId; const selectedPendingAuthRequest = authRequestDialogSelectedId
? pendingAuthRequests.find((request) => request.id === authRequestDialogSelectedId) || null
: null;
const authRequestDialogRequest = selectedPendingAuthRequest || (
latestPendingAuthRequest && latestPendingAuthRequest.id !== authRequestDialogDismissedId
? latestPendingAuthRequest
: null
);
const authRequestDialogOpen = !!authRequestDialogRequest;
async function beginApproveAuthRequest(authRequest: AuthRequest): Promise<void> {
setAuthRequestDialogSelectedId(authRequest.id);
setAuthRequestDialogDismissedId(null);
}
async function approveAuthRequest(authRequest: AuthRequest): Promise<void> { async function approveAuthRequest(authRequest: AuthRequest): Promise<void> {
if (!session) throw new Error(t('txt_vault_key_unavailable')); if (!session) throw new Error(t('txt_vault_key_unavailable'));
@@ -1110,6 +1147,7 @@ export default function App() {
requestApproved: true, requestApproved: true,
}); });
setAuthRequestDialogDismissedId(null); setAuthRequestDialogDismissedId(null);
setAuthRequestDialogSelectedId(null);
pushToast('success', t('txt_auth_request_approved')); pushToast('success', t('txt_auth_request_approved'));
await pendingAuthRequestsQuery.refetch(); await pendingAuthRequestsQuery.refetch();
} finally { } finally {
@@ -1125,6 +1163,7 @@ export default function App() {
requestApproved: false, requestApproved: false,
}); });
setAuthRequestDialogDismissedId(null); setAuthRequestDialogDismissedId(null);
setAuthRequestDialogSelectedId(null);
pushToast('success', t('txt_auth_request_denied')); pushToast('success', t('txt_auth_request_denied'));
await pendingAuthRequestsQuery.refetch(); await pendingAuthRequestsQuery.refetch();
} finally { } finally {
@@ -1189,13 +1228,25 @@ export default function App() {
if (!isAdminProfile(profile)) return; if (!isAdminProfile(profile)) return;
if (repairAttemptRef.current === session.accessToken) return; if (repairAttemptRef.current === session.accessToken) return;
const loginScopedRepairAuth = loginScopedBackupRepairAuthRef.current?.accessToken === session.accessToken
? loginScopedBackupRepairAuthRef.current
: null;
repairAttemptRef.current = session.accessToken; repairAttemptRef.current = session.accessToken;
void silentlyRepairBackupSettingsIfNeeded(session, profile); void (async () => {
try {
await silentlyRepairBackupSettingsIfNeeded(session, profile, loginScopedRepairAuth);
} finally {
if (loginScopedBackupRepairAuthRef.current?.accessToken === session.accessToken) {
loginScopedBackupRepairAuthRef.current = null;
}
}
})();
}, [phase, session?.accessToken, session?.symEncKey, session?.symMacKey, profile, vaultInitialDecryptDone]); }, [phase, session?.accessToken, session?.symEncKey, session?.symMacKey, profile, vaultInitialDecryptDone]);
useEffect(() => { useEffect(() => {
if (session?.accessToken) return; if (session?.accessToken) return;
repairAttemptRef.current = ''; repairAttemptRef.current = '';
loginScopedBackupRepairAuthRef.current = null;
uriChecksumRepairAttemptRef.current = ''; uriChecksumRepairAttemptRef.current = '';
}, [session?.accessToken]); }, [session?.accessToken]);
@@ -1950,8 +2001,8 @@ export default function App() {
sendUploadPercent: vaultSendActions.sendUploadPercent, sendUploadPercent: vaultSendActions.sendUploadPercent,
onChangePassword: accountSecurityActions.changePassword, onChangePassword: accountSecurityActions.changePassword,
onSavePasswordHint: accountSecurityActions.savePasswordHint, onSavePasswordHint: accountSecurityActions.savePasswordHint,
onEnableTotp: async (secret: string, token: string) => { onEnableTotp: async (secret: string, token: string, masterPassword: string) => {
await accountSecurityActions.enableTotp(secret, token); await accountSecurityActions.enableTotp(secret, token, masterPassword);
await totpStatusQuery.refetch(); await totpStatusQuery.refetch();
}, },
onOpenDisableTotp: () => setDisableTotpOpen(true), onOpenDisableTotp: () => setDisableTotpOpen(true),
@@ -1967,7 +2018,7 @@ export default function App() {
onRefreshPendingAuthRequests: async () => { onRefreshPendingAuthRequests: async () => {
await pendingAuthRequestsQuery.refetch(); await pendingAuthRequestsQuery.refetch();
}, },
onApproveAuthRequest: approveAuthRequest, onApproveAuthRequest: beginApproveAuthRequest,
onDenyAuthRequest: denyAuthRequest, onDenyAuthRequest: denyAuthRequest,
onLockTimeoutChange: setLockTimeoutMinutes, onLockTimeoutChange: setLockTimeoutMinutes,
onSessionTimeoutActionChange: setSessionTimeoutAction, onSessionTimeoutActionChange: setSessionTimeoutAction,
@@ -1992,22 +2043,50 @@ export default function App() {
onLoadAuditLogSettings: () => getAuditLogSettings(authedFetch), onLoadAuditLogSettings: () => getAuditLogSettings(authedFetch),
onSaveAuditLogSettings: (settings: AuditLogSettings) => saveAuditLogSettings(authedFetch, settings), onSaveAuditLogSettings: (settings: AuditLogSettings) => saveAuditLogSettings(authedFetch, settings),
onClearAuditLogs: () => clearAuditLogs(authedFetch), onClearAuditLogs: () => clearAuditLogs(authedFetch),
onExportBackup: backupActions.exportBackup, onExportBackup: async (masterPassword: string, includeAttachments?: boolean) => {
onImportBackup: backupActions.importBackup, const hash = await deriveCurrentMasterPasswordHash(masterPassword);
onImportBackupAllowingChecksumMismatch: backupActions.importBackupAllowingChecksumMismatch, return backupActions.exportBackup(hash, includeAttachments);
},
onImportBackup: async (masterPassword: string, file: File, replaceExisting?: boolean) => {
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
return backupActions.importBackup(hash, file, replaceExisting);
},
onImportBackupAllowingChecksumMismatch: async (masterPassword: string, file: File, replaceExisting?: boolean) => {
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
return backupActions.importBackupAllowingChecksumMismatch(hash, file, replaceExisting);
},
onLoadBackupSettings: () => queryClient.ensureQueryData({ onLoadBackupSettings: () => queryClient.ensureQueryData({
queryKey: ['admin-backup-settings', vaultCacheKey], queryKey: ['admin-backup-settings', vaultCacheKey],
queryFn: () => backupActions.loadSettings(), queryFn: () => backupActions.loadSettings(),
staleTime: 30_000, staleTime: 30_000,
}), }),
onSaveBackupSettings: backupActions.saveSettings, onSaveBackupSettings: async (masterPassword: string, settings: AdminBackupSettings) => {
onRunRemoteBackup: backupActions.runRemoteBackup, const hash = await deriveCurrentMasterPasswordHash(masterPassword);
const saved = await backupActions.saveSettings(hash, settings);
queryClient.setQueryData(['admin-backup-settings', vaultCacheKey], saved);
return saved;
},
onRunRemoteBackup: async (masterPassword: string, destinationId?: string | null) => {
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
const result = await backupActions.runRemoteBackup(hash, destinationId);
queryClient.setQueryData(['admin-backup-settings', vaultCacheKey], result.settings);
return result;
},
onListRemoteBackups: backupActions.listRemoteBackups, onListRemoteBackups: backupActions.listRemoteBackups,
onDownloadRemoteBackup: backupActions.downloadRemoteBackup, onDownloadRemoteBackup: async (masterPassword: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) => {
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
return backupActions.downloadRemoteBackup(hash, destinationId, path, onProgress);
},
onInspectRemoteBackup: backupActions.inspectRemoteBackup, onInspectRemoteBackup: backupActions.inspectRemoteBackup,
onDeleteRemoteBackup: backupActions.deleteRemoteBackup, onDeleteRemoteBackup: backupActions.deleteRemoteBackup,
onRestoreRemoteBackup: backupActions.restoreRemoteBackup, onRestoreRemoteBackup: async (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => {
onRestoreRemoteBackupAllowingChecksumMismatch: backupActions.restoreRemoteBackupAllowingChecksumMismatch, const hash = await deriveCurrentMasterPasswordHash(masterPassword);
return backupActions.restoreRemoteBackup(hash, destinationId, path, replaceExisting);
},
onRestoreRemoteBackupAllowingChecksumMismatch: async (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => {
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
return backupActions.restoreRemoteBackupAllowingChecksumMismatch(hash, destinationId, path, replaceExisting);
},
}; };
const effectiveMainRoutesProps = IS_DEMO_MODE const effectiveMainRoutesProps = IS_DEMO_MODE
? createDemoMainRoutesProps(mainRoutesProps, pushToast, { ? createDemoMainRoutesProps(mainRoutesProps, pushToast, {
@@ -2215,21 +2294,24 @@ export default function App() {
/> />
<AuthRequestApprovalDialog <AuthRequestApprovalDialog
open={authRequestDialogOpen} open={authRequestDialogOpen}
authRequest={latestPendingAuthRequest} authRequest={authRequestDialogRequest}
submitting={!!authRequestSubmittingId} submitting={!!authRequestSubmittingId}
onApprove={() => { onApprove={() => {
if (!latestPendingAuthRequest) return; if (!authRequestDialogRequest) return;
void approveAuthRequest(latestPendingAuthRequest).catch((error) => { void approveAuthRequest(authRequestDialogRequest).catch((error) => {
pushToast('error', error instanceof Error ? error.message : t('txt_auth_request_update_failed')); pushToast('error', error instanceof Error ? error.message : t('txt_auth_request_update_failed'));
}); });
}} }}
onDeny={() => { onDeny={() => {
if (!latestPendingAuthRequest) return; if (!authRequestDialogRequest) return;
void denyAuthRequest(latestPendingAuthRequest).catch((error) => { void denyAuthRequest(authRequestDialogRequest).catch((error) => {
pushToast('error', error instanceof Error ? error.message : t('txt_auth_request_update_failed')); pushToast('error', error instanceof Error ? error.message : t('txt_auth_request_update_failed'));
}); });
}} }}
onClose={() => setAuthRequestDialogDismissedId(latestPendingAuthRequest?.id || null)} onClose={() => {
setAuthRequestDialogSelectedId(null);
setAuthRequestDialogDismissedId(authRequestDialogRequest?.id || null);
}}
/> />
</> </>
); );
+9 -14
View File
@@ -107,7 +107,7 @@ export interface AppMainRoutesProps {
sendUploadPercent: number | null; sendUploadPercent: number | null;
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>; onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
onSavePasswordHint: (masterPasswordHint: string) => Promise<void>; onSavePasswordHint: (masterPasswordHint: string) => Promise<void>;
onEnableTotp: (secret: string, token: string) => Promise<void>; onEnableTotp: (secret: string, token: string, masterPassword: string) => Promise<void>;
onOpenDisableTotp: () => void; onOpenDisableTotp: () => void;
onGetRecoveryCode: (masterPassword: string) => Promise<string>; onGetRecoveryCode: (masterPassword: string) => Promise<string>;
onGetApiKey: (masterPassword: string) => Promise<string>; onGetApiKey: (masterPassword: string) => Promise<string>;
@@ -142,18 +142,18 @@ export interface AppMainRoutesProps {
onLoadAuditLogSettings: () => Promise<AuditLogSettings>; onLoadAuditLogSettings: () => Promise<AuditLogSettings>;
onSaveAuditLogSettings: (settings: AuditLogSettings) => Promise<AuditLogSettings>; onSaveAuditLogSettings: (settings: AuditLogSettings) => Promise<AuditLogSettings>;
onClearAuditLogs: () => Promise<number>; onClearAuditLogs: () => Promise<number>;
onExportBackup: (includeAttachments?: boolean) => Promise<void>; onExportBackup: (masterPassword: string, includeAttachments?: boolean) => Promise<void>;
onImportBackup: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>; onImportBackup: (masterPassword: string, file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onImportBackupAllowingChecksumMismatch: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>; onImportBackupAllowingChecksumMismatch: (masterPassword: string, file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onLoadBackupSettings: () => Promise<AdminBackupSettings>; onLoadBackupSettings: () => Promise<AdminBackupSettings>;
onSaveBackupSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>; onSaveBackupSettings: (masterPassword: string, settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>; onRunRemoteBackup: (masterPassword: string, destinationId?: string | null) => Promise<AdminBackupRunResponse>;
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>; onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
onDownloadRemoteBackup: (destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>; onDownloadRemoteBackup: (masterPassword: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: { hasChecksumPrefix: boolean; expectedPrefix: string | null; actualPrefix: string; matches: boolean } }>; onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: { hasChecksumPrefix: boolean; expectedPrefix: string | null; actualPrefix: string; matches: boolean } }>;
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>; onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>; onRestoreRemoteBackup: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onRestoreRemoteBackupAllowingChecksumMismatch: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>; onRestoreRemoteBackupAllowingChecksumMismatch: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
} }
export default function AppMainRoutes(props: AppMainRoutesProps) { export default function AppMainRoutes(props: AppMainRoutesProps) {
@@ -275,11 +275,6 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
onCreateAccountPasskey={props.onCreateAccountPasskey} onCreateAccountPasskey={props.onCreateAccountPasskey}
onEnableAccountPasskeyDirectUnlock={props.onEnableAccountPasskeyDirectUnlock} onEnableAccountPasskeyDirectUnlock={props.onEnableAccountPasskeyDirectUnlock}
onDeleteAccountPasskey={props.onDeleteAccountPasskey} onDeleteAccountPasskey={props.onDeleteAccountPasskey}
pendingAuthRequests={props.pendingAuthRequests}
pendingAuthRequestsLoading={props.pendingAuthRequestsLoading}
onRefreshPendingAuthRequests={props.onRefreshPendingAuthRequests}
onApproveAuthRequest={props.onApproveAuthRequest}
onDenyAuthRequest={props.onDenyAuthRequest}
onLockTimeoutChange={props.onLockTimeoutChange} onLockTimeoutChange={props.onLockTimeoutChange}
onSessionTimeoutActionChange={props.onSessionTimeoutActionChange} onSessionTimeoutActionChange={props.onSessionTimeoutActionChange}
onNotify={props.onNotify} onNotify={props.onNotify}
+161 -17
View File
@@ -34,18 +34,18 @@ import { BackupOperationsSidebar } from './backup-center/BackupOperationsSidebar
interface BackupCenterPageProps { interface BackupCenterPageProps {
currentUserId: string | null; currentUserId: string | null;
onExport: (includeAttachments?: boolean) => Promise<void>; onExport: (masterPassword: string, includeAttachments?: boolean) => Promise<void>;
onImport: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>; onImport: (masterPassword: string, file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onImportAllowingChecksumMismatch: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>; onImportAllowingChecksumMismatch: (masterPassword: string, file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onLoadSettings: () => Promise<AdminBackupSettings>; onLoadSettings: () => Promise<AdminBackupSettings>;
onSaveSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>; onSaveSettings: (masterPassword: string, settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>; onRunRemoteBackup: (masterPassword: string, destinationId?: string | null) => Promise<AdminBackupRunResponse>;
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>; onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
onDownloadRemoteBackup: (destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>; onDownloadRemoteBackup: (masterPassword: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: BackupFileIntegrityCheckResult }>; onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: BackupFileIntegrityCheckResult }>;
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>; onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>; onRestoreRemoteBackup: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onRestoreRemoteBackupAllowingChecksumMismatch: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>; onRestoreRemoteBackupAllowingChecksumMismatch: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void; onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
} }
@@ -53,6 +53,14 @@ type PendingRestoreIntegrity =
| { source: 'local'; fileName: string; result: BackupFileIntegrityCheckResult } | { source: 'local'; fileName: string; result: BackupFileIntegrityCheckResult }
| { source: 'remote'; fileName: string; path: string; result: BackupFileIntegrityCheckResult }; | { source: 'remote'; fileName: string; path: string; result: BackupFileIntegrityCheckResult };
type PendingBackupVerification =
| { action: 'export' }
| { action: 'saveSettings' }
| { action: 'import'; replaceExisting: boolean; allowChecksumMismatch: boolean; knownIntegrity?: BackupFileIntegrityCheckResult }
| { action: 'runRemoteBackup' }
| { action: 'downloadRemote'; path: string }
| { action: 'restoreRemote'; path: string; replaceExisting: boolean; allowChecksumMismatch: boolean; knownIntegrity?: BackupFileIntegrityCheckResult };
interface BackupProgressPhase { interface BackupProgressPhase {
titleKey: string; titleKey: string;
detailKey: string; detailKey: string;
@@ -193,6 +201,9 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
const [confirmIntegrityWarningOpen, setConfirmIntegrityWarningOpen] = useState(false); const [confirmIntegrityWarningOpen, setConfirmIntegrityWarningOpen] = useState(false);
const [confirmDeleteDestinationOpen, setConfirmDeleteDestinationOpen] = useState(false); const [confirmDeleteDestinationOpen, setConfirmDeleteDestinationOpen] = useState(false);
const [confirmRemoteDeleteOpen, setConfirmRemoteDeleteOpen] = useState(false); const [confirmRemoteDeleteOpen, setConfirmRemoteDeleteOpen] = useState(false);
const [pendingBackupVerification, setPendingBackupVerification] = useState<PendingBackupVerification | null>(null);
const [backupPasswordValue, setBackupPasswordValue] = useState('');
const [backupPasswordSubmitting, setBackupPasswordSubmitting] = useState(false);
const [pendingRestoreIntegrity, setPendingRestoreIntegrity] = useState<PendingRestoreIntegrity | null>(null); const [pendingRestoreIntegrity, setPendingRestoreIntegrity] = useState<PendingRestoreIntegrity | null>(null);
const [pendingRemoteRestorePath, setPendingRemoteRestorePath] = useState(''); const [pendingRemoteRestorePath, setPendingRemoteRestorePath] = useState('');
const [pendingRemoteDeletePath, setPendingRemoteDeletePath] = useState(''); const [pendingRemoteDeletePath, setPendingRemoteDeletePath] = useState('');
@@ -209,7 +220,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
const selectedDestination = getDestinationById(settings, selectedDestinationId); const selectedDestination = getDestinationById(settings, selectedDestinationId);
const savedSelectedDestination = getDestinationById(savedSettings, selectedDestinationId); const savedSelectedDestination = getDestinationById(savedSettings, selectedDestinationId);
const selectedDestinationIsSaved = !!savedSelectedDestination; const selectedDestinationIsSaved = !!savedSelectedDestination;
const disableWhileBusy = exporting || importing || savingSettings || runningRemoteBackup; const disableWhileBusy = exporting || importing || savingSettings || runningRemoteBackup || backupPasswordSubmitting;
const currentRemoteBrowserPath = savedSelectedDestination ? (remoteBrowserPathByDestination[savedSelectedDestination.id] || '') : ''; const currentRemoteBrowserPath = savedSelectedDestination ? (remoteBrowserPathByDestination[savedSelectedDestination.id] || '') : '';
const currentRemoteBrowserKey = savedSelectedDestination ? getRemoteBrowserCacheKey(savedSelectedDestination.id, currentRemoteBrowserPath) : ''; const currentRemoteBrowserKey = savedSelectedDestination ? getRemoteBrowserCacheKey(savedSelectedDestination.id, currentRemoteBrowserPath) : '';
const remoteBrowser = currentRemoteBrowserKey ? remoteBrowserCache[currentRemoteBrowserKey] || null : null; const remoteBrowser = currentRemoteBrowserKey ? remoteBrowserCache[currentRemoteBrowserKey] || null : null;
@@ -226,6 +237,18 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
const recommendedS3Providers = RECOMMENDED_PROVIDERS.filter((provider) => provider.protocol === 's3'); const recommendedS3Providers = RECOMMENDED_PROVIDERS.filter((provider) => provider.protocol === 's3');
const canRunSelectedDestination = !!selectedDestination && selectedDestinationIsSaved; const canRunSelectedDestination = !!selectedDestination && selectedDestinationIsSaved;
const canBrowseSelectedDestination = !!savedSelectedDestination; const canBrowseSelectedDestination = !!savedSelectedDestination;
const backupPasswordPromptTitle =
pendingBackupVerification?.action === 'export'
? t('txt_backup_export')
: pendingBackupVerification?.action === 'saveSettings'
? t('txt_backup_save_settings')
: pendingBackupVerification?.action === 'runRemoteBackup'
? t('txt_backup_run_manual')
: pendingBackupVerification?.action === 'downloadRemote'
? t('txt_backup_remote_download')
: pendingBackupVerification?.action === 'restoreRemote'
? t('txt_backup_import')
: t('txt_backup_import');
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -507,11 +530,17 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
} }
async function handleExport() { async function handleExport() {
if (exporting) return;
setPendingBackupVerification({ action: 'export' });
setBackupPasswordValue('');
}
async function executeExport(masterPassword: string) {
setLocalError(''); setLocalError('');
setExporting(true); setExporting(true);
try { try {
startRestoreProgress('backup-export', t('txt_backup_export'), { source: 'local', includeAttachments: exportIncludeAttachments }); startRestoreProgress('backup-export', t('txt_backup_export'), { source: 'local', includeAttachments: exportIncludeAttachments });
await props.onExport(exportIncludeAttachments); await props.onExport(masterPassword, exportIncludeAttachments);
props.onNotify('success', t('txt_backup_export_success')); props.onNotify('success', t('txt_backup_export_success'));
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_export_failed'); const message = error instanceof Error ? error.message : t('txt_backup_export_failed');
@@ -527,6 +556,28 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
replaceExisting: boolean, replaceExisting: boolean,
allowChecksumMismatch: boolean = false, allowChecksumMismatch: boolean = false,
knownIntegrity?: BackupFileIntegrityCheckResult knownIntegrity?: BackupFileIntegrityCheckResult
) {
if (importing) return;
if (!selectedFile) {
const message = t('txt_backup_file_required');
setLocalError(message);
props.onNotify('error', message);
return;
}
setPendingBackupVerification({
action: 'import',
replaceExisting,
allowChecksumMismatch,
knownIntegrity,
});
setBackupPasswordValue('');
}
async function executeLocalRestore(
masterPassword: string,
replaceExisting: boolean,
allowChecksumMismatch: boolean = false,
knownIntegrity?: BackupFileIntegrityCheckResult
) { ) {
if (importing) return; if (importing) return;
if (!selectedFile) { if (!selectedFile) {
@@ -547,8 +598,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
delayMs: replaceExisting ? 480 : 1400, delayMs: replaceExisting ? 480 : 1400,
}); });
const result = allowChecksumMismatch const result = allowChecksumMismatch
? await props.onImportAllowingChecksumMismatch(selectedFile, replaceExisting) ? await props.onImportAllowingChecksumMismatch(masterPassword, selectedFile, replaceExisting)
: await props.onImport(selectedFile, replaceExisting); : await props.onImport(masterPassword, selectedFile, replaceExisting);
props.onNotify('success', `${buildIntegrityStatusMessage(integrity)} ${t('txt_backup_restore_success_relogin')}`); props.onNotify('success', `${buildIntegrityStatusMessage(integrity)} ${t('txt_backup_restore_success_relogin')}`);
const skippedMessage = buildSkippedImportMessage(result); const skippedMessage = buildSkippedImportMessage(result);
if (skippedMessage) props.onNotify('warning', skippedMessage); if (skippedMessage) props.onNotify('warning', skippedMessage);
@@ -573,12 +624,18 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
} }
async function handleSaveSettings() { async function handleSaveSettings() {
if (savingSettings) return;
setPendingBackupVerification({ action: 'saveSettings' });
setBackupPasswordValue('');
}
async function executeSaveSettings(masterPassword: string) {
const payload = buildSettingsPayloadForSelectedDestination(); const payload = buildSettingsPayloadForSelectedDestination();
const destinationIdToInvalidate = selectedDestinationId; const destinationIdToInvalidate = selectedDestinationId;
setSavingSettings(true); setSavingSettings(true);
setLocalError(''); setLocalError('');
try { try {
const saved = await props.onSaveSettings(payload); const saved = await props.onSaveSettings(masterPassword, payload);
const nextSelected = const nextSelected =
(selectedDestinationId && saved.destinations.some((destination) => destination.id === selectedDestinationId) && selectedDestinationId) (selectedDestinationId && saved.destinations.some((destination) => destination.id === selectedDestinationId) && selectedDestinationId)
|| getFirstVisibleDestinationId(saved) || getFirstVisibleDestinationId(saved)
@@ -613,6 +670,12 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
} }
async function handleRunRemoteBackup() { async function handleRunRemoteBackup() {
if (!selectedDestination || runningRemoteBackup) return;
setPendingBackupVerification({ action: 'runRemoteBackup' });
setBackupPasswordValue('');
}
async function executeRunRemoteBackup(masterPassword: string) {
if (!selectedDestination) return; if (!selectedDestination) return;
setRunningRemoteBackup(true); setRunningRemoteBackup(true);
setLocalError(''); setLocalError('');
@@ -621,7 +684,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
source: 'remote', source: 'remote',
includeAttachments: !!selectedDestination.includeAttachments, includeAttachments: !!selectedDestination.includeAttachments,
}); });
const result = await props.onRunRemoteBackup(selectedDestination.id); const result = await props.onRunRemoteBackup(masterPassword, selectedDestination.id);
setSavedSettings(result.settings); setSavedSettings(result.settings);
setSettings(result.settings); setSettings(result.settings);
setSelectedDestinationId(selectedDestination.id); setSelectedDestinationId(selectedDestination.id);
@@ -638,12 +701,17 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
} }
async function handleDownloadRemote(path: string) { async function handleDownloadRemote(path: string) {
setPendingBackupVerification({ action: 'downloadRemote', path });
setBackupPasswordValue('');
}
async function executeDownloadRemote(masterPassword: string, path: string) {
if (!savedSelectedDestination) return; if (!savedSelectedDestination) return;
setDownloadingRemotePath(path); setDownloadingRemotePath(path);
setDownloadingRemotePercent(null); setDownloadingRemotePercent(null);
setLocalError(''); setLocalError('');
try { try {
await props.onDownloadRemoteBackup(savedSelectedDestination.id, path, setDownloadingRemotePercent); await props.onDownloadRemoteBackup(masterPassword, savedSelectedDestination.id, path, setDownloadingRemotePercent);
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_remote_download_failed'); const message = error instanceof Error ? error.message : t('txt_backup_remote_download_failed');
setLocalError(message); setLocalError(message);
@@ -724,6 +792,25 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
replaceExisting: boolean, replaceExisting: boolean,
allowChecksumMismatch: boolean = false, allowChecksumMismatch: boolean = false,
knownIntegrity?: BackupFileIntegrityCheckResult knownIntegrity?: BackupFileIntegrityCheckResult
) {
if (restoringRemotePath) return;
if (!savedSelectedDestination) return;
setPendingBackupVerification({
action: 'restoreRemote',
path,
replaceExisting,
allowChecksumMismatch,
knownIntegrity,
});
setBackupPasswordValue('');
}
async function executeRemoteRestore(
masterPassword: string,
path: string,
replaceExisting: boolean,
allowChecksumMismatch: boolean = false,
knownIntegrity?: BackupFileIntegrityCheckResult
) { ) {
if (restoringRemotePath) return; if (restoringRemotePath) return;
if (!savedSelectedDestination) return; if (!savedSelectedDestination) return;
@@ -738,8 +825,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
delayMs: replaceExisting ? 480 : 1400, delayMs: replaceExisting ? 480 : 1400,
}); });
const result = allowChecksumMismatch const result = allowChecksumMismatch
? await props.onRestoreRemoteBackupAllowingChecksumMismatch(savedSelectedDestination.id, path, replaceExisting) ? await props.onRestoreRemoteBackupAllowingChecksumMismatch(masterPassword, savedSelectedDestination.id, path, replaceExisting)
: await props.onRestoreRemoteBackup(savedSelectedDestination.id, path, replaceExisting); : await props.onRestoreRemoteBackup(masterPassword, savedSelectedDestination.id, path, replaceExisting);
setConfirmRemoteReplaceOpen(false); setConfirmRemoteReplaceOpen(false);
setPendingRemoteRestorePath(''); setPendingRemoteRestorePath('');
props.onNotify('success', `${buildIntegrityStatusMessage(integrity.result, { remote: true })} ${t('txt_backup_restore_success_relogin')}`); props.onNotify('success', `${buildIntegrityStatusMessage(integrity.result, { remote: true })} ${t('txt_backup_restore_success_relogin')}`);
@@ -762,6 +849,36 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
} }
} }
async function submitBackupPasswordPrompt(): Promise<void> {
const request = pendingBackupVerification;
const masterPassword = backupPasswordValue;
if (!request || backupPasswordSubmitting) return;
if (!masterPassword.trim()) {
props.onNotify('error', t('txt_master_password_is_required'));
return;
}
setBackupPasswordSubmitting(true);
setPendingBackupVerification(null);
setBackupPasswordValue('');
try {
if (request.action === 'export') {
await executeExport(masterPassword);
} else if (request.action === 'saveSettings') {
await executeSaveSettings(masterPassword);
} else if (request.action === 'import') {
await executeLocalRestore(masterPassword, request.replaceExisting, request.allowChecksumMismatch, request.knownIntegrity);
} else if (request.action === 'runRemoteBackup') {
await executeRunRemoteBackup(masterPassword);
} else if (request.action === 'downloadRemote') {
await executeDownloadRemote(masterPassword, request.path);
} else if (request.action === 'restoreRemote') {
await executeRemoteRestore(masterPassword, request.path, request.replaceExisting, request.allowChecksumMismatch, request.knownIntegrity);
}
} finally {
setBackupPasswordSubmitting(false);
}
}
return ( return (
<div className="backup-grid"> <div className="backup-grid">
<input <input
@@ -893,6 +1010,33 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
</div> </div>
), document.body) : null} ), document.body) : null}
<ConfirmDialog
open={pendingBackupVerification !== null}
title={backupPasswordPromptTitle}
message={t('txt_enter_master_password_to_continue')}
confirmText={t('txt_continue')}
cancelText={t('txt_cancel')}
confirmDisabled={backupPasswordSubmitting || !backupPasswordValue.trim()}
cancelDisabled={backupPasswordSubmitting}
onConfirm={() => void submitBackupPasswordPrompt()}
onCancel={() => {
if (backupPasswordSubmitting) return;
setPendingBackupVerification(null);
setBackupPasswordValue('');
}}
>
<label className="field">
<span>{t('txt_master_password')}</span>
<input
className="input"
type="password"
autoComplete="current-password"
value={backupPasswordValue}
onInput={(event) => setBackupPasswordValue((event.currentTarget as HTMLInputElement).value)}
/>
</label>
</ConfirmDialog>
<ConfirmDialog <ConfirmDialog
open={confirmLocalRestoreOpen} open={confirmLocalRestoreOpen}
title={t('txt_backup_import')} title={t('txt_backup_import')}
+31 -35
View File
@@ -89,42 +89,38 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
/> />
<section className="card"> <section className="card">
<div className="section-head"> <div className="section-head">
<div> <div>
<h3 className="flush-title">{t('txt_device_management')}</h3> <h3 className="flush-title">{t('txt_authorized_devices')}</h3>
<div className="muted-inline section-note"> <div className="muted-inline section-note">
{t('txt_manage_device_sessions_and_30_day_totp_trusted_sessions')} {t('txt_manage_device_sessions_and_30_day_totp_trusted_sessions')}
</div>
</div>
<div className="actions">
<button type="button" className="btn btn-secondary small" disabled={props.loading} onClick={props.onRefresh}>
<RefreshCw size={14} className="btn-icon" />
{t('txt_refresh')}
</button>
<button type="button" className="btn btn-danger small" onClick={props.onRevokeAll}>
<ShieldOff size={14} className="btn-icon" />
{t('txt_revoke_all_trusted')}
</button>
<button type="button" className="btn btn-danger small" onClick={props.onRemoveAll}>
<Trash2 size={14} className="btn-icon" />
{t('txt_remove_all_devices')}
</button>
</div> </div>
</div> </div>
<div className="actions"> {!!props.error && (
<button type="button" className="btn btn-secondary small" disabled={props.loading} onClick={props.onRefresh}> <div className="local-error">
<RefreshCw size={14} className="btn-icon" /> <span>{props.error}</span>
{t('txt_refresh')} <button type="button" className="btn btn-secondary small" disabled={props.loading} onClick={props.onRefresh}>
</button> <RefreshCw size={14} className="btn-icon" />
<button type="button" className="btn btn-danger small" onClick={props.onRevokeAll}> {t('txt_refresh')}
<ShieldOff size={14} className="btn-icon" /> </button>
{t('txt_revoke_all_trusted')} </div>
</button> )}
<button type="button" className="btn btn-danger small" onClick={props.onRemoveAll}> <table className="table authorized-devices-table">
<Trash2 size={14} className="btn-icon" />
{t('txt_remove_all_devices')}
</button>
</div>
</div>
</section>
<section className="card">
<h3 className="section-title-flush">{t('txt_authorized_devices')}</h3>
{!!props.error && (
<div className="local-error">
<span>{props.error}</span>
<button type="button" className="btn btn-secondary small" disabled={props.loading} onClick={props.onRefresh}>
<RefreshCw size={14} className="btn-icon" />
{t('txt_refresh')}
</button>
</div>
)}
<table className="table authorized-devices-table">
<colgroup> <colgroup>
<col className="authorized-devices-col-device" /> <col className="authorized-devices-col-device" />
<col className="authorized-devices-col-type" /> <col className="authorized-devices-col-type" />
@@ -233,7 +229,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
</tr> </tr>
)} )}
</tbody> </tbody>
</table> </table>
</section> </section>
</div> </div>
+15 -24
View File
@@ -2,10 +2,9 @@ import { useEffect, useMemo, useState } from 'preact/hooks';
import { Clipboard, KeyRound, RefreshCw, ShieldCheck, ShieldOff, Trash2 } from 'lucide-preact'; import { Clipboard, KeyRound, RefreshCw, ShieldCheck, ShieldOff, Trash2 } from 'lucide-preact';
import { copyTextToClipboard } from '@/lib/clipboard'; import { copyTextToClipboard } from '@/lib/clipboard';
import qrcode from 'qrcode-generator'; import qrcode from 'qrcode-generator';
import type { AccountPasskeyCredential, AuthRequest, Profile } from '@/lib/types'; import type { AccountPasskeyCredential, Profile } from '@/lib/types';
import { AVAILABLE_LOCALES, getLocale, setLocale, t, type Locale } from '@/lib/i18n'; import { AVAILABLE_LOCALES, getLocale, setLocale, t, type Locale } from '@/lib/i18n';
import ConfirmDialog from '@/components/ConfirmDialog'; import ConfirmDialog from '@/components/ConfirmDialog';
import PendingAuthRequestsPanel from '@/components/PendingAuthRequestsPanel';
interface SettingsPageProps { interface SettingsPageProps {
profile: Profile; profile: Profile;
@@ -14,7 +13,7 @@ interface SettingsPageProps {
sessionTimeoutAction: 'lock' | 'logout'; sessionTimeoutAction: 'lock' | 'logout';
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>; onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
onSavePasswordHint: (masterPasswordHint: string) => Promise<void>; onSavePasswordHint: (masterPasswordHint: string) => Promise<void>;
onEnableTotp: (secret: string, token: string) => Promise<void>; onEnableTotp: (secret: string, token: string, masterPassword: string) => Promise<void>;
onOpenDisableTotp: () => void; onOpenDisableTotp: () => void;
onGetRecoveryCode: (masterPassword: string) => Promise<string>; onGetRecoveryCode: (masterPassword: string) => Promise<string>;
onGetApiKey: (masterPassword: string) => Promise<string>; onGetApiKey: (masterPassword: string) => Promise<string>;
@@ -23,17 +22,13 @@ interface SettingsPageProps {
onCreateAccountPasskey: (name: string, masterPassword: string, directUnlock: boolean) => Promise<AccountPasskeyCredential | null>; onCreateAccountPasskey: (name: string, masterPassword: string, directUnlock: boolean) => Promise<AccountPasskeyCredential | null>;
onEnableAccountPasskeyDirectUnlock: (id: string, masterPassword: string) => Promise<void>; onEnableAccountPasskeyDirectUnlock: (id: string, masterPassword: string) => Promise<void>;
onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise<void>; onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise<void>;
pendingAuthRequests: AuthRequest[];
pendingAuthRequestsLoading: boolean;
onRefreshPendingAuthRequests: () => Promise<void>;
onApproveAuthRequest: (request: AuthRequest) => Promise<void>;
onDenyAuthRequest: (request: AuthRequest) => Promise<void>;
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;
onNotify?: (type: 'success' | 'error' | 'warning', text: string) => void; onNotify?: (type: 'success' | 'error' | 'warning', text: string) => void;
} }
type MasterPasswordPromptAction = type MasterPasswordPromptAction =
| 'enableTotp'
| 'recovery' | 'recovery'
| 'apiKey' | 'apiKey'
| 'rotateApiKey' | 'rotateApiKey'
@@ -141,12 +136,12 @@ export default function SettingsPage(props: SettingsPageProps) {
}, [props.profile.email, secret]); }, [props.profile.email, secret]);
async function enableTotp(): Promise<void> { async function enableTotp(): Promise<void> {
try { if (totpLocked) return;
await props.onEnableTotp(secret, token); if (!secret.trim() || !token.trim()) {
setTotpLocked(true); props.onNotify?.('error', t('txt_secret_and_code_are_required'));
} catch { return;
// Keep inputs editable after a failed attempt.
} }
openMasterPasswordPrompt('enableTotp');
} }
async function refreshAccountPasskeys(): Promise<void> { async function refreshAccountPasskeys(): Promise<void> {
@@ -178,7 +173,10 @@ export default function SettingsPage(props: SettingsPageProps) {
const masterPassword = masterPasswordPromptValue; const masterPassword = masterPasswordPromptValue;
setMasterPasswordPromptSubmitting(true); setMasterPasswordPromptSubmitting(true);
try { try {
if (masterPasswordPrompt === 'recovery') { if (masterPasswordPrompt === 'enableTotp') {
await props.onEnableTotp(secret, token, masterPassword);
setTotpLocked(true);
} else if (masterPasswordPrompt === 'recovery') {
const code = await props.onGetRecoveryCode(masterPassword); const code = await props.onGetRecoveryCode(masterPassword);
setRecoveryCode(code); setRecoveryCode(code);
props.onNotify?.('success', t('txt_recovery_code_loaded')); props.onNotify?.('success', t('txt_recovery_code_loaded'));
@@ -214,7 +212,9 @@ export default function SettingsPage(props: SettingsPageProps) {
} }
const masterPasswordPromptTitle = const masterPasswordPromptTitle =
masterPasswordPrompt === 'recovery' masterPasswordPrompt === 'enableTotp'
? t('txt_enable_totp')
: masterPasswordPrompt === 'recovery'
? t('txt_view_recovery_code') ? t('txt_view_recovery_code')
: masterPasswordPrompt === 'rotateApiKey' : masterPasswordPrompt === 'rotateApiKey'
? t('txt_rotate_api_key') ? t('txt_rotate_api_key')
@@ -509,15 +509,6 @@ export default function SettingsPage(props: SettingsPageProps) {
)} )}
</div> </div>
</section> </section>
<PendingAuthRequestsPanel
pendingAuthRequests={props.pendingAuthRequests}
pendingAuthRequestsLoading={props.pendingAuthRequestsLoading}
onRefreshPendingAuthRequests={props.onRefreshPendingAuthRequests}
onApproveAuthRequest={props.onApproveAuthRequest}
onDenyAuthRequest={props.onDenyAuthRequest}
/>
<section className="settings-module sensitive-actions-module"> <section className="settings-module sensitive-actions-module">
<div className="sensitive-actions-grid"> <div className="sensitive-actions-grid">
<div className="sensitive-action"> <div className="sensitive-action">
@@ -54,21 +54,18 @@ function renderRecommendedProviderDetails(provider: RecommendedProvider) {
<> <>
<div className="backup-recommendation-steps"> <div className="backup-recommendation-steps">
<div className="backup-recommendation-step"> <div className="backup-recommendation-step">
<strong>1.</strong> {t('txt_backup_recommend_koofr_step_1')} <strong>1.</strong> {t('txt_backup_recommend_koofr_step_2_prefix')}{' '}
</div>
<div className="backup-recommendation-step">
<strong>2.</strong> {t('txt_backup_recommend_koofr_step_2_prefix')}{' '}
<a href={provider.passwordUrl} target="_blank" rel="noreferrer">{t('txt_backup_recommend_koofr_password_link')}</a> <a href={provider.passwordUrl} target="_blank" rel="noreferrer">{t('txt_backup_recommend_koofr_password_link')}</a>
{t('txt_backup_recommend_koofr_step_2_suffix')} {t('txt_backup_recommend_koofr_step_2_suffix')}
</div> </div>
<div className="backup-recommendation-step"> <div className="backup-recommendation-step">
<strong>3.</strong> {t('txt_backup_recommend_koofr_step_3')} <strong>2.</strong> {t('txt_backup_recommend_koofr_step_3')}
</div> </div>
<div className="backup-recommendation-step"> <div className="backup-recommendation-step">
<strong>4.</strong> {t('txt_backup_recommend_koofr_step_4')} <strong>3.</strong> {t('txt_backup_recommend_koofr_step_4')}
</div> </div>
<div className="backup-recommendation-step"> <div className="backup-recommendation-step">
<strong>5.</strong> {t('txt_backup_recommend_koofr_step_5_prefix')}{' '} <strong>4.</strong> {t('txt_backup_recommend_koofr_step_5_prefix')}{' '}
<a href={provider.storageUrl} target="_blank" rel="noreferrer">{t('txt_backup_recommend_koofr_storage_link')}</a> <a href={provider.storageUrl} target="_blank" rel="noreferrer">{t('txt_backup_recommend_koofr_storage_link')}</a>
{t('txt_backup_recommend_koofr_step_5_suffix')} {t('txt_backup_recommend_koofr_step_5_suffix')}
</div> </div>
@@ -98,13 +95,10 @@ function renderRecommendedProviderDetails(provider: RecommendedProvider) {
return ( return (
<div className="backup-recommendation-steps"> <div className="backup-recommendation-steps">
<div className="backup-recommendation-step"> <div className="backup-recommendation-step">
<strong>1.</strong> {t('txt_backup_recommend_pcloud_step_1')} <strong>1.</strong> {t('txt_backup_recommend_pcloud_step_2')}
</div> </div>
<div className="backup-recommendation-step"> <div className="backup-recommendation-step">
<strong>2.</strong> {t('txt_backup_recommend_pcloud_step_2')} <strong>2.</strong> {t('txt_backup_recommend_pcloud_step_3')}
</div>
<div className="backup-recommendation-step">
<strong>3.</strong> {t('txt_backup_recommend_pcloud_step_3')}
</div> </div>
</div> </div>
); );
@@ -112,18 +106,87 @@ function renderRecommendedProviderDetails(provider: RecommendedProvider) {
return ( return (
<div className="backup-recommendation-steps"> <div className="backup-recommendation-steps">
<div className="backup-recommendation-step"> <div className="backup-recommendation-step">
<strong>1.</strong> {t('txt_backup_recommend_infinicloud_step_1')} <strong>1.</strong> {t('txt_backup_recommend_infinicloud_step_2_prefix')}{' '}
</div>
<div className="backup-recommendation-step">
<strong>2.</strong> {t('txt_backup_recommend_infinicloud_step_2_prefix')}{' '}
<a href="https://infini-cloud.net/en/modules/mypage/usage/" target="_blank" rel="noreferrer">My Page</a> <a href="https://infini-cloud.net/en/modules/mypage/usage/" target="_blank" rel="noreferrer">My Page</a>
{t('txt_backup_recommend_infinicloud_step_2_suffix')} {t('txt_backup_recommend_infinicloud_step_2_suffix')}
</div> </div>
<div className="backup-recommendation-step"> <div className="backup-recommendation-step">
<strong>3.</strong> {t('txt_backup_recommend_infinicloud_step_3')} <strong>2.</strong> {t('txt_backup_recommend_infinicloud_step_3')}
</div> </div>
<div className="backup-recommendation-step"> <div className="backup-recommendation-step">
<strong>4.</strong> {t('txt_backup_recommend_infinicloud_step_4')} <strong>3.</strong> {t('txt_backup_recommend_infinicloud_step_4')}
</div>
</div>
);
case 'backblaze-b2':
return (
<div className="backup-recommendation-steps">
<div className="backup-recommendation-step">
<strong>1.</strong> {t('txt_backup_recommend_backblaze_step_2_prefix')}{' '}
<a href={provider.bucketsUrl} target="_blank" rel="noreferrer">Buckets</a>
{t('txt_backup_recommend_backblaze_step_2_suffix')}
</div>
<div className="backup-recommendation-step">
<strong>2.</strong> {t('txt_backup_recommend_backblaze_step_3')}
</div>
<div className="backup-recommendation-step">
<strong>3.</strong> {t('txt_backup_recommend_backblaze_step_4_prefix')}{' '}
<a href={provider.applicationKeysUrl} target="_blank" rel="noreferrer">Application Keys</a>
{t('txt_backup_recommend_backblaze_step_4_suffix')}
</div>
<div className="backup-recommendation-step">
<strong>4.</strong> {t('txt_backup_recommend_backblaze_step_5')}
</div>
<div className="backup-recommendation-step">
<strong>5.</strong> {t('txt_backup_recommend_s3_path_prefix_step')}
</div>
</div>
);
case 'cloudflare-r2':
return (
<div className="backup-recommendation-steps">
<div className="backup-recommendation-step">
<strong>1.</strong> {t('txt_backup_recommend_cloudflare_r2_step_1_prefix')}{' '}
<a href={provider.bucketUrl} target="_blank" rel="noreferrer">{t('txt_backup_recommend_cloudflare_r2_bucket_link')}</a>
{t('txt_backup_recommend_cloudflare_r2_step_1_suffix')}
</div>
<div className="backup-recommendation-step">
<strong>2.</strong> {t('txt_backup_recommend_cloudflare_r2_step_2_prefix')}{' '}
<a href={provider.apiTokenUrl} target="_blank" rel="noreferrer">{t('txt_backup_recommend_cloudflare_r2_api_link')}</a>
{t('txt_backup_recommend_cloudflare_r2_step_2_suffix')}
</div>
<div className="backup-recommendation-step">
<strong>3.</strong> {t('txt_backup_recommend_cloudflare_r2_step_3')}
</div>
<div className="backup-recommendation-step">
<strong>4.</strong> {t('txt_backup_recommend_cloudflare_r2_step_4')}
</div>
<div className="backup-recommendation-step">
<strong>5.</strong> {t('txt_backup_recommend_cloudflare_r2_step_5')}
</div>
</div>
);
case 'tigris':
return (
<div className="backup-recommendation-steps">
<div className="backup-recommendation-step">
<strong>1.</strong> {t('txt_backup_recommend_tigris_step_2_prefix')}{' '}
<a href={provider.bucketUrl} target="_blank" rel="noreferrer">Create Bucket</a>
{t('txt_backup_recommend_tigris_step_2_suffix')}
</div>
<div className="backup-recommendation-step">
<strong>2.</strong> {t('txt_backup_recommend_tigris_step_3_prefix')}{' '}
<a href={provider.accessKeyUrl} target="_blank" rel="noreferrer">{t('txt_backup_recommend_tigris_access_key_link')}</a>
{t('txt_backup_recommend_tigris_step_3_suffix')}
</div>
<div className="backup-recommendation-step">
<strong>3.</strong> {t('txt_backup_recommend_tigris_step_4')}
</div>
<div className="backup-recommendation-step">
<strong>4.</strong> {t('txt_backup_recommend_tigris_step_5')}
</div>
<div className="backup-recommendation-step">
<strong>5.</strong> {t('txt_backup_recommend_s3_path_prefix_step')}
</div> </div>
</div> </div>
); );
@@ -147,6 +210,9 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
<div className="backup-inline-note"> <div className="backup-inline-note">
{props.selectedRecommendedProvider.id === 'infinicloud' ? t('txt_backup_recommend_infinicloud_summary') {props.selectedRecommendedProvider.id === 'infinicloud' ? t('txt_backup_recommend_infinicloud_summary')
: props.selectedRecommendedProvider.id === 'koofr' ? t('txt_backup_recommend_koofr_summary') : props.selectedRecommendedProvider.id === 'koofr' ? t('txt_backup_recommend_koofr_summary')
: props.selectedRecommendedProvider.id === 'backblaze-b2' ? t('txt_backup_recommend_backblaze_summary')
: props.selectedRecommendedProvider.id === 'cloudflare-r2' ? t('txt_backup_recommend_cloudflare_r2_summary')
: props.selectedRecommendedProvider.id === 'tigris' ? t('txt_backup_recommend_tigris_summary')
: t('txt_backup_recommend_pcloud_summary')} : t('txt_backup_recommend_pcloud_summary')}
</div> </div>
</div> </div>
@@ -387,7 +453,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
className="input" className="input"
value={(props.selectedDestination.destination as WebDavBackupDestination).remotePath} value={(props.selectedDestination.destination as WebDavBackupDestination).remotePath}
disabled={props.loadingSettings || props.disableWhileBusy} disabled={props.loadingSettings || props.disableWhileBusy}
placeholder="nodewarden/backups" placeholder="nodewarden"
onInput={(event) => props.onUpdateDestination((destination) => ({ onInput={(event) => props.onUpdateDestination((destination) => ({
...destination, ...destination,
destination: { destination: {
@@ -504,7 +570,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
className="input" className="input"
value={(props.selectedDestination.destination as S3BackupDestination).rootPath} value={(props.selectedDestination.destination as S3BackupDestination).rootPath}
disabled={props.loadingSettings || props.disableWhileBusy} disabled={props.loadingSettings || props.disableWhileBusy}
placeholder="nodewarden/backups" placeholder=""
onInput={(event) => props.onUpdateDestination((destination) => ({ onInput={(event) => props.onUpdateDestination((destination) => ({
...destination, ...destination,
destination: { destination: {
@@ -1,9 +1,12 @@
import { Download, FileUp } from 'lucide-preact'; import { Download, FileUp } from 'lucide-preact';
import { useEffect, useState } from 'preact/hooks';
import type { RecommendedProvider } from '@/lib/backup-recommendations'; import type { RecommendedProvider } from '@/lib/backup-recommendations';
import { hasLinkedStorages } from '@/lib/backup-recommendations'; import { hasLinkedStorages } from '@/lib/backup-recommendations';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import { BackupIncludeAttachmentsField } from './BackupIncludeAttachmentsField'; import { BackupIncludeAttachmentsField } from './BackupIncludeAttachmentsField';
const MOBILE_RECOMMENDATIONS_QUERY = '(max-width: 760px)';
interface BackupOperationsSidebarProps { interface BackupOperationsSidebarProps {
disableWhileBusy: boolean; disableWhileBusy: boolean;
exporting: boolean; exporting: boolean;
@@ -18,7 +21,30 @@ interface BackupOperationsSidebarProps {
onSelectProvider: (providerId: string) => void; onSelectProvider: (providerId: string) => void;
} }
function getDefaultRecommendationsOpen() {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return true;
}
return !window.matchMedia(MOBILE_RECOMMENDATIONS_QUERY).matches;
}
export function BackupOperationsSidebar(props: BackupOperationsSidebarProps) { export function BackupOperationsSidebar(props: BackupOperationsSidebarProps) {
const [recommendationsOpen, setRecommendationsOpen] = useState(getDefaultRecommendationsOpen);
const [recommendationsTouched, setRecommendationsTouched] = useState(false);
useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function' || recommendationsTouched) {
return;
}
const media = window.matchMedia(MOBILE_RECOMMENDATIONS_QUERY);
const syncOpenState = () => setRecommendationsOpen(!media.matches);
syncOpenState();
media.addEventListener('change', syncOpenState);
return () => media.removeEventListener('change', syncOpenState);
}, [recommendationsTouched]);
return ( return (
<aside className="backup-operations-sidebar"> <aside className="backup-operations-sidebar">
<div className="section-head"> <div className="section-head">
@@ -41,7 +67,14 @@ export function BackupOperationsSidebar(props: BackupOperationsSidebarProps) {
</button> </button>
</div> </div>
<details className="backup-recommendations-disclosure"> <details
className="backup-recommendations-disclosure"
open={recommendationsOpen}
onToggle={(event) => {
setRecommendationsTouched(true);
setRecommendationsOpen((event.currentTarget as HTMLDetailsElement).open);
}}
>
<summary className="backup-recommendations-summary"> <summary className="backup-recommendations-summary">
<span> <span>
<strong>{t('txt_backup_recommend_title')}</strong> <strong>{t('txt_backup_recommend_title')}</strong>
+18 -2
View File
@@ -145,14 +145,30 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
} }
}, },
async enableTotp(secret: string, token: string) { async enableTotp(secret: string, token: string, masterPassword: string) {
if (!profile) {
const error = new Error(t('txt_profile_unavailable'));
onNotify('error', error.message);
throw error;
}
if (!secret.trim() || !token.trim()) { if (!secret.trim() || !token.trim()) {
const error = new Error(t('txt_secret_and_code_are_required')); const error = new Error(t('txt_secret_and_code_are_required'));
onNotify('error', error.message); onNotify('error', error.message);
throw error; throw error;
} }
if (!masterPassword) {
const error = new Error(t('txt_master_password_is_required'));
onNotify('error', error.message);
throw error;
}
try { try {
await setTotp(authedFetch, { enabled: true, secret: secret.trim(), token: token.trim() }); const derived = await deriveLoginHash(profile.email, masterPassword, defaultKdfIterations);
await setTotp(authedFetch, {
enabled: true,
secret: secret.trim(),
token: token.trim(),
masterPasswordHash: derived.hash,
});
onNotify('success', t('txt_totp_enabled')); onNotify('success', t('txt_totp_enabled'));
} catch (error) { } catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_enable_totp_failed')); onNotify('error', error instanceof Error ? error.message : t('txt_enable_totp_failed'));
+16 -15
View File
@@ -27,9 +27,10 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
return useMemo( return useMemo(
() => ({ () => ({
async exportBackup(includeAttachments: boolean = false) { async exportBackup(masterPasswordHash: string, includeAttachments: boolean = false) {
const payload = await buildCompleteAdminBackupExport( const payload = await buildCompleteAdminBackupExport(
authedFetch, authedFetch,
masterPasswordHash,
includeAttachments, includeAttachments,
async (event: BackupExportClientProgressEvent) => { async (event: BackupExportClientProgressEvent) => {
dispatchBackupProgress(event); dispatchBackupProgress(event);
@@ -48,14 +49,14 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
}); });
}, },
async importBackup(file: File, replaceExisting: boolean = false) { async importBackup(masterPasswordHash: string, file: File, replaceExisting: boolean = false) {
const result = await importAdminBackup(authedFetch, file, replaceExisting); const result = await importAdminBackup(authedFetch, masterPasswordHash, file, replaceExisting);
onImported?.(); onImported?.();
return result; return result;
}, },
async importBackupAllowingChecksumMismatch(file: File, replaceExisting: boolean = false) { async importBackupAllowingChecksumMismatch(masterPasswordHash: string, file: File, replaceExisting: boolean = false) {
const result = await importAdminBackup(authedFetch, file, replaceExisting, true); const result = await importAdminBackup(authedFetch, masterPasswordHash, file, replaceExisting, true);
onImported?.(); onImported?.();
return result; return result;
}, },
@@ -64,20 +65,20 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
return getAdminBackupSettings(authedFetch); return getAdminBackupSettings(authedFetch);
}, },
async saveSettings(settings: Parameters<typeof saveAdminBackupSettings>[1]) { async saveSettings(masterPasswordHash: string, settings: Parameters<typeof saveAdminBackupSettings>[2]) {
return saveAdminBackupSettings(authedFetch, settings); return saveAdminBackupSettings(authedFetch, masterPasswordHash, settings);
}, },
async runRemoteBackup(destinationId?: string | null) { async runRemoteBackup(masterPasswordHash: string, destinationId?: string | null) {
return runAdminBackupNow(authedFetch, destinationId); return runAdminBackupNow(authedFetch, masterPasswordHash, destinationId);
}, },
async listRemoteBackups(destinationId: string, path: string) { async listRemoteBackups(destinationId: string, path: string) {
return listRemoteBackups(authedFetch, destinationId, path); return listRemoteBackups(authedFetch, destinationId, path);
}, },
async downloadRemoteBackup(destinationId: string, path: string, onProgress?: (percent: number | null) => void) { async downloadRemoteBackup(masterPasswordHash: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) {
const payload = await fetchRemoteBackupPayload(authedFetch, destinationId, path, onProgress); const payload = await fetchRemoteBackupPayload(authedFetch, masterPasswordHash, destinationId, path, onProgress);
downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType); downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType);
}, },
@@ -89,14 +90,14 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
await deleteRemoteBackup(authedFetch, destinationId, path); await deleteRemoteBackup(authedFetch, destinationId, path);
}, },
async restoreRemoteBackup(destinationId: string, path: string, replaceExisting: boolean = false) { async restoreRemoteBackup(masterPasswordHash: string, destinationId: string, path: string, replaceExisting: boolean = false) {
const result = await restoreRemoteBackupRequest(authedFetch, destinationId, path, replaceExisting); const result = await restoreRemoteBackupRequest(authedFetch, masterPasswordHash, destinationId, path, replaceExisting);
onRestored?.(); onRestored?.();
return result; return result;
}, },
async restoreRemoteBackupAllowingChecksumMismatch(destinationId: string, path: string, replaceExisting: boolean = false) { async restoreRemoteBackupAllowingChecksumMismatch(masterPasswordHash: string, destinationId: string, path: string, replaceExisting: boolean = false) {
const result = await restoreRemoteBackupRequest(authedFetch, destinationId, path, replaceExisting, true); const result = await restoreRemoteBackupRequest(authedFetch, masterPasswordHash, destinationId, path, replaceExisting, true);
onRestored?.(); onRestored?.();
return result; return result;
}, },
+25 -10
View File
@@ -49,6 +49,11 @@ export interface BackupSettingsRepairStateResponse {
portable: BackupSettingsPortablePayload | null; portable: BackupSettingsPortablePayload | null;
} }
export interface BackupUserVerificationPayload {
masterPasswordHash?: string | null;
userVerificationToken?: string | null;
}
export interface AdminBackupRunResponse { export interface AdminBackupRunResponse {
object: 'backup-run'; object: 'backup-run';
result: { result: {
@@ -173,12 +178,13 @@ async function applyBackupFileIntegrityName(fileName: string, bytes: Uint8Array)
export async function exportAdminBackup( export async function exportAdminBackup(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
masterPasswordHash: string,
includeAttachments: boolean = false includeAttachments: boolean = false
): Promise<AdminBackupExportPayload> { ): Promise<AdminBackupExportPayload> {
const resp = await authedFetch('/api/admin/backup/export', { const resp = await authedFetch('/api/admin/backup/export', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ includeAttachments }), body: JSON.stringify({ includeAttachments, masterPasswordHash }),
}); });
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_export_failed'))); if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_export_failed')));
@@ -201,10 +207,11 @@ export async function downloadAdminBackupAttachmentBlob(
export async function buildCompleteAdminBackupExport( export async function buildCompleteAdminBackupExport(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
masterPasswordHash: string,
includeAttachments: boolean = false, includeAttachments: boolean = false,
onProgress?: (event: BackupExportClientProgressEvent) => void | Promise<void> onProgress?: (event: BackupExportClientProgressEvent) => void | Promise<void>
): Promise<AdminBackupExportPayload> { ): Promise<AdminBackupExportPayload> {
const payload = await exportAdminBackup(authedFetch, includeAttachments); const payload = await exportAdminBackup(authedFetch, masterPasswordHash, includeAttachments);
if (!includeAttachments) { if (!includeAttachments) {
await onProgress?.({ await onProgress?.({
operation: 'backup-export', operation: 'backup-export',
@@ -278,12 +285,13 @@ export async function getAdminBackupSettings(authedFetch: AuthedFetch): Promise<
export async function saveAdminBackupSettings( export async function saveAdminBackupSettings(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
masterPasswordHash: string,
settings: AdminBackupSettings settings: AdminBackupSettings
): Promise<AdminBackupSettings> { ): Promise<AdminBackupSettings> {
const resp = await authedFetch('/api/admin/backup/settings', { const resp = await authedFetch('/api/admin/backup/settings', {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings), body: JSON.stringify({ ...settings, masterPasswordHash }),
}); });
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_settings_save_failed'))); if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_settings_save_failed')));
const body = await parseJson<AdminBackupSettings>(resp); const body = await parseJson<AdminBackupSettings>(resp);
@@ -305,12 +313,13 @@ export async function getAdminBackupSettingsRepairState(
export async function repairAdminBackupSettings( export async function repairAdminBackupSettings(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
verification: BackupUserVerificationPayload,
settings: AdminBackupSettings settings: AdminBackupSettings
): Promise<AdminBackupSettings> { ): Promise<AdminBackupSettings> {
const resp = await authedFetch('/api/admin/backup/settings/repair', { const resp = await authedFetch('/api/admin/backup/settings/repair', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings), body: JSON.stringify({ ...settings, ...verification }),
}); });
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_settings_save_failed'))); if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_settings_save_failed')));
const body = await parseJson<AdminBackupSettings>(resp); const body = await parseJson<AdminBackupSettings>(resp);
@@ -320,12 +329,13 @@ export async function repairAdminBackupSettings(
export async function runAdminBackupNow( export async function runAdminBackupNow(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
masterPasswordHash: string,
destinationId?: string | null destinationId?: string | null
): Promise<AdminBackupRunResponse> { ): Promise<AdminBackupRunResponse> {
const resp = await authedFetch('/api/admin/backup/run', { const resp = await authedFetch('/api/admin/backup/run', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(destinationId ? { destinationId } : {}), body: JSON.stringify(destinationId ? { destinationId, masterPasswordHash } : { masterPasswordHash }),
}); });
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_run_failed'))); if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_run_failed')));
const body = await parseJson<AdminBackupRunResponse>(resp); const body = await parseJson<AdminBackupRunResponse>(resp);
@@ -351,14 +361,16 @@ export async function listRemoteBackups(
export async function downloadRemoteBackup( export async function downloadRemoteBackup(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
masterPasswordHash: string,
destinationId: string, destinationId: string,
path: string, path: string,
onProgress?: (percent: number | null) => void onProgress?: (percent: number | null) => void
): Promise<AdminBackupExportPayload> { ): Promise<AdminBackupExportPayload> {
const params = new URLSearchParams(); const resp = await authedFetch('/api/admin/backup/remote/download', {
params.set('destinationId', destinationId); method: 'POST',
params.set('path', path); headers: { 'Content-Type': 'application/json' },
const resp = await authedFetch(`/api/admin/backup/remote/download?${params.toString()}`, { method: 'GET' }); body: JSON.stringify({ destinationId, path, masterPasswordHash }),
});
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_download_failed'))); if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_download_failed')));
const mimeType = String(resp.headers.get('Content-Type') || 'application/zip').trim() || 'application/zip'; const mimeType = String(resp.headers.get('Content-Type') || 'application/zip').trim() || 'application/zip';
const fileName = parseContentDispositionFileName(resp, 'nodewarden_remote_backup.zip'); const fileName = parseContentDispositionFileName(resp, 'nodewarden_remote_backup.zip');
@@ -418,6 +430,7 @@ export async function inspectRemoteBackupIntegrity(
export async function restoreRemoteBackup( export async function restoreRemoteBackup(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
masterPasswordHash: string,
destinationId: string, destinationId: string,
path: string, path: string,
replaceExisting: boolean = false, replaceExisting: boolean = false,
@@ -426,7 +439,7 @@ export async function restoreRemoteBackup(
const resp = await authedFetch('/api/admin/backup/remote/restore', { const resp = await authedFetch('/api/admin/backup/remote/restore', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ destinationId, path, replaceExisting, allowChecksumMismatch }), body: JSON.stringify({ destinationId, path, replaceExisting, allowChecksumMismatch, masterPasswordHash }),
}); });
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_restore_failed'))); if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_restore_failed')));
const body = await parseJson<AdminBackupImportResponse>(resp); const body = await parseJson<AdminBackupImportResponse>(resp);
@@ -436,12 +449,14 @@ export async function restoreRemoteBackup(
export async function importAdminBackup( export async function importAdminBackup(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
masterPasswordHash: string,
file: File, file: File,
replaceExisting: boolean = false, replaceExisting: boolean = false,
allowChecksumMismatch: boolean = false allowChecksumMismatch: boolean = false
): Promise<AdminBackupImportResponse> { ): Promise<AdminBackupImportResponse> {
const formData = new FormData(); const formData = new FormData();
formData.set('file', file, file.name || 'nodewarden_backup.zip'); formData.set('file', file, file.name || 'nodewarden_backup.zip');
formData.set('masterPasswordHash', masterPasswordHash);
if (replaceExisting) { if (replaceExisting) {
formData.set('replaceExisting', '1'); formData.set('replaceExisting', '1');
} }
+20 -7
View File
@@ -66,6 +66,12 @@ export interface CompletedLogin {
session: SessionState; session: SessionState;
profile: Profile; profile: Profile;
profilePromise: Promise<Profile>; profilePromise: Promise<Profile>;
freshMasterPasswordHash?: string | null;
freshUserVerificationToken?: string | null;
}
function readTokenUserVerificationToken(token: TokenSuccess): string | null {
return String(token.UserVerificationToken || token.userVerificationToken || '').trim() || null;
} }
export type PasswordLoginResult = export type PasswordLoginResult =
@@ -319,7 +325,8 @@ export async function completeLogin(
token: TokenSuccess, token: TokenSuccess,
email: string, email: string,
masterKey: Uint8Array, masterKey: Uint8Array,
fallbackKdfIterations: number fallbackKdfIterations: number,
freshMasterPasswordHash?: string | null
): Promise<CompletedLogin> { ): Promise<CompletedLogin> {
const normalizedEmail = email.trim().toLowerCase(); const normalizedEmail = email.trim().toLowerCase();
const fallbackProfile = loadProfileSnapshot(normalizedEmail); const fallbackProfile = loadProfileSnapshot(normalizedEmail);
@@ -348,6 +355,8 @@ export async function completeLogin(
session: { ...baseSession, ...keys }, session: { ...baseSession, ...keys },
profile, profile,
profilePromise: getProfile(tempFetch), profilePromise: getProfile(tempFetch),
freshMasterPasswordHash: freshMasterPasswordHash || null,
freshUserVerificationToken: readTokenUserVerificationToken(token),
}; };
} }
@@ -360,7 +369,8 @@ async function completeLoginWithVaultKeys(
token: TokenSuccess, token: TokenSuccess,
email: string, email: string,
keys: { symEncKey: string; symMacKey: string }, keys: { symEncKey: string; symMacKey: string },
fallbackKdfIterations: number fallbackKdfIterations: number,
freshMasterPasswordHash?: string | null
): Promise<CompletedLogin> { ): Promise<CompletedLogin> {
const normalizedEmail = email.trim().toLowerCase(); const normalizedEmail = email.trim().toLowerCase();
const fallbackProfile = loadProfileSnapshot(normalizedEmail); const fallbackProfile = loadProfileSnapshot(normalizedEmail);
@@ -385,6 +395,8 @@ async function completeLoginWithVaultKeys(
session: { ...baseSession, ...keys }, session: { ...baseSession, ...keys },
profile, profile,
profilePromise: getProfile(tempFetch), profilePromise: getProfile(tempFetch),
freshMasterPasswordHash: freshMasterPasswordHash || null,
freshUserVerificationToken: readTokenUserVerificationToken(token),
}; };
} }
@@ -400,7 +412,7 @@ export async function performPasswordLogin(
if ('access_token' in token && token.access_token) { if ('access_token' in token && token.access_token) {
return { return {
kind: 'success', kind: 'success',
login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations), login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations, derived.hash),
}; };
} }
@@ -476,7 +488,7 @@ export async function completePasskeyPasswordLogin(
password: string password: string
): Promise<CompletedLogin> { ): Promise<CompletedLogin> {
const derived = await deriveLoginHashLocally(pending.email, password, pending.kdfIterations); const derived = await deriveLoginHashLocally(pending.email, password, pending.kdfIterations);
return completeLogin(pending.token, pending.email, derived.masterKey, pending.kdfIterations); return completeLogin(pending.token, pending.email, derived.masterKey, pending.kdfIterations, derived.hash);
} }
export async function performTotpLogin( export async function performTotpLogin(
@@ -489,7 +501,7 @@ export async function performTotpLogin(
rememberDevice, rememberDevice,
}); });
if ('access_token' in token && token.access_token) { if ('access_token' in token && token.access_token) {
return completeLogin(token, pendingTotp.email, pendingTotp.masterKey, pendingTotp.kdfIterations); return completeLogin(token, pendingTotp.email, pendingTotp.masterKey, pendingTotp.kdfIterations, pendingTotp.passwordHash);
} }
const tokenError = token as { error_description?: string; error?: string }; const tokenError = token as { error_description?: string; error?: string };
throw new Error(translateServerError(tokenError.error_description || tokenError.error, t('txt_totp_verify_failed'))); throw new Error(translateServerError(tokenError.error_description || tokenError.error, t('txt_totp_verify_failed')));
@@ -508,7 +520,7 @@ export async function performRecoverTwoFactorLogin(
if ('access_token' in token && token.access_token) { if ('access_token' in token && token.access_token) {
return { return {
login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations), login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations, derived.hash),
newRecoveryCode: recovered.newRecoveryCode || null, newRecoveryCode: recovered.newRecoveryCode || null,
}; };
} }
@@ -557,6 +569,7 @@ export async function performUnlock(
session: offline.session, session: offline.session,
profile: offline.profile, profile: offline.profile,
profilePromise: Promise.resolve(offline.profile), profilePromise: Promise.resolve(offline.profile),
freshMasterPasswordHash: null,
}, },
}; };
} catch { } catch {
@@ -589,7 +602,7 @@ export async function performUnlock(
if ('access_token' in token && token.access_token) { if ('access_token' in token && token.access_token) {
return { return {
kind: 'success', kind: 'success',
login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations), login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations, derived.hash),
}; };
} }
+47 -2
View File
@@ -4,7 +4,7 @@ export interface RecommendedStorageLink {
} }
export interface RecommendedProviderBase { export interface RecommendedProviderBase {
id: 'infinicloud' | 'koofr' | 'pcloud'; id: 'infinicloud' | 'koofr' | 'pcloud' | 'backblaze-b2' | 'cloudflare-r2' | 'tigris';
name: string; name: string;
capacity: string; capacity: string;
protocol: 'webdav' | 's3'; protocol: 'webdav' | 's3';
@@ -28,7 +28,25 @@ export interface PcloudProvider extends RecommendedProviderBase {
id: 'pcloud'; id: 'pcloud';
} }
export type RecommendedProvider = InfinicloudProvider | KoofrProvider | PcloudProvider; export interface BackblazeB2Provider extends RecommendedProviderBase {
id: 'backblaze-b2';
bucketsUrl: string;
applicationKeysUrl: string;
}
export interface CloudflareR2Provider extends RecommendedProviderBase {
id: 'cloudflare-r2';
bucketUrl: string;
apiTokenUrl: string;
}
export interface TigrisProvider extends RecommendedProviderBase {
id: 'tigris';
bucketUrl: string;
accessKeyUrl: string;
}
export type RecommendedProvider = InfinicloudProvider | KoofrProvider | PcloudProvider | BackblazeB2Provider | CloudflareR2Provider | TigrisProvider;
export const RECOMMENDED_PROVIDERS: RecommendedProvider[] = [ export const RECOMMENDED_PROVIDERS: RecommendedProvider[] = [
{ {
@@ -61,6 +79,33 @@ export const RECOMMENDED_PROVIDERS: RecommendedProvider[] = [
signupUrl: 'https://u.pcloud.com/#/register?invite=GITx7ZvEU1N7', signupUrl: 'https://u.pcloud.com/#/register?invite=GITx7ZvEU1N7',
hasAffiliateLink: true, hasAffiliateLink: true,
}, },
{
id: 'backblaze-b2',
name: 'Backblaze B2',
capacity: '10G',
protocol: 's3',
signupUrl: 'https://secure.backblaze.com/user_signin.htm',
bucketsUrl: 'https://secure.backblaze.com/b2_buckets.htm',
applicationKeysUrl: 'https://secure.backblaze.com/app_keys.htm',
},
{
id: 'cloudflare-r2',
name: 'Cloudflare R2',
capacity: '10G',
protocol: 's3',
signupUrl: 'https://dash.cloudflare.com/?to=/:account/r2/new',
bucketUrl: 'https://dash.cloudflare.com/?to=/:account/r2/new',
apiTokenUrl: 'https://dash.cloudflare.com/?to=/:account/r2/api-tokens/create?type=user',
},
{
id: 'tigris',
name: 'Tigris',
capacity: '5G',
protocol: 's3',
signupUrl: 'https://console.storage.dev/signup',
bucketUrl: 'https://console.storage.dev/createbucket',
accessKeyUrl: 'https://console.storage.dev/createaccesskey',
},
]; ];
export function hasLinkedStorages(provider: RecommendedProvider): provider is KoofrProvider { export function hasLinkedStorages(provider: RecommendedProvider): provider is KoofrProvider {
+4 -2
View File
@@ -5,7 +5,8 @@ import type { Profile, SessionState } from './types';
export async function silentlyRepairBackupSettingsIfNeeded( export async function silentlyRepairBackupSettingsIfNeeded(
activeSession: SessionState, activeSession: SessionState,
activeProfile: Profile activeProfile: Profile,
verification?: { masterPasswordHash?: string | null; userVerificationToken?: string | null } | null
): Promise<void> { ): Promise<void> {
if (activeProfile.role !== 'admin') return; if (activeProfile.role !== 'admin') return;
if (!activeSession.accessToken || !activeSession.symEncKey || !activeSession.symMacKey) return; if (!activeSession.accessToken || !activeSession.symEncKey || !activeSession.symMacKey) return;
@@ -14,8 +15,9 @@ export async function silentlyRepairBackupSettingsIfNeeded(
try { try {
const state = await getAdminBackupSettingsRepairState(tempFetch); const state = await getAdminBackupSettingsRepairState(tempFetch);
if (!state.needsRepair || !state.portable) return; if (!state.needsRepair || !state.portable) return;
if (!verification?.masterPasswordHash && !verification?.userVerificationToken) return;
const repairedSettings = await decryptPortableBackupSettings(state.portable, activeProfile, activeSession); const repairedSettings = await decryptPortableBackupSettings(state.portable, activeProfile, activeSession);
await repairAdminBackupSettings(tempFetch, repairedSettings); await repairAdminBackupSettings(tempFetch, verification, repairedSettings);
} catch (error) { } catch (error) {
console.error('Backup settings auto-repair failed:', error); console.error('Backup settings auto-repair failed:', error);
} }
+8 -8
View File
@@ -1156,32 +1156,32 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
notify('success', t('txt_logs_cleared')); notify('success', t('txt_logs_cleared'));
return 0; return 0;
}, },
onExportBackup: async () => { onExportBackup: async (_masterPassword: string) => {
notify('success', t('txt_backup_export_success')); notify('success', t('txt_backup_export_success'));
}, },
onImportBackup: async () => { onImportBackup: async (_masterPassword: string, _file: File, _replaceExisting?: boolean) => {
resetDemoVaultState(state); resetDemoVaultState(state);
notify('success', t('txt_backup_import_success_relogin')); notify('success', t('txt_backup_import_success_relogin'));
return createDemoImportBackupResult(); return createDemoImportBackupResult();
}, },
onImportBackupAllowingChecksumMismatch: async () => { onImportBackupAllowingChecksumMismatch: async (_masterPassword: string, _file: File, _replaceExisting?: boolean) => {
resetDemoVaultState(state); resetDemoVaultState(state);
notify('success', t('txt_backup_import_success_relogin')); notify('success', t('txt_backup_import_success_relogin'));
return createDemoImportBackupResult(); return createDemoImportBackupResult();
}, },
onLoadBackupSettings: async () => state.backupSettings, onLoadBackupSettings: async () => state.backupSettings,
onSaveBackupSettings: async (settings) => { onSaveBackupSettings: async (_masterPassword: string, settings) => {
const next = cloneJson(settings); const next = cloneJson(settings);
state.setBackupSettings(next); state.setBackupSettings(next);
notify('success', t('txt_backup_settings_saved')); notify('success', t('txt_backup_settings_saved'));
return next; return next;
}, },
onRunRemoteBackup: async (destinationId?: string | null) => { onRunRemoteBackup: async (_masterPassword: string, destinationId?: string | null) => {
notify('success', t('txt_backup_remote_run_success')); notify('success', t('txt_backup_remote_run_success'));
return createDemoBackupRun(state.backupSettings, destinationId); return createDemoBackupRun(state.backupSettings, destinationId);
}, },
onListRemoteBackups: async (destinationId: string, path: string) => createDemoRemoteBrowser(destinationId, path), onListRemoteBackups: async (destinationId: string, path: string) => createDemoRemoteBrowser(destinationId, path),
onDownloadRemoteBackup: async () => { onDownloadRemoteBackup: async (_masterPassword: string, _destinationId: string, _path: string, _onProgress?: (percent: number | null) => void) => {
notify('success', t('txt_demo_download_prepared')); notify('success', t('txt_demo_download_prepared'));
}, },
onInspectRemoteBackup: async (_destinationId: string, path: string) => ({ onInspectRemoteBackup: async (_destinationId: string, path: string) => ({
@@ -1199,13 +1199,13 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
onDeleteRemoteBackup: async () => { onDeleteRemoteBackup: async () => {
notify('success', t('txt_backup_remote_delete_success')); notify('success', t('txt_backup_remote_delete_success'));
}, },
onRestoreRemoteBackup: async (_destinationId, path) => { onRestoreRemoteBackup: async (_masterPassword: string, _destinationId, path) => {
await runDemoRemoteRestoreProgress(path.split('/').pop() || path || 'nodewarden_backup_demo.zip'); await runDemoRemoteRestoreProgress(path.split('/').pop() || path || 'nodewarden_backup_demo.zip');
resetDemoVaultState(state); resetDemoVaultState(state);
notify('success', t('txt_backup_remote_restore_completed_verified')); notify('success', t('txt_backup_remote_restore_completed_verified'));
return createDemoImportBackupResult(); return createDemoImportBackupResult();
}, },
onRestoreRemoteBackupAllowingChecksumMismatch: async (_destinationId, path) => { onRestoreRemoteBackupAllowingChecksumMismatch: async (_masterPassword: string, _destinationId, path) => {
await runDemoRemoteRestoreProgress(path.split('/').pop() || path || 'nodewarden_backup_demo.zip'); await runDemoRemoteRestoreProgress(path.split('/').pop() || path || 'nodewarden_backup_demo.zip');
resetDemoVaultState(state); resetDemoVaultState(state);
notify('success', t('txt_backup_remote_restore_completed_verified')); notify('success', t('txt_backup_remote_restore_completed_verified'));
+31
View File
@@ -85,6 +85,37 @@ const en: Record<string, string> = {
"txt_backup_recommend_pcloud_step_1": "Register a pCloud account with just your email address.", "txt_backup_recommend_pcloud_step_1": "Register a pCloud account with just your email address.",
"txt_backup_recommend_pcloud_step_2": "Use https://webdav.pcloud.com/ as the WebDAV server URL.", "txt_backup_recommend_pcloud_step_2": "Use https://webdav.pcloud.com/ as the WebDAV server URL.",
"txt_backup_recommend_pcloud_step_3": "Use your registration email as the WebDAV username and your account password as the WebDAV password.", "txt_backup_recommend_pcloud_step_3": "Use your registration email as the WebDAV username and your account password as the WebDAV password.",
"txt_backup_recommend_backblaze_summary": "S3-compatible object storage with 10 GB free and no credit card required.",
"txt_backup_recommend_backblaze_step_1": "Register or sign in to a Backblaze account.",
"txt_backup_recommend_backblaze_step_2_prefix": "Open",
"txt_backup_recommend_backblaze_step_2_suffix": ", click Create a Bucket, enter only the bucket name, leave the other settings unchanged, and create it.",
"txt_backup_recommend_backblaze_step_3": "After creation, put the displayed Endpoint into S3 Endpoint URL, use the bucket name for Bucket Name, and use the middle segment of the endpoint, such as us-west-004, for Region.",
"txt_backup_recommend_backblaze_step_4_prefix": "Open",
"txt_backup_recommend_backblaze_step_4_suffix": ", click Add a New Application Key, enter any Name of Key, leave the other settings unchanged, and create it.",
"txt_backup_recommend_backblaze_step_5": "Use keyID as the access key and applicationKey as the secret key.",
"txt_backup_recommend_cloudflare_r2_summary": "S3-compatible object storage with 10 GB free, but it requires credit card verification.",
"txt_backup_recommend_cloudflare_r2_bucket_link": "Create bucket page",
"txt_backup_recommend_cloudflare_r2_api_link": "API token page",
"txt_backup_recommend_cloudflare_r2_step_1_prefix": "Open the",
"txt_backup_recommend_cloudflare_r2_step_1_suffix": ", enter only the bucket name, and create it directly.",
"txt_backup_recommend_cloudflare_r2_step_2_prefix": "Open the",
"txt_backup_recommend_cloudflare_r2_step_2_suffix": ", select Object Read & Write for permissions, and create it directly.",
"txt_backup_recommend_cloudflare_r2_step_3": "Ignore the token value after creation. Fill Access Key ID into Access ID, and Secret Access Key into Access Password.",
"txt_backup_recommend_cloudflare_r2_step_4": "Copy the address shown below into S3 Endpoint URL, fill Bucket Name exactly as shown, and leave Region as auto.",
"txt_backup_recommend_cloudflare_r2_step_5": "Set Path Prefix as needed, for example nodewarden, or leave it empty if you do not want a folder prefix.",
"txt_backup_recommend_s3_path_prefix_step": "Set Path Prefix as needed, for example nodewarden, or leave it empty if you do not want a folder prefix.",
"txt_backup_recommend_tigris_summary": "S3-compatible object storage with 5 GB free and no credit card required.",
"txt_backup_recommend_tigris_signup_link": "signup page",
"txt_backup_recommend_tigris_bucket_link": "Create Bucket page",
"txt_backup_recommend_tigris_access_key_link": "Create Access Key page",
"txt_backup_recommend_tigris_step_1_prefix": "Open the",
"txt_backup_recommend_tigris_step_1_suffix": ", sign up, and log in to Tigris.",
"txt_backup_recommend_tigris_step_2_prefix": "Open",
"txt_backup_recommend_tigris_step_2_suffix": ", enter only the bucket name, leave everything else unchanged, and create it.",
"txt_backup_recommend_tigris_step_3_prefix": "Then open the",
"txt_backup_recommend_tigris_step_3_suffix": ", use any name you like, and create it.",
"txt_backup_recommend_tigris_step_4": "Ignore Endpoint URL IAM after creation. Fill the other displayed values into the backup page using the matching field names.",
"txt_backup_recommend_tigris_step_5": "Finally, click Manage Key Permissions and turn on Admin Access, otherwise writing backups will fail.",
"txt_backup_add_destination": "Add Destination", "txt_backup_add_destination": "Add Destination",
"txt_backup_schedule_panel_title": "Automatic Schedule", "txt_backup_schedule_panel_title": "Automatic Schedule",
"txt_backup_schedule_panel_note": "Each destination can keep its own daily backup schedule.", "txt_backup_schedule_panel_note": "Each destination can keep its own daily backup schedule.",
+31
View File
@@ -85,6 +85,37 @@ const es: Record<string, string> = {
"txt_backup_recommend_pcloud_step_1": "Registre una cuenta pCloud solo con su dirección de correo.", "txt_backup_recommend_pcloud_step_1": "Registre una cuenta pCloud solo con su dirección de correo.",
"txt_backup_recommend_pcloud_step_2": "Use https://webdav.pcloud.com/ como URL del servidor WebDAV.", "txt_backup_recommend_pcloud_step_2": "Use https://webdav.pcloud.com/ como URL del servidor WebDAV.",
"txt_backup_recommend_pcloud_step_3": "Use su correo de registro como nombre de usuario WebDAV y su contraseña de cuenta como contraseña WebDAV.", "txt_backup_recommend_pcloud_step_3": "Use su correo de registro como nombre de usuario WebDAV y su contraseña de cuenta como contraseña WebDAV.",
"txt_backup_recommend_backblaze_summary": "Almacenamiento de objetos compatible con S3 con 10 GB gratis y sin tarjeta de crédito.",
"txt_backup_recommend_backblaze_step_1": "Registre o inicie sesión en una cuenta de Backblaze.",
"txt_backup_recommend_backblaze_step_2_prefix": "Abra",
"txt_backup_recommend_backblaze_step_2_suffix": ", haga clic en Create a Bucket, introduzca solo el nombre del bucket, deje lo demás sin cambios y créelo.",
"txt_backup_recommend_backblaze_step_3": "Después de crearlo, ponga el Endpoint mostrado en S3 Endpoint URL, use el nombre del bucket en Bucket Name y la parte central del endpoint, como us-west-004, en Region.",
"txt_backup_recommend_backblaze_step_4_prefix": "Abra",
"txt_backup_recommend_backblaze_step_4_suffix": ", haga clic en Add a New Application Key, introduzca cualquier Name of Key, deje lo demás sin cambios y créelo.",
"txt_backup_recommend_backblaze_step_5": "Use keyID como clave de acceso y applicationKey como clave secreta.",
"txt_backup_recommend_cloudflare_r2_summary": "Almacenamiento de objetos compatible con S3 con 10 GB gratis, pero requiere verificación con tarjeta de crédito.",
"txt_backup_recommend_cloudflare_r2_bucket_link": "página para crear bucket",
"txt_backup_recommend_cloudflare_r2_api_link": "página de token API",
"txt_backup_recommend_cloudflare_r2_step_1_prefix": "Abra la",
"txt_backup_recommend_cloudflare_r2_step_1_suffix": ", introduzca solo el nombre del bucket y créelo directamente.",
"txt_backup_recommend_cloudflare_r2_step_2_prefix": "Abra la",
"txt_backup_recommend_cloudflare_r2_step_2_suffix": ", seleccione Object Read & Write en permisos y créelo directamente.",
"txt_backup_recommend_cloudflare_r2_step_3": "Ignore el valor del token después de crearlo. Use Access Key ID como ID de acceso y Secret Access Key como contraseña de acceso.",
"txt_backup_recommend_cloudflare_r2_step_4": "Copie la dirección mostrada abajo en S3 Endpoint URL, rellene Bucket Name tal como aparece y deje Region en auto.",
"txt_backup_recommend_cloudflare_r2_step_5": "Configure Path Prefix si lo necesita, por ejemplo nodewarden, o déjelo vacío si no quiere un prefijo de carpeta.",
"txt_backup_recommend_s3_path_prefix_step": "Configure Path Prefix si lo necesita, por ejemplo nodewarden, o déjelo vacío si no quiere un prefijo de carpeta.",
"txt_backup_recommend_tigris_summary": "Almacenamiento de objetos compatible con S3 con 5 GB gratis y sin tarjeta de crédito.",
"txt_backup_recommend_tigris_signup_link": "página de registro",
"txt_backup_recommend_tigris_bucket_link": "página Create Bucket",
"txt_backup_recommend_tigris_access_key_link": "página Create Access Key",
"txt_backup_recommend_tigris_step_1_prefix": "Abra la",
"txt_backup_recommend_tigris_step_1_suffix": ", regístrese e inicie sesión en Tigris.",
"txt_backup_recommend_tigris_step_2_prefix": "Abra",
"txt_backup_recommend_tigris_step_2_suffix": ", introduzca solo el nombre del bucket, deje todo lo demás sin cambios y créelo.",
"txt_backup_recommend_tigris_step_3_prefix": "Luego abra la",
"txt_backup_recommend_tigris_step_3_suffix": ", use cualquier nombre y créela.",
"txt_backup_recommend_tigris_step_4": "Ignore Endpoint URL IAM después de crearla. Rellene los demás valores mostrados en la página de copia de seguridad usando los nombres correspondientes.",
"txt_backup_recommend_tigris_step_5": "Por último, haga clic en Manage Key Permissions y active Admin Access; de lo contrario, no podrá escribir copias de seguridad.",
"txt_backup_add_destination": "Añadir destino", "txt_backup_add_destination": "Añadir destino",
"txt_backup_schedule_panel_title": "Programación automática", "txt_backup_schedule_panel_title": "Programación automática",
"txt_backup_schedule_panel_note": "Cada destino puede mantener su propia programación de copia de seguridad diaria.", "txt_backup_schedule_panel_note": "Cada destino puede mantener su propia programación de copia de seguridad diaria.",
+31
View File
@@ -86,6 +86,37 @@ const ru: Record<string, string> = {
"txt_backup_recommend_pcloud_step_1": "Зарегистрируйте учетную запись pCloud, используя только свой адрес электронной почты.", "txt_backup_recommend_pcloud_step_1": "Зарегистрируйте учетную запись pCloud, используя только свой адрес электронной почты.",
"txt_backup_recommend_pcloud_step_2": "Используйте https://webdav.ploud.com/ в качестве URL-адреса сервера WebDAV.", "txt_backup_recommend_pcloud_step_2": "Используйте https://webdav.ploud.com/ в качестве URL-адреса сервера WebDAV.",
"txt_backup_recommend_pcloud_step_3": "Используйте свой регистрационный адрес электронной почты в качестве имени пользователя WebDAV и пароль своей учетной записи в качестве пароля WebDAV.", "txt_backup_recommend_pcloud_step_3": "Используйте свой регистрационный адрес электронной почты в качестве имени пользователя WebDAV и пароль своей учетной записи в качестве пароля WebDAV.",
"txt_backup_recommend_backblaze_summary": "S3-совместимое объектное хранилище с бесплатными 10 ГБ и без кредитной карты.",
"txt_backup_recommend_backblaze_step_1": "Зарегистрируйте учетную запись Backblaze или войдите в нее.",
"txt_backup_recommend_backblaze_step_2_prefix": "Откройте",
"txt_backup_recommend_backblaze_step_2_suffix": ", нажмите Create a Bucket, введите только имя bucket, оставьте остальные настройки без изменений и создайте его.",
"txt_backup_recommend_backblaze_step_3": "После создания вставьте показанный Endpoint в S3 Endpoint URL, имя bucket укажите в Bucket Name, а среднюю часть endpoint, например us-west-004, используйте как Region.",
"txt_backup_recommend_backblaze_step_4_prefix": "Откройте",
"txt_backup_recommend_backblaze_step_4_suffix": ", нажмите Add a New Application Key, введите любое Name of Key, оставьте остальные настройки без изменений и создайте ключ.",
"txt_backup_recommend_backblaze_step_5": "Используйте keyID как ключ доступа, а applicationKey как секретный ключ.",
"txt_backup_recommend_cloudflare_r2_summary": "S3-совместимое объектное хранилище с бесплатными 10 ГБ, но с обязательной проверкой кредитной карты.",
"txt_backup_recommend_cloudflare_r2_bucket_link": "страницу создания bucket",
"txt_backup_recommend_cloudflare_r2_api_link": "страницу API token",
"txt_backup_recommend_cloudflare_r2_step_1_prefix": "Откройте",
"txt_backup_recommend_cloudflare_r2_step_1_suffix": ", введите только имя bucket и сразу создайте его.",
"txt_backup_recommend_cloudflare_r2_step_2_prefix": "Откройте",
"txt_backup_recommend_cloudflare_r2_step_2_suffix": ", выберите Object Read & Write в разрешениях и сразу создайте токен.",
"txt_backup_recommend_cloudflare_r2_step_3": "После создания игнорируйте token value. Введите Access Key ID как ID доступа, а Secret Access Key как пароль доступа.",
"txt_backup_recommend_cloudflare_r2_step_4": "Вставьте показанный ниже адрес в S3 Endpoint URL, заполните Bucket Name как показано и оставьте Region в значении auto.",
"txt_backup_recommend_cloudflare_r2_step_5": "Укажите Path Prefix при необходимости, например nodewarden, или оставьте пустым, если префикс папки не нужен.",
"txt_backup_recommend_s3_path_prefix_step": "Укажите Path Prefix при необходимости, например nodewarden, или оставьте пустым, если префикс папки не нужен.",
"txt_backup_recommend_tigris_summary": "S3-совместимое объектное хранилище с бесплатными 5 ГБ и без кредитной карты.",
"txt_backup_recommend_tigris_signup_link": "страницу регистрации",
"txt_backup_recommend_tigris_bucket_link": "страницу Create Bucket",
"txt_backup_recommend_tigris_access_key_link": "страницу Create Access Key",
"txt_backup_recommend_tigris_step_1_prefix": "Откройте",
"txt_backup_recommend_tigris_step_1_suffix": ", зарегистрируйтесь и войдите в Tigris.",
"txt_backup_recommend_tigris_step_2_prefix": "Откройте",
"txt_backup_recommend_tigris_step_2_suffix": ", введите только имя bucket, ничего больше не меняйте и создайте его.",
"txt_backup_recommend_tigris_step_3_prefix": "Затем откройте",
"txt_backup_recommend_tigris_step_3_suffix": ", введите любое имя и создайте ключ.",
"txt_backup_recommend_tigris_step_4": "После создания игнорируйте Endpoint URL IAM. Остальные показанные значения заполните на странице резервного копирования по совпадающим названиям полей.",
"txt_backup_recommend_tigris_step_5": "В конце нажмите Manage Key Permissions и включите Admin Access, иначе запись резервных копий не будет работать.",
"txt_backup_add_destination": "Добавить пункт назначения", "txt_backup_add_destination": "Добавить пункт назначения",
"txt_backup_schedule_panel_title": "Автоматическое расписание", "txt_backup_schedule_panel_title": "Автоматическое расписание",
"txt_backup_schedule_panel_note": "Каждый пункт назначения может иметь собственный ежедневный график резервного копирования.", "txt_backup_schedule_panel_note": "Каждый пункт назначения может иметь собственный ежедневный график резервного копирования.",
+37 -6
View File
@@ -85,6 +85,37 @@ const zhCN: Record<string, string> = {
"txt_backup_recommend_pcloud_step_1": "先用邮箱注册一个 pCloud 账号。", "txt_backup_recommend_pcloud_step_1": "先用邮箱注册一个 pCloud 账号。",
"txt_backup_recommend_pcloud_step_2": "WebDAV 地址填写 https://webdav.pcloud.com/ 。", "txt_backup_recommend_pcloud_step_2": "WebDAV 地址填写 https://webdav.pcloud.com/ 。",
"txt_backup_recommend_pcloud_step_3": "注册邮箱用作 WebDAV 用户名,注册密码用作 WebDAV 密码。", "txt_backup_recommend_pcloud_step_3": "注册邮箱用作 WebDAV 用户名,注册密码用作 WebDAV 密码。",
"txt_backup_recommend_backblaze_summary": "兼容 S3 的对象存储,免费容量 10 GB,无需信用卡。",
"txt_backup_recommend_backblaze_step_1": "先注册或登录 Backblaze 账号。",
"txt_backup_recommend_backblaze_step_2_prefix": "打开",
"txt_backup_recommend_backblaze_step_2_suffix": ",点击创建一个桶,只输入桶名字,其他地方不修改,然后创建。",
"txt_backup_recommend_backblaze_step_3": "创建后显示的 Endpoint 填到 S3 端点 URL;桶名字填到存储桶名称;区域填 Endpoint 中间那段,例如 us-west-004。",
"txt_backup_recommend_backblaze_step_4_prefix": "打开",
"txt_backup_recommend_backblaze_step_4_suffix": ",点击 Add a New Application Key,随便输入 Name of Key,其他地方不动,然后创建。",
"txt_backup_recommend_backblaze_step_5": "生成结果里的 keyID 填到 访问 IDapplicationKey 填到 访问密码。",
"txt_backup_recommend_cloudflare_r2_summary": "兼容 S3 的对象存储,免费容量 10 GB,需要信用卡认证。",
"txt_backup_recommend_cloudflare_r2_bucket_link": "创建储存桶页面",
"txt_backup_recommend_cloudflare_r2_api_link": "API 创建页面",
"txt_backup_recommend_cloudflare_r2_step_1_prefix": "打开",
"txt_backup_recommend_cloudflare_r2_step_1_suffix": ",只输入存储桶名称,直接创建。",
"txt_backup_recommend_cloudflare_r2_step_2_prefix": "打开",
"txt_backup_recommend_cloudflare_r2_step_2_suffix": ",权限全选“对象读和写”,直接创建。",
"txt_backup_recommend_cloudflare_r2_step_3": "创建后令牌值不用管;Access Key ID 填到 访问 IDSecret Access Key 填到 访问密码。",
"txt_backup_recommend_cloudflare_r2_step_4": "把下面显示的地址填到 S3 端点 URL;存储桶名称如实填写;区域保持 auto 不改。",
"txt_backup_recommend_cloudflare_r2_step_5": "路径前缀按需要填写,例如 nodewarden;不想分目录可以留空。",
"txt_backup_recommend_s3_path_prefix_step": "路径前缀按需要填写,例如 nodewarden;不想分目录可以留空。",
"txt_backup_recommend_tigris_summary": "兼容 S3 的对象存储。免费容量 5 GB,无需信用卡。",
"txt_backup_recommend_tigris_signup_link": "注册页面",
"txt_backup_recommend_tigris_bucket_link": "Create Bucket 页面",
"txt_backup_recommend_tigris_access_key_link": "Create Access Key 页面",
"txt_backup_recommend_tigris_step_1_prefix": "打开",
"txt_backup_recommend_tigris_step_1_suffix": ",注册并登录 Tigris。",
"txt_backup_recommend_tigris_step_2_prefix": "打开",
"txt_backup_recommend_tigris_step_2_suffix": ",只输入桶的名字,其他地方不动,直接创建。",
"txt_backup_recommend_tigris_step_3_prefix": "然后打开",
"txt_backup_recommend_tigris_step_3_suffix": ",名字随意,直接创建。",
"txt_backup_recommend_tigris_step_4": "创建后显示的 Endpoint URL IAM 不用管;其余显示出来的内容按名称填写到备份页面里。",
"txt_backup_recommend_tigris_step_5": "最后点击 Manage Key Permissions,把 Admin Access 打开,否则无法写入。",
"txt_backup_add_destination": "新增地点", "txt_backup_add_destination": "新增地点",
"txt_backup_schedule_panel_title": "自动备份计划", "txt_backup_schedule_panel_title": "自动备份计划",
"txt_backup_schedule_panel_note": "每个备份地点都可以单独配置自己的每日自动备份计划。", "txt_backup_schedule_panel_note": "每个备份地点都可以单独配置自己的每日自动备份计划。",
@@ -266,15 +297,15 @@ const zhCN: Record<string, string> = {
"txt_backup_webdav_username": "WebDAV 用户名", "txt_backup_webdav_username": "WebDAV 用户名",
"txt_backup_webdav_password": "WebDAV 密码", "txt_backup_webdav_password": "WebDAV 密码",
"txt_backup_webdav_path": "远程目录", "txt_backup_webdav_path": "远程目录",
"txt_backup_s3_endpoint": "S3 端点", "txt_backup_s3_endpoint": "S3 端点 URL",
"txt_backup_s3_addressing_style": "S3 寻址方式", "txt_backup_s3_addressing_style": "寻址方式",
"txt_backup_s3_addressing_path_style": "path-style(默认)", "txt_backup_s3_addressing_path_style": "path-style(默认)",
"txt_backup_s3_addressing_virtual_hosted_style": "virtual-hosted-style", "txt_backup_s3_addressing_virtual_hosted_style": "virtual-hosted-style",
"txt_backup_s3_bucket": "存储桶", "txt_backup_s3_bucket": "存储桶名称",
"txt_backup_s3_region": "区域", "txt_backup_s3_region": "区域",
"txt_backup_s3_access_key": "访问密钥", "txt_backup_s3_access_key": "访问 ID",
"txt_backup_s3_secret_key": "秘密密钥", "txt_backup_s3_secret_key": "访问密码",
"txt_backup_s3_path": "远程路径", "txt_backup_s3_path": "路径前缀",
"txt_backup_reserved_name": "预留类型名称", "txt_backup_reserved_name": "预留类型名称",
"txt_backup_reserved_notes": "预留备注", "txt_backup_reserved_notes": "预留备注",
"txt_backup_reserved_notes_placeholder": "给下一个备份地点先留个说明", "txt_backup_reserved_notes_placeholder": "给下一个备份地点先留个说明",
+31
View File
@@ -85,6 +85,37 @@ const zhTW: Record<string, string> = {
"txt_backup_recommend_pcloud_step_1": "先用郵箱註冊一個 pCloud 賬號。", "txt_backup_recommend_pcloud_step_1": "先用郵箱註冊一個 pCloud 賬號。",
"txt_backup_recommend_pcloud_step_2": "WebDAV 地址填寫 https://webdav.pcloud.com/ 。", "txt_backup_recommend_pcloud_step_2": "WebDAV 地址填寫 https://webdav.pcloud.com/ 。",
"txt_backup_recommend_pcloud_step_3": "註冊郵箱用作 WebDAV 用戶名,註冊密碼用作 WebDAV 密碼。", "txt_backup_recommend_pcloud_step_3": "註冊郵箱用作 WebDAV 用戶名,註冊密碼用作 WebDAV 密碼。",
"txt_backup_recommend_backblaze_summary": "兼容 S3 的對象儲存,免費容量 10 GB,無需信用卡。",
"txt_backup_recommend_backblaze_step_1": "先註冊或登入 Backblaze 賬號。",
"txt_backup_recommend_backblaze_step_2_prefix": "打開",
"txt_backup_recommend_backblaze_step_2_suffix": ",點擊創建一個桶,只輸入桶名字,其他地方不修改,然後創建。",
"txt_backup_recommend_backblaze_step_3": "創建後顯示的 Endpoint 填到 S3 端點 URL;桶名字填到儲存桶名稱;區域填 Endpoint 中間那段,例如 us-west-004。",
"txt_backup_recommend_backblaze_step_4_prefix": "打開",
"txt_backup_recommend_backblaze_step_4_suffix": ",點擊 Add a New Application Key,隨便輸入 Name of Key,其他地方不動,然後創建。",
"txt_backup_recommend_backblaze_step_5": "生成結果裡的 keyID 填存取金鑰,applicationKey 填秘密金鑰。",
"txt_backup_recommend_cloudflare_r2_summary": "兼容 S3 的對象儲存,免費容量 10 GB,需要信用卡驗證。",
"txt_backup_recommend_cloudflare_r2_bucket_link": "創建儲存桶頁面",
"txt_backup_recommend_cloudflare_r2_api_link": "API 創建頁面",
"txt_backup_recommend_cloudflare_r2_step_1_prefix": "打開",
"txt_backup_recommend_cloudflare_r2_step_1_suffix": ",只輸入儲存桶名稱,直接創建。",
"txt_backup_recommend_cloudflare_r2_step_2_prefix": "打開",
"txt_backup_recommend_cloudflare_r2_step_2_suffix": ",權限全選「對象讀和寫」,直接創建。",
"txt_backup_recommend_cloudflare_r2_step_3": "創建後令牌值不用管;Access Key ID 填到存取 IDSecret Access Key 填到存取密碼。",
"txt_backup_recommend_cloudflare_r2_step_4": "把下面顯示的地址填到 S3 端點 URL;儲存桶名稱如實填寫;區域保持 auto 不改。",
"txt_backup_recommend_cloudflare_r2_step_5": "路徑前綴按需要填寫,例如 nodewarden;不想分目錄可以留空。",
"txt_backup_recommend_s3_path_prefix_step": "路徑前綴按需要填寫,例如 nodewarden;不想分目錄可以留空。",
"txt_backup_recommend_tigris_summary": "兼容 S3 的對象儲存。免費容量 5 GB,無需信用卡。",
"txt_backup_recommend_tigris_signup_link": "註冊頁面",
"txt_backup_recommend_tigris_bucket_link": "Create Bucket 頁面",
"txt_backup_recommend_tigris_access_key_link": "Create Access Key 頁面",
"txt_backup_recommend_tigris_step_1_prefix": "打開",
"txt_backup_recommend_tigris_step_1_suffix": ",註冊並登入 Tigris。",
"txt_backup_recommend_tigris_step_2_prefix": "打開",
"txt_backup_recommend_tigris_step_2_suffix": ",只輸入桶的名字,其他地方不動,直接創建。",
"txt_backup_recommend_tigris_step_3_prefix": "然後打開",
"txt_backup_recommend_tigris_step_3_suffix": ",名字隨意,直接創建。",
"txt_backup_recommend_tigris_step_4": "創建後顯示的 Endpoint URL IAM 不用管;其餘顯示出來的內容按名稱填寫到備份頁面裡。",
"txt_backup_recommend_tigris_step_5": "最後點擊 Manage Key Permissions,把 Admin Access 打開,否則無法寫入。",
"txt_backup_add_destination": "新增地點", "txt_backup_add_destination": "新增地點",
"txt_backup_schedule_panel_title": "自動備份計劃", "txt_backup_schedule_panel_title": "自動備份計劃",
"txt_backup_schedule_panel_note": "每個備份地點都可以單獨配置自己的每日自動備份計劃。", "txt_backup_schedule_panel_note": "每個備份地點都可以單獨配置自己的每日自動備份計劃。",
+2
View File
@@ -314,6 +314,8 @@ export interface TokenSuccess {
ResetMasterPassword?: boolean; ResetMasterPassword?: boolean;
scope?: string; scope?: string;
unofficialServer?: boolean; unofficialServer?: boolean;
UserVerificationToken?: string;
userVerificationToken?: string;
UserDecryptionOptions?: unknown; UserDecryptionOptions?: unknown;
userDecryptionOptions?: unknown; userDecryptionOptions?: unknown;
VaultKeys?: { VaultKeys?: {
+9
View File
@@ -351,6 +351,15 @@
color: var(--muted); color: var(--muted);
} }
:root[data-theme='dark'] .backup-recommendation-step a {
color: var(--primary);
}
:root[data-theme='dark'] .backup-recommendation-step a:hover,
:root[data-theme='dark'] .backup-recommendation-step a:focus-visible {
color: var(--primary-strong);
}
:root[data-theme='dark'] .restore-progress-overlay { :root[data-theme='dark'] .restore-progress-overlay {
background: var(--overlay-strong); background: var(--overlay-strong);
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
+12
View File
@@ -193,6 +193,18 @@
line-height: 1.5; line-height: 1.5;
} }
.backup-recommendation-step a {
color: #1d4ed8;
font-weight: 700;
text-decoration: underline;
text-underline-offset: 2px;
}
.backup-recommendation-step a:hover,
.backup-recommendation-step a:focus-visible {
color: #1742b0;
}
.backup-recommendation-inline-note { .backup-recommendation-inline-note {
color: #475467; color: #475467;
line-height: 1.5; line-height: 1.5;