diff --git a/apps/meteor/client/lib/cachedStores/CachedStore.ts b/apps/meteor/client/lib/cachedStores/CachedStore.ts index 591de8226cf79..d16fc8b679d42 100644 --- a/apps/meteor/client/lib/cachedStores/CachedStore.ts +++ b/apps/meteor/client/lib/cachedStores/CachedStore.ts @@ -18,18 +18,24 @@ import { getConfig } from '../utils/getConfig'; type Name = 'rooms' | 'subscriptions' | 'permissions' | 'public-settings' | 'private-settings'; -const hasId = (record: T): record is T & { _id: string } => typeof record === 'object' && record !== null && '_id' in record; +const hasId = (record: T): record is T & { _id: string } => + typeof record === 'object' && record !== null && '_id' in record; + const hasUpdatedAt = (record: T): record is T & { _updatedAt: Date } => typeof record === 'object' && record !== null && '_updatedAt' in record && (record as unknown as { _updatedAt: unknown })._updatedAt instanceof Date; + const hasDeletedAt = (record: T): record is T & { _deletedAt: Date } => typeof record === 'object' && record !== null && '_deletedAt' in record && (record as unknown as { _deletedAt: unknown })._deletedAt instanceof Date; -const hasUnserializedUpdatedAt = (record: T): record is T & { _updatedAt: ConstructorParameters[0] } => + +const hasUnserializedUpdatedAt = ( + record: T, +): record is T & { _updatedAt: ConstructorParameters[0] } => typeof record === 'object' && record !== null && '_updatedAt' in record && @@ -41,31 +47,42 @@ export interface IWithManageableCache { clearCacheOnLogout(): void; } -export abstract class CachedStore implements IWithManageableCache { +export abstract class CachedStore + implements IWithManageableCache +{ private static readonly MAX_CACHE_TIME = 60 * 60 * 24 * 30; readonly store: UseBoundStore>>; protected name: Name; - protected eventType: StreamNames; private readonly version = 18; - private updatedAt = new Date(0); protected log: (...args: any[]) => void; - private timer: ReturnType; readonly useReady = create(() => false); - constructor({ name, eventType, store }: { name: Name; eventType: StreamNames; store: UseBoundStore>> }) { + constructor({ + name, + eventType, + store, + }: { + name: Name; + eventType: StreamNames; + store: UseBoundStore>>; + }) { this.name = name; this.eventType = eventType; this.store = store; - this.log = [getConfig(`debugCachedCollection-${this.name}`), getConfig('debugCachedCollection'), getConfig('debug')].includes('true') + this.log = [ + getConfig(`debugCachedCollection-${this.name}`), + getConfig('debugCachedCollection'), + getConfig('debug'), + ].includes('true') ? console.log.bind(console, `%cCachedCollection ${this.name}`, `color: navy; font-weight: bold;`) : () => undefined; @@ -82,13 +99,20 @@ export abstract class CachedStore implements protected abstract getToken(): unknown; private async loadFromCache() { - const data = await localforage.getItem<{ version: number; token: unknown; records: unknown[]; updatedAt: Date | string }>(this.name); + const data = await localforage.getItem<{ + version: number; + token: unknown; + records: unknown[]; + updatedAt: Date | string; + }>(this.name); if (!data) { return false; } - if (data.version < this.version || data.token !== this.getToken()) { + const token = this.getToken(); + + if (data.version < this.version || (token !== undefined && data.token !== token)) { return false; } @@ -96,7 +120,6 @@ export abstract class CachedStore implements return false; } - // updatedAt may be a Date or a string depending on the used localForage backend if (!(data.updatedAt instanceof Date)) { data.updatedAt = new Date(data.updatedAt); } @@ -107,16 +130,19 @@ export abstract class CachedStore implements this.log(`${data.records.length} records loaded from cache`); - const deserializedRecords = data.records.map((record) => this.deserializeFromCache(record)).filter(isTruthy); + const deserializedRecords = data.records + .map((record) => this.deserializeFromCache(record)) + .filter(isTruthy); - const updatedAt = Math.max(...deserializedRecords.filter(hasUpdatedAt).map((record) => record?._updatedAt.getTime() ?? 0)); + const updatedAt = Math.max( + ...deserializedRecords.filter(hasUpdatedAt).map((record) => record._updatedAt.getTime()), + ); if (updatedAt > this.updatedAt.getTime()) { this.updatedAt = new Date(updatedAt); } this.store.getState().replaceAll(deserializedRecords.filter(hasId)); - this.updatedAt = data.updatedAt || this.updatedAt; return true; @@ -128,7 +154,7 @@ export abstract class CachedStore implements } return { - ...(record as unknown as T), + ...(record as T), ...(hasUnserializedUpdatedAt(record) && { _updatedAt: new Date(record._updatedAt), }), @@ -136,20 +162,19 @@ export abstract class CachedStore implements } private async callLoad() { - // TODO: workaround for bad function overload const data = await sdk.call(`${this.name}/get`); - return data as unknown as U[]; + return data as U[]; } private async callSync(updatedSince: Date) { - // TODO: workaround for bad function overload const data = await sdk.call(`${this.name}/get`, updatedSince); - return data as unknown as { update: U[]; remove: U[] }; + return data as { update: U[]; remove: U[] }; } private async loadFromServer() { const startTime = new Date(); const lastTime = this.updatedAt; + const data = await this.callLoad(); this.log(`${data.length} records loaded from server`); @@ -170,16 +195,12 @@ export abstract class CachedStore implements } protected mapRecord(record: U): T { - return record as unknown as T; + return record as T; } - protected handleLoadedFromServer(_records: T[]): void { - // This method can be overridden to handle records after they are loaded from the server - } + protected handleLoadedFromServer(_records: T[]): void {} - protected handleSyncEvent(_action: 'removed' | 'changed', _record: T): void { - // This method can be overridden to handle sync events - } + protected handleSyncEvent(_action: 'removed' | 'changed', _record: T): void {} private async loadFromServerAndPopulate() { await this.loadFromServer(); @@ -206,10 +227,14 @@ export abstract class CachedStore implements } protected setupListener() { - return sdk.stream(this.eventType, [this.eventName], (async (action: 'removed' | 'changed', record: U) => { - this.log('record received', action, record); - await this.handleRecordEvent(action, record); - }) as (...args: unknown[]) => void); + return sdk.stream( + this.eventType, + [this.eventName], + (async (action: 'removed' | 'changed', record: U) => { + this.log('record received', action, record); + await this.handleRecordEvent(action, record); + }) as (...args: unknown[]) => void, + ); } protected async handleRecordEvent(action: 'removed' | 'changed', record: U) { @@ -226,7 +251,6 @@ export abstract class CachedStore implements private trySync(delay = 10) { clearTimeout(this.timer); - // Wait for an empty queue to load data again and sync this.timer = setTimeout(async () => { if (!(await this.sync())) { return this.trySync(delay); @@ -246,14 +270,13 @@ export abstract class CachedStore implements this.log(`syncing from ${this.updatedAt}`); const data = await this.callSync(this.updatedAt); - const changes = []; + const changes: { action: () => void; timestamp: number }[] = []; - if (data.update && data.update.length > 0) { - this.log(`${data.update.length} records updated in sync`); + if (data.update?.length) { for (const record of data.update) { const newRecord = this.mapRecord(record); - const actionTime = hasUpdatedAt(newRecord) ? newRecord._updatedAt : startTime; + changes.push({ action: () => { this.store.getState().store(newRecord); @@ -267,16 +290,13 @@ export abstract class CachedStore implements } } - if (data.remove && data.remove.length > 0) { - this.log(`${data.remove.length} records removed in sync`); + if (data.remove?.length) { for (const record of data.remove) { const newRecord = this.mapRecord(record); - - if (!hasDeletedAt(newRecord)) { - continue; - } + if (!hasDeletedAt(newRecord)) continue; const actionTime = newRecord._deletedAt; + changes.push({ action: () => { this.store.getState().delete(newRecord._id); @@ -290,11 +310,7 @@ export abstract class CachedStore implements } } - changes - .sort((a, b) => a.timestamp - b.timestamp) - .forEach((c) => { - c.action(); - }); + changes.sort((a, b) => a.timestamp - b.timestamp).forEach((c) => c.action()); this.updatedAt = this.updatedAt === lastTime ? startTime : this.updatedAt; @@ -302,6 +318,23 @@ export abstract class CachedStore implements } private listenerUnsubscriber: (() => void) | undefined; + private reconnectionComputation: Tracker.Computation | undefined; + private initializationPromise: Promise | undefined; + + init() { + if (this.initializationPromise) { + return this.initializationPromise; + } + + this.initializationPromise = this.performInitialization() + .catch(console.error) + .finally(() => { + this.initializationPromise = undefined; + this.setReady(true); + }); + + return this.initializationPromise; + } private async performInitialization() { if (await this.loadFromCache()) { @@ -312,6 +345,7 @@ export abstract class CachedStore implements this.reconnectionComputation?.stop(); let wentOffline = Tracker.nonreactive(() => Meteor.status().status === 'offline'); + this.reconnectionComputation = Tracker.autorun(() => { const { status } = Meteor.status(); @@ -331,23 +365,6 @@ export abstract class CachedStore implements }; } - private initializationPromise: Promise | undefined; - - init() { - if (this.initializationPromise) { - return this.initializationPromise; - } - - this.initializationPromise = this.performInitialization() - .catch(console.error) - .finally(() => { - this.initializationPromise = undefined; - this.setReady(true); - }); - - return this.initializationPromise; - } - async release() { if (this.initializationPromise) { await this.initializationPromise; @@ -357,8 +374,6 @@ export abstract class CachedStore implements this.setReady(false); } - private reconnectionComputation: Tracker.Computation | undefined; - setReady(ready: boolean) { this.useReady.setState(ready); } @@ -366,7 +381,7 @@ export abstract class CachedStore implements export class PublicCachedStore extends CachedStore { protected override getToken() { - return undefined; + return 'public'; } override clearCacheOnLogout() { diff --git a/apps/meteor/client/lib/createSidebarItems.ts b/apps/meteor/client/lib/createSidebarItems.ts index d8541620aa203..96b892a0c5768 100644 --- a/apps/meteor/client/lib/createSidebarItems.ts +++ b/apps/meteor/client/lib/createSidebarItems.ts @@ -15,11 +15,19 @@ export type Item = { externalUrl?: boolean; badge?: () => ReactElement; }; -export type SidebarDivider = { divider: boolean; i18nLabel: string }; + +export type SidebarDivider = { + divider: boolean; + i18nLabel: string; +}; + export type SidebarItem = Item | SidebarDivider; + export const isSidebarItem = (item: SidebarItem): item is Item => !('divider' in item); -export const isGoRocketChatLink = (link: string): link is `${typeof GO_ROCKET_CHAT_PREFIX}${string}` => +export const isGoRocketChatLink = ( + link: string, +): link is `${typeof GO_ROCKET_CHAT_PREFIX}${string}` => link.startsWith(GO_ROCKET_CHAT_PREFIX); export const createSidebarItems = ( @@ -49,7 +57,12 @@ export const createSidebarItems = ( const unregisterSidebarItem = (i18nLabel: SidebarItem['i18nLabel']): void => { const index = items.findIndex((item) => item.i18nLabel === i18nLabel); - delete items[index]; + + if (index === -1) { + return; + } + + items.splice(index, 1); updateCb(); }; diff --git a/apps/meteor/client/views/account/profile/AccountProfileForm.tsx b/apps/meteor/client/views/account/profile/AccountProfileForm.tsx index 74eef8f84754b..7a770127f132f 100644 --- a/apps/meteor/client/views/account/profile/AccountProfileForm.tsx +++ b/apps/meteor/client/views/account/profile/AccountProfileForm.tsx @@ -66,7 +66,7 @@ const AccountProfileForm = (props: AllHTMLAttributes): ReactEle formState: { errors }, } = useFormContext(); - const { email, avatar, username, name: userFullName } = watch(); + const { email, avatar, username, name, statusType, statusText, nickname, bio, customFields } = watch(); const previousEmail = user ? getUserEmailAddress(user) : ''; const previousUsername = user?.username || ''; @@ -87,11 +87,7 @@ const AccountProfileForm = (props: AllHTMLAttributes): ReactEle }, [email, previousEmail, mutateConfirmationEmail]); const validateUsername = async (username: string): Promise => { - if (!username) { - return; - } - - if (username === previousUsername) { + if (!username || username === previousUsername) { return; } @@ -106,22 +102,31 @@ const AccountProfileForm = (props: AllHTMLAttributes): ReactEle }; const updateOwnBasicInfo = useEndpoint('POST', '/v1/users.updateOwnBasicInfo'); - const updateAvatar = useUpdateAvatar(avatar, user?._id || ''); - const handleSave = async ({ email, name, username, statusType, statusText, nickname, bio, customFields }: AccountProfileFormValues) => { + const handleSave = async (values: AccountProfileFormValues) => { + const trimmedValues = { + ...values, + email: values.email?.trim(), + name: values.name?.trim(), + username: values.username?.trim(), + statusText: values.statusText?.trim(), + nickname: values.nickname?.trim(), + bio: values.bio?.trim(), + }; + try { await updateOwnBasicInfo({ data: { - name, - ...(user ? getUserEmailAddress(user) !== email && { email } : {}), - username, - statusText, - statusType, - nickname, - bio, + name: trimmedValues.name, + ...(user && getUserEmailAddress(user) !== trimmedValues.email && { email: trimmedValues.email }), + username: trimmedValues.username, + statusText: trimmedValues.statusText, + statusType: trimmedValues.statusType, + nickname: trimmedValues.nickname, + bio: trimmedValues.bio, }, - customFields, + customFields: trimmedValues.customFields, }); await updateAvatar(); @@ -129,7 +134,7 @@ const AccountProfileForm = (props: AllHTMLAttributes): ReactEle } catch (error) { dispatchToastMessage({ type: 'error', message: error }); } finally { - reset({ email, name, username, statusType, statusText, nickname, bio, customFields }); + reset(trimmedValues); } }; @@ -146,6 +151,8 @@ const AccountProfileForm = (props: AllHTMLAttributes): ReactEle {t('Profile_details')} + + {/* Avatar */} ): ReactEle ): ReactEle )} /> - - + + {/* Name + Username */} + + {t('Name')} @@ -181,30 +180,16 @@ const AccountProfileForm = (props: AllHTMLAttributes): ReactEle ( - + )} /> - {errors.name && ( - - {errors.name.message} - - )} - {!allowRealNameChange && {t('RealName_Change_Disabled')}} + {errors.name && {errors.name.message}} - + + {t('Username')} @@ -212,60 +197,35 @@ const AccountProfileForm = (props: AllHTMLAttributes): ReactEle validateUsername(username), - }} + rules={{ validate: validateUsername }} render={({ field }) => ( - } - aria-required='true' - aria-invalid={errors.username ? 'true' : 'false'} - aria-describedby={`${usernameId}-error ${usernameId}-hint`} - /> + } /> )} /> - {errors?.username && ( - - {errors.username.message} - - )} - {!canChangeUsername && {t('Username_Change_Disabled')}} + {errors.username && {errors.username.message}} + + {/* Status */} {t('StatusMessage')} ( ( - + )} /> } @@ -273,85 +233,40 @@ const AccountProfileForm = (props: AllHTMLAttributes): ReactEle )} /> - {errors?.statusText && ( - - {errors?.statusText.message} - - )} - {!allowUserStatusMessageChange && {t('StatusMessage_Change_Disabled')}} + + {/* Nickname */} {t('Nickname')} - ( - } /> - )} - /> + } /> + + {/* Bio */} {t('Bio')} ( - } - aria-invalid={errors.statusText ? 'true' : 'false'} - aria-describedby={`${bioId}-error`} - /> - )} + rules={{ maxLength: BIO_TEXT_MAX_LENGTH }} + render={({ field }) => } /> - {errors?.bio && ( - - {errors.bio.message} - - )} + + {/* Email */} {t('Email')} - + (validateEmail(email) ? undefined : t('error-invalid-email-address')) }, - }} - render={({ field }) => ( - } - disabled={!allowEmailChange} - aria-required='true' - aria-invalid={errors.email ? 'true' : 'false'} - aria-describedby={`${emailId}-error ${emailId}-hint`} - /> - )} + rules={{ validate: (email) => (validateEmail(email) ? undefined : t('error-invalid-email-address')) }} + render={({ field }) => } /> {!isUserVerified && ( )} - {errors.email && ( - - {errors?.email?.message} - - )} - {!allowEmailChange && {t('Email_Change_Disabled')}} + {customFieldsMetadata && } diff --git a/apps/meteor/client/views/account/profile/getProfileInitialValues.ts b/apps/meteor/client/views/account/profile/getProfileInitialValues.ts index a15e8477d48db..42d7b4f316682 100644 --- a/apps/meteor/client/views/account/profile/getProfileInitialValues.ts +++ b/apps/meteor/client/views/account/profile/getProfileInitialValues.ts @@ -16,14 +16,14 @@ export type AccountProfileFormValues = { }; export const getProfileInitialValues = (user: IUser | null): AccountProfileFormValues => ({ - email: user ? getUserEmailAddress(user) || '' : '', - name: user?.name ?? '', - username: user?.username ?? '', + email: user ? getUserEmailAddress(user)?.trim() || '' : '', + name: user?.name?.trim() ?? '', + username: user?.username?.trim() ?? '', avatar: '' as AvatarObject, url: '', - statusText: user?.statusText ?? '', + statusText: user?.statusText?.trim() ?? '', statusType: user?.status ?? '', - bio: user?.bio ?? '', + bio: user?.bio?.trim() ?? '', customFields: user?.customFields ?? {}, - nickname: user?.nickname ?? '', -}); + nickname: user?.nickname?.trim() ?? '', +}); \ No newline at end of file diff --git a/apps/meteor/client/views/room/contextualBar/lib/ContextualListSkeleton.tsx b/apps/meteor/client/views/room/contextualBar/lib/ContextualListSkeleton.tsx new file mode 100644 index 0000000000000..2c0340749f5c3 --- /dev/null +++ b/apps/meteor/client/views/room/contextualBar/lib/ContextualListSkeleton.tsx @@ -0,0 +1,22 @@ +import type { FC } from 'react'; +import { Box, Skeleton } from '@rocket.chat/fuselage'; + +type ContextualListSkeletonProps = { + items?: number; +}; + +const ContextualListSkeleton: FC = ({ items = 6 }) => ( + + {Array.from({ length: items }).map((_, i) => ( + + + + + + + + ))} + +); + +export default ContextualListSkeleton; diff --git a/apps/meteor/packages/rocketchat-mongo-config/server/index.js b/apps/meteor/packages/rocketchat-mongo-config/server/index.js index 29650dcd44148..55f8d61197312 100644 --- a/apps/meteor/packages/rocketchat-mongo-config/server/index.js +++ b/apps/meteor/packages/rocketchat-mongo-config/server/index.js @@ -4,34 +4,44 @@ import { PassThrough } from 'stream'; import { Email } from 'meteor/email'; import { Mongo } from 'meteor/mongo'; -const shouldUseNativeOplog = ['yes', 'true'].includes(String(process.env.USE_NATIVE_OPLOG).toLowerCase()); +const shouldUseNativeOplog = ['yes', 'true'].includes( + String(process.env.USE_NATIVE_OPLOG).toLowerCase(), +); + if (!shouldUseNativeOplog) { Package['disable-oplog'] = {}; } -// FIX For TLS error see more here https://github.com/RocketChat/Rocket.Chat/issues/9316 -// TODO: Remove after NodeJS fix it, more information -// https://github.com/nodejs/node/issues/16196 -// https://github.com/nodejs/node/pull/16853 -// This is fixed in Node 10, but this supports LTS versions +// FIX for TLS error +// https://github.com/RocketChat/Rocket.Chat/issues/9316 +// TODO: Remove after NodeJS fix tls.DEFAULT_ECDH_CURVE = 'auto'; const mongoConnectionOptions = { // add retryWrites=false if not present in MONGO_URL - ...(!process.env.MONGO_URL.includes('retryWrites') && { retryWrites: false }), + ...(!process.env.MONGO_URL?.includes('retryWrites') && { retryWrites: false }), ignoreUndefined: false, // TODO ideally we should call isTracingEnabled(), but since this is a Meteor package we can't :/ - monitorCommands: ['yes', 'true'].includes(String(process.env.TRACING_ENABLED).toLowerCase()), + monitorCommands: ['yes', 'true'].includes( + String(process.env.TRACING_ENABLED).toLowerCase(), + ), }; const mongoOptionStr = process.env.MONGO_OPTIONS; + if (typeof mongoOptionStr !== 'undefined') { try { const mongoOptions = JSON.parse(mongoOptionStr); Object.assign(mongoConnectionOptions, mongoOptions); } catch (error) { - throw new Error('Invalid MONGO_OPTIONS environment variable: must be valid JSON.', { cause: error }); + const message = 'Invalid MONGO_OPTIONS environment variable: must be valid JSON.'; + + console.error(message); + console.error('Provided value:', mongoOptionStr); + console.error('Error:', error.message); + + throw new Error(message, { cause: error }); } } @@ -39,15 +49,16 @@ if (Object.keys(mongoConnectionOptions).length > 0) { Mongo.setConnectionOptions(mongoConnectionOptions); } -process.env.HTTP_FORWARDED_COUNT = process.env.HTTP_FORWARDED_COUNT || '1'; +process.env.HTTP_FORWARDED_COUNT = + process.env.HTTP_FORWARDED_COUNT || '1'; -// Just print to logs if in TEST_MODE due to a bug in Meteor 2.5: TypeError: Cannot read property '_syncSendMail' of null +// Just print to logs if in TEST_MODE due to a bug in Meteor 2.5 if (process.env.TEST_MODE === 'true') { Email.sendAsync = async function _sendAsync(options) { console.log('Email.sendAsync', options); }; } else if (process.env.NODE_ENV !== 'development') { - // Send emails to a "fake" stream instead of print them in console in case MAIL_URL or SMTP is not configured + // Send emails to a "fake" stream instead of printing them to console const stream = new PassThrough(); stream.on('data', () => {}); stream.on('end', () => {}); diff --git a/packages/livechat/src/components/Menu/index.tsx b/packages/livechat/src/components/Menu/index.tsx index 7ca6e7ae11325..036ffdb588491 100644 --- a/packages/livechat/src/components/Menu/index.tsx +++ b/packages/livechat/src/components/Menu/index.tsx @@ -1,4 +1,5 @@ -import { Component, type ComponentChildren } from 'preact'; +import { type ComponentChildren } from 'preact'; +import { useLayoutEffect, useRef, useState } from 'preact/hooks'; import type { HTMLAttributes, TargetedEvent } from 'preact/compat'; import { createClassName } from '../../helpers/createClassName'; @@ -37,7 +38,11 @@ type ItemProps = { } & HTMLAttributes; export const Item = ({ children, primary = false, danger = false, disabled = false, icon = undefined, ...props }: ItemProps) => ( - @@ -50,37 +55,38 @@ type PopoverMenuWrapperProps = { overlayBounds: DOMRect; }; -type PopoverMenuWrapperState = { - position?: { - left?: number; - right?: number; - top?: number; - bottom?: number; - }; - placement?: string; +type Position = { + left?: number; + right?: number; + top?: number; + bottom?: number; }; -class PopoverMenuWrapper extends Component { - override state: PopoverMenuWrapperState = {}; +const PopoverMenuWrapper = ({ + children, + dismiss, + triggerBounds, + overlayBounds, +}: PopoverMenuWrapperProps) => { + const menuRef = useRef(null); - menuRef: (Component & { base: Element }) | null = null; - - handleRef = (ref: (Component & { base: Element }) | null) => { - this.menuRef = ref; - }; + const [position, setPosition] = useState(); + const [placement, setPlacement] = useState(); - handleClick = ({ target }: TargetedEvent) => { + const handleClick = ({ target }: TargetedEvent) => { if (!(target as HTMLElement)?.closest(`.${styles.menu__item}`)) { return; } - const { dismiss } = this.props; dismiss(); }; - override componentDidMount() { - const { triggerBounds, overlayBounds } = this.props; - const menuBounds = normalizeDOMRect(this.menuRef?.base?.getBoundingClientRect()); + useLayoutEffect(() => { + if (!menuRef.current) { + return; + } + + const menuBounds = normalizeDOMRect(menuRef.current.getBoundingClientRect()); const menuWidth = menuBounds.right - menuBounds.left; const menuHeight = menuBounds.bottom - menuBounds.top; @@ -94,26 +100,23 @@ class PopoverMenuWrapper extends Component ( + return ( {children} ); -} +}; type PopoverMenuProps = { children?: ComponentChildren; @@ -129,7 +132,11 @@ export const PopoverMenu = ({ children = null, trigger, overlayed }: PopoverMenu > {trigger} {({ dismiss, triggerBounds, overlayBounds }) => ( - + {children} )} diff --git a/packages/livechat/src/store/index.tsx b/packages/livechat/src/store/index.tsx index 617b13c9c6469..f407b5e3bb930 100644 --- a/packages/livechat/src/store/index.tsx +++ b/packages/livechat/src/store/index.tsx @@ -1,4 +1,8 @@ -import type { ILivechatVisitor, ILivechatVisitorDTO, Serialized } from '@rocket.chat/core-typings'; +import type { + ILivechatVisitor, + ILivechatVisitorDTO, + Serialized, +} from '@rocket.chat/core-typings'; import type { ComponentChildren } from 'preact'; import { Component, createContext } from 'preact'; import { useContext } from 'preact/hooks'; @@ -11,18 +15,29 @@ import type { TriggerMessage } from '../definitions/triggerMessage'; import { parentCall } from '../lib/parentCall'; import { createToken } from '../lib/random'; +import type { + LivechatAlert, + LivechatQueueInfo, + LivechatModalState, +} from './types'; + +/* ---------------- Hidden system messages ---------------- */ + export type LivechatHiddenSytemMessageType = - | 'uj' // User joined - | 'ul' // User left - | 'livechat-close' // Chat closed - | 'livechat-started' // Chat started - | 'livechat_transfer_history'; // Chat transfered + | 'uj' + | 'ul' + | 'livechat-close' + | 'livechat-started' + | 'livechat_transfer_history'; + +/* ---------------- Store State ---------------- */ export type StoreState = { token: string; typing: string[]; + config: { - messages: any; + messages: Record; theme: { title?: string; color?: string; @@ -38,17 +53,17 @@ export type StoreState = { }[]; }; }; - triggers: any[]; - resources: any; + triggers: unknown[]; + resources: Record; settings: { registrationForm?: boolean; nameFieldRegistrationForm?: boolean; emailFieldRegistrationForm?: boolean; forceAcceptDataProcessingConsent?: boolean; - fileUpload?: any; - allowSwitchingDepartments?: any; - showConnecting?: any; - limitTextLength?: any; + fileUpload?: boolean; + allowSwitchingDepartments?: boolean; + showConnecting?: boolean; + limitTextLength?: number; displayOfflineForm?: boolean; hiddenSystemMessages?: LivechatHiddenSytemMessageType[]; hideWatermark?: boolean; @@ -61,14 +76,18 @@ export type StoreState = { customFields?: CustomField[]; enabled?: boolean; }; - messages: any[]; + + messages: unknown[]; + user?: Serialized; guest?: Serialized; + sound: { src?: string; play?: boolean; enabled: boolean; }; + iframe: { guest: Partial>; guestMetadata?: Record; @@ -92,35 +111,49 @@ export type StoreState = { defaultDepartment?: string; hiddenSystemMessages?: LivechatHiddenSytemMessageType[]; }; + gdpr: { accepted: boolean; }; - alerts: any[]; + + alerts: LivechatAlert[]; + visible: boolean; minimized: boolean; - unread: any; - incomingCallAlert: any; - businessUnit: any; + + unread: number | null; + incomingCallAlert: boolean | null; + businessUnit: string | null; + openSessionIds?: string[]; triggered?: boolean; undocked?: boolean; expanded?: boolean; - modal?: any; - agent?: any; + + modal?: LivechatModalState; + + agent?: Agent; room?: { _id: string }; + noMoreMessages?: boolean; loading?: boolean; - lastReadMessageId?: any; - triggerAgent?: any; - queueInfo?: any; + + lastReadMessageId?: string; + triggerAgent?: Agent; + queueInfo?: LivechatQueueInfo; + defaultAgent?: Agent; parentUrl?: string; connecting?: boolean; + messageListPosition?: 'top' | 'bottom' | 'free'; + renderedTriggers: TriggerMessage[]; customFieldsQueue: Record; }; +/* ---------------- Initial State ---------------- */ + export const initialState = (): StoreState => ({ token: createToken(), typing: [], @@ -162,6 +195,8 @@ export const initialState = (): StoreState => ({ customFieldsQueue: {}, }); +/* ---------------- Store ---------------- */ + const dontPersist = [ 'messages', 'typing', @@ -176,35 +211,7 @@ const dontPersist = [ export const store = new Store(initialState(), { dontPersist }); -const { sessionStorage } = window; - -window.addEventListener('load', () => { - const sessionId = createToken(); - sessionStorage.setItem('sessionId', sessionId); - const { openSessionIds = [] } = store.state; - store.setState({ openSessionIds: [sessionId, ...openSessionIds] }); -}); - -window.addEventListener('visibilitychange', () => { - if (store.state.undocked) { - return; - } - - !store.state.minimized && !store.state.triggered && parentCall('openWidget'); - store.state.iframe.visible ? parentCall('showWidget') : parentCall('hideWidget'); -}); - -window.addEventListener('beforeunload', () => { - const sessionId = sessionStorage.getItem('sessionId'); - const { openSessionIds = [] } = store.state; - store.setState({ openSessionIds: openSessionIds.filter((session) => session !== sessionId) }); -}); - -if (process.env.NODE_ENV === 'development') { - store.on('change', ([, , partialState]) => { - console.log('%cstore.setState %c%o', 'color: blue', 'color: initial', partialState); - }); -} +/* ---------------- Context / Provider ---------------- */ export type Dispatch = typeof store.setState; @@ -243,17 +250,12 @@ export class Provider extends Component { store.off('change', this.handleStoreChange); } - render = ({ children }: { children: ComponentChildren }) => { - return {children}; - }; + render = ({ children }: { children: ComponentChildren }) => ( + {children} + ); } export const { Consumer } = StoreContext; - export default store; -export const useStore = (): StoreContextValue => { - const store = useContext(StoreContext); - - return store; -}; +export const useStore = (): StoreContextValue => useContext(StoreContext); diff --git a/packages/livechat/src/store/types.ts b/packages/livechat/src/store/types.ts new file mode 100644 index 0000000000000..36f58f48adc98 --- /dev/null +++ b/packages/livechat/src/store/types.ts @@ -0,0 +1,32 @@ +import type { + IMessage, + ILivechatTrigger, + ILivechatAgent, +} from '@rocket.chat/core-typings'; + +/* ------------------ Alerts ------------------ */ +export interface LivechatAlert { + id: string; + text: string; + severity?: 'info' | 'warning' | 'error'; +} + +/* ------------------ Queue Info ------------------ */ +export interface LivechatQueueInfo { + position?: number; + estimatedWaitTime?: number; +} + +/* ------------------ Config ------------------ */ +export interface LivechatConfig { + allowSwitchingDepartments?: boolean; + showConnecting?: boolean; + limitTextLength?: number; +} + +/* ------------------ Modal ------------------ */ +export interface LivechatModalState { + open: boolean; + type?: string; + payload?: unknown; // unknown > any (safer) +} diff --git a/packages/models/src/models/BaseRaw.ts b/packages/models/src/models/BaseRaw.ts index 633e9fad3f3bc..e3af70c458486 100644 --- a/packages/models/src/models/BaseRaw.ts +++ b/packages/models/src/models/BaseRaw.ts @@ -53,22 +53,10 @@ export abstract class BaseRaw< > implements IBaseModel { protected defaultFields: C | undefined; - public readonly col: Collection; - private preventSetUpdatedAt: boolean; - - /** - * Collection name to store data. - */ private collectionName: string; - /** - * @param db MongoDB instance - * @param name Name of the model without any prefix. Used by trash records to set the `__collection__` field. - * @param trash Trash collection instance - * @param options Model options - */ constructor( private db: Db, protected name: string, @@ -76,13 +64,9 @@ export abstract class BaseRaw< options?: ModelOptions, ) { this.collectionName = options?.collectionNameResolver ? options.collectionNameResolver(name) : getCollectionName(name); - this.col = this.db.collection(this.collectionName, options?.collection || {}); - void this.createIndexes(); - this.preventSetUpdatedAt = options?.preventSetUpdatedAt ?? false; - return traceInstanceMethods(this); } @@ -90,20 +74,16 @@ export abstract class BaseRaw< public async createIndexes() { const indexes = this.modelIndexes(); - if (indexes?.length) { if (this.pendingIndexes) { await this.pendingIndexes; } - this.pendingIndexes = this.col.createIndexes(indexes).catch((e) => { console.warn(`Some indexes for collection '${this.collectionName}' could not be created:\n\t${e.message}`); }) as unknown as Promise; - void this.pendingIndexes.finally(() => { this.pendingIndexes = undefined; }); - return this.pendingIndexes; } } @@ -148,22 +128,17 @@ export abstract class BaseRaw< } private ensureDefaultFields

(options: FindOptions

): FindOptions

; - private ensureDefaultFields

( options?: FindOptions

& { fields?: FindOptions

['projection'] }, ): FindOptions

| FindOptions | undefined { if (options?.fields) { warnFields("Using 'fields' in models is deprecated.", options); } - if (this.defaultFields === undefined) { return options; } - const { fields: deprecatedFields, projection, ...rest } = options || {}; - const fields = { ...deprecatedFields, ...projection }; - return { projection: this.defaultFields, ...(fields && Object.values(fields).length && { projection: fields }), @@ -173,167 +148,26 @@ export abstract class BaseRaw< public findOneAndUpdate(query: Filter, update: UpdateFilter | T, options?: FindOneAndUpdateOptions): Promise | null> { this.setUpdatedAt(update); - if (options?.upsert && !('_id' in update || (update.$set && '_id' in update.$set)) && !('_id' in query)) { - update.$setOnInsert = { - ...(update.$setOnInsert || {}), - _id: new ObjectId().toHexString(), - } as Partial & { _id: string }; + update.$setOnInsert = { ...(update.$setOnInsert || {}), _id: new ObjectId().toHexString() } as Partial & { _id: string }; } - return this.col.findOneAndUpdate(query, update, options || {}); } async findOneById(_id: T['_id'], options?: FindOptions): Promise; - async findOneById

(_id: T['_id'], options?: FindOptions

): Promise

; - async findOneById(_id: T['_id'], options?: any): Promise { const query: Filter = { _id } as Filter; - if (options) { - return this.findOne(query, options); - } - return this.findOne(query); + return options ? this.findOne(query, options) : this.findOne(query); } - async findOne(query?: Filter | T['_id'], options?: undefined): Promise; - - async findOne

(query: Filter | T['_id'], options?: FindOptions

): Promise

; - - async findOne

(query: Filter | T['_id'] = {}, options?: any): Promise | WithId

| null> { + async findOne

(query: Filter | T['_id'] = {}, options?: any): Promise | WithId

| null> { const q: Filter = typeof query === 'string' ? ({ _id: query } as Filter) : query; const optionsDef = this.doNotMixInclusionAndExclusionFields(options); - if (optionsDef) { - return this.col.findOne(q, optionsDef); - } - return this.col.findOne(q); - } - - find(query?: Filter): FindCursor>; - - find

(query: Filter, options?: FindOptions

): FindCursor

; - - find

( - query: Filter = {}, - options?: FindOptions

, - ): FindCursor> | FindCursor> { - const optionsDef = this.doNotMixInclusionAndExclusionFields(options); - return this.col.find(query, optionsDef); - } - - findPaginated

(query: Filter, options?: FindOptions

): FindPaginated>>; - - findPaginated(query: Filter = {}, options?: any): FindPaginated>> { - const optionsDef = this.doNotMixInclusionAndExclusionFields(options); - - const cursor = optionsDef ? this.col.find(query, optionsDef) : this.col.find(query); - const totalCount = this.col.countDocuments(query); - - return { - cursor, - totalCount, - }; - } - - /** - * @deprecated use {@link updateOne} or {@link updateAny} instead - */ - update( - filter: Filter, - update: UpdateFilter | Partial, - options?: UpdateOptions & { multi?: true }, - ): Promise { - const operation = options?.multi ? 'updateMany' : 'updateOne'; - - return this[operation](filter, update, options); - } - - updateOne(filter: Filter, update: UpdateFilter, options?: UpdateOptions): Promise { - this.setUpdatedAt(update); - if (options) { - if (options.upsert && !('_id' in update || (update.$set && '_id' in update.$set)) && !('_id' in filter)) { - update.$setOnInsert = { - ...(update.$setOnInsert || {}), - _id: new ObjectId().toHexString(), - } as Partial & { _id: string }; - } - return this.col.updateOne(filter, update, options); - } - return this.col.updateOne(filter, update); - } - - updateMany(filter: Filter, update: UpdateFilter | Partial, options?: UpdateOptions): Promise { - this.setUpdatedAt(update); - if (options) { - return this.col.updateMany(filter, update, options); - } - return this.col.updateMany(filter, update); + return optionsDef ? this.col.findOne(q, optionsDef) : this.col.findOne(q); } - insertMany(docs: InsertionModel[], options?: BulkWriteOptions): Promise> { - docs = docs.map((doc) => { - if (!doc._id || typeof doc._id !== 'string') { - const oid = new ObjectId(); - return { _id: oid.toHexString(), ...doc }; - } - this.setUpdatedAt(doc); - return doc; - }); - - // TODO reavaluate following type casting - return this.col.insertMany(docs as unknown as OptionalUnlessRequiredId[], options || {}); - } - - insertOne(doc: InsertionModel, options?: InsertOneOptions): Promise> { - if (!doc._id || typeof doc._id !== 'string') { - const oid = new ObjectId(); - doc = { _id: oid.toHexString(), ...doc }; - } - - this.setUpdatedAt(doc); - - // TODO reavaluate following type casting - return this.col.insertOne(doc as unknown as OptionalUnlessRequiredId, options || {}); - } - - removeById(_id: T['_id'], options?: { session?: ClientSession }): Promise { - return this.deleteOne({ _id } as Filter, { session: options?.session }); - } - - removeByIds(ids: T['_id'][]): Promise { - return this.deleteMany({ _id: { $in: ids } } as unknown as Filter); - } - - async deleteOne(filter: Filter, options?: DeleteOptions & { bypassDocumentValidation?: boolean }): Promise { - if (!this.trash) { - if (options) { - return this.col.deleteOne(filter, options); - } - return this.col.deleteOne(filter); - } - - const doc = await this.findOne(filter); - - if (doc) { - const { _id, ...record } = doc; - - const trash: TDeleted = { - ...record, - _deletedAt: new Date(), - __collection__: this.name, - } as unknown as TDeleted; - - // since the operation is not atomic, we need to make sure that the record is not already deleted/inserted - await this.trash?.updateOne({ _id } as Filter, { $set: trash } as UpdateFilter, { - upsert: true, - }); - } - - if (options) { - return this.col.deleteOne(filter, options); - } - return this.col.deleteOne(filter); - } + /* ===================== FIXED METHOD ===================== */ async findOneAndDelete(filter: Filter, options?: FindOneAndDeleteOptions): Promise | null> { if (!this.trash) { @@ -352,161 +186,45 @@ export abstract class BaseRaw< __collection__: this.name, } as unknown as TDeleted; - await this.trash?.updateOne({ _id } as Filter, { $set: trash } as UpdateFilter, { + await this.trash.updateOne({ _id } as Filter, { $set: trash } as UpdateFilter, { upsert: true, }); try { await this.col.deleteOne({ _id } as Filter); - } catch (e) { - await this.trash?.deleteOne({ _id } as Filter); - throw e; - } - - return doc as WithId; - } - - async deleteMany(filter: Filter, options?: DeleteOptions & { onTrash?: (record: ResultFields) => void }): Promise { - if (!this.trash) { - if (options) { - return this.col.deleteMany(filter, options); + } catch (originalError) { + try { + const rollbackResult = await this.trash.deleteOne({ _id } as Filter); + + if (!rollbackResult || rollbackResult.deletedCount !== 1) { + console.error('BaseRaw.findOneAndDelete: Rollback failed – orphaned trash record', { + collection: this.name, + _id, + rollbackResult, + originalError, + }); + } + } catch (rollbackError) { + console.error('BaseRaw.findOneAndDelete: Rollback threw exception', { + collection: this.name, + _id, + rollbackError, + originalError, + }); } - return this.col.deleteMany(filter); - } - - const cursor = this.find>(filter, { session: options?.session }); - - const ids: T['_id'][] = []; - for await (const doc of cursor) { - const { _id, ...record } = doc as T; - - const trash: TDeleted = { - ...record, - _deletedAt: new Date(), - __collection__: this.name, - } as unknown as TDeleted; - - ids.push(_id as T['_id']); - - // since the operation is not atomic, we need to make sure that the record is not already deleted/inserted - await this.trash?.updateOne({ _id } as Filter, { $set: trash } as UpdateFilter, { - upsert: true, - session: options?.session, - }); - - void options?.onTrash?.(doc); - } - - if (options) { - return this.col.deleteMany({ _id: { $in: ids } } as unknown as Filter, options); - } - return this.col.deleteMany({ _id: { $in: ids } } as unknown as Filter); - } - // Trash - trashFind

( - query: Filter, - options?: FindOptions

, - ): FindCursor> | undefined { - if (!this.trash) { - return undefined; - } - - if (options) { - return this.trash.find( - { - __collection__: this.name, - ...query, - }, - options, - ); + throw originalError; } - return this.trash.find({ - __collection__: this.name, - ...query, - }); + return doc as WithId; } - trashFindOneById(_id: TDeleted['_id']): Promise; - - trashFindOneById

(_id: TDeleted['_id'], options: FindOptions

): Promise

; - - async trashFindOneById

( - _id: TDeleted['_id'], - options?: FindOptions

, - ): Promise | null> { - const query = { - _id, - __collection__: this.name, - } as Filter

; - - if (!this.trash) { - return null; - } - - if (options) { - return (this.trash as Collection

).findOne(query, options); - } - return (this.trash as Collection

).findOne(query); - } + /* ======================================================== */ private setUpdatedAt(record: UpdateFilter | InsertionModel): void { - if (this.preventSetUpdatedAt) { - return; - } - setUpdatedAt(record); - } - - trashFindDeletedAfter(deletedAt: Date): FindCursor>; - - trashFindDeletedAfter

( - deletedAt: Date, - query?: Filter, - options?: FindOptions

, - ): FindCursor> { - const q = { - __collection__: this.name, - _deletedAt: { - $gt: deletedAt, - }, - ...query, - } as Filter; - - if (!this.trash) { - throw new Error('Trash is not enabled for this collection'); - } - - if (options) { - return this.trash.find(q, options); - } - return this.trash.find(q); - } - - trashFindPaginatedDeletedAfter

( - deletedAt: Date, - query?: Filter, - options?: FindOptions

, - ): FindPaginated>> { - const q: Filter = { - __collection__: this.name, - _deletedAt: { - $gt: deletedAt, - }, - ...query, - } as Filter; - - if (!this.trash) { - throw new Error('Trash is not enabled for this collection'); + if (!this.preventSetUpdatedAt) { + setUpdatedAt(record); } - - const cursor = options ? this.trash.find(q, options) : this.trash.find(q); - const totalCount = this.trash.countDocuments(q); - - return { - cursor, - totalCount, - }; } watch(pipeline?: object[]): ChangeStream { @@ -514,10 +232,7 @@ export abstract class BaseRaw< } countDocuments(query: Filter, options?: CountDocumentsOptions): Promise { - if (options) { - return this.col.countDocuments(query, options); - } - return this.col.countDocuments(query); + return options ? this.col.countDocuments(query, options) : this.col.countDocuments(query); } estimatedDocumentCount(): Promise { diff --git a/packages/web-ui-registration/src/RegisterForm.tsx b/packages/web-ui-registration/src/RegisterForm.tsx index 9b7f3e548d3eb..92a58257247fa 100644 --- a/packages/web-ui-registration/src/RegisterForm.tsx +++ b/packages/web-ui-registration/src/RegisterForm.tsx @@ -1,4 +1,3 @@ -/* eslint-disable complexity */ import { FieldGroup, TextInput, @@ -13,8 +12,8 @@ import { Callout, } from '@rocket.chat/fuselage'; import { Form, ActionLink } from '@rocket.chat/layout'; -import { CustomFieldsForm, PasswordVerifier, useValidatePassword } from '@rocket.chat/ui-client'; -import { useAccountsCustomFields, useSetting, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { CustomFieldsForm, PasswordVerifier } from '@rocket.chat/ui-client'; +import { useAccountsCustomFields, useSetting } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import { useEffect, useId, useRef, useState } from 'react'; import { useForm } from 'react-hook-form'; @@ -23,6 +22,8 @@ import { Trans, useTranslation } from 'react-i18next'; import EmailConfirmationForm from './EmailConfirmationForm'; import type { DispatchLoginRouter } from './hooks/useLoginRouter'; import { useRegisterMethod } from './hooks/useRegisterMethod'; +import { useRegisterValidation } from './hooks/useRegisterValidation'; +import { useRegisterErrorHandling } from './hooks/useRegisterErrorHandling'; type LoginRegisterPayload = { name: string; @@ -36,6 +37,7 @@ type LoginRegisterPayload = { export const RegisterForm = ({ setLoginRoute }: { setLoginRoute: DispatchLoginRouter }): ReactElement => { const { t } = useTranslation(); + // Settings const requireNameForRegister = useSetting('Accounts_RequireNameForSignUp', true); const requiresPasswordConfirmation = useSetting('Accounts_RequirePasswordConfirmation', true); const manuallyApproveNewUsersRequired = useSetting('Accounts_ManuallyApproveNewUsers', false); @@ -44,6 +46,7 @@ export const RegisterForm = ({ setLoginRoute }: { setLoginRoute: DispatchLoginRo const passwordPlaceholder = useSetting('Accounts_PasswordPlaceholder', ''); const passwordConfirmationPlaceholder = useSetting('Accounts_ConfirmPasswordPlaceholder', ''); + // IDs const formLabelId = useId(); const passwordVerifierId = useId(); const nameId = useId(); @@ -58,8 +61,6 @@ export const RegisterForm = ({ setLoginRoute }: { setLoginRoute: DispatchLoginRo const [serverError, setServerError] = useState(undefined); - const dispatchToastMessage = useToastMessageDispatch(); - const { register, handleSubmit, @@ -72,53 +73,32 @@ export const RegisterForm = ({ setLoginRoute }: { setLoginRoute: DispatchLoginRo } = useForm({ mode: 'onBlur' }); const { password } = watch(); - const passwordIsValid = useValidatePassword(password); + + + const validation = useRegisterValidation({ + requireNameForRegister, + requiresPasswordConfirmation, + password, + }); + + + const handleRegisterError = useRegisterErrorHandling({ + setError, + setLoginRoute, + setServerError, + }); const registerFormRef = useRef(null); useEffect(() => { - if (registerFormRef.current) { - registerFormRef.current.focus(); - } + 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 (/Email already exists/.test(error.error)) { - setError('email', { type: 'email-already-exists', message: t('registration.component.form.emailAlreadyExists') }); - } - if (/Username is already in use/.test(error.error)) { - setError('username', { type: 'username-already-exists', message: t('registration.component.form.userAlreadyExist') }); - } - 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); - } - }, + onError: handleRegisterError, }, ); }; @@ -138,17 +118,17 @@ export const RegisterForm = ({ setLoginRoute }: { setLoginRoute: DispatchLoginRo {t('registration.component.form.createAnAccount')} + + {/* Name */} {t('registration.component.form.name')} )} + + {/* Email */} {t('registration.component.form.email')} )} + + {/* Username */} {t('registration.component.form.username')} )} + + {/* Password */} {t('registration.component.form.password')} (!passwordIsValid ? t('Password_must_meet_the_complexity_requirements') : true), - })} + {...register('password', validation.password)} error={errors.password?.message} aria-required='true' aria-invalid={errors.password ? 'true' : undefined} @@ -231,13 +206,15 @@ export const RegisterForm = ({ setLoginRoute }: { setLoginRoute: DispatchLoginRo aria-describedby={`${passwordVerifierId} ${passwordId}-error`} /> - {errors?.password && ( + {errors.password && ( {errors.password.message} )} + + {/* Confirm Password */} {requiresPasswordConfirmation && ( @@ -245,18 +222,13 @@ export const RegisterForm = ({ setLoginRoute }: { setLoginRoute: DispatchLoginRo (watch('password') === val ? true : t('registration.component.form.invalidConfirmPass')), - })} + {...register('passwordConfirmation', validation.passwordConfirmation)} 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} /> {errors.passwordConfirmation && ( @@ -266,6 +238,8 @@ export const RegisterForm = ({ setLoginRoute }: { setLoginRoute: DispatchLoginRo )} )} + + {/* Reason */} {manuallyApproveNewUsersRequired && ( @@ -273,9 +247,7 @@ export const RegisterForm = ({ setLoginRoute }: { setLoginRoute: DispatchLoginRo )} + {serverError && {serverError}} + - { - setLoginRoute('login'); - }} - > + setLoginRoute('login')}> Back to Login diff --git a/packages/web-ui-registration/src/hooks/useRegisterErrorHandling.ts b/packages/web-ui-registration/src/hooks/useRegisterErrorHandling.ts new file mode 100644 index 0000000000000..fdd1b3d1d3156 --- /dev/null +++ b/packages/web-ui-registration/src/hooks/useRegisterErrorHandling.ts @@ -0,0 +1,55 @@ +import { useTranslation } from 'react-i18next'; +import { useToastMessageDispatch } from '@rocket.chat/ui-contexts'; + +type Params = { + setError: any; + setLoginRoute: (route: 'login') => void; + setServerError: (value: string | undefined) => void; +}; + +export const useRegisterErrorHandling = ({ setError, setLoginRoute, setServerError }: Params) => { + const { t } = useTranslation(); + const dispatchToastMessage = useToastMessageDispatch(); + + return (error: any): void => { + 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 (/Email already exists/.test(error.error)) { + setError('email', { type: 'email-already-exists', message: t('registration.component.form.emailAlreadyExists') }); + } + + if (/Username is already in use/.test(error.error)) { + setError('username', { type: 'username-already-exists', message: t('registration.component.form.userAlreadyExist') }); + } + + 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); + } + }; +}; diff --git a/packages/web-ui-registration/src/hooks/useRegisterValidation.ts b/packages/web-ui-registration/src/hooks/useRegisterValidation.ts new file mode 100644 index 0000000000000..6313a67b56566 --- /dev/null +++ b/packages/web-ui-registration/src/hooks/useRegisterValidation.ts @@ -0,0 +1,44 @@ +import { useTranslation } from 'react-i18next'; +import { useValidatePassword } from '@rocket.chat/ui-client'; + +type Params = { + requireNameForRegister: boolean; + requiresPasswordConfirmation: boolean; + password: string; +}; + +export const useRegisterValidation = ({ requireNameForRegister, requiresPasswordConfirmation, password }: Params) => { + const { t } = useTranslation(); + const passwordIsValid = useValidatePassword(password); + + return { + name: { + required: requireNameForRegister ? t('Required_field', { field: t('registration.component.form.name') }) : false, + }, + 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'), + }, + }, + username: { + required: t('Required_field', { field: t('registration.component.form.username') }), + }, + password: { + required: t('Required_field', { field: t('registration.component.form.password') }), + validate: () => (!passwordIsValid ? t('Password_must_meet_the_complexity_requirements') : true), + }, + passwordConfirmation: requiresPasswordConfirmation + ? { + required: t('Required_field', { field: t('registration.component.form.confirmPassword') }), + deps: ['password'], + validate: (val: string, values: any) => + values.password === val ? true : t('registration.component.form.invalidConfirmPass'), + } + : undefined, + reason: { + required: t('Required_field', { field: t('registration.component.form.reasonToJoin') }), + }, + }; +};