diff --git a/config-raw.json b/config-raw.json index 68b8767c3b..d885270682 100644 --- a/config-raw.json +++ b/config-raw.json @@ -133,6 +133,16 @@ "default_value": "false", "comment": "Enables the public team feature. If enabled, it is possible to configure teams to be public, which makes them\ndiscoverable when sharing a project, therefore not only showing teams the user is member of." }, + { + "key": "enablereadercomments", + "default_value": "true", + "comment": "Whether to enable comments for users with read access." + }, + { + "key": "enableassigneeedit", + "default_value": "true", + "comment": "Whether to enable task editing for users with read access who are assigned to the task." + }, { "key": "bcryptrounds", "default_value": "11", diff --git a/frontend/src/components/project/views/ProjectKanban.vue b/frontend/src/components/project/views/ProjectKanban.vue index 556b9e6b36..c71b6cb586 100644 --- a/frontend/src/components/project/views/ProjectKanban.vue +++ b/frontend/src/components/project/views/ProjectKanban.vue @@ -434,7 +434,9 @@ const bucketDraggableComponentData = computed(() => ({ })) const project = computed(() => projectId.value ? projectStore.projects[projectId.value] : null) const view = computed(() => project.value?.views.find(v => v.id === props.viewId) as IProjectView || null) -const canWrite = computed(() => baseStore.currentProject?.maxPermission > Permissions.READ && view.value.bucketConfigurationMode === 'manual') +const canWrite = computed(() => { + return baseStore.currentProject?.maxPermission > Permissions.READ && view.value.bucketConfigurationMode === 'manual' +}) const canCreateTasks = computed(() => canWrite.value && projectId.value > 0) const buckets = computed(() => kanbanStore.buckets) const loading = computed(() => kanbanStore.isLoading) diff --git a/frontend/src/stores/config.ts b/frontend/src/stores/config.ts index f759b07df1..212f46b488 100644 --- a/frontend/src/stores/config.ts +++ b/frontend/src/stores/config.ts @@ -43,6 +43,8 @@ export interface ConfigState { }, }, publicTeamsEnabled: boolean, + readerCommentsEnabled: boolean, + assigneeEditEnabled: boolean, } export const useConfigStore = defineStore('config', () => { @@ -81,6 +83,8 @@ export const useConfigStore = defineStore('config', () => { }, }, publicTeamsEnabled: false, + readerCommentsEnabled: false, + assigneeEditEnabled: false, }) const migratorsEnabled = computed(() => state.availableMigrators?.length > 0) diff --git a/frontend/src/views/tasks/TaskDetailView.vue b/frontend/src/views/tasks/TaskDetailView.vue index b9c0a7d8e0..048a71577e 100644 --- a/frontend/src/views/tasks/TaskDetailView.vue +++ b/frontend/src/views/tasks/TaskDetailView.vue @@ -403,7 +403,7 @@ (new TaskModel()) const taskNotFound = ref(false) @@ -786,10 +788,31 @@ const projectRoute = computed(() => ({ hash: route.hash, })) -const canWrite = computed(() => ( - task.value.maxPermission !== null && - task.value.maxPermission > PERMISSIONS.READ -)) +const canWrite = computed(() => { + if (task.value.maxPermission !== null && task.value.maxPermission > PERMISSIONS.READ) { + return true + } + + if (configStore.assigneeEditEnabled && task.value.maxPermission !== null && task.value.maxPermission >= PERMISSIONS.READ) { + // Executor feature: Creator or Assignee can edit if enabled + const userId = authStore.info?.id + if (userId && task.value.id) { + const isCreator = task.value.createdBy?.id === userId + const isAssignee = task.value.assignees?.some(a => a.id === userId) + if (isCreator || isAssignee) { + return true + } + } + } + return false +}) + +const canComment = computed(() => { + if (canWrite.value) { + return true + } + return configStore.readerCommentsEnabled && task.value.maxPermission !== null && task.value.maxPermission >= PERMISSIONS.READ +}) const color = computed(() => { const color = task.value.getHexColor diff --git a/pkg/config/config.go b/pkg/config/config.go index d778f0bab4..fbc28b878a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -69,6 +69,8 @@ const ( ServiceEnablePublicTeams Key = `service.enablepublicteams` ServiceBcryptRounds Key = `service.bcryptrounds` ServiceEnableOpenIDTeamUserOnlySearch Key = `service.enableopenidteamusersearch` + ServiceEnableReaderComments Key = `service.enablereadercomments` + ServiceEnableAssigneeEdit Key = `service.enableassigneeedit` SentryEnabled Key = `sentry.enabled` SentryDsn Key = `sentry.dsn` @@ -356,6 +358,8 @@ func InitDefaultConfig() { ServiceEnablePublicTeams.setDefault(false) ServiceBcryptRounds.setDefault(11) ServiceEnableOpenIDTeamUserOnlySearch.setDefault(false) + ServiceEnableReaderComments.setDefault(false) + ServiceEnableAssigneeEdit.setDefault(false) // Sentry SentryDsn.setDefault("https://440eedc957d545a795c17bbaf477497c@o1047380.ingest.sentry.io/4504254983634944") diff --git a/pkg/models/task_comment_permissions.go b/pkg/models/task_comment_permissions.go index 8bba4ebb65..6d6da2cac9 100644 --- a/pkg/models/task_comment_permissions.go +++ b/pkg/models/task_comment_permissions.go @@ -17,6 +17,7 @@ package models import ( + "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/web" "xorm.io/xorm" ) @@ -67,5 +68,15 @@ func (tc *TaskComment) CanUpdate(s *xorm.Session, a web.Auth) (bool, error) { // CanCreate checks if a user can create a new comment func (tc *TaskComment) CanCreate(s *xorm.Session, a web.Auth) (bool, error) { t := Task{ID: tc.TaskID} + if config.ServiceEnableReaderComments.GetBool() { + canRead, _, err := t.CanRead(s, a) + if err != nil { + return false, err + } + if canRead { + return true, nil + } + } return t.CanWrite(s, a) + } diff --git a/pkg/models/tasks_permissions.go b/pkg/models/tasks_permissions.go index cfae002f19..f0c72268f7 100644 --- a/pkg/models/tasks_permissions.go +++ b/pkg/models/tasks_permissions.go @@ -17,6 +17,7 @@ package models import ( + "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/web" "xorm.io/xorm" ) @@ -28,14 +29,65 @@ func (t *Task) CanDelete(s *xorm.Session, a web.Auth) (bool, error) { // CanUpdate determines if a user has the permission to update a project task func (t *Task) CanUpdate(s *xorm.Session, a web.Auth) (bool, error) { - return t.canDoTask(s, a) + authorized, err := t.canDoTask(s, a) + if err != nil { + return false, err + } + if authorized { + return true, nil + } + + if config.ServiceEnableAssigneeEdit.GetBool() { + ot, err := GetTaskByIDSimple(s, t.ID) + if err != nil { + return false, err + } + + if t.ProjectID != 0 && t.ProjectID != ot.ProjectID { + return false, nil + } + + if ot.CreatedByID == a.GetID() { + l := &Project{ID: ot.ProjectID} + canRead, _, err := l.CanRead(s, a) + if err != nil { + return false, err + } + return canRead, nil + } + + // Check if the user is assigned to the task + exists, err := s.Where("task_id = ? AND user_id = ?", t.ID, a.GetID()).Exist(&TaskAssginee{}) + if err != nil { + return false, err + } + if exists { + l := &Project{ID: ot.ProjectID} + canRead, _, err := l.CanRead(s, a) + if err != nil { + return false, err + } + return canRead, nil + } + + } + + return false, nil } // CanCreate determines if a user has the permission to create a project task func (t *Task) CanCreate(s *xorm.Session, a web.Auth) (bool, error) { // A user can do a task if he has write acces to its project l := &Project{ID: t.ProjectID} - return l.CanWrite(s, a) + canWrite, err := l.CanWrite(s, a) + if err != nil { + return false, err + } + if canWrite { + return true, nil + } + + return false, nil } // CanRead determines if a user can read a task diff --git a/pkg/routes/api/v1/info.go b/pkg/routes/api/v1/info.go index 3bd8f4956a..08321ef3b5 100644 --- a/pkg/routes/api/v1/info.go +++ b/pkg/routes/api/v1/info.go @@ -52,6 +52,8 @@ type vikunjaInfos struct { DemoModeEnabled bool `json:"demo_mode_enabled"` WebhooksEnabled bool `json:"webhooks_enabled"` PublicTeamsEnabled bool `json:"public_teams_enabled"` + ReaderCommentsEnabled bool `json:"reader_comments_enabled"` + AssigneeEditEnabled bool `json:"assignee_edit_enabled"` } type authInfo struct { @@ -103,6 +105,8 @@ func Info(c echo.Context) error { DemoModeEnabled: config.ServiceDemoMode.GetBool(), WebhooksEnabled: config.WebhooksEnabled.GetBool(), PublicTeamsEnabled: config.ServiceEnablePublicTeams.GetBool(), + ReaderCommentsEnabled: config.ServiceEnableReaderComments.GetBool(), + AssigneeEditEnabled: config.ServiceEnableAssigneeEdit.GetBool(), AvailableMigrators: []string{ (&vikunja_file.FileMigrator{}).Name(), (&ticktick.Migrator{}).Name(),