Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Chore: Account/Profile to TS #25929

Merged
merged 7 commits into from
Jun 28, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion apps/meteor/client/components/CustomFieldsForm.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,13 @@ const CustomFieldsAssembler = ({ formValues, formHandlers, customFields, ...prop
return null;
});

export default function CustomFieldsForm({ jsonCustomFields, customFieldsData, setCustomFieldsData, onLoadFields = () => {}, ...props }) {
export default function CustomFieldsForm({
jsonCustomFields = undefined,
customFieldsData,
setCustomFieldsData,
onLoadFields = () => {},
...props
}) {
const accountsCustomFieldsJson = useSetting('Accounts_CustomFields');

const [customFields] = useState(() => {
Expand Down
21 changes: 5 additions & 16 deletions apps/meteor/client/hooks/useUpdateAvatar.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,20 @@
import { IUser } from '@rocket.chat/core-typings';
import { AvatarObject, AvatarServiceObject, AvatarReset, AvatarUrlObj, IUser } from '@rocket.chat/core-typings';
import { useToastMessageDispatch, useMethod, useTranslation } from '@rocket.chat/ui-contexts';
import { useMemo, useCallback } from 'react';

import { useEndpointAction } from './useEndpointAction';
import { useEndpointUpload } from './useEndpointUpload';

type AvatarUrlObj = {
avatarUrl: string;
};

type AvatarReset = 'reset';

type AvatarServiceObject = {
blob: Blob;
contentType: string;
service: string;
};

type AvatarObject = AvatarReset | AvatarUrlObj | FormData | AvatarServiceObject;

const isAvatarReset = (avatarObj: AvatarObject): avatarObj is AvatarReset => avatarObj === 'reset';
const isServiceObject = (avatarObj: AvatarObject): avatarObj is AvatarServiceObject =>
!isAvatarReset(avatarObj) && typeof avatarObj === 'object' && 'service' in avatarObj;
const isAvatarUrl = (avatarObj: AvatarObject): avatarObj is AvatarUrlObj =>
!isAvatarReset(avatarObj) && typeof avatarObj === 'object' && 'service' && 'avatarUrl' in avatarObj;

export const useUpdateAvatar = (avatarObj: AvatarObject, userId: IUser['_id']): (() => void) => {
export const useUpdateAvatar = (
avatarObj: AvatarObject,
userId: IUser['_id'],
): (() => Promise<{ success: boolean } | null | undefined>) => {
const t = useTranslation();
const avatarUrl = isAvatarUrl(avatarObj) ? avatarObj.avatarUrl : '';

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { IUser } from '@rocket.chat/core-typings';
import {
Field,
FieldGroup,
Expand All @@ -12,25 +13,34 @@ import {
Margins,
} from '@rocket.chat/fuselage';
import { useDebouncedCallback, useSafely } from '@rocket.chat/fuselage-hooks';
import { useToastMessageDispatch, useMethod, useTranslation } from '@rocket.chat/ui-contexts';
import React, { useCallback, useMemo, useEffect, useState } from 'react';
import { useToastMessageDispatch, useMethod, useTranslation, TranslationKey } from '@rocket.chat/ui-contexts';
import React, { Dispatch, ReactElement, SetStateAction, useCallback, useMemo, useEffect, useState } from 'react';

import { validateEmail } from '../../../../lib/emailValidator';
import { getUserEmailAddress } from '../../../../lib/getUserEmailAddress';
import CustomFieldsForm from '../../../components/CustomFieldsForm';
import { USER_STATUS_TEXT_MAX_LENGTH } from '../../../components/UserStatus';
import UserStatusMenu from '../../../components/UserStatusMenu';
import UserAvatarEditor from '../../../components/avatar/UserAvatarEditor';
import { AccountFormValues } from './AccountProfilePage';

function AccountProfileForm({ values, handlers, user, settings, onSaveStateChange, ...props }) {
type AccountProfileFormProps = {
values: Record<string, unknown>;
handlers: Record<string, (eventOrValue: unknown) => void>;
user: IUser | null;
settings: Record<string, unknown> & { namesRegex: RegExp };
onSaveStateChange: Dispatch<SetStateAction<boolean>>;
};

const AccountProfileForm = ({ values, handlers, user, settings, onSaveStateChange, ...props }: AccountProfileFormProps): ReactElement => {
const t = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();

const checkUsernameAvailability = useMethod('checkUsernameAvailability');
const getAvatarSuggestions = useMethod('getAvatarSuggestion');
const sendConfirmationEmail = useMethod('sendConfirmationEmail');

const [usernameError, setUsernameError] = useState();
const [usernameError, setUsernameError] = useState<string | undefined>();
const [avatarSuggestions, setAvatarSuggestions] = useSafely(useState());

const {
Expand All @@ -44,7 +54,8 @@ function AccountProfileForm({ values, handlers, user, settings, onSaveStateChang
requireName,
} = settings;

const { realname, email, username, password, confirmationPassword, statusText, bio, statusType, customFields, nickname } = values;
const { realname, email, username, password, confirmationPassword, statusText, bio, statusType, customFields, nickname } =
values as AccountFormValues;

const {
handleRealname,
Expand All @@ -60,7 +71,7 @@ function AccountProfileForm({ values, handlers, user, settings, onSaveStateChang
handleNickname,
} = handlers;

const previousEmail = getUserEmailAddress(user);
const previousEmail = user ? getUserEmailAddress(user) : '';

const handleSendConfirmationEmail = useCallback(async () => {
if (email !== previousEmail) {
Expand All @@ -80,8 +91,8 @@ function AccountProfileForm({ values, handlers, user, settings, onSaveStateChang
);
const emailError = useMemo(() => (validateEmail(email) ? undefined : 'error-invalid-email-address'), [email]);
const checkUsername = useDebouncedCallback(
async (username) => {
if (user.username === username) {
async (username: string) => {
if (user?.username === username) {
return setUsernameError(undefined);
}
if (!namesRegex.test(username)) {
Expand All @@ -94,11 +105,11 @@ function AccountProfileForm({ values, handlers, user, settings, onSaveStateChang
setUsernameError(undefined);
},
400,
[namesRegex, t, user.username, checkUsernameAvailability, setUsernameError],
[namesRegex, t, user?.username, checkUsernameAvailability, setUsernameError],
);

useEffect(() => {
const getSuggestions = async () => {
const getSuggestions = async (): Promise<void> => {
const suggestions = await getAvatarSuggestions();
setAvatarSuggestions(suggestions);
};
Expand All @@ -116,13 +127,13 @@ function AccountProfileForm({ values, handlers, user, settings, onSaveStateChang
}, [password, handleConfirmationPassword]);

const nameError = useMemo(() => {
if (user.name === realname) {
if (user?.name === realname) {
return undefined;
}
if (!realname && requireName) {
return t('Field_required');
}
}, [realname, requireName, t, user.name]);
}, [realname, requireName, t, user?.name]);

const statusTextError = useMemo(() => {
if (statusText && statusText.length > USER_STATUS_TEXT_MAX_LENGTH) {
Expand All @@ -133,7 +144,7 @@ function AccountProfileForm({ values, handlers, user, settings, onSaveStateChang
}, [statusText, t]);
const {
emails: [{ verified = false } = { verified: false }],
} = user;
} = user as any;

const canSave = !![!!passwordError, !!emailError, !!usernameError, !!nameError, !!statusTextError].filter(Boolean);

Expand All @@ -151,16 +162,16 @@ function AccountProfileForm({ values, handlers, user, settings, onSaveStateChang
() => (
<Field>
<UserAvatarEditor
etag={user.avatarETag}
currentUsername={user.username}
etag={user?.avatarETag}
currentUsername={user?.username}
username={username}
setAvatarObj={handleAvatar}
disabled={!allowUserAvatarChange}
suggestions={avatarSuggestions}
/>
</Field>
),
[username, user.username, handleAvatar, allowUserAvatarChange, avatarSuggestions, user.avatarETag],
[username, user?.username, handleAvatar, allowUserAvatarChange, avatarSuggestions, user?.avatarETag],
)}
<Box display='flex' flexDirection='row' justifyContent='space-between'>
{useMemo(
Expand Down Expand Up @@ -209,7 +220,7 @@ function AccountProfileForm({ values, handlers, user, settings, onSaveStateChang
value={statusText}
onChange={handleStatusText}
placeholder={t('StatusMessage_Placeholder')}
addon={<UserStatusMenu margin='neg-x2' onChange={handleStatusType} initialStatus={statusType} />}
addon={<UserStatusMenu margin='neg-x2' onChange={handleStatusType} initialStatus={statusType as IUser['status']} />}
/>
</Field.Row>
{!allowUserStatusMessageChange && <Field.Hint>{t('StatusMessage_Change_Disabled')}</Field.Hint>}
Expand Down Expand Up @@ -270,7 +281,7 @@ function AccountProfileForm({ values, handlers, user, settings, onSaveStateChang
/>
</Field.Row>
{!allowEmailChange && <Field.Hint>{t('Email_Change_Disabled')}</Field.Hint>}
<Field.Error>{t(emailError)}</Field.Error>
<Field.Error>{t(emailError as TranslationKey)}</Field.Error>
</Field>
),
[t, email, handleEmail, verified, allowEmailChange, emailError],
Expand Down Expand Up @@ -340,6 +351,6 @@ function AccountProfileForm({ values, handlers, user, settings, onSaveStateChang
<CustomFieldsForm customFieldsData={customFields} setCustomFieldsData={handleCustomFields} />
</FieldGroup>
);
}
};

export default AccountProfileForm;
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { AvatarObject, IUser } from '@rocket.chat/core-typings';
import { ButtonGroup, Button, Box, Icon } from '@rocket.chat/fuselage';
import {
useSetModal,
Expand All @@ -8,9 +9,10 @@ import {
useEndpoint,
useMethod,
useTranslation,
TranslationKey,
} from '@rocket.chat/ui-contexts';
import { SHA256 } from 'meteor/sha';
import React, { useMemo, useState, useCallback } from 'react';
import React, { ReactElement, useMemo, useState, useCallback } from 'react';

import { getUserEmailAddress } from '../../../../lib/getUserEmailAddress';
import ConfirmOwnerChangeWarningModal from '../../../components/ConfirmOwnerChangeWarningModal';
Expand All @@ -20,28 +22,43 @@ import { useUpdateAvatar } from '../../../hooks/useUpdateAvatar';
import AccountProfileForm from './AccountProfileForm';
import ActionConfirmModal from './ActionConfirmModal';

const getInitialValues = (user) => ({
realname: user.name ?? '',
email: getUserEmailAddress(user) ?? '',
username: user.username ?? '',
export type AccountFormValues = {
realname: string;
email: string;
username: string;
password: string;
confirmationPassword: string;
avatar: AvatarObject;
url: string;
statusText: string;
statusType: string;
bio: string;
customFields: Record<string, string>;
nickname: string;
};

const getInitialValues = (user: IUser | null): AccountFormValues => ({
realname: user?.name ?? '',
email: user ? getUserEmailAddress(user) || '' : '',
username: user?.username ?? '',
password: '',
confirmationPassword: '',
avatar: '',
url: user.avatarUrl ?? '',
statusText: user.statusText ?? '',
statusType: user.status ?? '',
bio: user.bio ?? '',
customFields: user.customFields ?? {},
nickname: user.nickname ?? '',
avatar: '' as AvatarObject,
url: '',
statusText: user?.statusText ?? '',
statusType: user?.status ?? '',
bio: user?.bio ?? '',
customFields: user?.customFields ?? {},
nickname: user?.nickname ?? '',
});

const AccountProfilePage = () => {
const AccountProfilePage = (): ReactElement => {
const t = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();

const user = useUser();

const { values, handlers, hasUnsavedChanges, commit, reset } = useForm(getInitialValues(user ?? {}));
const { values, handlers, hasUnsavedChanges, commit, reset } = useForm(getInitialValues(user));
const [canSave, setCanSave] = useState(true);
const setModal = useSetModal();
const logout = useLogout();
Expand All @@ -53,7 +70,7 @@ const AccountProfilePage = () => {

const closeModal = useCallback(() => setModal(null), [setModal]);

const localPassword = Boolean(user?.services?.password?.exists);
const localPassword = Boolean(user?.services?.password?.bcrypt);

const erasureType = useSetting('Message_ErasureType');
const allowRealNameChange = useSetting('Accounts_AllowRealNameChange');
Expand Down Expand Up @@ -98,22 +115,22 @@ const AccountProfilePage = () => {
],
);

const { realname, email, avatar, username, password, statusText, statusType, customFields, bio, nickname } = values;
const { realname, email, avatar, username, password, statusText, statusType, customFields, bio, nickname } = values as AccountFormValues;

const { handleAvatar, handlePassword, handleConfirmationPassword } = handlers;

const updateAvatar = useUpdateAvatar(avatar, user?._id);
const updateAvatar = useUpdateAvatar(avatar, user?._id || '');

const onSave = useCallback(async () => {
const save = async (typedPassword) => {
const save = async (typedPassword?: string): Promise<void> => {
try {
await saveFn(
{
...(allowRealNameChange && { realname }),
...(allowEmailChange && getUserEmailAddress(user) !== email && { email }),
...(allowPasswordChange && { newPassword: password }),
...(canChangeUsername && { username }),
...(allowUserStatusMessageChange && { statusText }),
...(allowRealNameChange ? { realname } : {}),
...(allowEmailChange && user ? getUserEmailAddress(user) !== email && { email } : {}),
...(allowPasswordChange ? { newPassword: password } : {}),
...(canChangeUsername ? { username } : {}),
...(allowUserStatusMessageChange ? { statusText } : {}),
...(typedPassword && { typedPassword: SHA256(typedPassword) }),
statusType,
nickname,
Expand Down Expand Up @@ -177,7 +194,7 @@ const AccountProfilePage = () => {

const handleConfirmOwnerChange = useCallback(
(passwordOrUsername, shouldChangeOwner, shouldBeRemoved) => {
const handleConfirm = async () => {
const handleConfirm = async (): Promise<void> => {
try {
await deleteOwnAccount(SHA256(passwordOrUsername), true);
dispatchToastMessage({ type: 'success', message: t('User_has_been_deleted') });
Expand All @@ -192,7 +209,7 @@ const AccountProfilePage = () => {
<ConfirmOwnerChangeWarningModal
onConfirm={handleConfirm}
onCancel={closeModal}
contentTitle={t(`Delete_User_Warning_${erasureType}`)}
contentTitle={t(`Delete_User_Warning_${erasureType}` as TranslationKey)}
confirmLabel={t('Delete')}
shouldChangeOwner={shouldChangeOwner}
shouldBeRemoved={shouldBeRemoved}
Expand All @@ -203,7 +220,7 @@ const AccountProfilePage = () => {
);

const handleDeleteOwnAccount = useCallback(async () => {
const handleConfirm = async (passwordOrUsername) => {
const handleConfirm = async (passwordOrUsername: string): Promise<void> => {
try {
await deleteOwnAccount(SHA256(passwordOrUsername));
dispatchToastMessage({ type: 'success', message: t('User_has_been_deleted') });
Expand Down Expand Up @@ -235,19 +252,13 @@ const AccountProfilePage = () => {
</Page.Header>
<Page.ScrollableContentWithShadow>
<Box maxWidth='600px' w='full' alignSelf='center'>
<AccountProfileForm
values={values}
handlers={handlers}
user={user ?? { emails: [] }}
settings={settings}
onSaveStateChange={setCanSave}
/>
<AccountProfileForm values={values} handlers={handlers} user={user} settings={settings} onSaveStateChange={setCanSave} />
<ButtonGroup stretch mb='x12'>
<Button onClick={handleLogoutOtherLocations} flexGrow={0} disabled={loggingOut}>
{t('Logout_Others')}
</Button>
{allowDeleteOwnAccount && (
<Button secondaryDanger onClick={handleDeleteOwnAccount}>
<Button danger onClick={handleDeleteOwnAccount}>
<Icon name='trash' size='x20' mie='x4' />
{t('Delete_my_account')}
</Button>
Expand Down
15 changes: 15 additions & 0 deletions packages/core-typings/src/IUser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ export interface IUser extends IRocketChatRecord {
bio?: string;
avatarOrigin?: string;
avatarETag?: string;
avatarUrl?: string;
utcOffset?: number;
language?: string;
statusDefault?: UserStatus;
Expand Down Expand Up @@ -170,3 +171,17 @@ export type IUserInRole = Pick<
IUser,
'_id' | 'name' | 'username' | 'emails' | 'avatarETag' | 'createdAt' | 'roles' | 'type' | 'active' | '_updatedAt'
>;

export type AvatarUrlObj = {
avatarUrl: string;
};

export type AvatarReset = 'reset';

export type AvatarServiceObject = {
blob: Blob;
contentType: string;
service: string;
};

export type AvatarObject = AvatarReset | AvatarUrlObj | FormData | AvatarServiceObject;