mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-21 13:20:13 +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",
|
"name": "nodewarden",
|
||||||
"version": "1.4.6",
|
"version": "1.5.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "nodewarden",
|
"name": "nodewarden",
|
||||||
"version": "1.4.6",
|
"version": "1.5.1",
|
||||||
"license": "LGPL-3.0",
|
"license": "LGPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
|||||||
+5
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "nodewarden",
|
"name": "nodewarden",
|
||||||
"version": "1.4.6",
|
"version": "1.5.1",
|
||||||
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
|
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
|
||||||
"author": "shuaiplus",
|
"author": "shuaiplus",
|
||||||
"license": "LGPL-3.0",
|
"license": "LGPL-3.0",
|
||||||
@@ -9,11 +9,14 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "wrangler dev -c wrangler.toml",
|
"dev": "wrangler dev -c wrangler.toml",
|
||||||
"dev:kv": "wrangler dev -c wrangler.kv.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": "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": "node scripts/i18n-validate.cjs",
|
||||||
"i18n:validate": "node scripts/i18n-validate.cjs",
|
"i18n:validate": "node scripts/i18n-validate.cjs",
|
||||||
"deploy": "wrangler deploy",
|
"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": [
|
"keywords": [
|
||||||
"bitwarden",
|
"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> {
|
async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-found' = 'default'): Promise<Response> {
|
||||||
const normalizedHost = normalizeIconHost(host);
|
const normalizedHost = normalizeIconHost(host);
|
||||||
if (!normalizedHost) return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
|
if (!normalizedHost) return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
|
||||||
@@ -164,14 +184,7 @@ async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-fo
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
for (const source of upstreamSources) {
|
for (const source of upstreamSources) {
|
||||||
const resp = await fetch(source.url, {
|
const resp = await fetchIconSource(source);
|
||||||
headers: source.headers,
|
|
||||||
redirect: 'follow',
|
|
||||||
cf: {
|
|
||||||
cacheEverything: true,
|
|
||||||
cacheTtl: LIMITS.cache.iconTtlSeconds,
|
|
||||||
},
|
|
||||||
} as RequestInit & { cf: { cacheEverything: boolean; cacheTtl: number } });
|
|
||||||
|
|
||||||
if (!resp.ok) continue;
|
if (!resp.ok) continue;
|
||||||
const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase();
|
const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase();
|
||||||
|
|||||||
+20
-2
@@ -57,6 +57,12 @@ export class AuthService {
|
|||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getFreshUser(userId: string): Promise<User | null> {
|
||||||
|
const user = await this.storage.getUserById(userId);
|
||||||
|
this.writeCachedUser(userId, user);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
private readCachedDevice(userId: string, deviceId: string) {
|
private readCachedDevice(userId: string, deviceId: string) {
|
||||||
const cacheKey = `${userId}:${deviceId}`;
|
const cacheKey = `${userId}:${deviceId}`;
|
||||||
const cached = AuthService.deviceCache.get(cacheKey);
|
const cached = AuthService.deviceCache.get(cacheKey);
|
||||||
@@ -84,6 +90,12 @@ export class AuthService {
|
|||||||
return device;
|
return device;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getFreshDevice(userId: string, deviceId: string) {
|
||||||
|
const device = await this.storage.getDevice(userId, deviceId);
|
||||||
|
this.writeCachedDevice(userId, deviceId, device);
|
||||||
|
return device;
|
||||||
|
}
|
||||||
|
|
||||||
// Second-layer hash: PBKDF2-SHA256(clientHash, email-salt, iterations).
|
// Second-layer hash: PBKDF2-SHA256(clientHash, email-salt, iterations).
|
||||||
// Ensures database contents alone cannot be used to authenticate (pass-the-hash defense).
|
// Ensures database contents alone cannot be used to authenticate (pass-the-hash defense).
|
||||||
// Result is prefixed with "$s$" to distinguish from legacy raw client hashes.
|
// Result is prefixed with "$s$" to distinguish from legacy raw client hashes.
|
||||||
@@ -162,7 +174,10 @@ export class AuthService {
|
|||||||
const payload = await verifyJWT(parts[1], this.env.JWT_SECRET);
|
const payload = await verifyJWT(parts[1], this.env.JWT_SECRET);
|
||||||
if (!payload) return null;
|
if (!payload) return null;
|
||||||
|
|
||||||
const user = await this.getCachedUser(payload.sub);
|
let user = await this.getCachedUser(payload.sub);
|
||||||
|
if (!user || user.status !== 'active' || payload.sstamp !== user.securityStamp) {
|
||||||
|
user = await this.getFreshUser(payload.sub);
|
||||||
|
}
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
if (user.status !== 'active') return null;
|
if (user.status !== 'active') return null;
|
||||||
|
|
||||||
@@ -171,7 +186,10 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (payload.did) {
|
if (payload.did) {
|
||||||
const device = await this.getCachedDevice(user.id, payload.did);
|
let device = await this.getCachedDevice(user.id, payload.did);
|
||||||
|
if (!device || !payload.dstamp || payload.dstamp !== device.sessionStamp) {
|
||||||
|
device = await this.getFreshDevice(user.id, payload.did);
|
||||||
|
}
|
||||||
if (!device) return null;
|
if (!device) return null;
|
||||||
if (!payload.dstamp || payload.dstamp !== device.sessionStamp) return null;
|
if (!payload.dstamp || payload.dstamp !== device.sessionStamp) return null;
|
||||||
}
|
}
|
||||||
|
|||||||
+67
-1
@@ -20,9 +20,75 @@
|
|||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
|
||||||
<title>NodeWarden</title>
|
<title>NodeWarden</title>
|
||||||
|
<style>
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: #eef4ff;
|
||||||
|
color: #0f172a;
|
||||||
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boot-screen {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boot-card {
|
||||||
|
width: min(420px, 100%);
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
justify-items: center;
|
||||||
|
padding: 28px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||||
|
border-radius: 22px;
|
||||||
|
background: rgba(255, 255, 255, 0.82);
|
||||||
|
box-shadow: 0 20px 45px rgba(15, 23, 42, 0.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
.boot-logo {
|
||||||
|
width: 74px;
|
||||||
|
height: 58px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boot-line {
|
||||||
|
width: 72%;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(90deg, #dbeafe, #bfdbfe, #dbeafe);
|
||||||
|
background-size: 180% 100%;
|
||||||
|
animation: boot-shimmer 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boot-line.short {
|
||||||
|
width: 46%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes boot-shimmer {
|
||||||
|
0% { background-position: 180% 0; }
|
||||||
|
100% { background-position: -180% 0; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root">
|
||||||
|
<div class="boot-screen">
|
||||||
|
<div class="boot-card" aria-label="Loading NodeWarden">
|
||||||
|
<img class="boot-logo" src="/nodewarden-logo.svg" alt="" />
|
||||||
|
<div class="boot-line"></div>
|
||||||
|
<div class="boot-line short"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+258
-32
@@ -1,9 +1,10 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
import { useLocation } from 'wouter';
|
import { useLocation } from 'wouter';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import AppAuthenticatedShell from '@/components/AppAuthenticatedShell';
|
import AppAuthenticatedShell from '@/components/AppAuthenticatedShell';
|
||||||
import AppGlobalOverlays, { type AppConfirmState } from '@/components/AppGlobalOverlays';
|
import AppGlobalOverlays, { type AppConfirmState } from '@/components/AppGlobalOverlays';
|
||||||
import AuthViews from '@/components/AuthViews';
|
import AuthViews from '@/components/AuthViews';
|
||||||
|
import NotFoundPage from '@/components/NotFoundPage';
|
||||||
import PublicSendPage from '@/components/PublicSendPage';
|
import PublicSendPage from '@/components/PublicSendPage';
|
||||||
import RecoverTwoFactorPage from '@/components/RecoverTwoFactorPage';
|
import RecoverTwoFactorPage from '@/components/RecoverTwoFactorPage';
|
||||||
import JwtWarningPage from '@/components/JwtWarningPage';
|
import JwtWarningPage from '@/components/JwtWarningPage';
|
||||||
@@ -29,6 +30,7 @@ import {
|
|||||||
parseSignalRTextFrames,
|
parseSignalRTextFrames,
|
||||||
readInviteCodeFromUrl,
|
readInviteCodeFromUrl,
|
||||||
} from '@/lib/app-support';
|
} from '@/lib/app-support';
|
||||||
|
import { preloadAuthenticatedWorkspace, preloadDemoExperience } from '@/lib/app-preload';
|
||||||
import {
|
import {
|
||||||
bootstrapAppSession,
|
bootstrapAppSession,
|
||||||
type CompletedLogin,
|
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 { dispatchBackupProgress, type BackupProgressDetail } from '@/lib/backup-restore-progress';
|
||||||
import { decryptSends, decryptVaultCore } from '@/lib/vault-decrypt';
|
import { decryptSends, decryptVaultCore } from '@/lib/vault-decrypt';
|
||||||
import { decryptSendsInWorker, decryptVaultCoreInWorker } from '@/lib/vault-worker';
|
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';
|
import type { VaultCoreSnapshot } from '@/lib/vault-cache';
|
||||||
|
|
||||||
function isBackupProgressDetail(value: unknown): value is BackupProgressDetail {
|
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 IMPORT_ROUTE_ALIASES: ReadonlySet<string> = new Set(IMPORT_ROUTE_PATHS.filter((path) => path !== IMPORT_ROUTE));
|
||||||
const SETTINGS_HOME_ROUTE = '/settings';
|
const SETTINGS_HOME_ROUTE = '/settings';
|
||||||
const SETTINGS_ACCOUNT_ROUTE = '/settings/account';
|
const SETTINGS_ACCOUNT_ROUTE = '/settings/account';
|
||||||
|
const AUTH_ROUTE_PATHS = ['/', '/login', '/register', '/lock', '/recover-2fa'] as const;
|
||||||
|
const APP_ROUTE_PATHS = [
|
||||||
|
'/',
|
||||||
|
'/vault',
|
||||||
|
'/vault/totp',
|
||||||
|
'/sends',
|
||||||
|
'/admin',
|
||||||
|
'/security/devices',
|
||||||
|
'/backup',
|
||||||
|
'/settings',
|
||||||
|
SETTINGS_ACCOUNT_ROUTE,
|
||||||
|
'/help',
|
||||||
|
...IMPORT_ROUTE_PATHS,
|
||||||
|
] as const;
|
||||||
|
const AUTH_ROUTES: ReadonlySet<string> = new Set(AUTH_ROUTE_PATHS);
|
||||||
|
const APP_ROUTES: ReadonlySet<string> = new Set(APP_ROUTE_PATHS);
|
||||||
|
|
||||||
function isAdminProfile(profile: Profile | null): profile is Profile {
|
function isAdminProfile(profile: Profile | null): profile is Profile {
|
||||||
return String(profile?.role || '').toLowerCase() === 'admin';
|
return String(profile?.role || '').toLowerCase() === 'admin';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeRoutePath(path: string): string {
|
||||||
|
const pathOnly = String(path || '/').split('?')[0].split('#')[0];
|
||||||
|
const normalized = pathOnly.startsWith('/') ? pathOnly : `/${pathOnly}`;
|
||||||
|
return normalized.length > 1 ? normalized.replace(/\/+$/, '') : '/';
|
||||||
|
}
|
||||||
const THEME_STORAGE_KEY = 'nodewarden.theme.preference.v1';
|
const THEME_STORAGE_KEY = 'nodewarden.theme.preference.v1';
|
||||||
const SIGNALR_RECORD_SEPARATOR = String.fromCharCode(0x1e);
|
const SIGNALR_RECORD_SEPARATOR = String.fromCharCode(0x1e);
|
||||||
const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5;
|
const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5;
|
||||||
@@ -114,9 +152,16 @@ function readSessionTimeoutAction(): SessionTimeoutAction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const initialBootstrap = useMemo(() => readInitialAppBootstrapState(), []);
|
const initialBootstrap = useMemo(
|
||||||
|
() => (IS_DEMO_MODE ? createDemoInitialBootstrapState() : readInitialAppBootstrapState()),
|
||||||
|
[]
|
||||||
|
);
|
||||||
const initialInviteCode = useMemo(() => readInviteCodeFromUrl(), []);
|
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 [pendingAuthAction, setPendingAuthAction] = useState<'login' | 'register' | 'unlock' | null>(null);
|
||||||
const [location, navigate] = useLocation();
|
const [location, navigate] = useLocation();
|
||||||
const [phase, setPhase] = useState<AppPhase>(initialBootstrap.phase);
|
const [phase, setPhase] = useState<AppPhase>(initialBootstrap.phase);
|
||||||
@@ -167,8 +212,14 @@ export default function App() {
|
|||||||
const [decryptedFolders, setDecryptedFolders] = useState<VaultFolder[]>([]);
|
const [decryptedFolders, setDecryptedFolders] = useState<VaultFolder[]>([]);
|
||||||
const [decryptedCiphers, setDecryptedCiphers] = useState<Cipher[]>([]);
|
const [decryptedCiphers, setDecryptedCiphers] = useState<Cipher[]>([]);
|
||||||
const [decryptedSends, setDecryptedSends] = useState<Send[]>([]);
|
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 [cachedVaultCore, setCachedVaultCore] = useState<VaultCoreSnapshot | null>(null);
|
||||||
const [vaultInitialDecryptDone, setVaultInitialDecryptDone] = useState(false);
|
const [vaultInitialDecryptDone, setVaultInitialDecryptDone] = useState(false);
|
||||||
|
const [vaultDecryptError, setVaultDecryptError] = useState('');
|
||||||
|
const [sendsDecryptDone, setSendsDecryptDone] = useState(false);
|
||||||
const sessionRef = useRef<SessionState | null>(initialBootstrap.session);
|
const sessionRef = useRef<SessionState | null>(initialBootstrap.session);
|
||||||
const silentRefreshVaultRef = useRef<() => Promise<void>>(async () => {});
|
const silentRefreshVaultRef = useRef<() => Promise<void>>(async () => {});
|
||||||
const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {});
|
const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {});
|
||||||
@@ -267,6 +318,7 @@ export default function App() {
|
|||||||
}, [themePreference]);
|
}, [themePreference]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (IS_DEMO_MODE) return;
|
||||||
saveProfileSnapshot(profile);
|
saveProfileSnapshot(profile);
|
||||||
}, [profile]);
|
}, [profile]);
|
||||||
|
|
||||||
@@ -347,6 +399,22 @@ export default function App() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
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;
|
let mounted = true;
|
||||||
(async () => {
|
(async () => {
|
||||||
const boot = await bootstrapAppSession(initialBootstrap);
|
const boot = await bootstrapAppSession(initialBootstrap);
|
||||||
@@ -366,6 +434,7 @@ export default function App() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (phase !== 'locked' || !session) return;
|
if (phase !== 'locked' || !session) return;
|
||||||
|
if (IS_DEMO_MODE) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const result = await hydrateLockedSession(session, profile);
|
const result = await hydrateLockedSession(session, profile);
|
||||||
@@ -414,6 +483,15 @@ export default function App() {
|
|||||||
|
|
||||||
async function handleLogin() {
|
async function handleLogin() {
|
||||||
if (pendingAuthAction) return;
|
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) {
|
if (!loginValues.email || !loginValues.password) {
|
||||||
pushToast('error', t('txt_please_input_email_and_password'));
|
pushToast('error', t('txt_please_input_email_and_password'));
|
||||||
return;
|
return;
|
||||||
@@ -486,6 +564,12 @@ export default function App() {
|
|||||||
|
|
||||||
async function handleRegister() {
|
async function handleRegister() {
|
||||||
if (pendingAuthAction) return;
|
if (pendingAuthAction) return;
|
||||||
|
if (IS_DEMO_MODE) {
|
||||||
|
pushToast('warning', t('txt_demo_readonly_message'));
|
||||||
|
setPhase('login');
|
||||||
|
navigate('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!registerValues.email || !registerValues.password) {
|
if (!registerValues.email || !registerValues.password) {
|
||||||
pushToast('error', t('txt_please_input_email_and_password'));
|
pushToast('error', t('txt_please_input_email_and_password'));
|
||||||
return;
|
return;
|
||||||
@@ -534,6 +618,10 @@ export default function App() {
|
|||||||
|
|
||||||
async function handleTogglePasswordHint() {
|
async function handleTogglePasswordHint() {
|
||||||
if (pendingAuthAction) return;
|
if (pendingAuthAction) return;
|
||||||
|
if (IS_DEMO_MODE) {
|
||||||
|
openPasswordHintDialog(t('txt_demo_master_password_hint'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
const email = loginValues.email.trim().toLowerCase();
|
const email = loginValues.email.trim().toLowerCase();
|
||||||
if (!email) return;
|
if (!email) return;
|
||||||
|
|
||||||
@@ -568,12 +656,21 @@ export default function App() {
|
|||||||
|
|
||||||
function handleShowLockedPasswordHint() {
|
function handleShowLockedPasswordHint() {
|
||||||
if (pendingAuthAction) return;
|
if (pendingAuthAction) return;
|
||||||
openPasswordHintDialog(profile?.masterPasswordHint ?? null);
|
openPasswordHintDialog((IS_DEMO_MODE ? t('txt_demo_master_password_hint') : profile?.masterPasswordHint) ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleUnlock() {
|
async function handleUnlock() {
|
||||||
if (pendingAuthAction) return;
|
if (pendingAuthAction) return;
|
||||||
if (!session?.email) 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) {
|
if (!unlockPassword) {
|
||||||
pushToast('error', t('txt_please_input_master_password'));
|
pushToast('error', t('txt_please_input_master_password'));
|
||||||
return;
|
return;
|
||||||
@@ -625,7 +722,9 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function logoutNow() {
|
function logoutNow() {
|
||||||
void revokeCurrentSession(sessionRef.current);
|
if (!IS_DEMO_MODE) {
|
||||||
|
void revokeCurrentSession(sessionRef.current);
|
||||||
|
}
|
||||||
setConfirm(null);
|
setConfirm(null);
|
||||||
setSession(null);
|
setSession(null);
|
||||||
clearProfileSnapshot();
|
clearProfileSnapshot();
|
||||||
@@ -731,6 +830,36 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
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;
|
let cancelled = false;
|
||||||
if (phase !== 'app' || !session?.symEncKey || !session?.symMacKey || !vaultCacheKey) {
|
if (phase !== 'app' || !session?.symEncKey || !session?.symMacKey || !vaultCacheKey) {
|
||||||
setCachedVaultCore(null);
|
setCachedVaultCore(null);
|
||||||
@@ -763,22 +892,35 @@ export default function App() {
|
|||||||
const vaultCoreQuery = useQuery({
|
const vaultCoreQuery = useQuery({
|
||||||
queryKey: ['vault-core', vaultCacheKey],
|
queryKey: ['vault-core', vaultCacheKey],
|
||||||
queryFn: () => loadVaultCoreSyncSnapshot(authedFetch, 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,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
const encryptedVaultCore = vaultCoreQuery.data || cachedVaultCore;
|
const encryptedVaultCore = vaultCoreQuery.data || cachedVaultCore;
|
||||||
const encryptedFolders = encryptedVaultCore?.folders;
|
const encryptedFolders = encryptedVaultCore?.folders;
|
||||||
const encryptedCiphers = encryptedVaultCore?.ciphers;
|
const encryptedCiphers = encryptedVaultCore?.ciphers;
|
||||||
|
const encryptedSendsFromSync = encryptedVaultCore?.sends;
|
||||||
|
const sendsQueryKey = useMemo(() => ['sends', vaultCacheKey || session?.email] as const, [vaultCacheKey, session?.email]);
|
||||||
const sendsQuery = useQuery({
|
const sendsQuery = useQuery({
|
||||||
queryKey: ['sends', vaultCacheKey || session?.email],
|
queryKey: sendsQueryKey,
|
||||||
queryFn: () => getSends(authedFetch),
|
queryFn: () => getSends(authedFetch),
|
||||||
enabled: phase === 'app' && !!session?.symEncKey && !!session?.symMacKey && (vaultInitialDecryptDone || location === '/sends'),
|
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.symEncKey && !!session?.symMacKey && location === '/sends' && !encryptedSendsFromSync,
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
|
const encryptedSends = sendsQuery.data || encryptedSendsFromSync;
|
||||||
|
async function refetchSendsFromVaultCore() {
|
||||||
|
const result = await refetchVaultCoreData() as { data?: VaultCoreSnapshot };
|
||||||
|
const sends = Array.isArray(result.data?.sends) ? result.data.sends : [];
|
||||||
|
queryClient.setQueryData(sendsQueryKey, sends);
|
||||||
|
return { data: sends };
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
if (!Array.isArray(encryptedSendsFromSync)) return;
|
||||||
|
queryClient.setQueryData(sendsQueryKey, encryptedSendsFromSync);
|
||||||
|
}, [queryClient, sendsQueryKey, encryptedSendsFromSync]);
|
||||||
const profileQuery = useQuery({
|
const profileQuery = useQuery({
|
||||||
queryKey: ['profile', vaultCacheKey || session?.email],
|
queryKey: ['profile', vaultCacheKey || session?.email],
|
||||||
queryFn: () => getProfile(authedFetch),
|
queryFn: () => getProfile(authedFetch),
|
||||||
enabled: phase === 'app' && !!session?.accessToken,
|
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken,
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -790,29 +932,47 @@ export default function App() {
|
|||||||
const usersQuery = useQuery({
|
const usersQuery = useQuery({
|
||||||
queryKey: ['admin-users', vaultCacheKey],
|
queryKey: ['admin-users', vaultCacheKey],
|
||||||
queryFn: () => listAdminUsers(authedFetch),
|
queryFn: () => listAdminUsers(authedFetch),
|
||||||
enabled: phase === 'app' && isAdmin && vaultInitialDecryptDone,
|
enabled: !IS_DEMO_MODE && phase === 'app' && isAdmin && vaultInitialDecryptDone,
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
const invitesQuery = useQuery({
|
const invitesQuery = useQuery({
|
||||||
queryKey: ['admin-invites', vaultCacheKey],
|
queryKey: ['admin-invites', vaultCacheKey],
|
||||||
queryFn: () => listAdminInvites(authedFetch),
|
queryFn: () => listAdminInvites(authedFetch),
|
||||||
enabled: phase === 'app' && isAdmin && vaultInitialDecryptDone,
|
enabled: !IS_DEMO_MODE && phase === 'app' && isAdmin && vaultInitialDecryptDone,
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
const totpStatusQuery = useQuery({
|
const totpStatusQuery = useQuery({
|
||||||
queryKey: ['totp-status', vaultCacheKey || session?.email],
|
queryKey: ['totp-status', vaultCacheKey || session?.email],
|
||||||
queryFn: () => getTotpStatus(authedFetch),
|
queryFn: () => getTotpStatus(authedFetch),
|
||||||
enabled: phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
|
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
const authorizedDevicesQuery = useQuery({
|
const authorizedDevicesQuery = useQuery({
|
||||||
queryKey: ['authorized-devices', vaultCacheKey || session?.email],
|
queryKey: ['authorized-devices', vaultCacheKey || session?.email],
|
||||||
queryFn: () => getAuthorizedDevices(authedFetch),
|
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,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
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 (phase !== 'app' || !session?.accessToken || !session?.symEncKey || !session?.symMacKey) return;
|
||||||
if (!vaultInitialDecryptDone) return;
|
if (!vaultInitialDecryptDone) return;
|
||||||
if (!isAdminProfile(profile)) return;
|
if (!isAdminProfile(profile)) return;
|
||||||
@@ -828,11 +988,14 @@ export default function App() {
|
|||||||
}, [session?.accessToken]);
|
}, [session?.accessToken]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (IS_DEMO_MODE) return;
|
||||||
if (!session?.symEncKey || !session?.symMacKey) {
|
if (!session?.symEncKey || !session?.symMacKey) {
|
||||||
setDecryptedFolders([]);
|
setDecryptedFolders([]);
|
||||||
setDecryptedCiphers([]);
|
setDecryptedCiphers([]);
|
||||||
setDecryptedSends([]);
|
setDecryptedSends([]);
|
||||||
setVaultInitialDecryptDone(false);
|
setVaultInitialDecryptDone(false);
|
||||||
|
setVaultDecryptError('');
|
||||||
|
setSendsDecryptDone(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!encryptedFolders || !encryptedCiphers) return;
|
if (!encryptedFolders || !encryptedCiphers) return;
|
||||||
@@ -840,6 +1003,7 @@ export default function App() {
|
|||||||
let active = true;
|
let active = true;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
|
setVaultDecryptError('');
|
||||||
let result;
|
let result;
|
||||||
try {
|
try {
|
||||||
result = await decryptVaultCoreInWorker({
|
result = await decryptVaultCoreInWorker({
|
||||||
@@ -863,7 +1027,10 @@ export default function App() {
|
|||||||
setVaultInitialDecryptDone(true);
|
setVaultInitialDecryptDone(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
pushToast('error', error instanceof Error ? error.message : t('txt_decrypt_failed_2'));
|
const message = error instanceof Error ? error.message : t('txt_decrypt_failed_2');
|
||||||
|
setVaultDecryptError(message);
|
||||||
|
setVaultInitialDecryptDone(true);
|
||||||
|
pushToast('error', message);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -873,26 +1040,37 @@ export default function App() {
|
|||||||
}, [session?.symEncKey, session?.symMacKey, encryptedFolders, encryptedCiphers]);
|
}, [session?.symEncKey, session?.symMacKey, encryptedFolders, encryptedCiphers]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (IS_DEMO_MODE) return;
|
||||||
if (!session?.symEncKey || !session?.symMacKey) {
|
if (!session?.symEncKey || !session?.symMacKey) {
|
||||||
setDecryptedSends([]);
|
setDecryptedSends([]);
|
||||||
|
setSendsDecryptDone(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!encryptedSends) {
|
||||||
|
setSendsDecryptDone(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!encryptedSends.length) {
|
||||||
|
setDecryptedSends([]);
|
||||||
|
setSendsDecryptDone(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!sendsQuery.data) return;
|
|
||||||
|
|
||||||
let active = true;
|
let active = true;
|
||||||
|
setSendsDecryptDone(false);
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
let sends;
|
let sends;
|
||||||
try {
|
try {
|
||||||
sends = await decryptSendsInWorker({
|
sends = await decryptSendsInWorker({
|
||||||
sends: sendsQuery.data,
|
sends: encryptedSends,
|
||||||
symEncKeyB64: session.symEncKey!,
|
symEncKeyB64: session.symEncKey!,
|
||||||
symMacKeyB64: session.symMacKey!,
|
symMacKeyB64: session.symMacKey!,
|
||||||
origin: window.location.origin,
|
origin: window.location.origin,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
sends = await decryptSends({
|
sends = await decryptSends({
|
||||||
sends: sendsQuery.data,
|
sends: encryptedSends,
|
||||||
symEncKeyB64: session.symEncKey!,
|
symEncKeyB64: session.symEncKey!,
|
||||||
symMacKeyB64: session.symMacKey!,
|
symMacKeyB64: session.symMacKey!,
|
||||||
origin: window.location.origin,
|
origin: window.location.origin,
|
||||||
@@ -901,8 +1079,10 @@ export default function App() {
|
|||||||
|
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
setDecryptedSends(sends);
|
setDecryptedSends(sends);
|
||||||
|
setSendsDecryptDone(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
|
setSendsDecryptDone(true);
|
||||||
pushToast('error', error instanceof Error ? error.message : t('txt_decrypt_failed_2'));
|
pushToast('error', error instanceof Error ? error.message : t('txt_decrypt_failed_2'));
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@@ -910,18 +1090,14 @@ export default function App() {
|
|||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
}, [session?.symEncKey, session?.symMacKey, sendsQuery.data]);
|
}, [session?.symEncKey, session?.symMacKey, encryptedSends]);
|
||||||
|
|
||||||
async function refreshVaultSilently() {
|
async function refreshVaultSilently() {
|
||||||
if (pendingVaultCoreRefreshRef.current) {
|
if (pendingVaultCoreRefreshRef.current) {
|
||||||
await pendingVaultCoreRefreshRef.current;
|
await pendingVaultCoreRefreshRef.current;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const tasks: Promise<unknown>[] = [refetchVaultCoreData()];
|
const request = refetchVaultCoreData().finally(() => {
|
||||||
if (location === '/sends') {
|
|
||||||
tasks.push(sendsQuery.refetch());
|
|
||||||
}
|
|
||||||
const request = Promise.all(tasks).finally(() => {
|
|
||||||
if (pendingVaultCoreRefreshRef.current === request) {
|
if (pendingVaultCoreRefreshRef.current === request) {
|
||||||
pendingVaultCoreRefreshRef.current = null;
|
pendingVaultCoreRefreshRef.current = null;
|
||||||
}
|
}
|
||||||
@@ -933,6 +1109,7 @@ export default function App() {
|
|||||||
silentRefreshVaultRef.current = refreshVaultSilently;
|
silentRefreshVaultRef.current = refreshVaultSilently;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (IS_DEMO_MODE) return;
|
||||||
if (phase !== 'app' || !session?.accessToken || !session?.symEncKey || !session?.symMacKey || !vaultInitialDecryptDone) return;
|
if (phase !== 'app' || !session?.accessToken || !session?.symEncKey || !session?.symMacKey || !vaultInitialDecryptDone) return;
|
||||||
|
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
@@ -1087,7 +1264,7 @@ export default function App() {
|
|||||||
const result = await refetchVaultCoreData() as { data?: VaultCoreSnapshot };
|
const result = await refetchVaultCoreData() as { data?: VaultCoreSnapshot };
|
||||||
return { data: result.data?.folders };
|
return { data: result.data?.folders };
|
||||||
},
|
},
|
||||||
refetchSends: sendsQuery.refetch,
|
refetchSends: refetchSendsFromVaultCore,
|
||||||
onNotify: pushToast,
|
onNotify: pushToast,
|
||||||
patchDecryptedCiphers: setDecryptedCiphers,
|
patchDecryptedCiphers: setDecryptedCiphers,
|
||||||
patchDecryptedFolders: setDecryptedFolders,
|
patchDecryptedFolders: setDecryptedFolders,
|
||||||
@@ -1127,11 +1304,17 @@ export default function App() {
|
|||||||
const trimmedHashPath = hashPathOnly.replace(/^\/+/, '').replace(/\/+$/, '');
|
const trimmedHashPath = hashPathOnly.replace(/^\/+/, '').replace(/\/+$/, '');
|
||||||
const normalizedHashPath = trimmedHashPath ? `/${trimmedHashPath}` : '/';
|
const normalizedHashPath = trimmedHashPath ? `/${trimmedHashPath}` : '/';
|
||||||
const isImportHashRoute = IMPORT_ROUTE_ALIASES.has(normalizedHashPath);
|
const isImportHashRoute = IMPORT_ROUTE_ALIASES.has(normalizedHashPath);
|
||||||
const effectiveLocation = hashPath.startsWith('/send/') || hashPath === '/recover-2fa' ? hashPath : location;
|
const normalizedLocation = normalizeRoutePath(location);
|
||||||
|
const routeLocation = hashPath.startsWith('/') ? normalizedHashPath : normalizedLocation;
|
||||||
|
const effectiveLocation = routeLocation;
|
||||||
const publicSendMatch = effectiveLocation.match(/^\/send\/([^/]+)(?:\/([^/]+))?\/?$/i);
|
const publicSendMatch = effectiveLocation.match(/^\/send\/([^/]+)(?:\/([^/]+))?\/?$/i);
|
||||||
const isRecoverTwoFactorRoute = effectiveLocation === '/recover-2fa';
|
const isRecoverTwoFactorRoute = effectiveLocation === '/recover-2fa';
|
||||||
const isPublicSendRoute = !!publicSendMatch;
|
const isPublicSendRoute = !!publicSendMatch;
|
||||||
const isImportRoute = location === IMPORT_ROUTE || IMPORT_ROUTE_ALIASES.has(location);
|
const isMalformedSendRoute = /^\/send(?:\/|$)/i.test(effectiveLocation) && !publicSendMatch;
|
||||||
|
const isKnownAuthRoute = AUTH_ROUTES.has(routeLocation) || isPublicSendRoute || isRecoverTwoFactorRoute;
|
||||||
|
const isKnownAppRoute = APP_ROUTES.has(routeLocation) || isPublicSendRoute || isImportHashRoute;
|
||||||
|
const isUnknownRoute = isMalformedSendRoute || (phase === 'app' ? !isKnownAppRoute : !isKnownAuthRoute && !APP_ROUTES.has(routeLocation));
|
||||||
|
const isImportRoute = routeLocation === IMPORT_ROUTE || IMPORT_ROUTE_ALIASES.has(routeLocation);
|
||||||
const showSidebarToggle = mobileLayout && (location === '/vault' || location === '/sends');
|
const showSidebarToggle = mobileLayout && (location === '/vault' || location === '/sends');
|
||||||
const sidebarToggleTitle = location === '/vault' ? t('txt_folders') : t('txt_type');
|
const sidebarToggleTitle = location === '/vault' ? t('txt_folders') : t('txt_type');
|
||||||
const mobilePrimaryRoute =
|
const mobilePrimaryRoute =
|
||||||
@@ -1178,6 +1361,7 @@ export default function App() {
|
|||||||
|
|
||||||
const mainRoutesProps = {
|
const mainRoutesProps = {
|
||||||
profile,
|
profile,
|
||||||
|
profileLoading: profileQuery.isFetching && !profile,
|
||||||
session,
|
session,
|
||||||
mobileLayout,
|
mobileLayout,
|
||||||
mobileSidebarToggleKey,
|
mobileSidebarToggleKey,
|
||||||
@@ -1187,16 +1371,20 @@ export default function App() {
|
|||||||
decryptedCiphers,
|
decryptedCiphers,
|
||||||
decryptedFolders,
|
decryptedFolders,
|
||||||
decryptedSends,
|
decryptedSends,
|
||||||
ciphersLoading: vaultCoreQuery.isFetching && !encryptedVaultCore,
|
vaultError: vaultCoreQuery.isError && !encryptedVaultCore ? t('txt_load_vault_failed') : vaultDecryptError,
|
||||||
foldersLoading: vaultCoreQuery.isFetching && !encryptedVaultCore,
|
ciphersLoading: !(vaultCoreQuery.isError && !encryptedVaultCore) && !vaultDecryptError && !vaultInitialDecryptDone,
|
||||||
sendsLoading: sendsQuery.isFetching && !sendsQuery.data,
|
foldersLoading: !(vaultCoreQuery.isError && !encryptedVaultCore) && !vaultDecryptError && !vaultInitialDecryptDone,
|
||||||
|
sendsLoading: (sendsQuery.isFetching && !encryptedSends) || (!!encryptedSends && !sendsDecryptDone),
|
||||||
users: usersQuery.data || [],
|
users: usersQuery.data || [],
|
||||||
invites: invitesQuery.data || [],
|
invites: invitesQuery.data || [],
|
||||||
|
adminLoading: (usersQuery.isFetching && !usersQuery.data) || (invitesQuery.isFetching && !invitesQuery.data),
|
||||||
|
adminError: usersQuery.isError || invitesQuery.isError ? t('txt_load_admin_data_failed') : '',
|
||||||
totpEnabled: !!totpStatusQuery.data?.enabled,
|
totpEnabled: !!totpStatusQuery.data?.enabled,
|
||||||
lockTimeoutMinutes,
|
lockTimeoutMinutes,
|
||||||
sessionTimeoutAction,
|
sessionTimeoutAction,
|
||||||
authorizedDevices: authorizedDevicesQuery.data || [],
|
authorizedDevices: authorizedDevicesQuery.data || [],
|
||||||
authorizedDevicesLoading: authorizedDevicesQuery.isFetching,
|
authorizedDevicesLoading: authorizedDevicesQuery.isFetching,
|
||||||
|
authorizedDevicesError: authorizedDevicesQuery.isError && !authorizedDevicesQuery.data ? t('txt_load_devices_failed') : '',
|
||||||
onNavigate: navigate,
|
onNavigate: navigate,
|
||||||
onLogout: handleLogout,
|
onLogout: handleLogout,
|
||||||
onNotify: pushToast,
|
onNotify: pushToast,
|
||||||
@@ -1258,7 +1446,11 @@ export default function App() {
|
|||||||
onExportBackup: backupActions.exportBackup,
|
onExportBackup: backupActions.exportBackup,
|
||||||
onImportBackup: backupActions.importBackup,
|
onImportBackup: backupActions.importBackup,
|
||||||
onImportBackupAllowingChecksumMismatch: backupActions.importBackupAllowingChecksumMismatch,
|
onImportBackupAllowingChecksumMismatch: backupActions.importBackupAllowingChecksumMismatch,
|
||||||
onLoadBackupSettings: backupActions.loadSettings,
|
onLoadBackupSettings: () => queryClient.ensureQueryData({
|
||||||
|
queryKey: ['admin-backup-settings', vaultCacheKey],
|
||||||
|
queryFn: () => backupActions.loadSettings(),
|
||||||
|
staleTime: 30_000,
|
||||||
|
}),
|
||||||
onSaveBackupSettings: backupActions.saveSettings,
|
onSaveBackupSettings: backupActions.saveSettings,
|
||||||
onRunRemoteBackup: backupActions.runRemoteBackup,
|
onRunRemoteBackup: backupActions.runRemoteBackup,
|
||||||
onListRemoteBackups: backupActions.listRemoteBackups,
|
onListRemoteBackups: backupActions.listRemoteBackups,
|
||||||
@@ -1268,6 +1460,24 @@ export default function App() {
|
|||||||
onRestoreRemoteBackup: backupActions.restoreRemoteBackup,
|
onRestoreRemoteBackup: backupActions.restoreRemoteBackup,
|
||||||
onRestoreRemoteBackupAllowingChecksumMismatch: backupActions.restoreRemoteBackupAllowingChecksumMismatch,
|
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) {
|
if (jwtWarning) {
|
||||||
return <JwtWarningPage reason={jwtWarning.reason} minLength={jwtWarning.minLength} />;
|
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') {
|
if (isRecoverTwoFactorRoute && phase !== 'app') {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -1305,6 +1524,9 @@ export default function App() {
|
|||||||
<AuthViews
|
<AuthViews
|
||||||
mode={phase}
|
mode={phase}
|
||||||
pendingAction={pendingAuthAction}
|
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}
|
unlockReady={!!session?.email}
|
||||||
unlockPreparing={unlockPreparing}
|
unlockPreparing={unlockPreparing}
|
||||||
loginValues={loginValues}
|
loginValues={loginValues}
|
||||||
@@ -1323,6 +1545,10 @@ export default function App() {
|
|||||||
navigate('/login');
|
navigate('/login');
|
||||||
}}
|
}}
|
||||||
onGotoRegister={() => {
|
onGotoRegister={() => {
|
||||||
|
if (IS_DEMO_MODE) {
|
||||||
|
pushToast('warning', t('txt_demo_readonly_message'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (inviteCodeFromUrl) {
|
if (inviteCodeFromUrl) {
|
||||||
setRegisterValues((prev) => ({ ...prev, inviteCode: inviteCodeFromUrl }));
|
setRegisterValues((prev) => ({ ...prev, inviteCode: inviteCodeFromUrl }));
|
||||||
}
|
}
|
||||||
@@ -1389,7 +1615,7 @@ export default function App() {
|
|||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
onToggleTheme={handleToggleTheme}
|
onToggleTheme={handleToggleTheme}
|
||||||
onToggleMobileSidebar={() => setMobileSidebarToggleKey((key) => key + 1)}
|
onToggleMobileSidebar={() => setMobileSidebarToggleKey((key) => key + 1)}
|
||||||
mainRoutesProps={mainRoutesProps}
|
mainRoutesProps={effectiveMainRoutesProps}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AppGlobalOverlays
|
<AppGlobalOverlays
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'preact/hooks';
|
import { useState } from 'preact/hooks';
|
||||||
import { ChevronLeft, ChevronRight, Clipboard, Plus, RefreshCw, Trash2, UserCheck, UserX } from 'lucide-preact';
|
import { ChevronLeft, ChevronRight, Clipboard, Plus, RefreshCw, Trash2, UserCheck, UserX } from 'lucide-preact';
|
||||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
|
import LoadingState from '@/components/LoadingState';
|
||||||
import type { AdminInvite, AdminUser } from '@/lib/types';
|
import type { AdminInvite, AdminUser } from '@/lib/types';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
|
|
||||||
@@ -8,6 +9,8 @@ interface AdminPageProps {
|
|||||||
currentUserId: string;
|
currentUserId: string;
|
||||||
users: AdminUser[];
|
users: AdminUser[];
|
||||||
invites: AdminInvite[];
|
invites: AdminInvite[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
onCreateInvite: (hours: number) => Promise<void>;
|
onCreateInvite: (hours: number) => Promise<void>;
|
||||||
onDeleteAllInvites: () => Promise<void>;
|
onDeleteAllInvites: () => Promise<void>;
|
||||||
@@ -48,8 +51,22 @@ export default function AdminPage(props: AdminPageProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="stack">
|
<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">
|
<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">
|
<table className="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -94,6 +111,20 @@ export default function AdminPage(props: AdminPageProps) {
|
|||||||
</tr>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
@@ -101,7 +132,7 @@ export default function AdminPage(props: AdminPageProps) {
|
|||||||
<section className="card">
|
<section className="card">
|
||||||
<div className="section-head">
|
<div className="section-head">
|
||||||
<h3>{t('txt_invites')}</h3>
|
<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')}
|
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -160,6 +191,20 @@ export default function AdminPage(props: AdminPageProps) {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useEffect } from 'preact/hooks';
|
|||||||
import { Link, Route, Switch } from 'wouter';
|
import { Link, Route, Switch } from 'wouter';
|
||||||
import { ArrowUpDown, Cloud, LogOut, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
|
import { ArrowUpDown, Cloud, LogOut, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
|
||||||
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
|
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
|
||||||
|
import LoadingState from '@/components/LoadingState';
|
||||||
import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup';
|
import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup';
|
||||||
import type { CiphersImportPayload } from '@/lib/api/vault';
|
import type { CiphersImportPayload } from '@/lib/api/vault';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
@@ -19,7 +20,7 @@ const BackupCenterPage = lazy(() => import('@/components/BackupCenterPage'));
|
|||||||
const ImportPage = lazy(() => import('@/components/ImportPage'));
|
const ImportPage = lazy(() => import('@/components/ImportPage'));
|
||||||
|
|
||||||
function RouteContentFallback() {
|
function RouteContentFallback() {
|
||||||
return <div className="loading-screen">{t('txt_loading_nodewarden')}</div>;
|
return <LoadingState card lines={5} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function LegacyBackupRedirect(props: { onNavigate: (path: string) => void }) {
|
function LegacyBackupRedirect(props: { onNavigate: (path: string) => void }) {
|
||||||
@@ -31,6 +32,7 @@ function LegacyBackupRedirect(props: { onNavigate: (path: string) => void }) {
|
|||||||
|
|
||||||
export interface AppMainRoutesProps {
|
export interface AppMainRoutesProps {
|
||||||
profile: Profile | null;
|
profile: Profile | null;
|
||||||
|
profileLoading: boolean;
|
||||||
session: SessionState | null;
|
session: SessionState | null;
|
||||||
mobileLayout: boolean;
|
mobileLayout: boolean;
|
||||||
mobileSidebarToggleKey: number;
|
mobileSidebarToggleKey: number;
|
||||||
@@ -40,16 +42,20 @@ export interface AppMainRoutesProps {
|
|||||||
decryptedCiphers: Cipher[];
|
decryptedCiphers: Cipher[];
|
||||||
decryptedFolders: VaultFolder[];
|
decryptedFolders: VaultFolder[];
|
||||||
decryptedSends: Send[];
|
decryptedSends: Send[];
|
||||||
|
vaultError: string;
|
||||||
ciphersLoading: boolean;
|
ciphersLoading: boolean;
|
||||||
foldersLoading: boolean;
|
foldersLoading: boolean;
|
||||||
sendsLoading: boolean;
|
sendsLoading: boolean;
|
||||||
users: AdminUser[];
|
users: AdminUser[];
|
||||||
invites: AdminInvite[];
|
invites: AdminInvite[];
|
||||||
|
adminLoading: boolean;
|
||||||
|
adminError: string;
|
||||||
totpEnabled: boolean;
|
totpEnabled: boolean;
|
||||||
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
|
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
|
||||||
sessionTimeoutAction: 'lock' | 'logout';
|
sessionTimeoutAction: 'lock' | 'logout';
|
||||||
authorizedDevices: AuthorizedDevice[];
|
authorizedDevices: AuthorizedDevice[];
|
||||||
authorizedDevicesLoading: boolean;
|
authorizedDevicesLoading: boolean;
|
||||||
|
authorizedDevicesError: string;
|
||||||
onNavigate: (path: string) => void;
|
onNavigate: (path: string) => void;
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
|
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
|
||||||
@@ -187,6 +193,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
ciphers={props.decryptedCiphers}
|
ciphers={props.decryptedCiphers}
|
||||||
folders={props.decryptedFolders}
|
folders={props.decryptedFolders}
|
||||||
loading={props.ciphersLoading || props.foldersLoading}
|
loading={props.ciphersLoading || props.foldersLoading}
|
||||||
|
error={props.vaultError}
|
||||||
emailForReprompt={props.profile?.email || props.session?.email || ''}
|
emailForReprompt={props.profile?.email || props.session?.email || ''}
|
||||||
onRefresh={props.onRefreshVault}
|
onRefresh={props.onRefreshVault}
|
||||||
onCreate={props.onCreateVaultItem}
|
onCreate={props.onCreateVaultItem}
|
||||||
@@ -216,7 +223,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path={props.settingsAccountRoute}>
|
<Route path={props.settingsAccountRoute}>
|
||||||
{props.profile && (
|
{props.profile ? (
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
{props.mobileLayout && (
|
{props.mobileLayout && (
|
||||||
<div className="mobile-settings-subhead">
|
<div className="mobile-settings-subhead">
|
||||||
@@ -245,10 +252,12 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : props.profileLoading ? (
|
||||||
|
<LoadingState card lines={5} />
|
||||||
|
) : null}
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/settings">
|
<Route path="/settings">
|
||||||
{props.profile && (
|
{props.profile ? (
|
||||||
<section className="card mobile-settings-card">
|
<section className="card mobile-settings-card">
|
||||||
<div className="mobile-settings-links">
|
<div className="mobile-settings-links">
|
||||||
<Link href={props.settingsAccountRoute} className="mobile-settings-link">
|
<Link href={props.settingsAccountRoute} className="mobile-settings-link">
|
||||||
@@ -281,7 +290,9 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
{t('txt_sign_out')}
|
{t('txt_sign_out')}
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
)}
|
) : props.profileLoading ? (
|
||||||
|
<LoadingState card lines={4} />
|
||||||
|
) : null}
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/security/devices">
|
<Route path="/security/devices">
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
@@ -297,6 +308,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
<SecurityDevicesPage
|
<SecurityDevicesPage
|
||||||
devices={props.authorizedDevices}
|
devices={props.authorizedDevices}
|
||||||
loading={props.authorizedDevicesLoading}
|
loading={props.authorizedDevicesLoading}
|
||||||
|
error={props.authorizedDevicesError}
|
||||||
onRefresh={() => void props.onRefreshAuthorizedDevices()}
|
onRefresh={() => void props.onRefreshAuthorizedDevices()}
|
||||||
onRenameDevice={props.onRenameAuthorizedDevice}
|
onRenameDevice={props.onRenameAuthorizedDevice}
|
||||||
onRevokeTrust={props.onRevokeDeviceTrust}
|
onRevokeTrust={props.onRevokeDeviceTrust}
|
||||||
@@ -322,6 +334,8 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
currentUserId={props.profile?.id || ''}
|
currentUserId={props.profile?.id || ''}
|
||||||
users={props.users}
|
users={props.users}
|
||||||
invites={props.invites}
|
invites={props.invites}
|
||||||
|
loading={props.adminLoading}
|
||||||
|
error={props.adminError}
|
||||||
onRefresh={props.onRefreshAdmin}
|
onRefresh={props.onRefreshAdmin}
|
||||||
onCreateInvite={props.onCreateInvite}
|
onCreateInvite={props.onCreateInvite}
|
||||||
onDeleteAllInvites={props.onDeleteAllInvites}
|
onDeleteAllInvites={props.onDeleteAllInvites}
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ interface RegisterValues {
|
|||||||
|
|
||||||
interface AuthViewsProps {
|
interface AuthViewsProps {
|
||||||
mode: 'login' | 'register' | 'locked';
|
mode: 'login' | 'register' | 'locked';
|
||||||
|
relaxedLoginInput?: boolean;
|
||||||
|
authPlaceholder?: string;
|
||||||
|
unlockPlaceholder?: string;
|
||||||
pendingAction: 'login' | 'register' | 'unlock' | null;
|
pendingAction: 'login' | 'register' | 'unlock' | null;
|
||||||
unlockReady: boolean;
|
unlockReady: boolean;
|
||||||
unlockPreparing: boolean;
|
unlockPreparing: boolean;
|
||||||
@@ -46,6 +49,7 @@ function PasswordField(props: {
|
|||||||
onInput: (v: string) => void;
|
onInput: (v: string) => void;
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
autoComplete?: string;
|
autoComplete?: string;
|
||||||
|
placeholder?: string;
|
||||||
}) {
|
}) {
|
||||||
const [show, setShow] = useState(false);
|
const [show, setShow] = useState(false);
|
||||||
return (
|
return (
|
||||||
@@ -59,6 +63,7 @@ function PasswordField(props: {
|
|||||||
onInput={(e) => props.onInput((e.currentTarget as HTMLInputElement).value)}
|
onInput={(e) => props.onInput((e.currentTarget as HTMLInputElement).value)}
|
||||||
autoFocus={props.autoFocus}
|
autoFocus={props.autoFocus}
|
||||||
autoComplete={props.autoComplete}
|
autoComplete={props.autoComplete}
|
||||||
|
placeholder={props.placeholder}
|
||||||
/>
|
/>
|
||||||
<button type="button" className="eye-btn" onClick={() => setShow((v) => !v)}>
|
<button type="button" className="eye-btn" onClick={() => setShow((v) => !v)}>
|
||||||
{show ? <EyeOff size={16} /> : <Eye size={16} />}
|
{show ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
@@ -90,6 +95,7 @@ export default function AuthViews(props: AuthViewsProps) {
|
|||||||
value={props.unlockPassword}
|
value={props.unlockPassword}
|
||||||
autoFocus
|
autoFocus
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
|
placeholder={props.unlockPlaceholder}
|
||||||
onInput={props.onChangeUnlock}
|
onInput={props.onChangeUnlock}
|
||||||
/>
|
/>
|
||||||
<div className="auth-support-row">
|
<div className="auth-support-row">
|
||||||
@@ -217,9 +223,10 @@ export default function AuthViews(props: AuthViewsProps) {
|
|||||||
<span>{t('txt_email')}</span>
|
<span>{t('txt_email')}</span>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
type="email"
|
type={props.relaxedLoginInput ? 'text' : 'email'}
|
||||||
value={props.loginValues.email}
|
value={props.loginValues.email}
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
|
placeholder={props.authPlaceholder}
|
||||||
onInput={(e) => props.onChangeLogin({ ...props.loginValues, email: (e.currentTarget as HTMLInputElement).value })}
|
onInput={(e) => props.onChangeLogin({ ...props.loginValues, email: (e.currentTarget as HTMLInputElement).value })}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -227,6 +234,7 @@ export default function AuthViews(props: AuthViewsProps) {
|
|||||||
label={t('txt_master_password')}
|
label={t('txt_master_password')}
|
||||||
value={props.loginValues.password}
|
value={props.loginValues.password}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
|
placeholder={props.authPlaceholder}
|
||||||
onInput={(v) => props.onChangeLogin({ ...props.loginValues, password: v })}
|
onInput={(v) => props.onChangeLogin({ ...props.loginValues, password: v })}
|
||||||
autoFocus
|
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 { 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 { accessPublicSend, accessPublicSendFile, decryptPublicSend, decryptPublicSendFileBytes } from '@/lib/api/send';
|
||||||
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
import { toBufferSource } from '@/lib/crypto';
|
import { toBufferSource } from '@/lib/crypto';
|
||||||
import { downloadBytesAsFile, readResponseBytesWithProgress } from '@/lib/download';
|
import { downloadBytesAsFile, readResponseBytesWithProgress } from '@/lib/download';
|
||||||
|
import NotFoundPage from '@/components/NotFoundPage';
|
||||||
import StandalonePageFrame from '@/components/StandalonePageFrame';
|
import StandalonePageFrame from '@/components/StandalonePageFrame';
|
||||||
|
import { getDemoPublicSend, IS_DEMO_MODE } from '@/lib/demo';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
|
|
||||||
interface PublicSendPageProps {
|
interface PublicSendPageProps {
|
||||||
@@ -27,6 +30,25 @@ interface PublicSendData {
|
|||||||
file?: PublicSendFileData | null;
|
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 {
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
return value && typeof value === 'object' ? value as 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) {
|
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 [password, setPassword] = useState('');
|
||||||
const [needPassword, setNeedPassword] = useState(false);
|
const [needPassword, setNeedPassword] = useState(false);
|
||||||
const [error, setError] = useState('');
|
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 [busy, setBusy] = useState(false);
|
||||||
const [downloadPercent, setDownloadPercent] = useState<number | null>(null);
|
const [downloadPercent, setDownloadPercent] = useState<number | null>(null);
|
||||||
const loadRequestRef = useRef(0);
|
const loadRequestRef = useRef(0);
|
||||||
@@ -83,8 +107,25 @@ export default function PublicSendPage(props: PublicSendPageProps) {
|
|||||||
loadAbortRef.current = controller;
|
loadAbortRef.current = controller;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError('');
|
setError('');
|
||||||
|
setNotFound(false);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
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 });
|
const data = await accessPublicSend(props.accessId, props.keyPart, pass, { signal: controller.signal });
|
||||||
if (controller.signal.aborted || requestId !== loadRequestRef.current) return;
|
if (controller.signal.aborted || requestId !== loadRequestRef.current) return;
|
||||||
if (!props.keyPart) {
|
if (!props.keyPart) {
|
||||||
@@ -104,6 +145,10 @@ export default function PublicSendPage(props: PublicSendPageProps) {
|
|||||||
if (err.status === 401) {
|
if (err.status === 401) {
|
||||||
setNeedPassword(true);
|
setNeedPassword(true);
|
||||||
setError(t('txt_this_send_is_password_protected'));
|
setError(t('txt_this_send_is_password_protected'));
|
||||||
|
} else if (err.status === 404) {
|
||||||
|
setNeedPassword(false);
|
||||||
|
setNotFound(true);
|
||||||
|
setError('');
|
||||||
} else {
|
} else {
|
||||||
setError(err.message || t('txt_failed_to_open_send'));
|
setError(err.message || t('txt_failed_to_open_send'));
|
||||||
}
|
}
|
||||||
@@ -121,6 +166,11 @@ export default function PublicSendPage(props: PublicSendPageProps) {
|
|||||||
setDownloadPercent(null);
|
setDownloadPercent(null);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
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 url = await accessPublicSendFile(sendData.id, sendData.file.id, props.keyPart, password || undefined);
|
||||||
const resp = await fetch(url);
|
const resp = await fetch(url);
|
||||||
if (!resp.ok) throw new Error(t('txt_download_failed'));
|
if (!resp.ok) throw new Error(t('txt_download_failed'));
|
||||||
@@ -152,15 +202,31 @@ export default function PublicSendPage(props: PublicSendPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (IS_DEMO_MODE) {
|
||||||
|
const demoSend = getDemoPublicSend(props.accessId);
|
||||||
|
setSendData(demoSend);
|
||||||
|
setNotFound(!demoSend);
|
||||||
|
setNeedPassword(false);
|
||||||
|
setError('');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
void loadSend();
|
void loadSend();
|
||||||
return () => {
|
return () => {
|
||||||
loadAbortRef.current?.abort();
|
loadAbortRef.current?.abort();
|
||||||
};
|
};
|
||||||
}, [props.accessId, props.keyPart]);
|
}, [props.accessId, props.keyPart]);
|
||||||
|
|
||||||
|
if (!loading && notFound) {
|
||||||
|
return <NotFoundPage title={t('txt_page_not_found')} message={t('txt_send_unavailable')} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="auth-page public-send-page">
|
<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 && <p className="muted">{t('txt_loading')}</p>}
|
||||||
|
|
||||||
{!loading && needPassword && (
|
{!loading && needPassword && (
|
||||||
@@ -190,9 +256,20 @@ export default function PublicSendPage(props: PublicSendPageProps) {
|
|||||||
|
|
||||||
{!loading && sendData && (
|
{!loading && sendData && (
|
||||||
<>
|
<>
|
||||||
<h2 className="public-send-title">{sendData.decName || t('txt_no_name')}</h2>
|
|
||||||
{sendData.type === 0 ? (
|
{sendData.type === 0 ? (
|
||||||
<div className="card public-send-card">
|
<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 className="notes">{sendData.decText || ''}</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { useState } from 'preact/hooks';
|
import { useState } from 'preact/hooks';
|
||||||
import { Clock3, Pencil, RefreshCw, ShieldOff, Trash2 } from 'lucide-preact';
|
import { Clock3, Pencil, RefreshCw, ShieldOff, Trash2 } from 'lucide-preact';
|
||||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||||
|
import LoadingState from '@/components/LoadingState';
|
||||||
import type { AuthorizedDevice } from '@/lib/types';
|
import type { AuthorizedDevice } from '@/lib/types';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
|
|
||||||
interface SecurityDevicesPageProps {
|
interface SecurityDevicesPageProps {
|
||||||
devices: AuthorizedDevice[];
|
devices: AuthorizedDevice[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
error: string;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
onRenameDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
|
onRenameDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
|
||||||
onRevokeTrust: (device: AuthorizedDevice) => void;
|
onRevokeTrust: (device: AuthorizedDevice) => void;
|
||||||
@@ -72,7 +74,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="actions">
|
<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" />
|
<RefreshCw size={14} className="btn-icon" />
|
||||||
{t('txt_refresh')}
|
{t('txt_refresh')}
|
||||||
</button>
|
</button>
|
||||||
@@ -90,6 +92,15 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
|||||||
|
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<h3 className="section-title-flush">{t('txt_authorized_devices')}</h3>
|
<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">
|
<table className="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -166,6 +177,13 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
{props.loading && props.devices.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7}>
|
||||||
|
<LoadingState lines={5} compact />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
{!props.loading && props.devices.length === 0 && (
|
{!props.loading && props.devices.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7}>
|
<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 {
|
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') });
|
void copyTextToClipboard(url, { successMessage: t('txt_link_copied') });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { APP_VERSION } from '@shared/app-version';
|
|||||||
|
|
||||||
interface StandalonePageFrameProps {
|
interface StandalonePageFrameProps {
|
||||||
title: string;
|
title: string;
|
||||||
|
eyebrow?: ComponentChildren;
|
||||||
children: ComponentChildren;
|
children: ComponentChildren;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ export default function StandalonePageFrame(props: StandalonePageFrameProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="auth-card">
|
<div className="auth-card">
|
||||||
|
{props.eyebrow && <div className="standalone-eyebrow">{props.eyebrow}</div>}
|
||||||
<h1 className="standalone-title">{props.title}</h1>
|
<h1 className="standalone-title">{props.title}</h1>
|
||||||
{props.children}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
@@ -26,7 +28,14 @@ export default function StandalonePageFrame(props: StandalonePageFrameProps) {
|
|||||||
<span> | </span>
|
<span> | </span>
|
||||||
<a href="https://github.com/shuaiplus" target="_blank" rel="noreferrer">Author: @shuaiplus</a>
|
<a href="https://github.com/shuaiplus" target="_blank" rel="noreferrer">Author: @shuaiplus</a>
|
||||||
<span> | </span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ interface VaultPageProps {
|
|||||||
ciphers: Cipher[];
|
ciphers: Cipher[];
|
||||||
folders: Folder[];
|
folders: Folder[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
error: string;
|
||||||
emailForReprompt: string;
|
emailForReprompt: string;
|
||||||
onRefresh: () => Promise<void>;
|
onRefresh: () => Promise<void>;
|
||||||
onCreate: (draft: VaultDraft, attachments?: File[]) => Promise<void>;
|
onCreate: (draft: VaultDraft, attachments?: File[]) => Promise<void>;
|
||||||
@@ -1021,6 +1022,7 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
|||||||
<VaultListPanel
|
<VaultListPanel
|
||||||
busy={busy}
|
busy={busy}
|
||||||
loading={props.loading}
|
loading={props.loading}
|
||||||
|
error={props.error}
|
||||||
searchInput={searchInput}
|
searchInput={searchInput}
|
||||||
sortMode={sortMode}
|
sortMode={sortMode}
|
||||||
sortMenuOpen={sortMenuOpen}
|
sortMenuOpen={sortMenuOpen}
|
||||||
@@ -1140,7 +1142,20 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
|||||||
</div>
|
</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>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { createPortal } from 'preact/compat';
|
import { createPortal } from 'preact/compat';
|
||||||
import { useMemo, useState } from 'preact/hooks';
|
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 { useDialogLifecycle } from '@/components/ConfirmDialog';
|
||||||
import type { Cipher } from '@/lib/types';
|
import type { Cipher } from '@/lib/types';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import {
|
import {
|
||||||
TOTP_PERIOD_SECONDS,
|
TOTP_PERIOD_SECONDS,
|
||||||
TOTP_RING_CIRCUMFERENCE,
|
TOTP_RING_CIRCUMFERENCE,
|
||||||
|
VaultListIcon,
|
||||||
copyToClipboard,
|
copyToClipboard,
|
||||||
formatAttachmentSize,
|
formatAttachmentSize,
|
||||||
formatHistoryTime,
|
formatHistoryTime,
|
||||||
@@ -115,8 +116,18 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
|||||||
{(Number(props.selectedCipher.reprompt || 0) !== 1 || props.repromptApprovedCipherId === props.selectedCipher.id) && (
|
{(Number(props.selectedCipher.reprompt || 0) !== 1 || props.repromptApprovedCipherId === props.selectedCipher.id) && (
|
||||||
<>
|
<>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3 className="detail-title">{props.selectedCipher.decName || t('txt_no_name')}</h3>
|
<div className="detail-title-row">
|
||||||
<div className="detail-sub">{props.folderName(props.selectedCipher.folderId)}</div>
|
<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>}
|
{isArchived && <div className="list-badge archive-badge">{t('txt_archived')}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { JSX, RefObject } from 'preact';
|
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 { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
|
import { useDialogLifecycle } from '@/components/ConfirmDialog';
|
||||||
import {
|
import {
|
||||||
closestCenter,
|
closestCenter,
|
||||||
DndContext,
|
DndContext,
|
||||||
@@ -137,8 +139,16 @@ function SortableWebsiteRow(props: SortableWebsiteRowProps) {
|
|||||||
export default function VaultEditor(props: VaultEditorProps) {
|
export default function VaultEditor(props: VaultEditorProps) {
|
||||||
const createTypeOptions = getCreateTypeOptions();
|
const createTypeOptions = getCreateTypeOptions();
|
||||||
const uriIdSeedRef = useRef(0);
|
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 [uriItemIds, setUriItemIds] = useState<string[]>([]);
|
||||||
const [activeUriId, setActiveUriId] = useState<string | null>(null);
|
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(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {
|
useSensor(PointerSensor, {
|
||||||
activationConstraint: {
|
activationConstraint: {
|
||||||
@@ -155,6 +165,63 @@ export default function VaultEditor(props: VaultEditorProps) {
|
|||||||
|
|
||||||
const createUriId = () => `login-uri-${uriIdSeedRef.current++}`;
|
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(() => {
|
useEffect(() => {
|
||||||
setUriItemIds((prev) => {
|
setUriItemIds((prev) => {
|
||||||
if (prev.length === props.draft.loginUris.length) return prev;
|
if (prev.length === props.draft.loginUris.length) return prev;
|
||||||
@@ -170,6 +237,77 @@ export default function VaultEditor(props: VaultEditorProps) {
|
|||||||
setActiveUriId(null);
|
setActiveUriId(null);
|
||||||
}, [props.draft.id, props.isCreating]);
|
}, [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 formatDownloadLabel = (attachmentId: string) => {
|
||||||
const downloadKey = `${props.selectedCipher?.id || ''}:${attachmentId}`;
|
const downloadKey = `${props.selectedCipher?.id || ''}:${attachmentId}`;
|
||||||
if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download');
|
if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download');
|
||||||
@@ -274,7 +412,22 @@ export default function VaultEditor(props: VaultEditorProps) {
|
|||||||
</div>
|
</div>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t('txt_totp_secret')}</span>
|
<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>
|
</label>
|
||||||
<div className="section-head">
|
<div className="section-head">
|
||||||
<h4>{t('txt_websites')}</h4>
|
<h4>{t('txt_websites')}</h4>
|
||||||
@@ -571,6 +724,52 @@ export default function VaultEditor(props: VaultEditorProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{props.localError && <div className="local-error">{props.localError}</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 {
|
interface VaultListPanelProps {
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
error: string;
|
||||||
searchInput: string;
|
searchInput: string;
|
||||||
sortMode: VaultSortMode;
|
sortMode: VaultSortMode;
|
||||||
sortMenuOpen: boolean;
|
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)}>
|
<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.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 && (
|
{!!props.filteredCiphers.length && (
|
||||||
<div style={{ paddingTop: `${props.virtualRange.padTop}px`, paddingBottom: `${props.virtualRange.padBottom}px` }}>
|
<div style={{ paddingTop: `${props.virtualRange.padTop}px`, paddingBottom: `${props.virtualRange.padBottom}px` }}>
|
||||||
{props.visibleCiphers.map((cipher) => (
|
{props.visibleCiphers.map((cipher) => (
|
||||||
@@ -253,7 +262,7 @@ export default function VaultListPanel(props: VaultListPanelProps) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,15 +3,16 @@ import type { ComponentChildren } from 'preact';
|
|||||||
import { Globe } from 'lucide-preact';
|
import { Globe } from 'lucide-preact';
|
||||||
import type { Cipher } from '@/lib/types';
|
import type { Cipher } from '@/lib/types';
|
||||||
import {
|
import {
|
||||||
|
getWebsiteIconImageUrl,
|
||||||
getWebsiteIconStatus,
|
getWebsiteIconStatus,
|
||||||
markWebsiteIconErrored,
|
|
||||||
markWebsiteIconLoaded,
|
|
||||||
preloadWebsiteIcon,
|
preloadWebsiteIcon,
|
||||||
subscribeWebsiteIconStatus,
|
subscribeWebsiteIconStatus,
|
||||||
} from '@/lib/website-icon-cache';
|
} from '@/lib/website-icon-cache';
|
||||||
|
import { demoBrandIconUrl } from '@/lib/demo-brand-icons';
|
||||||
import { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils';
|
import { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils';
|
||||||
|
|
||||||
const ICON_LOAD_ROOT_MARGIN = '180px 0px';
|
const ICON_LOAD_ROOT_MARGIN = '180px 0px';
|
||||||
|
const SHOULD_LOAD_DEMO_BRAND_ICONS = __NODEWARDEN_DEMO__;
|
||||||
|
|
||||||
interface WebsiteIconProps {
|
interface WebsiteIconProps {
|
||||||
cipher: Cipher;
|
cipher: Cipher;
|
||||||
@@ -24,17 +25,24 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
|
|||||||
const nodeRef = useRef<HTMLSpanElement | null>(null);
|
const nodeRef = useRef<HTMLSpanElement | null>(null);
|
||||||
const [shouldLoad, setShouldLoad] = useState(() => (host ? getWebsiteIconStatus(host) === 'loaded' : true));
|
const [shouldLoad, setShouldLoad] = useState(() => (host ? getWebsiteIconStatus(host) === 'loaded' : true));
|
||||||
const [status, setStatus] = useState(() => (host ? getWebsiteIconStatus(host) : 'idle'));
|
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(() => {
|
useEffect(() => {
|
||||||
if (!host) {
|
if (!host) {
|
||||||
setShouldLoad(true);
|
setShouldLoad(true);
|
||||||
setStatus('idle');
|
setStatus('idle');
|
||||||
|
setImageUrl('');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const nextStatus = getWebsiteIconStatus(host);
|
const nextStatus = getWebsiteIconStatus(host);
|
||||||
setShouldLoad(nextStatus === 'loaded');
|
setShouldLoad(nextStatus === 'loaded');
|
||||||
setStatus(nextStatus);
|
setStatus(nextStatus);
|
||||||
return subscribeWebsiteIconStatus(host, setStatus);
|
setImageUrl(getWebsiteIconImageUrl(host));
|
||||||
|
return subscribeWebsiteIconStatus(host, (next) => {
|
||||||
|
setStatus(next);
|
||||||
|
setImageUrl(getWebsiteIconImageUrl(host));
|
||||||
|
});
|
||||||
}, [host]);
|
}, [host]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -67,15 +75,33 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
|
|||||||
}, [host, shouldLoad, status]);
|
}, [host, shouldLoad, status]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (SHOULD_LOAD_DEMO_BRAND_ICONS) return;
|
||||||
|
if (demoIconUrl) return;
|
||||||
if (!host || !src || !shouldLoad || status === 'loaded' || status === 'error') return;
|
if (!host || !src || !shouldLoad || status === 'loaded' || status === 'error') return;
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
void preloadWebsiteIcon(host, src).then((nextStatus) => {
|
void preloadWebsiteIcon(host, src).then((nextStatus) => {
|
||||||
if (!disposed) setStatus(nextStatus);
|
if (disposed) return;
|
||||||
|
setStatus(nextStatus);
|
||||||
|
setImageUrl(getWebsiteIconImageUrl(host));
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
disposed = true;
|
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') {
|
if (!host || status === 'error') {
|
||||||
return <span className="list-icon-fallback">{props.fallback ?? <Globe size={18} />}</span>;
|
return <span className="list-icon-fallback">{props.fallback ?? <Globe size={18} />}</span>;
|
||||||
@@ -84,18 +110,16 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
|
|||||||
return (
|
return (
|
||||||
<span className="list-icon-stack" ref={nodeRef}>
|
<span className="list-icon-stack" ref={nodeRef}>
|
||||||
{status !== 'loaded' && <span className="list-icon-fallback">{props.fallback ?? <Globe size={18} />}</span>}
|
{status !== 'loaded' && <span className="list-icon-fallback">{props.fallback ?? <Globe size={18} />}</span>}
|
||||||
{status === 'loaded' && (
|
{status === 'loaded' && imageUrl && (
|
||||||
<img
|
<img
|
||||||
className="list-icon loaded"
|
className="list-icon loaded"
|
||||||
src={src}
|
src={imageUrl}
|
||||||
alt=""
|
alt=""
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
referrerPolicy="no-referrer"
|
|
||||||
onLoad={() => markWebsiteIconLoaded(host)}
|
|
||||||
onError={() => markWebsiteIconErrored(host)}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,26 +20,39 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
|
|||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
refreshAdmin() {
|
refreshAdmin() {
|
||||||
void refetchUsers();
|
void Promise.all([refetchUsers(), refetchInvites()]).catch((error) => {
|
||||||
void refetchInvites();
|
onNotify('error', error instanceof Error ? error.message : t('txt_load_admin_data_failed'));
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async createInvite(hours: number) {
|
async createInvite(hours: number) {
|
||||||
await createInvite(authedFetch, hours);
|
try {
|
||||||
await refetchInvites();
|
await createInvite(authedFetch, hours);
|
||||||
onNotify('success', t('txt_invite_created'));
|
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') {
|
async toggleUserStatus(userId: string, status: 'active' | 'banned') {
|
||||||
await setUserStatus(authedFetch, userId, status === 'active' ? 'banned' : 'active');
|
try {
|
||||||
await refetchUsers();
|
await setUserStatus(authedFetch, userId, status === 'active' ? 'banned' : 'active');
|
||||||
onNotify('success', t('txt_user_status_updated'));
|
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) {
|
async revokeInvite(code: string) {
|
||||||
await revokeInvite(authedFetch, code);
|
try {
|
||||||
await refetchInvites();
|
await revokeInvite(authedFetch, code);
|
||||||
onNotify('success', t('txt_invite_revoked'));
|
await refetchInvites();
|
||||||
|
onNotify('success', t('txt_invite_revoked'));
|
||||||
|
} catch (error) {
|
||||||
|
onNotify('error', error instanceof Error ? error.message : t('txt_revoke_invite_failed'));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async deleteAllInvites() {
|
async deleteAllInvites() {
|
||||||
@@ -50,9 +63,13 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
|
|||||||
onConfirm: () => {
|
onConfirm: () => {
|
||||||
onSetConfirm(null);
|
onSetConfirm(null);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
await deleteAllInvites(authedFetch);
|
try {
|
||||||
await refetchInvites();
|
await deleteAllInvites(authedFetch);
|
||||||
onNotify('success', t('txt_all_invites_deleted'));
|
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: () => {
|
onConfirm: () => {
|
||||||
onSetConfirm(null);
|
onSetConfirm(null);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
await deleteUser(authedFetch, userId);
|
try {
|
||||||
await refetchUsers();
|
await deleteUser(authedFetch, userId);
|
||||||
onNotify('success', t('txt_user_deleted'));
|
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;
|
type RefreshResult = RefreshFailure | RefreshSuccess;
|
||||||
|
|
||||||
|
const pendingRefreshes = new Map<string, Promise<RefreshResult>>();
|
||||||
|
|
||||||
function randomHex(length: number): string {
|
function randomHex(length: number): string {
|
||||||
const bytes = crypto.getRandomValues(new Uint8Array(Math.max(1, Math.ceil(length / 2))));
|
const bytes = crypto.getRandomValues(new Uint8Array(Math.max(1, Math.ceil(length / 2))));
|
||||||
return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, length);
|
return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, length);
|
||||||
@@ -312,6 +314,25 @@ export async function refreshAccessToken(session: SessionState): Promise<Refresh
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function refreshKey(session: SessionState): string {
|
||||||
|
if (session.authMode === 'web-cookie') return `web-cookie:${session.email || ''}`;
|
||||||
|
return `token:${session.refreshToken || ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshAccessTokenOnce(session: SessionState): Promise<RefreshResult> {
|
||||||
|
const key = refreshKey(session);
|
||||||
|
const existing = pendingRefreshes.get(key);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const request = refreshAccessToken(session).finally(() => {
|
||||||
|
if (pendingRefreshes.get(key) === request) {
|
||||||
|
pendingRefreshes.delete(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
pendingRefreshes.set(key, request);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
export async function revokeCurrentSession(session: SessionState | null): Promise<void> {
|
export async function revokeCurrentSession(session: SessionState | null): Promise<void> {
|
||||||
const body = new URLSearchParams();
|
const body = new URLSearchParams();
|
||||||
if (session?.authMode !== 'web-cookie' && session?.refreshToken) {
|
if (session?.authMode !== 'web-cookie' && session?.refreshToken) {
|
||||||
@@ -436,7 +457,16 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
|
|||||||
let resp = await retryableRequest(headers);
|
let resp = await retryableRequest(headers);
|
||||||
if (resp.status !== 401 || (!session.refreshToken && session.authMode !== 'web-cookie')) return resp;
|
if (resp.status !== 401 || (!session.refreshToken && session.authMode !== 'web-cookie')) return resp;
|
||||||
|
|
||||||
const refreshed = await refreshAccessToken(session);
|
const latest = getSession();
|
||||||
|
if (latest?.accessToken && latest.accessToken !== session.accessToken) {
|
||||||
|
const latestHeaders = new Headers(init.headers || {});
|
||||||
|
latestHeaders.set('Authorization', `Bearer ${latest.accessToken}`);
|
||||||
|
resp = await retryableRequest(latestHeaders);
|
||||||
|
if (resp.status !== 401) return resp;
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshSource = latest || session;
|
||||||
|
const refreshed = await refreshAccessTokenOnce(refreshSource);
|
||||||
if (!refreshed.ok) {
|
if (!refreshed.ok) {
|
||||||
if (refreshed.transient) {
|
if (refreshed.transient) {
|
||||||
throw new Error(refreshed.error || 'Session refresh temporarily unavailable');
|
throw new Error(refreshed.error || 'Session refresh temporarily unavailable');
|
||||||
@@ -446,10 +476,10 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
|
|||||||
}
|
}
|
||||||
|
|
||||||
const nextSession: SessionState = {
|
const nextSession: SessionState = {
|
||||||
...session,
|
...refreshSource,
|
||||||
accessToken: refreshed.token.access_token,
|
accessToken: refreshed.token.access_token,
|
||||||
refreshToken: refreshed.token.refresh_token || session.refreshToken,
|
refreshToken: refreshed.token.refresh_token || refreshSource.refreshToken,
|
||||||
authMode: refreshed.token.web_session ? 'web-cookie' : (session.authMode || 'token'),
|
authMode: refreshed.token.web_session ? 'web-cookie' : (refreshSource.authMode || 'token'),
|
||||||
};
|
};
|
||||||
setSession(nextSession);
|
setSession(nextSession);
|
||||||
saveSession(nextSession);
|
saveSession(nextSession);
|
||||||
|
|||||||
@@ -16,6 +16,15 @@ function normalizeSnapshot(body: VaultSyncResponse | null | undefined): VaultCor
|
|||||||
return {
|
return {
|
||||||
ciphers: Array.isArray(body?.ciphers) ? body!.ciphers! : [],
|
ciphers: Array.isArray(body?.ciphers) ? body!.ciphers! : [],
|
||||||
folders: Array.isArray(body?.folders) ? body!.folders! : [],
|
folders: Array.isArray(body?.folders) ? body!.folders! : [],
|
||||||
|
sends: Array.isArray(body?.sends) ? body!.sends! : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCachedSnapshot(snapshot: Partial<VaultCoreSnapshot> | null | undefined): VaultCoreSnapshot {
|
||||||
|
return {
|
||||||
|
ciphers: Array.isArray(snapshot?.ciphers) ? snapshot.ciphers : [],
|
||||||
|
folders: Array.isArray(snapshot?.folders) ? snapshot.folders : [],
|
||||||
|
sends: Array.isArray(snapshot?.sends) ? snapshot.sends : [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,49 +35,70 @@ export async function getCachedVaultCoreSnapshot(cacheKey: string): Promise<Vaul
|
|||||||
if (memory) return memory.snapshot;
|
if (memory) return memory.snapshot;
|
||||||
const cached = await loadCachedVaultCoreSnapshot(normalizedKey);
|
const cached = await loadCachedVaultCoreSnapshot(normalizedKey);
|
||||||
if (!cached?.snapshot) return null;
|
if (!cached?.snapshot) return null;
|
||||||
|
const snapshot = normalizeCachedSnapshot(cached.snapshot);
|
||||||
memoryVaultCoreCache.set(normalizedKey, {
|
memoryVaultCoreCache.set(normalizedKey, {
|
||||||
revisionStamp: cached.revisionStamp,
|
revisionStamp: cached.revisionStamp,
|
||||||
snapshot: cached.snapshot,
|
snapshot,
|
||||||
});
|
});
|
||||||
return cached.snapshot;
|
return snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadVaultCoreSyncSnapshot(authedFetch: AuthedFetch, cacheKey: string): Promise<VaultCoreSnapshot> {
|
export async function loadVaultCoreSyncSnapshot(authedFetch: AuthedFetch, cacheKey: string): Promise<VaultCoreSnapshot> {
|
||||||
const normalizedKey = String(cacheKey || '').trim();
|
const normalizedKey = String(cacheKey || '').trim();
|
||||||
if (!normalizedKey) return { ciphers: [], folders: [] };
|
if (!normalizedKey) return { ciphers: [], folders: [], sends: [] };
|
||||||
|
|
||||||
const existing = pendingVaultCoreRequests.get(normalizedKey);
|
const existing = pendingVaultCoreRequests.get(normalizedKey);
|
||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
|
|
||||||
const request = (async () => {
|
const request = (async () => {
|
||||||
const revisionStamp = await getVaultRevisionDate(authedFetch);
|
|
||||||
const memory = memoryVaultCoreCache.get(normalizedKey);
|
const memory = memoryVaultCoreCache.get(normalizedKey);
|
||||||
if (memory?.revisionStamp === revisionStamp) {
|
let cached = await loadCachedVaultCoreSnapshot(normalizedKey);
|
||||||
return memory.snapshot;
|
if (!memory && cached?.snapshot) {
|
||||||
}
|
const snapshot = normalizeCachedSnapshot(cached.snapshot);
|
||||||
|
|
||||||
const cached = await loadCachedVaultCoreSnapshot(normalizedKey);
|
|
||||||
if (cached?.revisionStamp === revisionStamp && cached.snapshot) {
|
|
||||||
memoryVaultCoreCache.set(normalizedKey, {
|
memoryVaultCoreCache.set(normalizedKey, {
|
||||||
revisionStamp,
|
revisionStamp: cached.revisionStamp,
|
||||||
snapshot: cached.snapshot,
|
snapshot,
|
||||||
});
|
});
|
||||||
return cached.snapshot;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const resp = await authedFetch('/api/sync?excludeSends=true&excludeDomains=true', {
|
try {
|
||||||
cache: 'no-store',
|
const revisionStamp = await getVaultRevisionDate(authedFetch);
|
||||||
headers: {
|
const currentMemory = memoryVaultCoreCache.get(normalizedKey);
|
||||||
'Cache-Control': 'no-cache',
|
if (currentMemory?.revisionStamp === revisionStamp) {
|
||||||
Pragma: 'no-cache',
|
return currentMemory.snapshot;
|
||||||
},
|
}
|
||||||
});
|
|
||||||
if (!resp.ok) throw new Error('Failed to load vault');
|
if (!cached) {
|
||||||
const body = await parseJson<VaultSyncResponse>(resp);
|
cached = await loadCachedVaultCoreSnapshot(normalizedKey);
|
||||||
const snapshot = normalizeSnapshot(body);
|
}
|
||||||
memoryVaultCoreCache.set(normalizedKey, { revisionStamp, snapshot });
|
if (cached?.revisionStamp === revisionStamp && cached.snapshot) {
|
||||||
void saveCachedVaultCoreSnapshot(normalizedKey, revisionStamp, snapshot);
|
const snapshot = normalizeCachedSnapshot(cached.snapshot);
|
||||||
return 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);
|
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'
|
| 'ru'
|
||||||
| 'es';
|
| 'es';
|
||||||
|
|
||||||
|
import enMessages from './i18n/locales/en';
|
||||||
|
|
||||||
const LOCALE_STORAGE_KEY = 'nodewarden.locale';
|
const LOCALE_STORAGE_KEY = 'nodewarden.locale';
|
||||||
|
|
||||||
type MessageTable = Record<string, string>;
|
type MessageTable = Record<string, string>;
|
||||||
@@ -18,8 +20,8 @@ export const AVAILABLE_LOCALES: readonly { value: Locale; label: string }[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
let locale: Locale = resolveInitialLocale();
|
let locale: Locale = resolveInitialLocale();
|
||||||
let activeMessages: MessageTable = {};
|
let activeMessages: MessageTable = enMessages;
|
||||||
const loadedMessages = new Map<Locale, MessageTable>();
|
const loadedMessages = new Map<Locale, MessageTable>([['en', enMessages]]);
|
||||||
|
|
||||||
function isLocale(value: unknown): value is Locale {
|
function isLocale(value: unknown): value is Locale {
|
||||||
return AVAILABLE_LOCALES.some((item) => item.value === value);
|
return AVAILABLE_LOCALES.some((item) => item.value === value);
|
||||||
@@ -46,7 +48,7 @@ function resolveInitialLocale(): Locale {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const localeLoaders: Record<Locale, () => Promise<{ default: MessageTable }>> = {
|
const localeLoaders: Record<Locale, () => Promise<{ default: MessageTable }>> = {
|
||||||
en: () => import('./i18n/locales/en'),
|
en: () => Promise.resolve({ default: enMessages }),
|
||||||
'zh-CN': () => import('./i18n/locales/zh-CN'),
|
'zh-CN': () => import('./i18n/locales/zh-CN'),
|
||||||
'zh-TW': () => import('./i18n/locales/zh-TW'),
|
'zh-TW': () => import('./i18n/locales/zh-TW'),
|
||||||
ru: () => import('./i18n/locales/ru'),
|
ru: () => import('./i18n/locales/ru'),
|
||||||
@@ -63,11 +65,7 @@ async function loadLocaleMessages(next: Locale): Promise<MessageTable> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadFallbackMessages(): Promise<MessageTable> {
|
async function loadFallbackMessages(): Promise<MessageTable> {
|
||||||
const cached = loadedMessages.get('en');
|
return enMessages;
|
||||||
if (cached) return cached;
|
|
||||||
const mod = await import('./i18n/locales/en');
|
|
||||||
loadedMessages.set('en', mod.default);
|
|
||||||
return mod.default;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type I18nParams = Record<string, string | number | null | undefined>;
|
export type I18nParams = Record<string, string | number | null | undefined>;
|
||||||
|
|||||||
@@ -7,10 +7,21 @@ const en: Record<string, string> = {
|
|||||||
"nav_sends": "Sends",
|
"nav_sends": "Sends",
|
||||||
"nav_backup_strategy": "Cloud Backup",
|
"nav_backup_strategy": "Cloud Backup",
|
||||||
"nav_import_export": "Import & Export",
|
"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_title": "Cloud Backup",
|
||||||
"backup_strategy_under_construction": "Under construction.",
|
"backup_strategy_under_construction": "Under construction.",
|
||||||
"import_export_title": "Import & Export",
|
"import_export_title": "Import & Export",
|
||||||
"import_export_under_construction": "Under construction.",
|
"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_export": "Export Backup",
|
||||||
"txt_backup_import": "Restore",
|
"txt_backup_import": "Restore",
|
||||||
"txt_backup_include_attachments": "Include attachments",
|
"txt_backup_include_attachments": "Include attachments",
|
||||||
@@ -283,6 +294,7 @@ const en: Record<string, string> = {
|
|||||||
"txt_address_3": "Address 3",
|
"txt_address_3": "Address 3",
|
||||||
"txt_all_device_authorizations_revoked": "All device trust revoked",
|
"txt_all_device_authorizations_revoked": "All device trust revoked",
|
||||||
"txt_all_invites_deleted": "All invites deleted",
|
"txt_all_invites_deleted": "All invites deleted",
|
||||||
|
"txt_delete_all_invites_failed": "Failed to delete all invites",
|
||||||
"txt_all_items": "All Items",
|
"txt_all_items": "All Items",
|
||||||
"txt_all_sends": "All Sends",
|
"txt_all_sends": "All Sends",
|
||||||
"txt_android": "Android",
|
"txt_android": "Android",
|
||||||
@@ -388,6 +400,7 @@ const en: Record<string, string> = {
|
|||||||
"txt_device_note_required": "Device name is required",
|
"txt_device_note_required": "Device name is required",
|
||||||
"txt_device_note_updated": "Device name updated",
|
"txt_device_note_updated": "Device name updated",
|
||||||
"txt_device_removed": "Device removed",
|
"txt_device_removed": "Device removed",
|
||||||
|
"txt_load_admin_data_failed": "Failed to load admin data",
|
||||||
"txt_load_devices_failed": "Failed to load devices",
|
"txt_load_devices_failed": "Failed to load devices",
|
||||||
"txt_disable_this_send": "Disable this send",
|
"txt_disable_this_send": "Disable this send",
|
||||||
"txt_disable_totp": "Disable TOTP",
|
"txt_disable_totp": "Disable TOTP",
|
||||||
@@ -450,9 +463,11 @@ const en: Record<string, string> = {
|
|||||||
"txt_identity": "Identity",
|
"txt_identity": "Identity",
|
||||||
"txt_identity_details": "Identity Details",
|
"txt_identity_details": "Identity Details",
|
||||||
"txt_ie_browser": "IE Browser",
|
"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_code_optional": "Invite Code (Not required for the first account; required for all others)",
|
||||||
"txt_invite_created": "Invite created",
|
"txt_invite_created": "Invite created",
|
||||||
"txt_invite_revoked": "Invite revoked",
|
"txt_invite_revoked": "Invite revoked",
|
||||||
|
"txt_revoke_invite_failed": "Failed to revoke invite",
|
||||||
"txt_invite_validity_hours": "Invite validity (hours)",
|
"txt_invite_validity_hours": "Invite validity (hours)",
|
||||||
"txt_invites": "Invites",
|
"txt_invites": "Invites",
|
||||||
"txt_ios": "iOS",
|
"txt_ios": "iOS",
|
||||||
@@ -473,6 +488,9 @@ const en: Record<string, string> = {
|
|||||||
"txt_linux_desktop": "Linux Desktop",
|
"txt_linux_desktop": "Linux Desktop",
|
||||||
"txt_loading": "Loading...",
|
"txt_loading": "Loading...",
|
||||||
"txt_loading_nodewarden": "Loading NodeWarden...",
|
"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_title": "Server Security Warning",
|
||||||
"txt_jwt_warning_subtitle": "JWT secret is not configured safely.",
|
"txt_jwt_warning_subtitle": "JWT secret is not configured safely.",
|
||||||
"txt_jwt_title_missing": "JWT_SECRET is missing",
|
"txt_jwt_title_missing": "JWT_SECRET is missing",
|
||||||
@@ -544,7 +562,9 @@ const en: Record<string, string> = {
|
|||||||
"txt_no": "No",
|
"txt_no": "No",
|
||||||
"txt_no_devices_found": "No devices found.",
|
"txt_no_devices_found": "No devices found.",
|
||||||
"txt_no_folder": "No Folder",
|
"txt_no_folder": "No Folder",
|
||||||
|
"txt_no_invites_found": "No invites found.",
|
||||||
"txt_no_items": "No items",
|
"txt_no_items": "No items",
|
||||||
|
"txt_no_users_found": "No users found.",
|
||||||
"txt_no_username": "(No username)",
|
"txt_no_username": "(No username)",
|
||||||
"txt_no_verification_codes": "No verification codes",
|
"txt_no_verification_codes": "No verification codes",
|
||||||
"txt_no_name": "(No Name)",
|
"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_totp_is_enabled_for_this_account": "TOTP is enabled for this account.",
|
||||||
"txt_total_items_count": "{count} items",
|
"txt_total_items_count": "{count} items",
|
||||||
"txt_totp_secret": "TOTP Secret",
|
"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_totp_verify_failed": "TOTP verify failed",
|
||||||
"txt_attachments": "Attachments",
|
"txt_attachments": "Attachments",
|
||||||
"txt_upload_attachments": "Upload 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_remove_all_devices_failed": "Failed to remove all devices",
|
||||||
"txt_update_item_failed": "Update item failed",
|
"txt_update_item_failed": "Update item failed",
|
||||||
"txt_update_send_failed": "Update send 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_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_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_deleted": "User deleted",
|
||||||
"txt_user_status_updated": "User status updated",
|
"txt_user_status_updated": "User status updated",
|
||||||
"txt_username": "Username",
|
"txt_username": "Username",
|
||||||
|
|||||||
@@ -7,10 +7,21 @@ const es: Record<string, string> = {
|
|||||||
"nav_sends": "Envíos",
|
"nav_sends": "Envíos",
|
||||||
"nav_backup_strategy": "Copia de seguridad en la nube",
|
"nav_backup_strategy": "Copia de seguridad en la nube",
|
||||||
"nav_import_export": "Importar y exportar",
|
"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_title": "Copia de seguridad en la nube",
|
||||||
"backup_strategy_under_construction": "En construcción.",
|
"backup_strategy_under_construction": "En construcción.",
|
||||||
"import_export_title": "Importar y exportar",
|
"import_export_title": "Importar y exportar",
|
||||||
"import_export_under_construction": "En construcción.",
|
"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_export": "Exportar copia de seguridad",
|
||||||
"txt_backup_import": "Restaurar",
|
"txt_backup_import": "Restaurar",
|
||||||
"txt_backup_include_attachments": "Incluir archivos adjuntos",
|
"txt_backup_include_attachments": "Incluir archivos adjuntos",
|
||||||
@@ -283,6 +294,7 @@ const es: Record<string, string> = {
|
|||||||
"txt_address_3": "Dirección 3",
|
"txt_address_3": "Dirección 3",
|
||||||
"txt_all_device_authorizations_revoked": "Confianza de todos los dispositivos revocada",
|
"txt_all_device_authorizations_revoked": "Confianza de todos los dispositivos revocada",
|
||||||
"txt_all_invites_deleted": "Todas las invitaciones eliminadas",
|
"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_items": "Todos los elementos",
|
||||||
"txt_all_sends": "Todos los envíos",
|
"txt_all_sends": "Todos los envíos",
|
||||||
"txt_android": "Android",
|
"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_required": "El nombre del dispositivo es obligatorio",
|
||||||
"txt_device_note_updated": "Nombre del dispositivo actualizado",
|
"txt_device_note_updated": "Nombre del dispositivo actualizado",
|
||||||
"txt_device_removed": "Dispositivo eliminado",
|
"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_load_devices_failed": "Error al cargar dispositivos",
|
||||||
"txt_disable_this_send": "Desactivar este envío",
|
"txt_disable_this_send": "Desactivar este envío",
|
||||||
"txt_disable_totp": "Desactivar TOTP",
|
"txt_disable_totp": "Desactivar TOTP",
|
||||||
@@ -450,9 +463,11 @@ const es: Record<string, string> = {
|
|||||||
"txt_identity": "Identidad",
|
"txt_identity": "Identidad",
|
||||||
"txt_identity_details": "Detalles de identidad",
|
"txt_identity_details": "Detalles de identidad",
|
||||||
"txt_ie_browser": "Navegador Internet Explorer",
|
"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_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_created": "Invitación creada",
|
||||||
"txt_invite_revoked": "Invitación revocada",
|
"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_invite_validity_hours": "Validez de la invitación en horas",
|
||||||
"txt_invites": "Invitaciones",
|
"txt_invites": "Invitaciones",
|
||||||
"txt_ios": "iOS",
|
"txt_ios": "iOS",
|
||||||
@@ -473,6 +488,9 @@ const es: Record<string, string> = {
|
|||||||
"txt_linux_desktop": "Escritorio Linux",
|
"txt_linux_desktop": "Escritorio Linux",
|
||||||
"txt_loading": "Cargando...",
|
"txt_loading": "Cargando...",
|
||||||
"txt_loading_nodewarden": "Cargando NodeWarden...",
|
"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_title": "Advertencia de seguridad del servidor",
|
||||||
"txt_jwt_warning_subtitle": "El secreto JWT no está configurado de forma segura.",
|
"txt_jwt_warning_subtitle": "El secreto JWT no está configurado de forma segura.",
|
||||||
"txt_jwt_title_missing": "Falta JWT_SECRET",
|
"txt_jwt_title_missing": "Falta JWT_SECRET",
|
||||||
@@ -544,7 +562,9 @@ const es: Record<string, string> = {
|
|||||||
"txt_no": "No",
|
"txt_no": "No",
|
||||||
"txt_no_devices_found": "No se encontraron dispositivos.",
|
"txt_no_devices_found": "No se encontraron dispositivos.",
|
||||||
"txt_no_folder": "Sin carpeta",
|
"txt_no_folder": "Sin carpeta",
|
||||||
|
"txt_no_invites_found": "No se encontraron invitaciones.",
|
||||||
"txt_no_items": "No hay elementos",
|
"txt_no_items": "No hay elementos",
|
||||||
|
"txt_no_users_found": "No se encontraron usuarios.",
|
||||||
"txt_no_username": "(Sin nombre de usuario)",
|
"txt_no_username": "(Sin nombre de usuario)",
|
||||||
"txt_no_verification_codes": "Sin códigos de verificación",
|
"txt_no_verification_codes": "Sin códigos de verificación",
|
||||||
"txt_no_name": "(Sin nombre)",
|
"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_totp_is_enabled_for_this_account": "TOTP está activado para esta cuenta.",
|
||||||
"txt_total_items_count": "{count} elementos",
|
"txt_total_items_count": "{count} elementos",
|
||||||
"txt_totp_secret": "Secreto TOTP",
|
"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_totp_verify_failed": "Error al verificar TOTP",
|
||||||
"txt_attachments": "Archivos adjuntos",
|
"txt_attachments": "Archivos adjuntos",
|
||||||
"txt_upload_attachments": "Subir 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_remove_all_devices_failed": "Error al quitar todos los dispositivos",
|
||||||
"txt_update_item_failed": "Error al actualizar elemento",
|
"txt_update_item_failed": "Error al actualizar elemento",
|
||||||
"txt_update_send_failed": "Error al actualizar envío",
|
"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_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_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_deleted": "Usuario eliminado",
|
||||||
"txt_user_status_updated": "Estado del usuario actualizado",
|
"txt_user_status_updated": "Estado del usuario actualizado",
|
||||||
"txt_username": "Nombre de usuario",
|
"txt_username": "Nombre de usuario",
|
||||||
|
|||||||
@@ -8,10 +8,21 @@ const ru: Record<string, string> = {
|
|||||||
"nav_sends": "Отправляет",
|
"nav_sends": "Отправляет",
|
||||||
"nav_backup_strategy": "Облачное резервное копирование",
|
"nav_backup_strategy": "Облачное резервное копирование",
|
||||||
"nav_import_export": "Импорт и экспорт",
|
"nav_import_export": "Импорт и экспорт",
|
||||||
|
"txt_page_not_found": "Страница не найдена",
|
||||||
|
"txt_page_not_found_hint": "Страница могла быть удалена, срок ее действия истек, или ссылка неполная.",
|
||||||
|
"txt_back_to_home": "На главную",
|
||||||
"backup_strategy_title": "Облачное резервное копирование",
|
"backup_strategy_title": "Облачное резервное копирование",
|
||||||
"backup_strategy_under_construction": "В стадии строительства.",
|
"backup_strategy_under_construction": "В стадии строительства.",
|
||||||
"import_export_title": "Импорт и экспорт",
|
"import_export_title": "Импорт и экспорт",
|
||||||
"import_export_under_construction": "В стадии строительства.",
|
"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_export": "Экспортировать резервную копию",
|
||||||
"txt_backup_import": "Восстановить",
|
"txt_backup_import": "Восстановить",
|
||||||
"txt_backup_include_attachments": "Включить вложения",
|
"txt_backup_include_attachments": "Включить вложения",
|
||||||
@@ -283,6 +294,7 @@ const ru: Record<string, string> = {
|
|||||||
"txt_address_3": "Адрес 3",
|
"txt_address_3": "Адрес 3",
|
||||||
"txt_all_device_authorizations_revoked": "Все доверие к устройствам отозвано",
|
"txt_all_device_authorizations_revoked": "Все доверие к устройствам отозвано",
|
||||||
"txt_all_invites_deleted": "Все приглашения удалены",
|
"txt_all_invites_deleted": "Все приглашения удалены",
|
||||||
|
"txt_delete_all_invites_failed": "Не удалось удалить все приглашения",
|
||||||
"txt_all_items": "Все предметы",
|
"txt_all_items": "Все предметы",
|
||||||
"txt_all_sends": "Все отправки",
|
"txt_all_sends": "Все отправки",
|
||||||
"txt_android": "Андроид",
|
"txt_android": "Андроид",
|
||||||
@@ -388,6 +400,7 @@ const ru: Record<string, string> = {
|
|||||||
"txt_device_note_required": "Укажите имя устройства.",
|
"txt_device_note_required": "Укажите имя устройства.",
|
||||||
"txt_device_note_updated": "Имя устройства обновлено.",
|
"txt_device_note_updated": "Имя устройства обновлено.",
|
||||||
"txt_device_removed": "Устройство удалено",
|
"txt_device_removed": "Устройство удалено",
|
||||||
|
"txt_load_admin_data_failed": "Не удалось загрузить данные администрирования",
|
||||||
"txt_load_devices_failed": "Не удалось загрузить устройства.",
|
"txt_load_devices_failed": "Не удалось загрузить устройства.",
|
||||||
"txt_disable_this_send": "Отключить эту отправку",
|
"txt_disable_this_send": "Отключить эту отправку",
|
||||||
"txt_disable_totp": "Отключить TOTP",
|
"txt_disable_totp": "Отключить TOTP",
|
||||||
@@ -450,9 +463,11 @@ const ru: Record<string, string> = {
|
|||||||
"txt_identity": "идентичность",
|
"txt_identity": "идентичность",
|
||||||
"txt_identity_details": "Данные личности",
|
"txt_identity_details": "Данные личности",
|
||||||
"txt_ie_browser": "IE-браузер",
|
"txt_ie_browser": "IE-браузер",
|
||||||
|
"txt_create_invite_failed": "Не удалось создать приглашение",
|
||||||
"txt_invite_code_optional": "Пригласительный код (не требуется для первой учетной записи; требуется для всех остальных)",
|
"txt_invite_code_optional": "Пригласительный код (не требуется для первой учетной записи; требуется для всех остальных)",
|
||||||
"txt_invite_created": "Приглашение создано",
|
"txt_invite_created": "Приглашение создано",
|
||||||
"txt_invite_revoked": "Приглашение отозвано",
|
"txt_invite_revoked": "Приглашение отозвано",
|
||||||
|
"txt_revoke_invite_failed": "Не удалось отозвать приглашение",
|
||||||
"txt_invite_validity_hours": "Срок действия приглашения (часы)",
|
"txt_invite_validity_hours": "Срок действия приглашения (часы)",
|
||||||
"txt_invites": "Приглашает",
|
"txt_invites": "Приглашает",
|
||||||
"txt_ios": "iOS",
|
"txt_ios": "iOS",
|
||||||
@@ -473,6 +488,9 @@ const ru: Record<string, string> = {
|
|||||||
"txt_linux_desktop": "Рабочий стол Linux",
|
"txt_linux_desktop": "Рабочий стол Linux",
|
||||||
"txt_loading": "Загрузка...",
|
"txt_loading": "Загрузка...",
|
||||||
"txt_loading_nodewarden": "Загрузка NodeWarden...",
|
"txt_loading_nodewarden": "Загрузка NodeWarden...",
|
||||||
|
"txt_loading_vault": "Загрузка хранилища...",
|
||||||
|
"txt_load_vault_failed": "Не удалось загрузить хранилище.",
|
||||||
|
"txt_retry_sync": "Повторить синхронизацию",
|
||||||
"txt_jwt_warning_title": "Предупреждение безопасности сервера",
|
"txt_jwt_warning_title": "Предупреждение безопасности сервера",
|
||||||
"txt_jwt_warning_subtitle": "Секрет JWT настроен неправильно.",
|
"txt_jwt_warning_subtitle": "Секрет JWT настроен неправильно.",
|
||||||
"txt_jwt_title_missing": "JWT_SECRET отсутствует.",
|
"txt_jwt_title_missing": "JWT_SECRET отсутствует.",
|
||||||
@@ -544,7 +562,9 @@ const ru: Record<string, string> = {
|
|||||||
"txt_no": "Нет",
|
"txt_no": "Нет",
|
||||||
"txt_no_devices_found": "Устройства не найдены.",
|
"txt_no_devices_found": "Устройства не найдены.",
|
||||||
"txt_no_folder": "Нет папки",
|
"txt_no_folder": "Нет папки",
|
||||||
|
"txt_no_invites_found": "Приглашения не найдены.",
|
||||||
"txt_no_items": "Нет товаров",
|
"txt_no_items": "Нет товаров",
|
||||||
|
"txt_no_users_found": "Пользователи не найдены.",
|
||||||
"txt_no_username": "(Нет имени пользователя)",
|
"txt_no_username": "(Нет имени пользователя)",
|
||||||
"txt_no_verification_codes": "Нет кодов подтверждения",
|
"txt_no_verification_codes": "Нет кодов подтверждения",
|
||||||
"txt_no_name": "(Без имени)",
|
"txt_no_name": "(Без имени)",
|
||||||
@@ -697,6 +717,16 @@ const ru: Record<string, string> = {
|
|||||||
"txt_totp_is_enabled_for_this_account": "TOTP включен для этой учетной записи.",
|
"txt_totp_is_enabled_for_this_account": "TOTP включен для этой учетной записи.",
|
||||||
"txt_total_items_count": "{count} товаров",
|
"txt_total_items_count": "{count} товаров",
|
||||||
"txt_totp_secret": "Секрет TOTP",
|
"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_totp_verify_failed": "Проверка TOTP не удалась",
|
||||||
"txt_attachments": "Вложения",
|
"txt_attachments": "Вложения",
|
||||||
"txt_upload_attachments": "Загрузить вложения",
|
"txt_upload_attachments": "Загрузить вложения",
|
||||||
@@ -725,8 +755,10 @@ const ru: Record<string, string> = {
|
|||||||
"txt_remove_all_devices_failed": "Не удалось удалить все устройства.",
|
"txt_remove_all_devices_failed": "Не удалось удалить все устройства.",
|
||||||
"txt_update_item_failed": "Обновить элемент не удалось",
|
"txt_update_item_failed": "Обновить элемент не удалось",
|
||||||
"txt_update_send_failed": "Send обновления не удалась",
|
"txt_update_send_failed": "Send обновления не удалась",
|
||||||
|
"txt_update_user_status_failed": "Не удалось обновить статус пользователя",
|
||||||
"txt_use_recovery_code": "Использовать код восстановления",
|
"txt_use_recovery_code": "Использовать код восстановления",
|
||||||
"txt_use_your_one_time_recovery_code_to_disable_two_step_verification": "Используйте одноразовый код восстановления, чтобы отключить двухэтапную проверку.",
|
"txt_use_your_one_time_recovery_code_to_disable_two_step_verification": "Используйте одноразовый код восстановления, чтобы отключить двухэтапную проверку.",
|
||||||
|
"txt_delete_user_failed": "Не удалось удалить пользователя",
|
||||||
"txt_user_deleted": "Пользователь удален",
|
"txt_user_deleted": "Пользователь удален",
|
||||||
"txt_user_status_updated": "Статус пользователя обновлен",
|
"txt_user_status_updated": "Статус пользователя обновлен",
|
||||||
"txt_username": "Имя пользователя",
|
"txt_username": "Имя пользователя",
|
||||||
|
|||||||
@@ -7,10 +7,21 @@ const zhCN: Record<string, string> = {
|
|||||||
"nav_sends": "Send",
|
"nav_sends": "Send",
|
||||||
"nav_backup_strategy": "云端备份",
|
"nav_backup_strategy": "云端备份",
|
||||||
"nav_import_export": "导入导出",
|
"nav_import_export": "导入导出",
|
||||||
|
"txt_page_not_found": "页面不存在",
|
||||||
|
"txt_page_not_found_hint": "这个页面可能已经删除、过期,或者链接不完整。",
|
||||||
|
"txt_back_to_home": "回到首页",
|
||||||
"backup_strategy_title": "云端备份",
|
"backup_strategy_title": "云端备份",
|
||||||
"backup_strategy_under_construction": "正在搭建中",
|
"backup_strategy_under_construction": "正在搭建中",
|
||||||
"import_export_title": "导入导出",
|
"import_export_title": "导入导出",
|
||||||
"import_export_under_construction": "正在搭建中",
|
"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_export": "导出备份",
|
||||||
"txt_backup_import": "还原",
|
"txt_backup_import": "还原",
|
||||||
"txt_backup_include_attachments": "包含附件",
|
"txt_backup_include_attachments": "包含附件",
|
||||||
@@ -283,8 +294,9 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_address_3": "地址 3",
|
"txt_address_3": "地址 3",
|
||||||
"txt_all_device_authorizations_revoked": "已撤销所有设备信任",
|
"txt_all_device_authorizations_revoked": "已撤销所有设备信任",
|
||||||
"txt_all_invites_deleted": "已删除所有邀请码",
|
"txt_all_invites_deleted": "已删除所有邀请码",
|
||||||
|
"txt_delete_all_invites_failed": "删除所有邀请码失败",
|
||||||
"txt_all_items": "所有项目",
|
"txt_all_items": "所有项目",
|
||||||
"txt_all_sends": "所有发送",
|
"txt_all_sends": "所有 Send",
|
||||||
"txt_android": "安卓",
|
"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": "确认删除所选的 {count} 个项目?",
|
||||||
"txt_are_you_sure_you_want_to_delete_count_selected_items_permanently": "确认永久删除所选的 {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_delete_failed": "批量删除失败",
|
||||||
"txt_bulk_permanent_delete_failed": "批量永久删除失败",
|
"txt_bulk_permanent_delete_failed": "批量永久删除失败",
|
||||||
"txt_bulk_restore_failed": "批量恢复失败",
|
"txt_bulk_restore_failed": "批量恢复失败",
|
||||||
"txt_bulk_delete_sends_failed": "批量删除发送失败",
|
"txt_bulk_delete_sends_failed": "批量删除 Send 失败",
|
||||||
"txt_bulk_move_failed": "批量移动失败",
|
"txt_bulk_move_failed": "批量移动失败",
|
||||||
"txt_cancel": "取消",
|
"txt_cancel": "取消",
|
||||||
"txt_continue": "继续",
|
"txt_continue": "继续",
|
||||||
@@ -337,7 +349,7 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_create_folder": "创建文件夹",
|
"txt_create_folder": "创建文件夹",
|
||||||
"txt_create_folder_failed": "创建文件夹失败",
|
"txt_create_folder_failed": "创建文件夹失败",
|
||||||
"txt_create_item_failed": "创建项目失败",
|
"txt_create_item_failed": "创建项目失败",
|
||||||
"txt_create_send_failed": "创建发送失败",
|
"txt_create_send_failed": "创建 Send 失败",
|
||||||
"txt_create_timed_invite": "创建时效邀请码",
|
"txt_create_timed_invite": "创建时效邀请码",
|
||||||
"txt_created_value": "创建于:{value}",
|
"txt_created_value": "创建于:{value}",
|
||||||
"txt_current_new_password_is_required": "需要输入当前密码和新密码",
|
"txt_current_new_password_is_required": "需要输入当前密码和新密码",
|
||||||
@@ -372,13 +384,13 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_delete_selected": "删除",
|
"txt_delete_selected": "删除",
|
||||||
"txt_delete_selected_items": "删除所选项目",
|
"txt_delete_selected_items": "删除所选项目",
|
||||||
"txt_delete_selected_items_permanently": "Delete Selected Items Permanently",
|
"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_this_user_and_all_user_data": "删除此用户及其所有数据?",
|
||||||
"txt_delete_user": "删除用户",
|
"txt_delete_user": "删除用户",
|
||||||
"txt_deleted_selected_items": "已删除所选项目",
|
"txt_deleted_selected_items": "已删除所选项目",
|
||||||
"txt_deleted_selected_items_permanently": "已永久删除所选项目",
|
"txt_deleted_selected_items_permanently": "已永久删除所选项目",
|
||||||
"txt_restored_selected_items": "已恢复所选项目",
|
"txt_restored_selected_items": "已恢复所选项目",
|
||||||
"txt_deleted_selected_sends": "已删除所选发送",
|
"txt_deleted_selected_sends": "已删除所选 Send",
|
||||||
"txt_deletion_date": "删除日期",
|
"txt_deletion_date": "删除日期",
|
||||||
"txt_deletion_days": "删除天数",
|
"txt_deletion_days": "删除天数",
|
||||||
"txt_device": "设备",
|
"txt_device": "设备",
|
||||||
@@ -388,8 +400,9 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_device_note_required": "设备名称不能为空",
|
"txt_device_note_required": "设备名称不能为空",
|
||||||
"txt_device_note_updated": "设备名称已更新",
|
"txt_device_note_updated": "设备名称已更新",
|
||||||
"txt_device_removed": "设备已移除",
|
"txt_device_removed": "设备已移除",
|
||||||
|
"txt_load_admin_data_failed": "加载管理数据失败",
|
||||||
"txt_load_devices_failed": "加载设备失败",
|
"txt_load_devices_failed": "加载设备失败",
|
||||||
"txt_disable_this_send": "禁用此发送",
|
"txt_disable_this_send": "禁用此 Send",
|
||||||
"txt_disable_totp": "停用 TOTP",
|
"txt_disable_totp": "停用 TOTP",
|
||||||
"txt_disable_totp_failed": "禁用 TOTP 失败",
|
"txt_disable_totp_failed": "禁用 TOTP 失败",
|
||||||
"txt_download": "下载",
|
"txt_download": "下载",
|
||||||
@@ -404,7 +417,7 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_edge_browser": "Edge 浏览器",
|
"txt_edge_browser": "Edge 浏览器",
|
||||||
"txt_edge_extension": "Edge 扩展",
|
"txt_edge_extension": "Edge 扩展",
|
||||||
"txt_edit": "编辑",
|
"txt_edit": "编辑",
|
||||||
"txt_edit_send": "编辑发送",
|
"txt_edit_send": "编辑 Send",
|
||||||
"txt_email": "邮箱",
|
"txt_email": "邮箱",
|
||||||
"txt_email_password_and_recovery_code_are_required": "需要输入邮箱、密码和恢复代码",
|
"txt_email_password_and_recovery_code_are_required": "需要输入邮箱、密码和恢复代码",
|
||||||
"txt_enable_totp": "启用 TOTP",
|
"txt_enable_totp": "启用 TOTP",
|
||||||
@@ -423,7 +436,7 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_expiry": "有效期",
|
"txt_expiry": "有效期",
|
||||||
"txt_expiry_month": "有效期月",
|
"txt_expiry_month": "有效期月",
|
||||||
"txt_expiry_year": "有效期年",
|
"txt_expiry_year": "有效期年",
|
||||||
"txt_failed_to_open_send": "打开发送失败",
|
"txt_failed_to_open_send": "打开 Send 失败",
|
||||||
"txt_favorite": "收藏",
|
"txt_favorite": "收藏",
|
||||||
"txt_favorites": "收藏",
|
"txt_favorites": "收藏",
|
||||||
"txt_duplicates": "重复项",
|
"txt_duplicates": "重复项",
|
||||||
@@ -434,7 +447,7 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_field_value": "字段值",
|
"txt_field_value": "字段值",
|
||||||
"txt_file": "文件",
|
"txt_file": "文件",
|
||||||
"txt_file_name": "文件名",
|
"txt_file_name": "文件名",
|
||||||
"txt_file_send": "文件发送",
|
"txt_file_send": "文件 Send",
|
||||||
"txt_file_size": "文件大小",
|
"txt_file_size": "文件大小",
|
||||||
"txt_fingerprint": "指纹",
|
"txt_fingerprint": "指纹",
|
||||||
"txt_firefox_browser": "Firefox 浏览器",
|
"txt_firefox_browser": "Firefox 浏览器",
|
||||||
@@ -450,9 +463,11 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_identity": "身份",
|
"txt_identity": "身份",
|
||||||
"txt_identity_details": "身份详情",
|
"txt_identity_details": "身份详情",
|
||||||
"txt_ie_browser": "IE 浏览器",
|
"txt_ie_browser": "IE 浏览器",
|
||||||
|
"txt_create_invite_failed": "创建邀请码失败",
|
||||||
"txt_invite_code_optional": "邀请码(首位注册者无需填写,其他人必填)",
|
"txt_invite_code_optional": "邀请码(首位注册者无需填写,其他人必填)",
|
||||||
"txt_invite_created": "邀请码已创建",
|
"txt_invite_created": "邀请码已创建",
|
||||||
"txt_invite_revoked": "邀请码已撤销",
|
"txt_invite_revoked": "邀请码已撤销",
|
||||||
|
"txt_revoke_invite_failed": "撤销邀请码失败",
|
||||||
"txt_invite_validity_hours": "邀请码有效期(小时)",
|
"txt_invite_validity_hours": "邀请码有效期(小时)",
|
||||||
"txt_invites": "邀请码",
|
"txt_invites": "邀请码",
|
||||||
"txt_ios": "iOS",
|
"txt_ios": "iOS",
|
||||||
@@ -473,6 +488,9 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_linux_desktop": "Linux 桌面端",
|
"txt_linux_desktop": "Linux 桌面端",
|
||||||
"txt_loading": "加载中...",
|
"txt_loading": "加载中...",
|
||||||
"txt_loading_nodewarden": "正在加载 NodeWarden...",
|
"txt_loading_nodewarden": "正在加载 NodeWarden...",
|
||||||
|
"txt_loading_vault": "正在加载保管库...",
|
||||||
|
"txt_load_vault_failed": "保管库加载失败。",
|
||||||
|
"txt_retry_sync": "重试同步",
|
||||||
"txt_jwt_warning_title": "JWT_SECRET 配置警告",
|
"txt_jwt_warning_title": "JWT_SECRET 配置警告",
|
||||||
"txt_jwt_warning_subtitle": "JWT 密钥当前不安全,请先修复后再继续。",
|
"txt_jwt_warning_subtitle": "JWT 密钥当前不安全,请先修复后再继续。",
|
||||||
"txt_jwt_title_missing": "未检测到 JWT_SECRET",
|
"txt_jwt_title_missing": "未检测到 JWT_SECRET",
|
||||||
@@ -539,17 +557,19 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_nothing_to_copy": "没有可复制的内容",
|
"txt_nothing_to_copy": "没有可复制的内容",
|
||||||
"txt_new_password_must_be_at_least_12_chars": "新密码至少需要 12 个字符",
|
"txt_new_password_must_be_at_least_12_chars": "新密码至少需要 12 个字符",
|
||||||
"txt_new_passwords_do_not_match": "两次输入的新密码不一致",
|
"txt_new_passwords_do_not_match": "两次输入的新密码不一致",
|
||||||
"txt_new_send": "新建发送",
|
"txt_new_send": "新建 Send",
|
||||||
"txt_next": "下一页",
|
"txt_next": "下一页",
|
||||||
"txt_no": "否",
|
"txt_no": "否",
|
||||||
"txt_no_devices_found": "未找到设备",
|
"txt_no_devices_found": "未找到设备",
|
||||||
"txt_no_folder": "无文件夹",
|
"txt_no_folder": "无文件夹",
|
||||||
|
"txt_no_invites_found": "暂无邀请码",
|
||||||
"txt_no_items": "没有项目",
|
"txt_no_items": "没有项目",
|
||||||
|
"txt_no_users_found": "暂无用户",
|
||||||
"txt_no_username": "无用户名",
|
"txt_no_username": "无用户名",
|
||||||
"txt_no_verification_codes": "没有验证码",
|
"txt_no_verification_codes": "没有验证码",
|
||||||
"txt_no_name": "(无名称)",
|
"txt_no_name": "(无名称)",
|
||||||
"txt_no_sends": "没有发送",
|
"txt_no_sends": "没有 Send",
|
||||||
"txt_nodewarden_send": "NodeWarden 发送",
|
"txt_nodewarden_send": "NodeWarden Send",
|
||||||
"txt_not_trusted": "未信任",
|
"txt_not_trusted": "未信任",
|
||||||
"txt_note": "笔记",
|
"txt_note": "笔记",
|
||||||
"txt_notes": "备注",
|
"txt_notes": "备注",
|
||||||
@@ -644,7 +664,7 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_save": "保存",
|
"txt_save": "保存",
|
||||||
"txt_save_profile": "保存资料",
|
"txt_save_profile": "保存资料",
|
||||||
"txt_save_profile_failed": "保存资料失败",
|
"txt_save_profile_failed": "保存资料失败",
|
||||||
"txt_search_sends": "搜索发送...",
|
"txt_search_sends": "搜索 Send...",
|
||||||
"txt_search_your_secure_vault": "搜索你的密码库...",
|
"txt_search_your_secure_vault": "搜索你的密码库...",
|
||||||
"txt_clear_search": "清空搜索",
|
"txt_clear_search": "清空搜索",
|
||||||
"txt_clear_search_esc": "清空搜索(Esc)",
|
"txt_clear_search_esc": "清空搜索(Esc)",
|
||||||
@@ -660,12 +680,12 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_select_all": "全选",
|
"txt_select_all": "全选",
|
||||||
"txt_select_duplicate_items": "选择重复项",
|
"txt_select_duplicate_items": "选择重复项",
|
||||||
"txt_select_an_item": "请选择一个项目",
|
"txt_select_an_item": "请选择一个项目",
|
||||||
"txt_send_created": "发送已创建",
|
"txt_send_created": "Send 已创建",
|
||||||
"txt_send_deleted": "发送已删除",
|
"txt_send_deleted": "Send 已删除",
|
||||||
"txt_send_details": "发送详情",
|
"txt_send_details": "Send 详情",
|
||||||
"txt_send_file": "发送文件",
|
"txt_send_file": "Send 文件",
|
||||||
"txt_send_unavailable": "发送不可用。",
|
"txt_send_unavailable": "Send 不可用。",
|
||||||
"txt_send_updated": "发送已更新",
|
"txt_send_updated": "Send 已更新",
|
||||||
"txt_sign_out": "退出登录",
|
"txt_sign_out": "退出登录",
|
||||||
"txt_ssh_key": "SSH 密钥",
|
"txt_ssh_key": "SSH 密钥",
|
||||||
"txt_ssn": "社保号",
|
"txt_ssn": "社保号",
|
||||||
@@ -684,11 +704,11 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_text_2fa_recovered_new_recovery_code_code": "2FA 已恢复,新的恢复代码:{code}",
|
"txt_text_2fa_recovered_new_recovery_code_code": "2FA 已恢复,新的恢复代码:{code}",
|
||||||
"txt_text_3": "------",
|
"txt_text_3": "------",
|
||||||
"txt_text_is_required": "文本不能为空",
|
"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_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_item_requires_master_password_every_time_before_viewing_details": "每次查看详情前均需输入主密码",
|
||||||
"txt_this_link_is_missing_decryption_key": "此链接缺少解密密钥",
|
"txt_this_link_is_missing_decryption_key": "此链接缺少解密密钥",
|
||||||
"txt_this_send_is_password_protected": "此发送受密码保护",
|
"txt_this_send_is_password_protected": "此 Send 受密码保护",
|
||||||
"txt_title": "称谓",
|
"txt_title": "称谓",
|
||||||
"txt_totp": "TOTP",
|
"txt_totp": "TOTP",
|
||||||
"txt_totp_code": "TOTP 验证码",
|
"txt_totp_code": "TOTP 验证码",
|
||||||
@@ -697,6 +717,16 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_totp_is_enabled_for_this_account": "此账户已启用 TOTP。",
|
"txt_totp_is_enabled_for_this_account": "此账户已启用 TOTP。",
|
||||||
"txt_total_items_count": "共 {count} 项",
|
"txt_total_items_count": "共 {count} 项",
|
||||||
"txt_totp_secret": "TOTP 密钥",
|
"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_totp_verify_failed": "TOTP 验证失败",
|
||||||
"txt_attachments": "附件",
|
"txt_attachments": "附件",
|
||||||
"txt_upload_attachments": "上传附件",
|
"txt_upload_attachments": "上传附件",
|
||||||
@@ -717,16 +747,18 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_unlock_failed": "解锁失败",
|
"txt_unlock_failed": "解锁失败",
|
||||||
"txt_unlock_failed_master_password_is_incorrect": "解锁失败,主密码不正确。",
|
"txt_unlock_failed_master_password_is_incorrect": "解锁失败,主密码不正确。",
|
||||||
"txt_unlock_item": "解锁项目",
|
"txt_unlock_item": "解锁项目",
|
||||||
"txt_unlock_send": "解锁发送",
|
"txt_unlock_send": "解锁 Send",
|
||||||
"txt_unlock_vault": "解锁密码库",
|
"txt_unlock_vault": "解锁密码库",
|
||||||
"txt_unlocked": "已解锁",
|
"txt_unlocked": "已解锁",
|
||||||
"txt_all_devices_removed": "已移除所有设备",
|
"txt_all_devices_removed": "已移除所有设备",
|
||||||
"txt_remove_device_failed": "移除设备失败",
|
"txt_remove_device_failed": "移除设备失败",
|
||||||
"txt_remove_all_devices_failed": "移除所有设备失败",
|
"txt_remove_all_devices_failed": "移除所有设备失败",
|
||||||
"txt_update_item_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_recovery_code": "使用恢复代码",
|
||||||
"txt_use_your_one_time_recovery_code_to_disable_two_step_verification": "使用一次性恢复代码禁用两步验证。",
|
"txt_use_your_one_time_recovery_code_to_disable_two_step_verification": "使用一次性恢复代码禁用两步验证。",
|
||||||
|
"txt_delete_user_failed": "删除用户失败",
|
||||||
"txt_user_deleted": "用户已删除",
|
"txt_user_deleted": "用户已删除",
|
||||||
"txt_user_status_updated": "用户状态已更新",
|
"txt_user_status_updated": "用户状态已更新",
|
||||||
"txt_username": "用户名",
|
"txt_username": "用户名",
|
||||||
|
|||||||
@@ -7,10 +7,21 @@ const zhTW: Record<string, string> = {
|
|||||||
"nav_sends": "Send",
|
"nav_sends": "Send",
|
||||||
"nav_backup_strategy": "雲端備份",
|
"nav_backup_strategy": "雲端備份",
|
||||||
"nav_import_export": "導入導出",
|
"nav_import_export": "導入導出",
|
||||||
|
"txt_page_not_found": "頁面不存在",
|
||||||
|
"txt_page_not_found_hint": "這個頁面可能已經刪除、過期,或者連結不完整。",
|
||||||
|
"txt_back_to_home": "回到首頁",
|
||||||
"backup_strategy_title": "雲端備份",
|
"backup_strategy_title": "雲端備份",
|
||||||
"backup_strategy_under_construction": "正在搭建中",
|
"backup_strategy_under_construction": "正在搭建中",
|
||||||
"import_export_title": "導入導出",
|
"import_export_title": "導入導出",
|
||||||
"import_export_under_construction": "正在搭建中",
|
"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_export": "導出備份",
|
||||||
"txt_backup_import": "還原",
|
"txt_backup_import": "還原",
|
||||||
"txt_backup_include_attachments": "包含附件",
|
"txt_backup_include_attachments": "包含附件",
|
||||||
@@ -283,8 +294,9 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_address_3": "地址 3",
|
"txt_address_3": "地址 3",
|
||||||
"txt_all_device_authorizations_revoked": "已撤銷所有設備信任",
|
"txt_all_device_authorizations_revoked": "已撤銷所有設備信任",
|
||||||
"txt_all_invites_deleted": "已刪除所有邀請碼",
|
"txt_all_invites_deleted": "已刪除所有邀請碼",
|
||||||
|
"txt_delete_all_invites_failed": "刪除所有邀請碼失敗",
|
||||||
"txt_all_items": "所有項目",
|
"txt_all_items": "所有項目",
|
||||||
"txt_all_sends": "所有發送",
|
"txt_all_sends": "所有 Send",
|
||||||
"txt_android": "安卓",
|
"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": "確認刪除所選的 {count} 個項目?",
|
||||||
"txt_are_you_sure_you_want_to_delete_count_selected_items_permanently": "確認永久刪除所選的 {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_delete_failed": "批量刪除失敗",
|
||||||
"txt_bulk_permanent_delete_failed": "批量永久刪除失敗",
|
"txt_bulk_permanent_delete_failed": "批量永久刪除失敗",
|
||||||
"txt_bulk_restore_failed": "批量恢復失敗",
|
"txt_bulk_restore_failed": "批量恢復失敗",
|
||||||
"txt_bulk_delete_sends_failed": "批量刪除發送失敗",
|
"txt_bulk_delete_sends_failed": "批量刪除 Send 失敗",
|
||||||
"txt_bulk_move_failed": "批量移動失敗",
|
"txt_bulk_move_failed": "批量移動失敗",
|
||||||
"txt_cancel": "取消",
|
"txt_cancel": "取消",
|
||||||
"txt_continue": "繼續",
|
"txt_continue": "繼續",
|
||||||
@@ -337,7 +349,7 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_create_folder": "創建文件夾",
|
"txt_create_folder": "創建文件夾",
|
||||||
"txt_create_folder_failed": "創建文件夾失敗",
|
"txt_create_folder_failed": "創建文件夾失敗",
|
||||||
"txt_create_item_failed": "創建項目失敗",
|
"txt_create_item_failed": "創建項目失敗",
|
||||||
"txt_create_send_failed": "創建發送失敗",
|
"txt_create_send_failed": "創建 Send 失敗",
|
||||||
"txt_create_timed_invite": "創建時效邀請碼",
|
"txt_create_timed_invite": "創建時效邀請碼",
|
||||||
"txt_created_value": "創建於:{value}",
|
"txt_created_value": "創建於:{value}",
|
||||||
"txt_current_new_password_is_required": "需要輸入當前密碼和新密碼",
|
"txt_current_new_password_is_required": "需要輸入當前密碼和新密碼",
|
||||||
@@ -372,13 +384,13 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_delete_selected": "刪除",
|
"txt_delete_selected": "刪除",
|
||||||
"txt_delete_selected_items": "刪除所選項目",
|
"txt_delete_selected_items": "刪除所選項目",
|
||||||
"txt_delete_selected_items_permanently": "Delete Selected Items Permanently",
|
"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_this_user_and_all_user_data": "刪除此用戶及其所有數據?",
|
||||||
"txt_delete_user": "刪除用戶",
|
"txt_delete_user": "刪除用戶",
|
||||||
"txt_deleted_selected_items": "已刪除所選項目",
|
"txt_deleted_selected_items": "已刪除所選項目",
|
||||||
"txt_deleted_selected_items_permanently": "已永久刪除所選項目",
|
"txt_deleted_selected_items_permanently": "已永久刪除所選項目",
|
||||||
"txt_restored_selected_items": "已恢復所選項目",
|
"txt_restored_selected_items": "已恢復所選項目",
|
||||||
"txt_deleted_selected_sends": "已刪除所選發送",
|
"txt_deleted_selected_sends": "已刪除所選 Send",
|
||||||
"txt_deletion_date": "刪除日期",
|
"txt_deletion_date": "刪除日期",
|
||||||
"txt_deletion_days": "刪除天數",
|
"txt_deletion_days": "刪除天數",
|
||||||
"txt_device": "設備",
|
"txt_device": "設備",
|
||||||
@@ -388,8 +400,9 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_device_note_required": "設備名稱不能為空",
|
"txt_device_note_required": "設備名稱不能為空",
|
||||||
"txt_device_note_updated": "設備名稱已更新",
|
"txt_device_note_updated": "設備名稱已更新",
|
||||||
"txt_device_removed": "設備已移除",
|
"txt_device_removed": "設備已移除",
|
||||||
|
"txt_load_admin_data_failed": "加載管理數據失敗",
|
||||||
"txt_load_devices_failed": "加載設備失敗",
|
"txt_load_devices_failed": "加載設備失敗",
|
||||||
"txt_disable_this_send": "禁用此發送",
|
"txt_disable_this_send": "禁用此 Send",
|
||||||
"txt_disable_totp": "停用 TOTP",
|
"txt_disable_totp": "停用 TOTP",
|
||||||
"txt_disable_totp_failed": "禁用 TOTP 失敗",
|
"txt_disable_totp_failed": "禁用 TOTP 失敗",
|
||||||
"txt_download": "下載",
|
"txt_download": "下載",
|
||||||
@@ -404,7 +417,7 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_edge_browser": "Edge 瀏覽器",
|
"txt_edge_browser": "Edge 瀏覽器",
|
||||||
"txt_edge_extension": "Edge 擴展",
|
"txt_edge_extension": "Edge 擴展",
|
||||||
"txt_edit": "編輯",
|
"txt_edit": "編輯",
|
||||||
"txt_edit_send": "編輯發送",
|
"txt_edit_send": "編輯 Send",
|
||||||
"txt_email": "郵箱",
|
"txt_email": "郵箱",
|
||||||
"txt_email_password_and_recovery_code_are_required": "需要輸入郵箱、密碼和恢復代碼",
|
"txt_email_password_and_recovery_code_are_required": "需要輸入郵箱、密碼和恢復代碼",
|
||||||
"txt_enable_totp": "啟用 TOTP",
|
"txt_enable_totp": "啟用 TOTP",
|
||||||
@@ -423,7 +436,7 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_expiry": "有效期",
|
"txt_expiry": "有效期",
|
||||||
"txt_expiry_month": "有效期月",
|
"txt_expiry_month": "有效期月",
|
||||||
"txt_expiry_year": "有效期年",
|
"txt_expiry_year": "有效期年",
|
||||||
"txt_failed_to_open_send": "打開發送失敗",
|
"txt_failed_to_open_send": "打開 Send 失敗",
|
||||||
"txt_favorite": "收藏",
|
"txt_favorite": "收藏",
|
||||||
"txt_favorites": "收藏",
|
"txt_favorites": "收藏",
|
||||||
"txt_duplicates": "重複項",
|
"txt_duplicates": "重複項",
|
||||||
@@ -434,7 +447,7 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_field_value": "字段值",
|
"txt_field_value": "字段值",
|
||||||
"txt_file": "文件",
|
"txt_file": "文件",
|
||||||
"txt_file_name": "文件名",
|
"txt_file_name": "文件名",
|
||||||
"txt_file_send": "文件發送",
|
"txt_file_send": "文件 Send",
|
||||||
"txt_file_size": "文件大小",
|
"txt_file_size": "文件大小",
|
||||||
"txt_fingerprint": "指紋",
|
"txt_fingerprint": "指紋",
|
||||||
"txt_firefox_browser": "Firefox 瀏覽器",
|
"txt_firefox_browser": "Firefox 瀏覽器",
|
||||||
@@ -450,9 +463,11 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_identity": "身份",
|
"txt_identity": "身份",
|
||||||
"txt_identity_details": "身份詳情",
|
"txt_identity_details": "身份詳情",
|
||||||
"txt_ie_browser": "IE 瀏覽器",
|
"txt_ie_browser": "IE 瀏覽器",
|
||||||
|
"txt_create_invite_failed": "創建邀請碼失敗",
|
||||||
"txt_invite_code_optional": "邀請碼(首位註冊者無需填寫,其他人必填)",
|
"txt_invite_code_optional": "邀請碼(首位註冊者無需填寫,其他人必填)",
|
||||||
"txt_invite_created": "邀請碼已創建",
|
"txt_invite_created": "邀請碼已創建",
|
||||||
"txt_invite_revoked": "邀請碼已撤銷",
|
"txt_invite_revoked": "邀請碼已撤銷",
|
||||||
|
"txt_revoke_invite_failed": "撤銷邀請碼失敗",
|
||||||
"txt_invite_validity_hours": "邀請碼有效期(小時)",
|
"txt_invite_validity_hours": "邀請碼有效期(小時)",
|
||||||
"txt_invites": "邀請碼",
|
"txt_invites": "邀請碼",
|
||||||
"txt_ios": "iOS",
|
"txt_ios": "iOS",
|
||||||
@@ -473,6 +488,9 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_linux_desktop": "Linux 桌面端",
|
"txt_linux_desktop": "Linux 桌面端",
|
||||||
"txt_loading": "加載中...",
|
"txt_loading": "加載中...",
|
||||||
"txt_loading_nodewarden": "正在加載 NodeWarden...",
|
"txt_loading_nodewarden": "正在加載 NodeWarden...",
|
||||||
|
"txt_loading_vault": "正在加載保管庫...",
|
||||||
|
"txt_load_vault_failed": "保管庫加載失敗。",
|
||||||
|
"txt_retry_sync": "重試同步",
|
||||||
"txt_jwt_warning_title": "JWT_SECRET 配置警告",
|
"txt_jwt_warning_title": "JWT_SECRET 配置警告",
|
||||||
"txt_jwt_warning_subtitle": "JWT 密鑰當前不安全,請先修復後再繼續。",
|
"txt_jwt_warning_subtitle": "JWT 密鑰當前不安全,請先修復後再繼續。",
|
||||||
"txt_jwt_title_missing": "未檢測到 JWT_SECRET",
|
"txt_jwt_title_missing": "未檢測到 JWT_SECRET",
|
||||||
@@ -539,17 +557,19 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_nothing_to_copy": "沒有可複製的內容",
|
"txt_nothing_to_copy": "沒有可複製的內容",
|
||||||
"txt_new_password_must_be_at_least_12_chars": "新密碼至少需要 12 個字符",
|
"txt_new_password_must_be_at_least_12_chars": "新密碼至少需要 12 個字符",
|
||||||
"txt_new_passwords_do_not_match": "兩次輸入的新密碼不一致",
|
"txt_new_passwords_do_not_match": "兩次輸入的新密碼不一致",
|
||||||
"txt_new_send": "新建發送",
|
"txt_new_send": "新建 Send",
|
||||||
"txt_next": "下一頁",
|
"txt_next": "下一頁",
|
||||||
"txt_no": "否",
|
"txt_no": "否",
|
||||||
"txt_no_devices_found": "未找到設備",
|
"txt_no_devices_found": "未找到設備",
|
||||||
"txt_no_folder": "無文件夾",
|
"txt_no_folder": "無文件夾",
|
||||||
|
"txt_no_invites_found": "暫無邀請碼",
|
||||||
"txt_no_items": "沒有項目",
|
"txt_no_items": "沒有項目",
|
||||||
|
"txt_no_users_found": "暫無用戶",
|
||||||
"txt_no_username": "無用戶名",
|
"txt_no_username": "無用戶名",
|
||||||
"txt_no_verification_codes": "沒有驗證碼",
|
"txt_no_verification_codes": "沒有驗證碼",
|
||||||
"txt_no_name": "(無名稱)",
|
"txt_no_name": "(無名稱)",
|
||||||
"txt_no_sends": "沒有發送",
|
"txt_no_sends": "沒有 Send",
|
||||||
"txt_nodewarden_send": "NodeWarden 發送",
|
"txt_nodewarden_send": "NodeWarden Send",
|
||||||
"txt_not_trusted": "未信任",
|
"txt_not_trusted": "未信任",
|
||||||
"txt_note": "筆記",
|
"txt_note": "筆記",
|
||||||
"txt_notes": "備註",
|
"txt_notes": "備註",
|
||||||
@@ -644,7 +664,7 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_save": "保存",
|
"txt_save": "保存",
|
||||||
"txt_save_profile": "保存資料",
|
"txt_save_profile": "保存資料",
|
||||||
"txt_save_profile_failed": "保存資料失敗",
|
"txt_save_profile_failed": "保存資料失敗",
|
||||||
"txt_search_sends": "搜索發送...",
|
"txt_search_sends": "搜索 Send...",
|
||||||
"txt_search_your_secure_vault": "搜索你的密碼庫...",
|
"txt_search_your_secure_vault": "搜索你的密碼庫...",
|
||||||
"txt_clear_search": "清空搜索",
|
"txt_clear_search": "清空搜索",
|
||||||
"txt_clear_search_esc": "清空搜索(Esc)",
|
"txt_clear_search_esc": "清空搜索(Esc)",
|
||||||
@@ -660,12 +680,12 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_select_all": "全選",
|
"txt_select_all": "全選",
|
||||||
"txt_select_duplicate_items": "選擇重複項",
|
"txt_select_duplicate_items": "選擇重複項",
|
||||||
"txt_select_an_item": "請選擇一個項目",
|
"txt_select_an_item": "請選擇一個項目",
|
||||||
"txt_send_created": "發送已創建",
|
"txt_send_created": "Send 已創建",
|
||||||
"txt_send_deleted": "發送已刪除",
|
"txt_send_deleted": "Send 已刪除",
|
||||||
"txt_send_details": "發送詳情",
|
"txt_send_details": "Send 詳情",
|
||||||
"txt_send_file": "發送文件",
|
"txt_send_file": "Send 文件",
|
||||||
"txt_send_unavailable": "發送不可用。",
|
"txt_send_unavailable": "Send 不可用。",
|
||||||
"txt_send_updated": "發送已更新",
|
"txt_send_updated": "Send 已更新",
|
||||||
"txt_sign_out": "退出登錄",
|
"txt_sign_out": "退出登錄",
|
||||||
"txt_ssh_key": "SSH 密鑰",
|
"txt_ssh_key": "SSH 密鑰",
|
||||||
"txt_ssn": "社保號",
|
"txt_ssn": "社保號",
|
||||||
@@ -684,11 +704,11 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_text_2fa_recovered_new_recovery_code_code": "2FA 已恢復,新的恢復代碼:{code}",
|
"txt_text_2fa_recovered_new_recovery_code_code": "2FA 已恢復,新的恢復代碼:{code}",
|
||||||
"txt_text_3": "------",
|
"txt_text_3": "------",
|
||||||
"txt_text_is_required": "文本不能為空",
|
"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_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_item_requires_master_password_every_time_before_viewing_details": "每次查看詳情前均需輸入主密碼",
|
||||||
"txt_this_link_is_missing_decryption_key": "此鏈接缺少解密密鑰",
|
"txt_this_link_is_missing_decryption_key": "此鏈接缺少解密密鑰",
|
||||||
"txt_this_send_is_password_protected": "此發送受密碼保護",
|
"txt_this_send_is_password_protected": "此 Send 受密碼保護",
|
||||||
"txt_title": "稱謂",
|
"txt_title": "稱謂",
|
||||||
"txt_totp": "TOTP",
|
"txt_totp": "TOTP",
|
||||||
"txt_totp_code": "TOTP 驗證碼",
|
"txt_totp_code": "TOTP 驗證碼",
|
||||||
@@ -697,6 +717,16 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_totp_is_enabled_for_this_account": "此賬戶已啟用 TOTP。",
|
"txt_totp_is_enabled_for_this_account": "此賬戶已啟用 TOTP。",
|
||||||
"txt_total_items_count": "共 {count} 項",
|
"txt_total_items_count": "共 {count} 項",
|
||||||
"txt_totp_secret": "TOTP 密鑰",
|
"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_totp_verify_failed": "TOTP 驗證失敗",
|
||||||
"txt_attachments": "附件",
|
"txt_attachments": "附件",
|
||||||
"txt_upload_attachments": "上傳附件",
|
"txt_upload_attachments": "上傳附件",
|
||||||
@@ -717,16 +747,18 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_unlock_failed": "解鎖失敗",
|
"txt_unlock_failed": "解鎖失敗",
|
||||||
"txt_unlock_failed_master_password_is_incorrect": "解鎖失敗,主密碼不正確。",
|
"txt_unlock_failed_master_password_is_incorrect": "解鎖失敗,主密碼不正確。",
|
||||||
"txt_unlock_item": "解鎖項目",
|
"txt_unlock_item": "解鎖項目",
|
||||||
"txt_unlock_send": "解鎖發送",
|
"txt_unlock_send": "解鎖 Send",
|
||||||
"txt_unlock_vault": "解鎖密碼庫",
|
"txt_unlock_vault": "解鎖密碼庫",
|
||||||
"txt_unlocked": "已解鎖",
|
"txt_unlocked": "已解鎖",
|
||||||
"txt_all_devices_removed": "已移除所有設備",
|
"txt_all_devices_removed": "已移除所有設備",
|
||||||
"txt_remove_device_failed": "移除設備失敗",
|
"txt_remove_device_failed": "移除設備失敗",
|
||||||
"txt_remove_all_devices_failed": "移除所有設備失敗",
|
"txt_remove_all_devices_failed": "移除所有設備失敗",
|
||||||
"txt_update_item_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_recovery_code": "使用恢復代碼",
|
||||||
"txt_use_your_one_time_recovery_code_to_disable_two_step_verification": "使用一次性恢復代碼禁用兩步驗證。",
|
"txt_use_your_one_time_recovery_code_to_disable_two_step_verification": "使用一次性恢復代碼禁用兩步驗證。",
|
||||||
|
"txt_delete_user_failed": "刪除用戶失敗",
|
||||||
"txt_user_deleted": "用戶已刪除",
|
"txt_user_deleted": "用戶已刪除",
|
||||||
"txt_user_status_updated": "用戶狀態已更新",
|
"txt_user_status_updated": "用戶狀態已更新",
|
||||||
"txt_username": "用戶名",
|
"txt_username": "用戶名",
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { Cipher, Folder } from './types';
|
import type { Cipher, Folder, Send } from './types';
|
||||||
|
|
||||||
export interface VaultCoreSnapshot {
|
export interface VaultCoreSnapshot {
|
||||||
ciphers: Cipher[];
|
ciphers: Cipher[];
|
||||||
folders: Folder[];
|
folders: Folder[];
|
||||||
|
sends: Send[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VaultCoreCacheRecord {
|
interface VaultCoreCacheRecord {
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
type WebsiteIconStatus = 'idle' | 'loading' | 'loaded' | 'error';
|
type WebsiteIconStatus = 'idle' | 'loading' | 'loaded' | 'error';
|
||||||
|
|
||||||
|
const ICON_LOAD_TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
interface WebsiteIconRecord {
|
interface WebsiteIconRecord {
|
||||||
status: WebsiteIconStatus;
|
status: WebsiteIconStatus;
|
||||||
promise: Promise<WebsiteIconStatus> | null;
|
promise: Promise<WebsiteIconStatus> | null;
|
||||||
|
imageUrl: string | null;
|
||||||
listeners: Set<(status: WebsiteIconStatus) => void>;
|
listeners: Set<(status: WebsiteIconStatus) => void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,6 +17,7 @@ function ensureRecord(host: string): WebsiteIconRecord {
|
|||||||
record = {
|
record = {
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
promise: null,
|
promise: null,
|
||||||
|
imageUrl: null,
|
||||||
listeners: new Set(),
|
listeners: new Set(),
|
||||||
};
|
};
|
||||||
iconRecords.set(host, record);
|
iconRecords.set(host, record);
|
||||||
@@ -34,6 +38,11 @@ export function getWebsiteIconStatus(host: string): WebsiteIconStatus {
|
|||||||
return ensureRecord(host).status;
|
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 {
|
export function subscribeWebsiteIconStatus(host: string, listener: (status: WebsiteIconStatus) => void): () => void {
|
||||||
if (!host) return () => undefined;
|
if (!host) return () => undefined;
|
||||||
const record = ensureRecord(host);
|
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;
|
if (!host) return;
|
||||||
const record = ensureRecord(host);
|
const record = ensureRecord(host);
|
||||||
record.promise = null;
|
record.promise = null;
|
||||||
|
if (imageUrl) {
|
||||||
|
record.imageUrl = imageUrl;
|
||||||
|
}
|
||||||
notifyRecord(host, 'loaded');
|
notifyRecord(host, 'loaded');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,9 +66,19 @@ export function markWebsiteIconErrored(host: string): void {
|
|||||||
if (!host) return;
|
if (!host) return;
|
||||||
const record = ensureRecord(host);
|
const record = ensureRecord(host);
|
||||||
record.promise = null;
|
record.promise = null;
|
||||||
|
record.imageUrl = null;
|
||||||
notifyRecord(host, 'error');
|
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> {
|
export function preloadWebsiteIcon(host: string, src: string): Promise<WebsiteIconStatus> {
|
||||||
if (!host) return Promise.resolve('error');
|
if (!host) return Promise.resolve('error');
|
||||||
|
|
||||||
@@ -68,21 +90,31 @@ export function preloadWebsiteIcon(host: string, src: string): Promise<WebsiteIc
|
|||||||
return record.promise;
|
return record.promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
record.status = 'loading';
|
notifyRecord(host, 'loading');
|
||||||
record.promise = new Promise<WebsiteIconStatus>((resolve) => {
|
record.promise = (async () => {
|
||||||
const img = new Image();
|
const controller = new AbortController();
|
||||||
img.decoding = 'async';
|
const timeout = window.setTimeout(() => controller.abort(), ICON_LOAD_TIMEOUT_MS);
|
||||||
img.referrerPolicy = 'no-referrer';
|
try {
|
||||||
img.onload = () => {
|
const resp = await fetch(src, {
|
||||||
markWebsiteIconLoaded(host);
|
cache: 'force-cache',
|
||||||
resolve('loaded');
|
signal: controller.signal,
|
||||||
};
|
});
|
||||||
img.onerror = () => {
|
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);
|
markWebsiteIconErrored(host);
|
||||||
resolve('error');
|
return 'error';
|
||||||
};
|
} finally {
|
||||||
img.src = src;
|
window.clearTimeout(timeout);
|
||||||
});
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
return record.promise;
|
return record.promise;
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-4
@@ -15,14 +15,19 @@ const queryClient = new QueryClient({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
async function bootstrap(): Promise<void> {
|
const root = document.getElementById('root')!;
|
||||||
await initI18n();
|
|
||||||
|
function renderApp(): void {
|
||||||
render(
|
render(
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<App />
|
<App />
|
||||||
</QueryClientProvider>,
|
</QueryClientProvider>,
|
||||||
document.getElementById('root')!
|
root
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void bootstrap();
|
renderApp();
|
||||||
|
|
||||||
|
void initI18n().then(() => {
|
||||||
|
renderApp();
|
||||||
|
});
|
||||||
|
|||||||
@@ -19,6 +19,427 @@
|
|||||||
@apply mt-2.5;
|
@apply mt-2.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.public-send-card-head {
|
||||||
|
@apply mb-2 flex items-center justify-between gap-2 text-sm font-bold;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-send-copy-btn {
|
||||||
|
@apply h-8 shrink-0 px-3 text-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-page {
|
||||||
|
@apply relative grid min-h-full place-items-center overflow-hidden p-6 text-center;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 50% 42%, rgba(28, 118, 255, 0.24), transparent 27rem),
|
||||||
|
radial-gradient(circle at 16% 84%, rgba(22, 163, 255, 0.10), transparent 22rem),
|
||||||
|
linear-gradient(180deg, #020b1a 0%, #061328 48%, #0a1730 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-shell {
|
||||||
|
@apply relative z-20 grid w-full max-w-[620px] justify-items-center gap-5 px-4 py-7 text-center;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-space {
|
||||||
|
@apply pointer-events-none absolute inset-0 overflow-hidden;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes not-found-star-fall {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-100vh);
|
||||||
|
}
|
||||||
|
|
||||||
|
20% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(100vh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes not-found-astronaut-spin {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-star-box {
|
||||||
|
@apply absolute left-0 top-0 z-10 h-full w-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-star-box-1 {
|
||||||
|
animation: not-found-star-fall 9s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-star-box-2 {
|
||||||
|
animation: not-found-star-fall 9s -2.1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-star-box-3 {
|
||||||
|
animation: not-found-star-fall 9s -4.3s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-star-box-4 {
|
||||||
|
animation: not-found-star-fall 9s -6.4s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-star {
|
||||||
|
@apply absolute h-[3px] w-[3px] rounded-full;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 0 10px rgba(255, 255, 255, 0.82);
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-star::before,
|
||||||
|
.not-found-star::after {
|
||||||
|
@apply absolute rounded-full;
|
||||||
|
content: "";
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 0 12px rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-star::before {
|
||||||
|
@apply h-[5px] w-[5px];
|
||||||
|
top: 72px;
|
||||||
|
left: 78px;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-star::after {
|
||||||
|
@apply h-[7px] w-[7px];
|
||||||
|
top: 10px;
|
||||||
|
left: 168px;
|
||||||
|
opacity: 0.88;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-star-position-1 {
|
||||||
|
top: 5vh;
|
||||||
|
left: 8%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-star-position-2 {
|
||||||
|
top: 23vh;
|
||||||
|
left: 21%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-star-position-3 {
|
||||||
|
top: 42vh;
|
||||||
|
left: 38%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-star-position-4 {
|
||||||
|
top: 61vh;
|
||||||
|
left: 56%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-star-position-5 {
|
||||||
|
top: 15vh;
|
||||||
|
left: 70%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-star-position-6 {
|
||||||
|
top: 34vh;
|
||||||
|
left: 82%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-star-position-7 {
|
||||||
|
top: 72vh;
|
||||||
|
left: 93%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-astronaut {
|
||||||
|
@apply relative z-10;
|
||||||
|
width: 220px;
|
||||||
|
height: 264px;
|
||||||
|
animation: not-found-astronaut-spin 5s linear infinite;
|
||||||
|
filter: drop-shadow(0 28px 36px rgba(0, 0, 0, 0.28));
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-astro-stage {
|
||||||
|
@apply relative grid place-items-center;
|
||||||
|
height: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-astro-pack {
|
||||||
|
@apply absolute z-[1];
|
||||||
|
top: 57px;
|
||||||
|
left: 66px;
|
||||||
|
width: 88px;
|
||||||
|
height: 132px;
|
||||||
|
background: #8db0c7;
|
||||||
|
border-radius: 44px 44px 0 0 / 27px 27px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-astro-head {
|
||||||
|
@apply absolute z-[3] rounded-full;
|
||||||
|
top: 30px;
|
||||||
|
left: 68px;
|
||||||
|
width: 85px;
|
||||||
|
height: 70px;
|
||||||
|
background: linear-gradient(90deg, #e4ebef 0%, #e4ebef 50%, #fbfdfa 50%, #fbfdfa 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-astro-head::after {
|
||||||
|
@apply absolute rounded-[13px];
|
||||||
|
content: "";
|
||||||
|
top: 13px;
|
||||||
|
left: 16px;
|
||||||
|
width: 53px;
|
||||||
|
height: 44px;
|
||||||
|
background: linear-gradient(180deg, #28c4df 0%, #28c4df 50%, #078dbb 50%, #078dbb 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-astro-head::before {
|
||||||
|
@apply absolute rounded-[5px];
|
||||||
|
content: "";
|
||||||
|
top: 23px;
|
||||||
|
left: -4px;
|
||||||
|
width: 11px;
|
||||||
|
height: 22px;
|
||||||
|
background: #587789;
|
||||||
|
box-shadow: 81px 0 0 #587789;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-astro-body {
|
||||||
|
@apply absolute z-[2];
|
||||||
|
top: 92px;
|
||||||
|
left: 73px;
|
||||||
|
width: 75px;
|
||||||
|
height: 88px;
|
||||||
|
border-radius: 36px / 18px;
|
||||||
|
background: linear-gradient(90deg, #e4ebef 0%, #e4ebef 50%, #fbfdfa 50%, #fbfdfa 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-astro-panel {
|
||||||
|
@apply absolute;
|
||||||
|
top: 18px;
|
||||||
|
left: 11px;
|
||||||
|
width: 53px;
|
||||||
|
height: 36px;
|
||||||
|
background: #b8cdec;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-astro-panel::before {
|
||||||
|
@apply absolute;
|
||||||
|
content: "";
|
||||||
|
top: 8px;
|
||||||
|
left: 6px;
|
||||||
|
width: 27px;
|
||||||
|
height: 5px;
|
||||||
|
background: #fbfdfa;
|
||||||
|
box-shadow: 0 8px 0 #fbfdfa, 0 16px 0 #fbfdfa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-astro-panel::after {
|
||||||
|
@apply absolute rounded-full;
|
||||||
|
content: "";
|
||||||
|
top: 8px;
|
||||||
|
right: 6px;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background: #fbfdfa;
|
||||||
|
box-shadow: 0 13px 0 2px #fbfdfa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-astro-arm {
|
||||||
|
@apply absolute z-[2];
|
||||||
|
top: 107px;
|
||||||
|
width: 70px;
|
||||||
|
height: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-astro-arm-left {
|
||||||
|
left: 28px;
|
||||||
|
background: #e4ebef;
|
||||||
|
border-radius: 0 0 0 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-astro-arm-right {
|
||||||
|
right: 28px;
|
||||||
|
background: #fbfdfa;
|
||||||
|
border-radius: 0 0 34px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-astro-arm::before {
|
||||||
|
@apply absolute;
|
||||||
|
content: "";
|
||||||
|
top: -35px;
|
||||||
|
width: 26px;
|
||||||
|
height: 62px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-astro-arm-left::before {
|
||||||
|
left: 0;
|
||||||
|
background: #e4ebef;
|
||||||
|
border-radius: 44px 44px 0 105px / 44px 44px 0 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-astro-arm-right::before {
|
||||||
|
right: 0;
|
||||||
|
background: #fbfdfa;
|
||||||
|
border-radius: 44px 44px 105px 0 / 44px 44px 96px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-astro-arm::after {
|
||||||
|
@apply absolute;
|
||||||
|
content: "";
|
||||||
|
top: -21px;
|
||||||
|
width: 26px;
|
||||||
|
height: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-astro-arm-left::after {
|
||||||
|
left: 0;
|
||||||
|
background: #6e91a4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-astro-arm-right::after {
|
||||||
|
right: 0;
|
||||||
|
background: #b6d2e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-astro-leg {
|
||||||
|
@apply absolute z-[2];
|
||||||
|
bottom: 62px;
|
||||||
|
width: 26px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-astro-leg-left {
|
||||||
|
left: 67px;
|
||||||
|
background: #e4ebef;
|
||||||
|
transform: rotate(20deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-astro-leg-right {
|
||||||
|
right: 64px;
|
||||||
|
background: #fbfdfa;
|
||||||
|
transform: rotate(-20deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-astro-leg::before {
|
||||||
|
@apply absolute;
|
||||||
|
content: "";
|
||||||
|
bottom: -23px;
|
||||||
|
width: 44px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-astro-leg-left::before {
|
||||||
|
left: -18px;
|
||||||
|
background: #e4ebef;
|
||||||
|
border-bottom: 9px solid #6d96ac;
|
||||||
|
border-radius: 27px 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-astro-leg-right::before {
|
||||||
|
right: -18px;
|
||||||
|
background: #fbfdfa;
|
||||||
|
border-bottom: 9px solid #b0cfe4;
|
||||||
|
border-radius: 0 27px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-brand {
|
||||||
|
@apply inline-flex max-w-full items-center justify-center gap-3.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-logo {
|
||||||
|
@apply h-14 w-[70px] flex-shrink-0 object-contain;
|
||||||
|
filter: drop-shadow(0 8px 18px rgba(43, 102, 217, 0.22));
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-wordmark {
|
||||||
|
@apply block max-w-full;
|
||||||
|
width: clamp(200px, 38vw, 330px);
|
||||||
|
aspect-ratio: 862 / 102;
|
||||||
|
background: #116ff9;
|
||||||
|
mask: url('/nodewarden-wordmark.svg') center / contain no-repeat;
|
||||||
|
-webkit-mask: url('/nodewarden-wordmark.svg') center / contain no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-code {
|
||||||
|
@apply rounded-full px-3 py-1 text-sm font-extrabold;
|
||||||
|
background: #eef4ff;
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-copy {
|
||||||
|
@apply grid justify-items-center gap-3;
|
||||||
|
text-shadow: 0 2px 18px rgba(0, 0, 0, 0.38);
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-shell h1 {
|
||||||
|
@apply m-0 text-3xl font-extrabold leading-tight;
|
||||||
|
color: #f8fbff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-shell p {
|
||||||
|
@apply m-0 max-w-[420px] text-sm leading-relaxed;
|
||||||
|
color: rgba(220, 232, 251, 0.82);
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-action {
|
||||||
|
@apply mt-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.not-found-page {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 50% 36%, rgba(28, 118, 255, 0.24), transparent 18rem),
|
||||||
|
radial-gradient(circle at 18% 82%, rgba(22, 163, 255, 0.10), transparent 16rem),
|
||||||
|
linear-gradient(180deg, #020b1a 0%, #061328 48%, #0a1730 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-shell {
|
||||||
|
@apply gap-4 px-5 py-8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-brand {
|
||||||
|
@apply gap-2.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-logo {
|
||||||
|
@apply h-12 w-[60px];
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-wordmark {
|
||||||
|
width: clamp(170px, 52vw, 230px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-space {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-astronaut {
|
||||||
|
width: 176px;
|
||||||
|
height: 211px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-astro-stage {
|
||||||
|
height: 224px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.not-found-star-box,
|
||||||
|
.not-found-astronaut {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.inline-status-icon {
|
.inline-status-icon {
|
||||||
@apply align-text-bottom;
|
@apply align-text-bottom;
|
||||||
}
|
}
|
||||||
@@ -33,6 +454,11 @@
|
|||||||
@apply m-0 mb-1 text-center;
|
@apply m-0 mb-1 text-center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.standalone-eyebrow {
|
||||||
|
@apply mb-1 text-center text-xs font-extrabold uppercase tracking-[0.12em];
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
.standalone-shell {
|
.standalone-shell {
|
||||||
@apply grid w-[min(640px,100%)] gap-3.5;
|
@apply grid w-[min(640px,100%)] gap-3.5;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
|
|
||||||
:root[data-theme='dark'] .muted,
|
:root[data-theme='dark'] .muted,
|
||||||
:root[data-theme='dark'] .detail-sub,
|
:root[data-theme='dark'] .detail-sub,
|
||||||
|
:root[data-theme='dark'] .detail-folder-line,
|
||||||
:root[data-theme='dark'] .field-help,
|
:root[data-theme='dark'] .field-help,
|
||||||
:root[data-theme='dark'] .list-sub,
|
:root[data-theme='dark'] .list-sub,
|
||||||
:root[data-theme='dark'] .kv-label,
|
:root[data-theme='dark'] .kv-label,
|
||||||
@@ -296,3 +297,8 @@
|
|||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
-webkit-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;
|
@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 {
|
.password-toggle {
|
||||||
@apply absolute right-2 top-1/2 grid cursor-pointer place-items-center border-0 bg-transparent text-blue-700 transition;
|
@apply absolute right-2 top-1/2 grid cursor-pointer place-items-center border-0 bg-transparent text-blue-700 transition;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
@@ -175,7 +198,7 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.or {
|
.or {
|
||||||
@apply my-2.5 text-center text-slate-700;
|
@apply text-center text-slate-700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-help {
|
.field-help {
|
||||||
|
|||||||
@@ -698,7 +698,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.local-error {
|
.local-error {
|
||||||
@apply mt-2.5 font-semibold;
|
@apply mt-2.5 flex flex-wrap items-center gap-2 font-semibold;
|
||||||
color: #b42318;
|
color: #b42318;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,15 +61,15 @@
|
|||||||
|
|
||||||
@media (max-width: 1180px) {
|
@media (max-width: 1180px) {
|
||||||
.auth-page {
|
.auth-page {
|
||||||
@apply items-start p-3.5;
|
@apply items-center p-3.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.standalone-shell {
|
.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 {
|
.standalone-brand-outside {
|
||||||
@apply justify-start;
|
@apply justify-center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.standalone-brand-logo {
|
.standalone-brand-logo {
|
||||||
@@ -501,6 +501,10 @@
|
|||||||
height: 160px;
|
height: 160px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.totp-scan-actions {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.invite-toolbar {
|
.invite-toolbar {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
@@ -659,6 +663,49 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@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 {
|
.backup-interval-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -663,10 +663,112 @@
|
|||||||
color: #0f172a;
|
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 {
|
.totp-codes-page {
|
||||||
@apply flex min-h-full flex-col;
|
@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 {
|
.totp-codes-list {
|
||||||
@apply grid w-full items-start gap-2.5;
|
@apply grid w-full items-start gap-2.5;
|
||||||
grid-template-columns: repeat(var(--totp-columns, 1), minmax(300px, 1fr));
|
grid-template-columns: repeat(var(--totp-columns, 1), minmax(300px, 1fr));
|
||||||
@@ -882,3 +984,12 @@
|
|||||||
@apply grid min-h-[120px] place-items-center;
|
@apply grid min-h-[120px] place-items-center;
|
||||||
color: #667085;
|
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;
|
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));
|
const rootDir = fileURLToPath(new URL('.', import.meta.url));
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
root: rootDir,
|
const isDemo = mode === 'demo';
|
||||||
plugins: [preact()],
|
|
||||||
resolve: {
|
return {
|
||||||
alias: {
|
root: rootDir,
|
||||||
'@': path.resolve(rootDir, 'src'),
|
plugins: [preact()],
|
||||||
'@shared': path.resolve(rootDir, '../shared'),
|
define: {
|
||||||
|
__NODEWARDEN_DEMO__: JSON.stringify(isDemo),
|
||||||
},
|
},
|
||||||
},
|
resolve: {
|
||||||
build: {
|
alias: {
|
||||||
outDir: path.resolve(rootDir, '../dist'),
|
'@/lib/demo': path.resolve(rootDir, isDemo ? 'src/lib/demo.ts' : 'src/lib/demo.empty.ts'),
|
||||||
emptyOutDir: true,
|
'@/lib/demo-brand-icons': path.resolve(
|
||||||
sourcemap: false,
|
rootDir,
|
||||||
target: 'esnext',
|
isDemo ? 'src/lib/demo-brand-icons.ts' : 'src/lib/demo.empty.ts'
|
||||||
rollupOptions: {
|
),
|
||||||
output: {
|
'@': path.resolve(rootDir, 'src'),
|
||||||
manualChunks(id) {
|
'@shared': path.resolve(rootDir, '../shared'),
|
||||||
if (id.includes('/node_modules/')) {
|
},
|
||||||
return 'vendor';
|
},
|
||||||
}
|
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$/);
|
const localeMatch = normalized.match(/\/src\/lib\/i18n\/locales\/(.+)\.ts$/);
|
||||||
if (localeMatch) {
|
if (localeMatch) {
|
||||||
return `i18n-${localeMatch[1]}`;
|
if (localeMatch[1] === 'en') return undefined;
|
||||||
}
|
return `i18n-${localeMatch[1]}`;
|
||||||
|
}
|
||||||
|
|
||||||
if (normalized.includes('/src/lib/i18n.ts')) {
|
if (normalized.includes('/src/lib/i18n.ts')) {
|
||||||
return 'i18n-core';
|
return 'i18n-core';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
normalized.includes('/src/components/AuthViews.tsx') ||
|
normalized.includes('/src/components/AuthViews.tsx') ||
|
||||||
normalized.includes('/src/components/PublicSendPage.tsx') ||
|
normalized.includes('/src/components/PublicSendPage.tsx') ||
|
||||||
normalized.includes('/src/components/RecoverTwoFactorPage.tsx') ||
|
normalized.includes('/src/components/RecoverTwoFactorPage.tsx') ||
|
||||||
normalized.includes('/src/components/JwtWarningPage.tsx') ||
|
normalized.includes('/src/components/JwtWarningPage.tsx') ||
|
||||||
normalized.includes('/src/lib/app-auth.ts')
|
normalized.includes('/src/lib/app-auth.ts')
|
||||||
) {
|
) {
|
||||||
return 'auth-suite';
|
return 'auth-suite';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
normalized.includes('/src/components/ImportPage.tsx') ||
|
!isDemo &&
|
||||||
normalized.includes('/src/lib/import-') ||
|
(
|
||||||
normalized.includes('/src/lib/export-formats.ts') ||
|
normalized.includes('/src/components/ImportPage.tsx') ||
|
||||||
normalized.includes('/src/components/SendsPage.tsx') ||
|
normalized.includes('/src/lib/import-') ||
|
||||||
normalized.includes('/src/components/TotpCodesPage.tsx')
|
normalized.includes('/src/lib/export-formats.ts') ||
|
||||||
) {
|
normalized.includes('/src/components/SendsPage.tsx') ||
|
||||||
return 'workspace-suite';
|
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 (
|
return undefined;
|
||||||
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;
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
server: {
|
||||||
server: {
|
port: 5173,
|
||||||
port: 5173,
|
fs: {
|
||||||
fs: {
|
allow: [path.resolve(rootDir, '..')],
|
||||||
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