Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4942eef86 | ||
|
|
4148a52c47 | ||
|
|
3851d0302b | ||
|
|
408d47a53d |
19 changed files with 431 additions and 54 deletions
|
|
@ -24,5 +24,10 @@
|
|||
|
||||
"dings": {
|
||||
"enabled": false
|
||||
},
|
||||
|
||||
"registration": {
|
||||
"enabled": false,
|
||||
"url": ""
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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={() => {
|
||||
|
|
|
|||
22
src/app/pages/auth/RegisterEntry.tsx
Normal file
22
src/app/pages/auth/RegisterEntry.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
src/app/pages/auth/SignedUp.tsx
Normal file
54
src/app/pages/auth/SignedUp.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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.`;
|
||||
|
|
|
|||
40
src/app/pages/auth/authRoutes.tsx
Normal file
40
src/app/pages/auth/authRoutes.tsx
Normal 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 />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
export * from './AuthLayout';
|
||||
export * from './login';
|
||||
export * from './register';
|
||||
export * from './SignedUp';
|
||||
export * from './reset-password';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
37
src/app/pages/auth/register/RegisterRoute.test.tsx
Normal file
37
src/app/pages/auth/register/RegisterRoute.test.tsx
Normal 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'
|
||||
);
|
||||
});
|
||||
});
|
||||
26
src/app/pages/auth/register/RegisterRoute.tsx
Normal file
26
src/app/pages/auth/register/RegisterRoute.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
src/app/pages/auth/register/registerErrors.test.ts
Normal file
39
src/app/pages/auth/register/registerErrors.test.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
47
src/app/pages/auth/registrationEntry.test.ts
Normal file
47
src/app/pages/auth/registrationEntry.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
25
src/app/pages/auth/registrationEntry.ts
Normal file
25
src/app/pages/auth/registrationEntry.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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/';
|
||||
|
|
|
|||
51
src/app/pages/signedUpRoute.test.tsx
Normal file
51
src/app/pages/signedUpRoute.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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' };
|
||||
|
|
|
|||
Loading…
Reference in a new issue