diff --git a/src/hooks.ts b/src/hooks.ts index b178e82..4f3239c 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -5,7 +5,7 @@ import { ThunkAction } from "redux-thunk"; import { useBentoAuthContext } from "./contexts"; import { Resource, makeResourceKey } from "./resources"; -import { fetchResourcePermissions, refreshTokens, tokenHandoff } from "./redux/authSlice"; +import { AuthSliceState, fetchResourcesPermissions, refreshTokens, tokenHandoff } from "./redux/authSlice"; import { LS_SIGN_IN_POPUP, createAuthURL } from "./performAuth"; import { fetchOpenIdConfigurationIfNecessary } from "./redux/openIdConfigSlice"; import { getIsAuthenticated, logMissingAuthContext, makeAuthorizationHeader } from "./utils"; @@ -16,47 +16,66 @@ const AUTH_RESULT_TYPE = "authResult"; type MessageHandlerFunc = (e: MessageEvent) => void; +export const useAuthState = (): AuthSliceState => useSelector((state: RootState) => state.auth); + export const useIsAuthenticated = () => { - const { idTokenContents } = useSelector((state: RootState) => state.auth); + const { idTokenContents } = useAuthState(); return getIsAuthenticated(idTokenContents); }; -export const useAccessToken = () => useSelector((state: RootState) => state.auth.accessToken); +export const useAccessToken = () => useAuthState().accessToken; export const useAuthorizationHeader = () => { const accessToken = useAccessToken(); return useMemo(() => makeAuthorizationHeader(accessToken), [accessToken]); }; -export const useResourcePermissions = (resource: Resource, authzUrl: string) => { +export const useResourcesPermissions = (resources: Resource[], authzUrl: string) => { const dispatch: AppDispatch = useDispatch(); const haveAuthorizationService = !!authzUrl; - const key = useMemo(() => makeResourceKey(resource), [resource]); + const keys = useMemo(() => resources.map((resource) => makeResourceKey(resource)), [resources]); - const { permissions, isFetching, hasAttempted, error } = - useSelector((state: RootState) => state.auth.resourcePermissions?.[key]) ?? {}; + const { resourcePermissions } = useAuthState(); useEffect(() => { - if (!haveAuthorizationService || isFetching || permissions || hasAttempted) return; - dispatch(fetchResourcePermissions({ resource, authzUrl })); + const anyFetching = keys.some((key) => !!resourcePermissions[key]?.isFetching); + const allHavePermissions = keys.every((key) => !!resourcePermissions[key]?.permissions?.length); + const allAttempted = keys.every((key) => !!resourcePermissions[key]?.hasAttempted); + + // If any permissions are currently fetching, or all requested permissions have already been tried/returned, we + // don't need to dispatch the fetch action: + if (!haveAuthorizationService || anyFetching || allHavePermissions || allAttempted) return; + + dispatch(fetchResourcesPermissions({ resources, authzUrl })); }, [ dispatch, haveAuthorizationService, - isFetching, - permissions, - hasAttempted, - resource, + keys, + resourcePermissions, authzUrl, ]); - return { - permissions: permissions ?? [], - isFetching: isFetching ?? false, - hasAttempted: hasAttempted ?? false, - error: error ?? "", - }; + // Construct an object with resource keys yielding an object containing the permissions on the object + return useMemo(() => Object.fromEntries(keys.map((key) => { + const { permissions, isFetching, hasAttempted, error } = resourcePermissions[key] ?? {}; + return [ + key, + { + permissions: permissions ?? [], + isFetching: isFetching ?? false, + hasAttempted: hasAttempted ?? false, + error: error ?? "", + }, + ]; + })), [keys, resourcePermissions]); +}; + +export const useResourcePermissions = (resource: Resource, authzUrl: string) => { + const key = makeResourceKey(resource); + const resourcesPermissions = useResourcesPermissions([resource], authzUrl); + return resourcesPermissions[key]; }; export const useHasResourcePermission = (resource: Resource, authzUrl: string, permission: string) => { diff --git a/src/index.ts b/src/index.ts index 8f0bb79..6f652e1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,7 +10,7 @@ export * from "./utils"; export { default as AuthReducer, - fetchResourcePermissions, + fetchResourcesPermissions, refreshTokens, signOut, tokenHandoff diff --git a/src/performSignOut.ts b/src/performSignOut.ts index 64a4ebc..360e788 100644 --- a/src/performSignOut.ts +++ b/src/performSignOut.ts @@ -1,18 +1,16 @@ import { useCallback } from "react"; -import { useDispatch, useSelector } from "react-redux"; +import { useDispatch } from "react-redux"; import { useBentoAuthContext } from "./contexts"; -import { useOpenIdConfig } from "./hooks"; +import { useAuthState, useOpenIdConfig } from "./hooks"; import { setLSNotSignedIn } from "./performAuth"; import { signOut } from "./redux/authSlice"; import { logMissingAuthContext } from "./utils"; -import type { RootState } from "./redux/store"; - export const usePerformSignOut = () => { const dispatch = useDispatch(); const { clientId, postSignOutUrl } = useBentoAuthContext(); - const { idToken } = useSelector((state: RootState) => state.auth); + const { idToken } = useAuthState(); const openIdConfig = useOpenIdConfig(); const endSessionEndpoint = openIdConfig?.end_session_endpoint; diff --git a/src/redux/authSlice.ts b/src/redux/authSlice.ts index 111b669..0f607e7 100644 --- a/src/redux/authSlice.ts +++ b/src/redux/authSlice.ts @@ -102,31 +102,36 @@ export const refreshTokens = createAsyncThunk( } ); -type FetchPermissionPayload = { +type FetchPermissionsPayload = { result: string[][]; }; -type FetchPermissionParams = { - resource: Resource; +type FetchPermissionsParams = { + resources: Resource[]; authzUrl: string; } -export const fetchResourcePermissions = createAsyncThunk( - "auth/FETCH_RESOURCE_PERMISSIONS", - async ({ resource, authzUrl }: FetchPermissionParams, { getState }) => { +export const fetchResourcesPermissions = createAsyncThunk( + "auth/FETCH_RESOURCES_PERMISSIONS", + async ({ resources, authzUrl }: FetchPermissionsParams, { getState }) => { const url = `${authzUrl}/policy/permissions`; const { auth } = getState() as RootState; const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", ...makeAuthorizationHeader(auth.accessToken) }, - body: JSON.stringify({resources: [resource]}), + body: JSON.stringify({ resources }), }); return await response.json(); }, { - condition: ({ resource }, { getState }) => { + condition: ({ resources }, { getState }) => { + // allow action to fire if all requested resource permission-sets are not being fetched right now: + const { auth } = getState() as RootState; - const key = makeResourceKey(resource); - const rp = auth.resourcePermissions?.[key]; - return !rp?.isFetching; + + return resources.every((resource) => { + const key = makeResourceKey(resource); + const rp = auth.resourcePermissions?.[key]; + return !rp?.isFetching; + }); }, } ); @@ -258,37 +263,45 @@ export const authSlice = createSlice({ setLSNotSignedIn(); }) - .addCase(fetchResourcePermissions.pending, (state, { meta }) => { - const key = makeResourceKey(meta.arg.resource); - state.resourcePermissions[key] = { - ...state.resourcePermissions[key], - isFetching: true, - hasAttempted: false, - permissions: [], - error: "", - }; + .addCase(fetchResourcesPermissions.pending, (state, { meta }) => { + for (const resource of meta.arg.resources) { + const key = makeResourceKey(resource); + state.resourcePermissions[key] = { + ...state.resourcePermissions[key], + isFetching: true, + hasAttempted: false, + permissions: [], + error: "", + }; + } }) - .addCase(fetchResourcePermissions.fulfilled, (state, { meta, payload }) => { - const key = makeResourceKey(meta.arg.resource); - state.resourcePermissions[key] = { - ...state.resourcePermissions[key], - isFetching: false, - hasAttempted: true, - permissions: payload?.result?.[0] ?? [], - }; + .addCase(fetchResourcesPermissions.fulfilled, (state, { meta, payload }) => { + const resources = meta.arg.resources; + for (const r in resources) { + const key = makeResourceKey(resources[r]); + state.resourcePermissions[key] = { + ...state.resourcePermissions[key], + isFetching: false, + hasAttempted: true, + permissions: payload?.result?.[r] ?? [], + }; + } }) - .addCase(fetchResourcePermissions.rejected, (state, { meta, error }) => { - const key = makeResourceKey(meta.arg.resource); + .addCase(fetchResourcesPermissions.rejected, (state, { meta, error }) => { if (error) console.error(error); - const permissionsError = error.message ?? - "An error occurred while fetching permissions for a resource"; - state.resourcePermissions[key] = { - ...state.resourcePermissions[key], - isFetching: false, - hasAttempted: true, - error: permissionsError, - }; + for (const resource of meta.arg.resources) { + const key = makeResourceKey(resource); + + const permissionsError = error.message ?? + "An error occurred while fetching permissions for a resource"; + state.resourcePermissions[key] = { + ...state.resourcePermissions[key], + isFetching: false, + hasAttempted: true, + error: permissionsError, + }; + } }); }, });