Add isolated Pages demo mode with sample vault data

This commit is contained in:
shuaiplus
2026-05-04 21:09:10 +08:00
parent ba38b77387
commit 70dc9a76a9
16 changed files with 1574 additions and 84 deletions
+4 -1
View File
@@ -9,11 +9,14 @@
"scripts": {
"dev": "wrangler dev -c wrangler.toml",
"dev:kv": "wrangler dev -c wrangler.kv.toml",
"dev:demo": "vite --config webapp/vite.config.ts --mode demo --host 127.0.0.1 --port 5174",
"build": "vite build --config webapp/vite.config.ts",
"build:demo": "vite build --config webapp/vite.config.ts --mode demo && node scripts/pages-spa-redirects.cjs",
"i18n": "node scripts/i18n-validate.cjs",
"i18n:validate": "node scripts/i18n-validate.cjs",
"deploy": "wrangler deploy",
"deploy:kv": "wrangler deploy -c wrangler.kv.toml"
"deploy:kv": "wrangler deploy -c wrangler.kv.toml",
"deploy:demo": "npm run build:demo && wrangler pages deploy dist --project-name nw-demo"
},
"keywords": [
"bitwarden",
+7
View File
@@ -0,0 +1,7 @@
const fs = require('node:fs');
const path = require('node:path');
const distDir = path.resolve(__dirname, '..', 'dist');
fs.mkdirSync(distDir, { recursive: true });
fs.writeFileSync(path.join(distDir, '_redirects'), '/* /index.html 200\n');
+140 -14
View File
@@ -54,7 +54,21 @@ import { APP_NOTIFY_EVENT, type AppNotifyDetail } from '@/lib/app-notify';
import { dispatchBackupProgress, type BackupProgressDetail } from '@/lib/backup-restore-progress';
import { decryptSends, decryptVaultCore } from '@/lib/vault-decrypt';
import { decryptSendsInWorker, decryptVaultCoreInWorker } from '@/lib/vault-worker';
import type { AppPhase, Cipher, Folder as VaultFolder, Profile, Send, SessionState } from '@/lib/types';
import {
DEMO_CIPHERS,
DEMO_ADMIN_INVITES,
DEMO_ADMIN_USERS,
DEMO_AUTHORIZED_DEVICES,
DEMO_FOLDERS,
DEMO_SENDS,
createDemoBackupSettings,
IS_DEMO_MODE,
createDemoCompletedLogin,
createDemoInitialBootstrapState,
createDemoMainRoutesProps,
} from '@/lib/demo';
import type { AdminBackupSettings } from '@/lib/api/backup';
import type { AdminInvite, AdminUser, AppPhase, AuthorizedDevice, Cipher, Folder as VaultFolder, Profile, Send, SessionState } from '@/lib/types';
import type { VaultCoreSnapshot } from '@/lib/vault-cache';
function isBackupProgressDetail(value: unknown): value is BackupProgressDetail {
@@ -138,9 +152,15 @@ function readSessionTimeoutAction(): SessionTimeoutAction {
}
export default function App() {
const initialBootstrap = useMemo(() => readInitialAppBootstrapState(), []);
const initialBootstrap = useMemo(
() => (IS_DEMO_MODE ? createDemoInitialBootstrapState() : readInitialAppBootstrapState()),
[]
);
const initialInviteCode = useMemo(() => readInviteCodeFromUrl(), []);
const initialProfileSnapshot = useMemo(() => loadProfileSnapshot(initialBootstrap.session?.email), [initialBootstrap]);
const initialProfileSnapshot = useMemo(
() => (IS_DEMO_MODE ? null : loadProfileSnapshot(initialBootstrap.session?.email)),
[initialBootstrap]
);
const queryClient = useQueryClient();
const [pendingAuthAction, setPendingAuthAction] = useState<'login' | 'register' | 'unlock' | null>(null);
const [location, navigate] = useLocation();
@@ -192,6 +212,10 @@ export default function App() {
const [decryptedFolders, setDecryptedFolders] = useState<VaultFolder[]>([]);
const [decryptedCiphers, setDecryptedCiphers] = useState<Cipher[]>([]);
const [decryptedSends, setDecryptedSends] = useState<Send[]>([]);
const [demoUsers, setDemoUsers] = useState<AdminUser[]>(() => DEMO_ADMIN_USERS.map((user) => ({ ...user })));
const [demoInvites, setDemoInvites] = useState<AdminInvite[]>(() => DEMO_ADMIN_INVITES.map((invite) => ({ ...invite })));
const [demoAuthorizedDevices, setDemoAuthorizedDevices] = useState<AuthorizedDevice[]>(() => DEMO_AUTHORIZED_DEVICES.map((device) => ({ ...device })));
const [demoBackupSettings, setDemoBackupSettings] = useState<AdminBackupSettings>(() => createDemoBackupSettings());
const [cachedVaultCore, setCachedVaultCore] = useState<VaultCoreSnapshot | null>(null);
const [vaultInitialDecryptDone, setVaultInitialDecryptDone] = useState(false);
const [vaultDecryptError, setVaultDecryptError] = useState('');
@@ -294,6 +318,7 @@ export default function App() {
}, [themePreference]);
useEffect(() => {
if (IS_DEMO_MODE) return;
saveProfileSnapshot(profile);
}, [profile]);
@@ -374,6 +399,17 @@ export default function App() {
});
useEffect(() => {
if (IS_DEMO_MODE) {
setDefaultKdfIterations(initialBootstrap.defaultKdfIterations);
setJwtWarning(null);
setSession(null);
setProfile(null);
setPhase('login');
setUnlockPreparing(false);
if (location !== '/login') navigate('/login');
return;
}
let mounted = true;
(async () => {
const boot = await bootstrapAppSession(initialBootstrap);
@@ -393,6 +429,7 @@ export default function App() {
useEffect(() => {
if (phase !== 'locked' || !session) return;
if (IS_DEMO_MODE) return;
let cancelled = false;
void (async () => {
const result = await hydrateLockedSession(session, profile);
@@ -441,6 +478,15 @@ export default function App() {
async function handleLogin() {
if (pendingAuthAction) return;
if (IS_DEMO_MODE) {
setPendingAuthAction('login');
try {
await finalizeLogin(createDemoCompletedLogin(loginValues.email), t('txt_login_success'));
} finally {
setPendingAuthAction(null);
}
return;
}
if (!loginValues.email || !loginValues.password) {
pushToast('error', t('txt_please_input_email_and_password'));
return;
@@ -513,6 +559,12 @@ export default function App() {
async function handleRegister() {
if (pendingAuthAction) return;
if (IS_DEMO_MODE) {
pushToast('warning', t('txt_demo_readonly_message'));
setPhase('login');
navigate('/login');
return;
}
if (!registerValues.email || !registerValues.password) {
pushToast('error', t('txt_please_input_email_and_password'));
return;
@@ -561,6 +613,10 @@ export default function App() {
async function handleTogglePasswordHint() {
if (pendingAuthAction) return;
if (IS_DEMO_MODE) {
openPasswordHintDialog(t('txt_demo_master_password_hint'));
return;
}
const email = loginValues.email.trim().toLowerCase();
if (!email) return;
@@ -595,12 +651,21 @@ export default function App() {
function handleShowLockedPasswordHint() {
if (pendingAuthAction) return;
openPasswordHintDialog(profile?.masterPasswordHint ?? null);
openPasswordHintDialog((IS_DEMO_MODE ? t('txt_demo_master_password_hint') : profile?.masterPasswordHint) ?? null);
}
async function handleUnlock() {
if (pendingAuthAction) return;
if (!session?.email) return;
if (IS_DEMO_MODE) {
setPendingAuthAction('unlock');
try {
await finalizeLogin(createDemoCompletedLogin(session.email), t('txt_unlocked'));
} finally {
setPendingAuthAction(null);
}
return;
}
if (!unlockPassword) {
pushToast('error', t('txt_please_input_master_password'));
return;
@@ -652,7 +717,9 @@ export default function App() {
}
function logoutNow() {
void revokeCurrentSession(sessionRef.current);
if (!IS_DEMO_MODE) {
void revokeCurrentSession(sessionRef.current);
}
setConfirm(null);
setSession(null);
clearProfileSnapshot();
@@ -758,6 +825,36 @@ export default function App() {
}
useEffect(() => {
if (!IS_DEMO_MODE) return;
if (phase !== 'app') {
setDecryptedFolders([]);
setDecryptedCiphers([]);
setDecryptedSends([]);
setDemoUsers(DEMO_ADMIN_USERS.map((user) => ({ ...user })));
setDemoInvites(DEMO_ADMIN_INVITES.map((invite) => ({ ...invite })));
setDemoAuthorizedDevices(DEMO_AUTHORIZED_DEVICES.map((device) => ({ ...device })));
setDemoBackupSettings(createDemoBackupSettings());
setVaultInitialDecryptDone(false);
setSendsDecryptDone(false);
return;
}
setDecryptedFolders(DEMO_FOLDERS.map((folder) => ({ ...folder })));
setDecryptedCiphers(DEMO_CIPHERS.map((cipher) => ({ ...cipher })));
setDecryptedSends(DEMO_SENDS.map((send) => ({ ...send })));
setDemoUsers(DEMO_ADMIN_USERS.map((user) => ({ ...user })));
setDemoInvites(DEMO_ADMIN_INVITES.map((invite) => ({ ...invite })));
setDemoAuthorizedDevices(DEMO_AUTHORIZED_DEVICES.map((device) => ({ ...device })));
setDemoBackupSettings(createDemoBackupSettings());
setVaultDecryptError('');
setVaultInitialDecryptDone(true);
setSendsDecryptDone(true);
}, [phase]);
useEffect(() => {
if (IS_DEMO_MODE) {
setCachedVaultCore(null);
return;
}
let cancelled = false;
if (phase !== 'app' || !session?.symEncKey || !session?.symMacKey || !vaultCacheKey) {
setCachedVaultCore(null);
@@ -790,7 +887,7 @@ export default function App() {
const vaultCoreQuery = useQuery({
queryKey: ['vault-core', vaultCacheKey],
queryFn: () => loadVaultCoreSyncSnapshot(authedFetch, vaultCacheKey),
enabled: phase === 'app' && !!session?.symEncKey && !!session?.symMacKey && !!vaultCacheKey,
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.symEncKey && !!session?.symMacKey && !!vaultCacheKey,
staleTime: 30_000,
});
const encryptedVaultCore = vaultCoreQuery.data || cachedVaultCore;
@@ -801,7 +898,7 @@ export default function App() {
const sendsQuery = useQuery({
queryKey: sendsQueryKey,
queryFn: () => getSends(authedFetch),
enabled: phase === 'app' && !!session?.symEncKey && !!session?.symMacKey && location === '/sends' && !encryptedSendsFromSync,
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.symEncKey && !!session?.symMacKey && location === '/sends' && !encryptedSendsFromSync,
staleTime: 30_000,
});
const encryptedSends = sendsQuery.data || encryptedSendsFromSync;
@@ -818,7 +915,7 @@ export default function App() {
const profileQuery = useQuery({
queryKey: ['profile', vaultCacheKey || session?.email],
queryFn: () => getProfile(authedFetch),
enabled: phase === 'app' && !!session?.accessToken,
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken,
staleTime: 30_000,
});
useEffect(() => {
@@ -830,31 +927,31 @@ export default function App() {
const usersQuery = useQuery({
queryKey: ['admin-users', vaultCacheKey],
queryFn: () => listAdminUsers(authedFetch),
enabled: phase === 'app' && isAdmin && vaultInitialDecryptDone,
enabled: !IS_DEMO_MODE && phase === 'app' && isAdmin && vaultInitialDecryptDone,
staleTime: 30_000,
});
const invitesQuery = useQuery({
queryKey: ['admin-invites', vaultCacheKey],
queryFn: () => listAdminInvites(authedFetch),
enabled: phase === 'app' && isAdmin && vaultInitialDecryptDone,
enabled: !IS_DEMO_MODE && phase === 'app' && isAdmin && vaultInitialDecryptDone,
staleTime: 30_000,
});
const totpStatusQuery = useQuery({
queryKey: ['totp-status', vaultCacheKey || session?.email],
queryFn: () => getTotpStatus(authedFetch),
enabled: phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
staleTime: 30_000,
});
const authorizedDevicesQuery = useQuery({
queryKey: ['authorized-devices', vaultCacheKey || session?.email],
queryFn: () => getAuthorizedDevices(authedFetch),
enabled: phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
staleTime: 30_000,
});
useQuery({
queryKey: ['admin-backup-settings', vaultCacheKey],
queryFn: () => backupActions.loadSettings(),
enabled: phase === 'app' && isAdmin && vaultInitialDecryptDone,
enabled: !IS_DEMO_MODE && phase === 'app' && isAdmin && vaultInitialDecryptDone,
staleTime: 30_000,
});
@@ -864,6 +961,7 @@ export default function App() {
}, [phase, vaultInitialDecryptDone, isAdmin]);
useEffect(() => {
if (IS_DEMO_MODE) return;
if (phase !== 'app' || !session?.accessToken || !session?.symEncKey || !session?.symMacKey) return;
if (!vaultInitialDecryptDone) return;
if (!isAdminProfile(profile)) return;
@@ -879,6 +977,7 @@ export default function App() {
}, [session?.accessToken]);
useEffect(() => {
if (IS_DEMO_MODE) return;
if (!session?.symEncKey || !session?.symMacKey) {
setDecryptedFolders([]);
setDecryptedCiphers([]);
@@ -930,6 +1029,7 @@ export default function App() {
}, [session?.symEncKey, session?.symMacKey, encryptedFolders, encryptedCiphers]);
useEffect(() => {
if (IS_DEMO_MODE) return;
if (!session?.symEncKey || !session?.symMacKey) {
setDecryptedSends([]);
setSendsDecryptDone(false);
@@ -998,6 +1098,7 @@ export default function App() {
silentRefreshVaultRef.current = refreshVaultSilently;
useEffect(() => {
if (IS_DEMO_MODE) return;
if (phase !== 'app' || !session?.accessToken || !session?.symEncKey || !session?.symMacKey || !vaultInitialDecryptDone) return;
let disposed = false;
@@ -1348,6 +1449,24 @@ export default function App() {
onRestoreRemoteBackup: backupActions.restoreRemoteBackup,
onRestoreRemoteBackupAllowingChecksumMismatch: backupActions.restoreRemoteBackupAllowingChecksumMismatch,
};
const effectiveMainRoutesProps = IS_DEMO_MODE
? createDemoMainRoutesProps(mainRoutesProps, pushToast, {
ciphers: decryptedCiphers,
folders: decryptedFolders,
sends: decryptedSends,
users: demoUsers,
invites: demoInvites,
authorizedDevices: demoAuthorizedDevices,
backupSettings: demoBackupSettings,
setCiphers: setDecryptedCiphers,
setFolders: setDecryptedFolders,
setSends: setDecryptedSends,
setUsers: setDemoUsers,
setInvites: setDemoInvites,
setAuthorizedDevices: setDemoAuthorizedDevices,
setBackupSettings: setDemoBackupSettings,
})
: mainRoutesProps;
if (jwtWarning) {
return <JwtWarningPage reason={jwtWarning.reason} minLength={jwtWarning.minLength} />;
@@ -1394,6 +1513,9 @@ export default function App() {
<AuthViews
mode={phase}
pendingAction={pendingAuthAction}
relaxedLoginInput={IS_DEMO_MODE}
authPlaceholder={IS_DEMO_MODE ? t('txt_demo_auth_placeholder') : undefined}
unlockPlaceholder={IS_DEMO_MODE ? t('txt_demo_unlock_placeholder') : undefined}
unlockReady={!!session?.email}
unlockPreparing={unlockPreparing}
loginValues={loginValues}
@@ -1412,6 +1534,10 @@ export default function App() {
navigate('/login');
}}
onGotoRegister={() => {
if (IS_DEMO_MODE) {
pushToast('warning', t('txt_demo_readonly_message'));
return;
}
if (inviteCodeFromUrl) {
setRegisterValues((prev) => ({ ...prev, inviteCode: inviteCodeFromUrl }));
}
@@ -1478,7 +1604,7 @@ export default function App() {
onLogout={handleLogout}
onToggleTheme={handleToggleTheme}
onToggleMobileSidebar={() => setMobileSidebarToggleKey((key) => key + 1)}
mainRoutesProps={mainRoutesProps}
mainRoutesProps={effectiveMainRoutesProps}
/>
<AppGlobalOverlays
+9 -1
View File
@@ -19,6 +19,9 @@ interface RegisterValues {
interface AuthViewsProps {
mode: 'login' | 'register' | 'locked';
relaxedLoginInput?: boolean;
authPlaceholder?: string;
unlockPlaceholder?: string;
pendingAction: 'login' | 'register' | 'unlock' | null;
unlockReady: boolean;
unlockPreparing: boolean;
@@ -46,6 +49,7 @@ function PasswordField(props: {
onInput: (v: string) => void;
autoFocus?: boolean;
autoComplete?: string;
placeholder?: string;
}) {
const [show, setShow] = useState(false);
return (
@@ -59,6 +63,7 @@ function PasswordField(props: {
onInput={(e) => props.onInput((e.currentTarget as HTMLInputElement).value)}
autoFocus={props.autoFocus}
autoComplete={props.autoComplete}
placeholder={props.placeholder}
/>
<button type="button" className="eye-btn" onClick={() => setShow((v) => !v)}>
{show ? <EyeOff size={16} /> : <Eye size={16} />}
@@ -90,6 +95,7 @@ export default function AuthViews(props: AuthViewsProps) {
value={props.unlockPassword}
autoFocus
autoComplete="current-password"
placeholder={props.unlockPlaceholder}
onInput={props.onChangeUnlock}
/>
<div className="auth-support-row">
@@ -217,9 +223,10 @@ export default function AuthViews(props: AuthViewsProps) {
<span>{t('txt_email')}</span>
<input
className="input"
type="email"
type={props.relaxedLoginInput ? 'text' : 'email'}
value={props.loginValues.email}
autoComplete="username"
placeholder={props.authPlaceholder}
onInput={(e) => props.onChangeLogin({ ...props.loginValues, email: (e.currentTarget as HTMLInputElement).value })}
/>
</label>
@@ -227,6 +234,7 @@ export default function AuthViews(props: AuthViewsProps) {
label={t('txt_master_password')}
value={props.loginValues.password}
autoComplete="current-password"
placeholder={props.authPlaceholder}
onInput={(v) => props.onChangeLogin({ ...props.loginValues, password: v })}
autoFocus
/>
+17
View File
@@ -6,6 +6,7 @@ import { toBufferSource } from '@/lib/crypto';
import { downloadBytesAsFile, readResponseBytesWithProgress } from '@/lib/download';
import NotFoundPage from '@/components/NotFoundPage';
import StandalonePageFrame from '@/components/StandalonePageFrame';
import { getDemoPublicSend, IS_DEMO_MODE } from '@/lib/demo';
import { t } from '@/lib/i18n';
interface PublicSendPageProps {
@@ -108,6 +109,17 @@ export default function PublicSendPage(props: PublicSendPageProps) {
setNotFound(false);
setLoading(true);
try {
if (IS_DEMO_MODE) {
const demoSend = getDemoPublicSend(props.accessId);
if (!demoSend) {
setNotFound(true);
setSendData(null);
return;
}
setSendData(demoSend);
setNeedPassword(false);
return;
}
if (!hasUsableSendKey(props.keyPart)) {
setNotFound(true);
setSendData(null);
@@ -153,6 +165,11 @@ export default function PublicSendPage(props: PublicSendPageProps) {
setDownloadPercent(null);
setError('');
try {
if (IS_DEMO_MODE) {
const bytes = new TextEncoder().encode('NodeWarden demo file Send.\nThis download is generated locally in demo mode.\n');
downloadBytesAsFile(bytes, sendData.decFileName || sendData.file?.fileName || 'nodewarden-demo-send.txt', 'application/octet-stream');
return;
}
const url = await accessPublicSendFile(sendData.id, sendData.file.id, props.keyPart, password || undefined);
const resp = await fetch(url);
if (!resp.ok) throw new Error(t('txt_download_failed'));
+33 -1
View File
@@ -11,6 +11,7 @@ import {
import { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils';
const ICON_LOAD_ROOT_MARGIN = '180px 0px';
const SHOULD_LOAD_DEMO_BRAND_ICONS = __NODEWARDEN_DEMO__;
interface WebsiteIconProps {
cipher: Cipher;
@@ -24,6 +25,21 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
const [shouldLoad, setShouldLoad] = useState(() => (host ? getWebsiteIconStatus(host) === 'loaded' : true));
const [status, setStatus] = useState(() => (host ? getWebsiteIconStatus(host) : 'idle'));
const [imageUrl, setImageUrl] = useState(() => (host ? getWebsiteIconImageUrl(host) : ''));
const [demoIconUrl, setDemoIconUrl] = useState('');
useEffect(() => {
if (!SHOULD_LOAD_DEMO_BRAND_ICONS || !host) {
setDemoIconUrl('');
return;
}
let disposed = false;
void import('@/lib/demo-brand-icons').then(({ demoBrandIconUrl }) => {
if (!disposed) setDemoIconUrl(demoBrandIconUrl(host));
});
return () => {
disposed = true;
};
}, [host]);
useEffect(() => {
if (!host) {
@@ -72,6 +88,7 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
}, [host, shouldLoad, status]);
useEffect(() => {
if (demoIconUrl) return;
if (!host || !src || !shouldLoad || status === 'loaded' || status === 'error') return;
let disposed = false;
void preloadWebsiteIcon(host, src).then((nextStatus) => {
@@ -82,7 +99,21 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
return () => {
disposed = true;
};
}, [host, src, shouldLoad, status]);
}, [demoIconUrl, host, src, shouldLoad, status]);
if (demoIconUrl) {
return (
<span className="list-icon-stack" ref={nodeRef}>
<img
className="list-icon loaded"
src={demoIconUrl}
alt=""
loading="lazy"
decoding="async"
/>
</span>
);
}
if (!host || status === 'error') {
return <span className="list-icon-fallback">{props.fallback ?? <Globe size={18} />}</span>;
@@ -103,3 +134,4 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
</span>
);
}
File diff suppressed because one or more lines are too long
+42
View File
@@ -0,0 +1,42 @@
import type { AppMainRoutesProps } from '@/components/AppMainRoutes';
import type { CompletedLogin, InitialAppBootstrapState } from '@/lib/app-auth';
import type { AdminBackupSettings } from '@/lib/api/backup';
import type { AdminInvite, AdminUser, AuthorizedDevice, Cipher, Folder, Send } from '@/lib/types';
export const IS_DEMO_MODE = false;
export const DEMO_CIPHERS: Cipher[] = [];
export const DEMO_ADMIN_INVITES: AdminInvite[] = [];
export const DEMO_ADMIN_USERS: AdminUser[] = [];
export const DEMO_AUTHORIZED_DEVICES: AuthorizedDevice[] = [];
export const DEMO_FOLDERS: Folder[] = [];
export const DEMO_SENDS: Send[] = [];
export function createDemoBackupSettings(): AdminBackupSettings {
return { destinations: [] };
}
export function createDemoInitialBootstrapState(): InitialAppBootstrapState {
return {
defaultKdfIterations: 600000,
jwtWarning: null,
session: null,
phase: 'login',
};
}
export function createDemoCompletedLogin(): CompletedLogin {
throw new Error('Demo mode is not available in this build.');
}
export function createDemoMainRoutesProps(base: AppMainRoutesProps): AppMainRoutesProps {
return base;
}
export function getDemoPublicSend(): null {
return null;
}
export function demoBrandIconUrl(_host: string): string {
return '';
}
File diff suppressed because it is too large Load Diff
+8
View File
@@ -14,6 +14,14 @@ const en: Record<string, string> = {
"backup_strategy_under_construction": "Under construction.",
"import_export_title": "Import & Export",
"import_export_under_construction": "Under construction.",
"txt_demo_admin_refreshed": "Demo admin data refreshed.",
"txt_demo_auth_placeholder": "Demo: enter anything, or leave it empty",
"txt_demo_data_reset": "Demo data reset to defaults.",
"txt_demo_devices_refreshed": "Demo devices refreshed.",
"txt_demo_download_prepared": "Demo download prepared.",
"txt_demo_master_password_hint": "In demo mode, any input unlocks the vault.",
"txt_demo_readonly_message": "Demo mode is read-only for this action. No changes were saved.",
"txt_demo_unlock_placeholder": "Demo: any password works, even empty",
"txt_backup_export": "Export Backup",
"txt_backup_import": "Restore",
"txt_backup_include_attachments": "Include attachments",
+8
View File
@@ -14,6 +14,14 @@ const es: Record<string, string> = {
"backup_strategy_under_construction": "En construcción.",
"import_export_title": "Importar y exportar",
"import_export_under_construction": "En construcción.",
"txt_demo_admin_refreshed": "Datos de administración de la demo actualizados.",
"txt_demo_auth_placeholder": "Demo: escribe cualquier cosa, o déjalo vacío",
"txt_demo_data_reset": "Los datos de la demo volvieron a sus valores predeterminados.",
"txt_demo_devices_refreshed": "Dispositivos de la demo actualizados.",
"txt_demo_download_prepared": "Descarga de la demo preparada.",
"txt_demo_master_password_hint": "En modo demo, cualquier entrada desbloquea la bóveda.",
"txt_demo_readonly_message": "En modo demo, esta acción es de solo lectura. No se guardaron cambios.",
"txt_demo_unlock_placeholder": "Demo: cualquier contraseña funciona, incluso vacío",
"txt_backup_export": "Exportar copia de seguridad",
"txt_backup_import": "Restaurar",
"txt_backup_include_attachments": "Incluir archivos adjuntos",
+8
View File
@@ -15,6 +15,14 @@ const ru: Record<string, string> = {
"backup_strategy_under_construction": "В стадии строительства.",
"import_export_title": "Импорт и экспорт",
"import_export_under_construction": "В стадии строительства.",
"txt_demo_admin_refreshed": "Демо-данные администратора обновлены.",
"txt_demo_auth_placeholder": "Демо: введите что угодно или оставьте пустым",
"txt_demo_data_reset": "Демо-данные сброшены к значениям по умолчанию.",
"txt_demo_devices_refreshed": "Демо-устройства обновлены.",
"txt_demo_download_prepared": "Демо-загрузка подготовлена.",
"txt_demo_master_password_hint": "В демо-режиме любое значение разблокирует хранилище.",
"txt_demo_readonly_message": "В демо-режиме это действие только для чтения. Изменения не сохранены.",
"txt_demo_unlock_placeholder": "Демо: подойдет любой пароль, даже пустой",
"txt_backup_export": "Экспортировать резервную копию",
"txt_backup_import": "Восстановить",
"txt_backup_include_attachments": "Включить вложения",
+8
View File
@@ -14,6 +14,14 @@ const zhCN: Record<string, string> = {
"backup_strategy_under_construction": "正在搭建中",
"import_export_title": "导入导出",
"import_export_under_construction": "正在搭建中",
"txt_demo_admin_refreshed": "Demo 管理数据已刷新。",
"txt_demo_auth_placeholder": "Demo:随便输入,也可以留空",
"txt_demo_data_reset": "Demo 数据已恢复为默认状态。",
"txt_demo_devices_refreshed": "Demo 设备已刷新。",
"txt_demo_download_prepared": "Demo 下载已准备好。",
"txt_demo_master_password_hint": "Demo 模式下,任意输入都可以解锁保险库。",
"txt_demo_readonly_message": "Demo 模式下此操作为只读,未保存任何更改。",
"txt_demo_unlock_placeholder": "Demo:任意密码都可解锁,留空也可以",
"txt_backup_export": "导出备份",
"txt_backup_import": "还原",
"txt_backup_include_attachments": "包含附件",
+8
View File
@@ -14,6 +14,14 @@ const zhTW: Record<string, string> = {
"backup_strategy_under_construction": "正在搭建中",
"import_export_title": "導入導出",
"import_export_under_construction": "正在搭建中",
"txt_demo_admin_refreshed": "Demo 管理數據已刷新。",
"txt_demo_auth_placeholder": "Demo:隨便輸入,也可以留空",
"txt_demo_data_reset": "Demo 數據已恢復為默認狀態。",
"txt_demo_devices_refreshed": "Demo 設備已刷新。",
"txt_demo_download_prepared": "Demo 下載已準備好。",
"txt_demo_master_password_hint": "Demo 模式下,任意輸入都可以解鎖保險庫。",
"txt_demo_readonly_message": "Demo 模式下此操作為只讀,未保存任何更改。",
"txt_demo_unlock_placeholder": "Demo:任意密碼都可解鎖,留空也可以",
"txt_backup_export": "導出備份",
"txt_backup_import": "還原",
"txt_backup_include_attachments": "包含附件",
+2
View File
@@ -24,3 +24,5 @@ interface BarcodeDetectorConstructor {
interface Window {
BarcodeDetector?: BarcodeDetectorConstructor;
}
declare const __NODEWARDEN_DEMO__: boolean;
+79 -67
View File
@@ -5,82 +5,94 @@ import { defineConfig } from 'vite';
const rootDir = fileURLToPath(new URL('.', import.meta.url));
export default defineConfig({
root: rootDir,
plugins: [preact()],
resolve: {
alias: {
'@': path.resolve(rootDir, 'src'),
'@shared': path.resolve(rootDir, '../shared'),
export default defineConfig(({ mode }) => {
const isDemo = mode === 'demo';
return {
root: rootDir,
plugins: [preact()],
define: {
__NODEWARDEN_DEMO__: JSON.stringify(isDemo),
},
},
build: {
outDir: path.resolve(rootDir, '../dist'),
emptyOutDir: true,
sourcemap: false,
target: 'esnext',
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('/node_modules/')) {
return 'vendor';
}
resolve: {
alias: {
'@/lib/demo': path.resolve(rootDir, isDemo ? 'src/lib/demo.ts' : 'src/lib/demo.empty.ts'),
'@/lib/demo-brand-icons': path.resolve(
rootDir,
isDemo ? 'src/lib/demo-brand-icons.ts' : 'src/lib/demo.empty.ts'
),
'@': path.resolve(rootDir, 'src'),
'@shared': path.resolve(rootDir, '../shared'),
},
},
build: {
outDir: path.resolve(rootDir, '../dist'),
emptyOutDir: true,
sourcemap: false,
target: 'esnext',
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('/node_modules/')) {
return 'vendor';
}
const normalized = id.replace(/\\/g, '/');
const normalized = id.replace(/\\/g, '/');
const localeMatch = normalized.match(/\/src\/lib\/i18n\/locales\/(.+)\.ts$/);
if (localeMatch) {
if (localeMatch[1] === 'en') return undefined;
return `i18n-${localeMatch[1]}`;
}
const localeMatch = normalized.match(/\/src\/lib\/i18n\/locales\/(.+)\.ts$/);
if (localeMatch) {
if (localeMatch[1] === 'en') return undefined;
return `i18n-${localeMatch[1]}`;
}
if (normalized.includes('/src/lib/i18n.ts')) {
return 'i18n-core';
}
if (normalized.includes('/src/lib/i18n.ts')) {
return 'i18n-core';
}
if (
normalized.includes('/src/components/AuthViews.tsx') ||
normalized.includes('/src/components/PublicSendPage.tsx') ||
normalized.includes('/src/components/RecoverTwoFactorPage.tsx') ||
normalized.includes('/src/components/JwtWarningPage.tsx') ||
normalized.includes('/src/lib/app-auth.ts')
) {
return 'auth-suite';
}
if (
normalized.includes('/src/components/AuthViews.tsx') ||
normalized.includes('/src/components/PublicSendPage.tsx') ||
normalized.includes('/src/components/RecoverTwoFactorPage.tsx') ||
normalized.includes('/src/components/JwtWarningPage.tsx') ||
normalized.includes('/src/lib/app-auth.ts')
) {
return 'auth-suite';
}
if (
normalized.includes('/src/components/ImportPage.tsx') ||
normalized.includes('/src/lib/import-') ||
normalized.includes('/src/lib/export-formats.ts') ||
normalized.includes('/src/components/SendsPage.tsx') ||
normalized.includes('/src/components/TotpCodesPage.tsx') ||
normalized.includes('/src/components/BackupCenterPage.tsx') ||
normalized.includes('/src/components/backup-center/') ||
normalized.includes('/src/components/SettingsPage.tsx') ||
normalized.includes('/src/components/SecurityDevicesPage.tsx') ||
normalized.includes('/src/components/AdminPage.tsx')
) {
return 'workspace-suite';
}
if (
normalized.includes('/src/components/ImportPage.tsx') ||
normalized.includes('/src/lib/import-') ||
normalized.includes('/src/lib/export-formats.ts') ||
normalized.includes('/src/components/SendsPage.tsx') ||
normalized.includes('/src/components/TotpCodesPage.tsx') ||
normalized.includes('/src/components/BackupCenterPage.tsx') ||
normalized.includes('/src/components/backup-center/') ||
normalized.includes('/src/components/SettingsPage.tsx') ||
normalized.includes('/src/components/SecurityDevicesPage.tsx') ||
normalized.includes('/src/components/AdminPage.tsx')
) {
return 'workspace-suite';
}
return undefined;
return undefined;
},
},
},
},
},
server: {
port: 5173,
fs: {
allow: [path.resolve(rootDir, '..')],
server: {
port: 5173,
fs: {
allow: [path.resolve(rootDir, '..')],
},
proxy: {
'/api': 'http://127.0.0.1:8787',
'/identity': 'http://127.0.0.1:8787',
'/setup': 'http://127.0.0.1:8787',
'/icons': 'http://127.0.0.1:8787',
'/config': 'http://127.0.0.1:8787',
'/notifications': 'http://127.0.0.1:8787',
'/.well-known': 'http://127.0.0.1:8787',
},
},
proxy: {
'/api': 'http://127.0.0.1:8787',
'/identity': 'http://127.0.0.1:8787',
'/setup': 'http://127.0.0.1:8787',
'/icons': 'http://127.0.0.1:8787',
'/config': 'http://127.0.0.1:8787',
'/notifications': 'http://127.0.0.1:8787',
'/.well-known': 'http://127.0.0.1:8787',
},
},
};
});