Soft logout keeps local data and re-logs in on the same device; MAS front hides register and reset
Soft (soft_logout === true) drops only the access token and records the device; sign-in is fixed to that user and sends device_id; same user and device keeps the stores, anything else wipes via the one wipeLocalData. Hard logout still wipes. When discovery advertises an msc2965 account or issuer, the register and forgot-password entries give way to a notice pointing at the account page. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
dc9a541820
commit
4fd83fb4c4
15 changed files with 541 additions and 77 deletions
11
src/app/pages/auth/MasAccountNotice.tsx
Normal file
11
src/app/pages/auth/MasAccountNotice.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { Text } from 'folds';
|
||||
import React from 'react';
|
||||
|
||||
export function MasAccountNotice({ url }: { url: string }) {
|
||||
return (
|
||||
<Text align="Center">
|
||||
Accounts here are managed by your sign-in service. Create or recover an account on its{' '}
|
||||
<a href={url}>account page</a>.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,15 @@ export function Login() {
|
|||
}
|
||||
|
||||
const parsedFlows = useParsedLoginFlows(loginFlows.flows);
|
||||
const masUrl = useMasAccountUrl();
|
||||
const softLogout = useSoftLogout();
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="500">
|
||||
<Text size="H2" priority="400">
|
||||
Login
|
||||
</Text>
|
||||
{softLogout && <SoftLogoutNotice record={softLogout} />}
|
||||
{parsedFlows.token && loginSearchParams.loginToken && (
|
||||
<TokenLogin token={loginSearchParams.loginToken} />
|
||||
)}
|
||||
|
|
@ -93,9 +100,9 @@ export function Login() {
|
|||
<span data-spacing-node />
|
||||
</>
|
||||
)}
|
||||
{registrationEntry(config) ? (
|
||||
<RegisterEntry />
|
||||
) : (
|
||||
{masUrl && <MasAccountNotice url={masUrl} />}
|
||||
{!masUrl && registrationEntry(config) && <RegisterEntry />}
|
||||
{!masUrl && !registrationEntry(config) && (
|
||||
<Text align="Center">
|
||||
Do not have an account? <Link to={getRegisterPath(server)}>Register</Link>
|
||||
</Text>
|
||||
|
|
|
|||
|
|
@ -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<HTMLFormElement> = (evt) => {
|
||||
|
|
@ -207,7 +222,8 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog
|
|||
Username
|
||||
</Text>
|
||||
<Input
|
||||
defaultValue={defaultUsername ?? defaultEmail}
|
||||
defaultValue={softLogout?.userId ?? defaultUsername ?? defaultEmail}
|
||||
readOnly={softLogout !== undefined}
|
||||
style={{ paddingRight: config.space.S300 }}
|
||||
name="usernameInput"
|
||||
variant="Background"
|
||||
|
|
@ -252,13 +268,15 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog
|
|||
)}
|
||||
</>
|
||||
)}
|
||||
<Box grow="Yes" shrink="No" justifyContent="End">
|
||||
<Text as="span" size="T200" priority="400" align="Right">
|
||||
<Link className={ForgotLink} to={getResetPasswordPath(server)}>
|
||||
Forget Password?
|
||||
</Link>
|
||||
</Text>
|
||||
</Box>
|
||||
{!masUrl && (
|
||||
<Box grow="Yes" shrink="No" justifyContent="End">
|
||||
<Text as="span" size="T200" priority="400" align="Right">
|
||||
<Link className={ForgotLink} to={getResetPasswordPath(server)}>
|
||||
Forget Password?
|
||||
</Link>
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Button type="submit" variant="Primary" size="500">
|
||||
|
|
|
|||
20
src/app/pages/auth/login/SoftLogoutNotice.tsx
Normal file
20
src/app/pages/auth/login/SoftLogoutNotice.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { Box, Button, Text } from 'folds';
|
||||
import React from 'react';
|
||||
import { clearLoginData } from '../../../../client/initMatrix';
|
||||
import type { SoftLogoutRecord } from '../../../state/softLogout';
|
||||
|
||||
// Sign-in after the server dropped the token but not the device. "Someone else" is the hard path.
|
||||
export function SoftLogoutNotice({ record }: { record: SoftLogoutRecord }) {
|
||||
return (
|
||||
<Box direction="Column" gap="200" alignItems="Start">
|
||||
<Text size="T300">
|
||||
Your session expired. Your local data was kept; sign in as {record.userId} to pick it up.
|
||||
</Text>
|
||||
<Button type="button" variant="Secondary" fill="Soft" size="300" onClick={clearLoginData}>
|
||||
<Text as="span" size="B300">
|
||||
Sign in as someone else
|
||||
</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ import {
|
|||
} from '../../afterLoginRedirectPath';
|
||||
import { getHomePath } from '../../pathUtils';
|
||||
import { setFallbackSession } from '../../../state/sessions';
|
||||
import { clearSoftLogout, decideLoginOutcome, getSoftLogout } from '../../../state/softLogout';
|
||||
import { wipeLocalData } from '../../../../client/initMatrix';
|
||||
|
||||
export enum GetBaseUrlError {
|
||||
NotAllow = 'NotAllow',
|
||||
|
|
@ -112,12 +114,18 @@ export const useLoginComplete = (data?: CustomLoginResponse) => {
|
|||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
const { response: loginRes, baseUrl: loginBaseUrl } = data;
|
||||
setFallbackSession(loginRes.access_token, loginRes.device_id, loginRes.user_id, loginBaseUrl);
|
||||
if (!data) return;
|
||||
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.
|
||||
const outcome = decideLoginOutcome(getSoftLogout(), loginRes);
|
||||
if (outcome === 'wipe') await wipeLocalData();
|
||||
clearSoftLogout();
|
||||
setFallbackSession(loginRes.access_token, loginRes.device_id, loginRes.user_id, loginBaseUrl);
|
||||
deleteAfterLoginRedirectPath();
|
||||
navigate(afterLoginRedirectUrl ?? getHomePath(), { replace: true });
|
||||
}
|
||||
};
|
||||
finish();
|
||||
}, [data, navigate]);
|
||||
};
|
||||
|
|
|
|||
74
src/app/pages/auth/masAccount.test.ts
Normal file
74
src/app/pages/auth/masAccount.test.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'fs';
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
const src = (p: string) => readFileSync(`${process.cwd()}/src/app/pages/auth/${p}`, 'utf8');
|
||||
|
||||
describe('MAS front guards', () => {
|
||||
it('Login hides both register entries behind the MAS predicate', () => {
|
||||
const s = src('login/Login.tsx');
|
||||
expect(s).toContain('useMasAccountUrl()');
|
||||
expect(s).toContain('{masUrl && <MasAccountNotice');
|
||||
expect(s).toContain('{!masUrl && registrationEntry(config) && <RegisterEntry />}');
|
||||
expect(s).toContain('{!masUrl && !registrationEntry(config) && (');
|
||||
});
|
||||
it('PasswordLoginForm hides the forgot link behind the MAS predicate', () => {
|
||||
const s = src('login/PasswordLoginForm.tsx');
|
||||
expect(s).toContain('useMasAccountUrl()');
|
||||
expect(s).toMatch(/\{!masUrl && \([\s\S]*getResetPasswordPath/);
|
||||
});
|
||||
it('RegisterRoute and ResetPassword show the notice under MAS', () => {
|
||||
['register/RegisterRoute.tsx', 'reset-password/ResetPassword.tsx'].forEach((p) => {
|
||||
const s = src(p);
|
||||
expect(s).toContain('useMasAccountUrl()');
|
||||
expect(s).toContain('<MasAccountNotice');
|
||||
});
|
||||
});
|
||||
it('every login request path carries the soft-logout device', () => {
|
||||
expect(src('login/PasswordLoginForm.tsx').match(/withDevice\(\{/g)).toHaveLength(3);
|
||||
expect(src('login/TokenLogin.tsx')).toContain('withSoftLogoutDevice(');
|
||||
});
|
||||
it('the logout listener and login completion defer to the one wipe', () => {
|
||||
const root = readFileSync(`${process.cwd()}/src/app/pages/client/ClientRoot.tsx`, 'utf8');
|
||||
expect(root).toContain('classifyLogout(err)');
|
||||
expect(root).toContain('wipeLocalData(mx)');
|
||||
expect(root).not.toContain('localStorage.clear');
|
||||
expect(src('login/loginUtil.ts')).toContain('wipeLocalData()');
|
||||
});
|
||||
});
|
||||
32
src/app/pages/auth/masAccount.ts
Normal file
32
src/app/pages/auth/masAccount.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo';
|
||||
import type { AutoDiscoveryInfo } from '../../cs-api';
|
||||
|
||||
// Plain http is for a developer's own machine only.
|
||||
const isLocalHost = (hostname: string): boolean =>
|
||||
hostname === 'localhost' ||
|
||||
hostname.endsWith('.localhost') ||
|
||||
hostname === '127.0.0.1' ||
|
||||
hostname === '[::1]';
|
||||
|
||||
const usable = (value: unknown): string | undefined => {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
try {
|
||||
const { protocol, hostname } = new URL(value);
|
||||
if (protocol === 'https:' || (protocol === 'http:' && isLocalHost(hostname))) return value;
|
||||
} catch {
|
||||
// not a url
|
||||
}
|
||||
return 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());
|
||||
|
|
@ -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: () => <div>in-client register form</div> }));
|
||||
|
||||
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(
|
||||
<ClientConfigProvider value={config}>
|
||||
<RegisterRoute />
|
||||
</ClientConfigProvider>
|
||||
<AutoDiscoveryInfoProvider value={info}>
|
||||
<ClientConfigProvider value={config}>
|
||||
<RegisterRoute />
|
||||
</ClientConfigProvider>
|
||||
</AutoDiscoveryInfoProvider>
|
||||
);
|
||||
|
||||
describe('the /register route', () => {
|
||||
|
|
@ -23,15 +33,26 @@ 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 shows the account page notice, not the form, with the switch on or off', () => {
|
||||
[{}, { registration: { enabled: true, url: 'https://accounts.example/join' } }].forEach(
|
||||
(config) => {
|
||||
const html = render(config, mas);
|
||||
expect(html).toContain('href="https://auth.example/account"');
|
||||
expect(html).not.toContain('in-client register form');
|
||||
expect(html).not.toContain('Continue to sign up');
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,21 @@ 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]);
|
||||
if (entry && !masUrl) window.location.replace(entry.url);
|
||||
}, [entry, masUrl]);
|
||||
// Under MAS the in-client sign-up (UIA) cannot work; the account page is the only way.
|
||||
if (masUrl) {
|
||||
return (
|
||||
<Box direction="Column" gap="300">
|
||||
<Text size="H2" priority="400">
|
||||
Register
|
||||
</Text>
|
||||
<MasAccountNotice url={masUrl} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (!entry) return <Register />;
|
||||
return (
|
||||
<Box direction="Column" gap="300">
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<Text size="H2" priority="400">
|
||||
Reset Password
|
||||
</Text>
|
||||
<PasswordResetForm defaultEmail={resetPasswordSearchParams.email} />
|
||||
{masUrl ? (
|
||||
<MasAccountNotice url={masUrl} />
|
||||
) : (
|
||||
<PasswordResetForm defaultEmail={resetPasswordSearchParams.email} />
|
||||
)}
|
||||
<span data-spacing-node />
|
||||
|
||||
<Text align="Center">
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import React, { MouseEventHandler, ReactNode, useCallback, useEffect, useState }
|
|||
import {
|
||||
clearCacheAndReload,
|
||||
clearLoginData,
|
||||
wipeLocalData,
|
||||
initClient,
|
||||
logoutClient,
|
||||
startClient,
|
||||
|
|
@ -35,6 +36,8 @@ import { stopPropagation } from '../../utils/keyboard';
|
|||
import { SyncStatus } from './SyncStatus';
|
||||
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
|
||||
import { getFallbackSession } from '../../state/sessions';
|
||||
import { beginSoftLogout, classifyLogout } from '../../state/softLogout';
|
||||
import { pushSessionToSW } from '../../../sw-session';
|
||||
import { AutoDiscovery } from './AutoDiscovery';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
|
||||
|
|
@ -127,10 +130,15 @@ function ClientRootOptions({ mx }: { mx?: MatrixClient }) {
|
|||
|
||||
const useLogoutListener = (mx?: MatrixClient) => {
|
||||
useEffect(() => {
|
||||
const handleLogout: HttpApiEventHandlerMap[HttpApiEvent.SessionLoggedOut] = async () => {
|
||||
const handleLogout: HttpApiEventHandlerMap[HttpApiEvent.SessionLoggedOut] = async (err) => {
|
||||
mx?.stopClient();
|
||||
await mx?.clearStores();
|
||||
window.localStorage.clear();
|
||||
// Soft: the token is dead, the device is not. Keep the stores and ask for a sign-in.
|
||||
if (classifyLogout(err) === 'soft' && beginSoftLogout()) {
|
||||
pushSessionToSW();
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
await wipeLocalData(mx);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
|
|
|
|||
120
src/app/state/softLogout.test.ts
Normal file
120
src/app/state/softLogout.test.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { MatrixError } from 'matrix-js-sdk';
|
||||
import { setFallbackSession, getFallbackSession } from './sessions';
|
||||
import {
|
||||
beginSoftLogout,
|
||||
classifyLogout,
|
||||
clearSoftLogout,
|
||||
decideLoginOutcome,
|
||||
getSoftLogout,
|
||||
setSoftLogout,
|
||||
withSoftLogoutDevice,
|
||||
} from './softLogout';
|
||||
|
||||
const memoryStorage = (): Storage => {
|
||||
const m = new Map<string, string>();
|
||||
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', baseUrl: 'https://hs.example' };
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
91
src/app/state/softLogout.ts
Normal file
91
src/app/state/softLogout.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
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;
|
||||
baseUrl: string;
|
||||
};
|
||||
|
||||
export const getSoftLogout = (storage: Storage = localStorage): SoftLogoutRecord | undefined => {
|
||||
const raw = storage.getItem(SOFT_LOGOUT_KEY);
|
||||
if (!raw) return undefined;
|
||||
try {
|
||||
const { userId, deviceId, baseUrl } = JSON.parse(raw) as Partial<SoftLogoutRecord>;
|
||||
if (
|
||||
typeof userId === 'string' &&
|
||||
typeof deviceId === 'string' &&
|
||||
typeof baseUrl === 'string' &&
|
||||
userId &&
|
||||
deviceId &&
|
||||
baseUrl
|
||||
) {
|
||||
return { userId, deviceId, baseUrl };
|
||||
}
|
||||
} 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, baseUrl: session.baseUrl },
|
||||
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];
|
||||
|
|
@ -111,6 +111,45 @@ export const clearCacheAndReload = async (mx: MatrixClient) => {
|
|||
window.location.reload();
|
||||
};
|
||||
|
||||
// The rust crypto databases the SDK names, for browsers without indexedDB.databases().
|
||||
const KNOWN_DATABASES = [
|
||||
'web-sync-store',
|
||||
'crypto-store',
|
||||
'matrix-js-sdk::matrix-sdk-crypto',
|
||||
'matrix-js-sdk::matrix-sdk-crypto-meta',
|
||||
];
|
||||
|
||||
const deleteDatabase = (name: string) =>
|
||||
new Promise<void>((resolve) => {
|
||||
const req = window.indexedDB.deleteDatabase(name);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => resolve();
|
||||
req.onblocked = () => 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. Callers reload afterwards.
|
||||
*/
|
||||
export const wipeLocalData = async (mx?: MatrixClient) => {
|
||||
try {
|
||||
await mx?.clearStores();
|
||||
} catch {
|
||||
// the databases go below regardless
|
||||
}
|
||||
let names: string[];
|
||||
try {
|
||||
names = (await window.indexedDB.databases())
|
||||
.map((db) => db.name)
|
||||
.filter((name): name is string => !!name);
|
||||
} catch {
|
||||
names = KNOWN_DATABASES;
|
||||
}
|
||||
await Promise.all(names.map(deleteDatabase));
|
||||
window.localStorage.clear();
|
||||
};
|
||||
|
||||
export const logoutClient = async (mx: MatrixClient) => {
|
||||
pushSessionToSW();
|
||||
mx.stopClient();
|
||||
|
|
@ -119,21 +158,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();
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue