From f3c6312a9ec0695305cf3c5b637175ef27cea463 Mon Sep 17 00:00:00 2001 From: Tink Date: Fri, 19 Jun 2026 10:15:58 +0200 Subject: [PATCH] feat(projects): make duplicating shares opt-in (#2932) --- frontend/src/i18n/lang/en.json | 1 + frontend/src/modelTypes/IProjectDuplicate.ts | 1 + frontend/src/models/projectDuplicateModel.ts | 1 + frontend/src/stores/projects.ts | 3 +- .../settings/ProjectSettingsDuplicate.vue | 10 +- pkg/models/project_duplicate.go | 98 ++++++++++--------- pkg/models/project_duplicate_test.go | 52 +++++++++- pkg/routes/api/v2/project_duplicate.go | 2 +- 8 files changed, 117 insertions(+), 51 deletions(-) diff --git a/frontend/src/i18n/lang/en.json b/frontend/src/i18n/lang/en.json index a48d793ac..82e7d0e5a 100644 --- a/frontend/src/i18n/lang/en.json +++ b/frontend/src/i18n/lang/en.json @@ -393,6 +393,7 @@ "title": "Duplicate this project", "label": "Duplicate", "text": "Select a parent project which should hold the duplicated project:", + "shares": "Copy shares (users, teams and link shares) to the duplicate", "success": "The project was successfully duplicated." }, "edit": { diff --git a/frontend/src/modelTypes/IProjectDuplicate.ts b/frontend/src/modelTypes/IProjectDuplicate.ts index a24efa58c..cf9ba9167 100644 --- a/frontend/src/modelTypes/IProjectDuplicate.ts +++ b/frontend/src/modelTypes/IProjectDuplicate.ts @@ -5,4 +5,5 @@ export interface IProjectDuplicate extends IAbstract { projectId: number duplicatedProject: IProject | null parentProjectId: IProject['id'] + duplicateShares: boolean } diff --git a/frontend/src/models/projectDuplicateModel.ts b/frontend/src/models/projectDuplicateModel.ts index ac137714d..53af125ba 100644 --- a/frontend/src/models/projectDuplicateModel.ts +++ b/frontend/src/models/projectDuplicateModel.ts @@ -8,6 +8,7 @@ export default class ProjectDuplicateModel extends AbstractModel) { super() diff --git a/frontend/src/stores/projects.ts b/frontend/src/stores/projects.ts index 37110100e..18a3dfad2 100644 --- a/frontend/src/stores/projects.ts +++ b/frontend/src/stores/projects.ts @@ -380,10 +380,11 @@ export function useProject(projectId: MaybeRefOrGetter) { success({message: t('project.edit.success')}) } - async function duplicateProject(parentProjectId: IProject['id']) { + async function duplicateProject(parentProjectId: IProject['id'], duplicateShares: boolean = false) { const projectDuplicate = new ProjectDuplicateModel({ projectId: Number(toValue(projectId)), parentProjectId, + duplicateShares, }) const duplicate = await projectDuplicateService.create(projectDuplicate) diff --git a/frontend/src/views/project/settings/ProjectSettingsDuplicate.vue b/frontend/src/views/project/settings/ProjectSettingsDuplicate.vue index 764a44803..ebfc61003 100644 --- a/frontend/src/views/project/settings/ProjectSettingsDuplicate.vue +++ b/frontend/src/views/project/settings/ProjectSettingsDuplicate.vue @@ -8,6 +8,12 @@ >

{{ $t('project.duplicate.text') }}

+ + {{ $t('project.duplicate.shares') }} + @@ -18,6 +24,7 @@ import {useI18n} from 'vue-i18n' import CreateEdit from '@/components/misc/CreateEdit.vue' import ProjectSearch from '@/components/tasks/partials/ProjectSearch.vue' +import FancyCheckbox from '@/components/input/FancyCheckbox.vue' import {success} from '@/message' import {useTitle} from '@/composables/useTitle' @@ -33,6 +40,7 @@ const projectStore = useProjectStore() const {project, isLoading, duplicateProject} = useProject(route.params.projectId) const parentProject = ref(null) +const duplicateShares = ref(true) const isDuplicating = ref(false) const loadingModel = computed({ @@ -53,7 +61,7 @@ async function duplicate() { isDuplicating.value = true try { - await duplicateProject(parentProject.value?.id ?? 0) + await duplicateProject(parentProject.value?.id ?? 0, duplicateShares.value) success({message: t('project.duplicate.success')}) } finally { isDuplicating.value = false diff --git a/pkg/models/project_duplicate.go b/pkg/models/project_duplicate.go index 947b07d5a..6b7c2f87d 100644 --- a/pkg/models/project_duplicate.go +++ b/pkg/models/project_duplicate.go @@ -34,6 +34,8 @@ type ProjectDuplicate struct { ProjectID int64 `json:"-" param:"projectid"` // The target parent project ParentProjectID int64 `json:"parent_project_id,omitempty" doc:"The id of the project under which the duplicate should be created. Omit or 0 to place the copy at the top level; you need write access to the parent."` + // Whether to copy the project's shares to the duplicate + DuplicateShares bool `json:"duplicate_shares,omitempty" doc:"Whether to copy the project's user, team and link shares to the duplicate. Defaults to false."` // The copied project Project *Project `json:"duplicated_project,omitempty" readOnly:"true" doc:"The newly created duplicate project, populated by the server in the response."` @@ -62,7 +64,7 @@ func (pd *ProjectDuplicate) CanCreate(s *xorm.Session, a web.Auth) (canCreate bo // Create duplicates a project // @Summary Duplicate an existing project -// @Description Copies the project, tasks, files, kanban data, assignees, comments, attachments, labels, relations, backgrounds, user/team permissions and link shares from one project to a new one. The user needs read access in the project and write access in the parent of the new project. +// @Description Copies the project, tasks, files, kanban data, assignees, comments, attachments, labels, relations and backgrounds from one project to a new one. User/team permissions and link shares are only copied when duplicate_shares is set to true. The user needs read access in the project and write access in the parent of the new project. // @tags project // @Accept json // @Produce json @@ -117,56 +119,58 @@ func (pd *ProjectDuplicate) Create(s *xorm.Session, doer web.Auth) (err error) { return } - // Permissions / Shares - // To keep it simple(r) we will only copy permissions which are directly used with the project, not the parent - users := []*ProjectUser{} - err = s.Where("project_id = ?", pd.ProjectID).Find(&users) - if err != nil { - return - } - for _, u := range users { - u.ID = 0 - u.ProjectID = pd.Project.ID - if _, err := s.Insert(u); err != nil { - return err - } - } - - log.Debugf("Duplicated user shares from project %d into %d", pd.ProjectID, pd.Project.ID) - - teams := []*TeamProject{} - err = s.Where("project_id = ?", pd.ProjectID).Find(&teams) - if err != nil { - return - } - for _, t := range teams { - t.ID = 0 - t.ProjectID = pd.Project.ID - if _, err := s.Insert(t); err != nil { - return err - } - } - - // Generate new link shares if any are available - linkShares := []*LinkSharing{} - err = s.Where("project_id = ?", pd.ProjectID).Find(&linkShares) - if err != nil { - return - } - for _, share := range linkShares { - share.ID = 0 - share.ProjectID = pd.Project.ID - hash, err := utils.CryptoRandomString(40) + if pd.DuplicateShares { + // Permissions / Shares + // To keep it simple(r) we will only copy permissions which are directly used with the project, not the parent + users := []*ProjectUser{} + err = s.Where("project_id = ?", pd.ProjectID).Find(&users) if err != nil { - return err + return } - share.Hash = hash - if _, err := s.Insert(share); err != nil { - return err + for _, u := range users { + u.ID = 0 + u.ProjectID = pd.Project.ID + if _, err := s.Insert(u); err != nil { + return err + } } - } - log.Debugf("Duplicated all link shares from project %d into %d", pd.ProjectID, pd.Project.ID) + log.Debugf("Duplicated user shares from project %d into %d", pd.ProjectID, pd.Project.ID) + + teams := []*TeamProject{} + err = s.Where("project_id = ?", pd.ProjectID).Find(&teams) + if err != nil { + return + } + for _, t := range teams { + t.ID = 0 + t.ProjectID = pd.Project.ID + if _, err := s.Insert(t); err != nil { + return err + } + } + + // Generate new link shares if any are available + linkShares := []*LinkSharing{} + err = s.Where("project_id = ?", pd.ProjectID).Find(&linkShares) + if err != nil { + return + } + for _, share := range linkShares { + share.ID = 0 + share.ProjectID = pd.Project.ID + hash, err := utils.CryptoRandomString(40) + if err != nil { + return err + } + share.Hash = hash + if _, err := s.Insert(share); err != nil { + return err + } + } + + log.Debugf("Duplicated all link shares from project %d into %d", pd.ProjectID, pd.Project.ID) + } err = pd.Project.ReadOne(s, doer) return diff --git a/pkg/models/project_duplicate_test.go b/pkg/models/project_duplicate_test.go index a89342edb..5e3360a1b 100644 --- a/pkg/models/project_duplicate_test.go +++ b/pkg/models/project_duplicate_test.go @@ -25,6 +25,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "xorm.io/xorm" ) func TestProjectDuplicate(t *testing.T) { @@ -38,6 +39,54 @@ func TestProjectDuplicate(t *testing.T) { // (non-Unsplash) background would fail with an internal server error testProjectDuplicate(t, 35, 6) }) + + t.Run("shares are not copied by default", func(t *testing.T) { + files.InitTestFileFixtures(t) + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + // Project 3 has user, team and link shares + u := &user.User{ID: 3} + l := &ProjectDuplicate{ProjectID: 3} + can, err := l.CanCreate(s, u) + require.NoError(t, err) + assert.True(t, can) + require.NoError(t, l.Create(s, u)) + + assertShareCount(t, s, l.Project.ID, 0, 0, 0) + }) + + t.Run("shares are copied when duplicate_shares is set", func(t *testing.T) { + files.InitTestFileFixtures(t) + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + // Project 3 has 2 user shares, 1 team share and 1 link share + u := &user.User{ID: 3} + l := &ProjectDuplicate{ProjectID: 3, DuplicateShares: true} + can, err := l.CanCreate(s, u) + require.NoError(t, err) + assert.True(t, can) + require.NoError(t, l.Create(s, u)) + + assertShareCount(t, s, l.Project.ID, 2, 1, 1) + }) +} + +func assertShareCount(t *testing.T, s *xorm.Session, projectID, users, teams, links int64) { + userCount, err := s.Where("project_id = ?", projectID).Count(&ProjectUser{}) + require.NoError(t, err) + assert.Equal(t, users, userCount, "unexpected number of user shares") + + teamCount, err := s.Where("project_id = ?", projectID).Count(&TeamProject{}) + require.NoError(t, err) + assert.Equal(t, teams, teamCount, "unexpected number of team shares") + + linkCount, err := s.Where("project_id = ?", projectID).Count(&LinkSharing{}) + require.NoError(t, err) + assert.Equal(t, links, linkCount, "unexpected number of link shares") } func testProjectDuplicate(t *testing.T, projectID int64, userID int64) { @@ -51,7 +100,8 @@ func testProjectDuplicate(t *testing.T, projectID int64, userID int64) { } l := &ProjectDuplicate{ - ProjectID: projectID, + ProjectID: projectID, + DuplicateShares: true, } can, err := l.CanCreate(s, u) require.NoError(t, err) diff --git a/pkg/routes/api/v2/project_duplicate.go b/pkg/routes/api/v2/project_duplicate.go index 9fd23798f..6a050b2c2 100644 --- a/pkg/routes/api/v2/project_duplicate.go +++ b/pkg/routes/api/v2/project_duplicate.go @@ -37,7 +37,7 @@ func RegisterProjectDuplicateRoutes(api huma.API) { Register(api, huma.Operation{ OperationID: "projects-duplicate", Summary: "Duplicate a project", - Description: "Deep-copies a project — its tasks, files, kanban data, assignees, comments, attachments, labels, relations, backgrounds and user/team/link shares — into a new project owned by the authenticated user. The user needs read access to the source project, plus write access to the parent project when one is given. The copy is placed under parent_project_id (top level if omitted). Returns the duplicate in duplicated_project.", + Description: "Deep-copies a project — its tasks, files, kanban data, assignees, comments, attachments, labels, relations and backgrounds — into a new project owned by the authenticated user. User/team/link shares are only copied when duplicate_shares is set to true. The user needs read access to the source project, plus write access to the parent project when one is given. The copy is placed under parent_project_id (top level if omitted). Returns the duplicate in duplicated_project.", Method: http.MethodPost, Path: "/projects/{projectid}/duplicate", Tags: tags,