Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
542 changes: 429 additions & 113 deletions apps/sensenet/src/components/login/login-page.tsx

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions apps/sensenet/src/context/local-repository-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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
Expand Down
16 changes: 15 additions & 1 deletion apps/sensenet/src/context/repository-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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,
Expand Down Expand Up @@ -39,6 +40,7 @@ export function RepositoryProvider({
prepareAuthentication: (repoUrl: string) => Promise<boolean>
}) {
const [isLoginInProgress, setIsLoginInProgress] = useState(false)
const [failedRepositoryUrl, setFailedRepositoryUrl] = useState('')
const [authenticationCheckedFor, setAuthenticationCheckedFor] = useState('')
const logger = useLogger('repository-provider')
const globalClasses = useGlobalStyles()
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -131,7 +134,10 @@ export function RepositoryProvider({
) : (
<LoginPage
isLoginInProgress={isLoginInProgress}
initialRepositoryUrl={failedRepositoryUrl}
connectionFailed={!!failedRepositoryUrl}
handleSubmit={(formUrl) => {
setFailedRepositoryUrl('')
setAuthState({
repoUrl: normalizeRepositoryUrl(formUrl),
config: null,
Expand Down Expand Up @@ -191,6 +197,14 @@ const RepoProvider = ({
const { oidcUser, login, logout, error, errorOperation } = useOidcAuthentication()
const logger = useLogger('repo-provider')
const [repo, setRepo] = useState<Repository>()
const rememberedRepository = useRef('')

useEffect(() => {
if (oidcUser && !oidcUser.expired && rememberedRepository.current !== repoUrl) {
rememberRepositoryConnection(repoUrl)
rememberedRepository.current = repoUrl
}
}, [oidcUser, repoUrl])

useEffect(() => {
setRepo((prevRepo) => {
Expand Down
23 changes: 13 additions & 10 deletions apps/sensenet/src/context/sn-auth-repository-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -45,6 +45,7 @@ export function SnAuthRepositoryProvider({
prepareAuthentication: (repoUrl: string) => Promise<boolean>
}) {
const [isLoginInProgress, setIsLoginInProgress] = useState(false)
const [failedRepositoryUrl, setFailedRepositoryUrl] = useState('')
const [authenticationCheckedFor, setAuthenticationCheckedFor] = useState('')
const logger = useLogger('repository-provider')
const globalClasses = useGlobalStyles()
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -213,15 +216,10 @@ export function SnAuthRepositoryProvider({
) : (
<LoginPage
isLoginInProgress={isLoginInProgress}
repositoryOptions={getSnAuthRepositorySessions()}
handleSelectRepository={(repoUrl) => {
startSnAuthRepositoryLogin(repoUrl)
setAuthState({
repoUrl: normalizeRepositoryUrl(repoUrl),
config: null,
})
}}
initialRepositoryUrl={failedRepositoryUrl}
connectionFailed={!!failedRepositoryUrl}
handleSubmit={(formUrl) => {
setFailedRepositoryUrl('')
startSnAuthRepositoryLogin(formUrl)
setAuthState({
repoUrl: normalizeRepositoryUrl(formUrl),
Expand Down Expand Up @@ -290,6 +288,7 @@ const RepoProvider = ({
const { user, externalLogin, logout, accessToken, error, isLoading } = useSnAuth()
const logger = useLogger('repo-provider')
const [repo, setRepo] = useState<Repository>()
const rememberedRepository = useRef('')

useEffect(() => {
setRepo((prevRepo) => {
Expand Down Expand Up @@ -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])

Expand Down
26 changes: 26 additions & 0 deletions apps/sensenet/src/hooks/use-repository-connections.ts
Original file line number Diff line number Diff line change
@@ -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 }
}
20 changes: 20 additions & 0 deletions apps/sensenet/src/localization/default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
22 changes: 21 additions & 1 deletion apps/sensenet/src/localization/hungarian.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
156 changes: 156 additions & 0 deletions apps/sensenet/src/services/repository-connections.ts
Original file line number Diff line number Diff line change
@@ -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<string, RepositoryConnection>()

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])
}
Loading
Loading