1
0
mirror of https://github.com/owncloud/ocis.git synced 2026-10-23 23:29:02 +03:00

fix(web): [OCISDEV-1518] show correct message when Safe access is denied for lacking permission (#13111)

* fix(web): [OCISDEV-1518] show correct message when Safe access is denied for lacking permission

* chore: changelog
This commit is contained in:
Michal Klos
2026-10-13 13:29:58 +02:00
committed by GitHub
parent 9269643c0e
commit 2a23956a8b
5 changed files with 71 additions and 12 deletions

View File

@@ -0,0 +1,8 @@
Bugfix: Show correct message when Safe access is denied for lacking permission
A user without permission to use the Safe who opened the Safe URL directly was
shown "Not logged in" with a "Log in again" button, even though they were
still logged in. The page now shows "Access denied" with a "Continue" button
that returns to the app for this case.
https://github.com/owncloud/ocis/pull/13111

View File

@@ -51,6 +51,7 @@ export default defineComponent({
const { currentTheme } = storeToRefs(themeStore)
const configStore = useConfigStore()
const redirectUrlQuery = useRouteQuery('redirectUrl')
const reasonQuery = useRouteQuery('reason')
const { $gettext } = useGettext()
@@ -58,18 +59,34 @@ export default defineComponent({
const footerSlogan = computed(() => currentTheme.value.common.slogan)
const logoImg = computed(() => currentTheme.value.logo.login)
// "forbidden" means the user is still logged in but lacks permission for this area
// (e.g. no Vault access) — distinct from an actual session/logout problem.
const isForbidden = computed(() => queryItemAsString(unref(reasonQuery)) === 'forbidden')
const cardTitle = computed(() => {
return $gettext('Not logged in')
return unref(isForbidden) ? $gettext('Access denied') : $gettext('Not logged in')
})
const cardHint = computed(() => {
return $gettext(
'This could be because of a routine safety log out, or because your account is either inactive or not yet authorized for use. Please try logging in after a while or seek help from your Administrator.'
)
return unref(isForbidden)
? $gettext('You do not have permission to access this area. You are still logged in.')
: $gettext(
'This could be because of a routine safety log out, or because your account is either inactive or not yet authorized for use. Please try logging in after a while or seek help from your Administrator.'
)
})
const navigateToLoginText = computed(() => {
return $gettext('Log in again')
return unref(isForbidden) ? $gettext('Continue') : $gettext('Log in again')
})
const logoutButtonsAttrs = computed(() => {
// Still logged in — send back into the app, not through the login flow.
if (unref(isForbidden)) {
// Same fallback the login redirect uses: don't target a specific extension's
// route, let the app resolve its own default/home view.
return {
type: 'router-link',
to: '/'
}
}
const redirectUrl = queryItemAsString(unref(redirectUrlQuery))
if (configStore.options.loginUrl) {
const configLoginURL = new URL(encodeURI(configStore.options.loginUrl))

View File

@@ -152,7 +152,7 @@ export class AuthService implements AuthServiceInterface {
if (to.params.scope === 'vault') {
// Permissions known: send an unentitled user to accessDenied instead of the IdP.
if (this.authStore.userContextReady && !this.ability.can('read-all', 'Vault')) {
return { name: 'accessDenied' }
return { name: 'accessDenied', query: { reason: 'forbidden' } }
}
// Capabilities only — the full context loads vault spaces, which 401 without MFA and crash.
@@ -173,7 +173,7 @@ export class AuthService implements AuthServiceInterface {
return { name: 'accessDenied' }
}
if (!this.ability.can('read-all', 'Vault')) {
return { name: 'accessDenied' }
return { name: 'accessDenied', query: { reason: 'forbidden' } }
}
}

View File

@@ -1,8 +1,11 @@
import accessDenied from '../../../src/pages/accessDenied.vue'
import { defaultComponentMocks, defaultPlugins, mount } from '@ownclouders/web-test-helpers'
import { mock } from 'vitest-mock-extended'
import { RouteLocationNormalizedLoaded } from 'vue-router'
const selectors = {
logInAgainButton: '#exitAnchor'
logInAgainButton: '#exitAnchor',
cardTitle: '.oc-login-card-title'
}
describe('access denied page', () => {
@@ -23,11 +26,26 @@ describe('access denied page', () => {
expect(loginAgainUrl.toString()).toEqual(loginUrl)
})
})
describe('reason=forbidden (still logged in, lacking permission)', () => {
it('does not claim the user was logged out, and sends them back into the app', () => {
const { wrapper } = getWrapper({ reason: 'forbidden' })
expect(wrapper.find(selectors.cardTitle).text()).not.toContain('logged')
expect(wrapper.html()).not.toContain('log out')
const goBackButton = wrapper.find(selectors.logInAgainButton)
const destination = Object.values(goBackButton.attributes()).join(' ') + goBackButton.html()
expect(destination.toLowerCase()).not.toContain('login')
})
})
})
function getWrapper({ loginUrl = '' } = {}) {
function getWrapper({ loginUrl = '', reason = '' } = {}) {
const currentRoute = mock<RouteLocationNormalizedLoaded>({
query: { ...(reason && { reason }) }
})
const mocks = {
...defaultComponentMocks()
...defaultComponentMocks({ currentRoute })
}
return {

View File

@@ -555,7 +555,7 @@ describe('AuthService', () => {
// initializeContext returns the redirect target; the guard (setupAuthGuard) returns it
// so vue-router cancels the navigation to the vault route.
expect(result).toEqual({ name: 'accessDenied' })
expect(result).toEqual({ name: 'accessDenied', query: { reason: 'forbidden' } })
expect(mockSignInRedirect).not.toHaveBeenCalled()
})
@@ -600,7 +600,7 @@ describe('AuthService', () => {
const result = await authService.initializeContext(vaultRoute)
expect(mockLoadUserAbilities).toHaveBeenCalled()
expect(result).toEqual({ name: 'accessDenied' })
expect(result).toEqual({ name: 'accessDenied', query: { reason: 'forbidden' } })
expect(mockSignInRedirect).not.toHaveBeenCalled()
})
@@ -619,6 +619,22 @@ describe('AuthService', () => {
expect(mockSignInRedirect).not.toHaveBeenCalled()
})
it('does not report a load failure as a confirmed permission denial', async () => {
const { authService, mockLoadUserAbilities } = setupVaultAuthService({
canAccessVault: true,
userContextReady: false,
getUser: () => Promise.resolve(mock<User>({ profile: { acr: 'regular' }, expired: false }))
})
mockLoadUserAbilities.mockRejectedValueOnce(new Error('network error'))
const result = await authService.initializeContext(vaultRoute)
expect(mockLoadUserAbilities).toHaveBeenCalled()
// reason: 'forbidden' tells accessDenied.vue the ability check ran and failed;
// here it never ran, so claiming that is false regardless of the (correct) denial.
expect((result as { query?: { reason?: string } })?.query?.reason).not.toBe('forbidden')
})
it('on a cold load with a valid MFA session, initializes the user context instead of bouncing to login', async () => {
const { authService, mockSignInRedirect, mockUpdateContext } = setupVaultAuthService({
canAccessVault: true,