12 Commits

45 changed files with 3344 additions and 280 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "nodewarden",
"version": "1.4.6",
"version": "1.5.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nodewarden",
"version": "1.4.6",
"version": "1.5.1",
"license": "LGPL-3.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
+5 -2
View File
@@ -1,6 +1,6 @@
{
"name": "nodewarden",
"version": "1.4.6",
"version": "1.5.1",
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
"author": "shuaiplus",
"license": "LGPL-3.0",
@@ -9,11 +9,14 @@
"scripts": {
"dev": "wrangler dev -c wrangler.toml",
"dev:kv": "wrangler dev -c wrangler.kv.toml",
"dev:demo": "vite --config webapp/vite.config.ts --mode demo --host 127.0.0.1 --port 5174",
"build": "vite build --config webapp/vite.config.ts",
"build:demo": "vite build --config webapp/vite.config.ts --mode demo && node scripts/pages-spa-redirects.cjs",
"i18n": "node scripts/i18n-validate.cjs",
"i18n:validate": "node scripts/i18n-validate.cjs",
"deploy": "wrangler deploy",
"deploy:kv": "wrangler deploy -c wrangler.kv.toml"
"deploy:kv": "wrangler deploy -c wrangler.kv.toml",
"deploy:demo": "npm run build:demo && wrangler pages deploy dist --project-name nw-demo"
},
"keywords": [
"bitwarden",
+7
View File
@@ -0,0 +1,7 @@
const fs = require('node:fs');
const path = require('node:path');
const distDir = path.resolve(__dirname, '..', 'dist');
fs.mkdirSync(distDir, { recursive: true });
fs.writeFileSync(path.join(distDir, '_redirects'), '/* /index.html 200\n');
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = '1.4.6';
export const APP_VERSION = '1.5.1';
+21 -8
View File
@@ -141,6 +141,26 @@ function normalizeIconHost(rawHost: string): string | null {
}
}
const ICON_UPSTREAM_TIMEOUT_MS = 2500;
async function fetchIconSource(source: { url: string; headers?: HeadersInit }): Promise<Response> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), ICON_UPSTREAM_TIMEOUT_MS);
try {
return await fetch(source.url, {
headers: source.headers,
redirect: 'follow',
signal: controller.signal,
cf: {
cacheEverything: true,
cacheTtl: LIMITS.cache.iconTtlSeconds,
},
} as RequestInit & { cf: { cacheEverything: boolean; cacheTtl: number } });
} finally {
clearTimeout(timeout);
}
}
async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-found' = 'default'): Promise<Response> {
const normalizedHost = normalizeIconHost(host);
if (!normalizedHost) return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
@@ -164,14 +184,7 @@ async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-fo
try {
for (const source of upstreamSources) {
const resp = await fetch(source.url, {
headers: source.headers,
redirect: 'follow',
cf: {
cacheEverything: true,
cacheTtl: LIMITS.cache.iconTtlSeconds,
},
} as RequestInit & { cf: { cacheEverything: boolean; cacheTtl: number } });
const resp = await fetchIconSource(source);
if (!resp.ok) continue;
const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase();
+20 -2
View File
@@ -57,6 +57,12 @@ export class AuthService {
return user;
}
private async getFreshUser(userId: string): Promise<User | null> {
const user = await this.storage.getUserById(userId);
this.writeCachedUser(userId, user);
return user;
}
private readCachedDevice(userId: string, deviceId: string) {
const cacheKey = `${userId}:${deviceId}`;
const cached = AuthService.deviceCache.get(cacheKey);
@@ -84,6 +90,12 @@ export class AuthService {
return device;
}
private async getFreshDevice(userId: string, deviceId: string) {
const device = await this.storage.getDevice(userId, deviceId);
this.writeCachedDevice(userId, deviceId, device);
return device;
}
// Second-layer hash: PBKDF2-SHA256(clientHash, email-salt, iterations).
// Ensures database contents alone cannot be used to authenticate (pass-the-hash defense).
// Result is prefixed with "$s$" to distinguish from legacy raw client hashes.
@@ -162,7 +174,10 @@ export class AuthService {
const payload = await verifyJWT(parts[1], this.env.JWT_SECRET);
if (!payload) return null;
const user = await this.getCachedUser(payload.sub);
let user = await this.getCachedUser(payload.sub);
if (!user || user.status !== 'active' || payload.sstamp !== user.securityStamp) {
user = await this.getFreshUser(payload.sub);
}
if (!user) return null;
if (user.status !== 'active') return null;
@@ -171,7 +186,10 @@ export class AuthService {
}
if (payload.did) {
const device = await this.getCachedDevice(user.id, payload.did);
let device = await this.getCachedDevice(user.id, payload.did);
if (!device || !payload.dstamp || payload.dstamp !== device.sessionStamp) {
device = await this.getFreshDevice(user.id, payload.did);
}
if (!device) return null;
if (!payload.dstamp || payload.dstamp !== device.sessionStamp) return null;
}
+68 -2
View File
@@ -20,9 +20,75 @@
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<title>NodeWarden</title>
<style>
html,
body,
#root {
min-height: 100%;
}
body {
margin: 0;
background: #eef4ff;
color: #0f172a;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.boot-screen {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
box-sizing: border-box;
}
.boot-card {
width: min(420px, 100%);
display: grid;
gap: 14px;
justify-items: center;
padding: 28px;
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: 22px;
background: rgba(255, 255, 255, 0.82);
box-shadow: 0 20px 45px rgba(15, 23, 42, 0.10);
}
.boot-logo {
width: 74px;
height: 58px;
object-fit: contain;
}
.boot-line {
width: 72%;
height: 12px;
border-radius: 999px;
background: linear-gradient(90deg, #dbeafe, #bfdbfe, #dbeafe);
background-size: 180% 100%;
animation: boot-shimmer 1.2s ease-in-out infinite;
}
.boot-line.short {
width: 46%;
}
@keyframes boot-shimmer {
0% { background-position: 180% 0; }
100% { background-position: -180% 0; }
}
</style>
</head>
<body>
<div id="root"></div>
<div id="root">
<div class="boot-screen">
<div class="boot-card" aria-label="Loading NodeWarden">
<img class="boot-logo" src="/nodewarden-logo.svg" alt="" />
<div class="boot-line"></div>
<div class="boot-line short"></div>
</div>
</div>
</div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
</html>
+258 -32
View File
@@ -1,9 +1,10 @@
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { useLocation } from 'wouter';
import { useQuery } from '@tanstack/react-query';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import AppAuthenticatedShell from '@/components/AppAuthenticatedShell';
import AppGlobalOverlays, { type AppConfirmState } from '@/components/AppGlobalOverlays';
import AuthViews from '@/components/AuthViews';
import NotFoundPage from '@/components/NotFoundPage';
import PublicSendPage from '@/components/PublicSendPage';
import RecoverTwoFactorPage from '@/components/RecoverTwoFactorPage';
import JwtWarningPage from '@/components/JwtWarningPage';
@@ -29,6 +30,7 @@ import {
parseSignalRTextFrames,
readInviteCodeFromUrl,
} from '@/lib/app-support';
import { preloadAuthenticatedWorkspace, preloadDemoExperience } from '@/lib/app-preload';
import {
bootstrapAppSession,
type CompletedLogin,
@@ -52,7 +54,21 @@ import { APP_NOTIFY_EVENT, type AppNotifyDetail } from '@/lib/app-notify';
import { dispatchBackupProgress, type BackupProgressDetail } from '@/lib/backup-restore-progress';
import { decryptSends, decryptVaultCore } from '@/lib/vault-decrypt';
import { decryptSendsInWorker, decryptVaultCoreInWorker } from '@/lib/vault-worker';
import type { AppPhase, Cipher, Folder as VaultFolder, Profile, Send, SessionState } from '@/lib/types';
import {
DEMO_CIPHERS,
DEMO_ADMIN_INVITES,
DEMO_ADMIN_USERS,
DEMO_AUTHORIZED_DEVICES,
DEMO_FOLDERS,
DEMO_SENDS,
createDemoBackupSettings,
IS_DEMO_MODE,
createDemoCompletedLogin,
createDemoInitialBootstrapState,
createDemoMainRoutesProps,
} from '@/lib/demo';
import type { AdminBackupSettings } from '@/lib/api/backup';
import type { AdminInvite, AdminUser, AppPhase, AuthorizedDevice, Cipher, Folder as VaultFolder, Profile, Send, SessionState } from '@/lib/types';
import type { VaultCoreSnapshot } from '@/lib/vault-cache';
function isBackupProgressDetail(value: unknown): value is BackupProgressDetail {
@@ -71,10 +87,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;
@@ -114,9 +152,16 @@ function readSessionTimeoutAction(): SessionTimeoutAction {
}
export default function App() {
const initialBootstrap = useMemo(() => readInitialAppBootstrapState(), []);
const initialBootstrap = useMemo(
() => (IS_DEMO_MODE ? createDemoInitialBootstrapState() : readInitialAppBootstrapState()),
[]
);
const initialInviteCode = useMemo(() => readInviteCodeFromUrl(), []);
const initialProfileSnapshot = useMemo(() => loadProfileSnapshot(initialBootstrap.session?.email), [initialBootstrap]);
const initialProfileSnapshot = useMemo(
() => (IS_DEMO_MODE ? null : 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);
@@ -167,8 +212,14 @@ export default function App() {
const [decryptedFolders, setDecryptedFolders] = useState<VaultFolder[]>([]);
const [decryptedCiphers, setDecryptedCiphers] = useState<Cipher[]>([]);
const [decryptedSends, setDecryptedSends] = useState<Send[]>([]);
const [demoUsers, setDemoUsers] = useState<AdminUser[]>(() => DEMO_ADMIN_USERS.map((user) => ({ ...user })));
const [demoInvites, setDemoInvites] = useState<AdminInvite[]>(() => DEMO_ADMIN_INVITES.map((invite) => ({ ...invite })));
const [demoAuthorizedDevices, setDemoAuthorizedDevices] = useState<AuthorizedDevice[]>(() => DEMO_AUTHORIZED_DEVICES.map((device) => ({ ...device })));
const [demoBackupSettings, setDemoBackupSettings] = useState<AdminBackupSettings>(() => createDemoBackupSettings());
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 () => {});
@@ -267,6 +318,7 @@ export default function App() {
}, [themePreference]);
useEffect(() => {
if (IS_DEMO_MODE) return;
saveProfileSnapshot(profile);
}, [profile]);
@@ -347,6 +399,22 @@ export default function App() {
});
useEffect(() => {
if (IS_DEMO_MODE) {
const currentHashPath = typeof window !== 'undefined'
? (window.location.hash || '').replace(/^#/, '').split('?')[0].split('#')[0]
: '';
const normalizedCurrentHashPath = currentHashPath.replace(/^\/+/, '').replace(/\/+$/, '');
const isDemoPublicSendRoute = /^send\/[^/]+(?:\/[^/]+)?$/i.test(normalizedCurrentHashPath);
setDefaultKdfIterations(initialBootstrap.defaultKdfIterations);
setJwtWarning(null);
setSession(null);
setProfile(null);
setPhase('login');
setUnlockPreparing(false);
if (!isDemoPublicSendRoute && location !== '/login') navigate('/login');
return;
}
let mounted = true;
(async () => {
const boot = await bootstrapAppSession(initialBootstrap);
@@ -366,6 +434,7 @@ export default function App() {
useEffect(() => {
if (phase !== 'locked' || !session) return;
if (IS_DEMO_MODE) return;
let cancelled = false;
void (async () => {
const result = await hydrateLockedSession(session, profile);
@@ -414,6 +483,15 @@ export default function App() {
async function handleLogin() {
if (pendingAuthAction) return;
if (IS_DEMO_MODE) {
setPendingAuthAction('login');
try {
await finalizeLogin(createDemoCompletedLogin(loginValues.email), t('txt_login_success'));
} finally {
setPendingAuthAction(null);
}
return;
}
if (!loginValues.email || !loginValues.password) {
pushToast('error', t('txt_please_input_email_and_password'));
return;
@@ -486,6 +564,12 @@ export default function App() {
async function handleRegister() {
if (pendingAuthAction) return;
if (IS_DEMO_MODE) {
pushToast('warning', t('txt_demo_readonly_message'));
setPhase('login');
navigate('/login');
return;
}
if (!registerValues.email || !registerValues.password) {
pushToast('error', t('txt_please_input_email_and_password'));
return;
@@ -534,6 +618,10 @@ export default function App() {
async function handleTogglePasswordHint() {
if (pendingAuthAction) return;
if (IS_DEMO_MODE) {
openPasswordHintDialog(t('txt_demo_master_password_hint'));
return;
}
const email = loginValues.email.trim().toLowerCase();
if (!email) return;
@@ -568,12 +656,21 @@ export default function App() {
function handleShowLockedPasswordHint() {
if (pendingAuthAction) return;
openPasswordHintDialog(profile?.masterPasswordHint ?? null);
openPasswordHintDialog((IS_DEMO_MODE ? t('txt_demo_master_password_hint') : profile?.masterPasswordHint) ?? null);
}
async function handleUnlock() {
if (pendingAuthAction) return;
if (!session?.email) return;
if (IS_DEMO_MODE) {
setPendingAuthAction('unlock');
try {
await finalizeLogin(createDemoCompletedLogin(session.email), t('txt_unlocked'));
} finally {
setPendingAuthAction(null);
}
return;
}
if (!unlockPassword) {
pushToast('error', t('txt_please_input_master_password'));
return;
@@ -625,7 +722,9 @@ export default function App() {
}
function logoutNow() {
void revokeCurrentSession(sessionRef.current);
if (!IS_DEMO_MODE) {
void revokeCurrentSession(sessionRef.current);
}
setConfirm(null);
setSession(null);
clearProfileSnapshot();
@@ -731,6 +830,36 @@ export default function App() {
}
useEffect(() => {
if (!IS_DEMO_MODE) return;
if (phase !== 'app') {
setDecryptedFolders([]);
setDecryptedCiphers([]);
setDecryptedSends([]);
setDemoUsers(DEMO_ADMIN_USERS.map((user) => ({ ...user })));
setDemoInvites(DEMO_ADMIN_INVITES.map((invite) => ({ ...invite })));
setDemoAuthorizedDevices(DEMO_AUTHORIZED_DEVICES.map((device) => ({ ...device })));
setDemoBackupSettings(createDemoBackupSettings());
setVaultInitialDecryptDone(false);
setSendsDecryptDone(false);
return;
}
setDecryptedFolders(DEMO_FOLDERS.map((folder) => ({ ...folder })));
setDecryptedCiphers(DEMO_CIPHERS.map((cipher) => ({ ...cipher })));
setDecryptedSends(DEMO_SENDS.map((send) => ({ ...send })));
setDemoUsers(DEMO_ADMIN_USERS.map((user) => ({ ...user })));
setDemoInvites(DEMO_ADMIN_INVITES.map((invite) => ({ ...invite })));
setDemoAuthorizedDevices(DEMO_AUTHORIZED_DEVICES.map((device) => ({ ...device })));
setDemoBackupSettings(createDemoBackupSettings());
setVaultDecryptError('');
setVaultInitialDecryptDone(true);
setSendsDecryptDone(true);
}, [phase]);
useEffect(() => {
if (IS_DEMO_MODE) {
setCachedVaultCore(null);
return;
}
let cancelled = false;
if (phase !== 'app' || !session?.symEncKey || !session?.symMacKey || !vaultCacheKey) {
setCachedVaultCore(null);
@@ -763,22 +892,35 @@ export default function App() {
const vaultCoreQuery = useQuery({
queryKey: ['vault-core', vaultCacheKey],
queryFn: () => loadVaultCoreSyncSnapshot(authedFetch, vaultCacheKey),
enabled: phase === 'app' && !!session?.symEncKey && !!session?.symMacKey && !!vaultCacheKey,
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.symEncKey && !!session?.symMacKey && !!vaultCacheKey,
staleTime: 30_000,
});
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: !IS_DEMO_MODE && 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),
enabled: phase === 'app' && !!session?.accessToken,
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken,
staleTime: 30_000,
});
useEffect(() => {
@@ -790,29 +932,47 @@ export default function App() {
const usersQuery = useQuery({
queryKey: ['admin-users', vaultCacheKey],
queryFn: () => listAdminUsers(authedFetch),
enabled: phase === 'app' && isAdmin && vaultInitialDecryptDone,
enabled: !IS_DEMO_MODE && phase === 'app' && isAdmin && vaultInitialDecryptDone,
staleTime: 30_000,
});
const invitesQuery = useQuery({
queryKey: ['admin-invites', vaultCacheKey],
queryFn: () => listAdminInvites(authedFetch),
enabled: phase === 'app' && isAdmin && vaultInitialDecryptDone,
enabled: !IS_DEMO_MODE && phase === 'app' && isAdmin && vaultInitialDecryptDone,
staleTime: 30_000,
});
const totpStatusQuery = useQuery({
queryKey: ['totp-status', vaultCacheKey || session?.email],
queryFn: () => getTotpStatus(authedFetch),
enabled: phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
staleTime: 30_000,
});
const authorizedDevicesQuery = useQuery({
queryKey: ['authorized-devices', vaultCacheKey || session?.email],
queryFn: () => getAuthorizedDevices(authedFetch),
enabled: phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
staleTime: 30_000,
});
useQuery({
queryKey: ['admin-backup-settings', vaultCacheKey],
queryFn: () => backupActions.loadSettings(),
enabled: !IS_DEMO_MODE && phase === 'app' && isAdmin && vaultInitialDecryptDone,
staleTime: 30_000,
});
useEffect(() => {
if (!IS_DEMO_MODE) return;
return preloadDemoExperience();
}, []);
useEffect(() => {
if (IS_DEMO_MODE) return;
if (phase !== 'app' || !vaultInitialDecryptDone) return;
void preloadAuthenticatedWorkspace(isAdmin);
}, [phase, vaultInitialDecryptDone, isAdmin]);
useEffect(() => {
if (IS_DEMO_MODE) return;
if (phase !== 'app' || !session?.accessToken || !session?.symEncKey || !session?.symMacKey) return;
if (!vaultInitialDecryptDone) return;
if (!isAdminProfile(profile)) return;
@@ -828,11 +988,14 @@ export default function App() {
}, [session?.accessToken]);
useEffect(() => {
if (IS_DEMO_MODE) return;
if (!session?.symEncKey || !session?.symMacKey) {
setDecryptedFolders([]);
setDecryptedCiphers([]);
setDecryptedSends([]);
setVaultInitialDecryptDone(false);
setVaultDecryptError('');
setSendsDecryptDone(false);
return;
}
if (!encryptedFolders || !encryptedCiphers) return;
@@ -840,6 +1003,7 @@ export default function App() {
let active = true;
(async () => {
try {
setVaultDecryptError('');
let result;
try {
result = await decryptVaultCoreInWorker({
@@ -863,7 +1027,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);
}
})();
@@ -873,26 +1040,37 @@ export default function App() {
}, [session?.symEncKey, session?.symMacKey, encryptedFolders, encryptedCiphers]);
useEffect(() => {
if (IS_DEMO_MODE) return;
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 +1079,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 +1090,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;
}
@@ -933,6 +1109,7 @@ export default function App() {
silentRefreshVaultRef.current = refreshVaultSilently;
useEffect(() => {
if (IS_DEMO_MODE) return;
if (phase !== 'app' || !session?.accessToken || !session?.symEncKey || !session?.symMacKey || !vaultInitialDecryptDone) return;
let disposed = false;
@@ -1087,7 +1264,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 +1304,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 +1361,7 @@ export default function App() {
const mainRoutesProps = {
profile,
profileLoading: profileQuery.isFetching && !profile,
session,
mobileLayout,
mobileSidebarToggleKey,
@@ -1187,16 +1371,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 +1446,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,
@@ -1268,6 +1460,24 @@ export default function App() {
onRestoreRemoteBackup: backupActions.restoreRemoteBackup,
onRestoreRemoteBackupAllowingChecksumMismatch: backupActions.restoreRemoteBackupAllowingChecksumMismatch,
};
const effectiveMainRoutesProps = IS_DEMO_MODE
? createDemoMainRoutesProps(mainRoutesProps, pushToast, {
ciphers: decryptedCiphers,
folders: decryptedFolders,
sends: decryptedSends,
users: demoUsers,
invites: demoInvites,
authorizedDevices: demoAuthorizedDevices,
backupSettings: demoBackupSettings,
setCiphers: setDecryptedCiphers,
setFolders: setDecryptedFolders,
setSends: setDecryptedSends,
setUsers: setDemoUsers,
setInvites: setDemoInvites,
setAuthorizedDevices: setDemoAuthorizedDevices,
setBackupSettings: setDemoBackupSettings,
})
: mainRoutesProps;
if (jwtWarning) {
return <JwtWarningPage reason={jwtWarning.reason} minLength={jwtWarning.minLength} />;
@@ -1282,6 +1492,15 @@ export default function App() {
);
}
if (isUnknownRoute) {
return (
<>
<NotFoundPage />
{renderPassiveOverlays()}
</>
);
}
if (isRecoverTwoFactorRoute && phase !== 'app') {
return (
<>
@@ -1305,6 +1524,9 @@ export default function App() {
<AuthViews
mode={phase}
pendingAction={pendingAuthAction}
relaxedLoginInput={IS_DEMO_MODE}
authPlaceholder={IS_DEMO_MODE ? t('txt_demo_auth_placeholder') : undefined}
unlockPlaceholder={IS_DEMO_MODE ? t('txt_demo_unlock_placeholder') : undefined}
unlockReady={!!session?.email}
unlockPreparing={unlockPreparing}
loginValues={loginValues}
@@ -1323,6 +1545,10 @@ export default function App() {
navigate('/login');
}}
onGotoRegister={() => {
if (IS_DEMO_MODE) {
pushToast('warning', t('txt_demo_readonly_message'));
return;
}
if (inviteCodeFromUrl) {
setRegisterValues((prev) => ({ ...prev, inviteCode: inviteCodeFromUrl }));
}
@@ -1389,7 +1615,7 @@ export default function App() {
onLogout={handleLogout}
onToggleTheme={handleToggleTheme}
onToggleMobileSidebar={() => setMobileSidebarToggleKey((key) => key + 1)}
mainRoutesProps={mainRoutesProps}
mainRoutesProps={effectiveMainRoutesProps}
/>
<AppGlobalOverlays
+47 -2
View File
@@ -1,6 +1,7 @@
import { useState } from 'preact/hooks';
import { ChevronLeft, ChevronRight, Clipboard, Plus, RefreshCw, Trash2, UserCheck, UserX } from 'lucide-preact';
import { copyTextToClipboard } from '@/lib/clipboard';
import LoadingState from '@/components/LoadingState';
import type { AdminInvite, AdminUser } from '@/lib/types';
import { t } from '@/lib/i18n';
@@ -8,6 +9,8 @@ interface AdminPageProps {
currentUserId: string;
users: AdminUser[];
invites: AdminInvite[];
loading: boolean;
error: string;
onRefresh: () => void;
onCreateInvite: (hours: number) => Promise<void>;
onDeleteAllInvites: () => Promise<void>;
@@ -48,8 +51,22 @@ export default function AdminPage(props: AdminPageProps) {
return (
<div className="stack">
{!!props.error && (
<div className="local-error">
<span>{props.error}</span>
<button type="button" className="btn btn-secondary small" onClick={props.onRefresh}>
<RefreshCw size={14} className="btn-icon" />
{t('txt_refresh')}
</button>
</div>
)}
<section className="card">
<h3>{t('txt_users')}</h3>
<div className="section-head">
<h3>{t('txt_users')}</h3>
<button type="button" className="btn btn-secondary small" disabled={props.loading} onClick={props.onRefresh}>
<RefreshCw size={14} className="btn-icon" /> {t('txt_refresh')}
</button>
</div>
<table className="table">
<thead>
<tr>
@@ -94,6 +111,20 @@ export default function AdminPage(props: AdminPageProps) {
</tr>
);
})}
{props.loading && !props.users.length && (
<tr>
<td colSpan={5}>
<LoadingState lines={4} compact />
</td>
</tr>
)}
{!props.loading && !props.users.length && (
<tr>
<td colSpan={5}>
<div className="empty empty-comfortable">{t('txt_no_users_found')}</div>
</td>
</tr>
)}
</tbody>
</table>
</section>
@@ -101,7 +132,7 @@ export default function AdminPage(props: AdminPageProps) {
<section className="card">
<div className="section-head">
<h3>{t('txt_invites')}</h3>
<button type="button" className="btn btn-secondary" onClick={props.onRefresh}>
<button type="button" className="btn btn-secondary" disabled={props.loading} onClick={props.onRefresh}>
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync')}
</button>
</div>
@@ -160,6 +191,20 @@ export default function AdminPage(props: AdminPageProps) {
</td>
</tr>
))}
{props.loading && !props.invites.length && (
<tr>
<td colSpan={4}>
<LoadingState lines={4} compact />
</td>
</tr>
)}
{!props.loading && !props.invites.length && (
<tr>
<td colSpan={4}>
<div className="empty empty-comfortable">{t('txt_no_invites_found')}</div>
</td>
</tr>
)}
</tbody>
</table>
<div className="actions">
+19 -5
View File
@@ -3,6 +3,7 @@ import { useEffect } from 'preact/hooks';
import { Link, Route, Switch } from 'wouter';
import { ArrowUpDown, Cloud, LogOut, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
import LoadingState from '@/components/LoadingState';
import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup';
import type { CiphersImportPayload } from '@/lib/api/vault';
import { t } from '@/lib/i18n';
@@ -19,7 +20,7 @@ const BackupCenterPage = lazy(() => import('@/components/BackupCenterPage'));
const ImportPage = lazy(() => import('@/components/ImportPage'));
function RouteContentFallback() {
return <div className="loading-screen">{t('txt_loading_nodewarden')}</div>;
return <LoadingState card lines={5} />;
}
function LegacyBackupRedirect(props: { onNavigate: (path: string) => void }) {
@@ -31,6 +32,7 @@ function LegacyBackupRedirect(props: { onNavigate: (path: string) => void }) {
export interface AppMainRoutesProps {
profile: Profile | null;
profileLoading: boolean;
session: SessionState | null;
mobileLayout: boolean;
mobileSidebarToggleKey: number;
@@ -40,16 +42,20 @@ export interface AppMainRoutesProps {
decryptedCiphers: Cipher[];
decryptedFolders: VaultFolder[];
decryptedSends: Send[];
vaultError: string;
ciphersLoading: boolean;
foldersLoading: boolean;
sendsLoading: boolean;
users: AdminUser[];
invites: AdminInvite[];
adminLoading: boolean;
adminError: string;
totpEnabled: boolean;
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
sessionTimeoutAction: 'lock' | 'logout';
authorizedDevices: AuthorizedDevice[];
authorizedDevicesLoading: boolean;
authorizedDevicesError: string;
onNavigate: (path: string) => void;
onLogout: () => void;
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
@@ -187,6 +193,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
ciphers={props.decryptedCiphers}
folders={props.decryptedFolders}
loading={props.ciphersLoading || props.foldersLoading}
error={props.vaultError}
emailForReprompt={props.profile?.email || props.session?.email || ''}
onRefresh={props.onRefreshVault}
onCreate={props.onCreateVaultItem}
@@ -216,7 +223,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
</Suspense>
</Route>
<Route path={props.settingsAccountRoute}>
{props.profile && (
{props.profile ? (
<div className="stack">
{props.mobileLayout && (
<div className="mobile-settings-subhead">
@@ -245,10 +252,12 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
/>
</Suspense>
</div>
)}
) : props.profileLoading ? (
<LoadingState card lines={5} />
) : null}
</Route>
<Route path="/settings">
{props.profile && (
{props.profile ? (
<section className="card mobile-settings-card">
<div className="mobile-settings-links">
<Link href={props.settingsAccountRoute} className="mobile-settings-link">
@@ -281,7 +290,9 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
{t('txt_sign_out')}
</button>
</section>
)}
) : props.profileLoading ? (
<LoadingState card lines={4} />
) : null}
</Route>
<Route path="/security/devices">
<div className="stack">
@@ -297,6 +308,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
<SecurityDevicesPage
devices={props.authorizedDevices}
loading={props.authorizedDevicesLoading}
error={props.authorizedDevicesError}
onRefresh={() => void props.onRefreshAuthorizedDevices()}
onRenameDevice={props.onRenameAuthorizedDevice}
onRevokeTrust={props.onRevokeDeviceTrust}
@@ -322,6 +334,8 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
currentUserId={props.profile?.id || ''}
users={props.users}
invites={props.invites}
loading={props.adminLoading}
error={props.adminError}
onRefresh={props.onRefreshAdmin}
onCreateInvite={props.onCreateInvite}
onDeleteAllInvites={props.onDeleteAllInvites}
+9 -1
View File
@@ -19,6 +19,9 @@ interface RegisterValues {
interface AuthViewsProps {
mode: 'login' | 'register' | 'locked';
relaxedLoginInput?: boolean;
authPlaceholder?: string;
unlockPlaceholder?: string;
pendingAction: 'login' | 'register' | 'unlock' | null;
unlockReady: boolean;
unlockPreparing: boolean;
@@ -46,6 +49,7 @@ function PasswordField(props: {
onInput: (v: string) => void;
autoFocus?: boolean;
autoComplete?: string;
placeholder?: string;
}) {
const [show, setShow] = useState(false);
return (
@@ -59,6 +63,7 @@ function PasswordField(props: {
onInput={(e) => props.onInput((e.currentTarget as HTMLInputElement).value)}
autoFocus={props.autoFocus}
autoComplete={props.autoComplete}
placeholder={props.placeholder}
/>
<button type="button" className="eye-btn" onClick={() => setShow((v) => !v)}>
{show ? <EyeOff size={16} /> : <Eye size={16} />}
@@ -90,6 +95,7 @@ export default function AuthViews(props: AuthViewsProps) {
value={props.unlockPassword}
autoFocus
autoComplete="current-password"
placeholder={props.unlockPlaceholder}
onInput={props.onChangeUnlock}
/>
<div className="auth-support-row">
@@ -217,9 +223,10 @@ export default function AuthViews(props: AuthViewsProps) {
<span>{t('txt_email')}</span>
<input
className="input"
type="email"
type={props.relaxedLoginInput ? 'text' : 'email'}
value={props.loginValues.email}
autoComplete="username"
placeholder={props.authPlaceholder}
onInput={(e) => props.onChangeLogin({ ...props.loginValues, email: (e.currentTarget as HTMLInputElement).value })}
/>
</label>
@@ -227,6 +234,7 @@ export default function AuthViews(props: AuthViewsProps) {
label={t('txt_master_password')}
value={props.loginValues.password}
autoComplete="current-password"
placeholder={props.authPlaceholder}
onInput={(v) => props.onChangeLogin({ ...props.loginValues, password: v })}
autoFocus
/>
+58
View File
@@ -0,0 +1,58 @@
import { Home } from 'lucide-preact';
import { t } from '@/lib/i18n';
interface NotFoundPageProps {
title?: string;
message?: string;
homeHref?: string;
}
export default function NotFoundPage(props: NotFoundPageProps) {
const starBoxes = [1, 2, 3, 4];
const stars = [1, 2, 3, 4, 5, 6, 7];
return (
<main className="not-found-page">
<div className="not-found-space" aria-hidden="true">
{starBoxes.map((box) => (
<div key={box} className={`not-found-star-box not-found-star-box-${box}`}>
{stars.map((star) => (
<span key={star} className={`not-found-star not-found-star-position-${star}`} />
))}
</div>
))}
</div>
<section className="not-found-shell" aria-labelledby="not-found-title">
<div className="not-found-brand">
<img src="/nodewarden-logo.svg" alt="NodeWarden logo" className="not-found-logo" />
<span className="not-found-wordmark" aria-label="NodeWarden" role="img" />
</div>
<div className="not-found-astro-stage" aria-hidden="true">
<div className="not-found-astronaut">
<div className="not-found-astro-head" />
<div className="not-found-astro-arm not-found-astro-arm-left" />
<div className="not-found-astro-arm not-found-astro-arm-right" />
<div className="not-found-astro-body">
<div className="not-found-astro-panel" />
</div>
<div className="not-found-astro-leg not-found-astro-leg-left" />
<div className="not-found-astro-leg not-found-astro-leg-right" />
<div className="not-found-astro-pack" />
</div>
</div>
<div className="not-found-copy">
<div className="not-found-code">404</div>
<h1 id="not-found-title">{props.title || t('txt_page_not_found')}</h1>
<p>{props.message || t('txt_page_not_found_hint')}</p>
<a className="btn btn-primary not-found-action" href={props.homeHref || '/'}>
<Home size={14} className="btn-icon" />
{t('txt_back_to_home')}
</a>
</div>
</section>
</main>
);
}
+82 -5
View File
@@ -1,9 +1,12 @@
import { useEffect, useRef, useState } from 'preact/hooks';
import { Download, Eye, Lock } from 'lucide-preact';
import { Clipboard, Download, Eye, Lock } from 'lucide-preact';
import { accessPublicSend, accessPublicSendFile, decryptPublicSend, decryptPublicSendFileBytes } from '@/lib/api/send';
import { copyTextToClipboard } from '@/lib/clipboard';
import { toBufferSource } from '@/lib/crypto';
import { downloadBytesAsFile, readResponseBytesWithProgress } from '@/lib/download';
import NotFoundPage from '@/components/NotFoundPage';
import StandalonePageFrame from '@/components/StandalonePageFrame';
import { getDemoPublicSend, IS_DEMO_MODE } from '@/lib/demo';
import { t } from '@/lib/i18n';
interface PublicSendPageProps {
@@ -27,6 +30,25 @@ interface PublicSendData {
file?: PublicSendFileData | null;
}
function decodeBase64Url(value: string): Uint8Array | null {
try {
const raw = value.replace(/-/g, '+').replace(/_/g, '/');
const padded = raw + '='.repeat((4 - (raw.length % 4)) % 4);
const decoded = atob(padded);
const out = new Uint8Array(decoded.length);
for (let i = 0; i < decoded.length; i += 1) out[i] = decoded.charCodeAt(i);
return out;
} catch {
return null;
}
}
function hasUsableSendKey(keyPart: string | null): boolean {
if (!keyPart) return false;
const bytes = decodeBase64Url(keyPart);
return !!bytes && bytes.length >= 16;
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' ? value as Record<string, unknown> : null;
}
@@ -65,11 +87,13 @@ function parsePublicSendData(value: unknown): PublicSendData | null {
}
export default function PublicSendPage(props: PublicSendPageProps) {
const [loading, setLoading] = useState(true);
const initialDemoSend = IS_DEMO_MODE ? getDemoPublicSend(props.accessId) : null;
const [loading, setLoading] = useState(!IS_DEMO_MODE);
const [password, setPassword] = useState('');
const [needPassword, setNeedPassword] = useState(false);
const [error, setError] = useState('');
const [sendData, setSendData] = useState<PublicSendData | null>(null);
const [notFound, setNotFound] = useState(IS_DEMO_MODE && !initialDemoSend);
const [sendData, setSendData] = useState<PublicSendData | null>(initialDemoSend);
const [busy, setBusy] = useState(false);
const [downloadPercent, setDownloadPercent] = useState<number | null>(null);
const loadRequestRef = useRef(0);
@@ -83,8 +107,25 @@ export default function PublicSendPage(props: PublicSendPageProps) {
loadAbortRef.current = controller;
setBusy(true);
setError('');
setNotFound(false);
setLoading(true);
try {
if (IS_DEMO_MODE) {
const demoSend = getDemoPublicSend(props.accessId);
if (!demoSend) {
setNotFound(true);
setSendData(null);
return;
}
setSendData(demoSend);
setNeedPassword(false);
return;
}
if (!hasUsableSendKey(props.keyPart)) {
setNotFound(true);
setSendData(null);
return;
}
const data = await accessPublicSend(props.accessId, props.keyPart, pass, { signal: controller.signal });
if (controller.signal.aborted || requestId !== loadRequestRef.current) return;
if (!props.keyPart) {
@@ -104,6 +145,10 @@ export default function PublicSendPage(props: PublicSendPageProps) {
if (err.status === 401) {
setNeedPassword(true);
setError(t('txt_this_send_is_password_protected'));
} else if (err.status === 404) {
setNeedPassword(false);
setNotFound(true);
setError('');
} else {
setError(err.message || t('txt_failed_to_open_send'));
}
@@ -121,6 +166,11 @@ export default function PublicSendPage(props: PublicSendPageProps) {
setDownloadPercent(null);
setError('');
try {
if (IS_DEMO_MODE) {
const bytes = new TextEncoder().encode('NodeWarden demo file Send.\nThis download is generated locally in demo mode.\n');
downloadBytesAsFile(bytes, sendData.decFileName || sendData.file?.fileName || 'nodewarden-demo-send.txt', 'application/octet-stream');
return;
}
const url = await accessPublicSendFile(sendData.id, sendData.file.id, props.keyPart, password || undefined);
const resp = await fetch(url);
if (!resp.ok) throw new Error(t('txt_download_failed'));
@@ -152,15 +202,31 @@ export default function PublicSendPage(props: PublicSendPageProps) {
}
useEffect(() => {
if (IS_DEMO_MODE) {
const demoSend = getDemoPublicSend(props.accessId);
setSendData(demoSend);
setNotFound(!demoSend);
setNeedPassword(false);
setError('');
setLoading(false);
return;
}
void loadSend();
return () => {
loadAbortRef.current?.abort();
};
}, [props.accessId, props.keyPart]);
if (!loading && notFound) {
return <NotFoundPage title={t('txt_page_not_found')} message={t('txt_send_unavailable')} />;
}
return (
<div className="auth-page public-send-page">
<StandalonePageFrame title={t('txt_nodewarden_send')}>
<StandalonePageFrame
title={sendData ? (sendData.decName || t('txt_no_name')) : t('txt_nodewarden_send')}
eyebrow={sendData ? t('txt_nodewarden_send') : undefined}
>
{loading && <p className="muted">{t('txt_loading')}</p>}
{!loading && needPassword && (
@@ -190,9 +256,20 @@ export default function PublicSendPage(props: PublicSendPageProps) {
{!loading && sendData && (
<>
<h2 className="public-send-title">{sendData.decName || t('txt_no_name')}</h2>
{sendData.type === 0 ? (
<div className="card public-send-card">
<div className="public-send-card-head">
<span>{t('txt_text_send')}</span>
<button
type="button"
className="btn btn-secondary small public-send-copy-btn"
disabled={!sendData.decText}
onClick={() => void copyTextToClipboard(sendData.decText || '')}
>
<Clipboard size={14} className="btn-icon" />
{t('txt_copy')}
</button>
</div>
<div className="notes">{sendData.decText || ''}</div>
</div>
) : (
+19 -1
View File
@@ -1,12 +1,14 @@
import { useState } from 'preact/hooks';
import { Clock3, Pencil, RefreshCw, ShieldOff, Trash2 } from 'lucide-preact';
import ConfirmDialog from '@/components/ConfirmDialog';
import LoadingState from '@/components/LoadingState';
import type { AuthorizedDevice } from '@/lib/types';
import { t } from '@/lib/i18n';
interface SecurityDevicesPageProps {
devices: AuthorizedDevice[];
loading: boolean;
error: string;
onRefresh: () => void;
onRenameDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
onRevokeTrust: (device: AuthorizedDevice) => void;
@@ -72,7 +74,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
</div>
</div>
<div className="actions">
<button type="button" className="btn btn-secondary small" onClick={props.onRefresh}>
<button type="button" className="btn btn-secondary small" disabled={props.loading} onClick={props.onRefresh}>
<RefreshCw size={14} className="btn-icon" />
{t('txt_refresh')}
</button>
@@ -90,6 +92,15 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
<section className="card">
<h3 className="section-title-flush">{t('txt_authorized_devices')}</h3>
{!!props.error && (
<div className="local-error">
<span>{props.error}</span>
<button type="button" className="btn btn-secondary small" disabled={props.loading} onClick={props.onRefresh}>
<RefreshCw size={14} className="btn-icon" />
{t('txt_refresh')}
</button>
</div>
)}
<table className="table">
<thead>
<tr>
@@ -166,6 +177,13 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
</td>
</tr>
))}
{props.loading && props.devices.length === 0 && (
<tr>
<td colSpan={7}>
<LoadingState lines={5} compact />
</td>
</tr>
)}
{!props.loading && props.devices.length === 0 && (
<tr>
<td colSpan={7}>
+10 -1
View File
@@ -224,8 +224,17 @@ export default function SendsPage(props: SendsPageProps) {
}
}
function getAccessUrl(send: Send): string {
const rawUrl = send.shareUrl || `/send/${send.accessId}`;
if (/^https?:\/\//i.test(rawUrl)) return rawUrl;
if (rawUrl.startsWith('/#/')) return `${window.location.origin}${rawUrl}`;
if (rawUrl.startsWith('#/')) return `${window.location.origin}/${rawUrl}`;
if (rawUrl.startsWith('/')) return `${window.location.origin}/#${rawUrl}`;
return `${window.location.origin}/#/${rawUrl.replace(/^\/+/, '')}`;
}
function copyAccessUrl(send: Send): void {
const url = send.shareUrl || `${window.location.origin}/#/send/${send.accessId}`;
const url = getAccessUrl(send);
void copyTextToClipboard(url, { successMessage: t('txt_link_copied') });
}
+10 -1
View File
@@ -3,6 +3,7 @@ import { APP_VERSION } from '@shared/app-version';
interface StandalonePageFrameProps {
title: string;
eyebrow?: ComponentChildren;
children: ComponentChildren;
}
@@ -17,6 +18,7 @@ export default function StandalonePageFrame(props: StandalonePageFrameProps) {
</div>
<div className="auth-card">
{props.eyebrow && <div className="standalone-eyebrow">{props.eyebrow}</div>}
<h1 className="standalone-title">{props.title}</h1>
{props.children}
</div>
@@ -26,7 +28,14 @@ export default function StandalonePageFrame(props: StandalonePageFrameProps) {
<span> | </span>
<a href="https://github.com/shuaiplus" target="_blank" rel="noreferrer">Author: @shuaiplus</a>
<span> | </span>
<span className="standalone-version">v{APP_VERSION}</span>
<a
href="https://github.com/shuaiplus/NodeWarden/releases/latest"
target="_blank"
rel="noreferrer"
className="standalone-version"
>
v{APP_VERSION}
</a>
</div>
</div>
);
+16 -1
View File
@@ -36,6 +36,7 @@ interface VaultPageProps {
ciphers: Cipher[];
folders: Folder[];
loading: boolean;
error: string;
emailForReprompt: string;
onRefresh: () => Promise<void>;
onCreate: (draft: VaultDraft, attachments?: File[]) => Promise<void>;
@@ -1021,6 +1022,7 @@ const folderName = useCallback((id: string | null | undefined): string => {
<VaultListPanel
busy={busy}
loading={props.loading}
error={props.error}
searchInput={searchInput}
sortMode={sortMode}
sortMenuOpen={sortMenuOpen}
@@ -1140,7 +1142,20 @@ const folderName = useCallback((id: string | null | undefined): string => {
</div>
)}
{!isEditing && !selectedCipher && (props.loading ? <LoadingState card lines={5} /> : <div className="empty card">{t('txt_select_an_item')}</div>)}
{!isEditing && !selectedCipher && (
props.loading
? <LoadingState card lines={5} />
: props.error
? (
<div className="empty card vault-error-state">
<strong>{props.error}</strong>
<button type="button" className="btn btn-secondary small" disabled={busy} onClick={handleSyncVault}>
{t('txt_retry_sync')}
</button>
</div>
)
: <div className="empty card">{t('txt_select_an_item')}</div>
)}
</section>
</div>
@@ -1,12 +1,13 @@
import { createPortal } from 'preact/compat';
import { useMemo, useState } from 'preact/hooks';
import { Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Paperclip, Pencil, RotateCcw, Trash2, X } from 'lucide-preact';
import { Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Folder, Paperclip, Pencil, RotateCcw, Trash2, X } from 'lucide-preact';
import { useDialogLifecycle } from '@/components/ConfirmDialog';
import type { Cipher } from '@/lib/types';
import { t } from '@/lib/i18n';
import {
TOTP_PERIOD_SECONDS,
TOTP_RING_CIRCUMFERENCE,
VaultListIcon,
copyToClipboard,
formatAttachmentSize,
formatHistoryTime,
@@ -115,8 +116,18 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
{(Number(props.selectedCipher.reprompt || 0) !== 1 || props.repromptApprovedCipherId === props.selectedCipher.id) && (
<>
<div className="card">
<h3 className="detail-title">{props.selectedCipher.decName || t('txt_no_name')}</h3>
<div className="detail-sub">{props.folderName(props.selectedCipher.folderId)}</div>
<div className="detail-title-row">
<span className="detail-title-icon" aria-hidden="true">
<VaultListIcon cipher={props.selectedCipher} />
</span>
<div className="detail-title-main">
<h3 className="detail-title">{props.selectedCipher.decName || t('txt_no_name')}</h3>
<div className="detail-folder-line">
<Folder size={13} aria-hidden="true" />
<span>{props.folderName(props.selectedCipher.folderId)}</span>
</div>
</div>
</div>
{isArchived && <div className="list-badge archive-badge">{t('txt_archived')}</div>}
</div>
+201 -2
View File
@@ -1,6 +1,8 @@
import type { JSX, RefObject } from 'preact';
import { CheckCheck, Download, GripVertical, Paperclip, Plus, RefreshCw, Star, StarOff, Trash2, Upload, X } from 'lucide-preact';
import { createPortal } from 'preact/compat';
import { CheckCheck, Download, GripVertical, Paperclip, Plus, QrCode, RefreshCw, Star, StarOff, Trash2, Upload, X } from 'lucide-preact';
import { useEffect, useRef, useState } from 'preact/hooks';
import { useDialogLifecycle } from '@/components/ConfirmDialog';
import {
closestCenter,
DndContext,
@@ -137,8 +139,16 @@ function SortableWebsiteRow(props: SortableWebsiteRowProps) {
export default function VaultEditor(props: VaultEditorProps) {
const createTypeOptions = getCreateTypeOptions();
const uriIdSeedRef = useRef(0);
const totpQrVideoRef = useRef<HTMLVideoElement | null>(null);
const totpQrFileRef = useRef<HTMLInputElement | null>(null);
const totpQrStreamRef = useRef<MediaStream | null>(null);
const totpQrFrameRef = useRef<number | null>(null);
const [uriItemIds, setUriItemIds] = useState<string[]>([]);
const [activeUriId, setActiveUriId] = useState<string | null>(null);
const [totpQrOpen, setTotpQrOpen] = useState(false);
const [totpQrStatus, setTotpQrStatus] = useState('');
const [totpQrBusy, setTotpQrBusy] = useState(false);
useDialogLifecycle(totpQrOpen, () => setTotpQrOpen(false));
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
@@ -155,6 +165,63 @@ export default function VaultEditor(props: VaultEditorProps) {
const createUriId = () => `login-uri-${uriIdSeedRef.current++}`;
const stopTotpQrScanner = () => {
if (totpQrFrameRef.current != null) {
window.cancelAnimationFrame(totpQrFrameRef.current);
totpQrFrameRef.current = null;
}
if (totpQrStreamRef.current) {
for (const track of totpQrStreamRef.current.getTracks()) track.stop();
totpQrStreamRef.current = null;
}
if (totpQrVideoRef.current) {
totpQrVideoRef.current.srcObject = null;
}
};
const applyTotpQrValue = (value: string) => {
const trimmed = value.trim();
if (!trimmed) return false;
props.onUpdateDraft({ loginTotp: trimmed });
setTotpQrStatus(t('txt_totp_qr_scanned'));
setTotpQrOpen(false);
return true;
};
const createTotpQrDetector = (): BarcodeDetector | null => {
if (typeof window === 'undefined' || !window.BarcodeDetector) return null;
return new window.BarcodeDetector({ formats: ['qr_code'] });
};
const decodeTotpQrImage = async (source: ImageBitmapSource): Promise<boolean> => {
const detector = createTotpQrDetector();
if (!detector) {
setTotpQrStatus(t('txt_totp_qr_unsupported'));
return false;
}
const results = await detector.detect(source);
const value = String(results[0]?.rawValue || '').trim();
if (!value) return false;
return applyTotpQrValue(value);
};
const handleTotpQrFile = async (file: File | null) => {
if (!file) return;
setTotpQrBusy(true);
setTotpQrStatus(t('txt_totp_qr_scanning'));
let bitmap: ImageBitmap | null = null;
try {
bitmap = await createImageBitmap(file);
const found = await decodeTotpQrImage(bitmap);
if (!found) setTotpQrStatus(t('txt_totp_qr_not_found'));
} catch {
setTotpQrStatus(t('txt_totp_qr_scan_failed'));
} finally {
bitmap?.close();
setTotpQrBusy(false);
}
};
useEffect(() => {
setUriItemIds((prev) => {
if (prev.length === props.draft.loginUris.length) return prev;
@@ -170,6 +237,77 @@ export default function VaultEditor(props: VaultEditorProps) {
setActiveUriId(null);
}, [props.draft.id, props.isCreating]);
useEffect(() => {
if (!totpQrOpen) {
stopTotpQrScanner();
return;
}
let stopped = false;
const detector = createTotpQrDetector();
if (!detector) {
setTotpQrStatus(t('txt_totp_qr_unsupported'));
return () => {
stopped = true;
stopTotpQrScanner();
};
}
if (!navigator.mediaDevices?.getUserMedia) {
setTotpQrStatus(t('txt_totp_qr_camera_unavailable'));
return () => {
stopped = true;
stopTotpQrScanner();
};
}
const scan = async () => {
if (stopped) return;
const video = totpQrVideoRef.current;
if (!video || video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) {
totpQrFrameRef.current = window.requestAnimationFrame(scan);
return;
}
try {
const results = await detector.detect(video);
const value = String(results[0]?.rawValue || '').trim();
if (value && applyTotpQrValue(value)) return;
} catch {
// Keep the camera active; transient frame decode failures are common.
}
totpQrFrameRef.current = window.requestAnimationFrame(scan);
};
setTotpQrBusy(true);
setTotpQrStatus(t('txt_totp_qr_starting_camera'));
navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' }, audio: false })
.then((stream) => {
if (stopped) {
for (const track of stream.getTracks()) track.stop();
return;
}
totpQrStreamRef.current = stream;
const video = totpQrVideoRef.current;
if (!video) return;
video.srcObject = stream;
setTotpQrStatus(t('txt_totp_qr_point_camera'));
void video.play().then(() => {
setTotpQrBusy(false);
totpQrFrameRef.current = window.requestAnimationFrame(scan);
}).catch(() => {
setTotpQrBusy(false);
setTotpQrStatus(t('txt_totp_qr_camera_unavailable'));
});
})
.catch(() => {
setTotpQrBusy(false);
setTotpQrStatus(t('txt_totp_qr_camera_unavailable'));
});
return () => {
stopped = true;
stopTotpQrScanner();
};
}, [totpQrOpen]);
const formatDownloadLabel = (attachmentId: string) => {
const downloadKey = `${props.selectedCipher?.id || ''}:${attachmentId}`;
if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download');
@@ -274,7 +412,22 @@ export default function VaultEditor(props: VaultEditorProps) {
</div>
<label className="field">
<span>{t('txt_totp_secret')}</span>
<input className="input" value={props.draft.loginTotp} onInput={(e) => props.onUpdateDraft({ loginTotp: (e.currentTarget as HTMLInputElement).value })} />
<div className="input-action-wrap">
<input className="input" value={props.draft.loginTotp} onInput={(e) => props.onUpdateDraft({ loginTotp: (e.currentTarget as HTMLInputElement).value })} />
<button
type="button"
className="input-icon-btn"
title={t('txt_scan_totp_qr')}
aria-label={t('txt_scan_totp_qr')}
disabled={props.busy}
onClick={() => {
setTotpQrStatus('');
setTotpQrOpen(true);
}}
>
<QrCode size={18} className="btn-icon" />
</button>
</div>
</label>
<div className="section-head">
<h4>{t('txt_websites')}</h4>
@@ -571,6 +724,52 @@ export default function VaultEditor(props: VaultEditorProps) {
)}
</div>
{props.localError && <div className="local-error">{props.localError}</div>}
{totpQrOpen && typeof document !== 'undefined' ? createPortal((
<div className="dialog-mask totp-scan-mask open" onClick={(event) => event.target === event.currentTarget && setTotpQrOpen(false)}>
<section className="dialog-card totp-scan-dialog open" role="dialog" aria-modal="true" aria-label={t('txt_scan_totp_qr')}>
<div className="totp-scan-head">
<h3 className="dialog-title">{t('txt_scan_totp_qr')}</h3>
<button
type="button"
className="totp-scan-close"
onClick={() => setTotpQrOpen(false)}
title={t('txt_close')}
aria-label={t('txt_close')}
>
<X size={20} className="btn-icon" />
</button>
</div>
<div className="totp-scan-frame">
<video ref={totpQrVideoRef} className="totp-scan-video" muted playsInline />
<div className="totp-scan-corners" aria-hidden="true" />
</div>
<div className="totp-scan-footer">
<div className="dialog-message totp-scan-status">{totpQrStatus || t('txt_totp_qr_point_camera')}</div>
<div className="actions totp-scan-actions">
<button type="button" className="btn btn-secondary dialog-btn" disabled={totpQrBusy} onClick={() => totpQrFileRef.current?.click()}>
<Upload size={14} className="btn-icon" />
{t('txt_totp_qr_choose_image')}
</button>
<button type="button" className="btn btn-primary dialog-btn" onClick={() => setTotpQrOpen(false)}>
<X size={14} className="btn-icon" />
{t('txt_close')}
</button>
</div>
</div>
<input
ref={totpQrFileRef}
type="file"
accept="image/*"
className="attachment-file-input"
onChange={(event) => {
const input = event.currentTarget as HTMLInputElement;
void handleTotpQrFile(input.files?.[0] || null);
input.value = '';
}}
/>
</section>
</div>
), document.body) : null}
</>
);
}
+10 -1
View File
@@ -24,6 +24,7 @@ interface VirtualRange {
interface VaultListPanelProps {
busy: boolean;
loading: boolean;
error: string;
searchInput: string;
sortMode: VaultSortMode;
sortMenuOpen: boolean;
@@ -238,6 +239,14 @@ export default function VaultListPanel(props: VaultListPanelProps) {
<div className="list-panel" ref={props.listPanelRef} onScroll={(event) => props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}>
{props.loading && !props.filteredCiphers.length && <LoadingState lines={7} compact />}
{!props.loading && !!props.error && !props.filteredCiphers.length && (
<div className="empty vault-error-state">
<strong>{props.error}</strong>
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onSyncVault}>
{t('txt_retry_sync')}
</button>
</div>
)}
{!!props.filteredCiphers.length && (
<div style={{ paddingTop: `${props.virtualRange.padTop}px`, paddingBottom: `${props.virtualRange.padBottom}px` }}>
{props.visibleCiphers.map((cipher) => (
@@ -253,7 +262,7 @@ export default function VaultListPanel(props: VaultListPanelProps) {
))}
</div>
)}
{!props.loading && !props.filteredCiphers.length && <div className="empty">{t('txt_no_items')}</div>}
{!props.loading && !props.error && !props.filteredCiphers.length && <div className="empty">{t('txt_no_items')}</div>}
</div>
</section>
);
+34 -10
View File
@@ -3,15 +3,16 @@ import type { ComponentChildren } from 'preact';
import { Globe } from 'lucide-preact';
import type { Cipher } from '@/lib/types';
import {
getWebsiteIconImageUrl,
getWebsiteIconStatus,
markWebsiteIconErrored,
markWebsiteIconLoaded,
preloadWebsiteIcon,
subscribeWebsiteIconStatus,
} from '@/lib/website-icon-cache';
import { demoBrandIconUrl } from '@/lib/demo-brand-icons';
import { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils';
const ICON_LOAD_ROOT_MARGIN = '180px 0px';
const SHOULD_LOAD_DEMO_BRAND_ICONS = __NODEWARDEN_DEMO__;
interface WebsiteIconProps {
cipher: Cipher;
@@ -24,17 +25,24 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
const nodeRef = useRef<HTMLSpanElement | null>(null);
const [shouldLoad, setShouldLoad] = useState(() => (host ? getWebsiteIconStatus(host) === 'loaded' : true));
const [status, setStatus] = useState(() => (host ? getWebsiteIconStatus(host) : 'idle'));
const [imageUrl, setImageUrl] = useState(() => (host ? getWebsiteIconImageUrl(host) : ''));
const demoIconUrl = SHOULD_LOAD_DEMO_BRAND_ICONS && host ? demoBrandIconUrl(host) : '';
useEffect(() => {
if (!host) {
setShouldLoad(true);
setStatus('idle');
setImageUrl('');
return;
}
const nextStatus = getWebsiteIconStatus(host);
setShouldLoad(nextStatus === 'loaded');
setStatus(nextStatus);
return subscribeWebsiteIconStatus(host, setStatus);
setImageUrl(getWebsiteIconImageUrl(host));
return subscribeWebsiteIconStatus(host, (next) => {
setStatus(next);
setImageUrl(getWebsiteIconImageUrl(host));
});
}, [host]);
useEffect(() => {
@@ -67,15 +75,33 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
}, [host, shouldLoad, status]);
useEffect(() => {
if (SHOULD_LOAD_DEMO_BRAND_ICONS) return;
if (demoIconUrl) return;
if (!host || !src || !shouldLoad || status === 'loaded' || status === 'error') return;
let disposed = false;
void preloadWebsiteIcon(host, src).then((nextStatus) => {
if (!disposed) setStatus(nextStatus);
if (disposed) return;
setStatus(nextStatus);
setImageUrl(getWebsiteIconImageUrl(host));
});
return () => {
disposed = true;
};
}, [host, src, shouldLoad, status]);
}, [demoIconUrl, host, src, shouldLoad, status]);
if (demoIconUrl) {
return (
<span className="list-icon-stack" ref={nodeRef}>
<img
className="list-icon loaded"
src={demoIconUrl}
alt=""
loading="lazy"
decoding="async"
/>
</span>
);
}
if (!host || status === 'error') {
return <span className="list-icon-fallback">{props.fallback ?? <Globe size={18} />}</span>;
@@ -84,18 +110,16 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
return (
<span className="list-icon-stack" ref={nodeRef}>
{status !== 'loaded' && <span className="list-icon-fallback">{props.fallback ?? <Globe size={18} />}</span>}
{status === 'loaded' && (
{status === 'loaded' && imageUrl && (
<img
className="list-icon loaded"
src={src}
src={imageUrl}
alt=""
loading="lazy"
decoding="async"
referrerPolicy="no-referrer"
onLoad={() => markWebsiteIconLoaded(host)}
onError={() => markWebsiteIconErrored(host)}
/>
)}
</span>
);
}
+38 -17
View File
@@ -20,26 +20,39 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
return useMemo(
() => ({
refreshAdmin() {
void refetchUsers();
void refetchInvites();
void Promise.all([refetchUsers(), refetchInvites()]).catch((error) => {
onNotify('error', error instanceof Error ? error.message : t('txt_load_admin_data_failed'));
});
},
async createInvite(hours: number) {
await createInvite(authedFetch, hours);
await refetchInvites();
onNotify('success', t('txt_invite_created'));
try {
await createInvite(authedFetch, hours);
await refetchInvites();
onNotify('success', t('txt_invite_created'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_create_invite_failed'));
}
},
async toggleUserStatus(userId: string, status: 'active' | 'banned') {
await setUserStatus(authedFetch, userId, status === 'active' ? 'banned' : 'active');
await refetchUsers();
onNotify('success', t('txt_user_status_updated'));
try {
await setUserStatus(authedFetch, userId, status === 'active' ? 'banned' : 'active');
await refetchUsers();
onNotify('success', t('txt_user_status_updated'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_update_user_status_failed'));
}
},
async revokeInvite(code: string) {
await revokeInvite(authedFetch, code);
await refetchInvites();
onNotify('success', t('txt_invite_revoked'));
try {
await revokeInvite(authedFetch, code);
await refetchInvites();
onNotify('success', t('txt_invite_revoked'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_revoke_invite_failed'));
}
},
async deleteAllInvites() {
@@ -50,9 +63,13 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
onConfirm: () => {
onSetConfirm(null);
void (async () => {
await deleteAllInvites(authedFetch);
await refetchInvites();
onNotify('success', t('txt_all_invites_deleted'));
try {
await deleteAllInvites(authedFetch);
await refetchInvites();
onNotify('success', t('txt_all_invites_deleted'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_delete_all_invites_failed'));
}
})();
},
});
@@ -66,9 +83,13 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
onConfirm: () => {
onSetConfirm(null);
void (async () => {
await deleteUser(authedFetch, userId);
await refetchUsers();
onNotify('success', t('txt_user_deleted'));
try {
await deleteUser(authedFetch, userId);
await refetchUsers();
onNotify('success', t('txt_user_deleted'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_delete_user_failed'));
}
})();
},
});
+34 -4
View File
@@ -46,6 +46,8 @@ interface RefreshSuccess {
type RefreshResult = RefreshFailure | RefreshSuccess;
const pendingRefreshes = new Map<string, Promise<RefreshResult>>();
function randomHex(length: number): string {
const bytes = crypto.getRandomValues(new Uint8Array(Math.max(1, Math.ceil(length / 2))));
return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, length);
@@ -312,6 +314,25 @@ export async function refreshAccessToken(session: SessionState): Promise<Refresh
}
}
function refreshKey(session: SessionState): string {
if (session.authMode === 'web-cookie') return `web-cookie:${session.email || ''}`;
return `token:${session.refreshToken || ''}`;
}
function refreshAccessTokenOnce(session: SessionState): Promise<RefreshResult> {
const key = refreshKey(session);
const existing = pendingRefreshes.get(key);
if (existing) return existing;
const request = refreshAccessToken(session).finally(() => {
if (pendingRefreshes.get(key) === request) {
pendingRefreshes.delete(key);
}
});
pendingRefreshes.set(key, request);
return request;
}
export async function revokeCurrentSession(session: SessionState | null): Promise<void> {
const body = new URLSearchParams();
if (session?.authMode !== 'web-cookie' && session?.refreshToken) {
@@ -436,7 +457,16 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
let resp = await retryableRequest(headers);
if (resp.status !== 401 || (!session.refreshToken && session.authMode !== 'web-cookie')) return resp;
const refreshed = await refreshAccessToken(session);
const latest = getSession();
if (latest?.accessToken && latest.accessToken !== session.accessToken) {
const latestHeaders = new Headers(init.headers || {});
latestHeaders.set('Authorization', `Bearer ${latest.accessToken}`);
resp = await retryableRequest(latestHeaders);
if (resp.status !== 401) return resp;
}
const refreshSource = latest || session;
const refreshed = await refreshAccessTokenOnce(refreshSource);
if (!refreshed.ok) {
if (refreshed.transient) {
throw new Error(refreshed.error || 'Session refresh temporarily unavailable');
@@ -446,10 +476,10 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
}
const nextSession: SessionState = {
...session,
...refreshSource,
accessToken: refreshed.token.access_token,
refreshToken: refreshed.token.refresh_token || session.refreshToken,
authMode: refreshed.token.web_session ? 'web-cookie' : (session.authMode || 'token'),
refreshToken: refreshed.token.refresh_token || refreshSource.refreshToken,
authMode: refreshed.token.web_session ? 'web-cookie' : (refreshSource.authMode || 'token'),
};
setSession(nextSession);
saveSession(nextSession);
+56 -26
View File
@@ -16,6 +16,15 @@ function normalizeSnapshot(body: VaultSyncResponse | null | undefined): VaultCor
return {
ciphers: Array.isArray(body?.ciphers) ? body!.ciphers! : [],
folders: Array.isArray(body?.folders) ? body!.folders! : [],
sends: Array.isArray(body?.sends) ? body!.sends! : [],
};
}
function normalizeCachedSnapshot(snapshot: Partial<VaultCoreSnapshot> | null | undefined): VaultCoreSnapshot {
return {
ciphers: Array.isArray(snapshot?.ciphers) ? snapshot.ciphers : [],
folders: Array.isArray(snapshot?.folders) ? snapshot.folders : [],
sends: Array.isArray(snapshot?.sends) ? snapshot.sends : [],
};
}
@@ -26,49 +35,70 @@ export async function getCachedVaultCoreSnapshot(cacheKey: string): Promise<Vaul
if (memory) return memory.snapshot;
const cached = await loadCachedVaultCoreSnapshot(normalizedKey);
if (!cached?.snapshot) return null;
const snapshot = normalizeCachedSnapshot(cached.snapshot);
memoryVaultCoreCache.set(normalizedKey, {
revisionStamp: cached.revisionStamp,
snapshot: cached.snapshot,
snapshot,
});
return cached.snapshot;
return snapshot;
}
export async function loadVaultCoreSyncSnapshot(authedFetch: AuthedFetch, cacheKey: string): Promise<VaultCoreSnapshot> {
const normalizedKey = String(cacheKey || '').trim();
if (!normalizedKey) return { ciphers: [], folders: [] };
if (!normalizedKey) return { ciphers: [], folders: [], sends: [] };
const existing = pendingVaultCoreRequests.get(normalizedKey);
if (existing) return existing;
const request = (async () => {
const revisionStamp = await getVaultRevisionDate(authedFetch);
const memory = memoryVaultCoreCache.get(normalizedKey);
if (memory?.revisionStamp === revisionStamp) {
return memory.snapshot;
}
const cached = await loadCachedVaultCoreSnapshot(normalizedKey);
if (cached?.revisionStamp === revisionStamp && cached.snapshot) {
let cached = await loadCachedVaultCoreSnapshot(normalizedKey);
if (!memory && cached?.snapshot) {
const snapshot = normalizeCachedSnapshot(cached.snapshot);
memoryVaultCoreCache.set(normalizedKey, {
revisionStamp,
snapshot: cached.snapshot,
revisionStamp: cached.revisionStamp,
snapshot,
});
return cached.snapshot;
}
const resp = await authedFetch('/api/sync?excludeSends=true&excludeDomains=true', {
cache: 'no-store',
headers: {
'Cache-Control': 'no-cache',
Pragma: 'no-cache',
},
});
if (!resp.ok) throw new Error('Failed to load vault');
const body = await parseJson<VaultSyncResponse>(resp);
const snapshot = normalizeSnapshot(body);
memoryVaultCoreCache.set(normalizedKey, { revisionStamp, snapshot });
void saveCachedVaultCoreSnapshot(normalizedKey, revisionStamp, snapshot);
return snapshot;
try {
const revisionStamp = await getVaultRevisionDate(authedFetch);
const currentMemory = memoryVaultCoreCache.get(normalizedKey);
if (currentMemory?.revisionStamp === revisionStamp) {
return currentMemory.snapshot;
}
if (!cached) {
cached = await loadCachedVaultCoreSnapshot(normalizedKey);
}
if (cached?.revisionStamp === revisionStamp && cached.snapshot) {
const snapshot = normalizeCachedSnapshot(cached.snapshot);
memoryVaultCoreCache.set(normalizedKey, {
revisionStamp,
snapshot,
});
return snapshot;
}
const resp = await authedFetch('/api/sync', {
cache: 'no-store',
headers: {
'Cache-Control': 'no-cache',
Pragma: 'no-cache',
},
});
if (!resp.ok) throw new Error('Failed to load vault');
const body = await parseJson<VaultSyncResponse>(resp);
const snapshot = normalizeSnapshot(body);
memoryVaultCoreCache.set(normalizedKey, { revisionStamp, snapshot });
void saveCachedVaultCoreSnapshot(normalizedKey, revisionStamp, snapshot);
return snapshot;
} catch (error) {
const fallbackMemory = memoryVaultCoreCache.get(normalizedKey);
if (fallbackMemory?.snapshot) return fallbackMemory.snapshot;
if (cached?.snapshot) return normalizeCachedSnapshot(cached.snapshot);
throw error;
}
})();
pendingVaultCoreRequests.set(normalizedKey, request);
+73
View File
@@ -0,0 +1,73 @@
let workspacePreload: Promise<unknown> | null = null;
let adminPreload: Promise<unknown> | null = null;
let demoExperiencePreloadStarted = false;
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;
}
export function preloadDemoExperience(): () => void {
if (demoExperiencePreloadStarted || typeof window === 'undefined') {
return () => undefined;
}
demoExperiencePreloadStarted = true;
let cancelled = false;
let timerId: number | null = null;
const tasks = [
() => import('@/components/VaultPage'),
() => import('@/components/SendsPage'),
() => import('@/components/TotpCodesPage'),
() => import('@/components/SettingsPage'),
() => import('@/components/SecurityDevicesPage'),
() => import('@/components/AdminPage'),
() => import('@/components/BackupCenterPage'),
() => import('@/components/ImportPage'),
];
const wait = (ms: number) => new Promise<void>((resolve) => {
timerId = window.setTimeout(() => {
timerId = null;
resolve();
}, ms);
});
void (async () => {
await wait(120);
for (const task of tasks) {
if (cancelled) return;
await task().catch(() => undefined);
await wait(180);
}
})();
return () => {
cancelled = true;
if (timerId !== null) {
window.clearTimeout(timerId);
timerId = null;
}
};
}
File diff suppressed because one or more lines are too long
+42
View File
@@ -0,0 +1,42 @@
import type { AppMainRoutesProps } from '@/components/AppMainRoutes';
import type { CompletedLogin, InitialAppBootstrapState } from '@/lib/app-auth';
import type { AdminBackupSettings } from '@/lib/api/backup';
import type { AdminInvite, AdminUser, AuthorizedDevice, Cipher, Folder, Send } from '@/lib/types';
export const IS_DEMO_MODE = false;
export const DEMO_CIPHERS: Cipher[] = [];
export const DEMO_ADMIN_INVITES: AdminInvite[] = [];
export const DEMO_ADMIN_USERS: AdminUser[] = [];
export const DEMO_AUTHORIZED_DEVICES: AuthorizedDevice[] = [];
export const DEMO_FOLDERS: Folder[] = [];
export const DEMO_SENDS: Send[] = [];
export function createDemoBackupSettings(): AdminBackupSettings {
return { destinations: [] };
}
export function createDemoInitialBootstrapState(): InitialAppBootstrapState {
return {
defaultKdfIterations: 600000,
jwtWarning: null,
session: null,
phase: 'login',
};
}
export function createDemoCompletedLogin(): CompletedLogin {
throw new Error('Demo mode is not available in this build.');
}
export function createDemoMainRoutesProps(base: AppMainRoutesProps): AppMainRoutesProps {
return base;
}
export function getDemoPublicSend(): null {
return null;
}
export function demoBrandIconUrl(_host: string): string {
return '';
}
File diff suppressed because it is too large Load Diff
+6 -8
View File
@@ -5,6 +5,8 @@ export type Locale =
| 'ru'
| 'es';
import enMessages from './i18n/locales/en';
const LOCALE_STORAGE_KEY = 'nodewarden.locale';
type MessageTable = Record<string, string>;
@@ -18,8 +20,8 @@ export const AVAILABLE_LOCALES: readonly { value: Locale; label: string }[] = [
];
let locale: Locale = resolveInitialLocale();
let activeMessages: MessageTable = {};
const loadedMessages = new Map<Locale, MessageTable>();
let activeMessages: MessageTable = enMessages;
const loadedMessages = new Map<Locale, MessageTable>([['en', enMessages]]);
function isLocale(value: unknown): value is Locale {
return AVAILABLE_LOCALES.some((item) => item.value === value);
@@ -46,7 +48,7 @@ function resolveInitialLocale(): Locale {
}
const localeLoaders: Record<Locale, () => Promise<{ default: MessageTable }>> = {
en: () => import('./i18n/locales/en'),
en: () => Promise.resolve({ default: enMessages }),
'zh-CN': () => import('./i18n/locales/zh-CN'),
'zh-TW': () => import('./i18n/locales/zh-TW'),
ru: () => import('./i18n/locales/ru'),
@@ -63,11 +65,7 @@ async function loadLocaleMessages(next: Locale): Promise<MessageTable> {
}
async function loadFallbackMessages(): Promise<MessageTable> {
const cached = loadedMessages.get('en');
if (cached) return cached;
const mod = await import('./i18n/locales/en');
loadedMessages.set('en', mod.default);
return mod.default;
return enMessages;
}
export type I18nParams = Record<string, string | number | null | undefined>;
+32
View File
@@ -7,10 +7,21 @@ const en: Record<string, string> = {
"nav_sends": "Sends",
"nav_backup_strategy": "Cloud Backup",
"nav_import_export": "Import & Export",
"txt_page_not_found": "Page Not Found",
"txt_page_not_found_hint": "The page may have been removed, expired, or the link is incomplete.",
"txt_back_to_home": "Back To Home",
"backup_strategy_title": "Cloud Backup",
"backup_strategy_under_construction": "Under construction.",
"import_export_title": "Import & Export",
"import_export_under_construction": "Under construction.",
"txt_demo_admin_refreshed": "Demo admin data refreshed.",
"txt_demo_auth_placeholder": "Demo: enter anything, or leave it empty",
"txt_demo_data_reset": "Demo data reset to defaults.",
"txt_demo_devices_refreshed": "Demo devices refreshed.",
"txt_demo_download_prepared": "Demo download prepared.",
"txt_demo_master_password_hint": "In demo mode, any input unlocks the vault.",
"txt_demo_readonly_message": "Demo mode is read-only for this action. No changes were saved.",
"txt_demo_unlock_placeholder": "Demo: any password works, even empty",
"txt_backup_export": "Export Backup",
"txt_backup_import": "Restore",
"txt_backup_include_attachments": "Include attachments",
@@ -283,6 +294,7 @@ const en: Record<string, string> = {
"txt_address_3": "Address 3",
"txt_all_device_authorizations_revoked": "All device trust revoked",
"txt_all_invites_deleted": "All invites deleted",
"txt_delete_all_invites_failed": "Failed to delete all invites",
"txt_all_items": "All Items",
"txt_all_sends": "All Sends",
"txt_android": "Android",
@@ -388,6 +400,7 @@ const en: Record<string, string> = {
"txt_device_note_required": "Device name is required",
"txt_device_note_updated": "Device name updated",
"txt_device_removed": "Device removed",
"txt_load_admin_data_failed": "Failed to load admin data",
"txt_load_devices_failed": "Failed to load devices",
"txt_disable_this_send": "Disable this send",
"txt_disable_totp": "Disable TOTP",
@@ -450,9 +463,11 @@ const en: Record<string, string> = {
"txt_identity": "Identity",
"txt_identity_details": "Identity Details",
"txt_ie_browser": "IE Browser",
"txt_create_invite_failed": "Failed to create invite",
"txt_invite_code_optional": "Invite Code (Not required for the first account; required for all others)",
"txt_invite_created": "Invite created",
"txt_invite_revoked": "Invite revoked",
"txt_revoke_invite_failed": "Failed to revoke invite",
"txt_invite_validity_hours": "Invite validity (hours)",
"txt_invites": "Invites",
"txt_ios": "iOS",
@@ -473,6 +488,9 @@ const en: Record<string, string> = {
"txt_linux_desktop": "Linux Desktop",
"txt_loading": "Loading...",
"txt_loading_nodewarden": "Loading NodeWarden...",
"txt_loading_vault": "Loading vault...",
"txt_load_vault_failed": "Failed to load vault.",
"txt_retry_sync": "Retry sync",
"txt_jwt_warning_title": "Server Security Warning",
"txt_jwt_warning_subtitle": "JWT secret is not configured safely.",
"txt_jwt_title_missing": "JWT_SECRET is missing",
@@ -544,7 +562,9 @@ const en: Record<string, string> = {
"txt_no": "No",
"txt_no_devices_found": "No devices found.",
"txt_no_folder": "No Folder",
"txt_no_invites_found": "No invites found.",
"txt_no_items": "No items",
"txt_no_users_found": "No users found.",
"txt_no_username": "(No username)",
"txt_no_verification_codes": "No verification codes",
"txt_no_name": "(No Name)",
@@ -697,6 +717,16 @@ const en: Record<string, string> = {
"txt_totp_is_enabled_for_this_account": "TOTP is enabled for this account.",
"txt_total_items_count": "{count} items",
"txt_totp_secret": "TOTP Secret",
"txt_scan_totp_qr": "Scan TOTP QR code",
"txt_totp_qr_starting_camera": "Starting camera...",
"txt_totp_qr_point_camera": "Point the camera at a TOTP QR code.",
"txt_totp_qr_scanning": "Scanning QR code...",
"txt_totp_qr_scanned": "TOTP value added.",
"txt_totp_qr_not_found": "No QR code found in that image.",
"txt_totp_qr_scan_failed": "Failed to scan QR code.",
"txt_totp_qr_unsupported": "This browser does not support QR scanning. Try Chrome or Edge, or paste the TOTP link or secret manually.",
"txt_totp_qr_camera_unavailable": "Camera is unavailable. Check browser permission, or choose an image.",
"txt_totp_qr_choose_image": "Choose image",
"txt_totp_verify_failed": "TOTP verify failed",
"txt_attachments": "Attachments",
"txt_upload_attachments": "Upload attachments",
@@ -725,8 +755,10 @@ const en: Record<string, string> = {
"txt_remove_all_devices_failed": "Failed to remove all devices",
"txt_update_item_failed": "Update item failed",
"txt_update_send_failed": "Update send failed",
"txt_update_user_status_failed": "Failed to update user status",
"txt_use_recovery_code": "Use Recovery Code",
"txt_use_your_one_time_recovery_code_to_disable_two_step_verification": "Use your one-time recovery code to disable two-step verification.",
"txt_delete_user_failed": "Failed to delete user",
"txt_user_deleted": "User deleted",
"txt_user_status_updated": "User status updated",
"txt_username": "Username",
+33 -1
View File
@@ -7,10 +7,21 @@ const es: Record<string, string> = {
"nav_sends": "Envíos",
"nav_backup_strategy": "Copia de seguridad en la nube",
"nav_import_export": "Importar y exportar",
"txt_page_not_found": "Página no encontrada",
"txt_page_not_found_hint": "La página pudo haberse eliminado, expirado, o el enlace está incompleto.",
"txt_back_to_home": "Volver al inicio",
"backup_strategy_title": "Copia de seguridad en la nube",
"backup_strategy_under_construction": "En construcción.",
"import_export_title": "Importar y exportar",
"import_export_under_construction": "En construcción.",
"txt_demo_admin_refreshed": "Datos de administración de la demo actualizados.",
"txt_demo_auth_placeholder": "Demo: escribe cualquier cosa, o déjalo vacío",
"txt_demo_data_reset": "Los datos de la demo volvieron a sus valores predeterminados.",
"txt_demo_devices_refreshed": "Dispositivos de la demo actualizados.",
"txt_demo_download_prepared": "Descarga de la demo preparada.",
"txt_demo_master_password_hint": "En modo demo, cualquier entrada desbloquea la bóveda.",
"txt_demo_readonly_message": "En modo demo, esta acción es de solo lectura. No se guardaron cambios.",
"txt_demo_unlock_placeholder": "Demo: cualquier contraseña funciona, incluso vacío",
"txt_backup_export": "Exportar copia de seguridad",
"txt_backup_import": "Restaurar",
"txt_backup_include_attachments": "Incluir archivos adjuntos",
@@ -283,6 +294,7 @@ const es: Record<string, string> = {
"txt_address_3": "Dirección 3",
"txt_all_device_authorizations_revoked": "Confianza de todos los dispositivos revocada",
"txt_all_invites_deleted": "Todas las invitaciones eliminadas",
"txt_delete_all_invites_failed": "Error al eliminar todas las invitaciones",
"txt_all_items": "Todos los elementos",
"txt_all_sends": "Todos los envíos",
"txt_android": "Android",
@@ -388,6 +400,7 @@ const es: Record<string, string> = {
"txt_device_note_required": "El nombre del dispositivo es obligatorio",
"txt_device_note_updated": "Nombre del dispositivo actualizado",
"txt_device_removed": "Dispositivo eliminado",
"txt_load_admin_data_failed": "Error al cargar datos de administración",
"txt_load_devices_failed": "Error al cargar dispositivos",
"txt_disable_this_send": "Desactivar este envío",
"txt_disable_totp": "Desactivar TOTP",
@@ -450,9 +463,11 @@ const es: Record<string, string> = {
"txt_identity": "Identidad",
"txt_identity_details": "Detalles de identidad",
"txt_ie_browser": "Navegador Internet Explorer",
"txt_create_invite_failed": "Error al crear invitación",
"txt_invite_code_optional": "Código de invitación (No obligatorio para la primera cuenta; obligatorio para todas las demás)",
"txt_invite_created": "Invitación creada",
"txt_invite_revoked": "Invitación revocada",
"txt_revoke_invite_failed": "Error al revocar invitación",
"txt_invite_validity_hours": "Validez de la invitación en horas",
"txt_invites": "Invitaciones",
"txt_ios": "iOS",
@@ -473,6 +488,9 @@ const es: Record<string, string> = {
"txt_linux_desktop": "Escritorio Linux",
"txt_loading": "Cargando...",
"txt_loading_nodewarden": "Cargando NodeWarden...",
"txt_loading_vault": "Cargando bóveda...",
"txt_load_vault_failed": "No se pudo cargar la bóveda.",
"txt_retry_sync": "Reintentar sincronización",
"txt_jwt_warning_title": "Advertencia de seguridad del servidor",
"txt_jwt_warning_subtitle": "El secreto JWT no está configurado de forma segura.",
"txt_jwt_title_missing": "Falta JWT_SECRET",
@@ -544,7 +562,9 @@ const es: Record<string, string> = {
"txt_no": "No",
"txt_no_devices_found": "No se encontraron dispositivos.",
"txt_no_folder": "Sin carpeta",
"txt_no_invites_found": "No se encontraron invitaciones.",
"txt_no_items": "No hay elementos",
"txt_no_users_found": "No se encontraron usuarios.",
"txt_no_username": "(Sin nombre de usuario)",
"txt_no_verification_codes": "Sin códigos de verificación",
"txt_no_name": "(Sin nombre)",
@@ -697,6 +717,16 @@ const es: Record<string, string> = {
"txt_totp_is_enabled_for_this_account": "TOTP está activado para esta cuenta.",
"txt_total_items_count": "{count} elementos",
"txt_totp_secret": "Secreto TOTP",
"txt_scan_totp_qr": "Escanear QR TOTP",
"txt_totp_qr_starting_camera": "Iniciando cámara...",
"txt_totp_qr_point_camera": "Apunte la cámara a un código QR TOTP.",
"txt_totp_qr_scanning": "Escaneando código QR...",
"txt_totp_qr_scanned": "Valor TOTP agregado.",
"txt_totp_qr_not_found": "No se encontró ningún código QR en esa imagen.",
"txt_totp_qr_scan_failed": "No se pudo escanear el código QR.",
"txt_totp_qr_unsupported": "Este navegador no admite escaneo QR. Pruebe Chrome o Edge, o pegue manualmente el enlace o secreto TOTP.",
"txt_totp_qr_camera_unavailable": "La cámara no está disponible. Revise el permiso del navegador o elija una imagen.",
"txt_totp_qr_choose_image": "Elegir imagen",
"txt_totp_verify_failed": "Error al verificar TOTP",
"txt_attachments": "Archivos adjuntos",
"txt_upload_attachments": "Subir archivos adjuntos",
@@ -725,8 +755,10 @@ const es: Record<string, string> = {
"txt_remove_all_devices_failed": "Error al quitar todos los dispositivos",
"txt_update_item_failed": "Error al actualizar elemento",
"txt_update_send_failed": "Error al actualizar envío",
"txt_update_user_status_failed": "Error al actualizar estado de usuario",
"txt_use_recovery_code": "Usar código de recuperación",
"txt_use_your_one_time_recovery_code_to_disable_two_step_verification": "Use su código de recuperación de un solo uso para desactivar la verificación en dos pasos.",
"txt_delete_user_failed": "Error al eliminar usuario",
"txt_user_deleted": "Usuario eliminado",
"txt_user_status_updated": "Estado del usuario actualizado",
"txt_username": "Nombre de usuario",
@@ -845,4 +877,4 @@ const es: Record<string, string> = {
"txt_language_saved_locally": "Esta preferencia se guarda en este navegador y se usa antes de que la aplicación cargue la próxima vez."
};
export default es;
export default es;
+32
View File
@@ -8,10 +8,21 @@ const ru: Record<string, string> = {
"nav_sends": "Отправляет",
"nav_backup_strategy": "Облачное резервное копирование",
"nav_import_export": "Импорт и экспорт",
"txt_page_not_found": "Страница не найдена",
"txt_page_not_found_hint": "Страница могла быть удалена, срок ее действия истек, или ссылка неполная.",
"txt_back_to_home": "На главную",
"backup_strategy_title": "Облачное резервное копирование",
"backup_strategy_under_construction": "В стадии строительства.",
"import_export_title": "Импорт и экспорт",
"import_export_under_construction": "В стадии строительства.",
"txt_demo_admin_refreshed": "Демо-данные администратора обновлены.",
"txt_demo_auth_placeholder": "Демо: введите что угодно или оставьте пустым",
"txt_demo_data_reset": "Демо-данные сброшены к значениям по умолчанию.",
"txt_demo_devices_refreshed": "Демо-устройства обновлены.",
"txt_demo_download_prepared": "Демо-загрузка подготовлена.",
"txt_demo_master_password_hint": "В демо-режиме любое значение разблокирует хранилище.",
"txt_demo_readonly_message": "В демо-режиме это действие только для чтения. Изменения не сохранены.",
"txt_demo_unlock_placeholder": "Демо: подойдет любой пароль, даже пустой",
"txt_backup_export": "Экспортировать резервную копию",
"txt_backup_import": "Восстановить",
"txt_backup_include_attachments": "Включить вложения",
@@ -283,6 +294,7 @@ const ru: Record<string, string> = {
"txt_address_3": "Адрес 3",
"txt_all_device_authorizations_revoked": "Все доверие к устройствам отозвано",
"txt_all_invites_deleted": "Все приглашения удалены",
"txt_delete_all_invites_failed": "Не удалось удалить все приглашения",
"txt_all_items": "Все предметы",
"txt_all_sends": "Все отправки",
"txt_android": "Андроид",
@@ -388,6 +400,7 @@ const ru: Record<string, string> = {
"txt_device_note_required": "Укажите имя устройства.",
"txt_device_note_updated": "Имя устройства обновлено.",
"txt_device_removed": "Устройство удалено",
"txt_load_admin_data_failed": "Не удалось загрузить данные администрирования",
"txt_load_devices_failed": "Не удалось загрузить устройства.",
"txt_disable_this_send": "Отключить эту отправку",
"txt_disable_totp": "Отключить TOTP",
@@ -450,9 +463,11 @@ const ru: Record<string, string> = {
"txt_identity": "идентичность",
"txt_identity_details": "Данные личности",
"txt_ie_browser": "IE-браузер",
"txt_create_invite_failed": "Не удалось создать приглашение",
"txt_invite_code_optional": "Пригласительный код (не требуется для первой учетной записи; требуется для всех остальных)",
"txt_invite_created": "Приглашение создано",
"txt_invite_revoked": "Приглашение отозвано",
"txt_revoke_invite_failed": "Не удалось отозвать приглашение",
"txt_invite_validity_hours": "Срок действия приглашения (часы)",
"txt_invites": "Приглашает",
"txt_ios": "iOS",
@@ -473,6 +488,9 @@ const ru: Record<string, string> = {
"txt_linux_desktop": "Рабочий стол Linux",
"txt_loading": "Загрузка...",
"txt_loading_nodewarden": "Загрузка NodeWarden...",
"txt_loading_vault": "Загрузка хранилища...",
"txt_load_vault_failed": "Не удалось загрузить хранилище.",
"txt_retry_sync": "Повторить синхронизацию",
"txt_jwt_warning_title": "Предупреждение безопасности сервера",
"txt_jwt_warning_subtitle": "Секрет JWT настроен неправильно.",
"txt_jwt_title_missing": "JWT_SECRET отсутствует.",
@@ -544,7 +562,9 @@ const ru: Record<string, string> = {
"txt_no": "Нет",
"txt_no_devices_found": "Устройства не найдены.",
"txt_no_folder": "Нет папки",
"txt_no_invites_found": "Приглашения не найдены.",
"txt_no_items": "Нет товаров",
"txt_no_users_found": "Пользователи не найдены.",
"txt_no_username": "(Нет имени пользователя)",
"txt_no_verification_codes": "Нет кодов подтверждения",
"txt_no_name": "(Без имени)",
@@ -697,6 +717,16 @@ const ru: Record<string, string> = {
"txt_totp_is_enabled_for_this_account": "TOTP включен для этой учетной записи.",
"txt_total_items_count": "{count} товаров",
"txt_totp_secret": "Секрет TOTP",
"txt_scan_totp_qr": "Сканировать QR TOTP",
"txt_totp_qr_starting_camera": "Запуск камеры...",
"txt_totp_qr_point_camera": "Наведите камеру на QR-код TOTP.",
"txt_totp_qr_scanning": "Сканирование QR-кода...",
"txt_totp_qr_scanned": "Значение TOTP добавлено.",
"txt_totp_qr_not_found": "QR-код на этом изображении не найден.",
"txt_totp_qr_scan_failed": "Не удалось отсканировать QR-код.",
"txt_totp_qr_unsupported": "Этот браузер не поддерживает сканирование QR. Попробуйте Chrome или Edge либо вставьте ссылку или секрет TOTP вручную.",
"txt_totp_qr_camera_unavailable": "Камера недоступна. Проверьте разрешение браузера или выберите изображение.",
"txt_totp_qr_choose_image": "Выбрать изображение",
"txt_totp_verify_failed": "Проверка TOTP не удалась",
"txt_attachments": "Вложения",
"txt_upload_attachments": "Загрузить вложения",
@@ -725,8 +755,10 @@ const ru: Record<string, string> = {
"txt_remove_all_devices_failed": "Не удалось удалить все устройства.",
"txt_update_item_failed": "Обновить элемент не удалось",
"txt_update_send_failed": "Send обновления не удалась",
"txt_update_user_status_failed": "Не удалось обновить статус пользователя",
"txt_use_recovery_code": "Использовать код восстановления",
"txt_use_your_one_time_recovery_code_to_disable_two_step_verification": "Используйте одноразовый код восстановления, чтобы отключить двухэтапную проверку.",
"txt_delete_user_failed": "Не удалось удалить пользователя",
"txt_user_deleted": "Пользователь удален",
"txt_user_status_updated": "Статус пользователя обновлен",
"txt_username": "Имя пользователя",
+55 -23
View File
@@ -7,10 +7,21 @@ const zhCN: Record<string, string> = {
"nav_sends": "Send",
"nav_backup_strategy": "云端备份",
"nav_import_export": "导入导出",
"txt_page_not_found": "页面不存在",
"txt_page_not_found_hint": "这个页面可能已经删除、过期,或者链接不完整。",
"txt_back_to_home": "回到首页",
"backup_strategy_title": "云端备份",
"backup_strategy_under_construction": "正在搭建中",
"import_export_title": "导入导出",
"import_export_under_construction": "正在搭建中",
"txt_demo_admin_refreshed": "Demo 管理数据已刷新。",
"txt_demo_auth_placeholder": "Demo:随便输入,也可以留空",
"txt_demo_data_reset": "Demo 数据已恢复为默认状态。",
"txt_demo_devices_refreshed": "Demo 设备已刷新。",
"txt_demo_download_prepared": "Demo 下载已准备好。",
"txt_demo_master_password_hint": "Demo 模式下,任意输入都可以解锁保险库。",
"txt_demo_readonly_message": "Demo 模式下此操作为只读,未保存任何更改。",
"txt_demo_unlock_placeholder": "Demo:任意密码都可解锁,留空也可以",
"txt_backup_export": "导出备份",
"txt_backup_import": "还原",
"txt_backup_include_attachments": "包含附件",
@@ -283,8 +294,9 @@ const zhCN: Record<string, string> = {
"txt_address_3": "地址 3",
"txt_all_device_authorizations_revoked": "已撤销所有设备信任",
"txt_all_invites_deleted": "已删除所有邀请码",
"txt_delete_all_invites_failed": "删除所有邀请码失败",
"txt_all_items": "所有项目",
"txt_all_sends": "所有发送",
"txt_all_sends": "所有 Send",
"txt_android": "安卓",
"txt_are_you_sure_you_want_to_delete_count_selected_items": "确认删除所选的 {count} 个项目?",
"txt_are_you_sure_you_want_to_delete_count_selected_items_permanently": "确认永久删除所选的 {count} 个项目?",
@@ -302,7 +314,7 @@ const zhCN: Record<string, string> = {
"txt_bulk_delete_failed": "批量删除失败",
"txt_bulk_permanent_delete_failed": "批量永久删除失败",
"txt_bulk_restore_failed": "批量恢复失败",
"txt_bulk_delete_sends_failed": "批量删除发送失败",
"txt_bulk_delete_sends_failed": "批量删除 Send 失败",
"txt_bulk_move_failed": "批量移动失败",
"txt_cancel": "取消",
"txt_continue": "继续",
@@ -337,7 +349,7 @@ const zhCN: Record<string, string> = {
"txt_create_folder": "创建文件夹",
"txt_create_folder_failed": "创建文件夹失败",
"txt_create_item_failed": "创建项目失败",
"txt_create_send_failed": "创建发送失败",
"txt_create_send_failed": "创建 Send 失败",
"txt_create_timed_invite": "创建时效邀请码",
"txt_created_value": "创建于:{value}",
"txt_current_new_password_is_required": "需要输入当前密码和新密码",
@@ -372,13 +384,13 @@ const zhCN: Record<string, string> = {
"txt_delete_selected": "删除",
"txt_delete_selected_items": "删除所选项目",
"txt_delete_selected_items_permanently": "Delete Selected Items Permanently",
"txt_delete_send_failed": "删除发送失败",
"txt_delete_send_failed": "删除 Send 失败",
"txt_delete_this_user_and_all_user_data": "删除此用户及其所有数据?",
"txt_delete_user": "删除用户",
"txt_deleted_selected_items": "已删除所选项目",
"txt_deleted_selected_items_permanently": "已永久删除所选项目",
"txt_restored_selected_items": "已恢复所选项目",
"txt_deleted_selected_sends": "已删除所选发送",
"txt_deleted_selected_sends": "已删除所选 Send",
"txt_deletion_date": "删除日期",
"txt_deletion_days": "删除天数",
"txt_device": "设备",
@@ -388,8 +400,9 @@ const zhCN: Record<string, string> = {
"txt_device_note_required": "设备名称不能为空",
"txt_device_note_updated": "设备名称已更新",
"txt_device_removed": "设备已移除",
"txt_load_admin_data_failed": "加载管理数据失败",
"txt_load_devices_failed": "加载设备失败",
"txt_disable_this_send": "禁用此发送",
"txt_disable_this_send": "禁用此 Send",
"txt_disable_totp": "停用 TOTP",
"txt_disable_totp_failed": "禁用 TOTP 失败",
"txt_download": "下载",
@@ -404,7 +417,7 @@ const zhCN: Record<string, string> = {
"txt_edge_browser": "Edge 浏览器",
"txt_edge_extension": "Edge 扩展",
"txt_edit": "编辑",
"txt_edit_send": "编辑发送",
"txt_edit_send": "编辑 Send",
"txt_email": "邮箱",
"txt_email_password_and_recovery_code_are_required": "需要输入邮箱、密码和恢复代码",
"txt_enable_totp": "启用 TOTP",
@@ -423,7 +436,7 @@ const zhCN: Record<string, string> = {
"txt_expiry": "有效期",
"txt_expiry_month": "有效期月",
"txt_expiry_year": "有效期年",
"txt_failed_to_open_send": "打开发送失败",
"txt_failed_to_open_send": "打开 Send 失败",
"txt_favorite": "收藏",
"txt_favorites": "收藏",
"txt_duplicates": "重复项",
@@ -434,7 +447,7 @@ const zhCN: Record<string, string> = {
"txt_field_value": "字段值",
"txt_file": "文件",
"txt_file_name": "文件名",
"txt_file_send": "文件发送",
"txt_file_send": "文件 Send",
"txt_file_size": "文件大小",
"txt_fingerprint": "指纹",
"txt_firefox_browser": "Firefox 浏览器",
@@ -450,9 +463,11 @@ const zhCN: Record<string, string> = {
"txt_identity": "身份",
"txt_identity_details": "身份详情",
"txt_ie_browser": "IE 浏览器",
"txt_create_invite_failed": "创建邀请码失败",
"txt_invite_code_optional": "邀请码(首位注册者无需填写,其他人必填)",
"txt_invite_created": "邀请码已创建",
"txt_invite_revoked": "邀请码已撤销",
"txt_revoke_invite_failed": "撤销邀请码失败",
"txt_invite_validity_hours": "邀请码有效期(小时)",
"txt_invites": "邀请码",
"txt_ios": "iOS",
@@ -473,6 +488,9 @@ const zhCN: Record<string, string> = {
"txt_linux_desktop": "Linux 桌面端",
"txt_loading": "加载中...",
"txt_loading_nodewarden": "正在加载 NodeWarden...",
"txt_loading_vault": "正在加载保管库...",
"txt_load_vault_failed": "保管库加载失败。",
"txt_retry_sync": "重试同步",
"txt_jwt_warning_title": "JWT_SECRET 配置警告",
"txt_jwt_warning_subtitle": "JWT 密钥当前不安全,请先修复后再继续。",
"txt_jwt_title_missing": "未检测到 JWT_SECRET",
@@ -539,17 +557,19 @@ const zhCN: Record<string, string> = {
"txt_nothing_to_copy": "没有可复制的内容",
"txt_new_password_must_be_at_least_12_chars": "新密码至少需要 12 个字符",
"txt_new_passwords_do_not_match": "两次输入的新密码不一致",
"txt_new_send": "新建发送",
"txt_new_send": "新建 Send",
"txt_next": "下一页",
"txt_no": "否",
"txt_no_devices_found": "未找到设备",
"txt_no_folder": "无文件夹",
"txt_no_invites_found": "暂无邀请码",
"txt_no_items": "没有项目",
"txt_no_users_found": "暂无用户",
"txt_no_username": "无用户名",
"txt_no_verification_codes": "没有验证码",
"txt_no_name": "(无名称)",
"txt_no_sends": "没有发送",
"txt_nodewarden_send": "NodeWarden 发送",
"txt_no_sends": "没有 Send",
"txt_nodewarden_send": "NodeWarden Send",
"txt_not_trusted": "未信任",
"txt_note": "笔记",
"txt_notes": "备注",
@@ -644,7 +664,7 @@ const zhCN: Record<string, string> = {
"txt_save": "保存",
"txt_save_profile": "保存资料",
"txt_save_profile_failed": "保存资料失败",
"txt_search_sends": "搜索发送...",
"txt_search_sends": "搜索 Send...",
"txt_search_your_secure_vault": "搜索你的密码库...",
"txt_clear_search": "清空搜索",
"txt_clear_search_esc": "清空搜索(Esc",
@@ -660,12 +680,12 @@ const zhCN: Record<string, string> = {
"txt_select_all": "全选",
"txt_select_duplicate_items": "选择重复项",
"txt_select_an_item": "请选择一个项目",
"txt_send_created": "发送已创建",
"txt_send_deleted": "发送已删除",
"txt_send_details": "发送详情",
"txt_send_file": "发送文件",
"txt_send_unavailable": "发送不可用。",
"txt_send_updated": "发送已更新",
"txt_send_created": "Send 已创建",
"txt_send_deleted": "Send 已删除",
"txt_send_details": "Send 详情",
"txt_send_file": "Send 文件",
"txt_send_unavailable": "Send 不可用。",
"txt_send_updated": "Send 已更新",
"txt_sign_out": "退出登录",
"txt_ssh_key": "SSH 密钥",
"txt_ssn": "社保号",
@@ -684,11 +704,11 @@ const zhCN: Record<string, string> = {
"txt_text_2fa_recovered_new_recovery_code_code": "2FA 已恢复,新的恢复代码:{code}",
"txt_text_3": "------",
"txt_text_is_required": "文本不能为空",
"txt_text_send": "文本发送",
"txt_text_send": "文本 Send",
"txt_this_is_a_one_time_code_after_it_is_used_a_new_code_is_generated_automatically": "这是一次性恢复代码,使用后将自动生成新的恢复代码。",
"txt_this_item_requires_master_password_every_time_before_viewing_details": "每次查看详情前均需输入主密码",
"txt_this_link_is_missing_decryption_key": "此链接缺少解密密钥",
"txt_this_send_is_password_protected": "此发送受密码保护",
"txt_this_send_is_password_protected": "此 Send 受密码保护",
"txt_title": "称谓",
"txt_totp": "TOTP",
"txt_totp_code": "TOTP 验证码",
@@ -697,6 +717,16 @@ const zhCN: Record<string, string> = {
"txt_totp_is_enabled_for_this_account": "此账户已启用 TOTP。",
"txt_total_items_count": "共 {count} 项",
"txt_totp_secret": "TOTP 密钥",
"txt_scan_totp_qr": "扫描 TOTP 二维码",
"txt_totp_qr_starting_camera": "正在启动摄像头...",
"txt_totp_qr_point_camera": "把摄像头对准 TOTP 二维码。",
"txt_totp_qr_scanning": "正在扫描二维码...",
"txt_totp_qr_scanned": "TOTP 内容已填入。",
"txt_totp_qr_not_found": "这张图片里没有识别到二维码。",
"txt_totp_qr_scan_failed": "二维码扫描失败。",
"txt_totp_qr_unsupported": "当前浏览器不支持二维码扫描。可尝试 Chrome 或 Edge,或手动粘贴 TOTP 链接/密钥。",
"txt_totp_qr_camera_unavailable": "无法使用摄像头。请检查浏览器权限,或选择图片。",
"txt_totp_qr_choose_image": "选择图片",
"txt_totp_verify_failed": "TOTP 验证失败",
"txt_attachments": "附件",
"txt_upload_attachments": "上传附件",
@@ -717,16 +747,18 @@ const zhCN: Record<string, string> = {
"txt_unlock_failed": "解锁失败",
"txt_unlock_failed_master_password_is_incorrect": "解锁失败,主密码不正确。",
"txt_unlock_item": "解锁项目",
"txt_unlock_send": "解锁发送",
"txt_unlock_send": "解锁 Send",
"txt_unlock_vault": "解锁密码库",
"txt_unlocked": "已解锁",
"txt_all_devices_removed": "已移除所有设备",
"txt_remove_device_failed": "移除设备失败",
"txt_remove_all_devices_failed": "移除所有设备失败",
"txt_update_item_failed": "更新项目失败",
"txt_update_send_failed": "更新发送失败",
"txt_update_send_failed": "更新 Send 失败",
"txt_update_user_status_failed": "更新用户状态失败",
"txt_use_recovery_code": "使用恢复代码",
"txt_use_your_one_time_recovery_code_to_disable_two_step_verification": "使用一次性恢复代码禁用两步验证。",
"txt_delete_user_failed": "删除用户失败",
"txt_user_deleted": "用户已删除",
"txt_user_status_updated": "用户状态已更新",
"txt_username": "用户名",
+55 -23
View File
@@ -7,10 +7,21 @@ const zhTW: Record<string, string> = {
"nav_sends": "Send",
"nav_backup_strategy": "雲端備份",
"nav_import_export": "導入導出",
"txt_page_not_found": "頁面不存在",
"txt_page_not_found_hint": "這個頁面可能已經刪除、過期,或者連結不完整。",
"txt_back_to_home": "回到首頁",
"backup_strategy_title": "雲端備份",
"backup_strategy_under_construction": "正在搭建中",
"import_export_title": "導入導出",
"import_export_under_construction": "正在搭建中",
"txt_demo_admin_refreshed": "Demo 管理數據已刷新。",
"txt_demo_auth_placeholder": "Demo:隨便輸入,也可以留空",
"txt_demo_data_reset": "Demo 數據已恢復為默認狀態。",
"txt_demo_devices_refreshed": "Demo 設備已刷新。",
"txt_demo_download_prepared": "Demo 下載已準備好。",
"txt_demo_master_password_hint": "Demo 模式下,任意輸入都可以解鎖保險庫。",
"txt_demo_readonly_message": "Demo 模式下此操作為只讀,未保存任何更改。",
"txt_demo_unlock_placeholder": "Demo:任意密碼都可解鎖,留空也可以",
"txt_backup_export": "導出備份",
"txt_backup_import": "還原",
"txt_backup_include_attachments": "包含附件",
@@ -283,8 +294,9 @@ const zhTW: Record<string, string> = {
"txt_address_3": "地址 3",
"txt_all_device_authorizations_revoked": "已撤銷所有設備信任",
"txt_all_invites_deleted": "已刪除所有邀請碼",
"txt_delete_all_invites_failed": "刪除所有邀請碼失敗",
"txt_all_items": "所有項目",
"txt_all_sends": "所有發送",
"txt_all_sends": "所有 Send",
"txt_android": "安卓",
"txt_are_you_sure_you_want_to_delete_count_selected_items": "確認刪除所選的 {count} 個項目?",
"txt_are_you_sure_you_want_to_delete_count_selected_items_permanently": "確認永久刪除所選的 {count} 個項目?",
@@ -302,7 +314,7 @@ const zhTW: Record<string, string> = {
"txt_bulk_delete_failed": "批量刪除失敗",
"txt_bulk_permanent_delete_failed": "批量永久刪除失敗",
"txt_bulk_restore_failed": "批量恢復失敗",
"txt_bulk_delete_sends_failed": "批量刪除發送失敗",
"txt_bulk_delete_sends_failed": "批量刪除 Send 失敗",
"txt_bulk_move_failed": "批量移動失敗",
"txt_cancel": "取消",
"txt_continue": "繼續",
@@ -337,7 +349,7 @@ const zhTW: Record<string, string> = {
"txt_create_folder": "創建文件夾",
"txt_create_folder_failed": "創建文件夾失敗",
"txt_create_item_failed": "創建項目失敗",
"txt_create_send_failed": "創建發送失敗",
"txt_create_send_failed": "創建 Send 失敗",
"txt_create_timed_invite": "創建時效邀請碼",
"txt_created_value": "創建於:{value}",
"txt_current_new_password_is_required": "需要輸入當前密碼和新密碼",
@@ -372,13 +384,13 @@ const zhTW: Record<string, string> = {
"txt_delete_selected": "刪除",
"txt_delete_selected_items": "刪除所選項目",
"txt_delete_selected_items_permanently": "Delete Selected Items Permanently",
"txt_delete_send_failed": "刪除發送失敗",
"txt_delete_send_failed": "刪除 Send 失敗",
"txt_delete_this_user_and_all_user_data": "刪除此用戶及其所有數據?",
"txt_delete_user": "刪除用戶",
"txt_deleted_selected_items": "已刪除所選項目",
"txt_deleted_selected_items_permanently": "已永久刪除所選項目",
"txt_restored_selected_items": "已恢復所選項目",
"txt_deleted_selected_sends": "已刪除所選發送",
"txt_deleted_selected_sends": "已刪除所選 Send",
"txt_deletion_date": "刪除日期",
"txt_deletion_days": "刪除天數",
"txt_device": "設備",
@@ -388,8 +400,9 @@ const zhTW: Record<string, string> = {
"txt_device_note_required": "設備名稱不能為空",
"txt_device_note_updated": "設備名稱已更新",
"txt_device_removed": "設備已移除",
"txt_load_admin_data_failed": "加載管理數據失敗",
"txt_load_devices_failed": "加載設備失敗",
"txt_disable_this_send": "禁用此發送",
"txt_disable_this_send": "禁用此 Send",
"txt_disable_totp": "停用 TOTP",
"txt_disable_totp_failed": "禁用 TOTP 失敗",
"txt_download": "下載",
@@ -404,7 +417,7 @@ const zhTW: Record<string, string> = {
"txt_edge_browser": "Edge 瀏覽器",
"txt_edge_extension": "Edge 擴展",
"txt_edit": "編輯",
"txt_edit_send": "編輯發送",
"txt_edit_send": "編輯 Send",
"txt_email": "郵箱",
"txt_email_password_and_recovery_code_are_required": "需要輸入郵箱、密碼和恢復代碼",
"txt_enable_totp": "啟用 TOTP",
@@ -423,7 +436,7 @@ const zhTW: Record<string, string> = {
"txt_expiry": "有效期",
"txt_expiry_month": "有效期月",
"txt_expiry_year": "有效期年",
"txt_failed_to_open_send": "打開發送失敗",
"txt_failed_to_open_send": "打開 Send 失敗",
"txt_favorite": "收藏",
"txt_favorites": "收藏",
"txt_duplicates": "重複項",
@@ -434,7 +447,7 @@ const zhTW: Record<string, string> = {
"txt_field_value": "字段值",
"txt_file": "文件",
"txt_file_name": "文件名",
"txt_file_send": "文件發送",
"txt_file_send": "文件 Send",
"txt_file_size": "文件大小",
"txt_fingerprint": "指紋",
"txt_firefox_browser": "Firefox 瀏覽器",
@@ -450,9 +463,11 @@ const zhTW: Record<string, string> = {
"txt_identity": "身份",
"txt_identity_details": "身份詳情",
"txt_ie_browser": "IE 瀏覽器",
"txt_create_invite_failed": "創建邀請碼失敗",
"txt_invite_code_optional": "邀請碼(首位註冊者無需填寫,其他人必填)",
"txt_invite_created": "邀請碼已創建",
"txt_invite_revoked": "邀請碼已撤銷",
"txt_revoke_invite_failed": "撤銷邀請碼失敗",
"txt_invite_validity_hours": "邀請碼有效期(小時)",
"txt_invites": "邀請碼",
"txt_ios": "iOS",
@@ -473,6 +488,9 @@ const zhTW: Record<string, string> = {
"txt_linux_desktop": "Linux 桌面端",
"txt_loading": "加載中...",
"txt_loading_nodewarden": "正在加載 NodeWarden...",
"txt_loading_vault": "正在加載保管庫...",
"txt_load_vault_failed": "保管庫加載失敗。",
"txt_retry_sync": "重試同步",
"txt_jwt_warning_title": "JWT_SECRET 配置警告",
"txt_jwt_warning_subtitle": "JWT 密鑰當前不安全,請先修復後再繼續。",
"txt_jwt_title_missing": "未檢測到 JWT_SECRET",
@@ -539,17 +557,19 @@ const zhTW: Record<string, string> = {
"txt_nothing_to_copy": "沒有可複製的內容",
"txt_new_password_must_be_at_least_12_chars": "新密碼至少需要 12 個字符",
"txt_new_passwords_do_not_match": "兩次輸入的新密碼不一致",
"txt_new_send": "新建發送",
"txt_new_send": "新建 Send",
"txt_next": "下一頁",
"txt_no": "否",
"txt_no_devices_found": "未找到設備",
"txt_no_folder": "無文件夾",
"txt_no_invites_found": "暫無邀請碼",
"txt_no_items": "沒有項目",
"txt_no_users_found": "暫無用戶",
"txt_no_username": "無用戶名",
"txt_no_verification_codes": "沒有驗證碼",
"txt_no_name": "(無名稱)",
"txt_no_sends": "沒有發送",
"txt_nodewarden_send": "NodeWarden 發送",
"txt_no_sends": "沒有 Send",
"txt_nodewarden_send": "NodeWarden Send",
"txt_not_trusted": "未信任",
"txt_note": "筆記",
"txt_notes": "備註",
@@ -644,7 +664,7 @@ const zhTW: Record<string, string> = {
"txt_save": "保存",
"txt_save_profile": "保存資料",
"txt_save_profile_failed": "保存資料失敗",
"txt_search_sends": "搜索發送...",
"txt_search_sends": "搜索 Send...",
"txt_search_your_secure_vault": "搜索你的密碼庫...",
"txt_clear_search": "清空搜索",
"txt_clear_search_esc": "清空搜索(Esc",
@@ -660,12 +680,12 @@ const zhTW: Record<string, string> = {
"txt_select_all": "全選",
"txt_select_duplicate_items": "選擇重複項",
"txt_select_an_item": "請選擇一個項目",
"txt_send_created": "發送已創建",
"txt_send_deleted": "發送已刪除",
"txt_send_details": "發送詳情",
"txt_send_file": "發送文件",
"txt_send_unavailable": "發送不可用。",
"txt_send_updated": "發送已更新",
"txt_send_created": "Send 已創建",
"txt_send_deleted": "Send 已刪除",
"txt_send_details": "Send 詳情",
"txt_send_file": "Send 文件",
"txt_send_unavailable": "Send 不可用。",
"txt_send_updated": "Send 已更新",
"txt_sign_out": "退出登錄",
"txt_ssh_key": "SSH 密鑰",
"txt_ssn": "社保號",
@@ -684,11 +704,11 @@ const zhTW: Record<string, string> = {
"txt_text_2fa_recovered_new_recovery_code_code": "2FA 已恢復,新的恢復代碼:{code}",
"txt_text_3": "------",
"txt_text_is_required": "文本不能為空",
"txt_text_send": "文本發送",
"txt_text_send": "文本 Send",
"txt_this_is_a_one_time_code_after_it_is_used_a_new_code_is_generated_automatically": "這是一次性恢復代碼,使用後將自動生成新的恢復代碼。",
"txt_this_item_requires_master_password_every_time_before_viewing_details": "每次查看詳情前均需輸入主密碼",
"txt_this_link_is_missing_decryption_key": "此鏈接缺少解密密鑰",
"txt_this_send_is_password_protected": "此發送受密碼保護",
"txt_this_send_is_password_protected": "此 Send 受密碼保護",
"txt_title": "稱謂",
"txt_totp": "TOTP",
"txt_totp_code": "TOTP 驗證碼",
@@ -697,6 +717,16 @@ const zhTW: Record<string, string> = {
"txt_totp_is_enabled_for_this_account": "此賬戶已啟用 TOTP。",
"txt_total_items_count": "共 {count} 項",
"txt_totp_secret": "TOTP 密鑰",
"txt_scan_totp_qr": "掃描 TOTP 二維碼",
"txt_totp_qr_starting_camera": "正在啟動攝影機...",
"txt_totp_qr_point_camera": "把攝影機對準 TOTP 二維碼。",
"txt_totp_qr_scanning": "正在掃描二維碼...",
"txt_totp_qr_scanned": "TOTP 內容已填入。",
"txt_totp_qr_not_found": "這張圖片裡沒有識別到二維碼。",
"txt_totp_qr_scan_failed": "二維碼掃描失敗。",
"txt_totp_qr_unsupported": "目前瀏覽器不支援二維碼掃描。可嘗試 Chrome 或 Edge,或手動貼上 TOTP 連結/密鑰。",
"txt_totp_qr_camera_unavailable": "無法使用攝影機。請檢查瀏覽器權限,或選擇圖片。",
"txt_totp_qr_choose_image": "選擇圖片",
"txt_totp_verify_failed": "TOTP 驗證失敗",
"txt_attachments": "附件",
"txt_upload_attachments": "上傳附件",
@@ -717,16 +747,18 @@ const zhTW: Record<string, string> = {
"txt_unlock_failed": "解鎖失敗",
"txt_unlock_failed_master_password_is_incorrect": "解鎖失敗,主密碼不正確。",
"txt_unlock_item": "解鎖項目",
"txt_unlock_send": "解鎖發送",
"txt_unlock_send": "解鎖 Send",
"txt_unlock_vault": "解鎖密碼庫",
"txt_unlocked": "已解鎖",
"txt_all_devices_removed": "已移除所有設備",
"txt_remove_device_failed": "移除設備失敗",
"txt_remove_all_devices_failed": "移除所有設備失敗",
"txt_update_item_failed": "更新項目失敗",
"txt_update_send_failed": "更新發送失敗",
"txt_update_send_failed": "更新 Send 失敗",
"txt_update_user_status_failed": "更新用戶狀態失敗",
"txt_use_recovery_code": "使用恢復代碼",
"txt_use_your_one_time_recovery_code_to_disable_two_step_verification": "使用一次性恢復代碼禁用兩步驗證。",
"txt_delete_user_failed": "刪除用戶失敗",
"txt_user_deleted": "用戶已刪除",
"txt_user_status_updated": "用戶狀態已更新",
"txt_username": "用戶名",
+2 -1
View File
@@ -1,8 +1,9 @@
import type { Cipher, Folder } from './types';
import type { Cipher, Folder, Send } from './types';
export interface VaultCoreSnapshot {
ciphers: Cipher[];
folders: Folder[];
sends: Send[];
}
interface VaultCoreCacheRecord {
+47 -15
View File
@@ -1,8 +1,11 @@
type WebsiteIconStatus = 'idle' | 'loading' | 'loaded' | 'error';
const ICON_LOAD_TIMEOUT_MS = 5000;
interface WebsiteIconRecord {
status: WebsiteIconStatus;
promise: Promise<WebsiteIconStatus> | null;
imageUrl: string | null;
listeners: Set<(status: WebsiteIconStatus) => void>;
}
@@ -14,6 +17,7 @@ function ensureRecord(host: string): WebsiteIconRecord {
record = {
status: 'idle',
promise: null,
imageUrl: null,
listeners: new Set(),
};
iconRecords.set(host, record);
@@ -34,6 +38,11 @@ export function getWebsiteIconStatus(host: string): WebsiteIconStatus {
return ensureRecord(host).status;
}
export function getWebsiteIconImageUrl(host: string): string {
if (!host) return '';
return ensureRecord(host).imageUrl || '';
}
export function subscribeWebsiteIconStatus(host: string, listener: (status: WebsiteIconStatus) => void): () => void {
if (!host) return () => undefined;
const record = ensureRecord(host);
@@ -43,10 +52,13 @@ export function subscribeWebsiteIconStatus(host: string, listener: (status: Webs
};
}
export function markWebsiteIconLoaded(host: string): void {
export function markWebsiteIconLoaded(host: string, imageUrl?: string): void {
if (!host) return;
const record = ensureRecord(host);
record.promise = null;
if (imageUrl) {
record.imageUrl = imageUrl;
}
notifyRecord(host, 'loaded');
}
@@ -54,9 +66,19 @@ export function markWebsiteIconErrored(host: string): void {
if (!host) return;
const record = ensureRecord(host);
record.promise = null;
record.imageUrl = null;
notifyRecord(host, 'error');
}
function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : '');
reader.onerror = () => reject(reader.error || new Error('Failed to read icon'));
reader.readAsDataURL(blob);
});
}
export function preloadWebsiteIcon(host: string, src: string): Promise<WebsiteIconStatus> {
if (!host) return Promise.resolve('error');
@@ -68,21 +90,31 @@ export function preloadWebsiteIcon(host: string, src: string): Promise<WebsiteIc
return record.promise;
}
record.status = 'loading';
record.promise = new Promise<WebsiteIconStatus>((resolve) => {
const img = new Image();
img.decoding = 'async';
img.referrerPolicy = 'no-referrer';
img.onload = () => {
markWebsiteIconLoaded(host);
resolve('loaded');
};
img.onerror = () => {
notifyRecord(host, 'loading');
record.promise = (async () => {
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), ICON_LOAD_TIMEOUT_MS);
try {
const resp = await fetch(src, {
cache: 'force-cache',
signal: controller.signal,
});
if (!resp.ok) throw new Error('Icon unavailable');
const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase();
if (!contentType.startsWith('image/')) throw new Error('Icon response is not an image');
const blob = await resp.blob();
if (!blob.size) throw new Error('Icon response is empty');
const imageUrl = await blobToDataUrl(blob);
if (!imageUrl) throw new Error('Icon response is empty');
markWebsiteIconLoaded(host, imageUrl);
return 'loaded';
} catch {
markWebsiteIconErrored(host);
resolve('error');
};
img.src = src;
});
return 'error';
} finally {
window.clearTimeout(timeout);
}
})();
return record.promise;
}
+9 -4
View File
@@ -15,14 +15,19 @@ const queryClient = new QueryClient({
},
});
async function bootstrap(): Promise<void> {
await initI18n();
const root = document.getElementById('root')!;
function renderApp(): void {
render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>,
document.getElementById('root')!
root
);
}
void bootstrap();
renderApp();
void initI18n().then(() => {
renderApp();
});
+426
View File
@@ -19,6 +19,427 @@
@apply mt-2.5;
}
.public-send-card-head {
@apply mb-2 flex items-center justify-between gap-2 text-sm font-bold;
color: var(--muted);
}
.public-send-copy-btn {
@apply h-8 shrink-0 px-3 text-sm;
}
.not-found-page {
@apply relative grid min-h-full place-items-center overflow-hidden p-6 text-center;
background:
radial-gradient(circle at 50% 42%, rgba(28, 118, 255, 0.24), transparent 27rem),
radial-gradient(circle at 16% 84%, rgba(22, 163, 255, 0.10), transparent 22rem),
linear-gradient(180deg, #020b1a 0%, #061328 48%, #0a1730 100%);
}
.not-found-shell {
@apply relative z-20 grid w-full max-w-[620px] justify-items-center gap-5 px-4 py-7 text-center;
background: transparent;
border: 0;
box-shadow: none;
}
.not-found-space {
@apply pointer-events-none absolute inset-0 overflow-hidden;
height: 100%;
min-height: 100%;
}
@keyframes not-found-star-fall {
0% {
opacity: 0;
transform: translateY(-100vh);
}
20% {
opacity: 1;
}
100% {
opacity: 1;
transform: translateY(100vh);
}
}
@keyframes not-found-astronaut-spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.not-found-star-box {
@apply absolute left-0 top-0 z-10 h-full w-full;
}
.not-found-star-box-1 {
animation: not-found-star-fall 9s linear infinite;
}
.not-found-star-box-2 {
animation: not-found-star-fall 9s -2.1s linear infinite;
}
.not-found-star-box-3 {
animation: not-found-star-fall 9s -4.3s linear infinite;
}
.not-found-star-box-4 {
animation: not-found-star-fall 9s -6.4s linear infinite;
}
.not-found-star {
@apply absolute h-[3px] w-[3px] rounded-full;
background: #fff;
box-shadow: 0 0 10px rgba(255, 255, 255, 0.82);
opacity: 0.72;
}
.not-found-star::before,
.not-found-star::after {
@apply absolute rounded-full;
content: "";
background: #fff;
box-shadow: 0 0 12px rgba(255, 255, 255, 0.7);
}
.not-found-star::before {
@apply h-[5px] w-[5px];
top: 72px;
left: 78px;
opacity: 0.65;
}
.not-found-star::after {
@apply h-[7px] w-[7px];
top: 10px;
left: 168px;
opacity: 0.88;
}
.not-found-star-position-1 {
top: 5vh;
left: 8%;
}
.not-found-star-position-2 {
top: 23vh;
left: 21%;
}
.not-found-star-position-3 {
top: 42vh;
left: 38%;
}
.not-found-star-position-4 {
top: 61vh;
left: 56%;
}
.not-found-star-position-5 {
top: 15vh;
left: 70%;
}
.not-found-star-position-6 {
top: 34vh;
left: 82%;
}
.not-found-star-position-7 {
top: 72vh;
left: 93%;
}
.not-found-astronaut {
@apply relative z-10;
width: 220px;
height: 264px;
animation: not-found-astronaut-spin 5s linear infinite;
filter: drop-shadow(0 28px 36px rgba(0, 0, 0, 0.28));
}
.not-found-astro-stage {
@apply relative grid place-items-center;
height: 280px;
}
.not-found-astro-pack {
@apply absolute z-[1];
top: 57px;
left: 66px;
width: 88px;
height: 132px;
background: #8db0c7;
border-radius: 44px 44px 0 0 / 27px 27px 0 0;
}
.not-found-astro-head {
@apply absolute z-[3] rounded-full;
top: 30px;
left: 68px;
width: 85px;
height: 70px;
background: linear-gradient(90deg, #e4ebef 0%, #e4ebef 50%, #fbfdfa 50%, #fbfdfa 100%);
}
.not-found-astro-head::after {
@apply absolute rounded-[13px];
content: "";
top: 13px;
left: 16px;
width: 53px;
height: 44px;
background: linear-gradient(180deg, #28c4df 0%, #28c4df 50%, #078dbb 50%, #078dbb 100%);
}
.not-found-astro-head::before {
@apply absolute rounded-[5px];
content: "";
top: 23px;
left: -4px;
width: 11px;
height: 22px;
background: #587789;
box-shadow: 81px 0 0 #587789;
}
.not-found-astro-body {
@apply absolute z-[2];
top: 92px;
left: 73px;
width: 75px;
height: 88px;
border-radius: 36px / 18px;
background: linear-gradient(90deg, #e4ebef 0%, #e4ebef 50%, #fbfdfa 50%, #fbfdfa 100%);
}
.not-found-astro-panel {
@apply absolute;
top: 18px;
left: 11px;
width: 53px;
height: 36px;
background: #b8cdec;
}
.not-found-astro-panel::before {
@apply absolute;
content: "";
top: 8px;
left: 6px;
width: 27px;
height: 5px;
background: #fbfdfa;
box-shadow: 0 8px 0 #fbfdfa, 0 16px 0 #fbfdfa;
}
.not-found-astro-panel::after {
@apply absolute rounded-full;
content: "";
top: 8px;
right: 6px;
width: 8px;
height: 8px;
background: #fbfdfa;
box-shadow: 0 13px 0 2px #fbfdfa;
}
.not-found-astro-arm {
@apply absolute z-[2];
top: 107px;
width: 70px;
height: 26px;
}
.not-found-astro-arm-left {
left: 28px;
background: #e4ebef;
border-radius: 0 0 0 34px;
}
.not-found-astro-arm-right {
right: 28px;
background: #fbfdfa;
border-radius: 0 0 34px 0;
}
.not-found-astro-arm::before {
@apply absolute;
content: "";
top: -35px;
width: 26px;
height: 62px;
}
.not-found-astro-arm-left::before {
left: 0;
background: #e4ebef;
border-radius: 44px 44px 0 105px / 44px 44px 0 96px;
}
.not-found-astro-arm-right::before {
right: 0;
background: #fbfdfa;
border-radius: 44px 44px 105px 0 / 44px 44px 96px 0;
}
.not-found-astro-arm::after {
@apply absolute;
content: "";
top: -21px;
width: 26px;
height: 9px;
}
.not-found-astro-arm-left::after {
left: 0;
background: #6e91a4;
}
.not-found-astro-arm-right::after {
right: 0;
background: #b6d2e0;
}
.not-found-astro-leg {
@apply absolute z-[2];
bottom: 62px;
width: 26px;
height: 36px;
}
.not-found-astro-leg-left {
left: 67px;
background: #e4ebef;
transform: rotate(20deg);
}
.not-found-astro-leg-right {
right: 64px;
background: #fbfdfa;
transform: rotate(-20deg);
}
.not-found-astro-leg::before {
@apply absolute;
content: "";
bottom: -23px;
width: 44px;
height: 22px;
}
.not-found-astro-leg-left::before {
left: -18px;
background: #e4ebef;
border-bottom: 9px solid #6d96ac;
border-radius: 27px 0 0 0;
}
.not-found-astro-leg-right::before {
right: -18px;
background: #fbfdfa;
border-bottom: 9px solid #b0cfe4;
border-radius: 0 27px 0 0;
}
.not-found-brand {
@apply inline-flex max-w-full items-center justify-center gap-3.5;
}
.not-found-logo {
@apply h-14 w-[70px] flex-shrink-0 object-contain;
filter: drop-shadow(0 8px 18px rgba(43, 102, 217, 0.22));
}
.not-found-wordmark {
@apply block max-w-full;
width: clamp(200px, 38vw, 330px);
aspect-ratio: 862 / 102;
background: #116ff9;
mask: url('/nodewarden-wordmark.svg') center / contain no-repeat;
-webkit-mask: url('/nodewarden-wordmark.svg') center / contain no-repeat;
}
.not-found-code {
@apply rounded-full px-3 py-1 text-sm font-extrabold;
background: #eef4ff;
color: #1d4ed8;
}
.not-found-copy {
@apply grid justify-items-center gap-3;
text-shadow: 0 2px 18px rgba(0, 0, 0, 0.38);
}
.not-found-shell h1 {
@apply m-0 text-3xl font-extrabold leading-tight;
color: #f8fbff;
}
.not-found-shell p {
@apply m-0 max-w-[420px] text-sm leading-relaxed;
color: rgba(220, 232, 251, 0.82);
}
.not-found-action {
@apply mt-1;
}
@media (max-width: 520px) {
.not-found-page {
background:
radial-gradient(circle at 50% 36%, rgba(28, 118, 255, 0.24), transparent 18rem),
radial-gradient(circle at 18% 82%, rgba(22, 163, 255, 0.10), transparent 16rem),
linear-gradient(180deg, #020b1a 0%, #061328 48%, #0a1730 100%);
}
.not-found-shell {
@apply gap-4 px-5 py-8;
}
.not-found-brand {
@apply gap-2.5;
}
.not-found-logo {
@apply h-12 w-[60px];
}
.not-found-wordmark {
width: clamp(170px, 52vw, 230px);
}
.not-found-space {
height: 100%;
min-height: 100%;
}
.not-found-astronaut {
width: 176px;
height: 211px;
}
.not-found-astro-stage {
height: 224px;
}
}
@media (prefers-reduced-motion: reduce) {
.not-found-star-box,
.not-found-astronaut {
animation: none;
}
}
.inline-status-icon {
@apply align-text-bottom;
}
@@ -33,6 +454,11 @@
@apply m-0 mb-1 text-center;
}
.standalone-eyebrow {
@apply mb-1 text-center text-xs font-extrabold uppercase tracking-[0.12em];
color: var(--muted);
}
.standalone-shell {
@apply grid w-[min(640px,100%)] gap-3.5;
}
+6
View File
@@ -36,6 +36,7 @@
:root[data-theme='dark'] .muted,
:root[data-theme='dark'] .detail-sub,
:root[data-theme='dark'] .detail-folder-line,
:root[data-theme='dark'] .field-help,
:root[data-theme='dark'] .list-sub,
:root[data-theme='dark'] .kv-label,
@@ -296,3 +297,8 @@
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
:root[data-theme='dark'] .not-found-code {
background: color-mix(in srgb, var(--primary) 18%, var(--panel));
color: var(--primary-strong);
}
+24 -1
View File
@@ -74,6 +74,29 @@ input[type='file'].input::file-selector-button:hover {
@apply pr-11;
}
.input-action-wrap {
@apply relative;
}
.input-action-wrap .input {
@apply pr-12;
}
.input-icon-btn {
@apply absolute right-2 top-1/2 grid h-8 w-8 cursor-pointer place-items-center rounded-full border-0 bg-transparent text-slate-700 transition;
transform: translateY(-50%);
}
.input-icon-btn:hover:not(:disabled) {
color: var(--primary);
background: rgba(37, 99, 235, 0.08);
transform: translateY(-50%) scale(1.04);
}
.input-icon-btn:disabled {
@apply cursor-not-allowed text-slate-400;
}
.password-toggle {
@apply absolute right-2 top-1/2 grid cursor-pointer place-items-center border-0 bg-transparent text-blue-700 transition;
transform: translateY(-50%);
@@ -175,7 +198,7 @@ input[type='file'].input::file-selector-button:hover {
}
.or {
@apply my-2.5 text-center text-slate-700;
@apply text-center text-slate-700;
}
.field-help {
+1 -1
View File
@@ -698,7 +698,7 @@
}
.local-error {
@apply mt-2.5 font-semibold;
@apply mt-2.5 flex flex-wrap items-center gap-2 font-semibold;
color: #b42318;
}
+50 -3
View File
@@ -61,15 +61,15 @@
@media (max-width: 1180px) {
.auth-page {
@apply items-start p-3.5;
@apply items-center p-3.5;
}
.standalone-shell {
@apply w-full max-w-[460px] gap-2.5 pt-3;
@apply w-full max-w-[460px] gap-2.5;
}
.standalone-brand-outside {
@apply justify-start;
@apply justify-center;
}
.standalone-brand-logo {
@@ -501,6 +501,10 @@
height: 160px;
}
.totp-scan-actions {
grid-template-columns: 1fr;
}
.invite-toolbar {
align-items: stretch;
}
@@ -659,6 +663,49 @@
}
@media (max-width: 640px) {
.settings-module h3 {
margin-bottom: 12px;
}
.settings-module .field,
.auth-card .field {
margin-bottom: 12px;
}
.settings-module .field > span,
.auth-card .field > span {
margin-top: 0;
margin-bottom: 6px;
}
.settings-module .field-grid,
.auth-card .field-grid,
.session-timeout-fields {
gap: 12px;
}
.settings-module .btn,
.auth-card .btn:not(.full) {
margin-top: 2px;
}
.dialog-mask.totp-scan-mask {
display: block;
padding: 0;
background: #0f172a;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.dialog-card.totp-scan-dialog {
width: 100vw;
max-width: none;
height: 100dvh;
max-height: 100dvh;
border-radius: 0;
box-shadow: none;
}
.backup-interval-row {
grid-template-columns: 1fr;
}
+111
View File
@@ -663,10 +663,112 @@
color: #0f172a;
}
.dialog-mask.totp-scan-mask {
@apply grid place-items-center p-5;
background: rgba(15, 23, 42, 0.78);
}
.dialog-card.totp-scan-dialog {
@apply flex w-full max-w-[560px] flex-col overflow-hidden rounded-3xl border-0 p-0 text-left;
height: min(720px, calc(100dvh - 48px));
max-height: calc(100dvh - 48px);
background: #0f172a;
color: #f8fafc;
box-shadow: 0 28px 80px rgba(2, 6, 23, 0.45);
}
.totp-scan-head {
@apply flex shrink-0 items-center justify-between gap-3 px-4 py-3;
padding-top: calc(12px + env(safe-area-inset-top));
}
.totp-scan-head .dialog-title {
@apply m-0 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-xl;
color: #f8fafc;
}
.totp-scan-close {
@apply grid h-10 w-10 shrink-0 cursor-pointer place-items-center rounded-full border-0 bg-white/10 text-white transition;
}
.totp-scan-close:hover {
background: rgba(255, 255, 255, 0.18);
transform: scale(1.04);
}
.totp-scan-frame {
@apply relative min-h-0 flex-1 overflow-hidden;
background: #0f172a;
}
.totp-scan-video {
@apply h-full w-full object-cover;
}
.totp-scan-corners {
@apply pointer-events-none absolute rounded-[18px];
inset: max(34px, 10vmin);
border: 2px solid rgba(255, 255, 255, 0.88);
box-shadow: 0 0 0 999px rgba(15, 23, 42, 0.28);
}
.totp-scan-footer {
@apply shrink-0 px-4 py-3;
padding-bottom: calc(12px + env(safe-area-inset-bottom));
background: linear-gradient(180deg, rgba(15, 23, 42, 0.74), #0f172a);
}
.totp-scan-status {
@apply mb-3 min-h-6 text-center text-sm;
color: rgba(248, 250, 252, 0.86);
}
.totp-scan-actions {
@apply grid gap-2;
grid-template-columns: 1fr 1fr;
}
.totp-scan-actions .dialog-btn {
@apply mt-0 text-base;
}
.totp-codes-page {
@apply flex min-h-full flex-col;
}
.detail-title-row {
@apply flex min-w-0 items-center gap-3;
}
.detail-title-icon {
@apply flex h-11 w-11 shrink-0 items-center justify-center;
}
.detail-title-icon .list-icon-wrap,
.detail-title-icon .list-icon-stack,
.detail-title-icon .list-icon,
.detail-title-icon .list-icon-fallback {
width: 40px;
height: 40px;
}
.detail-title-main {
@apply min-w-0;
}
.detail-folder-line {
@apply mt-1 flex min-w-0 items-center gap-1.5 text-xs font-semibold;
color: #667085;
}
.detail-folder-line span {
@apply min-w-0 overflow-hidden text-ellipsis whitespace-nowrap;
}
.detail-folder-line svg {
@apply shrink-0;
}
.totp-codes-list {
@apply grid w-full items-start gap-2.5;
grid-template-columns: repeat(var(--totp-columns, 1), minmax(300px, 1fr));
@@ -882,3 +984,12 @@
@apply grid min-h-[120px] place-items-center;
color: #667085;
}
.vault-error-state {
@apply gap-3 text-center;
}
.vault-error-state strong {
@apply text-sm;
color: var(--danger);
}
+18
View File
@@ -8,3 +8,21 @@ declare module 'qrcode-generator' {
}
export default function qrcode(typeNumber: number, errorCorrectionLevel: 'L' | 'M' | 'Q' | 'H'): QrCode;
}
interface BarcodeDetectorResult {
rawValue: string;
}
interface BarcodeDetector {
detect(image: ImageBitmapSource): Promise<BarcodeDetectorResult[]>;
}
interface BarcodeDetectorConstructor {
new (options?: { formats?: string[] }): BarcodeDetector;
}
interface Window {
BarcodeDetector?: BarcodeDetectorConstructor;
}
declare const __NODEWARDEN_DEMO__: boolean;
+82 -71
View File
@@ -5,86 +5,97 @@ import { defineConfig } from 'vite';
const rootDir = fileURLToPath(new URL('.', import.meta.url));
export default defineConfig({
root: rootDir,
plugins: [preact()],
resolve: {
alias: {
'@': path.resolve(rootDir, 'src'),
'@shared': path.resolve(rootDir, '../shared'),
export default defineConfig(({ mode }) => {
const isDemo = mode === 'demo';
return {
root: rootDir,
plugins: [preact()],
define: {
__NODEWARDEN_DEMO__: JSON.stringify(isDemo),
},
},
build: {
outDir: path.resolve(rootDir, '../dist'),
emptyOutDir: true,
sourcemap: false,
target: 'esnext',
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('/node_modules/')) {
return 'vendor';
}
resolve: {
alias: {
'@/lib/demo': path.resolve(rootDir, isDemo ? 'src/lib/demo.ts' : 'src/lib/demo.empty.ts'),
'@/lib/demo-brand-icons': path.resolve(
rootDir,
isDemo ? 'src/lib/demo-brand-icons.ts' : 'src/lib/demo.empty.ts'
),
'@': path.resolve(rootDir, 'src'),
'@shared': path.resolve(rootDir, '../shared'),
},
},
build: {
outDir: path.resolve(rootDir, '../dist'),
emptyOutDir: true,
sourcemap: false,
target: 'esnext',
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('/node_modules/')) {
return 'vendor';
}
const normalized = id.replace(/\\/g, '/');
const normalized = id.replace(/\\/g, '/');
const localeMatch = normalized.match(/\/src\/lib\/i18n\/locales\/(.+)\.ts$/);
if (localeMatch) {
return `i18n-${localeMatch[1]}`;
}
const localeMatch = normalized.match(/\/src\/lib\/i18n\/locales\/(.+)\.ts$/);
if (localeMatch) {
if (localeMatch[1] === 'en') return undefined;
return `i18n-${localeMatch[1]}`;
}
if (normalized.includes('/src/lib/i18n.ts')) {
return 'i18n-core';
}
if (normalized.includes('/src/lib/i18n.ts')) {
return 'i18n-core';
}
if (
normalized.includes('/src/components/AuthViews.tsx') ||
normalized.includes('/src/components/PublicSendPage.tsx') ||
normalized.includes('/src/components/RecoverTwoFactorPage.tsx') ||
normalized.includes('/src/components/JwtWarningPage.tsx') ||
normalized.includes('/src/lib/app-auth.ts')
) {
return 'auth-suite';
}
if (
normalized.includes('/src/components/AuthViews.tsx') ||
normalized.includes('/src/components/PublicSendPage.tsx') ||
normalized.includes('/src/components/RecoverTwoFactorPage.tsx') ||
normalized.includes('/src/components/JwtWarningPage.tsx') ||
normalized.includes('/src/lib/app-auth.ts')
) {
return 'auth-suite';
}
if (
normalized.includes('/src/components/ImportPage.tsx') ||
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 (
!isDemo &&
(
normalized.includes('/src/components/ImportPage.tsx') ||
normalized.includes('/src/lib/import-') ||
normalized.includes('/src/lib/export-formats.ts') ||
normalized.includes('/src/components/SendsPage.tsx') ||
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 'workspace-suite';
}
if (
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 undefined;
return undefined;
},
},
},
},
},
server: {
port: 5173,
fs: {
allow: [path.resolve(rootDir, '..')],
server: {
port: 5173,
fs: {
allow: [path.resolve(rootDir, '..')],
},
proxy: {
'/api': 'http://127.0.0.1:8787',
'/identity': 'http://127.0.0.1:8787',
'/setup': 'http://127.0.0.1:8787',
'/icons': 'http://127.0.0.1:8787',
'/config': 'http://127.0.0.1:8787',
'/notifications': 'http://127.0.0.1:8787',
'/.well-known': 'http://127.0.0.1:8787',
},
},
proxy: {
'/api': 'http://127.0.0.1:8787',
'/identity': 'http://127.0.0.1:8787',
'/setup': 'http://127.0.0.1:8787',
'/icons': 'http://127.0.0.1:8787',
'/config': 'http://127.0.0.1:8787',
'/notifications': 'http://127.0.0.1:8787',
'/.well-known': 'http://127.0.0.1:8787',
},
},
};
});