0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-10 22:22:45 -05:00

refactor(console): user details page (#2859)

This commit is contained in:
Xiao Yijun 2023-01-09 15:25:50 +08:00 committed by GitHub
parent 1654322ead
commit 158dec3c0c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 100 additions and 95 deletions

View file

@ -38,11 +38,14 @@ import {
ConnectorsTabs,
RoleDetailsTabs,
SignInExperiencePage,
UserDetailsTabs,
} from './consts/page-tabs';
import ApiResourcePermissions from './pages/ApiResourceDetails/ApiResourcePermissions';
import ApiResourceSettings from './pages/ApiResourceDetails/ApiResourceSettings';
import RolePermissions from './pages/RoleDetails/RolePermissions';
import RoleSettings from './pages/RoleDetails/RoleSettings';
import UserLogs from './pages/UserDetails/UserLogs';
import UserSettings from './pages/UserDetails/UserSettings';
import { getBasename } from './utilities/router';
void initI18n();
@ -95,9 +98,13 @@ const Main = () => {
<Route path="users">
<Route index element={<Users />} />
<Route path="create" element={<Users />} />
<Route path=":userId" element={<UserDetails />} />
<Route path=":userId/logs" element={<UserDetails />} />
<Route path=":userId/logs/:logId" element={<AuditLogDetails />} />
<Route path=":id" element={<UserDetails />}>
<Route index element={<Navigate replace to={UserDetailsTabs.Settings} />} />
<Route path={UserDetailsTabs.Settings} element={<UserSettings />} />
<Route path={UserDetailsTabs.Logs} element={<UserLogs />}>
<Route path=":logId" element={<AuditLogDetails />} />
</Route>
</Route>
</Route>
<Route path="audit-logs">
<Route index element={<AuditLogs />} />

View file

@ -24,11 +24,12 @@ const pageSize = 20;
type Props = {
userId?: string;
className?: string;
};
const defaultTableColumn = 4;
const AuditLogTable = ({ userId }: Props) => {
const AuditLogTable = ({ userId, className }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { pathname } = useLocation();
const [query, setQuery] = useSearchParams();
@ -68,7 +69,7 @@ const AuditLogTable = ({ userId }: Props) => {
};
return (
<div className={styles.container}>
<div className={classNames(styles.container, className)}>
<div className={styles.tableLayout}>
<div className={styles.filter}>
<div className={styles.title}>{t('logs.filter_by')}</div>

View file

@ -14,6 +14,11 @@ export enum SignInExperiencePage {
OthersTab = 'others',
}
export enum UserDetailsTabs {
Settings = 'settings',
Logs = 'logs',
}
export enum RoleDetailsTabs {
Settings = 'settings',
Permissions = 'permissions',

View file

@ -28,16 +28,16 @@ const getDetailsTabNavLink = (logId: string, userId?: string) =>
userId ? `/users/${userId}/logs/${logId}` : `/audit-logs/${logId}`;
const AuditLogDetails = () => {
const { userId, logId } = useParams();
const { id, logId } = useParams();
const { pathname } = useLocation();
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { data, error } = useSWR<LogDto, RequestError>(logId && `/api/logs/${logId}`);
const { data: userData } = useSWR<User, RequestError>(userId && `/api/users/${userId}`);
const { data: userData } = useSWR<User, RequestError>(id && `/api/users/${id}`);
const isLoading = !data && !error;
const backLink = getAuditLogDetailsRelatedResourceLink(pathname);
const backLinkTitle = userId
const backLinkTitle = id
? t('log_details.back_to_user', { name: userData?.name ?? t('users.unnamed') })
: t('log_details.back_to_logs');
@ -101,7 +101,7 @@ const AuditLogDetails = () => {
</div>
</Card>
<TabNav>
<TabNavItem href={getDetailsTabNavLink(logId, userId)}>
<TabNavItem href={getDetailsTabNavLink(logId, id)}>
{t('log_details.tab_details')}
</TabNavItem>
</TabNav>

View file

@ -0,0 +1,16 @@
import { useOutletContext } from 'react-router-dom';
import AuditLogTable from '@/components/AuditLogTable';
import type { UserDetailsOutletContext } from '../types';
import * as styles from './index.module.scss';
const UserLogs = () => {
const {
user: { id },
} = useOutletContext<UserDetailsOutletContext>();
return <AuditLogTable userId={id} className={styles.logs} />;
};
export default UserLogs;

View file

@ -14,7 +14,7 @@ import type { RequestError } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import { getConnectorGroups } from '@/pages/Connectors/utils';
import * as styles from './UserConnectors.module.scss';
import * as styles from './index.module.scss';
type Props = {
userId: string;

View file

@ -1,9 +1,9 @@
import type { User } from '@logto/schemas';
import { arbitraryObjectGuard } from '@logto/schemas';
import { useEffect } from 'react';
import { useForm, useController } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import { useOutletContext } from 'react-router-dom';
import CodeEditor from '@/components/CodeEditor';
import DetailsForm from '@/components/DetailsForm';
@ -15,19 +15,17 @@ import useApi from '@/hooks/use-api';
import { safeParseJson } from '@/utilities/json';
import { uriValidator } from '@/utilities/validator';
import type { UserDetailsForm } from '../types';
import UserConnectors from './UserConnectors';
import type { UserDetailsForm, UserDetailsOutletContext } from '../types';
import { userDetailsParser } from '../utils';
import UserConnectors from './components/UserConnectors';
type Props = {
userData: User;
userFormData: UserDetailsForm;
onUserUpdated: (user?: User) => void;
isDeleted: boolean;
};
const UserSettings = ({ userData, userFormData, isDeleted, onUserUpdated }: Props) => {
const UserSettings = () => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { user, isDeleting, onUserUpdated } = useOutletContext<UserDetailsOutletContext>();
const userFormData = userDetailsParser.toLocalForm(user);
const {
handleSubmit,
register,
@ -35,7 +33,7 @@ const UserSettings = ({ userData, userFormData, isDeleted, onUserUpdated }: Prop
reset,
formState: { isSubmitting, errors, isDirty },
getValues,
} = useForm<UserDetailsForm>();
} = useForm<UserDetailsForm>({ defaultValues: userFormData });
const {
field: { onChange, value },
@ -43,10 +41,6 @@ const UserSettings = ({ userData, userFormData, isDeleted, onUserUpdated }: Prop
const api = useApi();
useEffect(() => {
reset(userFormData);
}, [reset, userFormData]);
const onSubmit = handleSubmit(async (formData) => {
if (isSubmitting) {
return;
@ -76,9 +70,8 @@ const UserSettings = ({ userData, userFormData, isDeleted, onUserUpdated }: Prop
customData: guardResult.data,
};
const updatedUser = await api
.patch(`/api/users/${userData.id}`, { json: payload })
.json<User>();
const updatedUser = await api.patch(`/api/users/${user.id}`, { json: payload }).json<User>();
reset(userDetailsParser.toLocalForm(updatedUser));
onUserUpdated(updatedUser);
toast.success(t('general.saved'));
});
@ -127,8 +120,8 @@ const UserSettings = ({ userData, userFormData, isDeleted, onUserUpdated }: Prop
</FormField>
<FormField title="user_details.field_connectors">
<UserConnectors
userId={userData.id}
connectors={userData.identities}
userId={user.id}
connectors={user.identities}
onDelete={() => {
onUserUpdated();
}}
@ -143,7 +136,7 @@ const UserSettings = ({ userData, userFormData, isDeleted, onUserUpdated }: Prop
</FormField>
</FormCard>
</DetailsForm>
<UnsavedChangesAlertModal hasUnsavedChanges={!isDeleted && isDirty} />
<UnsavedChangesAlertModal hasUnsavedChanges={!isDeleting && isDirty} />
</>
);
};

View file

@ -10,7 +10,7 @@ import IconButton from '@/components/IconButton';
import ModalLayout from '@/components/ModalLayout';
import * as modalStyles from '@/scss/modal.module.scss';
import * as styles from './CreateSuccess.module.scss';
import * as styles from './index.module.scss';
type Props = {
username: string;

View file

@ -1,17 +0,0 @@
import AuditLogTable from '@/components/AuditLogTable';
import * as styles from './UserLogs.module.scss';
type Props = {
userId: string;
};
const UserLogs = ({ userId }: Props) => {
return (
<div className={styles.logs}>
<AuditLogTable userId={userId} />
</div>
);
};
export default UserLogs;

View file

@ -1,10 +1,10 @@
import type { User } from '@logto/schemas';
import classNames from 'classnames';
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import ReactModal from 'react-modal';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { Outlet, useLocation, useNavigate, useParams } from 'react-router-dom';
import useSWR from 'swr';
import Back from '@/assets/images/back.svg';
@ -19,6 +19,7 @@ import DetailsSkeleton from '@/components/DetailsSkeleton';
import TabNav, { TabNavItem } from '@/components/TabNav';
import TextLink from '@/components/TextLink';
import UserAvatar from '@/components/UserAvatar';
import { UserDetailsTabs } from '@/consts/page-tabs';
import type { RequestError } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import * as detailsStyles from '@/scss/details.module.scss';
@ -26,35 +27,24 @@ import * as modalStyles from '@/scss/modal.module.scss';
import CreateSuccess from './components/CreateSuccess';
import ResetPasswordForm from './components/ResetPasswordForm';
import UserLogs from './components/UserLogs';
import UserSettings from './components/UserSettings';
import * as styles from './index.module.scss';
import { userDetailsParser } from './utils';
import { UserDetailsOutletContext } from './types';
const UserDetails = () => {
const { pathname } = useLocation();
const isLogs = pathname.endsWith('/logs');
const { userId } = useParams();
const isOnLogsPage = pathname.endsWith(UserDetailsTabs.Logs);
const { id } = useParams();
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const [isDeleteFormOpen, setIsDeleteFormOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isDeleted, setIsDeleted] = useState(false);
const [isResetPasswordFormOpen, setIsResetPasswordFormOpen] = useState(false);
const [resetResult, setResetResult] = useState<string>();
const { data, error, mutate } = useSWR<User, RequestError>(userId && `/api/users/${userId}`);
const { data, error, mutate } = useSWR<User, RequestError>(id && `/api/users/${id}`);
const isLoading = !data && !error;
const api = useApi();
const navigate = useNavigate();
const userFormData = useMemo(() => {
if (!data) {
return;
}
return userDetailsParser.toLocalForm(data);
}, [data]);
useEffect(() => {
setIsDeleteFormOpen(false);
setIsResetPasswordFormOpen(false);
@ -69,24 +59,21 @@ const UserDetails = () => {
try {
await api.delete(`/api/users/${data.id}`);
setIsDeleted(true);
setIsDeleting(false);
setIsDeleteFormOpen(false);
toast.success(t('user_details.deleted', { name: data.name }));
navigate('/users');
} catch {
} finally {
setIsDeleting(false);
}
};
return (
<div className={classNames(detailsStyles.container, isLogs && styles.resourceLayout)}>
<div className={classNames(detailsStyles.container, isOnLogsPage && styles.resourceLayout)}>
<TextLink to="/users" icon={<Back />} className={styles.backLink}>
{t('user_details.back_to_users')}
</TextLink>
{isLoading && <DetailsSkeleton />}
{!data && error && <div>{`error occurred: ${error.body?.message ?? error.message}`}</div>}
{userId && data && (
{data && (
<>
<Card className={styles.header}>
<UserAvatar className={styles.avatar} url={data.avatar} />
@ -163,33 +150,37 @@ const UserDetails = () => {
</div>
</Card>
<TabNav>
<TabNavItem href={`/users/${userId}`}>{t('general.settings_nav')}</TabNavItem>
<TabNavItem href={`/users/${userId}/logs`}>{t('user_details.tab_logs')}</TabNavItem>
<TabNavItem href={`/users/${data.id}/${UserDetailsTabs.Settings}`}>
{t('general.settings_nav')}
</TabNavItem>
<TabNavItem href={`/users/${data.id}/${UserDetailsTabs.Logs}`}>
{t('user_details.tab_logs')}
</TabNavItem>
</TabNav>
{isLogs && <UserLogs userId={data.id} />}
{!isLogs && userFormData && (
<UserSettings
userData={data}
userFormData={userFormData}
isDeleted={isDeleted}
onUserUpdated={(user) => {
void mutate(user);
<Outlet
context={
{
user: data,
isDeleting,
onUserUpdated: (user) => {
void mutate(user);
},
} satisfies UserDetailsOutletContext
}
/>
{resetResult && (
<CreateSuccess
title="user_details.reset_password.congratulations"
username={data.username ?? '-'}
password={resetResult}
passwordLabel={t('user_details.reset_password.new_password')}
onClose={() => {
setResetResult(undefined);
}}
/>
)}
</>
)}
{data && resetResult && (
<CreateSuccess
title="user_details.reset_password.congratulations"
username={data.username ?? '-'}
password={resetResult}
passwordLabel={t('user_details.reset_password.new_password')}
onClose={() => {
setResetResult(undefined);
}}
/>
)}
</div>
);
};

View file

@ -1,3 +1,5 @@
import type { User } from '@logto/schemas';
export type UserDetailsForm = {
primaryEmail: string;
primaryPhone: string;
@ -6,3 +8,9 @@ export type UserDetailsForm = {
avatar: string;
customData: string;
};
export type UserDetailsOutletContext = {
user: User;
isDeleting: boolean;
onUserUpdated: (user?: User) => void;
};

View file

@ -17,6 +17,7 @@ import TableEmpty from '@/components/Table/TableEmpty';
import TableError from '@/components/Table/TableError';
import TableLoading from '@/components/Table/TableLoading';
import UserAvatar from '@/components/UserAvatar';
import { UserDetailsTabs } from '@/consts/page-tabs';
import type { RequestError } from '@/hooks/use-api';
import * as resourcesStyles from '@/scss/resources.module.scss';
import * as tableStyles from '@/scss/table.module.scss';
@ -30,7 +31,7 @@ const userTableColumn = 3;
const usersPathname = '/users';
const createUserPathname = `${usersPathname}/create`;
const buildDetailsPathname = (id: string) => `${usersPathname}/${id}`;
const buildDetailsPathname = (id: string) => `${usersPathname}/${id}/${UserDetailsTabs.Settings}`;
const Users = () => {
const { pathname } = useLocation();