0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

feat(console): update user access immediately on tenant role updates (#5720)

* feat(console): update user access immediately on tenant role updates

* chore: improve comments

Co-authored-by: Gao Sun <gao@silverhand.io>

---------

Co-authored-by: Gao Sun <gao@silverhand.io>
This commit is contained in:
Charles Zhao 2024-04-17 00:31:34 +08:00 committed by GitHub
parent 75deb2db04
commit 59acedeecd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 164 additions and 100 deletions

View file

@ -48,6 +48,6 @@
"access": "public"
},
"devDependencies": {
"@logto/cloud": "0.2.5-ab8a489"
"@logto/cloud": "0.2.5-821690c"
}
}

View file

@ -28,13 +28,13 @@
"@fontsource/roboto-mono": "^5.0.0",
"@jest/types": "^29.5.0",
"@logto/app-insights": "workspace:^1.4.0",
"@logto/cloud": "0.2.5-94f7bcc",
"@logto/cloud": "0.2.5-821690c",
"@logto/connector-kit": "workspace:^3.0.0",
"@logto/core-kit": "workspace:^2.4.0",
"@logto/language-kit": "workspace:^1.1.0",
"@logto/phrases": "workspace:^1.10.0",
"@logto/phrases-experience": "workspace:^1.6.1",
"@logto/react": "^3.0.5",
"@logto/react": "^3.0.8",
"@logto/schemas": "workspace:^1.15.0",
"@logto/shared": "workspace:^3.1.0",
"@mdx-js/react": "^1.6.22",

View file

@ -0,0 +1,59 @@
import { Prompt, useLogto } from '@logto/react';
import { getTenantOrganizationId } from '@logto/schemas';
import { useContext, useEffect, useState } from 'react';
import { TenantsContext } from '@/contexts/TenantsProvider';
import useCurrentTenantScopes from '@/hooks/use-current-tenant-scopes';
import useRedirectUri from '@/hooks/use-redirect-uri';
import { saveRedirect } from '@/utils/storage';
/**
* Listens to the tenant scope changes for the current signed-in user. This hook will fetch the tenant scopes
* for the user, and compare it with the "scope" token claim in access token. After comparing the scopes:
*
* - If the user has been granted new scopes, it will re-consent to obtain the additional scopes.
* - If the user has been revoked scopes, it will clear the cached access token and renew one with shrunk scopes.
*
* Note: This hook should only be used once in the ConsoleContent component.
*/
const useTenantScopeListener = () => {
const { currentTenantId } = useContext(TenantsContext);
const { clearAccessToken, clearAllTokens, getOrganizationTokenClaims, signIn } = useLogto();
const [tokenClaims, setTokenClaims] = useState<string[]>();
const redirectUri = useRedirectUri();
const { scopes = [], isLoading } = useCurrentTenantScopes();
useEffect(() => {
(async () => {
const organizationId = getTenantOrganizationId(currentTenantId);
const claims = await getOrganizationTokenClaims(organizationId);
setTokenClaims(claims?.scope?.split(' ') ?? []);
})();
}, [currentTenantId, getOrganizationTokenClaims]);
useEffect(() => {
if (isLoading || tokenClaims === undefined) {
return;
}
const hasScopesGranted = scopes.some((scope) => !tokenClaims.includes(scope));
const hasScopesRevoked = tokenClaims.some((claim) => !scopes.includes(claim));
if (hasScopesGranted) {
(async () => {
// User has been newly granted scopes. Need to re-consent to obtain the additional scopes.
saveRedirect();
await clearAllTokens();
void signIn({
redirectUri: redirectUri.href,
prompt: Prompt.Consent,
});
})();
}
if (hasScopesRevoked) {
// User has been revoked scopes. Need to clear the cached access token and it will be renewed
// automatically with shrunk scopes.
void clearAccessToken();
}
}, [clearAccessToken, clearAllTokens, isLoading, redirectUri.href, scopes, signIn, tokenClaims]);
};
export default useTenantScopeListener;

View file

@ -6,11 +6,14 @@ import { useConsoleRoutes } from '@/hooks/use-console-routes';
import type { AppContentOutletContext } from '../AppContent/types';
import Sidebar from './Sidebar';
import useTenantScopeListener from './hooks';
import * as styles from './index.module.scss';
function ConsoleContent() {
const { scrollableContent } = useOutletContext<AppContentOutletContext>();
const routes = useConsoleRoutes();
// Use this hook here to make sure console listens to user tenant scope changes.
useTenantScopeListener();
return (
<div className={styles.content}>

View file

@ -14,7 +14,9 @@ import TenantMembers from '@/pages/TenantSettings/TenantMembers';
export const useTenantSettings = () => {
const { isDevTenant } = useContext(TenantsContext);
const { canManageTenant } = useCurrentTenantScopes();
const {
access: { canManageTenant },
} = useCurrentTenantScopes();
const tenantSettings: RouteObject = useMemo(
() => ({

View file

@ -1,61 +1,52 @@
import { useLogto } from '@logto/react';
import { TenantScope, getTenantOrganizationId } from '@logto/schemas';
import { useContext, useEffect, useState } from 'react';
import { TenantScope } from '@logto/schemas';
import { useContext, useMemo } from 'react';
import useSWR from 'swr';
import { useAuthedCloudApi } from '@/cloud/hooks/use-cloud-api';
import { TenantsContext } from '@/contexts/TenantsProvider';
import { type RequestError } from './use-api';
import useCurrentUser from './use-current-user';
const useCurrentTenantScopes = () => {
const { currentTenantId, isInitComplete } = useContext(TenantsContext);
const { isAuthenticated, getOrganizationTokenClaims } = useLogto();
const cloudApi = useAuthedCloudApi();
const { user } = useCurrentUser();
const userId = user?.id ?? '';
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);
const {
data: scopes,
isLoading,
mutate,
} = useSWR<string[], RequestError>(
userId && isInitComplete && `api/tenants/${currentTenantId}/members/${userId}/scopes`,
async () => {
const scopes = await cloudApi.get('/api/tenants/:tenantId/members/:userId/scopes', {
params: { tenantId: currentTenantId, userId },
});
return scopes.map(({ name }) => name);
}
);
useEffect(() => {
(async () => {
if (isAuthenticated && isInitComplete) {
const organizationId = getTenantOrganizationId(currentTenantId);
const claims = await getOrganizationTokenClaims(organizationId);
const allScopes = claims?.scope?.split(' ') ?? [];
setScopes(allScopes);
const access = useMemo(
() => ({
canInviteMember: Boolean(scopes?.includes(TenantScope.InviteMember)),
canRemoveMember: Boolean(scopes?.includes(TenantScope.RemoveMember)),
canUpdateMemberRole: Boolean(scopes?.includes(TenantScope.UpdateMemberRole)),
canManageTenant: Boolean(scopes?.includes(TenantScope.ManageTenant)),
}),
[scopes]
);
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,
};
return useMemo(
() => ({
isLoading,
scopes,
access,
mutate,
}),
[isLoading, scopes, access, mutate]
);
};
export default useCurrentTenantScopes;

View file

@ -16,7 +16,9 @@ type Props = {
};
function ProfileForm({ currentTenantId }: Props) {
const { canManageTenant } = useCurrentTenantScopes();
const {
access: { canManageTenant },
} = useCurrentTenantScopes();
const {
register,
formState: { errors },

View file

@ -30,7 +30,9 @@ const tenantProfileToForm = (tenant?: TenantResponse): TenantSettingsForm => {
function TenantBasicSettings() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { canManageTenant } = useCurrentTenantScopes();
const {
access: { canManageTenant },
} = useCurrentTenantScopes();
const api = useCloudApi();
const {
currentTenant,

View file

@ -23,7 +23,9 @@ function TenantDomainSettings() {
const { data: customDomain, isLoading: isLoadingCustomDomain, mutate } = useCustomDomain(true);
const { getDocumentationUrl } = useDocumentationUrl();
const api = useApi();
const { canManageTenant } = useCurrentTenantScopes();
const {
access: { canManageTenant },
} = useCurrentTenantScopes();
if (isLoadingCustomDomain) {
return <Skeleton />;

View file

@ -12,6 +12,7 @@ import FormField from '@/ds-components/FormField';
import ModalLayout from '@/ds-components/ModalLayout';
import Select, { type Option } from '@/ds-components/Select';
import { useConfirmModal } from '@/hooks/use-confirm-modal';
import useCurrentTenantScopes from '@/hooks/use-current-tenant-scopes';
import * as modalStyles from '@/scss/modal.module.scss';
type Props = {
@ -23,6 +24,7 @@ type Props = {
function EditMemberModal({ user, isOpen, onClose }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.tenant_members' });
const { currentTenantId } = useContext(TenantsContext);
const { mutate: mutateUserTenantScopes } = useCurrentTenantScopes();
const [isLoading, setIsLoading] = useState(false);
const [role, setRole] = useState(TenantRole.Collaborator);
@ -57,6 +59,7 @@ function EditMemberModal({ user, isOpen, onClose }: Props) {
params: { tenantId: currentTenantId, userId: user.id },
body: { roleName: role },
});
void mutateUserTenantScopes();
onClose();
} finally {
setIsLoading(false);

View file

@ -53,7 +53,9 @@ function Invitations() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.tenant_members' });
const cloudApi = useAuthedCloudApi();
const { currentTenantId } = useContext(TenantsContext);
const { canInviteMember, canRemoveMember } = useCurrentTenantScopes();
const {
access: { canInviteMember, canRemoveMember },
} = useCurrentTenantScopes();
const { data, error, isLoading, mutate } = useSWR<TenantInvitationResponse[], RequestError>(
`api/tenants/${currentTenantId}/invitations`,

View file

@ -24,7 +24,9 @@ function Members() {
const cloudApi = useAuthedCloudApi();
const { currentTenantId } = useContext(TenantsContext);
const { user: currentUser } = useCurrentUser();
const { canRemoveMember, canUpdateMemberRole } = useCurrentTenantScopes();
const {
access: { canRemoveMember, canUpdateMemberRole },
} = useCurrentTenantScopes();
const { data, error, isLoading, mutate } = useSWR<TenantMemberResponse[], RequestError>(
`api/tenants/${currentTenantId}/members`,

View file

@ -13,7 +13,9 @@ import { hasReachedQuotaLimit, hasSurpassedQuotaLimit } from '@/utils/quota';
const useTenantMembersUsage = () => {
const { currentPlan } = useContext(SubscriptionDataContext);
const { currentTenantId } = useContext(TenantsContext);
const { canInviteMember } = useCurrentTenantScopes();
const {
access: { canInviteMember },
} = useCurrentTenantScopes();
const cloudApi = useAuthedCloudApi();

View file

@ -30,7 +30,9 @@ function TenantMembers() {
const { hasTenantMembersSurpassedLimit } = useTenantMembersUsage();
const { navigate, match } = useTenantPathname();
const [showInviteModal, setShowInviteModal] = useState(false);
const { canInviteMember } = useCurrentTenantScopes();
const {
access: { canInviteMember },
} = useCurrentTenantScopes();
const isInvitationTab = match(
`/tenant-settings/${TenantSettingsTabs.Members}/${invitationsRoute}`

View file

@ -12,7 +12,9 @@ import * as styles from './index.module.scss';
function TenantSettings() {
const { isDevTenant } = useContext(TenantsContext);
const { canManageTenant } = useCurrentTenantScopes();
const {
access: { canManageTenant },
} = useCurrentTenantScopes();
return (
<div className={styles.container}>

View file

@ -92,7 +92,7 @@
"zod": "^3.22.4"
},
"devDependencies": {
"@logto/cloud": "0.2.5-94f7bcc",
"@logto/cloud": "0.2.5-821690c",
"@silverhand/eslint-config": "5.0.0",
"@silverhand/ts-config": "5.0.0",
"@types/debug": "^4.1.7",

View file

@ -22,7 +22,7 @@
"@logto/core-kit": "workspace:^2.4.0",
"@logto/language-kit": "workspace:^1.1.0",
"@logto/phrases": "workspace:^1.10.0",
"@logto/react": "^3.0.5",
"@logto/react": "^3.0.8",
"@logto/schemas": "workspace:^1.15.0",
"@parcel/core": "2.9.3",
"@parcel/transformer-sass": "2.9.3",

View file

@ -27,7 +27,7 @@
"@logto/connector-kit": "workspace:^3.0.0",
"@logto/core-kit": "workspace:^",
"@logto/js": "^4.1.1",
"@logto/node": "^2.4.4",
"@logto/node": "^2.4.7",
"@logto/schemas": "workspace:^1.15.0",
"@logto/shared": "workspace:^3.1.0",
"@silverhand/eslint-config": "5.0.0",

View file

@ -1235,8 +1235,8 @@ importers:
version: 3.22.4
devDependencies:
'@logto/cloud':
specifier: 0.2.5-ab8a489
version: 0.2.5-ab8a489(zod@3.22.4)
specifier: 0.2.5-821690c
version: 0.2.5-821690c(zod@3.22.4)
'@rollup/plugin-commonjs':
specifier: ^25.0.0
version: 25.0.7(rollup@4.12.0)
@ -2715,8 +2715,8 @@ importers:
specifier: workspace:^1.4.0
version: link:../app-insights
'@logto/cloud':
specifier: 0.2.5-94f7bcc
version: 0.2.5-94f7bcc(zod@3.22.4)
specifier: 0.2.5-821690c
version: 0.2.5-821690c(zod@3.22.4)
'@logto/connector-kit':
specifier: workspace:^3.0.0
version: link:../toolkit/connector-kit
@ -2733,8 +2733,8 @@ importers:
specifier: workspace:^1.6.1
version: link:../phrases-experience
'@logto/react':
specifier: ^3.0.5
version: 3.0.5(react@18.2.0)
specifier: ^3.0.8
version: 3.0.8(react@18.2.0)
'@logto/schemas':
specifier: workspace:^1.15.0
version: link:../schemas
@ -3205,8 +3205,8 @@ importers:
version: 3.22.4
devDependencies:
'@logto/cloud':
specifier: 0.2.5-94f7bcc
version: 0.2.5-94f7bcc(zod@3.22.4)
specifier: 0.2.5-821690c
version: 0.2.5-821690c(zod@3.22.4)
'@silverhand/eslint-config':
specifier: 5.0.0
version: 5.0.0(eslint@8.44.0)(prettier@3.0.0)(typescript@5.3.3)
@ -3319,8 +3319,8 @@ importers:
specifier: workspace:^1.10.0
version: link:../phrases
'@logto/react':
specifier: ^3.0.5
version: 3.0.5(react@18.2.0)
specifier: ^3.0.8
version: 3.0.8(react@18.2.0)
'@logto/schemas':
specifier: workspace:^1.15.0
version: link:../schemas
@ -3638,8 +3638,8 @@ importers:
specifier: ^4.1.1
version: 4.1.1
'@logto/node':
specifier: ^2.4.4
version: 2.4.4
specifier: ^2.4.7
version: 2.4.7
'@logto/schemas':
specifier: workspace:^1.15.0
version: link:../schemas
@ -7630,16 +7630,16 @@ packages:
tiny-cookie: 2.4.1
dev: false
/@logto/browser@2.2.7:
resolution: {integrity: sha512-+tB4QWB4/JSO5pXItX491mRR4Id5dsYlEJchI0gPC8JNX7cl4968/oDXhqQ42XWqFnqX3W5Wx7RgKeV6JtTMhg==}
/@logto/browser@2.2.10:
resolution: {integrity: sha512-y6NauaxctqpfApccP6uFVmpg/vG1OhsDVLD4Pdpzbmj3whl63Nb17yxSTQHt4eYNKmSZJ2SzudAnMnVEYD91iQ==}
dependencies:
'@logto/client': 2.6.3
'@logto/client': 2.6.6
'@silverhand/essentials': 2.9.0
js-base64: 3.7.5
dev: true
/@logto/client@2.6.3:
resolution: {integrity: sha512-uZphb17TZD2rXTiYfhPaIpiavMbUec+WwznIWIm2wJ9x4th8UO05egw9eTPiSaoEOZSuoPs6oWBROP1SQ00iBg==}
/@logto/client@2.6.6:
resolution: {integrity: sha512-QT7jMnzEIWHBNrf9/M8p1OErRBbbNZjoekXGji5aZCyUh975hh8+GEBL21HV71FT3H/5Cq4Gf1GzUbAIW3izMA==}
dependencies:
'@logto/js': 4.1.1
'@silverhand/essentials': 2.9.0
@ -7647,18 +7647,8 @@ packages:
jose: 5.2.2
dev: true
/@logto/cloud@0.2.5-94f7bcc(zod@3.22.4):
resolution: {integrity: sha512-1nY3o1/gXgEIqgvjel2no0X3rR+BGnfozB7Vev+FY2qTkDyQIWRtHAnx+kkv4iEIIFcZW86LRNlvfjDUqR2yIg==}
engines: {node: ^20.9.0}
dependencies:
'@silverhand/essentials': 2.9.0
'@withtyped/server': 0.13.3(zod@3.22.4)
transitivePeerDependencies:
- zod
dev: true
/@logto/cloud@0.2.5-ab8a489(zod@3.22.4):
resolution: {integrity: sha512-nUD1n2CDe/nu6x4cOhXfJ5VyKKDqkKv+a/u9zSfbIMxIF0nShybd2LiCYJDO0SPuMqLnmlYFg+79KrdPCNvjIQ==}
/@logto/cloud@0.2.5-821690c(zod@3.22.4):
resolution: {integrity: sha512-eVTlJxknWbvmaeaitKzPPMTx6C4GK4TLTb97hFr91E2u6SwKP+csE3oMBgL7ZdoDLOGG+nY+j08JpVMQ8QdOWw==}
engines: {node: ^20.9.0}
dependencies:
'@silverhand/essentials': 2.9.0
@ -7674,20 +7664,20 @@ packages:
camelcase-keys: 7.0.2
dev: true
/@logto/node@2.4.4:
resolution: {integrity: sha512-3qkhXQKGZX5cVBfWT6n2l0kN9ln3fPShXngHaY5LTBBRd0b2e20h1XIrXCdoGoMmdSp1zntEo2PMv0+fBodzcw==}
/@logto/node@2.4.7:
resolution: {integrity: sha512-AlANeqY1NIt93EBcRzrTmyAVHXOHpszTJK+qe1ok50rmZlTmX2p7yQvrg0/Ehwf/+4Rla5vooAR+HIFMaOmPpQ==}
dependencies:
'@logto/client': 2.6.3
'@logto/client': 2.6.6
'@silverhand/essentials': 2.9.0
js-base64: 3.7.5
dev: true
/@logto/react@3.0.5(react@18.2.0):
resolution: {integrity: sha512-oCwKBGRf79QRo/MixPi8C8myZwHOx7eMon3/05nho0iiwBPllI2zSUJ7jUOnlFFnKTOLYV03l8pEMFnF+ODKyw==}
/@logto/react@3.0.8(react@18.2.0):
resolution: {integrity: sha512-p3pV4rX4g8ZwHQ159mxI+pP3Bwome47dNEmP1hI8/10WqdIPXGYTnfYn5c2l4Y2DyslYyK3ur2Sy4i4K6ept9A==}
peerDependencies:
react: '>=16.8.0 || ^18.0.0'
dependencies:
'@logto/browser': 2.2.7
'@logto/browser': 2.2.10
'@silverhand/essentials': 2.9.0
react: 18.2.0
dev: true