mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
f4d2e7932a
- Removed password breach checking logic and related state management from VaultPage. - Introduced bulk delete functionality for folders with a confirmation dialog. - Enhanced list rendering with virtualization to improve performance. - Updated styles for folder actions and list items for better UI consistency. - Removed unused password breach library and related translations.
2406 lines
95 KiB
TypeScript
2406 lines
95 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
|
import { Link, Route, Switch, useLocation } from 'wouter';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { ArrowUpDown, Cloud, Clock3, Folder as FolderIcon, KeyRound, Lock, LogOut, Send as SendIcon, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
|
|
import AuthViews from '@/components/AuthViews';
|
|
import ConfirmDialog from '@/components/ConfirmDialog';
|
|
import ToastHost from '@/components/ToastHost';
|
|
import VaultPage from '@/components/VaultPage';
|
|
import SendsPage from '@/components/SendsPage';
|
|
import PublicSendPage from '@/components/PublicSendPage';
|
|
import RecoverTwoFactorPage from '@/components/RecoverTwoFactorPage';
|
|
import JwtWarningPage from '@/components/JwtWarningPage';
|
|
import SettingsPage from '@/components/SettingsPage';
|
|
import SecurityDevicesPage from '@/components/SecurityDevicesPage';
|
|
import AdminPage from '@/components/AdminPage';
|
|
import HelpPage from '@/components/HelpPage';
|
|
import ImportPage from '@/components/ImportPage';
|
|
import TotpCodesPage from '@/components/TotpCodesPage';
|
|
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
|
|
import {
|
|
buildCipherImportPayload,
|
|
bulkDeleteFolders,
|
|
changeMasterPassword,
|
|
createFolder,
|
|
updateFolder,
|
|
deleteCipherAttachment,
|
|
deleteFolder,
|
|
bulkDeleteCiphers,
|
|
bulkDeleteSends,
|
|
createCipher,
|
|
createAuthedFetch,
|
|
createInvite,
|
|
downloadCipherAttachmentDecrypted,
|
|
encryptFolderImportName,
|
|
exportAdminBackup,
|
|
importAdminBackup,
|
|
importCiphers,
|
|
createSend,
|
|
deleteAllInvites,
|
|
deleteCipher,
|
|
deleteSend,
|
|
deleteUser,
|
|
deriveLoginHash,
|
|
getAttachmentDownloadInfo,
|
|
bulkMoveCiphers,
|
|
getCiphers,
|
|
getFolders,
|
|
getPreloginKdfConfig,
|
|
getProfile,
|
|
getAuthorizedDevices,
|
|
getCurrentDeviceIdentifier,
|
|
getSetupStatus,
|
|
getSends,
|
|
getTotpStatus,
|
|
getTotpRecoveryCode,
|
|
getWebConfig,
|
|
listAdminInvites,
|
|
listAdminUsers,
|
|
loadSession,
|
|
loginWithPassword,
|
|
registerAccount,
|
|
recoverTwoFactor,
|
|
revokeInvite,
|
|
revokeAuthorizedDeviceTrust,
|
|
revokeAllAuthorizedDeviceTrust,
|
|
saveSession,
|
|
setTotp,
|
|
setUserStatus,
|
|
deleteAllAuthorizedDevices,
|
|
deleteAuthorizedDevice,
|
|
uploadCipherAttachment,
|
|
updateCipher,
|
|
updateSend,
|
|
buildSendShareKey,
|
|
unlockVaultKey,
|
|
verifyMasterPassword,
|
|
type ImportedCipherMapEntry,
|
|
} from '@/lib/api';
|
|
import { base64ToBytes, decryptBw, decryptBwFileData, decryptStr, hkdf } from '@/lib/crypto';
|
|
import {
|
|
attachNodeWardenEncryptedAttachmentPayload,
|
|
buildAccountEncryptedBitwardenJsonString,
|
|
buildBitwardenZipBytes,
|
|
buildExportFileName,
|
|
buildNodeWardenAttachmentRecords,
|
|
buildNodeWardenPlainJsonDocument,
|
|
buildPasswordProtectedBitwardenJsonString,
|
|
buildPlainBitwardenJsonString,
|
|
encryptZipBytesWithPassword,
|
|
type ExportRequest,
|
|
type ZipAttachmentEntry,
|
|
} from '@/lib/export-formats';
|
|
import { t } from '@/lib/i18n';
|
|
import type { CiphersImportPayload } from '@/lib/api';
|
|
import type { AppPhase, AuthorizedDevice, Cipher, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, ToastMessage, VaultDraft } from '@/lib/types';
|
|
|
|
interface PendingTotp {
|
|
email: string;
|
|
passwordHash: string;
|
|
masterKey: Uint8Array;
|
|
}
|
|
|
|
type JwtUnsafeReason = 'missing' | 'default' | 'too_short';
|
|
|
|
const SEND_KEY_SALT = 'bitwarden-send';
|
|
const SEND_KEY_PURPOSE = 'send';
|
|
const IMPORT_ROUTE = '/help/import-export';
|
|
const IMPORT_ROUTE_ALIASES = new Set(['/tools/import', '/tools/import-export', '/tools/import-data', '/import', '/import-export']);
|
|
const SETTINGS_HOME_ROUTE = '/settings';
|
|
const SETTINGS_ACCOUNT_ROUTE = '/settings/account';
|
|
|
|
function looksLikeCipherString(value: string): boolean {
|
|
return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim());
|
|
}
|
|
|
|
function asText(value: unknown): string {
|
|
if (value === null || value === undefined) return '';
|
|
return String(value);
|
|
}
|
|
|
|
function readInviteCodeFromUrl(): string {
|
|
if (typeof window === 'undefined') return '';
|
|
|
|
const searchInvite = new URLSearchParams(window.location.search || '').get('invite');
|
|
if (searchInvite && searchInvite.trim()) return searchInvite.trim();
|
|
|
|
const rawHash = String(window.location.hash || '');
|
|
const queryIndex = rawHash.indexOf('?');
|
|
if (queryIndex >= 0) {
|
|
const hashInvite = new URLSearchParams(rawHash.slice(queryIndex + 1)).get('invite');
|
|
if (hashInvite && hashInvite.trim()) return hashInvite.trim();
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
function summarizeImportResult(
|
|
ciphers: Array<Record<string, unknown>>,
|
|
folderCount: number,
|
|
attachmentSummary?: {
|
|
total: number;
|
|
imported: number;
|
|
failed: Array<{ fileName: string; reason: string }>;
|
|
}
|
|
): ImportResultSummary {
|
|
const typeLabel = (type: number): string => {
|
|
if (type === 1) return t('txt_login');
|
|
if (type === 2) return t('txt_secure_note');
|
|
if (type === 3) return t('txt_card');
|
|
if (type === 4) return t('txt_identity');
|
|
if (type === 5) return t('txt_ssh_key');
|
|
return t('txt_other');
|
|
};
|
|
const counter = new Map<number, number>();
|
|
for (const raw of ciphers) {
|
|
const cipherType = Number(raw?.type || 1) || 1;
|
|
counter.set(cipherType, (counter.get(cipherType) || 0) + 1);
|
|
}
|
|
const order = [1, 2, 3, 4, 5];
|
|
const seen = new Set<number>(order);
|
|
const typeCounts = order
|
|
.filter((type) => (counter.get(type) || 0) > 0)
|
|
.map((type) => ({ label: typeLabel(type), count: counter.get(type) || 0 }));
|
|
for (const [type, count] of counter.entries()) {
|
|
if (!seen.has(type) && count > 0) typeCounts.push({ label: typeLabel(type), count });
|
|
}
|
|
return {
|
|
totalItems: ciphers.length,
|
|
folderCount: Math.max(0, folderCount),
|
|
typeCounts,
|
|
attachmentCount: Math.max(0, attachmentSummary?.total || 0),
|
|
importedAttachmentCount: Math.max(0, attachmentSummary?.imported || 0),
|
|
failedAttachments: attachmentSummary?.failed || [],
|
|
};
|
|
}
|
|
|
|
function buildEmptyImportDraft(type: number): VaultDraft {
|
|
return {
|
|
type,
|
|
favorite: false,
|
|
name: '',
|
|
folderId: '',
|
|
notes: '',
|
|
reprompt: false,
|
|
loginUsername: '',
|
|
loginPassword: '',
|
|
loginTotp: '',
|
|
loginUris: [''],
|
|
loginFido2Credentials: [],
|
|
cardholderName: '',
|
|
cardNumber: '',
|
|
cardBrand: '',
|
|
cardExpMonth: '',
|
|
cardExpYear: '',
|
|
cardCode: '',
|
|
identTitle: '',
|
|
identFirstName: '',
|
|
identMiddleName: '',
|
|
identLastName: '',
|
|
identUsername: '',
|
|
identCompany: '',
|
|
identSsn: '',
|
|
identPassportNumber: '',
|
|
identLicenseNumber: '',
|
|
identEmail: '',
|
|
identPhone: '',
|
|
identAddress1: '',
|
|
identAddress2: '',
|
|
identAddress3: '',
|
|
identCity: '',
|
|
identState: '',
|
|
identPostalCode: '',
|
|
identCountry: '',
|
|
sshPrivateKey: '',
|
|
sshPublicKey: '',
|
|
sshFingerprint: '',
|
|
customFields: [],
|
|
};
|
|
}
|
|
|
|
function importCipherToDraft(cipher: Record<string, unknown>, folderId: string | null): VaultDraft {
|
|
const type = Number(cipher.type || 1) || 1;
|
|
const draft = buildEmptyImportDraft(type);
|
|
draft.name = asText(cipher.name).trim() || 'Untitled';
|
|
draft.notes = asText(cipher.notes);
|
|
draft.favorite = !!cipher.favorite;
|
|
draft.reprompt = Number(cipher.reprompt || 0) === 1;
|
|
draft.folderId = folderId || '';
|
|
|
|
const customFieldsRaw = Array.isArray(cipher.fields) ? cipher.fields : [];
|
|
draft.customFields = customFieldsRaw
|
|
.map((raw) => {
|
|
const field = (raw || {}) as Record<string, unknown>;
|
|
const label = asText(field.name).trim();
|
|
if (!label) return null;
|
|
const parsedType = Number(field.type ?? 0);
|
|
const fieldType = parsedType === 1 || parsedType === 2 || parsedType === 3 ? (parsedType as 1 | 2 | 3) : 0;
|
|
return {
|
|
type: fieldType,
|
|
label,
|
|
value: asText(field.value),
|
|
};
|
|
})
|
|
.filter((x): x is VaultDraft['customFields'][number] => !!x);
|
|
|
|
if (type === 1) {
|
|
const login = (cipher.login || {}) as Record<string, unknown>;
|
|
draft.loginUsername = asText(login.username);
|
|
draft.loginPassword = asText(login.password);
|
|
draft.loginTotp = asText(login.totp);
|
|
draft.loginFido2Credentials = Array.isArray(login.fido2Credentials)
|
|
? login.fido2Credentials
|
|
.filter((credential): credential is Record<string, unknown> => !!credential && typeof credential === 'object')
|
|
.map((credential) => ({ ...credential }))
|
|
: [];
|
|
const urisRaw = Array.isArray(login.uris) ? login.uris : [];
|
|
const uris = urisRaw
|
|
.map((u) => asText((u as Record<string, unknown>)?.uri).trim())
|
|
.filter((u) => !!u);
|
|
draft.loginUris = uris.length ? uris : [''];
|
|
} else if (type === 3) {
|
|
const card = (cipher.card || {}) as Record<string, unknown>;
|
|
draft.cardholderName = asText(card.cardholderName);
|
|
draft.cardNumber = asText(card.number);
|
|
draft.cardBrand = asText(card.brand);
|
|
draft.cardExpMonth = asText(card.expMonth);
|
|
draft.cardExpYear = asText(card.expYear);
|
|
draft.cardCode = asText(card.code);
|
|
} else if (type === 4) {
|
|
const identity = (cipher.identity || {}) as Record<string, unknown>;
|
|
draft.identTitle = asText(identity.title);
|
|
draft.identFirstName = asText(identity.firstName);
|
|
draft.identMiddleName = asText(identity.middleName);
|
|
draft.identLastName = asText(identity.lastName);
|
|
draft.identUsername = asText(identity.username);
|
|
draft.identCompany = asText(identity.company);
|
|
draft.identSsn = asText(identity.ssn);
|
|
draft.identPassportNumber = asText(identity.passportNumber);
|
|
draft.identLicenseNumber = asText(identity.licenseNumber);
|
|
draft.identEmail = asText(identity.email);
|
|
draft.identPhone = asText(identity.phone);
|
|
draft.identAddress1 = asText(identity.address1);
|
|
draft.identAddress2 = asText(identity.address2);
|
|
draft.identAddress3 = asText(identity.address3);
|
|
draft.identCity = asText(identity.city);
|
|
draft.identState = asText(identity.state);
|
|
draft.identPostalCode = asText(identity.postalCode);
|
|
draft.identCountry = asText(identity.country);
|
|
} else if (type === 5) {
|
|
const sshKey = (cipher.sshKey || {}) as Record<string, unknown>;
|
|
draft.sshPrivateKey = asText(sshKey.privateKey);
|
|
draft.sshPublicKey = asText(sshKey.publicKey);
|
|
draft.sshFingerprint = asText(sshKey.keyFingerprint ?? sshKey.fingerprint);
|
|
}
|
|
|
|
return draft;
|
|
}
|
|
|
|
function buildPublicSendUrl(origin: string, accessId: string, keyPart: string): string {
|
|
return `${origin}/#/send/${accessId}/${keyPart}`;
|
|
}
|
|
|
|
const SIGNALR_RECORD_SEPARATOR = String.fromCharCode(0x1e);
|
|
const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5;
|
|
const SIGNALR_UPDATE_TYPE_LOG_OUT = 11;
|
|
const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12;
|
|
|
|
interface WebVaultSignalRInvocation {
|
|
type?: number;
|
|
target?: string;
|
|
arguments?: Array<{
|
|
ContextId?: string | null;
|
|
Type?: number;
|
|
Payload?: {
|
|
UserId?: string;
|
|
Date?: string;
|
|
RevisionDate?: string;
|
|
};
|
|
}>;
|
|
}
|
|
|
|
function parseSignalRTextFrames(raw: string): WebVaultSignalRInvocation[] {
|
|
return raw
|
|
.split(SIGNALR_RECORD_SEPARATOR)
|
|
.map((frame) => frame.trim())
|
|
.filter(Boolean)
|
|
.map((frame) => {
|
|
try {
|
|
return JSON.parse(frame) as WebVaultSignalRInvocation;
|
|
} catch {
|
|
return null;
|
|
}
|
|
})
|
|
.filter((frame): frame is WebVaultSignalRInvocation => !!frame);
|
|
}
|
|
|
|
async function deriveSendKeyParts(sendKeyMaterial: Uint8Array): Promise<{ enc: Uint8Array; mac: Uint8Array }> {
|
|
if (sendKeyMaterial.length >= 64) {
|
|
return { enc: sendKeyMaterial.slice(0, 32), mac: sendKeyMaterial.slice(32, 64) };
|
|
}
|
|
const derived = await hkdf(sendKeyMaterial, SEND_KEY_SALT, SEND_KEY_PURPOSE, 64);
|
|
return { enc: derived.slice(0, 32), mac: derived.slice(32, 64) };
|
|
}
|
|
|
|
export default function App() {
|
|
const [location, navigate] = useLocation();
|
|
const [phase, setPhase] = useState<AppPhase>('loading');
|
|
const [session, setSessionState] = useState<SessionState | null>(null);
|
|
const [profile, setProfile] = useState<Profile | null>(null);
|
|
const [defaultKdfIterations, setDefaultKdfIterations] = useState(600000);
|
|
const [setupRegistered, setSetupRegistered] = useState(true);
|
|
const [jwtWarning, setJwtWarning] = useState<{ reason: JwtUnsafeReason; minLength: number } | null>(null);
|
|
|
|
const [loginValues, setLoginValues] = useState({ email: '', password: '' });
|
|
const [registerValues, setRegisterValues] = useState({
|
|
name: '',
|
|
email: '',
|
|
password: '',
|
|
password2: '',
|
|
inviteCode: '',
|
|
});
|
|
const [inviteCodeFromUrl, setInviteCodeFromUrl] = useState('');
|
|
const [unlockPassword, setUnlockPassword] = useState('');
|
|
const [pendingTotp, setPendingTotp] = useState<PendingTotp | null>(null);
|
|
const [totpCode, setTotpCode] = useState('');
|
|
const [rememberDevice, setRememberDevice] = useState(true);
|
|
|
|
const [disableTotpOpen, setDisableTotpOpen] = useState(false);
|
|
const [disableTotpPassword, setDisableTotpPassword] = useState('');
|
|
const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' });
|
|
|
|
const [confirm, setConfirm] = useState<{
|
|
title: string;
|
|
message: string;
|
|
danger?: boolean;
|
|
showIcon?: boolean;
|
|
onConfirm: () => void;
|
|
} | null>(null);
|
|
|
|
const [toasts, setToasts] = useState<ToastMessage[]>([]);
|
|
const [mobileLayout, setMobileLayout] = useState(false);
|
|
const [decryptedFolders, setDecryptedFolders] = useState<VaultFolder[]>([]);
|
|
const [decryptedCiphers, setDecryptedCiphers] = useState<Cipher[]>([]);
|
|
const [decryptedSends, setDecryptedSends] = useState<Send[]>([]);
|
|
const migratedPlainFolderIdsRef = useRef<Set<string>>(new Set());
|
|
const silentRefreshVaultRef = useRef<() => Promise<void>>(async () => {});
|
|
const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {});
|
|
|
|
useEffect(() => {
|
|
const syncInviteFromUrl = () => {
|
|
setInviteCodeFromUrl(readInviteCodeFromUrl());
|
|
};
|
|
syncInviteFromUrl();
|
|
window.addEventListener('hashchange', syncInviteFromUrl);
|
|
window.addEventListener('popstate', syncInviteFromUrl);
|
|
return () => {
|
|
window.removeEventListener('hashchange', syncInviteFromUrl);
|
|
window.removeEventListener('popstate', syncInviteFromUrl);
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!inviteCodeFromUrl) return;
|
|
setRegisterValues((prev) => (prev.inviteCode === inviteCodeFromUrl ? prev : { ...prev, inviteCode: inviteCodeFromUrl }));
|
|
}, [inviteCodeFromUrl]);
|
|
|
|
useEffect(() => {
|
|
if (!inviteCodeFromUrl) return;
|
|
if (phase === 'loading' || phase === 'locked' || phase === 'app') return;
|
|
setPhase('register');
|
|
if (location !== '/register') navigate('/register');
|
|
if (typeof window !== 'undefined' && typeof window.history?.replaceState === 'function') {
|
|
window.history.replaceState(null, '', '/register');
|
|
}
|
|
setInviteCodeFromUrl('');
|
|
}, [inviteCodeFromUrl, phase, location, navigate]);
|
|
|
|
useEffect(() => {
|
|
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
|
|
const media = window.matchMedia('(max-width: 900px)');
|
|
const sync = () => setMobileLayout(media.matches);
|
|
sync();
|
|
if (typeof media.addEventListener === 'function') {
|
|
media.addEventListener('change', sync);
|
|
return () => media.removeEventListener('change', sync);
|
|
}
|
|
media.addListener(sync);
|
|
return () => media.removeListener(sync);
|
|
}, []);
|
|
|
|
function setSession(next: SessionState | null) {
|
|
setSessionState(next);
|
|
saveSession(next);
|
|
}
|
|
|
|
function pushToast(type: ToastMessage['type'], text: string) {
|
|
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
setToasts((prev) => [...prev.slice(-3), { id, type, text }]);
|
|
window.setTimeout(() => {
|
|
setToasts((prev) => prev.filter((x) => x.id !== id));
|
|
}, 4500);
|
|
}
|
|
|
|
const authedFetch = useMemo(
|
|
() =>
|
|
createAuthedFetch(
|
|
() => session,
|
|
(next) => {
|
|
setSession(next);
|
|
if (!next) {
|
|
setProfile(null);
|
|
setPhase(setupRegistered ? 'login' : 'register');
|
|
}
|
|
}
|
|
),
|
|
[session, setupRegistered]
|
|
);
|
|
const importAuthedFetch = useMemo(
|
|
() => async (input: string, init?: RequestInit) => {
|
|
const headers = new Headers(init?.headers || {});
|
|
headers.set('X-NodeWarden-Import', '1');
|
|
return authedFetch(input, { ...init, headers });
|
|
},
|
|
[authedFetch]
|
|
);
|
|
|
|
useEffect(() => {
|
|
let mounted = true;
|
|
(async () => {
|
|
const [setup, config] = await Promise.all([getSetupStatus(), getWebConfig()]);
|
|
if (!mounted) return;
|
|
setSetupRegistered(setup.registered);
|
|
setDefaultKdfIterations(Number(config.defaultKdfIterations || 600000));
|
|
const jwtUnsafeReason = config.jwtUnsafeReason || null;
|
|
if (jwtUnsafeReason) {
|
|
setJwtWarning({
|
|
reason: jwtUnsafeReason,
|
|
minLength: Number(config.jwtSecretMinLength || 32),
|
|
});
|
|
setSession(null);
|
|
setProfile(null);
|
|
setPhase('login');
|
|
return;
|
|
}
|
|
setJwtWarning(null);
|
|
|
|
const loaded = loadSession();
|
|
if (!loaded) {
|
|
setPhase(setup.registered ? 'login' : 'register');
|
|
return;
|
|
}
|
|
setSession(loaded);
|
|
|
|
try {
|
|
const profileResp = await getProfile(
|
|
createAuthedFetch(
|
|
() => loaded,
|
|
(next) => {
|
|
if (!next) return;
|
|
setSession(next);
|
|
}
|
|
)
|
|
);
|
|
if (!mounted) return;
|
|
setProfile(profileResp);
|
|
setPhase('locked');
|
|
} catch {
|
|
setSession(null);
|
|
setPhase(setup.registered ? 'login' : 'register');
|
|
}
|
|
})();
|
|
|
|
return () => {
|
|
mounted = false;
|
|
};
|
|
}, []);
|
|
|
|
async function finalizeLogin(tokenAccess: string, tokenRefresh: string, email: string, masterKey: Uint8Array) {
|
|
const baseSession: SessionState = { accessToken: tokenAccess, refreshToken: tokenRefresh, email };
|
|
const tempFetch = createAuthedFetch(
|
|
() => baseSession,
|
|
() => {}
|
|
);
|
|
const profileResp = await getProfile(tempFetch);
|
|
const keys = await unlockVaultKey(profileResp.key, masterKey);
|
|
const nextSession = { ...baseSession, ...keys };
|
|
setSession(nextSession);
|
|
setProfile(profileResp);
|
|
setPendingTotp(null);
|
|
setTotpCode('');
|
|
setPhase('app');
|
|
if (location === '/' || location === '/login' || location === '/register' || location === '/lock') {
|
|
navigate('/vault');
|
|
}
|
|
pushToast('success', t('txt_login_success'));
|
|
}
|
|
|
|
async function handleLogin() {
|
|
if (!loginValues.email || !loginValues.password) {
|
|
pushToast('error', t('txt_please_input_email_and_password'));
|
|
return;
|
|
}
|
|
try {
|
|
const derived = await deriveLoginHash(loginValues.email, loginValues.password, defaultKdfIterations);
|
|
const token = await loginWithPassword(loginValues.email, derived.hash, { useRememberToken: true });
|
|
if ('access_token' in token && token.access_token) {
|
|
await finalizeLogin(token.access_token, token.refresh_token, loginValues.email.toLowerCase(), derived.masterKey);
|
|
return;
|
|
}
|
|
const tokenError = token as { TwoFactorProviders?: unknown; error_description?: string; error?: string };
|
|
if (tokenError.TwoFactorProviders) {
|
|
setPendingTotp({
|
|
email: loginValues.email.toLowerCase(),
|
|
passwordHash: derived.hash,
|
|
masterKey: derived.masterKey,
|
|
});
|
|
setTotpCode('');
|
|
setRememberDevice(true);
|
|
return;
|
|
}
|
|
pushToast('error', tokenError.error_description || tokenError.error || t('txt_login_failed'));
|
|
} catch (error) {
|
|
pushToast('error', error instanceof Error ? error.message : t('txt_login_failed'));
|
|
}
|
|
}
|
|
|
|
async function handleTotpVerify() {
|
|
if (!pendingTotp) return;
|
|
if (!totpCode.trim()) {
|
|
pushToast('error', t('txt_please_input_totp_code'));
|
|
return;
|
|
}
|
|
const token = await loginWithPassword(pendingTotp.email, pendingTotp.passwordHash, {
|
|
totpCode: totpCode.trim(),
|
|
rememberDevice,
|
|
});
|
|
if ('access_token' in token && token.access_token) {
|
|
await finalizeLogin(token.access_token, token.refresh_token, pendingTotp.email, pendingTotp.masterKey);
|
|
return;
|
|
}
|
|
const tokenError = token as { error_description?: string; error?: string };
|
|
pushToast('error', tokenError.error_description || tokenError.error || t('txt_totp_verify_failed'));
|
|
}
|
|
|
|
async function handleRecoverTwoFactorSubmit() {
|
|
const email = recoverValues.email.trim().toLowerCase();
|
|
const password = recoverValues.password;
|
|
const recoveryCode = recoverValues.recoveryCode.trim();
|
|
if (!email || !password || !recoveryCode) {
|
|
pushToast('error', t('txt_email_password_and_recovery_code_are_required'));
|
|
return;
|
|
}
|
|
try {
|
|
const derived = await deriveLoginHash(email, password, defaultKdfIterations);
|
|
const recovered = await recoverTwoFactor(email, derived.hash, recoveryCode);
|
|
const token = await loginWithPassword(email, derived.hash, { useRememberToken: false });
|
|
if ('access_token' in token && token.access_token) {
|
|
await finalizeLogin(token.access_token, token.refresh_token, email, derived.masterKey);
|
|
if (recovered.newRecoveryCode) {
|
|
pushToast('success', t('txt_text_2fa_recovered_new_recovery_code_code', { code: recovered.newRecoveryCode }));
|
|
} else {
|
|
pushToast('success', t('txt_text_2fa_recovered'));
|
|
}
|
|
return;
|
|
}
|
|
pushToast('error', t('txt_recovered_but_auto_login_failed_please_sign_in'));
|
|
navigate('/login');
|
|
} catch (error) {
|
|
pushToast('error', error instanceof Error ? error.message : t('txt_recover_2fa_failed'));
|
|
}
|
|
}
|
|
|
|
async function handleRegister() {
|
|
if (!registerValues.email || !registerValues.password) {
|
|
pushToast('error', t('txt_please_input_email_and_password'));
|
|
return;
|
|
}
|
|
if (registerValues.password.length < 12) {
|
|
pushToast('error', t('txt_master_password_must_be_at_least_12_chars'));
|
|
return;
|
|
}
|
|
if (registerValues.password !== registerValues.password2) {
|
|
pushToast('error', t('txt_passwords_do_not_match'));
|
|
return;
|
|
}
|
|
const resp = await registerAccount({
|
|
email: registerValues.email.toLowerCase(),
|
|
name: registerValues.name.trim(),
|
|
password: registerValues.password,
|
|
inviteCode: registerValues.inviteCode.trim(),
|
|
fallbackIterations: defaultKdfIterations,
|
|
});
|
|
if (!resp.ok) {
|
|
pushToast('error', resp.message);
|
|
return;
|
|
}
|
|
setLoginValues({ email: registerValues.email.toLowerCase(), password: '' });
|
|
setPhase('login');
|
|
navigate('/login');
|
|
pushToast('success', t('txt_registration_succeeded_please_sign_in'));
|
|
}
|
|
|
|
async function handleUnlock() {
|
|
if (!session || !profile) return;
|
|
if (!unlockPassword) {
|
|
pushToast('error', t('txt_please_input_master_password'));
|
|
return;
|
|
}
|
|
try {
|
|
const derived = await deriveLoginHash(profile.email || session.email, unlockPassword, defaultKdfIterations);
|
|
const keys = await unlockVaultKey(profile.key, derived.masterKey);
|
|
setSession({ ...session, ...keys });
|
|
setUnlockPassword('');
|
|
setPhase('app');
|
|
if (location === '/' || location === '/lock') navigate('/vault');
|
|
pushToast('success', t('txt_unlocked'));
|
|
} catch {
|
|
pushToast('error', t('txt_unlock_failed_master_password_is_incorrect'));
|
|
}
|
|
}
|
|
|
|
function handleLock() {
|
|
if (!session) return;
|
|
const nextSession = { ...session };
|
|
delete nextSession.symEncKey;
|
|
delete nextSession.symMacKey;
|
|
setSession(nextSession);
|
|
setPhase('locked');
|
|
navigate('/lock');
|
|
}
|
|
|
|
function logoutNow() {
|
|
setConfirm(null);
|
|
setSession(null);
|
|
setProfile(null);
|
|
setPendingTotp(null);
|
|
setPhase(setupRegistered ? 'login' : 'register');
|
|
navigate(setupRegistered ? '/login' : '/register');
|
|
}
|
|
|
|
function handleLogout() {
|
|
setConfirm({
|
|
title: t('txt_log_out'),
|
|
message: t('txt_are_you_sure_you_want_to_log_out'),
|
|
showIcon: false,
|
|
onConfirm: () => {
|
|
logoutNow();
|
|
},
|
|
});
|
|
}
|
|
|
|
const ciphersQuery = useQuery({
|
|
queryKey: ['ciphers', session?.accessToken],
|
|
queryFn: () => getCiphers(authedFetch),
|
|
enabled: phase === 'app' && !!session?.symEncKey && !!session?.symMacKey,
|
|
});
|
|
const foldersQuery = useQuery({
|
|
queryKey: ['folders', session?.accessToken],
|
|
queryFn: () => getFolders(authedFetch),
|
|
enabled: phase === 'app' && !!session?.symEncKey && !!session?.symMacKey,
|
|
});
|
|
const sendsQuery = useQuery({
|
|
queryKey: ['sends', session?.accessToken],
|
|
queryFn: () => getSends(authedFetch),
|
|
enabled: phase === 'app' && !!session?.symEncKey && !!session?.symMacKey,
|
|
});
|
|
const usersQuery = useQuery({
|
|
queryKey: ['admin-users', session?.accessToken],
|
|
queryFn: () => listAdminUsers(authedFetch),
|
|
enabled: phase === 'app' && profile?.role === 'admin',
|
|
});
|
|
const invitesQuery = useQuery({
|
|
queryKey: ['admin-invites', session?.accessToken],
|
|
queryFn: () => listAdminInvites(authedFetch),
|
|
enabled: phase === 'app' && profile?.role === 'admin',
|
|
});
|
|
const totpStatusQuery = useQuery({
|
|
queryKey: ['totp-status', session?.accessToken],
|
|
queryFn: () => getTotpStatus(authedFetch),
|
|
enabled: phase === 'app' && !!session?.accessToken,
|
|
});
|
|
const authorizedDevicesQuery = useQuery({
|
|
queryKey: ['authorized-devices', session?.accessToken],
|
|
queryFn: () => getAuthorizedDevices(authedFetch),
|
|
enabled: phase === 'app' && !!session?.accessToken,
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!session?.symEncKey || !session?.symMacKey) {
|
|
setDecryptedFolders([]);
|
|
setDecryptedCiphers([]);
|
|
setDecryptedSends([]);
|
|
return;
|
|
}
|
|
if (!foldersQuery.data || !ciphersQuery.data || !sendsQuery.data) return;
|
|
|
|
let active = true;
|
|
(async () => {
|
|
try {
|
|
const encKey = base64ToBytes(session.symEncKey!);
|
|
const macKey = base64ToBytes(session.symMacKey!);
|
|
const decryptField = async (
|
|
value: string | null | undefined,
|
|
fieldEnc: Uint8Array = encKey,
|
|
fieldMac: Uint8Array = macKey
|
|
): Promise<string> => {
|
|
if (!value || typeof value !== 'string') return '';
|
|
try {
|
|
return await decryptStr(value, fieldEnc, fieldMac);
|
|
} catch {
|
|
// Backward-compatibility: some records may already be plain text.
|
|
return value;
|
|
}
|
|
};
|
|
|
|
const folders = await Promise.all(
|
|
foldersQuery.data.map(async (folder) => ({
|
|
...folder,
|
|
decName: await decryptField(folder.name, encKey, macKey),
|
|
}))
|
|
);
|
|
|
|
const ciphers = await Promise.all(
|
|
ciphersQuery.data.map(async (cipher) => {
|
|
let itemEnc = encKey;
|
|
let itemMac = macKey;
|
|
if (cipher.key) {
|
|
try {
|
|
const itemKey = await decryptBw(cipher.key, encKey, macKey);
|
|
itemEnc = itemKey.slice(0, 32);
|
|
itemMac = itemKey.slice(32, 64);
|
|
} catch {
|
|
// keep user key when item key decrypt fails
|
|
}
|
|
}
|
|
|
|
const nextCipher: Cipher = {
|
|
...cipher,
|
|
decName: await decryptField(cipher.name || '', itemEnc, itemMac),
|
|
decNotes: await decryptField(cipher.notes || '', itemEnc, itemMac),
|
|
};
|
|
if (cipher.login) {
|
|
nextCipher.login = {
|
|
...cipher.login,
|
|
decUsername: await decryptField(cipher.login.username || '', itemEnc, itemMac),
|
|
decPassword: await decryptField(cipher.login.password || '', itemEnc, itemMac),
|
|
decTotp: await decryptField(cipher.login.totp || '', itemEnc, itemMac),
|
|
fido2Credentials: Array.isArray(cipher.login.fido2Credentials)
|
|
? cipher.login.fido2Credentials.map((credential) => ({ ...credential }))
|
|
: null,
|
|
uris: await Promise.all(
|
|
(cipher.login.uris || []).map(async (u) => ({
|
|
...u,
|
|
decUri: await decryptField(u.uri || '', itemEnc, itemMac),
|
|
}))
|
|
),
|
|
};
|
|
}
|
|
if (cipher.card) {
|
|
nextCipher.card = {
|
|
...cipher.card,
|
|
decCardholderName: await decryptField(cipher.card.cardholderName || '', itemEnc, itemMac),
|
|
decNumber: await decryptField(cipher.card.number || '', itemEnc, itemMac),
|
|
decBrand: await decryptField(cipher.card.brand || '', itemEnc, itemMac),
|
|
decExpMonth: await decryptField(cipher.card.expMonth || '', itemEnc, itemMac),
|
|
decExpYear: await decryptField(cipher.card.expYear || '', itemEnc, itemMac),
|
|
decCode: await decryptField(cipher.card.code || '', itemEnc, itemMac),
|
|
};
|
|
}
|
|
if (cipher.identity) {
|
|
nextCipher.identity = {
|
|
...cipher.identity,
|
|
decTitle: await decryptField(cipher.identity.title || '', itemEnc, itemMac),
|
|
decFirstName: await decryptField(cipher.identity.firstName || '', itemEnc, itemMac),
|
|
decMiddleName: await decryptField(cipher.identity.middleName || '', itemEnc, itemMac),
|
|
decLastName: await decryptField(cipher.identity.lastName || '', itemEnc, itemMac),
|
|
decUsername: await decryptField(cipher.identity.username || '', itemEnc, itemMac),
|
|
decCompany: await decryptField(cipher.identity.company || '', itemEnc, itemMac),
|
|
decSsn: await decryptField(cipher.identity.ssn || '', itemEnc, itemMac),
|
|
decPassportNumber: await decryptField(cipher.identity.passportNumber || '', itemEnc, itemMac),
|
|
decLicenseNumber: await decryptField(cipher.identity.licenseNumber || '', itemEnc, itemMac),
|
|
decEmail: await decryptField(cipher.identity.email || '', itemEnc, itemMac),
|
|
decPhone: await decryptField(cipher.identity.phone || '', itemEnc, itemMac),
|
|
decAddress1: await decryptField(cipher.identity.address1 || '', itemEnc, itemMac),
|
|
decAddress2: await decryptField(cipher.identity.address2 || '', itemEnc, itemMac),
|
|
decAddress3: await decryptField(cipher.identity.address3 || '', itemEnc, itemMac),
|
|
decCity: await decryptField(cipher.identity.city || '', itemEnc, itemMac),
|
|
decState: await decryptField(cipher.identity.state || '', itemEnc, itemMac),
|
|
decPostalCode: await decryptField(cipher.identity.postalCode || '', itemEnc, itemMac),
|
|
decCountry: await decryptField(cipher.identity.country || '', itemEnc, itemMac),
|
|
};
|
|
}
|
|
if (cipher.sshKey) {
|
|
const encryptedFingerprint = cipher.sshKey.keyFingerprint || cipher.sshKey.fingerprint || '';
|
|
nextCipher.sshKey = {
|
|
...cipher.sshKey,
|
|
decPrivateKey: await decryptField(cipher.sshKey.privateKey || '', itemEnc, itemMac),
|
|
decPublicKey: await decryptField(cipher.sshKey.publicKey || '', itemEnc, itemMac),
|
|
keyFingerprint: encryptedFingerprint || null,
|
|
fingerprint: encryptedFingerprint || null,
|
|
decFingerprint: await decryptField(encryptedFingerprint, itemEnc, itemMac),
|
|
};
|
|
}
|
|
if (cipher.fields) {
|
|
nextCipher.fields = await Promise.all(
|
|
cipher.fields.map(async (field) => ({
|
|
...field,
|
|
decName: await decryptField(field.name || '', itemEnc, itemMac),
|
|
decValue: await decryptField(field.value || '', itemEnc, itemMac),
|
|
}))
|
|
);
|
|
}
|
|
if (Array.isArray(cipher.attachments)) {
|
|
nextCipher.attachments = await Promise.all(
|
|
cipher.attachments.map(async (attachment) => ({
|
|
...attachment,
|
|
decFileName: await decryptField(attachment.fileName || '', itemEnc, itemMac),
|
|
}))
|
|
);
|
|
}
|
|
return nextCipher;
|
|
})
|
|
);
|
|
|
|
const sends = await Promise.all(
|
|
sendsQuery.data.map(async (send) => {
|
|
const nextSend: Send = { ...send };
|
|
try {
|
|
if (send.key) {
|
|
const sendKeyRaw = await decryptBw(send.key, encKey, macKey);
|
|
const derived = await deriveSendKeyParts(sendKeyRaw);
|
|
nextSend.decName = await decryptField(send.name || '', derived.enc, derived.mac);
|
|
nextSend.decNotes = await decryptField(send.notes || '', derived.enc, derived.mac);
|
|
nextSend.decText = await decryptField(send.text?.text || '', derived.enc, derived.mac);
|
|
if (send.file?.fileName) {
|
|
const decFileName = await decryptField(send.file.fileName, derived.enc, derived.mac);
|
|
nextSend.file = {
|
|
...(send.file || {}),
|
|
fileName: decFileName || send.file.fileName,
|
|
};
|
|
}
|
|
const shareKey = await buildSendShareKey(send.key, session.symEncKey!, session.symMacKey!);
|
|
nextSend.decShareKey = shareKey;
|
|
nextSend.shareUrl = buildPublicSendUrl(window.location.origin, send.accessId, shareKey);
|
|
} else {
|
|
nextSend.decName = '';
|
|
nextSend.decNotes = '';
|
|
nextSend.decText = '';
|
|
}
|
|
} catch {
|
|
nextSend.decName = t('txt_decrypt_failed');
|
|
}
|
|
return nextSend;
|
|
})
|
|
);
|
|
|
|
if (!active) return;
|
|
setDecryptedFolders(folders);
|
|
setDecryptedCiphers(ciphers);
|
|
setDecryptedSends(sends);
|
|
} catch (error) {
|
|
if (!active) return;
|
|
pushToast('error', error instanceof Error ? error.message : t('txt_decrypt_failed_2'));
|
|
}
|
|
})();
|
|
|
|
return () => {
|
|
active = false;
|
|
};
|
|
}, [session?.symEncKey, session?.symMacKey, foldersQuery.data, ciphersQuery.data, sendsQuery.data]);
|
|
|
|
useEffect(() => {
|
|
if (!session?.symEncKey || !session?.symMacKey || !foldersQuery.data?.length) return;
|
|
let cancelled = false;
|
|
(async () => {
|
|
const pending = foldersQuery.data.filter((folder) => {
|
|
if (!folder?.id || !folder?.name) return false;
|
|
if (migratedPlainFolderIdsRef.current.has(folder.id)) return false;
|
|
return !looksLikeCipherString(String(folder.name));
|
|
});
|
|
if (!pending.length) return;
|
|
for (const folder of pending) {
|
|
try {
|
|
await updateFolder(authedFetch, session, folder.id, String(folder.name));
|
|
migratedPlainFolderIdsRef.current.add(folder.id);
|
|
} catch {
|
|
// keep silent; web still supports plaintext fallback display
|
|
}
|
|
}
|
|
if (!cancelled) await foldersQuery.refetch();
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [session?.symEncKey, session?.symMacKey, foldersQuery.data, authedFetch]);
|
|
|
|
async function changePasswordAction(currentPassword: string, nextPassword: string, nextPassword2: string) {
|
|
if (!profile) return;
|
|
if (!currentPassword || !nextPassword) {
|
|
pushToast('error', t('txt_current_new_password_is_required'));
|
|
return;
|
|
}
|
|
if (nextPassword.length < 12) {
|
|
pushToast('error', t('txt_new_password_must_be_at_least_12_chars'));
|
|
return;
|
|
}
|
|
if (nextPassword !== nextPassword2) {
|
|
pushToast('error', t('txt_new_passwords_do_not_match'));
|
|
return;
|
|
}
|
|
try {
|
|
await changeMasterPassword(authedFetch, {
|
|
email: profile.email,
|
|
currentPassword,
|
|
newPassword: nextPassword,
|
|
currentIterations: defaultKdfIterations,
|
|
profileKey: profile.key,
|
|
});
|
|
handleLogout();
|
|
pushToast('success', t('txt_master_password_changed_please_login_again'));
|
|
} catch (error) {
|
|
pushToast('error', error instanceof Error ? error.message : t('txt_change_password_failed'));
|
|
}
|
|
}
|
|
|
|
async function enableTotpAction(secret: string, token: string) {
|
|
if (!secret.trim() || !token.trim()) {
|
|
const error = new Error(t('txt_secret_and_code_are_required'));
|
|
pushToast('error', error.message);
|
|
throw error;
|
|
}
|
|
try {
|
|
await setTotp(authedFetch, { enabled: true, secret: secret.trim(), token: token.trim() });
|
|
pushToast('success', t('txt_totp_enabled'));
|
|
} catch (error) {
|
|
pushToast('error', error instanceof Error ? error.message : t('txt_enable_totp_failed'));
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function disableTotpAction() {
|
|
if (!profile) return;
|
|
if (!disableTotpPassword) {
|
|
pushToast('error', t('txt_please_input_master_password'));
|
|
return;
|
|
}
|
|
try {
|
|
const derived = await deriveLoginHash(profile.email, disableTotpPassword, defaultKdfIterations);
|
|
await setTotp(authedFetch, { enabled: false, masterPasswordHash: derived.hash });
|
|
if (profile?.id) localStorage.removeItem(`nodewarden.totp.secret.${profile.id}`);
|
|
setDisableTotpOpen(false);
|
|
setDisableTotpPassword('');
|
|
await totpStatusQuery.refetch();
|
|
pushToast('success', t('txt_totp_disabled'));
|
|
} catch (error) {
|
|
pushToast('error', error instanceof Error ? error.message : t('txt_disable_totp_failed'));
|
|
}
|
|
}
|
|
|
|
async function refreshVault() {
|
|
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch(), sendsQuery.refetch()]);
|
|
pushToast('success', t('txt_vault_synced'));
|
|
}
|
|
|
|
async function refreshVaultSilently() {
|
|
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch(), sendsQuery.refetch()]);
|
|
}
|
|
|
|
silentRefreshVaultRef.current = refreshVaultSilently;
|
|
|
|
useEffect(() => {
|
|
if (phase !== 'app' || !session?.accessToken || !session?.symEncKey || !session?.symMacKey) return;
|
|
|
|
let disposed = false;
|
|
let socket: WebSocket | null = null;
|
|
let reconnectTimer: number | null = null;
|
|
let reconnectAttempts = 0;
|
|
|
|
const clearReconnectTimer = () => {
|
|
if (reconnectTimer !== null) {
|
|
window.clearTimeout(reconnectTimer);
|
|
reconnectTimer = null;
|
|
}
|
|
};
|
|
|
|
const scheduleReconnect = () => {
|
|
if (disposed) return;
|
|
clearReconnectTimer();
|
|
const delay = Math.min(10000, 1000 * Math.max(1, reconnectAttempts + 1));
|
|
reconnectAttempts += 1;
|
|
reconnectTimer = window.setTimeout(() => {
|
|
reconnectTimer = null;
|
|
connect();
|
|
}, delay);
|
|
};
|
|
|
|
const connect = () => {
|
|
if (disposed) return;
|
|
try {
|
|
const hubUrl = new URL('/notifications/hub', window.location.origin);
|
|
hubUrl.searchParams.set('access_token', session.accessToken);
|
|
hubUrl.protocol = hubUrl.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
socket = new WebSocket(hubUrl.toString());
|
|
} catch {
|
|
scheduleReconnect();
|
|
return;
|
|
}
|
|
|
|
socket.addEventListener('open', () => {
|
|
reconnectAttempts = 0;
|
|
void refreshAuthorizedDevicesRef.current();
|
|
try {
|
|
socket?.send(`{"protocol":"json","version":1}${SIGNALR_RECORD_SEPARATOR}`);
|
|
} catch {
|
|
socket?.close();
|
|
}
|
|
});
|
|
|
|
socket.addEventListener('message', (event) => {
|
|
if (disposed) return;
|
|
if (typeof event.data !== 'string') return;
|
|
|
|
const frames = parseSignalRTextFrames(event.data);
|
|
for (const frame of frames) {
|
|
if (frame.type !== 1 || frame.target !== 'ReceiveMessage') continue;
|
|
const updateType = Number(frame.arguments?.[0]?.Type || 0);
|
|
if (updateType === SIGNALR_UPDATE_TYPE_LOG_OUT) {
|
|
logoutNow();
|
|
return;
|
|
}
|
|
if (updateType === SIGNALR_UPDATE_TYPE_DEVICE_STATUS) {
|
|
void refreshAuthorizedDevicesRef.current();
|
|
continue;
|
|
}
|
|
if (updateType !== SIGNALR_UPDATE_TYPE_SYNC_VAULT) continue;
|
|
const contextId = String(frame.arguments?.[0]?.ContextId || '').trim();
|
|
if (contextId && contextId === getCurrentDeviceIdentifier()) continue;
|
|
void silentRefreshVaultRef.current();
|
|
}
|
|
});
|
|
|
|
socket.addEventListener('close', () => {
|
|
socket = null;
|
|
void refreshAuthorizedDevicesRef.current();
|
|
scheduleReconnect();
|
|
});
|
|
|
|
socket.addEventListener('error', () => {
|
|
try {
|
|
socket?.close();
|
|
} catch {
|
|
// ignore close races
|
|
}
|
|
});
|
|
};
|
|
|
|
connect();
|
|
|
|
return () => {
|
|
disposed = true;
|
|
clearReconnectTimer();
|
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
|
try {
|
|
socket.close();
|
|
} catch {
|
|
// ignore close races
|
|
}
|
|
}
|
|
};
|
|
}, [phase, session?.accessToken, session?.symEncKey, session?.symMacKey]);
|
|
|
|
async function refreshAuthorizedDevices() {
|
|
await authorizedDevicesQuery.refetch();
|
|
}
|
|
|
|
refreshAuthorizedDevicesRef.current = refreshAuthorizedDevices;
|
|
|
|
async function revokeDeviceTrustAction(device: AuthorizedDevice) {
|
|
await revokeAuthorizedDeviceTrust(authedFetch, device.identifier);
|
|
await authorizedDevicesQuery.refetch();
|
|
pushToast('success', t('txt_device_authorization_revoked'));
|
|
}
|
|
|
|
async function revokeAllDeviceTrustAction() {
|
|
await revokeAllAuthorizedDeviceTrust(authedFetch);
|
|
await authorizedDevicesQuery.refetch();
|
|
pushToast('success', t('txt_all_device_authorizations_revoked'));
|
|
}
|
|
|
|
async function removeDeviceAction(device: AuthorizedDevice) {
|
|
await deleteAuthorizedDevice(authedFetch, device.identifier);
|
|
if (device.identifier === getCurrentDeviceIdentifier()) {
|
|
pushToast('success', t('txt_device_removed'));
|
|
logoutNow();
|
|
return;
|
|
}
|
|
await authorizedDevicesQuery.refetch();
|
|
pushToast('success', t('txt_device_removed'));
|
|
}
|
|
|
|
async function removeAllDevicesAction() {
|
|
await deleteAllAuthorizedDevices(authedFetch);
|
|
pushToast('success', t('txt_all_devices_removed'));
|
|
logoutNow();
|
|
}
|
|
|
|
async function createVaultItem(draft: VaultDraft, attachments: File[] = []) {
|
|
if (!session) return;
|
|
try {
|
|
const created = await createCipher(authedFetch, session, draft);
|
|
for (const file of attachments) {
|
|
await uploadCipherAttachment(authedFetch, session, created.id, file);
|
|
}
|
|
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
|
|
pushToast('success', t('txt_item_created'));
|
|
} catch (error) {
|
|
pushToast('error', error instanceof Error ? error.message : t('txt_create_item_failed'));
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function updateVaultItem(
|
|
cipher: Cipher,
|
|
draft: VaultDraft,
|
|
options?: { addFiles?: File[]; removeAttachmentIds?: string[] }
|
|
) {
|
|
if (!session) return;
|
|
const addFiles = Array.isArray(options?.addFiles) ? options.addFiles : [];
|
|
const removeAttachmentIds = Array.isArray(options?.removeAttachmentIds) ? options.removeAttachmentIds : [];
|
|
try {
|
|
await updateCipher(authedFetch, session, cipher, draft);
|
|
for (const attachmentId of removeAttachmentIds) {
|
|
const id = String(attachmentId || '').trim();
|
|
if (!id) continue;
|
|
await deleteCipherAttachment(authedFetch, cipher.id, id);
|
|
}
|
|
for (const file of addFiles) {
|
|
await uploadCipherAttachment(authedFetch, session, cipher.id, file, cipher);
|
|
}
|
|
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
|
|
pushToast('success', t('txt_item_updated'));
|
|
} catch (error) {
|
|
pushToast('error', error instanceof Error ? error.message : t('txt_update_item_failed'));
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function downloadVaultAttachment(cipher: Cipher, attachmentId: string) {
|
|
if (!session) return;
|
|
try {
|
|
const file = await downloadCipherAttachmentDecrypted(authedFetch, session, cipher, attachmentId);
|
|
const fileName = String(file.fileName || '').trim() || 'attachment.bin';
|
|
const payload = new ArrayBuffer(file.bytes.byteLength);
|
|
new Uint8Array(payload).set(file.bytes);
|
|
const blob = new Blob([payload], { type: 'application/octet-stream' });
|
|
const href = URL.createObjectURL(blob);
|
|
const anchor = document.createElement('a');
|
|
anchor.href = href;
|
|
anchor.download = fileName;
|
|
anchor.rel = 'noopener';
|
|
document.body.appendChild(anchor);
|
|
anchor.click();
|
|
anchor.remove();
|
|
URL.revokeObjectURL(href);
|
|
} catch (error) {
|
|
pushToast('error', error instanceof Error ? error.message : t('txt_download_failed'));
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function deleteVaultItem(cipher: Cipher) {
|
|
try {
|
|
await deleteCipher(authedFetch, cipher.id);
|
|
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
|
|
pushToast('success', t('txt_item_deleted'));
|
|
} catch (error) {
|
|
pushToast('error', error instanceof Error ? error.message : t('txt_delete_item_failed'));
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function bulkDeleteVaultItems(ids: string[]) {
|
|
try {
|
|
await bulkDeleteCiphers(authedFetch, ids);
|
|
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
|
|
pushToast('success', t('txt_deleted_selected_items'));
|
|
} catch (error) {
|
|
pushToast('error', error instanceof Error ? error.message : t('txt_bulk_delete_failed'));
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function bulkMoveVaultItems(ids: string[], folderId: string | null) {
|
|
try {
|
|
await bulkMoveCiphers(authedFetch, ids, folderId);
|
|
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
|
|
pushToast('success', t('txt_moved_selected_items'));
|
|
} catch (error) {
|
|
pushToast('error', error instanceof Error ? error.message : t('txt_bulk_move_failed'));
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function getRecoveryCodeAction(masterPassword: string): Promise<string> {
|
|
if (!profile) throw new Error(t('txt_profile_unavailable'));
|
|
const normalized = String(masterPassword || '');
|
|
if (!normalized) throw new Error(t('txt_master_password_is_required'));
|
|
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
|
|
const code = await getTotpRecoveryCode(authedFetch, derived.hash);
|
|
if (!code) throw new Error(t('txt_recovery_code_is_empty'));
|
|
return code;
|
|
}
|
|
|
|
async function createSendItem(draft: SendDraft, autoCopyLink: boolean) {
|
|
if (!session) return;
|
|
try {
|
|
const created = await createSend(authedFetch, session, draft);
|
|
await sendsQuery.refetch();
|
|
if (autoCopyLink && created.key && session.symEncKey && session.symMacKey) {
|
|
const keyPart = await buildSendShareKey(created.key, session.symEncKey, session.symMacKey);
|
|
const shareUrl = buildPublicSendUrl(window.location.origin, created.accessId, keyPart);
|
|
await navigator.clipboard.writeText(shareUrl);
|
|
}
|
|
pushToast('success', t('txt_send_created'));
|
|
} catch (error) {
|
|
pushToast('error', error instanceof Error ? error.message : t('txt_create_send_failed'));
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function updateSendItem(send: Send, draft: SendDraft, autoCopyLink: boolean) {
|
|
if (!session) return;
|
|
try {
|
|
const updated = await updateSend(authedFetch, session, send, draft);
|
|
await sendsQuery.refetch();
|
|
if (autoCopyLink && updated.key && session.symEncKey && session.symMacKey) {
|
|
const keyPart = await buildSendShareKey(updated.key, session.symEncKey, session.symMacKey);
|
|
const shareUrl = buildPublicSendUrl(window.location.origin, updated.accessId, keyPart);
|
|
await navigator.clipboard.writeText(shareUrl);
|
|
}
|
|
pushToast('success', t('txt_send_updated'));
|
|
} catch (error) {
|
|
pushToast('error', error instanceof Error ? error.message : t('txt_update_send_failed'));
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function deleteSendItem(send: Send) {
|
|
try {
|
|
await deleteSend(authedFetch, send.id);
|
|
await sendsQuery.refetch();
|
|
pushToast('success', t('txt_send_deleted'));
|
|
} catch (error) {
|
|
pushToast('error', error instanceof Error ? error.message : t('txt_delete_send_failed'));
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function bulkDeleteSendItems(ids: string[]) {
|
|
try {
|
|
await bulkDeleteSends(authedFetch, ids);
|
|
await sendsQuery.refetch();
|
|
pushToast('success', t('txt_deleted_selected_sends'));
|
|
} catch (error) {
|
|
pushToast('error', error instanceof Error ? error.message : t('txt_bulk_delete_sends_failed'));
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function verifyMasterPasswordAction(email: string, password: string) {
|
|
const derived = await deriveLoginHash(email, password, defaultKdfIterations);
|
|
await verifyMasterPassword(authedFetch, derived.hash);
|
|
}
|
|
|
|
async function createFolderAction(name: string) {
|
|
const folderName = name.trim();
|
|
if (!folderName) {
|
|
pushToast('error', t('txt_folder_name_is_required'));
|
|
return;
|
|
}
|
|
try {
|
|
if (!session) throw new Error(t('txt_vault_key_unavailable'));
|
|
await createFolder(authedFetch, session, folderName);
|
|
await foldersQuery.refetch();
|
|
pushToast('success', t('txt_folder_created'));
|
|
} catch (error) {
|
|
pushToast('error', error instanceof Error ? error.message : t('txt_create_folder_failed'));
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function deleteFolderAction(folderId: string) {
|
|
const id = String(folderId || '').trim();
|
|
if (!id) {
|
|
pushToast('error', t('txt_folder_not_found'));
|
|
return;
|
|
}
|
|
try {
|
|
await deleteFolder(authedFetch, id);
|
|
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
|
|
pushToast('success', t('txt_folder_deleted'));
|
|
} catch (error) {
|
|
pushToast('error', error instanceof Error ? error.message : t('txt_delete_folder_failed'));
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function bulkDeleteFoldersAction(ids: string[]) {
|
|
const folderIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
|
if (!folderIds.length) return;
|
|
try {
|
|
await bulkDeleteFolders(authedFetch, folderIds);
|
|
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
|
|
pushToast('success', t('txt_folders_deleted'));
|
|
} catch (error) {
|
|
pushToast('error', error instanceof Error ? error.message : t('txt_delete_all_folders_failed'));
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function uploadImportedAttachments(
|
|
attachments: ImportAttachmentFile[],
|
|
idMaps: { byIndex: Map<number, string>; bySourceId: Map<string, string> }
|
|
): Promise<{ total: number; imported: number; failed: Array<{ fileName: string; reason: string }> }> {
|
|
if (!attachments.length) {
|
|
return { total: 0, imported: 0, failed: [] };
|
|
}
|
|
if (!session?.symEncKey || !session?.symMacKey) throw new Error(t('txt_vault_key_unavailable'));
|
|
|
|
const initialCiphers = (await ciphersQuery.refetch()).data || [];
|
|
const cipherById = new Map(initialCiphers.map((cipher) => [String(cipher.id || ''), cipher]));
|
|
const failed: Array<{ fileName: string; reason: string }> = [];
|
|
let imported = 0;
|
|
|
|
for (const attachment of attachments) {
|
|
const sourceId = String(attachment.sourceCipherId || '').trim();
|
|
const sourceIndex = Number(attachment.sourceCipherIndex);
|
|
const byId = sourceId ? idMaps.bySourceId.get(sourceId) : null;
|
|
const byIndex = Number.isFinite(sourceIndex) ? idMaps.byIndex.get(sourceIndex) : null;
|
|
const targetCipherId = byId || byIndex || null;
|
|
if (!targetCipherId) {
|
|
failed.push({
|
|
fileName: String(attachment.fileName || '').trim() || 'attachment.bin',
|
|
reason: t('txt_import_attachment_target_not_found'),
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const name = String(attachment.fileName || '').trim() || 'attachment.bin';
|
|
const fileBytes = Uint8Array.from(attachment.bytes);
|
|
const file = new File([fileBytes], name, { type: 'application/octet-stream' });
|
|
const cipher = cipherById.get(targetCipherId) || null;
|
|
try {
|
|
await uploadCipherAttachment(importAuthedFetch, session, targetCipherId, file, cipher);
|
|
imported += 1;
|
|
} catch (error) {
|
|
failed.push({
|
|
fileName: name,
|
|
reason: error instanceof Error ? error.message : t('txt_upload_attachment_failed'),
|
|
});
|
|
}
|
|
}
|
|
|
|
await ciphersQuery.refetch();
|
|
return {
|
|
total: attachments.length,
|
|
imported,
|
|
failed,
|
|
};
|
|
}
|
|
|
|
function toImportedCipherMapsFromResponse(
|
|
cipherMap: ImportedCipherMapEntry[] | null
|
|
): { byIndex: Map<number, string>; bySourceId: Map<string, string> } {
|
|
const byIndex = new Map<number, string>();
|
|
const bySourceId = new Map<string, string>();
|
|
for (const row of cipherMap || []) {
|
|
const idx = Number(row?.index);
|
|
const id = String(row?.id || '').trim();
|
|
if (!Number.isFinite(idx) || !id) continue;
|
|
byIndex.set(idx, id);
|
|
const sourceId = String(row?.sourceId || '').trim();
|
|
if (sourceId) bySourceId.set(sourceId, id);
|
|
}
|
|
return { byIndex, bySourceId };
|
|
}
|
|
|
|
async function handleImportAction(
|
|
payload: CiphersImportPayload,
|
|
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null },
|
|
attachments: ImportAttachmentFile[] = []
|
|
): Promise<ImportResultSummary> {
|
|
if (!session?.symEncKey || !session?.symMacKey) throw new Error(t('txt_vault_key_unavailable'));
|
|
|
|
const mode = options.folderMode || 'original';
|
|
const targetFolderId = (options.targetFolderId || '').trim() || null;
|
|
const nextPayload: CiphersImportPayload = {
|
|
ciphers: [],
|
|
folders: [],
|
|
folderRelationships: [],
|
|
};
|
|
if (mode === 'original') {
|
|
const folderIndexByLegacyId = new Map<string, number>();
|
|
const folderIndexByName = new Map<string, number>();
|
|
for (let i = 0; i < payload.folders.length; i++) {
|
|
const folderRaw = (payload.folders[i] || {}) as Record<string, unknown>;
|
|
const name = String(folderRaw.name || '').trim();
|
|
if (!name) continue;
|
|
let folderIndex = folderIndexByName.get(name);
|
|
if (folderIndex == null) {
|
|
folderIndex = nextPayload.folders.length;
|
|
nextPayload.folders.push({ name: await encryptFolderImportName(session, name) });
|
|
folderIndexByName.set(name, folderIndex);
|
|
}
|
|
const legacyId = String(folderRaw.id || '').trim();
|
|
if (legacyId) folderIndexByLegacyId.set(legacyId, folderIndex);
|
|
}
|
|
for (let i = 0; i < payload.ciphers.length; i++) {
|
|
const raw = (payload.ciphers[i] || {}) as Record<string, unknown>;
|
|
let folderIndex: number | undefined;
|
|
for (const relation of payload.folderRelationships || []) {
|
|
const cipherIndex = Number(relation?.key);
|
|
const relFolderIndex = Number(relation?.value);
|
|
if (cipherIndex !== i || !Number.isFinite(relFolderIndex)) continue;
|
|
const importedFolder = payload.folders[relFolderIndex] as Record<string, unknown> | undefined;
|
|
const importedName = String(importedFolder?.name || '').trim();
|
|
if (importedName) folderIndex = folderIndexByName.get(importedName);
|
|
if (folderIndex != null) break;
|
|
}
|
|
if (folderIndex == null) {
|
|
const rawFolderId = String(raw.folderId || '').trim();
|
|
if (rawFolderId) folderIndex = folderIndexByLegacyId.get(rawFolderId);
|
|
}
|
|
if (folderIndex == null) {
|
|
const rawFolderName = String(raw.folder || '').trim();
|
|
if (rawFolderName) folderIndex = folderIndexByName.get(rawFolderName);
|
|
}
|
|
if (folderIndex != null) {
|
|
nextPayload.folderRelationships.push({ key: i, value: folderIndex });
|
|
}
|
|
}
|
|
}
|
|
for (let i = 0; i < payload.ciphers.length; i++) {
|
|
const raw = (payload.ciphers[i] || {}) as Record<string, unknown>;
|
|
const draft = importCipherToDraft(raw, mode === 'target' ? targetFolderId : null);
|
|
nextPayload.ciphers.push(await buildCipherImportPayload(session, draft));
|
|
}
|
|
const importedCipherMap = await importCiphers(importAuthedFetch, nextPayload, {
|
|
returnCipherMap: attachments.length > 0,
|
|
});
|
|
await Promise.all([foldersQuery.refetch(), ciphersQuery.refetch()]);
|
|
const attachmentSummary = attachments.length
|
|
? await uploadImportedAttachments(attachments, toImportedCipherMapsFromResponse(importedCipherMap))
|
|
: undefined;
|
|
return summarizeImportResult(payload.ciphers, mode === 'original' ? nextPayload.folders.length : 0, attachmentSummary);
|
|
}
|
|
|
|
async function handleImportEncryptedRawAction(
|
|
payload: CiphersImportPayload,
|
|
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null },
|
|
attachments: ImportAttachmentFile[] = []
|
|
): Promise<ImportResultSummary> {
|
|
const mode = options.folderMode || 'original';
|
|
const targetFolderId = (options.targetFolderId || '').trim() || null;
|
|
const nextPayload: CiphersImportPayload = {
|
|
ciphers: payload.ciphers.map((raw) => ({ ...(raw as Record<string, unknown>) })),
|
|
folders: mode === 'original' ? payload.folders : [],
|
|
folderRelationships: mode === 'original' ? payload.folderRelationships : [],
|
|
};
|
|
if (mode === 'none') {
|
|
for (const raw of nextPayload.ciphers) (raw as Record<string, unknown>).folderId = null;
|
|
} else if (mode === 'target' && targetFolderId) {
|
|
for (const raw of nextPayload.ciphers) (raw as Record<string, unknown>).folderId = targetFolderId;
|
|
}
|
|
|
|
const importedCipherMap = await importCiphers(importAuthedFetch, nextPayload, {
|
|
returnCipherMap: attachments.length > 0,
|
|
});
|
|
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
|
|
const attachmentSummary = attachments.length
|
|
? await uploadImportedAttachments(attachments, toImportedCipherMapsFromResponse(importedCipherMap))
|
|
: undefined;
|
|
return summarizeImportResult(
|
|
nextPayload.ciphers,
|
|
mode === 'original' ? nextPayload.folders.length : 0,
|
|
attachmentSummary
|
|
);
|
|
}
|
|
|
|
async function handleExportAction(request: ExportRequest) {
|
|
if (!session?.symEncKey || !session?.symMacKey) throw new Error(t('txt_vault_key_unavailable'));
|
|
const masterPassword = String(request.masterPassword || '').trim();
|
|
if (!masterPassword) throw new Error(t('txt_master_password_is_required'));
|
|
const email = String(profile?.email || session.email || '').trim().toLowerCase();
|
|
if (!email) throw new Error(t('txt_profile_unavailable'));
|
|
const verifyDerived = await deriveLoginHash(email, masterPassword, defaultKdfIterations);
|
|
await verifyMasterPassword(authedFetch, verifyDerived.hash);
|
|
|
|
const rawFolders = foldersQuery.data || [];
|
|
const rawCiphers = ciphersQuery.data || [];
|
|
if (!rawFolders || !rawCiphers) throw new Error(t('txt_vault_not_ready'));
|
|
|
|
let plainJsonCache: string | null = null;
|
|
let plainJsonDocCache: Record<string, unknown> | null = null;
|
|
let encryptedJsonCache: string | null = null;
|
|
let nodeWardenAttachmentsCache: ReturnType<typeof buildNodeWardenAttachmentRecords> | null = null;
|
|
const getPlainJson = async () => {
|
|
if (!plainJsonCache) {
|
|
plainJsonCache = await buildPlainBitwardenJsonString({
|
|
folders: rawFolders,
|
|
ciphers: rawCiphers,
|
|
userEncB64: session.symEncKey!,
|
|
userMacB64: session.symMacKey!,
|
|
});
|
|
}
|
|
return plainJsonCache;
|
|
};
|
|
const getPlainJsonDoc = async () => {
|
|
if (!plainJsonDocCache) {
|
|
plainJsonDocCache = JSON.parse(await getPlainJson()) as Record<string, unknown>;
|
|
}
|
|
return plainJsonDocCache;
|
|
};
|
|
const getEncryptedJson = async () => {
|
|
if (!encryptedJsonCache) {
|
|
encryptedJsonCache = await buildAccountEncryptedBitwardenJsonString({
|
|
folders: rawFolders,
|
|
ciphers: rawCiphers,
|
|
userEncB64: session.symEncKey!,
|
|
userMacB64: session.symMacKey!,
|
|
});
|
|
}
|
|
return encryptedJsonCache;
|
|
};
|
|
|
|
const zipAttachments = async (): Promise<ZipAttachmentEntry[]> => {
|
|
const userEnc = base64ToBytes(session.symEncKey!);
|
|
const userMac = base64ToBytes(session.symMacKey!);
|
|
const out: ZipAttachmentEntry[] = [];
|
|
const activeCiphers = rawCiphers.filter((cipher) => !cipher.deletedDate && !(cipher as { organizationId?: unknown }).organizationId);
|
|
|
|
for (const cipher of activeCiphers) {
|
|
const cipherId = String(cipher.id || '').trim();
|
|
if (!cipherId) continue;
|
|
const attachments = Array.isArray(cipher.attachments) ? cipher.attachments : [];
|
|
if (!attachments.length) continue;
|
|
|
|
let itemEnc = userEnc;
|
|
let itemMac = userMac;
|
|
const itemKey = String(cipher.key || '').trim();
|
|
if (itemKey && looksLikeCipherString(itemKey)) {
|
|
try {
|
|
const rawItemKey = await decryptBw(itemKey, userEnc, userMac);
|
|
if (rawItemKey.length >= 64) {
|
|
itemEnc = rawItemKey.slice(0, 32);
|
|
itemMac = rawItemKey.slice(32, 64);
|
|
}
|
|
} catch {
|
|
// fallback to user key
|
|
}
|
|
}
|
|
|
|
for (const attachment of attachments) {
|
|
const attachmentId = String(attachment?.id || '').trim();
|
|
if (!attachmentId) continue;
|
|
const info = await getAttachmentDownloadInfo(authedFetch, cipherId, attachmentId);
|
|
const fileResp = await fetch(info.url, { cache: 'no-store' });
|
|
if (!fileResp.ok) throw new Error(`Failed to download attachment ${attachmentId}`);
|
|
const encryptedBytes = new Uint8Array(await fileResp.arrayBuffer());
|
|
|
|
let fileEnc = itemEnc;
|
|
let fileMac = itemMac;
|
|
const attachmentKeyCipher = String(info.key || attachment?.key || '').trim();
|
|
if (attachmentKeyCipher && looksLikeCipherString(attachmentKeyCipher)) {
|
|
try {
|
|
const rawAttachmentKey = await decryptBw(attachmentKeyCipher, itemEnc, itemMac);
|
|
if (rawAttachmentKey.length >= 64) {
|
|
fileEnc = rawAttachmentKey.slice(0, 32);
|
|
fileMac = rawAttachmentKey.slice(32, 64);
|
|
}
|
|
} catch {
|
|
// fallback to item key
|
|
}
|
|
}
|
|
|
|
const plainBytes = await decryptBwFileData(encryptedBytes, fileEnc, fileMac);
|
|
|
|
const fileNameRaw = String(info.fileName || attachment?.fileName || '').trim();
|
|
let fileName = fileNameRaw || `attachment-${attachmentId}`;
|
|
if (fileNameRaw && looksLikeCipherString(fileNameRaw)) {
|
|
try {
|
|
fileName = (await decryptStr(fileNameRaw, itemEnc, itemMac)) || fileName;
|
|
} catch {
|
|
// fallback to raw encrypted name
|
|
}
|
|
}
|
|
|
|
out.push({
|
|
cipherId,
|
|
fileName,
|
|
bytes: plainBytes,
|
|
});
|
|
}
|
|
}
|
|
return out;
|
|
};
|
|
|
|
const getNodeWardenAttachmentRecords = async () => {
|
|
if (nodeWardenAttachmentsCache) return nodeWardenAttachmentsCache;
|
|
const [doc, attachments] = await Promise.all([getPlainJsonDoc(), zipAttachments()]);
|
|
const cipherIndexById = new Map<string, number>();
|
|
const items = Array.isArray(doc.items) ? (doc.items as Array<Record<string, unknown>>) : [];
|
|
for (let i = 0; i < items.length; i++) {
|
|
const id = String(items[i]?.id || '').trim();
|
|
if (id) cipherIndexById.set(id, i);
|
|
}
|
|
nodeWardenAttachmentsCache = buildNodeWardenAttachmentRecords(attachments, cipherIndexById);
|
|
return nodeWardenAttachmentsCache;
|
|
};
|
|
|
|
const format = request.format;
|
|
if (format === 'bitwarden_json') {
|
|
const bytes = new TextEncoder().encode(await getPlainJson());
|
|
return {
|
|
fileName: buildExportFileName(format),
|
|
mimeType: 'application/json',
|
|
bytes,
|
|
};
|
|
}
|
|
|
|
if (format === 'bitwarden_encrypted_json') {
|
|
if (request.encryptedJsonMode === 'password') {
|
|
const plainJson = await getPlainJson();
|
|
const kdf = await getPreloginKdfConfig(profile?.email || session.email, defaultKdfIterations);
|
|
const encrypted = await buildPasswordProtectedBitwardenJsonString({
|
|
plaintextJson: plainJson,
|
|
password: String(request.filePassword || ''),
|
|
kdf,
|
|
});
|
|
return {
|
|
fileName: buildExportFileName(format),
|
|
mimeType: 'application/json',
|
|
bytes: new TextEncoder().encode(encrypted),
|
|
};
|
|
}
|
|
const bytes = new TextEncoder().encode(await getEncryptedJson());
|
|
return {
|
|
fileName: buildExportFileName(format),
|
|
mimeType: 'application/json',
|
|
bytes,
|
|
};
|
|
}
|
|
|
|
if (format === 'nodewarden_json') {
|
|
const [plainDoc, attachments] = await Promise.all([getPlainJsonDoc(), getNodeWardenAttachmentRecords()]);
|
|
const nodeWardenDoc = buildNodeWardenPlainJsonDocument(plainDoc, attachments);
|
|
return {
|
|
fileName: buildExportFileName(format),
|
|
mimeType: 'application/json',
|
|
bytes: new TextEncoder().encode(JSON.stringify(nodeWardenDoc, null, 2)),
|
|
};
|
|
}
|
|
|
|
if (format === 'nodewarden_encrypted_json') {
|
|
if (request.encryptedJsonMode === 'password') {
|
|
const [plainDoc, attachments] = await Promise.all([getPlainJsonDoc(), getNodeWardenAttachmentRecords()]);
|
|
const nodeWardenDoc = buildNodeWardenPlainJsonDocument(plainDoc, attachments);
|
|
const kdf = await getPreloginKdfConfig(profile?.email || session.email, defaultKdfIterations);
|
|
const encrypted = await buildPasswordProtectedBitwardenJsonString({
|
|
plaintextJson: JSON.stringify(nodeWardenDoc, null, 2),
|
|
password: String(request.filePassword || ''),
|
|
kdf,
|
|
});
|
|
return {
|
|
fileName: buildExportFileName(format),
|
|
mimeType: 'application/json',
|
|
bytes: new TextEncoder().encode(encrypted),
|
|
};
|
|
}
|
|
|
|
const [encryptedJson, attachments] = await Promise.all([getEncryptedJson(), getNodeWardenAttachmentRecords()]);
|
|
const withAttachments = await attachNodeWardenEncryptedAttachmentPayload(
|
|
encryptedJson,
|
|
attachments,
|
|
session.symEncKey!,
|
|
session.symMacKey!
|
|
);
|
|
return {
|
|
fileName: buildExportFileName(format),
|
|
mimeType: 'application/json',
|
|
bytes: new TextEncoder().encode(withAttachments),
|
|
};
|
|
}
|
|
|
|
if (format === 'bitwarden_json_zip' || format === 'bitwarden_encrypted_json_zip') {
|
|
let dataJson = await getPlainJson();
|
|
if (format === 'bitwarden_encrypted_json_zip') {
|
|
if (request.encryptedJsonMode === 'password') {
|
|
const kdf = await getPreloginKdfConfig(profile?.email || session.email, defaultKdfIterations);
|
|
dataJson = await buildPasswordProtectedBitwardenJsonString({
|
|
plaintextJson: await getPlainJson(),
|
|
password: String(request.filePassword || ''),
|
|
kdf,
|
|
});
|
|
} else {
|
|
dataJson = await getEncryptedJson();
|
|
}
|
|
}
|
|
const attachments = await zipAttachments();
|
|
const zipBytes = buildBitwardenZipBytes(dataJson, attachments);
|
|
const encryptedZip = await encryptZipBytesWithPassword(zipBytes, String(request.zipPassword || ''));
|
|
return {
|
|
fileName: buildExportFileName(format, encryptedZip.encrypted),
|
|
mimeType: 'application/zip',
|
|
bytes: encryptedZip.bytes,
|
|
};
|
|
}
|
|
|
|
throw new Error(t('txt_unsupported_export_format'));
|
|
}
|
|
|
|
function downloadBytesAsFile(bytes: Uint8Array, fileName: string, mimeType: string) {
|
|
const payload = bytes.slice();
|
|
const blob = new Blob([payload], { type: mimeType || 'application/octet-stream' });
|
|
const objectUrl = URL.createObjectURL(blob);
|
|
const anchor = document.createElement('a');
|
|
anchor.href = objectUrl;
|
|
anchor.download = fileName || 'download.bin';
|
|
document.body.appendChild(anchor);
|
|
anchor.click();
|
|
anchor.remove();
|
|
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
|
|
}
|
|
|
|
async function handleBackupExportAction() {
|
|
const payload = await exportAdminBackup(authedFetch);
|
|
downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType);
|
|
}
|
|
|
|
async function handleBackupImportAction(file: File, replaceExisting: boolean = false) {
|
|
await importAdminBackup(authedFetch, file, replaceExisting);
|
|
window.setTimeout(() => {
|
|
logoutNow();
|
|
}, 200);
|
|
}
|
|
|
|
const hashPathRaw = typeof window !== 'undefined' ? window.location.hash || '' : '';
|
|
const hashPath = hashPathRaw.startsWith('#') ? hashPathRaw.slice(1) : hashPathRaw;
|
|
const hashPathOnly = String(hashPath || '').split('?')[0].split('#')[0];
|
|
const normalizedHashPath = `/${hashPathOnly.replace(/^\/+/, '').replace(/\/+$/, '')}`.replace(/^\/$/, '/');
|
|
const isImportHashRoute = IMPORT_ROUTE_ALIASES.has(normalizedHashPath);
|
|
const effectiveLocation = hashPath.startsWith('/send/') || hashPath === '/recover-2fa' ? hashPath : location;
|
|
const publicSendMatch = effectiveLocation.match(/^\/send\/([^/]+)(?:\/([^/]+))?\/?$/i);
|
|
const isRecoverTwoFactorRoute = effectiveLocation === '/recover-2fa';
|
|
const isPublicSendRoute = !!publicSendMatch;
|
|
const isImportRoute = location === IMPORT_ROUTE || IMPORT_ROUTE_ALIASES.has(location);
|
|
const showSidebarToggle = mobileLayout && (location === '/vault' || location === '/sends');
|
|
const sidebarToggleTitle = location === '/vault' ? t('txt_folders') : t('txt_type');
|
|
const mobilePrimaryRoute =
|
|
location === '/sends'
|
|
? '/sends'
|
|
: location === '/vault/totp'
|
|
? '/vault/totp'
|
|
: location === '/vault'
|
|
? '/vault'
|
|
: '/settings';
|
|
const currentPageTitle = (() => {
|
|
if (location === '/vault/totp') return t('txt_verification_code');
|
|
if (location === '/sends') return t('nav_sends');
|
|
if (location === '/admin') return t('nav_admin_panel');
|
|
if (location === '/security/devices') return t('nav_device_management');
|
|
if (location === '/help') return t('nav_backup_strategy');
|
|
if (isImportRoute) return t('nav_import_export');
|
|
if (location === SETTINGS_ACCOUNT_ROUTE) return t('nav_account_settings');
|
|
if (location === SETTINGS_HOME_ROUTE) return t('txt_settings');
|
|
return t('nav_my_vault');
|
|
})();
|
|
|
|
useEffect(() => {
|
|
if (phase === 'app' && location === '/' && !isPublicSendRoute) navigate('/vault');
|
|
}, [phase, location, isPublicSendRoute, navigate]);
|
|
|
|
useEffect(() => {
|
|
if (phase === 'app' && isImportHashRoute && location !== IMPORT_ROUTE) {
|
|
navigate(IMPORT_ROUTE);
|
|
}
|
|
}, [phase, isImportHashRoute, location, navigate]);
|
|
|
|
useEffect(() => {
|
|
if (phase === 'app' && profile?.role !== 'admin' && location === '/help') {
|
|
navigate('/vault');
|
|
}
|
|
}, [phase, profile?.role, location, navigate]);
|
|
|
|
useEffect(() => {
|
|
if (phase === 'app' && !mobileLayout && location === SETTINGS_HOME_ROUTE) {
|
|
navigate(SETTINGS_ACCOUNT_ROUTE);
|
|
}
|
|
}, [phase, mobileLayout, location, navigate]);
|
|
|
|
if (jwtWarning) {
|
|
return <JwtWarningPage reason={jwtWarning.reason} minLength={jwtWarning.minLength} />;
|
|
}
|
|
|
|
if (publicSendMatch) {
|
|
return (
|
|
<>
|
|
<PublicSendPage accessId={decodeURIComponent(publicSendMatch[1])} keyPart={publicSendMatch[2] ? decodeURIComponent(publicSendMatch[2]) : null} />
|
|
<ToastHost toasts={toasts} onClose={(id) => setToasts((prev) => prev.filter((x) => x.id !== id))} />
|
|
</>
|
|
);
|
|
}
|
|
|
|
if (isRecoverTwoFactorRoute && phase !== 'app') {
|
|
return (
|
|
<>
|
|
<RecoverTwoFactorPage
|
|
values={recoverValues}
|
|
onChange={setRecoverValues}
|
|
onSubmit={() => void handleRecoverTwoFactorSubmit()}
|
|
onCancel={() => {
|
|
setRecoverValues({ email: '', password: '', recoveryCode: '' });
|
|
navigate('/login');
|
|
}}
|
|
/>
|
|
<ToastHost toasts={toasts} onClose={(id) => setToasts((prev) => prev.filter((x) => x.id !== id))} />
|
|
</>
|
|
);
|
|
}
|
|
|
|
if (phase === 'loading') {
|
|
return (
|
|
<>
|
|
<div className="loading-screen">{t('txt_loading_nodewarden')}</div>
|
|
<ToastHost toasts={toasts} onClose={(id) => setToasts((prev) => prev.filter((x) => x.id !== id))} />
|
|
</>
|
|
);
|
|
}
|
|
|
|
if (phase === 'register' || phase === 'login' || phase === 'locked') {
|
|
return (
|
|
<>
|
|
<AuthViews
|
|
mode={phase}
|
|
loginValues={loginValues}
|
|
registerValues={registerValues}
|
|
unlockPassword={unlockPassword}
|
|
emailForLock={profile?.email || session?.email || ''}
|
|
onChangeLogin={setLoginValues}
|
|
onChangeRegister={setRegisterValues}
|
|
onChangeUnlock={setUnlockPassword}
|
|
onSubmitLogin={() => void handleLogin()}
|
|
onSubmitRegister={() => void handleRegister()}
|
|
onSubmitUnlock={() => void handleUnlock()}
|
|
onGotoLogin={() => {
|
|
setPhase('login');
|
|
navigate('/login');
|
|
}}
|
|
onGotoRegister={() => {
|
|
if (inviteCodeFromUrl) {
|
|
setRegisterValues((prev) => ({ ...prev, inviteCode: inviteCodeFromUrl }));
|
|
}
|
|
setPhase('register');
|
|
navigate('/register');
|
|
}}
|
|
onLogout={logoutNow}
|
|
/>
|
|
<ToastHost toasts={toasts} onClose={(id) => setToasts((prev) => prev.filter((x) => x.id !== id))} />
|
|
|
|
<ConfirmDialog
|
|
open={!!pendingTotp}
|
|
title={t('txt_two_step_verification')}
|
|
message={t('txt_password_is_already_verified')}
|
|
confirmText={t('txt_verify')}
|
|
cancelText={t('txt_cancel')}
|
|
showIcon={false}
|
|
onConfirm={() => void handleTotpVerify()}
|
|
onCancel={() => {
|
|
setPendingTotp(null);
|
|
setTotpCode('');
|
|
setRememberDevice(true);
|
|
}}
|
|
afterActions={(
|
|
<div className="dialog-extra">
|
|
<div className="dialog-divider" />
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary dialog-btn"
|
|
onClick={() => {
|
|
setPendingTotp(null);
|
|
setTotpCode('');
|
|
setRememberDevice(true);
|
|
navigate('/recover-2fa');
|
|
}}
|
|
>
|
|
{t('txt_use_recovery_code')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
>
|
|
<label className="field">
|
|
<span>{t('txt_totp_code')}</span>
|
|
<input className="input" value={totpCode} onInput={(e) => setTotpCode((e.currentTarget as HTMLInputElement).value)} />
|
|
</label>
|
|
<label className="check-line" style={{ marginBottom: 0 }}>
|
|
<input type="checkbox" checked={rememberDevice} onChange={(e) => setRememberDevice((e.currentTarget as HTMLInputElement).checked)} />
|
|
<span>{t('txt_trust_this_device_for_30_days')}</span>
|
|
</label>
|
|
</ConfirmDialog>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className="app-page">
|
|
<div className="app-shell">
|
|
<header className="topbar">
|
|
<div className="brand">
|
|
<img src="/logo-64.png" alt="NodeWarden logo" className="brand-logo" />
|
|
<span className="brand-name">NodeWarden</span>
|
|
<span className="mobile-page-title">{currentPageTitle}</span>
|
|
</div>
|
|
<div className="topbar-actions">
|
|
<div className="user-chip">
|
|
<ShieldUser size={16} />
|
|
<span>{profile?.email}</span>
|
|
</div>
|
|
<button type="button" className="btn btn-secondary small" onClick={handleLock}>
|
|
<Lock size={14} className="btn-icon" /> {t('txt_lock')}
|
|
</button>
|
|
{showSidebarToggle && (
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary small mobile-sidebar-toggle"
|
|
aria-label={sidebarToggleTitle}
|
|
title={sidebarToggleTitle}
|
|
onClick={() => window.dispatchEvent(new CustomEvent('nodewarden:toggle-sidebar'))}
|
|
>
|
|
<FolderIcon size={16} className="btn-icon" />
|
|
</button>
|
|
)}
|
|
<button type="button" className="btn btn-secondary small mobile-lock-btn" aria-label={t('txt_lock')} title={t('txt_lock')} onClick={handleLock}>
|
|
<Lock size={14} className="btn-icon" />
|
|
</button>
|
|
<button type="button" className="btn btn-secondary small" onClick={handleLogout}>
|
|
<LogOut size={14} className="btn-icon" /> {t('txt_sign_out')}
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<div className="app-main">
|
|
<aside className="app-side">
|
|
<Link href="/vault" className={`side-link ${location === '/vault' ? 'active' : ''}`}>
|
|
<KeyRound size={16} />
|
|
<span>{t('nav_my_vault')}</span>
|
|
</Link>
|
|
<Link href="/vault/totp" className={`side-link ${location === '/vault/totp' ? 'active' : ''}`}>
|
|
<Clock3 size={16} />
|
|
<span>{t('txt_verification_code')}</span>
|
|
</Link>
|
|
<Link href="/sends" className={`side-link ${location === '/sends' ? 'active' : ''}`}>
|
|
<SendIcon size={16} />
|
|
<span>{t('nav_sends')}</span>
|
|
</Link>
|
|
{profile?.role === 'admin' && (
|
|
<Link href="/admin" className={`side-link ${location === '/admin' ? 'active' : ''}`}>
|
|
<ShieldUser size={16} />
|
|
<span>{t('nav_admin_panel')}</span>
|
|
</Link>
|
|
)}
|
|
<Link href={SETTINGS_ACCOUNT_ROUTE} className={`side-link ${location === SETTINGS_ACCOUNT_ROUTE ? 'active' : ''}`}>
|
|
<SettingsIcon size={16} />
|
|
<span>{t('nav_account_settings')}</span>
|
|
</Link>
|
|
<Link href="/security/devices" className={`side-link ${location === '/security/devices' ? 'active' : ''}`}>
|
|
<Shield size={16} />
|
|
<span>{t('nav_device_management')}</span>
|
|
</Link>
|
|
{profile?.role === 'admin' && (
|
|
<Link href="/help" className={`side-link ${location === '/help' ? 'active' : ''}`}>
|
|
<Cloud size={16} />
|
|
<span>{t('nav_backup_strategy')}</span>
|
|
</Link>
|
|
)}
|
|
<Link href={IMPORT_ROUTE} className={`side-link ${isImportRoute ? 'active' : ''}`}>
|
|
<ArrowUpDown size={14} />
|
|
<span>{t('nav_import_export')}</span>
|
|
</Link>
|
|
</aside>
|
|
<main className="content">
|
|
<Switch>
|
|
<Route path="/sends">
|
|
<SendsPage
|
|
sends={decryptedSends}
|
|
loading={sendsQuery.isFetching}
|
|
onRefresh={refreshVault}
|
|
onCreate={createSendItem}
|
|
onUpdate={updateSendItem}
|
|
onDelete={deleteSendItem}
|
|
onBulkDelete={bulkDeleteSendItems}
|
|
onNotify={pushToast}
|
|
/>
|
|
</Route>
|
|
<Route path="/vault/totp">
|
|
<TotpCodesPage ciphers={decryptedCiphers} loading={ciphersQuery.isFetching} onNotify={pushToast} />
|
|
</Route>
|
|
<Route path="/vault">
|
|
<VaultPage
|
|
ciphers={decryptedCiphers}
|
|
folders={decryptedFolders}
|
|
loading={ciphersQuery.isFetching || foldersQuery.isFetching}
|
|
emailForReprompt={profile?.email || session?.email || ''}
|
|
onRefresh={refreshVault}
|
|
onCreate={createVaultItem}
|
|
onUpdate={updateVaultItem}
|
|
onDelete={deleteVaultItem}
|
|
onBulkDelete={bulkDeleteVaultItems}
|
|
onBulkMove={bulkMoveVaultItems}
|
|
onVerifyMasterPassword={verifyMasterPasswordAction}
|
|
onNotify={pushToast}
|
|
onCreateFolder={createFolderAction}
|
|
onDeleteFolder={deleteFolderAction}
|
|
onBulkDeleteFolders={bulkDeleteFoldersAction}
|
|
onDownloadAttachment={downloadVaultAttachment}
|
|
/>
|
|
</Route>
|
|
<Route path={SETTINGS_ACCOUNT_ROUTE}>
|
|
{profile && (
|
|
<div className="stack">
|
|
{mobileLayout && (
|
|
<div className="mobile-settings-subhead">
|
|
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => navigate(SETTINGS_HOME_ROUTE)}>
|
|
<span className="btn-icon" aria-hidden="true">{"<"}</span>
|
|
{t('txt_back')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
<SettingsPage
|
|
profile={profile}
|
|
totpEnabled={!!totpStatusQuery.data?.enabled}
|
|
onChangePassword={changePasswordAction}
|
|
onEnableTotp={async (secret, token) => {
|
|
await enableTotpAction(secret, token);
|
|
await totpStatusQuery.refetch();
|
|
}}
|
|
onOpenDisableTotp={() => setDisableTotpOpen(true)}
|
|
onGetRecoveryCode={getRecoveryCodeAction}
|
|
onNotify={pushToast}
|
|
/>
|
|
</div>
|
|
)}
|
|
</Route>
|
|
<Route path="/settings">
|
|
{profile && (
|
|
<section className="card mobile-settings-card">
|
|
<div className="mobile-settings-links">
|
|
<Link href={SETTINGS_ACCOUNT_ROUTE} className="mobile-settings-link">
|
|
<SettingsIcon size={18} />
|
|
<span>{t('nav_account_settings')}</span>
|
|
</Link>
|
|
<Link href="/security/devices" className="mobile-settings-link">
|
|
<Shield size={18} />
|
|
<span>{t('nav_device_management')}</span>
|
|
</Link>
|
|
<Link href={IMPORT_ROUTE} className="mobile-settings-link">
|
|
<ArrowUpDown size={18} />
|
|
<span>{t('nav_import_export')}</span>
|
|
</Link>
|
|
{profile.role === 'admin' && (
|
|
<Link href="/admin" className="mobile-settings-link">
|
|
<ShieldUser size={18} />
|
|
<span>{t('nav_admin_panel')}</span>
|
|
</Link>
|
|
)}
|
|
{profile.role === 'admin' && (
|
|
<Link href="/help" className="mobile-settings-link">
|
|
<Cloud size={18} />
|
|
<span>{t('nav_backup_strategy')}</span>
|
|
</Link>
|
|
)}
|
|
</div>
|
|
<button type="button" className="btn btn-secondary mobile-settings-logout" onClick={handleLogout}>
|
|
<LogOut size={14} className="btn-icon" />
|
|
{t('txt_sign_out')}
|
|
</button>
|
|
</section>
|
|
)}
|
|
</Route>
|
|
<Route path="/security/devices">
|
|
<div className="stack">
|
|
{mobileLayout && (
|
|
<div className="mobile-settings-subhead">
|
|
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => navigate(SETTINGS_HOME_ROUTE)}>
|
|
<span className="btn-icon" aria-hidden="true">{"<"}</span>
|
|
{t('txt_back')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
<SecurityDevicesPage
|
|
devices={authorizedDevicesQuery.data || []}
|
|
loading={authorizedDevicesQuery.isFetching}
|
|
onRefresh={() => void refreshAuthorizedDevices()}
|
|
onRevokeTrust={(device) => {
|
|
setConfirm({
|
|
title: t('txt_revoke_device_authorization'),
|
|
message: t('txt_revoke_30_day_totp_trust_for_name', { name: device.name }),
|
|
danger: true,
|
|
onConfirm: () => {
|
|
setConfirm(null);
|
|
void revokeDeviceTrustAction(device);
|
|
},
|
|
});
|
|
}}
|
|
onRemoveDevice={(device) => {
|
|
setConfirm({
|
|
title: t('txt_remove_device'),
|
|
message: t('txt_remove_device_and_sign_out_name', { name: device.name }),
|
|
danger: true,
|
|
onConfirm: () => {
|
|
setConfirm(null);
|
|
void removeDeviceAction(device);
|
|
},
|
|
});
|
|
}}
|
|
onRevokeAll={() => {
|
|
setConfirm({
|
|
title: t('txt_revoke_all_trusted_devices'),
|
|
message: t('txt_revoke_30_day_totp_trust_from_all_devices'),
|
|
danger: true,
|
|
onConfirm: () => {
|
|
setConfirm(null);
|
|
void revokeAllDeviceTrustAction();
|
|
},
|
|
});
|
|
}}
|
|
onRemoveAll={() => {
|
|
setConfirm({
|
|
title: t('txt_remove_all_devices'),
|
|
message: t('txt_remove_all_devices_and_sign_out_all_sessions'),
|
|
danger: true,
|
|
onConfirm: () => {
|
|
setConfirm(null);
|
|
void removeAllDevicesAction();
|
|
},
|
|
});
|
|
}}
|
|
/>
|
|
</div>
|
|
</Route>
|
|
<Route path="/admin">
|
|
<div className="stack">
|
|
{mobileLayout && (
|
|
<div className="mobile-settings-subhead">
|
|
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => navigate(SETTINGS_HOME_ROUTE)}>
|
|
<span className="btn-icon" aria-hidden="true">{"<"}</span>
|
|
{t('txt_back')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
<AdminPage
|
|
currentUserId={profile?.id || ''}
|
|
users={usersQuery.data || []}
|
|
invites={invitesQuery.data || []}
|
|
onRefresh={() => {
|
|
void usersQuery.refetch();
|
|
void invitesQuery.refetch();
|
|
}}
|
|
onCreateInvite={async (hours) => {
|
|
await createInvite(authedFetch, hours);
|
|
await invitesQuery.refetch();
|
|
pushToast('success', t('txt_invite_created'));
|
|
}}
|
|
onDeleteAllInvites={async () => {
|
|
setConfirm({
|
|
title: t('txt_delete_all_invites'),
|
|
message: t('txt_delete_all_invite_codes_active_inactive'),
|
|
danger: true,
|
|
onConfirm: () => {
|
|
setConfirm(null);
|
|
void (async () => {
|
|
await deleteAllInvites(authedFetch);
|
|
await invitesQuery.refetch();
|
|
pushToast('success', t('txt_all_invites_deleted'));
|
|
})();
|
|
},
|
|
});
|
|
}}
|
|
onToggleUserStatus={async (userId, status) => {
|
|
await setUserStatus(authedFetch, userId, status === 'active' ? 'banned' : 'active');
|
|
await usersQuery.refetch();
|
|
pushToast('success', t('txt_user_status_updated'));
|
|
}}
|
|
onDeleteUser={async (userId) => {
|
|
setConfirm({
|
|
title: t('txt_delete_user'),
|
|
message: t('txt_delete_this_user_and_all_user_data'),
|
|
danger: true,
|
|
onConfirm: () => {
|
|
setConfirm(null);
|
|
void (async () => {
|
|
await deleteUser(authedFetch, userId);
|
|
await usersQuery.refetch();
|
|
pushToast('success', t('txt_user_deleted'));
|
|
})();
|
|
},
|
|
});
|
|
}}
|
|
onRevokeInvite={async (code) => {
|
|
await revokeInvite(authedFetch, code);
|
|
await invitesQuery.refetch();
|
|
pushToast('success', t('txt_invite_revoked'));
|
|
}}
|
|
/>
|
|
</div>
|
|
</Route>
|
|
<Route path={IMPORT_ROUTE}>
|
|
<div className="stack">
|
|
{mobileLayout && (
|
|
<div className="mobile-settings-subhead">
|
|
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => navigate(SETTINGS_HOME_ROUTE)}>
|
|
<span className="btn-icon" aria-hidden="true">{"<"}</span>
|
|
{t('txt_back')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
<ImportPage
|
|
onImport={handleImportAction}
|
|
onImportEncryptedRaw={handleImportEncryptedRawAction}
|
|
accountKeys={session?.symEncKey && session?.symMacKey ? { encB64: session.symEncKey, macB64: session.symMacKey } : null}
|
|
onNotify={pushToast}
|
|
folders={decryptedFolders}
|
|
onExport={handleExportAction}
|
|
/>
|
|
</div>
|
|
</Route>
|
|
<Route path="/tools/import">
|
|
<ImportPage
|
|
onImport={handleImportAction}
|
|
onImportEncryptedRaw={handleImportEncryptedRawAction}
|
|
accountKeys={session?.symEncKey && session?.symMacKey ? { encB64: session.symEncKey, macB64: session.symMacKey } : null}
|
|
onNotify={pushToast}
|
|
folders={decryptedFolders}
|
|
onExport={handleExportAction}
|
|
/>
|
|
</Route>
|
|
<Route path="/tools/import-export">
|
|
<ImportPage
|
|
onImport={handleImportAction}
|
|
onImportEncryptedRaw={handleImportEncryptedRawAction}
|
|
accountKeys={session?.symEncKey && session?.symMacKey ? { encB64: session.symEncKey, macB64: session.symMacKey } : null}
|
|
onNotify={pushToast}
|
|
folders={decryptedFolders}
|
|
onExport={handleExportAction}
|
|
/>
|
|
</Route>
|
|
<Route path="/tools/import-data">
|
|
<ImportPage
|
|
onImport={handleImportAction}
|
|
onImportEncryptedRaw={handleImportEncryptedRawAction}
|
|
accountKeys={session?.symEncKey && session?.symMacKey ? { encB64: session.symEncKey, macB64: session.symMacKey } : null}
|
|
onNotify={pushToast}
|
|
folders={decryptedFolders}
|
|
onExport={handleExportAction}
|
|
/>
|
|
</Route>
|
|
<Route path="/import">
|
|
<ImportPage
|
|
onImport={handleImportAction}
|
|
onImportEncryptedRaw={handleImportEncryptedRawAction}
|
|
accountKeys={session?.symEncKey && session?.symMacKey ? { encB64: session.symEncKey, macB64: session.symMacKey } : null}
|
|
onNotify={pushToast}
|
|
folders={decryptedFolders}
|
|
onExport={handleExportAction}
|
|
/>
|
|
</Route>
|
|
<Route path="/import-export">
|
|
<ImportPage
|
|
onImport={handleImportAction}
|
|
onImportEncryptedRaw={handleImportEncryptedRawAction}
|
|
accountKeys={session?.symEncKey && session?.symMacKey ? { encB64: session.symEncKey, macB64: session.symMacKey } : null}
|
|
onNotify={pushToast}
|
|
folders={decryptedFolders}
|
|
onExport={handleExportAction}
|
|
/>
|
|
</Route>
|
|
<Route path="/help">
|
|
{profile?.role === 'admin' ? (
|
|
<div className="stack">
|
|
{mobileLayout && (
|
|
<div className="mobile-settings-subhead">
|
|
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => navigate(SETTINGS_HOME_ROUTE)}>
|
|
<span className="btn-icon" aria-hidden="true">{"<"}</span>
|
|
{t('txt_back')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
<HelpPage onExport={handleBackupExportAction} onImport={handleBackupImportAction} onNotify={pushToast} />
|
|
</div>
|
|
) : null}
|
|
</Route>
|
|
</Switch>
|
|
</main>
|
|
</div>
|
|
|
|
<nav className="mobile-tabbar" aria-label={t('txt_menu')}>
|
|
<Link href="/vault" className={`mobile-tab ${mobilePrimaryRoute === '/vault' ? 'active' : ''}`}>
|
|
<KeyRound size={18} />
|
|
<span>{t('nav_my_vault')}</span>
|
|
</Link>
|
|
<Link href="/vault/totp" className={`mobile-tab ${mobilePrimaryRoute === '/vault/totp' ? 'active' : ''}`}>
|
|
<Clock3 size={18} />
|
|
<span>{t('txt_verification_code')}</span>
|
|
</Link>
|
|
<Link href="/sends" className={`mobile-tab ${mobilePrimaryRoute === '/sends' ? 'active' : ''}`}>
|
|
<SendIcon size={18} />
|
|
<span>{t('nav_sends')}</span>
|
|
</Link>
|
|
<Link href="/settings" className={`mobile-tab ${mobilePrimaryRoute === '/settings' ? 'active' : ''}`}>
|
|
<SettingsIcon size={18} />
|
|
<span>{t('txt_settings')}</span>
|
|
</Link>
|
|
</nav>
|
|
</div>
|
|
</div>
|
|
|
|
<ConfirmDialog
|
|
open={!!confirm}
|
|
title={confirm?.title || ''}
|
|
message={confirm?.message || ''}
|
|
danger={confirm?.danger}
|
|
showIcon={confirm?.showIcon}
|
|
onConfirm={() => confirm?.onConfirm()}
|
|
onCancel={() => setConfirm(null)}
|
|
/>
|
|
|
|
<ConfirmDialog
|
|
open={disableTotpOpen}
|
|
title={t('txt_disable_totp')}
|
|
message={t('txt_enter_master_password_to_disable_two_step_verification')}
|
|
confirmText={t('txt_disable_totp')}
|
|
cancelText={t('txt_cancel')}
|
|
danger
|
|
showIcon={false}
|
|
onConfirm={() => void disableTotpAction()}
|
|
onCancel={() => {
|
|
setDisableTotpOpen(false);
|
|
setDisableTotpPassword('');
|
|
}}
|
|
>
|
|
<label className="field">
|
|
<span>{t('txt_master_password')}</span>
|
|
<input
|
|
className="input"
|
|
type="password"
|
|
value={disableTotpPassword}
|
|
onInput={(e) => setDisableTotpPassword((e.currentTarget as HTMLInputElement).value)}
|
|
/>
|
|
</label>
|
|
</ConfirmDialog>
|
|
|
|
<ToastHost toasts={toasts} onClose={(id) => setToasts((prev) => prev.filter((x) => x.id !== id))} />
|
|
</>
|
|
);
|
|
}
|