diff --git a/changelog/unreleased/enhancement-add-new-theme-colors.md b/changelog/unreleased/enhancement-add-new-theme-colors.md new file mode 100644 index 00000000000..0708184439f --- /dev/null +++ b/changelog/unreleased/enhancement-add-new-theme-colors.md @@ -0,0 +1,11 @@ +Enhancement: Add new theme colors + +We've added new theme colors. These new colors are: + +- background-sidebar +- search-input-text-default +- search-input-text-muted +- search-input-border +- search-input-bg + +https://github.com/owncloud/web/pull/13795 diff --git a/changelog/unreleased/enhancement-add-vault-to-search.md b/changelog/unreleased/enhancement-add-vault-to-search.md new file mode 100644 index 00000000000..abdb2465f99 --- /dev/null +++ b/changelog/unreleased/enhancement-add-vault-to-search.md @@ -0,0 +1,5 @@ +Enhancement: Add vault search separation + +We've implemented vault search separation by adding the `vault:true` query token to the `` search payload. The token is now included in both "All files" and "Current folder" search requests, ensuring vault content is correctly scoped in all search scenarios. + +https://github.com/owncloud/web/pull/13769 diff --git a/changelog/unreleased/enhancement-check-vault-permission.md b/changelog/unreleased/enhancement-check-vault-permission.md new file mode 100644 index 00000000000..94fd0510f1c --- /dev/null +++ b/changelog/unreleased/enhancement-check-vault-permission.md @@ -0,0 +1,5 @@ +Enhancement: Check vault permission + +When the user has the `VaultMode.ReadWriteEnabled.own` permission, the mode switch will be shown in the topbar. + +https://github.com/owncloud/web/pull/13802 diff --git a/changelog/unreleased/enhancement-mfa-session-expiry-warning.md b/changelog/unreleased/enhancement-mfa-session-expiry-warning.md new file mode 100644 index 00000000000..1ec3404f0ca --- /dev/null +++ b/changelog/unreleased/enhancement-mfa-session-expiry-warning.md @@ -0,0 +1,5 @@ +Enhancement: MFA session expiry warning + +We've added a warning modal that notifies users before their multi-factor authentication session expires. Users can extend the session via silent OIDC renewal or dismiss the warning. The modal state is synchronized across multiple browser tabs using a BroadcastChannel. + +https://github.com/owncloud/web/pull/13803 diff --git a/changelog/unreleased/enhancement-show-correct-modal-for-save-as-and-open.md b/changelog/unreleased/enhancement-show-correct-modal-for-save-as-and-open.md new file mode 100644 index 00000000000..3a8c1a80873 --- /dev/null +++ b/changelog/unreleased/enhancement-show-correct-modal-for-save-as-and-open.md @@ -0,0 +1,5 @@ +Enhancement: Show correct modal for saveAs and open actions + +We've added logic to show the correct modal when the user clicks on "Save As" or "Open" from the 3 dots context menu. + +https://github.com/owncloud/web/pull/13759 diff --git a/changelog/unreleased/enhancement-vault-aware-breadcrumbs.md b/changelog/unreleased/enhancement-vault-aware-breadcrumbs.md new file mode 100644 index 00000000000..fee2c0f8750 --- /dev/null +++ b/changelog/unreleased/enhancement-vault-aware-breadcrumbs.md @@ -0,0 +1,5 @@ +Enhancement: Vault-aware breadcrumbs + +We've introduced vault-aware breadcrumbs that show Vault or Drive as the root item depending on the active scope. Users without vault access see the original labels instead. + +https://github.com/owncloud/web/pull/13803 diff --git a/packages/design-system/src/components/OcButton/OcButton.vue b/packages/design-system/src/components/OcButton/OcButton.vue index 2de708d2693..b41d5c7859f 100644 --- a/packages/design-system/src/components/OcButton/OcButton.vue +++ b/packages/design-system/src/components/OcButton/OcButton.vue @@ -187,6 +187,15 @@ const handlers = computed(() => { .oc-icon > svg { fill: $color; } + + &:focus:not([disabled]), + &:hover:not([disabled]) { + color: $hover-color; + + .oc-icon > svg { + fill: $hover-color; + } + } } &-raw-inverse { color: $contrast-color; @@ -194,6 +203,15 @@ const handlers = computed(() => { .oc-icon > svg { fill: $contrast-color; } + + &:focus:not([disabled]), + &:hover:not([disabled]) { + color: $contrast-color; + + .oc-icon > svg { + fill: $contrast-color; + } + } } &-filled { @@ -340,12 +358,12 @@ const handlers = computed(() => { &-outline { &:focus:not([disabled]), &:hover:not([disabled]) { - color: var(--oc-color-swatch-passive-default); + color: var(--oc-color-swatch-passive-contrast); background-color: var(--oc-color-swatch-passive-hover-outline); border-color: var(--oc-color-swatch-passive-hover-outline); .oc-icon > svg { - fill: var(--oc-color-swatch-passive-default); + fill: var(--oc-color-swatch-passive-contrast); } } } diff --git a/packages/web-app-files/src/components/AppBar/SharesNavigation.vue b/packages/web-app-files/src/components/AppBar/SharesNavigation.vue index 8e83942cdbc..0379f233716 100644 --- a/packages/web-app-files/src/components/AppBar/SharesNavigation.vue +++ b/packages/web-app-files/src/components/AppBar/SharesNavigation.vue @@ -1,7 +1,7 @@ diff --git a/packages/web-app-files/src/index.ts b/packages/web-app-files/src/index.ts index 1a4988a2d07..1b0e11ea779 100644 --- a/packages/web-app-files/src/index.ts +++ b/packages/web-app-files/src/index.ts @@ -40,6 +40,8 @@ const appInfo: ApplicationInformation = { } export const navItems = (context: ComponentCustomProperties): AppNavigationItem[] => { + const currentPath = window.location.pathname + const isVault = currentPath.startsWith('/vault') const spacesStores = useSpacesStore() const userStore = useUserStore() const capabilityStore = useCapabilityStore() @@ -48,7 +50,7 @@ export const navItems = (context: ComponentCustomProperties): AppNavigationItem[ return [ { name() { - return $gettext('Personal') + return isVault ? $gettext('Safe-Personal') : $gettext('Personal') }, icon: appInfo.icon, route: { @@ -101,7 +103,7 @@ export const navItems = (context: ComponentCustomProperties): AppNavigationItem[ priority: 30 }, { - name: $gettext('Spaces'), + name: isVault ? $gettext('Safe-Spaces') : $gettext('Spaces'), icon: 'layout-grid', route: { path: `/${appInfo.id}/spaces/projects` diff --git a/packages/web-app-files/src/views/spaces/GenericSpace.vue b/packages/web-app-files/src/views/spaces/GenericSpace.vue index 85c256f2d88..0e29be959ad 100644 --- a/packages/web-app-files/src/views/spaces/GenericSpace.vue +++ b/packages/web-app-files/src/views/spaces/GenericSpace.vue @@ -129,6 +129,7 @@ import { import { ResourceTransfer, TransferType, + useAbility, useConfigStore, useExtensionRegistry, useFileActions, @@ -158,7 +159,8 @@ import { useKeyboardActions, useRoute, useRouteQuery, - FolderLoaderOptions + FolderLoaderOptions, + useCapabilityStore } from '@ownclouders/web-pkg' import CreateAndUpload from '../../components/AppBar/CreateAndUpload.vue' import FilesViewWrapper from '../../components/FilesViewWrapper.vue' @@ -189,6 +191,8 @@ interface Props { const { space = null, item = null, itemId = null } = defineProps() const router = useRouter() +const { can } = useAbility() +const capabilityStore = useCapabilityStore() const userStore = useUserStore() const { $gettext, $ngettext } = useGettext() const openWithDefaultAppQuery = useRouteQuery('openWithDefaultApp') @@ -256,12 +260,26 @@ const titleSegments = computed(() => { useDocumentTitle({ titleSegments }) const route = useRoute() +const canAccessVault = computed(() => capabilityStore.vaultEnabled && can('read-all', 'Vault')) + +const getSpacesBreadcrumbText = () => { + if (!unref(canAccessVault)) { + return $gettext('Spaces') + } + + if (unref(route).params.scope === 'vault') { + return $gettext('Vault') + } + + return $gettext('Drive') +} + const breadcrumbs = computed(() => { const rootBreadcrumbItems: BreadcrumbItem[] = [] if (isProjectSpaceResource(unref(space))) { rootBreadcrumbItems.push({ id: uuidV4(), - text: $gettext('Spaces'), + text: getSpacesBreadcrumbText(), to: createLocationSpaces('files-spaces-projects'), isStaticNav: true }) @@ -286,6 +304,17 @@ const breadcrumbs = computed(() => { let { params, query } = createFileRouteOptions(unref(space), { fileId: unref(space).fileId }) query = omit({ ...unref(route).query, ...query }, 'page') if (isPersonalSpaceResource(unref(space))) { + if (unref(canAccessVault)) { + const vaultText = + unref(route).params.scope === 'vault' ? $gettext('Vault') : $gettext('Drive') + rootBreadcrumbItems.push({ + id: uuidV4(), + text: vaultText, + to: createLocationSpaces('files-spaces-projects'), + isStaticNav: true + }) + } + spaceBreadcrumbItem = { id: uuidV4(), text: unref(space).name, diff --git a/packages/web-app-files/src/views/spaces/Projects.vue b/packages/web-app-files/src/views/spaces/Projects.vue index 505ec69f894..17fb88144d3 100644 --- a/packages/web-app-files/src/views/spaces/Projects.vue +++ b/packages/web-app-files/src/views/spaces/Projects.vue @@ -186,7 +186,8 @@ import { useRoute, Pagination, FileSideBar, - NoContentMessage + NoContentMessage, + useCapabilityStore } from '@ownclouders/web-pkg' import SpaceContextActions from '../../components/Spaces/SpaceContextActions.vue' import { @@ -221,6 +222,7 @@ const { $gettext } = language const filterTerm = ref('') const markInstance = ref(undefined) const includeDisabledParam = useRouteQuery('q_includeDisabled') +const capabilityStore = useCapabilityStore() const { setSelection, initResourceList, clearResourceList, setAncestorMetaData } = useResourcesStore() @@ -230,7 +232,8 @@ const loadResourcesTask = useTask(function* (signal) { setAncestorMetaData({}) yield spacesStore.reloadProjectSpaces({ graphClient: clientService.graphAuthenticated, - signal + signal, + isInVault: unref(route)?.params?.scope === 'vault' }) initResourceList({ currentFolder: null, resources: unref(spaces) }) }) @@ -248,7 +251,7 @@ let loadPreviewToken: string = null const { isSideBarOpen, sideBarActivePanel } = useSideBar() const runtimeSpaces = computed(() => { - return spacesStore.spaces.filter(isProjectSpaceResource) || [] + return spacesStore.spaces.filter((space) => isProjectSpaceResource(space)) || [] }) const selectedSpace = computed(() => { if ( @@ -334,6 +337,7 @@ watch(filterTerm, async () => { }) const hasCreatePermission = computed(() => can('create-all', 'Drive')) +const canAccessVault = computed(() => capabilityStore.vaultEnabled && can('read-all', 'Vault')) const extensionRegistry = useExtensionRegistry() const viewModes = computed(() => { @@ -458,10 +462,22 @@ const spacesHelpList = computed(() => { } ] }) +const getBreadcrumbText = () => { + if (!unref(canAccessVault)) { + return $gettext('Spaces') + } + + if (unref(route).params.scope === 'vault') { + return $gettext('Vault') + } + + return $gettext('Drive') +} + const breadcrumbs = computed(() => { return [ { - text: $gettext('Spaces'), + text: getBreadcrumbText(), onClick: () => loadResourcesTask.perform(), isStativNav: true } diff --git a/packages/web-app-files/src/views/trash/Overview.vue b/packages/web-app-files/src/views/trash/Overview.vue index 443e8298c96..9d497bb1ddd 100644 --- a/packages/web-app-files/src/views/trash/Overview.vue +++ b/packages/web-app-files/src/views/trash/Overview.vue @@ -97,6 +97,7 @@ import { AppLoadingSpinner } from '@ownclouders/web-pkg' import { NoContentMessage } from '@ownclouders/web-pkg' import { FieldType } from '@ownclouders/design-system/helpers' import { useFileListHeaderPosition } from '@ownclouders/web-pkg' +import { useRoute } from 'vue-router' const userStore = useUserStore() const spacesStore = useSpacesStore() @@ -107,6 +108,7 @@ const { y: fileListHeaderY } = useFileListHeaderPosition() const resourcesStore = useResourcesStore() const { isSideBarOpen, sideBarActivePanel } = useSideBar() const { isSticky } = useIsTopBarSticky() +const route = useRoute() const sortBy = ref('name') const sortDir = ref(SortDir.Asc) @@ -125,7 +127,8 @@ const loadResourcesTask = useTask(function* (signal) { resourcesStore.clearResourceList() yield spacesStore.reloadProjectSpaces({ graphClient: clientService.graphAuthenticated, - signal + signal, + isInVault: unref(route)?.params?.scope === 'vault' }) resourcesStore.initResourceList({ currentFolder: null, resources: unref(spaces) }) }) diff --git a/packages/web-app-files/tests/unit/views/spaces/GenericSpace.spec.ts b/packages/web-app-files/tests/unit/views/spaces/GenericSpace.spec.ts index 8394b8694d3..2082d321c90 100644 --- a/packages/web-app-files/tests/unit/views/spaces/GenericSpace.spec.ts +++ b/packages/web-app-files/tests/unit/views/spaces/GenericSpace.spec.ts @@ -1,6 +1,6 @@ import { computed, ref } from 'vue' import { mock, mockDeep } from 'vitest-mock-extended' -import { Resource, SpaceResource } from '@ownclouders/web-client' +import { AbilityRule, Resource, SpaceResource } from '@ownclouders/web-client' import GenericSpace from '../../../../src/views/spaces/GenericSpace.vue' import { useResourcesViewDefaults } from '../../../../src/composables/resourcesViewDefaults' import { useResourcesViewDefaultsMock } from '../../../../tests/mocks/useResourcesViewDefaultsMock' @@ -11,7 +11,8 @@ import { defaultStubs, RouteLocation, ComponentProps, - PartialComponentProps + PartialComponentProps, + PiniaMockOptions } from '@ownclouders/web-test-helpers' import { AppBar, @@ -114,6 +115,109 @@ describe('GenericSpace view', () => { expectedItems ) }) + describe('personal space with vault access', () => { + it('shows "Drive" as root breadcrumb when scope is not vault', () => { + const space = mock({ + id: '1', + getDriveAliasAndItem: vi.fn(), + driveType: 'personal', + name: 'Personal space', + isOwner: () => true + }) + const { wrapper } = getMountedWrapper({ + files: [mockDeep()], + props: { space }, + abilities: [{ action: 'read-all', subject: 'Vault' }], + capabilityState: { capabilities: { vault: { enabled: true } } } + }) + const breadcrumbs = wrapper.findComponent('app-bar-stub').props().breadcrumbs + expect(breadcrumbs.length).toBe(2) + expect(breadcrumbs[0].text).toBe('Drive') + expect(breadcrumbs[1].text).toBe('Personal space') + }) + it('shows "Vault" as root breadcrumb when scope is vault', () => { + const space = mock({ + id: '1', + getDriveAliasAndItem: vi.fn(), + driveType: 'personal', + name: 'Personal space', + isOwner: () => true + }) + const { wrapper } = getMountedWrapper({ + files: [mockDeep()], + props: { space }, + abilities: [{ action: 'read-all', subject: 'Vault' }], + capabilityState: { capabilities: { vault: { enabled: true } } }, + currentRoute: { name: 'files-spaces-generic', path: '/', params: { scope: 'vault' } } + }) + const breadcrumbs = wrapper.findComponent('app-bar-stub').props().breadcrumbs + expect(breadcrumbs.length).toBe(2) + expect(breadcrumbs[0].text).toBe('Vault') + expect(breadcrumbs[1].text).toBe('Personal space') + }) + it('shows only space name when user cannot access vault', () => { + const space = mock({ + id: '1', + getDriveAliasAndItem: vi.fn(), + driveType: 'personal', + name: 'Personal space', + isOwner: () => true + }) + const { wrapper } = getMountedWrapper({ + files: [mockDeep()], + props: { space } + }) + const breadcrumbs = wrapper.findComponent('app-bar-stub').props().breadcrumbs + expect(breadcrumbs.length).toBe(1) + expect(breadcrumbs[0].text).toBe('Personal space') + }) + }) + describe('project space breadcrumbs', () => { + it('shows "Spaces" when user cannot access vault', () => { + const space = mock({ + id: '1', + getDriveAliasAndItem: vi.fn(), + driveType: 'project' + }) + const { wrapper } = getMountedWrapper({ + files: [mockDeep()], + props: { space } + }) + const breadcrumbs = wrapper.findComponent('app-bar-stub').props().breadcrumbs + expect(breadcrumbs[0].text).toBe('Spaces') + }) + it('shows "Drive" when user can access vault and scope is not vault', () => { + const space = mock({ + id: '1', + getDriveAliasAndItem: vi.fn(), + driveType: 'project' + }) + const { wrapper } = getMountedWrapper({ + files: [mockDeep()], + props: { space }, + abilities: [{ action: 'read-all', subject: 'Vault' }], + capabilityState: { capabilities: { vault: { enabled: true } } } + }) + const breadcrumbs = wrapper.findComponent('app-bar-stub').props().breadcrumbs + expect(breadcrumbs[0].text).toBe('Drive') + }) + it('shows "Vault" when user can access vault and scope is vault', () => { + const space = mock({ + id: '1', + getDriveAliasAndItem: vi.fn(), + driveType: 'project' + }) + const { wrapper } = getMountedWrapper({ + files: [mockDeep()], + props: { space }, + abilities: [{ action: 'read-all', subject: 'Vault' }], + capabilityState: { capabilities: { vault: { enabled: true } } }, + currentRoute: { name: 'files-spaces-generic', path: '/', params: { scope: 'vault' } } + }) + const breadcrumbs = wrapper.findComponent('app-bar-stub').props().breadcrumbs + expect(breadcrumbs[0].text).toBe('Vault') + }) + }) it('include the root item and the current folder', () => { const folderName = 'someFolder' const { wrapper } = getMountedWrapper({ @@ -258,23 +362,29 @@ function getMountedWrapper({ driveType: '' }), breadcrumbsFromPath = [], - stubs = {} + stubs = {}, + abilities = [], + capabilityState = {} }: { mocks?: Record props?: PartialComponentProps files?: Resource[] loading?: boolean - currentRoute?: { name?: string; path?: string } + currentRoute?: { name?: string; path?: string; params?: Record } currentFolder?: Resource runningOnEos?: boolean space?: SpaceResource breadcrumbsFromPath?: BreadcrumbItem[] stubs?: any + abilities?: AbilityRule[] + capabilityState?: PiniaMockOptions['capabilityState'] } = {}) { const plugins = defaultPlugins({ + abilities, piniaOptions: { configState: { options: { runningOnEos } }, - resourcesStore: { currentFolder } + resourcesStore: { currentFolder }, + capabilityState } }) diff --git a/packages/web-app-files/tests/unit/views/spaces/Projects.spec.ts b/packages/web-app-files/tests/unit/views/spaces/Projects.spec.ts index 8bc4a705b0b..a694ed23ab7 100644 --- a/packages/web-app-files/tests/unit/views/spaces/Projects.spec.ts +++ b/packages/web-app-files/tests/unit/views/spaces/Projects.spec.ts @@ -123,6 +123,41 @@ describe('Projects view', () => { }) expect(wrapper.find('create-space-stub').exists()).toBeTruthy() }) + describe('breadcrumbs', () => { + it('shows "Spaces" when user cannot access vault', async () => { + const { wrapper } = getMountedWrapper({ spaces: spacesResources }) + await (wrapper.vm as any).loadResourcesTask.last + expect(wrapper.findComponent('app-bar-stub').props().breadcrumbs[0].text).toBe( + 'Spaces' + ) + }) + it('shows "Drive" when user can access vault and scope is not vault', async () => { + const { wrapper } = getMountedWrapper({ + spaces: spacesResources, + abilities: [{ action: 'read-all', subject: 'Vault' }], + store: { capabilityState: { capabilities: { vault: { enabled: true } } } } + }) + await (wrapper.vm as any).loadResourcesTask.last + expect(wrapper.findComponent('app-bar-stub').props().breadcrumbs[0].text).toBe( + 'Drive' + ) + }) + it('shows "Vault" when user can access vault and scope is vault', async () => { + const { wrapper } = getMountedWrapper({ + spaces: spacesResources, + abilities: [{ action: 'read-all', subject: 'Vault' }], + store: { capabilityState: { capabilities: { vault: { enabled: true } } } }, + currentRoute: mock({ + name: 'files-spaces-projects', + params: { scope: 'vault' } + }) + }) + await (wrapper.vm as any).loadResourcesTask.last + expect(wrapper.findComponent('app-bar-stub').props().breadcrumbs[0].text).toBe( + 'Vault' + ) + }) + }) it('should not pass selected resource as space to sidebar when driveType is not "project"', () => { const resource = mock({ id: 'selected-resource', driveType: 'personal' }) const { wrapper } = getMountedWrapper({ @@ -147,7 +182,8 @@ function getMountedWrapper({ abilities = [], stubAppBar = true, includeDisabled = false, - store = {} + store = {}, + currentRoute = mock({ name: 'files-spaces-projects' }) }: { mocks?: Record spaces?: SpaceResource[] @@ -155,6 +191,7 @@ function getMountedWrapper({ stubAppBar?: boolean includeDisabled?: boolean store?: PiniaMockOptions + currentRoute?: RouteLocation } = {}) { const plugins = defaultPlugins({ abilities, piniaOptions: { spacesState: { spaces }, ...store } }) @@ -184,9 +221,7 @@ function getMountedWrapper({ vi.mocked(requestExtensions).mockReturnValue(extensions) const defaultMocks = { - ...defaultComponentMocks({ - currentRoute: mock({ name: 'files-spaces-projects' }) - }), + ...defaultComponentMocks({ currentRoute }), ...(mocks && mocks) } diff --git a/packages/web-app-search/src/portals/SearchBar.vue b/packages/web-app-search/src/portals/SearchBar.vue index 135cb9dceb5..24db579d953 100644 --- a/packages/web-app-search/src/portals/SearchBar.vue +++ b/packages/web-app-search/src/portals/SearchBar.vue @@ -498,6 +498,10 @@ const search = async () => { terms.push(`scope:${unref(scope)}`) } + if (unref(route).params?.scope === 'vault') { + terms.push('vault:true') + } + loading.value = true for (const availableProvider of unref(availableProviders)) { @@ -709,10 +713,16 @@ onBeforeUnmount(() => { } .oc-search-input { - background-color: var(--oc-color-input-bg); + background-color: var(--oc-color-search-input-bg, var(--oc-color-input-bg)); + border: 1px solid var(--oc-color-search-input-border, var(--oc-color-input-border)); + color: var(--oc-color-search-input-text-default, var(--oc-color-input-text-default)); transition: 0s; height: 2.3rem; + &::placeholder { + color: var(--oc-color-search-input-text-muted, --oc-color-text-muted); + } + @media (max-width: 639px) { border: none; display: inline; @@ -746,11 +756,16 @@ onBeforeUnmount(() => { input, input:not(:placeholder-shown) { - background-color: var(--oc-color-input-bg); - border: 1px solid var(--oc-color-input-border); + background-color: var(--oc-color-search-input-bg, var(--oc-color-input-bg)); + border: 1px solid var(--oc-color-search-input-border, var(--oc-color-input-border)); + color: var(--oc-color-search-input-text-default, var(--oc-color-input-text-default)); z-index: var(--oc-z-index-modal); margin: 0 auto; padding-right: 5.875rem; + + &::placeholder { + color: var(--oc-color-search-input-text-muted, --oc-color-text-muted); + } } } } @@ -828,5 +843,13 @@ onBeforeUnmount(() => { } } } + + .oc-filter-chip-button.oc-pill { + color: var(--oc-color-search-input-text-default, var(--oc-color-text-muted)) !important; + } + + .oc-button-passive-raw .oc-icon > svg { + fill: var(--oc-color-search-input-text-default, var(--oc-color-text-muted)); + } } diff --git a/packages/web-app-search/tests/unit/portals/SearchBar.spec.ts b/packages/web-app-search/tests/unit/portals/SearchBar.spec.ts index b185ba37961..99e570a5a8e 100644 --- a/packages/web-app-search/tests/unit/portals/SearchBar.spec.ts +++ b/packages/web-app-search/tests/unit/portals/SearchBar.spec.ts @@ -269,6 +269,22 @@ describe('Search Bar portal component', () => { const spyRouterPushStub = wrapper.vm.$router.push expect(spyRouterPushStub).not.toHaveBeenCalled() }) + test('includes vault:true in search term when route scope is vault', async () => { + wrapper = getMountedWrapper({ routeParams: { scope: 'vault' } }).wrapper + wrapper.find(selectors.searchInput).setValue('albert') + await flushPromises() + expect(providerFiles.previewSearch.search).toHaveBeenCalledWith( + expect.stringContaining('vault:true') + ) + }) + test('does not include vault:true in search term when route scope is not vault', async () => { + wrapper = getMountedWrapper().wrapper + wrapper.find(selectors.searchInput).setValue('albert') + await flushPromises() + expect(providerFiles.previewSearch.search).not.toHaveBeenCalledWith( + expect.stringContaining('vault:true') + ) + }) }) type Mocks = { @@ -284,7 +300,8 @@ function getMountedWrapper({ userContextReady = true, providers = [providerFiles, providerContacts], route = 'files-spaces-generic', - store = {} + store = {}, + routeParams = {} as Record } = {}) { vi.mocked(useAvailableProviders).mockReturnValue(ref(providers)) @@ -293,7 +310,8 @@ function getMountedWrapper({ query: { term: mocks?.$route?.query?.term || '', provider: '' - } + }, + params: routeParams }) const localMocks = { ...defaultComponentMocks({ currentRoute }), diff --git a/packages/web-client/src/helpers/resource/types.ts b/packages/web-client/src/helpers/resource/types.ts index d232be33581..b08f0bf81cd 100644 --- a/packages/web-client/src/helpers/resource/types.ts +++ b/packages/web-client/src/helpers/resource/types.ts @@ -27,6 +27,7 @@ export type AbilitySubjects = | 'Role' | 'Setting' | 'Share' + | 'Vault' export type Ability = MongoAbility<[AbilityActions, AbilitySubjects]> export type AbilityRule = SubjectRawRule diff --git a/packages/web-client/src/ocs/capabilities.ts b/packages/web-client/src/ocs/capabilities.ts index c75ce18ba78..680fa5692ef 100644 --- a/packages/web-client/src/ocs/capabilities.ts +++ b/packages/web-client/src/ocs/capabilities.ts @@ -54,6 +54,7 @@ interface AuthCapability { mfa: { enabled?: boolean levelnames?: string[] + session_duration?: number } } @@ -174,6 +175,9 @@ export interface Capabilities { version?: string server_managed?: boolean } + vault?: { + enabled?: boolean + } graph?: { 'personal-data-export'?: boolean users: { diff --git a/packages/web-pkg/src/components/AppBar/CreateSpace.vue b/packages/web-pkg/src/components/AppBar/CreateSpace.vue index 37282774607..9942af538bc 100644 --- a/packages/web-pkg/src/components/AppBar/CreateSpace.vue +++ b/packages/web-pkg/src/components/AppBar/CreateSpace.vue @@ -43,7 +43,7 @@ const { upsertResource } = useResourcesStore() const addNewSpace = async (name: string) => { try { - const createdSpace = await createSpace(name) + const createdSpace = await createSpace(name, 'project') upsertResource(createdSpace) spacesStore.upsertSpace(createdSpace) emit('spaceCreated', createdSpace) diff --git a/packages/web-pkg/src/components/SideBar/SideBar.vue b/packages/web-pkg/src/components/SideBar/SideBar.vue index 080f7df5656..98f3ae4f6b1 100644 --- a/packages/web-pkg/src/components/SideBar/SideBar.vue +++ b/packages/web-pkg/src/components/SideBar/SideBar.vue @@ -284,7 +284,7 @@ onBeforeUnmount(() => { max-height: 100%; display: grid; grid-template-rows: auto auto 1fr; - background-color: var(--oc-color-background-default); + background-color: var(--oc-color-background-sidebar, var(--oc-color-background-default)); top: 0; position: absolute; transform: translateX(100%); diff --git a/packages/web-pkg/src/components/ViewOptions.vue b/packages/web-pkg/src/components/ViewOptions.vue index 6bdd63fd9d7..87fb65b8ef4 100644 --- a/packages/web-pkg/src/components/ViewOptions.vue +++ b/packages/web-pkg/src/components/ViewOptions.vue @@ -12,6 +12,7 @@ :class="viewMode.name" :appearance="viewModeCurrent === viewMode.name ? 'filled' : 'outline'" :aria-label="$gettext(viewMode.label)" + variation="primary" @click="setViewMode(viewMode)" > @@ -259,6 +261,10 @@ const fileExtensionsShownModel = computed(() => unref(areFileExtensionsShown)) flex-flow: initial; } +.viewmode-switch-buttons.oc-button-group { + outline: 1px solid var(--oc-color-swatch-primary-default); +} + #files-view-options-btn { vertical-align: middle; border: 3px solid transparent; diff --git a/packages/web-pkg/src/composables/actions/files/useFileActions.ts b/packages/web-pkg/src/composables/actions/files/useFileActions.ts index c096efa3e72..551aa704ec6 100644 --- a/packages/web-pkg/src/composables/actions/files/useFileActions.ts +++ b/packages/web-pkg/src/composables/actions/files/useFileActions.ts @@ -4,7 +4,7 @@ import { isShareSpaceResource } from '@ownclouders/web-client' import { routeToContextQuery } from '../../appDefaults' import { isLocationTrashActive } from '../../../router' import { computed, unref } from 'vue' -import { useRouter } from '../../router' +import { useRouter, useRoute } from '../../router' import { useGettext } from 'vue3-gettext' import { Action, @@ -53,6 +53,7 @@ export interface GetFileActionsOptions extends FileActionOptions { export const useFileActions = () => { const appsStore = useAppsStore() const router = useRouter() + const route = useRoute() const { $gettext } = useGettext() const isSearchActive = useIsSearchActive() const { isEnabled: isEmbedModeEnabled } = useEmbedMode() @@ -221,6 +222,7 @@ export const useFileActions = () => { driveAliasAndItem: space?.getDriveAliasAndItem(resource), filePath: resource.path, fileId: resource.fileId, + scope: route.value.params.scope, mode }, query: { diff --git a/packages/web-pkg/src/composables/actions/files/useFileActionsPaste.ts b/packages/web-pkg/src/composables/actions/files/useFileActionsPaste.ts index 5265c1e26a0..f652bb6830e 100644 --- a/packages/web-pkg/src/composables/actions/files/useFileActionsPaste.ts +++ b/packages/web-pkg/src/composables/actions/files/useFileActionsPaste.ts @@ -10,7 +10,13 @@ import { useClientService } from '../../clientService' import { useRouter } from '../../router' import { FileAction, FileActionOptions } from '../types' import { Resource, SpaceResource, isShareSpaceResource } from '@ownclouders/web-client' -import { useClipboardStore, useResourcesStore } from '../../piniaStores' +import { + ClipboardMode, + useClipboardStore, + useConfigStore, + useMessages, + useResourcesStore +} from '../../piniaStores' import { ClipboardActions, ResourceTransfer, TransferType } from '../../../helpers' import { storeToRefs } from 'pinia' import { usePasteWorker } from '../../webWorkers/pasteWorker' @@ -20,13 +26,30 @@ export const useFileActionsPaste = () => { const clientService = useClientService() const { getMatchingSpace } = useGetMatchingSpace() const { $gettext, $ngettext } = useGettext() + const { showErrorMessage } = useMessages() const clipboardStore = useClipboardStore() + const configStore = useConfigStore() const { startWorker } = usePasteWorker() const resourcesStore = useResourcesStore() const { currentFolder } = storeToRefs(resourcesStore) const { resources: clipboardResources } = storeToRefs(clipboardStore) + const isCrossModePasteAllowed = computed(() => { + return ( + !clipboardStore.sourceMode || + clipboardStore.sourceMode !== ClipboardMode.Vault || + configStore.isInVault + ) + }) + + const getSourceSpace = (resource: Resource): SpaceResource => { + const sourceBucketId = clipboardStore.getClipboardSourceSpaceKey(resource) + const persistedSpace = clipboardStore.sourceSpaces[sourceBucketId] + + return (persistedSpace as SpaceResource) || getMatchingSpace(resource) + } + const isMacOs = computed(() => { return window.navigator.platform.match('Mac') }) @@ -135,6 +158,13 @@ export const useFileActionsPaste = () => { }) const handler = async ({ space: targetSpace }: FileActionOptions) => { + if (!unref(isCrossModePasteAllowed)) { + showErrorMessage({ + title: $gettext('Pasting from Vault into the default mode is not supported.') + }) + return + } + if (unref(isCuttingAndPastingIntoSameFolder)) { return } @@ -142,18 +172,19 @@ export const useFileActionsPaste = () => { const resourceSpaceMapping = clipboardStore.resources.reduce< Record >((acc, resource) => { - if (resource.storageId in acc) { - acc[resource.storageId].resources.push(resource) + const sourceBucketId = clipboardStore.getClipboardSourceSpaceKey(resource) + const sourceSpace = getSourceSpace(resource) + + if (sourceBucketId in acc) { + acc[sourceBucketId].resources.push(resource) return acc } - const matchingSpace = getMatchingSpace(resource) - - if (!(matchingSpace.id in acc)) { - acc[matchingSpace.id] = { space: matchingSpace, resources: [] } + if (!(sourceSpace.id in acc)) { + acc[sourceSpace.id] = { space: sourceSpace, resources: [] } } - acc[matchingSpace.id].resources.push(resource) + acc[sourceSpace.id].resources.push(resource) return acc }, {}) @@ -177,6 +208,9 @@ export const useFileActionsPaste = () => { if (clipboardStore.resources.length === 0) { return false } + if (!unref(isCrossModePasteAllowed)) { + return false + } if ( !isLocationSpacesActive(router, 'files-spaces-generic') && !isLocationPublicActive(router, 'files-public-link') && diff --git a/packages/web-pkg/src/composables/appDefaults/useAppDefaults.ts b/packages/web-pkg/src/composables/appDefaults/useAppDefaults.ts index 5bf6a25ed2c..3d3fd9e6e48 100644 --- a/packages/web-pkg/src/composables/appDefaults/useAppDefaults.ts +++ b/packages/web-pkg/src/composables/appDefaults/useAppDefaults.ts @@ -63,7 +63,7 @@ export function useAppDefaults(options: AppDefaultsOptions): AppDefaultsResult { path = urlJoin(unref(space).webDavPath, unref(item)) } else { // deprecated. - path = urlJoin(queryItemAsString(unref(currentRoute).params.filePath)) + path = urlJoin(queryItemAsString(unref(currentRoute)?.params?.filePath)) } return { diff --git a/packages/web-pkg/src/composables/piniaStores/capabilities.ts b/packages/web-pkg/src/composables/piniaStores/capabilities.ts index 7ea3a06bc52..f649c59626f 100644 --- a/packages/web-pkg/src/composables/piniaStores/capabilities.ts +++ b/packages/web-pkg/src/composables/piniaStores/capabilities.ts @@ -71,6 +71,9 @@ const defaultValues = { max_quota: 0, projects: false, server_managed: false + }, + vault: { + enabled: false } } satisfies Partial @@ -153,6 +156,9 @@ export const useCapabilityStore = defineStore('capabilities', () => { const authMfaEnabled = computed(() => unref(capabilities).auth.mfa.enabled) const authMfaRequiredLevelname = computed(() => unref(capabilities).auth.mfa.levelnames.at(0)) + const authMfaSessionDuration = computed(() => unref(capabilities).auth.mfa.session_duration) + + const vaultEnabled = computed(() => unref(capabilities).vault?.enabled) return { isInitialized, @@ -202,7 +208,9 @@ export const useCapabilityStore = defineStore('capabilities', () => { searchMediaType, searchContent, authMfaEnabled, - authMfaRequiredLevelname + authMfaRequiredLevelname, + authMfaSessionDuration, + vaultEnabled } }) diff --git a/packages/web-pkg/src/composables/piniaStores/clipboard.ts b/packages/web-pkg/src/composables/piniaStores/clipboard.ts index 3f665a5348d..2d99a398b81 100644 --- a/packages/web-pkg/src/composables/piniaStores/clipboard.ts +++ b/packages/web-pkg/src/composables/piniaStores/clipboard.ts @@ -1,35 +1,167 @@ import { defineStore } from 'pinia' -import { ref } from 'vue' -import { Resource } from '@ownclouders/web-client' +import { computed, ref, unref } from 'vue' +import { Resource, SpaceResource } from '@ownclouders/web-client' import { ClipboardActions } from '../../helpers' import { useGettext } from 'vue3-gettext' import { useMessages } from './messages' +import { useConfigStore } from './config' +import { useGetMatchingSpace } from '../spaces' + +const clipboardStorageKey = 'oc-clipboard' + +export enum ClipboardMode { + Default = 'default', + Vault = 'vault' +} + +type ClipboardResourceSnapshot = Pick< + Resource, + | 'id' + | 'fileId' + | 'extension' + | 'isFolder' + | 'name' + | 'mimeType' + | 'parentFolderId' + | 'path' + | 'remoteItemId' + | 'remoteItemPath' + | 'shareTypes' + | 'spaceId' + | 'storageId' + | 'type' + | 'webDavPath' +> + +type ClipboardSpaceSnapshot = Pick + +type PersistedClipboardPayload = { + action: ClipboardActions + resources: ClipboardResourceSnapshot[] + sourceMode: ClipboardMode + sourceSpaces: Record +} export const useClipboardStore = defineStore('clipboard', () => { const { $gettext } = useGettext() const { showMessage } = useMessages() + const configStore = useConfigStore() + const { getMatchingSpace } = useGetMatchingSpace() const action = ref() const resources = ref([]) + const sourceMode = ref() + const sourceSpaces = ref>({}) + + const currentMode = computed(() => + unref(configStore.isInVault) ? ClipboardMode.Vault : ClipboardMode.Default + ) + + const toClipboardSnapshot = (resource: Resource): ClipboardResourceSnapshot => ({ + id: resource.id, + fileId: resource.fileId, + extension: resource.extension, + isFolder: resource.isFolder, + name: resource.name, + mimeType: resource.mimeType, + parentFolderId: resource.parentFolderId, + path: resource.path, + remoteItemId: resource.remoteItemId, + remoteItemPath: resource.remoteItemPath, + shareTypes: resource.shareTypes, + spaceId: resource.spaceId, + storageId: resource.storageId, + type: resource.type, + webDavPath: resource.webDavPath + }) + + const getClipboardSourceSpaceKey = (resource: Pick) => { + return resource.storageId || resource.spaceId + } + + const buildSourceSpaces = (spaces: SpaceResource[] = []) => + spaces.reduce>((acc, space) => { + const key = space && getClipboardSourceSpaceKey(space) + if (key) + acc[key] = { + driveType: space.driveType, + id: space.id, + storageId: space.storageId, + webDavPath: space.webDavPath + } + return acc + }, {}) + + const removePersistedClipboard = () => { + try { + sessionStorage.removeItem(clipboardStorageKey) + } catch (e) { + console.log('error removing session item: ', e) + } + } + + const persistClipboard = () => { + if (!action.value || resources.value.length === 0) { + removePersistedClipboard() + return + } + + const payload: PersistedClipboardPayload = { + action: action.value, + resources: resources.value.map(toClipboardSnapshot), + sourceMode: sourceMode.value, + sourceSpaces: sourceSpaces.value + } + + try { + sessionStorage.setItem(clipboardStorageKey, JSON.stringify(payload)) + } catch (e) { + console.log('error setting session item: ', e) + } + } + + const hydrateClipboard = () => { + try { + const stored = sessionStorage.getItem(clipboardStorageKey) + if (!stored) return + const payload = JSON.parse(stored) as PersistedClipboardPayload + if (!payload.action || !Array.isArray(payload.resources)) { + removePersistedClipboard() + return + } + action.value = payload.action + resources.value = payload.resources as Resource[] + sourceMode.value = payload.sourceMode + sourceSpaces.value = payload.sourceSpaces || {} + } catch { + removePersistedClipboard() + } + } const copyResources = (r: Resource[]) => { - if (!r[0].canDownload()) { + if (!r.length || !r[0].canDownload?.()) { return } action.value = ClipboardActions.Copy resources.value = r + sourceMode.value = unref(currentMode) + sourceSpaces.value = buildSourceSpaces(r.map(getMatchingSpace)) + persistClipboard() showMessage({ title: $gettext('Copied to clipboard!'), status: 'success' }) } const cutResources = (r: Resource[]) => { - if (!r[0].canDownload()) { + if (!r.length || !r[0].canDownload?.()) { return } action.value = ClipboardActions.Cut resources.value = r + sourceMode.value = unref(currentMode) + sourceSpaces.value = buildSourceSpaces(r.map(getMatchingSpace)) + persistClipboard() showMessage({ title: $gettext('Cut to clipboard!'), status: 'success' }) } @@ -37,11 +169,19 @@ export const useClipboardStore = defineStore('clipboard', () => { const clearClipboard = () => { action.value = undefined resources.value = [] + sourceMode.value = undefined + sourceSpaces.value = {} + removePersistedClipboard() } + hydrateClipboard() + return { action, resources, + sourceMode, + sourceSpaces, + getClipboardSourceSpaceKey, copyResources, cutResources, diff --git a/packages/web-pkg/src/composables/piniaStores/config/config.ts b/packages/web-pkg/src/composables/piniaStores/config/config.ts index 8ceee794a76..733c3f4089e 100644 --- a/packages/web-pkg/src/composables/piniaStores/config/config.ts +++ b/packages/web-pkg/src/composables/piniaStores/config/config.ts @@ -58,6 +58,8 @@ export const useConfigStore = defineStore('config', () => { const maintenanceMode = ref(false) + const isInVault = ref(false) + const serverUrl = computed(() => urlJoin(unref(server) || window.location.origin, { trailingSlash: true }) ) @@ -100,6 +102,10 @@ export const useConfigStore = defineStore('config', () => { maintenanceMode.value = value } + const setIsInVault = (value: boolean) => { + isInVault.value = value + } + return { options, oAuth2, @@ -115,6 +121,8 @@ export const useConfigStore = defineStore('config', () => { styles, serverUrl, maintenanceMode, + setIsInVault, + isInVault, loadConfig, setMaintenanceMode } diff --git a/packages/web-pkg/src/composables/piniaStores/extensionRegistry/extensionRegistry.ts b/packages/web-pkg/src/composables/piniaStores/extensionRegistry/extensionRegistry.ts index 72c7939215c..93ca4194f0f 100644 --- a/packages/web-pkg/src/composables/piniaStores/extensionRegistry/extensionRegistry.ts +++ b/packages/web-pkg/src/composables/piniaStores/extensionRegistry/extensionRegistry.ts @@ -1,12 +1,64 @@ import { defineStore } from 'pinia' import { ref, Ref, unref } from 'vue' import { useConfigStore } from '../config' -import { Extension, ExtensionPoint, ExtensionType } from './types' +import { Extension, ExtensionPoint, ExtensionType, SidebarNavExtension } from './types' + +const withScopePrefix = (routePath: string, isVaultScope: boolean) => { + const normalizedPath = routePath.replace(/^\/vault(?=\/|$)/, '') + const basePath = normalizedPath.startsWith('/') ? normalizedPath : `/${normalizedPath}` + + if (!isVaultScope) { + return basePath + } + + return basePath === '/' ? '/vault' : `/vault${basePath}` +} + +const mapNavRoute = (navRoute: SidebarNavExtension['navItem']['route'], isVaultScope: boolean) => { + if (typeof navRoute === 'string') { + return withScopePrefix(navRoute, isVaultScope) + } + + if (!navRoute || typeof navRoute !== 'object' || !('path' in navRoute)) { + return navRoute + } + + if (typeof navRoute.path !== 'string') { + return navRoute + } + + return { + ...navRoute, + path: withScopePrefix(navRoute.path, isVaultScope) + } +} export const useExtensionRegistry = defineStore('extensionRegistry', () => { const configStore = useConfigStore() const extensions = ref[]>([]) + const rebuild = ({ route }) => { + const isVaultScope = unref(route).params?.scope === 'vault' + + extensions.value = unref(extensions).map((extension) => + ref( + unref(extension).map((ext) => { + if (ext.type !== 'sidebarNav') { + return ext + } + + const sidebarExtension = ext as SidebarNavExtension + return { + ...sidebarExtension, + navItem: { + ...sidebarExtension.navItem, + route: mapNavRoute(sidebarExtension.navItem.route, isVaultScope) + } + } + }) + ) + ) + } const registerExtensions = (e: Ref) => { extensions.value.push(e) @@ -67,7 +119,8 @@ export const useExtensionRegistry = defineStore('extensionRegistry', () => { extensionPoints, registerExtensionPoints, unregisterExtensionPoints, - getExtensionPoints + getExtensionPoints, + rebuild } }) diff --git a/packages/web-pkg/src/composables/piniaStores/spaces.ts b/packages/web-pkg/src/composables/piniaStores/spaces.ts index a4210960129..3110ad39fb1 100644 --- a/packages/web-pkg/src/composables/piniaStores/spaces.ts +++ b/packages/web-pkg/src/composables/piniaStores/spaces.ts @@ -194,7 +194,13 @@ export const useSpacesStore = defineStore('spaces', () => { } } - const loadSpaces = async ({ graphClient }: { graphClient: Graph }) => { + const loadSpaces = async ({ + graphClient, + isInVault + }: { + graphClient: Graph + isInVault: boolean + }) => { spacesLoading.value = true try { /** @@ -254,9 +260,11 @@ export const useSpacesStore = defineStore('spaces', () => { const reloadProjectSpaces = async ({ graphClient, + isInVault, signal }: { graphClient: Graph + isInVault: boolean signal?: AbortSignal }) => { const projectSpaces = await getSpacesByType({ diff --git a/packages/web-pkg/src/composables/router/useActiveApp.ts b/packages/web-pkg/src/composables/router/useActiveApp.ts index 84c378ba035..1f485b2a2db 100644 --- a/packages/web-pkg/src/composables/router/useActiveApp.ts +++ b/packages/web-pkg/src/composables/router/useActiveApp.ts @@ -3,7 +3,8 @@ import { useRoute } from './useRoute' import { RouteLocationNormalizedLoaded } from 'vue-router' export const activeApp = (route: RouteLocationNormalizedLoaded): string => { - return route.path.split('/')[1] + const removeVaultPrefix = route.path.replace(/^\/vault/, '') + return removeVaultPrefix.split('/')[1] } export const useActiveApp = (): ComputedRef => { diff --git a/packages/web-pkg/src/composables/search/useSearch.ts b/packages/web-pkg/src/composables/search/useSearch.ts index dcf2ee0b6d2..3bf99f8ae95 100644 --- a/packages/web-pkg/src/composables/search/useSearch.ts +++ b/packages/web-pkg/src/composables/search/useSearch.ts @@ -78,7 +78,8 @@ export const useSearch = () => { lastModified, mediaType, scope, - useScope + useScope, + isVault }: { term: string isTitleOnlySearch?: boolean @@ -87,6 +88,7 @@ export const useSearch = () => { mediaType?: string scope?: string useScope?: boolean + isVault?: boolean }) => { const query: string[] = [] @@ -120,6 +122,11 @@ export const useSearch = () => { const mediatypes = mediaType.split('+').map((t) => `"${t}"`) query.push(`mediatype:(${mediatypes.join(' OR ')})`) } + + if (isVault) { + query.push('vault:true') + } + return query .sort((a, b) => Number(a.startsWith('scope:')) - Number(b.startsWith('scope:'))) .join(' AND ') diff --git a/packages/web-pkg/src/composables/spaces/useCreateSpace.ts b/packages/web-pkg/src/composables/spaces/useCreateSpace.ts index 19b59fca774..4d227401052 100644 --- a/packages/web-pkg/src/composables/spaces/useCreateSpace.ts +++ b/packages/web-pkg/src/composables/spaces/useCreateSpace.ts @@ -7,9 +7,9 @@ export const useCreateSpace = () => { const resourcesStore = useResourcesStore() const sharesStore = useSharesStore() - const createSpace = (name: string) => { + const createSpace = (name: string, driveType: string = 'project') => { const { graphAuthenticated } = clientService - return graphAuthenticated.drives.createDrive({ name }, sharesStore.graphRoles, { + return graphAuthenticated.drives.createDrive({ name, driveType }, sharesStore.graphRoles, { params: { template: 'default' } }) } diff --git a/packages/web-pkg/src/composables/webWorkers/index.ts b/packages/web-pkg/src/composables/webWorkers/index.ts index 822b352633d..239a0009c81 100644 --- a/packages/web-pkg/src/composables/webWorkers/index.ts +++ b/packages/web-pkg/src/composables/webWorkers/index.ts @@ -1,4 +1,5 @@ export * from './deleteWorker' +export * from './mfaExpiryWorker' export * from './pasteWorker' export * from './restoreWorker' export * from './tokenTimerWorker' diff --git a/packages/web-pkg/src/composables/webWorkers/mfaExpiryWorker/index.ts b/packages/web-pkg/src/composables/webWorkers/mfaExpiryWorker/index.ts new file mode 100644 index 00000000000..1b4c09c382f --- /dev/null +++ b/packages/web-pkg/src/composables/webWorkers/mfaExpiryWorker/index.ts @@ -0,0 +1 @@ +export * from './useMfaExpiryWorker' diff --git a/packages/web-pkg/src/composables/webWorkers/mfaExpiryWorker/useMfaExpiryWorker.ts b/packages/web-pkg/src/composables/webWorkers/mfaExpiryWorker/useMfaExpiryWorker.ts new file mode 100644 index 00000000000..06ac21788c0 --- /dev/null +++ b/packages/web-pkg/src/composables/webWorkers/mfaExpiryWorker/useMfaExpiryWorker.ts @@ -0,0 +1,47 @@ +import { ref, unref } from 'vue' +import { WebWorker, useWebWorkersStore } from '../../piniaStores/webWorkers' +import MfaWorker from './worker?worker' + +export type MfaExpiryWorkerTopic = 'set' | 'reset' + +const MFA_WARNING_THRESHOLD_SECONDS = 300 // 5 minutes before expiry + +export const useMfaExpiryWorker = ({ onExpiring }: { onExpiring: () => void }) => { + const { createWorker } = useWebWorkersStore() + + const worker = ref() + + const startWorker = () => { + worker.value = createWorker(MfaWorker as unknown as string) + + unref(unref(worker).worker).onmessage = () => { + onExpiring() + } + } + + const setMfaTimer = ({ expiresAt }: { expiresAt: number }) => { + if (!unref(worker)) { + console.error('mfa expiry worker is not running') + return + } + + unref(worker).post( + JSON.stringify({ + topic: 'set', + expiresAt, + warningThreshold: MFA_WARNING_THRESHOLD_SECONDS + }) + ) + } + + const resetMfaTimer = () => { + if (!unref(worker)) { + console.error('mfa expiry worker is not running') + return + } + + unref(worker).post(JSON.stringify({ topic: 'reset' })) + } + + return { startWorker, setMfaTimer, resetMfaTimer } +} diff --git a/packages/web-pkg/src/composables/webWorkers/mfaExpiryWorker/worker.ts b/packages/web-pkg/src/composables/webWorkers/mfaExpiryWorker/worker.ts new file mode 100644 index 00000000000..414c5ef3441 --- /dev/null +++ b/packages/web-pkg/src/composables/webWorkers/mfaExpiryWorker/worker.ts @@ -0,0 +1,35 @@ +import { MfaExpiryWorkerTopic } from './useMfaExpiryWorker' + +type Message = { + topic: MfaExpiryWorkerTopic + expiresAt: number + warningThreshold: number +} + +let timerId: ReturnType + +const resetTimer = () => { + clearTimeout(timerId) + timerId = undefined +} + +self.onmessage = (e: MessageEvent) => { + const { topic, expiresAt, warningThreshold } = JSON.parse(e.data) as Message + + if (topic === 'reset') { + resetTimer() + return + } + + const now = Math.floor(Date.now() / 1000) + let timerInSeconds = expiresAt - warningThreshold - now + if (timerInSeconds <= 0) { + timerInSeconds = 1 + } + + resetTimer() + + timerId = setTimeout(() => { + postMessage(true) + }, timerInSeconds * 1000) +} diff --git a/packages/web-pkg/src/services/client/client.ts b/packages/web-pkg/src/services/client/client.ts index 0ce9300fbe0..84f46e7c354 100644 --- a/packages/web-pkg/src/services/client/client.ts +++ b/packages/web-pkg/src/services/client/client.ts @@ -3,7 +3,7 @@ import { graph, ocs, webdav } from '@ownclouders/web-client' import { Graph } from '@ownclouders/web-client/graph' import { OCS } from '@ownclouders/web-client/ocs' import { AuthParameters } from './auth' -import axios, { AxiosResponse } from 'axios' +import axios, { AxiosInstance, AxiosResponse } from 'axios' import { v4 as uuidV4 } from 'uuid' import { WebDAV } from '@ownclouders/web-client/webdav' import { Language } from 'vue3-gettext' @@ -38,10 +38,12 @@ export class ClientService { private httpUnAuthenticatedClient: HttpClient private graphClient: Graph + private graphAxiosClient: AxiosInstance private ocsClient: OCS private webDavClient: WebDAV public initiatorId = uuidV4() + public lastSuccessfulRequestTime: number | null = null private staticHeaders: Record = { 'Initiator-ID': this.initiatorId, @@ -53,7 +55,7 @@ export class ClientService { this.language = options.language this.authStore = options.authStore - this.initGraphClient() + this.initGraphClient(this.configStore.isInVault) this.initOcsClient() this.initWebDavClient() @@ -86,6 +88,10 @@ export class ClientService { return this.graphClient } + public reinitializeGraphClient(isInVault: boolean) { + this.initGraphClient(isInVault) + } + public get sseAuthenticated(): EventSource { return sse( this.configStore.serverUrl, @@ -105,19 +111,24 @@ export class ClientService { return this.language.current } - private initGraphClient() { - const axiosClient = axios.create({ headers: this.staticHeaders }) - axiosClient.interceptors.request.use((config) => { - Object.assign(config.headers, this.getDynamicHeaders()) - return config - }) + private initGraphClient(isInVault: boolean) { + if (!this.graphAxiosClient) { + const axiosClient = axios.create({ headers: this.staticHeaders }) + axiosClient.interceptors.request.use((config) => { + Object.assign(config.headers, this.getDynamicHeaders()) + return config + }) + axiosClient.interceptors.response.use( + this.#handleAxiosResponse.bind(this), + this.#handleAxiosError.bind(this) + ) + this.graphAxiosClient = axiosClient + } - axiosClient.interceptors.response.use( - this.#handleAxiosResponse.bind(this), - this.#handleAxiosError.bind(this) + this.graphClient = graph( + isInVault ? `${this.configStore.serverUrl}vault` : this.configStore.serverUrl, + this.graphAxiosClient ) - - this.graphClient = graph(this.configStore.serverUrl, axiosClient) } private initOcsClient() { @@ -178,6 +189,8 @@ export class ClientService { this.configStore.setMaintenanceMode(false) } + this.lastSuccessfulRequestTime = Math.floor(Date.now() / 1000) + return response } diff --git a/packages/web-pkg/tests/unit/composables/piniaStores/extensionRegistry/extensionRegistry.spec.ts b/packages/web-pkg/tests/unit/composables/piniaStores/extensionRegistry/extensionRegistry.spec.ts index 8b620f8e176..c19970b1719 100644 --- a/packages/web-pkg/tests/unit/composables/piniaStores/extensionRegistry/extensionRegistry.spec.ts +++ b/packages/web-pkg/tests/unit/composables/piniaStores/extensionRegistry/extensionRegistry.spec.ts @@ -3,12 +3,13 @@ import { CustomComponentExtension, Extension, ExtensionPoint, + SidebarNavExtension, SidebarPanelExtension, useExtensionRegistry } from '../../../../../src' import { getComposableWrapper } from '@ownclouders/web-test-helpers' import { createPinia, setActivePinia } from 'pinia' -import { computed, unref } from 'vue' +import { computed, ref, unref } from 'vue' import { mock } from 'vitest-mock-extended' describe('useExtensionRegistry', () => { @@ -250,6 +251,52 @@ describe('useExtensionRegistry', () => { }) }) }) + + describe('rebuild', () => { + it('returns a non-vault route when scope is not vault', () => { + const sidebarExtension = mock({ + id: 'sidebar-non-vault', + type: 'sidebarNav', + navItem: { + name: 'Files', + route: 'files' + } + }) + + getWrapper({ + setup: (instance) => { + instance.registerExtensions(ref([sidebarExtension])) + + instance.rebuild({ route: ref({ params: { scope: 'projects' } }) }) + + const rebuiltExtension = unref(unref(instance.extensions)[0])[0] as SidebarNavExtension + expect(rebuiltExtension.navItem.route).toBe('/files') + } + }) + }) + + it('returns a vault-prefixed route when scope is vault', () => { + const sidebarExtension = mock({ + id: 'sidebar-vault', + type: 'sidebarNav', + navItem: { + name: 'Files', + route: 'files' + } + }) + + getWrapper({ + setup: (instance) => { + instance.registerExtensions(ref([sidebarExtension])) + + instance.rebuild({ route: ref({ params: { scope: 'vault' } }) }) + + const rebuiltExtension = unref(unref(instance.extensions)[0])[0] as SidebarNavExtension + expect(rebuiltExtension.navItem.route).toBe('/vault/files') + } + }) + }) + }) }) function getWrapper({ diff --git a/packages/web-pkg/tests/unit/composables/piniaStores/spaces.spec.ts b/packages/web-pkg/tests/unit/composables/piniaStores/spaces.spec.ts index 80c6e1c9431..761397f8679 100644 --- a/packages/web-pkg/tests/unit/composables/piniaStores/spaces.spec.ts +++ b/packages/web-pkg/tests/unit/composables/piniaStores/spaces.spec.ts @@ -179,7 +179,7 @@ describe('spaces', () => { const spaces = [mock({ id: '1' })] const graphClient = mockDeep() graphClient.drives.listMyDrives.mockResolvedValue(spaces) - await instance.loadSpaces({ graphClient }) + await instance.loadSpaces({ graphClient, isInVault: false }) expect(graphClient.drives.listMyDrives).toHaveBeenCalledTimes(2) expect(graphClient.drives.listMyDrives).toHaveBeenNthCalledWith( @@ -226,7 +226,7 @@ describe('spaces', () => { ] const graphClient = mockDeep() graphClient.drives.listMyDrives.mockResolvedValue(spaces) - await instance.loadSpaces({ graphClient }) + await instance.loadSpaces({ graphClient, isInVault: false }) expect(graphClient.drives.listMyDrives).toHaveBeenCalledTimes(2) expect(graphClient.drives.listMyDrives).toHaveBeenNthCalledWith( @@ -290,7 +290,7 @@ describe('spaces', () => { const spaces = [mock({ id: '1' })] const graphClient = mockDeep() 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 }) })