mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-24 06:20:14 +00:00
fix: address security issue
This commit is contained in:
@@ -353,9 +353,15 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
|
|||||||
return errorResponse('Invite code is required', 403);
|
return errorResponse('Invite code is required', 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const inviteMarked = await storage.markInviteUsed(inviteCode, user.id);
|
||||||
|
if (!inviteMarked) {
|
||||||
|
return errorResponse('Invite code is invalid or expired', 403);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await storage.createUser(user);
|
await storage.createUser(user);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
await storage.revertInviteUsed(inviteCode, user.id);
|
||||||
const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
|
const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
|
||||||
if (msg.includes('unique') || msg.includes('constraint')) {
|
if (msg.includes('unique') || msg.includes('constraint')) {
|
||||||
return errorResponse('Email already registered', 409);
|
return errorResponse('Email already registered', 409);
|
||||||
@@ -363,12 +369,6 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const inviteMarked = await storage.markInviteUsed(inviteCode, user.id);
|
|
||||||
if (!inviteMarked) {
|
|
||||||
await storage.deleteUserById(user.id);
|
|
||||||
return errorResponse('Invite code is invalid or expired', 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
await writeAuditEvent(storage, {
|
await writeAuditEvent(storage, {
|
||||||
actorUserId: user.id,
|
actorUserId: user.id,
|
||||||
action: 'user.register.invite',
|
action: 'user.register.invite',
|
||||||
@@ -891,7 +891,7 @@ export async function handleDisableTwoFactorProvider(request: Request, env: Env,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// PUT /api/accounts/totp
|
// PUT /api/accounts/totp
|
||||||
// enable: { enabled: true, secret: "...", token: "123456" }
|
// enable: { enabled: true, secret: "...", token: "123456", masterPasswordHash?: "...", userVerificationToken?: "..." }
|
||||||
// disable: { enabled: false, masterPasswordHash: "..." }
|
// disable: { enabled: false, masterPasswordHash: "..." }
|
||||||
export async function handleSetTotpStatus(request: Request, env: Env, userId: string): Promise<Response> {
|
export async function handleSetTotpStatus(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
@@ -899,7 +899,13 @@ export async function handleSetTotpStatus(request: Request, env: Env, userId: st
|
|||||||
const user = await storage.getUserById(userId);
|
const user = await storage.getUserById(userId);
|
||||||
if (!user) return errorResponse('User not found', 404);
|
if (!user) return errorResponse('User not found', 404);
|
||||||
|
|
||||||
let body: { enabled?: boolean; secret?: string; token?: string; masterPasswordHash?: string };
|
let body: {
|
||||||
|
enabled?: boolean;
|
||||||
|
secret?: string;
|
||||||
|
token?: string;
|
||||||
|
masterPasswordHash?: string;
|
||||||
|
userVerificationToken?: string;
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
body = await request.json();
|
body = await request.json();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -908,12 +914,24 @@ export async function handleSetTotpStatus(request: Request, env: Env, userId: st
|
|||||||
|
|
||||||
if (body.enabled === true) {
|
if (body.enabled === true) {
|
||||||
const normalizedSecret = normalizeTotpSecret(body.secret || '');
|
const normalizedSecret = normalizeTotpSecret(body.secret || '');
|
||||||
|
const masterPasswordHash = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash']);
|
||||||
|
const userVerificationToken = readBodyString(body, ['userVerificationToken', 'UserVerificationToken']);
|
||||||
if (!isTotpEnabled(normalizedSecret)) {
|
if (!isTotpEnabled(normalizedSecret)) {
|
||||||
return errorResponse('Invalid TOTP secret', 400);
|
return errorResponse('Invalid TOTP secret', 400);
|
||||||
}
|
}
|
||||||
if (!body.token) {
|
if (!body.token) {
|
||||||
return errorResponse('TOTP token is required', 400);
|
return errorResponse('TOTP token is required', 400);
|
||||||
}
|
}
|
||||||
|
let verifiedUser = false;
|
||||||
|
if (userVerificationToken) {
|
||||||
|
verifiedUser = await verifyTotpUserVerificationToken(env, user, normalizedSecret, userVerificationToken);
|
||||||
|
}
|
||||||
|
if (!verifiedUser && masterPasswordHash) {
|
||||||
|
verifiedUser = await auth.verifyPassword(masterPasswordHash, user.masterPasswordHash, user.email);
|
||||||
|
}
|
||||||
|
if (!verifiedUser) {
|
||||||
|
return errorResponse('User verification failed.', 400);
|
||||||
|
}
|
||||||
const verified = await verifyTotpToken(normalizedSecret, body.token);
|
const verified = await verifyTotpToken(normalizedSecret, body.token);
|
||||||
if (!verified) {
|
if (!verified) {
|
||||||
return errorResponse('Invalid TOTP token', 400);
|
return errorResponse('Invalid TOTP token', 400);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { StorageService } from '../services/storage';
|
|||||||
import { jsonResponse, errorResponse } from '../utils/response';
|
import { jsonResponse, errorResponse } from '../utils/response';
|
||||||
import { buildDirectUploadUrl, getSafeJwtSecret, parseDirectUploadPayload } from '../utils/direct-upload';
|
import { buildDirectUploadUrl, getSafeJwtSecret, parseDirectUploadPayload } from '../utils/direct-upload';
|
||||||
import { generateUUID } from '../utils/uuid';
|
import { generateUUID } from '../utils/uuid';
|
||||||
|
import { sanitizeDownloadContentType } from '../utils/content-type';
|
||||||
import {
|
import {
|
||||||
createAttachmentUploadToken,
|
createAttachmentUploadToken,
|
||||||
createFileDownloadToken,
|
createFileDownloadToken,
|
||||||
@@ -449,7 +450,7 @@ export async function handlePublicDownloadAttachment(
|
|||||||
|
|
||||||
return new Response(object.body, {
|
return new Response(object.body, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': object.contentType || 'application/octet-stream',
|
'Content-Type': sanitizeDownloadContentType(object.contentType),
|
||||||
'Content-Length': String(object.size),
|
'Content-Length': String(object.size),
|
||||||
'Content-Disposition': contentDispositionAttachment(attachment.fileName),
|
'Content-Disposition': contentDispositionAttachment(attachment.fileName),
|
||||||
'Cache-Control': 'private, no-cache',
|
'Cache-Control': 'private, no-cache',
|
||||||
|
|||||||
+80
-12
@@ -40,15 +40,51 @@ import {
|
|||||||
uploadBackupArchive,
|
uploadBackupArchive,
|
||||||
} from '../services/backup-uploader';
|
} from '../services/backup-uploader';
|
||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
|
import { AuthService } from '../services/auth';
|
||||||
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
|
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
|
||||||
import { getBlobObject } from '../services/blob-store';
|
import { getBlobObject } from '../services/blob-store';
|
||||||
import { notifyUserBackupProgress, notifyUserBackupRestoreProgress } from '../durable/notifications-hub';
|
import { notifyUserBackupProgress, notifyUserBackupRestoreProgress } from '../durable/notifications-hub';
|
||||||
|
import { verifyPasskeyUserVerificationToken } from '../utils/user-verification-token';
|
||||||
import { unzipSync } from 'fflate';
|
import { unzipSync } from 'fflate';
|
||||||
|
|
||||||
function isAdmin(user: User): boolean {
|
function isAdmin(user: User): boolean {
|
||||||
return user.role === 'admin' && user.status === 'active';
|
return user.role === 'admin' && user.status === 'active';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function requireBackupUserVerification(actorUser: User, masterPasswordHash: string, env: Env): Promise<Response | null> {
|
||||||
|
const normalized = String(masterPasswordHash || '').trim();
|
||||||
|
if (!normalized) {
|
||||||
|
return errorResponse('masterPasswordHash is required', 400);
|
||||||
|
}
|
||||||
|
const auth = new AuthService(env);
|
||||||
|
const valid = await auth.verifyPassword(normalized, actorUser.masterPasswordHash, actorUser.email);
|
||||||
|
if (!valid) {
|
||||||
|
return errorResponse('Invalid password', 400);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireBackupRepairVerification(
|
||||||
|
actorUser: User,
|
||||||
|
body: { masterPasswordHash?: string; userVerificationToken?: string },
|
||||||
|
env: Env
|
||||||
|
): Promise<Response | null> {
|
||||||
|
const masterPasswordHash = String(body.masterPasswordHash || '').trim();
|
||||||
|
if (masterPasswordHash) {
|
||||||
|
return requireBackupUserVerification(actorUser, masterPasswordHash, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userVerificationToken = String(body.userVerificationToken || '').trim();
|
||||||
|
if (!userVerificationToken) {
|
||||||
|
return errorResponse('masterPasswordHash or userVerificationToken is required', 400);
|
||||||
|
}
|
||||||
|
const valid = await verifyPasskeyUserVerificationToken(env, userVerificationToken, actorUser.id, 'backup.settings.repair');
|
||||||
|
if (!valid) {
|
||||||
|
return errorResponse('Invalid user verification token', 400);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
async function writeAuditLog(
|
async function writeAuditLog(
|
||||||
storage: StorageService,
|
storage: StorageService,
|
||||||
actorUserId: string | null,
|
actorUserId: string | null,
|
||||||
@@ -787,13 +823,16 @@ export async function handleGetAdminBackupSettings(request: Request, env: Env, a
|
|||||||
export async function handleUpdateAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> {
|
export async function handleUpdateAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||||
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||||
|
|
||||||
let body: BackupSettingsInput;
|
let body: BackupSettingsInput & { masterPasswordHash?: string };
|
||||||
try {
|
try {
|
||||||
body = await request.json<BackupSettingsInput>();
|
body = await request.json<BackupSettingsInput & { masterPasswordHash?: string }>();
|
||||||
} catch {
|
} catch {
|
||||||
return errorResponse('Backup settings payload is invalid', 400);
|
return errorResponse('Backup settings payload is invalid', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const verificationError = await requireBackupUserVerification(actorUser, String(body.masterPasswordHash || ''), env);
|
||||||
|
if (verificationError) return verificationError;
|
||||||
|
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
let previous;
|
let previous;
|
||||||
try {
|
try {
|
||||||
@@ -837,13 +876,16 @@ export async function handleGetAdminBackupSettingsRepairState(request: Request,
|
|||||||
export async function handleRepairAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> {
|
export async function handleRepairAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||||
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||||
|
|
||||||
let body: BackupSettingsInput;
|
let body: BackupSettingsInput & { masterPasswordHash?: string; userVerificationToken?: string };
|
||||||
try {
|
try {
|
||||||
body = await request.json<BackupSettingsInput>();
|
body = await request.json<BackupSettingsInput & { masterPasswordHash?: string; userVerificationToken?: string }>();
|
||||||
} catch {
|
} catch {
|
||||||
return errorResponse('Backup settings repair payload is invalid', 400);
|
return errorResponse('Backup settings repair payload is invalid', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const verificationError = await requireBackupRepairVerification(actorUser, body, env);
|
||||||
|
if (verificationError) return verificationError;
|
||||||
|
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
let previous;
|
let previous;
|
||||||
try {
|
try {
|
||||||
@@ -871,15 +913,18 @@ export async function handleRunAdminConfiguredBackup(request: Request, env: Env,
|
|||||||
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let body: { destinationId?: string } | null = null;
|
let body: { destinationId?: string; masterPasswordHash?: string } | null = null;
|
||||||
try {
|
try {
|
||||||
if ((request.headers.get('Content-Type') || '').includes('application/json')) {
|
if ((request.headers.get('Content-Type') || '').includes('application/json')) {
|
||||||
body = await request.json<{ destinationId?: string }>();
|
body = await request.json<{ destinationId?: string; masterPasswordHash?: string }>();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return errorResponse('Backup run payload is invalid', 400);
|
return errorResponse('Backup run payload is invalid', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const verificationError = await requireBackupUserVerification(actorUser, String(body?.masterPasswordHash || ''), env);
|
||||||
|
if (verificationError) return verificationError;
|
||||||
|
|
||||||
const outcome = await runConfiguredBackupInDurableObject(env, {
|
const outcome = await runConfiguredBackupInDurableObject(env, {
|
||||||
actorUserId: actorUser.id,
|
actorUserId: actorUser.id,
|
||||||
auditMetadata: auditRequestMetadata(request),
|
auditMetadata: auditRequestMetadata(request),
|
||||||
@@ -928,12 +973,21 @@ export async function handleListAdminRemoteBackups(request: Request, env: Env, a
|
|||||||
export async function handleDownloadAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
export async function handleDownloadAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||||
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||||
|
|
||||||
|
let body: { destinationId?: string; path?: string; masterPasswordHash?: string };
|
||||||
|
try {
|
||||||
|
body = await request.json<{ destinationId?: string; path?: string; masterPasswordHash?: string }>();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Remote backup download payload is invalid', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const verificationError = await requireBackupUserVerification(actorUser, String(body.masterPasswordHash || ''), env);
|
||||||
|
if (verificationError) return verificationError;
|
||||||
|
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
try {
|
try {
|
||||||
const settings = await loadBackupSettings(storage, env, 'UTC');
|
const settings = await loadBackupSettings(storage, env, 'UTC');
|
||||||
const url = new URL(request.url);
|
const path = ensureRemoteRestoreCandidate(String(body.path || ''));
|
||||||
const path = ensureRemoteRestoreCandidate(url.searchParams.get('path') || '');
|
const destination = requireBackupDestination(settings, body.destinationId || null);
|
||||||
const destination = requireBackupDestination(settings, url.searchParams.get('destinationId') || null);
|
|
||||||
const remoteFile = await downloadRemoteBackupFile(destination, path);
|
const remoteFile = await downloadRemoteBackupFile(destination, path);
|
||||||
return new Response(remoteFile.bytes, {
|
return new Response(remoteFile.bytes, {
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -994,13 +1048,22 @@ export async function handleDeleteAdminRemoteBackup(request: Request, env: Env,
|
|||||||
export async function handleRestoreAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
export async function handleRestoreAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||||
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||||
|
|
||||||
let body: { destinationId?: string; path?: string; replaceExisting?: boolean; allowChecksumMismatch?: boolean };
|
let body: {
|
||||||
|
destinationId?: string;
|
||||||
|
path?: string;
|
||||||
|
replaceExisting?: boolean;
|
||||||
|
allowChecksumMismatch?: boolean;
|
||||||
|
masterPasswordHash?: string;
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
body = await request.json<{ destinationId?: string; path?: string; replaceExisting?: boolean }>();
|
body = await request.json<{ destinationId?: string; path?: string; replaceExisting?: boolean }>();
|
||||||
} catch {
|
} catch {
|
||||||
return errorResponse('Remote restore payload is invalid', 400);
|
return errorResponse('Remote restore payload is invalid', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const verificationError = await requireBackupUserVerification(actorUser, String(body.masterPasswordHash || ''), env);
|
||||||
|
if (verificationError) return verificationError;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const path = ensureRemoteRestoreCandidate(String(body.path || ''));
|
const path = ensureRemoteRestoreCandidate(String(body.path || ''));
|
||||||
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
|
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
|
||||||
@@ -1028,14 +1091,16 @@ export async function handleAdminExportBackup(request: Request, env: Env, actorU
|
|||||||
|
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
|
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
|
||||||
let body: { includeAttachments?: boolean } | null = null;
|
let body: { includeAttachments?: boolean; masterPasswordHash?: string } | null = null;
|
||||||
try {
|
try {
|
||||||
if ((request.headers.get('Content-Type') || '').includes('application/json')) {
|
if ((request.headers.get('Content-Type') || '').includes('application/json')) {
|
||||||
body = await request.json<{ includeAttachments?: boolean }>();
|
body = await request.json<{ includeAttachments?: boolean; masterPasswordHash?: string }>();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return errorResponse('Backup export payload is invalid', 400);
|
return errorResponse('Backup export payload is invalid', 400);
|
||||||
}
|
}
|
||||||
|
const verificationError = await requireBackupUserVerification(actorUser, String(body?.masterPasswordHash || ''), env);
|
||||||
|
if (verificationError) return verificationError;
|
||||||
let archive: BackupArchiveBundle;
|
let archive: BackupArchiveBundle;
|
||||||
try {
|
try {
|
||||||
const progress = async (event: {
|
const progress = async (event: {
|
||||||
@@ -1140,6 +1205,9 @@ export async function handleAdminImportBackup(request: Request, env: Env, actorU
|
|||||||
return errorResponse('Backup file is required', 400);
|
return errorResponse('Backup file is required', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const verificationError = await requireBackupUserVerification(actorUser, String(formData.get('masterPasswordHash') || ''), env);
|
||||||
|
if (verificationError) return verificationError;
|
||||||
|
|
||||||
const replaceExisting = String(formData.get('replaceExisting') || '').trim() === '1';
|
const replaceExisting = String(formData.get('replaceExisting') || '').trim() === '1';
|
||||||
const allowChecksumMismatch = String(formData.get('allowChecksumMismatch') || '').trim() === '1';
|
const allowChecksumMismatch = String(formData.get('allowChecksumMismatch') || '').trim() === '1';
|
||||||
let archiveBytes: Uint8Array;
|
let archiveBytes: Uint8Array;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
buildAccountPasskeyTokenUserDecryptionOption,
|
buildAccountPasskeyTokenUserDecryptionOption,
|
||||||
} from './account-passkeys';
|
} from './account-passkeys';
|
||||||
import { isAuthRequestExpired } from '../services/storage-auth-request-repo';
|
import { isAuthRequestExpired } from '../services/storage-auth-request-repo';
|
||||||
|
import { createPasskeyUserVerificationToken } from '../utils/user-verification-token';
|
||||||
|
|
||||||
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
||||||
@@ -583,6 +584,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
|
|
||||||
const accessToken = await auth.generateAccessToken(user, deviceSession);
|
const accessToken = await auth.generateAccessToken(user, deviceSession);
|
||||||
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
|
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
|
||||||
|
const userVerificationToken = await createPasskeyUserVerificationToken(env, user.id, 'backup.settings.repair');
|
||||||
const accountKeys = buildAccountKeys(user);
|
const accountKeys = buildAccountKeys(user);
|
||||||
const webAuthnPrfOption = buildAccountPasskeyTokenUserDecryptionOption(credential);
|
const webAuthnPrfOption = buildAccountPasskeyTokenUserDecryptionOption(credential);
|
||||||
const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOption);
|
const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOption);
|
||||||
@@ -621,6 +623,8 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
ApiUseKeyConnector: false,
|
ApiUseKeyConnector: false,
|
||||||
scope: 'api offline_access',
|
scope: 'api offline_access',
|
||||||
unofficialServer: true,
|
unofficialServer: true,
|
||||||
|
UserVerificationToken: userVerificationToken,
|
||||||
|
userVerificationToken,
|
||||||
UserDecryptionOptions: userDecryptionOptions,
|
UserDecryptionOptions: userDecryptionOptions,
|
||||||
userDecryptionOptions: userDecryptionOptions,
|
userDecryptionOptions: userDecryptionOptions,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Env, SendType } from '../types';
|
|||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
|
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
|
||||||
import { jsonResponse, errorResponse } from '../utils/response';
|
import { jsonResponse, errorResponse } from '../utils/response';
|
||||||
|
import { sanitizeDownloadContentType } from '../utils/content-type';
|
||||||
import { LIMITS } from '../config/limits';
|
import { LIMITS } from '../config/limits';
|
||||||
import {
|
import {
|
||||||
createSendAccessToken,
|
createSendAccessToken,
|
||||||
@@ -306,7 +307,7 @@ export async function handleDownloadSendFile(
|
|||||||
|
|
||||||
return new Response(object.body, {
|
return new Response(object.body, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': object.contentType || 'application/octet-stream',
|
'Content-Type': sanitizeDownloadContentType(object.contentType),
|
||||||
'Content-Length': String(object.size),
|
'Content-Length': String(object.size),
|
||||||
'Content-Disposition': contentDispositionAttachment(fileName),
|
'Content-Disposition': contentDispositionAttachment(fileName),
|
||||||
'Cache-Control': 'private, no-cache',
|
'Cache-Control': 'private, no-cache',
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export async function handleAdminBackupRoute(
|
|||||||
return handleListAdminRemoteBackups(request, env, actorUser);
|
return handleListAdminRemoteBackups(request, env, actorUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === '/api/admin/backup/remote/download' && method === 'GET') {
|
if (path === '/api/admin/backup/remote/download' && method === 'POST') {
|
||||||
return handleDownloadAdminRemoteBackup(request, env, actorUser);
|
return handleDownloadAdminRemoteBackup(request, env, actorUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
handleNotificationsNegotiate,
|
handleNotificationsNegotiate,
|
||||||
} from './handlers/notifications';
|
} from './handlers/notifications';
|
||||||
import { handlePublicUploadSendFile } from './handlers/sends';
|
import { handlePublicUploadSendFile } from './handlers/sends';
|
||||||
|
import { isSafeWebsiteIconContentType } from './utils/content-type';
|
||||||
import { jsonResponse } from './utils/response';
|
import { jsonResponse } from './utils/response';
|
||||||
import { StorageService } from './services/storage';
|
import { StorageService } from './services/storage';
|
||||||
import type { Env } from './types';
|
import type { Env } from './types';
|
||||||
@@ -241,6 +242,7 @@ function iconResponse(body: BodyInit | null, contentType: string | null): Respon
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': contentType || 'image/png',
|
'Content-Type': contentType || 'image/png',
|
||||||
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}, immutable`,
|
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}, immutable`,
|
||||||
|
'Content-Security-Policy': "default-src 'none'; img-src 'self' data:; sandbox",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -272,7 +274,7 @@ async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-fo
|
|||||||
|
|
||||||
if (!resp.ok) continue;
|
if (!resp.ok) continue;
|
||||||
const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase();
|
const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase();
|
||||||
if (!contentType.startsWith('image/')) continue;
|
if (!isSafeWebsiteIconContentType(contentType)) continue;
|
||||||
|
|
||||||
const contentLength = getPositiveContentLength(resp.headers);
|
const contentLength = getPositiveContentLength(resp.headers);
|
||||||
if (contentLength !== null && contentLength > ICON_MAX_BUFFER_BYTES) continue;
|
if (contentLength !== null && contentLength > ICON_MAX_BUFFER_BYTES) continue;
|
||||||
|
|||||||
@@ -127,6 +127,17 @@ export async function markInviteUsed(db: D1Database, code: string, userId: strin
|
|||||||
return (result.meta.changes ?? 0) > 0;
|
return (result.meta.changes ?? 0) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function revertInviteUsed(db: D1Database, code: string, userId: string): Promise<boolean> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const result = await db
|
||||||
|
.prepare(
|
||||||
|
"UPDATE invites SET status = 'active', used_by = NULL, updated_at = ? WHERE code = ? AND status = 'used' AND used_by = ?"
|
||||||
|
)
|
||||||
|
.bind(now, code, userId)
|
||||||
|
.run();
|
||||||
|
return (result.meta.changes ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
export async function revokeInvite(db: D1Database, code: string): Promise<boolean> {
|
export async function revokeInvite(db: D1Database, code: string): Promise<boolean> {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const result = await db
|
const result = await db
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
markInviteUsed as markStoredInviteUsed,
|
markInviteUsed as markStoredInviteUsed,
|
||||||
pruneAuditLogs as pruneStoredAuditLogs,
|
pruneAuditLogs as pruneStoredAuditLogs,
|
||||||
pruneAuditLogsToMax as pruneStoredAuditLogsToMax,
|
pruneAuditLogsToMax as pruneStoredAuditLogsToMax,
|
||||||
|
revertInviteUsed as revertStoredInviteUsed,
|
||||||
revokeInvite as revokeStoredInvite,
|
revokeInvite as revokeStoredInvite,
|
||||||
} from './storage-admin-repo';
|
} from './storage-admin-repo';
|
||||||
import {
|
import {
|
||||||
@@ -313,6 +314,10 @@ export class StorageService {
|
|||||||
return markStoredInviteUsed(this.db, code, userId);
|
return markStoredInviteUsed(this.db, code, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async revertInviteUsed(code: string, userId: string): Promise<boolean> {
|
||||||
|
return revertStoredInviteUsed(this.db, code, userId);
|
||||||
|
}
|
||||||
|
|
||||||
async revokeInvite(code: string): Promise<boolean> {
|
async revokeInvite(code: string): Promise<boolean> {
|
||||||
return revokeStoredInvite(this.db, code);
|
return revokeStoredInvite(this.db, code);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -466,6 +466,8 @@ export interface TokenResponse {
|
|||||||
ResetMasterPassword: boolean;
|
ResetMasterPassword: boolean;
|
||||||
scope: string;
|
scope: string;
|
||||||
unofficialServer: boolean;
|
unofficialServer: boolean;
|
||||||
|
UserVerificationToken?: string;
|
||||||
|
userVerificationToken?: string;
|
||||||
MasterPasswordPolicy?: {
|
MasterPasswordPolicy?: {
|
||||||
minComplexity: number;
|
minComplexity: number;
|
||||||
minLength: number;
|
minLength: number;
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
const ACTIVE_DOWNLOAD_MEDIA_TYPES = new Set([
|
||||||
|
'application/xhtml+xml',
|
||||||
|
'application/xml',
|
||||||
|
'image/svg+xml',
|
||||||
|
'text/html',
|
||||||
|
'text/xml',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const SAFE_ICON_MEDIA_TYPES = new Set([
|
||||||
|
'image/avif',
|
||||||
|
'image/bmp',
|
||||||
|
'image/gif',
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png',
|
||||||
|
'image/vnd.microsoft.icon',
|
||||||
|
'image/webp',
|
||||||
|
'image/x-icon',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function normalizeMediaType(contentType: string | null | undefined): string {
|
||||||
|
return String(contentType || '')
|
||||||
|
.split(';', 1)[0]
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSafeWebsiteIconContentType(contentType: string | null | undefined): boolean {
|
||||||
|
return SAFE_ICON_MEDIA_TYPES.has(normalizeMediaType(contentType));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeDownloadContentType(contentType: string | null | undefined): string {
|
||||||
|
const mediaType = normalizeMediaType(contentType);
|
||||||
|
if (!mediaType) return 'application/octet-stream';
|
||||||
|
if (ACTIVE_DOWNLOAD_MEDIA_TYPES.has(mediaType)) {
|
||||||
|
return 'application/octet-stream';
|
||||||
|
}
|
||||||
|
return contentType || mediaType;
|
||||||
|
}
|
||||||
@@ -100,7 +100,9 @@ export function applyCors(
|
|||||||
headers.set('X-Frame-Options', 'DENY');
|
headers.set('X-Frame-Options', 'DENY');
|
||||||
headers.set('X-Content-Type-Options', 'nosniff');
|
headers.set('X-Content-Type-Options', 'nosniff');
|
||||||
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||||
|
if (!headers.has('Content-Security-Policy')) {
|
||||||
headers.set('Content-Security-Policy', "frame-ancestors 'none'; img-src 'self' data:");
|
headers.set('Content-Security-Policy', "frame-ancestors 'none'; img-src 'self' data:");
|
||||||
|
}
|
||||||
return new Response(response.body, {
|
return new Response(response.body, {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
statusText: response.statusText,
|
statusText: response.statusText,
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import type { Env } from '../types';
|
||||||
|
import { base64UrlToBytes, bytesToBase64Url } from './passkey';
|
||||||
|
|
||||||
|
const USER_VERIFICATION_TOKEN_TYPE = 'nodewarden.user-verification.v1';
|
||||||
|
const USER_VERIFICATION_TOKEN_TTL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
export type UserVerificationPurpose = 'backup.settings.repair';
|
||||||
|
|
||||||
|
interface UserVerificationTokenPayload {
|
||||||
|
typ: typeof USER_VERIFICATION_TOKEN_TYPE;
|
||||||
|
userId: string;
|
||||||
|
method: 'passkey';
|
||||||
|
purpose: UserVerificationPurpose;
|
||||||
|
iat: number;
|
||||||
|
exp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function textBytes(value: string): Uint8Array {
|
||||||
|
return new TextEncoder().encode(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importHmacKey(secret: string): Promise<CryptoKey> {
|
||||||
|
return crypto.subtle.importKey('raw', textBytes(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hmacSha256(secret: string, data: string): Promise<Uint8Array> {
|
||||||
|
const key = await importHmacKey(secret);
|
||||||
|
return new Uint8Array(await crypto.subtle.sign('HMAC', key, textBytes(data)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeJson(value: unknown): string {
|
||||||
|
return bytesToBase64Url(textBytes(JSON.stringify(value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeJson<T>(value: string): T | null {
|
||||||
|
try {
|
||||||
|
return JSON.parse(new TextDecoder().decode(base64UrlToBytes(value))) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPasskeyUserVerificationToken(
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
purpose: UserVerificationPurpose
|
||||||
|
): Promise<string> {
|
||||||
|
const now = Date.now();
|
||||||
|
const payload: UserVerificationTokenPayload = {
|
||||||
|
typ: USER_VERIFICATION_TOKEN_TYPE,
|
||||||
|
userId,
|
||||||
|
method: 'passkey',
|
||||||
|
purpose,
|
||||||
|
iat: now,
|
||||||
|
exp: now + USER_VERIFICATION_TOKEN_TTL_MS,
|
||||||
|
};
|
||||||
|
const header = { alg: 'HS256', typ: 'JWT' };
|
||||||
|
const data = `${encodeJson(header)}.${encodeJson(payload)}`;
|
||||||
|
const signature = bytesToBase64Url(await hmacSha256(env.JWT_SECRET, data));
|
||||||
|
return `${data}.${signature}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPasskeyUserVerificationToken(
|
||||||
|
env: Env,
|
||||||
|
token: string,
|
||||||
|
userId: string,
|
||||||
|
purpose: UserVerificationPurpose
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const parts = String(token || '').split('.');
|
||||||
|
if (parts.length !== 3) return false;
|
||||||
|
const data = `${parts[0]}.${parts[1]}`;
|
||||||
|
const expected = await hmacSha256(env.JWT_SECRET, data);
|
||||||
|
const actual = base64UrlToBytes(parts[2]);
|
||||||
|
if (actual.length !== expected.length) return false;
|
||||||
|
|
||||||
|
let diff = 0;
|
||||||
|
for (let i = 0; i < actual.length; i += 1) diff |= actual[i] ^ expected[i];
|
||||||
|
if (diff !== 0) return false;
|
||||||
|
|
||||||
|
const payload = decodeJson<UserVerificationTokenPayload>(parts[1]);
|
||||||
|
if (!payload || payload.typ !== USER_VERIFICATION_TOKEN_TYPE) return false;
|
||||||
|
if (payload.userId !== userId || payload.purpose !== purpose || payload.method !== 'passkey') return false;
|
||||||
|
if (!Number.isFinite(payload.exp) || payload.exp < Date.now()) return false;
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
+70
-11
@@ -11,6 +11,7 @@ import RecoverTwoFactorPage from '@/components/RecoverTwoFactorPage';
|
|||||||
import JwtWarningPage from '@/components/JwtWarningPage';
|
import JwtWarningPage from '@/components/JwtWarningPage';
|
||||||
import {
|
import {
|
||||||
createAuthedFetch,
|
createAuthedFetch,
|
||||||
|
deriveLoginHash,
|
||||||
getAuthorizedDevices,
|
getAuthorizedDevices,
|
||||||
clearProfileSnapshot,
|
clearProfileSnapshot,
|
||||||
getCurrentDeviceIdentifier,
|
getCurrentDeviceIdentifier,
|
||||||
@@ -264,6 +265,11 @@ export default function App() {
|
|||||||
const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {});
|
const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {});
|
||||||
const refreshPendingAuthRequestsRef = useRef<() => Promise<void>>(async () => {});
|
const refreshPendingAuthRequestsRef = useRef<() => Promise<void>>(async () => {});
|
||||||
const repairAttemptRef = useRef<string>('');
|
const repairAttemptRef = useRef<string>('');
|
||||||
|
const loginScopedBackupRepairAuthRef = useRef<{
|
||||||
|
accessToken: string;
|
||||||
|
masterPasswordHash?: string | null;
|
||||||
|
userVerificationToken?: string | null;
|
||||||
|
} | null>(null);
|
||||||
const uriChecksumRepairAttemptRef = useRef<string>('');
|
const uriChecksumRepairAttemptRef = useRef<string>('');
|
||||||
const pendingVaultCoreQueryRefreshRef = useRef<Promise<{ data?: VaultCoreSnapshot } | unknown> | null>(null);
|
const pendingVaultCoreQueryRefreshRef = useRef<Promise<{ data?: VaultCoreSnapshot } | unknown> | null>(null);
|
||||||
const pendingVaultCoreRefreshRef = useRef<Promise<unknown> | null>(null);
|
const pendingVaultCoreRefreshRef = useRef<Promise<unknown> | null>(null);
|
||||||
@@ -506,6 +512,14 @@ export default function App() {
|
|||||||
}, [phase, session?.email, location, navigate]);
|
}, [phase, session?.email, location, navigate]);
|
||||||
|
|
||||||
async function finalizeLogin(login: CompletedLogin) {
|
async function finalizeLogin(login: CompletedLogin) {
|
||||||
|
loginScopedBackupRepairAuthRef.current =
|
||||||
|
login.session.accessToken && (login.freshMasterPasswordHash || login.freshUserVerificationToken)
|
||||||
|
? {
|
||||||
|
accessToken: login.session.accessToken,
|
||||||
|
masterPasswordHash: login.freshMasterPasswordHash || null,
|
||||||
|
userVerificationToken: login.freshUserVerificationToken || null,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
setSession(login.session);
|
setSession(login.session);
|
||||||
setProfile(login.profile);
|
setProfile(login.profile);
|
||||||
setUnlockPreparing(false);
|
setUnlockPreparing(false);
|
||||||
@@ -1085,6 +1099,15 @@ export default function App() {
|
|||||||
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
|
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function deriveCurrentMasterPasswordHash(masterPassword: string): Promise<string> {
|
||||||
|
const email = String(profile?.email || session?.email || '').trim().toLowerCase();
|
||||||
|
if (!email) throw new Error(t('txt_profile_unavailable'));
|
||||||
|
const normalizedPassword = String(masterPassword || '');
|
||||||
|
if (!normalizedPassword) throw new Error(t('txt_master_password_is_required'));
|
||||||
|
const derived = await deriveLoginHash(email, normalizedPassword, defaultKdfIterations);
|
||||||
|
return derived.hash;
|
||||||
|
}
|
||||||
const pendingAuthRequestsQueryKey = useMemo(() => ['auth-requests-pending', vaultCacheKey || session?.email] as const, [vaultCacheKey, session?.email]);
|
const pendingAuthRequestsQueryKey = useMemo(() => ['auth-requests-pending', vaultCacheKey || session?.email] as const, [vaultCacheKey, session?.email]);
|
||||||
const pendingAuthRequestsQuery = useQuery({
|
const pendingAuthRequestsQuery = useQuery({
|
||||||
queryKey: pendingAuthRequestsQueryKey,
|
queryKey: pendingAuthRequestsQueryKey,
|
||||||
@@ -1189,13 +1212,25 @@ export default function App() {
|
|||||||
if (!isAdminProfile(profile)) return;
|
if (!isAdminProfile(profile)) return;
|
||||||
if (repairAttemptRef.current === session.accessToken) return;
|
if (repairAttemptRef.current === session.accessToken) return;
|
||||||
|
|
||||||
|
const loginScopedRepairAuth = loginScopedBackupRepairAuthRef.current?.accessToken === session.accessToken
|
||||||
|
? loginScopedBackupRepairAuthRef.current
|
||||||
|
: null;
|
||||||
repairAttemptRef.current = session.accessToken;
|
repairAttemptRef.current = session.accessToken;
|
||||||
void silentlyRepairBackupSettingsIfNeeded(session, profile);
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await silentlyRepairBackupSettingsIfNeeded(session, profile, loginScopedRepairAuth);
|
||||||
|
} finally {
|
||||||
|
if (loginScopedBackupRepairAuthRef.current?.accessToken === session.accessToken) {
|
||||||
|
loginScopedBackupRepairAuthRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
}, [phase, session?.accessToken, session?.symEncKey, session?.symMacKey, profile, vaultInitialDecryptDone]);
|
}, [phase, session?.accessToken, session?.symEncKey, session?.symMacKey, profile, vaultInitialDecryptDone]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (session?.accessToken) return;
|
if (session?.accessToken) return;
|
||||||
repairAttemptRef.current = '';
|
repairAttemptRef.current = '';
|
||||||
|
loginScopedBackupRepairAuthRef.current = null;
|
||||||
uriChecksumRepairAttemptRef.current = '';
|
uriChecksumRepairAttemptRef.current = '';
|
||||||
}, [session?.accessToken]);
|
}, [session?.accessToken]);
|
||||||
|
|
||||||
@@ -1950,8 +1985,8 @@ export default function App() {
|
|||||||
sendUploadPercent: vaultSendActions.sendUploadPercent,
|
sendUploadPercent: vaultSendActions.sendUploadPercent,
|
||||||
onChangePassword: accountSecurityActions.changePassword,
|
onChangePassword: accountSecurityActions.changePassword,
|
||||||
onSavePasswordHint: accountSecurityActions.savePasswordHint,
|
onSavePasswordHint: accountSecurityActions.savePasswordHint,
|
||||||
onEnableTotp: async (secret: string, token: string) => {
|
onEnableTotp: async (secret: string, token: string, masterPassword: string) => {
|
||||||
await accountSecurityActions.enableTotp(secret, token);
|
await accountSecurityActions.enableTotp(secret, token, masterPassword);
|
||||||
await totpStatusQuery.refetch();
|
await totpStatusQuery.refetch();
|
||||||
},
|
},
|
||||||
onOpenDisableTotp: () => setDisableTotpOpen(true),
|
onOpenDisableTotp: () => setDisableTotpOpen(true),
|
||||||
@@ -1992,22 +2027,46 @@ export default function App() {
|
|||||||
onLoadAuditLogSettings: () => getAuditLogSettings(authedFetch),
|
onLoadAuditLogSettings: () => getAuditLogSettings(authedFetch),
|
||||||
onSaveAuditLogSettings: (settings: AuditLogSettings) => saveAuditLogSettings(authedFetch, settings),
|
onSaveAuditLogSettings: (settings: AuditLogSettings) => saveAuditLogSettings(authedFetch, settings),
|
||||||
onClearAuditLogs: () => clearAuditLogs(authedFetch),
|
onClearAuditLogs: () => clearAuditLogs(authedFetch),
|
||||||
onExportBackup: backupActions.exportBackup,
|
onExportBackup: async (masterPassword: string, includeAttachments?: boolean) => {
|
||||||
onImportBackup: backupActions.importBackup,
|
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
|
||||||
onImportBackupAllowingChecksumMismatch: backupActions.importBackupAllowingChecksumMismatch,
|
return backupActions.exportBackup(hash, includeAttachments);
|
||||||
|
},
|
||||||
|
onImportBackup: async (masterPassword: string, file: File, replaceExisting?: boolean) => {
|
||||||
|
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
|
||||||
|
return backupActions.importBackup(hash, file, replaceExisting);
|
||||||
|
},
|
||||||
|
onImportBackupAllowingChecksumMismatch: async (masterPassword: string, file: File, replaceExisting?: boolean) => {
|
||||||
|
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
|
||||||
|
return backupActions.importBackupAllowingChecksumMismatch(hash, file, replaceExisting);
|
||||||
|
},
|
||||||
onLoadBackupSettings: () => queryClient.ensureQueryData({
|
onLoadBackupSettings: () => queryClient.ensureQueryData({
|
||||||
queryKey: ['admin-backup-settings', vaultCacheKey],
|
queryKey: ['admin-backup-settings', vaultCacheKey],
|
||||||
queryFn: () => backupActions.loadSettings(),
|
queryFn: () => backupActions.loadSettings(),
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
}),
|
}),
|
||||||
onSaveBackupSettings: backupActions.saveSettings,
|
onSaveBackupSettings: async (masterPassword: string, settings: AdminBackupSettings) => {
|
||||||
onRunRemoteBackup: backupActions.runRemoteBackup,
|
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
|
||||||
|
return backupActions.saveSettings(hash, settings);
|
||||||
|
},
|
||||||
|
onRunRemoteBackup: async (masterPassword: string, destinationId?: string | null) => {
|
||||||
|
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
|
||||||
|
return backupActions.runRemoteBackup(hash, destinationId);
|
||||||
|
},
|
||||||
onListRemoteBackups: backupActions.listRemoteBackups,
|
onListRemoteBackups: backupActions.listRemoteBackups,
|
||||||
onDownloadRemoteBackup: backupActions.downloadRemoteBackup,
|
onDownloadRemoteBackup: async (masterPassword: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) => {
|
||||||
|
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
|
||||||
|
return backupActions.downloadRemoteBackup(hash, destinationId, path, onProgress);
|
||||||
|
},
|
||||||
onInspectRemoteBackup: backupActions.inspectRemoteBackup,
|
onInspectRemoteBackup: backupActions.inspectRemoteBackup,
|
||||||
onDeleteRemoteBackup: backupActions.deleteRemoteBackup,
|
onDeleteRemoteBackup: backupActions.deleteRemoteBackup,
|
||||||
onRestoreRemoteBackup: backupActions.restoreRemoteBackup,
|
onRestoreRemoteBackup: async (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => {
|
||||||
onRestoreRemoteBackupAllowingChecksumMismatch: backupActions.restoreRemoteBackupAllowingChecksumMismatch,
|
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
|
||||||
|
return backupActions.restoreRemoteBackup(hash, destinationId, path, replaceExisting);
|
||||||
|
},
|
||||||
|
onRestoreRemoteBackupAllowingChecksumMismatch: async (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => {
|
||||||
|
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
|
||||||
|
return backupActions.restoreRemoteBackupAllowingChecksumMismatch(hash, destinationId, path, replaceExisting);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const effectiveMainRoutesProps = IS_DEMO_MODE
|
const effectiveMainRoutesProps = IS_DEMO_MODE
|
||||||
? createDemoMainRoutesProps(mainRoutesProps, pushToast, {
|
? createDemoMainRoutesProps(mainRoutesProps, pushToast, {
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export interface AppMainRoutesProps {
|
|||||||
sendUploadPercent: number | null;
|
sendUploadPercent: number | null;
|
||||||
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
|
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
|
||||||
onSavePasswordHint: (masterPasswordHint: string) => Promise<void>;
|
onSavePasswordHint: (masterPasswordHint: string) => Promise<void>;
|
||||||
onEnableTotp: (secret: string, token: string) => Promise<void>;
|
onEnableTotp: (secret: string, token: string, masterPassword: string) => Promise<void>;
|
||||||
onOpenDisableTotp: () => void;
|
onOpenDisableTotp: () => void;
|
||||||
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
||||||
onGetApiKey: (masterPassword: string) => Promise<string>;
|
onGetApiKey: (masterPassword: string) => Promise<string>;
|
||||||
@@ -142,18 +142,18 @@ export interface AppMainRoutesProps {
|
|||||||
onLoadAuditLogSettings: () => Promise<AuditLogSettings>;
|
onLoadAuditLogSettings: () => Promise<AuditLogSettings>;
|
||||||
onSaveAuditLogSettings: (settings: AuditLogSettings) => Promise<AuditLogSettings>;
|
onSaveAuditLogSettings: (settings: AuditLogSettings) => Promise<AuditLogSettings>;
|
||||||
onClearAuditLogs: () => Promise<number>;
|
onClearAuditLogs: () => Promise<number>;
|
||||||
onExportBackup: (includeAttachments?: boolean) => Promise<void>;
|
onExportBackup: (masterPassword: string, includeAttachments?: boolean) => Promise<void>;
|
||||||
onImportBackup: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
onImportBackup: (masterPassword: string, file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||||
onImportBackupAllowingChecksumMismatch: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
onImportBackupAllowingChecksumMismatch: (masterPassword: string, file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||||
onLoadBackupSettings: () => Promise<AdminBackupSettings>;
|
onLoadBackupSettings: () => Promise<AdminBackupSettings>;
|
||||||
onSaveBackupSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
|
onSaveBackupSettings: (masterPassword: string, settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
|
||||||
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
onRunRemoteBackup: (masterPassword: string, destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
||||||
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
|
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
|
||||||
onDownloadRemoteBackup: (destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
|
onDownloadRemoteBackup: (masterPassword: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
|
||||||
onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: { hasChecksumPrefix: boolean; expectedPrefix: string | null; actualPrefix: string; matches: boolean } }>;
|
onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: { hasChecksumPrefix: boolean; expectedPrefix: string | null; actualPrefix: string; matches: boolean } }>;
|
||||||
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
|
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
|
||||||
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
onRestoreRemoteBackup: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||||
onRestoreRemoteBackupAllowingChecksumMismatch: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
onRestoreRemoteBackupAllowingChecksumMismatch: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AppMainRoutes(props: AppMainRoutesProps) {
|
export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||||
|
|||||||
@@ -34,18 +34,18 @@ import { BackupOperationsSidebar } from './backup-center/BackupOperationsSidebar
|
|||||||
|
|
||||||
interface BackupCenterPageProps {
|
interface BackupCenterPageProps {
|
||||||
currentUserId: string | null;
|
currentUserId: string | null;
|
||||||
onExport: (includeAttachments?: boolean) => Promise<void>;
|
onExport: (masterPassword: string, includeAttachments?: boolean) => Promise<void>;
|
||||||
onImport: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
onImport: (masterPassword: string, file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||||
onImportAllowingChecksumMismatch: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
onImportAllowingChecksumMismatch: (masterPassword: string, file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||||
onLoadSettings: () => Promise<AdminBackupSettings>;
|
onLoadSettings: () => Promise<AdminBackupSettings>;
|
||||||
onSaveSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
|
onSaveSettings: (masterPassword: string, settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
|
||||||
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
onRunRemoteBackup: (masterPassword: string, destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
||||||
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
|
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
|
||||||
onDownloadRemoteBackup: (destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
|
onDownloadRemoteBackup: (masterPassword: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
|
||||||
onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: BackupFileIntegrityCheckResult }>;
|
onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: BackupFileIntegrityCheckResult }>;
|
||||||
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
|
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
|
||||||
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
onRestoreRemoteBackup: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||||
onRestoreRemoteBackupAllowingChecksumMismatch: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
onRestoreRemoteBackupAllowingChecksumMismatch: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||||
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
|
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +53,14 @@ type PendingRestoreIntegrity =
|
|||||||
| { source: 'local'; fileName: string; result: BackupFileIntegrityCheckResult }
|
| { source: 'local'; fileName: string; result: BackupFileIntegrityCheckResult }
|
||||||
| { source: 'remote'; fileName: string; path: string; result: BackupFileIntegrityCheckResult };
|
| { source: 'remote'; fileName: string; path: string; result: BackupFileIntegrityCheckResult };
|
||||||
|
|
||||||
|
type PendingBackupVerification =
|
||||||
|
| { action: 'export' }
|
||||||
|
| { action: 'saveSettings' }
|
||||||
|
| { action: 'import'; replaceExisting: boolean; allowChecksumMismatch: boolean; knownIntegrity?: BackupFileIntegrityCheckResult }
|
||||||
|
| { action: 'runRemoteBackup' }
|
||||||
|
| { action: 'downloadRemote'; path: string }
|
||||||
|
| { action: 'restoreRemote'; path: string; replaceExisting: boolean; allowChecksumMismatch: boolean; knownIntegrity?: BackupFileIntegrityCheckResult };
|
||||||
|
|
||||||
interface BackupProgressPhase {
|
interface BackupProgressPhase {
|
||||||
titleKey: string;
|
titleKey: string;
|
||||||
detailKey: string;
|
detailKey: string;
|
||||||
@@ -193,6 +201,9 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
const [confirmIntegrityWarningOpen, setConfirmIntegrityWarningOpen] = useState(false);
|
const [confirmIntegrityWarningOpen, setConfirmIntegrityWarningOpen] = useState(false);
|
||||||
const [confirmDeleteDestinationOpen, setConfirmDeleteDestinationOpen] = useState(false);
|
const [confirmDeleteDestinationOpen, setConfirmDeleteDestinationOpen] = useState(false);
|
||||||
const [confirmRemoteDeleteOpen, setConfirmRemoteDeleteOpen] = useState(false);
|
const [confirmRemoteDeleteOpen, setConfirmRemoteDeleteOpen] = useState(false);
|
||||||
|
const [pendingBackupVerification, setPendingBackupVerification] = useState<PendingBackupVerification | null>(null);
|
||||||
|
const [backupPasswordValue, setBackupPasswordValue] = useState('');
|
||||||
|
const [backupPasswordSubmitting, setBackupPasswordSubmitting] = useState(false);
|
||||||
const [pendingRestoreIntegrity, setPendingRestoreIntegrity] = useState<PendingRestoreIntegrity | null>(null);
|
const [pendingRestoreIntegrity, setPendingRestoreIntegrity] = useState<PendingRestoreIntegrity | null>(null);
|
||||||
const [pendingRemoteRestorePath, setPendingRemoteRestorePath] = useState('');
|
const [pendingRemoteRestorePath, setPendingRemoteRestorePath] = useState('');
|
||||||
const [pendingRemoteDeletePath, setPendingRemoteDeletePath] = useState('');
|
const [pendingRemoteDeletePath, setPendingRemoteDeletePath] = useState('');
|
||||||
@@ -209,7 +220,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
const selectedDestination = getDestinationById(settings, selectedDestinationId);
|
const selectedDestination = getDestinationById(settings, selectedDestinationId);
|
||||||
const savedSelectedDestination = getDestinationById(savedSettings, selectedDestinationId);
|
const savedSelectedDestination = getDestinationById(savedSettings, selectedDestinationId);
|
||||||
const selectedDestinationIsSaved = !!savedSelectedDestination;
|
const selectedDestinationIsSaved = !!savedSelectedDestination;
|
||||||
const disableWhileBusy = exporting || importing || savingSettings || runningRemoteBackup;
|
const disableWhileBusy = exporting || importing || savingSettings || runningRemoteBackup || backupPasswordSubmitting;
|
||||||
const currentRemoteBrowserPath = savedSelectedDestination ? (remoteBrowserPathByDestination[savedSelectedDestination.id] || '') : '';
|
const currentRemoteBrowserPath = savedSelectedDestination ? (remoteBrowserPathByDestination[savedSelectedDestination.id] || '') : '';
|
||||||
const currentRemoteBrowserKey = savedSelectedDestination ? getRemoteBrowserCacheKey(savedSelectedDestination.id, currentRemoteBrowserPath) : '';
|
const currentRemoteBrowserKey = savedSelectedDestination ? getRemoteBrowserCacheKey(savedSelectedDestination.id, currentRemoteBrowserPath) : '';
|
||||||
const remoteBrowser = currentRemoteBrowserKey ? remoteBrowserCache[currentRemoteBrowserKey] || null : null;
|
const remoteBrowser = currentRemoteBrowserKey ? remoteBrowserCache[currentRemoteBrowserKey] || null : null;
|
||||||
@@ -226,6 +237,18 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
const recommendedS3Providers = RECOMMENDED_PROVIDERS.filter((provider) => provider.protocol === 's3');
|
const recommendedS3Providers = RECOMMENDED_PROVIDERS.filter((provider) => provider.protocol === 's3');
|
||||||
const canRunSelectedDestination = !!selectedDestination && selectedDestinationIsSaved;
|
const canRunSelectedDestination = !!selectedDestination && selectedDestinationIsSaved;
|
||||||
const canBrowseSelectedDestination = !!savedSelectedDestination;
|
const canBrowseSelectedDestination = !!savedSelectedDestination;
|
||||||
|
const backupPasswordPromptTitle =
|
||||||
|
pendingBackupVerification?.action === 'export'
|
||||||
|
? t('txt_backup_export')
|
||||||
|
: pendingBackupVerification?.action === 'saveSettings'
|
||||||
|
? t('txt_backup_save_settings')
|
||||||
|
: pendingBackupVerification?.action === 'runRemoteBackup'
|
||||||
|
? t('txt_backup_run_manual')
|
||||||
|
: pendingBackupVerification?.action === 'downloadRemote'
|
||||||
|
? t('txt_backup_remote_download')
|
||||||
|
: pendingBackupVerification?.action === 'restoreRemote'
|
||||||
|
? t('txt_backup_import')
|
||||||
|
: t('txt_backup_import');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -507,11 +530,17 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleExport() {
|
async function handleExport() {
|
||||||
|
if (exporting) return;
|
||||||
|
setPendingBackupVerification({ action: 'export' });
|
||||||
|
setBackupPasswordValue('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeExport(masterPassword: string) {
|
||||||
setLocalError('');
|
setLocalError('');
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
try {
|
try {
|
||||||
startRestoreProgress('backup-export', t('txt_backup_export'), { source: 'local', includeAttachments: exportIncludeAttachments });
|
startRestoreProgress('backup-export', t('txt_backup_export'), { source: 'local', includeAttachments: exportIncludeAttachments });
|
||||||
await props.onExport(exportIncludeAttachments);
|
await props.onExport(masterPassword, exportIncludeAttachments);
|
||||||
props.onNotify('success', t('txt_backup_export_success'));
|
props.onNotify('success', t('txt_backup_export_success'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : t('txt_backup_export_failed');
|
const message = error instanceof Error ? error.message : t('txt_backup_export_failed');
|
||||||
@@ -527,6 +556,28 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
replaceExisting: boolean,
|
replaceExisting: boolean,
|
||||||
allowChecksumMismatch: boolean = false,
|
allowChecksumMismatch: boolean = false,
|
||||||
knownIntegrity?: BackupFileIntegrityCheckResult
|
knownIntegrity?: BackupFileIntegrityCheckResult
|
||||||
|
) {
|
||||||
|
if (importing) return;
|
||||||
|
if (!selectedFile) {
|
||||||
|
const message = t('txt_backup_file_required');
|
||||||
|
setLocalError(message);
|
||||||
|
props.onNotify('error', message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPendingBackupVerification({
|
||||||
|
action: 'import',
|
||||||
|
replaceExisting,
|
||||||
|
allowChecksumMismatch,
|
||||||
|
knownIntegrity,
|
||||||
|
});
|
||||||
|
setBackupPasswordValue('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeLocalRestore(
|
||||||
|
masterPassword: string,
|
||||||
|
replaceExisting: boolean,
|
||||||
|
allowChecksumMismatch: boolean = false,
|
||||||
|
knownIntegrity?: BackupFileIntegrityCheckResult
|
||||||
) {
|
) {
|
||||||
if (importing) return;
|
if (importing) return;
|
||||||
if (!selectedFile) {
|
if (!selectedFile) {
|
||||||
@@ -547,8 +598,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
delayMs: replaceExisting ? 480 : 1400,
|
delayMs: replaceExisting ? 480 : 1400,
|
||||||
});
|
});
|
||||||
const result = allowChecksumMismatch
|
const result = allowChecksumMismatch
|
||||||
? await props.onImportAllowingChecksumMismatch(selectedFile, replaceExisting)
|
? await props.onImportAllowingChecksumMismatch(masterPassword, selectedFile, replaceExisting)
|
||||||
: await props.onImport(selectedFile, replaceExisting);
|
: await props.onImport(masterPassword, selectedFile, replaceExisting);
|
||||||
props.onNotify('success', `${buildIntegrityStatusMessage(integrity)} ${t('txt_backup_restore_success_relogin')}`);
|
props.onNotify('success', `${buildIntegrityStatusMessage(integrity)} ${t('txt_backup_restore_success_relogin')}`);
|
||||||
const skippedMessage = buildSkippedImportMessage(result);
|
const skippedMessage = buildSkippedImportMessage(result);
|
||||||
if (skippedMessage) props.onNotify('warning', skippedMessage);
|
if (skippedMessage) props.onNotify('warning', skippedMessage);
|
||||||
@@ -573,12 +624,18 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleSaveSettings() {
|
async function handleSaveSettings() {
|
||||||
|
if (savingSettings) return;
|
||||||
|
setPendingBackupVerification({ action: 'saveSettings' });
|
||||||
|
setBackupPasswordValue('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeSaveSettings(masterPassword: string) {
|
||||||
const payload = buildSettingsPayloadForSelectedDestination();
|
const payload = buildSettingsPayloadForSelectedDestination();
|
||||||
const destinationIdToInvalidate = selectedDestinationId;
|
const destinationIdToInvalidate = selectedDestinationId;
|
||||||
setSavingSettings(true);
|
setSavingSettings(true);
|
||||||
setLocalError('');
|
setLocalError('');
|
||||||
try {
|
try {
|
||||||
const saved = await props.onSaveSettings(payload);
|
const saved = await props.onSaveSettings(masterPassword, payload);
|
||||||
const nextSelected =
|
const nextSelected =
|
||||||
(selectedDestinationId && saved.destinations.some((destination) => destination.id === selectedDestinationId) && selectedDestinationId)
|
(selectedDestinationId && saved.destinations.some((destination) => destination.id === selectedDestinationId) && selectedDestinationId)
|
||||||
|| getFirstVisibleDestinationId(saved)
|
|| getFirstVisibleDestinationId(saved)
|
||||||
@@ -613,6 +670,12 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleRunRemoteBackup() {
|
async function handleRunRemoteBackup() {
|
||||||
|
if (!selectedDestination || runningRemoteBackup) return;
|
||||||
|
setPendingBackupVerification({ action: 'runRemoteBackup' });
|
||||||
|
setBackupPasswordValue('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeRunRemoteBackup(masterPassword: string) {
|
||||||
if (!selectedDestination) return;
|
if (!selectedDestination) return;
|
||||||
setRunningRemoteBackup(true);
|
setRunningRemoteBackup(true);
|
||||||
setLocalError('');
|
setLocalError('');
|
||||||
@@ -621,7 +684,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
source: 'remote',
|
source: 'remote',
|
||||||
includeAttachments: !!selectedDestination.includeAttachments,
|
includeAttachments: !!selectedDestination.includeAttachments,
|
||||||
});
|
});
|
||||||
const result = await props.onRunRemoteBackup(selectedDestination.id);
|
const result = await props.onRunRemoteBackup(masterPassword, selectedDestination.id);
|
||||||
setSavedSettings(result.settings);
|
setSavedSettings(result.settings);
|
||||||
setSettings(result.settings);
|
setSettings(result.settings);
|
||||||
setSelectedDestinationId(selectedDestination.id);
|
setSelectedDestinationId(selectedDestination.id);
|
||||||
@@ -638,12 +701,17 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleDownloadRemote(path: string) {
|
async function handleDownloadRemote(path: string) {
|
||||||
|
setPendingBackupVerification({ action: 'downloadRemote', path });
|
||||||
|
setBackupPasswordValue('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeDownloadRemote(masterPassword: string, path: string) {
|
||||||
if (!savedSelectedDestination) return;
|
if (!savedSelectedDestination) return;
|
||||||
setDownloadingRemotePath(path);
|
setDownloadingRemotePath(path);
|
||||||
setDownloadingRemotePercent(null);
|
setDownloadingRemotePercent(null);
|
||||||
setLocalError('');
|
setLocalError('');
|
||||||
try {
|
try {
|
||||||
await props.onDownloadRemoteBackup(savedSelectedDestination.id, path, setDownloadingRemotePercent);
|
await props.onDownloadRemoteBackup(masterPassword, savedSelectedDestination.id, path, setDownloadingRemotePercent);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : t('txt_backup_remote_download_failed');
|
const message = error instanceof Error ? error.message : t('txt_backup_remote_download_failed');
|
||||||
setLocalError(message);
|
setLocalError(message);
|
||||||
@@ -724,6 +792,25 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
replaceExisting: boolean,
|
replaceExisting: boolean,
|
||||||
allowChecksumMismatch: boolean = false,
|
allowChecksumMismatch: boolean = false,
|
||||||
knownIntegrity?: BackupFileIntegrityCheckResult
|
knownIntegrity?: BackupFileIntegrityCheckResult
|
||||||
|
) {
|
||||||
|
if (restoringRemotePath) return;
|
||||||
|
if (!savedSelectedDestination) return;
|
||||||
|
setPendingBackupVerification({
|
||||||
|
action: 'restoreRemote',
|
||||||
|
path,
|
||||||
|
replaceExisting,
|
||||||
|
allowChecksumMismatch,
|
||||||
|
knownIntegrity,
|
||||||
|
});
|
||||||
|
setBackupPasswordValue('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeRemoteRestore(
|
||||||
|
masterPassword: string,
|
||||||
|
path: string,
|
||||||
|
replaceExisting: boolean,
|
||||||
|
allowChecksumMismatch: boolean = false,
|
||||||
|
knownIntegrity?: BackupFileIntegrityCheckResult
|
||||||
) {
|
) {
|
||||||
if (restoringRemotePath) return;
|
if (restoringRemotePath) return;
|
||||||
if (!savedSelectedDestination) return;
|
if (!savedSelectedDestination) return;
|
||||||
@@ -738,8 +825,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
delayMs: replaceExisting ? 480 : 1400,
|
delayMs: replaceExisting ? 480 : 1400,
|
||||||
});
|
});
|
||||||
const result = allowChecksumMismatch
|
const result = allowChecksumMismatch
|
||||||
? await props.onRestoreRemoteBackupAllowingChecksumMismatch(savedSelectedDestination.id, path, replaceExisting)
|
? await props.onRestoreRemoteBackupAllowingChecksumMismatch(masterPassword, savedSelectedDestination.id, path, replaceExisting)
|
||||||
: await props.onRestoreRemoteBackup(savedSelectedDestination.id, path, replaceExisting);
|
: await props.onRestoreRemoteBackup(masterPassword, savedSelectedDestination.id, path, replaceExisting);
|
||||||
setConfirmRemoteReplaceOpen(false);
|
setConfirmRemoteReplaceOpen(false);
|
||||||
setPendingRemoteRestorePath('');
|
setPendingRemoteRestorePath('');
|
||||||
props.onNotify('success', `${buildIntegrityStatusMessage(integrity.result, { remote: true })} ${t('txt_backup_restore_success_relogin')}`);
|
props.onNotify('success', `${buildIntegrityStatusMessage(integrity.result, { remote: true })} ${t('txt_backup_restore_success_relogin')}`);
|
||||||
@@ -762,6 +849,36 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function submitBackupPasswordPrompt(): Promise<void> {
|
||||||
|
const request = pendingBackupVerification;
|
||||||
|
const masterPassword = backupPasswordValue;
|
||||||
|
if (!request || backupPasswordSubmitting) return;
|
||||||
|
if (!masterPassword.trim()) {
|
||||||
|
props.onNotify('error', t('txt_master_password_is_required'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBackupPasswordSubmitting(true);
|
||||||
|
setPendingBackupVerification(null);
|
||||||
|
setBackupPasswordValue('');
|
||||||
|
try {
|
||||||
|
if (request.action === 'export') {
|
||||||
|
await executeExport(masterPassword);
|
||||||
|
} else if (request.action === 'saveSettings') {
|
||||||
|
await executeSaveSettings(masterPassword);
|
||||||
|
} else if (request.action === 'import') {
|
||||||
|
await executeLocalRestore(masterPassword, request.replaceExisting, request.allowChecksumMismatch, request.knownIntegrity);
|
||||||
|
} else if (request.action === 'runRemoteBackup') {
|
||||||
|
await executeRunRemoteBackup(masterPassword);
|
||||||
|
} else if (request.action === 'downloadRemote') {
|
||||||
|
await executeDownloadRemote(masterPassword, request.path);
|
||||||
|
} else if (request.action === 'restoreRemote') {
|
||||||
|
await executeRemoteRestore(masterPassword, request.path, request.replaceExisting, request.allowChecksumMismatch, request.knownIntegrity);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setBackupPasswordSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="backup-grid">
|
<div className="backup-grid">
|
||||||
<input
|
<input
|
||||||
@@ -893,6 +1010,33 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
), document.body) : null}
|
), document.body) : null}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={pendingBackupVerification !== null}
|
||||||
|
title={backupPasswordPromptTitle}
|
||||||
|
message={t('txt_enter_master_password_to_continue')}
|
||||||
|
confirmText={t('txt_continue')}
|
||||||
|
cancelText={t('txt_cancel')}
|
||||||
|
confirmDisabled={backupPasswordSubmitting || !backupPasswordValue.trim()}
|
||||||
|
cancelDisabled={backupPasswordSubmitting}
|
||||||
|
onConfirm={() => void submitBackupPasswordPrompt()}
|
||||||
|
onCancel={() => {
|
||||||
|
if (backupPasswordSubmitting) return;
|
||||||
|
setPendingBackupVerification(null);
|
||||||
|
setBackupPasswordValue('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_master_password')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={backupPasswordValue}
|
||||||
|
onInput={(event) => setBackupPasswordValue((event.currentTarget as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</ConfirmDialog>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={confirmLocalRestoreOpen}
|
open={confirmLocalRestoreOpen}
|
||||||
title={t('txt_backup_import')}
|
title={t('txt_backup_import')}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ interface SettingsPageProps {
|
|||||||
sessionTimeoutAction: 'lock' | 'logout';
|
sessionTimeoutAction: 'lock' | 'logout';
|
||||||
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
|
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
|
||||||
onSavePasswordHint: (masterPasswordHint: string) => Promise<void>;
|
onSavePasswordHint: (masterPasswordHint: string) => Promise<void>;
|
||||||
onEnableTotp: (secret: string, token: string) => Promise<void>;
|
onEnableTotp: (secret: string, token: string, masterPassword: string) => Promise<void>;
|
||||||
onOpenDisableTotp: () => void;
|
onOpenDisableTotp: () => void;
|
||||||
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
||||||
onGetApiKey: (masterPassword: string) => Promise<string>;
|
onGetApiKey: (masterPassword: string) => Promise<string>;
|
||||||
@@ -34,6 +34,7 @@ interface SettingsPageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type MasterPasswordPromptAction =
|
type MasterPasswordPromptAction =
|
||||||
|
| 'enableTotp'
|
||||||
| 'recovery'
|
| 'recovery'
|
||||||
| 'apiKey'
|
| 'apiKey'
|
||||||
| 'rotateApiKey'
|
| 'rotateApiKey'
|
||||||
@@ -141,12 +142,12 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
}, [props.profile.email, secret]);
|
}, [props.profile.email, secret]);
|
||||||
|
|
||||||
async function enableTotp(): Promise<void> {
|
async function enableTotp(): Promise<void> {
|
||||||
try {
|
if (totpLocked) return;
|
||||||
await props.onEnableTotp(secret, token);
|
if (!secret.trim() || !token.trim()) {
|
||||||
setTotpLocked(true);
|
props.onNotify?.('error', t('txt_secret_and_code_are_required'));
|
||||||
} catch {
|
return;
|
||||||
// Keep inputs editable after a failed attempt.
|
|
||||||
}
|
}
|
||||||
|
openMasterPasswordPrompt('enableTotp');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshAccountPasskeys(): Promise<void> {
|
async function refreshAccountPasskeys(): Promise<void> {
|
||||||
@@ -178,7 +179,10 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
const masterPassword = masterPasswordPromptValue;
|
const masterPassword = masterPasswordPromptValue;
|
||||||
setMasterPasswordPromptSubmitting(true);
|
setMasterPasswordPromptSubmitting(true);
|
||||||
try {
|
try {
|
||||||
if (masterPasswordPrompt === 'recovery') {
|
if (masterPasswordPrompt === 'enableTotp') {
|
||||||
|
await props.onEnableTotp(secret, token, masterPassword);
|
||||||
|
setTotpLocked(true);
|
||||||
|
} else if (masterPasswordPrompt === 'recovery') {
|
||||||
const code = await props.onGetRecoveryCode(masterPassword);
|
const code = await props.onGetRecoveryCode(masterPassword);
|
||||||
setRecoveryCode(code);
|
setRecoveryCode(code);
|
||||||
props.onNotify?.('success', t('txt_recovery_code_loaded'));
|
props.onNotify?.('success', t('txt_recovery_code_loaded'));
|
||||||
@@ -214,7 +218,9 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const masterPasswordPromptTitle =
|
const masterPasswordPromptTitle =
|
||||||
masterPasswordPrompt === 'recovery'
|
masterPasswordPrompt === 'enableTotp'
|
||||||
|
? t('txt_enable_totp')
|
||||||
|
: masterPasswordPrompt === 'recovery'
|
||||||
? t('txt_view_recovery_code')
|
? t('txt_view_recovery_code')
|
||||||
: masterPasswordPrompt === 'rotateApiKey'
|
: masterPasswordPrompt === 'rotateApiKey'
|
||||||
? t('txt_rotate_api_key')
|
? t('txt_rotate_api_key')
|
||||||
|
|||||||
@@ -145,14 +145,30 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async enableTotp(secret: string, token: string) {
|
async enableTotp(secret: string, token: string, masterPassword: string) {
|
||||||
|
if (!profile) {
|
||||||
|
const error = new Error(t('txt_profile_unavailable'));
|
||||||
|
onNotify('error', error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
if (!secret.trim() || !token.trim()) {
|
if (!secret.trim() || !token.trim()) {
|
||||||
const error = new Error(t('txt_secret_and_code_are_required'));
|
const error = new Error(t('txt_secret_and_code_are_required'));
|
||||||
onNotify('error', error.message);
|
onNotify('error', error.message);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
if (!masterPassword) {
|
||||||
|
const error = new Error(t('txt_master_password_is_required'));
|
||||||
|
onNotify('error', error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await setTotp(authedFetch, { enabled: true, secret: secret.trim(), token: token.trim() });
|
const derived = await deriveLoginHash(profile.email, masterPassword, defaultKdfIterations);
|
||||||
|
await setTotp(authedFetch, {
|
||||||
|
enabled: true,
|
||||||
|
secret: secret.trim(),
|
||||||
|
token: token.trim(),
|
||||||
|
masterPasswordHash: derived.hash,
|
||||||
|
});
|
||||||
onNotify('success', t('txt_totp_enabled'));
|
onNotify('success', t('txt_totp_enabled'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onNotify('error', error instanceof Error ? error.message : t('txt_enable_totp_failed'));
|
onNotify('error', error instanceof Error ? error.message : t('txt_enable_totp_failed'));
|
||||||
|
|||||||
@@ -27,9 +27,10 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
|
|||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
async exportBackup(includeAttachments: boolean = false) {
|
async exportBackup(masterPasswordHash: string, includeAttachments: boolean = false) {
|
||||||
const payload = await buildCompleteAdminBackupExport(
|
const payload = await buildCompleteAdminBackupExport(
|
||||||
authedFetch,
|
authedFetch,
|
||||||
|
masterPasswordHash,
|
||||||
includeAttachments,
|
includeAttachments,
|
||||||
async (event: BackupExportClientProgressEvent) => {
|
async (event: BackupExportClientProgressEvent) => {
|
||||||
dispatchBackupProgress(event);
|
dispatchBackupProgress(event);
|
||||||
@@ -48,14 +49,14 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async importBackup(file: File, replaceExisting: boolean = false) {
|
async importBackup(masterPasswordHash: string, file: File, replaceExisting: boolean = false) {
|
||||||
const result = await importAdminBackup(authedFetch, file, replaceExisting);
|
const result = await importAdminBackup(authedFetch, masterPasswordHash, file, replaceExisting);
|
||||||
onImported?.();
|
onImported?.();
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
|
||||||
async importBackupAllowingChecksumMismatch(file: File, replaceExisting: boolean = false) {
|
async importBackupAllowingChecksumMismatch(masterPasswordHash: string, file: File, replaceExisting: boolean = false) {
|
||||||
const result = await importAdminBackup(authedFetch, file, replaceExisting, true);
|
const result = await importAdminBackup(authedFetch, masterPasswordHash, file, replaceExisting, true);
|
||||||
onImported?.();
|
onImported?.();
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
@@ -64,20 +65,20 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
|
|||||||
return getAdminBackupSettings(authedFetch);
|
return getAdminBackupSettings(authedFetch);
|
||||||
},
|
},
|
||||||
|
|
||||||
async saveSettings(settings: Parameters<typeof saveAdminBackupSettings>[1]) {
|
async saveSettings(masterPasswordHash: string, settings: Parameters<typeof saveAdminBackupSettings>[2]) {
|
||||||
return saveAdminBackupSettings(authedFetch, settings);
|
return saveAdminBackupSettings(authedFetch, masterPasswordHash, settings);
|
||||||
},
|
},
|
||||||
|
|
||||||
async runRemoteBackup(destinationId?: string | null) {
|
async runRemoteBackup(masterPasswordHash: string, destinationId?: string | null) {
|
||||||
return runAdminBackupNow(authedFetch, destinationId);
|
return runAdminBackupNow(authedFetch, masterPasswordHash, destinationId);
|
||||||
},
|
},
|
||||||
|
|
||||||
async listRemoteBackups(destinationId: string, path: string) {
|
async listRemoteBackups(destinationId: string, path: string) {
|
||||||
return listRemoteBackups(authedFetch, destinationId, path);
|
return listRemoteBackups(authedFetch, destinationId, path);
|
||||||
},
|
},
|
||||||
|
|
||||||
async downloadRemoteBackup(destinationId: string, path: string, onProgress?: (percent: number | null) => void) {
|
async downloadRemoteBackup(masterPasswordHash: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) {
|
||||||
const payload = await fetchRemoteBackupPayload(authedFetch, destinationId, path, onProgress);
|
const payload = await fetchRemoteBackupPayload(authedFetch, masterPasswordHash, destinationId, path, onProgress);
|
||||||
downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType);
|
downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -89,14 +90,14 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
|
|||||||
await deleteRemoteBackup(authedFetch, destinationId, path);
|
await deleteRemoteBackup(authedFetch, destinationId, path);
|
||||||
},
|
},
|
||||||
|
|
||||||
async restoreRemoteBackup(destinationId: string, path: string, replaceExisting: boolean = false) {
|
async restoreRemoteBackup(masterPasswordHash: string, destinationId: string, path: string, replaceExisting: boolean = false) {
|
||||||
const result = await restoreRemoteBackupRequest(authedFetch, destinationId, path, replaceExisting);
|
const result = await restoreRemoteBackupRequest(authedFetch, masterPasswordHash, destinationId, path, replaceExisting);
|
||||||
onRestored?.();
|
onRestored?.();
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
|
||||||
async restoreRemoteBackupAllowingChecksumMismatch(destinationId: string, path: string, replaceExisting: boolean = false) {
|
async restoreRemoteBackupAllowingChecksumMismatch(masterPasswordHash: string, destinationId: string, path: string, replaceExisting: boolean = false) {
|
||||||
const result = await restoreRemoteBackupRequest(authedFetch, destinationId, path, replaceExisting, true);
|
const result = await restoreRemoteBackupRequest(authedFetch, masterPasswordHash, destinationId, path, replaceExisting, true);
|
||||||
onRestored?.();
|
onRestored?.();
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ export interface BackupSettingsRepairStateResponse {
|
|||||||
portable: BackupSettingsPortablePayload | null;
|
portable: BackupSettingsPortablePayload | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BackupUserVerificationPayload {
|
||||||
|
masterPasswordHash?: string | null;
|
||||||
|
userVerificationToken?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdminBackupRunResponse {
|
export interface AdminBackupRunResponse {
|
||||||
object: 'backup-run';
|
object: 'backup-run';
|
||||||
result: {
|
result: {
|
||||||
@@ -173,12 +178,13 @@ async function applyBackupFileIntegrityName(fileName: string, bytes: Uint8Array)
|
|||||||
|
|
||||||
export async function exportAdminBackup(
|
export async function exportAdminBackup(
|
||||||
authedFetch: AuthedFetch,
|
authedFetch: AuthedFetch,
|
||||||
|
masterPasswordHash: string,
|
||||||
includeAttachments: boolean = false
|
includeAttachments: boolean = false
|
||||||
): Promise<AdminBackupExportPayload> {
|
): Promise<AdminBackupExportPayload> {
|
||||||
const resp = await authedFetch('/api/admin/backup/export', {
|
const resp = await authedFetch('/api/admin/backup/export', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ includeAttachments }),
|
body: JSON.stringify({ includeAttachments, masterPasswordHash }),
|
||||||
});
|
});
|
||||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_export_failed')));
|
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_export_failed')));
|
||||||
|
|
||||||
@@ -201,10 +207,11 @@ export async function downloadAdminBackupAttachmentBlob(
|
|||||||
|
|
||||||
export async function buildCompleteAdminBackupExport(
|
export async function buildCompleteAdminBackupExport(
|
||||||
authedFetch: AuthedFetch,
|
authedFetch: AuthedFetch,
|
||||||
|
masterPasswordHash: string,
|
||||||
includeAttachments: boolean = false,
|
includeAttachments: boolean = false,
|
||||||
onProgress?: (event: BackupExportClientProgressEvent) => void | Promise<void>
|
onProgress?: (event: BackupExportClientProgressEvent) => void | Promise<void>
|
||||||
): Promise<AdminBackupExportPayload> {
|
): Promise<AdminBackupExportPayload> {
|
||||||
const payload = await exportAdminBackup(authedFetch, includeAttachments);
|
const payload = await exportAdminBackup(authedFetch, masterPasswordHash, includeAttachments);
|
||||||
if (!includeAttachments) {
|
if (!includeAttachments) {
|
||||||
await onProgress?.({
|
await onProgress?.({
|
||||||
operation: 'backup-export',
|
operation: 'backup-export',
|
||||||
@@ -278,12 +285,13 @@ export async function getAdminBackupSettings(authedFetch: AuthedFetch): Promise<
|
|||||||
|
|
||||||
export async function saveAdminBackupSettings(
|
export async function saveAdminBackupSettings(
|
||||||
authedFetch: AuthedFetch,
|
authedFetch: AuthedFetch,
|
||||||
|
masterPasswordHash: string,
|
||||||
settings: AdminBackupSettings
|
settings: AdminBackupSettings
|
||||||
): Promise<AdminBackupSettings> {
|
): Promise<AdminBackupSettings> {
|
||||||
const resp = await authedFetch('/api/admin/backup/settings', {
|
const resp = await authedFetch('/api/admin/backup/settings', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(settings),
|
body: JSON.stringify({ ...settings, masterPasswordHash }),
|
||||||
});
|
});
|
||||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_settings_save_failed')));
|
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_settings_save_failed')));
|
||||||
const body = await parseJson<AdminBackupSettings>(resp);
|
const body = await parseJson<AdminBackupSettings>(resp);
|
||||||
@@ -305,12 +313,13 @@ export async function getAdminBackupSettingsRepairState(
|
|||||||
|
|
||||||
export async function repairAdminBackupSettings(
|
export async function repairAdminBackupSettings(
|
||||||
authedFetch: AuthedFetch,
|
authedFetch: AuthedFetch,
|
||||||
|
verification: BackupUserVerificationPayload,
|
||||||
settings: AdminBackupSettings
|
settings: AdminBackupSettings
|
||||||
): Promise<AdminBackupSettings> {
|
): Promise<AdminBackupSettings> {
|
||||||
const resp = await authedFetch('/api/admin/backup/settings/repair', {
|
const resp = await authedFetch('/api/admin/backup/settings/repair', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(settings),
|
body: JSON.stringify({ ...settings, ...verification }),
|
||||||
});
|
});
|
||||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_settings_save_failed')));
|
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_settings_save_failed')));
|
||||||
const body = await parseJson<AdminBackupSettings>(resp);
|
const body = await parseJson<AdminBackupSettings>(resp);
|
||||||
@@ -320,12 +329,13 @@ export async function repairAdminBackupSettings(
|
|||||||
|
|
||||||
export async function runAdminBackupNow(
|
export async function runAdminBackupNow(
|
||||||
authedFetch: AuthedFetch,
|
authedFetch: AuthedFetch,
|
||||||
|
masterPasswordHash: string,
|
||||||
destinationId?: string | null
|
destinationId?: string | null
|
||||||
): Promise<AdminBackupRunResponse> {
|
): Promise<AdminBackupRunResponse> {
|
||||||
const resp = await authedFetch('/api/admin/backup/run', {
|
const resp = await authedFetch('/api/admin/backup/run', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(destinationId ? { destinationId } : {}),
|
body: JSON.stringify(destinationId ? { destinationId, masterPasswordHash } : { masterPasswordHash }),
|
||||||
});
|
});
|
||||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_run_failed')));
|
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_run_failed')));
|
||||||
const body = await parseJson<AdminBackupRunResponse>(resp);
|
const body = await parseJson<AdminBackupRunResponse>(resp);
|
||||||
@@ -351,14 +361,16 @@ export async function listRemoteBackups(
|
|||||||
|
|
||||||
export async function downloadRemoteBackup(
|
export async function downloadRemoteBackup(
|
||||||
authedFetch: AuthedFetch,
|
authedFetch: AuthedFetch,
|
||||||
|
masterPasswordHash: string,
|
||||||
destinationId: string,
|
destinationId: string,
|
||||||
path: string,
|
path: string,
|
||||||
onProgress?: (percent: number | null) => void
|
onProgress?: (percent: number | null) => void
|
||||||
): Promise<AdminBackupExportPayload> {
|
): Promise<AdminBackupExportPayload> {
|
||||||
const params = new URLSearchParams();
|
const resp = await authedFetch('/api/admin/backup/remote/download', {
|
||||||
params.set('destinationId', destinationId);
|
method: 'POST',
|
||||||
params.set('path', path);
|
headers: { 'Content-Type': 'application/json' },
|
||||||
const resp = await authedFetch(`/api/admin/backup/remote/download?${params.toString()}`, { method: 'GET' });
|
body: JSON.stringify({ destinationId, path, masterPasswordHash }),
|
||||||
|
});
|
||||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_download_failed')));
|
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_download_failed')));
|
||||||
const mimeType = String(resp.headers.get('Content-Type') || 'application/zip').trim() || 'application/zip';
|
const mimeType = String(resp.headers.get('Content-Type') || 'application/zip').trim() || 'application/zip';
|
||||||
const fileName = parseContentDispositionFileName(resp, 'nodewarden_remote_backup.zip');
|
const fileName = parseContentDispositionFileName(resp, 'nodewarden_remote_backup.zip');
|
||||||
@@ -418,6 +430,7 @@ export async function inspectRemoteBackupIntegrity(
|
|||||||
|
|
||||||
export async function restoreRemoteBackup(
|
export async function restoreRemoteBackup(
|
||||||
authedFetch: AuthedFetch,
|
authedFetch: AuthedFetch,
|
||||||
|
masterPasswordHash: string,
|
||||||
destinationId: string,
|
destinationId: string,
|
||||||
path: string,
|
path: string,
|
||||||
replaceExisting: boolean = false,
|
replaceExisting: boolean = false,
|
||||||
@@ -426,7 +439,7 @@ export async function restoreRemoteBackup(
|
|||||||
const resp = await authedFetch('/api/admin/backup/remote/restore', {
|
const resp = await authedFetch('/api/admin/backup/remote/restore', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ destinationId, path, replaceExisting, allowChecksumMismatch }),
|
body: JSON.stringify({ destinationId, path, replaceExisting, allowChecksumMismatch, masterPasswordHash }),
|
||||||
});
|
});
|
||||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_restore_failed')));
|
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_restore_failed')));
|
||||||
const body = await parseJson<AdminBackupImportResponse>(resp);
|
const body = await parseJson<AdminBackupImportResponse>(resp);
|
||||||
@@ -436,12 +449,14 @@ export async function restoreRemoteBackup(
|
|||||||
|
|
||||||
export async function importAdminBackup(
|
export async function importAdminBackup(
|
||||||
authedFetch: AuthedFetch,
|
authedFetch: AuthedFetch,
|
||||||
|
masterPasswordHash: string,
|
||||||
file: File,
|
file: File,
|
||||||
replaceExisting: boolean = false,
|
replaceExisting: boolean = false,
|
||||||
allowChecksumMismatch: boolean = false
|
allowChecksumMismatch: boolean = false
|
||||||
): Promise<AdminBackupImportResponse> {
|
): Promise<AdminBackupImportResponse> {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.set('file', file, file.name || 'nodewarden_backup.zip');
|
formData.set('file', file, file.name || 'nodewarden_backup.zip');
|
||||||
|
formData.set('masterPasswordHash', masterPasswordHash);
|
||||||
if (replaceExisting) {
|
if (replaceExisting) {
|
||||||
formData.set('replaceExisting', '1');
|
formData.set('replaceExisting', '1');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,12 @@ export interface CompletedLogin {
|
|||||||
session: SessionState;
|
session: SessionState;
|
||||||
profile: Profile;
|
profile: Profile;
|
||||||
profilePromise: Promise<Profile>;
|
profilePromise: Promise<Profile>;
|
||||||
|
freshMasterPasswordHash?: string | null;
|
||||||
|
freshUserVerificationToken?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readTokenUserVerificationToken(token: TokenSuccess): string | null {
|
||||||
|
return String(token.UserVerificationToken || token.userVerificationToken || '').trim() || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PasswordLoginResult =
|
export type PasswordLoginResult =
|
||||||
@@ -319,7 +325,8 @@ export async function completeLogin(
|
|||||||
token: TokenSuccess,
|
token: TokenSuccess,
|
||||||
email: string,
|
email: string,
|
||||||
masterKey: Uint8Array,
|
masterKey: Uint8Array,
|
||||||
fallbackKdfIterations: number
|
fallbackKdfIterations: number,
|
||||||
|
freshMasterPasswordHash?: string | null
|
||||||
): Promise<CompletedLogin> {
|
): Promise<CompletedLogin> {
|
||||||
const normalizedEmail = email.trim().toLowerCase();
|
const normalizedEmail = email.trim().toLowerCase();
|
||||||
const fallbackProfile = loadProfileSnapshot(normalizedEmail);
|
const fallbackProfile = loadProfileSnapshot(normalizedEmail);
|
||||||
@@ -348,6 +355,8 @@ export async function completeLogin(
|
|||||||
session: { ...baseSession, ...keys },
|
session: { ...baseSession, ...keys },
|
||||||
profile,
|
profile,
|
||||||
profilePromise: getProfile(tempFetch),
|
profilePromise: getProfile(tempFetch),
|
||||||
|
freshMasterPasswordHash: freshMasterPasswordHash || null,
|
||||||
|
freshUserVerificationToken: readTokenUserVerificationToken(token),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,7 +369,8 @@ async function completeLoginWithVaultKeys(
|
|||||||
token: TokenSuccess,
|
token: TokenSuccess,
|
||||||
email: string,
|
email: string,
|
||||||
keys: { symEncKey: string; symMacKey: string },
|
keys: { symEncKey: string; symMacKey: string },
|
||||||
fallbackKdfIterations: number
|
fallbackKdfIterations: number,
|
||||||
|
freshMasterPasswordHash?: string | null
|
||||||
): Promise<CompletedLogin> {
|
): Promise<CompletedLogin> {
|
||||||
const normalizedEmail = email.trim().toLowerCase();
|
const normalizedEmail = email.trim().toLowerCase();
|
||||||
const fallbackProfile = loadProfileSnapshot(normalizedEmail);
|
const fallbackProfile = loadProfileSnapshot(normalizedEmail);
|
||||||
@@ -385,6 +395,8 @@ async function completeLoginWithVaultKeys(
|
|||||||
session: { ...baseSession, ...keys },
|
session: { ...baseSession, ...keys },
|
||||||
profile,
|
profile,
|
||||||
profilePromise: getProfile(tempFetch),
|
profilePromise: getProfile(tempFetch),
|
||||||
|
freshMasterPasswordHash: freshMasterPasswordHash || null,
|
||||||
|
freshUserVerificationToken: readTokenUserVerificationToken(token),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,7 +412,7 @@ export async function performPasswordLogin(
|
|||||||
if ('access_token' in token && token.access_token) {
|
if ('access_token' in token && token.access_token) {
|
||||||
return {
|
return {
|
||||||
kind: 'success',
|
kind: 'success',
|
||||||
login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations),
|
login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations, derived.hash),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -476,7 +488,7 @@ export async function completePasskeyPasswordLogin(
|
|||||||
password: string
|
password: string
|
||||||
): Promise<CompletedLogin> {
|
): Promise<CompletedLogin> {
|
||||||
const derived = await deriveLoginHashLocally(pending.email, password, pending.kdfIterations);
|
const derived = await deriveLoginHashLocally(pending.email, password, pending.kdfIterations);
|
||||||
return completeLogin(pending.token, pending.email, derived.masterKey, pending.kdfIterations);
|
return completeLogin(pending.token, pending.email, derived.masterKey, pending.kdfIterations, derived.hash);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function performTotpLogin(
|
export async function performTotpLogin(
|
||||||
@@ -489,7 +501,7 @@ export async function performTotpLogin(
|
|||||||
rememberDevice,
|
rememberDevice,
|
||||||
});
|
});
|
||||||
if ('access_token' in token && token.access_token) {
|
if ('access_token' in token && token.access_token) {
|
||||||
return completeLogin(token, pendingTotp.email, pendingTotp.masterKey, pendingTotp.kdfIterations);
|
return completeLogin(token, pendingTotp.email, pendingTotp.masterKey, pendingTotp.kdfIterations, pendingTotp.passwordHash);
|
||||||
}
|
}
|
||||||
const tokenError = token as { error_description?: string; error?: string };
|
const tokenError = token as { error_description?: string; error?: string };
|
||||||
throw new Error(translateServerError(tokenError.error_description || tokenError.error, t('txt_totp_verify_failed')));
|
throw new Error(translateServerError(tokenError.error_description || tokenError.error, t('txt_totp_verify_failed')));
|
||||||
@@ -508,7 +520,7 @@ export async function performRecoverTwoFactorLogin(
|
|||||||
|
|
||||||
if ('access_token' in token && token.access_token) {
|
if ('access_token' in token && token.access_token) {
|
||||||
return {
|
return {
|
||||||
login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations),
|
login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations, derived.hash),
|
||||||
newRecoveryCode: recovered.newRecoveryCode || null,
|
newRecoveryCode: recovered.newRecoveryCode || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -557,6 +569,7 @@ export async function performUnlock(
|
|||||||
session: offline.session,
|
session: offline.session,
|
||||||
profile: offline.profile,
|
profile: offline.profile,
|
||||||
profilePromise: Promise.resolve(offline.profile),
|
profilePromise: Promise.resolve(offline.profile),
|
||||||
|
freshMasterPasswordHash: null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
@@ -589,7 +602,7 @@ export async function performUnlock(
|
|||||||
if ('access_token' in token && token.access_token) {
|
if ('access_token' in token && token.access_token) {
|
||||||
return {
|
return {
|
||||||
kind: 'success',
|
kind: 'success',
|
||||||
login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations),
|
login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations, derived.hash),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import type { Profile, SessionState } from './types';
|
|||||||
|
|
||||||
export async function silentlyRepairBackupSettingsIfNeeded(
|
export async function silentlyRepairBackupSettingsIfNeeded(
|
||||||
activeSession: SessionState,
|
activeSession: SessionState,
|
||||||
activeProfile: Profile
|
activeProfile: Profile,
|
||||||
|
verification?: { masterPasswordHash?: string | null; userVerificationToken?: string | null } | null
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (activeProfile.role !== 'admin') return;
|
if (activeProfile.role !== 'admin') return;
|
||||||
if (!activeSession.accessToken || !activeSession.symEncKey || !activeSession.symMacKey) return;
|
if (!activeSession.accessToken || !activeSession.symEncKey || !activeSession.symMacKey) return;
|
||||||
@@ -14,8 +15,9 @@ export async function silentlyRepairBackupSettingsIfNeeded(
|
|||||||
try {
|
try {
|
||||||
const state = await getAdminBackupSettingsRepairState(tempFetch);
|
const state = await getAdminBackupSettingsRepairState(tempFetch);
|
||||||
if (!state.needsRepair || !state.portable) return;
|
if (!state.needsRepair || !state.portable) return;
|
||||||
|
if (!verification?.masterPasswordHash && !verification?.userVerificationToken) return;
|
||||||
const repairedSettings = await decryptPortableBackupSettings(state.portable, activeProfile, activeSession);
|
const repairedSettings = await decryptPortableBackupSettings(state.portable, activeProfile, activeSession);
|
||||||
await repairAdminBackupSettings(tempFetch, repairedSettings);
|
await repairAdminBackupSettings(tempFetch, verification, repairedSettings);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Backup settings auto-repair failed:', error);
|
console.error('Backup settings auto-repair failed:', error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1156,32 +1156,32 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
|
|||||||
notify('success', t('txt_logs_cleared'));
|
notify('success', t('txt_logs_cleared'));
|
||||||
return 0;
|
return 0;
|
||||||
},
|
},
|
||||||
onExportBackup: async () => {
|
onExportBackup: async (_masterPassword: string) => {
|
||||||
notify('success', t('txt_backup_export_success'));
|
notify('success', t('txt_backup_export_success'));
|
||||||
},
|
},
|
||||||
onImportBackup: async () => {
|
onImportBackup: async (_masterPassword: string, _file: File, _replaceExisting?: boolean) => {
|
||||||
resetDemoVaultState(state);
|
resetDemoVaultState(state);
|
||||||
notify('success', t('txt_backup_import_success_relogin'));
|
notify('success', t('txt_backup_import_success_relogin'));
|
||||||
return createDemoImportBackupResult();
|
return createDemoImportBackupResult();
|
||||||
},
|
},
|
||||||
onImportBackupAllowingChecksumMismatch: async () => {
|
onImportBackupAllowingChecksumMismatch: async (_masterPassword: string, _file: File, _replaceExisting?: boolean) => {
|
||||||
resetDemoVaultState(state);
|
resetDemoVaultState(state);
|
||||||
notify('success', t('txt_backup_import_success_relogin'));
|
notify('success', t('txt_backup_import_success_relogin'));
|
||||||
return createDemoImportBackupResult();
|
return createDemoImportBackupResult();
|
||||||
},
|
},
|
||||||
onLoadBackupSettings: async () => state.backupSettings,
|
onLoadBackupSettings: async () => state.backupSettings,
|
||||||
onSaveBackupSettings: async (settings) => {
|
onSaveBackupSettings: async (_masterPassword: string, settings) => {
|
||||||
const next = cloneJson(settings);
|
const next = cloneJson(settings);
|
||||||
state.setBackupSettings(next);
|
state.setBackupSettings(next);
|
||||||
notify('success', t('txt_backup_settings_saved'));
|
notify('success', t('txt_backup_settings_saved'));
|
||||||
return next;
|
return next;
|
||||||
},
|
},
|
||||||
onRunRemoteBackup: async (destinationId?: string | null) => {
|
onRunRemoteBackup: async (_masterPassword: string, destinationId?: string | null) => {
|
||||||
notify('success', t('txt_backup_remote_run_success'));
|
notify('success', t('txt_backup_remote_run_success'));
|
||||||
return createDemoBackupRun(state.backupSettings, destinationId);
|
return createDemoBackupRun(state.backupSettings, destinationId);
|
||||||
},
|
},
|
||||||
onListRemoteBackups: async (destinationId: string, path: string) => createDemoRemoteBrowser(destinationId, path),
|
onListRemoteBackups: async (destinationId: string, path: string) => createDemoRemoteBrowser(destinationId, path),
|
||||||
onDownloadRemoteBackup: async () => {
|
onDownloadRemoteBackup: async (_masterPassword: string, _destinationId: string, _path: string, _onProgress?: (percent: number | null) => void) => {
|
||||||
notify('success', t('txt_demo_download_prepared'));
|
notify('success', t('txt_demo_download_prepared'));
|
||||||
},
|
},
|
||||||
onInspectRemoteBackup: async (_destinationId: string, path: string) => ({
|
onInspectRemoteBackup: async (_destinationId: string, path: string) => ({
|
||||||
@@ -1199,13 +1199,13 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
|
|||||||
onDeleteRemoteBackup: async () => {
|
onDeleteRemoteBackup: async () => {
|
||||||
notify('success', t('txt_backup_remote_delete_success'));
|
notify('success', t('txt_backup_remote_delete_success'));
|
||||||
},
|
},
|
||||||
onRestoreRemoteBackup: async (_destinationId, path) => {
|
onRestoreRemoteBackup: async (_masterPassword: string, _destinationId, path) => {
|
||||||
await runDemoRemoteRestoreProgress(path.split('/').pop() || path || 'nodewarden_backup_demo.zip');
|
await runDemoRemoteRestoreProgress(path.split('/').pop() || path || 'nodewarden_backup_demo.zip');
|
||||||
resetDemoVaultState(state);
|
resetDemoVaultState(state);
|
||||||
notify('success', t('txt_backup_remote_restore_completed_verified'));
|
notify('success', t('txt_backup_remote_restore_completed_verified'));
|
||||||
return createDemoImportBackupResult();
|
return createDemoImportBackupResult();
|
||||||
},
|
},
|
||||||
onRestoreRemoteBackupAllowingChecksumMismatch: async (_destinationId, path) => {
|
onRestoreRemoteBackupAllowingChecksumMismatch: async (_masterPassword: string, _destinationId, path) => {
|
||||||
await runDemoRemoteRestoreProgress(path.split('/').pop() || path || 'nodewarden_backup_demo.zip');
|
await runDemoRemoteRestoreProgress(path.split('/').pop() || path || 'nodewarden_backup_demo.zip');
|
||||||
resetDemoVaultState(state);
|
resetDemoVaultState(state);
|
||||||
notify('success', t('txt_backup_remote_restore_completed_verified'));
|
notify('success', t('txt_backup_remote_restore_completed_verified'));
|
||||||
|
|||||||
@@ -314,6 +314,8 @@ export interface TokenSuccess {
|
|||||||
ResetMasterPassword?: boolean;
|
ResetMasterPassword?: boolean;
|
||||||
scope?: string;
|
scope?: string;
|
||||||
unofficialServer?: boolean;
|
unofficialServer?: boolean;
|
||||||
|
UserVerificationToken?: string;
|
||||||
|
userVerificationToken?: string;
|
||||||
UserDecryptionOptions?: unknown;
|
UserDecryptionOptions?: unknown;
|
||||||
userDecryptionOptions?: unknown;
|
userDecryptionOptions?: unknown;
|
||||||
VaultKeys?: {
|
VaultKeys?: {
|
||||||
|
|||||||
Reference in New Issue
Block a user