Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
Void
a4942eef86 auth: register failures in plain words; /register goes to the account service when the switch is on
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m9s
The previous commit carried only the fallback split. This one carries the rest of Libby's two blockers and Heimdall's decision:

- config.json keeps only the registration block over dev's; clientDefaultServer is dev's again (the gamecult.org default is #86).
- registerErrorTrouble maps a register error code to registerTroubleMessage (new troubles: password-weak, password-short, unknown); PasswordRegisterForm shows those, never the server's text.
- RegisterRoute: switch on, /register offers the account service link and redirects there; switch off, the in-client form.
- Tests: registerErrors.test.ts (code to words, no Matrix code leaks), RegisterRoute.test.tsx (on, off, unusable url).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 00:25:46 +00:00
Void
4148a52c47 auth: Libby's read: fallback split out, register errors in plain words, /register goes to the account service when the switch is on
- The gamecult.org default homeserver moves to its own PR (#86); config.json and clientDefaultServer are dev's again.
- PasswordRegisterForm no longer shows a server's own error text or the old "Failed to register" lines: registerErrorTrouble maps the error code to registerTroubleMessage (new troubles for weak and short passwords and for the unknown case). A consumer-side test goes through the code to the words and checks no Matrix code leaks.
- Per Heimdall: with the Register switch on, /register is RegisterRoute, which sends a stranger to the account service's page (one way to register); with it off the in-client form stays. Tested through the component, rendered on and off and with an unusable url.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 00:25:46 +00:00
Void
3851d0302b auth: sign-up return page renders; gamecult.org is the default homeserver
/signed-up/ sat inside AuthLayout, which discovers a homeserver and sends paths it does
not know to sign in, so it never rendered. The signed-out routes move to authRoutes.tsx
with the return page beside the layout (its own card, no discovery); a test matches the
real route table and renders the page. Drop the visible notice version from the entry
(it stays in registrationEntry's data and tests) and the unused getSignedUpPath.

clientDefaultServer falls back to gamecult.org, and the repo config.json lists it first
and defaults to it; a config that names its own list (the production pin) is unaffected.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 00:25:46 +00:00
Void
408d47a53d auth: Register entry beside Sign in, behind a config switch that is off
config.registration {enabled, url}: off by default and in config.json. When on with a
http(s) url, the sign-in page shows Register (a link to the account service's own page)
with the consent sentence and its version, both read from trainingNotice.ts
(new TRAINING_NOTICE_VERSION shared with the room notice). /signed-up/ is the return
page: it says to follow the steps from the sign-up page and promises nothing about mail;
with the switch off it redirects to sign in. Off keeps the existing Register link.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 00:25:46 +00:00
19 changed files with 431 additions and 54 deletions

View file

@ -24,5 +24,10 @@
"dings": {
"enabled": false
},
"registration": {
"enabled": false,
"url": ""
}
}

View file

@ -7,6 +7,11 @@ export type HashRouterConfig = {
basename?: string;
};
export type RegistrationConfig = {
enabled?: boolean;
url?: string;
};
export type ClientConfig = {
defaultHomeserver?: number;
homeserverList?: string[];
@ -29,6 +34,10 @@ export type ClientConfig = {
// Rooms (ids or aliases) shown as the album view; a room whose create type is org.gamecult.file_zone needs no entry.
fileZoneRooms?: string[];
// The sign-up entry on the sign-in page. Off unless `enabled` is true; `url` is the account
// service's own registration page (a value, never a route this client invents).
registration?: RegistrationConfig;
// Dev preview only: the build answers a pretend homeserver in-page (src/preview-demo).
previewDemo?: boolean;
};

View file

@ -9,16 +9,13 @@ import {
} from 'react-router-dom';
import { ClientConfig } from '../hooks/useClientConfig';
import { AuthLayout, Login, Register, ResetPassword } from './auth';
import { authRoutes } from './auth/authRoutes';
import {
DIRECT_PATH,
DESK_PATH,
EXPLORE_PATH,
HOME_PATH,
LOGIN_PATH,
INBOX_PATH,
REGISTER_PATH,
RESET_PASSWORD_PATH,
SPACE_PATH,
_CREATE_PATH,
_FEATURED_PATH,
@ -57,7 +54,7 @@ import { ScreenSize } from '../hooks/useScreenSize';
import { MobileFriendlyPageNav } from './MobileFriendly';
import { ClientInitStorageAtom } from './client/ClientInitStorageAtom';
import { ClientNonUIFeatures } from './client/ClientNonUIFeatures';
import { AuthRouteThemeManager, UnAuthRouteThemeManager } from './ThemeManager';
import { AuthRouteThemeManager } from './ThemeManager';
import { ReceiveSelfDeviceVerification } from '../components/DeviceVerification';
import { AutoRestoreBackupOnVerification } from '../components/BackupRestore';
import { RoomSettingsRenderer } from '../features/room-settings';
@ -88,25 +85,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
return redirect(getLoginPath());
}}
/>
<Route
loader={() => {
if (getFallbackSession()) {
return redirect(getHomePath());
}
return null;
}}
element={
<>
<AuthLayout />
<UnAuthRouteThemeManager />
</>
}
>
<Route path={LOGIN_PATH} element={<Login />} />
<Route path={REGISTER_PATH} element={<Register />} />
<Route path={RESET_PASSWORD_PATH} element={<ResetPassword />} />
</Route>
{authRoutes()}
<Route
loader={() => {

View file

@ -0,0 +1,22 @@
import { Box, Text, color } from 'folds';
import React from 'react';
import { useClientConfig } from '../../hooks/useClientConfig';
import { registrationEntry } from './registrationEntry';
// Beside Sign in. Nothing renders unless the config switch is on; the consent sentence and its
// version come from trainingNotice.ts, the same ones a room shows.
export function RegisterEntry() {
const config = useClientConfig();
const entry = registrationEntry(config);
if (!entry) return null;
return (
<Box direction="Column" gap="100" alignItems="Center">
<Text align="Center">
Do not have an account? <a href={entry.url}>Register</a>
</Text>
<Text size="T200" align="Center" style={{ color: color.SurfaceVariant.OnContainer }}>
{entry.notice}
</Text>
</Box>
);
}

View file

@ -0,0 +1,54 @@
import { Box, Header, Scroll, Text } from 'folds';
import classNames from 'classnames';
import React from 'react';
import { Link, Navigate } from 'react-router-dom';
import { useClientConfig } from '../../hooks/useClientConfig';
import { getLoginPath } from '../pathUtils';
import { registrationEntry } from './registrationEntry';
import { AuthFooter } from './AuthFooter';
import * as css from './styles.css';
import * as PatternsCss from '../../styles/Patterns.css';
import RatatoskrSVG from '../../../../public/res/svg/ratatoskr.svg';
import { LogoMark } from '../../components/logo/logoMark.css';
// Where the account service sends a newcomer back. It promises nothing about mail or timing:
// the sign-up page said what happens next, and this page points back to it. It has its own card
// because AuthLayout looks up a homeserver first and sends paths it does not know to sign in.
export function SignedUp() {
const config = useClientConfig();
if (!registrationEntry(config)) return <Navigate to={getLoginPath()} replace />;
return (
<Scroll variant="Background" visibility="Hover" size="300" hideTrack>
<Box
className={classNames(css.AuthLayout, PatternsCss.BackgroundDotPattern)}
direction="Column"
alignItems="Center"
justifyContent="SpaceBetween"
gap="400"
>
<Box direction="Column" className={css.AuthCard}>
<Header className={css.AuthHeader} size="600" variant="Surface">
<Box grow="Yes" direction="Row" gap="300" alignItems="Center">
<img
className={`${css.AuthLogo} ${LogoMark}`}
src={RatatoskrSVG}
alt="Ratatoskr logo"
/>
<Text size="H3">Ratatoskr</Text>
</Box>
</Header>
<Box className={css.AuthCardContent} direction="Column" gap="500">
<Text size="H2" priority="400">
You signed up
</Text>
<Text>Follow the steps from the sign-up page to finish. Then sign in here.</Text>
<Text>
<Link to={getLoginPath()}>Sign in</Link>
</Text>
</Box>
</Box>
<AuthFooter />
</Box>
</Scroll>
);
}

View file

@ -57,7 +57,14 @@ export const serverTroubleMessage = (
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 type RegisterTrouble =
| 'closed'
| 'rate-limited'
| 'invalid'
| 'unsupported'
| 'password-weak'
| 'password-short'
| 'unknown';
export const registerTroubleMessage = (trouble: RegisterTrouble, server: string): string => {
switch (trouble) {
@ -67,6 +74,12 @@ export const registerTroubleMessage = (trouble: RegisterTrouble, server: string)
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 'password-weak':
return 'That password is too easy to guess. Pick a longer one, with more than one kind of character.';
case 'password-short':
return 'That password is too short. Pick a longer one.';
case 'unknown':
return `Something went wrong making the account on ${server}. Try again in a minute; 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

@ -0,0 +1,40 @@
import React from 'react';
import { Route, redirect } from 'react-router-dom';
import { getFallbackSession } from '../../state/sessions';
import { UnAuthRouteThemeManager } from '../ThemeManager';
import { LOGIN_PATH, REGISTER_PATH, RESET_PASSWORD_PATH, SIGNED_UP_PATH } from '../paths';
import { getHomePath } from '../pathUtils';
import { AuthLayout } from './AuthLayout';
import { Login } from './login';
import { RegisterRoute } from './register/RegisterRoute';
import { ResetPassword } from './reset-password';
import { SignedUp } from './SignedUp';
// The routes a signed-out visitor sees. The sign-up return page sits beside the layout, not in
// it: the layout looks up a homeserver and sends any path it does not know to sign in.
export const authRoutes = () => (
<>
<Route
loader={() => (getFallbackSession() ? redirect(getHomePath()) : null)}
element={
<>
<AuthLayout />
<UnAuthRouteThemeManager />
</>
}
>
<Route path={LOGIN_PATH} element={<Login />} />
<Route path={REGISTER_PATH} element={<RegisterRoute />} />
<Route path={RESET_PASSWORD_PATH} element={<ResetPassword />} />
</Route>
<Route
path={SIGNED_UP_PATH}
element={
<>
<SignedUp />
<UnAuthRouteThemeManager />
</>
}
/>
</>
);

View file

@ -1,4 +1,5 @@
export * from './AuthLayout';
export * from './login';
export * from './register';
export * from './SignedUp';
export * from './reset-password';

View file

@ -9,6 +9,8 @@ import { PasswordLoginForm } from './PasswordLoginForm';
import { SSOLogin } from '../SSOLogin';
import { TokenLogin } from './TokenLogin';
import { OrDivider } from '../OrDivider';
import { RegisterEntry } from '../RegisterEntry';
import { registrationEntry } from '../registrationEntry';
import { getLoginPath, getRegisterPath, withSearchParam } from '../../pathUtils';
import { usePathWithOrigin } from '../../../hooks/usePathWithOrigin';
import { LoginPathSearchParams } from '../../paths';
@ -37,7 +39,8 @@ const useLoginSearchParams = (searchParams: URLSearchParams): LoginPathSearchPar
export function Login() {
const server = useAuthServer();
const { hashRouter, previewDemo } = useClientConfig();
const config = useClientConfig();
const { hashRouter, previewDemo } = config;
const { loginFlows } = useAuthFlows();
const [searchParams] = useSearchParams();
const loginSearchParams = useLoginSearchParams(searchParams);
@ -90,9 +93,13 @@ export function Login() {
<span data-spacing-node />
</>
)}
<Text align="Center">
Do not have an account? <Link to={getRegisterPath(server)}>Register</Link>
</Text>
{registrationEntry(config) ? (
<RegisterEntry />
) : (
<Text align="Center">
Do not have an account? <Link to={getRegisterPath(server)}>Register</Link>
</Text>
)}
</Box>
);
}

View file

@ -30,7 +30,15 @@ import {
import { useUIACompleted, useUIAFlow, useUIAParams } from '../../../hooks/useUIAFlows';
import { AsyncState, AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useAutoDiscoveryInfo } from '../../../hooks/useAutoDiscoveryInfo';
import { RegisterError, RegisterResult, register, useRegisterComplete } from './registerUtil';
import {
RegisterError,
RegisterResult,
register,
registerErrorTrouble,
useRegisterComplete,
} from './registerUtil';
import { registerTroubleMessage } from '../authMessages';
import { useAuthServer } from '../../../hooks/useAuthServer';
import { FieldError } from '../FiledError';
import { TrainingNotice } from '../TrainingNotice';
import {
@ -185,6 +193,7 @@ export function PasswordRegisterForm({
defaultEmail,
defaultRegisterToken,
}: PasswordRegisterFormProps) {
const server = useAuthServer();
const serverDiscovery = useAutoDiscoveryInfo();
const baseUrl = serverDiscovery['m.homeserver'].base_url;
const mx = useMemo(() => createClient({ baseUrl }), [baseUrl]);
@ -207,6 +216,9 @@ export function PasswordRegisterForm({
const registerError =
registerState.status === AsyncStatus.Error ? registerState.error : undefined;
// The password troubles show under the password field; the rest is the form's.
const registerTrouble = registerError && registerErrorTrouble(registerError.errcode);
const formTrouble = registerTrouble?.startsWith('password-') ? undefined : registerTrouble;
useRegisterComplete(customRegisterResp);
const handleSubmit: ChangeEventHandler<HTMLFormElement> = (evt) => {
@ -298,20 +310,10 @@ export function PasswordRegisterForm({
required
/>
{registerError?.errcode === RegisterError.PasswordWeak && (
<FieldError
message={
registerError.data.error ??
'Weak Password. Password rejected by server please choosing more strong Password.'
}
/>
<FieldError message={registerTroubleMessage('password-weak', server)} />
)}
{registerError?.errcode === RegisterError.PasswordShort && (
<FieldError
message={
registerError.data.error ??
'Short Password. Password rejected by server please choosing more long Password.'
}
/>
<FieldError message={registerTroubleMessage('password-short', server)} />
)}
</Box>
<Box direction="Column" gap="100">
@ -378,18 +380,7 @@ export function PasswordRegisterForm({
</Text>
</Box>
)}
{registerError?.errcode === RegisterError.RateLimited && (
<FieldError message="Failed to register. Your register request has been rate-limited by server, Please try after some time." />
)}
{registerError?.errcode === RegisterError.Forbidden && (
<FieldError message="Failed to register. The homeserver does not permit registration." />
)}
{registerError?.errcode === RegisterError.InvalidRequest && (
<FieldError message="Failed to register. Invalid request." />
)}
{registerError?.errcode === RegisterError.Unknown && (
<FieldError message={registerError.data.error ?? 'Failed to register. Unknown Reason.'} />
)}
{formTrouble && <FieldError message={registerTroubleMessage(formTrouble, server)} />}
<span data-spacing-node />
<TrainingNotice checked={trainingAck} onToggle={() => setTrainingAck((ack) => !ack)} />
<Button variant="Primary" size="500" type="submit" disabled={!trainingAck}>

View file

@ -0,0 +1,37 @@
import React from 'react';
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 { 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) =>
renderToString(
<ClientConfigProvider value={config}>
<RegisterRoute />
</ClientConfigProvider>
);
describe('the /register route', () => {
it('with the switch on offers only the account service link, not the in-client form', () => {
const html = render({ registration: { enabled: true, url: 'https://accounts.example/join' } });
expect(html).toContain('href="https://accounts.example/join"');
expect(html).not.toContain('in-client register form');
});
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({})).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'
);
});
});

View file

@ -0,0 +1,26 @@
import { Box, Text } from 'folds';
import React, { useEffect } from 'react';
import { useClientConfig } from '../../../hooks/useClientConfig';
import { registrationEntry } from '../registrationEntry';
import { Register } from './Register';
// With the Register switch on, a stranger gets one way to register: the account service's own
// page. This route sends them there (the link stays for when the redirect does not run). With
// the switch off, the in-client form is the way.
export function RegisterRoute() {
const entry = registrationEntry(useClientConfig());
useEffect(() => {
if (entry) window.location.replace(entry.url);
}, [entry]);
if (!entry) return <Register />;
return (
<Box direction="Column" gap="300">
<Text size="H2" priority="400">
Register
</Text>
<Text>
Sign-up happens on the account page. <a href={entry.url}>Continue to sign up</a>
</Text>
</Box>
);
}

View file

@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import { registerTroubleMessage } from '../authMessages';
import { RegisterError, registerErrorTrouble } from './registerUtil';
// What the form shows for a failed registration, from the error code the server answered with.
const shown = (errcode: string) => {
const trouble = registerErrorTrouble(errcode);
return trouble && registerTroubleMessage(trouble, 'chat.example');
};
describe('registration failures as the form shows them', () => {
it('say what to do next for every failure that is not a username field error', () => {
[
RegisterError.PasswordWeak,
RegisterError.PasswordShort,
RegisterError.RateLimited,
RegisterError.Forbidden,
RegisterError.InvalidRequest,
RegisterError.Unknown,
].forEach((e) => expect(shown(e)?.length).toBeGreaterThan(20));
});
it('never carry the server text or a Matrix error code', () => {
Object.values(RegisterError).forEach((e) => {
expect(shown(e) ?? '').not.toMatch(/M_[A-Z_]+|errcode|homeserver does not/i);
});
});
it('leave the username errors to their own field messages', () => {
[RegisterError.UserTaken, RegisterError.UserInvalid, RegisterError.UserExclusive].forEach((e) =>
expect(shown(e)).toBeUndefined()
);
});
it('tell a closed server from a rate limit', () => {
expect(shown(RegisterError.Forbidden)).toContain('chat.example');
expect(shown(RegisterError.RateLimited)).not.toBe(shown(RegisterError.Forbidden));
});
});

View file

@ -17,6 +17,7 @@ import {
import { getHomePath, getLoginPath, withSearchParam } from '../../pathUtils';
import { getMxIdLocalPart, getMxIdServer } from '../../../utils/matrix';
import { setFallbackSession } from '../../../state/sessions';
import type { RegisterTrouble } from '../authMessages';
export enum RegisterError {
UserTaken = 'UserTaken',
@ -30,6 +31,27 @@ export enum RegisterError {
Unknown = 'Unknown',
}
// The form-level words for a failed registration, never the server's own text. The username
// errors are the form's own field messages and have no trouble here.
export const registerErrorTrouble = (errcode: string): RegisterTrouble | undefined => {
switch (errcode) {
case RegisterError.PasswordWeak:
return 'password-weak';
case RegisterError.PasswordShort:
return 'password-short';
case RegisterError.RateLimited:
return 'rate-limited';
case RegisterError.Forbidden:
return 'closed';
case RegisterError.InvalidRequest:
return 'invalid';
case RegisterError.Unknown:
return 'unknown';
default:
return undefined;
}
};
export type CustomRegisterResponse = {
baseUrl: string;
response: RegisterResponse;

View file

@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { registrationEntry } from './registrationEntry';
import {
PREVIEW_NOTICE_TEXT,
TRAINING_NOTICE_TEXT,
TRAINING_NOTICE_VERSION,
} from '../../utils/trainingNotice';
import config from '../../../../config.json';
const url = 'https://accounts.example/register';
describe('registration entry', () => {
it('is hidden when the switch is absent or off', () => {
expect(registrationEntry({})).toBeUndefined();
expect(registrationEntry({ registration: { url } })).toBeUndefined();
expect(registrationEntry({ registration: { enabled: false, url } })).toBeUndefined();
});
it('ships off in config.json', () => {
expect(registrationEntry(config)).toBeUndefined();
expect(config.registration.enabled).toBe(false);
});
it('is hidden when the switch is on but the url is no web address', () => {
expect(registrationEntry({ registration: { enabled: true } })).toBeUndefined();
expect(registrationEntry({ registration: { enabled: true, url: '' } })).toBeUndefined();
expect(
registrationEntry({ registration: { enabled: true, url: '/register' } })
).toBeUndefined();
expect(
registrationEntry({ registration: { enabled: true, url: 'data:text/html,x' } })
).toBeUndefined();
});
it('reads the consent text and version from trainingNotice.ts', () => {
expect(registrationEntry({ registration: { enabled: true, url } })).toEqual({
url,
notice: TRAINING_NOTICE_TEXT,
noticeVersion: TRAINING_NOTICE_VERSION,
});
});
it('says the pretend sentence on the pretend server', () => {
const entry = registrationEntry({ previewDemo: true, registration: { enabled: true, url } });
expect(entry?.notice).toBe(PREVIEW_NOTICE_TEXT);
});
});

View file

@ -0,0 +1,25 @@
import type { ClientConfig } from '../../hooks/useClientConfig';
import { TRAINING_NOTICE_VERSION, trainingNoticeText } from '../../utils/trainingNotice';
export type RegistrationEntry = { url: string; notice: string; noticeVersion: number };
/**
* The sign-up entry the sign-in page may show: the account service's registration page, when the
* switch is on and the configured url is a plain http(s) address. Anything else is no entry. The
* consent sentence and its version are trainingNotice.ts's, the ones a room shows.
*/
export const registrationEntry = (config: ClientConfig): RegistrationEntry | undefined => {
const { enabled, url } = config.registration ?? {};
if (enabled !== true || typeof url !== 'string') return undefined;
try {
const { protocol } = new URL(url);
if (protocol !== 'https:' && protocol !== 'http:') return undefined;
} catch {
return undefined;
}
return {
url,
notice: trainingNoticeText(config.previewDemo),
noticeVersion: TRAINING_NOTICE_VERSION,
};
};

View file

@ -17,6 +17,8 @@ export const REGISTER_PATH = '/register/:server?/';
export type ResetPasswordPathSearchParams = {
email?: string;
};
export const SIGNED_UP_PATH = '/signed-up/';
export const RESET_PASSWORD_PATH = '/reset-password/:server?/';
export const _CREATE_PATH = 'create/';

View file

@ -0,0 +1,51 @@
import React from 'react';
import { beforeAll, describe, expect, it, vi } from 'vitest';
import { renderToString } from 'react-dom/server';
import { Route, createRoutesFromElements } from 'react-router-dom';
import { StaticRouter, createStaticHandler } from 'react-router-dom/server';
import { ClientConfigProvider } from '../hooks/useClientConfig';
import type { ClientConfig } from '../hooks/useClientConfig';
import { SIGNED_UP_PATH } from './paths';
const config: ClientConfig = { registration: { enabled: true, url: 'https://accounts.example/' } };
// The routes read localStorage when imported (settings atoms); the node environment has none.
let authRoutes: typeof import('./auth/authRoutes').authRoutes;
let SignedUp: typeof import('./auth').SignedUp;
beforeAll(async () => {
vi.stubGlobal('localStorage', {
getItem: () => null,
setItem: () => undefined,
removeItem: () => undefined,
});
({ authRoutes } = await import('./auth/authRoutes'));
({ SignedUp } = await import('./auth'));
}, 120_000);
const matchPath = async (path: string) => {
const { query } = createStaticHandler(createRoutesFromElements(<Route>{authRoutes()}</Route>));
const context = await query(new Request(`http://localhost${path}`));
if (context instanceof Response) {
throw new Error(`redirected to ${context.headers.get('Location')}`);
}
return context.matches;
};
describe('the sign-up return page in the real route table', () => {
it('is its own route, outside AuthLayout (which would send it to sign in)', async () => {
const matches = await matchPath(SIGNED_UP_PATH);
// the pathless root, then the page itself: no layout route in between
expect(matches?.map((m) => m.route.path)).toEqual([undefined, SIGNED_UP_PATH]);
});
it('renders its words, not the sign-in form, when the switch is on', () => {
const html = renderToString(
<ClientConfigProvider value={config}>
<StaticRouter location={SIGNED_UP_PATH}>
<SignedUp />
</StaticRouter>
</ClientConfigProvider>
);
expect(html).toContain('Follow the steps from the sign-up page');
});
});

View file

@ -28,6 +28,13 @@ export const trainingNoticeText = (previewDemo?: boolean): string =>
* else is malformed. Absent or malformed in any way means no train line.
*/
export const MAX_NOTICE_VERSION = 4294967295;
/**
* The version of the notice wording above. Registration shows it with the text, and a room's
* notice event carries the same number, so one text has one version on both surfaces. Bump it
* with the wording and nowhere else.
*/
export const TRAINING_NOTICE_VERSION = 1;
export const ROOM_NOTICE_EVENT_TYPE = StateEvent.GameCultNotice;
export type RoomNotice = { version: number; scope: 'show' | 'study' | 'train' };