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
+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