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?
+
+
+
+ )}