From 38524cc45984ad2b836f2125c02d81f9d15666fe Mon Sep 17 00:00:00 2001 From: 0neStep <146049978+AperturePlus@users.noreply.github.com> Date: Sun, 24 May 2026 23:27:11 +0800 Subject: [PATCH 01/16] feat(sharing): batch-apply collaborator/permission changes on Done + add regenerate share password --- web/src/components/common/ShareDialog.spec.ts | 329 ++++++++++ web/src/components/common/ShareDialog.vue | 580 ++++++++++-------- .../sharing/SharedLinksTable.spec.ts | 42 +- .../organisms/sharing/SharedLinksTable.vue | 11 +- web/src/composables/useSharingCenter.spec.ts | 174 ++++++ web/src/composables/useSharingCenter.ts | 44 +- web/src/i18n/messages.ts | 42 ++ web/src/pages/shared/SharedWithMe.vue | 8 +- 8 files changed, 956 insertions(+), 274 deletions(-) create mode 100644 web/src/components/common/ShareDialog.spec.ts create mode 100644 web/src/composables/useSharingCenter.spec.ts diff --git a/web/src/components/common/ShareDialog.spec.ts b/web/src/components/common/ShareDialog.spec.ts new file mode 100644 index 0000000..7b911b8 --- /dev/null +++ b/web/src/components/common/ShareDialog.spec.ts @@ -0,0 +1,329 @@ +import { describe, expect, it, beforeEach, vi } from 'vitest'; +import { nextTick } from 'vue'; +import { mount } from '../../test/mount'; +import ShareDialog from './ShareDialog.vue'; +import type { Share } from '../../types/share'; + +type MockPagination = { + totalItems: number; + totalPages: number; + perPage: number; + currentPage: number; + hasPrev: boolean; + hasNext: boolean; +}; + +type MockShareList = { + items: Share[]; + pagination: MockPagination; +}; + +const { + getSharesMock, + createShareMock, + updateShareSettingsMock, + deleteShareMock, + getPermissionsMock, + createPermissionMock, + updatePermissionMock, + deletePermissionMock, + getUsersMock, + getUserGroupsMock, + confirmMock, + copyTextMock, +} = vi.hoisted(() => ({ + getSharesMock: vi.fn(async (): Promise => ({ + items: [], + pagination: { totalItems: 0, totalPages: 1, perPage: 100, currentPage: 1, hasPrev: false, hasNext: false }, + })), + createShareMock: vi.fn(async (_payload?: Record): Promise => ({ + shareId: 's-new', + shareLink: 'NEW123', + itemType: 'file', + itemInfo: { id: 'f-1', name: 'draft.txt', size: 12, mimeType: 'text/plain', folderPath: '/My Files' }, + settings: { passwordProtected: false, expireAt: null, allowDownload: true, allowPreview: true }, + createdAt: '2026-05-24T00:00:00.000Z', + })), + updateShareSettingsMock: vi.fn(async (_shareLink?: string, _payload?: Record): Promise => ({ + shareId: 's-new', + shareLink: 'NEW123', + itemType: 'file', + itemInfo: { id: 'f-1', name: 'draft.txt', size: 12, mimeType: 'text/plain', folderPath: '/My Files' }, + settings: { passwordProtected: false, expireAt: null, allowDownload: true, allowPreview: false }, + createdAt: '2026-05-24T00:00:00.000Z', + })), + deleteShareMock: vi.fn(async () => ({ + shareId: 's-old', + shareLink: 'OLD999', + deletedAt: '2026-05-24T00:00:00.000Z', + })), + getPermissionsMock: vi.fn(async () => ({ items: [], pagination: { totalItems: 0, totalPages: 1, perPage: 50, currentPage: 1, hasPrev: false, hasNext: false } })), + createPermissionMock: vi.fn(async () => ({ + permissionId: 'perm-new', + itemType: 'file', + itemId: 'f-1', + grantedTo: { type: 'user', id: 'u-2', name: 'alice' }, + permission: 'read', + createdAt: '2026-05-24T00:00:00.000Z', + })), + updatePermissionMock: vi.fn(async () => ({})), + deletePermissionMock: vi.fn(async () => ({})), + getUsersMock: vi.fn(async () => ({ items: [{ userId: 'u-2', username: 'alice', email: 'alice@example.com' }] })), + getUserGroupsMock: vi.fn(async () => ({ items: [] })), + confirmMock: vi.fn(async () => true), + copyTextMock: vi.fn(async () => undefined), +})); + +vi.mock('@vueuse/core', () => ({ + useDebounceFn: (fn: (...args: unknown[]) => unknown) => fn, +})); + +vi.mock('../../api/share', () => ({ + getShares: getSharesMock, + createShare: createShareMock, + updateShareSettings: updateShareSettingsMock, + deleteShare: deleteShareMock, +})); + +vi.mock('../../api/permission', () => ({ + getPermissions: getPermissionsMock, + createPermission: createPermissionMock, + updatePermission: updatePermissionMock, + deletePermission: deletePermissionMock, +})); + +vi.mock('../../api/user', () => ({ + getUsers: getUsersMock, +})); + +vi.mock('../../api/usergroup', () => ({ + getUserGroups: getUserGroupsMock, +})); + +vi.mock('../../utils/ui', () => ({ + ui: { + confirm: confirmMock, + copyText: copyTextMock, + toast: vi.fn(), + promptText: vi.fn(), + resolveConfirm: vi.fn(), + resolvePrompt: vi.fn(), + dismissToast: vi.fn(), + }, + uiState: { + confirm: null, + prompt: null, + toasts: [], + }, +})); + +const baseItem = { + itemType: 'file' as const, + id: 'f-1', + name: 'draft.txt', + size: 12, + mimeType: 'text/plain', + ownerName: 'owner', + createdAt: '2026-05-24T00:00:00.000Z', + updatedAt: '2026-05-24T00:00:00.000Z', + folderId: 'root', +}; + +const makeShare = (overrides: Partial = {}): Share => { + const base: Share = { + shareId: 's-new', + shareLink: 'NEW123', + itemType: 'file', + itemInfo: { id: 'f-1', name: 'draft.txt', size: 12, mimeType: 'text/plain', folderPath: '/My Files' }, + settings: { passwordProtected: false, expireAt: null, allowDownload: true, allowPreview: true }, + createdAt: '2026-05-24T00:00:00.000Z', + }; + return { + ...base, + ...overrides, + itemInfo: { ...base.itemInfo, ...(overrides.itemInfo || {}) }, + settings: { ...base.settings, ...(overrides.settings || {}) }, + }; +}; + +const flush = async () => { + await Promise.resolve(); + await nextTick(); + await Promise.resolve(); +}; + +const openDialog = async () => { + const wrapper = mount(ShareDialog, { + props: { + isVisible: false, + itemToShare: baseItem, + }, + }); + await wrapper.setProps({ isVisible: true }); + await flush(); + return wrapper; +}; + +const addDraftCollaborator = async (wrapper: ReturnType) => { + const input = wrapper.find('.search-box input'); + await input.setValue('ali'); + await input.trigger('input'); + await flush(); + const result = wrapper.find('.search-item'); + await result.trigger('click'); + await flush(); +}; + +describe('ShareDialog draft submit flow', () => { + beforeEach(() => { + getSharesMock.mockClear(); + createShareMock.mockClear(); + updateShareSettingsMock.mockClear(); + deleteShareMock.mockClear(); + getPermissionsMock.mockClear(); + createPermissionMock.mockClear(); + updatePermissionMock.mockClear(); + deletePermissionMock.mockClear(); + getUsersMock.mockClear(); + getUserGroupsMock.mockClear(); + confirmMock.mockClear(); + copyTextMock.mockClear(); + }); + + it('does not submit anything when closed without done', async () => { + const wrapper = await openDialog(); + await wrapper.find('.modal-close').trigger('click'); + + expect(wrapper.emitted('close')).toBeTruthy(); + expect(createShareMock).not.toHaveBeenCalled(); + expect(updateShareSettingsMock).not.toHaveBeenCalled(); + expect(createPermissionMock).not.toHaveBeenCalled(); + expect(updatePermissionMock).not.toHaveBeenCalled(); + expect(deletePermissionMock).not.toHaveBeenCalled(); + expect(deleteShareMock).not.toHaveBeenCalled(); + }); + + it('submits collaborator and share settings only when done is clicked', async () => { + const wrapper = await openDialog(); + + const switchInput = wrapper.find('.switch input[type="checkbox"]'); + await switchInput.setValue(true); + await addDraftCollaborator(wrapper); + + const settingChecks = wrapper.findAll('.settings-grid .setting-check input[type="checkbox"]'); + await settingChecks[2].setValue(false); + await wrapper.find('.done-btn').trigger('click'); + await flush(); + + expect(createPermissionMock).toHaveBeenCalledTimes(1); + expect(createPermissionMock).toHaveBeenCalledWith({ + fileId: 'f-1', + userId: 'u-2', + groupId: undefined, + permission: 'read', + }); + expect(createShareMock).toHaveBeenCalledTimes(1); + expect(updateShareSettingsMock).toHaveBeenCalledTimes(1); + expect(updateShareSettingsMock).toHaveBeenCalledWith('NEW123', expect.objectContaining({ allowPreview: false })); + expect(wrapper.emitted('close')).toBeTruthy(); + }); + + it('requires confirmation before revoking an existing public link', async () => { + getSharesMock.mockImplementationOnce(async (): Promise => ({ + items: [makeShare({ shareId: 's-old', shareLink: 'OLD999' })], + pagination: { totalItems: 1, totalPages: 1, perPage: 100, currentPage: 1, hasPrev: false, hasNext: false }, + })); + + const wrapper = await openDialog(); + const switchInput = wrapper.find('.switch input[type="checkbox"]'); + await switchInput.setValue(false); + + confirmMock.mockResolvedValueOnce(false); + await wrapper.find('.done-btn').trigger('click'); + await flush(); + + expect(confirmMock).toHaveBeenCalledTimes(1); + expect(deleteShareMock).not.toHaveBeenCalled(); + expect(wrapper.emitted('close')).toBeFalsy(); + + confirmMock.mockResolvedValueOnce(true); + await wrapper.find('.done-btn').trigger('click'); + await flush(); + + expect(deleteShareMock).toHaveBeenCalledTimes(1); + expect(deleteShareMock).toHaveBeenCalledWith('OLD999'); + expect(wrapper.emitted('close')).toBeTruthy(); + }); + + it('shows auto-generated password after done when password was left blank', async () => { + updateShareSettingsMock.mockImplementationOnce(async () => makeShare({ + settings: { + passwordProtected: true, + password: 'AUTO-987654', + expireAt: null, + allowDownload: true, + allowPreview: true, + }, + })); + + const wrapper = await openDialog(); + await wrapper.find('.switch input[type="checkbox"]').setValue(true); + + const settingChecks = wrapper.findAll('.settings-grid .setting-check input[type="checkbox"]'); + await settingChecks[0].setValue(true); + await wrapper.find('.done-btn').trigger('click'); + await flush(); + + expect(updateShareSettingsMock).toHaveBeenCalledTimes(1); + const payload = updateShareSettingsMock.mock.calls[0]?.[1] as Record | undefined; + expect(payload?.passwordProtected).toBe(true); + expect(payload?.password).toBeUndefined(); + expect(copyTextMock).toHaveBeenCalledTimes(1); + expect(copyTextMock).toHaveBeenCalledWith(expect.objectContaining({ text: 'AUTO-987654' })); + expect(wrapper.emitted('close')).toBeTruthy(); + }); + + it('does not force password dialog after done when user provided password manually', async () => { + updateShareSettingsMock.mockImplementationOnce(async () => makeShare({ + settings: { + passwordProtected: true, + password: 'manual-pass', + expireAt: null, + allowDownload: true, + allowPreview: true, + }, + })); + + const wrapper = await openDialog(); + await wrapper.find('.switch input[type="checkbox"]').setValue(true); + + const settingChecks = wrapper.findAll('.settings-grid .setting-check input[type="checkbox"]'); + await settingChecks[0].setValue(true); + await wrapper.find('.password-row input').setValue('manual-pass'); + await wrapper.find('.done-btn').trigger('click'); + await flush(); + + expect(updateShareSettingsMock).toHaveBeenCalledTimes(1); + const payload = updateShareSettingsMock.mock.calls[0]?.[1] as Record | undefined; + expect(payload?.password).toBe('manual-pass'); + expect(copyTextMock).not.toHaveBeenCalled(); + expect(wrapper.emitted('close')).toBeTruthy(); + }); + + it('keeps dialog open and refreshes state when submit fails', async () => { + createPermissionMock.mockRejectedValueOnce(new Error('boom')); + const wrapper = await openDialog(); + const initialShareCalls = getSharesMock.mock.calls.length; + const initialPermissionCalls = getPermissionsMock.mock.calls.length; + + const switchInput = wrapper.find('.switch input[type="checkbox"]'); + await switchInput.setValue(true); + await addDraftCollaborator(wrapper); + await wrapper.find('.done-btn').trigger('click'); + await flush(); + + expect(wrapper.emitted('close')).toBeFalsy(); + expect(getSharesMock.mock.calls.length).toBeGreaterThan(initialShareCalls); + expect(getPermissionsMock.mock.calls.length).toBeGreaterThan(initialPermissionCalls); + }); +}); diff --git a/web/src/components/common/ShareDialog.vue b/web/src/components/common/ShareDialog.vue index f8dff89..b6a501e 100644 --- a/web/src/components/common/ShareDialog.vue +++ b/web/src/components/common/ShareDialog.vue @@ -5,13 +5,27 @@ import type { ContentItem } from '../../types/file'; import type { Collaborator, Share } from '../../types/share'; import type { PermissionItem } from '../../types/permission'; import type { User, UserGroup } from '../../types/user'; -import { createShare, getShares, updateShareSettings } from '../../api/share'; +import { createShare, deleteShare, getShares, updateShareSettings } from '../../api/share'; import { getUsers } from '../../api/user'; import { getUserGroups } from '../../api/usergroup'; import { createPermission, deletePermission, getPermissions, updatePermission } from '../../api/permission'; import { useLocaleStore } from '../../store/locale'; import { ui } from '../../utils/ui'; +type SharePermission = 'read' | 'write' | 'admin'; + +interface ShareSettingsDraft { + passwordProtected: boolean; + password: string; + expireAt: string; + allowDownload: boolean; + allowPreview: boolean; +} + +interface ShareApplyResult { + autoGeneratedPassword: string | null; +} + interface Props { isVisible: boolean; itemToShare: ContentItem | null; @@ -26,21 +40,24 @@ const searchKeyword = ref(''); const searchResults = ref([]); const collaborators = ref([]); const isSearching = ref(false); +const isSubmitting = ref(false); + +const initialCollaborators = ref([]); +const initialShare = ref(null); const publicLinkEnabled = ref(false); -const publicLink = ref(''); const currentShareLink = ref(''); -const isCreatingShare = ref(false); -const isSavingSettings = ref(false); const settingsMessage = ref(''); -const shareSettings = ref({ - passwordProtected: false, - password: '' as string, - expireAt: '' as string, - allowDownload: true, - allowPreview: true, - }); +const createDefaultSettings = (): ShareSettingsDraft => ({ + passwordProtected: false, + password: '', + expireAt: '', + allowDownload: true, + allowPreview: true, +}); + +const shareSettings = ref(createDefaultSettings()); const currentItemPayload = computed(() => { if (!props.itemToShare) return null; @@ -52,12 +69,48 @@ const currentItemPayload = computed(() => { return { folderId: props.itemToShare.id }; }); -const fetchPermissions = async () => { - if (!currentItemPayload.value) return; +const publicLink = computed(() => { + if (!currentShareLink.value) return ''; + return `${window.location.origin}/share/${currentShareLink.value}`; +}); + +const cloneCollaborator = (collaborator: Collaborator): Collaborator => ({ + id: collaborator.id, + name: collaborator.name, + type: collaborator.type, + email: collaborator.email, + avatar: collaborator.avatar, + permission: collaborator.permission, + permissionId: collaborator.permissionId, +}); + +const collaboratorKey = (collaborator: Pick): string => + `${collaborator.type}:${collaborator.id}`; + +const normalizePermission = (permission: Collaborator['permission']): SharePermission => + permission || 'read'; + +const hydrateShareSettings = (share: Share | null) => { + if (!share) { + shareSettings.value = createDefaultSettings(); + return; + } + + shareSettings.value = { + passwordProtected: share.settings.passwordProtected, + password: share.settings.password || '', + expireAt: share.settings.expireAt ? share.settings.expireAt.slice(0, 10) : '', + allowDownload: share.settings.allowDownload, + allowPreview: share.settings.allowPreview, + }; +}; + +const fetchPermissions = async (): Promise => { + if (!currentItemPayload.value) return []; try { const response = await getPermissions({ ...currentItemPayload.value, page: 1, perPage: 50 }); - collaborators.value = response.items.map((permission: PermissionItem) => ({ + return response.items.map((permission: PermissionItem) => ({ id: permission.grantedTo.id, name: permission.grantedTo.name, type: permission.grantedTo.type, @@ -66,54 +119,43 @@ const fetchPermissions = async () => { })); } catch (error) { console.error('Failed to fetch permissions', error); - collaborators.value = []; + return []; } }; -const hydrateShareSettings = (share: Share) => { - shareSettings.value = { - passwordProtected: share.settings.passwordProtected, - password: share.settings.password || '', - expireAt: share.settings.expireAt ? share.settings.expireAt.slice(0, 10) : '', - allowDownload: share.settings.allowDownload, - allowPreview: share.settings.allowPreview, - }; - }; - -const loadExistingShare = async () => { - if (!props.itemToShare) { - return; - } +const loadExistingShare = async (): Promise => { + if (!props.itemToShare) return null; try { const response = await getShares({ page: 1, perPage: 100 }); - const matched = response.items.find( - (item) => item.itemType === props.itemToShare?.itemType && item.itemInfo.id === props.itemToShare?.id, + return ( + response.items.find( + (item) => item.itemType === props.itemToShare?.itemType && item.itemInfo.id === props.itemToShare?.id, + ) || null ); - - if (!matched) { - currentShareLink.value = ''; - publicLink.value = ''; - publicLinkEnabled.value = false; - shareSettings.value = { - passwordProtected: false, - password: '', - expireAt: '', - allowDownload: true, - allowPreview: true, - }; - return; - } - - currentShareLink.value = matched.shareLink; - publicLink.value = `${window.location.origin}/share/${matched.shareLink}`; - publicLinkEnabled.value = true; - hydrateShareSettings(matched); } catch (error) { console.error('Failed to load existing share', error); + return null; } }; +const resetDraft = (nextCollaborators: Collaborator[], share: Share | null) => { + initialCollaborators.value = nextCollaborators.map(cloneCollaborator); + collaborators.value = nextCollaborators.map(cloneCollaborator); + initialShare.value = share; + currentShareLink.value = share?.shareLink || ''; + publicLinkEnabled.value = Boolean(share); + hydrateShareSettings(share); + searchKeyword.value = ''; + searchResults.value = []; + settingsMessage.value = ''; +}; + +const refreshDialogState = async () => { + const [permissionList, share] = await Promise.all([fetchPermissions(), loadExistingShare()]); + resetDraft(permissionList, share); +}; + const searchCollaborators = async (keyword: string) => { const query = keyword.trim(); if (!query) { @@ -141,8 +183,8 @@ const searchCollaborators = async (keyword: string) => { type: 'group', })); - const existingIds = new Set(collaborators.value.map((item) => `${item.type}:${item.id}`)); - searchResults.value = [...userResults, ...groupResults].filter((item) => !existingIds.has(`${item.type}:${item.id}`)); + const existingIds = new Set(collaborators.value.map((item) => collaboratorKey(item))); + searchResults.value = [...userResults, ...groupResults].filter((item) => !existingIds.has(collaboratorKey(item))); } finally { isSearching.value = false; } @@ -150,133 +192,30 @@ const searchCollaborators = async (keyword: string) => { const debouncedSearch = useDebounceFn(searchCollaborators, 260); -const addCollaborator = async (target: Collaborator) => { - if (!currentItemPayload.value) return; - - try { - const created = await createPermission({ - ...currentItemPayload.value, - userId: target.type === 'user' ? target.id : undefined, - groupId: target.type === 'group' ? target.id : undefined, - permission: 'read', - }); - - collaborators.value.push({ - ...target, - permission: created.permission, - permissionId: created.permissionId, - }); - - searchKeyword.value = ''; - searchResults.value = []; - } catch (error) { - console.error('Failed to add collaborator', error); +const addCollaborator = (target: Collaborator) => { + if (isSubmitting.value) return; + const key = collaboratorKey(target); + if (collaborators.value.some((item) => collaboratorKey(item) === key)) { + return; } -}; -const changePermission = async (collaborator: Collaborator, permission: 'read' | 'write' | 'admin') => { - if (!collaborator.permissionId) return; + collaborators.value.push({ + ...cloneCollaborator(target), + permission: 'read', + }); - try { - await updatePermission(collaborator.permissionId, { permission }); - collaborator.permission = permission; - } catch (error) { - console.error('Failed to update permission', error); - } + searchKeyword.value = ''; + searchResults.value = []; }; -const removeCollaborator = async (collaborator: Collaborator) => { - if (!collaborator.permissionId) return; - - try { - await deletePermission(collaborator.permissionId); - collaborators.value = collaborators.value.filter((item) => item.permissionId !== collaborator.permissionId); - } catch (error) { - console.error('Failed to remove permission', error); - } +const changePermission = (collaborator: Collaborator, permission: SharePermission) => { + if (isSubmitting.value) return; + collaborator.permission = permission; }; -const createPublicShare = async () => { - if (!props.itemToShare) return; - - isCreatingShare.value = true; - settingsMessage.value = ''; - try { - const share = await createShare({ - resourceType: props.itemToShare.itemType, - resourceId: props.itemToShare.id, - }); - - currentShareLink.value = share.shareLink; - publicLink.value = `${window.location.origin}/share/${share.shareLink}`; - hydrateShareSettings(share); - } catch (error) { - console.error('Failed to create share link', error); - publicLinkEnabled.value = false; - } finally { - isCreatingShare.value = false; - } -}; - -const saveShareSettings = async () => { - if (!currentShareLink.value) return; - - isSavingSettings.value = true; - settingsMessage.value = ''; - - try { - const payload: Record = { - passwordProtected: shareSettings.value.passwordProtected, - allowDownload: shareSettings.value.allowDownload, - allowPreview: shareSettings.value.allowPreview, - expireAt: shareSettings.value.expireAt ? `${shareSettings.value.expireAt}T23:59:59.000Z` : null, - }; - - const trimmedPassword = shareSettings.value.password?.trim(); - if (shareSettings.value.passwordProtected && trimmedPassword) { - payload.password = trimmedPassword; - } - - const updated = await updateShareSettings(currentShareLink.value, payload); - - hydrateShareSettings(updated); - if (updated.settings.password) { - shareSettings.value.password = updated.settings.password; - settingsMessage.value = t('share.dialog.settings.passwordUpdated'); - } else { - settingsMessage.value = t('share.dialog.settings.saved'); - } - } catch (error) { - console.error('Failed to save share settings', error); - settingsMessage.value = t('share.dialog.settings.saveFailed'); - } finally { - isSavingSettings.value = false; - } - }; - -const regeneratePassword = async () => { - if (!currentShareLink.value) return; - - isSavingSettings.value = true; - settingsMessage.value = ''; - - try { - const updated = await updateShareSettings(currentShareLink.value, { - passwordProtected: true, - regeneratePassword: true, - }); - - hydrateShareSettings(updated); - if (updated.settings.password) { - shareSettings.value.password = updated.settings.password; - } - settingsMessage.value = t('share.dialog.settings.regenerated'); - } catch (error) { - console.error('Failed to regenerate password', error); - settingsMessage.value = t('share.dialog.settings.regenerateFailed'); - } finally { - isSavingSettings.value = false; - } +const removeCollaborator = (collaborator: Collaborator) => { + if (isSubmitting.value) return; + collaborators.value = collaborators.value.filter((item) => collaboratorKey(item) !== collaboratorKey(collaborator)); }; const copyPassword = async () => { @@ -294,6 +233,7 @@ const copyPassword = async () => { }; const clearExpireDate = () => { + if (isSubmitting.value) return; shareSettings.value.expireAt = ''; }; @@ -312,28 +252,154 @@ const copyLink = async () => { } }; -watch( - () => props.isVisible, - async (visible) => { - if (!visible) return; +const buildShareSettingsPayload = () => { + const trimmedPassword = shareSettings.value.password.trim(); + const shouldAutoGeneratePassword = shareSettings.value.passwordProtected && !trimmedPassword; + const payload: Record = { + passwordProtected: shareSettings.value.passwordProtected, + allowDownload: shareSettings.value.allowDownload, + allowPreview: shareSettings.value.allowPreview, + expireAt: shareSettings.value.expireAt ? `${shareSettings.value.expireAt}T23:59:59.000Z` : null, + }; + + if (shareSettings.value.passwordProtected && trimmedPassword) { + payload.password = trimmedPassword; + } - searchKeyword.value = ''; - searchResults.value = []; - settingsMessage.value = ''; + return { payload, shouldAutoGeneratePassword }; +}; - await Promise.all([fetchPermissions(), loadExistingShare()]); - }, -); +const hasSettingsChanged = (before: Share, after: ShareSettingsDraft): boolean => { + const beforeExpireAt = before.settings.expireAt ? before.settings.expireAt.slice(0, 10) : ''; + if (before.settings.passwordProtected !== after.passwordProtected) return true; + if (before.settings.allowDownload !== after.allowDownload) return true; + if (before.settings.allowPreview !== after.allowPreview) return true; + if (beforeExpireAt !== after.expireAt) return true; + if (after.passwordProtected && after.password.trim()) return true; + return false; +}; + +const applyCollaboratorChanges = async () => { + const payloadBase = currentItemPayload.value; + if (!payloadBase) return; + + const initialMap = new Map(initialCollaborators.value.map((item) => [collaboratorKey(item), item])); + const draftMap = new Map(collaborators.value.map((item) => [collaboratorKey(item), item])); -watch(publicLinkEnabled, (enabled) => { - if (enabled && !currentShareLink.value) { - createPublicShare(); + for (const initialItem of initialCollaborators.value) { + const key = collaboratorKey(initialItem); + if (draftMap.has(key)) continue; + if (!initialItem.permissionId) continue; + await deletePermission(initialItem.permissionId); } - if (!enabled) { - settingsMessage.value = t('share.dialog.publicHiddenNotice'); + for (const draftItem of collaborators.value) { + const key = collaboratorKey(draftItem); + const initialItem = initialMap.get(key); + if (!initialItem) { + await createPermission({ + ...payloadBase, + userId: draftItem.type === 'user' ? draftItem.id : undefined, + groupId: draftItem.type === 'group' ? draftItem.id : undefined, + permission: normalizePermission(draftItem.permission), + }); + continue; + } + + if (!initialItem.permissionId) continue; + if (normalizePermission(initialItem.permission) === normalizePermission(draftItem.permission)) continue; + + await updatePermission(initialItem.permissionId, { permission: normalizePermission(draftItem.permission) }); } -}); +}; + +const applyShareChanges = async (): Promise => { + if (!props.itemToShare) { + return { autoGeneratedPassword: null }; + } + + if (!publicLinkEnabled.value) { + if (initialShare.value?.shareLink) { + await deleteShare(initialShare.value.shareLink); + currentShareLink.value = ''; + } + return { autoGeneratedPassword: null }; + } + + let shareLink = initialShare.value?.shareLink || ''; + if (!shareLink) { + const created = await createShare({ + resourceType: props.itemToShare.itemType, + resourceId: props.itemToShare.id, + }); + shareLink = created.shareLink; + currentShareLink.value = created.shareLink; + } + + if (initialShare.value && !hasSettingsChanged(initialShare.value, shareSettings.value)) { + return { autoGeneratedPassword: null }; + } + + const { payload, shouldAutoGeneratePassword } = buildShareSettingsPayload(); + const updated = await updateShareSettings(shareLink, payload); + currentShareLink.value = updated.shareLink; + const issuedPassword = updated.settings.password?.trim() || ''; + if (issuedPassword) { + shareSettings.value.password = issuedPassword; + settingsMessage.value = t('share.dialog.settings.passwordUpdated'); + } + return { + autoGeneratedPassword: shouldAutoGeneratePassword && issuedPassword ? issuedPassword : null, + }; +}; + +const handleDone = async () => { + if (isSubmitting.value) return; + settingsMessage.value = ''; + + if (initialShare.value?.shareLink && !publicLinkEnabled.value) { + const confirmed = await ui.confirm({ + title: t('share.dialog.confirmRevoke.title'), + message: t('share.dialog.confirmRevoke.message'), + confirmText: t('share.dialog.confirmRevoke.confirm'), + danger: true, + }); + if (!confirmed) return; + } + + isSubmitting.value = true; + try { + await applyCollaboratorChanges(); + const shareResult = await applyShareChanges(); + if (shareResult.autoGeneratedPassword) { + await ui.copyText({ + title: t('share.dialog.generatedPassword.title'), + message: t('share.dialog.generatedPassword.message'), + text: shareResult.autoGeneratedPassword, + }); + } + emit('close'); + } catch (error) { + console.error('Failed to submit share changes', error); + settingsMessage.value = t('share.dialog.settings.saveFailed'); + await refreshDialogState(); + } finally { + isSubmitting.value = false; + } +}; + +const handleClose = () => { + if (isSubmitting.value) return; + emit('close'); +}; + +watch( + () => props.isVisible, + async (visible) => { + if (!visible) return; + await refreshDialogState(); + }, +); watch( () => shareSettings.value.passwordProtected, @@ -343,18 +409,19 @@ watch( } }, ); +