From 7c45b19dd4ae032d4a6c8110de9460539dc07f25 Mon Sep 17 00:00:00 2001 From: pfvatterott Date: Fri, 20 Jun 2025 11:10:37 -0600 Subject: [PATCH 1/2] Active Org --- src/AuthContext.tsx | 123 +++++++++++++++++++++++++++++++++- src/AuthContextForTesting.tsx | 2 + src/hooks/useAuthInfo.ts | 16 ++++- src/withAuthInfo.tsx | 12 +++- src/withRequiredAuthInfo.tsx | 4 +- 5 files changed, 151 insertions(+), 6 deletions(-) diff --git a/src/AuthContext.tsx b/src/AuthContext.tsx index 0a8ef77..95aa3f2 100644 --- a/src/AuthContext.tsx +++ b/src/AuthContext.tsx @@ -7,8 +7,9 @@ import { RedirectToOrgPageOptions, RedirectToSetupSAMLPageOptions, RedirectToSignupOptions, + OrgMemberInfoClass } from "@propelauth/javascript" -import React, { useCallback, useEffect, useReducer } from "react" +import React, { useCallback, useEffect, useReducer, useState } from "react" import { loadOrgSelectionFromLocalStorage } from "./hooks/useActiveOrg" import { useClientRef, useClientRefCallback } from "./useClientRef" @@ -41,6 +42,10 @@ export interface InternalAuthState { authUrl: string tokens: Tokens + + activeOrg: OrgMemberInfoClass | undefined + setActiveOrg: (orgId: string) => Promise + refreshAuthInfo: () => Promise defaultDisplayWhileLoading?: React.ReactElement defaultDisplayIfLoggedOut?: React.ReactElement @@ -56,6 +61,7 @@ export type AuthProviderProps = { getActiveOrgFn?: () => string | null children?: React.ReactNode minSecondsBeforeRefresh?: number + useLocalStorageForActiveOrg?: boolean } export interface RequiredAuthProviderProps @@ -101,6 +107,60 @@ function authInfoStateReducer(_state: AuthInfoState, action: AuthInfoStateAction } } +const ACTIVE_ORG_KEY = 'activeOrgId'; + +const getStoredActiveOrgId = (): string | null => { + try { + return localStorage.getItem(ACTIVE_ORG_KEY); + } catch (error) { + console.warn('Failed to read from localStorage:', error); + return null; + } +}; + +const setStoredActiveOrgId = (orgId: string): void => { + try { + localStorage.setItem(ACTIVE_ORG_KEY, orgId); + } catch (error) { + console.warn('Failed to write to localStorage:', error); + } +}; + +const removeStoredActiveOrgId = (): void => { + try { + localStorage.removeItem(ACTIVE_ORG_KEY); + } catch (error) { + console.warn('Failed to remove from localStorage:', error); + } +}; + +const useLocalStorageSync = (key: string): string | null => { + const [value, setValue] = useState(() => { + try { + return localStorage.getItem(key); + } catch (error) { + console.warn('Failed to read from localStorage:', error); + return null; + } + }); + + useEffect(() => { + const handleStorageChange = (e: StorageEvent) => { + if (e.key === key) { + setValue(e.newValue); + } + }; + + window.addEventListener('storage', handleStorageChange); + + return () => { + window.removeEventListener('storage', handleStorageChange); + }; + }, [key]); + + return value; +}; + export const AuthProvider = (props: AuthProviderProps) => { const { authUrl, @@ -109,8 +169,11 @@ export const AuthProvider = (props: AuthProviderProps) => { children, defaultDisplayWhileLoading, defaultDisplayIfLoggedOut, + useLocalStorageForActiveOrg } = props + const storedActiveOrgId = useLocalStorageSync(ACTIVE_ORG_KEY); const [authInfoState, dispatch] = useReducer(authInfoStateReducer, initialAuthInfoState) + const [activeOrg, setActiveOrgState] = useState(); const { clientRef, accessTokenChangeCounter } = useClientRef({ authUrl, minSecondsBeforeRefresh, @@ -142,6 +205,13 @@ export const AuthProvider = (props: AuthProviderProps) => { } }, [accessTokenChangeCounter]) + // Re-render when stored active org is updated + useEffect(() => { + if (storedActiveOrgId && useLocalStorageForActiveOrg) { + setActiveOrg(storedActiveOrgId) + } + }, [storedActiveOrgId]) + // Deprecation warning useEffect(() => { if (deprecatedGetActiveOrgFn) { @@ -149,6 +219,7 @@ export const AuthProvider = (props: AuthProviderProps) => { } }, []) + const logout = useClientRefCallback(clientRef, (client) => client.logout) const redirectToLoginPage = useClientRefCallback(clientRef, (client) => client.redirectToLoginPage) const redirectToSignupPage = useClientRefCallback(clientRef, (client) => client.redirectToSignupPage) @@ -182,6 +253,54 @@ export const AuthProvider = (props: AuthProviderProps) => { dispatch({ authInfo }) }, [dispatch]) + + const setActiveOrg = async (orgId: string) => { + if (authInfoState.authInfo === null) { + return false + } + + const authInfo = authInfoState.authInfo + const userClass = authInfo?.userClass + + if (userClass.getOrg(orgId)) { + if (useLocalStorageForActiveOrg) { + setStoredActiveOrgId(orgId); + } + setActiveOrgState(userClass.getOrg(orgId)); + return true + } else { + if (useLocalStorageForActiveOrg) { + removeStoredActiveOrgId(); + } + setActiveOrgState(undefined); + return false + } + }; + + const getActiveOrg = () => { + if (authInfoState.authInfo === null) { + return undefined + } + + const authInfo = authInfoState.authInfo + const userClass = authInfo?.userClass + + if (!activeOrg && useLocalStorageForActiveOrg) { + const activeOrgIdFromLocalStorage = getStoredActiveOrgId() + if (activeOrgIdFromLocalStorage) { + return userClass.getOrg(activeOrgIdFromLocalStorage) + } + } + + if (activeOrg && userClass.getOrg(activeOrg.orgId)) { + return activeOrg + } else { + return undefined + } + } + + + // TODO: Remove this, as both `getActiveOrgFn` and `loadOrgSelectionFromLocalStorage` are deprecated. const deprecatedActiveOrgFn = deprecatedGetActiveOrgFn || loadOrgSelectionFromLocalStorage @@ -206,6 +325,8 @@ export const AuthProvider = (props: AuthProviderProps) => { getSetupSAMLPageUrl, authUrl, refreshAuthInfo, + activeOrg: getActiveOrg(), + setActiveOrg, tokens: { getAccessTokenForOrg, getAccessToken, diff --git a/src/AuthContextForTesting.tsx b/src/AuthContextForTesting.tsx index 980e132..9107c61 100644 --- a/src/AuthContextForTesting.tsx +++ b/src/AuthContextForTesting.tsx @@ -79,6 +79,8 @@ export const AuthProviderForTesting = ({ getAccessTokenForOrg: getAccessTokenForOrg, getAccessToken: () => Promise.resolve(userInformation?.accessToken ?? "ACCESS_TOKEN"), }, + activeOrg: undefined, + setActiveOrg: () => Promise.resolve(false) } return {children} diff --git a/src/hooks/useAuthInfo.ts b/src/hooks/useAuthInfo.ts index 576f4de..d84b0d2 100644 --- a/src/hooks/useAuthInfo.ts +++ b/src/hooks/useAuthInfo.ts @@ -1,4 +1,4 @@ -import { AccessHelper, OrgHelper, User, UserClass } from "@propelauth/javascript" +import { AccessHelper, OrgHelper, OrgMemberInfoClass, User, UserClass } from "@propelauth/javascript" import { useContext } from "react" import { AuthContext, Tokens } from "../AuthContext" @@ -15,6 +15,8 @@ export type UseAuthInfoLoading = { refreshAuthInfo: () => Promise tokens: Tokens accessTokenExpiresAtSeconds: undefined + activeOrg: undefined + setActiveOrg: undefined } export type UseAuthInfoLoggedInProps = { @@ -30,6 +32,8 @@ export type UseAuthInfoLoggedInProps = { refreshAuthInfo: () => Promise tokens: Tokens accessTokenExpiresAtSeconds: number + activeOrg: OrgMemberInfoClass | undefined + setActiveOrg: (orgId: string) => Promise } export type UseAuthInfoNotLoggedInProps = { @@ -45,6 +49,8 @@ export type UseAuthInfoNotLoggedInProps = { refreshAuthInfo: () => Promise tokens: Tokens accessTokenExpiresAtSeconds: undefined + activeOrg: undefined + setActiveOrg: undefined } export type UseAuthInfoProps = UseAuthInfoLoading | UseAuthInfoLoggedInProps | UseAuthInfoNotLoggedInProps @@ -55,7 +61,7 @@ export function useAuthInfo(): UseAuthInfoProps { throw new Error("useAuthInfo must be used within an AuthProvider or RequiredAuthProvider") } - const { loading, authInfo, refreshAuthInfo, tokens } = context + const { loading, authInfo, refreshAuthInfo, tokens, activeOrg, setActiveOrg } = context if (loading) { return { loading: true, @@ -70,6 +76,8 @@ export function useAuthInfo(): UseAuthInfoProps { refreshAuthInfo, tokens, accessTokenExpiresAtSeconds: undefined, + activeOrg: undefined, + setActiveOrg: undefined, } } else if (authInfo && authInfo.accessToken) { return { @@ -85,6 +93,8 @@ export function useAuthInfo(): UseAuthInfoProps { refreshAuthInfo, tokens, accessTokenExpiresAtSeconds: authInfo.expiresAtSeconds, + activeOrg, + setActiveOrg, } } return { @@ -100,5 +110,7 @@ export function useAuthInfo(): UseAuthInfoProps { refreshAuthInfo, tokens, accessTokenExpiresAtSeconds: undefined, + activeOrg: undefined, + setActiveOrg: undefined, } } diff --git a/src/withAuthInfo.tsx b/src/withAuthInfo.tsx index 7585057..c4b341a 100644 --- a/src/withAuthInfo.tsx +++ b/src/withAuthInfo.tsx @@ -1,4 +1,4 @@ -import { AccessHelper, OrgHelper, User, UserClass } from "@propelauth/javascript" +import { AccessHelper, OrgHelper, User, UserClass, OrgMemberInfoClass } from "@propelauth/javascript" import hoistNonReactStatics from "hoist-non-react-statics" import React, { useContext } from "react" import { Subtract } from "utility-types" @@ -17,6 +17,8 @@ export type WithLoggedInAuthInfoProps = { refreshAuthInfo: () => Promise tokens: Tokens accessTokenExpiresAtSeconds: number + activeOrg: OrgMemberInfoClass | undefined + setActiveOrg: (orgId: string) => Promise } export type WithNotLoggedInAuthInfoProps = { @@ -32,6 +34,8 @@ export type WithNotLoggedInAuthInfoProps = { refreshAuthInfo: () => Promise tokens: Tokens accessTokenExpiresAtSeconds: null + activeOrg: undefined + setActiveOrg: undefined } export type WithAuthInfoProps = WithLoggedInAuthInfoProps | WithNotLoggedInAuthInfoProps @@ -52,7 +56,7 @@ export function withAuthInfo

( throw new Error("withAuthInfo must be used within an AuthProvider or RequiredAuthProvider") } - const { loading, authInfo, defaultDisplayWhileLoading, refreshAuthInfo, tokens } = context + const { loading, authInfo, defaultDisplayWhileLoading, refreshAuthInfo, tokens, activeOrg, setActiveOrg } = context function displayLoading() { if (args?.displayWhileLoading) { @@ -80,6 +84,8 @@ export function withAuthInfo

( refreshAuthInfo, tokens, accessTokenExpiresAtSeconds: authInfo.expiresAtSeconds, + activeOrg, + setActiveOrg } return } else { @@ -97,6 +103,8 @@ export function withAuthInfo

( refreshAuthInfo, tokens, accessTokenExpiresAtSeconds: null, + activeOrg: undefined, + setActiveOrg: undefined } return } diff --git a/src/withRequiredAuthInfo.tsx b/src/withRequiredAuthInfo.tsx index eba4f63..f278282 100644 --- a/src/withRequiredAuthInfo.tsx +++ b/src/withRequiredAuthInfo.tsx @@ -22,7 +22,7 @@ export function withRequiredAuthInfo

( throw new Error("withRequiredAuthInfo must be used within an AuthProvider or RequiredAuthProvider") } - const { loading, authInfo, defaultDisplayIfLoggedOut, defaultDisplayWhileLoading, refreshAuthInfo, tokens } = + const { loading, authInfo, defaultDisplayIfLoggedOut, defaultDisplayWhileLoading, refreshAuthInfo, tokens, activeOrg, setActiveOrg } = context function displayLoading() { @@ -60,6 +60,8 @@ export function withRequiredAuthInfo

( refreshAuthInfo, tokens, accessTokenExpiresAtSeconds: authInfo.expiresAtSeconds, + activeOrg, + setActiveOrg } return } else { From 6be80e27885721f992dba7958c17252fbd045e57 Mon Sep 17 00:00:00 2001 From: pfvatterott Date: Mon, 30 Jun 2025 15:25:04 -0600 Subject: [PATCH 2/2] memoized getActiveOrg and add removeActiveOrg --- src/AuthContext.tsx | 42 +++++++++++++++++++---------------- src/AuthContextForTesting.tsx | 3 ++- src/hooks/useAuthInfo.ts | 8 ++++++- src/withAuthInfo.tsx | 10 ++++++--- src/withRequiredAuthInfo.tsx | 5 +++-- 5 files changed, 42 insertions(+), 26 deletions(-) diff --git a/src/AuthContext.tsx b/src/AuthContext.tsx index 95aa3f2..f2c0e9c 100644 --- a/src/AuthContext.tsx +++ b/src/AuthContext.tsx @@ -9,7 +9,7 @@ import { RedirectToSignupOptions, OrgMemberInfoClass } from "@propelauth/javascript" -import React, { useCallback, useEffect, useReducer, useState } from "react" +import React, { useCallback, useEffect, useReducer, useState, useMemo } from "react" import { loadOrgSelectionFromLocalStorage } from "./hooks/useActiveOrg" import { useClientRef, useClientRefCallback } from "./useClientRef" @@ -45,6 +45,7 @@ export interface InternalAuthState { activeOrg: OrgMemberInfoClass | undefined setActiveOrg: (orgId: string) => Promise + removeActiveOrg: () => void refreshAuthInfo: () => Promise defaultDisplayWhileLoading?: React.ReactElement @@ -255,18 +256,17 @@ export const AuthProvider = (props: AuthProviderProps) => { const setActiveOrg = async (orgId: string) => { - if (authInfoState.authInfo === null) { + const userClass = authInfoState?.authInfo?.userClass + if (!userClass) { return false } + const org = userClass.getOrg(orgId) - const authInfo = authInfoState.authInfo - const userClass = authInfo?.userClass - - if (userClass.getOrg(orgId)) { + if (org) { if (useLocalStorageForActiveOrg) { setStoredActiveOrgId(orgId); } - setActiveOrgState(userClass.getOrg(orgId)); + setActiveOrgState(org); return true } else { if (useLocalStorageForActiveOrg) { @@ -277,29 +277,32 @@ export const AuthProvider = (props: AuthProviderProps) => { } }; - const getActiveOrg = () => { - if (authInfoState.authInfo === null) { + const getActiveOrg = useMemo(() => { + const userClass = authInfoState?.authInfo?.userClass + if (!userClass) { return undefined } - const authInfo = authInfoState.authInfo - const userClass = authInfo?.userClass - if (!activeOrg && useLocalStorageForActiveOrg) { const activeOrgIdFromLocalStorage = getStoredActiveOrgId() if (activeOrgIdFromLocalStorage) { return userClass.getOrg(activeOrgIdFromLocalStorage) } } - - if (activeOrg && userClass.getOrg(activeOrg.orgId)) { - return activeOrg - } else { - return undefined + if (activeOrg) { + return userClass.getOrg(activeOrg.orgId) } + return undefined + }, [activeOrg, authInfoState?.authInfo?.userClass]) + + + function removeActiveOrg() { + if (useLocalStorageForActiveOrg) { + removeStoredActiveOrgId(); + } + setActiveOrgState(undefined); } - // TODO: Remove this, as both `getActiveOrgFn` and `loadOrgSelectionFromLocalStorage` are deprecated. const deprecatedActiveOrgFn = deprecatedGetActiveOrgFn || loadOrgSelectionFromLocalStorage @@ -325,8 +328,9 @@ export const AuthProvider = (props: AuthProviderProps) => { getSetupSAMLPageUrl, authUrl, refreshAuthInfo, - activeOrg: getActiveOrg(), + activeOrg: getActiveOrg, setActiveOrg, + removeActiveOrg, tokens: { getAccessTokenForOrg, getAccessToken, diff --git a/src/AuthContextForTesting.tsx b/src/AuthContextForTesting.tsx index 9107c61..8218088 100644 --- a/src/AuthContextForTesting.tsx +++ b/src/AuthContextForTesting.tsx @@ -80,7 +80,8 @@ export const AuthProviderForTesting = ({ getAccessToken: () => Promise.resolve(userInformation?.accessToken ?? "ACCESS_TOKEN"), }, activeOrg: undefined, - setActiveOrg: () => Promise.resolve(false) + setActiveOrg: () => Promise.resolve(false), + removeActiveOrg: () => "" } return {children} diff --git a/src/hooks/useAuthInfo.ts b/src/hooks/useAuthInfo.ts index d84b0d2..aa8458e 100644 --- a/src/hooks/useAuthInfo.ts +++ b/src/hooks/useAuthInfo.ts @@ -17,6 +17,7 @@ export type UseAuthInfoLoading = { accessTokenExpiresAtSeconds: undefined activeOrg: undefined setActiveOrg: undefined + removeActiveOrg: undefined } export type UseAuthInfoLoggedInProps = { @@ -34,6 +35,7 @@ export type UseAuthInfoLoggedInProps = { accessTokenExpiresAtSeconds: number activeOrg: OrgMemberInfoClass | undefined setActiveOrg: (orgId: string) => Promise + removeActiveOrg: () => void } export type UseAuthInfoNotLoggedInProps = { @@ -51,6 +53,7 @@ export type UseAuthInfoNotLoggedInProps = { accessTokenExpiresAtSeconds: undefined activeOrg: undefined setActiveOrg: undefined + removeActiveOrg: () => void } export type UseAuthInfoProps = UseAuthInfoLoading | UseAuthInfoLoggedInProps | UseAuthInfoNotLoggedInProps @@ -61,7 +64,7 @@ export function useAuthInfo(): UseAuthInfoProps { throw new Error("useAuthInfo must be used within an AuthProvider or RequiredAuthProvider") } - const { loading, authInfo, refreshAuthInfo, tokens, activeOrg, setActiveOrg } = context + const { loading, authInfo, refreshAuthInfo, tokens, activeOrg, setActiveOrg, removeActiveOrg } = context if (loading) { return { loading: true, @@ -78,6 +81,7 @@ export function useAuthInfo(): UseAuthInfoProps { accessTokenExpiresAtSeconds: undefined, activeOrg: undefined, setActiveOrg: undefined, + removeActiveOrg: undefined } } else if (authInfo && authInfo.accessToken) { return { @@ -95,6 +99,7 @@ export function useAuthInfo(): UseAuthInfoProps { accessTokenExpiresAtSeconds: authInfo.expiresAtSeconds, activeOrg, setActiveOrg, + removeActiveOrg, } } return { @@ -112,5 +117,6 @@ export function useAuthInfo(): UseAuthInfoProps { accessTokenExpiresAtSeconds: undefined, activeOrg: undefined, setActiveOrg: undefined, + removeActiveOrg } } diff --git a/src/withAuthInfo.tsx b/src/withAuthInfo.tsx index c4b341a..6b54ce9 100644 --- a/src/withAuthInfo.tsx +++ b/src/withAuthInfo.tsx @@ -19,6 +19,7 @@ export type WithLoggedInAuthInfoProps = { accessTokenExpiresAtSeconds: number activeOrg: OrgMemberInfoClass | undefined setActiveOrg: (orgId: string) => Promise + removeActiveOrg: () => void } export type WithNotLoggedInAuthInfoProps = { @@ -36,6 +37,7 @@ export type WithNotLoggedInAuthInfoProps = { accessTokenExpiresAtSeconds: null activeOrg: undefined setActiveOrg: undefined + removeActiveOrg: () => void } export type WithAuthInfoProps = WithLoggedInAuthInfoProps | WithNotLoggedInAuthInfoProps @@ -56,7 +58,7 @@ export function withAuthInfo

( throw new Error("withAuthInfo must be used within an AuthProvider or RequiredAuthProvider") } - const { loading, authInfo, defaultDisplayWhileLoading, refreshAuthInfo, tokens, activeOrg, setActiveOrg } = context + const { loading, authInfo, defaultDisplayWhileLoading, refreshAuthInfo, tokens, activeOrg, setActiveOrg, removeActiveOrg } = context function displayLoading() { if (args?.displayWhileLoading) { @@ -85,7 +87,8 @@ export function withAuthInfo

( tokens, accessTokenExpiresAtSeconds: authInfo.expiresAtSeconds, activeOrg, - setActiveOrg + setActiveOrg, + removeActiveOrg } return } else { @@ -104,7 +107,8 @@ export function withAuthInfo

( tokens, accessTokenExpiresAtSeconds: null, activeOrg: undefined, - setActiveOrg: undefined + setActiveOrg: undefined, + removeActiveOrg } return } diff --git a/src/withRequiredAuthInfo.tsx b/src/withRequiredAuthInfo.tsx index f278282..1c6af25 100644 --- a/src/withRequiredAuthInfo.tsx +++ b/src/withRequiredAuthInfo.tsx @@ -22,7 +22,7 @@ export function withRequiredAuthInfo

( throw new Error("withRequiredAuthInfo must be used within an AuthProvider or RequiredAuthProvider") } - const { loading, authInfo, defaultDisplayIfLoggedOut, defaultDisplayWhileLoading, refreshAuthInfo, tokens, activeOrg, setActiveOrg } = + const { loading, authInfo, defaultDisplayIfLoggedOut, defaultDisplayWhileLoading, refreshAuthInfo, tokens, activeOrg, setActiveOrg, removeActiveOrg } = context function displayLoading() { @@ -61,7 +61,8 @@ export function withRequiredAuthInfo

( tokens, accessTokenExpiresAtSeconds: authInfo.expiresAtSeconds, activeOrg, - setActiveOrg + setActiveOrg, + removeActiveOrg, } return } else {