From 75a6a593dca576b2474c6627c746c2e4c8454f29 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Mon, 4 May 2026 04:19:02 +0800 Subject: [PATCH] Improve app startup and route fallbacks --- src/router-public.ts | 29 +- src/services/auth.ts | 22 +- webapp/index.html | 70 +++- webapp/src/App.tsx | 129 +++++-- webapp/src/components/AppMainRoutes.tsx | 24 +- webapp/src/components/NotFoundPage.tsx | 58 ++++ webapp/src/lib/api/auth.ts | 38 ++- webapp/src/lib/api/vault-sync.ts | 82 +++-- webapp/src/lib/app-preload.ts | 27 ++ webapp/src/lib/i18n.ts | 14 +- webapp/src/lib/vault-cache.ts | 3 +- webapp/src/main.tsx | 13 +- webapp/src/styles/auth.css | 426 ++++++++++++++++++++++++ webapp/vite.config.ts | 10 +- 14 files changed, 858 insertions(+), 87 deletions(-) create mode 100644 webapp/src/components/NotFoundPage.tsx create mode 100644 webapp/src/lib/app-preload.ts diff --git a/src/router-public.ts b/src/router-public.ts index 13bc079..dbd9455 100644 --- a/src/router-public.ts +++ b/src/router-public.ts @@ -141,6 +141,26 @@ function normalizeIconHost(rawHost: string): string | null { } } +const ICON_UPSTREAM_TIMEOUT_MS = 2500; + +async function fetchIconSource(source: { url: string; headers?: HeadersInit }): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), ICON_UPSTREAM_TIMEOUT_MS); + try { + return await fetch(source.url, { + headers: source.headers, + redirect: 'follow', + signal: controller.signal, + cf: { + cacheEverything: true, + cacheTtl: LIMITS.cache.iconTtlSeconds, + }, + } as RequestInit & { cf: { cacheEverything: boolean; cacheTtl: number } }); + } finally { + clearTimeout(timeout); + } +} + async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-found' = 'default'): Promise { const normalizedHost = normalizeIconHost(host); if (!normalizedHost) return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon(); @@ -164,14 +184,7 @@ async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-fo try { for (const source of upstreamSources) { - const resp = await fetch(source.url, { - headers: source.headers, - redirect: 'follow', - cf: { - cacheEverything: true, - cacheTtl: LIMITS.cache.iconTtlSeconds, - }, - } as RequestInit & { cf: { cacheEverything: boolean; cacheTtl: number } }); + const resp = await fetchIconSource(source); if (!resp.ok) continue; const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase(); diff --git a/src/services/auth.ts b/src/services/auth.ts index 0f35a7f..755dd2f 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -57,6 +57,12 @@ export class AuthService { return user; } + private async getFreshUser(userId: string): Promise { + const user = await this.storage.getUserById(userId); + this.writeCachedUser(userId, user); + return user; + } + private readCachedDevice(userId: string, deviceId: string) { const cacheKey = `${userId}:${deviceId}`; const cached = AuthService.deviceCache.get(cacheKey); @@ -84,6 +90,12 @@ export class AuthService { return device; } + private async getFreshDevice(userId: string, deviceId: string) { + const device = await this.storage.getDevice(userId, deviceId); + this.writeCachedDevice(userId, deviceId, device); + return device; + } + // Second-layer hash: PBKDF2-SHA256(clientHash, email-salt, iterations). // Ensures database contents alone cannot be used to authenticate (pass-the-hash defense). // Result is prefixed with "$s$" to distinguish from legacy raw client hashes. @@ -162,7 +174,10 @@ export class AuthService { const payload = await verifyJWT(parts[1], this.env.JWT_SECRET); if (!payload) return null; - const user = await this.getCachedUser(payload.sub); + let user = await this.getCachedUser(payload.sub); + if (!user || user.status !== 'active' || payload.sstamp !== user.securityStamp) { + user = await this.getFreshUser(payload.sub); + } if (!user) return null; if (user.status !== 'active') return null; @@ -171,7 +186,10 @@ export class AuthService { } if (payload.did) { - const device = await this.getCachedDevice(user.id, payload.did); + let device = await this.getCachedDevice(user.id, payload.did); + if (!device || !payload.dstamp || payload.dstamp !== device.sessionStamp) { + device = await this.getFreshDevice(user.id, payload.did); + } if (!device) return null; if (!payload.dstamp || payload.dstamp !== device.sessionStamp) return null; } diff --git a/webapp/index.html b/webapp/index.html index 1113ba7..d08ed08 100644 --- a/webapp/index.html +++ b/webapp/index.html @@ -20,9 +20,75 @@ NodeWarden + -
+
+
+
+ +
+
+
+
+
- \ No newline at end of file + diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index a9331d0..3ed2643 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -1,9 +1,10 @@ import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import { useLocation } from 'wouter'; -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import AppAuthenticatedShell from '@/components/AppAuthenticatedShell'; import AppGlobalOverlays, { type AppConfirmState } from '@/components/AppGlobalOverlays'; import AuthViews from '@/components/AuthViews'; +import NotFoundPage from '@/components/NotFoundPage'; import PublicSendPage from '@/components/PublicSendPage'; import RecoverTwoFactorPage from '@/components/RecoverTwoFactorPage'; import JwtWarningPage from '@/components/JwtWarningPage'; @@ -29,6 +30,7 @@ import { parseSignalRTextFrames, readInviteCodeFromUrl, } from '@/lib/app-support'; +import { preloadAuthenticatedWorkspace } from '@/lib/app-preload'; import { bootstrapAppSession, type CompletedLogin, @@ -71,10 +73,32 @@ const IMPORT_ROUTE_PATHS = [IMPORT_ROUTE, '/tools/import', '/tools/import-export const IMPORT_ROUTE_ALIASES: ReadonlySet = new Set(IMPORT_ROUTE_PATHS.filter((path) => path !== IMPORT_ROUTE)); const SETTINGS_HOME_ROUTE = '/settings'; const SETTINGS_ACCOUNT_ROUTE = '/settings/account'; +const AUTH_ROUTE_PATHS = ['/', '/login', '/register', '/lock', '/recover-2fa'] as const; +const APP_ROUTE_PATHS = [ + '/', + '/vault', + '/vault/totp', + '/sends', + '/admin', + '/security/devices', + '/backup', + '/settings', + SETTINGS_ACCOUNT_ROUTE, + '/help', + ...IMPORT_ROUTE_PATHS, +] as const; +const AUTH_ROUTES: ReadonlySet = new Set(AUTH_ROUTE_PATHS); +const APP_ROUTES: ReadonlySet = new Set(APP_ROUTE_PATHS); function isAdminProfile(profile: Profile | null): profile is Profile { return String(profile?.role || '').toLowerCase() === 'admin'; } + +function normalizeRoutePath(path: string): string { + const pathOnly = String(path || '/').split('?')[0].split('#')[0]; + const normalized = pathOnly.startsWith('/') ? pathOnly : `/${pathOnly}`; + return normalized.length > 1 ? normalized.replace(/\/+$/, '') : '/'; +} const THEME_STORAGE_KEY = 'nodewarden.theme.preference.v1'; const SIGNALR_RECORD_SEPARATOR = String.fromCharCode(0x1e); const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5; @@ -117,6 +141,7 @@ export default function App() { const initialBootstrap = useMemo(() => readInitialAppBootstrapState(), []); const initialInviteCode = useMemo(() => readInviteCodeFromUrl(), []); const initialProfileSnapshot = useMemo(() => loadProfileSnapshot(initialBootstrap.session?.email), [initialBootstrap]); + const queryClient = useQueryClient(); const [pendingAuthAction, setPendingAuthAction] = useState<'login' | 'register' | 'unlock' | null>(null); const [location, navigate] = useLocation(); const [phase, setPhase] = useState(initialBootstrap.phase); @@ -169,6 +194,8 @@ export default function App() { const [decryptedSends, setDecryptedSends] = useState([]); const [cachedVaultCore, setCachedVaultCore] = useState(null); const [vaultInitialDecryptDone, setVaultInitialDecryptDone] = useState(false); + const [vaultDecryptError, setVaultDecryptError] = useState(''); + const [sendsDecryptDone, setSendsDecryptDone] = useState(false); const sessionRef = useRef(initialBootstrap.session); const silentRefreshVaultRef = useRef<() => Promise>(async () => {}); const refreshAuthorizedDevicesRef = useRef<() => Promise>(async () => {}); @@ -769,12 +796,25 @@ export default function App() { const encryptedVaultCore = vaultCoreQuery.data || cachedVaultCore; const encryptedFolders = encryptedVaultCore?.folders; const encryptedCiphers = encryptedVaultCore?.ciphers; + const encryptedSendsFromSync = encryptedVaultCore?.sends; + const sendsQueryKey = useMemo(() => ['sends', vaultCacheKey || session?.email] as const, [vaultCacheKey, session?.email]); const sendsQuery = useQuery({ - queryKey: ['sends', vaultCacheKey || session?.email], + queryKey: sendsQueryKey, queryFn: () => getSends(authedFetch), - enabled: phase === 'app' && !!session?.symEncKey && !!session?.symMacKey && (vaultInitialDecryptDone || location === '/sends'), + enabled: phase === 'app' && !!session?.symEncKey && !!session?.symMacKey && location === '/sends' && !encryptedSendsFromSync, staleTime: 30_000, }); + const encryptedSends = sendsQuery.data || encryptedSendsFromSync; + async function refetchSendsFromVaultCore() { + const result = await refetchVaultCoreData() as { data?: VaultCoreSnapshot }; + const sends = Array.isArray(result.data?.sends) ? result.data.sends : []; + queryClient.setQueryData(sendsQueryKey, sends); + return { data: sends }; + } + useEffect(() => { + if (!Array.isArray(encryptedSendsFromSync)) return; + queryClient.setQueryData(sendsQueryKey, encryptedSendsFromSync); + }, [queryClient, sendsQueryKey, encryptedSendsFromSync]); const profileQuery = useQuery({ queryKey: ['profile', vaultCacheKey || session?.email], queryFn: () => getProfile(authedFetch), @@ -811,6 +851,17 @@ export default function App() { enabled: phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone, staleTime: 30_000, }); + useQuery({ + queryKey: ['admin-backup-settings', vaultCacheKey], + queryFn: () => backupActions.loadSettings(), + enabled: phase === 'app' && isAdmin && vaultInitialDecryptDone, + staleTime: 30_000, + }); + + useEffect(() => { + if (phase !== 'app' || !vaultInitialDecryptDone) return; + void preloadAuthenticatedWorkspace(isAdmin); + }, [phase, vaultInitialDecryptDone, isAdmin]); useEffect(() => { if (phase !== 'app' || !session?.accessToken || !session?.symEncKey || !session?.symMacKey) return; @@ -833,6 +884,8 @@ export default function App() { setDecryptedCiphers([]); setDecryptedSends([]); setVaultInitialDecryptDone(false); + setVaultDecryptError(''); + setSendsDecryptDone(false); return; } if (!encryptedFolders || !encryptedCiphers) return; @@ -840,6 +893,7 @@ export default function App() { let active = true; (async () => { try { + setVaultDecryptError(''); let result; try { result = await decryptVaultCoreInWorker({ @@ -863,7 +917,10 @@ export default function App() { setVaultInitialDecryptDone(true); } catch (error) { if (!active) return; - pushToast('error', error instanceof Error ? error.message : t('txt_decrypt_failed_2')); + const message = error instanceof Error ? error.message : t('txt_decrypt_failed_2'); + setVaultDecryptError(message); + setVaultInitialDecryptDone(true); + pushToast('error', message); } })(); @@ -875,24 +932,34 @@ export default function App() { useEffect(() => { if (!session?.symEncKey || !session?.symMacKey) { setDecryptedSends([]); + setSendsDecryptDone(false); + return; + } + if (!encryptedSends) { + setSendsDecryptDone(false); + return; + } + if (!encryptedSends.length) { + setDecryptedSends([]); + setSendsDecryptDone(true); return; } - if (!sendsQuery.data) return; let active = true; + setSendsDecryptDone(false); (async () => { try { let sends; try { sends = await decryptSendsInWorker({ - sends: sendsQuery.data, + sends: encryptedSends, symEncKeyB64: session.symEncKey!, symMacKeyB64: session.symMacKey!, origin: window.location.origin, }); } catch { sends = await decryptSends({ - sends: sendsQuery.data, + sends: encryptedSends, symEncKeyB64: session.symEncKey!, symMacKeyB64: session.symMacKey!, origin: window.location.origin, @@ -901,8 +968,10 @@ export default function App() { if (!active) return; setDecryptedSends(sends); + setSendsDecryptDone(true); } catch (error) { if (!active) return; + setSendsDecryptDone(true); pushToast('error', error instanceof Error ? error.message : t('txt_decrypt_failed_2')); } })(); @@ -910,18 +979,14 @@ export default function App() { return () => { active = false; }; - }, [session?.symEncKey, session?.symMacKey, sendsQuery.data]); + }, [session?.symEncKey, session?.symMacKey, encryptedSends]); async function refreshVaultSilently() { if (pendingVaultCoreRefreshRef.current) { await pendingVaultCoreRefreshRef.current; return; } - const tasks: Promise[] = [refetchVaultCoreData()]; - if (location === '/sends') { - tasks.push(sendsQuery.refetch()); - } - const request = Promise.all(tasks).finally(() => { + const request = refetchVaultCoreData().finally(() => { if (pendingVaultCoreRefreshRef.current === request) { pendingVaultCoreRefreshRef.current = null; } @@ -1087,7 +1152,7 @@ export default function App() { const result = await refetchVaultCoreData() as { data?: VaultCoreSnapshot }; return { data: result.data?.folders }; }, - refetchSends: sendsQuery.refetch, + refetchSends: refetchSendsFromVaultCore, onNotify: pushToast, patchDecryptedCiphers: setDecryptedCiphers, patchDecryptedFolders: setDecryptedFolders, @@ -1127,11 +1192,17 @@ export default function App() { const trimmedHashPath = hashPathOnly.replace(/^\/+/, '').replace(/\/+$/, ''); const normalizedHashPath = trimmedHashPath ? `/${trimmedHashPath}` : '/'; const isImportHashRoute = IMPORT_ROUTE_ALIASES.has(normalizedHashPath); - const effectiveLocation = hashPath.startsWith('/send/') || hashPath === '/recover-2fa' ? hashPath : location; + const normalizedLocation = normalizeRoutePath(location); + const routeLocation = hashPath.startsWith('/') ? normalizedHashPath : normalizedLocation; + const effectiveLocation = routeLocation; const publicSendMatch = effectiveLocation.match(/^\/send\/([^/]+)(?:\/([^/]+))?\/?$/i); const isRecoverTwoFactorRoute = effectiveLocation === '/recover-2fa'; const isPublicSendRoute = !!publicSendMatch; - const isImportRoute = location === IMPORT_ROUTE || IMPORT_ROUTE_ALIASES.has(location); + const isMalformedSendRoute = /^\/send(?:\/|$)/i.test(effectiveLocation) && !publicSendMatch; + const isKnownAuthRoute = AUTH_ROUTES.has(routeLocation) || isPublicSendRoute || isRecoverTwoFactorRoute; + const isKnownAppRoute = APP_ROUTES.has(routeLocation) || isPublicSendRoute || isImportHashRoute; + const isUnknownRoute = isMalformedSendRoute || (phase === 'app' ? !isKnownAppRoute : !isKnownAuthRoute && !APP_ROUTES.has(routeLocation)); + const isImportRoute = routeLocation === IMPORT_ROUTE || IMPORT_ROUTE_ALIASES.has(routeLocation); const showSidebarToggle = mobileLayout && (location === '/vault' || location === '/sends'); const sidebarToggleTitle = location === '/vault' ? t('txt_folders') : t('txt_type'); const mobilePrimaryRoute = @@ -1178,6 +1249,7 @@ export default function App() { const mainRoutesProps = { profile, + profileLoading: profileQuery.isFetching && !profile, session, mobileLayout, mobileSidebarToggleKey, @@ -1187,16 +1259,20 @@ export default function App() { decryptedCiphers, decryptedFolders, decryptedSends, - ciphersLoading: vaultCoreQuery.isFetching && !encryptedVaultCore, - foldersLoading: vaultCoreQuery.isFetching && !encryptedVaultCore, - sendsLoading: sendsQuery.isFetching && !sendsQuery.data, + vaultError: vaultCoreQuery.isError && !encryptedVaultCore ? t('txt_load_vault_failed') : vaultDecryptError, + ciphersLoading: !(vaultCoreQuery.isError && !encryptedVaultCore) && !vaultDecryptError && !vaultInitialDecryptDone, + foldersLoading: !(vaultCoreQuery.isError && !encryptedVaultCore) && !vaultDecryptError && !vaultInitialDecryptDone, + sendsLoading: (sendsQuery.isFetching && !encryptedSends) || (!!encryptedSends && !sendsDecryptDone), users: usersQuery.data || [], invites: invitesQuery.data || [], + adminLoading: (usersQuery.isFetching && !usersQuery.data) || (invitesQuery.isFetching && !invitesQuery.data), + adminError: usersQuery.isError || invitesQuery.isError ? t('txt_load_admin_data_failed') : '', totpEnabled: !!totpStatusQuery.data?.enabled, lockTimeoutMinutes, sessionTimeoutAction, authorizedDevices: authorizedDevicesQuery.data || [], authorizedDevicesLoading: authorizedDevicesQuery.isFetching, + authorizedDevicesError: authorizedDevicesQuery.isError && !authorizedDevicesQuery.data ? t('txt_load_devices_failed') : '', onNavigate: navigate, onLogout: handleLogout, onNotify: pushToast, @@ -1258,7 +1334,11 @@ export default function App() { onExportBackup: backupActions.exportBackup, onImportBackup: backupActions.importBackup, onImportBackupAllowingChecksumMismatch: backupActions.importBackupAllowingChecksumMismatch, - onLoadBackupSettings: backupActions.loadSettings, + onLoadBackupSettings: () => queryClient.ensureQueryData({ + queryKey: ['admin-backup-settings', vaultCacheKey], + queryFn: () => backupActions.loadSettings(), + staleTime: 30_000, + }), onSaveBackupSettings: backupActions.saveSettings, onRunRemoteBackup: backupActions.runRemoteBackup, onListRemoteBackups: backupActions.listRemoteBackups, @@ -1282,6 +1362,15 @@ export default function App() { ); } + if (isUnknownRoute) { + return ( + <> + + {renderPassiveOverlays()} + + ); + } + if (isRecoverTwoFactorRoute && phase !== 'app') { return ( <> diff --git a/webapp/src/components/AppMainRoutes.tsx b/webapp/src/components/AppMainRoutes.tsx index 1e4cc3f..b63c7cb 100644 --- a/webapp/src/components/AppMainRoutes.tsx +++ b/webapp/src/components/AppMainRoutes.tsx @@ -3,6 +3,7 @@ import { useEffect } from 'preact/hooks'; import { Link, Route, Switch } from 'wouter'; import { ArrowUpDown, Cloud, LogOut, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact'; import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage'; +import LoadingState from '@/components/LoadingState'; import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup'; import type { CiphersImportPayload } from '@/lib/api/vault'; import { t } from '@/lib/i18n'; @@ -19,7 +20,7 @@ const BackupCenterPage = lazy(() => import('@/components/BackupCenterPage')); const ImportPage = lazy(() => import('@/components/ImportPage')); function RouteContentFallback() { - return
{t('txt_loading_nodewarden')}
; + return ; } function LegacyBackupRedirect(props: { onNavigate: (path: string) => void }) { @@ -31,6 +32,7 @@ function LegacyBackupRedirect(props: { onNavigate: (path: string) => void }) { export interface AppMainRoutesProps { profile: Profile | null; + profileLoading: boolean; session: SessionState | null; mobileLayout: boolean; mobileSidebarToggleKey: number; @@ -40,16 +42,20 @@ export interface AppMainRoutesProps { decryptedCiphers: Cipher[]; decryptedFolders: VaultFolder[]; decryptedSends: Send[]; + vaultError: string; ciphersLoading: boolean; foldersLoading: boolean; sendsLoading: boolean; users: AdminUser[]; invites: AdminInvite[]; + adminLoading: boolean; + adminError: string; totpEnabled: boolean; lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30; sessionTimeoutAction: 'lock' | 'logout'; authorizedDevices: AuthorizedDevice[]; authorizedDevicesLoading: boolean; + authorizedDevicesError: string; onNavigate: (path: string) => void; onLogout: () => void; onNotify: (type: 'success' | 'error' | 'warning', text: string) => void; @@ -187,6 +193,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) { ciphers={props.decryptedCiphers} folders={props.decryptedFolders} loading={props.ciphersLoading || props.foldersLoading} + error={props.vaultError} emailForReprompt={props.profile?.email || props.session?.email || ''} onRefresh={props.onRefreshVault} onCreate={props.onCreateVaultItem} @@ -216,7 +223,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) { - {props.profile && ( + {props.profile ? (
{props.mobileLayout && (
@@ -245,10 +252,12 @@ export default function AppMainRoutes(props: AppMainRoutesProps) { />
- )} + ) : props.profileLoading ? ( + + ) : null} - {props.profile && ( + {props.profile ? (
@@ -281,7 +290,9 @@ export default function AppMainRoutes(props: AppMainRoutesProps) { {t('txt_sign_out')}
- )} + ) : props.profileLoading ? ( + + ) : null}
@@ -297,6 +308,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) { void props.onRefreshAuthorizedDevices()} onRenameDevice={props.onRenameAuthorizedDevice} onRevokeTrust={props.onRevokeDeviceTrust} @@ -322,6 +334,8 @@ export default function AppMainRoutes(props: AppMainRoutesProps) { currentUserId={props.profile?.id || ''} users={props.users} invites={props.invites} + loading={props.adminLoading} + error={props.adminError} onRefresh={props.onRefreshAdmin} onCreateInvite={props.onCreateInvite} onDeleteAllInvites={props.onDeleteAllInvites} diff --git a/webapp/src/components/NotFoundPage.tsx b/webapp/src/components/NotFoundPage.tsx new file mode 100644 index 0000000..efc5ff0 --- /dev/null +++ b/webapp/src/components/NotFoundPage.tsx @@ -0,0 +1,58 @@ +import { Home } from 'lucide-preact'; +import { t } from '@/lib/i18n'; + +interface NotFoundPageProps { + title?: string; + message?: string; + homeHref?: string; +} + +export default function NotFoundPage(props: NotFoundPageProps) { + const starBoxes = [1, 2, 3, 4]; + const stars = [1, 2, 3, 4, 5, 6, 7]; + + return ( +
+ + +
+
+ NodeWarden logo + +
+ +
+
+ ); +} diff --git a/webapp/src/lib/api/auth.ts b/webapp/src/lib/api/auth.ts index f637514..5a4fe04 100644 --- a/webapp/src/lib/api/auth.ts +++ b/webapp/src/lib/api/auth.ts @@ -46,6 +46,8 @@ interface RefreshSuccess { type RefreshResult = RefreshFailure | RefreshSuccess; +const pendingRefreshes = new Map>(); + function randomHex(length: number): string { const bytes = crypto.getRandomValues(new Uint8Array(Math.max(1, Math.ceil(length / 2)))); return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, length); @@ -312,6 +314,25 @@ export async function refreshAccessToken(session: SessionState): Promise { + const key = refreshKey(session); + const existing = pendingRefreshes.get(key); + if (existing) return existing; + + const request = refreshAccessToken(session).finally(() => { + if (pendingRefreshes.get(key) === request) { + pendingRefreshes.delete(key); + } + }); + pendingRefreshes.set(key, request); + return request; +} + export async function revokeCurrentSession(session: SessionState | null): Promise { const body = new URLSearchParams(); if (session?.authMode !== 'web-cookie' && session?.refreshToken) { @@ -436,7 +457,16 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess let resp = await retryableRequest(headers); if (resp.status !== 401 || (!session.refreshToken && session.authMode !== 'web-cookie')) return resp; - const refreshed = await refreshAccessToken(session); + const latest = getSession(); + if (latest?.accessToken && latest.accessToken !== session.accessToken) { + const latestHeaders = new Headers(init.headers || {}); + latestHeaders.set('Authorization', `Bearer ${latest.accessToken}`); + resp = await retryableRequest(latestHeaders); + if (resp.status !== 401) return resp; + } + + const refreshSource = latest || session; + const refreshed = await refreshAccessTokenOnce(refreshSource); if (!refreshed.ok) { if (refreshed.transient) { throw new Error(refreshed.error || 'Session refresh temporarily unavailable'); @@ -446,10 +476,10 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess } const nextSession: SessionState = { - ...session, + ...refreshSource, accessToken: refreshed.token.access_token, - refreshToken: refreshed.token.refresh_token || session.refreshToken, - authMode: refreshed.token.web_session ? 'web-cookie' : (session.authMode || 'token'), + refreshToken: refreshed.token.refresh_token || refreshSource.refreshToken, + authMode: refreshed.token.web_session ? 'web-cookie' : (refreshSource.authMode || 'token'), }; setSession(nextSession); saveSession(nextSession); diff --git a/webapp/src/lib/api/vault-sync.ts b/webapp/src/lib/api/vault-sync.ts index 0ffda4e..ada3bae 100644 --- a/webapp/src/lib/api/vault-sync.ts +++ b/webapp/src/lib/api/vault-sync.ts @@ -16,6 +16,15 @@ function normalizeSnapshot(body: VaultSyncResponse | null | undefined): VaultCor return { ciphers: Array.isArray(body?.ciphers) ? body!.ciphers! : [], folders: Array.isArray(body?.folders) ? body!.folders! : [], + sends: Array.isArray(body?.sends) ? body!.sends! : [], + }; +} + +function normalizeCachedSnapshot(snapshot: Partial | null | undefined): VaultCoreSnapshot { + return { + ciphers: Array.isArray(snapshot?.ciphers) ? snapshot.ciphers : [], + folders: Array.isArray(snapshot?.folders) ? snapshot.folders : [], + sends: Array.isArray(snapshot?.sends) ? snapshot.sends : [], }; } @@ -26,49 +35,70 @@ export async function getCachedVaultCoreSnapshot(cacheKey: string): Promise { const normalizedKey = String(cacheKey || '').trim(); - if (!normalizedKey) return { ciphers: [], folders: [] }; + if (!normalizedKey) return { ciphers: [], folders: [], sends: [] }; const existing = pendingVaultCoreRequests.get(normalizedKey); if (existing) return existing; const request = (async () => { - const revisionStamp = await getVaultRevisionDate(authedFetch); const memory = memoryVaultCoreCache.get(normalizedKey); - if (memory?.revisionStamp === revisionStamp) { - return memory.snapshot; - } - - const cached = await loadCachedVaultCoreSnapshot(normalizedKey); - if (cached?.revisionStamp === revisionStamp && cached.snapshot) { + let cached = await loadCachedVaultCoreSnapshot(normalizedKey); + if (!memory && cached?.snapshot) { + const snapshot = normalizeCachedSnapshot(cached.snapshot); memoryVaultCoreCache.set(normalizedKey, { - revisionStamp, - snapshot: cached.snapshot, + revisionStamp: cached.revisionStamp, + snapshot, }); - return cached.snapshot; } - const resp = await authedFetch('/api/sync?excludeSends=true&excludeDomains=true', { - cache: 'no-store', - headers: { - 'Cache-Control': 'no-cache', - Pragma: 'no-cache', - }, - }); - if (!resp.ok) throw new Error('Failed to load vault'); - const body = await parseJson(resp); - const snapshot = normalizeSnapshot(body); - memoryVaultCoreCache.set(normalizedKey, { revisionStamp, snapshot }); - void saveCachedVaultCoreSnapshot(normalizedKey, revisionStamp, snapshot); - return snapshot; + try { + const revisionStamp = await getVaultRevisionDate(authedFetch); + const currentMemory = memoryVaultCoreCache.get(normalizedKey); + if (currentMemory?.revisionStamp === revisionStamp) { + return currentMemory.snapshot; + } + + if (!cached) { + cached = await loadCachedVaultCoreSnapshot(normalizedKey); + } + if (cached?.revisionStamp === revisionStamp && cached.snapshot) { + const snapshot = normalizeCachedSnapshot(cached.snapshot); + memoryVaultCoreCache.set(normalizedKey, { + revisionStamp, + snapshot, + }); + return snapshot; + } + + const resp = await authedFetch('/api/sync', { + cache: 'no-store', + headers: { + 'Cache-Control': 'no-cache', + Pragma: 'no-cache', + }, + }); + if (!resp.ok) throw new Error('Failed to load vault'); + const body = await parseJson(resp); + const snapshot = normalizeSnapshot(body); + memoryVaultCoreCache.set(normalizedKey, { revisionStamp, snapshot }); + void saveCachedVaultCoreSnapshot(normalizedKey, revisionStamp, snapshot); + return snapshot; + } catch (error) { + const fallbackMemory = memoryVaultCoreCache.get(normalizedKey); + if (fallbackMemory?.snapshot) return fallbackMemory.snapshot; + if (cached?.snapshot) return normalizeCachedSnapshot(cached.snapshot); + throw error; + } })(); pendingVaultCoreRequests.set(normalizedKey, request); diff --git a/webapp/src/lib/app-preload.ts b/webapp/src/lib/app-preload.ts new file mode 100644 index 0000000..780c2a2 --- /dev/null +++ b/webapp/src/lib/app-preload.ts @@ -0,0 +1,27 @@ +let workspacePreload: Promise | null = null; +let adminPreload: Promise | null = null; + +export function preloadAuthenticatedWorkspace(isAdmin: boolean): Promise { + if (!workspacePreload) { + workspacePreload = Promise.allSettled([ + import('@/components/SendsPage'), + import('@/components/TotpCodesPage'), + import('@/components/SettingsPage'), + import('@/components/SecurityDevicesPage'), + ]); + } + + if (!isAdmin) { + return workspacePreload; + } + + if (!adminPreload) { + adminPreload = Promise.allSettled([ + workspacePreload, + import('@/components/AdminPage'), + import('@/components/BackupCenterPage'), + ]); + } + + return adminPreload; +} diff --git a/webapp/src/lib/i18n.ts b/webapp/src/lib/i18n.ts index 7aa25c8..49acf26 100644 --- a/webapp/src/lib/i18n.ts +++ b/webapp/src/lib/i18n.ts @@ -5,6 +5,8 @@ export type Locale = | 'ru' | 'es'; +import enMessages from './i18n/locales/en'; + const LOCALE_STORAGE_KEY = 'nodewarden.locale'; type MessageTable = Record; @@ -18,8 +20,8 @@ export const AVAILABLE_LOCALES: readonly { value: Locale; label: string }[] = [ ]; let locale: Locale = resolveInitialLocale(); -let activeMessages: MessageTable = {}; -const loadedMessages = new Map(); +let activeMessages: MessageTable = enMessages; +const loadedMessages = new Map([['en', enMessages]]); function isLocale(value: unknown): value is Locale { return AVAILABLE_LOCALES.some((item) => item.value === value); @@ -46,7 +48,7 @@ function resolveInitialLocale(): Locale { } const localeLoaders: Record Promise<{ default: MessageTable }>> = { - en: () => import('./i18n/locales/en'), + en: () => Promise.resolve({ default: enMessages }), 'zh-CN': () => import('./i18n/locales/zh-CN'), 'zh-TW': () => import('./i18n/locales/zh-TW'), ru: () => import('./i18n/locales/ru'), @@ -63,11 +65,7 @@ async function loadLocaleMessages(next: Locale): Promise { } async function loadFallbackMessages(): Promise { - const cached = loadedMessages.get('en'); - if (cached) return cached; - const mod = await import('./i18n/locales/en'); - loadedMessages.set('en', mod.default); - return mod.default; + return enMessages; } export type I18nParams = Record; diff --git a/webapp/src/lib/vault-cache.ts b/webapp/src/lib/vault-cache.ts index 802b2d2..f72413b 100644 --- a/webapp/src/lib/vault-cache.ts +++ b/webapp/src/lib/vault-cache.ts @@ -1,8 +1,9 @@ -import type { Cipher, Folder } from './types'; +import type { Cipher, Folder, Send } from './types'; export interface VaultCoreSnapshot { ciphers: Cipher[]; folders: Folder[]; + sends: Send[]; } interface VaultCoreCacheRecord { diff --git a/webapp/src/main.tsx b/webapp/src/main.tsx index 60c2193..c7c709a 100644 --- a/webapp/src/main.tsx +++ b/webapp/src/main.tsx @@ -15,14 +15,19 @@ const queryClient = new QueryClient({ }, }); -async function bootstrap(): Promise { - await initI18n(); +const root = document.getElementById('root')!; + +function renderApp(): void { render( , - document.getElementById('root')! + root ); } -void bootstrap(); +renderApp(); + +void initI18n().then(() => { + renderApp(); +}); diff --git a/webapp/src/styles/auth.css b/webapp/src/styles/auth.css index 8c69004..03b739c 100644 --- a/webapp/src/styles/auth.css +++ b/webapp/src/styles/auth.css @@ -19,6 +19,427 @@ @apply mt-2.5; } +.public-send-card-head { + @apply mb-2 flex items-center justify-between gap-2 text-sm font-bold; + color: var(--muted); +} + +.public-send-copy-btn { + @apply h-8 shrink-0 px-3 text-sm; +} + +.not-found-page { + @apply relative grid min-h-full place-items-center overflow-hidden p-6 text-center; + background: + radial-gradient(circle at 50% 42%, rgba(28, 118, 255, 0.24), transparent 27rem), + radial-gradient(circle at 16% 84%, rgba(22, 163, 255, 0.10), transparent 22rem), + linear-gradient(180deg, #020b1a 0%, #061328 48%, #0a1730 100%); +} + +.not-found-shell { + @apply relative z-20 grid w-full max-w-[620px] justify-items-center gap-5 px-4 py-7 text-center; + background: transparent; + border: 0; + box-shadow: none; +} + +.not-found-space { + @apply pointer-events-none absolute inset-0 overflow-hidden; + height: 100%; + min-height: 100%; +} + +@keyframes not-found-star-fall { + 0% { + opacity: 0; + transform: translateY(-100vh); + } + + 20% { + opacity: 1; + } + + 100% { + opacity: 1; + transform: translateY(100vh); + } +} + +@keyframes not-found-astronaut-spin { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} + +.not-found-star-box { + @apply absolute left-0 top-0 z-10 h-full w-full; +} + +.not-found-star-box-1 { + animation: not-found-star-fall 9s linear infinite; +} + +.not-found-star-box-2 { + animation: not-found-star-fall 9s -2.1s linear infinite; +} + +.not-found-star-box-3 { + animation: not-found-star-fall 9s -4.3s linear infinite; +} + +.not-found-star-box-4 { + animation: not-found-star-fall 9s -6.4s linear infinite; +} + +.not-found-star { + @apply absolute h-[3px] w-[3px] rounded-full; + background: #fff; + box-shadow: 0 0 10px rgba(255, 255, 255, 0.82); + opacity: 0.72; +} + +.not-found-star::before, +.not-found-star::after { + @apply absolute rounded-full; + content: ""; + background: #fff; + box-shadow: 0 0 12px rgba(255, 255, 255, 0.7); +} + +.not-found-star::before { + @apply h-[5px] w-[5px]; + top: 72px; + left: 78px; + opacity: 0.65; +} + +.not-found-star::after { + @apply h-[7px] w-[7px]; + top: 10px; + left: 168px; + opacity: 0.88; +} + +.not-found-star-position-1 { + top: 5vh; + left: 8%; +} + +.not-found-star-position-2 { + top: 23vh; + left: 21%; +} + +.not-found-star-position-3 { + top: 42vh; + left: 38%; +} + +.not-found-star-position-4 { + top: 61vh; + left: 56%; +} + +.not-found-star-position-5 { + top: 15vh; + left: 70%; +} + +.not-found-star-position-6 { + top: 34vh; + left: 82%; +} + +.not-found-star-position-7 { + top: 72vh; + left: 93%; +} + +.not-found-astronaut { + @apply relative z-10; + width: 220px; + height: 264px; + animation: not-found-astronaut-spin 5s linear infinite; + filter: drop-shadow(0 28px 36px rgba(0, 0, 0, 0.28)); +} + +.not-found-astro-stage { + @apply relative grid place-items-center; + height: 280px; +} + +.not-found-astro-pack { + @apply absolute z-[1]; + top: 57px; + left: 66px; + width: 88px; + height: 132px; + background: #8db0c7; + border-radius: 44px 44px 0 0 / 27px 27px 0 0; +} + +.not-found-astro-head { + @apply absolute z-[3] rounded-full; + top: 30px; + left: 68px; + width: 85px; + height: 70px; + background: linear-gradient(90deg, #e4ebef 0%, #e4ebef 50%, #fbfdfa 50%, #fbfdfa 100%); +} + +.not-found-astro-head::after { + @apply absolute rounded-[13px]; + content: ""; + top: 13px; + left: 16px; + width: 53px; + height: 44px; + background: linear-gradient(180deg, #28c4df 0%, #28c4df 50%, #078dbb 50%, #078dbb 100%); +} + +.not-found-astro-head::before { + @apply absolute rounded-[5px]; + content: ""; + top: 23px; + left: -4px; + width: 11px; + height: 22px; + background: #587789; + box-shadow: 81px 0 0 #587789; +} + +.not-found-astro-body { + @apply absolute z-[2]; + top: 92px; + left: 73px; + width: 75px; + height: 88px; + border-radius: 36px / 18px; + background: linear-gradient(90deg, #e4ebef 0%, #e4ebef 50%, #fbfdfa 50%, #fbfdfa 100%); +} + +.not-found-astro-panel { + @apply absolute; + top: 18px; + left: 11px; + width: 53px; + height: 36px; + background: #b8cdec; +} + +.not-found-astro-panel::before { + @apply absolute; + content: ""; + top: 8px; + left: 6px; + width: 27px; + height: 5px; + background: #fbfdfa; + box-shadow: 0 8px 0 #fbfdfa, 0 16px 0 #fbfdfa; +} + +.not-found-astro-panel::after { + @apply absolute rounded-full; + content: ""; + top: 8px; + right: 6px; + width: 8px; + height: 8px; + background: #fbfdfa; + box-shadow: 0 13px 0 2px #fbfdfa; +} + +.not-found-astro-arm { + @apply absolute z-[2]; + top: 107px; + width: 70px; + height: 26px; +} + +.not-found-astro-arm-left { + left: 28px; + background: #e4ebef; + border-radius: 0 0 0 34px; +} + +.not-found-astro-arm-right { + right: 28px; + background: #fbfdfa; + border-radius: 0 0 34px 0; +} + +.not-found-astro-arm::before { + @apply absolute; + content: ""; + top: -35px; + width: 26px; + height: 62px; +} + +.not-found-astro-arm-left::before { + left: 0; + background: #e4ebef; + border-radius: 44px 44px 0 105px / 44px 44px 0 96px; +} + +.not-found-astro-arm-right::before { + right: 0; + background: #fbfdfa; + border-radius: 44px 44px 105px 0 / 44px 44px 96px 0; +} + +.not-found-astro-arm::after { + @apply absolute; + content: ""; + top: -21px; + width: 26px; + height: 9px; +} + +.not-found-astro-arm-left::after { + left: 0; + background: #6e91a4; +} + +.not-found-astro-arm-right::after { + right: 0; + background: #b6d2e0; +} + +.not-found-astro-leg { + @apply absolute z-[2]; + bottom: 62px; + width: 26px; + height: 36px; +} + +.not-found-astro-leg-left { + left: 67px; + background: #e4ebef; + transform: rotate(20deg); +} + +.not-found-astro-leg-right { + right: 64px; + background: #fbfdfa; + transform: rotate(-20deg); +} + +.not-found-astro-leg::before { + @apply absolute; + content: ""; + bottom: -23px; + width: 44px; + height: 22px; +} + +.not-found-astro-leg-left::before { + left: -18px; + background: #e4ebef; + border-bottom: 9px solid #6d96ac; + border-radius: 27px 0 0 0; +} + +.not-found-astro-leg-right::before { + right: -18px; + background: #fbfdfa; + border-bottom: 9px solid #b0cfe4; + border-radius: 0 27px 0 0; +} + +.not-found-brand { + @apply inline-flex max-w-full items-center justify-center gap-3.5; +} + +.not-found-logo { + @apply h-14 w-[70px] flex-shrink-0 object-contain; + filter: drop-shadow(0 8px 18px rgba(43, 102, 217, 0.22)); +} + +.not-found-wordmark { + @apply block max-w-full; + width: clamp(200px, 38vw, 330px); + aspect-ratio: 862 / 102; + background: #116ff9; + mask: url('/nodewarden-wordmark.svg') center / contain no-repeat; + -webkit-mask: url('/nodewarden-wordmark.svg') center / contain no-repeat; +} + +.not-found-code { + @apply rounded-full px-3 py-1 text-sm font-extrabold; + background: #eef4ff; + color: #1d4ed8; +} + +.not-found-copy { + @apply grid justify-items-center gap-3; + text-shadow: 0 2px 18px rgba(0, 0, 0, 0.38); +} + +.not-found-shell h1 { + @apply m-0 text-3xl font-extrabold leading-tight; + color: #f8fbff; +} + +.not-found-shell p { + @apply m-0 max-w-[420px] text-sm leading-relaxed; + color: rgba(220, 232, 251, 0.82); +} + +.not-found-action { + @apply mt-1; +} + +@media (max-width: 520px) { + .not-found-page { + background: + radial-gradient(circle at 50% 36%, rgba(28, 118, 255, 0.24), transparent 18rem), + radial-gradient(circle at 18% 82%, rgba(22, 163, 255, 0.10), transparent 16rem), + linear-gradient(180deg, #020b1a 0%, #061328 48%, #0a1730 100%); + } + + .not-found-shell { + @apply gap-4 px-5 py-8; + } + + .not-found-brand { + @apply gap-2.5; + } + + .not-found-logo { + @apply h-12 w-[60px]; + } + + .not-found-wordmark { + width: clamp(170px, 52vw, 230px); + } + + .not-found-space { + height: 100%; + min-height: 100%; + } + + .not-found-astronaut { + width: 176px; + height: 211px; + } + + .not-found-astro-stage { + height: 224px; + } +} + +@media (prefers-reduced-motion: reduce) { + .not-found-star-box, + .not-found-astronaut { + animation: none; + } +} + .inline-status-icon { @apply align-text-bottom; } @@ -33,6 +454,11 @@ @apply m-0 mb-1 text-center; } +.standalone-eyebrow { + @apply mb-1 text-center text-xs font-extrabold uppercase tracking-[0.12em]; + color: var(--muted); +} + .standalone-shell { @apply grid w-[min(640px,100%)] gap-3.5; } diff --git a/webapp/vite.config.ts b/webapp/vite.config.ts index 06aacdd..8199521 100644 --- a/webapp/vite.config.ts +++ b/webapp/vite.config.ts @@ -30,6 +30,7 @@ export default defineConfig({ const localeMatch = normalized.match(/\/src\/lib\/i18n\/locales\/(.+)\.ts$/); if (localeMatch) { + if (localeMatch[1] === 'en') return undefined; return `i18n-${localeMatch[1]}`; } @@ -52,19 +53,14 @@ export default defineConfig({ normalized.includes('/src/lib/import-') || normalized.includes('/src/lib/export-formats.ts') || normalized.includes('/src/components/SendsPage.tsx') || - normalized.includes('/src/components/TotpCodesPage.tsx') - ) { - return 'workspace-suite'; - } - - if ( + normalized.includes('/src/components/TotpCodesPage.tsx') || normalized.includes('/src/components/BackupCenterPage.tsx') || normalized.includes('/src/components/backup-center/') || normalized.includes('/src/components/SettingsPage.tsx') || normalized.includes('/src/components/SecurityDevicesPage.tsx') || normalized.includes('/src/components/AdminPage.tsx') ) { - return 'management-suite'; + return 'workspace-suite'; } return undefined;