mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat: add PWA offline unlock support
This commit is contained in:
+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",
|
||||||
|
|||||||
@@ -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" }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
@@ -746,6 +747,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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -12,6 +12,14 @@ 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 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 {
|
||||||
@@ -93,6 +101,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__;
|
||||||
@@ -248,6 +270,12 @@ export async function hydrateLockedSession(
|
|||||||
): Promise<{ session: SessionState | null; profile: Profile | null }> {
|
): Promise<{ session: SessionState | null; profile: Profile | null }> {
|
||||||
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 +300,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 +320,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 +345,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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,7 +377,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, kdfIterationsFromLogin(token, 600000));
|
||||||
}
|
}
|
||||||
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 +396,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 +432,56 @@ 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 && browserReportsOffline()) {
|
||||||
|
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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
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);
|
||||||
|
return {
|
||||||
|
session: {
|
||||||
|
...session,
|
||||||
|
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,11 @@
|
|||||||
|
export function registerNodeWardenServiceWorker(): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
if (!('serviceWorker' in navigator)) return;
|
||||||
|
if (import.meta.env.DEV) return;
|
||||||
|
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
void navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(() => {
|
||||||
|
// PWA support is progressive enhancement; the vault still works without it.
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
+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', '/vault'];
|
||||||
|
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(urls).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