mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
Improve app startup and route fallbacks
This commit is contained in:
+21
-8
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
+67
-1
@@ -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>
|
||||
+109
-20
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,37 +35,52 @@ 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;
|
||||
let cached = await loadCachedVaultCoreSnapshot(normalizedKey);
|
||||
if (!memory && cached?.snapshot) {
|
||||
const snapshot = normalizeCachedSnapshot(cached.snapshot);
|
||||
memoryVaultCoreCache.set(normalizedKey, {
|
||||
revisionStamp: cached.revisionStamp,
|
||||
snapshot,
|
||||
});
|
||||
}
|
||||
|
||||
const cached = await loadCachedVaultCoreSnapshot(normalizedKey);
|
||||
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: cached.snapshot,
|
||||
snapshot,
|
||||
});
|
||||
return cached.snapshot;
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
const resp = await authedFetch('/api/sync?excludeSends=true&excludeDomains=true', {
|
||||
const resp = await authedFetch('/api/sync', {
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
@@ -69,6 +93,12 @@ export async function loadVaultCoreSyncSnapshot(authedFetch: AuthedFetch, cacheK
|
||||
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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user