diff --git a/changelog/unreleased/enhancement-add-pasword-protected-folders-app.md b/changelog/unreleased/enhancement-add-pasword-protected-folders-app.md new file mode 100644 index 00000000000..bbf59ce38bd --- /dev/null +++ b/changelog/unreleased/enhancement-add-pasword-protected-folders-app.md @@ -0,0 +1,6 @@ +Enhancement: Add password protected folders app + +We've added a new application called "Password protected folders". This application allows users to create new folders that are accessible only by entering a password. + +https://github.com/owncloud/web/pull/12137 +https://github.com/owncloud/web/issues/12039 diff --git a/dev/docker/ocis.web.config.json b/dev/docker/ocis.web.config.json index eb43326cc22..492e81921ad 100644 --- a/dev/docker/ocis.web.config.json +++ b/dev/docker/ocis.web.config.json @@ -22,6 +22,7 @@ "epub-reader", "app-store", "activities", - "preview" + "preview", + "password-protected-folders" ] } diff --git a/packages/web-app-password-protected-folders/README.md b/packages/web-app-password-protected-folders/README.md new file mode 100644 index 00000000000..52ce620c023 --- /dev/null +++ b/packages/web-app-password-protected-folders/README.md @@ -0,0 +1,20 @@ +# web-app-password-protected-folders + +This web extension enhances the oCIS platform by allowing users to create password-protected folders. It provides an additional layer of security for sensitive or confidential information stored within oCIS. + +## Features + +- 🔒 Create password-protected folders within oCIS +- 🔑 Set unique passwords for each protected folder +- 🎨 Seamless integration with the oCIS user interface + +## Usage + +Once the extension is installed, users can create password-protected folders by following these steps: + +1. Log in to your oCIS instance. +2. Navigate to the folder where you want to create a password-protected subfolder. +3. Click the "New" button and select "Password Protected Folder." +4. Enter a name for the folder and set a unique password. +5. Click "Create" to create the password-protected folder. +6. To access the protected folder, users will be prompted to enter the corresponding password. diff --git a/packages/web-app-password-protected-folders/l10n/.tx/config b/packages/web-app-password-protected-folders/l10n/.tx/config new file mode 100644 index 00000000000..d7f17ed75cb --- /dev/null +++ b/packages/web-app-password-protected-folders/l10n/.tx/config @@ -0,0 +1,10 @@ +[main] +host = https://www.transifex.com + +[o:owncloud-org:p:owncloud-web:r:password-protected-folders] +file_filter = locale//app.po +minimum_perc = 0 +resource_name = web-password-protected-folders +source_file = template.pot +source_lang = en +type = PO diff --git a/packages/web-app-password-protected-folders/l10n/translations.json b/packages/web-app-password-protected-folders/l10n/translations.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/packages/web-app-password-protected-folders/l10n/translations.json @@ -0,0 +1 @@ +{} diff --git a/packages/web-app-password-protected-folders/package.json b/packages/web-app-password-protected-folders/package.json new file mode 100644 index 00000000000..8d41f9dfe3a --- /dev/null +++ b/packages/web-app-password-protected-folders/package.json @@ -0,0 +1,24 @@ +{ + "name": "web-app-password-protected-folders", + "version": "0.4.0", + "private": true, + "description": "ownCloud Web password protected folders", + "license": "AGPL-3.0", + "type": "module", + "scripts": { + "build": "pnpm vite build", + "build:w": "pnpm vite build --watch --mode development", + "check:types": "vue-tsc --noEmit", + "test:unit": "NODE_OPTIONS=--unhandled-rejections=throw vitest" + }, + "devDependencies": { + "@ownclouders/web-client": "workspace:*", + "@ownclouders/web-pkg": "workspace:*", + "@ownclouders/web-test-helpers": "workspace:*", + "@vue/test-utils": "^2.4.6", + "uuid": "^11.0.0", + "vitest-mock-extended": "2.0.2", + "vue": "^3.4.21", + "vue3-gettext": "^2.4.0" + } +} diff --git a/packages/web-app-password-protected-folders/public/manifest.json b/packages/web-app-password-protected-folders/public/manifest.json new file mode 100644 index 00000000000..abfebe361e1 --- /dev/null +++ b/packages/web-app-password-protected-folders/public/manifest.json @@ -0,0 +1,3 @@ +{ + "entrypoint": "password-protected-folders.js" +} diff --git a/packages/web-app-password-protected-folders/src/components/CreateFolderModal.vue b/packages/web-app-password-protected-folders/src/components/CreateFolderModal.vue new file mode 100644 index 00000000000..34fbffd85fe --- /dev/null +++ b/packages/web-app-password-protected-folders/src/components/CreateFolderModal.vue @@ -0,0 +1,70 @@ + + + diff --git a/packages/web-app-password-protected-folders/src/composables/useCreateFileHandler.ts b/packages/web-app-password-protected-folders/src/composables/useCreateFileHandler.ts new file mode 100644 index 00000000000..7b9855d8867 --- /dev/null +++ b/packages/web-app-password-protected-folders/src/composables/useCreateFileHandler.ts @@ -0,0 +1,45 @@ +import { Resource, SpaceResource, urlJoin } from '@ownclouders/web-client' +import { SharingLinkType } from '@ownclouders/web-client/graph/generated' +import { useClientService, useResourcesStore, useSharesStore } from '@ownclouders/web-pkg' +import { unref } from 'vue' + +export const useCreateFileHandler = () => { + const clientService = useClientService() + const { upsertResource } = useResourcesStore() + const { addLink } = useSharesStore() + + const createFileHandler = async ({ + fileName, + space, + currentFolder, + password + }: { + fileName: string + space: SpaceResource + currentFolder: Resource + password: string + }) => { + if (fileName === '') { + return + } + + const folderPath = '/.' + fileName + + const folder = await clientService.webdav.createFolder(unref(space), { path: folderPath }) + upsertResource(folder) + + await addLink({ + clientService, + space, + resource: folder, + options: { password, type: SharingLinkType.Edit } + }) + + const path = urlJoin(currentFolder.path, fileName + '.psec') + + const file = await clientService.webdav.putFileContents(unref(space), { path }) + upsertResource(file) + } + + return { createFileHandler } +} diff --git a/packages/web-app-password-protected-folders/src/composables/useCustomHandler.ts b/packages/web-app-password-protected-folders/src/composables/useCustomHandler.ts new file mode 100644 index 00000000000..1c1f94f5a91 --- /dev/null +++ b/packages/web-app-password-protected-folders/src/composables/useCustomHandler.ts @@ -0,0 +1,18 @@ +import { useModals } from '@ownclouders/web-pkg' +import { useGettext } from 'vue3-gettext' +import CreateFolderModal from '../components/CreateFolderModal.vue' + +export const useCustomHandler = () => { + const { dispatchModal } = useModals() + const { $gettext } = useGettext() + + const customHandler = () => { + dispatchModal({ + title: $gettext('Create a new password protected folder'), + customComponent: CreateFolderModal, + confirmText: $gettext('Create') + }) + } + + return { customHandler } +} diff --git a/packages/web-app-password-protected-folders/src/composables/useExtensions.ts b/packages/web-app-password-protected-folders/src/composables/useExtensions.ts new file mode 100644 index 00000000000..c8a3991e5bd --- /dev/null +++ b/packages/web-app-password-protected-folders/src/composables/useExtensions.ts @@ -0,0 +1,16 @@ +import { ActionExtension } from '@ownclouders/web-pkg' +import { computed, unref } from 'vue' +import { useOpenFolderAction } from './useOpenFolderAction' + +export const useExtensions = () => { + const action = useOpenFolderAction() + + const actionExtension = computed(() => ({ + id: 'com.github.owncloud.web-extensions.password-protected-folders', + type: 'action', + extensionPointIds: ['global.files.context-actions', 'global.files.default-actions'], + action: unref(action) + })) + + return computed(() => [unref(actionExtension)]) +} diff --git a/packages/web-app-password-protected-folders/src/composables/useOpenFolderAction.ts b/packages/web-app-password-protected-folders/src/composables/useOpenFolderAction.ts new file mode 100644 index 00000000000..bb154ea2c3b --- /dev/null +++ b/packages/web-app-password-protected-folders/src/composables/useOpenFolderAction.ts @@ -0,0 +1,29 @@ +import { FileAction } from '@ownclouders/web-pkg' +import { computed } from 'vue' +import { useGettext } from 'vue3-gettext' + +export const useOpenFolderAction = () => { + const { $gettext } = useGettext() + + const action = computed(() => ({ + name: 'open-password-protected-folder', + icon: 'external-link', + handler: () => { + // TODO: add handler + console.warn('NOT IMPLEMENTED') + }, + label: () => $gettext('Open folder'), + isDisabled: () => false, + isVisible: ({ resources }) => { + if (resources.length !== 1) { + return false + } + + return resources[0].extension === 'psec' + }, + componentType: 'button', + class: 'oc-files-actions-open-password-protected-folder' + })) + + return action +} diff --git a/packages/web-app-password-protected-folders/src/index.ts b/packages/web-app-password-protected-folders/src/index.ts new file mode 100644 index 00000000000..2d66bec5edc --- /dev/null +++ b/packages/web-app-password-protected-folders/src/index.ts @@ -0,0 +1,29 @@ +import { useGettext } from 'vue3-gettext' +import translations from '../l10n/translations.json' +import { defineWebApplication } from '@ownclouders/web-pkg' +import { useExtensions } from './composables/useExtensions' +import { useCustomHandler } from './composables/useCustomHandler' + +export default defineWebApplication({ + setup() { + const { $gettext } = useGettext() + const extensions = useExtensions() + const { customHandler } = useCustomHandler() + + return { + appInfo: { + name: $gettext('Password Protected Folders'), + id: 'password-protected-folders', + extensions: [ + { + newFileMenu: { menuTitle: () => $gettext('Password Protected Folder') }, + extension: 'psec', + customHandler + } + ] + }, + translations, + extensions + } + } +}) diff --git a/packages/web-app-password-protected-folders/tests/unit/components/CreateFolderModal.spec.ts b/packages/web-app-password-protected-folders/tests/unit/components/CreateFolderModal.spec.ts new file mode 100644 index 00000000000..1fb846da1f5 --- /dev/null +++ b/packages/web-app-password-protected-folders/tests/unit/components/CreateFolderModal.spec.ts @@ -0,0 +1,69 @@ +import { defaultComponentMocks, defaultPlugins, shallowMount } from '@ownclouders/web-test-helpers' +import CreateFolderModal from '../../../src/components/CreateFolderModal.vue' +import { useCreateFileHandler } from '../../../src/composables/useCreateFileHandler' +import { mock } from 'vitest-mock-extended' +import { Resource, SpaceResource } from '@ownclouders/web-client' +import { VueWrapper } from '@vue/test-utils' + +vi.mock('../../../src/composables/useCreateFileHandler', () => ({ + useCreateFileHandler: vi.fn().mockReturnValue({ createFileHandler: vi.fn() }) +})) + +const currentFolder = mock() +const currentSpace = mock() + +const SELECTORS = Object.freeze({ + inputFolderName: '#input-folder-name', + inputFolderPassword: '#input-folder-password' +}) + +describe('CreateFolderModal', () => { + it('should call "createFileHandler" when form is valid', () => { + const { wrapper } = getWrapper() + + const folderNameInput = wrapper.findComponent(SELECTORS.inputFolderName) as VueWrapper + const passwordInput = wrapper.findComponent(SELECTORS.inputFolderPassword) as VueWrapper + + folderNameInput.vm.$emit('update:modelValue', 'name') + passwordInput.vm.$emit('update:modelValue', 'password') + + wrapper.vm.onConfirm() + + expect(useCreateFileHandler().createFileHandler).toHaveBeenCalledWith({ + fileName: 'name', + password: 'password', + space: currentSpace, + currentFolder + }) + }) + + it('should not call "createFileHandler" when form is invalid', () => { + const { wrapper } = getWrapper() + + const folderNameInput = wrapper.findComponent(SELECTORS.inputFolderName) as VueWrapper + folderNameInput.vm.$emit('update:modelValue', 'name') + + expect(wrapper.vm.onConfirm()).rejects.toThrow() + expect(useCreateFileHandler().createFileHandler).not.toHaveBeenCalled() + }) +}) + +function getWrapper() { + const mocks = defaultComponentMocks() + + return { + wrapper: shallowMount(CreateFolderModal, { + global: { + plugins: defaultPlugins({ + piniaOptions: { + resourcesStore: { currentFolder }, + spacesState: { currentSpace } + } + }), + mocks, + provide: mocks + } + }), + mocks + } +} diff --git a/packages/web-app-password-protected-folders/tests/unit/composables/useCreateFileHandler.spec.ts b/packages/web-app-password-protected-folders/tests/unit/composables/useCreateFileHandler.spec.ts new file mode 100644 index 00000000000..70ad4b0150b --- /dev/null +++ b/packages/web-app-password-protected-folders/tests/unit/composables/useCreateFileHandler.spec.ts @@ -0,0 +1,65 @@ +import { defaultComponentMocks, getComposableWrapper } from '@ownclouders/web-test-helpers' +import { useCreateFileHandler } from '../../../src/composables/useCreateFileHandler' +import { mock } from 'vitest-mock-extended' +import { Resource, SpaceResource } from '@ownclouders/web-client' +import { useSharesStore } from '@ownclouders/web-pkg' +import { SharingLinkType } from '@ownclouders/web-client/graph/generated' + +const space = mock() +const currentFolder = mock({ path: '/current/folder' }) +const createdFolder = mock() + +describe('createFileHandler', () => { + it('should create all necessary resources and links', () => { + getWrapper({ + async setup(instance, mocks) { + const { addLink } = useSharesStore() + + await instance.createFileHandler({ + fileName: 'protected', + space, + currentFolder, + password: 'Pass$123' + }) + + expect(mocks.$clientService.webdav.createFolder).toHaveBeenCalledWith(space, { + path: '/.protected' + }) + expect(addLink).toHaveBeenCalledWith({ + clientService: mocks.$clientService, + space, + resource: createdFolder, + options: { password: 'Pass$123', type: SharingLinkType.Edit } + }) + expect(mocks.$clientService.webdav.putFileContents).toHaveBeenCalledWith(space, { + path: '/current/folder/protected.psec' + }) + } + }) + }) +}) + +function getWrapper({ + setup +}: { + setup: ( + instance: ReturnType, + mocks: ReturnType + ) => void +}) { + const mocks = defaultComponentMocks() + mocks.$clientService.webdav.createFolder.mockResolvedValue(createdFolder) + + return { + wrapper: getComposableWrapper( + () => { + const instance = useCreateFileHandler() + setup(instance, mocks) + }, + { + mocks, + provide: mocks + } + ) + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index efed6d5ae53..9ebb6ed7740 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -773,6 +773,33 @@ importers: specifier: 3.24.1 version: 3.24.1 + packages/web-app-password-protected-folders: + devDependencies: + '@ownclouders/web-client': + specifier: workspace:* + version: link:../web-client + '@ownclouders/web-pkg': + specifier: workspace:* + version: link:../web-pkg + '@ownclouders/web-test-helpers': + specifier: workspace:* + version: link:../web-test-helpers + '@vue/test-utils': + specifier: ^2.4.6 + version: 2.4.6 + uuid: + specifier: ^11.0.0 + version: 11.0.5 + vitest-mock-extended: + specifier: 2.0.2 + version: 2.0.2(typescript@5.6.3)(vitest@2.1.6(@types/node@22.9.0)(happy-dom@16.7.2)(jiti@1.21.0)(jsdom@26.0.0)(sass@1.81.0)(terser@5.36.0)(yaml@2.6.0)) + vue: + specifier: ^3.4.21 + version: 3.5.13(typescript@5.6.3) + vue3-gettext: + specifier: ^2.4.0 + version: 2.4.0(patch_hash=x32qkm4z6srz5xuveescagpdyu)(@vue/compiler-sfc@3.5.13)(vue@3.5.13(typescript@5.6.3)) + packages/web-app-pdf-viewer: dependencies: '@ownclouders/web-pkg': @@ -10172,7 +10199,7 @@ snapshots: '@cucumber/ci-environment': 9.1.0 '@cucumber/cucumber-expressions': 16.1.1 '@cucumber/gherkin': 26.0.3 - '@cucumber/gherkin-streams': 5.0.1(@cucumber/gherkin@26.0.3)(@cucumber/message-streams@4.0.1(@cucumber/messages@21.0.1))(@cucumber/messages@21.0.1) + '@cucumber/gherkin-streams': 5.0.1(@cucumber/gherkin@26.0.3)(@cucumber/message-streams@4.0.1(@cucumber/messages@27.0.2))(@cucumber/messages@21.0.1) '@cucumber/gherkin-utils': 8.0.2 '@cucumber/html-formatter': 20.2.1(@cucumber/messages@21.0.1) '@cucumber/message-streams': 4.0.1(@cucumber/messages@21.0.1) @@ -10210,7 +10237,7 @@ snapshots: yaml: 2.6.0 yup: 0.32.11 - '@cucumber/gherkin-streams@5.0.1(@cucumber/gherkin@26.0.3)(@cucumber/message-streams@4.0.1(@cucumber/messages@21.0.1))(@cucumber/messages@21.0.1)': + '@cucumber/gherkin-streams@5.0.1(@cucumber/gherkin@26.0.3)(@cucumber/message-streams@4.0.1(@cucumber/messages@27.0.2))(@cucumber/messages@21.0.1)': dependencies: '@cucumber/gherkin': 26.0.3 '@cucumber/message-streams': 4.0.1(@cucumber/messages@21.0.1)