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

refactor(console): update tenant settings access per user tenant scopes (#5571)

This commit is contained in:
Charles Zhao 2024-03-28 16:58:27 +08:00 committed by GitHub
parent 664c67dc6d
commit a2899a8101
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 288 additions and 161 deletions

View file

@ -13,8 +13,10 @@ import useActionTranslation from '@/hooks/use-action-translation';
import * as styles from './index.module.scss';
type Props = {
/** A function that will be called when the user confirms the deletion. */
onDelete: () => void | Promise<void>;
/** A function that will be called when the user confirms the deletion. If not provided,
* the delete button will not be displayed.
*/
onDelete?: () => void | Promise<void>;
/**
* A function that will be called when the user clicks the edit button. If not provided,
* the edit button will not be displayed.
@ -48,6 +50,10 @@ function ActionsButton({ onDelete, onEdit, deleteConfirmation, fieldName, textOv
const [isDeleting, setIsDeleting] = useState(false);
const handleDelete = useCallback(async () => {
if (!onDelete) {
return;
}
setIsDeleting(true);
try {
await onDelete();
@ -69,31 +75,35 @@ function ActionsButton({ onDelete, onEdit, deleteConfirmation, fieldName, textOv
)}
</ActionMenuItem>
)}
<ActionMenuItem
icon={<Delete />}
type="danger"
onClick={() => {
setIsModalOpen(true);
}}
>
{textOverrides?.delete ? (
<DynamicT forKey={textOverrides.delete} />
) : (
tAction('delete', fieldName)
)}
</ActionMenuItem>
{onDelete && (
<ActionMenuItem
icon={<Delete />}
type="danger"
onClick={() => {
setIsModalOpen(true);
}}
>
{textOverrides?.delete ? (
<DynamicT forKey={textOverrides.delete} />
) : (
tAction('delete', fieldName)
)}
</ActionMenuItem>
)}
</ActionMenu>
<ConfirmModal
isOpen={isModalOpen}
confirmButtonText={textOverrides?.deleteConfirmation ?? 'general.delete'}
isLoading={isDeleting}
onCancel={() => {
setIsModalOpen(false);
}}
onConfirm={handleDelete}
>
<DynamicT forKey={deleteConfirmation} />
</ConfirmModal>
{onDelete && (
<ConfirmModal
isOpen={isModalOpen}
confirmButtonText={textOverrides?.deleteConfirmation ?? 'general.delete'}
isLoading={isDeleting}
onCancel={() => {
setIsModalOpen(false);
}}
onConfirm={handleDelete}
>
<DynamicT forKey={deleteConfirmation} />
</ConfirmModal>
)}
</>
);
}

View file

@ -15,6 +15,7 @@ import {
import { isCloud, isDevFeaturesEnabled } from '@/consts/env';
import { TenantsContext } from '@/contexts/TenantsProvider';
import OverlayScrollbar from '@/ds-components/OverlayScrollbar';
import useCurrentTenantScopes from '@/hooks/use-current-tenant-scopes';
import ApiResourceDetails from '@/pages/ApiResourceDetails';
import ApiResourcePermissions from '@/pages/ApiResourceDetails/ApiResourcePermissions';
import ApiResourceSettings from '@/pages/ApiResourceDetails/ApiResourceSettings';
@ -74,6 +75,7 @@ import * as styles from './index.module.scss';
function ConsoleContent() {
const { scrollableContent } = useOutletContext<AppContentOutletContext>();
const { isDevTenant } = useContext(TenantsContext);
const { canManageTenant } = useCurrentTenantScopes();
return (
<div className={styles.content}>
@ -197,13 +199,25 @@ function ConsoleContent() {
<Route path="signing-keys" element={<SigningKeys />} />
{isCloud && (
<Route path="tenant-settings" element={<TenantSettings />}>
<Route index element={<Navigate replace to={TenantSettingsTabs.Settings} />} />
<Route
index
element={
<Navigate
replace
to={
canManageTenant ? TenantSettingsTabs.Settings : TenantSettingsTabs.Members
}
/>
}
/>
<Route path={TenantSettingsTabs.Settings} element={<TenantBasicSettings />} />
{isDevFeaturesEnabled && (
<Route path={`${TenantSettingsTabs.Members}/*`} element={<TenantMembers />} />
)}
<Route path={TenantSettingsTabs.Domains} element={<TenantDomainSettings />} />
{!isDevTenant && (
{canManageTenant && (
<Route path={TenantSettingsTabs.Domains} element={<TenantDomainSettings />} />
)}
{!isDevTenant && canManageTenant && (
<>
<Route path={TenantSettingsTabs.Subscription} element={<Subscription />} />
<Route path={TenantSettingsTabs.BillingHistory} element={<BillingHistory />} />

View file

@ -0,0 +1,61 @@
import { useLogto } from '@logto/react';
import { TenantScope, getTenantOrganizationId } from '@logto/schemas';
import { useContext, useEffect, useState } from 'react';
import { TenantsContext } from '@/contexts/TenantsProvider';
const useCurrentTenantScopes = () => {
const { currentTenantId, isInitComplete } = useContext(TenantsContext);
const { isAuthenticated, getOrganizationTokenClaims } = useLogto();
const [scopes, setScopes] = useState<string[]>([]);
const [canInviteMember, setCanInviteMember] = useState(false);
const [canRemoveMember, setCanRemoveMember] = useState(false);
const [canUpdateMemberRole, setCanUpdateMemberRole] = useState(false);
const [canManageTenant, setCanManageTenant] = useState(false);
useEffect(() => {
(async () => {
if (isAuthenticated && isInitComplete) {
const organizationId = getTenantOrganizationId(currentTenantId);
const claims = await getOrganizationTokenClaims(organizationId);
const allScopes = claims?.scope?.split(' ') ?? [];
setScopes(allScopes);
for (const scope of allScopes) {
switch (scope) {
case TenantScope.InviteMember: {
setCanInviteMember(true);
break;
}
case TenantScope.RemoveMember: {
setCanRemoveMember(true);
break;
}
case TenantScope.UpdateMemberRole: {
setCanUpdateMemberRole(true);
break;
}
case TenantScope.ManageTenant: {
setCanManageTenant(true);
break;
}
default: {
break;
}
}
}
}
})();
}, [currentTenantId, getOrganizationTokenClaims, isAuthenticated, isInitComplete]);
return {
canInviteMember,
canRemoveMember,
canUpdateMemberRole,
canManageTenant,
scopes,
};
};
export default useCurrentTenantScopes;

View file

@ -4,6 +4,7 @@ import FormCard from '@/components/FormCard';
import CopyToClipboard from '@/ds-components/CopyToClipboard';
import FormField from '@/ds-components/FormField';
import TextInput from '@/ds-components/TextInput';
import useCurrentTenantScopes from '@/hooks/use-current-tenant-scopes';
import { type TenantSettingsForm } from '../types.js';
@ -15,6 +16,7 @@ type Props = {
};
function ProfileForm({ currentTenantId }: Props) {
const { canManageTenant } = useCurrentTenantScopes();
const {
register,
formState: { errors },
@ -29,6 +31,7 @@ function ProfileForm({ currentTenantId }: Props) {
<FormField isRequired title="tenants.settings.tenant_name">
<TextInput
{...register('profile.name', { required: true })}
readOnly={!canManageTenant}
error={Boolean(errors.profile?.name)}
/>
</FormField>

View file

@ -12,6 +12,7 @@ import SubmitFormChangesActionBar from '@/components/SubmitFormChangesActionBar'
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
import { TenantsContext } from '@/contexts/TenantsProvider';
import { useConfirmModal } from '@/hooks/use-confirm-modal';
import useCurrentTenantScopes from '@/hooks/use-current-tenant-scopes';
import { trySubmitSafe } from '@/utils/form';
import DeleteCard from './DeleteCard';
@ -28,6 +29,7 @@ const tenantProfileToForm = (tenant?: TenantResponse): TenantSettingsForm => {
function TenantBasicSettings() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { canManageTenant } = useCurrentTenantScopes();
const api = useCloudApi();
const {
currentTenant,
@ -120,26 +122,34 @@ function TenantBasicSettings() {
<FormProvider {...methods}>
<div className={styles.fields}>
<ProfileForm currentTenantId={currentTenantId} />
<DeleteCard currentTenantId={currentTenantId} onClick={onClickDeletionButton} />
{canManageTenant && (
<DeleteCard currentTenantId={currentTenantId} onClick={onClickDeletionButton} />
)}
</div>
</FormProvider>
<SubmitFormChangesActionBar
isOpen={isDirty}
isSubmitting={isSubmitting}
onDiscard={reset}
onSubmit={onSubmit}
/>
{canManageTenant && (
<SubmitFormChangesActionBar
isOpen={isDirty}
isSubmitting={isSubmitting}
onDiscard={reset}
onSubmit={onSubmit}
/>
)}
</form>
<UnsavedChangesAlertModal hasUnsavedChanges={isDirty} />
<DeleteModal
isOpen={isDeletionModalOpen}
isLoading={isDeleting}
tenant={watch('profile')}
onClose={() => {
setIsDeletionModalOpen(false);
}}
onDelete={onDelete}
/>
{canManageTenant && (
<>
<UnsavedChangesAlertModal hasUnsavedChanges={isDirty} />
<DeleteModal
isOpen={isDeletionModalOpen}
isLoading={isDeleting}
tenant={watch('profile')}
onClose={() => {
setIsDeletionModalOpen(false);
}}
onDelete={onDelete}
/>
</>
)}
</>
);
}

View file

@ -1,4 +1,5 @@
import { OrganizationInvitationStatus } from '@logto/schemas';
import { condArray, conditional } from '@silverhand/essentials';
import { format } from 'date-fns';
import { useContext, useState } from 'react';
import { toast } from 'react-hot-toast';
@ -13,7 +14,7 @@ import Redo from '@/assets/icons/redo.svg';
import UsersEmptyDark from '@/assets/images/users-empty-dark.svg';
import UsersEmpty from '@/assets/images/users-empty.svg';
import { useAuthedCloudApi } from '@/cloud/hooks/use-cloud-api';
import { type TenantInvitationResponse } from '@/cloud/types/router';
import type { InvitationResponse, TenantInvitationResponse } from '@/cloud/types/router';
import { RoleOption } from '@/components/OrganizationRolesSelect';
import { TenantsContext } from '@/contexts/TenantsProvider';
import ActionMenu, { ActionMenuItem } from '@/ds-components/ActionMenu';
@ -24,6 +25,7 @@ import TablePlaceholder from '@/ds-components/Table/TablePlaceholder';
import Tag, { type Props as TagProps } from '@/ds-components/Tag';
import { type RequestError } from '@/hooks/use-api';
import { useConfirmModal } from '@/hooks/use-confirm-modal';
import useCurrentTenantScopes from '@/hooks/use-current-tenant-scopes';
import InviteMemberModal from '../InviteMemberModal';
@ -50,6 +52,7 @@ function Invitations() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.tenant_members' });
const cloudApi = useAuthedCloudApi();
const { currentTenantId } = useContext(TenantsContext);
const { canInviteMember, canRemoveMember } = useCurrentTenantScopes();
const { data, error, isLoading, mutate } = useSWR<TenantInvitationResponse[], RequestError>(
`api/tenant/${currentTenantId}/invitations`,
@ -105,17 +108,19 @@ function Invitations() {
imageDark={<UsersEmptyDark />}
title="tenant_members.invitation_empty_placeholder.title"
description="tenant_members.invitation_empty_placeholder.description"
action={
<Button
title="tenant_members.invite_members"
type="primary"
size="large"
icon={<Plus />}
onClick={() => {
setShowInviteModal(true);
}}
/>
}
action={conditional(
canInviteMember && (
<Button
title="tenant_members.invite_members"
type="primary"
size="large"
icon={<Plus />}
onClick={() => {
setShowInviteModal(true);
}}
/>
)
)}
/>
}
isLoading={isLoading}
@ -166,60 +171,64 @@ function Invitations() {
title: t('expiration_date'),
render: ({ expiresAt }) => <span>{format(expiresAt, 'MMM do, yyyy')}</span>,
},
{
dataIndex: 'actions',
title: null,
render: ({ id, status }) => (
<ActionMenu
icon={<More />}
iconSize="small"
title={<DynamicT forKey="general.more_options" />}
>
{status !== OrganizationInvitationStatus.Accepted && (
<ActionMenuItem
icon={<Invite />}
onClick={async () => {
await cloudApi.post(
'/api/tenants/:tenantId/invitations/:invitationId/message',
{
params: { tenantId: currentTenantId, invitationId: id },
}
);
toast.success(t('messages.invitation_sent'));
}}
...condArray(
(canInviteMember || canRemoveMember) && [
{
dataIndex: 'actions',
title: null,
render: ({ id, status }: InvitationResponse) => (
<ActionMenu
icon={<More />}
iconSize="small"
title={<DynamicT forKey="general.more_options" />}
>
{t('menu_options.resend_invite')}
</ActionMenuItem>
)}
{status === OrganizationInvitationStatus.Pending && (
<ActionMenuItem
icon={<Redo />}
type="danger"
onClick={() => {
void handleRevoke(id);
}}
>
{t('menu_options.revoke')}
</ActionMenuItem>
)}
{status !== OrganizationInvitationStatus.Pending && (
<ActionMenuItem
icon={<Delete />}
type="danger"
onClick={() => {
void handleDelete(id);
}}
>
{t('menu_options.delete_invitation_record')}
</ActionMenuItem>
)}
</ActionMenu>
),
},
{status !== OrganizationInvitationStatus.Accepted && canInviteMember && (
<ActionMenuItem
icon={<Invite />}
onClick={async () => {
await cloudApi.post(
'/api/tenants/:tenantId/invitations/:invitationId/message',
{
params: { tenantId: currentTenantId, invitationId: id },
}
);
toast.success(t('messages.invitation_sent'));
}}
>
{t('menu_options.resend_invite')}
</ActionMenuItem>
)}
{status === OrganizationInvitationStatus.Pending && canRemoveMember && (
<ActionMenuItem
icon={<Redo />}
type="danger"
onClick={() => {
void handleRevoke(id);
}}
>
{t('menu_options.revoke')}
</ActionMenuItem>
)}
{status !== OrganizationInvitationStatus.Pending && canRemoveMember && (
<ActionMenuItem
icon={<Delete />}
type="danger"
onClick={() => {
void handleDelete(id);
}}
>
{t('menu_options.delete_invitation_record')}
</ActionMenuItem>
)}
</ActionMenu>
),
},
]
),
]}
rowIndexKey="id"
/>
{showInviteModal && (
{canInviteMember && (
<InviteMemberModal
isOpen={showInviteModal}
onClose={() => {

View file

@ -28,12 +28,9 @@ function InviteMemberModal({ isOpen, onClose }: Props) {
const { currentPlan } = useContext(SubscriptionDataContext);
const { currentTenantId, isDevTenant } = useContext(TenantsContext);
const tenantMembersMaxLimit = useMemo(() => {
if (isDevTenant) {
if (currentPlan.id === ReservedPlanId.Pro || isDevTenant) {
return 10;
}
if (currentPlan.id === ReservedPlanId.Pro) {
return 3;
}
// Free plan can only have 1 admin, no other members allowed.
return 1;
}, [currentPlan.id, isDevTenant]);

View file

@ -1,3 +1,4 @@
import { condArray, conditional } from '@silverhand/essentials';
import { useContext, useState } from 'react';
import { useTranslation } from 'react-i18next';
import useSWR from 'swr';
@ -12,6 +13,7 @@ import { TenantsContext } from '@/contexts/TenantsProvider';
import Table from '@/ds-components/Table';
import Tag from '@/ds-components/Tag';
import { type RequestError } from '@/hooks/use-api';
import useCurrentTenantScopes from '@/hooks/use-current-tenant-scopes';
import EditMemberModal from '../EditMemberModal';
@ -19,6 +21,7 @@ function Members() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.tenant_members' });
const cloudApi = useAuthedCloudApi();
const { currentTenantId } = useContext(TenantsContext);
const { canInviteMember, canRemoveMember, canUpdateMemberRole } = useCurrentTenantScopes();
const { data, error, isLoading, mutate } = useSWR<TenantMemberResponse[], RequestError>(
`api/tenant/${currentTenantId}/members`,
@ -59,35 +62,45 @@ function Members() {
));
},
},
{
dataIndex: 'actions',
title: null,
colSpan: 1,
render: (user) => (
<ActionsButton
deleteConfirmation="tenant_members.delete_user_confirm"
fieldName="tenant_members.user"
textOverrides={{
edit: 'tenant_members.menu_options.edit',
delete: 'tenant_members.menu_options.delete',
deleteConfirmation: 'general.remove',
}}
onEdit={() => {
setUserToBeEdited(user);
}}
onDelete={async () => {
await cloudApi.delete(`/api/tenants/:tenantId/members/:userId`, {
params: { tenantId: currentTenantId, userId: user.id },
});
void mutate();
}}
/>
),
},
...condArray(
(canUpdateMemberRole || canRemoveMember) && [
{
dataIndex: 'actions',
title: null,
colSpan: 1,
render: (user: TenantMemberResponse) => (
<ActionsButton
deleteConfirmation="tenant_members.delete_user_confirm"
fieldName="tenant_members.user"
textOverrides={{
edit: 'tenant_members.menu_options.edit',
delete: 'tenant_members.menu_options.delete',
deleteConfirmation: 'general.remove',
}}
onEdit={conditional(
canUpdateMemberRole &&
(() => {
setUserToBeEdited(user);
})
)}
onDelete={conditional(
canRemoveMember &&
(async () => {
await cloudApi.delete(`/api/tenants/:tenantId/members/:userId`, {
params: { tenantId: currentTenantId, userId: user.id },
});
void mutate();
})
)}
/>
),
},
]
),
]}
rowIndexKey="id"
/>
{userToBeEdited && (
{canUpdateMemberRole && userToBeEdited && (
<EditMemberModal
isOpen
user={userToBeEdited}

View file

@ -8,6 +8,7 @@ import PlusIcon from '@/assets/icons/plus.svg';
import { TenantSettingsTabs } from '@/consts';
import Button from '@/ds-components/Button';
import Spacer from '@/ds-components/Spacer';
import useCurrentTenantScopes from '@/hooks/use-current-tenant-scopes';
import useTenantPathname from '@/hooks/use-tenant-pathname';
import NotFound from '@/pages/NotFound';
@ -21,6 +22,7 @@ const invitationsRoute = 'invitations';
function TenantMembers() {
const { navigate, match } = useTenantPathname();
const [showInviteModal, setShowInviteModal] = useState(false);
const { canInviteMember } = useCurrentTenantScopes();
const isInvitationTab = match(
`/tenant-settings/${TenantSettingsTabs.Members}/${invitationsRoute}`
@ -37,31 +39,35 @@ function TenantMembers() {
navigate('.');
}}
/>
<Button
className={classNames(styles.button, isInvitationTab && styles.active)}
icon={<InvitationIcon />}
title="tenant_members.invitations"
onClick={() => {
navigate('invitations');
}}
/>
{canInviteMember && (
<Button
className={classNames(styles.button, isInvitationTab && styles.active)}
icon={<InvitationIcon />}
title="tenant_members.invitations"
onClick={() => {
navigate('invitations');
}}
/>
)}
<Spacer />
<Button
type="primary"
size="large"
icon={<PlusIcon />}
title="tenant_members.invite_members"
onClick={() => {
setShowInviteModal(true);
}}
/>
{canInviteMember && (
<Button
type="primary"
size="large"
icon={<PlusIcon />}
title="tenant_members.invite_members"
onClick={() => {
setShowInviteModal(true);
}}
/>
)}
</div>
<Routes>
<Route path="*" element={<NotFound />} />
<Route index element={<Members />} />
<Route path={invitationsRoute} element={<Invitations />} />
{canInviteMember && <Route path={invitationsRoute} element={<Invitations />} />}
</Routes>
{showInviteModal && (
{canInviteMember && (
<InviteMemberModal
isOpen={showInviteModal}
onClose={(isSuccessful) => {

View file

@ -7,11 +7,13 @@ import { TenantsContext } from '@/contexts/TenantsProvider';
import CardTitle from '@/ds-components/CardTitle';
import DynamicT from '@/ds-components/DynamicT';
import TabNav, { TabNavItem } from '@/ds-components/TabNav';
import useCurrentTenantScopes from '@/hooks/use-current-tenant-scopes';
import * as styles from './index.module.scss';
function TenantSettings() {
const { isDevTenant } = useContext(TenantsContext);
const { canManageTenant } = useCurrentTenantScopes();
return (
<div className={styles.container}>
@ -29,10 +31,12 @@ function TenantSettings() {
<DynamicT forKey="tenants.tabs.members" />
</TabNavItem>
)}
<TabNavItem href={`/tenant-settings/${TenantSettingsTabs.Domains}`}>
<DynamicT forKey="tenants.tabs.domains" />
</TabNavItem>
{!isDevTenant && (
{canManageTenant && (
<TabNavItem href={`/tenant-settings/${TenantSettingsTabs.Domains}`}>
<DynamicT forKey="tenants.tabs.domains" />
</TabNavItem>
)}
{!isDevTenant && canManageTenant && (
<>
<TabNavItem href={`/tenant-settings/${TenantSettingsTabs.Subscription}`}>
<DynamicT forKey="tenants.tabs.subscription" />