diff --git a/changelog/unreleased/enhancement-delete-public-links-passwords-when-password-is-enforced b/changelog/unreleased/enhancement-delete-public-links-passwords-when-password-is-enforced new file mode 100644 index 00000000000..9df9c4ef3c9 --- /dev/null +++ b/changelog/unreleased/enhancement-delete-public-links-passwords-when-password-is-enforced @@ -0,0 +1,8 @@ +Enhancement: Add permission to delete link passwords when password is enforced + +We've enabled to ability to allow delete passwords on public links, even if the password is enforced. +Therefore, the user needs respective permission, granted by the server. +This feature is only possible on public links that have the viewer role. + +https://github.com/owncloud/web/pull/9857 +https://github.com/owncloud/ocis/issues/7538 diff --git a/packages/web-app-files/src/components/SideBar/Shares/FileLinks.vue b/packages/web-app-files/src/components/SideBar/Shares/FileLinks.vue index 9458ea61b02..9be55dfdcac 100644 --- a/packages/web-app-files/src/components/SideBar/Shares/FileLinks.vue +++ b/packages/web-app-files/src/components/SideBar/Shares/FileLinks.vue @@ -25,6 +25,7 @@ :is-folder-share="resource.isFolder" :is-modifiable="canEditLink(quicklink)" :is-password-enforced="isPasswordEnforcedFor(quicklink)" + :is-password-removable="canDeletePublicLinkPassword(quicklink)" :link="quicklink" @update-link="checkLinkToUpdate" @remove-public-link="deleteLinkConfirmation" @@ -57,6 +58,7 @@ :is-folder-share="resource.isFolder" :is-modifiable="canEditLink(link)" :is-password-enforced="isPasswordEnforcedFor(link)" + :is-password-removable="canDeletePublicLinkPassword(link)" :link="link" @update-link="checkLinkToUpdate" @remove-public-link="deleteLinkConfirmation" @@ -185,6 +187,11 @@ export default defineComponent({ }) ) const canCreatePublicLinks = computed(() => can('create-all', 'PublicLink')) + + const canDeleteReadOnlyPublicLinkPassword = computed(() => + can('delete-all', 'ReadOnlyPublicLinkPassword') + ) + const canCreateLinks = computed(() => { if (unref(resource).isReceivedShare() && !unref(hasResharing)) { return false @@ -225,6 +232,7 @@ export default defineComponent({ directLinks, indirectLinks, canCreatePublicLinks, + canDeleteReadOnlyPublicLinkPassword, configurationManager, passwordPolicyService, canCreateLinks, @@ -389,6 +397,21 @@ export default defineComponent({ ) }, + canDeletePublicLinkPassword(link) { + const isFolder = link.indirect || this.resource.isFolder + const isPasswordEnforced = this.isPasswordEnforcedFor(link) + + if (!isPasswordEnforced) { + return true + } + + const currentRole = LinkShareRoles.getByBitmask(parseInt(link.permissions), isFolder) + + return ( + currentRole.name === linkRoleViewerFolder.name && this.canDeleteReadOnlyPublicLinkPassword + ) + }, + addNewLink() { this.checkLinkToCreate({ link: { @@ -422,7 +445,7 @@ export default defineComponent({ checkLinkToUpdate({ link }) { const params = this.getParamsForLink(link) - if (!link.password && this.isPasswordEnforcedFor(link)) { + if (!link.password && !this.canDeletePublicLinkPassword(link)) { showQuickLinkPasswordModal( { ...this.$language, @@ -626,6 +649,7 @@ export default defineComponent({ #oc-files-sharing-sidebar { border-radius: 5px; } + .link-name-container { background-color: var(--oc-color-input-bg); border: 1px solid var(--oc-color-input-border); diff --git a/packages/web-app-files/src/components/SideBar/Shares/Links/DetailsAndEdit.vue b/packages/web-app-files/src/components/SideBar/Shares/Links/DetailsAndEdit.vue index 53a7a61bde9..fa89da2de6f 100644 --- a/packages/web-app-files/src/components/SideBar/Shares/Links/DetailsAndEdit.vue +++ b/packages/web-app-files/src/components/SideBar/Shares/Links/DetailsAndEdit.vue @@ -228,6 +228,10 @@ export default defineComponent({ type: Boolean, default: false }, + isPasswordRemovable: { + type: Boolean, + default: false + }, link: { type: Object, required: true @@ -323,7 +327,7 @@ export default defineComponent({ method: this.showPasswordModal }) - if (!this.isPasswordEnforced) { + if (this.isPasswordRemovable) { result.push({ id: 'remove-password', title: this.$gettext('Remove password'), @@ -338,7 +342,7 @@ export default defineComponent({ }) } } - if (!this.isPasswordEnforced && !this.link.password && !this.isAliasLink) { + if (!this.link.password && !this.isAliasLink) { result.push({ id: 'add-password', title: this.$gettext('Add password'), @@ -526,6 +530,7 @@ export default defineComponent({ width: 100%; } } + @media (min-width: $oc-breakpoint-medium-default) { .edit-public-link-role-dropdown { width: 400px; @@ -542,6 +547,7 @@ export default defineComponent({ &:first-child { margin-top: 0; } + &:last-child { margin-bottom: 0; } diff --git a/packages/web-client/src/helpers/resource/types.ts b/packages/web-client/src/helpers/resource/types.ts index a9ee0b622b2..54b77b950fb 100644 --- a/packages/web-client/src/helpers/resource/types.ts +++ b/packages/web-client/src/helpers/resource/types.ts @@ -22,6 +22,7 @@ export type AbilitySubjects = | 'Language' | 'Logo' | 'PublicLink' + | 'ReadOnlyPublicLinkPassword' | 'Role' | 'Setting' diff --git a/packages/web-pkg/src/composables/actions/files/useFileActionsCreateQuicklink.ts b/packages/web-pkg/src/composables/actions/files/useFileActionsCreateQuicklink.ts index 794f9dc89b0..3221b94ebcf 100644 --- a/packages/web-pkg/src/composables/actions/files/useFileActionsCreateQuicklink.ts +++ b/packages/web-pkg/src/composables/actions/files/useFileActionsCreateQuicklink.ts @@ -11,19 +11,27 @@ import { useStore } from '../../store' import { useGettext } from 'vue3-gettext' import { Store } from 'vuex' import { FileAction, FileActionOptions } from '../types' +import { usePasswordPolicyService } from '../../passwordPolicyService' -export const useFileActionsCreateQuickLink = ({ store }: { store?: Store } = {}) => { +export const useFileActionsCreateQuickLink = ({ + store +}: { + store?: Store +} = {}) => { store = store || useStore() const router = useRouter() const language = useGettext() const { $gettext } = language const ability = useAbility() const clientService = useClientService() + const passwordPolicyService = usePasswordPolicyService() const handler = async ({ space, resources }: FileActionOptions) => { const [resource] = resources + await copyQuicklink({ clientService, + passwordPolicyService, resource, storageId: space?.id || resource?.fileId || resource?.id, store, diff --git a/packages/web-pkg/src/helpers/share/link.ts b/packages/web-pkg/src/helpers/share/link.ts index b4cdc0cfae5..6ad3e6d1cbd 100644 --- a/packages/web-pkg/src/helpers/share/link.ts +++ b/packages/web-pkg/src/helpers/share/link.ts @@ -3,14 +3,18 @@ import { LinkShareRoles, Share, linkRoleInternalFolder, - linkRoleViewerFolder + linkRoleViewerFolder, + ShareTypes, + buildShare } from '@ownclouders/web-client/src/helpers/share' import { Store } from 'vuex' -import { ClientService } from '../../services' +import { ClientService, PasswordPolicyService } from '../../services' import { useClipboard } from '@vueuse/core' import { Ability } from '@ownclouders/web-client/src/helpers/resource/types' import { Resource } from '@ownclouders/web-client' import { Language } from 'vue3-gettext' +import { unref } from 'vue' +import { showQuickLinkPasswordModal } from '../../quickActions' export interface CreateQuicklink { clientService: ClientService @@ -22,37 +26,40 @@ export interface CreateQuicklink { ability: Ability } -export const copyQuicklink = async (args: CreateQuicklink) => { - const { store, language } = args - const { $gettext } = language +export interface CopyQuickLink extends CreateQuicklink { + passwordPolicyService: PasswordPolicyService +} - // doCopy creates the requested link and copies the url to the clipboard, - // the copy action uses the clipboard // clipboardItem api to work around the webkit limitations. - // - // https://developer.apple.com/forums/thread/691873 - // - // if those apis not available (or like in firefox behind dom.events.asyncClipboard.clipboardItem) - // it has a fallback to the vue-use implementation. - // - // https://webkit.org/blog/10855/ - const doCopy = async () => { +// doCopy creates the requested link and copies the url to the clipboard, +// the copy action uses the clipboard // clipboardItem api to work around the webkit limitations. +// +// https://developer.apple.com/forums/thread/691873 +// +// if those apis not available (or like in firefox behind dom.events.asyncClipboard.clipboardItem) +// it has a fallback to the vue-use implementation. +// +// https://webkit.org/blog/10855/ +const doCopy = async ({ + store, + language, + quickLinkUrl +}: { + store: Store + language: Language + quickLinkUrl: string +}) => { + const { $gettext } = language + try { if (typeof ClipboardItem && navigator?.clipboard?.write) { await navigator.clipboard.write([ new ClipboardItem({ - 'text/plain': createQuicklink(args).then( - (link) => new Blob([link.url], { type: 'text/plain' }) - ) + 'text/plain': new Blob([quickLinkUrl], { type: 'text/plain' }) }) ]) } else { - const link = await createQuicklink(args) const { copy } = useClipboard({ legacy: true }) - await copy(link.url) + await copy(quickLinkUrl) } - } - - try { - await doCopy() await store.dispatch('showMessage', { title: $gettext('The link has been copied to your clipboard.') }) @@ -64,6 +71,40 @@ export const copyQuicklink = async (args: CreateQuicklink) => { }) } } +export const copyQuicklink = async (args: CopyQuickLink) => { + const { store, language, resource, clientService, passwordPolicyService } = args + const { $gettext } = language + + const linkSharesForResource = await clientService.owncloudSdk.shares.getShares(resource.path, { + share_types: ShareTypes?.link?.value?.toString(), + include_tags: false + }) + + const existingQuickLink = linkSharesForResource + .map((share: any) => buildShare(share.shareInfo, null, null)) + .find((share: Share) => share.quicklink === true) + + if (existingQuickLink) { + return doCopy({ store, language, quickLinkUrl: existingQuickLink.url }) + } + + const isPasswordEnforced = + store.getters.capabilities?.files_sharing?.public?.password?.enforced_for?.read_only === true + + if (unref(isPasswordEnforced)) { + return showQuickLinkPasswordModal( + { $gettext, store, passwordPolicyService }, + async (password: string) => { + await store.dispatch('hideModal') + const quickLink = await createQuicklink({ ...args, password }) + return doCopy({ store, language, quickLinkUrl: quickLink.url }) + } + ) + } + + const quickLink = await createQuicklink(args) + return doCopy({ store, language, quickLinkUrl: quickLink.url }) +} export const createQuicklink = async (args: CreateQuicklink): Promise => { const { clientService, resource, store, password, language, ability } = args @@ -87,7 +128,9 @@ export const createQuicklink = async (args: CreateQuicklink): Promise => canContribute, alias ).bitmask(allowResharing) - const params: { [key: string]: unknown } = { + const params: { + [key: string]: unknown + } = { name: $gettext('Link'), permissions: permissions.toString(), quicklink: true diff --git a/packages/web-pkg/src/quickActions.ts b/packages/web-pkg/src/quickActions.ts index 57955a5fad2..702eaf17a78 100644 --- a/packages/web-pkg/src/quickActions.ts +++ b/packages/web-pkg/src/quickActions.ts @@ -74,28 +74,9 @@ export default { store, passwordPolicyService }: QuickLinkContext) => { - const passwordEnforced = - store.getters.capabilities?.files_sharing?.public?.password?.enforced_for?.read_only === - true - - if (passwordEnforced) { - return showQuickLinkPasswordModal( - { store, $gettext: language.$gettext, passwordPolicyService }, - async (password) => { - await copyQuicklink({ - ability, - clientService, - language, - password, - resource: item, - store - }) - } - ) - } - await copyQuicklink({ ability, + passwordPolicyService, clientService, language, resource: item, diff --git a/packages/web-pkg/tests/unit/helpers/share/link.spec.ts b/packages/web-pkg/tests/unit/helpers/share/link.spec.ts index 38cfc5f97d2..65fee84f516 100644 --- a/packages/web-pkg/tests/unit/helpers/share/link.spec.ts +++ b/packages/web-pkg/tests/unit/helpers/share/link.spec.ts @@ -1,7 +1,7 @@ import { copyQuicklink, createQuicklink, CreateQuicklink } from '../../../../src/helpers/share' import { DateTime } from 'luxon' import { Store } from 'vuex' -import { ClientService } from '../../../../src/services' +import { ClientService, PasswordPolicyService } from '../../../../src/services' import { useClipboard } from '@vueuse/core' import { Ability } from '@ownclouders/web-client/src/helpers/resource/types' import { mock, mockDeep } from 'jest-mock-extended' @@ -13,6 +13,19 @@ jest.mock('@vueuse/core', () => ({ })) const mockStore = { + getters: { + capabilities: { + files_sharing: { + public: { + password: { + enforced_for: { + read_only: false + } + } + } + } + } + }, state: { user: { capabilities: { @@ -24,6 +37,11 @@ const mockStore = { expire_date: { enforced: true, days: 5 + }, + password: { + enforced_for: { + read_only: false + } } } } @@ -51,6 +69,8 @@ jest.mock('@ownclouders/web-client/src/helpers/share', () => ({ describe('createQuicklink', () => { it('should create a quicklink with the correct parameters', async () => { const clientService = mockDeep() + clientService.owncloudSdk.shares.getShares.mockResolvedValue([]) + const passwordPolicyService = mockDeep() const args: CreateQuicklink = { store: mockStore as unknown as Store, resource: mockResource, @@ -65,7 +85,7 @@ describe('createQuicklink', () => { expect(link).toBeDefined() expect(link.url).toBeDefined() - await copyQuicklink(args) + await copyQuicklink({ ...args, passwordPolicyService }) expect(useClipboard).toHaveBeenCalled() expect(mockStore.dispatch).toHaveBeenCalledWith('Files/addLink', { @@ -91,6 +111,8 @@ describe('createQuicklink', () => { 'should create a quicklink without a password if no password is provided and capabilities set to default %s', async (role) => { const clientService = mockDeep() + clientService.owncloudSdk.shares.getShares.mockResolvedValue([]) + const passwordPolicyService = mockDeep() returnBitmask = role === 'viewer' ? 1 : 0 mockStore.state.user.capabilities.files_sharing.quickLink.default_role = role @@ -107,7 +129,7 @@ describe('createQuicklink', () => { expect(link).toBeDefined() expect(link.url).toBeDefined() - await copyQuicklink(args) + await copyQuicklink({ ...args, passwordPolicyService }) expect(useClipboard).toHaveBeenCalled() expect(mockStore.dispatch).toHaveBeenCalledWith('Files/addLink', { diff --git a/packages/web-runtime/src/services/auth/abilities.ts b/packages/web-runtime/src/services/auth/abilities.ts index 9aa02d79663..268329fb471 100644 --- a/packages/web-runtime/src/services/auth/abilities.ts +++ b/packages/web-runtime/src/services/auth/abilities.ts @@ -23,6 +23,9 @@ export const getAbilities = ( ], 'Logo.Write.all': [{ action: 'update-all', subject: 'Logo' }], 'PublicLink.Write.all': [{ action: 'create-all', subject: 'PublicLink' }], + 'ReadOnlyPublicLinkPassword.Delete.all': [ + { action: 'delete-all', subject: 'ReadOnlyPublicLinkPassword' } + ], 'Roles.ReadWrite.all': [ { action: 'create-all', subject: 'Role' }, { action: 'delete-all', subject: 'Role' },