Files
nodewarden/webapp/src/App.tsx
T

1176 lines
45 KiB
TypeScript

import { useEffect, useMemo, useState } from 'preact/hooks';
import { Link, Route, Switch, useLocation } from 'wouter';
import { useQuery } from '@tanstack/react-query';
import { CircleHelp, Lock, LogOut, Send as SendIcon, Settings as SettingsIcon, Shield, ShieldUser, Vault } 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 {
changeMasterPassword,
createFolder,
createCipher,
createAuthedFetch,
createInvite,
createSend,
deleteAllInvites,
deleteCipher,
deleteSend,
deleteUser,
deriveLoginHash,
bulkMoveCiphers,
getCiphers,
getFolders,
getProfile,
getAuthorizedDevices,
getSetupStatus,
getSends,
getTotpStatus,
getTotpRecoveryCode,
getWebConfig,
listAdminInvites,
listAdminUsers,
loadSession,
loginWithPassword,
registerAccount,
recoverTwoFactor,
revokeInvite,
revokeAuthorizedDeviceTrust,
revokeAllAuthorizedDeviceTrust,
saveSession,
setTotp,
setUserStatus,
deleteAuthorizedDevice,
updateCipher,
updateSend,
buildSendShareKey,
unlockVaultKey,
verifyMasterPassword,
} from '@/lib/api';
import { base64ToBytes, decryptBw, decryptStr, hkdf } from '@/lib/crypto';
import { t } from '@/lib/i18n';
import type { AppPhase, AuthorizedDevice, Cipher, Folder, 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';
function buildPublicSendUrl(origin: string, accessId: string, keyPart: string): string {
return `${origin}/#/send/${accessId}/${keyPart}`;
}
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 [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 [decryptedFolders, setDecryptedFolders] = useState<Folder[]>([]);
const [decryptedCiphers, setDecryptedCiphers] = useState<Cipher[]>([]);
const [decryptedSends, setDecryptedSends] = useState<Send[]>([]);
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]
);
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');
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('/login');
}
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),
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) {
nextCipher.sshKey = {
...cipher.sshKey,
decPrivateKey: await decryptField(cipher.sshKey.privateKey || '', itemEnc, itemMac),
decPublicKey: await decryptField(cipher.sshKey.publicKey || '', itemEnc, itemMac),
decFingerprint: await decryptField(cipher.sshKey.fingerprint || '', 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),
}))
);
}
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]);
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()) {
pushToast('error', t('txt_secret_and_code_are_required'));
return;
}
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'));
}
}
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 refreshAuthorizedDevices() {
await authorizedDevicesQuery.refetch();
}
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);
await authorizedDevicesQuery.refetch();
pushToast('success', t('txt_device_removed'));
}
async function createVaultItem(draft: VaultDraft) {
if (!session) return;
try {
await createCipher(authedFetch, session, draft);
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) {
if (!session) return;
try {
await updateCipher(authedFetch, session, cipher, draft);
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 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 {
for (const id of ids) {
await deleteCipher(authedFetch, id);
}
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 {
for (const id of ids) {
await deleteSend(authedFetch, id);
}
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 {
await createFolder(authedFetch, 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;
}
}
const hashPathRaw = typeof window !== 'undefined' ? window.location.hash || '' : '';
const hashPath = hashPathRaw.startsWith('#') ? hashPathRaw.slice(1) : hashPathRaw;
const effectiveLocation = hashPath.startsWith('/send/') || hashPath === '/recover-2fa' ? hashPath : location;
const publicSendMatch = effectiveLocation.match(/^\/send\/([^/]+)(?:\/([^/]+))?\/?$/i);
const isRecoverTwoFactorRoute = effectiveLocation === '/recover-2fa';
const isPublicSendRoute = !!publicSendMatch;
useEffect(() => {
if (phase === 'app' && location === '/' && !isPublicSendRoute) navigate('/vault');
}, [phase, location, isPublicSendRoute, 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')}
onGotoRegister={() => setPhase('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>NodeWarden</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>
<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' : ''}`}>
<Vault size={16} />
<span>{t('nav_my_vault')}</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" className={`side-link ${location === '/settings' ? '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>
<Link href="/help" className={`side-link ${location === '/help' ? 'active' : ''}`}>
<CircleHelp size={16} />
<span>{t('nav_support_center')}</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">
<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}
/>
</Route>
<Route path="/settings">
{profile && (
<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}
/>
)}
</Route>
<Route path="/security/devices">
<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_name_and_clear_its_2fa_trust', { 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();
},
});
}}
/>
</Route>
<Route path="/admin">
<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'));
}}
/>
</Route>
<Route path="/help">
<HelpPage />
</Route>
</Switch>
</main>
</div>
</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))} />
</>
);
}