0
Fork 0
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:
Wang Sijie 2022-03-21 11:48:27 +08:00 committed by GitHub
parent 04a82496c7
commit c4a0299b1c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 211 additions and 5 deletions

View file

@ -17,6 +17,7 @@
.main {
flex-grow: 1;
overflow-y: auto;
}
}

View file

@ -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;
}
}

View file

@ -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 ?? '-'} />}

View 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 {}
};

View file

@ -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';

View file

@ -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,
});

View file

@ -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!',
},
},
};

View file

@ -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: '保存成功!',
},
},
};