From f1287a6f3aefb1fad9fbb56b98f87693fbdf6160 Mon Sep 17 00:00:00 2001 From: Libby Date: Tue, 10 Nov 2026 17:29:51 +0000 Subject: [PATCH] auth: newcomer-facing sign-in, server and sign-up messages that say what to do next Replaces Cinny's stock wording (Invalid Username or Password., Failed to find homeserver., Failed to connect..., the dead-end registration-disabled text) with plain messages in one module, authMessages.ts, used by the login form, the auth layout, the login page and the register page. Co-Authored-By: Claude Sonnet 5.5 --- src/app/pages/auth/AuthLayout.tsx | 15 ++-- src/app/pages/auth/authMessages.test.ts | 68 +++++++++++++++++ src/app/pages/auth/authMessages.ts | 74 +++++++++++++++++++ src/app/pages/auth/login/Login.tsx | 5 +- .../pages/auth/login/PasswordLoginForm.tsx | 15 ++-- src/app/pages/auth/register/Register.tsx | 9 ++- 6 files changed, 166 insertions(+), 20 deletions(-) create mode 100644 src/app/pages/auth/authMessages.test.ts create mode 100644 src/app/pages/auth/authMessages.ts diff --git a/src/app/pages/auth/AuthLayout.tsx b/src/app/pages/auth/AuthLayout.tsx index 49c7b774..a2d652a3 100644 --- a/src/app/pages/auth/AuthLayout.tsx +++ b/src/app/pages/auth/AuthLayout.tsx @@ -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() { /> {discoveryState.status === AsyncStatus.Loading && ( - + )} {discoveryState.status === AsyncStatus.Error && ( - + )} {autoDiscoveryError?.action === AutoDiscoveryAction.FAIL_PROMPT && ( )} {autoDiscoveryError?.action === AutoDiscoveryAction.FAIL_ERROR && ( - + )} {discoveryState.status === AsyncStatus.Success && autoDiscoveryInfo && ( @@ -182,7 +183,7 @@ export function AuthLayout() { /> )} error={() => ( - + )} > {(specVersions) => ( @@ -192,7 +193,9 @@ export function AuthLayout() { )} error={() => ( - + )} > {(authFlows) => ( diff --git a/src/app/pages/auth/authMessages.test.ts b/src/app/pages/auth/authMessages.test.ts new file mode 100644 index 00000000..04b43ffb --- /dev/null +++ b/src/app/pages/auth/authMessages.test.ts @@ -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) + ); + }); +}); diff --git a/src/app/pages/auth/authMessages.ts b/src/app/pages/auth/authMessages.ts new file mode 100644 index 00000000..3cc858fe --- /dev/null +++ b/src/app/pages/auth/authMessages.ts @@ -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.`; + } +}; diff --git a/src/app/pages/auth/login/Login.tsx b/src/app/pages/auth/login/Login.tsx index 9d890bef..bc3136f4 100644 --- a/src/app/pages/auth/login/Login.tsx +++ b/src/app/pages/auth/login/Login.tsx @@ -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 && ( <> - - {`This client does not support login on "${server}" homeserver. Password and SSO based login method not found.`} - + {noSignInMethodMessage(server)} )} diff --git a/src/app/pages/auth/login/PasswordLoginForm.tsx b/src/app/pages/auth/login/PasswordLoginForm.tsx index 3c8641d9..7934bc9f 100644 --- a/src/app/pages/auth/login/PasswordLoginForm.tsx +++ b/src/app/pages/auth/login/PasswordLoginForm.tsx @@ -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 && ( - + )} {loginState.error.errcode === LoginError.InvalidServer && ( - + )} )} @@ -232,19 +233,19 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog {loginState.status === AsyncStatus.Error && ( <> {loginState.error.errcode === LoginError.Forbidden && ( - + )} {loginState.error.errcode === LoginError.UserDeactivated && ( - + )} {loginState.error.errcode === LoginError.InvalidRequest && ( - + )} {loginState.error.errcode === LoginError.RateLimited && ( - + )} {loginState.error.errcode === LoginError.Unknown && ( - + )} )} diff --git a/src/app/pages/auth/register/Register.tsx b/src/app/pages/auth/register/Register.tsx index 7176489b..286fa5d3 100644 --- a/src/app/pages/auth/register/Register.tsx +++ b/src/app/pages/auth/register/Register.tsx @@ -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() { {registerFlows.status === RegisterFlowStatus.RegistrationDisabled && !sso && ( - Registration has been disabled on this homeserver. + {registerTroubleMessage('closed', server)} )} {registerFlows.status === RegisterFlowStatus.RateLimited && !sso && ( - You have been rate-limited! Please try after some time. + {registerTroubleMessage('rate-limited', server)} )} {registerFlows.status === RegisterFlowStatus.InvalidRequest && !sso && ( - Invalid Request! Failed to get any registration options. + {registerTroubleMessage('invalid', server)} )} {registerFlows.status === RegisterFlowStatus.FlowRequired && ( @@ -62,7 +63,7 @@ export function Register() { {(supportedFlows) => supportedFlows.length === 0 ? ( - This application does not support registration on this homeserver. + {registerTroubleMessage('unsupported', server)} ) : (