From 0bd638d200f38afa2390e9376033b4b46941068f Mon Sep 17 00:00:00 2001 From: ruodeng Date: Mon, 22 Dec 2025 11:20:40 +0800 Subject: [PATCH] feat: implement executor features for read users via config Splits the original enableexecutorfeatures flag into enablereadercomments and enableassigneeedit settings for granular control. This change includes all backend and frontend modifications to support commenting and task editing for read-only users. --- config-raw.json | 10 ++++ .../project/views/ProjectKanban.vue | 4 +- frontend/src/stores/config.ts | 4 ++ frontend/src/views/tasks/TaskDetailView.vue | 33 +++++++++-- pkg/config/config.go | 4 ++ pkg/models/task_comment_permissions.go | 11 ++++ pkg/models/tasks_permissions.go | 56 ++++++++++++++++++- pkg/routes/api/v1/info.go | 4 ++ 8 files changed, 118 insertions(+), 8 deletions(-) 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(),