Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
08bf515997 Merge pull request 'Release candidate: dev into main' (#88) from dev into main 2026-11-11 02:13:08 +00:00
f8115609b2 Register entry: https only, http only for a local host (#87)
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m45s
Build pull request / Build pull request (pull_request) Successful in 3m29s
2026-11-11 01:31:35 +00:00
450a7e91ff auth: Register entry beside Sign in, behind a config switch (off) (#68)
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-11 01:30:40 +00:00
94c976632e Default homeserver is gamecult.org (#86)
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-11 01:30:16 +00:00
683d3b92f8 Merge pull request 'Release candidate: dev 64edacac to main' (#71) from release/dev-64edacac into main 2026-11-10 17:42:15 +00:00
20 changed files with 478 additions and 57 deletions

View file

@ -1,6 +1,13 @@
{
"defaultHomeserver": 1,
"homeserverList": ["converser.eu", "matrix.org", "mozilla.org", "unredacted.org", "xmr.se"],
"defaultHomeserver": 0,
"homeserverList": [
"gamecult.org",
"converser.eu",
"matrix.org",
"mozilla.org",
"unredacted.org",
"xmr.se"
],
"allowCustomHomeservers": true,
"featuredCommunities": {
"openAsDefault": false,
@ -24,5 +31,10 @@
"dings": {
"enabled": false
},
"registration": {
"enabled": false,
"url": ""
}
}

View file

@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { ClientConfig, clientDefaultServer } from './useClientConfig';
import config from '../../../config.json';
describe('default homeserver', () => {
it('is gamecult.org when the config names none', () => {
expect(clientDefaultServer({})).toBe('gamecult.org');
expect(clientDefaultServer({ homeserverList: [] })).toBe('gamecult.org');
});
it('is the listed server the config points at', () => {
expect(
clientDefaultServer({ homeserverList: ['chat.gamecult.org'], defaultHomeserver: 0 })
).toBe('chat.gamecult.org');
});
it('is gamecult.org in the repo config.json too', () => {
expect(clientDefaultServer(config as ClientConfig)).toBe('gamecult.org');
});
});

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;
};
@ -44,7 +53,7 @@ export function useClientConfig(): ClientConfig {
}
export const clientDefaultServer = (clientConfig: ClientConfig): string =>
clientConfig.homeserverList?.[clientConfig.defaultHomeserver ?? 0] ?? 'matrix.org';
clientConfig.homeserverList?.[clientConfig.defaultHomeserver ?? 0] ?? 'gamecult.org';
export const clientAllowedServer = (clientConfig: ClientConfig, server: string): boolean => {
const { homeserverList, allowCustomHomeservers } = clientConfig;

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,57 @@
import { describe, expect, it } from 'vitest';
import { registrationEntry } from './registrationEntry';
import type { ClientConfig } from '../../hooks/useClientConfig';
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 as ClientConfig)).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);
});
it('accepts http only for a local development host', () => {
const on = (u: string) => registrationEntry({ registration: { enabled: true, url: u } });
expect(on('http://localhost:8080/register')?.url).toBe('http://localhost:8080/register');
expect(on('http://127.0.0.1:8080/register')).toBeDefined();
expect(on('http://accounts.example/register')).toBeUndefined();
expect(on('http://localhost.evil.example/register')).toBeUndefined();
expect(on('https://accounts.example/register')).toBeDefined();
});
});

View file

@ -0,0 +1,32 @@
import type { ClientConfig } from '../../hooks/useClientConfig';
import { TRAINING_NOTICE_VERSION, trainingNoticeText } from '../../utils/trainingNotice';
export type RegistrationEntry = { url: string; notice: string; noticeVersion: number };
// Plain http is for a developer's own machine only; a stranger's sign-up page is https.
const isLocalHost = (hostname: string): boolean =>
hostname === 'localhost' ||
hostname.endsWith('.localhost') ||
hostname === '127.0.0.1' ||
hostname === '[::1]';
/**
* 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 an https address (http only for localhost). 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, hostname } = new URL(url);
if (protocol !== 'https:' && !(protocol === 'http:' && isLocalHost(hostname))) 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' };