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

feat(console): add org members

This commit is contained in:
Gao Sun 2023-10-23 15:05:49 +08:00
parent 0db5e9f1ce
commit 658f5f1423
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
15 changed files with 549 additions and 2 deletions

View file

@ -0,0 +1,19 @@
@use '@/scss/underscore' as _;
.icon {
width: 20px;
height: 20px;
border-radius: 6px;
}
.title {
flex: 1 1 0;
font: var(--font-body-2);
@include _.text-ellipsis;
margin-left: _.unit(2);
max-width: fit-content;
}
.suspended {
margin-left: _.unit(1);
}

View file

@ -0,0 +1,36 @@
import { type Application, type User } from '@logto/schemas';
import ApplicationIcon from '@/components/ApplicationIcon';
import UserAvatar from '@/components/UserAvatar';
import SuspendedTag from '@/pages/Users/components/SuspendedTag';
import { getUserTitle } from '@/utils/user';
import * as styles from './index.module.scss';
type UserItemProps = {
entity: User;
};
export function UserItem({ entity }: UserItemProps) {
return (
<>
<UserAvatar hasTooltip user={entity} size="micro" />
<div className={styles.title}>{getUserTitle(entity)}</div>
{entity.isSuspended && <SuspendedTag className={styles.suspended} />}
</>
);
}
type ApplicationItemProps = {
entity: Application;
};
// eslint-disable-next-line import/no-unused-modules -- will use in the next pull request
export function ApplicationItem({ entity }: ApplicationItemProps) {
return (
<>
<ApplicationIcon type={entity.type} className={styles.icon} />
<div className={styles.title}>{entity.name}</div>
</>
);
}

View file

@ -0,0 +1,7 @@
.search {
width: 100%;
}
.icon {
color: var(--color-text-secondary);
}

View file

@ -0,0 +1,128 @@
import { type AdminConsoleKey } from '@logto/phrases';
import { conditional } from '@silverhand/essentials';
import classNames from 'classnames';
import type { ChangeEvent, ReactNode } from 'react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import useSWR from 'swr';
import Search from '@/assets/icons/search.svg';
import EmptyDataPlaceholder from '@/components/EmptyDataPlaceholder';
import { defaultPageSize } from '@/consts';
import DynamicT from '@/ds-components/DynamicT';
import Pagination from '@/ds-components/Pagination';
import TextInput from '@/ds-components/TextInput';
import type { RequestError } from '@/hooks/use-api';
import useDebounce from '@/hooks/use-debounce';
import * as transferLayout from '@/scss/transfer.module.scss';
import { type Identifiable } from '@/types/general';
import { buildUrl, formatSearchKeyword } from '@/utils/url';
import SourceEntityItem from '../SourceEntityItem';
import * as styles from './index.module.scss';
type SearchProps = {
pathname: string;
parameters?: Record<string, string>;
};
export type Props<T> = {
searchProps: SearchProps;
onChange: (value: T[]) => void;
selectedEntities: T[];
emptyPlaceholder: AdminConsoleKey;
renderEntity: (entity: T) => ReactNode;
};
const pageSize = defaultPageSize;
function SourceEntitiesBox<T extends Identifiable>({
searchProps: { pathname, parameters },
selectedEntities,
onChange,
emptyPlaceholder,
renderEntity,
}: Props<T>) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const [page, setPage] = useState(1);
const [keyword, setKeyword] = useState('');
const debounce = useDebounce();
const commonSearchParams = {
page: String(page),
page_size: String(pageSize),
...conditional(keyword && { search: formatSearchKeyword(keyword) }),
};
const { data, error } = useSWR<[T[], number], RequestError>(
buildUrl(pathname, { ...parameters, ...commonSearchParams })
);
const isLoading = !data && !error;
const [dataSource = [], totalCount] = data ?? [];
const handleSearchInput = (event: ChangeEvent<HTMLInputElement>) => {
debounce(() => {
setPage(1);
setKeyword(event.target.value);
});
};
const isEntityAdded = (entity: T) =>
selectedEntities.findIndex(({ id }) => id === entity.id) >= 0;
const isEmpty = !isLoading && !error && dataSource.length === 0;
return (
<div className={transferLayout.box}>
<div className={transferLayout.boxTopBar}>
<TextInput
className={styles.search}
icon={<Search className={styles.icon} />}
placeholder={t('general.search_placeholder')}
onChange={handleSearchInput}
/>
</div>
<div
className={classNames(transferLayout.boxContent, isEmpty && transferLayout.emptyBoxContent)}
>
{isEmpty ? (
<EmptyDataPlaceholder size="small" title={<DynamicT forKey={emptyPlaceholder} />} />
) : (
dataSource.map((entity) => {
const isSelected = isEntityAdded(entity);
return (
<SourceEntityItem
key={entity.id}
entity={entity}
isSelected={isSelected}
render={renderEntity}
onSelect={() => {
onChange(
isSelected
? selectedEntities.filter(({ id }) => entity.id !== id)
: [entity, ...selectedEntities]
);
}}
/>
);
})
)}
</div>
<Pagination
mode="pico"
page={page}
totalCount={totalCount}
pageSize={pageSize}
className={transferLayout.boxPagination}
onChange={(page) => {
setPage(page);
}}
/>
</div>
);
}
export default SourceEntitiesBox;

View file

@ -0,0 +1,31 @@
@use '@/scss/underscore' as _;
.item {
display: flex;
align-items: center;
padding: _.unit(2.5) _.unit(4);
cursor: pointer;
user-select: none;
.icon {
width: 20px;
height: 20px;
border-radius: 6px;
}
.title {
flex: 1 1 0;
font: var(--font-body-2);
@include _.text-ellipsis;
margin-left: _.unit(2);
max-width: fit-content;
}
.suspended {
margin-left: _.unit(1);
}
&:hover {
background: var(--color-hover);
}
}

View file

@ -0,0 +1,45 @@
import { type ReactNode } from 'react';
import Checkbox from '@/ds-components/Checkbox';
import { type Identifiable } from '@/types/general';
import { onKeyDownHandler } from '@/utils/a11y';
import * as styles from './index.module.scss';
type Props<T> = {
entity: T;
isSelected: boolean;
onSelect: () => void;
render: (entity: T) => ReactNode;
};
function SourceEntityItem<T extends Identifiable>({
entity,
isSelected,
onSelect,
render,
}: Props<T>) {
return (
<div
role="button"
tabIndex={0}
className={styles.item}
onKeyDown={onKeyDownHandler(() => {
onSelect();
})}
onClick={() => {
onSelect();
}}
>
<Checkbox
checked={isSelected}
onChange={() => {
onSelect();
}}
/>
{render(entity)}
</div>
);
}
export default SourceEntityItem;

View file

@ -0,0 +1,3 @@
.added {
font: var(--font-label-2);
}

View file

@ -0,0 +1,47 @@
import { useTranslation } from 'react-i18next';
import * as transferLayout from '@/scss/transfer.module.scss';
import { type Identifiable } from '@/types/general';
import TargetEntityItem from '../TargetEntityItem';
import * as styles from './index.module.scss';
type Props<T> = {
renderEntity: (entity: T) => React.ReactNode;
selectedEntities: T[];
onChange: (value: T[]) => void;
};
function TargetEntitiesBox<T extends Identifiable>({
renderEntity,
selectedEntities,
onChange,
}: Props<T>) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
return (
<div className={transferLayout.box}>
<div className={transferLayout.boxTopBar}>
<span className={styles.added}>
{`${selectedEntities.length} `}
{t('general.added')}
</span>
</div>
<div className={transferLayout.boxContent}>
{selectedEntities.map((entity) => (
<TargetEntityItem
key={entity.id}
entity={entity}
render={renderEntity}
onDelete={() => {
onChange(selectedEntities.filter(({ id }) => id !== entity.id));
}}
/>
))}
</div>
</div>
);
}
export default TargetEntitiesBox;

View file

@ -0,0 +1,37 @@
@use '@/scss/underscore' as _;
.item {
display: flex;
align-items: center;
padding: _.unit(2) _.unit(3) _.unit(2) _.unit(4);
user-select: none;
.meta {
flex: 1;
display: flex;
align-items: center;
overflow: hidden;
.icon {
width: 20px;
height: 20px;
border-radius: 6px;
}
.title {
flex: 1 1 0;
font: var(--font-body-2);
@include _.text-ellipsis;
margin-left: _.unit(2);
max-width: fit-content;
}
.suspended {
margin: 0 _.unit(1);
}
}
&:hover {
background: var(--color-hover);
}
}

View file

@ -0,0 +1,31 @@
import { type ReactNode } from 'react';
import Close from '@/assets/icons/close.svg';
import IconButton from '@/ds-components/IconButton';
import { type Identifiable } from '@/types/general';
import * as styles from './index.module.scss';
type Props<T> = {
entity: T;
render: (entity: T) => ReactNode;
onDelete: () => void;
};
function TargetEntityItem<T extends Identifiable>({ entity, render, onDelete }: Props<T>) {
return (
<div className={styles.item}>
<div className={styles.meta}>{render(entity)}</div>
<IconButton
size="small"
onClick={() => {
onDelete();
}}
>
<Close />
</IconButton>
</div>
);
}
export default TargetEntityItem;

View file

@ -0,0 +1,5 @@
@use '@/scss/underscore' as _;
.rolesTransfer {
height: 360px;
}

View file

@ -0,0 +1,20 @@
import classNames from 'classnames';
import * as transferLayout from '@/scss/transfer.module.scss';
import { type Identifiable } from '@/types/general';
import SourceEntitiesBox, { type Props as SourceProps } from './components/SourceEntitiesBox';
import TargetEntitiesBox from './components/TargetEntitiesBox';
import * as styles from './index.module.scss';
function EntitiesTransfer<T extends Identifiable>(props: SourceProps<T>) {
return (
<div className={classNames(transferLayout.container, styles.rolesTransfer)}>
<SourceEntitiesBox {...props} />
<div className={transferLayout.verticalBar} />
<TargetEntitiesBox {...props} />
</div>
);
}
export default EntitiesTransfer;

View file

@ -0,0 +1,136 @@
import { type User, type Organization } from '@logto/schemas';
import { useEffect, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import ReactModal from 'react-modal';
import EntitiesTransfer from '@/components/EntitiesTransfer';
import { UserItem } from '@/components/EntitiesTransfer/components/EntityItem';
import OrganizationRolesSelect from '@/components/OrganizationRolesSelect';
import Button from '@/ds-components/Button';
import DangerousRaw from '@/ds-components/DangerousRaw';
import FormField from '@/ds-components/FormField';
import ModalLayout from '@/ds-components/ModalLayout';
import { type Option } from '@/ds-components/Select/MultiSelect';
import useActionTranslation from '@/hooks/use-action-translation';
import useApi from '@/hooks/use-api';
import * as modalStyles from '@/scss/modal.module.scss';
import { trySubmitSafe } from '@/utils/form';
type Props = {
organization: Organization;
isOpen: boolean;
onClose: () => void;
};
function AddMembersToOrganization({ organization, isOpen, onClose }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const tAction = useActionTranslation();
const api = useApi();
const {
reset,
control,
handleSubmit,
formState: { errors }, // TODO: handle errors
} = useForm<{ users: User[]; scopes: Array<Option<string>> }>({
defaultValues: { users: [], scopes: [] },
});
const [isLoading, setIsLoading] = useState(false);
const [keyword, setKeyword] = useState('');
const onSubmit = handleSubmit(
trySubmitSafe(async (data) => {
setIsLoading(true);
try {
await api.post(`api/organizations/${organization.id}/users`, {
json: {
userIds: data.users.map(({ id }) => id),
},
});
await api.post(`api/organizations/${organization.id}/users/roles`, {
json: {
userIds: data.users.map(({ id }) => id),
roleIds: data.scopes.map(({ value }) => value),
},
});
onClose();
} finally {
setIsLoading(false);
}
})
);
useEffect(() => {
if (isOpen) {
reset();
setKeyword('');
}
}, [isOpen, reset]);
return (
<ReactModal
isOpen={isOpen}
className={modalStyles.content}
overlayClassName={modalStyles.overlay}
onRequestClose={onClose}
>
<ModalLayout
title={
<DangerousRaw>
{t('organization_details.add_members_to_organization', {
name: organization.name,
})}
</DangerousRaw>
}
subtitle="organization_details.add_members_to_organization_description"
footer={
<Button
isLoading={isLoading}
size="large"
type="primary"
title={<>{tAction('add', 'organization_details.member_other')}</>}
onClick={onSubmit}
/>
}
onClose={onClose}
>
<FormField title="organization_details.member_other">
<Controller
name="users"
control={control}
render={({ field: { onChange, value } }) => (
<EntitiesTransfer
searchProps={{
pathname: 'api/users',
parameters: {
excludeOrganizationId: organization.id,
},
}}
selectedEntities={value}
emptyPlaceholder="errors.email_pattern_error"
renderEntity={(entity) => <UserItem entity={entity} />}
onChange={onChange}
/>
)}
/>
</FormField>
<FormField title="organization_details.add_with_organization_role">
<Controller
name="scopes"
control={control}
render={({ field: { onChange, value } }) => (
<OrganizationRolesSelect
keyword={keyword}
setKeyword={setKeyword}
value={value}
onChange={onChange}
/>
)}
/>
</FormField>
</ModalLayout>
</ReactModal>
);
}
export default AddMembersToOrganization;

View file

@ -16,6 +16,7 @@ import useActionTranslation from '@/hooks/use-action-translation';
import useApi, { type RequestError } from '@/hooks/use-api';
import { buildUrl } from '@/utils/url';
import AddMembersToOrganization from './AddMembersToOrganization';
import EditOrganizationRolesModal from './EditOrganizationRolesModal';
import * as styles from './index.module.scss';
@ -140,14 +141,14 @@ function Members({ organization }: Props) {
}}
/>
)}
{/* <AddMembersToOrganization
<AddMembersToOrganization
organization={organization}
isOpen={isModalOpen}
onClose={() => {
setIsModalOpen(false);
void mutate();
}}
/> */}
/>
</>
);
}

View file

@ -0,0 +1 @@
export type Identifiable = { id: string };