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
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:
3
packages/web-ui-registration/.storybook/main.ts
Normal file
3
packages/web-ui-registration/.storybook/main.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from '@rocket.chat/storybook-config/main';
|
||||
|
||||
export default baseConfig();
|
||||
11
packages/web-ui-registration/.storybook/preview.tsx
Normal file
11
packages/web-ui-registration/.storybook/preview.tsx
Normal 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;
|
||||
2615
packages/web-ui-registration/CHANGELOG.md
Normal file
2615
packages/web-ui-registration/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
7
packages/web-ui-registration/global.d.ts
vendored
Normal file
7
packages/web-ui-registration/global.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { IRocketChatDesktop } from '@rocket.chat/desktop-api';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
RocketChatDesktop?: IRocketChatDesktop;
|
||||
}
|
||||
}
|
||||
67
packages/web-ui-registration/package.json
Normal file
67
packages/web-ui-registration/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
41
packages/web-ui-registration/src/CMSPage.tsx
Normal file
41
packages/web-ui-registration/src/CMSPage.tsx
Normal 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;
|
||||
84
packages/web-ui-registration/src/EmailConfirmationForm.tsx
Normal file
84
packages/web-ui-registration/src/EmailConfirmationForm.tsx
Normal 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;
|
||||
31
packages/web-ui-registration/src/GuestForm.tsx
Normal file
31
packages/web-ui-registration/src/GuestForm.tsx
Normal 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;
|
||||
257
packages/web-ui-registration/src/LoginForm.tsx
Normal file
257
packages/web-ui-registration/src/LoginForm.tsx
Normal 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;
|
||||
71
packages/web-ui-registration/src/LoginServices.tsx
Normal file
71
packages/web-ui-registration/src/LoginServices.tsx
Normal 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;
|
||||
87
packages/web-ui-registration/src/LoginServicesButton.tsx
Normal file
87
packages/web-ui-registration/src/LoginServicesButton.tsx
Normal 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;
|
||||
319
packages/web-ui-registration/src/RegisterForm.tsx
Normal file
319
packages/web-ui-registration/src/RegisterForm.tsx
Normal 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;
|
||||
38
packages/web-ui-registration/src/RegisterFormDisabled.tsx
Normal file
38
packages/web-ui-registration/src/RegisterFormDisabled.tsx
Normal 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;
|
||||
@@ -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;
|
||||
22
packages/web-ui-registration/src/RegisterTemplate.tsx
Normal file
22
packages/web-ui-registration/src/RegisterTemplate.tsx
Normal 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;
|
||||
55
packages/web-ui-registration/src/RegistrationPageRouter.tsx
Normal file
55
packages/web-ui-registration/src/RegistrationPageRouter.tsx
Normal 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;
|
||||
@@ -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> = {};
|
||||
@@ -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;
|
||||
106
packages/web-ui-registration/src/ResetPasswordForm.tsx
Normal file
106
packages/web-ui-registration/src/ResetPasswordForm.tsx
Normal 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;
|
||||
36
packages/web-ui-registration/src/SecretRegisterForm.tsx
Normal file
36
packages/web-ui-registration/src/SecretRegisterForm.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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> = {};
|
||||
@@ -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;
|
||||
24
packages/web-ui-registration/src/components/LoginTerms.tsx
Normal file
24
packages/web-ui-registration/src/components/LoginTerms.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
};
|
||||
9
packages/web-ui-registration/src/hooks/useLoginRouter.ts
Normal file
9
packages/web-ui-registration/src/hooks/useLoginRouter.ts
Normal 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>;
|
||||
@@ -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'),
|
||||
});
|
||||
};
|
||||
17
packages/web-ui-registration/src/hooks/useRegisterMethod.ts
Normal file
17
packages/web-ui-registration/src/hooks/useRegisterMethod.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
8
packages/web-ui-registration/src/index.ts
Normal file
8
packages/web-ui-registration/src/index.ts
Normal 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;
|
||||
32
packages/web-ui-registration/src/template/FormSkeleton.tsx
Normal file
32
packages/web-ui-registration/src/template/FormSkeleton.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
5
packages/web-ui-registration/tsconfig.build.json
Normal file
5
packages/web-ui-registration/tsconfig.build.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": ["./src/**/*", "./global.d.ts"],
|
||||
"exclude": ["./src/**/*.spec.ts", "./src/**/*.stories.tsx"]
|
||||
}
|
||||
8
packages/web-ui-registration/tsconfig.json
Normal file
8
packages/web-ui-registration/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@rocket.chat/tsconfig/client.json",
|
||||
"compilerOptions": {
|
||||
"rootDirs": ["./src","./.storybook"],
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": ["./src", "./.storybook", "./global.d.ts"],
|
||||
}
|
||||
Reference in New Issue
Block a user