feat(tasks): add read-only sharing permission for task lists
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m5s
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:
1 parent
6896a29285
commit
4e9c4ffa8c
9 files changed
+300
-61
No files matched your search
+100
-10
@@ -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()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
@@ -118,7 +119,7 @@ func TestStoreLifecycle(t *testing.T) {
|
||||
}
|
||||
|
||||
// Test TaskList Sharing with Team
|
||||
shareTeam, err := store.AddTaskListShare(taskList.ID, "team", team.ID, user.ID)
|
||||
shareTeam, err := store.AddTaskListShare(taskList.ID, "team", team.ID, "editor", user.ID)
|
||||
if err != nil || shareTeam.ID == "" {
|
||||
t.Fatalf("AddTaskListShare team failed: %v", err)
|
||||
}
|
||||
@@ -147,6 +148,45 @@ func TestStoreLifecycle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser collab failed: %v", err)
|
||||
}
|
||||
|
||||
// Test read-only TaskList share
|
||||
ownerTask, err := store.CreateThoughtWithDetails(user.ID, "Rasen mähen", "normal", "manual", "", taskList.ID, "todo", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateThoughtWithDetails in own list failed: %v", err)
|
||||
}
|
||||
if _, err := store.UpdateThoughtTriage(user.ID, ownerTask.ID, "keep", "task", "", "", taskList.ID); err != nil {
|
||||
t.Fatalf("UpdateThoughtTriage in own list failed: %v", err)
|
||||
}
|
||||
if _, err := store.AddTaskListShare(taskList.ID, "user", user2.ID, "viewer", user.ID); err != nil {
|
||||
t.Fatalf("AddTaskListShare viewer failed: %v", err)
|
||||
}
|
||||
if perm := store.GetTaskListPermission(user2.ID, taskList.ID); perm != "viewer" {
|
||||
t.Fatalf("expected viewer permission, got %q", perm)
|
||||
}
|
||||
viewerTasks, err := store.GetTasks(user2.ID, taskList.ID)
|
||||
if err != nil || len(viewerTasks) == 0 {
|
||||
t.Fatalf("viewer should see tasks of shared list: %v", err)
|
||||
}
|
||||
done := true
|
||||
if _, err := store.UpdateThoughtDetails(user2.ID, ownerTask.ID, nil, &done, nil, nil, nil, nil, nil, nil, nil); !errors.Is(err, ErrTaskListReadOnly) {
|
||||
t.Fatalf("viewer must not update tasks, got %v", err)
|
||||
}
|
||||
if _, err := store.CreateThoughtWithDetails(user2.ID, "Hack", "normal", "manual", "", taskList.ID, "todo", ""); !errors.Is(err, ErrTaskListReadOnly) {
|
||||
t.Fatalf("viewer must not create tasks, got %v", err)
|
||||
}
|
||||
if err := store.DeleteThought(user2.ID, ownerTask.ID); !errors.Is(err, ErrTaskListReadOnly) {
|
||||
t.Fatalf("viewer must not delete tasks, got %v", err)
|
||||
}
|
||||
newTitle := "Umbenannt"
|
||||
if _, err := store.UpdateTaskList(user2.ID, taskList.ID, &newTitle, nil, nil, nil, nil, nil); !errors.Is(err, ErrTaskListReadOnly) {
|
||||
t.Fatalf("viewer must not update task list, got %v", err)
|
||||
}
|
||||
if _, err := store.AddTaskListShare(taskList.ID, "user", user2.ID, "editor", user.ID); err != nil {
|
||||
t.Fatalf("upgrading share to editor failed: %v", err)
|
||||
}
|
||||
if !store.CanEditTaskList(user2.ID, taskList.ID) {
|
||||
t.Fatalf("editor share should allow editing")
|
||||
}
|
||||
newDoc, err := store.CreateDocument(user.ID, "", "Test Note", "📄", "", []domain.DocumentBlock{
|
||||
{ID: "blk_1", Type: "bullet", Content: "Geheim", Level: 0},
|
||||
}, []string{"test"}, false)
|
||||
|
||||
Reference in new issue
Block a user