snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
import baseConfig from '@rocket.chat/storybook-config/main';
export default baseConfig();

View File

@@ -0,0 +1,11 @@
import { parameters, decorators } from '@rocket.chat/storybook-config/preview';
import type { Preview } from '@storybook/react';
const preview: Preview = {
parameters: {
...parameters,
},
decorators: [...decorators],
};
export default preview;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
import type { IRocketChatDesktop } from '@rocket.chat/desktop-api';
declare global {
interface Window {
RocketChatDesktop?: IRocketChatDesktop;
}
}

View File

@@ -0,0 +1,67 @@
{
"name": "@rocket.chat/web-ui-registration",
"version": "34.0.0",
"private": true,
"homepage": "https://rocket.chat",
"main": "./dist/index.js",
"typings": "./dist/index.d.ts",
"files": [
"/dist"
],
"scripts": {
"build": "tsc -p tsconfig.build.json",
"dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"storybook": "storybook dev -p 6006 --no-version-updates",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"dompurify": "~3.4.14"
},
"devDependencies": {
"@rocket.chat/core-typings": "workspace:~",
"@rocket.chat/css-in-js": "^0.33.1",
"@rocket.chat/emitter": "workspace:~",
"@rocket.chat/fuselage": "^0.89.0",
"@rocket.chat/fuselage-hooks": "^0.43.1",
"@rocket.chat/fuselage-tokens": "^0.34.0",
"@rocket.chat/i18n": "workspace:~",
"@rocket.chat/icons": "^0.49.0",
"@rocket.chat/layout": "~0.36.3",
"@rocket.chat/logo": "^0.33.2",
"@rocket.chat/mock-providers": "workspace:~",
"@rocket.chat/rest-typings": "workspace:~",
"@rocket.chat/storybook-config": "workspace:~",
"@rocket.chat/tools": "workspace:~",
"@rocket.chat/ui-client": "workspace:^",
"@rocket.chat/ui-contexts": "workspace:^",
"@storybook/react": "^9.1.20",
"@tanstack/react-query": "~5.65.1",
"@testing-library/dom": "~10.4.1",
"@testing-library/react": "~16.3.3",
"@types/react": "~19.2.18",
"@types/react-dom": "~19.2.5",
"eslint": "~9.39.5",
"i18next": "~23.4.9",
"react": "~19.2.8",
"react-dom": "~19.2.8",
"react-hook-form": "~7.54.2",
"react-i18next": "~13.2.2",
"react-virtuoso": "~4.18.12",
"storybook": "^9.1.20",
"typescript": "~5.9.3"
},
"peerDependencies": {
"@rocket.chat/layout": "*",
"@rocket.chat/tools": "0.4.0",
"@rocket.chat/ui-contexts": "34.0.0",
"@tanstack/react-query": "*",
"react": "*",
"react-hook-form": "*",
"react-i18next": "*"
},
"volta": {
"extends": "../../package.json"
}
}

View File

@@ -0,0 +1,41 @@
import { Box, IconButton } from '@rocket.chat/fuselage';
import { VerticalWizardLayout, VerticalWizardLayoutFooter, VerticalWizardLayoutForm, VerticalWizardLayoutTitle } from '@rocket.chat/layout';
import { useSetting, useAssetWithDarkModePath } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { LoginPoweredBy } from './components/LoginPoweredBy';
export type CMSPageProps = {
page: 'Layout_Terms_of_Service' | 'Layout_Privacy_Policy' | 'Layout_Legal_Notice';
};
const CMSPage = ({ page }: CMSPageProps) => {
const { t } = useTranslation();
const pageContent = useSetting(page, '');
const customLogo = useAssetWithDarkModePath('logo');
const customBackground = useAssetWithDarkModePath('background');
const dangerousContent = useMemo(() => ({ __html: pageContent }), [pageContent]);
return (
<VerticalWizardLayout
background={customBackground}
logo={customLogo ? <Box is='img' maxHeight='x40' marginInline='neg-x8' src={customLogo} alt='Logo' /> : undefined}
>
<VerticalWizardLayoutTitle>{t(page)}</VerticalWizardLayoutTitle>
<VerticalWizardLayoutForm>
<Box padding={32}>
<IconButton title={t('Back')} icon='arrow-back' onClick={() => window.history.back()} style={{ float: 'right' }} />
<Box withRichContent dangerouslySetInnerHTML={dangerousContent} />
</Box>
</VerticalWizardLayoutForm>
<VerticalWizardLayoutFooter>
<LoginPoweredBy />
</VerticalWizardLayoutFooter>
</VerticalWizardLayout>
);
};
export default CMSPage;

View File

@@ -0,0 +1,84 @@
import { FieldGroup, TextInput, Field, FieldLabel, FieldRow, FieldError, ButtonGroup, Button, Callout } from '@rocket.chat/fuselage';
import { Form, FormContainer, FormFooter, FormHeader, FormSubtitle, FormTitle, ActionLink } from '@rocket.chat/layout';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { useLoginSendEmailConfirmation } from './hooks/useLoginSendEmailConfirmation';
export const EmailConfirmationForm = ({ email, onBackToLogin }: { email?: string; onBackToLogin: () => void }) => {
const { t } = useTranslation();
const basicEmailRegex = /^[^@]+@[^@]+$/;
const isEmail = basicEmailRegex.test(email || '');
const {
register,
handleSubmit,
formState: { errors },
} = useForm<{
email: string;
}>({
defaultValues: {
email: isEmail ? email : '',
},
});
const sendEmail = useLoginSendEmailConfirmation();
return (
<Form
onSubmit={handleSubmit((data) => {
if (sendEmail.isPending) {
return;
}
sendEmail.mutate({ email: data.email });
})}
>
<FormHeader>
<FormTitle>{t('registration.component.form.confirmation')}</FormTitle>
<FormSubtitle>{t('registration.page.emailVerification.subTitle')}</FormSubtitle>
</FormHeader>
<FormContainer>
<FieldGroup disabled={sendEmail.isPending || sendEmail.isSuccess}>
<Field>
<FieldLabel htmlFor='email'>{t('registration.component.form.email')}*</FieldLabel>
<FieldRow>
<TextInput
{...register('email', {
required: true,
})}
error={errors.email && t('registration.component.form.requiredField')}
aria-invalid={errors?.email?.type === 'required'}
placeholder={t('registration.component.form.emailPlaceholder')}
id='email'
/>
</FieldRow>
{errors.email && <FieldError>{t('registration.component.form.requiredField')}</FieldError>}
</Field>
</FieldGroup>
{sendEmail.isSuccess && (
<FieldGroup>
<Callout type='success'>{t('registration.page.emailVerification.sent')}</Callout>
</FieldGroup>
)}
</FormContainer>
<FormFooter>
<ButtonGroup>
<Button loading={sendEmail.isPending} type='submit' primary>
{t('registration.component.form.sendConfirmationEmail')}
</Button>
</ButtonGroup>
<ActionLink
onClick={(): void => {
onBackToLogin();
}}
>
{t('registration.page.register.back')}
</ActionLink>
</FormFooter>
</Form>
);
};
export default EmailConfirmationForm;

View File

@@ -0,0 +1,31 @@
import { Button, ButtonGroup } from '@rocket.chat/fuselage';
import { Form, FormContainer, FormHeader, FormTitle } from '@rocket.chat/layout';
import { useDocumentTitle } from '@rocket.chat/ui-client';
import { useTranslation } from 'react-i18next';
import type { DispatchLoginRouter } from './hooks/useLoginRouter';
export type GuestFormProps = { setLoginRoute: DispatchLoginRouter };
const GuestForm = ({ setLoginRoute }: GuestFormProps) => {
const { t } = useTranslation();
useDocumentTitle(t('registration.component.login'), false);
return (
<Form>
<FormHeader>
<FormTitle>{t('registration.page.guest.chooseHowToJoin')}</FormTitle>
</FormHeader>
<FormContainer>
<ButtonGroup large stretch vertical>
<Button primary onClick={() => setLoginRoute('login')}>
{t('registration.page.guest.loginWithRocketChat')}
</Button>
<Button onClick={() => setLoginRoute('anonymous')}>{t('registration.page.guest.continueAsGuest')}</Button>
</ButtonGroup>
</FormContainer>
</Form>
);
};
export default GuestForm;

View File

@@ -0,0 +1,257 @@
import {
FieldGroup,
TextInput,
Field,
FieldLabel,
FieldRow,
FieldError,
FieldLink,
PasswordInput,
ButtonGroup,
Button,
Callout,
} from '@rocket.chat/fuselage';
import { Form, FormContainer, FormFooter, FormHeader, FormTitle, ActionLink } from '@rocket.chat/layout';
import { useDocumentTitle } from '@rocket.chat/ui-client';
import { useLoginWithPassword, useSetting } from '@rocket.chat/ui-contexts';
import { useMutation } from '@tanstack/react-query';
import { useEffect, useId, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { Trans, useTranslation } from 'react-i18next';
import EmailConfirmationForm from './EmailConfirmationForm';
import LoginServices from './LoginServices';
import type { DispatchLoginRouter } from './hooks/useLoginRouter';
const LOGIN_SUBMIT_ERRORS = {
'error-user-is-not-activated': {
type: 'warning',
i18n: 'registration.page.registration.waitActivationWarning',
},
'error-app-user-is-not-allowed-to-login': {
type: 'danger',
i18n: 'registration.page.login.errors.AppUserNotAllowedToLogin',
},
'error-login-blocked-for-ip': {
type: 'danger',
i18n: 'registration.page.login.errors.loginBlockedForIp',
},
'error-login-blocked-for-user': {
type: 'danger',
i18n: 'registration.page.login.errors.loginBlockedForUser',
},
'error-license-user-limit-reached': {
type: 'warning',
i18n: 'registration.page.login.errors.licenseUserLimitReached',
},
'error-invalid-email': {
type: 'danger',
i18n: 'registration.page.login.errors.invalidEmail',
},
} as const;
export type LoginErrors = keyof typeof LOGIN_SUBMIT_ERRORS | 'totp-canceled' | string;
export type LoginErrorState = [error: LoginErrors, message?: string] | undefined;
export type LoginFormProps = { setLoginRoute: DispatchLoginRouter };
export const LoginForm = ({ setLoginRoute }: LoginFormProps) => {
const {
register,
handleSubmit,
setError,
watch,
clearErrors,
getValues,
formState: { errors },
} = useForm<{ usernameOrEmail: string; password: string }>();
const watchUsernameOrEmail = watch('usernameOrEmail');
const watchPassword = watch('password');
useEffect(() => {
if (watchUsernameOrEmail) {
clearErrors('password');
}
}, [watchUsernameOrEmail, clearErrors]);
useEffect(() => {
if (watchPassword) {
clearErrors('usernameOrEmail');
}
}, [watchPassword, clearErrors]);
const { t } = useTranslation();
const formLabelId = useId();
const [errorOnSubmit, setErrorOnSubmit] = useState<LoginErrorState>(undefined);
const isResetPasswordAllowed = useSetting('Accounts_PasswordReset', true);
const login = useLoginWithPassword();
const showFormLogin = useSetting('Accounts_ShowFormLogin', true);
const usernameOrEmailPlaceholder = useSetting('Accounts_EmailOrUsernamePlaceholder', '');
const passwordPlaceholder = useSetting('Accounts_PasswordPlaceholder', '');
useDocumentTitle(t('registration.component.login'), false);
const loginMutation = useMutation({
mutationFn: (formData: { usernameOrEmail: string; password: string }) => {
return login(formData.usernameOrEmail, formData.password);
},
onError: (error: any) => {
if (error.error === 'user-not-found' || error.error === 401 || error.reason === 'User not found') {
setError('password', {
type: 'user-not-found',
message: t('registration.page.login.errors.wrongCredentials'),
});
return;
}
if ([error.error, error.errorType].includes('error-invalid-email')) {
setError('usernameOrEmail', { type: 'invalid-email', message: t('registration.page.login.errors.invalidEmail') });
return;
}
if ('error' in error && error.error !== 403) {
setErrorOnSubmit([error.error, error.reason]);
}
},
});
const usernameId = useId();
const passwordId = useId();
const loginFormRef = useRef<HTMLElement>(null);
useEffect(() => {
if (loginFormRef.current) {
loginFormRef.current.focus();
}
}, [errorOnSubmit]);
const renderErrorOnSubmit = ([error, message]: Exclude<LoginErrorState, undefined>) => {
if (error in LOGIN_SUBMIT_ERRORS) {
const { type, i18n } = LOGIN_SUBMIT_ERRORS[error as Exclude<LoginErrors, string>];
return (
<Callout id={`${usernameId}-error`} aria-live='assertive' type={type}>
{t(i18n)}
</Callout>
);
}
if (error === 'totp-canceled') {
return null;
}
if (message) {
return (
<Callout id={`${usernameId}-error`} aria-live='assertive' type='danger'>
{message}
</Callout>
);
}
return null;
};
if (errors.usernameOrEmail?.type === 'invalid-email') {
return <EmailConfirmationForm onBackToLogin={() => clearErrors('usernameOrEmail')} email={getValues('usernameOrEmail')} />;
}
const hasAuthError = errors.password?.type === 'user-not-found';
return (
<Form
tabIndex={-1}
ref={loginFormRef}
aria-labelledby={formLabelId}
aria-describedby='welcomeTitle'
onSubmit={handleSubmit(async (data) => loginMutation.mutate(data))}
>
<FormHeader>
<FormTitle id={formLabelId}>{t('registration.component.login')}</FormTitle>
</FormHeader>
{showFormLogin && (
<>
<FormContainer>
<FieldGroup disabled={loginMutation.isPending}>
<Field>
<FieldLabel required htmlFor={usernameId}>
{t('registration.component.form.emailOrUsername')}
</FieldLabel>
<FieldRow>
<TextInput
{...register('usernameOrEmail', {
required: t('Required_field', { field: t('registration.component.form.emailOrUsername') }),
})}
autoCapitalize='none'
autoCorrect='off'
placeholder={usernameOrEmailPlaceholder || t('registration.component.form.emailPlaceholder')}
error={errors.usernameOrEmail?.message || (hasAuthError ? errors.password?.message : undefined)}
aria-invalid={errors.usernameOrEmail || hasAuthError || errorOnSubmit ? 'true' : 'false'}
aria-describedby={`${usernameId}-error`}
id={usernameId}
/>
</FieldRow>
{errors.usernameOrEmail && (
<FieldError role='alert' id={`${usernameId}-error`}>
{errors.usernameOrEmail.message}
</FieldError>
)}
</Field>
<Field>
<FieldLabel required htmlFor={passwordId}>
{t('registration.component.form.password')}
</FieldLabel>
<FieldRow>
<PasswordInput
{...register('password', {
required: t('Required_field', { field: t('registration.component.form.password') }),
})}
placeholder={passwordPlaceholder}
error={errors.password?.message}
aria-invalid={errors.password || errorOnSubmit ? 'true' : 'false'}
aria-describedby={`${passwordId}-error`}
id={passwordId}
/>
</FieldRow>
{errors.password && (
<FieldError role='alert' id={`${passwordId}-error`}>
{errors.password.message}
</FieldError>
)}
{isResetPasswordAllowed && (
<FieldRow justifyContent='end'>
<FieldLink
href='#'
onClick={(e): void => {
e.preventDefault();
setLoginRoute('reset-password');
}}
>
<Trans i18nKey='registration.page.login.forgot'>Forgot your password?</Trans>
</FieldLink>
</FieldRow>
)}
</Field>
</FieldGroup>
{errorOnSubmit && <FieldGroup disabled={loginMutation.isPending}>{renderErrorOnSubmit(errorOnSubmit)}</FieldGroup>}
</FormContainer>
<FormFooter>
<ButtonGroup>
<Button loading={loginMutation.isPending} type='submit' primary>
{t('registration.component.login')}
</Button>
</ButtonGroup>
<p>
<Trans i18nKey='registration.page.login.register'>
New here? <ActionLink onClick={(): void => setLoginRoute('register')}>Create an account</ActionLink>
</Trans>
</p>
</FormFooter>
</>
)}
<LoginServices disabled={loginMutation.isPending} setError={setErrorOnSubmit} />
</Form>
);
};
export default LoginForm;

View File

@@ -0,0 +1,71 @@
import { Button, ButtonGroup, Divider } from '@rocket.chat/fuselage';
import { useLoginServices, useSetting } from '@rocket.chat/ui-contexts';
import { useMemo, type Dispatch, type SetStateAction } from 'react';
import { useTranslation } from 'react-i18next';
import type { LoginErrorState } from './LoginForm';
import LoginServicesButton from './LoginServicesButton';
const servicesToBeShownOnDesktop = ['cas', 'ldap'];
export type LoginServicesProps = { disabled?: boolean; setError: Dispatch<SetStateAction<LoginErrorState>> };
const LoginServices = ({ disabled, setError }: LoginServicesProps) => {
const { t } = useTranslation();
const services = useLoginServices();
const showFormLogin = useSetting('Accounts_ShowFormLogin');
const enableModernOAuthFlow = useSetting('Accounts_OAuth_Use_Modern_Flow', true);
const isDesktopApp = !!window.RocketChatDesktop?.openInBrowser && enableModernOAuthFlow;
const servicesToShow = useMemo(
() => (isDesktopApp ? services.filter(({ service }) => servicesToBeShownOnDesktop.includes(service)) : services),
[isDesktopApp, services],
);
if (services.length === 0) {
return null;
}
const handleLoginOnWeb = () => {
if (!isDesktopApp) {
return;
}
const redirectUrl = new URL(window.location.href);
redirectUrl.searchParams.set('loginClient', 'desktop');
window.RocketChatDesktop?.openInBrowser(redirectUrl.toString());
};
return (
<>
{showFormLogin && (
<Divider marginBlock={24} padding={0}>
{t('registration.component.form.divider')}
</Divider>
)}
{servicesToShow.length > 0 && (
<ButtonGroup vertical stretch small>
{servicesToShow.map((service) => (
<LoginServicesButton
disabled={disabled}
key={service.service}
{...service}
setError={setError}
enableModernOAuthFlow={enableModernOAuthFlow}
/>
))}
</ButtonGroup>
)}
{isDesktopApp && (
<Button width='100%' primary onClick={handleLoginOnWeb} marginBlockStart={4}>
{t('registration.component.login.onWeb')}
</Button>
)}
</>
);
};
export default LoginServices;

View File

@@ -0,0 +1,87 @@
import { Button } from '@rocket.chat/fuselage';
import type { Keys as IconName } from '@rocket.chat/icons';
import type { LoginService } from '@rocket.chat/ui-contexts';
import { useLoginWithService } from '@rocket.chat/ui-contexts';
import type { SetStateAction, Dispatch } from 'react';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import type { LoginErrorState, LoginErrors } from './LoginForm';
const servicesSupportedByMeteor = ['saml', 'cas', 'ldap'];
const LoginServicesButton = <T extends LoginService>({
buttonLabelText,
icon,
title,
service,
className,
disabled,
setError,
buttonColor,
buttonLabelColor,
loginStyle,
enableModernOAuthFlow,
...props
}: T & {
className?: string;
disabled?: boolean;
loginStyle?: 'popup' | 'redirect' | '';
setError?: Dispatch<SetStateAction<LoginErrorState>>;
enableModernOAuthFlow?: boolean;
}) => {
const { t } = useTranslation();
const handler = useLoginWithService({ service, buttonLabelText, ...props });
const handleOnClick = useCallback(() => {
if (!servicesSupportedByMeteor.includes(service) && enableModernOAuthFlow) {
const url = new URL(window.location.href);
const queryParams = url.searchParams;
const loginClient = queryParams.get('loginClient');
if (loginStyle === 'popup') {
window.open(
`/oauth/${service}${loginClient ? `?loginClient=${loginClient}` : ''}`,
'oauth',
'popup=yes,width=500,height=700,left=100,top=100',
);
return;
}
const redirectUrl = new URL(`/oauth/${service}`, window.location.origin);
if (loginClient) {
redirectUrl.searchParams.set('loginClient', loginClient);
}
window.location.href = redirectUrl.toString();
return;
}
handler().catch((e: { error?: LoginErrors; reason?: string }) => {
if (!e.error || typeof e.error !== 'string') {
return;
}
setError?.([e.error, e.reason]);
});
}, [handler, setError, service, enableModernOAuthFlow, loginStyle]);
return (
<Button
icon={icon as IconName}
className={className}
onClick={handleOnClick}
title={buttonLabelText && buttonLabelText !== title ? title : undefined}
disabled={disabled}
alignItems='center'
display='flex'
justifyContent='center'
color={buttonLabelColor}
backgroundColor={buttonColor}
>
{buttonLabelText || t('Sign_in_with__provider__', { provider: title })}
</Button>
);
};
export default LoginServicesButton;

View File

@@ -0,0 +1,319 @@
/* eslint-disable complexity */
import {
FieldGroup,
TextInput,
Field,
FieldLabel,
FieldRow,
FieldError,
PasswordInput,
ButtonGroup,
Button,
TextAreaInput,
Callout,
} from '@rocket.chat/fuselage';
import { Form, FormContainer, FormFooter, FormHeader, FormTitle, ActionLink } from '@rocket.chat/layout';
import { CustomFieldsForm, PasswordVerifier, useValidatePassword } from '@rocket.chat/ui-client';
import { useAccountsCustomFields, useSetting, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { useEffect, useId, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { Trans, useTranslation } from 'react-i18next';
import EmailConfirmationForm from './EmailConfirmationForm';
import type { DispatchLoginRouter } from './hooks/useLoginRouter';
import { useRegisterMethod } from './hooks/useRegisterMethod';
type LoginRegisterPayload = {
name: string;
passwordConfirmation: string;
username: string;
password: string;
email: string;
reason: string;
};
export type RegisterFormProps = { setLoginRoute: DispatchLoginRouter };
export const RegisterForm = ({ setLoginRoute }: RegisterFormProps) => {
const { t } = useTranslation();
const requireNameForRegister = useSetting('Accounts_RequireNameForSignUp', true);
const requiresPasswordConfirmation = useSetting('Accounts_RequirePasswordConfirmation', true);
const manuallyApproveNewUsersRequired = useSetting('Accounts_ManuallyApproveNewUsers', false);
const usernameOrEmailPlaceholder = useSetting('Accounts_EmailOrUsernamePlaceholder', '');
const passwordPlaceholder = useSetting('Accounts_PasswordPlaceholder', '');
const passwordConfirmationPlaceholder = useSetting('Accounts_ConfirmPasswordPlaceholder', '');
const formLabelId = useId();
const passwordVerifierId = useId();
const nameId = useId();
const emailId = useId();
const usernameId = useId();
const passwordId = useId();
const passwordConfirmationId = useId();
const reasonId = useId();
const registerUser = useRegisterMethod();
const customFields = useAccountsCustomFields();
const [serverError, setServerError] = useState<string | undefined>(undefined);
const dispatchToastMessage = useToastMessageDispatch();
const {
register,
handleSubmit,
setError,
watch,
getValues,
clearErrors,
control,
formState: { errors },
} = useForm<LoginRegisterPayload>();
const { password } = watch();
const passwordIsValid = useValidatePassword(password);
const registerFormRef = useRef<HTMLElement>(null);
useEffect(() => {
if (registerFormRef.current) {
registerFormRef.current.focus();
}
}, []);
const handleRegister = async ({ password, passwordConfirmation: _, ...formData }: LoginRegisterPayload) => {
registerUser.mutate(
{ pass: password, ...formData },
{
onError: (error: any) => {
if ([error.error, error.errorType].includes('error-invalid-email')) {
setError('email', { type: 'invalid-email', message: t('registration.component.form.invalidEmail') });
}
if (error.errorType === 'error-user-already-exists') {
setError('username', { type: 'user-already-exists', message: t('registration.component.form.usernameAlreadyExists') });
}
if (error.errorType === 'error-invalid-domain') {
setError('email', { type: 'invalid-domain', message: t('registration.component.form.invalidEmailDomain') });
}
if (/Email already exists/.test(error.error)) {
setError('email', { type: 'email-already-exists', message: t('registration.component.form.emailAlreadyInUse') });
}
if (/Username is already in use/.test(error.error)) {
setError('username', { type: 'username-already-exists', message: t('registration.component.form.usernameAlreadyInUse') });
}
if (/The username provided is not valid/.test(error.error)) {
setError('username', {
type: 'username-contains-invalid-chars',
message: t('registration.component.form.usernameContainsInvalidChars'),
});
}
if (/Name contains invalid characters/.test(error.error)) {
setError('name', { type: 'name-contains-invalid-chars', message: t('registration.component.form.nameContainsInvalidChars') });
}
if (/error-too-many-requests/.test(error.error)) {
dispatchToastMessage({ type: 'error', message: error.error });
}
if (/error-user-is-not-activated/.test(error.error)) {
dispatchToastMessage({ type: 'info', message: t('registration.page.registration.waitActivationWarning') });
setLoginRoute('login');
}
if (error.error === 'error-user-registration-custom-field') {
setServerError(error.message);
}
},
},
);
};
if (errors.email?.type === 'invalid-email') {
return <EmailConfirmationForm onBackToLogin={() => clearErrors('email')} email={getValues('email')} />;
}
return (
<Form
tabIndex={-1}
ref={registerFormRef}
aria-labelledby={formLabelId}
aria-describedby='welcomeTitle'
onSubmit={handleSubmit(handleRegister)}
>
<FormHeader>
<FormTitle id={formLabelId}>{t('registration.component.form.createAnAccount')}</FormTitle>
</FormHeader>
<FormContainer>
<FieldGroup>
<Field>
<FieldLabel required={requireNameForRegister} htmlFor={nameId}>
{t('registration.component.form.name')}
</FieldLabel>
<FieldRow>
<TextInput
{...register('name', {
required: requireNameForRegister ? t('Required_field', { field: t('registration.component.form.name') }) : false,
})}
error={errors?.name?.message}
aria-required={requireNameForRegister}
aria-invalid={errors.name ? 'true' : 'false'}
placeholder={t('onboarding.form.adminInfoForm.fields.fullName.placeholder')}
aria-describedby={`${nameId}-error`}
id={nameId}
/>
</FieldRow>
{errors.name && (
<FieldError role='alert' id={`${nameId}-error`}>
{errors.name.message}
</FieldError>
)}
</Field>
<Field>
<FieldLabel required htmlFor={emailId}>
{t('registration.component.form.email')}
</FieldLabel>
<FieldRow>
<TextInput
{...register('email', {
required: t('Required_field', { field: t('registration.component.form.email') }),
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: t('registration.component.form.invalidEmail'),
},
})}
placeholder={usernameOrEmailPlaceholder || t('registration.component.form.emailPlaceholder')}
error={errors?.email?.message}
aria-required='true'
aria-invalid={errors.email ? 'true' : 'false'}
aria-describedby={`${emailId}-error`}
id={emailId}
/>
</FieldRow>
{errors.email && (
<FieldError role='alert' id={`${emailId}-error`}>
{errors.email.message}
</FieldError>
)}
</Field>
<Field>
<FieldLabel required htmlFor={usernameId}>
{t('registration.component.form.username')}
</FieldLabel>
<FieldRow>
<TextInput
{...register('username', {
required: t('Required_field', { field: t('registration.component.form.username') }),
})}
error={errors?.username?.message}
aria-required='true'
aria-invalid={errors.username ? 'true' : 'false'}
aria-describedby={`${usernameId}-error`}
id={usernameId}
placeholder='jon.doe'
/>
</FieldRow>
{errors.username && (
<FieldError role='alert' id={`${usernameId}-error`}>
{errors.username.message}
</FieldError>
)}
</Field>
<Field>
<FieldLabel required htmlFor={passwordId}>
{t('registration.component.form.password')}
</FieldLabel>
<FieldRow>
<PasswordInput
{...register('password', {
required: t('Required_field', { field: t('registration.component.form.password') }),
validate: () => (!passwordIsValid ? t('Password_must_meet_the_complexity_requirements') : true),
})}
error={errors.password?.message}
aria-required='true'
aria-invalid={errors.password ? 'true' : undefined}
id={passwordId}
placeholder={passwordPlaceholder || t('Create_a_password')}
aria-describedby={`${passwordVerifierId} ${passwordId}-error`}
/>
</FieldRow>
{errors?.password && (
<FieldError role='alert' id={`${passwordId}-error`}>
{errors.password.message}
</FieldError>
)}
<PasswordVerifier password={password} id={passwordVerifierId} />
</Field>
{requiresPasswordConfirmation && (
<Field>
<FieldLabel required htmlFor={passwordConfirmationId}>
{t('registration.component.form.confirmPassword')}
</FieldLabel>
<FieldRow>
<PasswordInput
{...register('passwordConfirmation', {
required: t('Required_field', { field: t('registration.component.form.confirmPassword') }),
deps: ['password'],
validate: (val: string) => (watch('password') === val ? true : t('registration.component.form.invalidConfirmPass')),
})}
error={errors.passwordConfirmation?.message}
aria-required='true'
aria-invalid={errors.passwordConfirmation ? 'true' : 'false'}
id={passwordConfirmationId}
aria-describedby={`${passwordConfirmationId}-error`}
placeholder={passwordConfirmationPlaceholder || t('Confirm_password')}
disabled={!passwordIsValid}
/>
</FieldRow>
{errors.passwordConfirmation && (
<FieldError role='alert' id={`${passwordConfirmationId}-error`}>
{errors.passwordConfirmation.message}
</FieldError>
)}
</Field>
)}
{manuallyApproveNewUsersRequired && (
<Field>
<FieldLabel required htmlFor={reasonId}>
{t('registration.component.form.reasonToJoin')}
</FieldLabel>
<FieldRow>
<TextAreaInput
{...register('reason', {
required: t('Required_field', { field: t('registration.component.form.reasonToJoin') }),
})}
error={errors?.reason?.message}
aria-required='true'
aria-invalid={errors.reason ? 'true' : 'false'}
aria-describedby={`${reasonId}-error`}
id={reasonId}
/>
</FieldRow>
{errors.reason && (
<FieldError role='alert' id={`${reasonId}-error`}>
{errors.reason.message}
</FieldError>
)}
</Field>
)}
<CustomFieldsForm formName='customFields' formControl={control} metadata={customFields} />
{serverError && <Callout type='danger'>{serverError}</Callout>}
</FieldGroup>
</FormContainer>
<FormFooter>
<ButtonGroup>
<Button type='submit' loading={registerUser.isPending} primary>
{t('registration.component.form.joinYourTeam')}
</Button>
</ButtonGroup>
<ActionLink
onClick={(): void => {
setLoginRoute('login');
}}
>
<Trans i18nKey='registration.page.register.back'>Back to Login</Trans>
</ActionLink>
</FormFooter>
</Form>
);
};
export default RegisterForm;

View File

@@ -0,0 +1,38 @@
import { Callout } from '@rocket.chat/fuselage';
import { Form, FormContainer, FormFooter, FormHeader, FormTitle, ActionLink } from '@rocket.chat/layout';
import { useSetting } from '@rocket.chat/ui-contexts';
import { Trans, useTranslation } from 'react-i18next';
import type { DispatchLoginRouter } from './hooks/useLoginRouter';
export type RegisterFormDisabledProps = { setLoginRoute: DispatchLoginRouter };
export const RegisterFormDisabled = ({ setLoginRoute }: RegisterFormDisabledProps) => {
const linkReplacementText = useSetting('Accounts_RegistrationForm_LinkReplacementText', '');
const { t } = useTranslation();
return (
<Form>
<FormHeader>
<FormTitle>{t('registration.component.form.register')}</FormTitle>
</FormHeader>
<FormContainer>
<Callout role='status' type='warning'>
{linkReplacementText}
</Callout>
</FormContainer>
<FormFooter>
<ActionLink
onClick={(): void => {
setLoginRoute('login');
}}
>
<Trans i18nKey='registration.page.register.back'>Back to Login</Trans>
</ActionLink>
</FormFooter>
</Form>
);
};
export default RegisterFormDisabled;

View File

@@ -0,0 +1,60 @@
import { useDocumentTitle } from '@rocket.chat/ui-client';
import { useSetting } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';
import RegisterForm from './RegisterForm';
import RegisterFormDisabled from './RegisterFormDisabled';
import RegisterTemplate from './RegisterTemplate';
import SecretRegisterForm from './SecretRegisterForm';
import SecretRegisterInvalidForm from './SecretRegisterInvalidForm';
import type { DispatchLoginRouter } from './hooks/useLoginRouter';
import FormSkeleton from './template/FormSkeleton';
export const RegisterSecretPageRouter = ({
setLoginRoute,
origin,
}: {
setLoginRoute: DispatchLoginRouter;
origin: 'register' | 'secret-register' | 'invite-register';
}) => {
const { t } = useTranslation();
const registrationMode = useSetting<'Public' | 'Disabled' | 'Secret URL'>('Accounts_RegistrationForm', 'Public');
const isPublicRegistration = registrationMode === 'Public';
const isRegistrationAllowedForSecret = registrationMode === 'Secret URL';
const isRegistrationDisabled = registrationMode === 'Disabled' || (origin === 'register' && isRegistrationAllowedForSecret);
useDocumentTitle(t('registration.component.form.createAnAccount'), false);
if (origin === 'secret-register' && !isRegistrationAllowedForSecret) {
return <SecretRegisterInvalidForm />;
}
if (isPublicRegistration || (origin === 'invite-register' && isRegistrationAllowedForSecret)) {
return (
<RegisterTemplate aria-label={t('Register')}>
<RegisterForm setLoginRoute={setLoginRoute} />
</RegisterTemplate>
);
}
if (isRegistrationDisabled) {
return (
<RegisterTemplate>
<RegisterFormDisabled setLoginRoute={setLoginRoute} />
</RegisterTemplate>
);
}
if (isRegistrationAllowedForSecret) {
return <SecretRegisterForm setLoginRoute={setLoginRoute} />;
}
return (
<RegisterTemplate>
<FormSkeleton />
</RegisterTemplate>
);
};
export default RegisterSecretPageRouter;

View File

@@ -0,0 +1,22 @@
import { useSetting } from '@rocket.chat/ui-contexts';
import type { AllHTMLAttributes, ReactNode } from 'react';
import HorizontalTemplate from './template/HorizontalTemplate';
import VerticalTemplate from './template/VerticalTemplate';
export type RegisterTemplateProps = {
children: ReactNode;
} & AllHTMLAttributes<HTMLElement>;
const RegisterTemplate = ({ children, ...props }: RegisterTemplateProps) => {
const template = useSetting<'vertical-template' | 'horizontal-template'>('Layout_Login_Template', 'horizontal-template');
return (
<main {...props}>
{template === 'vertical-template' && <VerticalTemplate>{children}</VerticalTemplate>}
{template === 'horizontal-template' && <HorizontalTemplate>{children}</HorizontalTemplate>}
</main>
);
};
export default RegisterTemplate;

View File

@@ -0,0 +1,55 @@
import { useSession } from '@rocket.chat/ui-contexts';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import GuestForm from './GuestForm';
import { LoginForm } from './LoginForm';
import RegisterSecretPageRouter from './RegisterSecretPageRouter';
import RegisterTemplate from './RegisterTemplate';
import ResetPasswordForm from './ResetPasswordForm';
import { useLoginRouter } from './hooks/useLoginRouter';
import type { LoginRoutes } from './hooks/useLoginRouter';
export type RegistrationPageRouterProps = { defaultRoute?: LoginRoutes; children?: ReactNode };
export const RegistrationPageRouter = ({ defaultRoute = 'login', children }: RegistrationPageRouterProps) => {
const { t } = useTranslation();
const defaultRouteSession = useSession('loginDefaultState') as LoginRoutes | undefined;
const [route, setLoginRoute] = useLoginRouter(defaultRouteSession || defaultRoute);
if (route === 'guest') {
return (
<RegisterTemplate aria-label={t('Guest')}>
<GuestForm setLoginRoute={setLoginRoute} />
</RegisterTemplate>
);
}
if (route === 'login') {
return (
<RegisterTemplate aria-label={t('Login')}>
<LoginForm setLoginRoute={setLoginRoute} />
</RegisterTemplate>
);
}
if (route === 'reset-password') {
return (
<RegisterTemplate aria-label={t('Reset_password')}>
<ResetPasswordForm setLoginRoute={setLoginRoute} />
</RegisterTemplate>
);
}
if (route === 'secret-register' || route === 'register' || route === 'invite-register') {
return <RegisterSecretPageRouter origin={route} setLoginRoute={setLoginRoute} />;
}
if (route === 'anonymous') {
return <>{children}</>;
}
return null;
};
export default RegistrationPageRouter;

View File

@@ -0,0 +1,11 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import type { Meta, StoryObj } from '@storybook/react';
import ResetPasswordPage from './ResetPasswordPage';
export default {
component: ResetPasswordPage,
decorators: [mockAppRoot().buildStoryDecorator()],
} satisfies Meta<typeof ResetPasswordPage>;
export const Basic: StoryObj<typeof ResetPasswordPage> = {};

View File

@@ -0,0 +1,162 @@
import type { IUser } from '@rocket.chat/core-typings';
import { Button, FieldGroup, Field, FieldLabel, ButtonGroup, PasswordInput, FieldRow, FieldError } from '@rocket.chat/fuselage';
import { Form, FormContainer, FormFooter, FormHeader, FormSubtitle, FormTitle } from '@rocket.chat/layout';
import { PasswordVerifier, useValidatePassword } from '@rocket.chat/ui-client';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import { useSetting, useRouter, useRouteParameter, useUser, useMethod, useLoginWithToken, useEndpoint } from '@rocket.chat/ui-contexts';
import { useEffect, useId, useRef } from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import HorizontalTemplate from '../template/HorizontalTemplate';
const getChangePasswordReason = ({
requirePasswordChange,
requirePasswordChangeReason = requirePasswordChange ? 'You_need_to_change_your_password' : 'Please_enter_your_new_password_below',
}: Pick<IUser, 'requirePasswordChange' | 'requirePasswordChangeReason'> = {}) => requirePasswordChangeReason as TranslationKey;
const ResetPasswordPage = () => {
const user = useUser();
const { t } = useTranslation();
const setBasicInfo = useEndpoint('POST', '/v1/users.updateOwnBasicInfo');
const resetPassword = useMethod('resetPassword');
const token = useRouteParameter('token');
const resetPasswordFormRef = useRef<HTMLElement>(null);
const passwordId = useId();
const passwordConfirmationId = useId();
const passwordVerifierId = useId();
const formLabelId = useId();
const requiresPasswordConfirmation = useSetting('Accounts_RequirePasswordConfirmation', true);
const passwordPlaceholder = useSetting('Accounts_PasswordPlaceholder', '');
const passwordConfirmationPlaceholder = useSetting('Accounts_ConfirmPasswordPlaceholder', '');
const router = useRouter();
const changePasswordReason = getChangePasswordReason(user || {});
const loginWithToken = useLoginWithToken();
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting },
watch,
} = useForm<{
password: string;
passwordConfirmation: string;
}>();
const password = watch('password');
const passwordIsValid = useValidatePassword(password);
useEffect(() => {
if (resetPasswordFormRef.current) {
resetPasswordFormRef.current.focus();
}
}, []);
const handleResetPassword = async ({ password }: { password: string }) => {
try {
if (token) {
const result = await resetPassword(token, password);
await loginWithToken(result.token);
router.navigate('/home');
} else {
await setBasicInfo({
data: {
newPassword: password,
},
});
}
} catch ({ error, reason }: any) {
const _error = reason ?? error;
setError('password', { message: String(_error) });
}
};
return (
<HorizontalTemplate>
<Form
tabIndex={-1}
ref={resetPasswordFormRef}
aria-labelledby={formLabelId}
aria-describedby='welcomeTitle'
onSubmit={handleSubmit(handleResetPassword)}
>
<FormHeader>
<FormTitle id={formLabelId}>{t('Reset_password')}</FormTitle>
<FormSubtitle>{t(changePasswordReason)}</FormSubtitle>
</FormHeader>
<FormContainer>
<FieldGroup>
<Field>
<FieldLabel required htmlFor={passwordId}>
{t('registration.component.form.password')}
</FieldLabel>
<FieldRow>
<PasswordInput
{...register('password', {
required: t('registration.component.form.requiredField'),
validate: () => (!passwordIsValid ? t('Password_must_meet_the_complexity_requirements') : true),
})}
error={errors?.password?.message}
aria-invalid={errors.password ? 'true' : 'false'}
aria-required='true'
id={passwordId}
placeholder={passwordPlaceholder || t('Create_a_password')}
aria-describedby={`${passwordVerifierId} ${passwordId}-error`}
/>
</FieldRow>
{errors?.password && (
<FieldError aria-live='assertive' id={`${passwordId}-error`}>
{errors.password.message}
</FieldError>
)}
<PasswordVerifier password={password} id={passwordVerifierId} />
</Field>
{requiresPasswordConfirmation && (
<Field>
<FieldLabel required htmlFor={passwordConfirmationId}>
{t('registration.component.form.confirmPassword')}
</FieldLabel>
<FieldRow>
<PasswordInput
{...register('passwordConfirmation', {
required: t('registration.component.form.requiredField'),
deps: ['password'],
validate: (val: string) => (password === val ? true : t('registration.component.form.invalidConfirmPass')),
})}
error={errors?.passwordConfirmation?.message}
aria-required='true'
aria-invalid={errors.passwordConfirmation ? 'true' : 'false'}
aria-describedby={`${passwordConfirmationId}-error`}
id={passwordConfirmationId}
placeholder={passwordConfirmationPlaceholder || t('Confirm_password')}
disabled={!passwordIsValid}
/>
</FieldRow>
{errors.passwordConfirmation && (
<FieldError aria-live='assertive' id={`${passwordConfirmationId}-error`}>
{errors.passwordConfirmation?.message}
</FieldError>
)}
</Field>
)}
</FieldGroup>
</FormContainer>
<FormFooter>
<ButtonGroup>
<Button primary loading={isSubmitting} type='submit'>
{t('Reset')}
</Button>
</ButtonGroup>
</FormFooter>
</Form>
</HorizontalTemplate>
);
};
export default ResetPasswordPage;

View File

@@ -0,0 +1,106 @@
import { FieldGroup, TextInput, Field, FieldLabel, FieldRow, FieldError, ButtonGroup, Button, Callout } from '@rocket.chat/fuselage';
import { Form, FormContainer, FormFooter, FormHeader, FormTitle, ActionLink } from '@rocket.chat/layout';
import { useDocumentTitle } from '@rocket.chat/ui-client';
import { useEffect, useId, useRef } from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import type { DispatchLoginRouter } from './hooks/useLoginRouter';
import { useSendForgotPassword } from './hooks/useSendForgotPassword';
export type ResetPasswordFormProps = { setLoginRoute: DispatchLoginRouter };
export const ResetPasswordForm = ({ setLoginRoute }: ResetPasswordFormProps) => {
const { t } = useTranslation();
const emailId = useId();
const formLabelId = useId();
const forgotPasswordFormRef = useRef<HTMLElement>(null);
useDocumentTitle(t('registration.component.resetPassword'), false);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<{
email: string;
}>();
useEffect(() => {
if (forgotPasswordFormRef.current) {
forgotPasswordFormRef.current.focus();
}
}, []);
const { mutateAsync, isSuccess } = useSendForgotPassword();
return (
<Form
ref={forgotPasswordFormRef}
tabIndex={-1}
aria-labelledby={formLabelId}
aria-describedby='welcomeTitle'
onSubmit={handleSubmit((data) => {
void mutateAsync({ email: data.email });
})}
>
<FormHeader>
<FormTitle id={formLabelId}>{t('registration.component.resetPassword')}</FormTitle>
</FormHeader>
<FormContainer>
<FieldGroup>
<Field>
<FieldLabel required htmlFor={emailId}>
{t('registration.component.form.email')}
</FieldLabel>
<FieldRow>
<TextInput
{...register('email', {
required: t('Required_field', { field: t('registration.component.form.email') }),
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: t('registration.page.resetPassword.errors.invalidEmail'),
},
})}
error={errors.email?.message}
aria-invalid={Boolean(errors.email)}
aria-required='true'
aria-describedby={`${emailId}-error`}
placeholder={t('registration.component.form.emailPlaceholder')}
id={emailId}
/>
</FieldRow>
{errors.email && (
<FieldError aria-live='assertive' id={`${emailId}-error`}>
{errors.email.message}
</FieldError>
)}
</Field>
</FieldGroup>
{isSuccess && (
<FieldGroup>
<Callout aria-live='assertive' role='status' marginBlockStart={24} icon='mail'>
{t('registration.page.resetPassword.sent')}
</Callout>
</FieldGroup>
)}
</FormContainer>
<FormFooter>
<ButtonGroup>
<Button type='submit' loading={isSubmitting} primary>
{t('registration.page.resetPassword.sendInstructions')}
</Button>
</ButtonGroup>
<ActionLink
onClick={(): void => {
setLoginRoute('login');
}}
>
{t('registration.page.register.back')}
</ActionLink>
</FormFooter>
</Form>
);
};
export default ResetPasswordForm;

View File

@@ -0,0 +1,36 @@
import { useRouteParameter } from '@rocket.chat/ui-contexts';
import RegisterForm from './RegisterForm';
import SecretRegisterInvalidForm from './SecretRegisterInvalidForm';
import { useCheckRegistrationSecret } from './hooks/useCheckRegistrationSecret';
import type { DispatchLoginRouter } from './hooks/useLoginRouter';
import FormSkeleton from './template/FormSkeleton';
import HorizontalTemplate from './template/HorizontalTemplate';
export type SecretRegisterFormProps = { setLoginRoute: DispatchLoginRouter };
const SecretRegisterForm = ({ setLoginRoute }: SecretRegisterFormProps) => {
const hash = useRouteParameter('hash');
const { data: valid, isSuccess } = useCheckRegistrationSecret(hash);
if (isSuccess && !valid) {
return <SecretRegisterInvalidForm />;
}
if (isSuccess && valid) {
return (
<HorizontalTemplate>
<RegisterForm setLoginRoute={setLoginRoute} />
</HorizontalTemplate>
);
}
return (
<HorizontalTemplate>
<FormSkeleton />
</HorizontalTemplate>
);
};
export default SecretRegisterForm;

View File

@@ -0,0 +1,18 @@
import { Callout } from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
import VerticalTemplate from './template/VerticalTemplate';
const SecretRegisterInvalidForm = () => {
const { t } = useTranslation();
return (
<VerticalTemplate>
<Callout role='status' type='warning'>
{t('Invalid_secret_URL_message')}
</Callout>
</VerticalTemplate>
);
};
export default SecretRegisterInvalidForm;

View File

@@ -0,0 +1,23 @@
import { Box } from '@rocket.chat/fuselage';
import { ActionLink } from '@rocket.chat/layout';
import { useSetting } from '@rocket.chat/ui-contexts';
import { Trans } from 'react-i18next';
export const LoginPoweredBy = () => {
const hidePoweredBy = useSetting('Layout_Login_Hide_Powered_By', false);
if (hidePoweredBy) {
return null;
}
return (
<Box marginBlockEnd={18}>
<Trans i18nKey='registration.page.poweredBy'>
{'Powered by '}
<ActionLink href='https://rocket.chat/' target='_blank' rel='noopener noreferrer'>
Rocket.Chat
</ActionLink>
</Trans>
</Box>
);
};
export default LoginPoweredBy;

View File

@@ -0,0 +1,46 @@
import { Box, Tile } from '@rocket.chat/fuselage';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import type { Meta, StoryObj } from '@storybook/react';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import LoginSwitchLanguageFooter from './LoginSwitchLanguageFooter';
function ExampleTranslationDecorator({ children }: { children: ReactNode }) {
const { t } = useTranslation();
return (
<Box>
<Tile>{t('example.text')}</Tile>
{children}
</Box>
);
}
export default {
component: LoginSwitchLanguageFooter,
decorators: [
(Story) => (
<ExampleTranslationDecorator>
<Story />
</ExampleTranslationDecorator>
),
mockAppRoot()
.withSetting('Language', 'fi')
.withTranslations('en', 'registration', { 'component.switchLanguage': 'Change to <1>{{ name }}</1>' })
.withTranslations('en', 'example', { text: 'Hello!' })
.withTranslations('fi', 'registration', { 'component.switchLanguage': 'Vaihda kieleksi <1>{{ name }}</1>' })
.withTranslations('fi', 'example', { text: 'Hei!' })
.withTranslations('pt-BR', 'registration', { 'component.switchLanguage': 'Mudar para <1>{{ name }}</1>' })
.withTranslations('pt', 'example', { text: 'Olá!' })
.buildStoryDecorator(),
],
args: {
browserLanguage: 'pt-BR',
},
parameters: {
layout: 'centered',
},
} satisfies Meta<typeof LoginSwitchLanguageFooter>;
export const Example: StoryObj<typeof LoginSwitchLanguageFooter> = {};

View File

@@ -0,0 +1,81 @@
import { Button } from '@rocket.chat/fuselage';
import { useLocalStorage } from '@rocket.chat/fuselage-hooks';
import { HorizontalWizardLayoutCaption } from '@rocket.chat/layout';
import { normalizeLanguage } from '@rocket.chat/tools';
import { type TranslationLanguage, useSetting, useLoadLanguage, useLanguage, useLanguages } from '@rocket.chat/ui-contexts';
import { useMemo, useEffect } from 'react';
import type { UIEvent } from 'react';
import { Trans, useTranslation } from 'react-i18next';
const useSuggestedLanguages = ({
browserLanguage = normalizeLanguage(window.navigator.language ?? 'en'),
}: {
browserLanguage?: string;
}) => {
const availableLanguages = useLanguages();
const currentLanguage = useLanguage();
const serverLanguage = normalizeLanguage(useSetting('Language', 'en'));
const suggestions = useMemo(() => {
const potentialLanguages = new Set([serverLanguage, browserLanguage, 'en'].map(normalizeLanguage));
const potentialSuggestions = Array.from(potentialLanguages).map((potentialLanguageKey) =>
availableLanguages.find((language) => language.key === potentialLanguageKey),
);
return potentialSuggestions.filter((language): language is TranslationLanguage => {
return !!language && language.key !== currentLanguage;
});
}, [serverLanguage, browserLanguage, availableLanguages, currentLanguage]);
const { i18n } = useTranslation();
useEffect(() => {
void i18n.loadLanguages(suggestions.map((suggestion) => suggestion.key));
}, [i18n, suggestions]);
return { suggestions };
};
export type LoginSwitchLanguageFooterProps = {
browserLanguage?: string;
};
const LoginSwitchLanguageFooter = ({
browserLanguage = normalizeLanguage(window.navigator.language ?? 'en'),
}: LoginSwitchLanguageFooterProps) => {
const loadLanguage = useLoadLanguage();
const { suggestions } = useSuggestedLanguages({ browserLanguage });
const [, setPreferedLanguage] = useLocalStorage('preferedLanguage', '');
const handleSwitchLanguageClick =
(language: TranslationLanguage) =>
async (event: UIEvent): Promise<void> => {
event.preventDefault();
await loadLanguage(language.key);
setPreferedLanguage(language.key);
};
if (!suggestions.length) {
return null;
}
return (
<HorizontalWizardLayoutCaption>
{suggestions.map((suggestion) => {
// If suggestion is "Default", skip it
if (!suggestion.key) {
return;
}
return (
<Button secondary small marginInlineEnd={8} key={suggestion.key} onClick={handleSwitchLanguageClick(suggestion)}>
<Trans i18nKey='registration.component.switchLanguage' tOptions={{ lng: suggestion.key }} values={{ name: suggestion.ogName }}>
Change to <strong>{suggestion.ogName}</strong>
</Trans>
</Button>
);
})}
</HorizontalWizardLayoutCaption>
);
};
export default LoginSwitchLanguageFooter;

View File

@@ -0,0 +1,24 @@
import { Box } from '@rocket.chat/fuselage';
import { HorizontalWizardLayoutCaption } from '@rocket.chat/layout';
import { useSetting } from '@rocket.chat/ui-contexts';
import DOMPurify from 'dompurify';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
export const LoginTerms = () => {
const { t } = useTranslation();
const loginTerms = useSetting('Layout_Login_Terms', '');
const dangerousTerms = useMemo(
() => ({ __html: loginTerms !== '' ? DOMPurify.sanitize(loginTerms) : DOMPurify.sanitize(t('Layout_Login_Terms_Content')) }),
[loginTerms, t],
);
return (
<HorizontalWizardLayoutCaption>
<Box color='default' withRichContent dangerouslySetInnerHTML={dangerousTerms} />
</HorizontalWizardLayoutCaption>
);
};
export default LoginTerms;

View File

@@ -0,0 +1,17 @@
import { useSetting } from '@rocket.chat/ui-contexts';
import { Trans } from 'react-i18next';
export const RegisterTitle = () => {
const siteName = useSetting('Site_Name', 'Rocket.Chat');
const hideTitle = useSetting('Layout_Login_Hide_Title', false);
if (hideTitle) {
return null;
}
return (
<span id='welcomeTitle'>
<Trans i18nKey='registration.component.welcome'>Welcome to {siteName} workspace</Trans>
</span>
);
};

View File

@@ -0,0 +1,17 @@
import { useMethod } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
export const useCheckRegistrationSecret = (hash?: string) => {
const checkRegistrationSecretURL = useMethod('checkRegistrationSecretURL');
return useQuery({
queryKey: ['secretURL', hash],
queryFn: async () => {
if (!hash) {
return false;
}
return checkRegistrationSecretURL(hash);
},
});
};

View File

@@ -0,0 +1,9 @@
import type { Dispatch } from 'react';
import { useState } from 'react';
export type LoginRoutes =
'login' | 'reset-password' | 'register' | 'register-invalid' | 'secret-register' | 'invite-register' | 'guest' | 'anonymous';
export const useLoginRouter = (route: LoginRoutes): [LoginRoutes, DispatchLoginRouter] => useState<LoginRoutes>(route);
export type DispatchLoginRouter = Dispatch<LoginRoutes>;

View File

@@ -0,0 +1,8 @@
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useMutation } from '@tanstack/react-query';
export const useLoginSendEmailConfirmation = () => {
return useMutation({
mutationFn: useEndpoint('POST', '/v1/users.sendConfirmationEmail'),
});
};

View File

@@ -0,0 +1,17 @@
import { useEndpoint, useRouteParameter, useLoginWithPassword } from '@rocket.chat/ui-contexts';
import { useMutation } from '@tanstack/react-query';
export const useRegisterMethod = () => {
const register = useEndpoint('POST', '/v1/users.register');
const secret = useRouteParameter('hash');
const login = useLoginWithPassword();
return useMutation({
mutationFn: async ({ ...props }: Parameters<typeof register>[0]): Promise<ReturnType<typeof register>> => {
const result = await register({ ...props, secret });
await login(props.username, props.pass);
return result;
},
});
};

View File

@@ -0,0 +1,10 @@
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useMutation } from '@tanstack/react-query';
export const useSendForgotPassword = () => {
const sendForgotPassword = useEndpoint('POST', '/v1/users.forgotPassword');
return useMutation({
mutationFn: sendForgotPassword,
});
};

View File

@@ -0,0 +1,8 @@
import CMSPage from './CMSPage';
import RegistrationPageRouter from './RegistrationPageRouter';
import ResetPasswordPage from './ResetPassword/ResetPasswordPage';
export type { LoginRoutes } from './hooks/useLoginRouter';
export { CMSPage, ResetPasswordPage };
export default RegistrationPageRouter;

View File

@@ -0,0 +1,32 @@
import { Field, FieldLabel, FieldRow, FieldHint, FieldDescription, Skeleton, InputBoxSkeleton } from '@rocket.chat/fuselage';
import { Form, FormContainer, FormFooter, FormHeader, FormTitle } from '@rocket.chat/layout';
const FormSkeleton = () => (
<Form aria-busy>
<FormHeader>
<FormTitle>
<Skeleton />
</FormTitle>
</FormHeader>
<FormContainer></FormContainer>
<Field>
<FieldLabel>
<Skeleton />
</FieldLabel>
<FieldDescription>
<Skeleton />
</FieldDescription>
<FieldRow>
<InputBoxSkeleton />
</FieldRow>
<FieldHint>
<Skeleton />
</FieldHint>
</Field>
<FormFooter>
<Skeleton />
</FormFooter>
</Form>
);
export default FormSkeleton;

View File

@@ -0,0 +1,46 @@
import { Box } from '@rocket.chat/fuselage';
import {
HorizontalWizardLayout,
HorizontalWizardLayoutAside,
HorizontalWizardLayoutContent,
HorizontalWizardLayoutTitle,
HorizontalWizardLayoutFooter,
} from '@rocket.chat/layout';
import { useSetting, useAssetWithDarkModePath } from '@rocket.chat/ui-contexts';
import type { ReactNode } from 'react';
import LoginPoweredBy from '../components/LoginPoweredBy';
import LoginSwitchLanguageFooter from '../components/LoginSwitchLanguageFooter';
import LoginTerms from '../components/LoginTerms';
import { RegisterTitle } from '../components/RegisterTitle';
export type HorizontalTemplateProps = { children: ReactNode };
const HorizontalTemplate = ({ children }: HorizontalTemplateProps) => {
const hideLogo = useSetting('Layout_Login_Hide_Logo', false);
const customLogo = useAssetWithDarkModePath('logo');
const customBackground = useAssetWithDarkModePath('background');
return (
<HorizontalWizardLayout
background={customBackground}
logo={!hideLogo && customLogo ? <Box is='img' maxHeight='x40' marginInline='neg-x8' src={customLogo} alt='Logo' /> : <></>}
>
<HorizontalWizardLayoutAside>
<HorizontalWizardLayoutTitle>
<RegisterTitle />
</HorizontalWizardLayoutTitle>
<LoginPoweredBy />
</HorizontalWizardLayoutAside>
<HorizontalWizardLayoutContent>
{children}
<HorizontalWizardLayoutFooter>
<LoginTerms />
<LoginSwitchLanguageFooter />
</HorizontalWizardLayoutFooter>
</HorizontalWizardLayoutContent>
</HorizontalWizardLayout>
);
};
export default HorizontalTemplate;

View File

@@ -0,0 +1,36 @@
import { Box } from '@rocket.chat/fuselage';
import { VerticalWizardLayout, VerticalWizardLayoutTitle, VerticalWizardLayoutFooter } from '@rocket.chat/layout';
import { useSetting, useAssetWithDarkModePath } from '@rocket.chat/ui-contexts';
import type { ReactNode } from 'react';
import LoginPoweredBy from '../components/LoginPoweredBy';
import LoginSwitchLanguageFooter from '../components/LoginSwitchLanguageFooter';
import LoginTerms from '../components/LoginTerms';
import { RegisterTitle } from '../components/RegisterTitle';
export type VerticalTemplateProps = { children: ReactNode };
const VerticalTemplate = ({ children }: VerticalTemplateProps) => {
const hideLogo = useSetting('Layout_Login_Hide_Logo', false);
const customLogo = useAssetWithDarkModePath('logo');
const customBackground = useAssetWithDarkModePath('background');
return (
<VerticalWizardLayout
background={customBackground}
logo={!hideLogo && customLogo ? <Box is='img' maxHeight='x40' marginInline='neg-x8' src={customLogo} alt='Logo' /> : <></>}
>
<VerticalWizardLayoutTitle>
<RegisterTitle />
</VerticalWizardLayoutTitle>
<LoginPoweredBy />
{children}
<VerticalWizardLayoutFooter>
<LoginTerms />
<LoginSwitchLanguageFooter />
</VerticalWizardLayoutFooter>
</VerticalWizardLayout>
);
};
export default VerticalTemplate;

View File

@@ -0,0 +1,5 @@
{
"extends": "./tsconfig.json",
"include": ["./src/**/*", "./global.d.ts"],
"exclude": ["./src/**/*.spec.ts", "./src/**/*.stories.tsx"]
}

View File

@@ -0,0 +1,8 @@
{
"extends": "@rocket.chat/tsconfig/client.json",
"compilerOptions": {
"rootDirs": ["./src","./.storybook"],
"outDir": "./dist"
},
"include": ["./src", "./.storybook", "./global.d.ts"],
}