diff --git a/src/app/pages/auth/MasAccountNotice.tsx b/src/app/pages/auth/MasAccountNotice.tsx new file mode 100644 index 00000000..b8f506b4 --- /dev/null +++ b/src/app/pages/auth/MasAccountNotice.tsx @@ -0,0 +1,11 @@ +import { Text } from 'folds'; +import React from 'react'; + +export function MasAccountNotice({ url }: { url: string }) { + return ( + + Accounts here are managed by your sign-in service. Create or recover an account on its{' '} + account page. + + ); +} diff --git a/src/app/pages/auth/authFront.test.tsx b/src/app/pages/auth/authFront.test.tsx new file mode 100644 index 00000000..df15bbd5 --- /dev/null +++ b/src/app/pages/auth/authFront.test.tsx @@ -0,0 +1,113 @@ +import React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { renderToString } from 'react-dom/server'; +import { MemoryRouter } from 'react-router-dom'; +import { AutoDiscoveryInfoProvider } from '../../hooks/useAutoDiscoveryInfo'; +import { AuthServerProvider } from '../../hooks/useAuthServer'; +import { AuthFlowsProvider } from '../../hooks/useAuthFlows'; +import { ClientConfigProvider } from '../../hooks/useClientConfig'; +import type { ClientConfig } from '../../hooks/useClientConfig'; +import type { AutoDiscoveryInfo } from '../../cs-api'; +import { setSoftLogout } from '../../state/softLogout'; +import { Login } from './login/Login'; +import { ResetPassword } from './reset-password/ResetPassword'; + +vi.mock('../../../client/initMatrix', () => ({ clearLoginData: vi.fn() })); +// folds' Overlay portals into document, which the server renderer has none of. +vi.mock('folds', async (importOriginal) => ({ + ...(await importOriginal()), + Overlay: () => null, +})); +vi.mock('./reset-password/PasswordResetForm', () => ({ + PasswordResetForm: () =>
in-client reset form
, +})); + +const plain: AutoDiscoveryInfo = { 'm.homeserver': { base_url: 'https://hs.example' } }; +const mas: AutoDiscoveryInfo = { + ...plain, + 'org.matrix.msc2965.authentication': { account: 'https://auth.example/account' }, +}; +const entryConfig: ClientConfig = { + registration: { enabled: true, url: 'https://accounts.example/join' }, +}; +const flows = { + loginFlows: { flows: [{ type: 'm.login.password' }] }, + registerFlows: { status: 400 }, +} as never; + +const render = (ui: React.ReactElement, info: AutoDiscoveryInfo, config: ClientConfig = {}) => + renderToString( + + + + + {ui} + + + + + ); + +const store = new Map(); +beforeEach(() => { + store.clear(); + vi.stubGlobal('window', { location: { origin: 'https://app.example', search: '' } }); + vi.stubGlobal('localStorage', { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => store.set(k, v), + removeItem: (k: string) => store.delete(k), + }); +}); +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('Login register entries', () => { + it('non-MAS without an entry: the bare Register link, no notice', () => { + const html = render(, plain); + expect(html).toContain('Register'); + expect(html).not.toContain('managed by your sign-in service'); + }); + it('non-MAS with an entry: the entry and its notice', () => { + const html = render(, plain, entryConfig); + expect(html).toContain('href="https://accounts.example/join"'); + }); + it('MAS without an entry: the account page notice, no bare Register link', () => { + const html = render(, mas); + expect(html).toContain('managed by your sign-in service'); + expect(html).toContain('href="https://auth.example/account"'); + expect(html).not.toContain('Register'); + }); + it('MAS with an entry: the entry stays, no MAS notice', () => { + const html = render(, mas, entryConfig); + expect(html).toContain('href="https://accounts.example/join"'); + expect(html).not.toContain('managed by your sign-in service'); + }); +}); + +describe('PasswordLoginForm through Login', () => { + it('shows Forgot Password only without MAS', () => { + expect(render(, plain)).toContain('Forget Password?'); + expect(render(, mas)).not.toContain('Forget Password?'); + }); + it('fixes the username and offers someone else only with a soft record', () => { + expect(render(, plain)).not.toMatch(/readonly/i); + expect(render(, plain)).not.toContain('Sign in as someone else'); + setSoftLogout({ userId: '@a:hs.example', deviceId: 'DEV' }); + const html = render(, plain); + expect(html).toContain('value="@a:hs.example"'); + expect(html).toMatch(/readonly/i); + expect(html).toContain('Sign in as someone else'); + }); +}); + +describe('ResetPassword', () => { + it('shows the notice under MAS and the form otherwise', () => { + const under = render(, mas); + expect(under).toContain('href="https://auth.example/account"'); + expect(under).not.toContain('in-client reset form'); + const plainHtml = render(, plain); + expect(plainHtml).toContain('in-client reset form'); + expect(plainHtml).not.toContain('managed by your sign-in service'); + }); +}); diff --git a/src/app/pages/auth/login/Login.tsx b/src/app/pages/auth/login/Login.tsx index ea08e4f0..3606a9a4 100644 --- a/src/app/pages/auth/login/Login.tsx +++ b/src/app/pages/auth/login/Login.tsx @@ -15,6 +15,10 @@ import { getLoginPath, getRegisterPath, withSearchParam } from '../../pathUtils' import { usePathWithOrigin } from '../../../hooks/usePathWithOrigin'; import { LoginPathSearchParams } from '../../paths'; import { useClientConfig } from '../../../hooks/useClientConfig'; +import { useMasAccountUrl } from '../masAccount'; +import { MasAccountNotice } from '../MasAccountNotice'; +import { SoftLogoutNotice } from './SoftLogoutNotice'; +import { useSoftLogout } from '../../../state/softLogout'; import { noSignInMethodMessage } from '../authMessages'; const getLoginTokenSearchParam = () => { @@ -57,12 +61,16 @@ export function Login() { } const parsedFlows = useParsedLoginFlows(loginFlows.flows); + const masUrl = useMasAccountUrl(); + const registerEntry = registrationEntry(config); + const softLogout = useSoftLogout(); return ( Login + {softLogout && } {parsedFlows.token && loginSearchParams.loginToken && ( )} @@ -93,9 +101,9 @@ export function Login() { )} - {registrationEntry(config) ? ( - - ) : ( + {registerEntry && } + {!registerEntry && masUrl && } + {!registerEntry && !masUrl && ( Do not have an account? Register diff --git a/src/app/pages/auth/login/PasswordLoginForm.tsx b/src/app/pages/auth/login/PasswordLoginForm.tsx index 6372684c..63c28577 100644 --- a/src/app/pages/auth/login/PasswordLoginForm.tsx +++ b/src/app/pages/auth/login/PasswordLoginForm.tsx @@ -19,7 +19,7 @@ import { } from 'folds'; import FocusTrap from 'focus-trap-react'; import { Link } from 'react-router-dom'; -import { MatrixError } from 'matrix-js-sdk'; +import { LoginRequest, MatrixError } from 'matrix-js-sdk'; import { getMxIdLocalPart, getMxIdServer, isUserId } from '../../../utils/matrix'; import { EMAIL_REGEX } from '../../../utils/regex'; import { useAutoDiscoveryInfo } from '../../../hooks/useAutoDiscoveryInfo'; @@ -39,6 +39,8 @@ import { loginErrorMessage } from '../authMessages'; import { ForgotLink } from '../styles.css'; import { getResetPasswordPath } from '../../pathUtils'; import { stopPropagation } from '../../../utils/keyboard'; +import { useSoftLogout, withSoftLogoutDevice } from '../../../state/softLogout'; +import { useMasAccountUrl } from '../masAccount'; import { PhoneHit } from '../../../styles/PhoneHit.css'; function UsernameHint({ server }: { server: string }) { @@ -118,6 +120,10 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog const server = useAuthServer(); const clientConfig = useClientConfig(); + const softLogout = useSoftLogout(); + const masUrl = useMasAccountUrl(); + const withDevice = (request: LoginRequest) => withSoftLogoutDevice(request, softLogout); + const serverDiscovery = useAutoDiscoveryInfo(); const baseUrl = serverDiscovery['m.homeserver'].base_url; @@ -130,15 +136,18 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog useLoginComplete(loginState.status === AsyncStatus.Success ? loginState.data : undefined); const handleUsernameLogin = (username: string, password: string) => { - startLogin(baseUrl, { - type: 'm.login.password', - identifier: { - type: 'm.id.user', - user: username, - }, - password, - initial_device_display_name: 'Ratatoskr Web', - }); + startLogin( + baseUrl, + withDevice({ + type: 'm.login.password', + identifier: { + type: 'm.id.user', + user: username, + }, + password, + initial_device_display_name: 'Ratatoskr Web', + }) + ); }; const handleMxIdLogin = async (mxId: string, password: string) => { @@ -148,27 +157,33 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog const getBaseUrl = factoryGetBaseUrl(clientConfig, mxIdServer); - startLogin(getBaseUrl, { - type: 'm.login.password', - identifier: { - type: 'm.id.user', - user: mxIdUsername, - }, - password, - initial_device_display_name: 'Ratatoskr Web', - }); + startLogin( + getBaseUrl, + withDevice({ + type: 'm.login.password', + identifier: { + type: 'm.id.user', + user: mxIdUsername, + }, + password, + initial_device_display_name: 'Ratatoskr Web', + }) + ); }; const handleEmailLogin = (email: string, password: string) => { - startLogin(baseUrl, { - type: 'm.login.password', - identifier: { - type: 'm.id.thirdparty', - medium: 'email', - address: email, - }, - password, - initial_device_display_name: 'Ratatoskr Web', - }); + startLogin( + baseUrl, + withDevice({ + type: 'm.login.password', + identifier: { + type: 'm.id.thirdparty', + medium: 'email', + address: email, + }, + password, + initial_device_display_name: 'Ratatoskr Web', + }) + ); }; const handleSubmit: FormEventHandler = (evt) => { @@ -207,7 +222,8 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog Username )} - - - - Forget Password? - - - + {!masUrl && ( + + + + Forget Password? + + + + )} + + ); +} diff --git a/src/app/pages/auth/login/TokenLogin.tsx b/src/app/pages/auth/login/TokenLogin.tsx index 0541742d..51a4b46b 100644 --- a/src/app/pages/auth/login/TokenLogin.tsx +++ b/src/app/pages/auth/login/TokenLogin.tsx @@ -14,6 +14,7 @@ import React, { useCallback, useEffect } from 'react'; import { MatrixError } from 'matrix-js-sdk'; import { useAutoDiscoveryInfo } from '../../../hooks/useAutoDiscoveryInfo'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; +import { useSoftLogout, withSoftLogoutDevice } from '../../../state/softLogout'; import { CustomLoginResponse, LoginError, login, useLoginComplete } from './loginUtil'; function LoginTokenError({ message }: { message: string }) { @@ -46,6 +47,7 @@ type TokenLoginProps = { export function TokenLogin({ token }: TokenLoginProps) { const discovery = useAutoDiscoveryInfo(); const baseUrl = discovery['m.homeserver'].base_url; + const softLogout = useSoftLogout(); const [loginState, startLogin] = useAsyncCallback< CustomLoginResponse, @@ -54,12 +56,14 @@ export function TokenLogin({ token }: TokenLoginProps) { >(useCallback(login, [])); useEffect(() => { - startLogin(baseUrl, { - type: 'm.login.token', - token, - initial_device_display_name: 'Ratatoskr Web', - }); - }, [baseUrl, token, startLogin]); + startLogin( + baseUrl, + withSoftLogoutDevice( + { type: 'm.login.token', token, initial_device_display_name: 'Ratatoskr Web' }, + softLogout + ) + ); + }, [baseUrl, token, softLogout, startLogin]); useLoginComplete(loginState.status === AsyncStatus.Success ? loginState.data : undefined); diff --git a/src/app/pages/auth/login/loginUtil.ts b/src/app/pages/auth/login/loginUtil.ts index ba5b0bc9..d1cfea82 100644 --- a/src/app/pages/auth/login/loginUtil.ts +++ b/src/app/pages/auth/login/loginUtil.ts @@ -1,6 +1,6 @@ import to from 'await-to-js'; import { LoginRequest, LoginResponse, MatrixError, createClient } from 'matrix-js-sdk'; -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { ClientConfig, clientAllowedServer } from '../../../hooks/useClientConfig'; import { autoDiscovery, specVersions } from '../../../cs-api'; @@ -11,6 +11,8 @@ import { } from '../../afterLoginRedirectPath'; import { getHomePath } from '../../pathUtils'; import { setFallbackSession } from '../../../state/sessions'; +import { settleLocalData } from '../../../state/softLogout'; +import { wipeLocalData } from '../../../../client/wipeLocalData'; export enum GetBaseUrlError { NotAllow = 'NotAllow', @@ -110,14 +112,20 @@ export const login = async ( export const useLoginComplete = (data?: CustomLoginResponse) => { const navigate = useNavigate(); + const settled = useRef(); useEffect(() => { - if (data) { - const { response: loginRes, baseUrl: loginBaseUrl } = data; - setFallbackSession(loginRes.access_token, loginRes.device_id, loginRes.user_id, loginBaseUrl); + if (!data || settled.current === data) return; + settled.current = data; + const { response: loginRes, baseUrl: loginBaseUrl } = data; + const finish = async () => { const afterLoginRedirectUrl = getAfterLoginRedirectPath(); + // A soft logout left stores behind; they stay only for the same user on the same device. + await settleLocalData(loginRes, () => wipeLocalData()); + setFallbackSession(loginRes.access_token, loginRes.device_id, loginRes.user_id, loginBaseUrl); deleteAfterLoginRedirectPath(); navigate(afterLoginRedirectUrl ?? getHomePath(), { replace: true }); - } + }; + finish(); }, [data, navigate]); }; diff --git a/src/app/pages/auth/masAccount.test.ts b/src/app/pages/auth/masAccount.test.ts new file mode 100644 index 00000000..04bd6340 --- /dev/null +++ b/src/app/pages/auth/masAccount.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { masAccountUrl } from './masAccount'; +import type { AutoDiscoveryInfo } from '../../cs-api'; + +const info = (auth?: unknown): AutoDiscoveryInfo => + ({ + 'm.homeserver': { base_url: 'https://hs.example' }, + ...(auth === undefined ? {} : { 'org.matrix.msc2965.authentication': auth }), + } as AutoDiscoveryInfo); + +describe('masAccountUrl', () => { + it('prefers the account url, then the issuer', () => { + expect( + masAccountUrl(info({ account: 'https://a.example/acc', issuer: 'https://a.example/' })) + ).toBe('https://a.example/acc'); + expect(masAccountUrl(info({ issuer: 'https://a.example/' }))).toBe('https://a.example/'); + }); + it('is undefined when nothing is advertised', () => { + expect(masAccountUrl(info())).toBeUndefined(); + expect(masAccountUrl(info({}))).toBeUndefined(); + }); + it('accepts http only for localhost', () => { + expect(masAccountUrl(info({ account: 'http://a.example/acc' }))).toBeUndefined(); + expect(masAccountUrl(info({ account: 'http://localhost:8080/acc' }))).toBe( + 'http://localhost:8080/acc' + ); + }); + it('skips an unusable account url and falls to a usable issuer', () => { + expect(masAccountUrl(info({ account: 'not a url', issuer: 'https://a.example/' }))).toBe( + 'https://a.example/' + ); + }); + it('rejects non-strings and non-http schemes', () => { + expect(masAccountUrl(info({ account: 42, issuer: { x: 1 } }))).toBeUndefined(); + expect(masAccountUrl(info({ account: 'data:text/html,x' }))).toBeUndefined(); + expect(masAccountUrl(info({ account: 'ftp://a.example' }))).toBeUndefined(); + }); +}); diff --git a/src/app/pages/auth/masAccount.ts b/src/app/pages/auth/masAccount.ts new file mode 100644 index 00000000..6e65b2a4 --- /dev/null +++ b/src/app/pages/auth/masAccount.ts @@ -0,0 +1,18 @@ +import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo'; +import { isSecureOrLocalUrl } from '../../utils/secureUrl'; +import type { AutoDiscoveryInfo } from '../../cs-api'; + +const usable = (value: unknown): string | undefined => + typeof value === 'string' && isSecureOrLocalUrl(value) ? value : undefined; + +/** + * The account page of the sign-in service fronting this homeserver (MAS), when discovery + * advertises one: the account url, else the issuer, the same preference the devices page uses. + * Undefined means the homeserver manages its own accounts. + */ +export const masAccountUrl = (info: AutoDiscoveryInfo): string | undefined => { + const auth = info['org.matrix.msc2965.authentication']; + return usable(auth?.account) ?? usable(auth?.issuer); +}; + +export const useMasAccountUrl = (): string | undefined => masAccountUrl(useAutoDiscoveryInfo()); diff --git a/src/app/pages/auth/register/RegisterRoute.test.tsx b/src/app/pages/auth/register/RegisterRoute.test.tsx index 043c1174..47ea7179 100644 --- a/src/app/pages/auth/register/RegisterRoute.test.tsx +++ b/src/app/pages/auth/register/RegisterRoute.test.tsx @@ -3,16 +3,26 @@ import { describe, expect, it, vi } from 'vitest'; import { renderToString } from 'react-dom/server'; import { ClientConfigProvider } from '../../../hooks/useClientConfig'; import type { ClientConfig } from '../../../hooks/useClientConfig'; +import { AutoDiscoveryInfoProvider } from '../../../hooks/useAutoDiscoveryInfo'; +import type { AutoDiscoveryInfo } from '../../../cs-api'; import { RegisterRoute } from './RegisterRoute'; // The in-client form needs a homeserver; the route test only needs to know whether it was asked for. vi.mock('./Register', () => ({ Register: () =>
in-client register form
})); -const render = (config: ClientConfig) => +const plain: AutoDiscoveryInfo = { 'm.homeserver': { base_url: 'https://hs.example' } }; +const mas: AutoDiscoveryInfo = { + ...plain, + 'org.matrix.msc2965.authentication': { account: 'https://auth.example/account' }, +}; + +const render = (config: ClientConfig, info: AutoDiscoveryInfo = plain) => renderToString( - - - + + + + + ); describe('the /register route', () => { @@ -23,15 +33,31 @@ describe('the /register route', () => { }); it('with the switch off shows the in-client form', () => { - expect(render({ registration: { enabled: false, url: 'https://accounts.example/join' } })).toContain( - 'in-client register form' - ); + expect( + render({ registration: { enabled: false, url: 'https://accounts.example/join' } }) + ).toContain('in-client register form'); expect(render({})).toContain('in-client register form'); }); it('with the switch on but an unusable url falls back to the form', () => { - expect(render({ registration: { enabled: true, url: 'ftp://accounts.example/join' } })).toContain( - 'in-client register form' + expect( + render({ registration: { enabled: true, url: 'ftp://accounts.example/join' } }) + ).toContain('in-client register form'); + }); + + it('under MAS with no configured entry shows the account page notice, not the form', () => { + const html = render({}, mas); + expect(html).toContain('href="https://auth.example/account"'); + expect(html).not.toContain('in-client register form'); + }); + + it('under MAS a configured entry keeps its own page, not the notice or the form', () => { + const html = render( + { registration: { enabled: true, url: 'https://accounts.example/join' } }, + mas ); + expect(html).toContain('href="https://accounts.example/join"'); + expect(html).not.toContain('auth.example/account'); + expect(html).not.toContain('in-client register form'); }); }); diff --git a/src/app/pages/auth/register/RegisterRoute.tsx b/src/app/pages/auth/register/RegisterRoute.tsx index 094bc8fd..ee68dd45 100644 --- a/src/app/pages/auth/register/RegisterRoute.tsx +++ b/src/app/pages/auth/register/RegisterRoute.tsx @@ -2,6 +2,8 @@ import { Box, Text } from 'folds'; import React, { useEffect } from 'react'; import { useClientConfig } from '../../../hooks/useClientConfig'; import { registrationEntry } from '../registrationEntry'; +import { useMasAccountUrl } from '../masAccount'; +import { MasAccountNotice } from '../MasAccountNotice'; import { Register } from './Register'; // With the Register switch on, a stranger gets one way to register: the account service's own @@ -9,9 +11,22 @@ import { Register } from './Register'; // the switch off, the in-client form is the way. export function RegisterRoute() { const entry = registrationEntry(useClientConfig()); + const masUrl = useMasAccountUrl(); useEffect(() => { if (entry) window.location.replace(entry.url); }, [entry]); + // Under MAS the in-client sign-up (UIA) cannot work; with no configured entry the account page + // is the only way. A configured entry keeps its own page and its training notice. + if (!entry && masUrl) { + return ( + + + Register + + + + ); + } if (!entry) return ; return ( diff --git a/src/app/pages/auth/register/registerUtil.ts b/src/app/pages/auth/register/registerUtil.ts index 02f530fb..dca4dd17 100644 --- a/src/app/pages/auth/register/registerUtil.ts +++ b/src/app/pages/auth/register/registerUtil.ts @@ -6,7 +6,7 @@ import { RegisterRequest, RegisterResponse, } from 'matrix-js-sdk'; -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { LoginPathSearchParams } from '../../paths'; import { ErrorCode } from '../../../cs-errorcode'; @@ -17,6 +17,8 @@ import { import { getHomePath, getLoginPath, withSearchParam } from '../../pathUtils'; import { getMxIdLocalPart, getMxIdServer } from '../../../utils/matrix'; import { setFallbackSession } from '../../../state/sessions'; +import { settleLocalData } from '../../../state/softLogout'; +import { wipeLocalData } from '../../../../client/wipeLocalData'; import type { RegisterTrouble } from '../authMessages'; export enum RegisterError { @@ -131,9 +133,11 @@ export const register = async ( export const useRegisterComplete = (data?: CustomRegisterResponse) => { const navigate = useNavigate(); + const settled = useRef(); useEffect(() => { - if (data) { + if (data && settled.current !== data) { + settled.current = data; const { response, baseUrl } = data; const userId = response.user_id; @@ -141,10 +145,15 @@ export const useRegisterComplete = (data?: CustomRegisterResponse) => { const deviceId = response.device_id; if (accessToken && deviceId) { - setFallbackSession(accessToken, deviceId, userId, baseUrl); const afterLoginRedirectPath = getAfterLoginRedirectPath(); - deleteAfterLoginRedirectPath(); - navigate(afterLoginRedirectPath ?? getHomePath(), { replace: true }); + // A new account never boots on a soft-logged-out user's stores. + settleLocalData({ user_id: userId, device_id: deviceId }, () => wipeLocalData()).then( + () => { + setFallbackSession(accessToken, deviceId, userId, baseUrl); + deleteAfterLoginRedirectPath(); + navigate(afterLoginRedirectPath ?? getHomePath(), { replace: true }); + } + ); } else { const username = getMxIdLocalPart(userId); const userServer = getMxIdServer(userId); diff --git a/src/app/pages/auth/registrationEntry.ts b/src/app/pages/auth/registrationEntry.ts index 6c65732e..2aa0e34d 100644 --- a/src/app/pages/auth/registrationEntry.ts +++ b/src/app/pages/auth/registrationEntry.ts @@ -1,15 +1,9 @@ import type { ClientConfig } from '../../hooks/useClientConfig'; +import { isSecureOrLocalUrl } from '../../utils/secureUrl'; import { TRAINING_NOTICE_VERSION, trainingNoticeText } from '../../utils/trainingNotice'; export type RegistrationEntry = { url: string; notice: string; noticeVersion: number }; -// Plain http is for a developer's own machine only; a stranger's sign-up page is https. -const isLocalHost = (hostname: string): boolean => - hostname === 'localhost' || - hostname.endsWith('.localhost') || - hostname === '127.0.0.1' || - hostname === '[::1]'; - /** * The sign-up entry the sign-in page may show: the account service's registration page, when the * switch is on and the configured url is an https address (http only for localhost). Anything else is no entry. The @@ -18,12 +12,7 @@ const isLocalHost = (hostname: string): boolean => export const registrationEntry = (config: ClientConfig): RegistrationEntry | undefined => { const { enabled, url } = config.registration ?? {}; if (enabled !== true || typeof url !== 'string') return undefined; - try { - const { protocol, hostname } = new URL(url); - if (protocol !== 'https:' && !(protocol === 'http:' && isLocalHost(hostname))) return undefined; - } catch { - return undefined; - } + if (!isSecureOrLocalUrl(url)) return undefined; return { url, notice: trainingNoticeText(config.previewDemo), diff --git a/src/app/pages/auth/reset-password/ResetPassword.tsx b/src/app/pages/auth/reset-password/ResetPassword.tsx index c5e1d2ad..b9c2e9a6 100644 --- a/src/app/pages/auth/reset-password/ResetPassword.tsx +++ b/src/app/pages/auth/reset-password/ResetPassword.tsx @@ -4,6 +4,8 @@ import { Link, useSearchParams } from 'react-router-dom'; import { getLoginPath } from '../../pathUtils'; import { useAuthServer } from '../../../hooks/useAuthServer'; import { PasswordResetForm } from './PasswordResetForm'; +import { useMasAccountUrl } from '../masAccount'; +import { MasAccountNotice } from '../MasAccountNotice'; import { ResetPasswordPathSearchParams } from '../../paths'; const useResetPasswordSearchParams = ( @@ -18,6 +20,7 @@ const useResetPasswordSearchParams = ( export function ResetPassword() { const server = useAuthServer(); + const masUrl = useMasAccountUrl(); const [searchParams] = useSearchParams(); const resetPasswordSearchParams = useResetPasswordSearchParams(searchParams); @@ -26,7 +29,11 @@ export function ResetPassword() { Reset Password - + {masUrl ? ( + + ) : ( + + )} diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 565ff058..ba1876ce 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -23,6 +23,7 @@ import { logoutClient, startClient, } from '../../../client/initMatrix'; +import { wipeLocalData } from '../../../client/wipeLocalData'; import { SplashScreen } from '../../components/splash-screen'; import { ServerConfigsLoader } from '../../components/ServerConfigsLoader'; import { CapabilitiesProvider } from '../../hooks/useCapabilities'; @@ -35,6 +36,8 @@ import { stopPropagation } from '../../utils/keyboard'; import { SyncStatus } from './SyncStatus'; import { AuthMetadataProvider } from '../../hooks/useAuthMetadata'; import { getFallbackSession } from '../../state/sessions'; +import { createLogoutHandler } from '../../state/softLogout'; +import { pushSessionToSW } from '../../../sw-session'; import { AutoDiscovery } from './AutoDiscovery'; import { PhoneHit } from '../../styles/PhoneHit.css'; @@ -127,12 +130,15 @@ function ClientRootOptions({ mx }: { mx?: MatrixClient }) { const useLogoutListener = (mx?: MatrixClient) => { useEffect(() => { - const handleLogout: HttpApiEventHandlerMap[HttpApiEvent.SessionLoggedOut] = async () => { - mx?.stopClient(); - await mx?.clearStores(); - window.localStorage.clear(); - window.location.reload(); - }; + const handleLogout: HttpApiEventHandlerMap[HttpApiEvent.SessionLoggedOut] = createLogoutHandler( + { + storage: window.localStorage, + stop: () => mx?.stopClient(), + wipe: () => wipeLocalData(mx), + reload: () => window.location.reload(), + forgetSessionInWorker: () => pushSessionToSW(), + } + ); mx?.on(HttpApiEvent.SessionLoggedOut, handleLogout); return () => { diff --git a/src/app/state/softLogout.test.ts b/src/app/state/softLogout.test.ts new file mode 100644 index 00000000..2f113dd4 --- /dev/null +++ b/src/app/state/softLogout.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, it } from 'vitest'; +import { MatrixError } from 'matrix-js-sdk'; +import { setFallbackSession, getFallbackSession } from './sessions'; +import { + beginSoftLogout, + classifyLogout, + clearSoftLogout, + createLogoutHandler, + settleLocalData, + decideLoginOutcome, + getSoftLogout, + setSoftLogout, + withSoftLogoutDevice, +} from './softLogout'; + +const memoryStorage = (): Storage => { + const m = new Map(); + return { + get length() { + return m.size; + }, + clear: () => m.clear(), + getItem: (k) => m.get(k) ?? null, + key: (i) => [...m.keys()][i] ?? null, + removeItem: (k) => { + m.delete(k); + }, + setItem: (k, v) => { + m.set(k, String(v)); + }, + }; +}; + +const record = { userId: '@a:hs', deviceId: 'DEV' }; + +describe('classifyLogout', () => { + it('is soft only for soft_logout === true', () => { + expect(classifyLogout(new MatrixError({ errcode: 'M_UNKNOWN_TOKEN', soft_logout: true }))).toBe( + 'soft' + ); + expect(classifyLogout({ data: { soft_logout: true } })).toBe('soft'); + }); + it('is hard for false, absent, string-ish, and non-MatrixError values', () => { + expect( + classifyLogout(new MatrixError({ errcode: 'M_UNKNOWN_TOKEN', soft_logout: false })) + ).toBe('hard'); + expect(classifyLogout(new MatrixError({ errcode: 'M_UNKNOWN_TOKEN' }))).toBe('hard'); + expect(classifyLogout({ data: { soft_logout: 'true' } })).toBe('hard'); + expect(classifyLogout(undefined)).toBe('hard'); + expect(classifyLogout(null)).toBe('hard'); + expect(classifyLogout('boom')).toBe('hard'); + expect(classifyLogout(new Error('x'))).toBe('hard'); + }); +}); + +describe('the soft logout record', () => { + it('round-trips and clears', () => { + const s = memoryStorage(); + expect(getSoftLogout(s)).toBeUndefined(); + setSoftLogout(record, s); + expect(getSoftLogout(s)).toEqual(record); + clearSoftLogout(s); + expect(getSoftLogout(s)).toBeUndefined(); + }); + it('treats garbage and partial records as absent', () => { + const s = memoryStorage(); + s.setItem('ratatoskr_soft_logout', 'not json'); + expect(getSoftLogout(s)).toBeUndefined(); + s.setItem('ratatoskr_soft_logout', JSON.stringify({ userId: '@a:hs', deviceId: '' })); + expect(getSoftLogout(s)).toBeUndefined(); + }); +}); + +describe('beginSoftLogout', () => { + it('keeps the device keys and the record, drops only the access token', () => { + const s = memoryStorage(); + const real = globalThis.localStorage; + Object.defineProperty(globalThis, 'localStorage', { value: s, configurable: true }); + try { + setFallbackSession('tok', 'DEV', '@a:hs', 'https://hs.example'); + s.setItem('other_key', 'kept'); + } finally { + Object.defineProperty(globalThis, 'localStorage', { value: real, configurable: true }); + } + expect(beginSoftLogout(s)).toBe(true); + expect(getFallbackSession(s)).toBeUndefined(); + expect(s.getItem('cinny_access_token')).toBeNull(); + expect(s.getItem('cinny_device_id')).toBe('DEV'); + expect(s.getItem('other_key')).toBe('kept'); + expect(getSoftLogout(s)).toEqual(record); + }); + it('does nothing without a session', () => { + const s = memoryStorage(); + expect(beginSoftLogout(s)).toBe(false); + expect(getSoftLogout(s)).toBeUndefined(); + }); +}); + +describe('withSoftLogoutDevice', () => { + const req = { type: 'm.login.password', password: 'p' } as const; + it('asks for the recorded device on password and token requests alike', () => { + expect(withSoftLogoutDevice(req, record)).toEqual({ ...req, device_id: 'DEV' }); + const tok = { type: 'm.login.token', token: 't' }; + expect(withSoftLogoutDevice(tok, record)).toEqual({ ...tok, device_id: 'DEV' }); + }); + it('leaves the request alone without a record', () => { + expect(withSoftLogoutDevice(req, undefined)).toBe(req); + }); +}); + +describe('decideLoginOutcome', () => { + it('is plain without a record', () => { + expect(decideLoginOutcome(undefined, { user_id: '@a:hs', device_id: 'DEV' })).toBe('plain'); + }); + it('keeps for the same user and device', () => { + expect(decideLoginOutcome(record, { user_id: '@a:hs', device_id: 'DEV' })).toBe('keep'); + }); + it('wipes for another user or another device', () => { + expect(decideLoginOutcome(record, { user_id: '@b:hs', device_id: 'DEV' })).toBe('wipe'); + expect(decideLoginOutcome(record, { user_id: '@a:hs', device_id: 'NEW' })).toBe('wipe'); + }); +}); + +describe('the logout handler', () => { + const rig = (soft: boolean) => { + const storage = memoryStorage(); + storage.setItem('cinny_access_token', 'tok'); + storage.setItem('cinny_device_id', 'DEV'); + storage.setItem('cinny_user_id', '@a:hs'); + storage.setItem('cinny_hs_base_url', 'https://hs.example'); + const calls = { stop: 0, wipe: 0, reload: 0, worker: 0 }; + const handler = createLogoutHandler({ + storage, + stop: () => { + calls.stop += 1; + }, + wipe: async () => { + calls.wipe += 1; + }, + reload: () => { + calls.reload += 1; + }, + forgetSessionInWorker: () => { + calls.worker += 1; + }, + }); + const err = { data: { soft_logout: soft } }; + return { storage, calls, handler, err }; + }; + + it('soft twice: one begin, no wipe, record kept', async () => { + const { storage, calls, handler, err } = rig(true); + await handler(err); + await handler(err); + expect(calls).toEqual({ stop: 1, wipe: 0, reload: 1, worker: 1 }); + expect(getSoftLogout(storage)).toEqual(record); + expect(storage.getItem('cinny_device_id')).toBe('DEV'); + }); + it('soft then hard: the later event does not wipe', async () => { + const { calls, handler } = rig(true); + await handler({ data: { soft_logout: true } }); + await handler({ data: {} }); + expect(calls.wipe).toBe(0); + }); + it('a record already standing swallows a fresh handler too', async () => { + const { storage, calls, handler } = rig(false); + setSoftLogout(record, storage); + await handler({ data: {} }); + expect(calls).toEqual({ stop: 0, wipe: 0, reload: 0, worker: 0 }); + }); + it('soft with no session in storage: one wipe, no record', async () => { + const { storage, calls, handler } = rig(true); + storage.removeItem('cinny_access_token'); + await handler({ data: { soft_logout: true } }); + expect(calls).toEqual({ stop: 1, wipe: 1, reload: 1, worker: 0 }); + expect(getSoftLogout(storage)).toBeUndefined(); + }); + it('hard twice: one wipe', async () => { + const { calls, handler, err } = rig(false); + await handler(err); + await handler(err); + expect(calls).toEqual({ stop: 1, wipe: 1, reload: 1, worker: 0 }); + }); +}); + +describe('settleLocalData', () => { + const run = async ( + rec: typeof record | undefined, + res: { user_id: string; device_id: string } + ) => { + const s = memoryStorage(); + if (rec) setSoftLogout(rec, s); + let wipes = 0; + const outcome = await settleLocalData( + res, + async () => { + wipes += 1; + }, + s + ); + return { outcome, wipes, left: getSoftLogout(s) }; + }; + it('same user and device: keep stores, retire the record', async () => { + expect(await run(record, { user_id: '@a:hs', device_id: 'DEV' })).toEqual({ + outcome: 'keep', + wipes: 0, + left: undefined, + }); + }); + it('another user or device (a new account too): wipe once, retire the record', async () => { + expect(await run(record, { user_id: '@new:hs', device_id: 'X' })).toEqual({ + outcome: 'wipe', + wipes: 1, + left: undefined, + }); + expect((await run(record, { user_id: '@a:hs', device_id: 'OTHER' })).wipes).toBe(1); + }); + it('no record: plain, no wipe', async () => { + expect((await run(undefined, { user_id: '@a:hs', device_id: 'D' })).wipes).toBe(0); + }); +}); diff --git a/src/app/state/softLogout.ts b/src/app/state/softLogout.ts new file mode 100644 index 00000000..bf0c3fd1 --- /dev/null +++ b/src/app/state/softLogout.ts @@ -0,0 +1,125 @@ +import { useState } from 'react'; +import type { LoginRequest } from 'matrix-js-sdk'; +import { getFallbackSession } from './sessions'; + +// A soft logout is the server saying "this token is dead, the device is not": the local stores +// stay, only the access token goes, and this record says whose device to sign back in as. +const SOFT_LOGOUT_KEY = 'ratatoskr_soft_logout'; +const ACCESS_TOKEN_KEY = 'cinny_access_token'; + +export type SoftLogoutRecord = { + userId: string; + deviceId: string; +}; + +export const getSoftLogout = (storage: Storage = localStorage): SoftLogoutRecord | undefined => { + const raw = storage.getItem(SOFT_LOGOUT_KEY); + if (!raw) return undefined; + try { + const { userId, deviceId } = JSON.parse(raw) as Partial; + if (typeof userId === 'string' && typeof deviceId === 'string' && userId && deviceId) { + return { userId, deviceId }; + } + } catch { + // an unreadable record is no record + } + return undefined; +}; + +export const setSoftLogout = (record: SoftLogoutRecord, storage: Storage = localStorage) => { + storage.setItem(SOFT_LOGOUT_KEY, JSON.stringify(record)); +}; + +export const clearSoftLogout = (storage: Storage = localStorage) => { + storage.removeItem(SOFT_LOGOUT_KEY); +}; + +export type LogoutKind = 'soft' | 'hard'; + +/** Soft only when the server said so: soft_logout === true. Anything else is hard. */ +export const classifyLogout = (err: unknown): LogoutKind => { + const data = (err as { data?: { soft_logout?: unknown } } | null | undefined)?.data; + return data?.soft_logout === true ? 'soft' : 'hard'; +}; + +/** + * Enter the soft-logout state: remember the device, drop only the access token. Returns false + * (and changes nothing) when there is no session to remember, which the caller treats as hard. + */ +export const beginSoftLogout = (storage: Storage = localStorage): boolean => { + const session = getFallbackSession(storage); + if (!session) return false; + setSoftLogout({ userId: session.userId, deviceId: session.deviceId }, storage); + storage.removeItem(ACCESS_TOKEN_KEY); + return true; +}; + +/** Every login request while a record stands asks for the same device back. */ +export const withSoftLogoutDevice = ( + request: LoginRequest, + record: SoftLogoutRecord | undefined +): LoginRequest => (record ? { ...request, device_id: record.deviceId } : request); + +export type LoginOutcome = 'plain' | 'keep' | 'wipe'; + +/** + * What a finished login does to local data. No record: a plain login, as ever. A record that + * names this very user and device: keep the stores. Any other answer: the stores belong to + * someone else, wipe them first. + */ +export const decideLoginOutcome = ( + record: SoftLogoutRecord | undefined, + response: { user_id: string; device_id: string } +): LoginOutcome => { + if (!record) return 'plain'; + return record.userId === response.user_id && record.deviceId === response.device_id + ? 'keep' + : 'wipe'; +}; + +export const useSoftLogout = (): SoftLogoutRecord | undefined => useState(() => getSoftLogout())[0]; + +export type LogoutHandlerPorts = { + storage: Storage; + stop: () => void; + wipe: () => Promise; + reload: () => void; + forgetSessionInWorker: () => void; +}; + +/** + * The SessionLoggedOut handler. The SDK emits once per failing request and in-flight requests + * outlive stopClient, so the first event decides and every later one is ignored: a second soft + * event must never find the token already gone and fall into the wipe. + */ +export const createLogoutHandler = (ports: LogoutHandlerPorts) => { + let fired = false; + return async (err: unknown): Promise => { + if (fired || getSoftLogout(ports.storage)) return; + fired = true; + ports.stop(); + // Soft needs a session to remember; without one nothing can vouch for the stores, so wipe. + if (classifyLogout(err) === 'soft' && beginSoftLogout(ports.storage)) { + ports.forgetSessionInWorker(); + ports.reload(); + return; + } + await ports.wipe(); + ports.reload(); + }; +}; + +/** + * Settle local data when a login or registration hands back a session: wipe first when a soft + * logout left stores that belong to someone else, then retire the record. + */ +export const settleLocalData = async ( + response: { user_id: string; device_id: string }, + wipe: () => Promise, + storage: Storage = localStorage +): Promise => { + const outcome = decideLoginOutcome(getSoftLogout(storage), response); + if (outcome === 'wipe') await wipe(); + clearSoftLogout(storage); + return outcome; +}; diff --git a/src/app/utils/secureUrl.ts b/src/app/utils/secureUrl.ts new file mode 100644 index 00000000..f1dacd9c --- /dev/null +++ b/src/app/utils/secureUrl.ts @@ -0,0 +1,16 @@ +// Plain http is for a developer's own machine only; a stranger's page is https. +const isLocalHost = (hostname: string): boolean => + hostname === 'localhost' || + hostname.endsWith('.localhost') || + hostname === '127.0.0.1' || + hostname === '[::1]'; + +/** True for an https url, or an http url on localhost. Anything else, including non-urls, is false. */ +export const isSecureOrLocalUrl = (value: string): boolean => { + try { + const { protocol, hostname } = new URL(value); + return protocol === 'https:' || (protocol === 'http:' && isLocalHost(hostname)); + } catch { + return false; + } +}; diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 871c186a..a5cc2b35 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -7,6 +7,7 @@ import { SyncState, } from 'matrix-js-sdk'; +import { wipeLocalData } from './wipeLocalData'; import { cryptoCallbacks } from './secretStorageKeys'; import { clearNavToActivePathStore } from '../app/state/navToActivePath'; import { pushSessionToSW } from '../sw-session'; @@ -119,21 +120,11 @@ export const logoutClient = async (mx: MatrixClient) => { } catch { // ignore if failed to logout } - await mx.clearStores(); - window.localStorage.clear(); + await wipeLocalData(mx); window.location.reload(); }; export const clearLoginData = async () => { - const dbs = await window.indexedDB.databases(); - - dbs.forEach((idbInfo) => { - const { name } = idbInfo; - if (name) { - window.indexedDB.deleteDatabase(name); - } - }); - - window.localStorage.clear(); + await wipeLocalData(); window.location.reload(); }; diff --git a/src/client/wipeLocalData.test.ts b/src/client/wipeLocalData.test.ts new file mode 100644 index 00000000..82c835de --- /dev/null +++ b/src/client/wipeLocalData.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; +import { KNOWN_DATABASES, wipeLocalData } from './wipeLocalData'; + +type Mode = 'success' | 'blocked' | 'error' | 'throws'; + +const rig = (opts: { databases?: () => Promise<{ name?: string }[]>; mode?: Mode }) => { + const deleted: string[] = []; + let cleared = 0; + const indexedDB = { + databases: opts.databases, + deleteDatabase: (name: string) => { + deleted.push(name); + if (opts.mode === 'throws') throw new Error('sync boom'); + const req: Record void) | undefined> = {}; + setTimeout(() => { + if (opts.mode === 'blocked') req.onblocked?.(); + else if (opts.mode === 'error') req.onerror?.(); + else req.onsuccess?.(); + }, 0); + return req as unknown as IDBOpenDBRequest; + }, + }; + const storage = { + clear: () => { + cleared += 1; + }, + }; + return { deleted, cleared: () => cleared, ports: { indexedDB, storage } }; +}; + +describe('wipeLocalData', () => { + it('deletes every listed database and clears storage', async () => { + const r = rig({ databases: async () => [{ name: 'a' }, { name: 'b' }, {}] }); + await wipeLocalData(undefined, r.ports); + expect(r.deleted).toEqual(['a', 'b']); + expect(r.cleared()).toBe(1); + }); + it('falls back to the known names when databases() is absent or throws', async () => { + const absent = rig({}); + await wipeLocalData(undefined, absent.ports); + expect(absent.deleted).toEqual(KNOWN_DATABASES); + expect(KNOWN_DATABASES).toEqual([ + 'web-sync-store', + 'crypto-store', + 'matrix-js-sdk::matrix-sdk-crypto', + 'matrix-js-sdk::matrix-sdk-crypto-meta', + ]); + expect(absent.cleared()).toBe(1); + const throws = rig({ + databases: async () => { + throw new Error('nope'); + }, + }); + await wipeLocalData(undefined, throws.ports); + expect(throws.deleted).toEqual(KNOWN_DATABASES); + expect(throws.cleared()).toBe(1); + }); + it('a synchronous deleteDatabase throw does not abort the rest or the storage clear', async () => { + const r = rig({ databases: async () => [{ name: 'a' }, { name: 'b' }], mode: 'throws' }); + await expect(wipeLocalData(undefined, r.ports)).resolves.toBeUndefined(); + expect(r.deleted).toEqual(['a', 'b']); + expect(r.cleared()).toBe(1); + }); + it('blocked and errored deletions settle and still clear storage', async () => { + await Promise.all( + (['blocked', 'error'] as const).map(async (mode) => { + const r = rig({ databases: async () => [{ name: 'a' }], mode }); + await wipeLocalData(undefined, r.ports); + expect(r.cleared()).toBe(1); + }) + ); + }); + it('a throwing clearStores does not stop the wipe', async () => { + const r = rig({ databases: async () => [{ name: 'a' }] }); + const mx = { + clearStores: async () => { + throw new Error('x'); + }, + }; + await wipeLocalData(mx as never, r.ports); + expect(r.deleted).toEqual(['a']); + expect(r.cleared()).toBe(1); + }); + it('a storage that refuses to clear does not make the wipe reject', async () => { + const r = rig({ databases: async () => [] }); + const ports = { + ...r.ports, + storage: { + clear: () => { + throw new Error('SecurityError'); + }, + }, + }; + await expect(wipeLocalData(undefined, ports)).resolves.toBeUndefined(); + }); + it('a client store that never closes holds the wipe only for the timeout, then storage is cleared', async () => { + const r = rig({ databases: async () => [{ name: 'a' }] }); + const mx = { + clearStores: () => + new Promise(() => { + // never settles + }), + } as never; + await wipeLocalData(mx, { ...r.ports, clearStoresTimeoutMs: 10 }); + expect(r.deleted).toEqual(['a']); + expect(r.cleared()).toBe(1); + }); +}); diff --git a/src/client/wipeLocalData.ts b/src/client/wipeLocalData.ts new file mode 100644 index 00000000..d0a2da86 --- /dev/null +++ b/src/client/wipeLocalData.ts @@ -0,0 +1,72 @@ +import type { MatrixClient } from 'matrix-js-sdk'; + +// The rust crypto databases the SDK names, for browsers without indexedDB.databases(). +export const KNOWN_DATABASES = [ + 'web-sync-store', + 'crypto-store', + 'matrix-js-sdk::matrix-sdk-crypto', + 'matrix-js-sdk::matrix-sdk-crypto-meta', +]; + +export type WipePorts = { + indexedDB: Pick & { + databases?: () => Promise<{ name?: string }[]>; + }; + storage: Pick; + /** How long a client store that will not close may hold the wipe up. */ + clearStoresTimeoutMs?: number; +}; + +const CLEAR_STORES_TIMEOUT_MS = 5000; + +const deleteDatabase = (idb: WipePorts['indexedDB'], name: string) => + new Promise((resolve) => { + try { + const req = idb.deleteDatabase(name); + req.onsuccess = () => resolve(); + req.onerror = () => resolve(); + req.onblocked = () => resolve(); + } catch { + // one database failing must not stop the rest + resolve(); + } + }); + +/** + * The one wipe of everything this browser holds for the account: client stores, every IndexedDB + * database, localStorage. Hard logout, a soft logout answered by another user or device, and + * "sign in as someone else" all end here. It never rejects, and localStorage (the access token + * with it) is cleared whatever the databases do; a client store that hangs is given + * `clearStoresTimeoutMs`, then the wipe goes on. Callers reload afterwards. + */ +export const wipeLocalData = async ( + mx?: MatrixClient, + ports: WipePorts = { indexedDB: window.indexedDB, storage: window.localStorage } +): Promise => { + try { + try { + await Promise.race([ + mx?.clearStores(), + new Promise((resolve) => { + setTimeout(resolve, ports.clearStoresTimeoutMs ?? CLEAR_STORES_TIMEOUT_MS); + }), + ]); + } catch { + // the databases go below regardless + } + let names = KNOWN_DATABASES; + try { + const listed = await ports.indexedDB.databases?.(); + if (listed) names = listed.map((db) => db.name).filter((n): n is string => !!n); + } catch { + // keep the known names + } + await Promise.all(names.map((name) => deleteDatabase(ports.indexedDB, name))); + } finally { + try { + ports.storage.clear(); + } catch { + // storage that refuses to clear cannot be helped; the reload still has to follow + } + } +};