diff --git a/apps/sensenet/src/components/login/login-page.tsx b/apps/sensenet/src/components/login/login-page.tsx index 9b9bde9aa..0c18b6bf2 100644 --- a/apps/sensenet/src/components/login/login-page.tsx +++ b/apps/sensenet/src/components/login/login-page.tsx @@ -1,27 +1,48 @@ import { AppBar, Button, + ButtonBase, CircularProgress, Container, createStyles, - Grid, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, InputLabel, List, ListItem, - ListItemText, makeStyles, + Menu, + MenuItem, + Snackbar, TextField, - TextFieldProps, Theme, + Tooltip, Typography, } from '@material-ui/core' -import { PathHelper } from '@sensenet/client-utils' -import { clsx } from 'clsx' -import React, { useRef } from 'react' +import ArrowForward from '@material-ui/icons/ArrowForward' +import Bookmark from '@material-ui/icons/Bookmark' +import BookmarkBorder from '@material-ui/icons/BookmarkBorder' +import MoreHoriz from '@material-ui/icons/MoreHoriz' +import Storage from '@material-ui/icons/Storage' +import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import { Link } from 'react-router-dom' import snLogo from '../../assets/sensenet_white.png' -import { globals, useGlobalStyles } from '../../globalStyles' +import { globals } from '../../globalStyles' import { useLocalization } from '../../hooks' +import { useRepositoryConnections } from '../../hooks/use-repository-connections' +import { + parseRepositoryConnectionUrl, + removeRepositoryConnection, + renameRepositoryConnection, + RepositoryConnection, + restoreRepositoryConnection, + setRepositoryConnectionPinned, +} from '../../services/repository-connections' + +const recentRepositoryLimit = 6 const useStyles = makeStyles((theme: Theme) => createStyles({ @@ -31,155 +52,450 @@ const useStyles = makeStyles((theme: Theme) => backgroundColor: theme.palette.type === 'dark' ? globals.common.headerBackground : globals.common.headerLightBackground, boxShadow: 'none', + justifyContent: 'center', + paddingLeft: theme.spacing(2), }, - loginSubtitle: { - marginBottom: '1em', + logo: { height: 32, display: 'block' }, + container: { maxWidth: 880, paddingTop: theme.spacing(4), paddingBottom: theme.spacing(4) }, + title: { marginBottom: theme.spacing(3) }, + label: { marginBottom: theme.spacing(1) }, + formRow: { + display: 'flex', + alignItems: 'flex-start', + gap: theme.spacing(1.5), + [theme.breakpoints.down('xs')]: { flexDirection: 'column' }, }, - input: { - paddingTop: '10px', - paddingBottom: '10px', - - '&:-webkit-autofill': { - WebkitBoxShadow: 'unset', - WebkitTextFillColor: 'unset', - caretColor: 'unset', - }, + input: { paddingTop: 11, paddingBottom: 11 }, + continueButton: { + flexShrink: 0, + minHeight: 42, + [theme.breakpoints.down('xs')]: { width: '100%' }, + }, + group: { marginTop: theme.spacing(3) }, + groupTitle: { + display: 'flex', + gap: theme.spacing(1), + alignItems: 'center', + color: theme.palette.text.secondary, + marginBottom: theme.spacing(0.5), + }, + count: { fontWeight: 400 }, + row: { padding: 0, borderBottom: `1px solid ${theme.palette.divider}`, gap: theme.spacing(0.25) }, + open: { + flex: 1, + minWidth: 0, + padding: theme.spacing(1, 0.5), + justifyContent: 'flex-start', + textAlign: 'left', + gap: theme.spacing(1.5), + '&:hover': { backgroundColor: theme.palette.action.hover }, + }, + focused: { outline: `2px solid ${theme.palette.primary.main}`, outlineOffset: -2 }, + repositoryIcon: { + color: theme.palette.text.secondary, + marginLeft: theme.spacing(1), + flexShrink: 0, + [theme.breakpoints.down('xs')]: { display: 'none' }, + }, + repositoryText: { flex: 1, minWidth: 0, overflowWrap: 'anywhere' }, + repositoryName: { display: 'block', fontWeight: 500 }, + repositoryUrl: { display: 'block', overflowWrap: 'anywhere' }, + date: { + whiteSpace: 'nowrap', + marginLeft: theme.spacing(1), + [theme.breakpoints.down('xs')]: { display: 'none' }, + }, + arrow: { flexShrink: 0, marginLeft: theme.spacing(0.5), color: theme.palette.primary.main }, + iconButton: { + flexShrink: 0, + padding: theme.spacing(1), + '@media (pointer: coarse)': { minWidth: 44, minHeight: 44 }, }, + showAll: { marginTop: theme.spacing(1) }, + renameInput: { minWidth: 0 }, }), ) -const DEVDEMO_URL = `https://dev.demo.sensenet.com` - type LoginPageProps = { handleSubmit: (url: string) => void isLoginInProgress: boolean - repositoryOptions?: Array<{ repoUrl: string; lastUsed?: string }> - handleSelectRepository?: (url: string) => void + initialRepositoryUrl?: string + connectionFailed?: boolean +} + +const repositoryName = (connection: RepositoryConnection) => { + if (connection.name) return connection.name + try { + const url = new URL(connection.repoUrl) + return `${url.host}${url.pathname.replace(/\/$/, '')}` + } catch { + return connection.repoUrl + } +} + +const repositoryDate = (value?: string) => { + if (!value) return undefined + const date = new Date(value) + if (Number.isNaN(date.getTime())) return undefined + return date.toLocaleDateString(document.documentElement.lang || undefined, { + day: 'numeric', + month: 'short', + ...(date.getFullYear() !== new Date().getFullYear() ? { year: 'numeric' } : {}), + }) } export default function LoginPage({ handleSubmit, isLoginInProgress, - repositoryOptions = [], - handleSelectRepository, + initialRepositoryUrl = '', + connectionFailed = false, }: LoginPageProps) { const classes = useStyles() - const globalClasses = useGlobalStyles() const localization = useLocalization().login - const loginUrlInputField = useRef(null) + const { connections } = useRepositoryConnections() + const [repositoryUrl, setRepositoryUrl] = useState(initialRepositoryUrl) + const [invalidUrl, setInvalidUrl] = useState(false) + const [edited, setEdited] = useState(false) + const [showAllRecent, setShowAllRecent] = useState(false) + const [activeSubmission, setActiveSubmission] = useState() + const [menu, setMenu] = useState<{ anchor: HTMLElement; connection: RepositoryConnection }>() + const [renaming, setRenaming] = useState() + const [displayName, setDisplayName] = useState('') + const [removed, setRemoved] = useState() + const main = useRef(null) + const input = useRef(null) + const focusRequest = useRef<{ action: string; repoUrl?: string }>() + const menuFocusRequest = useRef<{ action: string; repoUrl?: string }>() + const dialogReturnUrl = useRef() + const pinned = connections.filter((connection) => connection.pinned) + const recent = connections.filter((connection) => !connection.pinned) + const visibleRecent = showAllRecent ? recent : recent.slice(0, recentRepositoryLimit) + const error = invalidUrl + ? localization.invalidRepositoryUrl + : connectionFailed && !edited + ? localization.repositoryConnectionFailed + : undefined - const handleOnSubmit = (ev: React.FormEvent) => { - ev.preventDefault() + useEffect(() => { + setRepositoryUrl(initialRepositoryUrl) + setEdited(false) + setInvalidUrl(false) + }, [initialRepositoryUrl, connectionFailed]) - const url = loginUrlInputField.current?.value as string + const focusControl = useCallback((action: string, repoUrl?: string) => { + const controls = Array.from(document.querySelectorAll('[data-repository-action]')) + const target = controls.find( + (control) => + control.dataset.repositoryAction === action && (!repoUrl || control.dataset.repositoryUrl === repoUrl), + ) + const fallback = main.current?.querySelector('[data-repository-action="show-all"]') + ;(target || fallback || input.current)?.focus() + }, []) - handleSubmit(PathHelper.ensureDefaultSchema(url)) + useLayoutEffect(() => { + const request = focusRequest.current + if (request) { + focusRequest.current = undefined + focusControl(request.action, request.repoUrl) + } + }, [connections, showAllRecent, removed, menu, focusControl]) + + const connect = (url: string, submission: string) => { + if (isLoginInProgress) return + setActiveSubmission(submission) + setInvalidUrl(false) + setEdited(true) + handleSubmit(url) } - const handleDemoSubmit = () => { - handleSubmit(DEVDEMO_URL) + const handleOnSubmit = (event: React.FormEvent) => { + event.preventDefault() + if (isLoginInProgress) return + const parsed = parseRepositoryConnectionUrl(repositoryUrl) + if (!parsed) { + setInvalidUrl(true) + input.current?.focus() + return + } + connect(parsed, 'input') } + const closeMenu = () => { + menuFocusRequest.current = { action: 'more', repoUrl: menu?.connection.repoUrl } + setMenu(undefined) + } + + const closeRename = () => { + dialogReturnUrl.current = renaming?.repoUrl + setRenaming(undefined) + } + + const renderConnections = (items: RepositoryConnection[]) => ( + + {items.map((connection) => { + const name = repositoryName(connection) + const date = repositoryDate(connection.lastUsed) + return ( + + connect(connection.repoUrl, connection.repoUrl)}> + + + + {name} + + + {connection.repoUrl} + + + {date && ( + + + {date} + + + )} + {isLoginInProgress && activeSubmission === connection.repoUrl ? ( + + ) : ( + + )} + + + + { + focusRequest.current = { action: 'pin', repoUrl: connection.repoUrl } + setRepositoryConnectionPinned(connection.repoUrl, !connection.pinned) + }}> + {connection.pinned ? : } + + + + setMenu({ anchor: event.currentTarget, connection })}> + + + + ) + })} + + ) + return ( <> - - - - - - sensenet logo - - - - + + + sensenet logo + - - - - - {localization.welcome} - - - - - - {localization.demoTitle} - - - - - - - -
- - {localization.repositoryUrl} - - - {localization.repositoryLabel} - + +
+ + {localization.repositoriesTitle} + + + + {localization.repositoryLabel} + +
{error} : undefined} + onChange={(event) => { + setRepositoryUrl(event.target.value) + setInvalidUrl(false) + setEdited(true) }} - style={{ paddingBottom: 30 }} /> - - - {repositoryOptions.length > 0 && handleSelectRepository ? ( - - +
+ + {pinned.length > 0 && ( +
+ + {localization.pinnedRepositories} + {pinned.length} + + {renderConnections(pinned)} +
+ )} + {recent.length > 0 && ( +
+ {localization.recentRepositories} + + {localization.recentRepositoryCount(visibleRecent.length, recent.length)} + - - {repositoryOptions.map((repositoryOption) => ( - handleSelectRepository(repositoryOption.repoUrl)}> - - - ))} - - - ) : null} - + {renderConnections(visibleRecent)} + {recent.length > recentRepositoryLimit && ( + + )} +
+ )} +
+ { + const request = menuFocusRequest.current + menuFocusRequest.current = undefined + if (request) focusControl(request.action, request.repoUrl) + }, + }}> + { + setRenaming(menu?.connection) + setDisplayName(menu?.connection.name || '') + setMenu(undefined) + }}> + {localization.renameRepository} + + { + if (!menu) return + menuFocusRequest.current = { action: 'undo' } + setRemoved(removeRepositoryConnection(menu.connection.repoUrl)) + setMenu(undefined) + }}> + {localization.removeRepositoryFromList} + + + { + if (dialogReturnUrl.current) focusControl('more', dialogReturnUrl.current) + dialogReturnUrl.current = undefined + }, + }}> +
{ + event.preventDefault() + if (renaming && !isLoginInProgress) renameRepositoryConnection(renaming.repoUrl, displayName) + closeRename() + }}> + {localization.renameRepository} + + setDisplayName(event.target.value)} + /> + + + + + +
+
+ { + if (reason !== 'clickaway') setRemoved(undefined) + }} + action={ + + } + /> ) } diff --git a/apps/sensenet/src/context/local-repository-provider.tsx b/apps/sensenet/src/context/local-repository-provider.tsx index 5479a8840..70d25b245 100644 --- a/apps/sensenet/src/context/local-repository-provider.tsx +++ b/apps/sensenet/src/context/local-repository-provider.tsx @@ -9,6 +9,7 @@ import { LocalRepositorySession, localSelectedRepository, } from '../services/local-authentication' +import { rememberRepositoryConnection } from '../services/repository-connections' import { AuthContext } from './auth-provider' export function LocalRepositoryProvider({ @@ -92,6 +93,7 @@ export function LocalRepositoryProvider({ if (String(result.d?.Id) !== current.subject) throw new Error('Could not identify your account.') await repo.reloadSchema() if (mounted.current && activeSession.current === current && current.revision === revision && current.hasSession) { + rememberRepositoryConnection(current.repositoryUrl) setRepository(repo) setUser(result.d) return true diff --git a/apps/sensenet/src/context/repository-provider.tsx b/apps/sensenet/src/context/repository-provider.tsx index c8aba84be..d30b6521c 100644 --- a/apps/sensenet/src/context/repository-provider.tsx +++ b/apps/sensenet/src/context/repository-provider.tsx @@ -2,7 +2,7 @@ import { CssBaseline } from '@material-ui/core' import { AuthenticationProvider, useOidcAuthentication, UserManagerSettings } from '@sensenet/authentication-oidc-react' import { Repository } from '@sensenet/client-core' import { RepositoryContext, useLogger } from '@sensenet/hooks-react' -import React, { lazy, ReactNode, Suspense, useCallback, useEffect, useState } from 'react' +import React, { lazy, ReactNode, Suspense, useCallback, useEffect, useRef, useState } from 'react' import { useHistory } from 'react-router-dom' import { FullScreenLoader } from '../components/full-screen-loader' import { AuthOverrideSkeleton } from '../components/login/auth-override-skeleton' @@ -12,6 +12,7 @@ import { NotificationComponent } from '../components/NotificationComponent' import { useGlobalStyles } from '../globalStyles' import { useQuery } from '../hooks' import { getAuthConfig } from '../services/auth-config' +import { rememberRepositoryConnection } from '../services/repository-connections' import { clearActiveRepositorySelection, normalizeRepositoryUrl, @@ -39,6 +40,7 @@ export function RepositoryProvider({ prepareAuthentication: (repoUrl: string) => Promise }) { const [isLoginInProgress, setIsLoginInProgress] = useState(false) + const [failedRepositoryUrl, setFailedRepositoryUrl] = useState('') const [authenticationCheckedFor, setAuthenticationCheckedFor] = useState('') const logger = useLogger('repository-provider') const globalClasses = useGlobalStyles() @@ -101,6 +103,7 @@ export function RepositoryProvider({ } catch (error) { if (!isCurrent()) return logger.warning({ data: error, message: `Couldn't connect to ${authState.repoUrl}` }) + setFailedRepositoryUrl(authState.repoUrl) clearActiveRepositorySelection() setAuthState((oldState) => ({ ...oldState, repoUrl: '' })) } finally { @@ -131,7 +134,10 @@ export function RepositoryProvider({ ) : ( { + setFailedRepositoryUrl('') setAuthState({ repoUrl: normalizeRepositoryUrl(formUrl), config: null, @@ -191,6 +197,14 @@ const RepoProvider = ({ const { oidcUser, login, logout, error, errorOperation } = useOidcAuthentication() const logger = useLogger('repo-provider') const [repo, setRepo] = useState() + const rememberedRepository = useRef('') + + useEffect(() => { + if (oidcUser && !oidcUser.expired && rememberedRepository.current !== repoUrl) { + rememberRepositoryConnection(repoUrl) + rememberedRepository.current = repoUrl + } + }, [oidcUser, repoUrl]) useEffect(() => { setRepo((prevRepo) => { diff --git a/apps/sensenet/src/context/sn-auth-repository-provider.tsx b/apps/sensenet/src/context/sn-auth-repository-provider.tsx index 38ea9c94d..177db9320 100644 --- a/apps/sensenet/src/context/sn-auth-repository-provider.tsx +++ b/apps/sensenet/src/context/sn-auth-repository-provider.tsx @@ -3,19 +3,19 @@ import { UserManagerSettings } from '@sensenet/authentication-oidc-react' import { Repository } from '@sensenet/client-core' import { RepositoryContext, useLogger } from '@sensenet/hooks-react' import { AuthenticationProvider, getPendingExternalLoginTarget, useSnAuth } from '@sensenet/sn-auth-react' -import React, { lazy, ReactNode, Suspense, useCallback, useEffect, useState } from 'react' +import React, { lazy, ReactNode, Suspense, useCallback, useEffect, useRef, useState } from 'react' import { FullScreenLoader } from '../components/full-screen-loader' import { AuthOverrideSkeleton } from '../components/login/auth-override-skeleton' import { NotificationComponent } from '../components/NotificationComponent' import { useGlobalStyles } from '../globalStyles' import { useQuery } from '../hooks' import { getAuthConfig } from '../services/auth-config' +import { rememberRepositoryConnection } from '../services/repository-connections' import { clearActiveRepositorySelection, consumeSnAuthRepositoryLogin, getSelectedSnAuthRepository, getSnAuthRepositoryConfig, - getSnAuthRepositorySessions, getSnAuthStorageKeyPrefix, hasPendingSnAuthRepositoryLogin, migrateLegacySnAuthTokens, @@ -45,6 +45,7 @@ export function SnAuthRepositoryProvider({ prepareAuthentication: (repoUrl: string) => Promise }) { const [isLoginInProgress, setIsLoginInProgress] = useState(false) + const [failedRepositoryUrl, setFailedRepositoryUrl] = useState('') const [authenticationCheckedFor, setAuthenticationCheckedFor] = useState('') const logger = useLogger('repository-provider') const globalClasses = useGlobalStyles() @@ -170,6 +171,8 @@ export function SnAuthRepositoryProvider({ } catch (error) { if (!isCurrent()) return logger.warning({ data: error, message: `Couldn't connect to ${authState.repoUrl}` }) + setFailedRepositoryUrl(authState.repoUrl) + setConfigString(null) clearActiveRepositorySelection() setAuthState((oldState) => ({ ...oldState, repoUrl: '' })) } finally { @@ -213,15 +216,10 @@ export function SnAuthRepositoryProvider({ ) : ( { - startSnAuthRepositoryLogin(repoUrl) - setAuthState({ - repoUrl: normalizeRepositoryUrl(repoUrl), - config: null, - }) - }} + initialRepositoryUrl={failedRepositoryUrl} + connectionFailed={!!failedRepositoryUrl} handleSubmit={(formUrl) => { + setFailedRepositoryUrl('') startSnAuthRepositoryLogin(formUrl) setAuthState({ repoUrl: normalizeRepositoryUrl(formUrl), @@ -290,6 +288,7 @@ const RepoProvider = ({ const { user, externalLogin, logout, accessToken, error, isLoading } = useSnAuth() const logger = useLogger('repo-provider') const [repo, setRepo] = useState() + const rememberedRepository = useRef('') useEffect(() => { setRepo((prevRepo) => { @@ -326,6 +325,10 @@ const RepoProvider = ({ useEffect(() => { if (user && accessToken && authServerUrl) { upsertSnAuthRepositorySession(repoUrl, authServerUrl) + if (rememberedRepository.current !== repoUrl) { + rememberRepositoryConnection(repoUrl) + rememberedRepository.current = repoUrl + } } }, [accessToken, authServerUrl, repoUrl, user]) diff --git a/apps/sensenet/src/hooks/use-repository-connections.ts b/apps/sensenet/src/hooks/use-repository-connections.ts new file mode 100644 index 000000000..efe98c465 --- /dev/null +++ b/apps/sensenet/src/hooks/use-repository-connections.ts @@ -0,0 +1,26 @@ +import { useEffect, useState } from 'react' +import { + getRepositoryConnections, + repositoryConnectionsChangedEvent, + repositoryConnectionsStorageKey, +} from '../services/repository-connections' + +export const useRepositoryConnections = () => { + const [connections, setConnections] = useState(getRepositoryConnections) + + useEffect(() => { + const update = () => setConnections(getRepositoryConnections()) + const onStorage = (event: StorageEvent) => { + if (event.key === null || event.key === repositoryConnectionsStorageKey) update() + } + window.addEventListener(repositoryConnectionsChangedEvent, update) + window.addEventListener('storage', onStorage) + update() + return () => { + window.removeEventListener(repositoryConnectionsChangedEvent, update) + window.removeEventListener('storage', onStorage) + } + }, []) + + return { connections } +} diff --git a/apps/sensenet/src/localization/default.ts b/apps/sensenet/src/localization/default.ts index c4f640794..ae82d128d 100644 --- a/apps/sensenet/src/localization/default.ts +++ b/apps/sensenet/src/localization/default.ts @@ -285,6 +285,26 @@ const values = { repositoryUrl: 'Otherwise please type in your repository URL to continue', recentRepositories: 'Recent repositories', lastUsedRepository: (lastUsed: string) => `Last used: ${lastUsed}`, + repositoriesTitle: 'Repositories', + continueToRepository: 'Continue', + pinnedRepositories: 'Pinned', + recentRepositoryCount: (visible: number, total: number) => + visible === total ? `${total}` : `${visible} of ${total}`, + showAllRepositories: (count: number) => `Show all ${count}`, + showFewerRepositories: 'Show fewer', + connectToRepository: (name: string) => `Connect to ${name}`, + pinRepository: 'Pin to top', + unpinRepository: 'Unpin', + repositoryOptions: 'Repository options', + renameRepository: 'Rename repository', + repositoryDisplayName: 'Display name', + cancelRepositoryRename: 'Cancel', + saveRepositoryName: 'Save', + removeRepositoryFromList: 'Remove from this list', + repositoryRemovedFromList: (name: string) => `${name} removed from this list.`, + undoRepositoryRemoval: 'Undo', + invalidRepositoryUrl: 'Enter a valid repository URL.', + repositoryConnectionFailed: 'Could not connect. Check the repository address and try again.', }, logout: { logoutButtonTitle: 'Log out', diff --git a/apps/sensenet/src/localization/hungarian.ts b/apps/sensenet/src/localization/hungarian.ts index 6422e268a..c73ce8026 100644 --- a/apps/sensenet/src/localization/hungarian.ts +++ b/apps/sensenet/src/localization/hungarian.ts @@ -112,10 +112,30 @@ const values: Localization = { userNameHelperText: 'A rendszerben tárolt felhasználónév', passwordLabel: 'Jelszó', passwordHelperText: 'A felhasználóhoz tartozó jelszó', - repositoryLabel: 'Elérési út', + repositoryLabel: 'Repository URL', 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}`, + repositoriesTitle: 'Repository-k', + continueToRepository: 'Tovább', + pinnedRepositories: 'Rögzített', + recentRepositoryCount: (visible: number, total: number) => + visible === total ? `${total}` : `${visible} / ${total}`, + showAllRepositories: (count: number) => `Összes megjelenítése (${count})`, + showFewerRepositories: 'Kevesebb megjelenítése', + connectToRepository: (name: string) => `Kapcsolódás: ${name}`, + pinRepository: 'Rögzítés felülre', + unpinRepository: 'Rögzítés feloldása', + repositoryOptions: 'Repository műveletek', + renameRepository: 'Repository átnevezése', + repositoryDisplayName: 'Megjelenített név', + cancelRepositoryRename: 'Mégsem', + saveRepositoryName: 'Mentés', + removeRepositoryFromList: 'Eltávolítás a listából', + repositoryRemovedFromList: (name: string) => `${name} eltávolítva a listából.`, + undoRepositoryRemoval: 'Visszavonás', + invalidRepositoryUrl: 'Adj meg egy érvényes repository URL-t.', + repositoryConnectionFailed: 'Nem sikerült kapcsolódni. Ellenőrizd a repository címét, és próbáld újra.', }, logout: { logoutCancel: 'Mégsem', diff --git a/apps/sensenet/src/services/repository-connections.ts b/apps/sensenet/src/services/repository-connections.ts new file mode 100644 index 000000000..0a6728089 --- /dev/null +++ b/apps/sensenet/src/services/repository-connections.ts @@ -0,0 +1,156 @@ +import { normalizeRepositoryUrl } from './repository-session' + +export const repositoryConnectionsStorageKey = 'sn-repository-connections' +export const repositoryConnectionsChangedEvent = 'sn-repository-connections-changed' +const legacySnAuthRepositorySessionsKey = 'sn-auth-repository-sessions' + +export type RepositoryConnection = { + repoUrl: string + name?: string + pinned: boolean + lastUsed?: string +} + +// A denied/quota-limited storage write should not prevent repository selection. +// Keep changes for this page while still accepting newer persistent data. +let fallbackConnections: RepositoryConnection[] | undefined +let fallbackBase: string | null | undefined + +export const parseRepositoryConnectionUrl = (input: string): string | undefined => { + const value = input.trim() + if (!value || /[\s\\]/.test(value) || value.startsWith('/')) return undefined + + const webUrl = /^https?:\/\//i.test(value) + // Do not let URL's permissive slash handling turn a mistyped scheme into a + // different repository host. A scheme-less host:port is still a valid input. + if (/^https?:/i.test(value) && !webUrl) return undefined + if (webUrl && !/^https?:\/\/[^/?#]/i.test(value)) return undefined + if (/^[a-z][a-z0-9+.-]*:/i.test(value) && !webUrl && !/^[^/?#:\\]+:\d+(?:[/?#]|$)/.test(value)) return undefined + + try { + const url = new URL(webUrl ? value : `https://${value}`) + if (!['http:', 'https:'].includes(url.protocol) || !url.hostname || url.username || url.password) return undefined + return normalizeRepositoryUrl(url.href) + } catch { + return undefined + } +} + +const sanitizeConnections = (records: unknown): RepositoryConnection[] => { + if (!Array.isArray(records)) return [] + const connections = new Map() + + for (const record of records) { + if (!record || typeof record.repoUrl !== 'string') continue + const repoUrl = parseRepositoryConnectionUrl(record.repoUrl) + if (!repoUrl) continue + const name = typeof record.name === 'string' ? record.name.trim().slice(0, 80) || undefined : undefined + const parsedDate = typeof record.lastUsed === 'string' ? Date.parse(record.lastUsed) : NaN + const lastUsed = Number.isFinite(parsedDate) ? new Date(parsedDate).toISOString() : undefined + const previous = connections.get(repoUrl) + connections.set(repoUrl, { + repoUrl, + name: previous?.name || name, + pinned: previous?.pinned || record.pinned === true, + lastUsed: previous?.lastUsed && (!lastUsed || previous.lastUsed > lastUsed) ? previous.lastUsed : lastUsed, + }) + } + + return [...connections.values()].sort((a, b) => (b.lastUsed || '').localeCompare(a.lastUsed || '')) +} + +const readJson = (value: string | null): unknown => { + try { + return value === null ? undefined : JSON.parse(value) + } catch { + return undefined + } +} + +const saveConnections = (records: RepositoryConnection[], notify = true) => { + const connections = sanitizeConnections(records) + try { + window.localStorage.setItem(repositoryConnectionsStorageKey, JSON.stringify({ version: 1, connections })) + fallbackConnections = undefined + fallbackBase = undefined + } catch { + fallbackConnections = connections + try { + fallbackBase = window.localStorage.getItem(repositoryConnectionsStorageKey) + } catch { + fallbackBase = undefined + } + } + + if (notify) window.dispatchEvent(new Event(repositoryConnectionsChangedEvent)) +} + +export const getRepositoryConnections = (): RepositoryConnection[] => { + try { + const raw = window.localStorage.getItem(repositoryConnectionsStorageKey) + if (fallbackConnections && raw === fallbackBase) return fallbackConnections.map((connection) => ({ ...connection })) + fallbackConnections = undefined + fallbackBase = undefined + + if (raw === null) { + // Import metadata once. Never copy credentials or modify the old session list. + // Persist even an empty list so forgetting a connection cannot re-import it. + const connections = sanitizeConnections(readJson(window.localStorage.getItem(legacySnAuthRepositorySessionsKey))) + saveConnections(connections, false) + return connections + } + + const stored = readJson(raw) as { version?: unknown; connections?: unknown } | undefined + return stored?.version === 1 ? sanitizeConnections(stored.connections) : [] + } catch { + return fallbackConnections?.map((connection) => ({ ...connection })) || [] + } +} + +export const rememberRepositoryConnection = (providedUrl: string): void => { + const repoUrl = parseRepositoryConnectionUrl(providedUrl) + if (!repoUrl) return + const connections = getRepositoryConnections() + const previous = connections.find((connection) => connection.repoUrl === repoUrl) + saveConnections([ + { ...previous, repoUrl, pinned: previous?.pinned || false, lastUsed: new Date().toISOString() }, + ...connections.filter((connection) => connection.repoUrl !== repoUrl), + ]) +} + +export const renameRepositoryConnection = (providedUrl: string, name: string): void => { + const repoUrl = parseRepositoryConnectionUrl(providedUrl) + if (!repoUrl) return + saveConnections( + getRepositoryConnections().map((connection) => + connection.repoUrl === repoUrl ? { ...connection, name: name.trim().slice(0, 80) || undefined } : connection, + ), + ) +} + +export const setRepositoryConnectionPinned = (providedUrl: string, pinned: boolean): void => { + const repoUrl = parseRepositoryConnectionUrl(providedUrl) + if (!repoUrl) return + saveConnections( + getRepositoryConnections().map((connection) => + connection.repoUrl === repoUrl ? { ...connection, pinned } : connection, + ), + ) +} + +export const removeRepositoryConnection = (providedUrl: string): RepositoryConnection | undefined => { + const repoUrl = parseRepositoryConnectionUrl(providedUrl) + if (!repoUrl) return undefined + const connections = getRepositoryConnections() + const removed = connections.find((connection) => connection.repoUrl === repoUrl) + if (removed) saveConnections(connections.filter((connection) => connection.repoUrl !== repoUrl)) + return removed +} + +export const restoreRepositoryConnection = (connection: RepositoryConnection): void => { + const restored = sanitizeConnections([connection])[0] + if (!restored) return + const connections = getRepositoryConnections() + if (!connections.some((existing) => existing.repoUrl === restored.repoUrl)) + saveConnections([...connections, restored]) +} diff --git a/apps/sensenet/test/local-authentication-provider.test.tsx b/apps/sensenet/test/local-authentication-provider.test.tsx index 98cbb2a11..1409ca882 100644 --- a/apps/sensenet/test/local-authentication-provider.test.tsx +++ b/apps/sensenet/test/local-authentication-provider.test.tsx @@ -4,6 +4,7 @@ import { act, Simulate } from 'react-dom/test-utils' import { AuthContext } from '../src/context/auth-provider' import { LocalRepositoryProvider } from '../src/context/local-repository-provider' import { localSessionKey } from '../src/services/local-authentication' +import { getRepositoryConnections } from '../src/services/repository-connections' jest.mock('@sensenet/client-core', () => ({ Repository: class { @@ -27,6 +28,7 @@ jest.mock( ) jest.mock('../src/services/repository-session', () => ({ normalizeRepositoryUrl: (url: string) => url.replace(/\/$/, ''), + getSnAuthRepositorySessions: () => [], })) const repo = 'https://repo.example' const endpoints = { @@ -54,6 +56,7 @@ const Probe = () => { let element: HTMLDivElement const previousFetch = global.fetch beforeEach(() => { + localStorage.clear() sessionStorage.clear() element = document.createElement('div') document.body.appendChild(element) @@ -64,6 +67,7 @@ afterEach(() => { }) element.remove() global.fetch = previousFetch + localStorage.clear() }) const mount = (initialResetToken?: string) => act(async () => { @@ -96,6 +100,7 @@ it('submits explicit credentials/MFA, loads the repository identity and revokes twoFactorCode: '123456', }) expect(element.textContent).toContain('Signed in as admin') + expect(getRepositoryConnections()).toEqual([expect.objectContaining({ repoUrl: repo })]) expect(sessionStorage.getItem(localSessionKey(repo, repo))).not.toContain('secret') await act(async () => { element.querySelector('button')!.click() @@ -103,6 +108,7 @@ it('submits explicit credentials/MFA, loads the repository identity and revokes expect(request.mock.calls[3][0]).toBe(repo + endpoints.logout) expect(element.textContent).not.toContain('Signed in as admin') expect(sessionStorage.getItem(localSessionKey(repo, repo))).toBeNull() + expect(getRepositoryConnections()).toEqual([expect.objectContaining({ repoUrl: repo })]) }) it('never renders authenticated content when a restored token resolves to Visitor', async () => { @@ -114,6 +120,7 @@ it('never renders authenticated content when a restored token resolves to Visito await mount() expect(element.textContent).not.toContain('Signed in as') expect(element.querySelector('[role=alert]')).not.toBeNull() + expect(getRepositoryConnections()).toEqual([]) }) it('consumes the recovery fragment without restoring an existing session or storing its token', async () => { diff --git a/apps/sensenet/test/repository-chooser.test.tsx b/apps/sensenet/test/repository-chooser.test.tsx new file mode 100644 index 000000000..50ef78635 --- /dev/null +++ b/apps/sensenet/test/repository-chooser.test.tsx @@ -0,0 +1,220 @@ +import React from 'react' +import { render, unmountComponentAtNode } from 'react-dom' +import { act, Simulate } from 'react-dom/test-utils' +import { MemoryRouter } from 'react-router-dom' +import LoginPage from '../src/components/login/login-page' +import DefaultLocalization from '../src/localization/default' +import HungarianLocalization from '../src/localization/hungarian' +import { + getRepositoryConnections, + rememberRepositoryConnection, + renameRepositoryConnection, + setRepositoryConnectionPinned, +} from '../src/services/repository-connections' + +let mockLocalization = DefaultLocalization +jest.mock('../src/hooks', () => ({ useLocalization: () => mockLocalization })) + +let element: HTMLDivElement +const onSubmit = jest.fn() + +beforeEach(() => { + jest.useFakeTimers() + localStorage.clear() + mockLocalization = DefaultLocalization + onSubmit.mockClear() + element = document.createElement('div') + document.body.appendChild(element) + jest.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + top: 0, + right: 100, + bottom: 36, + left: 0, + width: 100, + height: 36, + toJSON: () => ({}), + }) +}) + +afterEach(() => { + act(() => { + unmountComponentAtNode(element) + }) + element.remove() + jest.clearAllTimers() + jest.useRealTimers() + jest.restoreAllMocks() +}) + +const mount = (props: Partial> = {}) => { + act(() => { + render( + + + , + element, + ) + }) +} +const click = (target: Element) => act(() => Simulate.click(target)) +const fill = (value: string, selector = '#repository') => { + const input = document.querySelector(selector)! + act(() => Simulate.change(input, { target: { value } } as any)) +} +const submit = (form = element.querySelector('form')!) => act(() => Simulate.submit(form)) +const action = (name: string, url?: string) => + Array.from(document.querySelectorAll(`[data-repository-action="${name}"]`)).find( + (button) => !url || button.dataset.repositoryUrl === url, + )! +const menuItem = (text: string) => + Array.from(document.querySelectorAll('[role="menuitem"]')).find((item) => item.textContent === text)! +const rows = (group: string) => element.querySelectorAll(`[aria-labelledby="${group}-repositories"] li`) +const seed = (pinned: number, recent: number) => { + for (let i = 0; i < pinned + recent; i++) { + const url = `https://repo-${i}.example` + rememberRepositoryConnection(url) + if (i < pinned) setRepositoryConnectionPinned(url, true) + } +} + +it('shows the compact URL form without the obsolete welcome, demo or connection helper copy', () => { + mount() + expect(element.querySelector('h1')?.textContent).toBe('Repositories') + expect(element.querySelector('label[for="repository"]')?.textContent).toBe('Repository URL') + expect(element.textContent).toContain('Continue') + expect(element.textContent).not.toMatch(/welcome|demo|new connection|provided by|otherwise/i) + expect(element.querySelector('section')).toBeNull() +}) + +it('shows every pin, only six unpinned recent entries, and expands without losing the typed address or focus', () => { + seed(8, 14) + mount() + expect(rows('pinned')).toHaveLength(8) + expect(rows('recent')).toHaveLength(6) + expect(element.querySelector('#recent-repositories')?.textContent).toContain('6 of 14') + fill('https://new.example') + click(action('show-all')) + expect(rows('recent')).toHaveLength(14) + expect(action('show-all').getAttribute('aria-expanded')).toBe('true') + expect(document.activeElement).toBe(action('show-all')) + expect((element.querySelector('#repository') as HTMLInputElement).value).toBe('https://new.example') + click(action('show-all')) + expect(rows('recent')).toHaveLength(6) + expect(action('show-all').textContent).toContain('Show all 14') + expect(document.activeElement).toBe(action('show-all')) +}) + +it('moves a connection between groups without duplication and keeps a useful keyboard focus below the recent cutoff', () => { + seed(1, 8) + mount() + click(action('show-all')) + const oldestRecent = rows('recent')[rows('recent').length - 1] + const url = oldestRecent.querySelector('[data-repository-action="pin"]')!.dataset.repositoryUrl! + click(action('pin', url)) + expect(rows('pinned')).toHaveLength(2) + expect(getRepositoryConnections().filter((connection) => connection.repoUrl === url)).toHaveLength(1) + expect(document.activeElement).toBe(action('pin', url)) + click(action('show-all')) + click(action('pin', url)) + expect(rows('pinned')).toHaveLength(1) + expect(rows('recent')).toHaveLength(6) + expect(document.activeElement).toBe(action('show-all')) + expect(getRepositoryConnections().find((connection) => connection.repoUrl === url)?.pinned).toBe(false) +}) + +it('renames with safe text, preserves the exact URL, and accepts an empty name to restore its address', () => { + seed(0, 1) + const url = 'https://repo-0.example' + mount() + click(action('more', url)) + click(menuItem('Rename repository')) + fill('Personal name', '[name="repositoryDisplayName"]') + submit(document.querySelector('[role="dialog"] form')!) + act(() => jest.advanceTimersByTime(300)) + expect(element.textContent).toContain('Personal name') + expect(element.querySelector('b')).toBeNull() + expect(action('open', url).textContent).toContain(url) + expect(getRepositoryConnections()[0].name).toBe('Personal name') + click(action('more', url)) + click(menuItem('Rename repository')) + fill('', '[name="repositoryDisplayName"]') + submit(document.querySelector('[role="dialog"] form')!) + act(() => jest.advanceTimersByTime(300)) + expect(getRepositoryConnections()[0].name).toBeUndefined() + expect(action('open', url).textContent).toContain('repo-0.example') +}) + +it('removes only the remembered row and restores its name and pin with Undo', () => { + seed(1, 1) + const url = 'https://repo-0.example' + renameRepositoryConnection(url, 'My repository') + localStorage.setItem('auth-token-canary', 'unchanged') + mount() + click(action('more', url)) + click(menuItem('Remove from this list')) + act(() => jest.advanceTimersByTime(300)) + expect(action('open', url)).toBeUndefined() + expect(document.activeElement).toBe(action('undo')) + expect(document.body.textContent).toContain('My repository removed from this list.') + expect(localStorage.getItem('auth-token-canary')).toBe('unchanged') + click(action('undo')) + expect(action('open', url).textContent).toContain('My repository') + expect(action('pin', url).getAttribute('aria-pressed')).toBe('true') + expect(document.activeElement).toBe(action('open', url)) +}) + +it('validates URLs inline and submits a normalized address without storing failed or unconfirmed connections', () => { + mount() + for (const value of ['', 'ftp://repo.example', 'https://name:password@repo.example', 'not a url']) { + fill(value) + submit() + expect(element.querySelector('[role="alert"]')?.textContent).toBe('Enter a valid repository URL.') + expect(element.querySelector('#repository')?.getAttribute('aria-invalid')).toBe('true') + expect(document.activeElement).toBe(element.querySelector('#repository')) + } + expect(onSubmit).not.toHaveBeenCalled() + fill('repo.example/folder/?test=1#fragment') + submit() + expect(onSubmit).toHaveBeenCalledWith('https://repo.example/folder') + expect(getRepositoryConnections()).toEqual([]) +}) + +it('retains a failed repository address and dismisses the old connection error when the user edits it', () => { + mount({ initialRepositoryUrl: 'https://failed.example', connectionFailed: true }) + expect((element.querySelector('#repository') as HTMLInputElement).value).toBe('https://failed.example') + expect(element.querySelector('[role="alert"]')?.textContent).toContain('Could not connect') + fill('https://fixed.example') + expect(element.querySelector('[role="alert"]')).toBeNull() + mount({ initialRepositoryUrl: 'https://another-failure.example', connectionFailed: true }) + expect((element.querySelector('#repository') as HTMLInputElement).value).toBe('https://another-failure.example') + expect(element.querySelector('[role="alert"]')?.textContent).toContain('Could not connect') +}) + +it('disables mutations and shows one progress indicator on the selected connection while connecting', () => { + seed(1, 8) + const url = 'https://repo-0.example' + mount() + click(action('open', url)) + expect(onSubmit).toHaveBeenCalledWith(url) + mount({ isLoginInProgress: true }) + expect((element.querySelector('#repository') as HTMLInputElement).disabled).toBe(true) + expect(Array.from(element.querySelectorAll('button')).every((button) => button.disabled)).toBe(true) + expect(element.querySelectorAll('[role="progressbar"]')).toHaveLength(1) + expect(action('open', url).querySelector('[role="progressbar"]')).not.toBeNull() +}) + +it('localizes the new interactions in Hungarian', () => { + mockLocalization = { + ...DefaultLocalization, + login: { ...DefaultLocalization.login, ...HungarianLocalization.login }, + } + seed(1, 8) + mount() + expect(element.querySelector('h1')?.textContent).toBe('Repository-k') + expect(element.textContent).toContain('Tovább') + expect(element.textContent).toContain('Rögzített') + expect(action('show-all').textContent).toContain('Összes megjelenítése (8)') + expect(action('pin', 'https://repo-0.example').getAttribute('aria-label')).toContain('Rögzítés feloldása') +}) diff --git a/apps/sensenet/test/repository-connections-hook.test.tsx b/apps/sensenet/test/repository-connections-hook.test.tsx new file mode 100644 index 000000000..e4df22bbd --- /dev/null +++ b/apps/sensenet/test/repository-connections-hook.test.tsx @@ -0,0 +1,61 @@ +import React from 'react' +import { act, create, ReactTestRenderer } from 'react-test-renderer' +import { useRepositoryConnections } from '../src/hooks/use-repository-connections' +import { + rememberRepositoryConnection, + renameRepositoryConnection, + repositoryConnectionsStorageKey, +} from '../src/services/repository-connections' + +describe('remembered repository connection subscriptions', () => { + let renderers: ReactTestRenderer[] + let observed: Array> + const Probe = ({ index }: { index: number }) => { + observed[index] = useRepositoryConnections() + return null + } + + beforeEach(() => { + localStorage.clear() + renderers = [] + observed = [] + }) + + afterEach(() => { + act(() => renderers.forEach((renderer) => renderer.unmount())) + }) + + it('keeps mounted choosers synchronized after same-tab connection edits', () => { + act(() => { + renderers.push(create(), create()) + }) + expect(observed.map((value) => value.connections)).toEqual([[], []]) + act(() => rememberRepositoryConnection('https://repository.example')) + expect(observed[0].connections[0].repoUrl).toBe('https://repository.example') + expect(observed[1].connections).toEqual(observed[0].connections) + act(() => renameRepositoryConnection('https://repository.example', 'Documents')) + expect(observed.map((value) => value.connections[0].name)).toEqual(['Documents', 'Documents']) + }) + + it('reloads changes made in another tab, ignores unrelated keys, and handles a storage clear', () => { + act(() => { + renderers.push(create()) + }) + const initialConnections = observed[0].connections + const connections = [{ repoUrl: 'https://other-tab.example', pinned: true, name: 'Other tab' }] + localStorage.setItem(repositoryConnectionsStorageKey, JSON.stringify({ version: 1, connections })) + act(() => { + window.dispatchEvent(new StorageEvent('storage', { key: 'unrelated-setting' })) + }) + expect(observed[0].connections).toBe(initialConnections) + act(() => { + window.dispatchEvent(new StorageEvent('storage', { key: repositoryConnectionsStorageKey })) + }) + expect(observed[0].connections).toEqual(connections) + localStorage.clear() + act(() => { + window.dispatchEvent(new StorageEvent('storage', { key: null })) + }) + expect(observed[0].connections).toEqual([]) + }) +}) diff --git a/apps/sensenet/test/repository-connections-provider.test.tsx b/apps/sensenet/test/repository-connections-provider.test.tsx new file mode 100644 index 000000000..916b133b3 --- /dev/null +++ b/apps/sensenet/test/repository-connections-provider.test.tsx @@ -0,0 +1,186 @@ +import React from 'react' +import { render, unmountComponentAtNode } from 'react-dom' +import { act, Simulate } from 'react-dom/test-utils' +import { BrowserRouter } from 'react-router-dom' +import { RepositoryProvider } from '../src/context/repository-provider' +import { SnAuthRepositoryProvider } from '../src/context/sn-auth-repository-provider' +import { getAuthConfig } from '../src/services/auth-config' +import { + getRepositoryConnections, + removeRepositoryConnection, + renameRepositoryConnection, + setRepositoryConnectionPinned, +} from '../src/services/repository-connections' +import { setSnAuthRepositoryConfig } from '../src/services/repository-session' + +const mockLogger = { error: jest.fn(), warning: jest.fn() } +const mockSnAuth: any = { + user: { Id: 42 }, + accessToken: 'access-one', + externalLogin: jest.fn(), + logout: jest.fn(), + isLoading: false, +} +const mockOidc: any = { + oidcUser: { access_token: 'access-one', expired: false }, + login: jest.fn(), + logout: jest.fn(), +} +jest.mock('@sensenet/sn-auth-react', () => ({ + ...jest.requireActual('@sensenet/sn-auth-react'), + AuthenticationProvider: ({ children }: any) => children, + useSnAuth: () => mockSnAuth, +})) +jest.mock('@sensenet/authentication-oidc-react', () => ({ + AuthenticationProvider: ({ children }: any) => children, + useOidcAuthentication: () => mockOidc, +})) +jest.mock('@sensenet/client-core', () => ({ + Repository: class { + configuration: any + constructor(configuration: any) { + this.configuration = configuration + } + reloadSchema() { + return Promise.resolve() + } + }, +})) +jest.mock('@sensenet/hooks-react', () => ({ + RepositoryContext: jest.requireActual('react').createContext(null), + useLogger: () => mockLogger, +})) +jest.mock('../src/hooks', () => ({ useQuery: () => new URLSearchParams() })) +jest.mock('../src/globalStyles', () => ({ useGlobalStyles: () => ({ full: 'full' }) })) +jest.mock('../src/components/full-screen-loader', () => ({ FullScreenLoader: () =>
Loading
})) +jest.mock('../src/components/NotificationComponent', () => ({ NotificationComponent: () => null })) +jest.mock('../src/components/login/not-authenticated-override', () => ({ NotAuthenticatedOverride: () => null })) +jest.mock('../src/components/login/session-lost-override', () => ({ SessionLostOverride: () => null })) +jest.mock('../src/components/login/auth-override-skeleton', () => ({ AuthOverrideSkeleton: () => null })) +jest.mock('../src/services/auth-config', () => ({ getAuthConfig: jest.fn() })) +jest.mock( + '../src/components/login/login-page', + () => + function LoginPage({ initialRepositoryUrl, connectionFailed, handleSubmit }: any) { + return ( +
+ Repository picker + + {connectionFailed && Connection failed} + +
+ ) + }, +) + +type Mode = 'SNAuth' | 'IdentityServer' +const repository = 'https://repo.example.invalid' +const configuration = (mode: Mode) => ({ + authServerSettings: { type: mode }, + userManagerSettings: { + authority: 'https://auth.example.invalid', + extraQueryParams: { snrepo: repository }, + }, +}) + +describe.each(['SNAuth', 'IdentityServer'])('%s remembered repository integration', (mode) => { + let element: HTMLDivElement + const prepareAuthentication = jest.fn() + const changeAuthType = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + jest.useFakeTimers() + jest.setSystemTime(new Date('2026-11-06T16:00:00Z')) + localStorage.clear() + sessionStorage.clear() + window.history.replaceState(null, '', '/') + mockSnAuth.user = { Id: 42 } + mockSnAuth.accessToken = 'access-one' + mockOidc.oidcUser = { access_token: 'access-one', expired: false } + prepareAuthentication.mockResolvedValue(true) + ;(getAuthConfig as jest.Mock).mockResolvedValue(configuration(mode)) + element = document.createElement('div') + document.body.appendChild(element) + }) + + afterEach(() => { + act(() => { + unmountComponentAtNode(element) + }) + element.remove() + jest.useRealTimers() + localStorage.clear() + sessionStorage.clear() + }) + + const mount = () => + act(async () => { + const Provider = mode === 'SNAuth' ? SnAuthRepositoryProvider : RepositoryProvider + render( + + +
Authenticated content
+
+
, + element, + ) + }) + + it('remembers a successful opening once and preserves metadata through token renewal', async () => { + await mount() + expect(element.textContent).toContain('Authenticated content') + expect(getRepositoryConnections()).toEqual([ + expect.objectContaining({ repoUrl: repository, lastUsed: '2026-11-06T16:00:00.000Z' }), + ]) + renameRepositoryConnection(repository, 'Daily repository') + setRepositoryConnectionPinned(repository, true) + jest.setSystemTime(new Date('2026-11-07T16:00:00Z')) + mockSnAuth.accessToken = 'renewed-access' + mockOidc.oidcUser = { access_token: 'renewed-access', expired: false } + await mount() + expect(getRepositoryConnections()).toEqual([ + expect.objectContaining({ + repoUrl: repository, + name: 'Daily repository', + pinned: true, + lastUsed: '2026-11-06T16:00:00.000Z', + }), + ]) + }) + + it('does not resurrect a removed connection when its existing token is renewed', async () => { + await mount() + removeRepositoryConnection(repository) + jest.setSystemTime(new Date('2026-11-07T16:00:00Z')) + mockSnAuth.accessToken = 'renewed-access' + mockOidc.oidcUser = { access_token: 'renewed-access', expired: false } + await mount() + expect(getRepositoryConnections()).toEqual([]) + expect(element.textContent).toContain('Authenticated content') + }) + + it('keeps the failed URL in the chooser and remembers it only after a successful retry', async () => { + ;(getAuthConfig as jest.Mock).mockRejectedValueOnce(new Error('Repository is unavailable')) + await mount() + expect(element.textContent).toContain('Repository picker') + expect(element.querySelector('input')?.value).toBe(repository) + expect(element.querySelector('[role="alert"]')?.textContent).toBe('Connection failed') + expect(getRepositoryConnections()).toEqual([]) + await act(async () => Simulate.click(element.querySelector('button')!)) + expect(element.textContent).toContain('Authenticated content') + expect(getRepositoryConnections()).toEqual([expect.objectContaining({ repoUrl: repository })]) + }) + + it('returns to an editable chooser when discovery of a cached selection fails', async () => { + if (mode === 'SNAuth') setSnAuthRepositoryConfig(repository, configuration(mode)) + else localStorage.setItem('sn-oidc-config', JSON.stringify(configuration(mode))) + prepareAuthentication.mockRejectedValueOnce(new Error('Discovery is unavailable')) + await mount() + expect(element.textContent).toContain('Repository picker') + expect(element.textContent).not.toContain('Loading') + expect(element.querySelector('input')?.value).toBe(repository) + expect(element.querySelector('[role="alert"]')).not.toBeNull() + expect(getRepositoryConnections()).toEqual([]) + }) +}) diff --git a/apps/sensenet/test/repository-connections.test.ts b/apps/sensenet/test/repository-connections.test.ts new file mode 100644 index 000000000..a18594026 --- /dev/null +++ b/apps/sensenet/test/repository-connections.test.ts @@ -0,0 +1,240 @@ +import { + getRepositoryConnections, + parseRepositoryConnectionUrl, + rememberRepositoryConnection, + removeRepositoryConnection, + renameRepositoryConnection, + repositoryConnectionsChangedEvent, + repositoryConnectionsStorageKey, + restoreRepositoryConnection, + setRepositoryConnectionPinned, +} from '../src/services/repository-connections' + +const first = 'https://first.example/repository' +const second = 'https://second.example' +const legacyKey = 'sn-auth-repository-sessions' +const older = '2026-10-01T09:00:00.000Z' +const newer = '2026-11-01T09:00:00.000Z' + +describe('remembered repository connections', () => { + beforeEach(() => { + jest.restoreAllMocks() + localStorage.clear() + // Discard a previous test's temporary fallback once storage is available. + getRepositoryConnections() + localStorage.clear() + }) + + afterEach(() => jest.restoreAllMocks()) + + it('migrates legacy history once, deduplicates normalized URLs and copies only connection metadata', () => { + const legacy = [ + { + repoUrl: `${first}/?ignored#fragment`, + lastUsed: older, + authServerUrl: 'https://issuer.example', + accessToken: 'secret', + }, + { repoUrl: first, lastUsed: newer }, + { repoUrl: second, lastUsed: older }, + ] + localStorage.setItem(legacyKey, JSON.stringify(legacy)) + expect(getRepositoryConnections()).toEqual([ + { repoUrl: first, pinned: false, lastUsed: newer }, + { repoUrl: second, pinned: false, lastUsed: older }, + ]) + expect(localStorage.getItem(legacyKey)).toBe(JSON.stringify(legacy)) + expect(localStorage.getItem(repositoryConnectionsStorageKey)).not.toContain('secret') + expect(localStorage.getItem(repositoryConnectionsStorageKey)).not.toContain('issuer') + + removeRepositoryConnection(first) + localStorage.setItem(legacyKey, JSON.stringify([{ repoUrl: first, lastUsed: new Date().toISOString() }])) + expect(getRepositoryConnections().map((connection) => connection.repoUrl)).toEqual([second]) + }) + + it('saves an empty migration marker and never imports later authentication records', () => { + expect(getRepositoryConnections()).toEqual([]) + localStorage.setItem(legacyKey, JSON.stringify([{ repoUrl: first, lastUsed: newer }])) + expect(getRepositoryConnections()).toEqual([]) + expect(JSON.parse(localStorage.getItem(repositoryConnectionsStorageKey)!)).toEqual({ version: 1, connections: [] }) + }) + + it('keeps aliases and pins while recording explicit successful usage, without changing dates on metadata edits', () => { + const now = jest.spyOn(Date.prototype, 'toISOString').mockReturnValue(newer) + rememberRepositoryConnection(first) + renameRepositoryConnection(first, ' Documents ') + setRepositoryConnectionPinned(first, true) + expect(getRepositoryConnections()).toEqual([{ repoUrl: first, name: 'Documents', pinned: true, lastUsed: newer }]) + now.mockRestore() + + rememberRepositoryConnection(`${first}/?ignored=true`) + const [connection] = getRepositoryConnections() + expect(connection.name).toBe('Documents') + expect(connection.pinned).toBe(true) + expect(connection.lastUsed).not.toBe(newer) + expect(getRepositoryConnections()).toHaveLength(1) + + renameRepositoryConnection(first, 'x'.repeat(100)) + expect(getRepositoryConnections()[0].name).toHaveLength(80) + renameRepositoryConnection(first, ' ') + expect(getRepositoryConnections()[0].name).toBeUndefined() + }) + + it('removes metadata without changing tokens, session history, selected repository or authentication configuration', () => { + const authRecords = { + 'sn-auth-access-token': 'access', + 'sn-auth-refresh-token': 'refresh', + 'sn-auth-selected-repository': first, + 'sn-auth-config': 'global config', + [`sn-auth-config:${encodeURIComponent(first)}`]: 'repository config', + [legacyKey]: JSON.stringify([{ repoUrl: first, lastUsed: newer }]), + } + Object.entries(authRecords).forEach(([key, value]) => localStorage.setItem(key, value)) + renameRepositoryConnection(first, 'Saved name') + setRepositoryConnectionPinned(first, true) + const removed = removeRepositoryConnection(first) + expect(getRepositoryConnections()).toEqual([]) + Object.entries(authRecords).forEach(([key, value]) => expect(localStorage.getItem(key)).toBe(value)) + + restoreRepositoryConnection(removed!) + expect(getRepositoryConnections()).toEqual([{ repoUrl: first, name: 'Saved name', pinned: true, lastUsed: newer }]) + }) + + it('does not let Undo overwrite a connection re-added or edited after removal', () => { + rememberRepositoryConnection(first) + renameRepositoryConnection(first, 'Old name') + const removed = removeRepositoryConnection(first)! + rememberRepositoryConnection(first) + renameRepositoryConnection(first, 'New name') + restoreRepositoryConnection(removed) + expect(getRepositoryConnections()).toHaveLength(1) + expect(getRepositoryConnections()[0].name).toBe('New name') + }) + + it('sanitizes malformed records, dates and duplicates without trusting stored extra fields', () => { + localStorage.setItem( + repositoryConnectionsStorageKey, + JSON.stringify({ + version: 1, + connections: [ + null, + {}, + { repoUrl: 42 }, + { repoUrl: 'ftp://files.example' }, + { repoUrl: `${first}/`, name: ' Preferred ', pinned: true, lastUsed: older, token: 'secret' }, + { repoUrl: first, pinned: false, lastUsed: newer }, + { repoUrl: second, pinned: 'true', name: 42, lastUsed: 'not a date' }, + ], + }), + ) + expect(getRepositoryConnections()).toEqual([ + { repoUrl: first, name: 'Preferred', pinned: true, lastUsed: newer }, + { repoUrl: second, pinned: false, lastUsed: undefined }, + ]) + }) + + it.each(['{invalid', 'null', '[]', '{"version":2,"connections":[]}', '{"version":1,"connections":{}}'])( + 'handles corrupt or unsupported saved data without resurrecting old sessions: %s', + (raw) => { + localStorage.setItem(repositoryConnectionsStorageKey, raw) + localStorage.setItem(legacyKey, JSON.stringify([{ repoUrl: first, lastUsed: newer }])) + expect(getRepositoryConnections()).toEqual([]) + }, + ) + + it('notifies subscribers after same-tab mutations, but not while reading or migrating', () => { + const listener = jest.fn() + window.addEventListener(repositoryConnectionsChangedEvent, listener) + try { + getRepositoryConnections() + expect(listener).not.toHaveBeenCalled() + rememberRepositoryConnection(first) + renameRepositoryConnection(first, 'Name') + setRepositoryConnectionPinned(first, true) + const removed = removeRepositoryConnection(first)! + restoreRepositoryConnection(removed) + expect(listener).toHaveBeenCalledTimes(5) + } finally { + window.removeEventListener(repositoryConnectionsChangedEvent, listener) + } + }) + + it('keeps usable in-page metadata when writes fail and adopts newer data from another tab', () => { + getRepositoryConnections() + const stored = localStorage.getItem(repositoryConnectionsStorageKey) + const setItem = jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new DOMException('Full', 'QuotaExceededError') + }) + expect(() => rememberRepositoryConnection(first)).not.toThrow() + renameRepositoryConnection(first, 'Temporary') + setRepositoryConnectionPinned(first, true) + expect(getRepositoryConnections()[0]).toMatchObject({ repoUrl: first, name: 'Temporary', pinned: true }) + expect(localStorage.getItem(repositoryConnectionsStorageKey)).toBe(stored) + setItem.mockRestore() + localStorage.setItem( + repositoryConnectionsStorageKey, + JSON.stringify({ version: 1, connections: [{ repoUrl: second, pinned: false }] }), + ) + expect(getRepositoryConnections()).toEqual([{ repoUrl: second, pinned: false, lastUsed: undefined }]) + }) + + it('does not break selection when localStorage access is denied', () => { + const read = jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new DOMException('Denied', 'SecurityError') + }) + const write = jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new DOMException('Denied', 'SecurityError') + }) + expect(getRepositoryConnections()).toEqual([]) + expect(() => rememberRepositoryConnection(first)).not.toThrow() + expect(getRepositoryConnections()[0].repoUrl).toBe(first) + read.mockRestore() + write.mockRestore() + }) + + it('does not remember a different host when an explicit URL has a mistyped scheme', () => { + rememberRepositoryConnection('https:/repository.example') + rememberRepositoryConnection('http:/repository.example') + expect(getRepositoryConnections()).toEqual([]) + }) +}) + +describe('repository connection URL validation', () => { + it.each([ + [' first.example/repository/ ', first], + [`${first}///?query=ignored#fragment`, first], + ['localhost:8080', 'https://localhost:8080'], + ['repo:8080/path', 'https://repo:8080/path'], + ['repo.example:8443/tenant/', 'https://repo.example:8443/tenant'], + ['[2001:db8::1]:8443/tenant/', 'https://[2001:db8::1]:8443/tenant'], + ['http://[::1]:8080/repository/', 'http://[::1]:8080/repository'], + ['http://localhost:8080/', 'http://localhost:8080'], + ['https://FIRST.example:443/repository', first], + ])('normalizes %s', (input, expected) => expect(parseRepositoryConnectionUrl(input)).toBe(expected)) + + it.each([ + '', + ' ', + '/relative/path', + '//first.example', + 'ftp://first.example', + // eslint-disable-next-line no-script-url + 'javascript:alert(1)', + 'https://', + 'https:/repo.example', + 'http:/repo.example', + 'HTTPS:/repo.example', + 'https:repo.example', + 'https:8080', + 'https:///repo.example', + 'https:////repo.example', + 'ftp:/repo.example', + 'https:\\repo.example', + 'https:\\\\repo.example', + 'https://repo.example\\path', + 'repo.example\\path', + 'https://user:secret@first.example', + 'https://first .example', + 'https://first.example/a b', + ])('rejects %s', (input) => expect(parseRepositoryConnectionUrl(input)).toBeUndefined()) +})