auth: newcomer-facing sign-in, server and sign-up messages #73
6 changed files with 166 additions and 20 deletions
|
|
@ -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) => (
|
||||
|
|
|
|||
68
src/app/pages/auth/authMessages.test.ts
Normal file
68
src/app/pages/auth/authMessages.test.ts
Normal 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)
|
||||
);
|
||||
});
|
||||
});
|
||||
74
src/app/pages/auth/authMessages.ts
Normal file
74
src/app/pages/auth/authMessages.ts
Normal 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.`;
|
||||
}
|
||||
};
|
||||
|
|
@ -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 />
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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)} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue