()
graphClient.drives.listMyDrives.mockResolvedValue(spaces)
- await instance.reloadProjectSpaces({ graphClient })
+ await instance.reloadProjectSpaces({ graphClient, isInVault: false })
expect(graphClient.drives.listMyDrives).toHaveBeenCalledTimes(1)
expect(graphClient.drives.listMyDrives).toHaveBeenCalledWith(
diff --git a/packages/web-pkg/tests/unit/composables/search/useSearch.spec.ts b/packages/web-pkg/tests/unit/composables/search/useSearch.spec.ts
index 2b8295a5862..b3c0befb8ef 100644
--- a/packages/web-pkg/tests/unit/composables/search/useSearch.spec.ts
+++ b/packages/web-pkg/tests/unit/composables/search/useSearch.spec.ts
@@ -3,6 +3,44 @@ import { CapabilityStore, useSearch } from '../../../../src/composables'
import { SearchResource, SpaceResource } from '@ownclouders/web-client'
describe('useSearch', () => {
+ describe('method "buildSearchTerm"', () => {
+ it('appends vault:true when isVault is true', () => {
+ const wrapper = createWrapper()
+ const result = wrapper.vm.buildSearchTerm({ term: 'test', isVault: true })
+ expect(result).toContain('vault:true')
+ })
+ it('does not append vault:true when isVault is false', () => {
+ const wrapper = createWrapper()
+ const result = wrapper.vm.buildSearchTerm({ term: 'test', isVault: false })
+ expect(result).not.toContain('vault:true')
+ })
+ it('does not append vault:true when isVault is undefined', () => {
+ const wrapper = createWrapper()
+ const result = wrapper.vm.buildSearchTerm({ term: 'test' })
+ expect(result).not.toContain('vault:true')
+ })
+ it('combines vault:true with other query parts', () => {
+ const wrapper = createWrapper()
+ const result = wrapper.vm.buildSearchTerm({
+ term: 'test',
+ tags: 'lorem',
+ isVault: true
+ })
+ expect(result).toContain('vault:true')
+ expect(result).toContain('tag:("lorem")')
+ expect(result).toContain('name:"*test*"')
+ })
+ it('places scope: at the end of the query', () => {
+ const wrapper = createWrapper()
+ const result = wrapper.vm.buildSearchTerm({
+ term: 'test',
+ scope: 'lorem',
+ useScope: true,
+ isVault: true
+ })
+ expect(result).toMatch(/scope:lorem$/)
+ })
+ })
describe('method "search"', () => {
it('can search', async () => {
const files = [
@@ -58,10 +96,11 @@ const createWrapper = ({ resources = [] }: { resources?: SearchResource[] } = {}
return getComposableWrapper(
() => {
- const { search } = useSearch()
+ const { search, buildSearchTerm } = useSearch()
return {
- search
+ search,
+ buildSearchTerm
}
},
{
diff --git a/packages/web-runtime/src/components/SidebarNav/SidebarNav.vue b/packages/web-runtime/src/components/SidebarNav/SidebarNav.vue
index 849eb8e8bdc..5b2a1c53208 100644
--- a/packages/web-runtime/src/components/SidebarNav/SidebarNav.vue
+++ b/packages/web-runtime/src/components/SidebarNav/SidebarNav.vue
@@ -172,7 +172,7 @@ export default defineComponent({
}
#web-nav-sidebar {
- background-color: var(--oc-color-background-default);
+ background-color: var(--oc-color-background-sidebar, var(--oc-color-background-default));
border-radius: 15px 0 0 15px;
box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.2);
display: flex;
diff --git a/packages/web-runtime/src/components/SidebarNav/SidebarNavItem.vue b/packages/web-runtime/src/components/SidebarNav/SidebarNavItem.vue
index a72a83f1d34..c4c422b3314 100644
--- a/packages/web-runtime/src/components/SidebarNav/SidebarNavItem.vue
+++ b/packages/web-runtime/src/components/SidebarNav/SidebarNavItem.vue
@@ -111,7 +111,6 @@ export default defineComponent({
}
.text {
opacity: 1;
- transition: all 0.3s;
}
.text-invisible {
opacity: 0 !important;
@@ -119,7 +118,12 @@ export default defineComponent({
}
&:hover:not(.active) {
- color: var(--oc-color-swatch-brand-hover) !important;
+ background-color: var(--oc-color-swatch-primary-hover) !important;
+ color: var(--oc-color-swatch-primary-contrast) !important;
+
+ .oc-icon > svg {
+ fill: var(--oc-color-swatch-primary-contrast);
+ }
}
&:hover,
@@ -128,10 +132,10 @@ export default defineComponent({
}
&.active {
overflow: hidden;
- }
- .oc-icon svg {
- transition: all 0.3s;
+ &:hover .oc-icon > svg {
+ fill: var(--oc-color-swatch-primary-contrast);
+ }
}
}
diff --git a/packages/web-runtime/src/components/Topbar/ApplicationsMenu.vue b/packages/web-runtime/src/components/Topbar/ApplicationsMenu.vue
index 548b7c43680..23a28bec4be 100644
--- a/packages/web-runtime/src/components/Topbar/ApplicationsMenu.vue
+++ b/packages/web-runtime/src/components/Topbar/ApplicationsMenu.vue
@@ -91,6 +91,19 @@ export default defineComponent({
const getAdditionalEventBindings = (item: AppMenuItemExtension) => {
return item.handler ? { click: item.handler } : {}
}
+
+ const getScopeAwarePath = (rawPath?: string) => {
+ if (!rawPath) {
+ return rawPath
+ }
+
+ const normalizedPath = rawPath.startsWith('/') ? rawPath : `/${rawPath}`
+ const pathWithoutVault = normalizedPath.replace(/^\/vault(?=\/|$)/, '')
+ const isVaultScope = unref(router.currentRoute).params?.scope === 'vault'
+
+ return isVaultScope ? `/vault${pathWithoutVault}` : pathWithoutVault
+ }
+
const getAdditionalAttributes = (item: AppMenuItemExtension) => {
let type: string
if (item.handler) {
@@ -103,12 +116,13 @@ export default defineComponent({
return {
type,
- ...(type === 'router-link' && { to: item.path }),
+ ...(type === 'router-link' && { to: getScopeAwarePath(item.path) }),
...(type === 'a' && { href: item.url, target: '_blank' })
}
}
const isMenuItemActive = (item: AppMenuItemExtension) => {
- return unref(activeRoutePath)?.startsWith(item.path)
+ const routePath = getScopeAwarePath(item.path)
+ return !!routePath && unref(activeRoutePath)?.startsWith(routePath)
}
return {
diff --git a/packages/web-runtime/src/components/Topbar/TopBar.vue b/packages/web-runtime/src/components/Topbar/TopBar.vue
index 3c87bee1bab..71c3d855f17 100644
--- a/packages/web-runtime/src/components/Topbar/TopBar.vue
+++ b/packages/web-runtime/src/components/Topbar/TopBar.vue
@@ -19,6 +19,37 @@
class="oc-logo-image"
/>
+
+
+
+
+
+
+
+
+
+ {{ option.label }}
+
+
+
+
+
+
@@ -63,8 +94,11 @@ import {
useExtensionRegistry,
useOpenEmptyEditor,
useRouter,
- useThemeStore
+ useThemeStore,
+ useClipboardStore,
+ useAbility
} from '@ownclouders/web-pkg'
+import { useGettext } from 'vue3-gettext'
import { isRuntimeRoute } from '../../router'
import { appMenuExtensionPoint, topBarCenterExtensionPoint } from '../../extensionPoints'
@@ -86,6 +120,7 @@ export default {
}
},
setup() {
+ const { $gettext } = useGettext()
const capabilityStore = useCapabilityStore()
const themeStore = useThemeStore()
const { currentTheme } = storeToRefs(themeStore)
@@ -93,6 +128,8 @@ export default {
const { options: configOptions } = storeToRefs(configStore)
const extensionRegistry = useExtensionRegistry()
const { openEmptyEditor } = useOpenEmptyEditor()
+ const { clearClipboard } = useClipboardStore()
+ const ability = useAbility()
const authStore = useAuthStore()
const router = useRouter()
@@ -120,7 +157,8 @@ export default {
}
}
- return '/'
+ const isVaultScope = unref(router.currentRoute).params?.scope === 'vault'
+ return isVaultScope ? '/vault/files' : '/'
})
const isFilesPublicUpoad = computed(() => {
@@ -146,6 +184,40 @@ export default {
)
})
+ const modeOptions = computed(() => {
+ return [
+ {
+ id: 'default-mode',
+ label: $gettext('Drive'),
+ route: '/'
+ },
+ {
+ id: 'vault-mode',
+ label: $gettext('Vault'),
+ route: '/vault/files'
+ }
+ ]
+ })
+
+ const selectedMode = computed({
+ get() {
+ const currentPath = window.location.pathname
+ return currentPath.startsWith('/vault') ? unref(modeOptions)[1] : unref(modeOptions)[0]
+ },
+ set(mode) {
+ if (mode.id === 'default-mode') {
+ clearClipboard()
+ }
+ if (mode?.route && mode.route !== window.location.pathname) {
+ window.location.href = mode.route
+ }
+ }
+ })
+
+ const canAccessVault = computed(
+ () => capabilityStore.vaultEnabled && ability.can('read-all', 'Vault')
+ )
+
return {
configOptions,
contentOnLeftPortal,
@@ -161,7 +233,10 @@ export default {
appMenuExtensions,
hideAppSwitcher,
hideAccountMenu,
- isUniversalAccessEnabled
+ isUniversalAccessEnabled,
+ modeOptions,
+ selectedMode,
+ canAccessVault
}
},
computed: {
@@ -259,5 +334,36 @@ export default {
justify-content: flex-end;
}
}
+
+ .oc-topbar-mode-switch {
+ color: var(--oc-color-swatch-brand-contrast);
+ flex-shrink: 0;
+ text-transform: uppercase;
+ font-weight: bold;
+
+ .oc-icon > svg {
+ fill: var(--oc-color-swatch-brand-contrast);
+ }
+ }
+
+ .oc-topbar-mode-switch-list li {
+ margin: var(--oc-space-xsmall) 0;
+
+ &:first-child {
+ margin-top: 0;
+ }
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+ }
+
+ .oc-topbar-mode-switch-option {
+ &:hover,
+ &:focus {
+ background-color: var(--oc-color-background-hover);
+ text-decoration: none;
+ }
+ }
}
diff --git a/packages/web-runtime/src/container/api.ts b/packages/web-runtime/src/container/api.ts
index d147135d2c9..314ddf2d6b1 100644
--- a/packages/web-runtime/src/container/api.ts
+++ b/packages/web-runtime/src/container/api.ts
@@ -38,7 +38,7 @@ const announceRoutes = (applicationId: string, router: Router, routes: RouteReco
applicationId === route.name ? route.name : namespaceRouteName(String(route.name))
}
- route.path = `/${encodeURI(applicationId)}${route.path}`
+ route.path = `/:scope(vault)?/${encodeURI(applicationId)}${route.path}`
if (route.children) {
route.children = route.children.map((childRoute) => {
diff --git a/packages/web-runtime/src/index.ts b/packages/web-runtime/src/index.ts
index 0d963ee53a8..88a5c7a2381 100644
--- a/packages/web-runtime/src/index.ts
+++ b/packages/web-runtime/src/index.ts
@@ -233,9 +233,14 @@ export const bootstrapApp = async (configurationPath: string, appsReadyCallback:
await clientService.graphAuthenticated.permissions.listRoleDefinitions()
sharesStore.setGraphRoles(graphRoleDefinitions)
+ const isInVault = window.location.pathname.startsWith('/vault')
+
+ configStore.setIsInVault(isInVault)
+ clientService.reinitializeGraphClient(isInVault)
+
// Load spaces to make them available across the application
try {
- await spacesStore.loadSpaces({ graphClient: clientService.graphAuthenticated })
+ await spacesStore.loadSpaces({ graphClient: clientService.graphAuthenticated, isInVault })
const personalSpace = spacesStore.spaces.find(isPersonalSpaceResource)
if (personalSpace) {
diff --git a/packages/web-runtime/src/layouts/Application.vue b/packages/web-runtime/src/layouts/Application.vue
index f5ee6c50732..1a9deabe108 100644
--- a/packages/web-runtime/src/layouts/Application.vue
+++ b/packages/web-runtime/src/layouts/Application.vue
@@ -113,6 +113,16 @@ export default defineComponent({
const allMessages = ref<{ id: string; title: string; desc?: string }[]>([])
+ watch(
+ () => route.value.params?.scope,
+ () => {
+ extensionRegistry.rebuild({ route })
+ },
+ {
+ immediate: true
+ }
+ )
+
watch(
() => messageStore.messages,
(messages) => {
@@ -183,6 +193,8 @@ export default defineComponent({
}
const { href: currentHref } = router.resolve(unref(route))
+ const newRef = currentHref.replace(/^\/vault/, '')
+
return orderBy(
unref(extensionNavItems).map((item) => {
let active = typeof item.isActive !== 'function' || item.isActive()
@@ -190,10 +202,11 @@ export default defineComponent({
if (active) {
active = [item.route, ...(item.activeFor || [])].filter(Boolean).some((currentItem) => {
try {
- const comparativeHref = router.resolve(
- currentItem as RouteLocationAsRelativeTyped
- ).href
- return currentHref.startsWith(comparativeHref)
+ const comparativeHref = router
+ .resolve(currentItem as RouteLocationAsRelativeTyped)
+ .href.replace(/^\/vault/, '')
+
+ return newRef.startsWith(comparativeHref)
} catch (e) {
console.error(e)
return false
diff --git a/packages/web-runtime/src/pages/resolvePublicLink.vue b/packages/web-runtime/src/pages/resolvePublicLink.vue
index bb8553f8fcb..151556ca9ae 100644
--- a/packages/web-runtime/src/pages/resolvePublicLink.vue
+++ b/packages/web-runtime/src/pages/resolvePublicLink.vue
@@ -118,7 +118,7 @@ const publicLinkSpace = computed(() =>
})
)
-const item = computed(() => queryItemAsString(unref(route).params.driveAliasAndItem))
+const item = computed(() => queryItemAsString(unref(route)?.params?.driveAliasAndItem))
const detailsQuery = useRouteQuery('details')
const details = computed(() => queryItemAsString(unref(detailsQuery)))
diff --git a/packages/web-runtime/src/services/auth/abilities.ts b/packages/web-runtime/src/services/auth/abilities.ts
index 77a75c2ec61..3b1fd8bd996 100644
--- a/packages/web-runtime/src/services/auth/abilities.ts
+++ b/packages/web-runtime/src/services/auth/abilities.ts
@@ -51,7 +51,8 @@ export const getAbilities = (
{ action: 'update-all', subject: 'Drive' }
],
'Drives.List.all': [{ action: 'read-all', subject: 'Drive' }],
- 'Drives.ReadWriteProjectQuota.all': [{ action: 'set-quota-all', subject: 'Drive' }]
+ 'Drives.ReadWriteProjectQuota.all': [{ action: 'set-quota-all', subject: 'Drive' }],
+ 'VaultMode.ReadWriteEnabled.own': [{ action: 'read-all', subject: 'Vault' }]
}
return Object.keys(abilities).reduce((acc, permission) => {
diff --git a/packages/web-runtime/src/services/auth/authService.ts b/packages/web-runtime/src/services/auth/authService.ts
index 7cd4d9bd9b0..3ec2a5220f0 100644
--- a/packages/web-runtime/src/services/auth/authService.ts
+++ b/packages/web-runtime/src/services/auth/authService.ts
@@ -7,6 +7,8 @@ import {
CapabilityStore,
ConfigStore,
useTokenTimerWorker,
+ useMfaExpiryWorker,
+ useModals,
AuthServiceInterface
} from '@ownclouders/web-pkg'
import { RouteLocation, Router } from 'vue-router'
@@ -40,6 +42,11 @@ export class AuthService implements AuthServiceInterface {
private tokenTimerWorker: ReturnType
private tokenTimerInitialized = false
+ private mfaExpiryWorker: ReturnType
+ private mfaExpiryModalDismissed = false
+ private mfaExpiryModalId: string | null = null
+ private mfaExpiryBroadcastChannel: BroadcastChannel
+
// number of seconds before an access token is to expire to raise the accessTokenExpiring event
private accessTokenExpiryThreshold = 10
@@ -119,10 +126,20 @@ export class AuthService implements AuthServiceInterface {
if (!options.embed?.enabled || !options.embed?.delegateAuthentication) {
this.tokenTimerWorker = useTokenTimerWorker({ authService: this })
this.tokenTimerWorker.startWorker()
+
+ this.mfaExpiryWorker = useMfaExpiryWorker({
+ onExpiring: () => this.showMfaExpiryWarning()
+ })
+ this.mfaExpiryWorker.startWorker()
+ this.initMfaExpiryBroadcastChannel()
}
}
}
+ if (to.params.scope === 'vault') {
+ this.requireAcr('advanced', to.fullPath)
+ }
+
if (isPublicLinkContextRequired(this.router, to)) {
const user = await this.userManager.getUser()
@@ -164,6 +181,7 @@ export class AuthService implements AuthServiceInterface {
)
try {
await this.userManager.updateContext(user.access_token, fetchUserData)
+ this.updateMfaExpiryTimer()
} catch (e) {
console.error(e)
await this.handleAuthError(unref(this.router.currentRoute))
@@ -173,6 +191,7 @@ export class AuthService implements AuthServiceInterface {
this.userManager.events.addUserUnloaded(() => {
console.log('user unloaded…')
this.tokenTimerWorker?.resetTokenTimer()
+ this.mfaExpiryWorker?.resetMfaTimer()
this.resetStateAfterUserLogout()
if (this.userManager.unloadReason === 'authError') {
@@ -224,6 +243,8 @@ export class AuthService implements AuthServiceInterface {
expiryThreshold: this.accessTokenExpiryThreshold
})
+ this.updateMfaExpiryTimer()
+
this.tokenTimerInitialized = true
}
} catch (e) {
@@ -263,6 +284,11 @@ export class AuthService implements AuthServiceInterface {
await this.userManager.signinRedirectCallback(this.buildSignInCallbackUrl())
}
+ const user = await this.userManager.getUser()
+ if (user) {
+ this.updateMfaExpiryTimer()
+ }
+
const redirectRoute = this.router.resolve(this.userManager.getAndClearPostLoginRedirectUrl())
return this.router.replace({
path: redirectRoute.path,
@@ -408,6 +434,85 @@ export class AuthService implements AuthServiceInterface {
this.userManager.setPostLoginRedirectUrl(redirectUrl)
return this.userManager.signinRedirect({ acr_values: acrValue })
}
+
+ private updateMfaExpiryTimer() {
+ if (!this.mfaExpiryWorker) {
+ return
+ }
+
+ const sessionDuration = this.capabilityStore.authMfaSessionDuration
+ if (!sessionDuration) {
+ return
+ }
+
+ const baseTime = this.clientService.lastSuccessfulRequestTime ?? Math.floor(Date.now() / 1000)
+ const expiresAt = baseTime + sessionDuration
+
+ this.mfaExpiryWorker.setMfaTimer({ expiresAt })
+ }
+
+ private showMfaExpiryWarning() {
+ if (this.mfaExpiryModalDismissed) {
+ return
+ }
+
+ this.mfaExpiryModalDismissed = true
+ const { $gettext } = this.language
+
+ const modalStore = useModals()
+ const modal = modalStore.dispatchModal({
+ title: $gettext('Session expiring'),
+ message: $gettext(
+ 'Your multi-factor authentication session is about to expire. Would you like to extend it?'
+ ),
+ confirmText: $gettext('Extend session'),
+ cancelText: $gettext('Dismiss'),
+ onConfirm: () => {
+ this.mfaExpiryModalId = null
+ this.mfaExpiryBroadcastChannel?.postMessage({ action: 'prolonged' })
+ this.prolongMfaSession()
+ },
+ onCancel: () => {
+ this.mfaExpiryModalId = null
+ this.mfaExpiryBroadcastChannel?.postMessage({ action: 'dismissed' })
+ }
+ })
+ this.mfaExpiryModalId = modal.id
+ }
+
+ private prolongMfaSession() {
+ const sessionDuration = this.capabilityStore.authMfaSessionDuration
+ if (!sessionDuration) {
+ return
+ }
+
+ this.mfaExpiryModalDismissed = false
+ const expiresAt = Math.floor(Date.now() / 1000) + sessionDuration
+ this.mfaExpiryWorker?.setMfaTimer({ expiresAt })
+ }
+
+ private initMfaExpiryBroadcastChannel() {
+ this.mfaExpiryBroadcastChannel = new BroadcastChannel('oc-mfa-expiry')
+ this.mfaExpiryBroadcastChannel.onmessage = (event: MessageEvent) => {
+ const { action } = event.data
+ if (action === 'prolonged') {
+ this.mfaExpiryModalDismissed = true
+ if (this.mfaExpiryModalId) {
+ const modalStore = useModals()
+ modalStore.removeModal(this.mfaExpiryModalId)
+ this.mfaExpiryModalId = null
+ }
+ this.prolongMfaSession()
+ } else if (action === 'dismissed') {
+ this.mfaExpiryModalDismissed = true
+ if (this.mfaExpiryModalId) {
+ const modalStore = useModals()
+ modalStore.removeModal(this.mfaExpiryModalId)
+ this.mfaExpiryModalId = null
+ }
+ }
+ }
+ }
}
export const authService = new AuthService()
diff --git a/packages/web-runtime/src/services/auth/userManager.ts b/packages/web-runtime/src/services/auth/userManager.ts
index 91edaac061c..faf0dbf8b43 100644
--- a/packages/web-runtime/src/services/auth/userManager.ts
+++ b/packages/web-runtime/src/services/auth/userManager.ts
@@ -75,8 +75,8 @@ export class UserManager extends OidcUserManager {
// we trigger the token renewal manually via a timer running in a web worker
automaticSilentRenew: false,
- // do not filter acr
- filterProtocolClaims: ['nbf', 'jti', 'auth_time', 'nonce', 'amr', 'azp', 'at_hash']
+ // do not filter acr and auth_time (needed for MFA session expiry detection)
+ filterProtocolClaims: ['nbf', 'jti', 'nonce', 'amr', 'azp', 'at_hash']
}
if (options.configStore.isOIDC) {
diff --git a/packages/web-runtime/tests/unit/services/auth/authService.spec.ts b/packages/web-runtime/tests/unit/services/auth/authService.spec.ts
index 081d590c789..3ffbef6326c 100644
--- a/packages/web-runtime/tests/unit/services/auth/authService.spec.ts
+++ b/packages/web-runtime/tests/unit/services/auth/authService.spec.ts
@@ -48,6 +48,7 @@ describe('AuthService', () => {
Object.defineProperty(authService, 'userManager', {
value: {
signinRedirectCallback: vi.fn(),
+ getUser: vi.fn().mockResolvedValue(null),
getAndClearPostLoginRedirectUrl: () => url
}
})
@@ -73,6 +74,7 @@ describe('AuthService', () => {
Object.defineProperty(authService, 'userManager', {
value: mock({
getAccessToken: vi.fn().mockResolvedValue('access-token'),
+ getUser: vi.fn().mockResolvedValue(mock({ expires_in: 3600 })),
updateContext: mockUpdateContext
})
})
@@ -107,6 +109,7 @@ describe('AuthService', () => {
Object.defineProperty(authService, 'userManager', {
value: mock({
getAccessToken: vi.fn().mockResolvedValue('access-token'),
+ getUser: vi.fn().mockResolvedValue(mock({ expires_in: 3600 })),
updateContext: mockUpdateContext
})
})
@@ -143,6 +146,7 @@ describe('AuthService', () => {
Object.defineProperty(authService, 'userManager', {
value: mock({
getAccessToken: vi.fn().mockResolvedValue('access-token'),
+ getUser: vi.fn().mockResolvedValue(mock({ expires_in: 3600 })),
updateContext: mockUpdateContext
})
})