Improve app startup and route fallbacks

This commit is contained in:
shuaiplus
2026-05-04 04:19:02 +08:00
parent 45f0387526
commit 75a6a593dc
14 changed files with 858 additions and 87 deletions
+21 -8
View File
@@ -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<Response> {
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<Response> {
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();
+20 -2
View File
@@ -57,6 +57,12 @@ export class AuthService {
return user;
}
private async getFreshUser(userId: string): Promise<User | null> {
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;
}
+68 -2
View File
@@ -20,9 +20,75 @@
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<title>NodeWarden</title>
<style>
html,
body,
#root {
min-height: 100%;
}
body {
margin: 0;
background: #eef4ff;
color: #0f172a;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.boot-screen {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
box-sizing: border-box;
}
.boot-card {
width: min(420px, 100%);
display: grid;
gap: 14px;
justify-items: center;
padding: 28px;
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: 22px;
background: rgba(255, 255, 255, 0.82);
box-shadow: 0 20px 45px rgba(15, 23, 42, 0.10);
}
.boot-logo {
width: 74px;
height: 58px;
object-fit: contain;
}
.boot-line {
width: 72%;
height: 12px;
border-radius: 999px;
background: linear-gradient(90deg, #dbeafe, #bfdbfe, #dbeafe);
background-size: 180% 100%;
animation: boot-shimmer 1.2s ease-in-out infinite;
}
.boot-line.short {
width: 46%;
}
@keyframes boot-shimmer {
0% { background-position: 180% 0; }
100% { background-position: -180% 0; }
}
</style>
</head>
<body>
<div id="root"></div>
<div id="root">
<div class="boot-screen">
<div class="boot-card" aria-label="Loading NodeWarden">
<img class="boot-logo" src="/nodewarden-logo.svg" alt="" />
<div class="boot-line"></div>
<div class="boot-line short"></div>
</div>
</div>
</div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
</html>
+109 -20
View File
@@ -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<string> = 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<string> = new Set(AUTH_ROUTE_PATHS);
const APP_ROUTES: ReadonlySet<string> = 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<AppPhase>(initialBootstrap.phase);
@@ -169,6 +194,8 @@ export default function App() {
const [decryptedSends, setDecryptedSends] = useState<Send[]>([]);
const [cachedVaultCore, setCachedVaultCore] = useState<VaultCoreSnapshot | null>(null);
const [vaultInitialDecryptDone, setVaultInitialDecryptDone] = useState(false);
const [vaultDecryptError, setVaultDecryptError] = useState('');
const [sendsDecryptDone, setSendsDecryptDone] = useState(false);
const sessionRef = useRef<SessionState | null>(initialBootstrap.session);
const silentRefreshVaultRef = useRef<() => Promise<void>>(async () => {});
const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(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<unknown>[] = [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 (
<>
<NotFoundPage />
{renderPassiveOverlays()}
</>
);
}
if (isRecoverTwoFactorRoute && phase !== 'app') {
return (
<>
+19 -5
View File
@@ -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 <div className="loading-screen">{t('txt_loading_nodewarden')}</div>;
return <LoadingState card lines={5} />;
}
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) {
</Suspense>
</Route>
<Route path={props.settingsAccountRoute}>
{props.profile && (
{props.profile ? (
<div className="stack">
{props.mobileLayout && (
<div className="mobile-settings-subhead">
@@ -245,10 +252,12 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
/>
</Suspense>
</div>
)}
) : props.profileLoading ? (
<LoadingState card lines={5} />
) : null}
</Route>
<Route path="/settings">
{props.profile && (
{props.profile ? (
<section className="card mobile-settings-card">
<div className="mobile-settings-links">
<Link href={props.settingsAccountRoute} className="mobile-settings-link">
@@ -281,7 +290,9 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
{t('txt_sign_out')}
</button>
</section>
)}
) : props.profileLoading ? (
<LoadingState card lines={4} />
) : null}
</Route>
<Route path="/security/devices">
<div className="stack">
@@ -297,6 +308,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
<SecurityDevicesPage
devices={props.authorizedDevices}
loading={props.authorizedDevicesLoading}
error={props.authorizedDevicesError}
onRefresh={() => 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}
+58
View File
@@ -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 (
<main className="not-found-page">
<div className="not-found-space" aria-hidden="true">
{starBoxes.map((box) => (
<div key={box} className={`not-found-star-box not-found-star-box-${box}`}>
{stars.map((star) => (
<span key={star} className={`not-found-star not-found-star-position-${star}`} />
))}
</div>
))}
</div>
<section className="not-found-shell" aria-labelledby="not-found-title">
<div className="not-found-brand">
<img src="/nodewarden-logo.svg" alt="NodeWarden logo" className="not-found-logo" />
<span className="not-found-wordmark" aria-label="NodeWarden" role="img" />
</div>
<div className="not-found-astro-stage" aria-hidden="true">
<div className="not-found-astronaut">
<div className="not-found-astro-head" />
<div className="not-found-astro-arm not-found-astro-arm-left" />
<div className="not-found-astro-arm not-found-astro-arm-right" />
<div className="not-found-astro-body">
<div className="not-found-astro-panel" />
</div>
<div className="not-found-astro-leg not-found-astro-leg-left" />
<div className="not-found-astro-leg not-found-astro-leg-right" />
<div className="not-found-astro-pack" />
</div>
</div>
<div className="not-found-copy">
<div className="not-found-code">404</div>
<h1 id="not-found-title">{props.title || t('txt_page_not_found')}</h1>
<p>{props.message || t('txt_page_not_found_hint')}</p>
<a className="btn btn-primary not-found-action" href={props.homeHref || '/'}>
<Home size={14} className="btn-icon" />
{t('txt_back_to_home')}
</a>
</div>
</section>
</main>
);
}
+34 -4
View File
@@ -46,6 +46,8 @@ interface RefreshSuccess {
type RefreshResult = RefreshFailure | RefreshSuccess;
const pendingRefreshes = new Map<string, Promise<RefreshResult>>();
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<Refresh
}
}
function refreshKey(session: SessionState): string {
if (session.authMode === 'web-cookie') return `web-cookie:${session.email || ''}`;
return `token:${session.refreshToken || ''}`;
}
function refreshAccessTokenOnce(session: SessionState): Promise<RefreshResult> {
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<void> {
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);
+56 -26
View File
@@ -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<VaultCoreSnapshot> | 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<Vaul
if (memory) return memory.snapshot;
const cached = await loadCachedVaultCoreSnapshot(normalizedKey);
if (!cached?.snapshot) return null;
const snapshot = normalizeCachedSnapshot(cached.snapshot);
memoryVaultCoreCache.set(normalizedKey, {
revisionStamp: cached.revisionStamp,
snapshot: cached.snapshot,
snapshot,
});
return cached.snapshot;
return snapshot;
}
export async function loadVaultCoreSyncSnapshot(authedFetch: AuthedFetch, cacheKey: string): Promise<VaultCoreSnapshot> {
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<VaultSyncResponse>(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<VaultSyncResponse>(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);
+27
View File
@@ -0,0 +1,27 @@
let workspacePreload: Promise<unknown> | null = null;
let adminPreload: Promise<unknown> | null = null;
export function preloadAuthenticatedWorkspace(isAdmin: boolean): Promise<unknown> {
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;
}
+6 -8
View File
@@ -5,6 +5,8 @@ export type Locale =
| 'ru'
| 'es';
import enMessages from './i18n/locales/en';
const LOCALE_STORAGE_KEY = 'nodewarden.locale';
type MessageTable = Record<string, string>;
@@ -18,8 +20,8 @@ export const AVAILABLE_LOCALES: readonly { value: Locale; label: string }[] = [
];
let locale: Locale = resolveInitialLocale();
let activeMessages: MessageTable = {};
const loadedMessages = new Map<Locale, MessageTable>();
let activeMessages: MessageTable = enMessages;
const loadedMessages = new Map<Locale, MessageTable>([['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<Locale, () => 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<MessageTable> {
}
async function loadFallbackMessages(): Promise<MessageTable> {
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<string, string | number | null | undefined>;
+2 -1
View File
@@ -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 {
+9 -4
View File
@@ -15,14 +15,19 @@ const queryClient = new QueryClient({
},
});
async function bootstrap(): Promise<void> {
await initI18n();
const root = document.getElementById('root')!;
function renderApp(): void {
render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>,
document.getElementById('root')!
root
);
}
void bootstrap();
renderApp();
void initI18n().then(() => {
renderApp();
});
+426
View File
@@ -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;
}
+3 -7
View File
@@ -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;