mirror of
https://github.com/logto-io/logto.git
synced 2025-04-07 23:01:25 -05:00
Merge pull request #383 from logto-io/sijie--log-1705-user-detail-header
feat(console): user details header
This commit is contained in:
commit
bff4a9860d
10 changed files with 302 additions and 16 deletions
|
@ -16,6 +16,7 @@ import Callback from './pages/Callback';
|
|||
import ConnectorDetails from './pages/ConnectorDetails';
|
||||
import Connectors from './pages/Connectors';
|
||||
import GetStarted from './pages/GetStarted';
|
||||
import UserDetails from './pages/UserDetails';
|
||||
import Users from './pages/Users';
|
||||
import { fetcher } from './swr';
|
||||
|
||||
|
@ -60,6 +61,7 @@ const Main = () => {
|
|||
</Route>
|
||||
<Route path="users">
|
||||
<Route index element={<Users />} />
|
||||
<Route path=":id" element={<UserDetails />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
|
|
16
packages/console/src/icons/Eye.tsx
Normal file
16
packages/console/src/icons/Eye.tsx
Normal file
|
@ -0,0 +1,16 @@
|
|||
import React, { SVGProps } from 'react';
|
||||
|
||||
const Eye = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg width="20" height="14" viewBox="0 0 20 14" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path
|
||||
d="M13 7C13 8.65685 11.6569 10 10 10C8.34315 10 7 8.65685 7 7C7 5.34315 8.34315 4 10 4C11.6569 4 13 5.34315 13 7Z"
|
||||
fill="#747778"
|
||||
/>
|
||||
<path
|
||||
d="M19.8944 6.55279C17.7362 2.23635 13.9031 0 10 0C6.09687 0 2.26379 2.23635 0.105573 6.55279C-0.0351909 6.83431 -0.0351909 7.16569 0.105573 7.44721C2.26379 11.7637 6.09687 14 10 14C13.9031 14 17.7362 11.7637 19.8944 7.44721C20.0352 7.16569 20.0352 6.83431 19.8944 6.55279ZM10 12C7.03121 12 3.99806 10.3792 2.12966 7C3.99806 3.62078 7.03121 2 10 2C12.9688 2 16.0019 3.62078 17.8703 7C16.0019 10.3792 12.9688 12 10 12Z"
|
||||
fill="#747778"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default Eye;
|
|
@ -0,0 +1,51 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.card {
|
||||
min-width: _.unit(100);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font: var(--font-title-large);
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.body {
|
||||
font: var(--font-body-2);
|
||||
|
||||
.info {
|
||||
margin-top: _.unit(6);
|
||||
background: var(--color-neutral-variant-90);
|
||||
padding: _.unit(5);
|
||||
border-radius: _.unit(2);
|
||||
|
||||
.infoLine {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: _.unit(2);
|
||||
}
|
||||
|
||||
.infoContent {
|
||||
font-weight: bold;
|
||||
padding-left: _.unit(1);
|
||||
}
|
||||
|
||||
.operation {
|
||||
padding-left: _.unit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
border-top: 1px solid var(--color-neutral-80);
|
||||
margin-top: _.unit(6);
|
||||
padding-top: _.unit(6);
|
||||
display: flex;
|
||||
justify-content: right;
|
||||
|
||||
button:not(:last-child) {
|
||||
margin-right: _.unit(2);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,86 @@
|
|||
import React, { useState } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ReactModal from 'react-modal';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
import Button from '@/components/Button';
|
||||
import Card from '@/components/Card';
|
||||
import Eye from '@/icons/Eye';
|
||||
import * as modalStyles from '@/scss/modal.module.scss';
|
||||
|
||||
import * as styles from './CreateSuccess.module.scss';
|
||||
|
||||
type Props = {
|
||||
username: string;
|
||||
};
|
||||
|
||||
const CreateSuccess = ({ username }: Props) => {
|
||||
const [searchParameters, setSearchParameters] = useSearchParams();
|
||||
const [passwordVisible, setPasswordVisible] = useState(false);
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
|
||||
const passwordEncoded = searchParameters.get('password');
|
||||
const password = passwordEncoded && atob(passwordEncoded);
|
||||
|
||||
const handleClose = () => {
|
||||
setSearchParameters({});
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!password) {
|
||||
return null;
|
||||
}
|
||||
await navigator.clipboard.writeText(
|
||||
`User username: ${username}\nInitial password: ${password}`
|
||||
);
|
||||
toast.success(t('copy.copied'));
|
||||
};
|
||||
|
||||
if (!password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ReactModal isOpen className={modalStyles.content} overlayClassName={modalStyles.overlay}>
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.header}>
|
||||
<h1>{t('user_details.created_title')}</h1>
|
||||
</div>
|
||||
<div className={styles.body}>
|
||||
<div>{t('user_details.created_guide')}</div>
|
||||
<div className={styles.info}>
|
||||
<div className={styles.infoLine}>
|
||||
<div>{t('user_details.created_username')}</div>
|
||||
<div className={styles.infoContent}>{username}</div>
|
||||
</div>
|
||||
<div className={styles.infoLine}>
|
||||
<div>{t('user_details.created_password')}</div>
|
||||
<div className={styles.infoContent}>
|
||||
{passwordVisible ? password : password.replace(/./g, '*')}
|
||||
</div>
|
||||
<div className={styles.operation}>
|
||||
{/* TODO: Replaced into IconButton(LOG-1890) */}
|
||||
<Eye
|
||||
onClick={() => {
|
||||
setPasswordVisible((previous) => !previous);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.footer}>
|
||||
<Button title="admin_console.user_details.created_button_close" onClick={handleClose} />
|
||||
<Button
|
||||
type="primary"
|
||||
title="admin_console.user_details.created_button_copy"
|
||||
onClick={handleCopy}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</ReactModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateSuccess;
|
70
packages/console/src/pages/UserDetails/index.module.scss
Normal file
70
packages/console/src/pages/UserDetails/index.module.scss
Normal file
|
@ -0,0 +1,70 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.container {
|
||||
> *:not(:first-child) {
|
||||
margin-top: _.unit(4);
|
||||
}
|
||||
}
|
||||
|
||||
.container .backButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
> *:not(:first-child) {
|
||||
margin-left: _.unit(1);
|
||||
}
|
||||
}
|
||||
|
||||
.container .header {
|
||||
padding: _.unit(8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
> *:not(:first-child) {
|
||||
margin-left: _.unit(6);
|
||||
}
|
||||
|
||||
.metadata {
|
||||
flex: 1;
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-top: _.unit(2);
|
||||
}
|
||||
|
||||
> *:not(:first-child) {
|
||||
margin-left: _.unit(2);
|
||||
}
|
||||
}
|
||||
|
||||
.name {
|
||||
font: var(--font-title-large);
|
||||
color: var(--color-component-text);
|
||||
}
|
||||
|
||||
.username {
|
||||
background-color: var(--color-neutral-90);
|
||||
padding: _.unit(0.5) _.unit(2);
|
||||
border-radius: 10px;
|
||||
font: var(--font-label-medium);
|
||||
}
|
||||
|
||||
.text {
|
||||
font: var(--font-subhead-2);
|
||||
color: var(--color-component-caption);
|
||||
}
|
||||
|
||||
.verticalBar {
|
||||
@include _.vertical-bar;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.copy {
|
||||
padding: _.unit(1) _.unit(4) _.unit(1) _.unit(2);
|
||||
}
|
||||
}
|
||||
}
|
51
packages/console/src/pages/UserDetails/index.tsx
Normal file
51
packages/console/src/pages/UserDetails/index.tsx
Normal file
|
@ -0,0 +1,51 @@
|
|||
import { User } from '@logto/schemas';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import BackLink from '@/components/BackLink';
|
||||
import Card from '@/components/Card';
|
||||
import CopyToClipboard from '@/components/CopyToClipboard';
|
||||
import ImagePlaceholder from '@/components/ImagePlaceholder';
|
||||
import { RequestError } from '@/swr';
|
||||
|
||||
import CreateSuccess from './components/CreateSuccess';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
const UserDetails = () => {
|
||||
const { id } = useParams();
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
|
||||
const { data, error } = useSWR<User, RequestError>(id && `/api/users/${id}`);
|
||||
const isLoading = !data && !error;
|
||||
|
||||
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 && (
|
||||
<>
|
||||
<Card className={styles.header}>
|
||||
<ImagePlaceholder size={76} borderRadius={16} />
|
||||
<div className={styles.metadata}>
|
||||
<div className={styles.name}>{data.name ?? '-'}</div>
|
||||
<div>
|
||||
<div className={styles.username}>{data.username}</div>
|
||||
<div className={styles.verticalBar} />
|
||||
<div className={styles.text}>User ID</div>
|
||||
<CopyToClipboard value={data.id} className={styles.copy} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>TBD</Card>
|
||||
</>
|
||||
)}
|
||||
{data && <CreateSuccess username={data.username ?? '-'} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserDetails;
|
|
@ -19,20 +19,15 @@ type FormData = {
|
|||
};
|
||||
|
||||
type Props = {
|
||||
onClose?: (createdUser?: User) => void;
|
||||
onClose?: (createdUser?: User, password?: string) => void;
|
||||
};
|
||||
|
||||
const CreateForm = ({ onClose }: Props) => {
|
||||
const { handleSubmit, register } = useForm<FormData>();
|
||||
|
||||
const onSubmit = handleSubmit(async (data) => {
|
||||
try {
|
||||
const createdUser = await api.post('/api/users', { json: data }).json<User>();
|
||||
|
||||
onClose?.(createdUser);
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
}
|
||||
const createdUser = await api.post('/api/users', { json: data }).json<User>();
|
||||
onClose?.(createdUser, btoa(data.password));
|
||||
});
|
||||
|
||||
return (
|
||||
|
@ -61,7 +56,7 @@ const CreateForm = ({ onClose }: Props) => {
|
|||
title="admin_console.users.create_form_password"
|
||||
className={styles.textField}
|
||||
>
|
||||
<TextInput {...register('name', { required: true })} />
|
||||
<TextInput {...register('password', { required: true })} />
|
||||
</FormField>
|
||||
<div className={styles.submit}>
|
||||
<Button
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import { User } from '@logto/schemas';
|
||||
import { conditional } from '@silverhand/essentials/lib/utilities/conditional.js';
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Modal from 'react-modal';
|
||||
|
@ -20,7 +19,7 @@ import * as styles from './index.module.scss';
|
|||
const Users = () => {
|
||||
const [isCreateFormOpen, setIsCreateFormOpen] = useState(false);
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const { data, error, mutate } = useSWR<User[], RequestError>('/api/users');
|
||||
const { data, error } = useSWR<User[], RequestError>('/api/users');
|
||||
const isLoading = !data && !error;
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
@ -41,13 +40,11 @@ const Users = () => {
|
|||
overlayClassName={modalStyles.overlay}
|
||||
>
|
||||
<CreateForm
|
||||
onClose={(createdUser) => {
|
||||
onClose={(createdUser, password) => {
|
||||
setIsCreateFormOpen(false);
|
||||
|
||||
if (createdUser) {
|
||||
void mutate(conditional(data && [...data, createdUser]));
|
||||
|
||||
navigate(`/applications/${createdUser.id}/get-started`);
|
||||
if (createdUser && password) {
|
||||
navigate(`/users/${createdUser.id}?password=${password}`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
|
|
@ -169,6 +169,15 @@ const translation = {
|
|||
create_form_password: 'Password',
|
||||
create_form_name: 'Full name',
|
||||
},
|
||||
user_details: {
|
||||
back_to_users: 'Back to user management',
|
||||
created_title: 'Congratulations! This user has been created.',
|
||||
created_guide: 'Now send this following information.',
|
||||
created_username: 'User username:',
|
||||
created_password: 'Initial password:',
|
||||
created_button_close: 'Close',
|
||||
created_button_copy: 'Copy',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -170,6 +170,15 @@ const translation = {
|
|||
create_form_password: '密码',
|
||||
create_form_name: '姓名',
|
||||
},
|
||||
user_details: {
|
||||
back_to_users: '返回用户管理',
|
||||
created_title: '恭喜!用户创建成功',
|
||||
created_guide: '用户信息如下',
|
||||
created_username: '用户名:',
|
||||
created_password: '初始密码:',
|
||||
created_button_close: '关闭',
|
||||
created_button_copy: '拷贝',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue