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:
parent
1654322ead
commit
158dec3c0c
16 changed files with 100 additions and 95 deletions
|
@ -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 />} />
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -14,6 +14,11 @@ export enum SignInExperiencePage {
|
|||
OthersTab = 'others',
|
||||
}
|
||||
|
||||
export enum UserDetailsTabs {
|
||||
Settings = 'settings',
|
||||
Logs = 'logs',
|
||||
}
|
||||
|
||||
export enum RoleDetailsTabs {
|
||||
Settings = 'settings',
|
||||
Permissions = 'permissions',
|
||||
|
|
|
@ -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>
|
||||
|
|
16
packages/console/src/pages/UserDetails/UserLogs/index.tsx
Normal file
16
packages/console/src/pages/UserDetails/UserLogs/index.tsx
Normal 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;
|
|
@ -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;
|
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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;
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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();
|
||||
|
|
Loading…
Add table
Reference in a new issue