auth: newcomer-facing sign-in, server and sign-up messages #73

Merged
void merged 1 commit from void/newcomer-signin-errors into dev 2026-11-10 17:32:14 +00:00
6 changed files with 166 additions and 20 deletions

View file

@ -32,6 +32,7 @@ import { AuthFlowsLoader } from '../../components/AuthFlowsLoader';
import { AuthFlowsProvider } from '../../hooks/useAuthFlows';
import { AuthServerProvider } from '../../hooks/useAuthServer';
import { tryDecodeURIComponent } from '../../utils/dom';
import { serverTroubleMessage } from './authMessages';
const currentAuthPath = (pathname: string): string => {
if (matchPath(LOGIN_PATH, pathname)) {
@ -158,18 +159,18 @@ export function AuthLayout() {
/>
</Box>
{discoveryState.status === AsyncStatus.Loading && (
<AuthLayoutLoading message="Looking for homeserver..." />
<AuthLayoutLoading message={`Looking for ${server}...`} />
)}
{discoveryState.status === AsyncStatus.Error && (
<AuthLayoutError message="Failed to find homeserver." />
<AuthLayoutError message={serverTroubleMessage('not-found', server)} />
)}
{autoDiscoveryError?.action === AutoDiscoveryAction.FAIL_PROMPT && (
<AuthLayoutError
message={`Failed to connect. Homeserver configuration found with ${autoDiscoveryError.host} appears unusable.`}
message={serverTroubleMessage('unusable-setup', server, autoDiscoveryError.host)}
/>
)}
{autoDiscoveryError?.action === AutoDiscoveryAction.FAIL_ERROR && (
<AuthLayoutError message="Failed to connect. Homeserver configuration base_url appears invalid." />
<AuthLayoutError message={serverTroubleMessage('bad-address', server)} />
)}
{discoveryState.status === AsyncStatus.Success && autoDiscoveryInfo && (
<AuthServerProvider value={discoveryState.data.serverName}>
@ -182,7 +183,7 @@ export function AuthLayout() {
/>
)}
error={() => (
<AuthLayoutError message="Failed to connect. Either homeserver is unavailable at this moment or does not exist." />
<AuthLayoutError message={serverTroubleMessage('unreachable', server)} />
)}
>
{(specVersions) => (
@ -192,7 +193,9 @@ export function AuthLayout() {
<AuthLayoutLoading message="Loading authentication flow..." />
)}
error={() => (
<AuthLayoutError message="Failed to get authentication flow information." />
<AuthLayoutError
message={serverTroubleMessage('no-sign-in-info', server)}
/>
)}
>
{(authFlows) => (

View file

@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import {
loginErrorMessage,
noSignInMethodMessage,
registerTroubleMessage,
serverTroubleMessage,
type RegisterTrouble,
type ServerTrouble,
} from './authMessages';
import { LoginError } from './login/loginUtil';
const stock = [
'Invalid Username or Password.',
'Failed to find homeserver.',
'Failed to connect',
'Failed to login',
];
const troubles: ServerTrouble[] = [
'not-found',
'unusable-setup',
'bad-address',
'unreachable',
'no-sign-in-info',
];
const registerTroubles: RegisterTrouble[] = ['closed', 'rate-limited', 'invalid', 'unsupported'];
describe('sign-in and sign-up messages', () => {
it('has a message for every sign-in error, each different, none the stock wording', () => {
const messages = Object.values(LoginError).map((e) => loginErrorMessage(e));
expect(new Set(messages).size).toBe(messages.length);
messages.forEach((m) => stock.forEach((s) => expect(m).not.toContain(s)));
});
it('keeps wrong credentials apart from a missing server', () => {
expect(loginErrorMessage(LoginError.Forbidden)).toContain('username and password');
expect(loginErrorMessage(LoginError.InvalidServer)).toContain('@name:server');
});
it('names the server in every server message and says what to do', () => {
troubles.forEach((t) => {
const m = serverTroubleMessage(t, 'chat.example', 'bad.host');
expect(m).toContain('chat.example');
stock.forEach((s) => expect(m).not.toContain(s));
});
expect(serverTroubleMessage('unusable-setup', 'chat.example', 'bad.host')).toContain(
'bad.host'
);
expect(serverTroubleMessage('unreachable', 'chat.example')).toContain('reload');
});
it('tells a person who cannot fix a broken server setup to tell its owner', () => {
expect(serverTroubleMessage('bad-address', 'chat.example')).toContain(
'whoever runs the server'
);
expect(noSignInMethodMessage('chat.example')).toContain('chat.example');
});
it('says closed sign-up is closed and points to sign in or the inviter, without a Register link to nowhere', () => {
const m = registerTroubleMessage('closed', 'chat.example');
expect(m).toContain('chat.example');
expect(m).toContain('sign in');
expect(m).toContain('invited you');
expect(m).not.toContain('disabled');
registerTroubles.forEach((t) =>
expect(registerTroubleMessage(t, 'chat.example').length).toBeGreaterThan(20)
);
});
});

View file

@ -0,0 +1,74 @@
import { LoginError } from './login/loginUtil';
// What the sign-in and sign-up pages say when something goes wrong. Each message says what happened
// in plain words and what the person can do next; none is a status code or a Matrix term.
export const loginErrorMessage = (error: LoginError): string => {
switch (error) {
case LoginError.ServerNotAllowed:
return 'This app only signs in to the servers in its Homeserver list. Pick one from the list above.';
case LoginError.InvalidServer:
return 'We could not find the server in that username. A full username looks like @name:server; the part after the colon is the server.';
case LoginError.Forbidden:
return 'That username and password did not match. Check both and try again, or use "Forget Password?" beside the password.';
case LoginError.UserDeactivated:
return 'This account has been turned off. Ask whoever runs this server to turn it back on.';
case LoginError.InvalidRequest:
return 'The server could not read that sign-in. Check the username and password for stray spaces and try again.';
case LoginError.RateLimited:
return 'Too many tries in a short time. Wait a minute, then try again.';
case LoginError.Unknown:
default:
return 'Sign-in did not work and the server gave no reason. Wait a minute and try again; if it keeps failing, tell whoever invited you.';
}
};
export type ServerTrouble =
| 'not-found'
| 'unusable-setup'
| 'bad-address'
| 'unreachable'
| 'no-sign-in-info';
/** `server` is the name shown in the Homeserver box; `host` is the name the server's own setup gave. */
export const serverTroubleMessage = (
trouble: ServerTrouble,
server: string,
host?: string
): string => {
switch (trouble) {
case 'not-found':
return `We could not find a chat server called ${server}. Check the name for typos, or try again in a minute.`;
case 'unusable-setup':
return `We found ${server}, but the setup it points to (${
host ?? 'unknown'
}) is not usable. You cannot fix this from here: tell whoever runs the server.`;
case 'bad-address':
return `We found ${server}, but the address it gives for chat is not valid. You cannot fix this from here: tell whoever runs the server.`;
case 'unreachable':
return `We found ${server} but could not connect to it. It may be down for a moment: wait a minute and reload this page. If it stays down, tell whoever invited you.`;
case 'no-sign-in-info':
return `Connected to ${server}, but it did not say how to sign in. Reload the page; if it happens again, tell whoever runs the server.`;
default:
return `We could not reach ${server}. Reload the page and try again.`;
}
};
export const noSignInMethodMessage = (server: string): string =>
`${server} does not offer a way to sign in that this app can use. Pick another server from the Homeserver list, or tell whoever runs ${server}.`;
export type RegisterTrouble = 'closed' | 'rate-limited' | 'invalid' | 'unsupported';
export const registerTroubleMessage = (trouble: RegisterTrouble, server: string): string => {
switch (trouble) {
case 'closed':
return `New accounts cannot be made on ${server} from this page yet. If you were given an account, sign in with it. If you were not, ask whoever invited you how to get one.`;
case 'rate-limited':
return 'Too many tries in a short time. Wait a minute, then try again.';
case 'invalid':
return `${server} did not accept the request to make an account. Reload the page and try again; if it keeps failing, tell whoever invited you.`;
case 'unsupported':
default:
return `${server} asks for something this app cannot do when making an account. Tell whoever invited you.`;
}
};

View file

@ -13,6 +13,7 @@ import { getLoginPath, getRegisterPath, withSearchParam } from '../../pathUtils'
import { usePathWithOrigin } from '../../../hooks/usePathWithOrigin';
import { LoginPathSearchParams } from '../../paths';
import { useClientConfig } from '../../../hooks/useClientConfig';
import { noSignInMethodMessage } from '../authMessages';
const getLoginTokenSearchParam = () => {
// when using hasRouter query params in existing route
@ -85,9 +86,7 @@ export function Login() {
)}
{!parsedFlows.password && !parsedFlows.sso && (
<>
<Text style={{ color: color.Critical.Main }}>
{`This client does not support login on "${server}" homeserver. Password and SSO based login method not found.`}
</Text>
<Text style={{ color: color.Critical.Main }}>{noSignInMethodMessage(server)}</Text>
<span data-spacing-node />
</>
)}

View file

@ -35,6 +35,7 @@ import {
} from './loginUtil';
import { PasswordInput } from '../../../components/password-input';
import { FieldError } from '../FiledError';
import { loginErrorMessage } from '../authMessages';
import { getResetPasswordPath } from '../../pathUtils';
import { stopPropagation } from '../../../utils/keyboard';
@ -215,10 +216,10 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog
{loginState.status === AsyncStatus.Error && (
<>
{loginState.error.errcode === LoginError.ServerNotAllowed && (
<FieldError message="Login with custom server not allowed by your client instance." />
<FieldError message={loginErrorMessage(LoginError.ServerNotAllowed)} />
)}
{loginState.error.errcode === LoginError.InvalidServer && (
<FieldError message="Failed to find your Matrix ID server." />
<FieldError message={loginErrorMessage(LoginError.InvalidServer)} />
)}
</>
)}
@ -232,19 +233,19 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog
{loginState.status === AsyncStatus.Error && (
<>
{loginState.error.errcode === LoginError.Forbidden && (
<FieldError message="Invalid Username or Password." />
<FieldError message={loginErrorMessage(LoginError.Forbidden)} />
)}
{loginState.error.errcode === LoginError.UserDeactivated && (
<FieldError message="This account has been deactivated." />
<FieldError message={loginErrorMessage(LoginError.UserDeactivated)} />
)}
{loginState.error.errcode === LoginError.InvalidRequest && (
<FieldError message="Failed to login. Part of your request data is invalid." />
<FieldError message={loginErrorMessage(LoginError.InvalidRequest)} />
)}
{loginState.error.errcode === LoginError.RateLimited && (
<FieldError message="Failed to login. Your login request has been rate-limited by server, Please try after some time." />
<FieldError message={loginErrorMessage(LoginError.RateLimited)} />
)}
{loginState.error.errcode === LoginError.Unknown && (
<FieldError message="Failed to login. Unknown reason." />
<FieldError message={loginErrorMessage(LoginError.Unknown)} />
)}
</>
)}

View file

@ -12,6 +12,7 @@ import { SupportedUIAFlowsLoader } from '../../../components/SupportedUIAFlowsLo
import { getLoginPath } from '../../pathUtils';
import { usePathWithOrigin } from '../../../hooks/usePathWithOrigin';
import { RegisterPathSearchParams } from '../../paths';
import { registerTroubleMessage } from '../authMessages';
const useRegisterSearchParams = (searchParams: URLSearchParams): RegisterPathSearchParams =>
useMemo(
@ -40,17 +41,17 @@ export function Register() {
</Text>
{registerFlows.status === RegisterFlowStatus.RegistrationDisabled && !sso && (
<Text style={{ color: color.Critical.Main }} size="T300">
Registration has been disabled on this homeserver.
{registerTroubleMessage('closed', server)}
</Text>
)}
{registerFlows.status === RegisterFlowStatus.RateLimited && !sso && (
<Text style={{ color: color.Critical.Main }} size="T300">
You have been rate-limited! Please try after some time.
{registerTroubleMessage('rate-limited', server)}
</Text>
)}
{registerFlows.status === RegisterFlowStatus.InvalidRequest && !sso && (
<Text style={{ color: color.Critical.Main }} size="T300">
Invalid Request! Failed to get any registration options.
{registerTroubleMessage('invalid', server)}
</Text>
)}
{registerFlows.status === RegisterFlowStatus.FlowRequired && (
@ -62,7 +63,7 @@ export function Register() {
{(supportedFlows) =>
supportedFlows.length === 0 ? (
<Text style={{ color: color.Critical.Main }} size="T300">
This application does not support registration on this homeserver.
{registerTroubleMessage('unsupported', server)}
</Text>
) : (
<PasswordRegisterForm