diff --git a/packages/console/src/components/EntitiesTransfer/components/EntityItem/index.module.scss b/packages/console/src/components/EntitiesTransfer/components/EntityItem/index.module.scss new file mode 100644 index 000000000..78c459c72 --- /dev/null +++ b/packages/console/src/components/EntitiesTransfer/components/EntityItem/index.module.scss @@ -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); +} diff --git a/packages/console/src/components/EntitiesTransfer/components/EntityItem/index.tsx b/packages/console/src/components/EntitiesTransfer/components/EntityItem/index.tsx new file mode 100644 index 000000000..ed4221c83 --- /dev/null +++ b/packages/console/src/components/EntitiesTransfer/components/EntityItem/index.tsx @@ -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 ( + <> + +
{getUserTitle(entity)}
+ {entity.isSuspended && } + + ); +} + +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 ( + <> + +
{entity.name}
+ + ); +} diff --git a/packages/console/src/components/EntitiesTransfer/components/SourceEntitiesBox/index.module.scss b/packages/console/src/components/EntitiesTransfer/components/SourceEntitiesBox/index.module.scss new file mode 100644 index 000000000..b6084458b --- /dev/null +++ b/packages/console/src/components/EntitiesTransfer/components/SourceEntitiesBox/index.module.scss @@ -0,0 +1,7 @@ +.search { + width: 100%; +} + +.icon { + color: var(--color-text-secondary); +} diff --git a/packages/console/src/components/EntitiesTransfer/components/SourceEntitiesBox/index.tsx b/packages/console/src/components/EntitiesTransfer/components/SourceEntitiesBox/index.tsx new file mode 100644 index 000000000..437e0cac8 --- /dev/null +++ b/packages/console/src/components/EntitiesTransfer/components/SourceEntitiesBox/index.tsx @@ -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; +}; + +export type Props = { + searchProps: SearchProps; + onChange: (value: T[]) => void; + selectedEntities: T[]; + emptyPlaceholder: AdminConsoleKey; + renderEntity: (entity: T) => ReactNode; +}; + +const pageSize = defaultPageSize; + +function SourceEntitiesBox({ + searchProps: { pathname, parameters }, + selectedEntities, + onChange, + emptyPlaceholder, + renderEntity, +}: Props) { + 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) => { + 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 ( +
+
+ } + placeholder={t('general.search_placeholder')} + onChange={handleSearchInput} + /> +
+
+ {isEmpty ? ( + } /> + ) : ( + dataSource.map((entity) => { + const isSelected = isEntityAdded(entity); + + return ( + { + onChange( + isSelected + ? selectedEntities.filter(({ id }) => entity.id !== id) + : [entity, ...selectedEntities] + ); + }} + /> + ); + }) + )} +
+ { + setPage(page); + }} + /> +
+ ); +} +export default SourceEntitiesBox; diff --git a/packages/console/src/components/EntitiesTransfer/components/SourceEntityItem/index.module.scss b/packages/console/src/components/EntitiesTransfer/components/SourceEntityItem/index.module.scss new file mode 100644 index 000000000..1f4ccdfe2 --- /dev/null +++ b/packages/console/src/components/EntitiesTransfer/components/SourceEntityItem/index.module.scss @@ -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); + } +} diff --git a/packages/console/src/components/EntitiesTransfer/components/SourceEntityItem/index.tsx b/packages/console/src/components/EntitiesTransfer/components/SourceEntityItem/index.tsx new file mode 100644 index 000000000..c730cdaca --- /dev/null +++ b/packages/console/src/components/EntitiesTransfer/components/SourceEntityItem/index.tsx @@ -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 = { + entity: T; + isSelected: boolean; + onSelect: () => void; + render: (entity: T) => ReactNode; +}; + +function SourceEntityItem({ + entity, + isSelected, + onSelect, + render, +}: Props) { + return ( +
{ + onSelect(); + })} + onClick={() => { + onSelect(); + }} + > + { + onSelect(); + }} + /> + {render(entity)} +
+ ); +} + +export default SourceEntityItem; diff --git a/packages/console/src/components/EntitiesTransfer/components/TargetEntitiesBox/index.module.scss b/packages/console/src/components/EntitiesTransfer/components/TargetEntitiesBox/index.module.scss new file mode 100644 index 000000000..f4233af9b --- /dev/null +++ b/packages/console/src/components/EntitiesTransfer/components/TargetEntitiesBox/index.module.scss @@ -0,0 +1,3 @@ +.added { + font: var(--font-label-2); +} diff --git a/packages/console/src/components/EntitiesTransfer/components/TargetEntitiesBox/index.tsx b/packages/console/src/components/EntitiesTransfer/components/TargetEntitiesBox/index.tsx new file mode 100644 index 000000000..3bdb35418 --- /dev/null +++ b/packages/console/src/components/EntitiesTransfer/components/TargetEntitiesBox/index.tsx @@ -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 = { + renderEntity: (entity: T) => React.ReactNode; + selectedEntities: T[]; + onChange: (value: T[]) => void; +}; + +function TargetEntitiesBox({ + renderEntity, + selectedEntities, + onChange, +}: Props) { + const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); + + return ( +
+
+ + {`${selectedEntities.length} `} + {t('general.added')} + +
+
+ {selectedEntities.map((entity) => ( + { + onChange(selectedEntities.filter(({ id }) => id !== entity.id)); + }} + /> + ))} +
+
+ ); +} + +export default TargetEntitiesBox; diff --git a/packages/console/src/components/EntitiesTransfer/components/TargetEntityItem/index.module.scss b/packages/console/src/components/EntitiesTransfer/components/TargetEntityItem/index.module.scss new file mode 100644 index 000000000..0c01be48e --- /dev/null +++ b/packages/console/src/components/EntitiesTransfer/components/TargetEntityItem/index.module.scss @@ -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); + } +} diff --git a/packages/console/src/components/EntitiesTransfer/components/TargetEntityItem/index.tsx b/packages/console/src/components/EntitiesTransfer/components/TargetEntityItem/index.tsx new file mode 100644 index 000000000..dcacdf2bc --- /dev/null +++ b/packages/console/src/components/EntitiesTransfer/components/TargetEntityItem/index.tsx @@ -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 = { + entity: T; + render: (entity: T) => ReactNode; + onDelete: () => void; +}; + +function TargetEntityItem({ entity, render, onDelete }: Props) { + return ( +
+
{render(entity)}
+ { + onDelete(); + }} + > + + +
+ ); +} + +export default TargetEntityItem; diff --git a/packages/console/src/components/EntitiesTransfer/index.module.scss b/packages/console/src/components/EntitiesTransfer/index.module.scss new file mode 100644 index 000000000..7fe601a01 --- /dev/null +++ b/packages/console/src/components/EntitiesTransfer/index.module.scss @@ -0,0 +1,5 @@ +@use '@/scss/underscore' as _; + +.rolesTransfer { + height: 360px; +} diff --git a/packages/console/src/components/EntitiesTransfer/index.tsx b/packages/console/src/components/EntitiesTransfer/index.tsx new file mode 100644 index 000000000..aa29c200b --- /dev/null +++ b/packages/console/src/components/EntitiesTransfer/index.tsx @@ -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(props: SourceProps) { + return ( +
+ +
+ +
+ ); +} + +export default EntitiesTransfer; diff --git a/packages/console/src/pages/OrganizationDetails/Members/AddMembersToOrganization.tsx b/packages/console/src/pages/OrganizationDetails/Members/AddMembersToOrganization.tsx new file mode 100644 index 000000000..b3b7f471c --- /dev/null +++ b/packages/console/src/pages/OrganizationDetails/Members/AddMembersToOrganization.tsx @@ -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> }>({ + 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 ( + + + {t('organization_details.add_members_to_organization', { + name: organization.name, + })} + + } + subtitle="organization_details.add_members_to_organization_description" + footer={ +