Compare commits

..
15 Commits
Author SHA1 Message Date
shuaiplus 82f968e51f feat: add validFolderIds support for cipher responses and update folder handling in storage 2026-06-28 19:43:27 +08:00
shuaiplus a5ad16ac27 feat: add device selection and removal functionality in SecurityDevicesPage 2026-06-28 15:31:29 +08:00
shuaiplus 6a1a8357bf feat: refactor PRF extension handling in credential options 2026-06-28 14:13:06 +08:00
shuaiplus 31cfd19b6b feat: add support for excluding PRF extensions in credential options 2026-06-28 14:02:51 +08:00
shuaiplus 4cd9ad00d2 Add backup-related error messages and improve UI styles
- Updated English, Spanish, Russian, Simplified Chinese, and Traditional Chinese locale files to include new error messages related to backup and restore processes.
- Added prefix and suffix strings for the "cached empty" message to enhance clarity in user prompts.
- Enhanced the management CSS with new styles for the backup browser refresh prompt to improve layout and user experience.
2026-06-27 12:38:41 +08:00
shuaiplus 31dcc76ee2 Merge branch 'main' of https://github.com/shuaiplus/nodewarden 2026-06-26 20:58:22 +08:00
soncmsandShuai bf6ac7b405 Enable WebAuthn related origins support 2026-06-26 20:51:33 +08:00
shuaiplus 1bfb9a647d feat: refine app-shell styles for improved layout and dark mode consistency 2026-06-26 19:12:20 +08:00
shuaiplus e9272ec29a feat: enhance backup import functionality with locking mechanism and checksum support 2026-06-26 18:45:23 +08:00
shuaiplus 8942e5bd49 feat: add support for PRF extension request based on browser compatibility 2026-06-26 11:58:49 +08:00
shuaiplus d722815999 feat: add fullscreen layout support with toggle and localization updates 2026-06-26 11:26:02 +08:00
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
32 changed files with 1517 additions and 287 deletions
+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,
}; };
} }
+12 -1
View File
@@ -209,6 +209,7 @@ export class BackupTransferRunner {
} }
let completed = 0; let completed = 0;
const failures: Array<{ destinationId: string; error: string }> = [];
try { try {
await this.touchJob(token); await this.touchJob(token);
const storage = new StorageService(this.env.DB); const storage = new StorageService(this.env.DB);
@@ -230,6 +231,7 @@ export class BackupTransferRunner {
scanStartMs = now.getTime(); scanStartMs = now.getTime();
for (const destination of dueDestinations) { for (const destination of dueDestinations) {
await this.touchJob(token); await this.touchJob(token);
try {
await executeConfiguredBackup( await executeConfiguredBackup(
this.env, this.env,
storage, storage,
@@ -239,12 +241,20 @@ export class BackupTransferRunner {
() => this.touchJob(token) () => this.touchJob(token)
); );
completed += 1; completed += 1;
} catch (error) {
failures.push({
destinationId: destination.id,
error: error instanceof Error ? error.message : 'Scheduled backup failed',
});
}
} }
} }
return new Response(JSON.stringify({ return new Response(JSON.stringify({
ok: true, ok: true,
completed, completed,
failed: failures.length,
failures,
}), { }), {
status: 200, status: 200,
headers: { headers: {
@@ -318,7 +328,8 @@ export class BackupTransferRunner {
replaceExisting, replaceExisting,
!checksumOk, !checksumOk,
body.auditMetadata || null, body.auditMetadata || null,
targetDeviceIdentifier targetDeviceIdentifier,
() => this.touchJob(token)
); );
return new Response(JSON.stringify(result.result), { return new Response(JSON.stringify(result.result), {
+61 -26
View File
@@ -21,6 +21,7 @@ import {
repairBackupSettings, repairBackupSettings,
requireBackupDestination, requireBackupDestination,
saveBackupSettings, saveBackupSettings,
updateBackupDestinationRuntime,
} from '../services/backup-config'; } from '../services/backup-config';
import { import {
type BackupImportExecutionResult, type BackupImportExecutionResult,
@@ -260,6 +261,30 @@ async function uploadRemoteAttachmentChunk(
} }
} }
async function verifyUploadedBackupArchive(
session: RemoteBackupTransferSession,
archive: BackupArchiveBundle
): Promise<'metadata' | 'download'> {
try {
const stat = await session.stat(archive.fileName);
if (stat?.size === archive.bytes.byteLength) {
return 'metadata';
}
} catch {
// Fall through to a full read-back verification when lightweight metadata is unavailable.
}
const remoteFile = await session.download(archive.fileName);
const checksumOk = await verifyBackupArchiveFileNameChecksum(remoteFile.bytes, archive.fileName);
if (!checksumOk) {
throw new Error('Remote backup ZIP checksum verification failed');
}
if (remoteFile.bytes.byteLength !== archive.bytes.byteLength) {
throw new Error('Remote backup ZIP size verification failed');
}
return 'download';
}
export async function executeConfiguredBackup( export async function executeConfiguredBackup(
env: Env, env: Env,
storage: StorageService, storage: StorageService,
@@ -287,12 +312,14 @@ export async function executeConfiguredBackup(
const destination = requireBackupDestination(currentSettings, destinationId); const destination = requireBackupDestination(currentSettings, destinationId);
const now = new Date(); const now = new Date();
destination.runtime.lastAttemptAt = now.toISOString();
destination.runtime.lastAttemptLocalDate = getBackupLocalDateKey(now, destination.schedule.timezone);
destination.runtime.lastErrorAt = null;
destination.runtime.lastErrorMessage = null;
await touchLease(); await touchLease();
await saveBackupSettings(storage, env, currentSettings); destination.runtime = await updateBackupDestinationRuntime(storage, destination.id, (runtime) => ({
...runtime,
lastAttemptAt: now.toISOString(),
lastAttemptLocalDate: getBackupLocalDateKey(now, destination.schedule.timezone),
lastErrorAt: null,
lastErrorMessage: null,
}));
try { try {
await touchLease(); await touchLease();
@@ -354,6 +381,7 @@ export async function executeConfiguredBackup(
} }
} }
let upload: Awaited<ReturnType<typeof uploadBackupArchive>> | null = null; let upload: Awaited<ReturnType<typeof uploadBackupArchive>> | null = null;
let uploadVerificationMethod: 'metadata' | 'download' | null = null;
for (let attempt = 1; attempt <= maxArchiveUploadAttempts; attempt++) { for (let attempt = 1; attempt <= maxArchiveUploadAttempts; attempt++) {
await touchLease(); await touchLease();
await progress?.({ await progress?.({
@@ -373,14 +401,7 @@ export async function executeConfiguredBackup(
stageTitle: 'txt_backup_remote_run_progress_verify_title', stageTitle: 'txt_backup_remote_run_progress_verify_title',
stageDetail: 'txt_backup_remote_run_progress_verify_detail', stageDetail: 'txt_backup_remote_run_progress_verify_detail',
}); });
const remoteFile = await remoteSession.download(archive.fileName); uploadVerificationMethod = await verifyUploadedBackupArchive(remoteSession, archive);
const checksumOk = await verifyBackupArchiveFileNameChecksum(remoteFile.bytes, archive.fileName);
if (!checksumOk) {
throw new Error('Remote backup ZIP checksum verification failed');
}
if (remoteFile.bytes.byteLength !== archive.bytes.byteLength) {
throw new Error('Remote backup ZIP size verification failed');
}
break; break;
} catch (error) { } catch (error) {
await remoteSession.deleteFile(archive.fileName).catch(() => undefined); await remoteSession.deleteFile(archive.fileName).catch(() => undefined);
@@ -409,14 +430,16 @@ export async function executeConfiguredBackup(
pruneErrorMessage = error instanceof Error ? error.message : 'Old backup cleanup failed'; pruneErrorMessage = error instanceof Error ? error.message : 'Old backup cleanup failed';
} }
destination.runtime.lastSuccessAt = new Date().toISOString();
destination.runtime.lastErrorAt = null;
destination.runtime.lastErrorMessage = null;
destination.runtime.lastUploadedFileName = archive.fileName;
destination.runtime.lastUploadedSizeBytes = archive.bytes.byteLength;
destination.runtime.lastUploadedDestination = upload.remotePath;
await touchLease(); await touchLease();
await saveBackupSettings(storage, env, currentSettings); destination.runtime = await updateBackupDestinationRuntime(storage, destination.id, (runtime) => ({
...runtime,
lastSuccessAt: new Date().toISOString(),
lastErrorAt: null,
lastErrorMessage: null,
lastUploadedFileName: archive.fileName,
lastUploadedSizeBytes: archive.bytes.byteLength,
lastUploadedDestination: upload.remotePath,
}));
await touchLease(); await touchLease();
await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}`, 'backup', null, { await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}`, 'backup', null, {
@@ -426,6 +449,7 @@ export async function executeConfiguredBackup(
fileName: archive.fileName, fileName: archive.fileName,
fileBytes: archive.bytes.byteLength, fileBytes: archive.bytes.byteLength,
uploadVerificationAttempts: maxArchiveUploadAttempts, uploadVerificationAttempts: maxArchiveUploadAttempts,
uploadVerificationMethod,
prunedFileCount, prunedFileCount,
pruneError: pruneErrorMessage, pruneError: pruneErrorMessage,
...(auditMetadata || {}), ...(auditMetadata || {}),
@@ -448,15 +472,18 @@ export async function executeConfiguredBackup(
provider: upload.provider, provider: upload.provider,
}; };
} catch (error) { } catch (error) {
destination.runtime.lastErrorAt = new Date().toISOString(); const errorMessage = error instanceof Error ? error.message : 'Backup upload failed';
destination.runtime.lastErrorMessage = error instanceof Error ? error.message : 'Backup upload failed';
await touchLease(); await touchLease();
await saveBackupSettings(storage, env, currentSettings); destination.runtime = await updateBackupDestinationRuntime(storage, destination.id, (runtime) => ({
...runtime,
lastErrorAt: new Date().toISOString(),
lastErrorMessage: errorMessage,
}));
await touchLease(); await touchLease();
await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}.failed`, 'backup', null, { await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}.failed`, 'backup', null, {
...getBackupDestinationSummary(destination), ...getBackupDestinationSummary(destination),
error: destination.runtime.lastErrorMessage, error: errorMessage,
...(auditMetadata || {}), ...(auditMetadata || {}),
}); });
await progress?.({ await progress?.({
@@ -467,7 +494,7 @@ export async function executeConfiguredBackup(
stageDetail: 'txt_backup_remote_run_progress_failed_detail', stageDetail: 'txt_backup_remote_run_progress_failed_detail',
done: true, done: true,
ok: false, ok: false,
error: destination.runtime.lastErrorMessage, error: errorMessage,
}); });
throw error; throw error;
} }
@@ -655,12 +682,18 @@ export async function importAndAuditRemoteBackupFile(
replaceExisting: boolean, replaceExisting: boolean,
checksumMismatchAccepted: boolean, checksumMismatchAccepted: boolean,
auditMetadata: Record<string, unknown> | null = null, auditMetadata: Record<string, unknown> | null = null,
targetDeviceIdentifier: string | null = null targetDeviceIdentifier: string | null = null,
keepAlive?: (() => Promise<void>) | null
): Promise<BackupImportExecutionResult> { ): Promise<BackupImportExecutionResult> {
const touchLease = async () => {
await keepAlive?.();
};
const restoreFileName = remoteFile.fileName || remotePath.split('/').pop() || remotePath; const restoreFileName = remoteFile.fileName || remotePath.split('/').pop() || remotePath;
await touchLease();
const externalAttachmentBlobNames = collectExternalRemoteAttachmentBlobNames(remoteFile.bytes); const externalAttachmentBlobNames = collectExternalRemoteAttachmentBlobNames(remoteFile.bytes);
const externalAttachmentCache = new Map<string, Uint8Array | null>(); const externalAttachmentCache = new Map<string, Uint8Array | null>();
const progress: BackupRestoreProgressReporter = async (event) => { const progress: BackupRestoreProgressReporter = async (event) => {
await touchLease();
await notifyUserBackupRestoreProgress( await notifyUserBackupRestoreProgress(
env, env,
actorUserId, actorUserId,
@@ -678,6 +711,7 @@ export async function importAndAuditRemoteBackupFile(
replaceExisting, replaceExisting,
{ {
loadAttachment: async (blobName) => { loadAttachment: async (blobName) => {
await touchLease();
const normalized = String(blobName || '').trim(); const normalized = String(blobName || '').trim();
if (!normalized) return null; if (!normalized) return null;
if (externalAttachmentCache.has(normalized)) { if (externalAttachmentCache.has(normalized)) {
@@ -700,6 +734,7 @@ export async function importAndAuditRemoteBackupFile(
} catch { } catch {
externalAttachmentCache.set(normalized, await downloadRemoteAttachmentViaDurableObject(env, destination, normalized).catch(() => null)); externalAttachmentCache.set(normalized, await downloadRemoteAttachmentViaDurableObject(env, destination, normalized).catch(() => null));
} }
await touchLease();
return externalAttachmentCache.get(normalized) || null; return externalAttachmentCache.get(normalized) || null;
}, },
}, },
+10 -2
View File
@@ -32,6 +32,7 @@ import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events'
// attachments, import/export, and current official clients. // attachments, import/export, and current official clients.
export interface CipherResponseOptions { export interface CipherResponseOptions {
preserveRepairableUris?: boolean; preserveRepairableUris?: boolean;
validFolderIds?: ReadonlySet<string>;
} }
export function shouldPreserveRepairableCipherUris(request: Request): boolean { export function shouldPreserveRepairableCipherUris(request: Request): boolean {
@@ -48,6 +49,12 @@ function normalizeOptionalId(value: unknown): string | null {
return normalized ? normalized : null; return normalized ? normalized : null;
} }
function normalizeResponseFolderId(folderId: unknown, validFolderIds?: ReadonlySet<string>): string | null {
const normalized = normalizeOptionalId(folderId);
if (!normalized) return null;
return validFolderIds && !validFolderIds.has(normalized) ? null : normalized;
}
function readBooleanOrFallback(value: unknown, fallback: boolean): boolean { function readBooleanOrFallback(value: unknown, fallback: boolean): boolean {
return typeof value === 'boolean' ? value : fallback; return typeof value === 'boolean' ? value : fallback;
} }
@@ -727,7 +734,7 @@ export function cipherToResponse(
// Pass through ALL stored cipher fields (known + unknown) // Pass through ALL stored cipher fields (known + unknown)
...passthrough, ...passthrough,
// Server-computed / enforced fields (always override) // Server-computed / enforced fields (always override)
folderId: normalizeOptionalId(cipher.folderId), folderId: normalizeResponseFolderId(cipher.folderId, options.validFolderIds),
type: Number(cipher.type) || 1, type: Number(cipher.type) || 1,
organizationId: normalizeOptionalId((passthrough as any).organizationId ?? null), organizationId: normalizeOptionalId((passthrough as any).organizationId ?? null),
organizationUseTotp: !!((passthrough as any).organizationUseTotp ?? false), organizationUseTotp: !!((passthrough as any).organizationUseTotp ?? false),
@@ -785,9 +792,10 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
const attachmentsByCipher = await storage.getAttachmentsByCipherIds( const attachmentsByCipher = await storage.getAttachmentsByCipherIds(
filteredCiphers.map((cipher) => cipher.id) filteredCiphers.map((cipher) => cipher.id)
); );
const validFolderIds = new Set((await storage.getAllFolders(userId)).map((folder) => folder.id));
// Build responses only for the current page to keep pagination cheap. // Build responses only for the current page to keep pagination cheap.
const responseOptions = cipherResponseOptionsForRequest(request); const responseOptions = { ...cipherResponseOptionsForRequest(request), validFolderIds };
const cipherResponses: CipherResponse[] = []; const cipherResponses: CipherResponse[] = [];
for (const cipher of filteredCiphers) { for (const cipher of filteredCiphers) {
const attachments = attachmentsByCipher.get(cipher.id) || []; const attachments = attachmentsByCipher.get(cipher.id) || [];
+2 -1
View File
@@ -88,12 +88,13 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
.map(buildWebAuthnPrfOption) .map(buildWebAuthnPrfOption)
.filter((option): option is NonNullable<typeof option> => !!option); .filter((option): option is NonNullable<typeof option> => !!option);
const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOptions[0] || null); const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOptions[0] || null);
const validFolderIds = new Set(folders.map((folder) => folder.id));
const profile: ProfileResponse = buildProfileResponse(user, env); const profile: ProfileResponse = buildProfileResponse(user, env);
const cipherResponses: CipherResponse[] = []; const cipherResponses: CipherResponse[] = [];
for (const cipher of ciphers) { for (const cipher of ciphers) {
const response = cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || [], { preserveRepairableUris }); const response = cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || [], { preserveRepairableUris, validFolderIds });
if (isCipherResponseSyncCompatible(response)) { if (isCipherResponseSyncCompatible(response)) {
cipherResponses.push(response); cipherResponses.push(response);
} }
+2 -1
View File
@@ -127,6 +127,7 @@ function buildConfigResponse(origin: string) {
'email-verification': true, 'email-verification': true,
'pm-19051-send-email-verification': false, 'pm-19051-send-email-verification': false,
'pm-19148-innovation-archive': true, 'pm-19148-innovation-archive': true,
'pm-30529-webauthn-related-origins': true,
'unauth-ui-refresh': true, 'unauth-ui-refresh': true,
'web-push': false, 'web-push': false,
}, },
@@ -469,7 +470,7 @@ export async function handlePublicRoute(
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute); const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
if (blocked) return blocked; if (blocked) return blocked;
const origin = new URL(request.url).origin; const origin = new URL(request.url).origin;
return jsonResponse(buildConfigResponse(origin)); return jsonResponse(buildConfigResponse(origin), 200, { 'Cache-Control': 'no-store' });
} }
if (path === '/api/version' && method === 'GET') { if (path === '/api/version' && method === 'GET') {
+78 -12
View File
@@ -26,6 +26,7 @@ import {
} from '../../shared/backup-schema'; } from '../../shared/backup-schema';
export const BACKUP_SETTINGS_CONFIG_KEY = 'backup.settings.v1'; export const BACKUP_SETTINGS_CONFIG_KEY = 'backup.settings.v1';
const BACKUP_RUNTIME_CONFIG_KEY = 'backup.runtime.v1';
export const BACKUP_SCHEDULER_WINDOW_MINUTES = 5; export const BACKUP_SCHEDULER_WINDOW_MINUTES = 5;
const MAX_BACKUP_DESTINATIONS = 24; const MAX_BACKUP_DESTINATIONS = 24;
@@ -324,6 +325,47 @@ function mapDestinationsById(destinations: BackupDestinationRecord[]): Map<strin
return new Map(destinations.map((destination) => [destination.id, destination])); return new Map(destinations.map((destination) => [destination.id, destination]));
} }
function stripRuntimeFromSettings(settings: BackupSettings): BackupSettings {
return {
destinations: settings.destinations.map((destination) => ({
...destination,
runtime: normalizeRuntime(null),
})),
};
}
function serializeRuntimeState(settings: BackupSettings): string {
return JSON.stringify({
version: 1,
destinations: Object.fromEntries(
settings.destinations.map((destination) => [destination.id, normalizeRuntime(destination.runtime)])
),
});
}
async function loadBackupRuntimeStates(storage: StorageService): Promise<Map<string, BackupRuntimeState>> {
const raw = await storage.getConfigValue(BACKUP_RUNTIME_CONFIG_KEY);
if (!raw) return new Map();
try {
const parsed = JSON.parse(raw) as { destinations?: Record<string, unknown> };
const entries = Object.entries(parsed.destinations || {})
.filter(([id]) => !!asTrimmedString(id))
.map(([id, runtime]) => [id, normalizeRuntime(runtime)] as const);
return new Map(entries);
} catch {
return new Map();
}
}
function mergeRuntimeStates(settings: BackupSettings, runtimes: Map<string, BackupRuntimeState>): BackupSettings {
return {
destinations: settings.destinations.map((destination) => ({
...destination,
runtime: runtimes.get(destination.id) || normalizeRuntime(destination.runtime),
})),
};
}
export function getDefaultBackupSettings(timezone: string = 'UTC'): BackupSettings { export function getDefaultBackupSettings(timezone: string = 'UTC'): BackupSettings {
return createSharedDefaultBackupSettings(assertValidTimeZone(timezone)); return createSharedDefaultBackupSettings(assertValidTimeZone(timezone));
} }
@@ -387,27 +429,30 @@ export function normalizeBackupSettingsInput(
} }
export function serializeBackupSettings(settings: BackupSettings): string { export function serializeBackupSettings(settings: BackupSettings): string {
return JSON.stringify(settings); return JSON.stringify(stripRuntimeFromSettings(settings));
} }
export async function loadBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<BackupSettings> { export async function loadBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<BackupSettings> {
const raw = await storage.getConfigValue(BACKUP_SETTINGS_CONFIG_KEY); const raw = await storage.getConfigValue(BACKUP_SETTINGS_CONFIG_KEY);
const mergeRuntime = async (settings: BackupSettings): Promise<BackupSettings> => (
mergeRuntimeStates(settings, await loadBackupRuntimeStates(storage))
);
if (!raw) { if (!raw) {
const settings = getDefaultBackupSettings(fallbackTimezone); const settings = getDefaultBackupSettings(fallbackTimezone);
await saveBackupSettings(storage, env, settings); await saveBackupSettings(storage, env, settings);
return settings; return mergeRuntime(settings);
} }
const envelope = parseBackupSettingsEnvelope(raw); const envelope = parseBackupSettingsEnvelope(raw);
if (!envelope) { if (!envelope) {
const settings = parseBackupSettings(raw, fallbackTimezone); const settings = parseBackupSettings(raw, fallbackTimezone);
await saveBackupSettings(storage, env, settings); await saveBackupSettings(storage, env, settings);
return settings; return mergeRuntime(settings);
} }
try { try {
const decrypted = await decryptBackupSettingsRuntime(raw, env); const decrypted = await decryptBackupSettingsRuntime(raw, env);
return parseBackupSettings(decrypted, fallbackTimezone); return mergeRuntime(parseBackupSettings(decrypted, fallbackTimezone));
} catch { } catch {
throw new Error('Backup settings need administrator reactivation after restore'); throw new Error('Backup settings need administrator reactivation after restore');
} }
@@ -417,6 +462,27 @@ export async function saveBackupSettings(storage: StorageService, env: Env, sett
const users = await storage.getAllUsers(); const users = await storage.getAllUsers();
const encrypted = await encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users); const encrypted = await encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
await storage.setConfigValue(BACKUP_SETTINGS_CONFIG_KEY, encrypted); await storage.setConfigValue(BACKUP_SETTINGS_CONFIG_KEY, encrypted);
await saveBackupRuntimeStates(storage, settings);
}
export async function saveBackupRuntimeStates(storage: StorageService, settings: BackupSettings): Promise<void> {
await storage.setConfigValue(BACKUP_RUNTIME_CONFIG_KEY, serializeRuntimeState(settings));
}
export async function updateBackupDestinationRuntime(
storage: StorageService,
destinationId: string,
mutator: (runtime: BackupRuntimeState) => BackupRuntimeState
): Promise<BackupRuntimeState> {
const runtimes = await loadBackupRuntimeStates(storage);
const current = runtimes.get(destinationId) || normalizeRuntime(null);
const next = normalizeRuntime(mutator(current));
runtimes.set(destinationId, next);
await storage.setConfigValue(BACKUP_RUNTIME_CONFIG_KEY, JSON.stringify({
version: 1,
destinations: Object.fromEntries(runtimes.entries()),
}));
return next;
} }
export async function normalizeImportedBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<void> { export async function normalizeImportedBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<void> {
@@ -596,9 +662,9 @@ export function hasBackupSlotBetween(
const endMs = endExclusive.getTime(); const endMs = endExclusive.getTime();
if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) return false; if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) return false;
const lastAttemptAt = destination.runtime.lastAttemptAt ? new Date(destination.runtime.lastAttemptAt) : null; const lastSuccessAt = destination.runtime.lastSuccessAt ? new Date(destination.runtime.lastSuccessAt) : null;
const lastAttemptMs = lastAttemptAt && Number.isFinite(lastAttemptAt.getTime()) const lastSuccessMs = lastSuccessAt && Number.isFinite(lastSuccessAt.getTime())
? lastAttemptAt.getTime() ? lastSuccessAt.getTime()
: Number.NEGATIVE_INFINITY; : Number.NEGATIVE_INFINITY;
const dayCursor = new Date(startMs); const dayCursor = new Date(startMs);
@@ -620,7 +686,7 @@ export function hasBackupSlotBetween(
for (const slotStart of slotStarts) { for (const slotStart of slotStarts) {
const slotStartMs = slotStart.getTime(); const slotStartMs = slotStart.getTime();
if (slotStartMs < startMs || slotStartMs >= endMs) continue; if (slotStartMs < startMs || slotStartMs >= endMs) continue;
if (lastAttemptMs >= slotStartMs) continue; if (lastSuccessMs >= slotStartMs) continue;
return true; return true;
} }
} }
@@ -637,9 +703,9 @@ export function isBackupDueNow(
): boolean { ): boolean {
if (!destination.schedule.enabled) return false; if (!destination.schedule.enabled) return false;
const toleranceMs = Math.max(1, windowMinutes) * 60 * 1000; const toleranceMs = Math.max(1, windowMinutes) * 60 * 1000;
const lastAttemptAt = destination.runtime.lastAttemptAt ? new Date(destination.runtime.lastAttemptAt) : null; const lastSuccessAt = destination.runtime.lastSuccessAt ? new Date(destination.runtime.lastSuccessAt) : null;
const lastAttemptMs = lastAttemptAt && Number.isFinite(lastAttemptAt.getTime()) const lastSuccessMs = lastSuccessAt && Number.isFinite(lastSuccessAt.getTime())
? lastAttemptAt.getTime() ? lastSuccessAt.getTime()
: Number.NEGATIVE_INFINITY; : Number.NEGATIVE_INFINITY;
const localDateKey = getBackupLocalDateKey(now, destination.schedule.timezone); const localDateKey = getBackupLocalDateKey(now, destination.schedule.timezone);
const slotStarts = getBackupSlotStartsForLocalDay( const slotStarts = getBackupSlotStartsForLocalDay(
@@ -652,7 +718,7 @@ export function isBackupDueNow(
for (const slotStart of slotStarts) { for (const slotStart of slotStarts) {
const slotStartMs = slotStart.getTime(); const slotStartMs = slotStart.getTime();
if (now.getTime() < slotStartMs || now.getTime() >= slotStartMs + toleranceMs) continue; if (now.getTime() < slotStartMs || now.getTime() >= slotStartMs + toleranceMs) continue;
if (lastAttemptMs >= slotStartMs) return false; if (lastSuccessMs >= slotStartMs) return false;
return true; return true;
} }
return false; return false;
+45 -6
View File
@@ -33,6 +33,13 @@ export interface RemoteBackupFile {
bytes: Uint8Array; bytes: Uint8Array;
} }
export interface RemoteBackupFileStat {
provider: BackupDestinationType;
remotePath: string;
size: number | null;
modifiedAt: string | null;
}
export interface RemoteBackupFilePutOptions { export interface RemoteBackupFilePutOptions {
contentType?: string; contentType?: string;
} }
@@ -433,6 +440,10 @@ async function deleteFromWebDav(config: WebDavBackupDestination, relativePath: s
} }
async function existsInWebDav(config: WebDavBackupDestination, relativePath: string): Promise<boolean> { async function existsInWebDav(config: WebDavBackupDestination, relativePath: string): Promise<boolean> {
return (await statWebDavFile(config, relativePath)) !== null;
}
async function statWebDavFile(config: WebDavBackupDestination, relativePath: string): Promise<RemoteBackupFileStat | null> {
const authHeader = toBasicAuthHeader(config.username, config.password); const authHeader = toBasicAuthHeader(config.username, config.password);
const remotePath = webDavFullPath(config, relativePath); const remotePath = webDavFullPath(config, relativePath);
const response = await fetch(buildWebDavUrl(config.baseUrl, remotePath), { const response = await fetch(buildWebDavUrl(config.baseUrl, remotePath), {
@@ -441,11 +452,17 @@ async function existsInWebDav(config: WebDavBackupDestination, relativePath: str
Authorization: authHeader, Authorization: authHeader,
}, },
}); });
if (response.status === 404) return false; if (response.status === 404) return null;
if (!response.ok) { if (!response.ok) {
throw new Error(`WebDAV existence check failed: ${response.status}`); throw new Error(`WebDAV existence check failed: ${response.status}`);
} }
return true; const size = Number(response.headers.get('Content-Length') || '');
return {
provider: 'webdav',
remotePath: normalizeRelativePath(relativePath),
size: Number.isFinite(size) ? size : null,
modifiedAt: parseHttpDate(response.headers.get('Last-Modified') || ''),
};
} }
function isBucketHostedS3Endpoint(endpoint: URL, bucket: string): boolean { function isBucketHostedS3Endpoint(endpoint: URL, bucket: string): boolean {
@@ -540,10 +557,16 @@ async function listS3Entries(config: S3BackupDestination, relativePath: string):
const currentPath = normalizeRelativePath(relativePath); const currentPath = normalizeRelativePath(relativePath);
const targetPrefixBase = normalizeS3ObjectKey(config, currentPath); const targetPrefixBase = normalizeS3ObjectKey(config, currentPath);
const targetPrefix = trimSlashes(targetPrefixBase) ? `${trimSlashes(targetPrefixBase)}/` : ''; const targetPrefix = trimSlashes(targetPrefixBase) ? `${trimSlashes(targetPrefixBase)}/` : '';
const rootPrefix = trimSlashes(config.rootPath);
const items: RemoteBackupItem[] = [];
let continuationToken = '';
do {
const url = s3BucketBaseUrl(config); const url = s3BucketBaseUrl(config);
url.searchParams.set('list-type', '2'); url.searchParams.set('list-type', '2');
url.searchParams.set('delimiter', '/'); url.searchParams.set('delimiter', '/');
if (targetPrefix) url.searchParams.set('prefix', targetPrefix); if (targetPrefix) url.searchParams.set('prefix', targetPrefix);
if (continuationToken) url.searchParams.set('continuation-token', continuationToken);
const response = await signedS3Request(config, 'GET', url); const response = await signedS3Request(config, 'GET', url);
if (!response.ok) { if (!response.ok) {
@@ -551,8 +574,6 @@ async function listS3Entries(config: S3BackupDestination, relativePath: string):
} }
const xml = await response.text(); const xml = await response.text();
const rootPrefix = trimSlashes(config.rootPath);
const items: RemoteBackupItem[] = [];
for (const prefix of extractXmlBlocks(xml, 'CommonPrefixes')) { for (const prefix of extractXmlBlocks(xml, 'CommonPrefixes')) {
const fullPrefix = trimSlashes(extractXmlFirst(prefix, 'Prefix') || ''); const fullPrefix = trimSlashes(extractXmlFirst(prefix, 'Prefix') || '');
@@ -596,6 +617,9 @@ async function listS3Entries(config: S3BackupDestination, relativePath: string):
}); });
} }
continuationToken = extractXmlFirst(xml, 'NextContinuationToken') || '';
} while (continuationToken);
const deduped = new Map<string, RemoteBackupItem>(); const deduped = new Map<string, RemoteBackupItem>();
for (const item of items) deduped.set(`${item.isDirectory ? 'd' : 'f'}:${item.path}`, item); for (const item of items) deduped.set(`${item.isDirectory ? 'd' : 'f'}:${item.path}`, item);
@@ -637,14 +661,24 @@ async function deleteFromS3(config: S3BackupDestination, relativePath: string):
} }
async function existsInS3(config: S3BackupDestination, relativePath: string): Promise<boolean> { async function existsInS3(config: S3BackupDestination, relativePath: string): Promise<boolean> {
return (await statS3File(config, relativePath)) !== null;
}
async function statS3File(config: S3BackupDestination, relativePath: string): Promise<RemoteBackupFileStat | null> {
const objectKey = normalizeS3ObjectKey(config, relativePath); const objectKey = normalizeS3ObjectKey(config, relativePath);
const url = s3ObjectUrl(config, objectKey); const url = s3ObjectUrl(config, objectKey);
const response = await signedS3Request(config, 'HEAD', url); const response = await signedS3Request(config, 'HEAD', url);
if (response.status === 404) return false; if (response.status === 404) return null;
if (!response.ok) { if (!response.ok) {
throw new Error(`S3 existence check failed: ${response.status}`); throw new Error(`S3 existence check failed: ${response.status}`);
} }
return true; const size = Number(response.headers.get('Content-Length') || '');
return {
provider: 's3',
remotePath: normalizeRelativePath(relativePath),
size: Number.isFinite(size) ? size : null,
modifiedAt: parseHttpDate(response.headers.get('Last-Modified') || ''),
};
} }
interface ConfiguredDestinationAdapter { interface ConfiguredDestinationAdapter {
@@ -656,6 +690,7 @@ interface ConfiguredDestinationAdapter {
download: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<RemoteBackupFile>; download: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<RemoteBackupFile>;
deleteFile: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<void>; deleteFile: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<void>;
exists: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<boolean>; exists: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<boolean>;
stat: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<RemoteBackupFileStat | null>;
} }
export interface RemoteBackupTransferSession { export interface RemoteBackupTransferSession {
@@ -666,6 +701,7 @@ export interface RemoteBackupTransferSession {
download(relativePath: string): Promise<RemoteBackupFile>; download(relativePath: string): Promise<RemoteBackupFile>;
deleteFile(relativePath: string): Promise<void>; deleteFile(relativePath: string): Promise<void>;
exists(relativePath: string): Promise<boolean>; exists(relativePath: string): Promise<boolean>;
stat(relativePath: string): Promise<RemoteBackupFileStat | null>;
} }
function resolveConfiguredDestinationAdapter( function resolveConfiguredDestinationAdapter(
@@ -683,6 +719,7 @@ function resolveConfiguredDestinationAdapter(
download: (config, relativePath) => downloadFromWebDav(config as WebDavBackupDestination, relativePath), download: (config, relativePath) => downloadFromWebDav(config as WebDavBackupDestination, relativePath),
deleteFile: (config, relativePath) => deleteFromWebDav(config as WebDavBackupDestination, relativePath), deleteFile: (config, relativePath) => deleteFromWebDav(config as WebDavBackupDestination, relativePath),
exists: (config, relativePath) => existsInWebDav(config as WebDavBackupDestination, relativePath), exists: (config, relativePath) => existsInWebDav(config as WebDavBackupDestination, relativePath),
stat: (config, relativePath) => statWebDavFile(config as WebDavBackupDestination, relativePath),
}; };
} }
if (destination.type === 's3') { if (destination.type === 's3') {
@@ -695,6 +732,7 @@ function resolveConfiguredDestinationAdapter(
download: (config, relativePath) => downloadFromS3(config as S3BackupDestination, relativePath), download: (config, relativePath) => downloadFromS3(config as S3BackupDestination, relativePath),
deleteFile: (config, relativePath) => deleteFromS3(config as S3BackupDestination, relativePath), deleteFile: (config, relativePath) => deleteFromS3(config as S3BackupDestination, relativePath),
exists: (config, relativePath) => existsInS3(config as S3BackupDestination, relativePath), exists: (config, relativePath) => existsInS3(config as S3BackupDestination, relativePath),
stat: (config, relativePath) => statS3File(config as S3BackupDestination, relativePath),
}; };
} }
@@ -730,6 +768,7 @@ export function createRemoteBackupTransferSession(destination: BackupDestination
download: async (relativePath: string) => adapter.download(adapter.config, relativePath), download: async (relativePath: string) => adapter.download(adapter.config, relativePath),
deleteFile: async (relativePath: string) => adapter.deleteFile(adapter.config, normalizeRelativePath(relativePath)), deleteFile: async (relativePath: string) => adapter.deleteFile(adapter.config, normalizeRelativePath(relativePath)),
exists: async (relativePath: string) => adapter.exists(adapter.config, normalizeRelativePath(relativePath)), exists: async (relativePath: string) => adapter.exists(adapter.config, normalizeRelativePath(relativePath)),
stat: async (relativePath: string) => adapter.stat(adapter.config, normalizeRelativePath(relativePath)),
}; };
} }
+14 -4
View File
@@ -44,9 +44,14 @@ export async function clearFolderFromCiphers(
`UPDATE ciphers `UPDATE ciphers
SET folder_id = NULL, updated_at = ?, SET folder_id = NULL, updated_at = ?,
data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate') data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate')
WHERE user_id = ? AND folder_id = ?` WHERE user_id = ?
AND (
folder_id = ?
OR json_extract(data, '$.folderId') = ?
OR json_extract(data, '$.folder_id') = ?
)`
) )
.bind(now, userId, folderId) .bind(now, userId, folderId, folderId, folderId)
.run(); .run();
} }
@@ -71,9 +76,14 @@ export async function bulkDeleteFolders(
`UPDATE ciphers `UPDATE ciphers
SET folder_id = NULL, updated_at = ?, SET folder_id = NULL, updated_at = ?,
data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate') data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate')
WHERE user_id = ? AND folder_id IN (${placeholders})` WHERE user_id = ?
AND (
folder_id IN (${placeholders})
OR json_extract(data, '$.folderId') IN (${placeholders})
OR json_extract(data, '$.folder_id') IN (${placeholders})
)`
) )
.bind(now, userId, ...chunk) .bind(now, userId, ...chunk, ...chunk, ...chunk)
.run(); .run();
await db await db
+2
View File
@@ -1959,6 +1959,7 @@ export default function App() {
lockTimeoutMinutes, lockTimeoutMinutes,
sessionTimeoutAction, sessionTimeoutAction,
authorizedDevices: authorizedDevicesQuery.data || [], authorizedDevices: authorizedDevicesQuery.data || [],
currentDeviceIdentifier: getCurrentDeviceIdentifier(),
authorizedDevicesLoading: authorizedDevicesQuery.isFetching, authorizedDevicesLoading: authorizedDevicesQuery.isFetching,
authorizedDevicesError: authorizedDevicesQuery.isError && !authorizedDevicesQuery.data ? t('txt_load_devices_failed') : '', authorizedDevicesError: authorizedDevicesQuery.isError && !authorizedDevicesQuery.data ? t('txt_load_devices_failed') : '',
domainRules: IS_DEMO_MODE ? demoDomainRules : domainRulesQuery.data || null, domainRules: IS_DEMO_MODE ? demoDomainRules : domainRulesQuery.data || null,
@@ -2031,6 +2032,7 @@ export default function App() {
onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust, onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust,
onTrustDevicePermanently: accountSecurityActions.openTrustDevicePermanently, onTrustDevicePermanently: accountSecurityActions.openTrustDevicePermanently,
onRemoveDevice: accountSecurityActions.openRemoveDevice, onRemoveDevice: accountSecurityActions.openRemoveDevice,
onRemoveSelectedDevices: accountSecurityActions.openRemoveSelectedDevices,
onRevokeAllDeviceTrust: accountSecurityActions.openRevokeAllDeviceTrust, onRevokeAllDeviceTrust: accountSecurityActions.openRevokeAllDeviceTrust,
onRemoveAllDevices: accountSecurityActions.openRemoveAllDevices, onRemoveAllDevices: accountSecurityActions.openRemoveAllDevices,
onRefreshAdmin: adminActions.refreshAdmin, onRefreshAdmin: adminActions.refreshAdmin,
+4 -5
View File
@@ -57,6 +57,7 @@ export interface AppMainRoutesProps {
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30; lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
sessionTimeoutAction: 'lock' | 'logout'; sessionTimeoutAction: 'lock' | 'logout';
authorizedDevices: AuthorizedDevice[]; authorizedDevices: AuthorizedDevice[];
currentDeviceIdentifier: string;
authorizedDevicesLoading: boolean; authorizedDevicesLoading: boolean;
authorizedDevicesError: string; authorizedDevicesError: string;
domainRules: DomainRules | null; domainRules: DomainRules | null;
@@ -130,6 +131,7 @@ export interface AppMainRoutesProps {
onRevokeDeviceTrust: (device: AuthorizedDevice) => void; onRevokeDeviceTrust: (device: AuthorizedDevice) => void;
onTrustDevicePermanently: (device: AuthorizedDevice) => void; onTrustDevicePermanently: (device: AuthorizedDevice) => void;
onRemoveDevice: (device: AuthorizedDevice) => void; onRemoveDevice: (device: AuthorizedDevice) => void;
onRemoveSelectedDevices: (devices: AuthorizedDevice[]) => void;
onRevokeAllDeviceTrust: () => void; onRevokeAllDeviceTrust: () => void;
onRemoveAllDevices: () => void; onRemoveAllDevices: () => void;
onCreateInvite: (hours: number) => Promise<void>; onCreateInvite: (hours: number) => Promise<void>;
@@ -275,11 +277,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}
@@ -352,6 +349,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
<Suspense fallback={<RouteContentFallback />}> <Suspense fallback={<RouteContentFallback />}>
<SecurityDevicesPage <SecurityDevicesPage
devices={props.authorizedDevices} devices={props.authorizedDevices}
currentDeviceIdentifier={props.currentDeviceIdentifier}
loading={props.authorizedDevicesLoading} loading={props.authorizedDevicesLoading}
error={props.authorizedDevicesError} error={props.authorizedDevicesError}
pendingAuthRequests={props.pendingAuthRequests} pendingAuthRequests={props.pendingAuthRequests}
@@ -364,6 +362,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
onRevokeTrust={props.onRevokeDeviceTrust} onRevokeTrust={props.onRevokeDeviceTrust}
onTrustPermanently={props.onTrustDevicePermanently} onTrustPermanently={props.onTrustDevicePermanently}
onRemoveDevice={props.onRemoveDevice} onRemoveDevice={props.onRemoveDevice}
onRemoveSelectedDevices={props.onRemoveSelectedDevices}
onRevokeAll={props.onRevokeAllDeviceTrust} onRevokeAll={props.onRevokeAllDeviceTrust}
onRemoveAll={props.onRemoveAllDevices} onRemoveAll={props.onRemoveAllDevices}
/> />
+12 -4
View File
@@ -56,6 +56,7 @@ type PendingRestoreIntegrity =
type PendingBackupVerification = type PendingBackupVerification =
| { action: 'export' } | { action: 'export' }
| { action: 'saveSettings' } | { action: 'saveSettings' }
| { action: 'deleteDestination'; destinationId: string; settings: AdminBackupSettings }
| { action: 'import'; replaceExisting: boolean; allowChecksumMismatch: boolean; knownIntegrity?: BackupFileIntegrityCheckResult } | { action: 'import'; replaceExisting: boolean; allowChecksumMismatch: boolean; knownIntegrity?: BackupFileIntegrityCheckResult }
| { action: 'runRemoteBackup' } | { action: 'runRemoteBackup' }
| { action: 'downloadRemote'; path: string } | { action: 'downloadRemote'; path: string }
@@ -192,7 +193,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
const [downloadingRemotePercent, setDownloadingRemotePercent] = useState<number | null>(null); const [downloadingRemotePercent, setDownloadingRemotePercent] = useState<number | null>(null);
const [restoringRemotePath, setRestoringRemotePath] = useState(''); const [restoringRemotePath, setRestoringRemotePath] = useState('');
const [deletingRemotePath, setDeletingRemotePath] = useState(''); const [deletingRemotePath, setDeletingRemotePath] = useState('');
const [localError, setLocalError] = useState(''); const [, setLocalError] = useState('');
const [restoreProgress, setRestoreProgress] = useState<BackupProgressState | null>(null); const [restoreProgress, setRestoreProgress] = useState<BackupProgressState | null>(null);
const [restoreElapsedSeconds, setRestoreElapsedSeconds] = useState(0); const [restoreElapsedSeconds, setRestoreElapsedSeconds] = useState(0);
const [confirmLocalRestoreOpen, setConfirmLocalRestoreOpen] = useState(false); const [confirmLocalRestoreOpen, setConfirmLocalRestoreOpen] = useState(false);
@@ -240,7 +241,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
const backupPasswordPromptTitle = const backupPasswordPromptTitle =
pendingBackupVerification?.action === 'export' pendingBackupVerification?.action === 'export'
? t('txt_backup_export') ? t('txt_backup_export')
: pendingBackupVerification?.action === 'saveSettings' : pendingBackupVerification?.action === 'saveSettings' || pendingBackupVerification?.action === 'deleteDestination'
? t('txt_backup_save_settings') ? t('txt_backup_save_settings')
: pendingBackupVerification?.action === 'runRemoteBackup' : pendingBackupVerification?.action === 'runRemoteBackup'
? t('txt_backup_run_manual') ? t('txt_backup_run_manual')
@@ -501,10 +502,16 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
destinations: (savedSettings?.destinations || []).filter((destination) => destination.id !== destinationIdToDelete), destinations: (savedSettings?.destinations || []).filter((destination) => destination.id !== destinationIdToDelete),
}; };
setPendingBackupVerification({ action: 'deleteDestination', destinationId: destinationIdToDelete, settings: nextSettings });
setBackupPasswordValue('');
setConfirmDeleteDestinationOpen(false);
}
async function executeDeleteDestination(masterPassword: string, destinationIdToDelete: string, payload: AdminBackupSettings) {
setSavingSettings(true); setSavingSettings(true);
setLocalError(''); setLocalError('');
try { try {
const saved = await props.onSaveSettings(nextSettings); const saved = await props.onSaveSettings(masterPassword, payload);
const nextDraftDestinations = settings.destinations.filter((destination) => destination.id !== destinationIdToDelete); const nextDraftDestinations = settings.destinations.filter((destination) => destination.id !== destinationIdToDelete);
const nextSelected = getFirstVisibleDestinationId({ destinations: nextDraftDestinations }) || getFirstVisibleDestinationId(saved); const nextSelected = getFirstVisibleDestinationId({ destinations: nextDraftDestinations }) || getFirstVisibleDestinationId(saved);
setSavedSettings(saved); setSavedSettings(saved);
@@ -865,6 +872,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
await executeExport(masterPassword); await executeExport(masterPassword);
} else if (request.action === 'saveSettings') { } else if (request.action === 'saveSettings') {
await executeSaveSettings(masterPassword); await executeSaveSettings(masterPassword);
} else if (request.action === 'deleteDestination') {
await executeDeleteDestination(masterPassword, request.destinationId, request.settings);
} else if (request.action === 'import') { } else if (request.action === 'import') {
await executeLocalRestore(masterPassword, request.replaceExisting, request.allowChecksumMismatch, request.knownIntegrity); await executeLocalRestore(masterPassword, request.replaceExisting, request.allowChecksumMismatch, request.knownIntegrity);
} else if (request.action === 'runRemoteBackup') { } else if (request.action === 'runRemoteBackup') {
@@ -965,7 +974,6 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
}} }}
/> />
{localError ? <div className="local-error">{localError}</div> : null}
{restoreProgress && typeof document !== 'undefined' ? createPortal(( {restoreProgress && typeof document !== 'undefined' ? createPortal((
<div className="restore-progress-overlay" aria-live="polite"> <div className="restore-progress-overlay" aria-live="polite">
<section className="restore-progress-card restore-progress-modal"> <section className="restore-progress-card restore-progress-modal">
+60 -8
View File
@@ -1,5 +1,5 @@
import { useState } from 'preact/hooks'; import { useState } from 'preact/hooks';
import { Clock3, Pencil, RefreshCw, ShieldCheck, ShieldOff, Trash2 } from 'lucide-preact'; import { CheckSquare, Clock3, Pencil, RefreshCw, ShieldCheck, ShieldOff, Trash2 } from 'lucide-preact';
import ConfirmDialog from '@/components/ConfirmDialog'; import ConfirmDialog from '@/components/ConfirmDialog';
import LoadingState from '@/components/LoadingState'; import LoadingState from '@/components/LoadingState';
import PendingAuthRequestsPanel from '@/components/PendingAuthRequestsPanel'; import PendingAuthRequestsPanel from '@/components/PendingAuthRequestsPanel';
@@ -8,6 +8,7 @@ import { t } from '@/lib/i18n';
interface SecurityDevicesPageProps { interface SecurityDevicesPageProps {
devices: AuthorizedDevice[]; devices: AuthorizedDevice[];
currentDeviceIdentifier: string;
loading: boolean; loading: boolean;
error: string; error: string;
pendingAuthRequests: AuthRequest[]; pendingAuthRequests: AuthRequest[];
@@ -20,6 +21,7 @@ interface SecurityDevicesPageProps {
onRevokeTrust: (device: AuthorizedDevice) => void; onRevokeTrust: (device: AuthorizedDevice) => void;
onTrustPermanently: (device: AuthorizedDevice) => void; onTrustPermanently: (device: AuthorizedDevice) => void;
onRemoveDevice: (device: AuthorizedDevice) => void; onRemoveDevice: (device: AuthorizedDevice) => void;
onRemoveSelectedDevices: (devices: AuthorizedDevice[]) => void;
onRevokeAll: () => void; onRevokeAll: () => void;
onRemoveAll: () => void; onRemoveAll: () => void;
} }
@@ -62,6 +64,14 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
const [editingDevice, setEditingDevice] = useState<AuthorizedDevice | null>(null); const [editingDevice, setEditingDevice] = useState<AuthorizedDevice | null>(null);
const [deviceNote, setDeviceNote] = useState(''); const [deviceNote, setDeviceNote] = useState('');
const [savingNote, setSavingNote] = useState(false); const [savingNote, setSavingNote] = useState(false);
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]);
const currentDeviceIdentifier = props.currentDeviceIdentifier;
const selectableDevices = props.devices.filter((device) => (
device.identifier !== currentDeviceIdentifier
));
const selectedDeviceIdSet = new Set(selectedDeviceIds);
const selectedDevices = selectableDevices.filter((device) => selectedDeviceIdSet.has(device.identifier));
const allSelectableSelected = selectableDevices.length > 0 && selectedDevices.length === selectableDevices.length;
async function handleSaveDeviceNote(): Promise<void> { async function handleSaveDeviceNote(): Promise<void> {
if (!editingDevice || savingNote) return; if (!editingDevice || savingNote) return;
@@ -75,6 +85,19 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
} }
} }
function toggleSelectAllDevices(): void {
setSelectedDeviceIds(allSelectableSelected ? [] : selectableDevices.map((device) => device.identifier));
}
function toggleSelectedDevice(device: AuthorizedDevice): void {
if (device.identifier === currentDeviceIdentifier) return;
setSelectedDeviceIds((current) => (
current.includes(device.identifier)
? current.filter((id) => id !== device.identifier)
: [...current, device.identifier]
));
}
return ( return (
<> <>
<div className="stack"> <div className="stack">
@@ -91,7 +114,7 @@ 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>
@@ -101,6 +124,27 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
<RefreshCw size={14} className="btn-icon" /> <RefreshCw size={14} className="btn-icon" />
{t('txt_refresh')} {t('txt_refresh')}
</button> </button>
<button
type="button"
className="btn btn-secondary small"
disabled={props.loading || selectableDevices.length === 0}
onClick={toggleSelectAllDevices}
>
<CheckSquare size={14} className="btn-icon" />
{allSelectableSelected ? t('txt_clear_selection') : t('txt_select_all')}
</button>
<button
type="button"
className="btn btn-danger small"
disabled={selectedDevices.length === 0}
onClick={() => {
props.onRemoveSelectedDevices(selectedDevices);
setSelectedDeviceIds([]);
}}
>
<Trash2 size={14} className="btn-icon" />
{t('txt_remove_selected_devices', { count: selectedDevices.length })}
</button>
<button type="button" className="btn btn-danger small" onClick={props.onRevokeAll}> <button type="button" className="btn btn-danger small" onClick={props.onRevokeAll}>
<ShieldOff size={14} className="btn-icon" /> <ShieldOff size={14} className="btn-icon" />
{t('txt_revoke_all_trusted')} {t('txt_revoke_all_trusted')}
@@ -111,10 +155,6 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
</button> </button>
</div> </div>
</div> </div>
</section>
<section className="card">
<h3 className="section-title-flush">{t('txt_authorized_devices')}</h3>
{!!props.error && ( {!!props.error && (
<div className="local-error"> <div className="local-error">
<span>{props.error}</span> <span>{props.error}</span>
@@ -126,6 +166,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
)} )}
<table className="table authorized-devices-table"> <table className="table authorized-devices-table">
<colgroup> <colgroup>
<col className="authorized-devices-col-select" />
<col className="authorized-devices-col-device" /> <col className="authorized-devices-col-device" />
<col className="authorized-devices-col-type" /> <col className="authorized-devices-col-type" />
<col className="authorized-devices-col-status" /> <col className="authorized-devices-col-status" />
@@ -136,6 +177,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
</colgroup> </colgroup>
<thead> <thead>
<tr> <tr>
<th>{t('txt_select')}</th>
<th>{t('txt_device')}</th> <th>{t('txt_device')}</th>
<th>{t('txt_type')}</th> <th>{t('txt_type')}</th>
<th>{t('txt_status')}</th> <th>{t('txt_status')}</th>
@@ -148,6 +190,16 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
<tbody> <tbody>
{props.devices.map((device) => ( {props.devices.map((device) => (
<tr key={device.identifier}> <tr key={device.identifier}>
<td data-label={t('txt_select')}>
<input
type="checkbox"
className="authorized-device-checkbox"
checked={selectedDeviceIdSet.has(device.identifier)}
disabled={device.identifier === currentDeviceIdentifier}
aria-label={t('txt_select_device_name', { name: device.name || t('txt_unknown_device') })}
onChange={() => toggleSelectedDevice(device)}
/>
</td>
<td data-label={t('txt_device')}> <td data-label={t('txt_device')}>
<div>{device.name || t('txt_unknown_device')}</div> <div>{device.name || t('txt_unknown_device')}</div>
{!!device.deviceNote && !!device.systemName && device.systemName !== device.name && ( {!!device.deviceNote && !!device.systemName && device.systemName !== device.name && (
@@ -220,14 +272,14 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
))} ))}
{props.loading && props.devices.length === 0 && ( {props.loading && props.devices.length === 0 && (
<tr> <tr>
<td colSpan={7}> <td colSpan={8}>
<LoadingState lines={5} compact /> <LoadingState lines={5} compact />
</td> </td>
</tr> </tr>
)} )}
{!props.loading && props.devices.length === 0 && ( {!props.loading && props.devices.length === 0 && (
<tr> <tr>
<td colSpan={7}> <td colSpan={8}>
<div className="empty empty-comfortable">{t('txt_no_devices_found')}</div> <div className="empty empty-comfortable">{t('txt_no_devices_found')}</div>
</td> </td>
</tr> </tr>
+1 -16
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;
@@ -23,11 +22,6 @@ 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;
@@ -515,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>
@@ -1,4 +1,4 @@
import { Download, FileArchive, FolderOpen, RefreshCw, RotateCcw, Trash2 } from 'lucide-preact'; import { Download, FileArchive, FolderOpen, FolderUp, RefreshCw, RotateCcw, Trash2 } from 'lucide-preact';
import type { RemoteBackupBrowserResponse } from '@/lib/api/backup'; import type { RemoteBackupBrowserResponse } from '@/lib/api/backup';
import { formatBytes, formatDateTime, isZipCandidate } from '@/lib/backup-center'; import { formatBytes, formatDateTime, isZipCandidate } from '@/lib/backup-center';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
@@ -32,26 +32,32 @@ export function RemoteBackupBrowser(props: RemoteBackupBrowserProps) {
: t('txt_downloading_percent', { percent: props.downloadingRemotePercent }); : t('txt_downloading_percent', { percent: props.downloadingRemotePercent });
}; };
const renderRefreshPrompt = () => (
<div className="backup-browser-empty">
<span className="backup-browser-refresh-prompt">
<span>{t('txt_backup_remote_cached_empty_prefix')}</span>
<button type="button" className="btn btn-secondary small" disabled={!props.canBrowse || props.loadingRemoteBrowser || props.disableWhileBusy} onClick={props.onRefresh}>
{t('txt_backup_remote_refresh')}
</button>
<span>{t('txt_backup_remote_cached_empty_suffix')}</span>
</span>
</div>
);
return ( return (
<> <>
<div className="backup-divider" /> <div className="backup-divider" />
<div className="section-head"> <div className="section-head">
<h3>{t('txt_backup_remote_title')}</h3> <h3>{t('txt_backup_remote_title')}</h3>
{props.canBrowse ? (
<div className="actions">
<button type="button" className="btn btn-secondary small" disabled={props.loadingRemoteBrowser || props.disableWhileBusy} onClick={props.onRefresh}>
<RefreshCw size={14} className="btn-icon" />
{t('txt_backup_remote_refresh')}
</button>
</div>
) : null}
</div> </div>
{!props.destinationIsSaved ? ( {!props.destinationIsSaved ? (
<div className="backup-browser-empty">{t('txt_backup_remote_save_first')}</div> <div className="backup-browser-empty">{t('txt_backup_remote_save_first')}</div>
) : props.loadingRemoteBrowser && !props.remoteBrowser ? (
<div className="backup-browser-empty">{t('txt_backup_remote_loading')}</div>
) : !props.remoteBrowser ? ( ) : !props.remoteBrowser ? (
<div className="backup-browser-empty">{t('txt_backup_remote_cached_empty')}</div> renderRefreshPrompt()
) : ( ) : (
<> <>
<div className="backup-browser-path"> <div className="backup-browser-path">
@@ -59,7 +65,8 @@ export function RemoteBackupBrowser(props: RemoteBackupBrowserProps) {
<span>{props.remoteBrowser.currentPath ? `/${props.remoteBrowser.currentPath}` : '/'}</span> <span>{props.remoteBrowser.currentPath ? `/${props.remoteBrowser.currentPath}` : '/'}</span>
</div> </div>
<div className="actions backup-browser-nav"> <div className="backup-browser-nav">
<div className="actions backup-browser-nav-left">
<button type="button" className="btn btn-secondary small" disabled={props.loadingRemoteBrowser || props.disableWhileBusy} onClick={() => props.onShowPath('')}> <button type="button" className="btn btn-secondary small" disabled={props.loadingRemoteBrowser || props.disableWhileBusy} onClick={() => props.onShowPath('')}>
<FolderOpen size={14} className="btn-icon" /> <FolderOpen size={14} className="btn-icon" />
{t('txt_backup_remote_root')} {t('txt_backup_remote_root')}
@@ -70,16 +77,29 @@ export function RemoteBackupBrowser(props: RemoteBackupBrowserProps) {
disabled={props.loadingRemoteBrowser || props.disableWhileBusy || props.remoteBrowser.parentPath === null} disabled={props.loadingRemoteBrowser || props.disableWhileBusy || props.remoteBrowser.parentPath === null}
onClick={() => props.onShowPath(props.remoteBrowser?.parentPath || '')} onClick={() => props.onShowPath(props.remoteBrowser?.parentPath || '')}
> >
<RotateCcw size={14} className="btn-icon" /> <FolderUp size={14} className="btn-icon" />
{t('txt_backup_remote_up')} {t('txt_backup_remote_up')}
</button> </button>
</div> </div>
{props.canBrowse ? (
<button type="button" className="btn btn-secondary small" disabled={props.loadingRemoteBrowser || props.disableWhileBusy} onClick={props.onRefresh}>
<RefreshCw size={14} className="btn-icon" />
{t('txt_backup_remote_refresh')}
</button>
) : null}
</div>
{props.loadingRemoteBrowser ? ( {props.loadingRemoteBrowser ? (
<div className="backup-browser-empty">{t('txt_backup_remote_loading')}</div> <div className="backup-browser-empty">{t('txt_backup_remote_loading')}</div>
) : props.remoteBrowser.items.length ? ( ) : props.remoteBrowser.items.length ? (
<> <>
<div className="backup-browser-list"> <div className="backup-browser-list">
<div className="backup-browser-head" aria-hidden="true">
<span>{t('txt_name')}</span>
<span>{t('txt_backup_remote_modified')}</span>
<span>{t('txt_backup_remote_size')}</span>
<span>{t('txt_actions')}</span>
</div>
{props.visibleItems.map((item) => ( {props.visibleItems.map((item) => (
<div key={`${item.isDirectory ? 'd' : 'f'}:${item.path}`} className="backup-browser-row"> <div key={`${item.isDirectory ? 'd' : 'f'}:${item.path}`} className="backup-browser-row">
<button <button
@@ -92,10 +112,12 @@ export function RemoteBackupBrowser(props: RemoteBackupBrowserProps) {
{item.isDirectory ? <FolderOpen size={16} className="btn-icon" /> : <FileArchive size={16} className="btn-icon" />} {item.isDirectory ? <FolderOpen size={16} className="btn-icon" /> : <FileArchive size={16} className="btn-icon" />}
<span className="backup-browser-name">{item.name}</span> <span className="backup-browser-name">{item.name}</span>
</button> </button>
<div className="backup-browser-meta"> <span className="backup-browser-meta backup-browser-modified">
<span>{item.modifiedAt ? formatDateTime(item.modifiedAt) : t('txt_backup_remote_unknown_time')}</span> {item.modifiedAt ? formatDateTime(item.modifiedAt) : t('txt_backup_remote_unknown_time')}
<span>{item.isDirectory ? t('txt_backup_remote_folder') : formatBytes(item.size)}</span> </span>
</div> <span className="backup-browser-meta backup-browser-size">
{item.isDirectory ? t('txt_backup_remote_folder') : formatBytes(item.size)}
</span>
<div className="actions backup-browser-actions"> <div className="actions backup-browser-actions">
{item.isDirectory ? ( {item.isDirectory ? (
<button type="button" className="btn btn-secondary small" onClick={() => props.onShowPath(item.path)}> <button type="button" className="btn btn-secondary small" onClick={() => props.onShowPath(item.path)}>
+43 -1
View File
@@ -3,6 +3,7 @@ import {
changeMasterPassword, changeMasterPassword,
deleteAllAuthorizedDevices, deleteAllAuthorizedDevices,
deleteAuthorizedDevice, deleteAuthorizedDevice,
deleteAuthorizedDevices,
deriveLoginHash, deriveLoginHash,
deleteAccountPasskey as deleteAccountPasskeyApi, deleteAccountPasskey as deleteAccountPasskeyApi,
enableAccountPasskeyDirectUnlock as enableAccountPasskeyDirectUnlockApi, enableAccountPasskeyDirectUnlock as enableAccountPasskeyDirectUnlockApi,
@@ -234,11 +235,19 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
const normalizedName = String(name || '').trim() || t('txt_account_passkey'); const normalizedName = String(name || '').trim() || t('txt_account_passkey');
const derived = await deriveLoginHash(profile.email, normalizedPassword, defaultKdfIterations); const derived = await deriveLoginHash(profile.email, normalizedPassword, defaultKdfIterations);
const options = await getAccountPasskeyAttestationOptions(authedFetch, derived.hash); const options = await getAccountPasskeyAttestationOptions(authedFetch, derived.hash);
const pending = await createAccountPasskeyCredential(options); const pending = await createAccountPasskeyCredential(options, directUnlock);
let keySet = null; let keySet = null;
let savedWithoutDirectUnlock = false; let savedWithoutDirectUnlock = false;
if (directUnlock) { if (directUnlock) {
if (!session?.symEncKey || !session?.symMacKey) throw new Error(t('txt_vault_key_unavailable')); if (!session?.symEncKey || !session?.symMacKey) throw new Error(t('txt_vault_key_unavailable'));
if (!pending.supportsPrf) {
const shouldSaveLoginOnly = await confirmSaveLoginOnlyAccountPasskey();
if (!shouldSaveLoginOnly) {
onNotify('warning', t('txt_account_passkey_not_saved'));
return null;
}
savedWithoutDirectUnlock = true;
} else {
try { try {
keySet = await buildAccountPasskeyPrfKeySet(pending, { keySet = await buildAccountPasskeyPrfKeySet(pending, {
symEncKey: session.symEncKey, symEncKey: session.symEncKey,
@@ -254,6 +263,7 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
savedWithoutDirectUnlock = true; savedWithoutDirectUnlock = true;
} }
} }
}
const credential = await saveAccountPasskey(authedFetch, { const credential = await saveAccountPasskey(authedFetch, {
name: normalizedName, name: normalizedName,
token: pending.token, token: pending.token,
@@ -380,6 +390,38 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
}); });
}, },
openRemoveSelectedDevices(devices: AuthorizedDevice[]) {
const selectedDevices = devices.filter((device) => String(device.identifier || '').trim());
if (selectedDevices.length === 0) {
onNotify('warning', t('txt_no_devices_selected'));
return;
}
const includesCurrentDevice = selectedDevices.some((device) => device.identifier === getCurrentDeviceIdentifier());
onSetConfirm({
title: t('txt_remove_selected_devices', { count: selectedDevices.length }),
message: includesCurrentDevice
? t('txt_remove_selected_devices_and_sign_out_current', { count: selectedDevices.length })
: t('txt_remove_selected_devices_confirm', { count: selectedDevices.length }),
danger: true,
onConfirm: () => {
onSetConfirm(null);
void (async () => {
try {
await deleteAuthorizedDevices(authedFetch, selectedDevices);
onNotify('success', t('txt_selected_devices_removed', { count: selectedDevices.length }));
if (includesCurrentDevice) {
onLogoutNow();
return;
}
await refetchAuthorizedDevices();
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_remove_selected_devices_failed'));
}
})();
},
});
},
openRevokeAllDeviceTrust() { openRevokeAllDeviceTrust() {
onSetConfirm({ onSetConfirm({
title: t('txt_revoke_all_trusted_devices'), title: t('txt_revoke_all_trusted_devices'),
+56 -5
View File
@@ -136,6 +136,19 @@ function withPrfExtension(
}; };
} }
function withoutCreatePrfExtension(options: PublicKeyCredentialCreationOptions): PublicKeyCredentialCreationOptions {
const extensions = { ...(((options as any).extensions || {}) as Record<string, unknown>) };
delete extensions.prf;
if (!Object.keys(extensions).length) {
const { extensions: _extensions, ...rest } = options as any;
return rest as PublicKeyCredentialCreationOptions;
}
return {
...options,
extensions: extensions as any,
};
}
function readPrfFirstResult(credential: PublicKeyCredential): ArrayBuffer | undefined { function readPrfFirstResult(credential: PublicKeyCredential): ArrayBuffer | undefined {
const result = (credential.getClientExtensionResults() as any).prf?.results?.first; const result = (credential.getClientExtensionResults() as any).prf?.results?.first;
return result instanceof ArrayBuffer ? result : undefined; return result instanceof ArrayBuffer ? result : undefined;
@@ -150,6 +163,22 @@ function shouldRetryWithLegacyPrf(error: unknown): boolean {
return name === 'NotSupportedError' || name === 'SyntaxError' || name === 'TypeError'; return name === 'NotSupportedError' || name === 'SyntaxError' || name === 'TypeError';
} }
function shouldRetryCreateWithoutPrf(error: unknown): boolean {
const name = error instanceof DOMException || error instanceof Error ? error.name : '';
const message = error instanceof DOMException || error instanceof Error ? error.message : '';
return (
name === 'NotSupportedError' ||
name === 'SyntaxError' ||
name === 'TypeError' ||
(name === 'UnknownError' && /transient/i.test(message))
);
}
async function canRequestPrfExtension(): Promise<boolean> {
if (/\bFirefox\//i.test(navigator.userAgent)) return false;
return true;
}
async function getPublicKeyCredentialWithPrf( async function getPublicKeyCredentialWithPrf(
options: PublicKeyCredentialRequestOptions, options: PublicKeyCredentialRequestOptions,
salt: Uint8Array, salt: Uint8Array,
@@ -265,17 +294,39 @@ export async function assertAccountPasskey(
} }
export async function createAccountPasskeyCredential( export async function createAccountPasskeyCredential(
response: { options: unknown; token: string } response: { options: unknown; token: string },
requestPrf: boolean = false
): Promise<PendingAccountPasskeyCredential> { ): Promise<PendingAccountPasskeyCredential> {
if (!window.PublicKeyCredential || !navigator.credentials) { if (!window.PublicKeyCredential || !navigator.credentials) {
throw new Error(t('txt_passkey_browser_not_supported')); throw new Error(t('txt_passkey_browser_not_supported'));
} }
const nativeOptions = cloneCreationOptions(response.options); const nativeOptions = cloneCreationOptions(response.options);
(nativeOptions as any).extensions = { const noPrfOptions = withoutCreatePrfExtension(nativeOptions);
...((nativeOptions as any).extensions || {}), const createWithOptions = async (options: PublicKeyCredentialCreationOptions): Promise<PublicKeyCredential> => {
prf: {}, const credential = await navigator.credentials.create({ publicKey: options });
if (!(credential instanceof PublicKeyCredential)) {
throw new Error(t('txt_no_passkey_created'));
}
return credential;
}; };
const credential = await navigator.credentials.create({ publicKey: nativeOptions }); let credential: PublicKeyCredential;
if (requestPrf && await canRequestPrfExtension()) {
const prfOptions: PublicKeyCredentialCreationOptions = {
...noPrfOptions,
extensions: {
...((noPrfOptions as any).extensions || {}),
prf: {},
} as any,
};
try {
credential = await createWithOptions(prfOptions);
} catch (error) {
if (!shouldRetryCreateWithoutPrf(error)) throw error;
credential = await createWithOptions(noPrfOptions);
}
} else {
credential = await createWithOptions(noPrfOptions);
}
if (!(credential instanceof PublicKeyCredential)) { if (!(credential instanceof PublicKeyCredential)) {
throw new Error(t('txt_no_passkey_created')); throw new Error(t('txt_no_passkey_created'));
} }
+14
View File
@@ -885,6 +885,20 @@ export async function deleteAuthorizedDevice(
if (!resp.ok) throw new Error(t('txt_remove_device_failed')); if (!resp.ok) throw new Error(t('txt_remove_device_failed'));
} }
export async function deleteAuthorizedDevices(
authedFetch: AuthedFetch,
devices: Array<Pick<AuthorizedDevice, 'identifier' | 'hasStoredDevice'>>
): Promise<void> {
const uniqueDevices = Array.from(
new Map(devices.map((device) => [String(device.identifier || '').trim(), device])).values()
).filter((device) => String(device.identifier || '').trim());
await Promise.all(uniqueDevices.map((device) => (
device.hasStoredDevice === false
? revokeAuthorizedDeviceTrust(authedFetch, device.identifier)
: deleteAuthorizedDevice(authedFetch, device.identifier)
)));
}
export async function updateAuthorizedDeviceName( export async function updateAuthorizedDeviceName(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
deviceIdentifier: string, deviceIdentifier: string,
+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 {
+88
View File
@@ -113,27 +113,115 @@ export function translateServerError(message: string | null | undefined, fallbac
return t('txt_rate_limit_try_again_seconds', { seconds: rateLimitMatch[1] }); return t('txt_rate_limit_try_again_seconds', { seconds: rateLimitMatch[1] });
} }
const backupDestinationLimitMatch = normalized.match(/^You can save up to (\d+) backup destinations$/i);
if (backupDestinationLimitMatch) {
return t('txt_backup_error_destination_limit', { count: backupDestinationLimitMatch[1] });
}
const backupArchiveVerificationMatch = normalized.match(/^Backup archive upload verification failed after (\d+) attempts: (.+)$/i);
if (backupArchiveVerificationMatch) {
return t('txt_backup_error_archive_upload_verification_failed_attempts', {
count: backupArchiveVerificationMatch[1],
reason: translateServerError(backupArchiveVerificationMatch[2], backupArchiveVerificationMatch[2]),
});
}
const remoteAttachmentStatusMatch = normalized.match(/^Remote attachment (download|batch download) failed: (\d+)$/i);
if (remoteAttachmentStatusMatch) {
return t(
remoteAttachmentStatusMatch[1].toLowerCase() === 'batch download'
? 'txt_backup_error_remote_attachment_batch_download_failed_status'
: 'txt_backup_error_remote_attachment_download_failed_status',
{ status: remoteAttachmentStatusMatch[2] }
);
}
const providerStatusMatch = normalized.match(/^(WebDAV|S3) (directory creation|upload|listing|download|delete|existence check) failed: (\d+)$/i);
if (providerStatusMatch) {
const provider = providerStatusMatch[1].toLowerCase() === 'webdav' ? 'webdav' : 's3';
const actionKey = providerStatusMatch[2].toLowerCase().replace(/\s+/g, '_');
return t(`txt_backup_error_${provider}_${actionKey}_failed_status`, { status: providerStatusMatch[3] });
}
const key = { const key = {
'Account is disabled': 'txt_server_error_account_disabled', 'Account is disabled': 'txt_server_error_account_disabled',
'Another backup or restore run is already in progress': 'txt_backup_error_another_backup_or_restore_running',
'Another backup run is already in progress': 'txt_backup_error_another_backup_running',
'Backup archive upload failed': 'txt_backup_error_archive_upload_failed',
'Backup attachment blob is invalid': 'txt_backup_error_attachment_blob_invalid',
'Backup attachment blob is required': 'txt_backup_error_attachment_blob_required',
'Backup attachment blob not found': 'txt_backup_error_attachment_blob_not_found',
'Backup attachment download failed': 'txt_backup_error_attachment_download_failed',
'Backup destination is invalid': 'txt_backup_error_destination_invalid',
'Backup destination not found': 'txt_backup_error_destination_not_found',
'Backup destination ids must be unique': 'txt_backup_error_destination_ids_unique',
'Backup destination type is invalid': 'txt_backup_error_destination_type_invalid',
'Backup destinations are invalid': 'txt_backup_error_destinations_invalid',
'Backup export payload is invalid': 'txt_backup_error_export_payload_invalid',
'Backup file checksum does not match its filename': 'txt_backup_error_file_checksum_mismatch',
'Backup file is required': 'txt_backup_error_file_required',
'Backup interval hours must be between 1 and 99': 'txt_backup_error_interval_hours_range',
'Backup retention count must be between 1 and 1000': 'txt_backup_error_retention_count_range',
'Backup run failed': 'txt_backup_error_run_failed',
'Backup run payload is invalid': 'txt_backup_error_run_payload_invalid',
'Backup run response is invalid': 'txt_backup_error_run_response_invalid',
'Backup settings are invalid': 'txt_backup_error_settings_invalid',
'Backup settings could not be loaded': 'txt_backup_error_settings_load_failed',
'Backup settings envelope is invalid': 'txt_backup_error_settings_envelope_invalid',
'Backup settings need administrator reactivation after restore': 'txt_backup_error_settings_need_reactivation',
'Backup settings payload is invalid': 'txt_backup_error_settings_payload_invalid',
'Backup settings repair payload is invalid': 'txt_backup_error_settings_repair_payload_invalid',
'Backup settings repair state could not be loaded': 'txt_backup_error_settings_repair_state_load_failed',
'Backup start time must be in HH:mm format': 'txt_backup_error_start_time_format',
'Client IP is required': 'txt_server_error_client_ip_required', 'Client IP is required': 'txt_server_error_client_ip_required',
'ClientId or clientSecret is incorrect. Try again': 'txt_server_error_client_credentials_incorrect', 'ClientId or clientSecret is incorrect. Try again': 'txt_server_error_client_credentials_incorrect',
'Content-Type must be multipart/form-data': 'txt_backup_error_multipart_required',
'Email already registered': 'txt_server_error_email_already_registered', 'Email already registered': 'txt_server_error_email_already_registered',
'Email and password are required': 'txt_server_error_email_password_required', 'Email and password are required': 'txt_server_error_email_password_required',
'Email is required': 'txt_server_error_email_required', 'Email is required': 'txt_server_error_email_required',
'Forbidden': 'txt_server_error_forbidden',
'Invite code is invalid or expired': 'txt_server_error_invite_invalid_or_expired', 'Invite code is invalid or expired': 'txt_server_error_invite_invalid_or_expired',
'Invite code is required': 'txt_server_error_invite_required', 'Invite code is required': 'txt_server_error_invite_required',
'Invalid backup timezone': 'txt_backup_error_timezone_invalid',
'Invalid password': 'txt_server_error_invalid_password',
'Invalid refresh token': 'txt_server_error_invalid_refresh_token', 'Invalid refresh token': 'txt_server_error_invalid_refresh_token',
'Invalid remote backup path': 'txt_backup_error_remote_path_invalid',
'Invalid request payload': 'txt_server_error_invalid_request_payload', 'Invalid request payload': 'txt_server_error_invalid_request_payload',
'Invalid user verification token': 'txt_server_error_invalid_user_verification_token',
'JWT_SECRET is not set': 'txt_server_error_jwt_secret_missing', 'JWT_SECRET is not set': 'txt_server_error_jwt_secret_missing',
'JWT_SECRET is using the default/sample value. Please change it.': 'txt_server_error_jwt_secret_default', 'JWT_SECRET is using the default/sample value. Please change it.': 'txt_server_error_jwt_secret_default',
'JWT_SECRET must be at least 32 characters': 'txt_server_error_jwt_secret_too_short', 'JWT_SECRET must be at least 32 characters': 'txt_server_error_jwt_secret_too_short',
'Parameter error': 'txt_server_error_parameter_error', 'Parameter error': 'txt_server_error_parameter_error',
'Please select a backup file': 'txt_backup_error_select_backup_file',
'Please select a backup ZIP file': 'txt_backup_error_select_backup_zip_file',
'Refresh token is required': 'txt_server_error_refresh_token_required', 'Refresh token is required': 'txt_server_error_refresh_token_required',
'Remote backup ZIP checksum verification failed': 'txt_backup_error_remote_zip_checksum_failed',
'Remote backup ZIP size verification failed': 'txt_backup_error_remote_zip_size_failed',
'Remote backup delete failed': 'txt_backup_error_remote_delete_failed',
'Remote backup download failed': 'txt_backup_error_remote_download_failed',
'Remote backup download payload is invalid': 'txt_backup_error_remote_download_payload_invalid',
'Remote backup integrity inspection failed': 'txt_backup_error_remote_integrity_failed',
'Remote backup listing failed': 'txt_backup_error_remote_listing_failed',
'Remote restore payload is invalid': 'txt_backup_error_remote_restore_payload_invalid',
'Registration is temporarily unavailable, retry once': 'txt_server_error_registration_retry', 'Registration is temporarily unavailable, retry once': 'txt_server_error_registration_retry',
'S3 access key is required': 'txt_backup_error_s3_access_key_required',
'S3 bucket is required': 'txt_backup_error_s3_bucket_required',
'S3 endpoint is required': 'txt_backup_error_s3_endpoint_required',
'S3 endpoint must start with http:// or https://': 'txt_backup_error_s3_endpoint_protocol',
'S3 secret key is required': 'txt_backup_error_s3_secret_key_required',
'TOTP token is required': 'txt_server_error_totp_token_required', 'TOTP token is required': 'txt_server_error_totp_token_required',
'Two factor required.': 'txt_server_error_two_factor_required', 'Two factor required.': 'txt_server_error_two_factor_required',
'Two-step token is invalid. Try again.': 'txt_server_error_two_factor_invalid', 'Two-step token is invalid. Try again.': 'txt_server_error_two_factor_invalid',
'Unable to read backup file': 'txt_backup_error_read_backup_file_failed',
'Unsupported backup destination type': 'txt_backup_error_destination_type_unsupported',
'Username or password is incorrect. Try again': 'txt_server_error_username_password_incorrect', 'Username or password is incorrect. Try again': 'txt_server_error_username_password_incorrect',
'WebDAV password is required': 'txt_backup_error_webdav_password_required',
'WebDAV remote backup path is too deep for safe attachment batching': 'txt_backup_error_webdav_path_too_deep',
'WebDAV server URL is required': 'txt_backup_error_webdav_url_required',
'WebDAV server URL must start with http:// or https://': 'txt_backup_error_webdav_url_protocol',
'WebDAV username is required': 'txt_backup_error_webdav_username_required',
'masterPasswordHash is required': 'txt_server_error_master_password_hash_required',
'masterPasswordHash or userVerificationToken is required': 'txt_server_error_master_password_or_verification_required',
}[normalized]; }[normalized];
return key ? t(key) : normalized; return key ? t(key) : normalized;
+116
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.",
@@ -193,10 +224,14 @@ const en: Record<string, string> = {
"txt_backup_restore_progress_remote_finalize_detail": "The server is performing final validation and then switching the verified restore data into the live tables.", "txt_backup_restore_progress_remote_finalize_detail": "The server is performing final validation and then switching the verified restore data into the live tables.",
"txt_backup_remote_loading": "Loading remote backups...", "txt_backup_remote_loading": "Loading remote backups...",
"txt_backup_remote_cached_empty": "Click Refresh to load this destination.", "txt_backup_remote_cached_empty": "Click Refresh to load this destination.",
"txt_backup_remote_cached_empty_prefix": "Click",
"txt_backup_remote_cached_empty_suffix": "to load this destination.",
"txt_backup_remote_empty": "No backup files found in this folder.", "txt_backup_remote_empty": "No backup files found in this folder.",
"txt_backup_remote_folder": "Folder", "txt_backup_remote_folder": "Folder",
"txt_backup_remote_unknown_time": "Unknown time", "txt_backup_remote_unknown_time": "Unknown time",
"txt_backup_remote_current_path": "Current Folder", "txt_backup_remote_current_path": "Current Folder",
"txt_backup_remote_modified": "Modified",
"txt_backup_remote_size": "Size",
"txt_backup_remote_load_failed": "Loading remote backups failed", "txt_backup_remote_load_failed": "Loading remote backups failed",
"txt_backup_remote_invalid_response": "Invalid remote backup response", "txt_backup_remote_invalid_response": "Invalid remote backup response",
"txt_backup_remote_download_failed": "Downloading remote backup failed", "txt_backup_remote_download_failed": "Downloading remote backup failed",
@@ -214,6 +249,74 @@ const en: Record<string, string> = {
"txt_backup_remote_run_invalid_response": "Invalid remote backup run response", "txt_backup_remote_run_invalid_response": "Invalid remote backup run response",
"txt_backup_settings_invalid_response": "Invalid backup settings response", "txt_backup_settings_invalid_response": "Invalid backup settings response",
"txt_backup_import_invalid_response": "Invalid backup import response", "txt_backup_import_invalid_response": "Invalid backup import response",
"txt_backup_error_another_backup_or_restore_running": "Another backup or restore task is already running.",
"txt_backup_error_another_backup_running": "Another backup task is already running.",
"txt_backup_error_archive_upload_failed": "Backup archive upload failed.",
"txt_backup_error_archive_upload_verification_failed_attempts": "Backup upload verification failed after {count} attempt(s): {reason}",
"txt_backup_error_attachment_blob_invalid": "Backup attachment blob is invalid.",
"txt_backup_error_attachment_blob_required": "Backup attachment blob is required.",
"txt_backup_error_attachment_blob_not_found": "Backup attachment blob not found.",
"txt_backup_error_attachment_download_failed": "Backup attachment download failed.",
"txt_backup_error_destination_invalid": "Backup destination is invalid.",
"txt_backup_error_destination_limit": "You can save up to {count} backup destinations.",
"txt_backup_error_destination_not_found": "Backup destination not found.",
"txt_backup_error_destination_ids_unique": "Backup destination IDs must be unique.",
"txt_backup_error_destination_type_invalid": "Backup destination type is invalid.",
"txt_backup_error_destination_type_unsupported": "Unsupported backup destination type.",
"txt_backup_error_destinations_invalid": "Backup destinations are invalid.",
"txt_backup_error_export_payload_invalid": "Backup export payload is invalid.",
"txt_backup_error_file_checksum_mismatch": "Backup file checksum does not match its filename.",
"txt_backup_error_file_required": "Backup file is required.",
"txt_backup_error_interval_hours_range": "Backup interval must be between 1 and 99 hours.",
"txt_backup_error_multipart_required": "The upload request must use multipart/form-data.",
"txt_backup_error_read_backup_file_failed": "Unable to read backup file.",
"txt_backup_error_remote_attachment_batch_download_failed_status": "Remote attachment batch download failed: HTTP {status}.",
"txt_backup_error_remote_attachment_download_failed_status": "Remote attachment download failed: HTTP {status}.",
"txt_backup_error_remote_delete_failed": "Remote backup delete failed.",
"txt_backup_error_remote_download_failed": "Remote backup download failed.",
"txt_backup_error_remote_download_payload_invalid": "Remote backup download request is invalid.",
"txt_backup_error_remote_integrity_failed": "Remote backup integrity inspection failed.",
"txt_backup_error_remote_listing_failed": "Remote backup listing failed.",
"txt_backup_error_remote_path_invalid": "Remote backup path is invalid.",
"txt_backup_error_remote_restore_payload_invalid": "Remote restore request is invalid.",
"txt_backup_error_remote_zip_checksum_failed": "Remote backup ZIP checksum verification failed.",
"txt_backup_error_remote_zip_size_failed": "Remote backup ZIP size verification failed.",
"txt_backup_error_retention_count_range": "Backup retention count must be between 1 and 1000.",
"txt_backup_error_run_failed": "Backup run failed.",
"txt_backup_error_run_payload_invalid": "Backup run request is invalid.",
"txt_backup_error_run_response_invalid": "Backup run response is invalid.",
"txt_backup_error_s3_access_key_required": "S3 access key is required.",
"txt_backup_error_s3_bucket_required": "S3 bucket is required.",
"txt_backup_error_s3_delete_failed_status": "S3 delete failed: HTTP {status}.",
"txt_backup_error_s3_download_failed_status": "S3 download failed: HTTP {status}.",
"txt_backup_error_s3_endpoint_required": "S3 endpoint is required.",
"txt_backup_error_s3_endpoint_protocol": "S3 endpoint must start with http:// or https://.",
"txt_backup_error_s3_existence_check_failed_status": "S3 existence check failed: HTTP {status}.",
"txt_backup_error_s3_listing_failed_status": "S3 listing failed: HTTP {status}.",
"txt_backup_error_s3_secret_key_required": "S3 secret key is required.",
"txt_backup_error_s3_upload_failed_status": "S3 upload failed: HTTP {status}.",
"txt_backup_error_select_backup_file": "Please select a backup file.",
"txt_backup_error_select_backup_zip_file": "Please select a backup ZIP file.",
"txt_backup_error_settings_envelope_invalid": "Backup settings envelope is invalid.",
"txt_backup_error_settings_invalid": "Backup settings are invalid.",
"txt_backup_error_settings_load_failed": "Backup settings could not be loaded.",
"txt_backup_error_settings_need_reactivation": "Backup settings need administrator reactivation after restore.",
"txt_backup_error_settings_payload_invalid": "Backup settings request is invalid.",
"txt_backup_error_settings_repair_payload_invalid": "Backup settings repair request is invalid.",
"txt_backup_error_settings_repair_state_load_failed": "Backup settings repair state could not be loaded.",
"txt_backup_error_start_time_format": "Backup start time must be in HH:mm format.",
"txt_backup_error_timezone_invalid": "Backup timezone is invalid.",
"txt_backup_error_webdav_delete_failed_status": "WebDAV delete failed: HTTP {status}.",
"txt_backup_error_webdav_directory_creation_failed_status": "WebDAV directory creation failed: HTTP {status}.",
"txt_backup_error_webdav_download_failed_status": "WebDAV download failed: HTTP {status}.",
"txt_backup_error_webdav_existence_check_failed_status": "WebDAV existence check failed: HTTP {status}.",
"txt_backup_error_webdav_listing_failed_status": "WebDAV listing failed: HTTP {status}.",
"txt_backup_error_webdav_password_required": "WebDAV password is required.",
"txt_backup_error_webdav_path_too_deep": "WebDAV remote backup path is too deep for safe attachment batching.",
"txt_backup_error_webdav_upload_failed_status": "WebDAV upload failed: HTTP {status}.",
"txt_backup_error_webdav_url_required": "WebDAV server URL is required.",
"txt_backup_error_webdav_url_protocol": "WebDAV server URL must start with http:// or https://.",
"txt_backup_error_webdav_username_required": "WebDAV username is required.",
"txt_backup_destination": "Backup Destination", "txt_backup_destination": "Backup Destination",
"txt_backup_protocol_webdav": "WebDAV", "txt_backup_protocol_webdav": "WebDAV",
"txt_backup_protocol_s3": "S3", "txt_backup_protocol_s3": "S3",
@@ -489,16 +592,21 @@ const en: Record<string, string> = {
"txt_server_error_account_disabled": "Account is disabled", "txt_server_error_account_disabled": "Account is disabled",
"txt_server_error_client_credentials_incorrect": "Client ID or client secret is incorrect. Try again.", "txt_server_error_client_credentials_incorrect": "Client ID or client secret is incorrect. Try again.",
"txt_server_error_client_ip_required": "Client IP is required", "txt_server_error_client_ip_required": "Client IP is required",
"txt_server_error_forbidden": "You do not have permission to perform this action.",
"txt_server_error_email_already_registered": "Email already registered", "txt_server_error_email_already_registered": "Email already registered",
"txt_server_error_email_password_required": "Email and password are required", "txt_server_error_email_password_required": "Email and password are required",
"txt_server_error_email_required": "Email is required", "txt_server_error_email_required": "Email is required",
"txt_server_error_invalid_password": "Invalid password.",
"txt_server_error_invalid_refresh_token": "Session expired. Please sign in again.", "txt_server_error_invalid_refresh_token": "Session expired. Please sign in again.",
"txt_server_error_invalid_user_verification_token": "Invalid user verification token.",
"txt_server_error_invalid_request_payload": "Invalid request payload", "txt_server_error_invalid_request_payload": "Invalid request payload",
"txt_server_error_invite_invalid_or_expired": "Invite code is invalid or expired", "txt_server_error_invite_invalid_or_expired": "Invite code is invalid or expired",
"txt_server_error_invite_required": "Invite code is required", "txt_server_error_invite_required": "Invite code is required",
"txt_server_error_jwt_secret_default": "JWT_SECRET is using the default/sample value. Please change it.", "txt_server_error_jwt_secret_default": "JWT_SECRET is using the default/sample value. Please change it.",
"txt_server_error_jwt_secret_missing": "JWT_SECRET is not set", "txt_server_error_jwt_secret_missing": "JWT_SECRET is not set",
"txt_server_error_jwt_secret_too_short": "JWT_SECRET must be at least 32 characters", "txt_server_error_jwt_secret_too_short": "JWT_SECRET must be at least 32 characters",
"txt_server_error_master_password_hash_required": "Master password verification is required.",
"txt_server_error_master_password_or_verification_required": "Master password or user verification token is required.",
"txt_server_error_parameter_error": "Parameter error", "txt_server_error_parameter_error": "Parameter error",
"txt_server_error_refresh_token_required": "Session is missing. Please sign in again.", "txt_server_error_refresh_token_required": "Session is missing. Please sign in again.",
"txt_server_error_registration_retry": "Registration is temporarily unavailable. Please retry once.", "txt_server_error_registration_retry": "Registration is temporarily unavailable. Please retry once.",
@@ -730,6 +838,11 @@ const en: Record<string, string> = {
"txt_remove_all_devices": "Remove all devices", "txt_remove_all_devices": "Remove all devices",
"txt_remove_all_devices_and_clear_all_2fa_trust": "Remove all devices and clear all 2FA trust?", "txt_remove_all_devices_and_clear_all_2fa_trust": "Remove all devices and clear all 2FA trust?",
"txt_remove_all_devices_and_sign_out_all_sessions": "Remove all devices, clear all trust, and sign out every device?", "txt_remove_all_devices_and_sign_out_all_sessions": "Remove all devices, clear all trust, and sign out every device?",
"txt_remove_selected_devices": "Remove selected ({count})",
"txt_remove_selected_devices_confirm": "Remove {count} selected devices, clear their trust, and sign them out?",
"txt_remove_selected_devices_and_sign_out_current": "Remove {count} selected devices, clear their trust, and sign out this device too?",
"txt_selected_devices_removed": "Selected devices removed",
"txt_remove_selected_devices_failed": "Failed to remove selected devices",
"txt_remove_device_name_and_clear_its_2fa_trust": "Remove device \"{name}\" and clear its 2FA trust?", "txt_remove_device_name_and_clear_its_2fa_trust": "Remove device \"{name}\" and clear its 2FA trust?",
"txt_remove_device_and_sign_out_name": "Remove device \"{name}\", clear its trust, and sign it out?", "txt_remove_device_and_sign_out_name": "Remove device \"{name}\", clear its trust, and sign it out?",
"txt_reveal": "Reveal", "txt_reveal": "Reveal",
@@ -771,6 +884,9 @@ const en: Record<string, string> = {
"txt_security_code": "Security Code", "txt_security_code": "Security Code",
"txt_security_code_cvv": "Security Code (CVV)", "txt_security_code_cvv": "Security Code (CVV)",
"txt_select_all": "Select All", "txt_select_all": "Select All",
"txt_clear_selection": "Clear selection",
"txt_select_device_name": "Select {name}",
"txt_no_devices_selected": "No devices selected",
"txt_select": "Select", "txt_select": "Select",
"txt_select_duplicate_items": "Select Duplicates", "txt_select_duplicate_items": "Select Duplicates",
"txt_select_an_item": "Select an item", "txt_select_an_item": "Select an item",
+116
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.",
@@ -193,10 +224,14 @@ const es: Record<string, string> = {
"txt_backup_restore_progress_remote_finalize_detail": "El servidor está realizando la validación final y luego cambiando los datos de restauración verificados a las tablas activas.", "txt_backup_restore_progress_remote_finalize_detail": "El servidor está realizando la validación final y luego cambiando los datos de restauración verificados a las tablas activas.",
"txt_backup_remote_loading": "Cargando copias remotas...", "txt_backup_remote_loading": "Cargando copias remotas...",
"txt_backup_remote_cached_empty": "Haga clic en Actualizar para cargar este destino.", "txt_backup_remote_cached_empty": "Haga clic en Actualizar para cargar este destino.",
"txt_backup_remote_cached_empty_prefix": "Haga clic en",
"txt_backup_remote_cached_empty_suffix": "para cargar este destino.",
"txt_backup_remote_empty": "No se encontraron archivos de copia de seguridad en esta carpeta.", "txt_backup_remote_empty": "No se encontraron archivos de copia de seguridad en esta carpeta.",
"txt_backup_remote_folder": "Carpeta", "txt_backup_remote_folder": "Carpeta",
"txt_backup_remote_unknown_time": "Hora desconocida", "txt_backup_remote_unknown_time": "Hora desconocida",
"txt_backup_remote_current_path": "Carpeta actual", "txt_backup_remote_current_path": "Carpeta actual",
"txt_backup_remote_modified": "Modificado",
"txt_backup_remote_size": "Tamaño",
"txt_backup_remote_load_failed": "Error al cargar copias de seguridad remotas", "txt_backup_remote_load_failed": "Error al cargar copias de seguridad remotas",
"txt_backup_remote_invalid_response": "Respuesta de copia de seguridad remota no válida", "txt_backup_remote_invalid_response": "Respuesta de copia de seguridad remota no válida",
"txt_backup_remote_download_failed": "Error al descargar copia de seguridad remota", "txt_backup_remote_download_failed": "Error al descargar copia de seguridad remota",
@@ -214,6 +249,74 @@ const es: Record<string, string> = {
"txt_backup_remote_run_invalid_response": "Respuesta de ejecución de copia de seguridad remota no válida", "txt_backup_remote_run_invalid_response": "Respuesta de ejecución de copia de seguridad remota no válida",
"txt_backup_settings_invalid_response": "Respuesta de configuración de copia de seguridad no válida", "txt_backup_settings_invalid_response": "Respuesta de configuración de copia de seguridad no válida",
"txt_backup_import_invalid_response": "Respuesta de importación de copia de seguridad no válida", "txt_backup_import_invalid_response": "Respuesta de importación de copia de seguridad no válida",
"txt_backup_error_another_backup_or_restore_running": "Ya hay una tarea de copia o restauración en curso.",
"txt_backup_error_another_backup_running": "Ya hay una tarea de copia en curso.",
"txt_backup_error_archive_upload_failed": "No se pudo subir el archivo de copia.",
"txt_backup_error_archive_upload_verification_failed_attempts": "La verificación de subida falló tras {count} intento(s): {reason}",
"txt_backup_error_attachment_blob_invalid": "El objeto de adjunto de copia no es válido.",
"txt_backup_error_attachment_blob_required": "Falta el objeto de adjunto de copia.",
"txt_backup_error_attachment_blob_not_found": "No se encontró el objeto de adjunto de copia.",
"txt_backup_error_attachment_download_failed": "No se pudo descargar el adjunto de copia.",
"txt_backup_error_destination_invalid": "El destino de copia no es válido.",
"txt_backup_error_destination_limit": "Puede guardar hasta {count} destinos de copia.",
"txt_backup_error_destination_not_found": "No se encontró el destino de copia.",
"txt_backup_error_destination_ids_unique": "Los ID de destino de copia no pueden repetirse.",
"txt_backup_error_destination_type_invalid": "El tipo de destino de copia no es válido.",
"txt_backup_error_destination_type_unsupported": "Tipo de destino de copia no compatible.",
"txt_backup_error_destinations_invalid": "La lista de destinos de copia no es válida.",
"txt_backup_error_export_payload_invalid": "La solicitud de exportación de copia no es válida.",
"txt_backup_error_file_checksum_mismatch": "La suma de verificación de la copia no coincide con el nombre del archivo.",
"txt_backup_error_file_required": "Seleccione un archivo de copia.",
"txt_backup_error_interval_hours_range": "El intervalo de copia debe estar entre 1 y 99 horas.",
"txt_backup_error_multipart_required": "La solicitud de subida debe usar multipart/form-data.",
"txt_backup_error_read_backup_file_failed": "No se pudo leer el archivo de copia.",
"txt_backup_error_remote_attachment_batch_download_failed_status": "Error al descargar adjuntos remotos por lotes: HTTP {status}.",
"txt_backup_error_remote_attachment_download_failed_status": "Error al descargar adjunto remoto: HTTP {status}.",
"txt_backup_error_remote_delete_failed": "No se pudo eliminar la copia remota.",
"txt_backup_error_remote_download_failed": "No se pudo descargar la copia remota.",
"txt_backup_error_remote_download_payload_invalid": "La solicitud de descarga remota no es válida.",
"txt_backup_error_remote_integrity_failed": "No se pudo inspeccionar la integridad de la copia remota.",
"txt_backup_error_remote_listing_failed": "No se pudo leer la lista de copias remotas.",
"txt_backup_error_remote_path_invalid": "La ruta de copia remota no es válida.",
"txt_backup_error_remote_restore_payload_invalid": "La solicitud de restauración remota no es válida.",
"txt_backup_error_remote_zip_checksum_failed": "Falló la verificación de suma del ZIP remoto.",
"txt_backup_error_remote_zip_size_failed": "Falló la verificación de tamaño del ZIP remoto.",
"txt_backup_error_retention_count_range": "La retención debe estar entre 1 y 1000.",
"txt_backup_error_run_failed": "La ejecución de copia falló.",
"txt_backup_error_run_payload_invalid": "La solicitud de ejecución de copia no es válida.",
"txt_backup_error_run_response_invalid": "La respuesta de ejecución de copia no es válida.",
"txt_backup_error_s3_access_key_required": "La clave de acceso S3 es obligatoria.",
"txt_backup_error_s3_bucket_required": "El bucket S3 es obligatorio.",
"txt_backup_error_s3_delete_failed_status": "Eliminación S3 fallida: HTTP {status}.",
"txt_backup_error_s3_download_failed_status": "Descarga S3 fallida: HTTP {status}.",
"txt_backup_error_s3_endpoint_required": "El endpoint S3 es obligatorio.",
"txt_backup_error_s3_endpoint_protocol": "El endpoint S3 debe empezar por http:// o https://.",
"txt_backup_error_s3_existence_check_failed_status": "Comprobación de existencia S3 fallida: HTTP {status}.",
"txt_backup_error_s3_listing_failed_status": "Listado S3 fallido: HTTP {status}.",
"txt_backup_error_s3_secret_key_required": "La clave secreta S3 es obligatoria.",
"txt_backup_error_s3_upload_failed_status": "Subida S3 fallida: HTTP {status}.",
"txt_backup_error_select_backup_file": "Seleccione un archivo de copia.",
"txt_backup_error_select_backup_zip_file": "Seleccione un archivo ZIP de copia.",
"txt_backup_error_settings_envelope_invalid": "El contenedor cifrado de configuración de copia no es válido.",
"txt_backup_error_settings_invalid": "La configuración de copia no es válida.",
"txt_backup_error_settings_load_failed": "No se pudo cargar la configuración de copia.",
"txt_backup_error_settings_need_reactivation": "La configuración de copia requiere reactivación de administrador tras la restauración.",
"txt_backup_error_settings_payload_invalid": "La solicitud de configuración de copia no es válida.",
"txt_backup_error_settings_repair_payload_invalid": "La solicitud de reparación de configuración no es válida.",
"txt_backup_error_settings_repair_state_load_failed": "No se pudo cargar el estado de reparación de configuración.",
"txt_backup_error_start_time_format": "La hora de inicio debe tener formato HH:mm.",
"txt_backup_error_timezone_invalid": "La zona horaria de copia no es válida.",
"txt_backup_error_webdav_delete_failed_status": "Eliminación WebDAV fallida: HTTP {status}.",
"txt_backup_error_webdav_directory_creation_failed_status": "Creación de directorio WebDAV fallida: HTTP {status}.",
"txt_backup_error_webdav_download_failed_status": "Descarga WebDAV fallida: HTTP {status}.",
"txt_backup_error_webdav_existence_check_failed_status": "Comprobación de existencia WebDAV fallida: HTTP {status}.",
"txt_backup_error_webdav_listing_failed_status": "Listado WebDAV fallido: HTTP {status}.",
"txt_backup_error_webdav_password_required": "La contraseña WebDAV es obligatoria.",
"txt_backup_error_webdav_path_too_deep": "La ruta remota WebDAV es demasiado profunda para procesar adjuntos por lotes de forma segura.",
"txt_backup_error_webdav_upload_failed_status": "Subida WebDAV fallida: HTTP {status}.",
"txt_backup_error_webdav_url_required": "La URL del servidor WebDAV es obligatoria.",
"txt_backup_error_webdav_url_protocol": "La URL WebDAV debe empezar por http:// o https://.",
"txt_backup_error_webdav_username_required": "El usuario WebDAV es obligatorio.",
"txt_backup_destination": "Destino de copia", "txt_backup_destination": "Destino de copia",
"txt_backup_protocol_webdav": "WebDAV", "txt_backup_protocol_webdav": "WebDAV",
"txt_backup_protocol_s3": "S3", "txt_backup_protocol_s3": "S3",
@@ -489,16 +592,21 @@ const es: Record<string, string> = {
"txt_server_error_account_disabled": "La cuenta está deshabilitada", "txt_server_error_account_disabled": "La cuenta está deshabilitada",
"txt_server_error_client_credentials_incorrect": "El ID de cliente o el secreto de cliente no son correctos. Inténtalo de nuevo.", "txt_server_error_client_credentials_incorrect": "El ID de cliente o el secreto de cliente no son correctos. Inténtalo de nuevo.",
"txt_server_error_client_ip_required": "Se requiere la IP del cliente", "txt_server_error_client_ip_required": "Se requiere la IP del cliente",
"txt_server_error_forbidden": "No tiene permiso para realizar esta acción.",
"txt_server_error_email_already_registered": "Este correo ya está registrado", "txt_server_error_email_already_registered": "Este correo ya está registrado",
"txt_server_error_email_password_required": "Correo y contraseña son obligatorios", "txt_server_error_email_password_required": "Correo y contraseña son obligatorios",
"txt_server_error_email_required": "El correo es obligatorio", "txt_server_error_email_required": "El correo es obligatorio",
"txt_server_error_invalid_password": "Contraseña no válida.",
"txt_server_error_invalid_refresh_token": "La sesión caducó. Inicia sesión de nuevo.", "txt_server_error_invalid_refresh_token": "La sesión caducó. Inicia sesión de nuevo.",
"txt_server_error_invalid_user_verification_token": "Token de verificación de usuario no válido.",
"txt_server_error_invalid_request_payload": "Solicitud no válida", "txt_server_error_invalid_request_payload": "Solicitud no válida",
"txt_server_error_invite_invalid_or_expired": "El código de invitación no es válido o ha caducado", "txt_server_error_invite_invalid_or_expired": "El código de invitación no es válido o ha caducado",
"txt_server_error_invite_required": "El código de invitación es obligatorio", "txt_server_error_invite_required": "El código de invitación es obligatorio",
"txt_server_error_jwt_secret_default": "JWT_SECRET usa el valor predeterminado/de ejemplo. Cámbialo.", "txt_server_error_jwt_secret_default": "JWT_SECRET usa el valor predeterminado/de ejemplo. Cámbialo.",
"txt_server_error_jwt_secret_missing": "JWT_SECRET no está configurado", "txt_server_error_jwt_secret_missing": "JWT_SECRET no está configurado",
"txt_server_error_jwt_secret_too_short": "JWT_SECRET debe tener al menos 32 caracteres", "txt_server_error_jwt_secret_too_short": "JWT_SECRET debe tener al menos 32 caracteres",
"txt_server_error_master_password_hash_required": "Se requiere verificación de la contraseña maestra.",
"txt_server_error_master_password_or_verification_required": "Se requiere contraseña maestra o token de verificación de usuario.",
"txt_server_error_parameter_error": "Error de parámetros", "txt_server_error_parameter_error": "Error de parámetros",
"txt_server_error_refresh_token_required": "Falta la sesión. Inicia sesión de nuevo.", "txt_server_error_refresh_token_required": "Falta la sesión. Inicia sesión de nuevo.",
"txt_server_error_registration_retry": "El registro no está disponible temporalmente. Inténtalo una vez más.", "txt_server_error_registration_retry": "El registro no está disponible temporalmente. Inténtalo una vez más.",
@@ -730,6 +838,11 @@ const es: Record<string, string> = {
"txt_remove_all_devices": "Quitar todos los dispositivos", "txt_remove_all_devices": "Quitar todos los dispositivos",
"txt_remove_all_devices_and_clear_all_2fa_trust": "¿Quitar todos los dispositivos y limpiar toda la confianza 2FA?", "txt_remove_all_devices_and_clear_all_2fa_trust": "¿Quitar todos los dispositivos y limpiar toda la confianza 2FA?",
"txt_remove_all_devices_and_sign_out_all_sessions": "¿Quitar todos los dispositivos, limpiar toda la confianza y cerrar sesión en todos los dispositivos?", "txt_remove_all_devices_and_sign_out_all_sessions": "¿Quitar todos los dispositivos, limpiar toda la confianza y cerrar sesión en todos los dispositivos?",
"txt_remove_selected_devices": "Quitar seleccionados ({count})",
"txt_remove_selected_devices_confirm": "¿Quitar {count} dispositivos seleccionados, limpiar su confianza y cerrar sesión?",
"txt_remove_selected_devices_and_sign_out_current": "¿Quitar {count} dispositivos seleccionados, limpiar su confianza y cerrar también esta sesión?",
"txt_selected_devices_removed": "Dispositivos seleccionados quitados",
"txt_remove_selected_devices_failed": "Error al quitar los dispositivos seleccionados",
"txt_remove_device_name_and_clear_its_2fa_trust": "¿Quitar dispositivo \"{name}\" y limpiar su confianza 2FA?", "txt_remove_device_name_and_clear_its_2fa_trust": "¿Quitar dispositivo \"{name}\" y limpiar su confianza 2FA?",
"txt_remove_device_and_sign_out_name": "¿Quitar dispositivo \"{name}\", limpiar su confianza y cerrar sesión?", "txt_remove_device_and_sign_out_name": "¿Quitar dispositivo \"{name}\", limpiar su confianza y cerrar sesión?",
"txt_reveal": "Mostrar", "txt_reveal": "Mostrar",
@@ -771,6 +884,9 @@ const es: Record<string, string> = {
"txt_security_code": "Código de seguridad", "txt_security_code": "Código de seguridad",
"txt_security_code_cvv": "Código de seguridad (CVV)", "txt_security_code_cvv": "Código de seguridad (CVV)",
"txt_select_all": "Seleccionar todo", "txt_select_all": "Seleccionar todo",
"txt_clear_selection": "Borrar selección",
"txt_select_device_name": "Seleccionar {name}",
"txt_no_devices_selected": "No hay dispositivos seleccionados",
"txt_select": "Seleccionar", "txt_select": "Seleccionar",
"txt_select_duplicate_items": "Seleccionar duplicados", "txt_select_duplicate_items": "Seleccionar duplicados",
"txt_select_an_item": "Seleccione un elemento", "txt_select_an_item": "Seleccione un elemento",
+116
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": "Каждый пункт назначения может иметь собственный ежедневный график резервного копирования.",
@@ -193,10 +224,14 @@ const ru: Record<string, string> = {
"txt_backup_restore_progress_remote_finalize_detail": "Сервер выполняет окончательную проверку, а затем переключает проверенные данные восстановления в живые таблицы.", "txt_backup_restore_progress_remote_finalize_detail": "Сервер выполняет окончательную проверку, а затем переключает проверенные данные восстановления в живые таблицы.",
"txt_backup_remote_loading": "Загрузка удаленных резервных копий...", "txt_backup_remote_loading": "Загрузка удаленных резервных копий...",
"txt_backup_remote_cached_empty": "Нажмите «Обновить», чтобы загрузить это место назначения.", "txt_backup_remote_cached_empty": "Нажмите «Обновить», чтобы загрузить это место назначения.",
"txt_backup_remote_cached_empty_prefix": "Нажмите",
"txt_backup_remote_cached_empty_suffix": "чтобы загрузить это место назначения.",
"txt_backup_remote_empty": "В этой папке не найдено файлов резервных копий.", "txt_backup_remote_empty": "В этой папке не найдено файлов резервных копий.",
"txt_backup_remote_folder": "Папка", "txt_backup_remote_folder": "Папка",
"txt_backup_remote_unknown_time": "Неизвестное время", "txt_backup_remote_unknown_time": "Неизвестное время",
"txt_backup_remote_current_path": "Текущая папка", "txt_backup_remote_current_path": "Текущая папка",
"txt_backup_remote_modified": "Изменено",
"txt_backup_remote_size": "Размер",
"txt_backup_remote_load_failed": "Не удалось загрузить удаленные резервные копии.", "txt_backup_remote_load_failed": "Не удалось загрузить удаленные резервные копии.",
"txt_backup_remote_invalid_response": "Неверный ответ удаленного резервного копирования", "txt_backup_remote_invalid_response": "Неверный ответ удаленного резервного копирования",
"txt_backup_remote_download_failed": "Не удалось загрузить удаленную резервную копию.", "txt_backup_remote_download_failed": "Не удалось загрузить удаленную резервную копию.",
@@ -214,6 +249,74 @@ const ru: Record<string, string> = {
"txt_backup_remote_run_invalid_response": "Неверный ответ на удаленное резервное копирование.", "txt_backup_remote_run_invalid_response": "Неверный ответ на удаленное резервное копирование.",
"txt_backup_settings_invalid_response": "Неверный ответ на настройки резервного копирования", "txt_backup_settings_invalid_response": "Неверный ответ на настройки резервного копирования",
"txt_backup_import_invalid_response": "Неверный ответ на импорт резервной копии", "txt_backup_import_invalid_response": "Неверный ответ на импорт резервной копии",
"txt_backup_error_another_backup_or_restore_running": "Уже выполняется задача резервного копирования или восстановления.",
"txt_backup_error_another_backup_running": "Уже выполняется задача резервного копирования.",
"txt_backup_error_archive_upload_failed": "Не удалось загрузить архив резервной копии.",
"txt_backup_error_archive_upload_verification_failed_attempts": "Проверка загрузки не прошла после {count} попыток: {reason}",
"txt_backup_error_attachment_blob_invalid": "Объект вложения резервной копии недействителен.",
"txt_backup_error_attachment_blob_required": "Требуется объект вложения резервной копии.",
"txt_backup_error_attachment_blob_not_found": "Объект вложения резервной копии не найден.",
"txt_backup_error_attachment_download_failed": "Не удалось скачать вложение резервной копии.",
"txt_backup_error_destination_invalid": "Место назначения резервной копии недействительно.",
"txt_backup_error_destination_limit": "Можно сохранить не более {count} мест назначения резервной копии.",
"txt_backup_error_destination_not_found": "Место назначения резервной копии не найдено.",
"txt_backup_error_destination_ids_unique": "ID мест назначения резервной копии должны быть уникальными.",
"txt_backup_error_destination_type_invalid": "Тип места назначения резервной копии недействителен.",
"txt_backup_error_destination_type_unsupported": "Неподдерживаемый тип места назначения резервной копии.",
"txt_backup_error_destinations_invalid": "Список мест назначения резервной копии недействителен.",
"txt_backup_error_export_payload_invalid": "Запрос экспорта резервной копии недействителен.",
"txt_backup_error_file_checksum_mismatch": "Контрольная сумма файла резервной копии не совпадает с именем файла.",
"txt_backup_error_file_required": "Выберите файл резервной копии.",
"txt_backup_error_interval_hours_range": "Интервал резервного копирования должен быть от 1 до 99 часов.",
"txt_backup_error_multipart_required": "Запрос загрузки должен использовать multipart/form-data.",
"txt_backup_error_read_backup_file_failed": "Не удалось прочитать файл резервной копии.",
"txt_backup_error_remote_attachment_batch_download_failed_status": "Пакетное скачивание удаленных вложений не удалось: HTTP {status}.",
"txt_backup_error_remote_attachment_download_failed_status": "Скачивание удаленного вложения не удалось: HTTP {status}.",
"txt_backup_error_remote_delete_failed": "Не удалось удалить удаленную резервную копию.",
"txt_backup_error_remote_download_failed": "Не удалось скачать удаленную резервную копию.",
"txt_backup_error_remote_download_payload_invalid": "Запрос скачивания удаленной резервной копии недействителен.",
"txt_backup_error_remote_integrity_failed": "Не удалось проверить целостность удаленной резервной копии.",
"txt_backup_error_remote_listing_failed": "Не удалось получить список удаленных резервных копий.",
"txt_backup_error_remote_path_invalid": "Путь удаленной резервной копии недействителен.",
"txt_backup_error_remote_restore_payload_invalid": "Запрос удаленного восстановления недействителен.",
"txt_backup_error_remote_zip_checksum_failed": "Проверка контрольной суммы удаленного ZIP не прошла.",
"txt_backup_error_remote_zip_size_failed": "Проверка размера удаленного ZIP не прошла.",
"txt_backup_error_retention_count_range": "Количество сохраняемых копий должно быть от 1 до 1000.",
"txt_backup_error_run_failed": "Запуск резервного копирования не удался.",
"txt_backup_error_run_payload_invalid": "Запрос запуска резервного копирования недействителен.",
"txt_backup_error_run_response_invalid": "Ответ запуска резервного копирования недействителен.",
"txt_backup_error_s3_access_key_required": "Требуется ключ доступа S3.",
"txt_backup_error_s3_bucket_required": "Требуется bucket S3.",
"txt_backup_error_s3_delete_failed_status": "Удаление S3 не удалось: HTTP {status}.",
"txt_backup_error_s3_download_failed_status": "Скачивание S3 не удалось: HTTP {status}.",
"txt_backup_error_s3_endpoint_required": "Требуется endpoint S3.",
"txt_backup_error_s3_endpoint_protocol": "Endpoint S3 должен начинаться с http:// или https://.",
"txt_backup_error_s3_existence_check_failed_status": "Проверка существования S3 не удалась: HTTP {status}.",
"txt_backup_error_s3_listing_failed_status": "Получение списка S3 не удалось: HTTP {status}.",
"txt_backup_error_s3_secret_key_required": "Требуется секретный ключ S3.",
"txt_backup_error_s3_upload_failed_status": "Загрузка S3 не удалась: HTTP {status}.",
"txt_backup_error_select_backup_file": "Выберите файл резервной копии.",
"txt_backup_error_select_backup_zip_file": "Выберите ZIP-файл резервной копии.",
"txt_backup_error_settings_envelope_invalid": "Зашифрованный контейнер настроек резервного копирования недействителен.",
"txt_backup_error_settings_invalid": "Настройки резервного копирования недействительны.",
"txt_backup_error_settings_load_failed": "Не удалось загрузить настройки резервного копирования.",
"txt_backup_error_settings_need_reactivation": "После восстановления настройки резервного копирования нужно повторно активировать администратором.",
"txt_backup_error_settings_payload_invalid": "Запрос настроек резервного копирования недействителен.",
"txt_backup_error_settings_repair_payload_invalid": "Запрос восстановления настроек резервного копирования недействителен.",
"txt_backup_error_settings_repair_state_load_failed": "Не удалось загрузить состояние восстановления настроек резервного копирования.",
"txt_backup_error_start_time_format": "Время начала резервного копирования должно быть в формате HH:mm.",
"txt_backup_error_timezone_invalid": "Часовой пояс резервного копирования недействителен.",
"txt_backup_error_webdav_delete_failed_status": "Удаление WebDAV не удалось: HTTP {status}.",
"txt_backup_error_webdav_directory_creation_failed_status": "Создание каталога WebDAV не удалось: HTTP {status}.",
"txt_backup_error_webdav_download_failed_status": "Скачивание WebDAV не удалось: HTTP {status}.",
"txt_backup_error_webdav_existence_check_failed_status": "Проверка существования WebDAV не удалась: HTTP {status}.",
"txt_backup_error_webdav_listing_failed_status": "Получение списка WebDAV не удалось: HTTP {status}.",
"txt_backup_error_webdav_password_required": "Требуется пароль WebDAV.",
"txt_backup_error_webdav_path_too_deep": "Удаленный путь WebDAV слишком глубокий для безопасной пакетной обработки вложений.",
"txt_backup_error_webdav_upload_failed_status": "Загрузка WebDAV не удалась: HTTP {status}.",
"txt_backup_error_webdav_url_required": "Требуется URL сервера WebDAV.",
"txt_backup_error_webdav_url_protocol": "URL WebDAV должен начинаться с http:// или https://.",
"txt_backup_error_webdav_username_required": "Требуется имя пользователя WebDAV.",
"txt_backup_destination": "Место назначения резервного копирования", "txt_backup_destination": "Место назначения резервного копирования",
"txt_backup_protocol_webdav": "WebDAV", "txt_backup_protocol_webdav": "WebDAV",
"txt_backup_protocol_s3": "S3", "txt_backup_protocol_s3": "S3",
@@ -489,16 +592,21 @@ const ru: Record<string, string> = {
"txt_server_error_account_disabled": "Учетная запись отключена", "txt_server_error_account_disabled": "Учетная запись отключена",
"txt_server_error_client_credentials_incorrect": "ID клиента или секрет клиента неверны. Повторите попытку.", "txt_server_error_client_credentials_incorrect": "ID клиента или секрет клиента неверны. Повторите попытку.",
"txt_server_error_client_ip_required": "Требуется IP клиента", "txt_server_error_client_ip_required": "Требуется IP клиента",
"txt_server_error_forbidden": "У вас нет прав для выполнения этого действия.",
"txt_server_error_email_already_registered": "Этот адрес электронной почты уже зарегистрирован", "txt_server_error_email_already_registered": "Этот адрес электронной почты уже зарегистрирован",
"txt_server_error_email_password_required": "Требуются адрес электронной почты и пароль", "txt_server_error_email_password_required": "Требуются адрес электронной почты и пароль",
"txt_server_error_email_required": "Требуется адрес электронной почты", "txt_server_error_email_required": "Требуется адрес электронной почты",
"txt_server_error_invalid_password": "Неверный пароль.",
"txt_server_error_invalid_refresh_token": "Сеанс истек. Войдите снова.", "txt_server_error_invalid_refresh_token": "Сеанс истек. Войдите снова.",
"txt_server_error_invalid_user_verification_token": "Недействительный токен проверки пользователя.",
"txt_server_error_invalid_request_payload": "Недопустимый запрос", "txt_server_error_invalid_request_payload": "Недопустимый запрос",
"txt_server_error_invite_invalid_or_expired": "Код приглашения недействителен или истек", "txt_server_error_invite_invalid_or_expired": "Код приглашения недействителен или истек",
"txt_server_error_invite_required": "Требуется код приглашения", "txt_server_error_invite_required": "Требуется код приглашения",
"txt_server_error_jwt_secret_default": "JWT_SECRET использует значение по умолчанию/пример. Измените его.", "txt_server_error_jwt_secret_default": "JWT_SECRET использует значение по умолчанию/пример. Измените его.",
"txt_server_error_jwt_secret_missing": "JWT_SECRET не настроен", "txt_server_error_jwt_secret_missing": "JWT_SECRET не настроен",
"txt_server_error_jwt_secret_too_short": "JWT_SECRET должен содержать не менее 32 символов", "txt_server_error_jwt_secret_too_short": "JWT_SECRET должен содержать не менее 32 символов",
"txt_server_error_master_password_hash_required": "Требуется проверка мастер-пароля.",
"txt_server_error_master_password_or_verification_required": "Требуется мастер-пароль или токен проверки пользователя.",
"txt_server_error_parameter_error": "Ошибка параметров", "txt_server_error_parameter_error": "Ошибка параметров",
"txt_server_error_refresh_token_required": "Сеанс отсутствует. Войдите снова.", "txt_server_error_refresh_token_required": "Сеанс отсутствует. Войдите снова.",
"txt_server_error_registration_retry": "Регистрация временно недоступна. Повторите попытку один раз.", "txt_server_error_registration_retry": "Регистрация временно недоступна. Повторите попытку один раз.",
@@ -730,6 +838,11 @@ const ru: Record<string, string> = {
"txt_remove_all_devices": "Удалить все устройства", "txt_remove_all_devices": "Удалить все устройства",
"txt_remove_all_devices_and_clear_all_2fa_trust": "Удалить все устройства и очистить все доверие 2FA?", "txt_remove_all_devices_and_clear_all_2fa_trust": "Удалить все устройства и очистить все доверие 2FA?",
"txt_remove_all_devices_and_sign_out_all_sessions": "Удалить все устройства, отменить все доверительные отношения и выйти из системы на каждом устройстве?", "txt_remove_all_devices_and_sign_out_all_sessions": "Удалить все устройства, отменить все доверительные отношения и выйти из системы на каждом устройстве?",
"txt_remove_selected_devices": "Удалить выбранные ({count})",
"txt_remove_selected_devices_confirm": "Удалить {count} выбранных устройств, очистить их доверие и выйти из системы на них?",
"txt_remove_selected_devices_and_sign_out_current": "Удалить {count} выбранных устройств, очистить их доверие и также выйти из системы на этом устройстве?",
"txt_selected_devices_removed": "Выбранные устройства удалены",
"txt_remove_selected_devices_failed": "Не удалось удалить выбранные устройства",
"txt_remove_device_name_and_clear_its_2fa_trust": "Удалить устройство «{name}» и очистить его доверие 2FA?", "txt_remove_device_name_and_clear_its_2fa_trust": "Удалить устройство «{name}» и очистить его доверие 2FA?",
"txt_remove_device_and_sign_out_name": "Удалить устройство «{name}», очистить его доверие и выйти из системы?", "txt_remove_device_and_sign_out_name": "Удалить устройство «{name}», очистить его доверие и выйти из системы?",
"txt_reveal": "Раскрыть", "txt_reveal": "Раскрыть",
@@ -771,6 +884,9 @@ const ru: Record<string, string> = {
"txt_security_code": "Код безопасности", "txt_security_code": "Код безопасности",
"txt_security_code_cvv": "Код безопасности (CVV)", "txt_security_code_cvv": "Код безопасности (CVV)",
"txt_select_all": "Выбрать все", "txt_select_all": "Выбрать все",
"txt_clear_selection": "Очистить выбор",
"txt_select_device_name": "Выбрать {name}",
"txt_no_devices_selected": "Устройства не выбраны",
"txt_select": "Выбрать", "txt_select": "Выбрать",
"txt_select_duplicate_items": "Выберите дубликаты", "txt_select_duplicate_items": "Выберите дубликаты",
"txt_select_an_item": "Выберите элемент", "txt_select_an_item": "Выберите элемент",
+122 -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": "每个备份地点都可以单独配置自己的每日自动备份计划。",
@@ -193,10 +224,14 @@ const zhCN: Record<string, string> = {
"txt_backup_restore_progress_remote_finalize_detail": "服务器正在执行最终校验,校验通过后会把已验证的数据切换为正式数据。", "txt_backup_restore_progress_remote_finalize_detail": "服务器正在执行最终校验,校验通过后会把已验证的数据切换为正式数据。",
"txt_backup_remote_loading": "正在读取远端备份...", "txt_backup_remote_loading": "正在读取远端备份...",
"txt_backup_remote_cached_empty": "点击“刷新”后读取", "txt_backup_remote_cached_empty": "点击“刷新”后读取",
"txt_backup_remote_cached_empty_prefix": "点击",
"txt_backup_remote_cached_empty_suffix": "后读取",
"txt_backup_remote_empty": "这个目录下还没有备份文件", "txt_backup_remote_empty": "这个目录下还没有备份文件",
"txt_backup_remote_folder": "文件夹", "txt_backup_remote_folder": "文件夹",
"txt_backup_remote_unknown_time": "未知时间", "txt_backup_remote_unknown_time": "未知时间",
"txt_backup_remote_current_path": "当前目录", "txt_backup_remote_current_path": "当前目录",
"txt_backup_remote_modified": "修改时间",
"txt_backup_remote_size": "大小",
"txt_backup_remote_load_failed": "读取远端备份失败", "txt_backup_remote_load_failed": "读取远端备份失败",
"txt_backup_remote_invalid_response": "远端备份响应无效", "txt_backup_remote_invalid_response": "远端备份响应无效",
"txt_backup_remote_download_failed": "下载远端备份失败", "txt_backup_remote_download_failed": "下载远端备份失败",
@@ -214,6 +249,74 @@ const zhCN: Record<string, string> = {
"txt_backup_remote_run_invalid_response": "远端备份执行响应无效", "txt_backup_remote_run_invalid_response": "远端备份执行响应无效",
"txt_backup_settings_invalid_response": "备份设置响应无效", "txt_backup_settings_invalid_response": "备份设置响应无效",
"txt_backup_import_invalid_response": "备份还原响应无效", "txt_backup_import_invalid_response": "备份还原响应无效",
"txt_backup_error_another_backup_or_restore_running": "已有备份或还原任务正在执行。",
"txt_backup_error_another_backup_running": "已有备份任务正在执行。",
"txt_backup_error_archive_upload_failed": "备份压缩包上传失败。",
"txt_backup_error_archive_upload_verification_failed_attempts": "备份上传校验在 {count} 次尝试后仍失败:{reason}",
"txt_backup_error_attachment_blob_invalid": "备份附件对象无效。",
"txt_backup_error_attachment_blob_required": "缺少备份附件对象。",
"txt_backup_error_attachment_blob_not_found": "未找到备份附件对象。",
"txt_backup_error_attachment_download_failed": "备份附件下载失败。",
"txt_backup_error_destination_invalid": "备份地点无效。",
"txt_backup_error_destination_limit": "最多只能保存 {count} 个备份地点。",
"txt_backup_error_destination_not_found": "未找到备份地点。",
"txt_backup_error_destination_ids_unique": "备份地点 ID 不能重复。",
"txt_backup_error_destination_type_invalid": "备份地点类型无效。",
"txt_backup_error_destination_type_unsupported": "不支持的备份地点类型。",
"txt_backup_error_destinations_invalid": "备份地点列表无效。",
"txt_backup_error_export_payload_invalid": "备份导出请求无效。",
"txt_backup_error_file_checksum_mismatch": "备份文件校验值与文件名不一致。",
"txt_backup_error_file_required": "请选择备份文件。",
"txt_backup_error_interval_hours_range": "备份间隔必须在 1 到 99 小时之间。",
"txt_backup_error_multipart_required": "上传请求必须使用 multipart/form-data。",
"txt_backup_error_read_backup_file_failed": "无法读取备份文件。",
"txt_backup_error_remote_attachment_batch_download_failed_status": "远端附件批量下载失败:HTTP {status}。",
"txt_backup_error_remote_attachment_download_failed_status": "远端附件下载失败:HTTP {status}。",
"txt_backup_error_remote_delete_failed": "远端备份删除失败。",
"txt_backup_error_remote_download_failed": "远端备份下载失败。",
"txt_backup_error_remote_download_payload_invalid": "远端备份下载请求无效。",
"txt_backup_error_remote_integrity_failed": "远端备份完整性检查失败。",
"txt_backup_error_remote_listing_failed": "远端备份列表读取失败。",
"txt_backup_error_remote_path_invalid": "远端备份路径无效。",
"txt_backup_error_remote_restore_payload_invalid": "远端还原请求无效。",
"txt_backup_error_remote_zip_checksum_failed": "远端备份 ZIP 校验失败。",
"txt_backup_error_remote_zip_size_failed": "远端备份 ZIP 大小校验失败。",
"txt_backup_error_retention_count_range": "备份保留数量必须在 1 到 1000 之间。",
"txt_backup_error_run_failed": "备份执行失败。",
"txt_backup_error_run_payload_invalid": "备份执行请求无效。",
"txt_backup_error_run_response_invalid": "备份执行响应无效。",
"txt_backup_error_s3_access_key_required": "请填写 S3 访问 ID。",
"txt_backup_error_s3_bucket_required": "请填写 S3 存储桶名称。",
"txt_backup_error_s3_delete_failed_status": "S3 删除失败:HTTP {status}。",
"txt_backup_error_s3_download_failed_status": "S3 下载失败:HTTP {status}。",
"txt_backup_error_s3_endpoint_required": "请填写 S3 端点 URL。",
"txt_backup_error_s3_endpoint_protocol": "S3 端点 URL 必须以 http:// 或 https:// 开头。",
"txt_backup_error_s3_existence_check_failed_status": "S3 文件存在性检查失败:HTTP {status}。",
"txt_backup_error_s3_listing_failed_status": "S3 列表读取失败:HTTP {status}。",
"txt_backup_error_s3_secret_key_required": "请填写 S3 访问密码。",
"txt_backup_error_s3_upload_failed_status": "S3 上传失败:HTTP {status}。",
"txt_backup_error_select_backup_file": "请选择备份文件。",
"txt_backup_error_select_backup_zip_file": "请选择备份 ZIP 文件。",
"txt_backup_error_settings_envelope_invalid": "备份设置加密封装无效。",
"txt_backup_error_settings_invalid": "备份设置无效。",
"txt_backup_error_settings_load_failed": "无法加载备份设置。",
"txt_backup_error_settings_need_reactivation": "还原后需要管理员重新激活备份设置。",
"txt_backup_error_settings_payload_invalid": "备份设置请求无效。",
"txt_backup_error_settings_repair_payload_invalid": "备份设置修复请求无效。",
"txt_backup_error_settings_repair_state_load_failed": "无法加载备份设置修复状态。",
"txt_backup_error_start_time_format": "备份开始时间必须是 HH:mm 格式。",
"txt_backup_error_timezone_invalid": "备份时区无效。",
"txt_backup_error_webdav_delete_failed_status": "WebDAV 删除失败:HTTP {status}。",
"txt_backup_error_webdav_directory_creation_failed_status": "WebDAV 目录创建失败:HTTP {status}。",
"txt_backup_error_webdav_download_failed_status": "WebDAV 下载失败:HTTP {status}。",
"txt_backup_error_webdav_existence_check_failed_status": "WebDAV 文件存在性检查失败:HTTP {status}。",
"txt_backup_error_webdav_listing_failed_status": "WebDAV 列表读取失败:HTTP {status}。",
"txt_backup_error_webdav_password_required": "请填写 WebDAV 密码。",
"txt_backup_error_webdav_path_too_deep": "WebDAV 远端备份路径过深,无法安全分批处理附件。",
"txt_backup_error_webdav_upload_failed_status": "WebDAV 上传失败:HTTP {status}。",
"txt_backup_error_webdav_url_required": "请填写 WebDAV 服务地址。",
"txt_backup_error_webdav_url_protocol": "WebDAV 服务地址必须以 http:// 或 https:// 开头。",
"txt_backup_error_webdav_username_required": "请填写 WebDAV 用户名。",
"txt_backup_destination": "备份地点", "txt_backup_destination": "备份地点",
"txt_backup_protocol_webdav": "WebDAV", "txt_backup_protocol_webdav": "WebDAV",
"txt_backup_protocol_s3": "S3", "txt_backup_protocol_s3": "S3",
@@ -266,15 +369,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": "给下一个备份地点先留个说明",
@@ -489,16 +592,21 @@ const zhCN: Record<string, string> = {
"txt_server_error_account_disabled": "账号已被禁用", "txt_server_error_account_disabled": "账号已被禁用",
"txt_server_error_client_credentials_incorrect": "客户端 ID 或客户端密钥不正确,请重试", "txt_server_error_client_credentials_incorrect": "客户端 ID 或客户端密钥不正确,请重试",
"txt_server_error_client_ip_required": "无法获取客户端 IP", "txt_server_error_client_ip_required": "无法获取客户端 IP",
"txt_server_error_forbidden": "你没有权限执行此操作。",
"txt_server_error_email_already_registered": "该邮箱已注册", "txt_server_error_email_already_registered": "该邮箱已注册",
"txt_server_error_email_password_required": "邮箱和密码不能为空", "txt_server_error_email_password_required": "邮箱和密码不能为空",
"txt_server_error_email_required": "邮箱不能为空", "txt_server_error_email_required": "邮箱不能为空",
"txt_server_error_invalid_password": "密码无效。",
"txt_server_error_invalid_refresh_token": "登录状态已失效,请重新登录", "txt_server_error_invalid_refresh_token": "登录状态已失效,请重新登录",
"txt_server_error_invalid_user_verification_token": "用户验证令牌无效。",
"txt_server_error_invalid_request_payload": "请求内容无效", "txt_server_error_invalid_request_payload": "请求内容无效",
"txt_server_error_invite_invalid_or_expired": "邀请码无效或已过期", "txt_server_error_invite_invalid_or_expired": "邀请码无效或已过期",
"txt_server_error_invite_required": "邀请码不能为空", "txt_server_error_invite_required": "邀请码不能为空",
"txt_server_error_jwt_secret_default": "JWT_SECRET 正在使用默认示例值,请修改后再继续", "txt_server_error_jwt_secret_default": "JWT_SECRET 正在使用默认示例值,请修改后再继续",
"txt_server_error_jwt_secret_missing": "JWT_SECRET 未设置", "txt_server_error_jwt_secret_missing": "JWT_SECRET 未设置",
"txt_server_error_jwt_secret_too_short": "JWT_SECRET 至少需要 32 个字符", "txt_server_error_jwt_secret_too_short": "JWT_SECRET 至少需要 32 个字符",
"txt_server_error_master_password_hash_required": "需要验证主密码。",
"txt_server_error_master_password_or_verification_required": "需要主密码或用户验证令牌。",
"txt_server_error_parameter_error": "请求参数错误", "txt_server_error_parameter_error": "请求参数错误",
"txt_server_error_refresh_token_required": "登录状态缺失,请重新登录", "txt_server_error_refresh_token_required": "登录状态缺失,请重新登录",
"txt_server_error_registration_retry": "注册暂时不可用,请重试一次", "txt_server_error_registration_retry": "注册暂时不可用,请重试一次",
@@ -730,6 +838,11 @@ const zhCN: Record<string, string> = {
"txt_remove_all_devices": "移除所有设备", "txt_remove_all_devices": "移除所有设备",
"txt_remove_all_devices_and_clear_all_2fa_trust": "确认移除所有设备并清除全部 2FA 信任吗?", "txt_remove_all_devices_and_clear_all_2fa_trust": "确认移除所有设备并清除全部 2FA 信任吗?",
"txt_remove_all_devices_and_sign_out_all_sessions": "确认移除所有设备、清除全部信任,并让所有设备重新登录吗?", "txt_remove_all_devices_and_sign_out_all_sessions": "确认移除所有设备、清除全部信任,并让所有设备重新登录吗?",
"txt_remove_selected_devices": "移除已选({count}",
"txt_remove_selected_devices_confirm": "确认移除选中的 {count} 台设备、清除其信任,并让它们重新登录吗?",
"txt_remove_selected_devices_and_sign_out_current": "确认移除选中的 {count} 台设备、清除其信任,并同时退出本设备吗?",
"txt_selected_devices_removed": "已移除选中设备",
"txt_remove_selected_devices_failed": "移除选中设备失败",
"txt_remove_device_name_and_clear_its_2fa_trust": "确认移除设备“{name}”并清除其 2FA 信任吗?", "txt_remove_device_name_and_clear_its_2fa_trust": "确认移除设备“{name}”并清除其 2FA 信任吗?",
"txt_remove_device_and_sign_out_name": "确认移除设备“{name}”,清除其信任,并让它重新登录吗?", "txt_remove_device_and_sign_out_name": "确认移除设备“{name}”,清除其信任,并让它重新登录吗?",
"txt_reveal": "显示", "txt_reveal": "显示",
@@ -771,6 +884,9 @@ const zhCN: Record<string, string> = {
"txt_security_code": "安全码", "txt_security_code": "安全码",
"txt_security_code_cvv": "安全码 (CVV)", "txt_security_code_cvv": "安全码 (CVV)",
"txt_select_all": "全选", "txt_select_all": "全选",
"txt_clear_selection": "取消选择",
"txt_select_device_name": "选择 {name}",
"txt_no_devices_selected": "未选择设备",
"txt_select": "请选择", "txt_select": "请选择",
"txt_select_duplicate_items": "选择重复项", "txt_select_duplicate_items": "选择重复项",
"txt_select_an_item": "请选择一个项目", "txt_select_an_item": "请选择一个项目",
+116
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": "每個備份地點都可以單獨配置自己的每日自動備份計劃。",
@@ -193,10 +224,14 @@ const zhTW: Record<string, string> = {
"txt_backup_restore_progress_remote_finalize_detail": "服務器正在執行最終校驗,校驗通過後會把已驗證的數據切換為正式數據。", "txt_backup_restore_progress_remote_finalize_detail": "服務器正在執行最終校驗,校驗通過後會把已驗證的數據切換為正式數據。",
"txt_backup_remote_loading": "正在讀取遠端備份...", "txt_backup_remote_loading": "正在讀取遠端備份...",
"txt_backup_remote_cached_empty": "點擊“刷新”後讀取", "txt_backup_remote_cached_empty": "點擊“刷新”後讀取",
"txt_backup_remote_cached_empty_prefix": "點擊",
"txt_backup_remote_cached_empty_suffix": "後讀取",
"txt_backup_remote_empty": "這個目錄下還沒有備份文件", "txt_backup_remote_empty": "這個目錄下還沒有備份文件",
"txt_backup_remote_folder": "文件夾", "txt_backup_remote_folder": "文件夾",
"txt_backup_remote_unknown_time": "未知時間", "txt_backup_remote_unknown_time": "未知時間",
"txt_backup_remote_current_path": "當前目錄", "txt_backup_remote_current_path": "當前目錄",
"txt_backup_remote_modified": "修改時間",
"txt_backup_remote_size": "大小",
"txt_backup_remote_load_failed": "讀取遠端備份失敗", "txt_backup_remote_load_failed": "讀取遠端備份失敗",
"txt_backup_remote_invalid_response": "遠端備份響應無效", "txt_backup_remote_invalid_response": "遠端備份響應無效",
"txt_backup_remote_download_failed": "下載遠端備份失敗", "txt_backup_remote_download_failed": "下載遠端備份失敗",
@@ -214,6 +249,74 @@ const zhTW: Record<string, string> = {
"txt_backup_remote_run_invalid_response": "遠端備份執行響應無效", "txt_backup_remote_run_invalid_response": "遠端備份執行響應無效",
"txt_backup_settings_invalid_response": "備份設置響應無效", "txt_backup_settings_invalid_response": "備份設置響應無效",
"txt_backup_import_invalid_response": "備份還原響應無效", "txt_backup_import_invalid_response": "備份還原響應無效",
"txt_backup_error_another_backup_or_restore_running": "已有備份或還原任務正在執行。",
"txt_backup_error_another_backup_running": "已有備份任務正在執行。",
"txt_backup_error_archive_upload_failed": "備份壓縮包上傳失敗。",
"txt_backup_error_archive_upload_verification_failed_attempts": "備份上傳校驗在 {count} 次嘗試後仍失敗:{reason}",
"txt_backup_error_attachment_blob_invalid": "備份附件對象無效。",
"txt_backup_error_attachment_blob_required": "缺少備份附件對象。",
"txt_backup_error_attachment_blob_not_found": "未找到備份附件對象。",
"txt_backup_error_attachment_download_failed": "備份附件下載失敗。",
"txt_backup_error_destination_invalid": "備份地點無效。",
"txt_backup_error_destination_limit": "最多只能保存 {count} 個備份地點。",
"txt_backup_error_destination_not_found": "未找到備份地點。",
"txt_backup_error_destination_ids_unique": "備份地點 ID 不能重複。",
"txt_backup_error_destination_type_invalid": "備份地點類型無效。",
"txt_backup_error_destination_type_unsupported": "不支持的備份地點類型。",
"txt_backup_error_destinations_invalid": "備份地點列表無效。",
"txt_backup_error_export_payload_invalid": "備份導出請求無效。",
"txt_backup_error_file_checksum_mismatch": "備份文件校驗值與文件名不一致。",
"txt_backup_error_file_required": "請選擇備份文件。",
"txt_backup_error_interval_hours_range": "備份間隔必須在 1 到 99 小時之間。",
"txt_backup_error_multipart_required": "上傳請求必須使用 multipart/form-data。",
"txt_backup_error_read_backup_file_failed": "無法讀取備份文件。",
"txt_backup_error_remote_attachment_batch_download_failed_status": "遠端附件批量下載失敗:HTTP {status}。",
"txt_backup_error_remote_attachment_download_failed_status": "遠端附件下載失敗:HTTP {status}。",
"txt_backup_error_remote_delete_failed": "遠端備份刪除失敗。",
"txt_backup_error_remote_download_failed": "遠端備份下載失敗。",
"txt_backup_error_remote_download_payload_invalid": "遠端備份下載請求無效。",
"txt_backup_error_remote_integrity_failed": "遠端備份完整性檢查失敗。",
"txt_backup_error_remote_listing_failed": "遠端備份列表讀取失敗。",
"txt_backup_error_remote_path_invalid": "遠端備份路徑無效。",
"txt_backup_error_remote_restore_payload_invalid": "遠端還原請求無效。",
"txt_backup_error_remote_zip_checksum_failed": "遠端備份 ZIP 校驗失敗。",
"txt_backup_error_remote_zip_size_failed": "遠端備份 ZIP 大小校驗失敗。",
"txt_backup_error_retention_count_range": "備份保留數量必須在 1 到 1000 之間。",
"txt_backup_error_run_failed": "備份執行失敗。",
"txt_backup_error_run_payload_invalid": "備份執行請求無效。",
"txt_backup_error_run_response_invalid": "備份執行響應無效。",
"txt_backup_error_s3_access_key_required": "請填寫 S3 存取 ID。",
"txt_backup_error_s3_bucket_required": "請填寫 S3 儲存桶名稱。",
"txt_backup_error_s3_delete_failed_status": "S3 刪除失敗:HTTP {status}。",
"txt_backup_error_s3_download_failed_status": "S3 下載失敗:HTTP {status}。",
"txt_backup_error_s3_endpoint_required": "請填寫 S3 端點 URL。",
"txt_backup_error_s3_endpoint_protocol": "S3 端點 URL 必須以 http:// 或 https:// 開頭。",
"txt_backup_error_s3_existence_check_failed_status": "S3 文件存在性檢查失敗:HTTP {status}。",
"txt_backup_error_s3_listing_failed_status": "S3 列表讀取失敗:HTTP {status}。",
"txt_backup_error_s3_secret_key_required": "請填寫 S3 存取密碼。",
"txt_backup_error_s3_upload_failed_status": "S3 上傳失敗:HTTP {status}。",
"txt_backup_error_select_backup_file": "請選擇備份文件。",
"txt_backup_error_select_backup_zip_file": "請選擇備份 ZIP 文件。",
"txt_backup_error_settings_envelope_invalid": "備份設置加密封裝無效。",
"txt_backup_error_settings_invalid": "備份設置無效。",
"txt_backup_error_settings_load_failed": "無法加載備份設置。",
"txt_backup_error_settings_need_reactivation": "還原後需要管理員重新激活備份設置。",
"txt_backup_error_settings_payload_invalid": "備份設置請求無效。",
"txt_backup_error_settings_repair_payload_invalid": "備份設置修復請求無效。",
"txt_backup_error_settings_repair_state_load_failed": "無法加載備份設置修復狀態。",
"txt_backup_error_start_time_format": "備份開始時間必須是 HH:mm 格式。",
"txt_backup_error_timezone_invalid": "備份時區無效。",
"txt_backup_error_webdav_delete_failed_status": "WebDAV 刪除失敗:HTTP {status}。",
"txt_backup_error_webdav_directory_creation_failed_status": "WebDAV 目錄創建失敗:HTTP {status}。",
"txt_backup_error_webdav_download_failed_status": "WebDAV 下載失敗:HTTP {status}。",
"txt_backup_error_webdav_existence_check_failed_status": "WebDAV 文件存在性檢查失敗:HTTP {status}。",
"txt_backup_error_webdav_listing_failed_status": "WebDAV 列表讀取失敗:HTTP {status}。",
"txt_backup_error_webdav_password_required": "請填寫 WebDAV 密碼。",
"txt_backup_error_webdav_path_too_deep": "WebDAV 遠端備份路徑過深,無法安全分批處理附件。",
"txt_backup_error_webdav_upload_failed_status": "WebDAV 上傳失敗:HTTP {status}。",
"txt_backup_error_webdav_url_required": "請填寫 WebDAV 服務地址。",
"txt_backup_error_webdav_url_protocol": "WebDAV 服務地址必須以 http:// 或 https:// 開頭。",
"txt_backup_error_webdav_username_required": "請填寫 WebDAV 用戶名。",
"txt_backup_destination": "備份地點", "txt_backup_destination": "備份地點",
"txt_backup_protocol_webdav": "WebDAV", "txt_backup_protocol_webdav": "WebDAV",
"txt_backup_protocol_s3": "S3", "txt_backup_protocol_s3": "S3",
@@ -489,16 +592,21 @@ const zhTW: Record<string, string> = {
"txt_server_error_account_disabled": "帳號已被禁用", "txt_server_error_account_disabled": "帳號已被禁用",
"txt_server_error_client_credentials_incorrect": "客戶端 ID 或客戶端密鑰不正確,請重試", "txt_server_error_client_credentials_incorrect": "客戶端 ID 或客戶端密鑰不正確,請重試",
"txt_server_error_client_ip_required": "無法獲取客戶端 IP", "txt_server_error_client_ip_required": "無法獲取客戶端 IP",
"txt_server_error_forbidden": "你沒有權限執行此操作。",
"txt_server_error_email_already_registered": "該郵箱已註冊", "txt_server_error_email_already_registered": "該郵箱已註冊",
"txt_server_error_email_password_required": "郵箱和密碼不能為空", "txt_server_error_email_password_required": "郵箱和密碼不能為空",
"txt_server_error_email_required": "郵箱不能為空", "txt_server_error_email_required": "郵箱不能為空",
"txt_server_error_invalid_password": "密碼無效。",
"txt_server_error_invalid_refresh_token": "登入狀態已失效,請重新登入", "txt_server_error_invalid_refresh_token": "登入狀態已失效,請重新登入",
"txt_server_error_invalid_user_verification_token": "用戶驗證令牌無效。",
"txt_server_error_invalid_request_payload": "請求內容無效", "txt_server_error_invalid_request_payload": "請求內容無效",
"txt_server_error_invite_invalid_or_expired": "邀請碼無效或已過期", "txt_server_error_invite_invalid_or_expired": "邀請碼無效或已過期",
"txt_server_error_invite_required": "邀請碼不能為空", "txt_server_error_invite_required": "邀請碼不能為空",
"txt_server_error_jwt_secret_default": "JWT_SECRET 正在使用默認示例值,請修改後再繼續", "txt_server_error_jwt_secret_default": "JWT_SECRET 正在使用默認示例值,請修改後再繼續",
"txt_server_error_jwt_secret_missing": "JWT_SECRET 未設置", "txt_server_error_jwt_secret_missing": "JWT_SECRET 未設置",
"txt_server_error_jwt_secret_too_short": "JWT_SECRET 至少需要 32 個字符", "txt_server_error_jwt_secret_too_short": "JWT_SECRET 至少需要 32 個字符",
"txt_server_error_master_password_hash_required": "需要驗證主密碼。",
"txt_server_error_master_password_or_verification_required": "需要主密碼或用戶驗證令牌。",
"txt_server_error_parameter_error": "請求參數錯誤", "txt_server_error_parameter_error": "請求參數錯誤",
"txt_server_error_refresh_token_required": "登入狀態缺失,請重新登入", "txt_server_error_refresh_token_required": "登入狀態缺失,請重新登入",
"txt_server_error_registration_retry": "註冊暫時不可用,請重試一次", "txt_server_error_registration_retry": "註冊暫時不可用,請重試一次",
@@ -730,6 +838,11 @@ const zhTW: Record<string, string> = {
"txt_remove_all_devices": "移除所有設備", "txt_remove_all_devices": "移除所有設備",
"txt_remove_all_devices_and_clear_all_2fa_trust": "確認移除所有設備並清除全部 2FA 信任嗎?", "txt_remove_all_devices_and_clear_all_2fa_trust": "確認移除所有設備並清除全部 2FA 信任嗎?",
"txt_remove_all_devices_and_sign_out_all_sessions": "確認移除所有設備、清除全部信任,並讓所有設備重新登錄嗎?", "txt_remove_all_devices_and_sign_out_all_sessions": "確認移除所有設備、清除全部信任,並讓所有設備重新登錄嗎?",
"txt_remove_selected_devices": "移除已選({count}",
"txt_remove_selected_devices_confirm": "確認移除選中的 {count} 臺設備、清除其信任,並讓它們重新登錄嗎?",
"txt_remove_selected_devices_and_sign_out_current": "確認移除選中的 {count} 臺設備、清除其信任,並同時退出本設備嗎?",
"txt_selected_devices_removed": "已移除選中設備",
"txt_remove_selected_devices_failed": "移除選中設備失敗",
"txt_remove_device_name_and_clear_its_2fa_trust": "確認移除設備“{name}”並清除其 2FA 信任嗎?", "txt_remove_device_name_and_clear_its_2fa_trust": "確認移除設備“{name}”並清除其 2FA 信任嗎?",
"txt_remove_device_and_sign_out_name": "確認移除設備“{name}”,清除其信任,並讓它重新登錄嗎?", "txt_remove_device_and_sign_out_name": "確認移除設備“{name}”,清除其信任,並讓它重新登錄嗎?",
"txt_reveal": "顯示", "txt_reveal": "顯示",
@@ -771,6 +884,9 @@ const zhTW: Record<string, string> = {
"txt_security_code": "安全碼", "txt_security_code": "安全碼",
"txt_security_code_cvv": "安全碼 (CVV)", "txt_security_code_cvv": "安全碼 (CVV)",
"txt_select_all": "全選", "txt_select_all": "全選",
"txt_clear_selection": "取消選擇",
"txt_select_device_name": "選擇 {name}",
"txt_no_devices_selected": "未選擇設備",
"txt_select": "請選擇", "txt_select": "請選擇",
"txt_select_duplicate_items": "選擇重複項", "txt_select_duplicate_items": "選擇重複項",
"txt_select_an_item": "請選擇一個項目", "txt_select_an_item": "請選擇一個項目",
-20
View File
@@ -14,7 +14,6 @@
/* Unified product polish: refined, smooth, comfortable surfaces across desktop, mobile, and dark mode. */ /* Unified product polish: refined, smooth, comfortable surfaces across desktop, mobile, and dark mode. */
/* ── surface consistency ── */ /* ── surface consistency ── */
.app-shell,
.auth-card, .auth-card,
.dialog-card, .dialog-card,
.card, .card,
@@ -36,12 +35,6 @@
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
} }
.app-shell {
background: var(--panel-soft);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-lg);
}
.topbar, .topbar,
.mobile-tabbar, .mobile-tabbar,
.app-side { .app-side {
@@ -104,7 +97,6 @@
} }
/* ── dark mode surface resets ── */ /* ── dark mode surface resets ── */
:root[data-theme='dark'] .app-shell,
:root[data-theme='dark'] .auth-card, :root[data-theme='dark'] .auth-card,
:root[data-theme='dark'] .dialog-card, :root[data-theme='dark'] .dialog-card,
:root[data-theme='dark'] .card, :root[data-theme='dark'] .card,
@@ -259,17 +251,6 @@ h4 {
letter-spacing: 0; letter-spacing: 0;
} }
.app-page {
padding: 18px;
}
.app-shell {
border-radius: var(--radius-xl);
border: 1px solid var(--line);
background: var(--panel);
box-shadow: var(--shadow-lg);
}
.topbar { .topbar {
height: 56px; height: 56px;
padding-inline: 16px; padding-inline: 16px;
@@ -916,7 +897,6 @@ textarea {
background: var(--bg-accent); background: var(--bg-accent);
} }
:root[data-theme='dark'] .app-shell,
:root[data-theme='dark'] .topbar, :root[data-theme='dark'] .topbar,
:root[data-theme='dark'] .app-side, :root[data-theme='dark'] .app-side,
:root[data-theme='dark'] .mobile-tabbar, :root[data-theme='dark'] .mobile-tabbar,
+14 -4
View File
@@ -200,10 +200,6 @@
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.20), 0 8px 24px rgba(0, 0, 0, 0.16); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.20), 0 8px 24px rgba(0, 0, 0, 0.16);
} }
:root[data-theme='dark'] .app-shell {
box-shadow: 0 4px 40px rgba(0, 0, 0, 0.30);
}
:root[data-theme='dark'] .list-item:hover { :root[data-theme='dark'] .list-item:hover {
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.24), 0 0 0 1px rgba(139, 184, 255, 0.12); box-shadow: 0 10px 28px rgba(0, 0, 0, 0.24), 0 0 0 1px rgba(139, 184, 255, 0.12);
} }
@@ -340,6 +336,7 @@
:root[data-theme='dark'] .backup-recommendation-step, :root[data-theme='dark'] .backup-recommendation-step,
:root[data-theme='dark'] .backup-recommendation-inline-note, :root[data-theme='dark'] .backup-recommendation-inline-note,
:root[data-theme='dark'] .backup-recommendation-linked-item, :root[data-theme='dark'] .backup-recommendation-linked-item,
:root[data-theme='dark'] .backup-browser-head,
:root[data-theme='dark'] .backup-browser-meta, :root[data-theme='dark'] .backup-browser-meta,
:root[data-theme='dark'] .backup-browser-empty, :root[data-theme='dark'] .backup-browser-empty,
:root[data-theme='dark'] .backup-inline-note, :root[data-theme='dark'] .backup-inline-note,
@@ -351,6 +348,19 @@
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'] .backup-browser-head {
background: var(--panel-subtle);
}
: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);
+58 -7
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;
@@ -351,16 +363,34 @@
} }
.backup-browser-nav { .backup-browser-nav {
@apply mb-2.5; @apply mb-2.5 flex items-center justify-between gap-2;
}
.backup-browser-nav-left {
@apply min-w-0;
} }
.backup-browser-list { .backup-browser-list {
@apply overflow-hidden rounded-xl border bg-white; @apply overflow-hidden rounded-lg border bg-white;
border: 1px solid var(--line); border: 1px solid var(--line);
} }
.backup-browser-head {
@apply grid items-center gap-3 px-3 py-2 text-[11px] font-bold uppercase tracking-[0.08em];
grid-template-columns: minmax(180px, 1fr) minmax(150px, 0.75fr) minmax(92px, 0.4fr) minmax(220px, auto);
border-bottom: 1px solid var(--line);
color: #64748b;
background: #f8fafc;
}
.backup-browser-head span:nth-child(2),
.backup-browser-head span:nth-child(3),
.backup-browser-head span:nth-child(4) {
text-align: right;
}
.backup-browser-pagination { .backup-browser-pagination {
@apply mt-2.5 flex items-center justify-end gap-2.5; @apply mt-2.5 flex items-center justify-center gap-2.5;
} }
.backup-browser-page-indicator { .backup-browser-page-indicator {
@@ -373,13 +403,15 @@
} }
.backup-browser-row { .backup-browser-row {
@apply grid items-center gap-2.5 px-3 py-2.5; @apply grid items-center gap-3 px-3 py-2;
grid-template-columns: minmax(0, 1fr) auto auto; grid-template-columns: minmax(180px, 1fr) minmax(150px, 0.75fr) minmax(92px, 0.4fr) minmax(220px, auto);
min-height: 48px;
} }
.backup-browser-entry { .backup-browser-entry {
@apply inline-flex cursor-pointer items-center gap-2 border-0 bg-transparent p-0 text-left; @apply inline-flex cursor-pointer items-center gap-2 border-0 bg-transparent p-0 text-left;
color: #0f172a; color: #0f172a;
min-width: 0;
} }
.backup-browser-entry.file { .backup-browser-entry.file {
@@ -392,12 +424,17 @@
} }
.backup-browser-meta { .backup-browser-meta {
@apply grid justify-items-end gap-1 text-right text-[13px]; @apply block text-right text-[13px];
color: #64748b; color: #64748b;
} }
.backup-browser-size {
font-variant-numeric: tabular-nums;
}
.backup-browser-actions { .backup-browser-actions {
justify-content: flex-end; justify-content: flex-end;
flex-wrap: nowrap;
} }
.backup-browser-empty { .backup-browser-empty {
@@ -406,6 +443,10 @@
color: #64748b; color: #64748b;
} }
.backup-browser-refresh-prompt {
@apply inline-flex flex-wrap items-center justify-center gap-2;
}
.backup-inline-note { .backup-inline-note {
@apply m-0 mb-3 leading-[1.5]; @apply m-0 mb-3 leading-[1.5];
color: #64748b; color: #64748b;
@@ -1479,8 +1520,12 @@
table-layout: fixed; table-layout: fixed;
} }
.authorized-devices-col-select {
width: 4%;
}
.authorized-devices-col-device { .authorized-devices-col-device {
width: 28%; width: 26%;
} }
.authorized-devices-col-type { .authorized-devices-col-type {
@@ -1503,6 +1548,12 @@
width: 26%; width: 26%;
} }
.authorized-device-checkbox {
width: 16px;
height: 16px;
accent-color: #2563eb;
}
.authorized-devices-table td:first-child { .authorized-devices-table td:first-child {
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
+36
View File
@@ -46,6 +46,24 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.backup-browser-head {
display: none;
}
.backup-browser-meta {
text-align: left;
}
.backup-browser-actions {
justify-content: flex-start;
flex-wrap: wrap;
}
.backup-browser-nav {
align-items: flex-start;
flex-direction: column;
}
.settings-twofactor-grid { .settings-twofactor-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -1110,6 +1128,24 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.backup-browser-head {
display: none;
}
.backup-browser-meta {
text-align: left;
}
.backup-browser-actions {
justify-content: flex-start;
flex-wrap: wrap;
}
.backup-browser-nav {
align-items: flex-start;
flex-direction: column;
}
.backup-grid { .backup-grid {
gap: 8px; gap: 8px;
padding: 0; padding: 0;
+4 -10
View File
@@ -1,17 +1,11 @@
.app-page { .app-page {
@apply relative min-h-full bg-transparent p-5; @apply relative min-h-full bg-transparent;
} }
.app-shell { .app-shell {
@apply relative mx-auto flex max-w-[1600px] flex-col overflow-hidden border bg-panel-soft; @apply relative flex flex-col;
height: calc(100vh - 40px); height: 100vh;
border-color: var(--line); background: var(--bg-accent);
@apply rounded-3xl;
box-shadow:
0 20px 60px rgba(15, 23, 42, 0.12),
0 8px 24px rgba(15, 23, 42, 0.08),
0 0 0 1px rgba(15, 23, 42, 0.04);
transition: box-shadow var(--dur-medium) var(--ease-smooth);
} }
.topbar { .topbar {