mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +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> {
|
async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-found' = 'default'): Promise<Response> {
|
||||||
const normalizedHost = normalizeIconHost(host);
|
const normalizedHost = normalizeIconHost(host);
|
||||||
if (!normalizedHost) return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
|
if (!normalizedHost) return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
|
||||||
@@ -164,14 +184,7 @@ async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-fo
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
for (const source of upstreamSources) {
|
for (const source of upstreamSources) {
|
||||||
const resp = await fetch(source.url, {
|
const resp = await fetchIconSource(source);
|
||||||
headers: source.headers,
|
|
||||||
redirect: 'follow',
|
|
||||||
cf: {
|
|
||||||
cacheEverything: true,
|
|
||||||
cacheTtl: LIMITS.cache.iconTtlSeconds,
|
|
||||||
},
|
|
||||||
} as RequestInit & { cf: { cacheEverything: boolean; cacheTtl: number } });
|
|
||||||
|
|
||||||
if (!resp.ok) continue;
|
if (!resp.ok) continue;
|
||||||
const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase();
|
const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase();
|
||||||
|
|||||||
+20
-2
@@ -57,6 +57,12 @@ export class AuthService {
|
|||||||
return user;
|
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) {
|
private readCachedDevice(userId: string, deviceId: string) {
|
||||||
const cacheKey = `${userId}:${deviceId}`;
|
const cacheKey = `${userId}:${deviceId}`;
|
||||||
const cached = AuthService.deviceCache.get(cacheKey);
|
const cached = AuthService.deviceCache.get(cacheKey);
|
||||||
@@ -84,6 +90,12 @@ export class AuthService {
|
|||||||
return device;
|
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).
|
// Second-layer hash: PBKDF2-SHA256(clientHash, email-salt, iterations).
|
||||||
// Ensures database contents alone cannot be used to authenticate (pass-the-hash defense).
|
// 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.
|
// 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);
|
const payload = await verifyJWT(parts[1], this.env.JWT_SECRET);
|
||||||
if (!payload) return null;
|
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) return null;
|
||||||
if (user.status !== 'active') return null;
|
if (user.status !== 'active') return null;
|
||||||
|
|
||||||
@@ -171,7 +186,10 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (payload.did) {
|
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 (!device) return null;
|
||||||
if (!payload.dstamp || payload.dstamp !== device.sessionStamp) 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" />
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
|
||||||
<title>NodeWarden</title>
|
<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>
|
</head>
|
||||||
<body>
|
<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>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+109
-20
@@ -1,9 +1,10 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
import { useLocation } from 'wouter';
|
import { useLocation } from 'wouter';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import AppAuthenticatedShell from '@/components/AppAuthenticatedShell';
|
import AppAuthenticatedShell from '@/components/AppAuthenticatedShell';
|
||||||
import AppGlobalOverlays, { type AppConfirmState } from '@/components/AppGlobalOverlays';
|
import AppGlobalOverlays, { type AppConfirmState } from '@/components/AppGlobalOverlays';
|
||||||
import AuthViews from '@/components/AuthViews';
|
import AuthViews from '@/components/AuthViews';
|
||||||
|
import NotFoundPage from '@/components/NotFoundPage';
|
||||||
import PublicSendPage from '@/components/PublicSendPage';
|
import PublicSendPage from '@/components/PublicSendPage';
|
||||||
import RecoverTwoFactorPage from '@/components/RecoverTwoFactorPage';
|
import RecoverTwoFactorPage from '@/components/RecoverTwoFactorPage';
|
||||||
import JwtWarningPage from '@/components/JwtWarningPage';
|
import JwtWarningPage from '@/components/JwtWarningPage';
|
||||||
@@ -29,6 +30,7 @@ import {
|
|||||||
parseSignalRTextFrames,
|
parseSignalRTextFrames,
|
||||||
readInviteCodeFromUrl,
|
readInviteCodeFromUrl,
|
||||||
} from '@/lib/app-support';
|
} from '@/lib/app-support';
|
||||||
|
import { preloadAuthenticatedWorkspace } from '@/lib/app-preload';
|
||||||
import {
|
import {
|
||||||
bootstrapAppSession,
|
bootstrapAppSession,
|
||||||
type CompletedLogin,
|
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 IMPORT_ROUTE_ALIASES: ReadonlySet<string> = new Set(IMPORT_ROUTE_PATHS.filter((path) => path !== IMPORT_ROUTE));
|
||||||
const SETTINGS_HOME_ROUTE = '/settings';
|
const SETTINGS_HOME_ROUTE = '/settings';
|
||||||
const SETTINGS_ACCOUNT_ROUTE = '/settings/account';
|
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 {
|
function isAdminProfile(profile: Profile | null): profile is Profile {
|
||||||
return String(profile?.role || '').toLowerCase() === 'admin';
|
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 THEME_STORAGE_KEY = 'nodewarden.theme.preference.v1';
|
||||||
const SIGNALR_RECORD_SEPARATOR = String.fromCharCode(0x1e);
|
const SIGNALR_RECORD_SEPARATOR = String.fromCharCode(0x1e);
|
||||||
const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5;
|
const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5;
|
||||||
@@ -117,6 +141,7 @@ export default function App() {
|
|||||||
const initialBootstrap = useMemo(() => readInitialAppBootstrapState(), []);
|
const initialBootstrap = useMemo(() => readInitialAppBootstrapState(), []);
|
||||||
const initialInviteCode = useMemo(() => readInviteCodeFromUrl(), []);
|
const initialInviteCode = useMemo(() => readInviteCodeFromUrl(), []);
|
||||||
const initialProfileSnapshot = useMemo(() => loadProfileSnapshot(initialBootstrap.session?.email), [initialBootstrap]);
|
const initialProfileSnapshot = useMemo(() => loadProfileSnapshot(initialBootstrap.session?.email), [initialBootstrap]);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [pendingAuthAction, setPendingAuthAction] = useState<'login' | 'register' | 'unlock' | null>(null);
|
const [pendingAuthAction, setPendingAuthAction] = useState<'login' | 'register' | 'unlock' | null>(null);
|
||||||
const [location, navigate] = useLocation();
|
const [location, navigate] = useLocation();
|
||||||
const [phase, setPhase] = useState<AppPhase>(initialBootstrap.phase);
|
const [phase, setPhase] = useState<AppPhase>(initialBootstrap.phase);
|
||||||
@@ -169,6 +194,8 @@ export default function App() {
|
|||||||
const [decryptedSends, setDecryptedSends] = useState<Send[]>([]);
|
const [decryptedSends, setDecryptedSends] = useState<Send[]>([]);
|
||||||
const [cachedVaultCore, setCachedVaultCore] = useState<VaultCoreSnapshot | null>(null);
|
const [cachedVaultCore, setCachedVaultCore] = useState<VaultCoreSnapshot | null>(null);
|
||||||
const [vaultInitialDecryptDone, setVaultInitialDecryptDone] = useState(false);
|
const [vaultInitialDecryptDone, setVaultInitialDecryptDone] = useState(false);
|
||||||
|
const [vaultDecryptError, setVaultDecryptError] = useState('');
|
||||||
|
const [sendsDecryptDone, setSendsDecryptDone] = useState(false);
|
||||||
const sessionRef = useRef<SessionState | null>(initialBootstrap.session);
|
const sessionRef = useRef<SessionState | null>(initialBootstrap.session);
|
||||||
const silentRefreshVaultRef = useRef<() => Promise<void>>(async () => {});
|
const silentRefreshVaultRef = useRef<() => Promise<void>>(async () => {});
|
||||||
const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {});
|
const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {});
|
||||||
@@ -769,12 +796,25 @@ export default function App() {
|
|||||||
const encryptedVaultCore = vaultCoreQuery.data || cachedVaultCore;
|
const encryptedVaultCore = vaultCoreQuery.data || cachedVaultCore;
|
||||||
const encryptedFolders = encryptedVaultCore?.folders;
|
const encryptedFolders = encryptedVaultCore?.folders;
|
||||||
const encryptedCiphers = encryptedVaultCore?.ciphers;
|
const encryptedCiphers = encryptedVaultCore?.ciphers;
|
||||||
|
const encryptedSendsFromSync = encryptedVaultCore?.sends;
|
||||||
|
const sendsQueryKey = useMemo(() => ['sends', vaultCacheKey || session?.email] as const, [vaultCacheKey, session?.email]);
|
||||||
const sendsQuery = useQuery({
|
const sendsQuery = useQuery({
|
||||||
queryKey: ['sends', vaultCacheKey || session?.email],
|
queryKey: sendsQueryKey,
|
||||||
queryFn: () => getSends(authedFetch),
|
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,
|
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({
|
const profileQuery = useQuery({
|
||||||
queryKey: ['profile', vaultCacheKey || session?.email],
|
queryKey: ['profile', vaultCacheKey || session?.email],
|
||||||
queryFn: () => getProfile(authedFetch),
|
queryFn: () => getProfile(authedFetch),
|
||||||
@@ -811,6 +851,17 @@ export default function App() {
|
|||||||
enabled: phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
|
enabled: phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
|
||||||
staleTime: 30_000,
|
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(() => {
|
useEffect(() => {
|
||||||
if (phase !== 'app' || !session?.accessToken || !session?.symEncKey || !session?.symMacKey) return;
|
if (phase !== 'app' || !session?.accessToken || !session?.symEncKey || !session?.symMacKey) return;
|
||||||
@@ -833,6 +884,8 @@ export default function App() {
|
|||||||
setDecryptedCiphers([]);
|
setDecryptedCiphers([]);
|
||||||
setDecryptedSends([]);
|
setDecryptedSends([]);
|
||||||
setVaultInitialDecryptDone(false);
|
setVaultInitialDecryptDone(false);
|
||||||
|
setVaultDecryptError('');
|
||||||
|
setSendsDecryptDone(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!encryptedFolders || !encryptedCiphers) return;
|
if (!encryptedFolders || !encryptedCiphers) return;
|
||||||
@@ -840,6 +893,7 @@ export default function App() {
|
|||||||
let active = true;
|
let active = true;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
|
setVaultDecryptError('');
|
||||||
let result;
|
let result;
|
||||||
try {
|
try {
|
||||||
result = await decryptVaultCoreInWorker({
|
result = await decryptVaultCoreInWorker({
|
||||||
@@ -863,7 +917,10 @@ export default function App() {
|
|||||||
setVaultInitialDecryptDone(true);
|
setVaultInitialDecryptDone(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!active) return;
|
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(() => {
|
useEffect(() => {
|
||||||
if (!session?.symEncKey || !session?.symMacKey) {
|
if (!session?.symEncKey || !session?.symMacKey) {
|
||||||
setDecryptedSends([]);
|
setDecryptedSends([]);
|
||||||
|
setSendsDecryptDone(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!encryptedSends) {
|
||||||
|
setSendsDecryptDone(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!encryptedSends.length) {
|
||||||
|
setDecryptedSends([]);
|
||||||
|
setSendsDecryptDone(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!sendsQuery.data) return;
|
|
||||||
|
|
||||||
let active = true;
|
let active = true;
|
||||||
|
setSendsDecryptDone(false);
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
let sends;
|
let sends;
|
||||||
try {
|
try {
|
||||||
sends = await decryptSendsInWorker({
|
sends = await decryptSendsInWorker({
|
||||||
sends: sendsQuery.data,
|
sends: encryptedSends,
|
||||||
symEncKeyB64: session.symEncKey!,
|
symEncKeyB64: session.symEncKey!,
|
||||||
symMacKeyB64: session.symMacKey!,
|
symMacKeyB64: session.symMacKey!,
|
||||||
origin: window.location.origin,
|
origin: window.location.origin,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
sends = await decryptSends({
|
sends = await decryptSends({
|
||||||
sends: sendsQuery.data,
|
sends: encryptedSends,
|
||||||
symEncKeyB64: session.symEncKey!,
|
symEncKeyB64: session.symEncKey!,
|
||||||
symMacKeyB64: session.symMacKey!,
|
symMacKeyB64: session.symMacKey!,
|
||||||
origin: window.location.origin,
|
origin: window.location.origin,
|
||||||
@@ -901,8 +968,10 @@ export default function App() {
|
|||||||
|
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
setDecryptedSends(sends);
|
setDecryptedSends(sends);
|
||||||
|
setSendsDecryptDone(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
|
setSendsDecryptDone(true);
|
||||||
pushToast('error', error instanceof Error ? error.message : t('txt_decrypt_failed_2'));
|
pushToast('error', error instanceof Error ? error.message : t('txt_decrypt_failed_2'));
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@@ -910,18 +979,14 @@ export default function App() {
|
|||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
}, [session?.symEncKey, session?.symMacKey, sendsQuery.data]);
|
}, [session?.symEncKey, session?.symMacKey, encryptedSends]);
|
||||||
|
|
||||||
async function refreshVaultSilently() {
|
async function refreshVaultSilently() {
|
||||||
if (pendingVaultCoreRefreshRef.current) {
|
if (pendingVaultCoreRefreshRef.current) {
|
||||||
await pendingVaultCoreRefreshRef.current;
|
await pendingVaultCoreRefreshRef.current;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const tasks: Promise<unknown>[] = [refetchVaultCoreData()];
|
const request = refetchVaultCoreData().finally(() => {
|
||||||
if (location === '/sends') {
|
|
||||||
tasks.push(sendsQuery.refetch());
|
|
||||||
}
|
|
||||||
const request = Promise.all(tasks).finally(() => {
|
|
||||||
if (pendingVaultCoreRefreshRef.current === request) {
|
if (pendingVaultCoreRefreshRef.current === request) {
|
||||||
pendingVaultCoreRefreshRef.current = null;
|
pendingVaultCoreRefreshRef.current = null;
|
||||||
}
|
}
|
||||||
@@ -1087,7 +1152,7 @@ export default function App() {
|
|||||||
const result = await refetchVaultCoreData() as { data?: VaultCoreSnapshot };
|
const result = await refetchVaultCoreData() as { data?: VaultCoreSnapshot };
|
||||||
return { data: result.data?.folders };
|
return { data: result.data?.folders };
|
||||||
},
|
},
|
||||||
refetchSends: sendsQuery.refetch,
|
refetchSends: refetchSendsFromVaultCore,
|
||||||
onNotify: pushToast,
|
onNotify: pushToast,
|
||||||
patchDecryptedCiphers: setDecryptedCiphers,
|
patchDecryptedCiphers: setDecryptedCiphers,
|
||||||
patchDecryptedFolders: setDecryptedFolders,
|
patchDecryptedFolders: setDecryptedFolders,
|
||||||
@@ -1127,11 +1192,17 @@ export default function App() {
|
|||||||
const trimmedHashPath = hashPathOnly.replace(/^\/+/, '').replace(/\/+$/, '');
|
const trimmedHashPath = hashPathOnly.replace(/^\/+/, '').replace(/\/+$/, '');
|
||||||
const normalizedHashPath = trimmedHashPath ? `/${trimmedHashPath}` : '/';
|
const normalizedHashPath = trimmedHashPath ? `/${trimmedHashPath}` : '/';
|
||||||
const isImportHashRoute = IMPORT_ROUTE_ALIASES.has(normalizedHashPath);
|
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 publicSendMatch = effectiveLocation.match(/^\/send\/([^/]+)(?:\/([^/]+))?\/?$/i);
|
||||||
const isRecoverTwoFactorRoute = effectiveLocation === '/recover-2fa';
|
const isRecoverTwoFactorRoute = effectiveLocation === '/recover-2fa';
|
||||||
const isPublicSendRoute = !!publicSendMatch;
|
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 showSidebarToggle = mobileLayout && (location === '/vault' || location === '/sends');
|
||||||
const sidebarToggleTitle = location === '/vault' ? t('txt_folders') : t('txt_type');
|
const sidebarToggleTitle = location === '/vault' ? t('txt_folders') : t('txt_type');
|
||||||
const mobilePrimaryRoute =
|
const mobilePrimaryRoute =
|
||||||
@@ -1178,6 +1249,7 @@ export default function App() {
|
|||||||
|
|
||||||
const mainRoutesProps = {
|
const mainRoutesProps = {
|
||||||
profile,
|
profile,
|
||||||
|
profileLoading: profileQuery.isFetching && !profile,
|
||||||
session,
|
session,
|
||||||
mobileLayout,
|
mobileLayout,
|
||||||
mobileSidebarToggleKey,
|
mobileSidebarToggleKey,
|
||||||
@@ -1187,16 +1259,20 @@ export default function App() {
|
|||||||
decryptedCiphers,
|
decryptedCiphers,
|
||||||
decryptedFolders,
|
decryptedFolders,
|
||||||
decryptedSends,
|
decryptedSends,
|
||||||
ciphersLoading: vaultCoreQuery.isFetching && !encryptedVaultCore,
|
vaultError: vaultCoreQuery.isError && !encryptedVaultCore ? t('txt_load_vault_failed') : vaultDecryptError,
|
||||||
foldersLoading: vaultCoreQuery.isFetching && !encryptedVaultCore,
|
ciphersLoading: !(vaultCoreQuery.isError && !encryptedVaultCore) && !vaultDecryptError && !vaultInitialDecryptDone,
|
||||||
sendsLoading: sendsQuery.isFetching && !sendsQuery.data,
|
foldersLoading: !(vaultCoreQuery.isError && !encryptedVaultCore) && !vaultDecryptError && !vaultInitialDecryptDone,
|
||||||
|
sendsLoading: (sendsQuery.isFetching && !encryptedSends) || (!!encryptedSends && !sendsDecryptDone),
|
||||||
users: usersQuery.data || [],
|
users: usersQuery.data || [],
|
||||||
invites: invitesQuery.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,
|
totpEnabled: !!totpStatusQuery.data?.enabled,
|
||||||
lockTimeoutMinutes,
|
lockTimeoutMinutes,
|
||||||
sessionTimeoutAction,
|
sessionTimeoutAction,
|
||||||
authorizedDevices: authorizedDevicesQuery.data || [],
|
authorizedDevices: authorizedDevicesQuery.data || [],
|
||||||
authorizedDevicesLoading: authorizedDevicesQuery.isFetching,
|
authorizedDevicesLoading: authorizedDevicesQuery.isFetching,
|
||||||
|
authorizedDevicesError: authorizedDevicesQuery.isError && !authorizedDevicesQuery.data ? t('txt_load_devices_failed') : '',
|
||||||
onNavigate: navigate,
|
onNavigate: navigate,
|
||||||
onLogout: handleLogout,
|
onLogout: handleLogout,
|
||||||
onNotify: pushToast,
|
onNotify: pushToast,
|
||||||
@@ -1258,7 +1334,11 @@ export default function App() {
|
|||||||
onExportBackup: backupActions.exportBackup,
|
onExportBackup: backupActions.exportBackup,
|
||||||
onImportBackup: backupActions.importBackup,
|
onImportBackup: backupActions.importBackup,
|
||||||
onImportBackupAllowingChecksumMismatch: backupActions.importBackupAllowingChecksumMismatch,
|
onImportBackupAllowingChecksumMismatch: backupActions.importBackupAllowingChecksumMismatch,
|
||||||
onLoadBackupSettings: backupActions.loadSettings,
|
onLoadBackupSettings: () => queryClient.ensureQueryData({
|
||||||
|
queryKey: ['admin-backup-settings', vaultCacheKey],
|
||||||
|
queryFn: () => backupActions.loadSettings(),
|
||||||
|
staleTime: 30_000,
|
||||||
|
}),
|
||||||
onSaveBackupSettings: backupActions.saveSettings,
|
onSaveBackupSettings: backupActions.saveSettings,
|
||||||
onRunRemoteBackup: backupActions.runRemoteBackup,
|
onRunRemoteBackup: backupActions.runRemoteBackup,
|
||||||
onListRemoteBackups: backupActions.listRemoteBackups,
|
onListRemoteBackups: backupActions.listRemoteBackups,
|
||||||
@@ -1282,6 +1362,15 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isUnknownRoute) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NotFoundPage />
|
||||||
|
{renderPassiveOverlays()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (isRecoverTwoFactorRoute && phase !== 'app') {
|
if (isRecoverTwoFactorRoute && phase !== 'app') {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useEffect } from 'preact/hooks';
|
|||||||
import { Link, Route, Switch } from 'wouter';
|
import { Link, Route, Switch } from 'wouter';
|
||||||
import { ArrowUpDown, Cloud, LogOut, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
|
import { ArrowUpDown, Cloud, LogOut, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
|
||||||
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
|
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
|
||||||
|
import LoadingState from '@/components/LoadingState';
|
||||||
import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup';
|
import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup';
|
||||||
import type { CiphersImportPayload } from '@/lib/api/vault';
|
import type { CiphersImportPayload } from '@/lib/api/vault';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
@@ -19,7 +20,7 @@ const BackupCenterPage = lazy(() => import('@/components/BackupCenterPage'));
|
|||||||
const ImportPage = lazy(() => import('@/components/ImportPage'));
|
const ImportPage = lazy(() => import('@/components/ImportPage'));
|
||||||
|
|
||||||
function RouteContentFallback() {
|
function RouteContentFallback() {
|
||||||
return <div className="loading-screen">{t('txt_loading_nodewarden')}</div>;
|
return <LoadingState card lines={5} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function LegacyBackupRedirect(props: { onNavigate: (path: string) => void }) {
|
function LegacyBackupRedirect(props: { onNavigate: (path: string) => void }) {
|
||||||
@@ -31,6 +32,7 @@ function LegacyBackupRedirect(props: { onNavigate: (path: string) => void }) {
|
|||||||
|
|
||||||
export interface AppMainRoutesProps {
|
export interface AppMainRoutesProps {
|
||||||
profile: Profile | null;
|
profile: Profile | null;
|
||||||
|
profileLoading: boolean;
|
||||||
session: SessionState | null;
|
session: SessionState | null;
|
||||||
mobileLayout: boolean;
|
mobileLayout: boolean;
|
||||||
mobileSidebarToggleKey: number;
|
mobileSidebarToggleKey: number;
|
||||||
@@ -40,16 +42,20 @@ export interface AppMainRoutesProps {
|
|||||||
decryptedCiphers: Cipher[];
|
decryptedCiphers: Cipher[];
|
||||||
decryptedFolders: VaultFolder[];
|
decryptedFolders: VaultFolder[];
|
||||||
decryptedSends: Send[];
|
decryptedSends: Send[];
|
||||||
|
vaultError: string;
|
||||||
ciphersLoading: boolean;
|
ciphersLoading: boolean;
|
||||||
foldersLoading: boolean;
|
foldersLoading: boolean;
|
||||||
sendsLoading: boolean;
|
sendsLoading: boolean;
|
||||||
users: AdminUser[];
|
users: AdminUser[];
|
||||||
invites: AdminInvite[];
|
invites: AdminInvite[];
|
||||||
|
adminLoading: boolean;
|
||||||
|
adminError: string;
|
||||||
totpEnabled: boolean;
|
totpEnabled: boolean;
|
||||||
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
|
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
|
||||||
sessionTimeoutAction: 'lock' | 'logout';
|
sessionTimeoutAction: 'lock' | 'logout';
|
||||||
authorizedDevices: AuthorizedDevice[];
|
authorizedDevices: AuthorizedDevice[];
|
||||||
authorizedDevicesLoading: boolean;
|
authorizedDevicesLoading: boolean;
|
||||||
|
authorizedDevicesError: string;
|
||||||
onNavigate: (path: string) => void;
|
onNavigate: (path: string) => void;
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
|
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
|
||||||
@@ -187,6 +193,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
ciphers={props.decryptedCiphers}
|
ciphers={props.decryptedCiphers}
|
||||||
folders={props.decryptedFolders}
|
folders={props.decryptedFolders}
|
||||||
loading={props.ciphersLoading || props.foldersLoading}
|
loading={props.ciphersLoading || props.foldersLoading}
|
||||||
|
error={props.vaultError}
|
||||||
emailForReprompt={props.profile?.email || props.session?.email || ''}
|
emailForReprompt={props.profile?.email || props.session?.email || ''}
|
||||||
onRefresh={props.onRefreshVault}
|
onRefresh={props.onRefreshVault}
|
||||||
onCreate={props.onCreateVaultItem}
|
onCreate={props.onCreateVaultItem}
|
||||||
@@ -216,7 +223,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path={props.settingsAccountRoute}>
|
<Route path={props.settingsAccountRoute}>
|
||||||
{props.profile && (
|
{props.profile ? (
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
{props.mobileLayout && (
|
{props.mobileLayout && (
|
||||||
<div className="mobile-settings-subhead">
|
<div className="mobile-settings-subhead">
|
||||||
@@ -245,10 +252,12 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : props.profileLoading ? (
|
||||||
|
<LoadingState card lines={5} />
|
||||||
|
) : null}
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/settings">
|
<Route path="/settings">
|
||||||
{props.profile && (
|
{props.profile ? (
|
||||||
<section className="card mobile-settings-card">
|
<section className="card mobile-settings-card">
|
||||||
<div className="mobile-settings-links">
|
<div className="mobile-settings-links">
|
||||||
<Link href={props.settingsAccountRoute} className="mobile-settings-link">
|
<Link href={props.settingsAccountRoute} className="mobile-settings-link">
|
||||||
@@ -281,7 +290,9 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
{t('txt_sign_out')}
|
{t('txt_sign_out')}
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
)}
|
) : props.profileLoading ? (
|
||||||
|
<LoadingState card lines={4} />
|
||||||
|
) : null}
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/security/devices">
|
<Route path="/security/devices">
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
@@ -297,6 +308,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
<SecurityDevicesPage
|
<SecurityDevicesPage
|
||||||
devices={props.authorizedDevices}
|
devices={props.authorizedDevices}
|
||||||
loading={props.authorizedDevicesLoading}
|
loading={props.authorizedDevicesLoading}
|
||||||
|
error={props.authorizedDevicesError}
|
||||||
onRefresh={() => void props.onRefreshAuthorizedDevices()}
|
onRefresh={() => void props.onRefreshAuthorizedDevices()}
|
||||||
onRenameDevice={props.onRenameAuthorizedDevice}
|
onRenameDevice={props.onRenameAuthorizedDevice}
|
||||||
onRevokeTrust={props.onRevokeDeviceTrust}
|
onRevokeTrust={props.onRevokeDeviceTrust}
|
||||||
@@ -322,6 +334,8 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
currentUserId={props.profile?.id || ''}
|
currentUserId={props.profile?.id || ''}
|
||||||
users={props.users}
|
users={props.users}
|
||||||
invites={props.invites}
|
invites={props.invites}
|
||||||
|
loading={props.adminLoading}
|
||||||
|
error={props.adminError}
|
||||||
onRefresh={props.onRefreshAdmin}
|
onRefresh={props.onRefreshAdmin}
|
||||||
onCreateInvite={props.onCreateInvite}
|
onCreateInvite={props.onCreateInvite}
|
||||||
onDeleteAllInvites={props.onDeleteAllInvites}
|
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;
|
type RefreshResult = RefreshFailure | RefreshSuccess;
|
||||||
|
|
||||||
|
const pendingRefreshes = new Map<string, Promise<RefreshResult>>();
|
||||||
|
|
||||||
function randomHex(length: number): string {
|
function randomHex(length: number): string {
|
||||||
const bytes = crypto.getRandomValues(new Uint8Array(Math.max(1, Math.ceil(length / 2))));
|
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);
|
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> {
|
export async function revokeCurrentSession(session: SessionState | null): Promise<void> {
|
||||||
const body = new URLSearchParams();
|
const body = new URLSearchParams();
|
||||||
if (session?.authMode !== 'web-cookie' && session?.refreshToken) {
|
if (session?.authMode !== 'web-cookie' && session?.refreshToken) {
|
||||||
@@ -436,7 +457,16 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
|
|||||||
let resp = await retryableRequest(headers);
|
let resp = await retryableRequest(headers);
|
||||||
if (resp.status !== 401 || (!session.refreshToken && session.authMode !== 'web-cookie')) return resp;
|
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.ok) {
|
||||||
if (refreshed.transient) {
|
if (refreshed.transient) {
|
||||||
throw new Error(refreshed.error || 'Session refresh temporarily unavailable');
|
throw new Error(refreshed.error || 'Session refresh temporarily unavailable');
|
||||||
@@ -446,10 +476,10 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
|
|||||||
}
|
}
|
||||||
|
|
||||||
const nextSession: SessionState = {
|
const nextSession: SessionState = {
|
||||||
...session,
|
...refreshSource,
|
||||||
accessToken: refreshed.token.access_token,
|
accessToken: refreshed.token.access_token,
|
||||||
refreshToken: refreshed.token.refresh_token || session.refreshToken,
|
refreshToken: refreshed.token.refresh_token || refreshSource.refreshToken,
|
||||||
authMode: refreshed.token.web_session ? 'web-cookie' : (session.authMode || 'token'),
|
authMode: refreshed.token.web_session ? 'web-cookie' : (refreshSource.authMode || 'token'),
|
||||||
};
|
};
|
||||||
setSession(nextSession);
|
setSession(nextSession);
|
||||||
saveSession(nextSession);
|
saveSession(nextSession);
|
||||||
|
|||||||
@@ -16,6 +16,15 @@ function normalizeSnapshot(body: VaultSyncResponse | null | undefined): VaultCor
|
|||||||
return {
|
return {
|
||||||
ciphers: Array.isArray(body?.ciphers) ? body!.ciphers! : [],
|
ciphers: Array.isArray(body?.ciphers) ? body!.ciphers! : [],
|
||||||
folders: Array.isArray(body?.folders) ? body!.folders! : [],
|
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;
|
if (memory) return memory.snapshot;
|
||||||
const cached = await loadCachedVaultCoreSnapshot(normalizedKey);
|
const cached = await loadCachedVaultCoreSnapshot(normalizedKey);
|
||||||
if (!cached?.snapshot) return null;
|
if (!cached?.snapshot) return null;
|
||||||
|
const snapshot = normalizeCachedSnapshot(cached.snapshot);
|
||||||
memoryVaultCoreCache.set(normalizedKey, {
|
memoryVaultCoreCache.set(normalizedKey, {
|
||||||
revisionStamp: cached.revisionStamp,
|
revisionStamp: cached.revisionStamp,
|
||||||
snapshot: cached.snapshot,
|
snapshot,
|
||||||
});
|
});
|
||||||
return cached.snapshot;
|
return snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadVaultCoreSyncSnapshot(authedFetch: AuthedFetch, cacheKey: string): Promise<VaultCoreSnapshot> {
|
export async function loadVaultCoreSyncSnapshot(authedFetch: AuthedFetch, cacheKey: string): Promise<VaultCoreSnapshot> {
|
||||||
const normalizedKey = String(cacheKey || '').trim();
|
const normalizedKey = String(cacheKey || '').trim();
|
||||||
if (!normalizedKey) return { ciphers: [], folders: [] };
|
if (!normalizedKey) return { ciphers: [], folders: [], sends: [] };
|
||||||
|
|
||||||
const existing = pendingVaultCoreRequests.get(normalizedKey);
|
const existing = pendingVaultCoreRequests.get(normalizedKey);
|
||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
|
|
||||||
const request = (async () => {
|
const request = (async () => {
|
||||||
const revisionStamp = await getVaultRevisionDate(authedFetch);
|
|
||||||
const memory = memoryVaultCoreCache.get(normalizedKey);
|
const memory = memoryVaultCoreCache.get(normalizedKey);
|
||||||
if (memory?.revisionStamp === revisionStamp) {
|
let cached = await loadCachedVaultCoreSnapshot(normalizedKey);
|
||||||
return memory.snapshot;
|
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) {
|
if (cached?.revisionStamp === revisionStamp && cached.snapshot) {
|
||||||
|
const snapshot = normalizeCachedSnapshot(cached.snapshot);
|
||||||
memoryVaultCoreCache.set(normalizedKey, {
|
memoryVaultCoreCache.set(normalizedKey, {
|
||||||
revisionStamp,
|
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',
|
cache: 'no-store',
|
||||||
headers: {
|
headers: {
|
||||||
'Cache-Control': 'no-cache',
|
'Cache-Control': 'no-cache',
|
||||||
@@ -69,6 +93,12 @@ export async function loadVaultCoreSyncSnapshot(authedFetch: AuthedFetch, cacheK
|
|||||||
memoryVaultCoreCache.set(normalizedKey, { revisionStamp, snapshot });
|
memoryVaultCoreCache.set(normalizedKey, { revisionStamp, snapshot });
|
||||||
void saveCachedVaultCoreSnapshot(normalizedKey, revisionStamp, snapshot);
|
void saveCachedVaultCoreSnapshot(normalizedKey, revisionStamp, snapshot);
|
||||||
return 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);
|
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'
|
| 'ru'
|
||||||
| 'es';
|
| 'es';
|
||||||
|
|
||||||
|
import enMessages from './i18n/locales/en';
|
||||||
|
|
||||||
const LOCALE_STORAGE_KEY = 'nodewarden.locale';
|
const LOCALE_STORAGE_KEY = 'nodewarden.locale';
|
||||||
|
|
||||||
type MessageTable = Record<string, string>;
|
type MessageTable = Record<string, string>;
|
||||||
@@ -18,8 +20,8 @@ export const AVAILABLE_LOCALES: readonly { value: Locale; label: string }[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
let locale: Locale = resolveInitialLocale();
|
let locale: Locale = resolveInitialLocale();
|
||||||
let activeMessages: MessageTable = {};
|
let activeMessages: MessageTable = enMessages;
|
||||||
const loadedMessages = new Map<Locale, MessageTable>();
|
const loadedMessages = new Map<Locale, MessageTable>([['en', enMessages]]);
|
||||||
|
|
||||||
function isLocale(value: unknown): value is Locale {
|
function isLocale(value: unknown): value is Locale {
|
||||||
return AVAILABLE_LOCALES.some((item) => item.value === value);
|
return AVAILABLE_LOCALES.some((item) => item.value === value);
|
||||||
@@ -46,7 +48,7 @@ function resolveInitialLocale(): Locale {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const localeLoaders: Record<Locale, () => Promise<{ default: MessageTable }>> = {
|
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-CN': () => import('./i18n/locales/zh-CN'),
|
||||||
'zh-TW': () => import('./i18n/locales/zh-TW'),
|
'zh-TW': () => import('./i18n/locales/zh-TW'),
|
||||||
ru: () => import('./i18n/locales/ru'),
|
ru: () => import('./i18n/locales/ru'),
|
||||||
@@ -63,11 +65,7 @@ async function loadLocaleMessages(next: Locale): Promise<MessageTable> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadFallbackMessages(): Promise<MessageTable> {
|
async function loadFallbackMessages(): Promise<MessageTable> {
|
||||||
const cached = loadedMessages.get('en');
|
return enMessages;
|
||||||
if (cached) return cached;
|
|
||||||
const mod = await import('./i18n/locales/en');
|
|
||||||
loadedMessages.set('en', mod.default);
|
|
||||||
return mod.default;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type I18nParams = Record<string, string | number | null | undefined>;
|
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 {
|
export interface VaultCoreSnapshot {
|
||||||
ciphers: Cipher[];
|
ciphers: Cipher[];
|
||||||
folders: Folder[];
|
folders: Folder[];
|
||||||
|
sends: Send[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VaultCoreCacheRecord {
|
interface VaultCoreCacheRecord {
|
||||||
|
|||||||
+9
-4
@@ -15,14 +15,19 @@ const queryClient = new QueryClient({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
async function bootstrap(): Promise<void> {
|
const root = document.getElementById('root')!;
|
||||||
await initI18n();
|
|
||||||
|
function renderApp(): void {
|
||||||
render(
|
render(
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<App />
|
<App />
|
||||||
</QueryClientProvider>,
|
</QueryClientProvider>,
|
||||||
document.getElementById('root')!
|
root
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void bootstrap();
|
renderApp();
|
||||||
|
|
||||||
|
void initI18n().then(() => {
|
||||||
|
renderApp();
|
||||||
|
});
|
||||||
|
|||||||
@@ -19,6 +19,427 @@
|
|||||||
@apply mt-2.5;
|
@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 {
|
.inline-status-icon {
|
||||||
@apply align-text-bottom;
|
@apply align-text-bottom;
|
||||||
}
|
}
|
||||||
@@ -33,6 +454,11 @@
|
|||||||
@apply m-0 mb-1 text-center;
|
@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 {
|
.standalone-shell {
|
||||||
@apply grid w-[min(640px,100%)] gap-3.5;
|
@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$/);
|
const localeMatch = normalized.match(/\/src\/lib\/i18n\/locales\/(.+)\.ts$/);
|
||||||
if (localeMatch) {
|
if (localeMatch) {
|
||||||
|
if (localeMatch[1] === 'en') return undefined;
|
||||||
return `i18n-${localeMatch[1]}`;
|
return `i18n-${localeMatch[1]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,19 +53,14 @@ export default defineConfig({
|
|||||||
normalized.includes('/src/lib/import-') ||
|
normalized.includes('/src/lib/import-') ||
|
||||||
normalized.includes('/src/lib/export-formats.ts') ||
|
normalized.includes('/src/lib/export-formats.ts') ||
|
||||||
normalized.includes('/src/components/SendsPage.tsx') ||
|
normalized.includes('/src/components/SendsPage.tsx') ||
|
||||||
normalized.includes('/src/components/TotpCodesPage.tsx')
|
normalized.includes('/src/components/TotpCodesPage.tsx') ||
|
||||||
) {
|
|
||||||
return 'workspace-suite';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
normalized.includes('/src/components/BackupCenterPage.tsx') ||
|
normalized.includes('/src/components/BackupCenterPage.tsx') ||
|
||||||
normalized.includes('/src/components/backup-center/') ||
|
normalized.includes('/src/components/backup-center/') ||
|
||||||
normalized.includes('/src/components/SettingsPage.tsx') ||
|
normalized.includes('/src/components/SettingsPage.tsx') ||
|
||||||
normalized.includes('/src/components/SecurityDevicesPage.tsx') ||
|
normalized.includes('/src/components/SecurityDevicesPage.tsx') ||
|
||||||
normalized.includes('/src/components/AdminPage.tsx')
|
normalized.includes('/src/components/AdminPage.tsx')
|
||||||
) {
|
) {
|
||||||
return 'management-suite';
|
return 'workspace-suite';
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
Reference in New Issue
Block a user