feat(projects): make duplicating shares opt-in

When duplicating a project, user, team and link shares are no longer
copied by default. Set the new duplicate_shares flag to true to copy
them. The frontend duplicate dialog gains a checkbox (pre-checked) so
the default UX is unchanged.
This commit is contained in:
kolaente 2026-06-17 23:35:48 +02:00
parent 9cad4f388c
commit 10bec10ed4
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="mt-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,