Compare commits

3 Commits

Author SHA1 Message Date
shuaiplus 615caf5946 feat: improve offline PWA resilience 2026-06-09 14:09:46 +08:00
rootphantomer 1a10df4a18 fix: preserve cipher edit time during auto repair 2026-06-09 12:14:11 +08:00
shuaiplus d4749d3f82 feat: add PWA offline unlock support 2026-06-09 12:09:44 +08:00
29 changed files with 966 additions and 27 deletions
+2 -2
View File
@@ -7,8 +7,8 @@
"main": "src/index.ts", "main": "src/index.ts",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "npm run build && wrangler dev -c wrangler.toml", "dev": "wrangler dev -c wrangler.toml",
"dev:kv": "npm run build && wrangler dev -c wrangler.kv.toml", "dev:kv": "wrangler dev -c wrangler.kv.toml",
"dev:demo": "vite --config webapp/vite.config.ts --mode demo --host 127.0.0.1 --port 5174", "dev:demo": "vite --config webapp/vite.config.ts --mode demo --host 127.0.0.1 --port 5174",
"build": "vite build --config webapp/vite.config.ts", "build": "vite build --config webapp/vite.config.ts",
"build:demo": "vite build --config webapp/vite.config.ts --mode demo && node scripts/pages-spa-redirects.cjs", "build:demo": "vite build --config webapp/vite.config.ts --mode demo && node scripts/pages-spa-redirects.cjs",
+6 -2
View File
@@ -823,6 +823,9 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
const incomingPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']); const incomingPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']);
const incomingRevisionDate = readCipherRevisionDate(cipherData); const incomingRevisionDate = readCipherRevisionDate(cipherData);
const hasAttachmentMigrationMetadata = hasIncomingAttachmentMetadata(cipherData); const hasAttachmentMigrationMetadata = hasIncomingAttachmentMetadata(cipherData);
const preserveRevisionDate =
shouldPreserveRepairableCipherUris(request)
&& (body.preserveRevisionDate === true || cipherData.preserveRevisionDate === true);
if (incomingKey.present && !shouldAcceptCipherKey(incomingKey.value)) { if (incomingKey.present && !shouldAcceptCipherKey(incomingKey.value)) {
return errorResponse('Cipher key encryption is not supported by this server. Resync the client and try again.', 400); return errorResponse('Cipher key encryption is not supported by this server. Resync the client and try again.', 400);
@@ -840,9 +843,10 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
// Opaque passthrough: merge existing stored data with ALL incoming client fields. // Opaque passthrough: merge existing stored data with ALL incoming client fields.
// Unknown/future fields from the client are preserved; server-controlled fields are protected. // Unknown/future fields from the client are preserved; server-controlled fields are protected.
const { preserveRevisionDate: _preserveRevisionDate, PreserveRevisionDate: _pascalPreserveRevisionDate, ...cipherDataWithoutFlags } = cipherData;
const cipher: Cipher = { const cipher: Cipher = {
...existingCipher, // start with all existing stored data (including unknowns) ...existingCipher, // start with all existing stored data (including unknowns)
...cipherData, // overlay all client data (including new/unknown fields) ...cipherDataWithoutFlags, // overlay all client data (including new/unknown fields)
// Server-controlled fields (never from client) // Server-controlled fields (never from client)
id: existingCipher.id, id: existingCipher.id,
userId: existingCipher.userId, userId: existingCipher.userId,
@@ -850,7 +854,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
favorite: cipherData.favorite ?? existingCipher.favorite, favorite: cipherData.favorite ?? existingCipher.favorite,
reprompt: cipherData.reprompt ?? existingCipher.reprompt, reprompt: cipherData.reprompt ?? existingCipher.reprompt,
createdAt: existingCipher.createdAt, createdAt: existingCipher.createdAt,
updatedAt: new Date().toISOString(), updatedAt: preserveRevisionDate ? existingCipher.updatedAt : new Date().toISOString(),
archivedAt: readCipherArchivedAt(cipherData, existingCipher.archivedAt ?? null), archivedAt: readCipherArchivedAt(cipherData, existingCipher.archivedAt ?? null),
deletedAt: existingCipher.deletedAt, deletedAt: existingCipher.deletedAt,
}; };
+6
View File
@@ -18,6 +18,12 @@
<link rel="icon" type="image/svg+xml" href="/nodewarden-logo-bg.svg" /> <link rel="icon" type="image/svg+xml" href="/nodewarden-logo-bg.svg" />
<link rel="alternate icon" type="image/x-icon" href="/favicon.ico" /> <link rel="alternate icon" type="image/x-icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#0f172a" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="NodeWarden" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<title>NodeWarden</title> <title>NodeWarden</title>
<style> <style>
+48
View File
@@ -0,0 +1,48 @@
{
"name": "NodeWarden",
"short_name": "NodeWarden",
"description": "A lightweight Bitwarden-compatible vault for Cloudflare Workers.",
"id": "/",
"start_url": "/vault",
"scope": "/",
"display": "standalone",
"display_override": ["window-controls-overlay", "standalone", "minimal-ui"],
"orientation": "any",
"background_color": "#eef4ff",
"theme_color": "#0f172a",
"categories": ["security", "productivity", "utilities"],
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"shortcuts": [
{
"name": "Vault",
"short_name": "Vault",
"url": "/vault",
"icons": [{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }]
},
{
"name": "TOTP Codes",
"short_name": "TOTP",
"url": "/vault/totp",
"icons": [{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }]
}
]
}
+9 -5
View File
@@ -54,6 +54,7 @@ import { useToastManager } from '@/hooks/useToastManager';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import { APP_NOTIFY_EVENT, type AppNotifyDetail } from '@/lib/app-notify'; import { APP_NOTIFY_EVENT, type AppNotifyDetail } from '@/lib/app-notify';
import { dispatchBackupProgress, type BackupProgressDetail } from '@/lib/backup-restore-progress'; import { dispatchBackupProgress, type BackupProgressDetail } from '@/lib/backup-restore-progress';
import { clearOfflineUnlockRecord } from '@/lib/offline-auth';
import { decryptSends, decryptVaultCore } from '@/lib/vault-decrypt'; import { decryptSends, decryptVaultCore } from '@/lib/vault-decrypt';
import { decryptSendsInWorker, decryptVaultCoreInWorker } from '@/lib/vault-worker'; import { decryptSendsInWorker, decryptVaultCoreInWorker } from '@/lib/vault-worker';
import { import {
@@ -433,6 +434,7 @@ export default function App() {
(async () => { (async () => {
const boot = await bootstrapAppSession(initialBootstrap); const boot = await bootstrapAppSession(initialBootstrap);
if (!mounted) return; if (!mounted) return;
if (sessionRef.current?.symEncKey || sessionRef.current?.symMacKey) return;
setDefaultKdfIterations(boot.defaultKdfIterations); setDefaultKdfIterations(boot.defaultKdfIterations);
setRegistrationInviteRequired(boot.registrationInviteRequired); setRegistrationInviteRequired(boot.registrationInviteRequired);
setJwtWarning(boot.jwtWarning); setJwtWarning(boot.jwtWarning);
@@ -746,6 +748,7 @@ export default function App() {
setConfirm(null); setConfirm(null);
setSession(null); setSession(null);
clearProfileSnapshot(); clearProfileSnapshot();
clearOfflineUnlockRecord();
setProfile(null); setProfile(null);
setUnlockPreparing(false); setUnlockPreparing(false);
setPendingTotp(null); setPendingTotp(null);
@@ -910,7 +913,7 @@ export default function App() {
const vaultCoreQuery = useQuery({ const vaultCoreQuery = useQuery({
queryKey: ['vault-core', vaultCacheKey], queryKey: ['vault-core', vaultCacheKey],
queryFn: () => loadVaultCoreSyncSnapshot(authedFetch, vaultCacheKey), queryFn: () => loadVaultCoreSyncSnapshot(authedFetch, vaultCacheKey),
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.symEncKey && !!session?.symMacKey && !!vaultCacheKey, enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && !!session?.symEncKey && !!session?.symMacKey && !!vaultCacheKey,
staleTime: 30_000, staleTime: 30_000,
}); });
const encryptedVaultCore = vaultCoreQuery.data || cachedVaultCore; const encryptedVaultCore = vaultCoreQuery.data || cachedVaultCore;
@@ -921,7 +924,7 @@ export default function App() {
const sendsQuery = useQuery({ const sendsQuery = useQuery({
queryKey: sendsQueryKey, queryKey: sendsQueryKey,
queryFn: () => getSends(authedFetch), queryFn: () => getSends(authedFetch),
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.symEncKey && !!session?.symMacKey && location === '/sends' && !encryptedSendsFromSync, enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && !!session?.symEncKey && !!session?.symMacKey && location === '/sends' && !encryptedSendsFromSync,
staleTime: 30_000, staleTime: 30_000,
}); });
const encryptedSends = sendsQuery.data || encryptedSendsFromSync; const encryptedSends = sendsQuery.data || encryptedSendsFromSync;
@@ -950,13 +953,13 @@ export default function App() {
const usersQuery = useQuery({ const usersQuery = useQuery({
queryKey: ['admin-users', vaultCacheKey], queryKey: ['admin-users', vaultCacheKey],
queryFn: () => listAdminUsers(authedFetch), queryFn: () => listAdminUsers(authedFetch),
enabled: !IS_DEMO_MODE && phase === 'app' && isAdmin && vaultInitialDecryptDone, enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && isAdmin && vaultInitialDecryptDone,
staleTime: 30_000, staleTime: 30_000,
}); });
const invitesQuery = useQuery({ const invitesQuery = useQuery({
queryKey: ['admin-invites', vaultCacheKey], queryKey: ['admin-invites', vaultCacheKey],
queryFn: () => listAdminInvites(authedFetch), queryFn: () => listAdminInvites(authedFetch),
enabled: !IS_DEMO_MODE && phase === 'app' && isAdmin && vaultInitialDecryptDone, enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && isAdmin && vaultInitialDecryptDone,
staleTime: 30_000, staleTime: 30_000,
}); });
const totpStatusQuery = useQuery({ const totpStatusQuery = useQuery({
@@ -1013,7 +1016,7 @@ export default function App() {
useQuery({ useQuery({
queryKey: ['admin-backup-settings', vaultCacheKey], queryKey: ['admin-backup-settings', vaultCacheKey],
queryFn: () => backupActions.loadSettings(), queryFn: () => backupActions.loadSettings(),
enabled: !IS_DEMO_MODE && phase === 'app' && isAdmin && vaultInitialDecryptDone, enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && isAdmin && vaultInitialDecryptDone,
staleTime: 30_000, staleTime: 30_000,
}); });
@@ -1083,6 +1086,7 @@ export default function App() {
setDecryptedFolders(result.folders); setDecryptedFolders(result.folders);
setDecryptedCiphers(result.ciphers); setDecryptedCiphers(result.ciphers);
setVaultInitialDecryptDone(true); setVaultInitialDecryptDone(true);
if (!session.accessToken) return;
const repairKey = `${session.accessToken}:${encryptedCiphers.map((cipher) => `${cipher.id}:${cipher.revisionDate || ''}`).join(',')}`; const repairKey = `${session.accessToken}:${encryptedCiphers.map((cipher) => `${cipher.id}:${cipher.revisionDate || ''}`).join(',')}`;
if (uriChecksumRepairAttemptRef.current !== repairKey) { if (uriChecksumRepairAttemptRef.current !== repairKey) {
uriChecksumRepairAttemptRef.current = repairKey; uriChecksumRepairAttemptRef.current = repairKey;
@@ -3,6 +3,7 @@ import type { ComponentChildren } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks'; import { useEffect, useRef, useState } from 'preact/hooks';
import { Link } from 'wouter'; import { Link } from 'wouter';
import AppMainRoutes from '@/components/AppMainRoutes'; import AppMainRoutes from '@/components/AppMainRoutes';
import NetworkStatusBadge from '@/components/NetworkStatusBadge';
import ThemeSwitch from '@/components/ThemeSwitch'; import ThemeSwitch from '@/components/ThemeSwitch';
import type { AppMainRoutesProps } from '@/components/AppMainRoutes'; import type { AppMainRoutesProps } from '@/components/AppMainRoutes';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
@@ -237,6 +238,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
<span className="mobile-page-title">{props.currentPageTitle}</span> <span className="mobile-page-title">{props.currentPageTitle}</span>
</div> </div>
<div className="topbar-actions"> <div className="topbar-actions">
<NetworkStatusBadge />
<div className="user-chip"> <div className="user-chip">
<ShieldUser size={16} /> <ShieldUser size={16} />
<span>{props.profile?.email}</span> <span>{props.profile?.email}</span>
+4 -3
View File
@@ -1,5 +1,6 @@
import { useState } from 'preact/hooks'; import { useState } from 'preact/hooks';
import { ArrowLeft, Eye, EyeOff, LogIn, LogOut, Unlock, UserPlus } from 'lucide-preact'; import { ArrowLeft, Eye, EyeOff, LogIn, LogOut, Unlock, UserPlus } from 'lucide-preact';
import NetworkStatusBadge from '@/components/NetworkStatusBadge';
import StandalonePageFrame from '@/components/StandalonePageFrame'; import StandalonePageFrame from '@/components/StandalonePageFrame';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
@@ -83,7 +84,7 @@ export default function AuthViews(props: AuthViewsProps) {
if (props.mode === 'locked') { if (props.mode === 'locked') {
return ( return (
<div className="auth-page"> <div className="auth-page">
<StandalonePageFrame title={t('txt_unlock_vault')}> <StandalonePageFrame title={t('txt_unlock_vault')} titleAccessory={<NetworkStatusBadge />}>
<form <form
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
@@ -132,7 +133,7 @@ export default function AuthViews(props: AuthViewsProps) {
if (props.mode === 'register') { if (props.mode === 'register') {
return ( return (
<div className="auth-page"> <div className="auth-page">
<StandalonePageFrame title={t('txt_create_account')}> <StandalonePageFrame title={t('txt_create_account')} titleAccessory={<NetworkStatusBadge />}>
<form <form
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
@@ -216,7 +217,7 @@ export default function AuthViews(props: AuthViewsProps) {
return ( return (
<div className="auth-page"> <div className="auth-page">
<StandalonePageFrame title={t('txt_log_in')}> <StandalonePageFrame title={t('txt_log_in')} titleAccessory={<NetworkStatusBadge />}>
<form <form
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
@@ -0,0 +1,86 @@
import { Wifi, WifiOff } from 'lucide-preact';
import { useEffect, useState } from 'preact/hooks';
import { t } from '@/lib/i18n';
import {
browserReportsOffline,
getCurrentNetworkStatus,
probeNodeWardenService,
setCurrentNetworkStatus,
subscribeNetworkStatus,
type NetworkStatus,
} from '@/lib/network-status';
const STATUS_CHECK_INTERVAL_MS = 30_000;
function statusLabel(status: NetworkStatus): string {
if (status === 'online') return t('txt_online');
return t('txt_offline');
}
export default function NetworkStatusBadge() {
const [status, setStatus] = useState<NetworkStatus>(getCurrentNetworkStatus);
const label = statusLabel(status);
const Icon = status === 'online' ? Wifi : WifiOff;
useEffect(() => {
let cancelled = false;
let timer = 0;
const checkService = async () => {
if (browserReportsOffline()) {
setCurrentNetworkStatus('offline');
return;
}
const reachable = await probeNodeWardenService();
if (!cancelled) {
setCurrentNetworkStatus(reachable ? 'online' : 'offline');
}
};
const scheduleNextCheck = () => {
window.clearTimeout(timer);
timer = window.setTimeout(() => {
void checkService().finally(scheduleNextCheck);
}, STATUS_CHECK_INTERVAL_MS);
};
const handleOnline = () => {
void checkService();
};
const handleOffline = () => {
setCurrentNetworkStatus('offline');
};
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') void checkService();
};
const unsubscribe = subscribeNetworkStatus(setStatus);
void checkService().finally(scheduleNextCheck);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
window.addEventListener('focus', handleOnline);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
cancelled = true;
unsubscribe();
window.clearTimeout(timer);
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
window.removeEventListener('focus', handleOnline);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, []);
return (
<span
className={`network-status-badge ${status}`}
title={label}
aria-label={label}
aria-live="polite"
>
<Icon size={14} aria-hidden="true" />
<span className="network-status-label">{label}</span>
</span>
);
}
@@ -4,6 +4,7 @@ import { APP_VERSION } from '@shared/app-version';
interface StandalonePageFrameProps { interface StandalonePageFrameProps {
title: string; title: string;
eyebrow?: ComponentChildren; eyebrow?: ComponentChildren;
titleAccessory?: ComponentChildren;
children: ComponentChildren; children: ComponentChildren;
} }
@@ -19,7 +20,10 @@ export default function StandalonePageFrame(props: StandalonePageFrameProps) {
<div className="auth-card"> <div className="auth-card">
{props.eyebrow && <div className="standalone-eyebrow">{props.eyebrow}</div>} {props.eyebrow && <div className="standalone-eyebrow">{props.eyebrow}</div>}
<h1 className="standalone-title">{props.title}</h1> <div className="standalone-title-row">
<h1 className="standalone-title">{props.title}</h1>
{props.titleAccessory}
</div>
{props.children} {props.children}
</div> </div>
+30
View File
@@ -716,6 +716,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
setAttachmentQueue([]); setAttachmentQueue([]);
setRemovedAttachmentIds({}); setRemovedAttachmentIds({});
if (isMobileLayout) setMobilePanel('detail'); if (isMobileLayout) setMobilePanel('detail');
} catch {
// The action layer already shows the user-facing error toast.
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -729,6 +731,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
setPendingDelete(null); setPendingDelete(null);
cancelEdit(); cancelEdit();
if (isMobileLayout) setMobilePanel('list'); if (isMobileLayout) setMobilePanel('list');
} catch {
// The action layer already shows the user-facing error toast.
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -741,6 +745,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
if (isMobileLayout && selectedCipherId === cipher.id) { if (isMobileLayout && selectedCipherId === cipher.id) {
setMobilePanel('list'); setMobilePanel('list');
} }
} catch {
// The action layer already shows the user-facing error toast.
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -760,6 +766,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
} }
setSelectedMap({}); setSelectedMap({});
setBulkDeleteOpen(false); setBulkDeleteOpen(false);
} catch {
// The action layer already shows the user-facing error toast.
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -776,6 +784,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
await props.onBulkMove(ids, folderId); await props.onBulkMove(ids, folderId);
setSelectedMap({}); setSelectedMap({});
setMoveOpen(false); setMoveOpen(false);
} catch {
// The action layer already shows the user-facing error toast.
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -785,6 +795,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
setBusy(true); setBusy(true);
try { try {
await props.onRefresh(); await props.onRefresh();
} catch {
// The action layer already shows the user-facing error toast.
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -819,6 +831,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
await props.onCreateFolder(newFolderName); await props.onCreateFolder(newFolderName);
setCreateFolderOpen(false); setCreateFolderOpen(false);
setNewFolderName(''); setNewFolderName('');
} catch {
// The action layer already shows the user-facing error toast.
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -833,6 +847,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
setSidebarFilter({ kind: 'all' }); setSidebarFilter({ kind: 'all' });
} }
setPendingDeleteFolder(null); setPendingDeleteFolder(null);
} catch {
// The action layer already shows the user-facing error toast.
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -850,6 +866,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
await props.onRenameFolder(pendingRenameFolder.id, nextName); await props.onRenameFolder(pendingRenameFolder.id, nextName);
setPendingRenameFolder(null); setPendingRenameFolder(null);
setRenameFolderName(''); setRenameFolderName('');
} catch {
// The action layer already shows the user-facing error toast.
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -864,6 +882,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
try { try {
await props.onBulkRestore(ids); await props.onBulkRestore(ids);
setSelectedMap({}); setSelectedMap({});
} catch {
// The action layer already shows the user-facing error toast.
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -878,6 +898,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
if (isMobileLayout && selectedCipherId === pendingArchive.id) { if (isMobileLayout && selectedCipherId === pendingArchive.id) {
setMobilePanel('list'); setMobilePanel('list');
} }
} catch {
// The action layer already shows the user-facing error toast.
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -892,6 +914,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
delete next[cipher.id]; delete next[cipher.id];
return next; return next;
}); });
} catch {
// The action layer already shows the user-facing error toast.
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -907,6 +931,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
await props.onBulkArchive(ids); await props.onBulkArchive(ids);
setSelectedMap({}); setSelectedMap({});
setBulkArchiveOpen(false); setBulkArchiveOpen(false);
} catch {
// The action layer already shows the user-facing error toast.
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -921,6 +947,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
try { try {
await props.onBulkUnarchive(ids); await props.onBulkUnarchive(ids);
setSelectedMap({}); setSelectedMap({});
} catch {
// The action layer already shows the user-facing error toast.
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -935,6 +963,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
setSidebarFilter({ kind: 'all' }); setSidebarFilter({ kind: 'all' });
} }
setDeleteAllFoldersOpen(false); setDeleteAllFoldersOpen(false);
} catch {
// The action layer already shows the user-facing error toast.
} finally { } finally {
setBusy(false); setBusy(false);
} }
+6 -1
View File
@@ -9,6 +9,7 @@ import {
subscribeWebsiteIconStatus, subscribeWebsiteIconStatus,
} from '@/lib/website-icon-cache'; } from '@/lib/website-icon-cache';
import { demoBrandIconUrl } from '@/lib/demo-brand-icons'; import { demoBrandIconUrl } from '@/lib/demo-brand-icons';
import { getCurrentNetworkStatus, subscribeNetworkStatus } from '@/lib/network-status';
import { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils'; import { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils';
const ICON_LOAD_ROOT_MARGIN = '180px 0px'; const ICON_LOAD_ROOT_MARGIN = '180px 0px';
@@ -26,8 +27,11 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
const [shouldLoad, setShouldLoad] = useState(() => (host ? getWebsiteIconStatus(host) === 'loaded' : true)); const [shouldLoad, setShouldLoad] = useState(() => (host ? getWebsiteIconStatus(host) === 'loaded' : true));
const [status, setStatus] = useState(() => (host ? getWebsiteIconStatus(host) : 'idle')); const [status, setStatus] = useState(() => (host ? getWebsiteIconStatus(host) : 'idle'));
const [imageUrl, setImageUrl] = useState(() => (host ? getWebsiteIconImageUrl(host) : '')); const [imageUrl, setImageUrl] = useState(() => (host ? getWebsiteIconImageUrl(host) : ''));
const [networkStatus, setNetworkStatus] = useState(getCurrentNetworkStatus);
const demoIconUrl = SHOULD_LOAD_DEMO_BRAND_ICONS && host ? demoBrandIconUrl(host) : ''; const demoIconUrl = SHOULD_LOAD_DEMO_BRAND_ICONS && host ? demoBrandIconUrl(host) : '';
useEffect(() => subscribeNetworkStatus(setNetworkStatus), []);
useEffect(() => { useEffect(() => {
if (!host) { if (!host) {
setShouldLoad(true); setShouldLoad(true);
@@ -77,9 +81,10 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
useEffect(() => { useEffect(() => {
if (SHOULD_LOAD_DEMO_BRAND_ICONS) return; if (SHOULD_LOAD_DEMO_BRAND_ICONS) return;
if (demoIconUrl) return; if (demoIconUrl) return;
if (networkStatus !== 'online') return;
if (!host || !src || !shouldLoad || status !== 'idle') return; if (!host || !src || !shouldLoad || status !== 'idle') return;
beginWebsiteIconLoad(host, src); beginWebsiteIconLoad(host, src);
}, [demoIconUrl, host, src, shouldLoad, status]); }, [demoIconUrl, host, networkStatus, src, shouldLoad, status]);
if (demoIconUrl) { if (demoIconUrl) {
return ( return (
+120
View File
@@ -302,6 +302,11 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
await Promise.all([refetchCiphers(), refetchFolders(), refetchSends()]); await Promise.all([refetchCiphers(), refetchFolders(), refetchSends()]);
}; };
const requireOnlineWrite = () => {
if (session?.accessToken) return;
throw new Error(t('txt_offline_vault_readonly'));
};
const syncVaultCoreInBackground = (options?: { includeFolders?: boolean }) => { const syncVaultCoreInBackground = (options?: { includeFolders?: boolean }) => {
const tasks: Promise<unknown>[] = [Promise.resolve(refetchCiphers())]; const tasks: Promise<unknown>[] = [Promise.resolve(refetchCiphers())];
if (options?.includeFolders) { if (options?.includeFolders) {
@@ -447,6 +452,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
async createVaultItem(draft: VaultDraft, attachments: File[] = []) { async createVaultItem(draft: VaultDraft, attachments: File[] = []) {
if (!session) return; if (!session) return;
try {
requireOnlineWrite();
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
throw error;
}
const optimistic = optimisticCipherFromDraft(draft, null); const optimistic = optimisticCipherFromDraft(draft, null);
patchDecryptedCiphers((prev) => [optimistic, ...prev.filter((cipher) => cipher.id !== optimistic.id)]); patchDecryptedCiphers((prev) => [optimistic, ...prev.filter((cipher) => cipher.id !== optimistic.id)]);
try { try {
@@ -471,6 +482,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
async updateVaultItem(cipher: Cipher, draft: VaultDraft, options?: { addFiles?: File[]; removeAttachmentIds?: string[] }) { async updateVaultItem(cipher: Cipher, draft: VaultDraft, options?: { addFiles?: File[]; removeAttachmentIds?: string[] }) {
if (!session) return; if (!session) return;
try {
requireOnlineWrite();
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
throw error;
}
if (hasUnresolvedCipherData(cipher)) { if (hasUnresolvedCipherData(cipher)) {
throw new Error(t('txt_decrypt_failed_2')); throw new Error(t('txt_decrypt_failed_2'));
} }
@@ -543,6 +560,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
}, },
async deleteVaultItem(cipher: Cipher) { async deleteVaultItem(cipher: Cipher) {
try {
requireOnlineWrite();
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
throw error;
}
const previousCipher = { ...cipher }; const previousCipher = { ...cipher };
if (cipher.deletedDate || (cipher as { deletedAt?: string | null }).deletedAt) { if (cipher.deletedDate || (cipher as { deletedAt?: string | null }).deletedAt) {
try { try {
@@ -571,6 +594,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
}, },
async archiveVaultItem(cipher: Cipher) { async archiveVaultItem(cipher: Cipher) {
try {
requireOnlineWrite();
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
throw error;
}
const previousCipher = { ...cipher }; const previousCipher = { ...cipher };
const archivedDate = new Date().toISOString(); const archivedDate = new Date().toISOString();
patchCipherBatch([cipher.id], (current) => ({ ...current, archivedDate, deletedDate: null, revisionDate: archivedDate })); patchCipherBatch([cipher.id], (current) => ({ ...current, archivedDate, deletedDate: null, revisionDate: archivedDate }));
@@ -587,6 +616,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
}, },
async unarchiveVaultItem(cipher: Cipher) { async unarchiveVaultItem(cipher: Cipher) {
try {
requireOnlineWrite();
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
throw error;
}
const previousCipher = { ...cipher }; const previousCipher = { ...cipher };
const revisionDate = new Date().toISOString(); const revisionDate = new Date().toISOString();
patchCipherBatch([cipher.id], (current) => ({ ...current, archivedDate: null, revisionDate })); patchCipherBatch([cipher.id], (current) => ({ ...current, archivedDate: null, revisionDate }));
@@ -603,6 +638,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
}, },
async bulkDeleteVaultItems(ids: string[]) { async bulkDeleteVaultItems(ids: string[]) {
try {
requireOnlineWrite();
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
throw error;
}
try { try {
await bulkDeleteCiphers(authedFetch, ids); await bulkDeleteCiphers(authedFetch, ids);
const deletedDate = new Date().toISOString(); const deletedDate = new Date().toISOString();
@@ -616,6 +657,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
}, },
async bulkArchiveVaultItems(ids: string[]) { async bulkArchiveVaultItems(ids: string[]) {
try {
requireOnlineWrite();
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
throw error;
}
try { try {
await bulkArchiveCiphers(authedFetch, ids); await bulkArchiveCiphers(authedFetch, ids);
const archivedDate = new Date().toISOString(); const archivedDate = new Date().toISOString();
@@ -629,6 +676,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
}, },
async bulkUnarchiveVaultItems(ids: string[]) { async bulkUnarchiveVaultItems(ids: string[]) {
try {
requireOnlineWrite();
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
throw error;
}
try { try {
await bulkUnarchiveCiphers(authedFetch, ids); await bulkUnarchiveCiphers(authedFetch, ids);
patchCipherBatch(ids, (cipher) => ({ ...cipher, archivedDate: null })); patchCipherBatch(ids, (cipher) => ({ ...cipher, archivedDate: null }));
@@ -641,6 +694,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
}, },
async bulkMoveVaultItems(ids: string[], folderId: string | null) { async bulkMoveVaultItems(ids: string[], folderId: string | null) {
try {
requireOnlineWrite();
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
throw error;
}
try { try {
await bulkMoveCiphers(authedFetch, ids, folderId); await bulkMoveCiphers(authedFetch, ids, folderId);
patchCipherBatch(ids, (cipher) => ({ ...cipher, folderId })); patchCipherBatch(ids, (cipher) => ({ ...cipher, folderId }));
@@ -658,6 +717,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
onNotify('error', t('txt_folder_name_is_required')); onNotify('error', t('txt_folder_name_is_required'));
return; return;
} }
try {
requireOnlineWrite();
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
throw error;
}
try { try {
if (!session) throw new Error(t('txt_vault_key_unavailable')); if (!session) throw new Error(t('txt_vault_key_unavailable'));
const created = await createFolder(authedFetch, session, folderName); const created = await createFolder(authedFetch, session, folderName);
@@ -683,6 +748,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
onNotify('error', t('txt_folder_not_found')); onNotify('error', t('txt_folder_not_found'));
return; return;
} }
try {
requireOnlineWrite();
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
throw error;
}
try { try {
await deleteFolder(authedFetch, id); await deleteFolder(authedFetch, id);
patchFolderBatch([id], () => null); patchFolderBatch([id], () => null);
@@ -706,6 +777,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
onNotify('error', t('txt_folder_name_is_required')); onNotify('error', t('txt_folder_name_is_required'));
return; return;
} }
try {
requireOnlineWrite();
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
throw error;
}
try { try {
if (!session) throw new Error(t('txt_vault_key_unavailable')); if (!session) throw new Error(t('txt_vault_key_unavailable'));
await updateFolder(authedFetch, session, id, nextName); await updateFolder(authedFetch, session, id, nextName);
@@ -719,6 +796,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
}, },
async bulkRestoreVaultItems(ids: string[]) { async bulkRestoreVaultItems(ids: string[]) {
try {
requireOnlineWrite();
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
throw error;
}
try { try {
await bulkRestoreCiphers(authedFetch, ids); await bulkRestoreCiphers(authedFetch, ids);
patchCipherBatch(ids, (cipher) => ({ ...cipher, deletedDate: null })); patchCipherBatch(ids, (cipher) => ({ ...cipher, deletedDate: null }));
@@ -731,6 +814,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
}, },
async bulkPermanentDeleteVaultItems(ids: string[]) { async bulkPermanentDeleteVaultItems(ids: string[]) {
try {
requireOnlineWrite();
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
throw error;
}
try { try {
await bulkPermanentDeleteCiphers(authedFetch, ids); await bulkPermanentDeleteCiphers(authedFetch, ids);
patchCipherBatch(ids, () => null); patchCipherBatch(ids, () => null);
@@ -745,6 +834,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
async bulkDeleteFolders(folderIds: string[]) { async bulkDeleteFolders(folderIds: string[]) {
const ids = Array.from(new Set(folderIds.map((id) => String(id || '').trim()).filter(Boolean))); const ids = Array.from(new Set(folderIds.map((id) => String(id || '').trim()).filter(Boolean)));
if (!ids.length) return; if (!ids.length) return;
try {
requireOnlineWrite();
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
throw error;
}
try { try {
await bulkDeleteFolders(authedFetch, ids); await bulkDeleteFolders(authedFetch, ids);
const removedIds = new Set(ids); const removedIds = new Set(ids);
@@ -765,6 +860,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
async createSend(draft: SendDraft, autoCopyLink: boolean) { async createSend(draft: SendDraft, autoCopyLink: boolean) {
if (!session) return; if (!session) return;
try {
requireOnlineWrite();
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
throw error;
}
try { try {
const fileName = draft.type === 'file' ? String(draft.file?.name || '').trim() : ''; const fileName = draft.type === 'file' ? String(draft.file?.name || '').trim() : '';
if (fileName) { if (fileName) {
@@ -790,6 +891,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
async updateSend(send: Send, draft: SendDraft, autoCopyLink: boolean) { async updateSend(send: Send, draft: SendDraft, autoCopyLink: boolean) {
if (!session) return; if (!session) return;
try {
requireOnlineWrite();
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
throw error;
}
try { try {
const updated = await updateSend(authedFetch, session, send, draft); const updated = await updateSend(authedFetch, session, send, draft);
await refetchSends(); await refetchSends();
@@ -806,6 +913,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
}, },
async deleteSend(send: Send) { async deleteSend(send: Send) {
try {
requireOnlineWrite();
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
throw error;
}
try { try {
await deleteSend(authedFetch, send.id); await deleteSend(authedFetch, send.id);
await refetchSends(); await refetchSends();
@@ -817,6 +930,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
}, },
async bulkDeleteSends(ids: string[]) { async bulkDeleteSends(ids: string[]) {
try {
requireOnlineWrite();
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
throw error;
}
try { try {
await bulkDeleteSends(authedFetch, ids); await bulkDeleteSends(authedFetch, ids);
await refetchSends(); await refetchSends();
@@ -833,6 +952,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
attachments: ImportAttachmentFile[] = [] attachments: ImportAttachmentFile[] = []
): Promise<ImportResultSummary> { ): Promise<ImportResultSummary> {
if (!session?.symEncKey || !session?.symMacKey) throw new Error(t('txt_vault_key_unavailable')); if (!session?.symEncKey || !session?.symMacKey) throw new Error(t('txt_vault_key_unavailable'));
requireOnlineWrite();
const mode = options.folderMode || 'original'; const mode = options.folderMode || 'original';
const targetFolderId = (options.targetFolderId || '').trim() || null; const targetFolderId = (options.targetFolderId || '').trim() || null;
+9 -1
View File
@@ -95,6 +95,12 @@ export function loadSession(): SessionState | null {
authMode: 'web-cookie', authMode: 'web-cookie',
}; };
} }
if (parsed.authMode === 'token' && parsed.email && !parsed.accessToken && !parsed.refreshToken) {
return {
email: parsed.email,
authMode: 'token',
};
}
if (!parsed.accessToken || !parsed.refreshToken || !parsed.email) return null; if (!parsed.accessToken || !parsed.refreshToken || !parsed.email) return null;
return { return {
accessToken: parsed.accessToken, accessToken: parsed.accessToken,
@@ -233,6 +239,7 @@ export async function loginWithPassword(
totpCode?: string; totpCode?: string;
rememberDevice?: boolean; rememberDevice?: boolean;
useRememberToken?: boolean; useRememberToken?: boolean;
signal?: AbortSignal;
} }
): Promise<TokenSuccess | TokenError> { ): Promise<TokenSuccess | TokenError> {
const body = new URLSearchParams(); const body = new URLSearchParams();
@@ -262,6 +269,7 @@ export async function loginWithPassword(
[WEB_SESSION_HEADER]: '1', [WEB_SESSION_HEADER]: '1',
}, },
body: body.toString(), body: body.toString(),
signal: options?.signal,
}); });
const json = (await parseJson<TokenSuccess & TokenError>(resp)) || {}; const json = (await parseJson<TokenSuccess & TokenError>(resp)) || {};
if (resp.ok) { if (resp.ok) {
@@ -450,7 +458,7 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
}; };
const session = getSession(); const session = getSession();
if (!session?.accessToken) throw new Error('Unauthorized'); if (!session?.accessToken) throw new Error(t('txt_offline_vault_readonly'));
const headers = new Headers(init.headers || {}); const headers = new Headers(init.headers || {});
headers.set('Authorization', `Bearer ${session.accessToken}`); headers.set('Authorization', `Bearer ${session.accessToken}`);
headers.set('X-NodeWarden-Web', '1'); headers.set('X-NodeWarden-Web', '1');
+9 -2
View File
@@ -927,6 +927,7 @@ export async function repairCipherUriChecksums(
? cipher.fields.map(({ decName: _decName, decValue: _decValue, ...field }) => field) ? cipher.fields.map(({ decName: _decName, decValue: _decValue, ...field }) => field)
: null, : null,
lastKnownRevisionDate: cipher.revisionDate ?? null, lastKnownRevisionDate: cipher.revisionDate ?? null,
preserveRevisionDate: true,
}; };
if (keys.key) payload.key = keys.key; if (keys.key) payload.key = keys.key;
@@ -1091,7 +1092,9 @@ export async function repairCipherKeyMismatches(
if (!cipher?.id || !looksLikeCipherString(cipher.key)) continue; if (!cipher?.id || !looksLikeCipherString(cipher.key)) continue;
if (!(await hasItemKeyFieldMismatch(cipher, userEnc, userMac))) continue; if (!(await hasItemKeyFieldMismatch(cipher, userEnc, userMac))) continue;
if (hasUnresolvedEncryptedFields(cipher)) continue; if (hasUnresolvedEncryptedFields(cipher)) continue;
await updateCipher(authedFetch, session, cipher, draftFromDecryptedCipher(cipher)); await updateCipher(authedFetch, session, cipher, draftFromDecryptedCipher(cipher), {
preserveRevisionDate: true,
});
repaired += 1; repaired += 1;
} }
@@ -1225,9 +1228,13 @@ export async function updateCipher(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
session: SessionState, session: SessionState,
cipher: Cipher, cipher: Cipher,
draft: VaultDraft draft: VaultDraft,
extraPayload?: Record<string, unknown>
): Promise<Cipher> { ): Promise<Cipher> {
const payload = await buildCipherPayload(session, draft, cipher); const payload = await buildCipherPayload(session, draft, cipher);
if (extraPayload) {
Object.assign(payload, extraPayload);
}
const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipher.id)}`, { const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipher.id)}`, {
method: 'PUT', method: 'PUT',
+105 -7
View File
@@ -12,12 +12,22 @@ import {
} from '@/lib/api/auth'; } from '@/lib/api/auth';
import { readInviteCodeFromUrl } from '@/lib/app-support'; import { readInviteCodeFromUrl } from '@/lib/app-support';
import { t, translateServerError } from '@/lib/i18n'; import { t, translateServerError } from '@/lib/i18n';
import {
getOfflineUnlockKdfIterations,
hasOfflineUnlockRecord,
kdfIterationsFromLogin,
loadOfflineProfileSnapshot,
saveOfflineUnlockRecord,
unlockOfflineVaultWithMasterKey,
} from '@/lib/offline-auth';
import { probeNodeWardenService } from '@/lib/network-status';
import type { AppPhase, Profile, SessionState, TokenSuccess, WebBootstrapResponse } from '@/lib/types'; import type { AppPhase, Profile, SessionState, TokenSuccess, WebBootstrapResponse } from '@/lib/types';
export interface PendingTotp { export interface PendingTotp {
email: string; email: string;
passwordHash: string; passwordHash: string;
masterKey: Uint8Array; masterKey: Uint8Array;
kdfIterations: number;
} }
export type JwtUnsafeReason = 'missing' | 'default' | 'too_short'; export type JwtUnsafeReason = 'missing' | 'default' | 'too_short';
@@ -93,6 +103,20 @@ async function maybeRefreshSession(session: SessionState): Promise<SessionState
}; };
} }
function browserReportsOffline(): boolean {
return typeof navigator !== 'undefined' && navigator.onLine === false;
}
function createTimeoutAbortController(timeoutMs: number): { controller: AbortController; cancel: () => void } | null {
if (typeof AbortController === 'undefined') return null;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
return {
controller,
cancel: () => clearTimeout(timer),
};
}
function readWindowBootstrap(): WebBootstrapResponse { function readWindowBootstrap(): WebBootstrapResponse {
if (typeof window === 'undefined') return {}; if (typeof window === 'undefined') return {};
const raw = (window as Window & { __NW_BOOT__?: WebBootstrapResponse }).__NW_BOOT__; const raw = (window as Window & { __NW_BOOT__?: WebBootstrapResponse }).__NW_BOOT__;
@@ -246,8 +270,24 @@ export async function hydrateLockedSession(
session: SessionState, session: SessionState,
fallbackProfile: Profile | null = null fallbackProfile: Profile | null = null
): Promise<{ session: SessionState | null; profile: Profile | null }> { ): Promise<{ session: SessionState | null; profile: Profile | null }> {
if (hasOfflineUnlockRecord(session.email)) {
const serviceReachable = await probeNodeWardenService();
if (!serviceReachable) {
return {
session,
profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
};
}
}
const refreshedSession = await maybeRefreshSession(session); const refreshedSession = await maybeRefreshSession(session);
if (!refreshedSession?.accessToken) { if (!refreshedSession?.accessToken) {
if (hasOfflineUnlockRecord(session.email)) {
return {
session,
profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
};
}
return { session: null, profile: null }; return { session: null, profile: null };
} }
try { try {
@@ -272,7 +312,8 @@ export async function hydrateLockedSession(
export async function completeLogin( export async function completeLogin(
token: TokenSuccess, token: TokenSuccess,
email: string, email: string,
masterKey: Uint8Array masterKey: Uint8Array,
fallbackKdfIterations: number
): Promise<CompletedLogin> { ): Promise<CompletedLogin> {
const normalizedEmail = email.trim().toLowerCase(); const normalizedEmail = email.trim().toLowerCase();
const fallbackProfile = loadProfileSnapshot(normalizedEmail); const fallbackProfile = loadProfileSnapshot(normalizedEmail);
@@ -291,6 +332,12 @@ export async function completeLogin(
throw new Error('Missing profile key'); throw new Error('Missing profile key');
} }
const keys = await unlockVaultKey(profile.key, masterKey); const keys = await unlockVaultKey(profile.key, masterKey);
saveOfflineUnlockRecord({
email: normalizedEmail,
profile,
profileKey: profile.key,
kdfIterations: kdfIterationsFromLogin(token, fallbackKdfIterations),
});
return { return {
session: { ...baseSession, ...keys }, session: { ...baseSession, ...keys },
profile, profile,
@@ -310,7 +357,7 @@ export async function performPasswordLogin(
if ('access_token' in token && token.access_token) { if ('access_token' in token && token.access_token) {
return { return {
kind: 'success', kind: 'success',
login: await completeLogin(token, normalizedEmail, derived.masterKey), login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations),
}; };
} }
@@ -322,6 +369,7 @@ export async function performPasswordLogin(
email: normalizedEmail, email: normalizedEmail,
passwordHash: derived.hash, passwordHash: derived.hash,
masterKey: derived.masterKey, masterKey: derived.masterKey,
kdfIterations: derived.kdfIterations,
}, },
}; };
} }
@@ -342,7 +390,7 @@ export async function performTotpLogin(
rememberDevice, rememberDevice,
}); });
if ('access_token' in token && token.access_token) { if ('access_token' in token && token.access_token) {
return completeLogin(token, pendingTotp.email, pendingTotp.masterKey); return completeLogin(token, pendingTotp.email, pendingTotp.masterKey, pendingTotp.kdfIterations);
} }
const tokenError = token as { error_description?: string; error?: string }; const tokenError = token as { error_description?: string; error?: string };
throw new Error(translateServerError(tokenError.error_description || tokenError.error, t('txt_totp_verify_failed'))); throw new Error(translateServerError(tokenError.error_description || tokenError.error, t('txt_totp_verify_failed')));
@@ -361,7 +409,7 @@ export async function performRecoverTwoFactorLogin(
if ('access_token' in token && token.access_token) { if ('access_token' in token && token.access_token) {
return { return {
login: await completeLogin(token, normalizedEmail, derived.masterKey), login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations),
newRecoveryCode: recovered.newRecoveryCode || null, newRecoveryCode: recovered.newRecoveryCode || null,
}; };
} }
@@ -397,13 +445,62 @@ export async function performUnlock(
fallbackIterations: number fallbackIterations: number
): Promise<PasswordLoginResult> { ): Promise<PasswordLoginResult> {
const normalizedEmail = (profile?.email || session.email).trim().toLowerCase(); const normalizedEmail = (profile?.email || session.email).trim().toLowerCase();
const derived = await deriveLoginHashLocally(normalizedEmail, password, fallbackIterations); const offlineIterations = getOfflineUnlockKdfIterations(normalizedEmail);
const token = await loginWithPassword(normalizedEmail, derived.hash, { useRememberToken: true }); const hasOfflineUnlock = !!offlineIterations;
const kdfIterations = offlineIterations || fallbackIterations;
const derived = await deriveLoginHashLocally(normalizedEmail, password, kdfIterations);
const unlockOffline = async (): Promise<PasswordLoginResult> => {
try {
const offline = await unlockOfflineVaultWithMasterKey(session, profile, derived.masterKey);
return {
kind: 'success',
login: {
session: offline.session,
profile: offline.profile,
profilePromise: Promise.resolve(offline.profile),
},
};
} catch {
return {
kind: 'error',
message: t('txt_unlock_failed_master_password_is_incorrect'),
};
}
};
if (hasOfflineUnlock) {
if (browserReportsOffline()) {
return unlockOffline();
}
const serviceReachable = await probeNodeWardenService();
if (!serviceReachable) {
return unlockOffline();
}
}
let token: TokenSuccess | { TwoFactorProviders?: unknown; error_description?: string; error?: string };
const abortable = hasOfflineUnlock ? createTimeoutAbortController(2500) : null;
try {
token = await loginWithPassword(normalizedEmail, derived.hash, {
useRememberToken: true,
signal: abortable?.controller.signal,
});
} catch {
if (hasOfflineUnlock) {
return unlockOffline();
}
return {
kind: 'error',
message: t('txt_unlock_failed_master_password_is_incorrect'),
};
} finally {
abortable?.cancel();
}
if ('access_token' in token && token.access_token) { if ('access_token' in token && token.access_token) {
return { return {
kind: 'success', kind: 'success',
login: await completeLogin(token, normalizedEmail, derived.masterKey), login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations),
}; };
} }
@@ -415,6 +512,7 @@ export async function performUnlock(
email: normalizedEmail, email: normalizedEmail,
passwordHash: derived.hash, passwordHash: derived.hash,
masterKey: derived.masterKey, masterKey: derived.masterKey,
kdfIterations: derived.kdfIterations,
}, },
}; };
} }
+1
View File
@@ -735,6 +735,7 @@ const en: Record<string, string> = {
"txt_status": "Status", "txt_status": "Status",
"txt_online": "Online", "txt_online": "Online",
"txt_offline": "Offline", "txt_offline": "Offline",
"txt_offline_vault_readonly": "Offline mode is read-only. Connect to NodeWarden before changing your vault.",
"txt_submit": "Submit", "txt_submit": "Submit",
"txt_sync": "Sync", "txt_sync": "Sync",
"txt_sync_vault": "Sync Vault", "txt_sync_vault": "Sync Vault",
+1
View File
@@ -735,6 +735,7 @@ const es: Record<string, string> = {
"txt_status": "Estado", "txt_status": "Estado",
"txt_online": "En línea", "txt_online": "En línea",
"txt_offline": "Sin conexión", "txt_offline": "Sin conexión",
"txt_offline_vault_readonly": "El modo sin conexión es de solo lectura. Conecta con NodeWarden antes de cambiar la bóveda.",
"txt_submit": "Enviar", "txt_submit": "Enviar",
"txt_sync": "Sincronizar", "txt_sync": "Sincronizar",
"txt_sync_vault": "Sincronizar bóveda", "txt_sync_vault": "Sincronizar bóveda",
+1
View File
@@ -735,6 +735,7 @@ const ru: Record<string, string> = {
"txt_status": "Статус", "txt_status": "Статус",
"txt_online": "Онлайн", "txt_online": "Онлайн",
"txt_offline": "Офлайн", "txt_offline": "Офлайн",
"txt_offline_vault_readonly": "Автономный режим доступен только для чтения. Подключитесь к NodeWarden, чтобы изменить хранилище.",
"txt_submit": "Отправить", "txt_submit": "Отправить",
"txt_sync": "Синхронизировать", "txt_sync": "Синхронизировать",
"txt_sync_vault": "Синхронизировать хранилище", "txt_sync_vault": "Синхронизировать хранилище",
+1
View File
@@ -735,6 +735,7 @@ const zhCN: Record<string, string> = {
"txt_status": "状态", "txt_status": "状态",
"txt_online": "在线", "txt_online": "在线",
"txt_offline": "离线", "txt_offline": "离线",
"txt_offline_vault_readonly": "当前为离线模式,只能查看密码库。连接到 NodeWarden 后才能修改。",
"txt_submit": "提交", "txt_submit": "提交",
"txt_sync": "同步", "txt_sync": "同步",
"txt_sync_vault": "同步", "txt_sync_vault": "同步",
+1
View File
@@ -735,6 +735,7 @@ const zhTW: Record<string, string> = {
"txt_status": "狀態", "txt_status": "狀態",
"txt_online": "在線", "txt_online": "在線",
"txt_offline": "離線", "txt_offline": "離線",
"txt_offline_vault_readonly": "目前為離線模式,只能查看密碼庫。連線到 NodeWarden 後才能修改。",
"txt_submit": "提交", "txt_submit": "提交",
"txt_sync": "同步", "txt_sync": "同步",
"txt_sync_vault": "同步", "txt_sync_vault": "同步",
+79
View File
@@ -0,0 +1,79 @@
export type NetworkStatus = 'online' | 'offline';
const STATUS_PROBE_TIMEOUT_MS = 3500;
const STATUS_PROBE_CACHE_MS = 5000;
const listeners = new Set<(status: NetworkStatus) => void>();
let currentStatus: NetworkStatus = getInitialNetworkStatus();
let pendingProbe: Promise<boolean> | null = null;
let lastProbeAt = 0;
let lastProbeResult = false;
export function browserReportsOffline(): boolean {
return typeof navigator !== 'undefined' && navigator.onLine === false;
}
export function getInitialNetworkStatus(): NetworkStatus {
return 'offline';
}
export function getCurrentNetworkStatus(): NetworkStatus {
return currentStatus;
}
export function setCurrentNetworkStatus(status: NetworkStatus): void {
if (currentStatus === status) return;
currentStatus = status;
for (const listener of Array.from(listeners)) {
listener(status);
}
}
export function subscribeNetworkStatus(listener: (status: NetworkStatus) => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export async function probeNodeWardenService(): Promise<boolean> {
if (browserReportsOffline()) {
setCurrentNetworkStatus('offline');
return false;
}
const now = Date.now();
if (pendingProbe) return pendingProbe;
if (now - lastProbeAt < STATUS_PROBE_CACHE_MS) return lastProbeResult;
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
const timer = controller
? window.setTimeout(() => controller.abort(), STATUS_PROBE_TIMEOUT_MS)
: 0;
pendingProbe = (async () => {
const response = await fetch(`/api/web-bootstrap?statusProbe=${Date.now()}`, {
method: 'GET',
cache: 'no-store',
headers: {
Accept: 'application/json',
'Cache-Control': 'no-cache',
Pragma: 'no-cache',
},
signal: controller?.signal,
});
return response.ok;
})()
.catch(() => false)
.then((result) => {
lastProbeAt = Date.now();
lastProbeResult = result;
setCurrentNetworkStatus(result ? 'online' : 'offline');
return result;
})
.finally(() => {
if (timer) window.clearTimeout(timer);
pendingProbe = null;
});
return pendingProbe;
}
+161
View File
@@ -0,0 +1,161 @@
import { deriveLoginHashLocally, unlockVaultKey } from '@/lib/api/auth';
import type { Profile, SessionState, TokenSuccess } from '@/lib/types';
const OFFLINE_UNLOCK_KEY = 'nodewarden.web.offline-unlock.v1';
interface OfflineUnlockRecord {
version: 1;
email: string;
profile: Profile;
profileKey: string;
kdfIterations: number;
savedAt: number;
}
function normalizeEmail(email: string | null | undefined): string {
return String(email || '').trim().toLowerCase();
}
function stripOfflineProfile(profile: Profile): Profile {
return {
...profile,
email: normalizeEmail(profile.email),
key: '',
privateKey: null,
};
}
function parseRecord(raw: string | null): OfflineUnlockRecord | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as Partial<OfflineUnlockRecord>;
const email = normalizeEmail(parsed.email);
const profileKey = String(parsed.profileKey || '').trim();
const iterations = Number(parsed.kdfIterations || 0);
if (parsed.version !== 1 || !email || !profileKey || !Number.isFinite(iterations) || iterations <= 0) {
return null;
}
const profile = parsed.profile && typeof parsed.profile === 'object'
? stripOfflineProfile(parsed.profile as Profile)
: {
id: '',
email,
name: email,
key: '',
privateKey: null,
role: 'user',
};
return {
version: 1,
email,
profile,
profileKey,
kdfIterations: iterations,
savedAt: Number(parsed.savedAt || 0) || 0,
};
} catch {
return null;
}
}
function readRecord(): OfflineUnlockRecord | null {
if (typeof localStorage === 'undefined') return null;
return parseRecord(localStorage.getItem(OFFLINE_UNLOCK_KEY));
}
export function hasOfflineUnlockRecord(email?: string | null): boolean {
const record = readRecord();
if (!record) return false;
const normalized = normalizeEmail(email);
return !normalized || record.email === normalized;
}
export function getOfflineUnlockKdfIterations(email?: string | null): number | null {
const record = readRecord();
if (!record) return null;
const normalized = normalizeEmail(email);
if (normalized && record.email !== normalized) return null;
return record.kdfIterations;
}
export function loadOfflineProfileSnapshot(email?: string | null): Profile | null {
const record = readRecord();
if (!record) return null;
const normalized = normalizeEmail(email);
if (normalized && record.email !== normalized) return null;
return stripOfflineProfile(record.profile);
}
export function saveOfflineUnlockRecord(args: {
email: string;
profile: Profile;
profileKey: string;
kdfIterations: number;
}): void {
if (typeof localStorage === 'undefined') return;
const email = normalizeEmail(args.email || args.profile.email);
const profileKey = String(args.profileKey || '').trim();
const kdfIterations = Number(args.kdfIterations || 0);
if (!email || !profileKey || !Number.isFinite(kdfIterations) || kdfIterations <= 0) return;
const record: OfflineUnlockRecord = {
version: 1,
email,
profile: stripOfflineProfile({ ...args.profile, email }),
profileKey,
kdfIterations,
savedAt: Date.now(),
};
localStorage.setItem(OFFLINE_UNLOCK_KEY, JSON.stringify(record));
}
export function clearOfflineUnlockRecord(): void {
try {
localStorage.removeItem(OFFLINE_UNLOCK_KEY);
} catch {
// Ignore storage failures during logout cleanup.
}
}
export async function unlockOfflineVault(
session: SessionState,
profile: Profile | null,
password: string
): Promise<{ session: SessionState; profile: Profile }> {
const record = readRecord();
const email = normalizeEmail(profile?.email || session.email);
if (!record || record.email !== email) {
throw new Error('Offline unlock is not available on this device.');
}
const derived = await deriveLoginHashLocally(record.email, password, record.kdfIterations);
return unlockOfflineVaultWithMasterKey(session, profile, derived.masterKey);
}
export async function unlockOfflineVaultWithMasterKey(
session: SessionState,
profile: Profile | null,
masterKey: Uint8Array
): Promise<{ session: SessionState; profile: Profile }> {
const record = readRecord();
const email = normalizeEmail(profile?.email || session.email);
if (!record || record.email !== email) {
throw new Error('Offline unlock is not available on this device.');
}
const keys = await unlockVaultKey(record.profileKey, masterKey);
const { accessToken: _accessToken, refreshToken: _refreshToken, ...offlineSession } = session;
return {
session: {
...offlineSession,
email: record.email,
...keys,
},
profile: {
...stripOfflineProfile(record.profile),
key: record.profileKey,
},
};
}
export function kdfIterationsFromLogin(token: TokenSuccess, fallbackIterations: number): number {
const value = Number(token.KdfIterations || fallbackIterations || 600000);
return Number.isFinite(value) && value > 0 ? value : 600000;
}
+18
View File
@@ -0,0 +1,18 @@
export function registerNodeWardenServiceWorker(): void {
if (typeof window === 'undefined') return;
if (!('serviceWorker' in navigator)) return;
if (import.meta.env.DEV) return;
const register = () => {
void navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(() => {
// PWA support is progressive enhancement; the vault still works without it.
});
};
if (document.readyState === 'complete') {
register();
return;
}
window.addEventListener('load', register, { once: true });
}
+2
View File
@@ -1,4 +1,5 @@
import type { Send } from './types'; import type { Send } from './types';
import { getCurrentNetworkStatus } from './network-status';
import type { DecryptSendsArgs, DecryptVaultCoreArgs, DecryptVaultCoreResult } from './vault-decrypt'; import type { DecryptSendsArgs, DecryptVaultCoreArgs, DecryptVaultCoreResult } from './vault-decrypt';
type WorkerSuccess<T> = { id: number; ok: true; result: T }; type WorkerSuccess<T> = { id: number; ok: true; result: T };
@@ -12,6 +13,7 @@ const pending = new Map<number, { resolve: (value: any) => void; reject: (error:
function getWorker(): Worker | null { function getWorker(): Worker | null {
if (typeof Worker === 'undefined') return null; if (typeof Worker === 'undefined') return null;
if (worker) return worker; if (worker) return worker;
if (getCurrentNetworkStatus() === 'offline') return null;
worker = new Worker(new URL('../workers/vault-decrypt.worker.ts', import.meta.url), { type: 'module' }); worker = new Worker(new URL('../workers/vault-decrypt.worker.ts', import.meta.url), { type: 'module' });
worker.addEventListener('message', (event: MessageEvent<WorkerResponse<unknown>>) => { worker.addEventListener('message', (event: MessageEvent<WorkerResponse<unknown>>) => {
const message = event.data; const message = event.data;
+2
View File
@@ -2,6 +2,7 @@ import { render } from 'preact';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App'; import App from './App';
import { initI18n } from './lib/i18n'; import { initI18n } from './lib/i18n';
import { registerNodeWardenServiceWorker } from './lib/pwa';
import './tailwind.css'; import './tailwind.css';
import './styles.css'; import './styles.css';
@@ -29,4 +30,5 @@ function renderApp(): void {
void initI18n().finally(() => { void initI18n().finally(() => {
renderApp(); renderApp();
registerNodeWardenServiceWorker();
}); });
+12 -2
View File
@@ -451,7 +451,7 @@
} }
.auth-card h1 { .auth-card h1 {
@apply m-0 mb-1 text-center; @apply m-0;
} }
.standalone-eyebrow { .standalone-eyebrow {
@@ -486,7 +486,17 @@
} }
.standalone-title { .standalone-title {
@apply m-0 mb-1 text-left text-3xl font-bold leading-tight tracking-normal; @apply m-0 text-center text-3xl font-bold leading-tight tracking-normal;
}
.standalone-title-row {
@apply relative mb-1 flex min-h-9 items-center justify-center;
padding-inline: 84px;
}
.standalone-title-row > .network-status-badge {
@apply absolute right-0 top-1/2;
transform: translateY(-50%);
} }
.standalone-muted { .standalone-muted {
+12
View File
@@ -54,6 +54,10 @@
@apply text-2xl; @apply text-2xl;
} }
.standalone-title-row {
padding-inline: 68px;
}
.standalone-footer { .standalone-footer {
@apply text-xs leading-[1.4]; @apply text-xs leading-[1.4];
} }
@@ -128,6 +132,14 @@
@apply hidden; @apply hidden;
} }
.topbar-actions > .network-status-badge {
@apply h-8 px-2 text-[0];
}
.topbar-actions > .network-status-badge svg {
@apply m-0;
}
.mobile-sidebar-toggle, .mobile-sidebar-toggle,
.mobile-lock-btn { .mobile-lock-btn {
@apply inline-flex h-9 w-9 min-w-9 justify-center gap-0 p-0 text-[0]; @apply inline-flex h-9 w-9 min-w-9 justify-center gap-0 p-0 text-[0];
+25
View File
@@ -44,6 +44,31 @@
@apply flex items-center gap-2.5; @apply flex items-center gap-2.5;
} }
.network-status-badge {
@apply inline-flex h-[30px] shrink-0 select-none items-center gap-1.5 rounded-full border px-2.5 text-xs font-bold leading-none;
letter-spacing: 0;
}
.network-status-badge svg {
@apply shrink-0;
}
.network-status-badge.online {
background: color-mix(in srgb, var(--success) 10%, var(--panel));
border-color: color-mix(in srgb, var(--success) 36%, var(--line));
color: color-mix(in srgb, var(--success) 78%, var(--text));
}
.network-status-badge.offline {
background: color-mix(in srgb, var(--warning) 13%, var(--panel));
border-color: color-mix(in srgb, var(--warning) 42%, var(--line));
color: color-mix(in srgb, var(--warning) 82%, var(--text));
}
.topbar-actions > .network-status-badge {
@apply h-[34px] px-3;
}
.mobile-tabbar { .mobile-tabbar {
@apply hidden; @apply hidden;
} }
+203 -1
View File
@@ -1,10 +1,212 @@
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import path from 'node:path'; import path from 'node:path';
import { createHash } from 'node:crypto';
import preact from '@preact/preset-vite'; import preact from '@preact/preset-vite';
import { defineConfig, type Plugin } from 'vite'; import { defineConfig, type Plugin } from 'vite';
const rootDir = fileURLToPath(new URL('.', import.meta.url)); const rootDir = fileURLToPath(new URL('.', import.meta.url));
function buildServiceWorkerSource(precacheUrls: string[], version: string): string {
return `const CACHE_VERSION = ${JSON.stringify(`nodewarden-pwa-${version}`)};
const APP_SHELL_CACHE = \`\${CACHE_VERSION}-shell\`;
const RUNTIME_CACHE = 'nodewarden-pwa-runtime-v1';
const PRECACHE_URLS = ${JSON.stringify(precacheUrls, null, 2)};
const CRITICAL_SHELL_URLS = ['/', '/index.html'];
const STATIC_PATH_RE = /^\\/(?:assets\\/|payment-logos\\/|icon-|logo-|favicon|apple-touch-icon|nodewarden-|manifest\\.webmanifest$)/;
const NEVER_CACHE_PATH_RE = /^\\/(?:api|identity|setup|config|notifications|icons|\\.well-known|cdn-cgi)(?:\\/|$)/;
const OFFLINE_FALLBACK_HTML = '<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>NodeWarden</title><style>html,body{height:100%;margin:0;background:#eef4ff;color:#0f172a;font-family:ui-sans-serif,system-ui,sans-serif}.boot-screen{min-height:100%;display:grid;place-items:center;padding:24px;box-sizing:border-box}.boot-card{width:min(420px,100%);display:grid;gap:12px;justify-items:center;padding:28px;border:1px solid rgba(148,163,184,.35);border-radius:22px;background:rgba(255,255,255,.86);box-shadow:0 20px 45px rgba(15,23,42,.1)}.boot-logo{width:74px;height:58px;object-fit:contain}.boot-title{font-weight:700}.boot-sub{color:#475569;text-align:center;font-size:14px;line-height:1.5}</style></head><body><div class="boot-screen"><div class="boot-card"><img class="boot-logo" src="/nodewarden-logo.svg" alt=""><div class="boot-title">NodeWarden</div><div class="boot-sub">Offline cache is not ready on this device. Open NodeWarden once while online, then try offline again.</div></div></div></body></html>';
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(APP_SHELL_CACHE)
.then(async (cache) => {
await cache.addAll(CRITICAL_SHELL_URLS);
const nonCriticalUrls = PRECACHE_URLS.filter((url) => !CRITICAL_SHELL_URLS.includes(url));
await Promise.allSettled(nonCriticalUrls.map((url) => cache.add(url)));
})
.then(() => self.skipWaiting())
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys()
.then((keys) => Promise.all(
keys
.filter((key) => key.startsWith('nodewarden-pwa-') && key.endsWith('-shell') && key !== APP_SHELL_CACHE)
.map((key) => caches.delete(key))
))
.then(() => self.clients.claim())
);
});
function isSameOriginHttpGet(request) {
if (request.method !== 'GET') return false;
const url = new URL(request.url);
return url.origin === self.location.origin;
}
function isCacheableResponse(response) {
return response && response.ok && (response.type === 'basic' || response.type === 'default');
}
async function refreshNavigationCache(request) {
const cache = await caches.open(APP_SHELL_CACHE);
try {
const response = await fetch(request);
if (isCacheableResponse(response)) {
await cache.put('/', response.clone());
await cache.put('/index.html', response.clone());
await warmStaticDependencies(response.clone());
}
return response;
} catch {
return null;
}
}
async function warmStaticDependencies(response) {
try {
const html = await response.text();
const runtimeCache = await caches.open(RUNTIME_CACHE);
const urls = Array.from(html.matchAll(/\\b(?:src|href)=["']([^"']+)["']/g))
.map((match) => {
try {
return new URL(match[1], self.location.origin);
} catch {
return null;
}
})
.filter((url) => url && url.origin === self.location.origin && STATIC_PATH_RE.test(url.pathname))
.map((url) => url.pathname + url.search);
await Promise.allSettled(Array.from(new Set(urls)).map((url) => runtimeCache.add(url)));
await trimRuntimeCache(runtimeCache, 120);
} catch {
// Dependency warming is best-effort; never slow or break navigation for it.
}
}
async function appShellNavigation(request) {
const cache = await caches.open(APP_SHELL_CACHE);
const url = new URL(request.url);
return (
(await cache.match(request, { ignoreSearch: true }))
|| (await cache.match(url.pathname, { ignoreSearch: true }))
|| (await cache.match('/'))
|| (await cache.match('/index.html'))
|| new Response(OFFLINE_FALLBACK_HTML, {
status: 200,
headers: { 'Content-Type': 'text/html; charset=UTF-8' },
})
);
}
async function trimRuntimeCache(cache, maxEntries) {
const keys = await cache.keys();
if (keys.length <= maxEntries) return;
await Promise.all(keys.slice(0, keys.length - maxEntries).map((key) => cache.delete(key)));
}
async function cacheFirst(request) {
const shellCache = await caches.open(APP_SHELL_CACHE);
const cachedShell = await shellCache.match(request);
if (cachedShell) return cachedShell;
const runtimeCache = await caches.open(RUNTIME_CACHE);
const cachedRuntime = await runtimeCache.match(request);
if (cachedRuntime) return cachedRuntime;
const legacyRuntime = await matchLegacyRuntimeCache(request);
if (legacyRuntime) return legacyRuntime;
const response = await fetch(request);
if (isCacheableResponse(response)) {
void runtimeCache.put(request, response.clone()).then(() => trimRuntimeCache(runtimeCache, 120));
}
return response;
}
async function matchLegacyRuntimeCache(request) {
const keys = await caches.keys();
for (const key of keys) {
if (key === RUNTIME_CACHE || !key.startsWith('nodewarden-pwa-') || !key.endsWith('-runtime')) continue;
const cache = await caches.open(key);
const cached = await cache.match(request);
if (cached) return cached;
}
return null;
}
self.addEventListener('fetch', (event) => {
const request = event.request;
if (!isSameOriginHttpGet(request)) return;
const url = new URL(request.url);
if (NEVER_CACHE_PATH_RE.test(url.pathname)) return;
if (request.mode === 'navigate') {
event.respondWith(appShellNavigation(request));
if (navigator.onLine !== false) {
event.waitUntil(refreshNavigationCache(request));
}
return;
}
if (STATIC_PATH_RE.test(url.pathname) || request.destination === 'script' || request.destination === 'style' || request.destination === 'font' || request.destination === 'image' || request.destination === 'worker') {
event.respondWith(cacheFirst(request));
}
});
`;
}
function buildCacheVersion(isDemo: boolean, urls: string[]): string {
const digest = createHash('sha256')
.update(`${isDemo ? 'demo' : 'app'}\n${urls.join('\n')}`)
.digest('hex')
.slice(0, 16);
return `${isDemo ? 'demo' : 'app'}-${digest}`;
}
function pwaServiceWorkerPlugin(isDemo: boolean): Plugin {
return {
name: 'nodewarden-pwa-service-worker',
generateBundle(_, bundle) {
const urls = new Set<string>([
'/',
'/index.html',
'/vault',
'/manifest.webmanifest',
'/nodewarden-logo.svg',
'/nodewarden-logo-bg.svg',
'/nodewarden-wordmark.svg',
'/favicon.ico',
'/favicon-32.png',
'/apple-touch-icon.png',
'/icon-192.png',
'/icon-512.png',
'/logo-64.png',
]);
const buildUrls = new Set<string>(urls);
for (const [fileName, output] of Object.entries(bundle)) {
if (output.type !== 'chunk' && output.type !== 'asset') continue;
if (fileName === 'sw.js' || fileName === 'robots.txt') continue;
if (fileName.endsWith('.map')) continue;
buildUrls.add(`/${fileName}`);
}
const sortedUrls = Array.from(buildUrls).sort();
const version = buildCacheVersion(isDemo, Array.from(buildUrls).sort());
this.emitFile({
type: 'asset',
fileName: 'sw.js',
source: buildServiceWorkerSource(sortedUrls, version),
});
},
};
}
function searchIndexPolicyPlugin(isDemo: boolean): Plugin { function searchIndexPolicyPlugin(isDemo: boolean): Plugin {
return { return {
name: 'nodewarden-search-index-policy', name: 'nodewarden-search-index-policy',
@@ -59,7 +261,7 @@ export default defineConfig(({ mode }) => {
return { return {
root: rootDir, root: rootDir,
plugins: [preact(), searchIndexPolicyPlugin(isDemo), resourcePriorityPlugin(isDemo)], plugins: [preact(), searchIndexPolicyPlugin(isDemo), resourcePriorityPlugin(isDemo), pwaServiceWorkerPlugin(isDemo)],
define: { define: {
__NODEWARDEN_DEMO__: JSON.stringify(isDemo), __NODEWARDEN_DEMO__: JSON.stringify(isDemo),
}, },