feat(tasks): add read-only sharing permission for task lists
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m5s

Task list shares now carry a permission (editor/viewer). The server rejects
writes from viewers with 403, and the UI hides or disables edit controls
for read-only lists.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Sonnet 5.5 committed 2026-10-27 22:30:28 +01:00
1 parent 6896a29285
commit 4e9c4ffa8c
9 files changed
+300 -61

No files matched your search

+100 -10
View File
@@ -356,6 +356,7 @@ func (s *Store) migrate() error {
"CREATE TABLE IF NOT EXISTS task_list_shares (id TEXT PRIMARY KEY, task_list_id TEXT NOT NULL, target_type TEXT NOT NULL CHECK(target_type IN ('team', 'user')), target_id TEXT NOT NULL, created_at TEXT NOT NULL, created_by TEXT NOT NULL, UNIQUE(task_list_id, target_type, target_id));",
"CREATE INDEX IF NOT EXISTS idx_task_list_shares_target ON task_list_shares(target_type, target_id);",
"CREATE INDEX IF NOT EXISTS idx_task_list_shares_list ON task_list_shares(task_list_id);",
"ALTER TABLE task_list_shares ADD COLUMN permission TEXT NOT NULL DEFAULT 'editor';",
"CREATE TABLE IF NOT EXISTS doc_team_shares (id TEXT PRIMARY KEY, doc_id TEXT NOT NULL, team_id TEXT NOT NULL, permission TEXT NOT NULL CHECK(permission IN ('read', 'write')), created_at TEXT NOT NULL, created_by TEXT NOT NULL, UNIQUE(doc_id, team_id));",
"CREATE INDEX IF NOT EXISTS idx_doc_team_shares_team ON doc_team_shares(team_id);",
@@ -910,6 +911,9 @@ func (s *Store) CreateThoughtWithDetails(userID, content, priority, source, dueD
if kanbanStatus == "" {
kanbanStatus = "todo"
}
if err := s.ensureTaskListWritable(userID, taskListID); err != nil {
return nil, err
}
tags := domain.ExtractTags(content)
if strings.EqualFold(source, "voice") {
@@ -1145,8 +1149,13 @@ func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, target
if dueDate == "" {
dueDate = thought.DueDate
}
if err := s.ensureTaskListWritable(userID, thought.TaskListID); err != nil {
return nil, err
}
if taskListID == "" {
taskListID = thought.TaskListID
} else if err := s.ensureTaskListWritable(userID, taskListID); err != nil {
return nil, err
}
switch action {
@@ -1254,6 +1263,14 @@ func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string,
if err != nil {
return nil, err
}
if err := s.ensureTaskListWritable(userID, thought.TaskListID); err != nil {
return nil, err
}
if taskListID != nil && *taskListID != thought.TaskListID {
if err := s.ensureTaskListWritable(userID, *taskListID); err != nil {
return nil, err
}
}
if content != nil {
thought.Content = strings.TrimSpace(*content)
@@ -1322,6 +1339,9 @@ func (s *Store) DeleteThought(userID, thoughtID string) error {
if err != nil {
return err
}
if err := s.ensureTaskListWritable(userID, thought.TaskListID); err != nil {
return err
}
_, err = s.db.Exec(`DELETE FROM thoughts WHERE id = ?`, thought.ID)
return err
}
@@ -4578,12 +4598,73 @@ func (s *Store) CreateTaskList(userID, spaceID, teamID, title, icon, color strin
return nil, err
}
if teamID != "" {
_, _ = s.AddTaskListShare(tl.ID, "team", teamID, userID)
_, _ = s.AddTaskListShare(tl.ID, "team", teamID, "editor", userID)
}
return s.GetTaskListByID(userID, tl.ID)
}
func (s *Store) AddTaskListShare(taskListID, targetType, targetID, createdBy string) (*domain.TaskListShare, error) {
// ErrTaskListReadOnly is returned when a user with viewer permission tries to modify a task list or its tasks.
var ErrTaskListReadOnly = errors.New("Keine Schreibrechte für diese Aufgabenliste (nur Lesen)")
// GetTaskListPermission returns "owner", "editor", "viewer" or "" (no access) for the given user.
func (s *Store) GetTaskListPermission(userID, listID string) string {
var ownerID, teamID string
if err := s.db.QueryRow(`SELECT user_id, COALESCE(team_id, '') FROM task_lists WHERE id = ?`, listID).Scan(&ownerID, &teamID); err != nil {
return ""
}
if ownerID == userID {
return "owner"
}
rows, err := s.db.Query(
`SELECT permission FROM task_list_shares
WHERE task_list_id = ? AND (
(target_type = 'user' AND target_id = ?)
OR (target_type = 'team' AND target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
)`,
listID, userID, userID,
)
perm := ""
if err == nil {
for rows.Next() {
var p string
if rows.Scan(&p) == nil {
if p == "viewer" {
if perm == "" {
perm = "viewer"
}
} else {
perm = "editor"
}
}
}
_ = rows.Close()
}
if perm == "" && teamID != "" {
// Legacy team assignment without a share row grants edit access
var isMember, hasShareRow int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM team_members WHERE team_id = ? AND user_id = ?`, teamID, userID).Scan(&isMember)
_ = s.db.QueryRow(`SELECT COUNT(*) FROM task_list_shares WHERE task_list_id = ? AND target_type = 'team' AND target_id = ?`, listID, teamID).Scan(&hasShareRow)
if isMember > 0 && hasShareRow == 0 {
perm = "editor"
}
}
return perm
}
func (s *Store) CanEditTaskList(userID, listID string) bool {
p := s.GetTaskListPermission(userID, listID)
return p == "owner" || p == "editor"
}
// ensureTaskListWritable returns ErrTaskListReadOnly if the list is set and the user may not modify it.
func (s *Store) ensureTaskListWritable(userID, listID string) error {
if listID == "" || s.CanEditTaskList(userID, listID) {
return nil
}
return ErrTaskListReadOnly
}
func (s *Store) AddTaskListShare(taskListID, targetType, targetID, permission, createdBy string) (*domain.TaskListShare, error) {
if targetType != "team" && targetType != "user" {
return nil, errors.New("ungültiger Freigabetyp")
}
@@ -4591,19 +4672,23 @@ func (s *Store) AddTaskListShare(taskListID, targetType, targetID, createdBy str
if targetID == "" {
return nil, errors.New("Ziel-ID erforderlich")
}
if permission != "viewer" {
permission = "editor"
}
now := domain.NowISO()
share := &domain.TaskListShare{
ID: domain.NewID("tls"),
TaskListID: taskListID,
TargetType: targetType,
TargetID: targetID,
Permission: permission,
CreatedAt: now,
}
_, err := s.db.Exec(
`INSERT INTO task_list_shares (id, task_list_id, target_type, target_id, created_at, created_by)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(task_list_id, target_type, target_id) DO UPDATE SET created_at = excluded.created_at`,
share.ID, share.TaskListID, share.TargetType, share.TargetID, share.CreatedAt, createdBy,
`INSERT INTO task_list_shares (id, task_list_id, target_type, target_id, permission, created_at, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(task_list_id, target_type, target_id) DO UPDATE SET created_at = excluded.created_at, permission = excluded.permission`,
share.ID, share.TaskListID, share.TargetType, share.TargetID, share.Permission, share.CreatedAt, createdBy,
)
if err != nil {
return nil, err
@@ -4636,7 +4721,7 @@ func (s *Store) RemoveTaskListShare(taskListID, shareIDOrTargetID string) error
func (s *Store) GetTaskListShares(taskListID string) ([]domain.TaskListShare, error) {
rows, err := s.db.Query(
`SELECT id, task_list_id, target_type, target_id, created_at
`SELECT id, task_list_id, target_type, target_id, permission, created_at
FROM task_list_shares WHERE task_list_id = ? ORDER BY created_at ASC`,
taskListID,
)
@@ -4648,7 +4733,7 @@ func (s *Store) GetTaskListShares(taskListID string) ([]domain.TaskListShare, er
shares := make([]domain.TaskListShare, 0)
for rows.Next() {
var sh domain.TaskListShare
if err := rows.Scan(&sh.ID, &sh.TaskListID, &sh.TargetType, &sh.TargetID, &sh.CreatedAt); err == nil {
if err := rows.Scan(&sh.ID, &sh.TaskListID, &sh.TargetType, &sh.TargetID, &sh.Permission, &sh.CreatedAt); err == nil {
shares = append(shares, sh)
}
}
@@ -4783,6 +4868,7 @@ func (s *Store) GetTaskLists(userID string) ([]domain.TaskList, error) {
shares, _ := s.GetTaskListShares(list[i].ID)
list[i].Shares = shares
list[i].Permission = s.GetTaskListPermission(userID, list[i].ID)
list[i].Members = s.getMembersForTaskList(&list[i])
}
return list, nil
@@ -4825,6 +4911,7 @@ func (s *Store) GetTaskListByID(userID, listID string) (*domain.TaskList, error)
}
shares, _ := s.GetTaskListShares(tl.ID)
tl.Shares = shares
tl.Permission = s.GetTaskListPermission(userID, tl.ID)
tl.Members = s.getMembersForTaskList(&tl)
return &tl, nil
}
@@ -4834,6 +4921,9 @@ func (s *Store) UpdateTaskList(userID, listID string, title, icon, color, teamID
if err != nil {
return nil, err
}
if tl.Permission == "viewer" {
return nil, ErrTaskListReadOnly
}
if title != nil {
tl.Title = strings.TrimSpace(*title)
}
@@ -4855,8 +4945,8 @@ func (s *Store) UpdateTaskList(userID, listID string, title, icon, color, teamID
if oldTeamID != "" && oldTeamID != *teamID {
_ = s.RemoveTaskListShare(listID, oldTeamID)
}
if *teamID != "" {
_, _ = s.AddTaskListShare(listID, "team", *teamID, userID)
if *teamID != "" && *teamID != oldTeamID {
_, _ = s.AddTaskListShare(listID, "team", *teamID, "editor", userID)
}
}
tl.UpdatedAt = domain.NowISO()