mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
Compare commits
3 Commits
5ed7c949c1
...
615caf5946
| Author | SHA1 | Date | |
|---|---|---|---|
| 615caf5946 | |||
| 1a10df4a18 | |||
| d4749d3f82 |
+2
-2
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>}
|
||||||
|
<div className="standalone-title-row">
|
||||||
<h1 className="standalone-title">{props.title}</h1>
|
<h1 className="standalone-title">{props.title}</h1>
|
||||||
|
{props.titleAccessory}
|
||||||
|
</div>
|
||||||
{props.children}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "Синхронизировать хранилище",
|
||||||
|
|||||||
@@ -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": "同步",
|
||||||
|
|||||||
@@ -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": "同步",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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,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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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
@@ -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),
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user