From 4e9c4ffa8cbd2cb382cc6b6c451edaad875e412f Mon Sep 17 00:00:00 2001 From: Sandro Date: Tue, 27 Oct 2026 22:30:28 +0100 Subject: [PATCH] feat(tasks): add read-only sharing permission for task lists 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 --- Doku/sharing-and-permissions.md | 5 +- .../src/features/triage/TaskDetailModal.tsx | 73 ++++++++---- frontend/src/features/triage/TasksView.tsx | 83 +++++++++---- frontend/src/lib/api.ts | 2 +- frontend/src/lib/types.ts | 2 + internal/api/server.go | 42 ++++++- internal/domain/models.go | 2 + internal/storage/sqlite.go | 110 ++++++++++++++++-- internal/storage/sqlite_test.go | 42 ++++++- 9 files changed, 300 insertions(+), 61 deletions(-) diff --git a/Doku/sharing-and-permissions.md b/Doku/sharing-and-permissions.md index cb2eb90..da90f35 100644 --- a/Doku/sharing-and-permissions.md +++ b/Doku/sharing-and-permissions.md @@ -86,7 +86,10 @@ Dasselbe Prinzip gilt für einzeln geteilte Dokumente: ## 📋 5. Geteilte Aufgaben- & Einkaufslisten -- **Aufgabenlisten (`task_list_shares`):** Freigabe für Teams oder Einzelbenutzer mit Lese- oder Schreibrechten. +- **Aufgabenlisten (`task_list_shares`):** Freigabe für Teams oder Einzelbenutzer, Spalte `permission`: + - `editor`: Aufgaben anlegen, bearbeiten, abhaken, verschieben und löschen; Liste umbenennen und Spalten anpassen. + - `viewer`: Nur Lesezugriff. Der Server lehnt Schreibzugriffe mit `403` ab (`storage.ErrTaskListReadOnly`). + - Hat ein Benutzer über mehrere Wege Zugriff (direkt und über Gruppen), gilt die höchste Berechtigung. - **Geteilte Listen (`list_shares`):** - Rolle `editor`: Einträge hinzufügen, ändern, abhaken und löschen. - Rolle `viewer`: Nur Lesezugriff (Echtzeit-Ansicht). diff --git a/frontend/src/features/triage/TaskDetailModal.tsx b/frontend/src/features/triage/TaskDetailModal.tsx index b176250..a1d7dec 100644 --- a/frontend/src/features/triage/TaskDetailModal.tsx +++ b/frontend/src/features/triage/TaskDetailModal.tsx @@ -15,6 +15,7 @@ import { ChevronRight, Folder, Webhook, + Lock, } from 'lucide-react'; import { Thought, SubTask, TaskList, KanbanColumn } from '../../lib/types'; import { triggerHaptic } from '../../lib/api'; @@ -39,6 +40,7 @@ interface TaskDetailModalProps { onConvertToDoc: (task: Thought) => void; onOpenDocument?: (docId: string, blockId?: string) => void; onDeleteTask: (taskId: string) => void; + readOnly?: boolean; // Task belongs to a list shared with "viewer" permission } export const TaskDetailModal: React.FC = ({ @@ -60,6 +62,7 @@ export const TaskDetailModal: React.FC = ({ onConvertToDoc, onOpenDocument, onDeleteTask, + readOnly = false, }) => { if (!isOpen || !task) return null; @@ -176,6 +179,7 @@ export const TaskDetailModal: React.FC = ({
- {task.isCompleted ? 'Erledigte Aufgabe' : 'Aufgabe bearbeiten'} + {readOnly ? 'Aufgabe ansehen' : task.isCompleted ? 'Erledigte Aufgabe' : 'Aufgabe bearbeiten'} + {readOnly && ( + + + Nur Lesen + + )} {task.source?.toLowerCase() === 'webhook' && ( @@ -220,7 +230,7 @@ export const TaskDetailModal: React.FC = ({ In Notiz öffnen - ) : ( + ) : !readOnly && ( )} + {!readOnly && ( + )}
- {/* Scrollable Body */} -
+ {/* Scrollable Body (all controls disabled for read-only shares) */} +
{/* Task Title */}
{isEditingTitle ? ( @@ -303,9 +315,11 @@ export const TaskDetailModal: React.FC = ({
) : (
setIsEditingTitle(true)} - className="group cursor-pointer p-2 rounded-card hover:bg-panel transition-colors border border-transparent hover:border-border" - title="Klicken zum Bearbeiten des Titels" + onClick={() => !readOnly && setIsEditingTitle(true)} + className={`group p-2 rounded-card transition-colors border border-transparent ${ + readOnly ? '' : 'cursor-pointer hover:bg-panel hover:border-border' + }`} + title={readOnly ? undefined : 'Klicken zum Bearbeiten des Titels'} >

= ({ > {task.content}

- - Klicken zum Umbenennen - + {!readOnly && ( + + Klicken zum Umbenennen + + )}
)}
@@ -515,15 +531,18 @@ export const TaskDetailModal: React.FC = ({ className="inline-flex items-center gap-1 px-2 py-0.5 rounded-pill bg-panel border border-border text-xs text-main" > #{tag} - + {!readOnly && ( + + )} ))} + {!readOnly && (
= ({ className="h-6 w-24 px-1.5 text-xs input-field" />
+ )} @@ -587,6 +607,7 @@ export const TaskDetailModal: React.FC = ({ {st.title} + {!readOnly && ( + )} ))} )} {/* Inline add subtask form */} + {!readOnly && (
= ({ HinzufĂĽgen
+ )} {/* Notes Section */} @@ -626,7 +650,7 @@ export const TaskDetailModal: React.FC = ({ Detailnotizen: - {!isEditingNotes && task.notes && ( + {!readOnly && !isEditingNotes && task.notes && ( )} - + {/* Modal Footer */}
diff --git a/frontend/src/features/triage/TasksView.tsx b/frontend/src/features/triage/TasksView.tsx index b785334..5dd7628 100644 --- a/frontend/src/features/triage/TasksView.tsx +++ b/frontend/src/features/triage/TasksView.tsx @@ -229,6 +229,9 @@ export const TasksView: React.FC = ({ const safeTaskLists = Array.isArray(taskLists) ? taskLists : []; const currentSelectedList = safeTaskLists.find((l) => l.id === selectedListId); + const isSelectedListReadOnly = currentSelectedList?.permission === 'viewer'; + const isTaskReadOnly = (task: Thought) => + !!task.taskListId && safeTaskLists.find((l) => l.id === task.taskListId)?.permission === 'viewer'; // Active Kanban Columns (from selected list, or global custom, or default) const activeColumns: KanbanColumn[] = useMemo(() => { @@ -602,6 +605,10 @@ export const TasksView: React.FC = ({ const handleTaskDragStart = (e: React.DragEvent, task: Thought) => { e.stopPropagation(); + if (isTaskReadOnly(task)) { + e.preventDefault(); + return; + } e.dataTransfer.setData('application/mindsift-task-id', task.id); e.dataTransfer.setData('text/plain', task.id); e.dataTransfer.effectAllowed = 'move'; @@ -865,7 +872,7 @@ export const TasksView: React.FC = ({ useEffect(() => { setPrimaryAction(() => { - setMobileCreateListId(selectedListId === 'all' ? '' : selectedListId); + setMobileCreateListId(selectedListId === 'all' || isSelectedListReadOnly ? '' : selectedListId); setShowMobileCreateModal(true); }); @@ -876,7 +883,7 @@ export const TasksView: React.FC = ({ icon: , variant: 'accent', onClick: () => { - setMobileCreateListId(selectedListId === 'all' ? '' : selectedListId); + setMobileCreateListId(selectedListId === 'all' || isSelectedListReadOnly ? '' : selectedListId); setShowMobileCreateModal(true); }, }, @@ -965,7 +972,7 @@ export const TasksView: React.FC = ({ }, ]; - if (viewMode === 'kanban') { + if (viewMode === 'kanban' && !isSelectedListReadOnly) { viewItems.push({ id: 'manage-columns', label: 'Kanban-Spalten verwalten', @@ -1030,6 +1037,7 @@ export const TasksView: React.FC = ({ kanbanTasks.length, safeTaskLists, currentSelectedList, + isSelectedListReadOnly, setPrimaryAction, setGroups, ]); @@ -1071,13 +1079,14 @@ export const TasksView: React.FC = ({ // Clean Task Row in List View const renderTaskRow = (task: Thought) => { const isCompleted = task.isCompleted; + const readOnly = isTaskReadOnly(task); const isDragging = draggingTaskId === task.id; const isTarget = dragOverTaskTarget?.taskId === task.id; return (
handleTaskDragStart(e, task)} onDragEnd={() => { setDraggingTaskId(null); @@ -1091,7 +1100,9 @@ export const TasksView: React.FC = ({ setDetailTaskId(task.id); setShowDetailModal(true); }} - className={`px-3.5 py-3.5 sm:px-3 sm:py-2.5 flex items-center justify-between gap-3 transition-colors hover:bg-panel-hover group border-b border-border last:border-b-0 relative cursor-pointer cursor-grab active:cursor-grabbing select-none ${ + className={`px-3.5 py-3.5 sm:px-3 sm:py-2.5 flex items-center justify-between gap-3 transition-colors hover:bg-panel-hover group border-b border-border last:border-b-0 relative cursor-pointer select-none ${ + readOnly ? '' : 'cursor-grab active:cursor-grabbing' + } ${ isCompleted ? 'opacity-65' : '' } ${isDragging ? 'opacity-30 border border-dashed border-accent/40' : ''}`} > @@ -1108,17 +1119,18 @@ export const TasksView: React.FC = ({ @@ -1169,6 +1181,7 @@ export const TasksView: React.FC = ({ {/* Kanban status selector pill (Desktop only - on mobile done via TaskDetailModal) */}