feat(projects): make duplicating shares opt-in (#2932)

This commit is contained in:
Tink 2026-06-19 10:15:58 +02:00 committed by GitHub
parent bf175dde6d
commit f3c6312a9e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 117 additions and 51 deletions

View File

@ -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": {

View File

@ -5,4 +5,5 @@ export interface IProjectDuplicate extends IAbstract {
projectId: number
duplicatedProject: IProject | null
parentProjectId: IProject['id']
duplicateShares: boolean
}

View File

@ -8,6 +8,7 @@ export default class ProjectDuplicateModel extends AbstractModel<IProjectDuplica
projectId = 0
duplicatedProject: IProject | null = null
parentProjectId = 0
duplicateShares = false
constructor(data : Partial<IProjectDuplicate>) {
super()

View File

@ -380,10 +380,11 @@ export function useProject(projectId: MaybeRefOrGetter<IProject['id']>) {
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)

View File

@ -8,6 +8,12 @@
>
<p>{{ $t('project.duplicate.text') }}</p>
<ProjectSearch v-model="parentProject" />
<FancyCheckbox
v-model="duplicateShares"
class="mbs-2"
>
{{ $t('project.duplicate.shares') }}
</FancyCheckbox>
</CreateEdit>
</template>
@ -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<IProject | null>(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

View File

@ -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

View File

@ -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)

View File

@ -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,