mirror of
https://github.com/logto-io/logto.git
synced 2025-01-20 21:32:31 -05:00
feat(console): user settings unsaved changes alert (#1411)
This commit is contained in:
parent
4d7a091d24
commit
14b27b6d0d
3 changed files with 188 additions and 150 deletions
|
@ -0,0 +1,19 @@
|
|||
import React from 'react';
|
||||
|
||||
import AuditLogTable from '@/components/AuditLogTable';
|
||||
|
||||
import * as styles from '../index.module.scss';
|
||||
|
||||
type Props = {
|
||||
userId: string;
|
||||
};
|
||||
|
||||
const UserLogs = ({ userId }: Props) => {
|
||||
return (
|
||||
<div className={styles.logs}>
|
||||
<AuditLogTable userId={userId} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserLogs;
|
|
@ -0,0 +1,152 @@
|
|||
import { User } from '@logto/schemas';
|
||||
import { Nullable } from '@silverhand/essentials';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useForm, useController } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import Button from '@/components/Button';
|
||||
import CodeEditor from '@/components/CodeEditor';
|
||||
import FormField from '@/components/FormField';
|
||||
import TextInput from '@/components/TextInput';
|
||||
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import * as detailsStyles from '@/scss/details.module.scss';
|
||||
import { safeParseJson } from '@/utilities/json';
|
||||
import { uriValidator } from '@/utilities/validator';
|
||||
|
||||
import * as styles from '../index.module.scss';
|
||||
import UserConnectors from './UserConnectors';
|
||||
|
||||
type FormData = {
|
||||
primaryEmail: Nullable<string>;
|
||||
primaryPhone: Nullable<string>;
|
||||
username: Nullable<string>;
|
||||
name: Nullable<string>;
|
||||
avatar: Nullable<string>;
|
||||
roleNames: string[];
|
||||
customData: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
userData: User;
|
||||
userFormData: FormData;
|
||||
onUserUpdated: (user?: User) => void;
|
||||
};
|
||||
|
||||
const UserSettings = ({ userData, userFormData, onUserUpdated }: Props) => {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
control,
|
||||
reset,
|
||||
formState: { isSubmitting, errors, isDirty },
|
||||
getValues,
|
||||
} = useForm<FormData>();
|
||||
|
||||
const {
|
||||
field: { onChange, value },
|
||||
} = useController({ name: 'customData', control });
|
||||
|
||||
const api = useApi();
|
||||
|
||||
useEffect(() => {
|
||||
reset(userFormData);
|
||||
}, [reset, userFormData]);
|
||||
|
||||
const onSubmit = handleSubmit(async (formData) => {
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { customData: inputtedCustomData, name, avatar, roleNames } = formData;
|
||||
|
||||
const customData = inputtedCustomData ? safeParseJson(inputtedCustomData) : {};
|
||||
|
||||
if (!customData) {
|
||||
toast.error(t('user_details.custom_data_invalid'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: Partial<User> = {
|
||||
name,
|
||||
avatar,
|
||||
roleNames,
|
||||
customData,
|
||||
};
|
||||
|
||||
const updatedUser = await api
|
||||
.patch(`/api/users/${userData.id}`, { json: payload })
|
||||
.json<User>();
|
||||
onUserUpdated(updatedUser);
|
||||
toast.success(t('general.saved'));
|
||||
});
|
||||
|
||||
return (
|
||||
<form className={styles.form} onSubmit={onSubmit}>
|
||||
<div className={styles.fields}>
|
||||
{getValues('primaryEmail') && (
|
||||
<FormField title="admin_console.user_details.field_email" className={styles.textField}>
|
||||
<TextInput readOnly {...register('primaryEmail')} />
|
||||
</FormField>
|
||||
)}
|
||||
{getValues('primaryPhone') && (
|
||||
<FormField title="admin_console.user_details.field_phone" className={styles.textField}>
|
||||
<TextInput readOnly {...register('primaryPhone')} />
|
||||
</FormField>
|
||||
)}
|
||||
{getValues('username') && (
|
||||
<FormField title="admin_console.user_details.field_username" className={styles.textField}>
|
||||
<TextInput readOnly {...register('username')} />
|
||||
</FormField>
|
||||
)}
|
||||
<FormField title="admin_console.user_details.field_name" className={styles.textField}>
|
||||
<TextInput {...register('name')} />
|
||||
</FormField>
|
||||
<FormField title="admin_console.user_details.field_avatar" className={styles.textField}>
|
||||
<TextInput
|
||||
{...register('avatar', {
|
||||
validate: (value) => !value || uriValidator(value) || t('errors.invalid_uri_format'),
|
||||
})}
|
||||
hasError={Boolean(errors.avatar)}
|
||||
errorMessage={errors.avatar?.message}
|
||||
placeholder={t('user_details.field_avatar_placeholder')}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField title="admin_console.user_details.field_connectors" className={styles.textField}>
|
||||
<UserConnectors
|
||||
userId={userData.id}
|
||||
connectors={userData.identities}
|
||||
onDelete={() => {
|
||||
onUserUpdated();
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
isRequired
|
||||
title="admin_console.user_details.field_custom_data"
|
||||
className={styles.textField}
|
||||
>
|
||||
<CodeEditor language="json" value={value} onChange={onChange} />
|
||||
</FormField>
|
||||
</div>
|
||||
<div className={detailsStyles.footer}>
|
||||
<div className={detailsStyles.footerMain}>
|
||||
<Button
|
||||
isLoading={isSubmitting}
|
||||
htmlType="submit"
|
||||
type="primary"
|
||||
title="admin_console.general.save_changes"
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<UnsavedChangesAlertModal hasUnsavedChanges={isDirty} />
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserSettings;
|
|
@ -1,52 +1,33 @@
|
|||
import { User } from '@logto/schemas';
|
||||
import { Nullable } from '@silverhand/essentials';
|
||||
import classNames from 'classnames';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useController, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ReactModal from 'react-modal';
|
||||
import { useLocation, useParams, useSearchParams } from 'react-router-dom';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import ActionMenu, { ActionMenuItem } from '@/components/ActionMenu';
|
||||
import AuditLogTable from '@/components/AuditLogTable';
|
||||
import Button from '@/components/Button';
|
||||
import Card from '@/components/Card';
|
||||
import CodeEditor from '@/components/CodeEditor';
|
||||
import CopyToClipboard from '@/components/CopyToClipboard';
|
||||
import DetailsSkeleton from '@/components/DetailsSkeleton';
|
||||
import FormField from '@/components/FormField';
|
||||
import LinkButton from '@/components/LinkButton';
|
||||
import TabNav, { TabNavItem } from '@/components/TabNav';
|
||||
import TextInput from '@/components/TextInput';
|
||||
import { generateAvatarPlaceHolderById } from '@/consts/avatars';
|
||||
import useApi, { RequestError } from '@/hooks/use-api';
|
||||
import { RequestError } from '@/hooks/use-api';
|
||||
import Back from '@/icons/Back';
|
||||
import Delete from '@/icons/Delete';
|
||||
import More from '@/icons/More';
|
||||
import Reset from '@/icons/Reset';
|
||||
import * as detailsStyles from '@/scss/details.module.scss';
|
||||
import * as modalStyles from '@/scss/modal.module.scss';
|
||||
import { safeParseJson } from '@/utilities/json';
|
||||
import { uriValidator } from '@/utilities/validator';
|
||||
|
||||
import CreateSuccess from './components/CreateSuccess';
|
||||
import DeleteForm from './components/DeleteForm';
|
||||
import ResetPasswordForm from './components/ResetPasswordForm';
|
||||
import UserConnectors from './components/UserConnectors';
|
||||
import UserLogs from './components/UserLogs';
|
||||
import UserSettings from './components/UserSettings';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type FormData = {
|
||||
primaryEmail: Nullable<string>;
|
||||
primaryPhone: Nullable<string>;
|
||||
username: Nullable<string>;
|
||||
name: Nullable<string>;
|
||||
avatar: Nullable<string>;
|
||||
roleNames: string[];
|
||||
customData: string;
|
||||
};
|
||||
|
||||
const UserDetails = () => {
|
||||
const location = useLocation();
|
||||
const isLogs = location.pathname.endsWith('/logs');
|
||||
|
@ -62,56 +43,16 @@ const UserDetails = () => {
|
|||
const { data, error, mutate } = useSWR<User, RequestError>(userId && `/api/users/${userId}`);
|
||||
const isLoading = !data && !error;
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
control,
|
||||
reset,
|
||||
formState: { isSubmitting, errors },
|
||||
getValues,
|
||||
} = useForm<FormData>();
|
||||
|
||||
const {
|
||||
field: { onChange, value },
|
||||
} = useController({ name: 'customData', control });
|
||||
const api = useApi();
|
||||
|
||||
useEffect(() => {
|
||||
const userFormData = useMemo(() => {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
reset({
|
||||
|
||||
return {
|
||||
...data,
|
||||
customData: JSON.stringify(data.customData, null, 2),
|
||||
});
|
||||
}, [data, reset]);
|
||||
|
||||
const onSubmit = handleSubmit(async (formData) => {
|
||||
if (!data || isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { customData: inputtedCustomData, name, avatar, roleNames } = formData;
|
||||
|
||||
const customData = inputtedCustomData ? safeParseJson(inputtedCustomData) : {};
|
||||
|
||||
if (!customData) {
|
||||
toast.error(t('user_details.custom_data_invalid'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: Partial<User> = {
|
||||
name,
|
||||
avatar,
|
||||
roleNames,
|
||||
customData,
|
||||
};
|
||||
|
||||
const updatedUser = await api.patch(`/api/users/${data.id}`, { json: payload }).json<User>();
|
||||
void mutate(updatedUser);
|
||||
toast.success(t('general.saved'));
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<div className={detailsStyles.container}>
|
||||
|
@ -202,89 +143,15 @@ const UserDetails = () => {
|
|||
<TabNavItem href={`/users/${userId}`}>{t('general.settings_nav')}</TabNavItem>
|
||||
<TabNavItem href={`/users/${userId}/logs`}>{t('user_details.tab_logs')}</TabNavItem>
|
||||
</TabNav>
|
||||
{isLogs ? (
|
||||
<div className={styles.logs}>
|
||||
<AuditLogTable userId={data.id} />
|
||||
</div>
|
||||
) : (
|
||||
<form className={styles.form} onSubmit={onSubmit}>
|
||||
<div className={styles.fields}>
|
||||
{getValues('primaryEmail') && (
|
||||
<FormField
|
||||
title="admin_console.user_details.field_email"
|
||||
className={styles.textField}
|
||||
>
|
||||
<TextInput readOnly {...register('primaryEmail')} />
|
||||
</FormField>
|
||||
)}
|
||||
{getValues('primaryPhone') && (
|
||||
<FormField
|
||||
title="admin_console.user_details.field_phone"
|
||||
className={styles.textField}
|
||||
>
|
||||
<TextInput readOnly {...register('primaryPhone')} />
|
||||
</FormField>
|
||||
)}
|
||||
{getValues('username') && (
|
||||
<FormField
|
||||
title="admin_console.user_details.field_username"
|
||||
className={styles.textField}
|
||||
>
|
||||
<TextInput readOnly {...register('username')} />
|
||||
</FormField>
|
||||
)}
|
||||
<FormField
|
||||
title="admin_console.user_details.field_name"
|
||||
className={styles.textField}
|
||||
>
|
||||
<TextInput {...register('name')} />
|
||||
</FormField>
|
||||
<FormField
|
||||
title="admin_console.user_details.field_avatar"
|
||||
className={styles.textField}
|
||||
>
|
||||
<TextInput
|
||||
{...register('avatar', {
|
||||
validate: (value) =>
|
||||
!value || uriValidator(value) || t('errors.invalid_uri_format'),
|
||||
})}
|
||||
hasError={Boolean(errors.avatar)}
|
||||
errorMessage={errors.avatar?.message}
|
||||
placeholder={t('user_details.field_avatar_placeholder')}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
title="admin_console.user_details.field_connectors"
|
||||
className={styles.textField}
|
||||
>
|
||||
<UserConnectors
|
||||
userId={data.id}
|
||||
connectors={data.identities}
|
||||
onDelete={() => {
|
||||
void mutate();
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
isRequired
|
||||
title="admin_console.user_details.field_custom_data"
|
||||
className={styles.textField}
|
||||
>
|
||||
<CodeEditor language="json" value={value} onChange={onChange} />
|
||||
</FormField>
|
||||
</div>
|
||||
<div className={detailsStyles.footer}>
|
||||
<div className={detailsStyles.footerMain}>
|
||||
<Button
|
||||
isLoading={isSubmitting}
|
||||
htmlType="submit"
|
||||
type="primary"
|
||||
title="admin_console.general.save_changes"
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{isLogs && <UserLogs userId={data.id} />}
|
||||
{!isLogs && userFormData && (
|
||||
<UserSettings
|
||||
userData={data}
|
||||
userFormData={userFormData}
|
||||
onUserUpdated={(user) => {
|
||||
void mutate(user);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
|
|
Loading…
Add table
Reference in a new issue