mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 246c73a3d3 | |||
| 3d95c959f7 | |||
| e0737006c2 | |||
| 70dc9a76a9 | |||
| ba38b77387 | |||
| 1b4d263d6e | |||
| 97a3aa691d | |||
| 0ab7c44981 | |||
| 75a6a593dc | |||
| 45f0387526 | |||
| 851c9c4080 | |||
| a73f9a6d87 |
Generated
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 @@
|
||||
export const APP_VERSION = '1.4.6';
|
||||
export const APP_VERSION = '1.5.1';
|
||||
|
||||
+21
-8
@@ -141,6 +141,26 @@ function normalizeIconHost(rawHost: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
const ICON_UPSTREAM_TIMEOUT_MS = 2500;
|
||||
|
||||
async function fetchIconSource(source: { url: string; headers?: HeadersInit }): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), ICON_UPSTREAM_TIMEOUT_MS);
|
||||
try {
|
||||
return await fetch(source.url, {
|
||||
headers: source.headers,
|
||||
redirect: 'follow',
|
||||
signal: controller.signal,
|
||||
cf: {
|
||||
cacheEverything: true,
|
||||
cacheTtl: LIMITS.cache.iconTtlSeconds,
|
||||
},
|
||||
} as RequestInit & { cf: { cacheEverything: boolean; cacheTtl: number } });
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-found' = 'default'): Promise<Response> {
|
||||
const normalizedHost = normalizeIconHost(host);
|
||||
if (!normalizedHost) return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
|
||||
@@ -164,14 +184,7 @@ async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-fo
|
||||
|
||||
try {
|
||||
for (const source of upstreamSources) {
|
||||
const resp = await fetch(source.url, {
|
||||
headers: source.headers,
|
||||
redirect: 'follow',
|
||||
cf: {
|
||||
cacheEverything: true,
|
||||
cacheTtl: LIMITS.cache.iconTtlSeconds,
|
||||
},
|
||||
} as RequestInit & { cf: { cacheEverything: boolean; cacheTtl: number } });
|
||||
const resp = await fetchIconSource(source);
|
||||
|
||||
if (!resp.ok) continue;
|
||||
const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase();
|
||||
|
||||
+20
-2
@@ -57,6 +57,12 @@ export class AuthService {
|
||||
return user;
|
||||
}
|
||||
|
||||
private async getFreshUser(userId: string): Promise<User | null> {
|
||||
const user = await this.storage.getUserById(userId);
|
||||
this.writeCachedUser(userId, user);
|
||||
return user;
|
||||
}
|
||||
|
||||
private readCachedDevice(userId: string, deviceId: string) {
|
||||
const cacheKey = `${userId}:${deviceId}`;
|
||||
const cached = AuthService.deviceCache.get(cacheKey);
|
||||
@@ -84,6 +90,12 @@ export class AuthService {
|
||||
return device;
|
||||
}
|
||||
|
||||
private async getFreshDevice(userId: string, deviceId: string) {
|
||||
const device = await this.storage.getDevice(userId, deviceId);
|
||||
this.writeCachedDevice(userId, deviceId, device);
|
||||
return device;
|
||||
}
|
||||
|
||||
// Second-layer hash: PBKDF2-SHA256(clientHash, email-salt, iterations).
|
||||
// Ensures database contents alone cannot be used to authenticate (pass-the-hash defense).
|
||||
// Result is prefixed with "$s$" to distinguish from legacy raw client hashes.
|
||||
@@ -162,7 +174,10 @@ export class AuthService {
|
||||
const payload = await verifyJWT(parts[1], this.env.JWT_SECRET);
|
||||
if (!payload) return null;
|
||||
|
||||
const user = await this.getCachedUser(payload.sub);
|
||||
let user = await this.getCachedUser(payload.sub);
|
||||
if (!user || user.status !== 'active' || payload.sstamp !== user.securityStamp) {
|
||||
user = await this.getFreshUser(payload.sub);
|
||||
}
|
||||
if (!user) return null;
|
||||
if (user.status !== 'active') return null;
|
||||
|
||||
@@ -171,7 +186,10 @@ export class AuthService {
|
||||
}
|
||||
|
||||
if (payload.did) {
|
||||
const device = await this.getCachedDevice(user.id, payload.did);
|
||||
let device = await this.getCachedDevice(user.id, payload.did);
|
||||
if (!device || !payload.dstamp || payload.dstamp !== device.sessionStamp) {
|
||||
device = await this.getFreshDevice(user.id, payload.did);
|
||||
}
|
||||
if (!device) return null;
|
||||
if (!payload.dstamp || payload.dstamp !== device.sessionStamp) return null;
|
||||
}
|
||||
|
||||
+68
-2
@@ -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
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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') });
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
})();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -46,6 +46,8 @@ interface RefreshSuccess {
|
||||
|
||||
type RefreshResult = RefreshFailure | RefreshSuccess;
|
||||
|
||||
const pendingRefreshes = new Map<string, Promise<RefreshResult>>();
|
||||
|
||||
function randomHex(length: number): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(Math.max(1, Math.ceil(length / 2))));
|
||||
return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, length);
|
||||
@@ -312,6 +314,25 @@ export async function refreshAccessToken(session: SessionState): Promise<Refresh
|
||||
}
|
||||
}
|
||||
|
||||
function refreshKey(session: SessionState): string {
|
||||
if (session.authMode === 'web-cookie') return `web-cookie:${session.email || ''}`;
|
||||
return `token:${session.refreshToken || ''}`;
|
||||
}
|
||||
|
||||
function refreshAccessTokenOnce(session: SessionState): Promise<RefreshResult> {
|
||||
const key = refreshKey(session);
|
||||
const existing = pendingRefreshes.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const request = refreshAccessToken(session).finally(() => {
|
||||
if (pendingRefreshes.get(key) === request) {
|
||||
pendingRefreshes.delete(key);
|
||||
}
|
||||
});
|
||||
pendingRefreshes.set(key, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
export async function revokeCurrentSession(session: SessionState | null): Promise<void> {
|
||||
const body = new URLSearchParams();
|
||||
if (session?.authMode !== 'web-cookie' && session?.refreshToken) {
|
||||
@@ -436,7 +457,16 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
|
||||
let resp = await retryableRequest(headers);
|
||||
if (resp.status !== 401 || (!session.refreshToken && session.authMode !== 'web-cookie')) return resp;
|
||||
|
||||
const refreshed = await refreshAccessToken(session);
|
||||
const latest = getSession();
|
||||
if (latest?.accessToken && latest.accessToken !== session.accessToken) {
|
||||
const latestHeaders = new Headers(init.headers || {});
|
||||
latestHeaders.set('Authorization', `Bearer ${latest.accessToken}`);
|
||||
resp = await retryableRequest(latestHeaders);
|
||||
if (resp.status !== 401) return resp;
|
||||
}
|
||||
|
||||
const refreshSource = latest || session;
|
||||
const refreshed = await refreshAccessTokenOnce(refreshSource);
|
||||
if (!refreshed.ok) {
|
||||
if (refreshed.transient) {
|
||||
throw new Error(refreshed.error || 'Session refresh temporarily unavailable');
|
||||
@@ -446,10 +476,10 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
|
||||
}
|
||||
|
||||
const nextSession: SessionState = {
|
||||
...session,
|
||||
...refreshSource,
|
||||
accessToken: refreshed.token.access_token,
|
||||
refreshToken: refreshed.token.refresh_token || session.refreshToken,
|
||||
authMode: refreshed.token.web_session ? 'web-cookie' : (session.authMode || 'token'),
|
||||
refreshToken: refreshed.token.refresh_token || refreshSource.refreshToken,
|
||||
authMode: refreshed.token.web_session ? 'web-cookie' : (refreshSource.authMode || 'token'),
|
||||
};
|
||||
setSession(nextSession);
|
||||
saveSession(nextSession);
|
||||
|
||||
@@ -16,6 +16,15 @@ function normalizeSnapshot(body: VaultSyncResponse | null | undefined): VaultCor
|
||||
return {
|
||||
ciphers: Array.isArray(body?.ciphers) ? body!.ciphers! : [],
|
||||
folders: Array.isArray(body?.folders) ? body!.folders! : [],
|
||||
sends: Array.isArray(body?.sends) ? body!.sends! : [],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCachedSnapshot(snapshot: Partial<VaultCoreSnapshot> | null | undefined): VaultCoreSnapshot {
|
||||
return {
|
||||
ciphers: Array.isArray(snapshot?.ciphers) ? snapshot.ciphers : [],
|
||||
folders: Array.isArray(snapshot?.folders) ? snapshot.folders : [],
|
||||
sends: Array.isArray(snapshot?.sends) ? snapshot.sends : [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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>;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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": "Имя пользователя",
|
||||
|
||||
@@ -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": "用户名",
|
||||
|
||||
@@ -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": "用戶名",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -15,14 +15,19 @@ const queryClient = new QueryClient({
|
||||
},
|
||||
});
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
await initI18n();
|
||||
const root = document.getElementById('root')!;
|
||||
|
||||
function renderApp(): void {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>,
|
||||
document.getElementById('root')!
|
||||
root
|
||||
);
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
renderApp();
|
||||
|
||||
void initI18n().then(() => {
|
||||
renderApp();
|
||||
});
|
||||
|
||||
@@ -19,6 +19,427 @@
|
||||
@apply mt-2.5;
|
||||
}
|
||||
|
||||
.public-send-card-head {
|
||||
@apply mb-2 flex items-center justify-between gap-2 text-sm font-bold;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.public-send-copy-btn {
|
||||
@apply h-8 shrink-0 px-3 text-sm;
|
||||
}
|
||||
|
||||
.not-found-page {
|
||||
@apply relative grid min-h-full place-items-center overflow-hidden p-6 text-center;
|
||||
background:
|
||||
radial-gradient(circle at 50% 42%, rgba(28, 118, 255, 0.24), transparent 27rem),
|
||||
radial-gradient(circle at 16% 84%, rgba(22, 163, 255, 0.10), transparent 22rem),
|
||||
linear-gradient(180deg, #020b1a 0%, #061328 48%, #0a1730 100%);
|
||||
}
|
||||
|
||||
.not-found-shell {
|
||||
@apply relative z-20 grid w-full max-w-[620px] justify-items-center gap-5 px-4 py-7 text-center;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.not-found-space {
|
||||
@apply pointer-events-none absolute inset-0 overflow-hidden;
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
@keyframes not-found-star-fall {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(-100vh);
|
||||
}
|
||||
|
||||
20% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(100vh);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes not-found-astronaut-spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.not-found-star-box {
|
||||
@apply absolute left-0 top-0 z-10 h-full w-full;
|
||||
}
|
||||
|
||||
.not-found-star-box-1 {
|
||||
animation: not-found-star-fall 9s linear infinite;
|
||||
}
|
||||
|
||||
.not-found-star-box-2 {
|
||||
animation: not-found-star-fall 9s -2.1s linear infinite;
|
||||
}
|
||||
|
||||
.not-found-star-box-3 {
|
||||
animation: not-found-star-fall 9s -4.3s linear infinite;
|
||||
}
|
||||
|
||||
.not-found-star-box-4 {
|
||||
animation: not-found-star-fall 9s -6.4s linear infinite;
|
||||
}
|
||||
|
||||
.not-found-star {
|
||||
@apply absolute h-[3px] w-[3px] rounded-full;
|
||||
background: #fff;
|
||||
box-shadow: 0 0 10px rgba(255, 255, 255, 0.82);
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.not-found-star::before,
|
||||
.not-found-star::after {
|
||||
@apply absolute rounded-full;
|
||||
content: "";
|
||||
background: #fff;
|
||||
box-shadow: 0 0 12px rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.not-found-star::before {
|
||||
@apply h-[5px] w-[5px];
|
||||
top: 72px;
|
||||
left: 78px;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.not-found-star::after {
|
||||
@apply h-[7px] w-[7px];
|
||||
top: 10px;
|
||||
left: 168px;
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.not-found-star-position-1 {
|
||||
top: 5vh;
|
||||
left: 8%;
|
||||
}
|
||||
|
||||
.not-found-star-position-2 {
|
||||
top: 23vh;
|
||||
left: 21%;
|
||||
}
|
||||
|
||||
.not-found-star-position-3 {
|
||||
top: 42vh;
|
||||
left: 38%;
|
||||
}
|
||||
|
||||
.not-found-star-position-4 {
|
||||
top: 61vh;
|
||||
left: 56%;
|
||||
}
|
||||
|
||||
.not-found-star-position-5 {
|
||||
top: 15vh;
|
||||
left: 70%;
|
||||
}
|
||||
|
||||
.not-found-star-position-6 {
|
||||
top: 34vh;
|
||||
left: 82%;
|
||||
}
|
||||
|
||||
.not-found-star-position-7 {
|
||||
top: 72vh;
|
||||
left: 93%;
|
||||
}
|
||||
|
||||
.not-found-astronaut {
|
||||
@apply relative z-10;
|
||||
width: 220px;
|
||||
height: 264px;
|
||||
animation: not-found-astronaut-spin 5s linear infinite;
|
||||
filter: drop-shadow(0 28px 36px rgba(0, 0, 0, 0.28));
|
||||
}
|
||||
|
||||
.not-found-astro-stage {
|
||||
@apply relative grid place-items-center;
|
||||
height: 280px;
|
||||
}
|
||||
|
||||
.not-found-astro-pack {
|
||||
@apply absolute z-[1];
|
||||
top: 57px;
|
||||
left: 66px;
|
||||
width: 88px;
|
||||
height: 132px;
|
||||
background: #8db0c7;
|
||||
border-radius: 44px 44px 0 0 / 27px 27px 0 0;
|
||||
}
|
||||
|
||||
.not-found-astro-head {
|
||||
@apply absolute z-[3] rounded-full;
|
||||
top: 30px;
|
||||
left: 68px;
|
||||
width: 85px;
|
||||
height: 70px;
|
||||
background: linear-gradient(90deg, #e4ebef 0%, #e4ebef 50%, #fbfdfa 50%, #fbfdfa 100%);
|
||||
}
|
||||
|
||||
.not-found-astro-head::after {
|
||||
@apply absolute rounded-[13px];
|
||||
content: "";
|
||||
top: 13px;
|
||||
left: 16px;
|
||||
width: 53px;
|
||||
height: 44px;
|
||||
background: linear-gradient(180deg, #28c4df 0%, #28c4df 50%, #078dbb 50%, #078dbb 100%);
|
||||
}
|
||||
|
||||
.not-found-astro-head::before {
|
||||
@apply absolute rounded-[5px];
|
||||
content: "";
|
||||
top: 23px;
|
||||
left: -4px;
|
||||
width: 11px;
|
||||
height: 22px;
|
||||
background: #587789;
|
||||
box-shadow: 81px 0 0 #587789;
|
||||
}
|
||||
|
||||
.not-found-astro-body {
|
||||
@apply absolute z-[2];
|
||||
top: 92px;
|
||||
left: 73px;
|
||||
width: 75px;
|
||||
height: 88px;
|
||||
border-radius: 36px / 18px;
|
||||
background: linear-gradient(90deg, #e4ebef 0%, #e4ebef 50%, #fbfdfa 50%, #fbfdfa 100%);
|
||||
}
|
||||
|
||||
.not-found-astro-panel {
|
||||
@apply absolute;
|
||||
top: 18px;
|
||||
left: 11px;
|
||||
width: 53px;
|
||||
height: 36px;
|
||||
background: #b8cdec;
|
||||
}
|
||||
|
||||
.not-found-astro-panel::before {
|
||||
@apply absolute;
|
||||
content: "";
|
||||
top: 8px;
|
||||
left: 6px;
|
||||
width: 27px;
|
||||
height: 5px;
|
||||
background: #fbfdfa;
|
||||
box-shadow: 0 8px 0 #fbfdfa, 0 16px 0 #fbfdfa;
|
||||
}
|
||||
|
||||
.not-found-astro-panel::after {
|
||||
@apply absolute rounded-full;
|
||||
content: "";
|
||||
top: 8px;
|
||||
right: 6px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #fbfdfa;
|
||||
box-shadow: 0 13px 0 2px #fbfdfa;
|
||||
}
|
||||
|
||||
.not-found-astro-arm {
|
||||
@apply absolute z-[2];
|
||||
top: 107px;
|
||||
width: 70px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.not-found-astro-arm-left {
|
||||
left: 28px;
|
||||
background: #e4ebef;
|
||||
border-radius: 0 0 0 34px;
|
||||
}
|
||||
|
||||
.not-found-astro-arm-right {
|
||||
right: 28px;
|
||||
background: #fbfdfa;
|
||||
border-radius: 0 0 34px 0;
|
||||
}
|
||||
|
||||
.not-found-astro-arm::before {
|
||||
@apply absolute;
|
||||
content: "";
|
||||
top: -35px;
|
||||
width: 26px;
|
||||
height: 62px;
|
||||
}
|
||||
|
||||
.not-found-astro-arm-left::before {
|
||||
left: 0;
|
||||
background: #e4ebef;
|
||||
border-radius: 44px 44px 0 105px / 44px 44px 0 96px;
|
||||
}
|
||||
|
||||
.not-found-astro-arm-right::before {
|
||||
right: 0;
|
||||
background: #fbfdfa;
|
||||
border-radius: 44px 44px 105px 0 / 44px 44px 96px 0;
|
||||
}
|
||||
|
||||
.not-found-astro-arm::after {
|
||||
@apply absolute;
|
||||
content: "";
|
||||
top: -21px;
|
||||
width: 26px;
|
||||
height: 9px;
|
||||
}
|
||||
|
||||
.not-found-astro-arm-left::after {
|
||||
left: 0;
|
||||
background: #6e91a4;
|
||||
}
|
||||
|
||||
.not-found-astro-arm-right::after {
|
||||
right: 0;
|
||||
background: #b6d2e0;
|
||||
}
|
||||
|
||||
.not-found-astro-leg {
|
||||
@apply absolute z-[2];
|
||||
bottom: 62px;
|
||||
width: 26px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.not-found-astro-leg-left {
|
||||
left: 67px;
|
||||
background: #e4ebef;
|
||||
transform: rotate(20deg);
|
||||
}
|
||||
|
||||
.not-found-astro-leg-right {
|
||||
right: 64px;
|
||||
background: #fbfdfa;
|
||||
transform: rotate(-20deg);
|
||||
}
|
||||
|
||||
.not-found-astro-leg::before {
|
||||
@apply absolute;
|
||||
content: "";
|
||||
bottom: -23px;
|
||||
width: 44px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.not-found-astro-leg-left::before {
|
||||
left: -18px;
|
||||
background: #e4ebef;
|
||||
border-bottom: 9px solid #6d96ac;
|
||||
border-radius: 27px 0 0 0;
|
||||
}
|
||||
|
||||
.not-found-astro-leg-right::before {
|
||||
right: -18px;
|
||||
background: #fbfdfa;
|
||||
border-bottom: 9px solid #b0cfe4;
|
||||
border-radius: 0 27px 0 0;
|
||||
}
|
||||
|
||||
.not-found-brand {
|
||||
@apply inline-flex max-w-full items-center justify-center gap-3.5;
|
||||
}
|
||||
|
||||
.not-found-logo {
|
||||
@apply h-14 w-[70px] flex-shrink-0 object-contain;
|
||||
filter: drop-shadow(0 8px 18px rgba(43, 102, 217, 0.22));
|
||||
}
|
||||
|
||||
.not-found-wordmark {
|
||||
@apply block max-w-full;
|
||||
width: clamp(200px, 38vw, 330px);
|
||||
aspect-ratio: 862 / 102;
|
||||
background: #116ff9;
|
||||
mask: url('/nodewarden-wordmark.svg') center / contain no-repeat;
|
||||
-webkit-mask: url('/nodewarden-wordmark.svg') center / contain no-repeat;
|
||||
}
|
||||
|
||||
.not-found-code {
|
||||
@apply rounded-full px-3 py-1 text-sm font-extrabold;
|
||||
background: #eef4ff;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.not-found-copy {
|
||||
@apply grid justify-items-center gap-3;
|
||||
text-shadow: 0 2px 18px rgba(0, 0, 0, 0.38);
|
||||
}
|
||||
|
||||
.not-found-shell h1 {
|
||||
@apply m-0 text-3xl font-extrabold leading-tight;
|
||||
color: #f8fbff;
|
||||
}
|
||||
|
||||
.not-found-shell p {
|
||||
@apply m-0 max-w-[420px] text-sm leading-relaxed;
|
||||
color: rgba(220, 232, 251, 0.82);
|
||||
}
|
||||
|
||||
.not-found-action {
|
||||
@apply mt-1;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.not-found-page {
|
||||
background:
|
||||
radial-gradient(circle at 50% 36%, rgba(28, 118, 255, 0.24), transparent 18rem),
|
||||
radial-gradient(circle at 18% 82%, rgba(22, 163, 255, 0.10), transparent 16rem),
|
||||
linear-gradient(180deg, #020b1a 0%, #061328 48%, #0a1730 100%);
|
||||
}
|
||||
|
||||
.not-found-shell {
|
||||
@apply gap-4 px-5 py-8;
|
||||
}
|
||||
|
||||
.not-found-brand {
|
||||
@apply gap-2.5;
|
||||
}
|
||||
|
||||
.not-found-logo {
|
||||
@apply h-12 w-[60px];
|
||||
}
|
||||
|
||||
.not-found-wordmark {
|
||||
width: clamp(170px, 52vw, 230px);
|
||||
}
|
||||
|
||||
.not-found-space {
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.not-found-astronaut {
|
||||
width: 176px;
|
||||
height: 211px;
|
||||
}
|
||||
|
||||
.not-found-astro-stage {
|
||||
height: 224px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.not-found-star-box,
|
||||
.not-found-astronaut {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.inline-status-icon {
|
||||
@apply align-text-bottom;
|
||||
}
|
||||
@@ -33,6 +454,11 @@
|
||||
@apply m-0 mb-1 text-center;
|
||||
}
|
||||
|
||||
.standalone-eyebrow {
|
||||
@apply mb-1 text-center text-xs font-extrabold uppercase tracking-[0.12em];
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.standalone-shell {
|
||||
@apply grid w-[min(640px,100%)] gap-3.5;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Vendored
+18
@@ -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
@@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user