diff --git a/package.json b/package.json index 3948fbf..445f08f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/pages-spa-redirects.cjs b/scripts/pages-spa-redirects.cjs new file mode 100644 index 0000000..04dd7a3 --- /dev/null +++ b/scripts/pages-spa-redirects.cjs @@ -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'); diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 3ed2643..b487018 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -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([]); const [decryptedCiphers, setDecryptedCiphers] = useState([]); const [decryptedSends, setDecryptedSends] = useState([]); + const [demoUsers, setDemoUsers] = useState(() => DEMO_ADMIN_USERS.map((user) => ({ ...user }))); + const [demoInvites, setDemoInvites] = useState(() => DEMO_ADMIN_INVITES.map((invite) => ({ ...invite }))); + const [demoAuthorizedDevices, setDemoAuthorizedDevices] = useState(() => DEMO_AUTHORIZED_DEVICES.map((device) => ({ ...device }))); + const [demoBackupSettings, setDemoBackupSettings] = useState(() => createDemoBackupSettings()); const [cachedVaultCore, setCachedVaultCore] = useState(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 ; @@ -1394,6 +1513,9 @@ export default function App() { { + 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} /> 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} />