mirror of
https://github.com/logto-io/logto.git
synced 2025-03-31 22:51:25 -05:00
feat(console): user details settings (#403)
This commit is contained in:
parent
04a82496c7
commit
c4a0299b1c
8 changed files with 211 additions and 5 deletions
|
@ -17,6 +17,7 @@
|
|||
|
||||
.main {
|
||||
flex-grow: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -68,3 +68,27 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.container .body {
|
||||
> :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.form {
|
||||
margin-top: _.unit(8);
|
||||
}
|
||||
|
||||
.fields {
|
||||
padding-bottom: _.unit(16);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.textField {
|
||||
@include _.form-text-field;
|
||||
}
|
||||
|
||||
.submit {
|
||||
margin-top: _.unit(6);
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import { User } from '@logto/schemas';
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useController, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ReactModal from 'react-modal';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
@ -7,36 +9,103 @@ import useSWR from 'swr';
|
|||
|
||||
import ActionMenu, { ActionMenuItem } from '@/components/ActionMenu';
|
||||
import BackLink from '@/components/BackLink';
|
||||
import Button from '@/components/Button';
|
||||
import Card from '@/components/Card';
|
||||
import CodeEditor from '@/components/CodeEditor';
|
||||
import CopyToClipboard from '@/components/CopyToClipboard';
|
||||
import FormField from '@/components/FormField';
|
||||
import ImagePlaceholder from '@/components/ImagePlaceholder';
|
||||
import { RequestError } from '@/hooks/use-api';
|
||||
import TabNav, { TabNavLink } from '@/components/TabNav';
|
||||
import TextInput from '@/components/TextInput';
|
||||
import useApi, { RequestError } from '@/hooks/use-api';
|
||||
import Delete from '@/icons/Delete';
|
||||
import More from '@/icons/More';
|
||||
import Reset from '@/icons/Reset';
|
||||
import * as modalStyles from '@/scss/modal.module.scss';
|
||||
import { safeParseJson } from '@/utilities/json';
|
||||
|
||||
import CreateSuccess from './components/CreateSuccess';
|
||||
import DeleteForm from './components/DeleteForm';
|
||||
import ResetPasswordForm from './components/ResetPasswordForm';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type FormData = {
|
||||
primaryEmail: string;
|
||||
primaryPhone: string;
|
||||
username: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
roles: string;
|
||||
customData: string;
|
||||
};
|
||||
|
||||
const UserDetails = () => {
|
||||
const { id } = useParams();
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const [isDeleteFormOpen, setIsDeleteFormOpen] = useState(false);
|
||||
const [isResetPasswordFormOpen, setIsResetPasswordFormOpen] = useState(false);
|
||||
|
||||
const { data, error } = useSWR<User, RequestError>(id && `/api/users/${id}`);
|
||||
const { data, error, mutate } = useSWR<User, RequestError>(id && `/api/users/${id}`);
|
||||
const isLoading = !data && !error;
|
||||
|
||||
const { handleSubmit, register, control, reset } = useForm<FormData>();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const {
|
||||
field: { onChange, value },
|
||||
} = useController({ name: 'customData', control, rules: { required: true } });
|
||||
const api = useApi();
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
reset({
|
||||
primaryEmail: data.primaryEmail ?? '',
|
||||
primaryPhone: data.primaryPhone ?? '',
|
||||
username: data.username ?? '',
|
||||
name: data.name ?? '',
|
||||
avatar: data.avatar ?? '',
|
||||
roles: data.roleNames.join(','),
|
||||
customData: JSON.stringify(data.customData, null, 2),
|
||||
});
|
||||
}, [data, reset]);
|
||||
|
||||
const onSubmit = handleSubmit(async (formData) => {
|
||||
if (!data || submitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
const customData = safeParseJson(formData.customData);
|
||||
|
||||
if (!customData) {
|
||||
toast.error(t('user_details.custom_data_invalid'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: Partial<User> = {
|
||||
name: formData.name,
|
||||
avatar: formData.avatar,
|
||||
customData,
|
||||
};
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
const updatedUser = await api.patch(`/api/users/${data.id}`, { json: payload }).json<User>();
|
||||
void mutate(updatedUser);
|
||||
toast.success(t('user_details.saved'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<BackLink to="/users">{t('user_details.back_to_users')}</BackLink>
|
||||
|
||||
{isLoading && <div>loading</div>}
|
||||
{error && <div>{`error occurred: ${error.metadata.code}`}</div>}
|
||||
{data && (
|
||||
{id && data && (
|
||||
<>
|
||||
<Card className={styles.header}>
|
||||
<ImagePlaceholder size={76} borderRadius={16} />
|
||||
|
@ -95,7 +164,68 @@ const UserDetails = () => {
|
|||
</ReactModal>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>TBD</Card>
|
||||
<Card className={styles.body}>
|
||||
<TabNav>
|
||||
<TabNavLink href={`/users/${id}`}>{t('user_details.tab_settings')}</TabNavLink>
|
||||
<TabNavLink href={`/users/${id}/logs`}>{t('user_details.tab_logs')}</TabNavLink>
|
||||
</TabNav>
|
||||
<form className={styles.form} onSubmit={onSubmit}>
|
||||
<div className={styles.fields}>
|
||||
<FormField
|
||||
title="admin_console.user_details.field_email"
|
||||
className={styles.textField}
|
||||
>
|
||||
<TextInput readOnly {...register('primaryEmail')} />
|
||||
</FormField>
|
||||
<FormField
|
||||
title="admin_console.user_details.field_phone"
|
||||
className={styles.textField}
|
||||
>
|
||||
<TextInput readOnly {...register('primaryPhone')} />
|
||||
</FormField>
|
||||
<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')} />
|
||||
</FormField>
|
||||
<FormField
|
||||
title="admin_console.user_details.field_roles"
|
||||
className={styles.textField}
|
||||
>
|
||||
<TextInput readOnly {...register('roles')} />
|
||||
</FormField>
|
||||
<FormField
|
||||
isRequired
|
||||
title="admin_console.user_details.field_custom_data"
|
||||
className={styles.textField}
|
||||
>
|
||||
<CodeEditor height="200px" language="json" value={value} onChange={onChange} />
|
||||
</FormField>
|
||||
</div>
|
||||
<div className={styles.submit}>
|
||||
<Button
|
||||
disabled={submitting}
|
||||
htmlType="submit"
|
||||
type="primary"
|
||||
title="admin_console.user_details.save_changes"
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
{data && <CreateSuccess username={data.username ?? '-'} />}
|
||||
|
|
9
packages/console/src/utilities/json.ts
Normal file
9
packages/console/src/utilities/json.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
import { ArbitraryObject } from '@logto/schemas';
|
||||
|
||||
export const safeParseJson = <T extends ArbitraryObject = ArbitraryObject>(
|
||||
value: string
|
||||
): T | undefined => {
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch {}
|
||||
};
|
|
@ -167,6 +167,15 @@ describe('adminUserRoutes', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('PATCH /users/:userId should call clearUserCustomDataById if customData is present', async () => {
|
||||
const updateNameResponse = await userRequest.patch('/users/foo').send({ customData: {} });
|
||||
expect(updateNameResponse.status).toEqual(200);
|
||||
expect(updateNameResponse.body).toEqual({
|
||||
...mockUserResponse,
|
||||
});
|
||||
expect(clearUserCustomDataById).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('PATCH /users/:userId should updated with one field if the other is undefined', async () => {
|
||||
const name = 'Micheal';
|
||||
|
||||
|
|
|
@ -111,6 +111,7 @@ export default function adminUserRoutes<T extends AuthedRouter>(router: T) {
|
|||
body: object({
|
||||
name: string().regex(nameRegEx).optional(),
|
||||
avatar: string().url().optional(),
|
||||
customData: arbitraryObjectGuard.optional(),
|
||||
}),
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
|
@ -121,6 +122,12 @@ export default function adminUserRoutes<T extends AuthedRouter>(router: T) {
|
|||
|
||||
await findUserById(userId);
|
||||
|
||||
// Clear customData to achieve full replacement,
|
||||
// to partial update, call patch /users/:userId/customData
|
||||
if (body.customData) {
|
||||
await clearUserCustomDataById(userId);
|
||||
}
|
||||
|
||||
const user = await updateUserById(userId, {
|
||||
...body,
|
||||
});
|
||||
|
|
|
@ -214,6 +214,19 @@ const translation = {
|
|||
label: 'New password:',
|
||||
reset_password: 'Reset password',
|
||||
},
|
||||
tab_settings: 'Settings',
|
||||
tab_logs: 'User Logs',
|
||||
field_email: 'Primary Email',
|
||||
field_phone: 'Primary Phone',
|
||||
field_username: 'Username',
|
||||
field_name: 'Name',
|
||||
field_avatar: 'Avatar image URL',
|
||||
field_roles: 'Roles',
|
||||
field_custom_data: 'Custom data',
|
||||
field_connectors: 'Social Connectors',
|
||||
custom_data_invalid: 'Custom data must be a valid JSON',
|
||||
save_changes: 'Save changes',
|
||||
saved: 'Saved!',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -213,6 +213,19 @@ const translation = {
|
|||
label: '新密码:',
|
||||
reset_password: '重置密码',
|
||||
},
|
||||
tab_settings: '设置',
|
||||
tab_logs: '用户日志',
|
||||
field_email: '首选邮箱',
|
||||
field_phone: '首选手机号码',
|
||||
field_username: '用户名',
|
||||
field_name: '名称',
|
||||
field_avatar: '头像图片链接',
|
||||
field_roles: '角色',
|
||||
field_custom_data: '自定义数据',
|
||||
field_connectors: '社交账号',
|
||||
custom_data_invalid: '自定义数据必须是有效的 JSON',
|
||||
save_changes: '保存设置',
|
||||
saved: '保存成功!',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
Loading…
Add table
Reference in a new issue