mirror of
https://github.com/logto-io/logto.git
synced 2024-12-30 20:33:54 -05:00
feat(console): m2m pages in organizations
This commit is contained in:
parent
88f94c7001
commit
ca22bc6ae9
38 changed files with 527 additions and 84 deletions
|
@ -0,0 +1,43 @@
|
|||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="40" height="40" rx="6" fill="currentColor" />
|
||||
<circle cx="19.792" cy="13.5" r="6.5" fill="url(#paint0_linear_897_8053)" />
|
||||
<rect x="12.1941" y="11.8887" width="8.778" height="1.09725" rx="0.548625" fill="#2D2C61" />
|
||||
<rect x="23.167" y="11.8887" width="4.389" height="1.09725" rx="0.548625" fill="#2D2C61" />
|
||||
<path
|
||||
d="M10 28.5835C10 24.9475 12.9475 22 16.5835 22H23.4165C27.0525 22 30 24.9475 30 28.5835V30.8055C30 32.0175 29.0175 33 27.8055 33H12.1945C10.9825 33 10 32.0175 10 30.8055V28.5835Z"
|
||||
fill="#FAABFF" />
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M27.1999 22C26.8685 22 26.5998 22.2686 26.5998 22.6V26.2001C26.5998 26.5315 26.8685 26.8001 27.1999 26.8001H30.7999C31.1313 26.8001 31.4 26.5315 31.4 26.2001V22.6C31.4 22.2686 31.1313 22 30.7999 22H27.1999ZM23.6 29.1997C23.2686 29.1997 23 29.4683 23 29.7997V33.3998C23 33.7312 23.2686 33.9998 23.6 33.9998H27.2001C27.5315 33.9998 27.8001 33.7312 27.8001 33.3998V29.7997C27.8001 29.4683 27.5315 29.1997 27.2001 29.1997H23.6ZM30.2003 29.7997C30.2003 29.4683 30.4689 29.1997 30.8003 29.1997H34.4004C34.7318 29.1997 35.0004 29.4683 35.0004 29.7997V33.3998C35.0004 33.7312 34.7318 33.9998 34.4004 33.9998H30.8003C30.4689 33.9998 30.2003 33.7312 30.2003 33.3998V29.7997ZM24.7999 28.5992C24.7999 27.9364 25.3372 27.3991 25.9999 27.3991H28.3999L28.3999 26.8006H29.5999V27.3991H32.0001C32.6629 27.3991 33.2001 27.9364 33.2001 28.5992V29.1992H32.0001V28.5992H25.9999V29.1992H24.7999V28.5992Z"
|
||||
fill="url(#paint1_linear_897_8053)" />
|
||||
<path d="M27.8 23.2002V25.6003H30.2001V23.2002H27.8Z" fill="url(#paint2_linear_897_8053)" />
|
||||
<path d="M24.2 30.3994V32.7995H26.6V30.3994H24.2Z" fill="url(#paint3_linear_897_8053)" />
|
||||
<path d="M31.3999 30.4004V32.8004H33.8V30.4004H31.3999Z" fill="url(#paint4_linear_897_8053)" />
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_897_8053" x1="15.5264" y1="18.2396" x2="24.2607" y2="8.89583"
|
||||
gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#5D34F2" />
|
||||
<stop offset="1" stop-color="#FF88FA" />
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_897_8053" x1="25.0626" y1="32.375" x2="33.125" y2="23.7498"
|
||||
gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#5D34F2" />
|
||||
<stop offset="1" stop-color="#FF88FA" />
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_897_8053" x1="27.8334" y1="24.3102" x2="30.2001" y2="24.3102"
|
||||
gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#D2C4FF" />
|
||||
<stop offset="1" stop-color="#ECD5FF" />
|
||||
</linearGradient>
|
||||
<linearGradient id="paint3_linear_897_8053" x1="24.2333" y1="31.5094" x2="26.6" y2="31.5094"
|
||||
gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#D2C4FF" />
|
||||
<stop offset="1" stop-color="#ECD5FF" />
|
||||
</linearGradient>
|
||||
<linearGradient id="paint4_linear_897_8053" x1="31.4332" y1="31.5104" x2="33.8" y2="31.5104"
|
||||
gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#D2C4FF" />
|
||||
<stop offset="1" stop-color="#ECD5FF" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
13
packages/console/src/assets/icons/role-feature-dark.svg
Normal file
13
packages/console/src/assets/icons/role-feature-dark.svg
Normal file
|
@ -0,0 +1,13 @@
|
|||
<svg width="40" height="41" viewBox="0 0 40 41" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect y="0.5" width="40" height="40" rx="8" fill="#F7F8F8" fill-opacity="0.12"/>
|
||||
<circle cx="19.792" cy="14" r="6.5" fill="url(#paint0_linear_8738_113276)"/>
|
||||
<rect x="12.1941" y="12.3887" width="8.778" height="1.09725" rx="0.548625" fill="#2D2C61"/>
|
||||
<rect x="23.167" y="12.3887" width="4.389" height="1.09725" rx="0.548625" fill="#2D2C61"/>
|
||||
<path d="M10 29.0835C10 25.4475 12.9475 22.5 16.5835 22.5H23.4165C27.0525 22.5 30 25.4475 30 29.0835V31.3055C30 32.5175 29.0175 33.5 27.8055 33.5H12.1945C10.9825 33.5 10 32.5175 10 31.3055V29.0835Z" fill="#FAABFF"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_8738_113276" x1="15.5264" y1="18.7396" x2="24.2607" y2="9.39583" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#5D34F2"/>
|
||||
<stop offset="1" stop-color="#FF88FA"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
After Width: | Height: | Size: 893 B |
|
@ -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<Application, 'id' | 'name' | 'isThirdParty' | 'type'>;
|
||||
};
|
||||
|
||||
function ApplicationPreview({ data: { id, name, isThirdParty, type } }: Props) {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
|
||||
return (
|
||||
<ItemPreview
|
||||
title={name}
|
||||
subtitle={
|
||||
isThirdParty
|
||||
? t(`${applicationTypeI18nKey.thirdParty}.title`)
|
||||
: t(`${applicationTypeI18nKey[type]}.title`)
|
||||
}
|
||||
icon={<ApplicationIcon className={styles.icon} type={type} isThirdParty={isThirdParty} />}
|
||||
to={buildDetailsPathname(id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
export default ApplicationPreview;
|
|
@ -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<UserInfo, 'avatar' | 'name' | 'primaryEmail' | 'primaryPhone' | 'username' | 'isSuspended'>
|
||||
> &
|
||||
Pick<UserInfo, 'id'>;
|
||||
/**
|
||||
* Whether to provide a link to user details page. Explicitly set to `false` to hide it.
|
||||
*/
|
||||
|
|
|
@ -62,3 +62,7 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
|
|
@ -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<Option<string>>) => void;
|
||||
readonly keyword: string;
|
||||
readonly setKeyword: (keyword: string) => void;
|
||||
readonly roleType: RoleType;
|
||||
};
|
||||
|
||||
function OrganizationRolesSelect({ value, onChange, keyword, setKeyword }: Props) {
|
||||
const { data: scopes, isLoading } = useSearchValues<OrganizationScope>(
|
||||
function OrganizationRolesSelect({ value, onChange, keyword, setKeyword, roleType }: Props) {
|
||||
const { data: roles, isLoading } = useSearchValues<OrganizationRole>(
|
||||
'api/organization-roles',
|
||||
keyword
|
||||
);
|
||||
|
@ -41,7 +42,9 @@ function OrganizationRolesSelect({ value, onChange, keyword, setKeyword }: Props
|
|||
return (
|
||||
<MultiSelect
|
||||
value={value}
|
||||
options={scopes.map(({ id, name }) => ({ 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}
|
||||
|
|
|
@ -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: <OrganizationDetails />,
|
||||
children: [
|
||||
children: condArray(
|
||||
{ index: true, element: <Navigate replace to={OrganizationDetailsTabs.Settings} /> },
|
||||
{ path: OrganizationDetailsTabs.Settings, element: <Settings /> },
|
||||
{ path: OrganizationDetailsTabs.Members, element: <Members /> },
|
||||
],
|
||||
isDevFeaturesEnabled && {
|
||||
path: OrganizationDetailsTabs.MachineToMachine,
|
||||
element: <MachineToMachine />,
|
||||
}
|
||||
),
|
||||
}
|
||||
),
|
||||
};
|
||||
|
|
|
@ -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 }) => (
|
||||
<ItemPreview
|
||||
title={name}
|
||||
subtitle={
|
||||
isThirdParty
|
||||
? t(`${applicationTypeI18nKey.thirdParty}.title`)
|
||||
: t(`${applicationTypeI18nKey[type]}.title`)
|
||||
}
|
||||
icon={
|
||||
<ApplicationIcon
|
||||
className={styles.icon}
|
||||
type={type}
|
||||
isThirdParty={isThirdParty}
|
||||
/>
|
||||
}
|
||||
to={buildDetailsPathname(id)}
|
||||
/>
|
||||
),
|
||||
render: (data) => <ApplicationPreview data={data} />,
|
||||
},
|
||||
{
|
||||
title: t('applications.app_id'),
|
||||
|
|
|
@ -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<string>;
|
||||
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<Props['type'], RoleType>);
|
||||
|
||||
function EditOrganizationRolesModal({ organizationId, data, isOpen, onClose, type }: Props) {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [roles, setRoles] = useState<Array<Option<string>>>(
|
||||
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
|
|||
>
|
||||
<FormField title="organizations.organization_role_other">
|
||||
<OrganizationRolesSelect
|
||||
roleType={keyToRoleType[type]}
|
||||
value={roles}
|
||||
keyword={keyword}
|
||||
setKeyword={setKeyword}
|
|
@ -0,0 +1,148 @@
|
|||
import { type Organization, type Application, ApplicationType, RoleType } 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 { ApplicationItem } 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 = {
|
||||
readonly organization: Organization;
|
||||
readonly isOpen: boolean;
|
||||
readonly onClose: () => 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<Option<string>>;
|
||||
}>({
|
||||
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 (
|
||||
<ReactModal
|
||||
isOpen={isOpen}
|
||||
className={modalStyles.content}
|
||||
overlayClassName={modalStyles.overlay}
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<ModalLayout
|
||||
size="large"
|
||||
title={
|
||||
<DangerousRaw>
|
||||
{t('organization_details.add_applications_to_organization', {
|
||||
name: organization.name,
|
||||
})}
|
||||
</DangerousRaw>
|
||||
}
|
||||
subtitle="organization_details.add_applications_to_organization_description"
|
||||
footer={
|
||||
<Button
|
||||
isLoading={isSubmitting}
|
||||
size="large"
|
||||
type="primary"
|
||||
title={<>{tAction('add', 'organization_details.application_other')}</>}
|
||||
onClick={onSubmit}
|
||||
/>
|
||||
}
|
||||
onClose={onClose}
|
||||
>
|
||||
<FormField title="organization_details.application_other">
|
||||
<Controller
|
||||
name="applications"
|
||||
control={control}
|
||||
rules={{
|
||||
validate: (value) => {
|
||||
if (value.length === 0) {
|
||||
return t('organization_details.at_least_one_application');
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}}
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<EntitiesTransfer
|
||||
errorMessage={error?.message}
|
||||
searchProps={{
|
||||
pathname: 'api/applications',
|
||||
parameters: {
|
||||
excludeOrganizationId: organization.id,
|
||||
types: ApplicationType.MachineToMachine,
|
||||
},
|
||||
}}
|
||||
selectedEntities={value}
|
||||
emptyPlaceholder="errors.empty"
|
||||
renderEntity={(entity) => <ApplicationItem entity={entity} />}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField title="organization_details.add_with_organization_role">
|
||||
<Controller
|
||||
name="roles"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<OrganizationRolesSelect
|
||||
keyword={keyword}
|
||||
setKeyword={setKeyword}
|
||||
value={value}
|
||||
roleType={RoleType.MachineToMachine}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
</ModalLayout>
|
||||
</ReactModal>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddAppsToOrganization;
|
|
@ -0,0 +1,14 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.roles {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: _.unit(2);
|
||||
}
|
||||
|
||||
.filter {
|
||||
display: flex;
|
||||
gap: _.unit(2);
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
|
@ -0,0 +1,172 @@
|
|||
import { type ApplicationWithOrganizationRoles } from '@logto/schemas';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import Plus from '@/assets/icons/plus.svg';
|
||||
import ActionsButton from '@/components/ActionsButton';
|
||||
import EmptyDataPlaceholder from '@/components/EmptyDataPlaceholder';
|
||||
import ApplicationPreview from '@/components/ItemPreview/ApplicationPreview';
|
||||
import { RoleOption } from '@/components/OrganizationRolesSelect';
|
||||
import { defaultPageSize } from '@/consts';
|
||||
import Button from '@/ds-components/Button';
|
||||
import DangerousRaw from '@/ds-components/DangerousRaw';
|
||||
import Search from '@/ds-components/Search';
|
||||
import Table from '@/ds-components/Table';
|
||||
import Tag from '@/ds-components/Tag';
|
||||
import useActionTranslation from '@/hooks/use-action-translation';
|
||||
import useApi, { type RequestError } from '@/hooks/use-api';
|
||||
import { buildUrl } from '@/utils/url';
|
||||
|
||||
import EditOrganizationRolesModal from '../EditOrganizationRolesModal';
|
||||
import { type OrganizationDetailsOutletContext } from '../types';
|
||||
|
||||
import AddAppsToOrganization from './AddAppsToOrganization';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
const pageSize = defaultPageSize;
|
||||
|
||||
function MachineToMachine() {
|
||||
const { data: organization } = useOutletContext<OrganizationDetailsOutletContext>();
|
||||
const api = useApi();
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const {
|
||||
data: response,
|
||||
error,
|
||||
mutate,
|
||||
} = useSWR<[ApplicationWithOrganizationRoles[], number], RequestError>(
|
||||
buildUrl(`api/organizations/${organization.id}/applications`, {
|
||||
q: keyword,
|
||||
page: String(page),
|
||||
page_size: String(pageSize),
|
||||
})
|
||||
);
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const tAction = useActionTranslation();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [appToBeEdited, setAppToBeEdited] = useState<ApplicationWithOrganizationRoles>();
|
||||
const isLoading = !response && !error;
|
||||
const [data, totalCount] = response ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table
|
||||
isRowHoverEffectDisabled
|
||||
placeholder={<EmptyDataPlaceholder />}
|
||||
pagination={{
|
||||
page,
|
||||
totalCount,
|
||||
pageSize,
|
||||
onChange: setPage,
|
||||
}}
|
||||
isLoading={isLoading}
|
||||
errorMessage={error?.toString()}
|
||||
rowGroups={[{ key: 'data', data }]}
|
||||
columns={[
|
||||
{
|
||||
dataIndex: 'application',
|
||||
title: t('applications.application_name'),
|
||||
colSpan: 4,
|
||||
render: (data) => <ApplicationPreview data={data} />,
|
||||
},
|
||||
{
|
||||
dataIndex: 'roles',
|
||||
title: t('organization_details.roles'),
|
||||
colSpan: 6,
|
||||
render: ({ organizationRoles }) => {
|
||||
if (organizationRoles.length === 0) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.roles}>
|
||||
{organizationRoles.map(({ id, name }) => (
|
||||
<Tag key={id} variant="cell">
|
||||
<RoleOption value={id} title={name} />
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
dataIndex: 'actions',
|
||||
title: null,
|
||||
colSpan: 1,
|
||||
render: (data) => (
|
||||
<ActionsButton
|
||||
deleteConfirmation="organization_details.remove_application_from_organization_description"
|
||||
fieldName="organization_details.application"
|
||||
textOverrides={{
|
||||
edit: 'organization_details.edit_organization_roles',
|
||||
delete: 'organization_details.remove_application_from_organization',
|
||||
deleteConfirmation: 'general.remove',
|
||||
}}
|
||||
onEdit={() => {
|
||||
setAppToBeEdited(data);
|
||||
}}
|
||||
onDelete={async () => {
|
||||
await api.delete(`api/organizations/${organization.id}/applications/${data.id}`);
|
||||
void mutate();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
rowIndexKey="id"
|
||||
filter={
|
||||
<div className={styles.filter}>
|
||||
<Search
|
||||
defaultValue={keyword}
|
||||
isClearable={Boolean(keyword)}
|
||||
placeholder={t('organization_details.search_application_placeholder')}
|
||||
onSearch={(value) => {
|
||||
setKeyword(value);
|
||||
setPage(1);
|
||||
}}
|
||||
onClearSearch={() => {
|
||||
setKeyword('');
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="large"
|
||||
title={
|
||||
<DangerousRaw>{tAction('add', 'organizations.machine_to_machine')}</DangerousRaw>
|
||||
}
|
||||
type="primary"
|
||||
icon={<Plus />}
|
||||
onClick={() => {
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{appToBeEdited && (
|
||||
<EditOrganizationRolesModal
|
||||
isOpen
|
||||
type="application"
|
||||
organizationId={organization.id}
|
||||
data={appToBeEdited}
|
||||
onClose={() => {
|
||||
setAppToBeEdited(undefined);
|
||||
void mutate();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<AddAppsToOrganization
|
||||
organization={organization}
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => {
|
||||
setIsModalOpen(false);
|
||||
void mutate();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default MachineToMachine;
|
|
@ -1,4 +1,4 @@
|
|||
import { type User, type Organization } from '@logto/schemas';
|
||||
import { type User, type Organization, RoleType } from '@logto/schemas';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
@ -34,25 +34,25 @@ function AddMembersToOrganization({ organization, isOpen, onClose }: Props) {
|
|||
formState: { isSubmitting },
|
||||
} = useForm<{
|
||||
users: User[];
|
||||
scopes: Array<Option<string>>;
|
||||
roles: Array<Option<string>>;
|
||||
}>({
|
||||
defaultValues: { users: [], scopes: [] },
|
||||
defaultValues: { users: [], roles: [] },
|
||||
});
|
||||
const [keyword, setKeyword] = useState('');
|
||||
|
||||
const onSubmit = handleSubmit(
|
||||
trySubmitSafe(async (data) => {
|
||||
trySubmitSafe(async ({ users, roles }) => {
|
||||
await api.post(`api/organizations/${organization.id}/users`, {
|
||||
json: {
|
||||
userIds: data.users.map(({ id }) => id),
|
||||
userIds: users.map(({ id }) => id),
|
||||
},
|
||||
});
|
||||
|
||||
if (data.scopes.length > 0) {
|
||||
if (roles.length > 0) {
|
||||
await api.post(`api/organizations/${organization.id}/users/roles`, {
|
||||
json: {
|
||||
userIds: data.users.map(({ id }) => id),
|
||||
organizationRoleIds: data.scopes.map(({ value }) => value),
|
||||
userIds: users.map(({ id }) => id),
|
||||
organizationRoleIds: roles.map(({ value }) => value),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
@ -126,10 +126,11 @@ function AddMembersToOrganization({ organization, isOpen, onClose }: Props) {
|
|||
</FormField>
|
||||
<FormField title="organization_details.add_with_organization_role">
|
||||
<Controller
|
||||
name="scopes"
|
||||
name="roles"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<OrganizationRolesSelect
|
||||
roleType={RoleType.User}
|
||||
keyword={keyword}
|
||||
setKeyword={setKeyword}
|
||||
value={value}
|
||||
|
|
|
@ -20,10 +20,10 @@ import useActionTranslation from '@/hooks/use-action-translation';
|
|||
import useApi, { type RequestError } from '@/hooks/use-api';
|
||||
import { buildUrl } from '@/utils/url';
|
||||
|
||||
import EditOrganizationRolesModal from '../EditOrganizationRolesModal';
|
||||
import { type OrganizationDetailsOutletContext } from '../types';
|
||||
|
||||
import AddMembersToOrganization from './AddMembersToOrganization';
|
||||
import EditOrganizationRolesModal from './EditOrganizationRolesModal';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
const pageSize = defaultPageSize;
|
||||
|
@ -68,13 +68,13 @@ function Members() {
|
|||
columns={[
|
||||
{
|
||||
dataIndex: 'user',
|
||||
title: 'User',
|
||||
title: t('organization_details.user'),
|
||||
colSpan: 4,
|
||||
render: (user) => <UserPreview user={user} />,
|
||||
},
|
||||
{
|
||||
dataIndex: 'roles',
|
||||
title: 'Organization roles',
|
||||
title: t('organization_details.roles'),
|
||||
colSpan: 6,
|
||||
render: ({ organizationRoles }) => {
|
||||
if (organizationRoles.length === 0) {
|
||||
|
@ -94,7 +94,7 @@ function Members() {
|
|||
},
|
||||
{
|
||||
dataIndex: 'lastSignInAt',
|
||||
title: 'Last sign-in',
|
||||
title: t('users.latest_sign_in'),
|
||||
colSpan: 5,
|
||||
render: ({ lastSignInAt }) => <DateTime>{lastSignInAt}</DateTime>,
|
||||
},
|
||||
|
@ -153,8 +153,9 @@ function Members() {
|
|||
{userToBeEdited && (
|
||||
<EditOrganizationRolesModal
|
||||
isOpen
|
||||
type="user"
|
||||
organizationId={organization.id}
|
||||
user={userToBeEdited}
|
||||
data={userToBeEdited}
|
||||
onClose={() => {
|
||||
setUserToBeEdited(undefined);
|
||||
void mutate();
|
||||
|
|
|
@ -2,6 +2,7 @@ import {
|
|||
type SignInExperience,
|
||||
type Organization,
|
||||
type SsoConnectorWithProviderConfig,
|
||||
RoleType,
|
||||
} from '@logto/schemas';
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
|
@ -296,6 +297,7 @@ function Settings() {
|
|||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<OrganizationRolesSelect
|
||||
roleType={RoleType.User}
|
||||
keyword={keyword}
|
||||
setKeyword={setKeyword}
|
||||
value={value}
|
||||
|
@ -309,6 +311,8 @@ function Settings() {
|
|||
<UnsavedChangesAlertModal hasUnsavedChanges={!isDeleting && isDirty} />
|
||||
</DetailsForm>
|
||||
);
|
||||
|
||||
// eslint-disable-next-line max-lines -- Should be ok once dev features flag is removed
|
||||
}
|
||||
|
||||
export default Settings;
|
||||
|
|
|
@ -19,6 +19,7 @@ import Skeleton from '@/components/DetailsPage/Skeleton';
|
|||
import Drawer from '@/components/Drawer';
|
||||
import PageMeta from '@/components/PageMeta';
|
||||
import ThemedIcon from '@/components/ThemedIcon';
|
||||
import { isDevFeaturesEnabled } from '@/consts/env';
|
||||
import DeleteConfirmModal from '@/ds-components/DeleteConfirmModal';
|
||||
import TabNav, { TabNavItem } from '@/ds-components/TabNav';
|
||||
import useApi, { type RequestError } from '@/hooks/use-api';
|
||||
|
@ -133,6 +134,12 @@ function OrganizationDetails() {
|
|||
<TabNavItem href={`${pathname}/${id}/${OrganizationDetailsTabs.Members}`}>
|
||||
{t('organizations.members')}
|
||||
</TabNavItem>
|
||||
{/* TODO: Remove */}
|
||||
{isDevFeaturesEnabled && (
|
||||
<TabNavItem href={`${pathname}/${id}/${OrganizationDetailsTabs.MachineToMachine}`}>
|
||||
{t('organizations.machine_to_machine')}
|
||||
</TabNavItem>
|
||||
)}
|
||||
</TabNav>
|
||||
<Outlet
|
||||
context={
|
||||
|
|
|
@ -22,4 +22,5 @@ export type OrganizationDetailsOutletContext = {
|
|||
export enum OrganizationDetailsTabs {
|
||||
Settings = 'settings',
|
||||
Members = 'members',
|
||||
MachineToMachine = 'machine-to-machine',
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@ import { type CommonQueryMethods, sql } from '@silverhand/slonik';
|
|||
|
||||
import { type SearchOptions, buildSearchSql } from '#src/database/utils.js';
|
||||
import { TwoRelationsQueries, type GetEntitiesOptions } from '#src/utils/RelationQueries.js';
|
||||
import { convertToIdentifiers } from '#src/utils/sql.js';
|
||||
import { conditionalSql, convertToIdentifiers } from '#src/utils/sql.js';
|
||||
|
||||
import { type applicationSearchKeys } from '../application.js';
|
||||
|
||||
|
@ -28,7 +28,7 @@ export class ApplicationRelationQueries extends TwoRelationsQueries<
|
|||
|
||||
async getOrganizationsByApplicationId(
|
||||
applicationId: string,
|
||||
{ limit, offset }: GetEntitiesOptions
|
||||
options?: GetEntitiesOptions
|
||||
): Promise<[totalCount: number, organizations: readonly OrganizationWithRoles[]]> {
|
||||
const organizations = convertToIdentifiers(Organizations, true);
|
||||
const roles = convertToIdentifiers(OrganizationRoles, true);
|
||||
|
@ -57,8 +57,7 @@ export class ApplicationRelationQueries extends TwoRelationsQueries<
|
|||
on ${relations.fields.organizationRoleId} = ${roles.fields.id}
|
||||
where ${fields.applicationId} = ${applicationId}
|
||||
group by ${organizations.fields.id}
|
||||
limit ${limit}
|
||||
offset ${offset}
|
||||
${conditionalSql(options, ({ limit, offset }) => sql`limit ${limit} offset ${offset}`)}
|
||||
`),
|
||||
]);
|
||||
|
||||
|
|
|
@ -17,7 +17,7 @@ export default function applicationOrganizationRoutes<T extends ManagementApiRou
|
|||
|
||||
router.get(
|
||||
'/applications/:id/organizations',
|
||||
koaPagination(),
|
||||
koaPagination({ isOptional: true }),
|
||||
koaGuard({
|
||||
params: z.object({ id: z.string() }),
|
||||
response: organizationWithOrganizationRolesGuard.array(),
|
||||
|
@ -32,10 +32,12 @@ export default function applicationOrganizationRoutes<T extends ManagementApiRou
|
|||
const [count, entities] =
|
||||
await queries.organizations.relations.apps.getOrganizationsByApplicationId(
|
||||
id,
|
||||
ctx.pagination
|
||||
ctx.pagination.disabled ? undefined : ctx.pagination
|
||||
);
|
||||
|
||||
if (!ctx.pagination.disabled) {
|
||||
ctx.pagination.totalCount = count;
|
||||
}
|
||||
ctx.body = entities;
|
||||
|
||||
return next();
|
||||
|
|
|
@ -111,9 +111,20 @@ export const generateM2mLog = async (applicationId: string) => {
|
|||
};
|
||||
|
||||
/** Get organizations that an application is associated with. */
|
||||
export const getOrganizations = async (applicationId: string, page: number, pageSize: number) =>
|
||||
authedAdminApi
|
||||
export const getOrganizations = async (applicationId: string, page?: number, pageSize?: number) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (page) {
|
||||
searchParams.append('page', String(page));
|
||||
}
|
||||
|
||||
if (pageSize) {
|
||||
searchParams.append('page_size', String(pageSize));
|
||||
}
|
||||
|
||||
return authedAdminApi
|
||||
.get(`applications/${applicationId}/organizations`, {
|
||||
searchParams: { page, page_size: pageSize },
|
||||
searchParams,
|
||||
})
|
||||
.json<OrganizationWithRoles[]>();
|
||||
};
|
||||
|
|
|
@ -41,10 +41,11 @@ devFeatureTest.describe('application organizations', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('should get organizations by application id with pagination', async () => {
|
||||
it('should get organizations by application id with or without pagination', async () => {
|
||||
const organizations1 = await getOrganizations(applications[0]!.id, 1, 30);
|
||||
const organizations2 = await getOrganizations(applications[0]!.id, 2, 10);
|
||||
const organizations3 = await getOrganizations(applications[0]!.id, 2, 20);
|
||||
const organizations4 = await getOrganizations(applications[0]!.id);
|
||||
|
||||
expect(organizations1).toEqual(
|
||||
expect.arrayContaining(
|
||||
|
@ -56,6 +57,12 @@ devFeatureTest.describe('application organizations', () => {
|
|||
expect(organizations3).toHaveLength(10);
|
||||
expect(organizations2[0]?.id).toBe(organizations1[10]?.id);
|
||||
expect(organizations3[0]?.id).toBe(organizations1[20]?.id);
|
||||
expect(organizations4).toHaveLength(30);
|
||||
expect(organizations4).toEqual(
|
||||
expect.arrayContaining(
|
||||
organizationApi.organizations.map((object) => expect.objectContaining(object))
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('should be able to fetch applications by excluding an organization', async () => {
|
||||
|
|
|
@ -99,7 +99,6 @@ const user_details = {
|
|||
},
|
||||
warning_no_sign_in_identifier:
|
||||
'Der Benutzer muss mindestens einen der Anmelde-Identifikatoren (Benutzername, E-Mail, Telefonnummer oder soziales Konto) haben, um sich anzumelden. Sind Sie sicher, dass Sie fortfahren möchten?',
|
||||
organization_roles_tooltip: 'Die dem Benutzer innerhalb dieser Organisation zugewiesenen Rollen.',
|
||||
};
|
||||
|
||||
export default Object.freeze(user_details);
|
||||
|
|
|
@ -136,8 +136,6 @@ const application_details = {
|
|||
grant_organization_level_permissions: 'Grant permissions of organization data',
|
||||
},
|
||||
roles: {
|
||||
name_column: 'Machine-to-machine role',
|
||||
description_column: 'Description',
|
||||
assign_button: 'Assign machine-to-machine roles',
|
||||
delete_description:
|
||||
'This action will remove this role from this machine-to-machine app. The role itself will still exist, but it will no longer be associated with this machine-to-machine app.',
|
||||
|
|
|
@ -7,6 +7,7 @@ const organizations = {
|
|||
organization_template: 'Organization template',
|
||||
organization_id: 'Organization ID',
|
||||
members: 'Members',
|
||||
machine_to_machine: 'Machine-to-machine apps',
|
||||
create_organization: 'Create organization',
|
||||
setup_organization: 'Set up your organization',
|
||||
organization_list_placeholder_title: 'Organization',
|
||||
|
|
|
@ -98,7 +98,6 @@ const user_details = {
|
|||
},
|
||||
warning_no_sign_in_identifier:
|
||||
'User needs to have at least one of the sign-in identifiers (username, email, phone number or social) to sign in. Are you sure you want to continue?',
|
||||
organization_roles_tooltip: 'The roles assigned to the user within this organization.',
|
||||
};
|
||||
|
||||
export default Object.freeze(user_details);
|
||||
|
|
|
@ -98,7 +98,6 @@ const user_details = {
|
|||
},
|
||||
warning_no_sign_in_identifier:
|
||||
'El usuario necesita tener al menos uno de los identificadores de inicio de sesión (nombre de usuario, correo electrónico, número de teléfono o red social) para iniciar sesión. ¿Estás seguro/a de que quieres continuar?',
|
||||
organization_roles_tooltip: 'Los roles asignados al usuario dentro de esta organización.',
|
||||
};
|
||||
|
||||
export default Object.freeze(user_details);
|
||||
|
|
|
@ -99,7 +99,6 @@ const user_details = {
|
|||
},
|
||||
warning_no_sign_in_identifier:
|
||||
"L'utilisateur doit avoir au moins l'un des identifiants de connexion (nom d'utilisateur, e-mail, numéro de téléphone ou compte social) pour se connecter. Êtes-vous sûr(e) de vouloir continuer?",
|
||||
organization_roles_tooltip: "Les rôles attribués à l'utilisateur au sein de cette organisation.",
|
||||
};
|
||||
|
||||
export default Object.freeze(user_details);
|
||||
|
|
|
@ -98,7 +98,6 @@ const user_details = {
|
|||
},
|
||||
warning_no_sign_in_identifier:
|
||||
"L'utente deve avere almeno uno degli identificatori di accesso (nome utente, email, numero di telefono, o social) per accedere. Sei sicuro di voler continuare?",
|
||||
organization_roles_tooltip: "I ruoli assegnati all'utente all'interno di questa organizzazione.",
|
||||
};
|
||||
|
||||
export default Object.freeze(user_details);
|
||||
|
|
|
@ -92,7 +92,6 @@ const user_details = {
|
|||
},
|
||||
warning_no_sign_in_identifier:
|
||||
'ユーザーは、サインインに少なくとも1つの識別子(ユーザー名、メールアドレス、電話番号、またはソーシャル)を持っている必要があります。続行してよろしいですか?',
|
||||
organization_roles_tooltip: 'この組織内のユーザーに割り当てられた役割。',
|
||||
};
|
||||
|
||||
export default Object.freeze(user_details);
|
||||
|
|
|
@ -91,7 +91,6 @@ const user_details = {
|
|||
},
|
||||
warning_no_sign_in_identifier:
|
||||
'사용자는 로그인 식별자(사용자 이름, 이메일, 전화 번호 또는 소셜) 중 적어도 하나를 갖고 로그인해야 합니다. 계속 하시겠습니까?',
|
||||
organization_roles_tooltip: '조직 내에서 사용자에게 할당된 역할.',
|
||||
};
|
||||
|
||||
export default Object.freeze(user_details);
|
||||
|
|
|
@ -95,7 +95,6 @@ const user_details = {
|
|||
},
|
||||
warning_no_sign_in_identifier:
|
||||
'Aby się zalogować, użytkownik musi mieć co najmniej jeden identyfikator logowania (nazwa użytkownika, e-mail, numer telefonu lub konto społecznościowe). Czy na pewno chcesz kontynuować?',
|
||||
organization_roles_tooltip: 'Role przypisane użytkownikowi w ramach tej organizacji.',
|
||||
};
|
||||
|
||||
export default Object.freeze(user_details);
|
||||
|
|
|
@ -96,7 +96,6 @@ const user_details = {
|
|||
},
|
||||
warning_no_sign_in_identifier:
|
||||
'O usuário precisa ter pelo menos um dos identificadores de login (nome de usuário, e-mail, número de telefone ou social) para fazer login. Tem certeza de que deseja continuar?',
|
||||
organization_roles_tooltip: 'As funções atribuídas ao usuário dentro desta organização.',
|
||||
};
|
||||
|
||||
export default Object.freeze(user_details);
|
||||
|
|
|
@ -98,7 +98,6 @@ const user_details = {
|
|||
},
|
||||
warning_no_sign_in_identifier:
|
||||
'O utilizador precisa de ter pelo menos um dos identificadores de início de sessão (nome de utilizador, e-mail, número de telefone ou redes sociais) para iniciar sessão. Tem a certeza de que quer continuar?',
|
||||
organization_roles_tooltip: 'As funções atribuídas ao utilizador dentro desta organização.',
|
||||
};
|
||||
|
||||
export default Object.freeze(user_details);
|
||||
|
|
|
@ -96,7 +96,6 @@ const user_details = {
|
|||
},
|
||||
warning_no_sign_in_identifier:
|
||||
'Пользователь должен иметь хотя бы один из идентификаторов входа (имя пользователя, электронная почта, номер телефона или социальная сеть), чтобы войти. Вы уверены, что хотите продолжить?',
|
||||
organization_roles_tooltip: 'Роли, назначенные пользователю в этой организации.',
|
||||
};
|
||||
|
||||
export default Object.freeze(user_details);
|
||||
|
|
|
@ -97,7 +97,6 @@ const user_details = {
|
|||
},
|
||||
warning_no_sign_in_identifier:
|
||||
'Kullanıcının giriş yapmak için en az bir oturum açma kimliği (kullanıcı adı, e-posta, telefon numarası, veya sosyal) olması gerekiyor. Devam etmek istediğinizden emin misiniz?',
|
||||
organization_roles_tooltip: 'Bu organizasyon içinde kullanıcıya atanan roller.',
|
||||
};
|
||||
|
||||
export default Object.freeze(user_details);
|
||||
|
|
|
@ -87,7 +87,6 @@ const user_details = {
|
|||
},
|
||||
warning_no_sign_in_identifier:
|
||||
'用户需要至少拥有一个登录标识(用户名、邮箱、手机号或社交账户)才能登录。确定要继续吗?',
|
||||
organization_roles_tooltip: '用户在该组织内分配的角色。',
|
||||
};
|
||||
|
||||
export default Object.freeze(user_details);
|
||||
|
|
|
@ -87,7 +87,6 @@ const user_details = {
|
|||
},
|
||||
warning_no_sign_in_identifier:
|
||||
'用戶需要至少擁有一個登錄標識(用戶名、電子郵件、電話號碼或社交帳號)才能登錄。確定要繼續嗎?',
|
||||
organization_roles_tooltip: 'The roles assigned to the user within this organization.',
|
||||
};
|
||||
|
||||
export default Object.freeze(user_details);
|
||||
|
|
|
@ -87,7 +87,6 @@ const user_details = {
|
|||
},
|
||||
warning_no_sign_in_identifier:
|
||||
'使用者需要至少擁有一個登入標識(使用者名稱、電子郵件、電話號碼或社交帳號)才能登入。確定要繼續嗎?',
|
||||
organization_roles_tooltip: '該組織中分配給該用戶的角色。',
|
||||
};
|
||||
|
||||
export default Object.freeze(user_details);
|
||||
|
|
Loading…
Reference in a new issue