From 591ce5ca0d0e50a19f4ec28b9019563c5bd12ebe Mon Sep 17 00:00:00 2001 From: VargaJoe Date: Thu, 25 Jun 2026 14:44:04 +0200 Subject: [PATCH 1/2] feat(admin-ui): scope SNAuth sessions by repository --- .../sensenet/src/components/app-providers.tsx | 36 +++- apps/sensenet/src/components/app.tsx | 23 ++- .../src/components/dialogs/logout.tsx | 12 +- .../src/components/login/login-page.tsx | 37 ++++- .../src/context/repository-provider.tsx | 27 ++- .../context/sn-auth-repository-provider.tsx | 102 ++++++++++-- apps/sensenet/src/localization/default.ts | 3 + apps/sensenet/src/localization/hungarian.ts | 3 + .../src/services/repository-session.ts | 154 ++++++++++++++++++ .../components/authentication-provider.tsx | 14 +- packages/sn-auth-react/src/index.ts | 2 + packages/sn-auth-react/src/storage-helpers.ts | 28 ++-- 12 files changed, 389 insertions(+), 52 deletions(-) create mode 100644 apps/sensenet/src/services/repository-session.ts diff --git a/apps/sensenet/src/components/app-providers.tsx b/apps/sensenet/src/components/app-providers.tsx index 60605ead86..fc2968a142 100644 --- a/apps/sensenet/src/components/app-providers.tsx +++ b/apps/sensenet/src/components/app-providers.tsx @@ -1,4 +1,3 @@ -import { PathHelper } from '@sensenet/client-utils' import { InjectorContext, LoggerContextProvider } from '@sensenet/hooks-react' import React, { ReactNode, Suspense, useCallback, useEffect, useState } from 'react' import { BrowserRouter } from 'react-router-dom' @@ -22,6 +21,11 @@ import { NavigationCommandProvider, SearchCommandProvider, } from '../services' +import { + clearActiveRepositorySelection, + normalizeRepositoryUrl, + startSnAuthRepositoryLogin, +} from '../services/repository-session' import { DialogProvider } from './dialogs/dialog-provider' import { GridLoadingProvider } from './grid/Providers/GridLoadingProvider' @@ -34,28 +38,44 @@ export type AppProvidersProps = { } export default function AppProviders({ children }: AppProvidersProps) { - const initAuthType: AuthServerType = (window.localStorage.getItem('authType') as AuthServerType) ?? 'IdentityServer' + const initAuthType: AuthServerType = + (window.localStorage.getItem('authType') as AuthServerType) ?? defaultAuthConfig.authType const [authType, setAuthType] = useState<'IdentityServer' | 'SNAuth'>(initAuthType) const [url, setUrl] = useState('') + const selectRepository = useCallback((providedUrl: string) => { + const normalizedUrl = normalizeRepositoryUrl(providedUrl) + + clearActiveRepositorySelection() + startSnAuthRepositoryLogin(normalizedUrl) + setUrl(normalizedUrl) + }, []) + const changeAuthType = useCallback((providedUrl: string) => { - setUrl(PathHelper.ensureDefaultSchema(providedUrl)) + const normalizedUrl = normalizeRepositoryUrl(providedUrl) + + setUrl(normalizedUrl) setAuthType((prev) => { const newAuthType = prev === 'IdentityServer' ? 'SNAuth' : 'IdentityServer' + if (newAuthType === 'SNAuth') { + startSnAuthRepositoryLogin(normalizedUrl) + } window.localStorage.setItem('authType', newAuthType) return newAuthType }) }, []) useEffect(() => { - const IsAuthKey = localStorage.getItem(authConfigKeyIS) - const SnAuthKey = localStorage.getItem(authConfigKeySN) - if (IsAuthKey || SnAuthKey) return const repoUrl = new URL(window.location.href).searchParams.get('repoUrl') if (repoUrl) { - changeAuthType(repoUrl) + selectRepository(repoUrl) + return } - }, [changeAuthType]) + + const IsAuthKey = localStorage.getItem(authConfigKeyIS) + const SnAuthKey = localStorage.getItem(authConfigKeySN) + if (IsAuthKey || SnAuthKey) return + }, [selectRepository]) snInjector .getInstance(CommandProviderManager) diff --git a/apps/sensenet/src/components/app.tsx b/apps/sensenet/src/components/app.tsx index 0bddbc6f4d..5817f68caa 100644 --- a/apps/sensenet/src/components/app.tsx +++ b/apps/sensenet/src/components/app.tsx @@ -1,5 +1,6 @@ import { CssBaseline, StylesProvider } from '@material-ui/core' import React from 'react' +import { clearActiveRepositorySelection } from '../services/repository-session' import AppProviders from './app-providers' import { Dialogs } from './dialogs' import { ErrorBoundary } from './error-boundary' @@ -7,9 +8,29 @@ import { DesktopLayout } from './layout/DesktopLayout' import { MainRouter } from './MainRouter' import { NotificationComponent } from './NotificationComponent' +const AppErrorFallback = () => { + const switchRepository = () => { + clearActiveRepositorySelection() + window.location.assign('/') + } + + return ( +
+

Something went wrong

+

The current repository could not be loaded. You can reload the page or choose another repository.

+ + +
+ ) +} + export function App() { return ( - + diff --git a/apps/sensenet/src/components/dialogs/logout.tsx b/apps/sensenet/src/components/dialogs/logout.tsx index c9717cb598..a791e5248d 100644 --- a/apps/sensenet/src/components/dialogs/logout.tsx +++ b/apps/sensenet/src/components/dialogs/logout.tsx @@ -1,10 +1,10 @@ import { Button, DialogActions, DialogContent, DialogContentText } from '@material-ui/core' import { useRepository } from '@sensenet/hooks-react' import React from 'react' -import { authConfigKey } from '../../context' import { useAuth } from '../../context/auth-provider' import { useGlobalStyles } from '../../globalStyles' import { useLocalization } from '../../hooks' +import { clearActiveRepositorySelection } from '../../services/repository-session' import { Icon } from '../Icon' import { DialogTitle, useDialog } from '.' @@ -38,6 +38,14 @@ export function LogoutDialog() { + + {repositoryOptions.length > 0 && handleSelectRepository ? ( + + + {localization.recentRepositories} + + + {repositoryOptions.map((repositoryOption) => ( + handleSelectRepository(repositoryOption.repoUrl)}> + + + ))} + + + ) : null} diff --git a/apps/sensenet/src/context/repository-provider.tsx b/apps/sensenet/src/context/repository-provider.tsx index aff16471a0..f7e8588152 100644 --- a/apps/sensenet/src/context/repository-provider.tsx +++ b/apps/sensenet/src/context/repository-provider.tsx @@ -12,13 +12,18 @@ import { NotificationComponent } from '../components/NotificationComponent' import { useGlobalStyles } from '../globalStyles' import { useQuery } from '../hooks' import { getAuthConfig } from '../services/auth-config' +import { + clearActiveRepositorySelection, + normalizeRepositoryUrl, + oidcAuthConfigKey, +} from '../services/repository-session' const LoginPage = lazy(() => import(/* webpackChunkName: "login" */ '../components/login/login-page')) -export const authConfigKey = 'sn-oidc-config' +export const authConfigKey = oidcAuthConfigKey const customEvents = { onUserSignedOut: () => { - window.localStorage.removeItem(authConfigKey) + clearActiveRepositorySelection() }, } @@ -61,7 +66,7 @@ export function RepositoryProvider({ useEffect(() => { if (url) { - setAuthState({ repoUrl: url, config: null }) + setAuthState({ repoUrl: normalizeRepositoryUrl(url), config: null }) } }, [url]) @@ -73,17 +78,25 @@ export function RepositoryProvider({ try { setIsLoginInProgress(true) const config = await getAuthConfig(authState.repoUrl) + if (config.authServerSettings.type !== 'IdentityServer') { + changeAuthType(authState.repoUrl) + logger.error({ message: 'Incompatible authentication server type' }) + clearActiveRepositorySelection() + setAuthState((oldState) => ({ ...oldState, repoUrl: '' })) + return + } + window.localStorage.setItem(authConfigKey, JSON.stringify(config)) // Set only userManagerSettings in authState to match the expected type setAuthState((oldState) => ({ ...oldState, config: config.userManagerSettings })) } catch (error) { logger.warning({ data: error, message: `Couldn't connect to ${authState.repoUrl}` }) - window.localStorage.removeItem(authConfigKey) + clearActiveRepositorySelection() setAuthState((oldState) => ({ ...oldState, repoUrl: '' })) } finally { setIsLoginInProgress(false) } - }, [logger, authState.repoUrl]) + }, [logger, authState.repoUrl, changeAuthType]) useEffect(() => { getConfig() @@ -101,7 +114,7 @@ export function RepositoryProvider({ isLoginInProgress={isLoginInProgress} handleSubmit={(formUrl) => { setAuthState({ - repoUrl: formUrl, + repoUrl: normalizeRepositoryUrl(formUrl), config: null, }) }} @@ -216,7 +229,7 @@ const RepoProvider = ({ const config = JSON.parse(configString) changeAuthType(repoUrl) logger.error({ data: error, message: `Couldn't connect to ${config.authority}` }) - window.localStorage.removeItem(authConfigKey) + clearActiveRepositorySelection() clearAuthState() } } diff --git a/apps/sensenet/src/context/sn-auth-repository-provider.tsx b/apps/sensenet/src/context/sn-auth-repository-provider.tsx index 83a227c481..a873d058fc 100644 --- a/apps/sensenet/src/context/sn-auth-repository-provider.tsx +++ b/apps/sensenet/src/context/sn-auth-repository-provider.tsx @@ -9,10 +9,27 @@ import { NotificationComponent } from '../components/NotificationComponent' import { useGlobalStyles } from '../globalStyles' import { useQuery } from '../hooks' import { getAuthConfig } from '../services/auth-config' +import { + clearActiveRepositorySelection, + consumeSnAuthRepositoryLogin, + getSelectedSnAuthRepository, + getSnAuthRepositoryConfig, + getSnAuthRepositorySessions, + getSnAuthStorageKeyPrefix, + hasPendingSnAuthRepositoryLogin, + migrateLegacySnAuthTokens, + normalizeRepositoryUrl, + removeSnAuthRepositorySession, + setSelectedSnAuthRepository, + setSnAuthRepositoryConfig, + snAuthConfigKey, + startSnAuthRepositoryLogin, + upsertSnAuthRepositorySession, +} from '../services/repository-session' const LoginPage = lazy(() => import(/* webpackChunkName: "login" */ '../components/login/login-page')) -export const authConfigKey = 'sn-auth-config' +export const authConfigKey = snAuthConfigKey export function SnAuthRepositoryProvider({ children, @@ -39,29 +56,39 @@ export function SnAuthRepositoryProvider({ useEffect(() => { if (cancelledLogin) { - window.localStorage.removeItem(authConfigKey) + clearActiveRepositorySelection() setAuthState((oldState) => ({ ...oldState, repoUrl: '' })) } else { - setConfigString(window.localStorage.getItem(authConfigKey)) + const selectedRepository = getSelectedSnAuthRepository() + const selectedConfig = selectedRepository && getSnAuthRepositoryConfig(selectedRepository) + setConfigString(selectedConfig ? JSON.stringify(selectedConfig) : window.localStorage.getItem(authConfigKey)) } }, [cancelledLogin]) useEffect(() => { if (configString) { const prevAuthConfig = JSON.parse(configString) + const repoUrl = normalizeRepositoryUrl(prevAuthConfig?.userManagerSettings.extraQueryParams.snrepo || '') setAuthServerUrl(prevAuthConfig.userManagerSettings.authority) + setSelectedSnAuthRepository(repoUrl) + migrateLegacySnAuthTokens(repoUrl) setAuthState((oldState) => ({ - repoUrl: prevAuthConfig?.userManagerSettings.extraQueryParams.snrepo || '', + repoUrl, config: - prevAuthConfig?.userManagerSettings.extraQueryParams.snrepo === oldState.repoUrl ? prevAuthConfig : null, + repoUrl === oldState.repoUrl || !oldState.repoUrl ? prevAuthConfig.userManagerSettings : oldState.config, })) } }, [configString]) useEffect(() => { if (url) { - setAuthState({ repoUrl: url, config: null }) + const repoUrl = normalizeRepositoryUrl(url) + const storedConfig = getSnAuthRepositoryConfig(repoUrl) + + setSelectedSnAuthRepository(repoUrl) + setAuthState({ repoUrl, config: storedConfig?.userManagerSettings ?? null }) + setConfigString(storedConfig ? JSON.stringify(storedConfig) : null) } }, [url]) @@ -72,20 +99,29 @@ export function SnAuthRepositoryProvider({ } try { setIsLoginInProgress(true) + const storedConfig = getSnAuthRepositoryConfig(authState.repoUrl) + + if (storedConfig) { + setAuthServerUrl(storedConfig.userManagerSettings.authority) + window.localStorage.setItem(authConfigKey, JSON.stringify(storedConfig)) + setAuthState((oldState) => ({ ...oldState, config: storedConfig.userManagerSettings })) + return + } + const config = await getAuthConfig(authState.repoUrl) if (config.authServerSettings.type === 'SNAuth') { - window.localStorage.setItem(authConfigKey, JSON.stringify(config)) + setSnAuthRepositoryConfig(authState.repoUrl, config) setConfigString(window.localStorage.getItem(authConfigKey)) setAuthState((oldState) => ({ ...oldState, config: config.userManagerSettings })) } else { changeAuthType(authState.repoUrl) logger.error({ message: 'Incompatible authentication server type' }) - window.localStorage.removeItem(authConfigKey) + clearActiveRepositorySelection() setAuthState((oldState) => ({ ...oldState, repoUrl: '' })) } } catch (error) { logger.warning({ data: error, message: `Couldn't connect to ${authState.repoUrl}` }) - window.localStorage.removeItem(authConfigKey) + clearActiveRepositorySelection() setAuthState((oldState) => ({ ...oldState, repoUrl: '' })) } finally { setIsLoginInProgress(false) @@ -106,9 +142,18 @@ export function SnAuthRepositoryProvider({ ) : ( { + startSnAuthRepositoryLogin(repoUrl) + setAuthState({ + repoUrl: normalizeRepositoryUrl(repoUrl), + config: null, + }) + }} handleSubmit={(formUrl) => { + startSnAuthRepositoryLogin(formUrl) setAuthState({ - repoUrl: formUrl, + repoUrl: normalizeRepositoryUrl(formUrl), config: null, }) }} @@ -124,13 +169,23 @@ export function SnAuthRepositoryProvider({ @@ -158,7 +213,7 @@ const RepoProvider = ({ authServerUrl?: string changeAuthType: (x: string) => void }) => { - const { user, externalLogin, logout, accessToken, isLoading } = useSnAuth() + const { user, externalLogin, logout, accessToken, error, isLoading } = useSnAuth() const logger = useLogger('repo-provider') const [repo, setRepo] = useState() @@ -194,6 +249,12 @@ const RepoProvider = ({ }) }, [repoUrl, user, authServerUrl, accessToken]) + useEffect(() => { + if (user && accessToken && authServerUrl) { + upsertSnAuthRepositorySession(repoUrl, authServerUrl) + } + }, [accessToken, authServerUrl, repoUrl, user]) + useEffect(() => { if (repo) { repo.reloadSchema() @@ -203,13 +264,23 @@ const RepoProvider = ({ useEffect(() => { ;(async () => { const configString = window.localStorage.getItem(authConfigKey) + if (error && !user && !isLoading) { + clearActiveRepositorySelection() + clearAuthState() + return + } + if (!user && !isLoading && !accessToken && authServerUrl && configString) { + if (!consumeSnAuthRepositoryLogin(repoUrl)) { + return + } + try { await externalLogin() - } catch (error) { + } catch (externalLoginError) { changeAuthType(repoUrl) - logger.error({ data: error, message: `Couldn't connect to ${authServerUrl}` }) - window.localStorage.removeItem(authConfigKey) + logger.error({ data: externalLoginError, message: `Couldn't connect to ${authServerUrl}` }) + clearActiveRepositorySelection() clearAuthState() } } @@ -219,6 +290,7 @@ const RepoProvider = ({ logger, externalLogin, logout, + error, user, isLoading, accessToken, diff --git a/apps/sensenet/src/localization/default.ts b/apps/sensenet/src/localization/default.ts index 74665c28de..3465673e69 100644 --- a/apps/sensenet/src/localization/default.ts +++ b/apps/sensenet/src/localization/default.ts @@ -237,9 +237,12 @@ const values = { welcome: 'Welcome to admin.sensenet.com', demoTitle: `If you don't have your own repository yet`, repositoryUrl: 'Otherwise please type in your repository URL to continue', + recentRepositories: 'Recent repositories', + lastUsedRepository: (lastUsed: string) => `Last used: ${lastUsed}`, }, logout: { logoutButtonTitle: 'Log out', + switchRepositoryButtonTitle: 'Switch repository', logoutDialogTitle: 'Really log out?', loggingOutFrom: (repoUrl: string) => `Logging out from ${repoUrl}...`, logoutConfirmText: (repoUrl: string, userName: string) => diff --git a/apps/sensenet/src/localization/hungarian.ts b/apps/sensenet/src/localization/hungarian.ts index 4ec737fea1..c1a15626cd 100644 --- a/apps/sensenet/src/localization/hungarian.ts +++ b/apps/sensenet/src/localization/hungarian.ts @@ -109,10 +109,13 @@ const values: Localization = { passwordHelperText: 'A felhasználóhoz tartozó jelszó', repositoryLabel: 'Elérési út', repositoryHelperText: 'A repository teljes elérési útja (pl.: https://my-sensenet.com)', + recentRepositories: 'Korábban használt repository-k', + lastUsedRepository: (lastUsed: string) => `Legutóbb használva: ${lastUsed}`, }, logout: { logoutCancel: 'Mégsem', logoutButtonTitle: 'Kijelentkezés', + switchRepositoryButtonTitle: 'Repository váltása', logoutDialogTitle: 'Biztosan kijelentkezel?', loggingOutFrom: (repoUrl: string) => `Kijelentkezés a ${repoUrl} repository-ból...`, logoutConfirmText: (repoUrl: string, userName: string) => diff --git a/apps/sensenet/src/services/repository-session.ts b/apps/sensenet/src/services/repository-session.ts new file mode 100644 index 0000000000..fb640fb447 --- /dev/null +++ b/apps/sensenet/src/services/repository-session.ts @@ -0,0 +1,154 @@ +import { PathHelper } from '@sensenet/client-utils' +import { ACCESS_TOKEN_KEY, getStorageKey, REFRESH_TOKEN_KEY } from '@sensenet/sn-auth-react' + +export const oidcAuthConfigKey = 'sn-oidc-config' +export const snAuthConfigKey = 'sn-auth-config' + +const snAuthSelectedRepositoryKey = 'sn-auth-selected-repository' +const snAuthRepositorySessionsKey = 'sn-auth-repository-sessions' +const snAuthPendingLoginRepositoryKey = 'sn-auth-pending-login-repository' +const snAuthScopedConfigKeyPrefix = 'sn-auth-config' +const snAuthStorageKeyPrefix = 'sn-auth' + +export type SnAuthRepositorySession = { + repoUrl: string + authServerUrl?: string + lastUsed: string +} + +export const normalizeRepositoryUrl = (repoUrl: string) => { + const ensuredUrl = PathHelper.ensureDefaultSchema(repoUrl.trim()) + + try { + const url = new URL(ensuredUrl) + url.hash = '' + url.search = '' + url.pathname = url.pathname.replace(/\/+$/, '') + + return url.toString().replace(/\/$/, '') + } catch { + return ensuredUrl.replace(/\/+$/, '') + } +} + +const encodeRepositoryUrl = (repoUrl: string) => encodeURIComponent(normalizeRepositoryUrl(repoUrl)) + +export const getSnAuthStorageKeyPrefix = (repoUrl: string) => + `${snAuthStorageKeyPrefix}:${encodeRepositoryUrl(repoUrl)}` + +export const getSnAuthRepositoryConfigKey = (repoUrl: string) => + `${snAuthScopedConfigKeyPrefix}:${encodeRepositoryUrl(repoUrl)}` + +export const getSelectedSnAuthRepository = () => window.localStorage.getItem(snAuthSelectedRepositoryKey) + +export const setSelectedSnAuthRepository = (repoUrl: string) => { + window.localStorage.setItem(snAuthSelectedRepositoryKey, normalizeRepositoryUrl(repoUrl)) +} + +export const clearSelectedSnAuthRepository = () => { + window.localStorage.removeItem(snAuthSelectedRepositoryKey) +} + +export const startSnAuthRepositoryLogin = (repoUrl: string) => { + window.localStorage.setItem(snAuthPendingLoginRepositoryKey, normalizeRepositoryUrl(repoUrl)) +} + +export const hasPendingSnAuthRepositoryLogin = (repoUrl: string) => + window.localStorage.getItem(snAuthPendingLoginRepositoryKey) === normalizeRepositoryUrl(repoUrl) + +export const consumeSnAuthRepositoryLogin = (repoUrl: string) => { + if (hasPendingSnAuthRepositoryLogin(repoUrl)) { + window.localStorage.removeItem(snAuthPendingLoginRepositoryKey) + return true + } + + return false +} + +export const clearPendingSnAuthRepositoryLogin = () => { + window.localStorage.removeItem(snAuthPendingLoginRepositoryKey) +} + +export const getSnAuthRepositorySessions = (): SnAuthRepositorySession[] => { + try { + const sessions = JSON.parse(window.localStorage.getItem(snAuthRepositorySessionsKey) ?? '[]') + + return Array.isArray(sessions) + ? sessions + .filter((session): session is SnAuthRepositorySession => !!session?.repoUrl) + .sort((a, b) => b.lastUsed.localeCompare(a.lastUsed)) + : [] + } catch { + return [] + } +} + +export const upsertSnAuthRepositorySession = (repoUrl: string, authServerUrl?: string) => { + const normalizedRepoUrl = normalizeRepositoryUrl(repoUrl) + const sessions = getSnAuthRepositorySessions().filter((session) => session.repoUrl !== normalizedRepoUrl) + const nextSessions = [ + { + repoUrl: normalizedRepoUrl, + authServerUrl, + lastUsed: new Date().toISOString(), + }, + ...sessions, + ] + + window.localStorage.setItem(snAuthRepositorySessionsKey, JSON.stringify(nextSessions)) + setSelectedSnAuthRepository(normalizedRepoUrl) +} + +export const removeSnAuthRepositorySession = (repoUrl: string) => { + const normalizedRepoUrl = normalizeRepositoryUrl(repoUrl) + const storageKeyPrefix = getSnAuthStorageKeyPrefix(normalizedRepoUrl) + const sessions = getSnAuthRepositorySessions().filter((session) => session.repoUrl !== normalizedRepoUrl) + + window.localStorage.setItem(snAuthRepositorySessionsKey, JSON.stringify(sessions)) + window.localStorage.removeItem(getSnAuthRepositoryConfigKey(normalizedRepoUrl)) + window.localStorage.removeItem(getStorageKey(ACCESS_TOKEN_KEY, storageKeyPrefix)) + window.localStorage.removeItem(getStorageKey(REFRESH_TOKEN_KEY, storageKeyPrefix)) + + if (getSelectedSnAuthRepository() === normalizedRepoUrl) { + clearSelectedSnAuthRepository() + } +} + +export const getSnAuthRepositoryConfig = (repoUrl: string) => { + const configString = window.localStorage.getItem(getSnAuthRepositoryConfigKey(repoUrl)) + + return configString ? JSON.parse(configString) : null +} + +export const setSnAuthRepositoryConfig = (repoUrl: string, config: unknown) => { + const configString = JSON.stringify(config) + + window.localStorage.setItem(getSnAuthRepositoryConfigKey(repoUrl), configString) + window.localStorage.setItem(snAuthConfigKey, configString) +} + +export const migrateLegacySnAuthTokens = (repoUrl: string) => { + const storageKeyPrefix = getSnAuthStorageKeyPrefix(repoUrl) + const legacyAccessToken = window.localStorage.getItem(ACCESS_TOKEN_KEY) + const legacyRefreshToken = window.localStorage.getItem(REFRESH_TOKEN_KEY) + const scopedAccessTokenKey = getStorageKey(ACCESS_TOKEN_KEY, storageKeyPrefix) + const scopedRefreshTokenKey = getStorageKey(REFRESH_TOKEN_KEY, storageKeyPrefix) + + if (legacyAccessToken && !window.localStorage.getItem(scopedAccessTokenKey)) { + window.localStorage.setItem(scopedAccessTokenKey, legacyAccessToken) + } + + if (legacyRefreshToken && !window.localStorage.getItem(scopedRefreshTokenKey)) { + window.localStorage.setItem(scopedRefreshTokenKey, legacyRefreshToken) + } + + window.localStorage.removeItem(ACCESS_TOKEN_KEY) + window.localStorage.removeItem(REFRESH_TOKEN_KEY) +} + +export const clearActiveRepositorySelection = () => { + window.localStorage.removeItem(oidcAuthConfigKey) + window.localStorage.removeItem(snAuthConfigKey) + clearSelectedSnAuthRepository() + clearPendingSnAuthRepositoryLogin() +} diff --git a/packages/sn-auth-react/src/components/authentication-provider.tsx b/packages/sn-auth-react/src/components/authentication-provider.tsx index 5e9f8da2a8..3c0c6b658b 100644 --- a/packages/sn-auth-react/src/components/authentication-provider.tsx +++ b/packages/sn-auth-react/src/components/authentication-provider.tsx @@ -48,6 +48,7 @@ export interface AuthenticationProviderProps { snAuthConfiguration: SnAuthConfiguration repoUrl: string authServerUrl: string + storageKeyPrefix?: string eventCallbacks?: { onInitialized?: () => void onNoInitialization?: () => void @@ -68,6 +69,7 @@ const TOKEN_EXPIRY_THRESHOLD = 10 * 1000 export const AuthenticationProvider = (props: AuthenticationProviderProps) => { const [authState, setState] = useState({ isLoading: true }) const [path, setPath] = useState(window.location.pathname) + const { storageKeyPrefix } = props const setNewPath = () => setPath(window.location.pathname) @@ -90,8 +92,8 @@ export const AuthenticationProvider = (props: AuthenticationProviderProps) => { if (path !== props.snAuthConfiguration.callbackUri) { setState({ isLoading: true }) try { - let accessToken = getAccessToken() - let refreshToken = getRefreshToken() + let accessToken = getAccessToken(storageKeyPrefix) + let refreshToken = getRefreshToken(storageKeyPrefix) if (accessToken && refreshToken) { const isValid = await validateTokenApiCall(props.authServerUrl, accessToken) @@ -324,15 +326,15 @@ export const AuthenticationProvider = (props: AuthenticationProviderProps) => { refreshToken: undefined, }) - removeAccessToken() - removeRefreshToken() + removeAccessToken(storageKeyPrefix) + removeRefreshToken(storageKeyPrefix) window.history.pushState({}, '', '/') } const setAccessAndRefreshTokenStorage = (accessToken: string, refreshToken: string) => { - setAccessTokenStorage(accessToken) - setRefreshTokenStorage(refreshToken) + setAccessTokenStorage(accessToken, storageKeyPrefix) + setRefreshTokenStorage(refreshToken, storageKeyPrefix) } return ( diff --git a/packages/sn-auth-react/src/index.ts b/packages/sn-auth-react/src/index.ts index 3ab565f4c3..4db1ee6b17 100644 --- a/packages/sn-auth-react/src/index.ts +++ b/packages/sn-auth-react/src/index.ts @@ -1,3 +1,5 @@ export { useSnAuth } from './useSnAuth' export { AuthenticationProvider } from './components/authentication-provider' +export { ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY } from './constants' +export { getStorageKey } from './storage-helpers' export * from './models' diff --git a/packages/sn-auth-react/src/storage-helpers.ts b/packages/sn-auth-react/src/storage-helpers.ts index 657734a3f4..a494115557 100644 --- a/packages/sn-auth-react/src/storage-helpers.ts +++ b/packages/sn-auth-react/src/storage-helpers.ts @@ -1,25 +1,29 @@ import { ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY } from './constants' -export const getAccessToken = (): string | null => { - return window.localStorage.getItem(ACCESS_TOKEN_KEY) +export const getStorageKey = (key: string, storageKeyPrefix?: string): string => { + return storageKeyPrefix ? `${storageKeyPrefix}:${key}` : key } -export const setAccessToken = (token: string): void => { - window.localStorage.setItem(ACCESS_TOKEN_KEY, token) +export const getAccessToken = (storageKeyPrefix?: string): string | null => { + return window.localStorage.getItem(getStorageKey(ACCESS_TOKEN_KEY, storageKeyPrefix)) } -export const removeAccessToken = (): void => { - window.localStorage.removeItem(ACCESS_TOKEN_KEY) +export const setAccessToken = (token: string, storageKeyPrefix?: string): void => { + window.localStorage.setItem(getStorageKey(ACCESS_TOKEN_KEY, storageKeyPrefix), token) } -export const getRefreshToken = (): string | null => { - return window.localStorage.getItem(REFRESH_TOKEN_KEY) +export const removeAccessToken = (storageKeyPrefix?: string): void => { + window.localStorage.removeItem(getStorageKey(ACCESS_TOKEN_KEY, storageKeyPrefix)) } -export const setRefreshToken = (token: string): void => { - window.localStorage.setItem(REFRESH_TOKEN_KEY, token) +export const getRefreshToken = (storageKeyPrefix?: string): string | null => { + return window.localStorage.getItem(getStorageKey(REFRESH_TOKEN_KEY, storageKeyPrefix)) } -export const removeRefreshToken = (): void => { - window.localStorage.removeItem(REFRESH_TOKEN_KEY) +export const setRefreshToken = (token: string, storageKeyPrefix?: string): void => { + window.localStorage.setItem(getStorageKey(REFRESH_TOKEN_KEY, storageKeyPrefix), token) +} + +export const removeRefreshToken = (storageKeyPrefix?: string): void => { + window.localStorage.removeItem(getStorageKey(REFRESH_TOKEN_KEY, storageKeyPrefix)) } From e855d78098b740df48b7adc60bd039bd88d563a1 Mon Sep 17 00:00:00 2001 From: VargaJoe Date: Thu, 25 Jun 2026 15:13:05 +0200 Subject: [PATCH 2/2] feat(admin-ui): add SNAuth repository switcher --- .../sensenet/src/components/app-providers.tsx | 66 ++++++++++------ .../src/components/drawer/PermanentDrawer.tsx | 2 + .../src/components/drawer/TemporaryDrawer.tsx | 2 + .../components/drawer/repository-selector.tsx | 79 +++++++++++++++++++ apps/sensenet/src/context/index.ts | 1 + .../src/context/repository-switch-context.tsx | 14 ++++ .../context/sn-auth-repository-provider.tsx | 3 +- apps/sensenet/src/localization/default.ts | 1 + apps/sensenet/src/localization/hungarian.ts | 1 + .../src/services/repository-session.ts | 17 ++++ 10 files changed, 160 insertions(+), 26 deletions(-) create mode 100644 apps/sensenet/src/components/drawer/repository-selector.tsx create mode 100644 apps/sensenet/src/context/repository-switch-context.tsx diff --git a/apps/sensenet/src/components/app-providers.tsx b/apps/sensenet/src/components/app-providers.tsx index fc2968a142..1955a5d1f5 100644 --- a/apps/sensenet/src/components/app-providers.tsx +++ b/apps/sensenet/src/components/app-providers.tsx @@ -7,6 +7,7 @@ import { LocalizationProvider, PersonalSettingsContextProvider, RepositoryProvider, + RepositorySwitchContext, ResponsiveContextProvider, ThemeProvider, } from '../context' @@ -23,6 +24,7 @@ import { } from '../services' import { clearActiveRepositorySelection, + hasSnAuthRepositoryTokens, normalizeRepositoryUrl, startSnAuthRepositoryLogin, } from '../services/repository-session' @@ -65,6 +67,18 @@ export default function AppProviders({ children }: AppProvidersProps) { }) }, []) + const switchRepository = useCallback((providedUrl: string) => { + const normalizedUrl = normalizeRepositoryUrl(providedUrl) + + if (!hasSnAuthRepositoryTokens(normalizedUrl)) { + startSnAuthRepositoryLogin(normalizedUrl) + } + + window.localStorage.setItem('authType', 'SNAuth') + setAuthType('SNAuth') + setUrl(normalizedUrl) + }, []) + useEffect(() => { const repoUrl = new URL(window.location.href).searchParams.get('repoUrl') if (repoUrl) { @@ -96,31 +110,33 @@ export default function AppProviders({ children }: AppProvidersProps) { - {authType === 'IdentityServer' ? ( - - - - - - {children} - - - - - - ) : ( - - - - - - {children} - - - - - - )} + + {authType === 'IdentityServer' ? ( + + + + + + {children} + + + + + + ) : ( + + + + + + {children} + + + + + + )} + diff --git a/apps/sensenet/src/components/drawer/PermanentDrawer.tsx b/apps/sensenet/src/components/drawer/PermanentDrawer.tsx index f6fc517c53..59626d096b 100644 --- a/apps/sensenet/src/components/drawer/PermanentDrawer.tsx +++ b/apps/sensenet/src/components/drawer/PermanentDrawer.tsx @@ -15,6 +15,7 @@ import { useDrawerItems, useLocalization } from '../../hooks' import { AddButton } from '../AddButton' import { SearchButton } from '../search-button' import { PermanentDrawerItem } from './PermanentDrawerItem' +import { RepositorySelector } from './repository-selector' const useStyles = makeStyles((theme: Theme) => { return createStyles({ @@ -109,6 +110,7 @@ export const PermanentDrawer = () => { ) : null} + {opened ? : null} {matchPath(location.pathname, PATHS.savedQueries.appPath) ? : null}{' '} {matchPath(location.pathname, [ PATHS.content.appPath, diff --git a/apps/sensenet/src/components/drawer/TemporaryDrawer.tsx b/apps/sensenet/src/components/drawer/TemporaryDrawer.tsx index 8d2b24f9a2..a37624b5a4 100644 --- a/apps/sensenet/src/components/drawer/TemporaryDrawer.tsx +++ b/apps/sensenet/src/components/drawer/TemporaryDrawer.tsx @@ -18,6 +18,7 @@ import { ResponsiveContext, ResponsivePersonalSettings } from '../../context' import { useDrawerItems, useLocalization, useTheme } from '../../hooks' import { LogoutButton } from '../LogoutButton' import { UserAvatar } from '../UserAvatar' +import { RepositorySelector } from './repository-selector' type TemporaryDrawerProps = { isOpened: boolean @@ -60,6 +61,7 @@ export const TemporaryDrawer = (props: TemporaryDrawerProps) => { transition: 'width 100ms ease-in-out', }}>
+ {items.map((item, index) => { const isActive = matchPath(location.pathname, item.url) return isActive ? ( diff --git a/apps/sensenet/src/components/drawer/repository-selector.tsx b/apps/sensenet/src/components/drawer/repository-selector.tsx new file mode 100644 index 0000000000..7f743a6895 --- /dev/null +++ b/apps/sensenet/src/components/drawer/repository-selector.tsx @@ -0,0 +1,79 @@ +import { FormControl, InputLabel, makeStyles, MenuItem, Select, Theme } from '@material-ui/core' +import { useRepository } from '@sensenet/hooks-react' +import React, { useEffect, useState } from 'react' +import { useHistory } from 'react-router-dom' +import { PATHS } from '../../application-paths' +import { useRepositorySwitch } from '../../context' +import { useLocalization } from '../../hooks' +import { + getAuthenticatedSnAuthRepositorySessions, + normalizeRepositoryUrl, + snAuthRepositorySessionsChangedEvent, +} from '../../services/repository-session' + +const useStyles = makeStyles((theme: Theme) => ({ + root: { + padding: theme.spacing(1, 1, 0), + }, + formControl: { + width: '100%', + }, +})) + +const getRepositoryHost = (repoUrl: string) => { + try { + return new URL(repoUrl).host + } catch { + return repoUrl + } +} + +export const RepositorySelector = () => { + const classes = useStyles() + const history = useHistory() + const localization = useLocalization().repositorySelector + const repository = useRepository() + const { authType, switchRepository } = useRepositorySwitch() + const currentRepositoryUrl = normalizeRepositoryUrl(repository.configuration.repositoryUrl) + const [repositorySessions, setRepositorySessions] = useState(getAuthenticatedSnAuthRepositorySessions) + + useEffect(() => { + const refreshRepositorySessions = () => setRepositorySessions(getAuthenticatedSnAuthRepositorySessions()) + + window.addEventListener(snAuthRepositorySessionsChangedEvent, refreshRepositorySessions) + + return () => window.removeEventListener(snAuthRepositorySessionsChangedEvent, refreshRepositorySessions) + }, []) + + if (authType !== 'SNAuth' || repositorySessions.length < 2) { + return null + } + + return ( +
+ + {localization.activeRepository} + + +
+ ) +} diff --git a/apps/sensenet/src/context/index.ts b/apps/sensenet/src/context/index.ts index 7f783310d6..ab0678442f 100644 --- a/apps/sensenet/src/context/index.ts +++ b/apps/sensenet/src/context/index.ts @@ -2,6 +2,7 @@ export * from './current-user-provider' export * from './LocalizationContext' export * from './PersonalSettingsContext' export * from './repository-provider' +export * from './repository-switch-context' export * from './ResponsiveContextProvider' export * from './ThemeContext' export * from './ThemeProvider' diff --git a/apps/sensenet/src/context/repository-switch-context.tsx b/apps/sensenet/src/context/repository-switch-context.tsx new file mode 100644 index 0000000000..01843f635a --- /dev/null +++ b/apps/sensenet/src/context/repository-switch-context.tsx @@ -0,0 +1,14 @@ +import { createContext, useContext } from 'react' +import { AuthServerType } from '../auth-config' + +export type RepositorySwitchContextValue = { + authType: AuthServerType + switchRepository: (repoUrl: string) => void +} + +export const RepositorySwitchContext = createContext({ + authType: 'SNAuth', + switchRepository: () => {}, +}) + +export const useRepositorySwitch = () => useContext(RepositorySwitchContext) diff --git a/apps/sensenet/src/context/sn-auth-repository-provider.tsx b/apps/sensenet/src/context/sn-auth-repository-provider.tsx index a873d058fc..97ef01dbdb 100644 --- a/apps/sensenet/src/context/sn-auth-repository-provider.tsx +++ b/apps/sensenet/src/context/sn-auth-repository-provider.tsx @@ -50,7 +50,7 @@ export function SnAuthRepositoryProvider({ }) const cancelledLogin = useQuery().get('cancelledLogin') const [configString, setConfigString] = useState() - const [authServerUrl, setAuthServerUrl] = useState() + const [authServerUrl, setAuthServerUrl] = useState() const clearState = useCallback(() => setAuthState({ repoUrl: '', config: null }), []) @@ -87,6 +87,7 @@ export function SnAuthRepositoryProvider({ const storedConfig = getSnAuthRepositoryConfig(repoUrl) setSelectedSnAuthRepository(repoUrl) + setAuthServerUrl(storedConfig?.userManagerSettings.authority) setAuthState({ repoUrl, config: storedConfig?.userManagerSettings ?? null }) setConfigString(storedConfig ? JSON.stringify(storedConfig) : null) } diff --git a/apps/sensenet/src/localization/default.ts b/apps/sensenet/src/localization/default.ts index 3465673e69..8d4d6c022a 100644 --- a/apps/sensenet/src/localization/default.ts +++ b/apps/sensenet/src/localization/default.ts @@ -292,6 +292,7 @@ const values = { sendLogWithCrashReports: 'Send log data with crash reports by default', }, repositorySelector: { + activeRepository: 'Repository', loggedInAs: 'You are currently logged in as {0}', notLoggedIn: 'You are not logged in.', anotherRepo: 'Another repository', diff --git a/apps/sensenet/src/localization/hungarian.ts b/apps/sensenet/src/localization/hungarian.ts index c1a15626cd..d436fab0b6 100644 --- a/apps/sensenet/src/localization/hungarian.ts +++ b/apps/sensenet/src/localization/hungarian.ts @@ -142,6 +142,7 @@ const values: Localization = { repositoryDisplayName: 'Egy tetszőleges megjelenítendő név', }, repositorySelector: { + activeRepository: 'Repository', anotherRepo: 'Másik repository', loggedInAs: 'Bejelentkezve mint {0}', notLoggedIn: 'Nincs bejelentkezve', diff --git a/apps/sensenet/src/services/repository-session.ts b/apps/sensenet/src/services/repository-session.ts index fb640fb447..a9c505fc5f 100644 --- a/apps/sensenet/src/services/repository-session.ts +++ b/apps/sensenet/src/services/repository-session.ts @@ -10,6 +10,8 @@ const snAuthPendingLoginRepositoryKey = 'sn-auth-pending-login-repository' const snAuthScopedConfigKeyPrefix = 'sn-auth-config' const snAuthStorageKeyPrefix = 'sn-auth' +export const snAuthRepositorySessionsChangedEvent = 'sn-auth-repository-sessions-changed' + export type SnAuthRepositorySession = { repoUrl: string authServerUrl?: string @@ -39,6 +41,15 @@ export const getSnAuthStorageKeyPrefix = (repoUrl: string) => export const getSnAuthRepositoryConfigKey = (repoUrl: string) => `${snAuthScopedConfigKeyPrefix}:${encodeRepositoryUrl(repoUrl)}` +export const hasSnAuthRepositoryTokens = (repoUrl: string) => { + const storageKeyPrefix = getSnAuthStorageKeyPrefix(repoUrl) + + return ( + !!window.localStorage.getItem(getStorageKey(ACCESS_TOKEN_KEY, storageKeyPrefix)) && + !!window.localStorage.getItem(getStorageKey(REFRESH_TOKEN_KEY, storageKeyPrefix)) + ) +} + export const getSelectedSnAuthRepository = () => window.localStorage.getItem(snAuthSelectedRepositoryKey) export const setSelectedSnAuthRepository = (repoUrl: string) => { @@ -83,6 +94,9 @@ export const getSnAuthRepositorySessions = (): SnAuthRepositorySession[] => { } } +export const getAuthenticatedSnAuthRepositorySessions = () => + getSnAuthRepositorySessions().filter((session) => hasSnAuthRepositoryTokens(session.repoUrl)) + export const upsertSnAuthRepositorySession = (repoUrl: string, authServerUrl?: string) => { const normalizedRepoUrl = normalizeRepositoryUrl(repoUrl) const sessions = getSnAuthRepositorySessions().filter((session) => session.repoUrl !== normalizedRepoUrl) @@ -97,6 +111,7 @@ export const upsertSnAuthRepositorySession = (repoUrl: string, authServerUrl?: s window.localStorage.setItem(snAuthRepositorySessionsKey, JSON.stringify(nextSessions)) setSelectedSnAuthRepository(normalizedRepoUrl) + window.dispatchEvent(new Event(snAuthRepositorySessionsChangedEvent)) } export const removeSnAuthRepositorySession = (repoUrl: string) => { @@ -112,6 +127,8 @@ export const removeSnAuthRepositorySession = (repoUrl: string) => { if (getSelectedSnAuthRepository() === normalizedRepoUrl) { clearSelectedSnAuthRepository() } + + window.dispatchEvent(new Event(snAuthRepositorySessionsChangedEvent)) } export const getSnAuthRepositoryConfig = (repoUrl: string) => {