mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-23 05:50:14 +00:00
feat: improve offline PWA resilience
This commit is contained in:
@@ -458,7 +458,7 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
|
||||
};
|
||||
|
||||
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 || {});
|
||||
headers.set('Authorization', `Bearer ${session.accessToken}`);
|
||||
headers.set('X-NodeWarden-Web', '1');
|
||||
|
||||
@@ -20,12 +20,14 @@ import {
|
||||
saveOfflineUnlockRecord,
|
||||
unlockOfflineVaultWithMasterKey,
|
||||
} from '@/lib/offline-auth';
|
||||
import { probeNodeWardenService } from '@/lib/network-status';
|
||||
import type { AppPhase, Profile, SessionState, TokenSuccess, WebBootstrapResponse } from '@/lib/types';
|
||||
|
||||
export interface PendingTotp {
|
||||
email: string;
|
||||
passwordHash: string;
|
||||
masterKey: Uint8Array;
|
||||
kdfIterations: number;
|
||||
}
|
||||
|
||||
export type JwtUnsafeReason = 'missing' | 'default' | 'too_short';
|
||||
@@ -268,6 +270,16 @@ export async function hydrateLockedSession(
|
||||
session: SessionState,
|
||||
fallbackProfile: Profile | null = 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);
|
||||
if (!refreshedSession?.accessToken) {
|
||||
if (hasOfflineUnlockRecord(session.email)) {
|
||||
@@ -357,6 +369,7 @@ export async function performPasswordLogin(
|
||||
email: normalizedEmail,
|
||||
passwordHash: derived.hash,
|
||||
masterKey: derived.masterKey,
|
||||
kdfIterations: derived.kdfIterations,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -377,7 +390,7 @@ export async function performTotpLogin(
|
||||
rememberDevice,
|
||||
});
|
||||
if ('access_token' in token && token.access_token) {
|
||||
return completeLogin(token, pendingTotp.email, pendingTotp.masterKey, kdfIterationsFromLogin(token, 600000));
|
||||
return completeLogin(token, pendingTotp.email, pendingTotp.masterKey, pendingTotp.kdfIterations);
|
||||
}
|
||||
const tokenError = token as { error_description?: string; error?: string };
|
||||
throw new Error(translateServerError(tokenError.error_description || tokenError.error, t('txt_totp_verify_failed')));
|
||||
@@ -455,8 +468,14 @@ export async function performUnlock(
|
||||
}
|
||||
};
|
||||
|
||||
if (hasOfflineUnlock && browserReportsOffline()) {
|
||||
return unlockOffline();
|
||||
if (hasOfflineUnlock) {
|
||||
if (browserReportsOffline()) {
|
||||
return unlockOffline();
|
||||
}
|
||||
const serviceReachable = await probeNodeWardenService();
|
||||
if (!serviceReachable) {
|
||||
return unlockOffline();
|
||||
}
|
||||
}
|
||||
|
||||
let token: TokenSuccess | { TwoFactorProviders?: unknown; error_description?: string; error?: string };
|
||||
@@ -493,6 +512,7 @@ export async function performUnlock(
|
||||
email: normalizedEmail,
|
||||
passwordHash: derived.hash,
|
||||
masterKey: derived.masterKey,
|
||||
kdfIterations: derived.kdfIterations,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -735,6 +735,7 @@ const en: Record<string, string> = {
|
||||
"txt_status": "Status",
|
||||
"txt_online": "Online",
|
||||
"txt_offline": "Offline",
|
||||
"txt_offline_vault_readonly": "Offline mode is read-only. Connect to NodeWarden before changing your vault.",
|
||||
"txt_submit": "Submit",
|
||||
"txt_sync": "Sync",
|
||||
"txt_sync_vault": "Sync Vault",
|
||||
|
||||
@@ -735,6 +735,7 @@ const es: Record<string, string> = {
|
||||
"txt_status": "Estado",
|
||||
"txt_online": "En línea",
|
||||
"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_sync": "Sincronizar",
|
||||
"txt_sync_vault": "Sincronizar bóveda",
|
||||
|
||||
@@ -735,6 +735,7 @@ const ru: Record<string, string> = {
|
||||
"txt_status": "Статус",
|
||||
"txt_online": "Онлайн",
|
||||
"txt_offline": "Офлайн",
|
||||
"txt_offline_vault_readonly": "Автономный режим доступен только для чтения. Подключитесь к NodeWarden, чтобы изменить хранилище.",
|
||||
"txt_submit": "Отправить",
|
||||
"txt_sync": "Синхронизировать",
|
||||
"txt_sync_vault": "Синхронизировать хранилище",
|
||||
|
||||
@@ -735,6 +735,7 @@ const zhCN: Record<string, string> = {
|
||||
"txt_status": "状态",
|
||||
"txt_online": "在线",
|
||||
"txt_offline": "离线",
|
||||
"txt_offline_vault_readonly": "当前为离线模式,只能查看密码库。连接到 NodeWarden 后才能修改。",
|
||||
"txt_submit": "提交",
|
||||
"txt_sync": "同步",
|
||||
"txt_sync_vault": "同步",
|
||||
|
||||
@@ -735,6 +735,7 @@ const zhTW: Record<string, string> = {
|
||||
"txt_status": "狀態",
|
||||
"txt_online": "在線",
|
||||
"txt_offline": "離線",
|
||||
"txt_offline_vault_readonly": "目前為離線模式,只能查看密碼庫。連線到 NodeWarden 後才能修改。",
|
||||
"txt_submit": "提交",
|
||||
"txt_sync": "同步",
|
||||
"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;
|
||||
}
|
||||
@@ -141,9 +141,10 @@ export async function unlockOfflineVaultWithMasterKey(
|
||||
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: {
|
||||
...session,
|
||||
...offlineSession,
|
||||
email: record.email,
|
||||
...keys,
|
||||
},
|
||||
|
||||
@@ -3,9 +3,16 @@ export function registerNodeWardenServiceWorker(): void {
|
||||
if (!('serviceWorker' in navigator)) return;
|
||||
if (import.meta.env.DEV) return;
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
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 { getCurrentNetworkStatus } from './network-status';
|
||||
import type { DecryptSendsArgs, DecryptVaultCoreArgs, DecryptVaultCoreResult } from './vault-decrypt';
|
||||
|
||||
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 {
|
||||
if (typeof Worker === 'undefined') return null;
|
||||
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.addEventListener('message', (event: MessageEvent<WorkerResponse<unknown>>) => {
|
||||
const message = event.data;
|
||||
|
||||
Reference in New Issue
Block a user