diff --git a/packages/console/src/assets/icons/organization-role-feature.svg b/packages/console/src/assets/icons/organization-role-feature.svg new file mode 100644 index 00000000000..ad20408b729 --- /dev/null +++ b/packages/console/src/assets/icons/organization-role-feature.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/console/src/assets/icons/role-feature-dark.svg b/packages/console/src/assets/icons/role-feature-dark.svg new file mode 100644 index 00000000000..08f9d6a71ba --- /dev/null +++ b/packages/console/src/assets/icons/role-feature-dark.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/packages/console/src/components/ItemPreview/ApplicationPreview.tsx b/packages/console/src/components/ItemPreview/ApplicationPreview.tsx new file mode 100644 index 00000000000..e284a5894c6 --- /dev/null +++ b/packages/console/src/components/ItemPreview/ApplicationPreview.tsx @@ -0,0 +1,33 @@ +import { type Application } from '@logto/schemas'; +import { useTranslation } from 'react-i18next'; + +import ApplicationIcon from '@/components/ApplicationIcon'; +import { applicationTypeI18nKey } from '@/types/applications'; + +import ItemPreview from '.'; +import * as styles from './index.module.scss'; + +const applicationsPathname = '/applications'; +const buildDetailsPathname = (id: string) => `${applicationsPathname}/${id}`; + +type Props = { + readonly data: Pick; +}; + +function ApplicationPreview({ data: { id, name, isThirdParty, type } }: Props) { + const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); + + return ( + } + to={buildDetailsPathname(id)} + /> + ); +} +export default ApplicationPreview; diff --git a/packages/console/src/components/ItemPreview/UserPreview.tsx b/packages/console/src/components/ItemPreview/UserPreview.tsx index 716491daf80..234d4445d4f 100644 --- a/packages/console/src/components/ItemPreview/UserPreview.tsx +++ b/packages/console/src/components/ItemPreview/UserPreview.tsx @@ -12,15 +12,10 @@ type Props = { /** * A subset of User schema type that is used in the preview component. */ - readonly user: { - id: UserInfo['id']; - avatar?: UserInfo['avatar']; - name?: UserInfo['name']; - primaryEmail?: UserInfo['primaryEmail']; - primaryPhone?: UserInfo['primaryPhone']; - username?: UserInfo['username']; - isSuspended?: UserInfo['isSuspended']; - }; + readonly user: Partial< + Pick + > & + Pick; /** * Whether to provide a link to user details page. Explicitly set to `false` to hide it. */ diff --git a/packages/console/src/components/ItemPreview/index.module.scss b/packages/console/src/components/ItemPreview/index.module.scss index ee9ce986c92..292e43bf94a 100644 --- a/packages/console/src/components/ItemPreview/index.module.scss +++ b/packages/console/src/components/ItemPreview/index.module.scss @@ -62,3 +62,7 @@ } } } + +.icon { + flex-shrink: 0; +} diff --git a/packages/console/src/components/OrganizationRolesSelect/index.tsx b/packages/console/src/components/OrganizationRolesSelect/index.tsx index affea189c52..18e75e4485d 100644 --- a/packages/console/src/components/OrganizationRolesSelect/index.tsx +++ b/packages/console/src/components/OrganizationRolesSelect/index.tsx @@ -1,4 +1,4 @@ -import { type OrganizationScope } from '@logto/schemas'; +import { type OrganizationRole, type RoleType } from '@logto/schemas'; import classNames from 'classnames'; import RoleIcon from '@/assets/icons/role-feature.svg'; @@ -30,10 +30,11 @@ type Props = { readonly onChange: (value: Array>) => void; readonly keyword: string; readonly setKeyword: (keyword: string) => void; + readonly roleType: RoleType; }; -function OrganizationRolesSelect({ value, onChange, keyword, setKeyword }: Props) { - const { data: scopes, isLoading } = useSearchValues( +function OrganizationRolesSelect({ value, onChange, keyword, setKeyword, roleType }: Props) { + const { data: roles, isLoading } = useSearchValues( 'api/organization-roles', keyword ); @@ -41,7 +42,9 @@ function OrganizationRolesSelect({ value, onChange, keyword, setKeyword }: Props return ( ({ value: id, title: name }))} + options={roles + .filter(({ type }) => type === roleType) + .map(({ id, name }) => ({ value: id, title: name }))} placeholder="organizations.search_role_placeholder" isOptionsLoading={isLoading} renderOption={RoleOption} diff --git a/packages/console/src/hooks/use-console-routes/routes/organizations.tsx b/packages/console/src/hooks/use-console-routes/routes/organizations.tsx index 0af56c47ffc..cb6e5d69dfa 100644 --- a/packages/console/src/hooks/use-console-routes/routes/organizations.tsx +++ b/packages/console/src/hooks/use-console-routes/routes/organizations.tsx @@ -1,7 +1,9 @@ import { condArray } from '@silverhand/essentials'; import { Navigate, type RouteObject } from 'react-router-dom'; +import { isDevFeaturesEnabled } from '@/consts/env'; import OrganizationDetails from '@/pages/OrganizationDetails'; +import MachineToMachine from '@/pages/OrganizationDetails/MachineToMachine'; import Members from '@/pages/OrganizationDetails/Members'; import Settings from '@/pages/OrganizationDetails/Settings'; import { OrganizationDetailsTabs } from '@/pages/OrganizationDetails/types'; @@ -15,11 +17,15 @@ export const organizations: RouteObject = { { path: ':id/*', element: , - children: [ + children: condArray( { index: true, element: }, { path: OrganizationDetailsTabs.Settings, element: }, { path: OrganizationDetailsTabs.Members, element: }, - ], + isDevFeaturesEnabled && { + path: OrganizationDetailsTabs.MachineToMachine, + element: , + } + ), } ), }; diff --git a/packages/console/src/pages/Applications/index.tsx b/packages/console/src/pages/Applications/index.tsx index f063a09bba7..51ddd6312bf 100644 --- a/packages/console/src/pages/Applications/index.tsx +++ b/packages/console/src/pages/Applications/index.tsx @@ -6,10 +6,9 @@ import { useLocation } from 'react-router-dom'; import Plus from '@/assets/icons/plus.svg'; import ApplicationCreation from '@/components/ApplicationCreation'; -import ApplicationIcon from '@/components/ApplicationIcon'; import ChargeNotification from '@/components/ChargeNotification'; import { type SelectedGuide } from '@/components/Guide/GuideCard'; -import ItemPreview from '@/components/ItemPreview'; +import ApplicationPreview from '@/components/ItemPreview/ApplicationPreview'; import PageMeta from '@/components/PageMeta'; import { isCloud } from '@/consts/env'; import Button from '@/ds-components/Button'; @@ -20,7 +19,6 @@ import Table from '@/ds-components/Table'; import useApplicationsUsage from '@/hooks/use-applications-usage'; import useTenantPathname from '@/hooks/use-tenant-pathname'; import * as pageLayout from '@/scss/page-layout.module.scss'; -import { applicationTypeI18nKey } from '@/types/applications'; import { buildUrl } from '@/utils/url'; import GuideLibrary from './components/GuideLibrary'; @@ -178,24 +176,7 @@ function Applications({ tab }: Props) { title: t('applications.application_name'), dataIndex: 'name', colSpan: 6, - render: ({ id, name, type, isThirdParty }) => ( - - } - to={buildDetailsPathname(id)} - /> - ), + render: (data) => , }, { title: t('applications.app_id'), diff --git a/packages/console/src/pages/OrganizationDetails/Members/EditOrganizationRolesModal.tsx b/packages/console/src/pages/OrganizationDetails/EditOrganizationRolesModal/index.tsx similarity index 72% rename from packages/console/src/pages/OrganizationDetails/Members/EditOrganizationRolesModal.tsx rename to packages/console/src/pages/OrganizationDetails/EditOrganizationRolesModal/index.tsx index a9ae1fddc45..92835e31ed2 100644 --- a/packages/console/src/pages/OrganizationDetails/Members/EditOrganizationRolesModal.tsx +++ b/packages/console/src/pages/OrganizationDetails/EditOrganizationRolesModal/index.tsx @@ -1,4 +1,5 @@ -import { type UserWithOrganizationRoles } from '@logto/schemas'; +import { RoleType, type OrganizationRoleEntity } from '@logto/schemas'; +import { type Nullable } from '@silverhand/essentials'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import ReactModal from 'react-modal'; @@ -12,27 +13,39 @@ import useApi from '@/hooks/use-api'; import * as modalStyles from '@/scss/modal.module.scss'; import { decapitalize } from '@/utils/string'; +type WithOrganizationRoles = { + id: string; + name?: Nullable; + organizationRoles: OrganizationRoleEntity[]; +}; + type Props = { + readonly type: 'user' | 'application'; readonly organizationId: string; - readonly user: UserWithOrganizationRoles; + readonly data: WithOrganizationRoles; readonly isOpen: boolean; readonly onClose: () => void; }; -function EditOrganizationRolesModal({ organizationId, user, isOpen, onClose }: Props) { +const keyToRoleType = Object.freeze({ + user: RoleType.User, + application: RoleType.MachineToMachine, +} satisfies Record); + +function EditOrganizationRolesModal({ organizationId, data, isOpen, onClose, type }: Props) { const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const [keyword, setKeyword] = useState(''); const [roles, setRoles] = useState>>( - user.organizationRoles.map(({ id, name }) => ({ value: id, title: name })) + data.organizationRoles.map(({ id, name }) => ({ value: id, title: name })) ); - const name = user.name ?? decapitalize(t('organization_details.user')); + const name = data.name ?? decapitalize(t(`organization_details.${type}`)); const [isLoading, setIsLoading] = useState(false); const api = useApi(); const onSubmit = async () => { setIsLoading(true); try { - await api.put(`api/organizations/${organizationId}/users/${user.id}/roles`, { + await api.put(`api/organizations/${organizationId}/${type}s/${data.id}/roles`, { json: { organizationRoleIds: roles.map(({ value }) => value), }, @@ -72,6 +85,7 @@ function EditOrganizationRolesModal({ organizationId, user, isOpen, onClose }: P > void; +}; + +function AddAppsToOrganization({ organization, isOpen, onClose }: Props) { + const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); + const tAction = useActionTranslation(); + const api = useApi(); + const { + reset, + control, + handleSubmit, + formState: { isSubmitting }, + } = useForm<{ + applications: Application[]; + roles: Array>; + }>({ + defaultValues: { applications: [], roles: [] }, + }); + const [keyword, setKeyword] = useState(''); + + const onSubmit = handleSubmit( + trySubmitSafe(async ({ applications, roles }) => { + await api.post(`api/organizations/${organization.id}/applications`, { + json: { + applicationIds: applications.map(({ id }) => id), + }, + }); + + if (roles.length > 0) { + await api.post(`api/organizations/${organization.id}/applications/roles`, { + json: { + applicationIds: applications.map(({ id }) => id), + organizationRoleIds: roles.map(({ value }) => value), + }, + }); + } + onClose(); + }) + ); + + useEffect(() => { + if (isOpen) { + reset(); + setKeyword(''); + } + }, [isOpen, reset]); + + return ( + + + {t('organization_details.add_applications_to_organization', { + name: organization.name, + })} + + } + subtitle="organization_details.add_applications_to_organization_description" + footer={ +