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

+4 -1
View File
@@ -86,7 +86,10 @@ Dasselbe Prinzip gilt für einzeln geteilte Dokumente:
## 📋 5. Geteilte Aufgaben- & Einkaufslisten ## 📋 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`):** - **Geteilte Listen (`list_shares`):**
- Rolle `editor`: Einträge hinzufügen, ändern, abhaken und löschen. - Rolle `editor`: Einträge hinzufügen, ändern, abhaken und löschen.
- Rolle `viewer`: Nur Lesezugriff (Echtzeit-Ansicht). - Rolle `viewer`: Nur Lesezugriff (Echtzeit-Ansicht).
@@ -15,6 +15,7 @@ import {
ChevronRight, ChevronRight,
Folder, Folder,
Webhook, Webhook,
Lock,
} from 'lucide-react'; } from 'lucide-react';
import { Thought, SubTask, TaskList, KanbanColumn } from '../../lib/types'; import { Thought, SubTask, TaskList, KanbanColumn } from '../../lib/types';
import { triggerHaptic } from '../../lib/api'; import { triggerHaptic } from '../../lib/api';
@@ -39,6 +40,7 @@ interface TaskDetailModalProps {
onConvertToDoc: (task: Thought) => void; onConvertToDoc: (task: Thought) => void;
onOpenDocument?: (docId: string, blockId?: string) => void; onOpenDocument?: (docId: string, blockId?: string) => void;
onDeleteTask: (taskId: string) => void; onDeleteTask: (taskId: string) => void;
readOnly?: boolean; // Task belongs to a list shared with "viewer" permission
} }
export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
@@ -60,6 +62,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
onConvertToDoc, onConvertToDoc,
onOpenDocument, onOpenDocument,
onDeleteTask, onDeleteTask,
readOnly = false,
}) => { }) => {
if (!isOpen || !task) return null; if (!isOpen || !task) return null;
@@ -176,6 +179,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-panel"> <div className="flex items-center justify-between px-4 py-3 border-b border-border bg-panel">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<button <button
disabled={readOnly}
onClick={() => { onClick={() => {
triggerHaptic(15); triggerHaptic(15);
const nextState = !task.isCompleted; const nextState = !task.isCompleted;
@@ -184,7 +188,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
const nextKanban = nextState ? (doneCol ? doneCol.id : 'done') : (todoCol ? todoCol.id : 'todo'); const nextKanban = nextState ? (doneCol ? doneCol.id : 'done') : (todoCol ? todoCol.id : 'todo');
onUpdateStatus(task, nextKanban); onUpdateStatus(task, nextKanban);
}} }}
className={`w-5 h-5 checkbox-square flex items-center justify-center flex-shrink-0 transition-colors border ${ className={`w-5 h-5 checkbox-square flex items-center justify-center flex-shrink-0 transition-colors border disabled:cursor-default ${
task.isCompleted task.isCompleted
? 'bg-status-ok border-status-ok text-on-accent' ? 'bg-status-ok border-status-ok text-on-accent'
: 'border-strong hover:border-accent text-transparent' : 'border-strong hover:border-accent text-transparent'
@@ -194,8 +198,14 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
<Check className="w-3.5 h-3.5 stroke-[3]" /> <Check className="w-3.5 h-3.5 stroke-[3]" />
</button> </button>
<span className="text-xs font-semibold text-main truncate"> <span className="text-xs font-semibold text-main truncate">
{task.isCompleted ? 'Erledigte Aufgabe' : 'Aufgabe bearbeiten'} {readOnly ? 'Aufgabe ansehen' : task.isCompleted ? 'Erledigte Aufgabe' : 'Aufgabe bearbeiten'}
</span> </span>
{readOnly && (
<span className="px-1.5 py-0.5 border border-border bg-canvas text-muted rounded-btn text-[10px] font-medium flex items-center gap-1 flex-shrink-0">
<Lock className="w-2.5 h-2.5 text-faint" />
<span>Nur Lesen</span>
</span>
)}
{task.source?.toLowerCase() === 'webhook' && ( {task.source?.toLowerCase() === 'webhook' && (
<span className="px-1.5 py-0.5 border border-border bg-canvas text-muted rounded-btn text-[10px] font-medium flex items-center gap-1 flex-shrink-0"> <span className="px-1.5 py-0.5 border border-border bg-canvas text-muted rounded-btn text-[10px] font-medium flex items-center gap-1 flex-shrink-0">
<Webhook className="w-2.5 h-2.5 text-accent" /> <Webhook className="w-2.5 h-2.5 text-accent" />
@@ -220,7 +230,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
<FileText className="w-4 h-4 sm:w-3.5 sm:h-3.5" /> <FileText className="w-4 h-4 sm:w-3.5 sm:h-3.5" />
<span>In Notiz öffnen</span> <span>In Notiz öffnen</span>
</button> </button>
) : ( ) : !readOnly && (
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
@@ -234,6 +244,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
<span className="hidden sm:inline">Als Notiz vertiefen</span> <span className="hidden sm:inline">Als Notiz vertiefen</span>
</button> </button>
)} )}
{!readOnly && (
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
@@ -248,6 +259,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
<Trash2 className="w-4 h-4 sm:w-3.5 sm:h-3.5" /> <Trash2 className="w-4 h-4 sm:w-3.5 sm:h-3.5" />
<span className="sm:hidden font-medium">Löschen</span> <span className="sm:hidden font-medium">Löschen</span>
</button> </button>
)}
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
@@ -259,8 +271,8 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
</div> </div>
</div> </div>
{/* Scrollable Body */} {/* Scrollable Body (all controls disabled for read-only shares) */}
<div className="p-4 overflow-y-auto space-y-4 flex-1"> <fieldset disabled={readOnly} className="p-4 overflow-y-auto space-y-4 flex-1 min-w-0 border-0 m-0">
{/* Task Title */} {/* Task Title */}
<div> <div>
{isEditingTitle ? ( {isEditingTitle ? (
@@ -303,9 +315,11 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
</div> </div>
) : ( ) : (
<div <div
onClick={() => setIsEditingTitle(true)} onClick={() => !readOnly && setIsEditingTitle(true)}
className="group cursor-pointer p-2 rounded-card hover:bg-panel transition-colors border border-transparent hover:border-border" className={`group p-2 rounded-card transition-colors border border-transparent ${
title="Klicken zum Bearbeiten des Titels" readOnly ? '' : 'cursor-pointer hover:bg-panel hover:border-border'
}`}
title={readOnly ? undefined : 'Klicken zum Bearbeiten des Titels'}
> >
<h2 <h2
className={`text-base font-semibold break-words leading-snug ${ className={`text-base font-semibold break-words leading-snug ${
@@ -314,9 +328,11 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
> >
{task.content} {task.content}
</h2> </h2>
<span className="text-[10px] text-muted opacity-0 group-hover:opacity-100 transition-opacity"> {!readOnly && (
Klicken zum Umbenennen <span className="text-[10px] text-muted opacity-0 group-hover:opacity-100 transition-opacity">
</span> Klicken zum Umbenennen
</span>
)}
</div> </div>
)} )}
</div> </div>
@@ -515,15 +531,18 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-pill bg-panel border border-border text-xs text-main" className="inline-flex items-center gap-1 px-2 py-0.5 rounded-pill bg-panel border border-border text-xs text-main"
> >
<span>#{tag}</span> <span>#{tag}</span>
<button {!readOnly && (
type="button" <button
onClick={() => handleRemoveTag(tag)} type="button"
className="text-muted hover:text-status-err" onClick={() => handleRemoveTag(tag)}
> className="text-muted hover:text-status-err"
<X className="w-3 h-3" /> >
</button> <X className="w-3 h-3" />
</button>
)}
</span> </span>
))} ))}
{!readOnly && (
<form onSubmit={handleAddTagSubmit} className="inline-flex items-center gap-1"> <form onSubmit={handleAddTagSubmit} className="inline-flex items-center gap-1">
<input <input
type="text" type="text"
@@ -533,6 +552,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
className="h-6 w-24 px-1.5 text-xs input-field" className="h-6 w-24 px-1.5 text-xs input-field"
/> />
</form> </form>
)}
</div> </div>
</div> </div>
@@ -587,6 +607,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
{st.title} {st.title}
</span> </span>
</div> </div>
{!readOnly && (
<button <button
type="button" type="button"
onClick={() => onDeleteSubtask(task, st.id)} onClick={() => onDeleteSubtask(task, st.id)}
@@ -594,12 +615,14 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
> >
<Trash2 className="w-3 h-3" /> <Trash2 className="w-3 h-3" />
</button> </button>
)}
</div> </div>
))} ))}
</div> </div>
)} )}
{/* Inline add subtask form */} {/* Inline add subtask form */}
{!readOnly && (
<form onSubmit={handleAddSubtaskSubmit} className="flex items-center gap-1.5"> <form onSubmit={handleAddSubtaskSubmit} className="flex items-center gap-1.5">
<input <input
type="text" type="text"
@@ -617,6 +640,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
<span>Hinzufügen</span> <span>Hinzufügen</span>
</button> </button>
</form> </form>
)}
</div> </div>
{/* Notes Section */} {/* Notes Section */}
@@ -626,7 +650,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
<FileText className="w-3.5 h-3.5 text-accent" /> <FileText className="w-3.5 h-3.5 text-accent" />
<span>Detailnotizen:</span> <span>Detailnotizen:</span>
</label> </label>
{!isEditingNotes && task.notes && ( {!readOnly && !isEditingNotes && task.notes && (
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
@@ -673,14 +697,19 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
) : task.notes ? ( ) : task.notes ? (
<div <div
onClick={() => { onClick={() => {
if (readOnly) return;
setNotesDraft(task.notes || ''); setNotesDraft(task.notes || '');
setIsEditingNotes(true); setIsEditingNotes(true);
}} }}
className="p-3 bg-canvas border border-border rounded-card text-xs text-muted leading-relaxed cursor-pointer hover:border-strong transition-colors" className={`p-3 bg-canvas border border-border rounded-card text-xs text-muted leading-relaxed transition-colors ${
title="Klicken zum Bearbeiten" readOnly ? '' : 'cursor-pointer hover:border-strong'
}`}
title={readOnly ? undefined : 'Klicken zum Bearbeiten'}
> >
<MarkdownViewer content={task.notes} /> <MarkdownViewer content={task.notes} />
</div> </div>
) : readOnly ? (
<p className="text-xs text-faint">Keine Detailnotizen.</p>
) : ( ) : (
<button <button
type="button" type="button"
@@ -695,7 +724,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
</button> </button>
)} )}
</div> </div>
</div> </fieldset>
{/* Modal Footer */} {/* Modal Footer */}
<div className="flex items-center justify-between px-4 py-2.5 border-t border-border bg-panel"> <div className="flex items-center justify-between px-4 py-2.5 border-t border-border bg-panel">
+63 -20
View File
@@ -229,6 +229,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
const safeTaskLists = Array.isArray(taskLists) ? taskLists : []; const safeTaskLists = Array.isArray(taskLists) ? taskLists : [];
const currentSelectedList = safeTaskLists.find((l) => l.id === selectedListId); 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) // Active Kanban Columns (from selected list, or global custom, or default)
const activeColumns: KanbanColumn[] = useMemo(() => { const activeColumns: KanbanColumn[] = useMemo(() => {
@@ -602,6 +605,10 @@ export const TasksView: React.FC<TasksViewProps> = ({
const handleTaskDragStart = (e: React.DragEvent, task: Thought) => { const handleTaskDragStart = (e: React.DragEvent, task: Thought) => {
e.stopPropagation(); e.stopPropagation();
if (isTaskReadOnly(task)) {
e.preventDefault();
return;
}
e.dataTransfer.setData('application/mindsift-task-id', task.id); e.dataTransfer.setData('application/mindsift-task-id', task.id);
e.dataTransfer.setData('text/plain', task.id); e.dataTransfer.setData('text/plain', task.id);
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.effectAllowed = 'move';
@@ -865,7 +872,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
useEffect(() => { useEffect(() => {
setPrimaryAction(() => { setPrimaryAction(() => {
setMobileCreateListId(selectedListId === 'all' ? '' : selectedListId); setMobileCreateListId(selectedListId === 'all' || isSelectedListReadOnly ? '' : selectedListId);
setShowMobileCreateModal(true); setShowMobileCreateModal(true);
}); });
@@ -876,7 +883,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
icon: <Plus className="w-4 h-4" />, icon: <Plus className="w-4 h-4" />,
variant: 'accent', variant: 'accent',
onClick: () => { onClick: () => {
setMobileCreateListId(selectedListId === 'all' ? '' : selectedListId); setMobileCreateListId(selectedListId === 'all' || isSelectedListReadOnly ? '' : selectedListId);
setShowMobileCreateModal(true); setShowMobileCreateModal(true);
}, },
}, },
@@ -965,7 +972,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
}, },
]; ];
if (viewMode === 'kanban') { if (viewMode === 'kanban' && !isSelectedListReadOnly) {
viewItems.push({ viewItems.push({
id: 'manage-columns', id: 'manage-columns',
label: 'Kanban-Spalten verwalten', label: 'Kanban-Spalten verwalten',
@@ -1030,6 +1037,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
kanbanTasks.length, kanbanTasks.length,
safeTaskLists, safeTaskLists,
currentSelectedList, currentSelectedList,
isSelectedListReadOnly,
setPrimaryAction, setPrimaryAction,
setGroups, setGroups,
]); ]);
@@ -1071,13 +1079,14 @@ export const TasksView: React.FC<TasksViewProps> = ({
// Clean Task Row in List View // Clean Task Row in List View
const renderTaskRow = (task: Thought) => { const renderTaskRow = (task: Thought) => {
const isCompleted = task.isCompleted; const isCompleted = task.isCompleted;
const readOnly = isTaskReadOnly(task);
const isDragging = draggingTaskId === task.id; const isDragging = draggingTaskId === task.id;
const isTarget = dragOverTaskTarget?.taskId === task.id; const isTarget = dragOverTaskTarget?.taskId === task.id;
return ( return (
<div <div
key={task.id} key={task.id}
draggable draggable={!readOnly}
onDragStart={(e) => handleTaskDragStart(e, task)} onDragStart={(e) => handleTaskDragStart(e, task)}
onDragEnd={() => { onDragEnd={() => {
setDraggingTaskId(null); setDraggingTaskId(null);
@@ -1091,7 +1100,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
setDetailTaskId(task.id); setDetailTaskId(task.id);
setShowDetailModal(true); 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' : '' isCompleted ? 'opacity-65' : ''
} ${isDragging ? 'opacity-30 border border-dashed border-accent/40' : ''}`} } ${isDragging ? 'opacity-30 border border-dashed border-accent/40' : ''}`}
> >
@@ -1108,17 +1119,18 @@ export const TasksView: React.FC<TasksViewProps> = ({
<button <button
type="button" type="button"
draggable={false} draggable={false}
disabled={readOnly}
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleToggleDone(task); handleToggleDone(task);
}} }}
className={`w-5 h-5 sm:w-4 sm:h-4 checkbox-square flex items-center justify-center flex-shrink-0 transition-colors border ${ className={`w-5 h-5 sm:w-4 sm:h-4 checkbox-square flex items-center justify-center flex-shrink-0 transition-colors border disabled:cursor-default ${
isCompleted isCompleted
? 'bg-status-ok border-status-ok text-on-accent' ? 'bg-status-ok border-status-ok text-on-accent'
: 'border-strong hover:border-accent text-transparent' : 'border-strong hover:border-accent text-transparent'
}`} }`}
title={isCompleted ? 'Als offen markieren' : 'Als erledigt abhaken'} title={readOnly ? 'Nur Lesen' : isCompleted ? 'Als offen markieren' : 'Als erledigt abhaken'}
> >
<Check className="w-3.5 h-3.5 sm:w-3 sm:h-3 stroke-[3]" /> <Check className="w-3.5 h-3.5 sm:w-3 sm:h-3 stroke-[3]" />
</button> </button>
@@ -1169,6 +1181,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
{/* Kanban status selector pill (Desktop only - on mobile done via TaskDetailModal) */} {/* Kanban status selector pill (Desktop only - on mobile done via TaskDetailModal) */}
<select <select
value={task.kanbanStatus || (isCompleted ? 'done' : 'todo')} value={task.kanbanStatus || (isCompleted ? 'done' : 'todo')}
disabled={readOnly}
onChange={(e) => handleSetKanbanStatus(task, e.target.value)} onChange={(e) => handleSetKanbanStatus(task, e.target.value)}
className="hidden sm:inline-block h-5 px-1.5 text-[10px] rounded-btn border border-border bg-panel text-muted hover:text-main cursor-pointer" className="hidden sm:inline-block h-5 px-1.5 text-[10px] rounded-btn border border-border bg-panel text-muted hover:text-main cursor-pointer"
title="Status / Zustand ändern" title="Status / Zustand ändern"
@@ -1184,8 +1197,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
{task.priority === 'urgent' && ( {task.priority === 'urgent' && (
<button <button
type="button" type="button"
disabled={readOnly}
onClick={() => handleCyclePriority(task)} onClick={() => handleCyclePriority(task)}
className="badge-accent text-[10px] cursor-pointer hover:opacity-85" className="badge-accent text-[10px] cursor-pointer hover:opacity-85 disabled:cursor-default"
title="Klicken zum Ändern der Priorität (Dringend -> Normal)" title="Klicken zum Ändern der Priorität (Dringend -> Normal)"
> >
🔥 Dringend 🔥 Dringend
@@ -1194,8 +1208,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
{task.priority === 'high' && ( {task.priority === 'high' && (
<button <button
type="button" type="button"
disabled={readOnly}
onClick={() => handleCyclePriority(task)} onClick={() => handleCyclePriority(task)}
className="px-1.5 py-0.5 border border-status-warn text-status-warn rounded-btn text-[10px] font-medium cursor-pointer hover:opacity-85" className="px-1.5 py-0.5 border border-status-warn text-status-warn rounded-btn text-[10px] font-medium cursor-pointer hover:opacity-85 disabled:cursor-default"
title="Klicken zum Ändern der Priorität (Wichtig -> Dringend)" title="Klicken zum Ändern der Priorität (Wichtig -> Dringend)"
> >
⚠️ Wichtig ⚠️ Wichtig
@@ -1266,10 +1281,12 @@ export const TasksView: React.FC<TasksViewProps> = ({
setShowDetailModal(true); setShowDetailModal(true);
}} }}
className="text-faint hover:text-accent p-1.5 transition-colors" className="text-faint hover:text-accent p-1.5 transition-colors"
title="Aufgabe bearbeiten" title={readOnly ? 'Aufgabe ansehen' : 'Aufgabe bearbeiten'}
> >
<Edit3 className="w-3.5 h-3.5" /> {readOnly ? <Lock className="w-3.5 h-3.5" /> : <Edit3 className="w-3.5 h-3.5" />}
</button> </button>
{!readOnly && (
<>
<button <button
type="button" type="button"
draggable={false} draggable={false}
@@ -1288,6 +1305,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
> >
<Trash2 className="w-3.5 h-3.5" /> <Trash2 className="w-3.5 h-3.5" />
</button> </button>
</>
)}
</div> </div>
</div> </div>
); );
@@ -1479,7 +1498,17 @@ export const TasksView: React.FC<TasksViewProps> = ({
)} )}
</div> </div>
{isSelectedListReadOnly && (
<div className="flex items-center gap-2 p-2.5 rounded-btn bg-panel-elevated border border-border text-xs text-muted">
<Lock className="w-3.5 h-3.5 text-faint shrink-0" />
<span>
Diese Liste wurde von {currentSelectedList?.ownerName || 'einem anderen Benutzer'} nur zum Lesen freigegeben.
</span>
</div>
)}
{/* Quick Add Task Input (Desktop only - on mobile, tapping the bottom '+' FAB opens mobile create sheet) */} {/* Quick Add Task Input (Desktop only - on mobile, tapping the bottom '+' FAB opens mobile create sheet) */}
{!isSelectedListReadOnly && (
<form <form
onSubmit={handleQuickAddTask} onSubmit={handleQuickAddTask}
className="hidden sm:flex items-center gap-1.5 panel-surface p-2 sm:p-1.5" className="hidden sm:flex items-center gap-1.5 panel-surface p-2 sm:p-1.5"
@@ -1514,6 +1543,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
</button> </button>
</div> </div>
</form> </form>
)}
{/* Mobile Active Filter Badge Banner (minimal and clean) */} {/* Mobile Active Filter Badge Banner (minimal and clean) */}
{(selectedTag || groupBy !== 'none' || sortBy !== 'custom') && ( {(selectedTag || groupBy !== 'none' || sortBy !== 'custom') && (
@@ -1598,6 +1628,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
{/* Group By & Sort By Dropdowns & Manage Columns button */} {/* Group By & Sort By Dropdowns & Manage Columns button */}
<div className="flex items-center gap-2 flex-shrink-0 ml-auto"> <div className="flex items-center gap-2 flex-shrink-0 ml-auto">
{viewMode === 'kanban' ? ( {viewMode === 'kanban' ? (
!isSelectedListReadOnly && (
<button <button
onClick={() => setShowManageColumnsModal(true)} onClick={() => setShowManageColumnsModal(true)}
className="btn-action h-7 px-2.5 text-xs flex items-center gap-1.5" className="btn-action h-7 px-2.5 text-xs flex items-center gap-1.5"
@@ -1606,6 +1637,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
<Columns className="w-3.5 h-3.5 text-accent" /> <Columns className="w-3.5 h-3.5 text-accent" />
<span>Spalten anpassen</span> <span>Spalten anpassen</span>
</button> </button>
)
) : ( ) : (
<> <>
{/* Group By */} {/* Group By */}
@@ -2002,18 +2034,17 @@ export const TasksView: React.FC<TasksViewProps> = ({
targetName: sh.targetName || (sh.targetType === 'team' ? 'Gruppe' : sh.email || 'Benutzer'), targetName: sh.targetName || (sh.targetType === 'team' ? 'Gruppe' : sh.email || 'Benutzer'),
targetEmail: sh.email, targetEmail: sh.email,
avatarUrl: sh.avatarUrl, avatarUrl: sh.avatarUrl,
permission: 'editor' as const, permission: sh.permission === 'viewer' ? 'viewer' as const : 'editor' as const,
role: 'editor', role: sh.permission === 'viewer' ? 'viewer' : 'editor',
createdAt: sh.createdAt, createdAt: sh.createdAt,
}))} }))}
teams={safeTeams} teams={safeTeams}
collaborators={safeCollaborators} collaborators={safeCollaborators}
allowRoleSelection={false} onAddShare={async (targetType, targetId, permission) => {
fixedPermission="editor"
onAddShare={async (targetType, targetId) => {
await api.addTaskListShare(sharingTaskList.id, { await api.addTaskListShare(sharingTaskList.id, {
targetType, targetType,
targetId, targetId,
permission,
}); });
const [freshLists, freshShares] = await Promise.all([ const [freshLists, freshShares] = await Promise.all([
api.getTaskLists(), api.getTaskLists(),
@@ -2074,6 +2105,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
onDeleteSubtask={handleDeleteSubtask} onDeleteSubtask={handleDeleteSubtask}
onConvertToDoc={handleConvertToDoc} onConvertToDoc={handleConvertToDoc}
onDeleteTask={handleDeletePermanent} onDeleteTask={handleDeletePermanent}
readOnly={detailTask ? isTaskReadOnly(detailTask) : false}
/> />
{/* VIEW MODES: LIST OR KANBAN */} {/* VIEW MODES: LIST OR KANBAN */}
@@ -2130,11 +2162,12 @@ export const TasksView: React.FC<TasksViewProps> = ({
{colTasks.map((task) => { {colTasks.map((task) => {
const isDragging = draggingTaskId === task.id; const isDragging = draggingTaskId === task.id;
const isTarget = dragOverTaskTarget?.taskId === task.id; const isTarget = dragOverTaskTarget?.taskId === task.id;
const readOnly = isTaskReadOnly(task);
return ( return (
<div <div
key={task.id} key={task.id}
draggable draggable={!readOnly}
onDragStart={(e) => handleTaskDragStart(e, task)} onDragStart={(e) => handleTaskDragStart(e, task)}
onDragEnd={() => { onDragEnd={() => {
setDraggingTaskId(null); setDraggingTaskId(null);
@@ -2148,7 +2181,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
setDetailTaskId(task.id); setDetailTaskId(task.id);
setShowDetailModal(true); setShowDetailModal(true);
}} }}
className={`panel-surface p-2.5 space-y-2 hover:border-strong transition-all cursor-grab active:cursor-grabbing shadow-sm group relative ${ className={`panel-surface p-2.5 space-y-2 hover:border-strong transition-all shadow-sm group relative ${
readOnly ? 'cursor-pointer' : 'cursor-grab active:cursor-grabbing'
} ${
isDragging ? 'opacity-30 border border-dashed border-accent/40' : '' isDragging ? 'opacity-30 border border-dashed border-accent/40' : ''
}`} }`}
> >
@@ -2167,6 +2202,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
> >
{task.content} {task.content}
</p> </p>
{!readOnly && (
<div className="hidden sm:flex items-center gap-1 flex-shrink-0" onClick={(e) => e.stopPropagation()}> <div className="hidden sm:flex items-center gap-1 flex-shrink-0" onClick={(e) => e.stopPropagation()}>
<button <button
onClick={() => handleConvertToDoc(task)} onClick={() => handleConvertToDoc(task)}
@@ -2183,6 +2219,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
<Trash2 className="w-3 h-3" /> <Trash2 className="w-3 h-3" />
</button> </button>
</div> </div>
)}
</div> </div>
{/* Subtasks progress */} {/* Subtasks progress */}
@@ -2217,8 +2254,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
{task.priority === 'urgent' && ( {task.priority === 'urgent' && (
<button <button
type="button" type="button"
disabled={readOnly}
onClick={() => handleCyclePriority(task)} onClick={() => handleCyclePriority(task)}
className="badge-accent text-[9px] cursor-pointer hover:opacity-85" className="badge-accent text-[9px] cursor-pointer hover:opacity-85 disabled:cursor-default"
title="Klicken zum Ändern der Priorität (Dringend -> Normal)" title="Klicken zum Ändern der Priorität (Dringend -> Normal)"
> >
🔥 Dringend 🔥 Dringend
@@ -2227,8 +2265,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
{task.priority === 'high' && ( {task.priority === 'high' && (
<button <button
type="button" type="button"
disabled={readOnly}
onClick={() => handleCyclePriority(task)} onClick={() => handleCyclePriority(task)}
className="px-1.5 py-0.2 border border-status-warn text-status-warn rounded-btn text-[9px] font-medium cursor-pointer hover:opacity-85" className="px-1.5 py-0.2 border border-status-warn text-status-warn rounded-btn text-[9px] font-medium cursor-pointer hover:opacity-85 disabled:cursor-default"
title="Klicken zum Ändern der Priorität (Wichtig -> Dringend)" title="Klicken zum Ändern der Priorität (Wichtig -> Dringend)"
> >
⚠️ Wichtig ⚠️ Wichtig
@@ -2266,6 +2305,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
</div> </div>
{/* Column Navigation controls: Move Left / Move Right */} {/* Column Navigation controls: Move Left / Move Right */}
{!readOnly && (
<div <div
className="flex items-center justify-between pt-1 border-t border-border text-[10px]" className="flex items-center justify-between pt-1 border-t border-border text-[10px]"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
@@ -2292,6 +2332,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
<ArrowRight className="w-3 h-3" /> <ArrowRight className="w-3 h-3" />
</button> </button>
</div> </div>
)}
</div> </div>
); );
})} })}
@@ -2304,6 +2345,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
</div> </div>
{/* Quick Add Task to Column */} {/* Quick Add Task to Column */}
{!isSelectedListReadOnly && (
<div className="p-2 border-t border-border bg-panel"> <div className="p-2 border-t border-border bg-panel">
<button <button
type="button" type="button"
@@ -2314,6 +2356,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
<span>Aufgabe hinzufügen</span> <span>Aufgabe hinzufügen</span>
</button> </button>
</div> </div>
)}
</div> </div>
); );
})} })}
+1 -1
View File
@@ -430,7 +430,7 @@ export const api = {
async addTaskListShare( async addTaskListShare(
id: string, id: string,
payload: { targetType: 'team' | 'user'; targetId: string } payload: { targetType: 'team' | 'user'; targetId: string; permission?: 'editor' | 'viewer' }
): Promise<TaskListShare> { ): Promise<TaskListShare> {
return request<TaskListShare>(`/api/v1/task-lists/${id}/shares`, { return request<TaskListShare>(`/api/v1/task-lists/${id}/shares`, {
method: 'POST', method: 'POST',
+2
View File
@@ -83,6 +83,7 @@ export interface TaskListShare {
targetName?: string; targetName?: string;
email?: string; email?: string;
avatarUrl?: string; avatarUrl?: string;
permission?: 'editor' | 'viewer';
createdAt: string; createdAt: string;
} }
@@ -104,6 +105,7 @@ export interface TaskList {
ownerId?: string; ownerId?: string;
ownerName?: string; ownerName?: string;
isOwner?: boolean; isOwner?: boolean;
permission?: 'owner' | 'editor' | 'viewer';
members?: TaskListMember[]; members?: TaskListMember[];
shares?: TaskListShare[]; shares?: TaskListShare[];
columns?: KanbanColumn[]; columns?: KanbanColumn[];
+36 -6
View File
@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"crypto/tls" "crypto/tls"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"io/fs" "io/fs"
@@ -243,6 +244,15 @@ func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message}) writeJSON(w, status, map[string]string{"error": message})
} }
// writeStoreError maps read-only task list violations to 403 and everything else to the given fallback status.
func writeStoreError(w http.ResponseWriter, fallbackStatus int, err error) {
if errors.Is(err, storage.ErrTaskListReadOnly) {
writeError(w, http.StatusForbidden, err.Error())
return
}
writeError(w, fallbackStatus, err.Error())
}
func (s *Server) apiLoggingMiddleware(next http.Handler) http.Handler { func (s *Server) apiLoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Only log API requests (/api/...) // Only log API requests (/api/...)
@@ -673,7 +683,7 @@ func (s *Server) handleCaptureInbox(w http.ResponseWriter, r *http.Request) {
thought, err := s.store.CreateThoughtWithDetails(userID, req.Content, req.Priority, source, dueDate, taskListID, "todo", "") thought, err := s.store.CreateThoughtWithDetails(userID, req.Content, req.Priority, source, dueDate, taskListID, "todo", "")
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, err.Error()) writeStoreError(w, http.StatusBadRequest, err)
return return
} }
@@ -764,7 +774,7 @@ func (s *Server) handleTriageAction(w http.ResponseWriter, r *http.Request) {
updated, err := s.store.UpdateThoughtTriage(userID, thoughtID, req.Action, convertTo, targetListID, dueDate, taskListID) updated, err := s.store.UpdateThoughtTriage(userID, thoughtID, req.Action, convertTo, targetListID, dueDate, taskListID)
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, err.Error()) writeStoreError(w, http.StatusBadRequest, err)
return return
} }
@@ -805,6 +815,17 @@ func (s *Server) handleReorderTasks(w http.ResponseWriter, r *http.Request) {
return return
} }
if taskListID != "" && !s.store.CanEditTaskList(userID, taskListID) {
writeError(w, http.StatusForbidden, storage.ErrTaskListReadOnly.Error())
return
}
for _, it := range req.Items {
if th, err := s.store.GetThoughtByID(userID, it.ID); err == nil && th.TaskListID != "" && !s.store.CanEditTaskList(userID, th.TaskListID) {
writeError(w, http.StatusForbidden, storage.ErrTaskListReadOnly.Error())
return
}
}
if err := s.store.ReorderTasks(taskListID, req.Items); err != nil { if err := s.store.ReorderTasks(taskListID, req.Items); err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
@@ -840,7 +861,7 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
updated, err := s.store.UpdateThoughtDetails(userID, thoughtID, req.Content, req.IsCompleted, req.DueDate, req.Priority, req.TaskListID, req.KanbanStatus, req.Notes, req.Subtasks, req.Tags) updated, err := s.store.UpdateThoughtDetails(userID, thoughtID, req.Content, req.IsCompleted, req.DueDate, req.Priority, req.TaskListID, req.KanbanStatus, req.Notes, req.Subtasks, req.Tags)
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, err.Error()) writeStoreError(w, http.StatusBadRequest, err)
return return
} }
s.store.SyncDocBlockAndTask(userID, thoughtID, updated.IsCompleted) s.store.SyncDocBlockAndTask(userID, thoughtID, updated.IsCompleted)
@@ -858,9 +879,13 @@ func (s *Server) handleUnlinkEnhancedTask(w http.ResponseWriter, r *http.Request
func (s *Server) handleDeleteThought(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDeleteThought(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context()) userID := auth.GetUserID(r.Context())
thoughtID := chi.URLParam(r, "id") thoughtID := chi.URLParam(r, "id")
if th, err := s.store.GetThoughtByID(userID, thoughtID); err == nil && th.TaskListID != "" && !s.store.CanEditTaskList(userID, th.TaskListID) {
writeError(w, http.StatusForbidden, storage.ErrTaskListReadOnly.Error())
return
}
s.store.UnlinkTaskFromDocBlock(userID, thoughtID) s.store.UnlinkTaskFromDocBlock(userID, thoughtID)
if err := s.store.DeleteThought(userID, thoughtID); err != nil { if err := s.store.DeleteThought(userID, thoughtID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeStoreError(w, http.StatusInternalServerError, err)
return return
} }
s.wsHub.BroadcastToAll("TASK_DELETED", map[string]string{"id": thoughtID}) s.wsHub.BroadcastToAll("TASK_DELETED", map[string]string{"id": thoughtID})
@@ -2157,7 +2182,7 @@ func (s *Server) handleUpdateTaskList(w http.ResponseWriter, r *http.Request) {
} }
tl, err := s.store.UpdateTaskList(userID, listID, req.Title, req.Icon, req.Color, req.TeamID, req.SpaceID, req.Columns) tl, err := s.store.UpdateTaskList(userID, listID, req.Title, req.Icon, req.Color, req.TeamID, req.SpaceID, req.Columns)
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, err.Error()) writeStoreError(w, http.StatusBadRequest, err)
return return
} }
s.wsHub.BroadcastToAll("TASK_LIST_UPDATED", tl) s.wsHub.BroadcastToAll("TASK_LIST_UPDATED", tl)
@@ -2205,12 +2230,17 @@ func (s *Server) handleCreateTaskListShare(w http.ResponseWriter, r *http.Reques
var req struct { var req struct {
TargetType string `json:"targetType"` // team | user TargetType string `json:"targetType"` // team | user
TargetID string `json:"targetId"` TargetID string `json:"targetId"`
Permission string `json:"permission"` // editor | viewer
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON") writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return return
} }
share, err := s.store.AddTaskListShare(listID, req.TargetType, req.TargetID, userID) permission := strings.ToLower(strings.TrimSpace(req.Permission))
if permission != "viewer" {
permission = "editor"
}
share, err := s.store.AddTaskListShare(listID, req.TargetType, req.TargetID, permission, userID)
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, err.Error()) writeError(w, http.StatusBadRequest, err.Error())
return return
+2
View File
@@ -184,6 +184,7 @@ type TaskListShare struct {
TargetName string `json:"targetName,omitempty"` TargetName string `json:"targetName,omitempty"`
Email string `json:"email,omitempty"` Email string `json:"email,omitempty"`
AvatarURL string `json:"avatarUrl,omitempty"` AvatarURL string `json:"avatarUrl,omitempty"`
Permission string `json:"permission"` // editor | viewer
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
} }
@@ -211,6 +212,7 @@ type TaskList struct {
TeamName string `json:"teamName,omitempty"` TeamName string `json:"teamName,omitempty"`
OwnerName string `json:"ownerName,omitempty"` OwnerName string `json:"ownerName,omitempty"`
IsOwner bool `json:"isOwner"` IsOwner bool `json:"isOwner"`
Permission string `json:"permission,omitempty"` // owner | editor | viewer
Members []TaskListMember `json:"members,omitempty"` Members []TaskListMember `json:"members,omitempty"`
Shares []TaskListShare `json:"shares,omitempty"` Shares []TaskListShare `json:"shares,omitempty"`
Columns []KanbanColumn `json:"columns,omitempty"` Columns []KanbanColumn `json:"columns,omitempty"`
+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 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_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);", "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 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);", "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 == "" { if kanbanStatus == "" {
kanbanStatus = "todo" kanbanStatus = "todo"
} }
if err := s.ensureTaskListWritable(userID, taskListID); err != nil {
return nil, err
}
tags := domain.ExtractTags(content) tags := domain.ExtractTags(content)
if strings.EqualFold(source, "voice") { if strings.EqualFold(source, "voice") {
@@ -1145,8 +1149,13 @@ func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, target
if dueDate == "" { if dueDate == "" {
dueDate = thought.DueDate dueDate = thought.DueDate
} }
if err := s.ensureTaskListWritable(userID, thought.TaskListID); err != nil {
return nil, err
}
if taskListID == "" { if taskListID == "" {
taskListID = thought.TaskListID taskListID = thought.TaskListID
} else if err := s.ensureTaskListWritable(userID, taskListID); err != nil {
return nil, err
} }
switch action { switch action {
@@ -1254,6 +1263,14 @@ func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string,
if err != nil { if err != nil {
return nil, err 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 { if content != nil {
thought.Content = strings.TrimSpace(*content) thought.Content = strings.TrimSpace(*content)
@@ -1322,6 +1339,9 @@ func (s *Store) DeleteThought(userID, thoughtID string) error {
if err != nil { if err != nil {
return err return err
} }
if err := s.ensureTaskListWritable(userID, thought.TaskListID); err != nil {
return err
}
_, err = s.db.Exec(`DELETE FROM thoughts WHERE id = ?`, thought.ID) _, err = s.db.Exec(`DELETE FROM thoughts WHERE id = ?`, thought.ID)
return err return err
} }
@@ -4578,12 +4598,73 @@ func (s *Store) CreateTaskList(userID, spaceID, teamID, title, icon, color strin
return nil, err return nil, err
} }
if teamID != "" { if teamID != "" {
_, _ = s.AddTaskListShare(tl.ID, "team", teamID, userID) _, _ = s.AddTaskListShare(tl.ID, "team", teamID, "editor", userID)
} }
return s.GetTaskListByID(userID, tl.ID) 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" { if targetType != "team" && targetType != "user" {
return nil, errors.New("ungültiger Freigabetyp") return nil, errors.New("ungültiger Freigabetyp")
} }
@@ -4591,19 +4672,23 @@ func (s *Store) AddTaskListShare(taskListID, targetType, targetID, createdBy str
if targetID == "" { if targetID == "" {
return nil, errors.New("Ziel-ID erforderlich") return nil, errors.New("Ziel-ID erforderlich")
} }
if permission != "viewer" {
permission = "editor"
}
now := domain.NowISO() now := domain.NowISO()
share := &domain.TaskListShare{ share := &domain.TaskListShare{
ID: domain.NewID("tls"), ID: domain.NewID("tls"),
TaskListID: taskListID, TaskListID: taskListID,
TargetType: targetType, TargetType: targetType,
TargetID: targetID, TargetID: targetID,
Permission: permission,
CreatedAt: now, CreatedAt: now,
} }
_, err := s.db.Exec( _, err := s.db.Exec(
`INSERT INTO task_list_shares (id, task_list_id, target_type, target_id, created_at, created_by) `INSERT INTO task_list_shares (id, task_list_id, target_type, target_id, permission, created_at, created_by)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(task_list_id, target_type, target_id) DO UPDATE SET created_at = excluded.created_at`, 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.CreatedAt, createdBy, share.ID, share.TaskListID, share.TargetType, share.TargetID, share.Permission, share.CreatedAt, createdBy,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@@ -4636,7 +4721,7 @@ func (s *Store) RemoveTaskListShare(taskListID, shareIDOrTargetID string) error
func (s *Store) GetTaskListShares(taskListID string) ([]domain.TaskListShare, error) { func (s *Store) GetTaskListShares(taskListID string) ([]domain.TaskListShare, error) {
rows, err := s.db.Query( 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`, FROM task_list_shares WHERE task_list_id = ? ORDER BY created_at ASC`,
taskListID, taskListID,
) )
@@ -4648,7 +4733,7 @@ func (s *Store) GetTaskListShares(taskListID string) ([]domain.TaskListShare, er
shares := make([]domain.TaskListShare, 0) shares := make([]domain.TaskListShare, 0)
for rows.Next() { for rows.Next() {
var sh domain.TaskListShare 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) shares = append(shares, sh)
} }
} }
@@ -4783,6 +4868,7 @@ func (s *Store) GetTaskLists(userID string) ([]domain.TaskList, error) {
shares, _ := s.GetTaskListShares(list[i].ID) shares, _ := s.GetTaskListShares(list[i].ID)
list[i].Shares = shares list[i].Shares = shares
list[i].Permission = s.GetTaskListPermission(userID, list[i].ID)
list[i].Members = s.getMembersForTaskList(&list[i]) list[i].Members = s.getMembersForTaskList(&list[i])
} }
return list, nil return list, nil
@@ -4825,6 +4911,7 @@ func (s *Store) GetTaskListByID(userID, listID string) (*domain.TaskList, error)
} }
shares, _ := s.GetTaskListShares(tl.ID) shares, _ := s.GetTaskListShares(tl.ID)
tl.Shares = shares tl.Shares = shares
tl.Permission = s.GetTaskListPermission(userID, tl.ID)
tl.Members = s.getMembersForTaskList(&tl) tl.Members = s.getMembersForTaskList(&tl)
return &tl, nil return &tl, nil
} }
@@ -4834,6 +4921,9 @@ func (s *Store) UpdateTaskList(userID, listID string, title, icon, color, teamID
if err != nil { if err != nil {
return nil, err return nil, err
} }
if tl.Permission == "viewer" {
return nil, ErrTaskListReadOnly
}
if title != nil { if title != nil {
tl.Title = strings.TrimSpace(*title) tl.Title = strings.TrimSpace(*title)
} }
@@ -4855,8 +4945,8 @@ func (s *Store) UpdateTaskList(userID, listID string, title, icon, color, teamID
if oldTeamID != "" && oldTeamID != *teamID { if oldTeamID != "" && oldTeamID != *teamID {
_ = s.RemoveTaskListShare(listID, oldTeamID) _ = s.RemoveTaskListShare(listID, oldTeamID)
} }
if *teamID != "" { if *teamID != "" && *teamID != oldTeamID {
_, _ = s.AddTaskListShare(listID, "team", *teamID, userID) _, _ = s.AddTaskListShare(listID, "team", *teamID, "editor", userID)
} }
} }
tl.UpdatedAt = domain.NowISO() tl.UpdatedAt = domain.NowISO()
+41 -1
View File
@@ -1,6 +1,7 @@
package storage package storage
import ( import (
"errors"
"path/filepath" "path/filepath"
"testing" "testing"
@@ -118,7 +119,7 @@ func TestStoreLifecycle(t *testing.T) {
} }
// Test TaskList Sharing with Team // 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 == "" { if err != nil || shareTeam.ID == "" {
t.Fatalf("AddTaskListShare team failed: %v", err) t.Fatalf("AddTaskListShare team failed: %v", err)
} }
@@ -147,6 +148,45 @@ func TestStoreLifecycle(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("CreateUser collab failed: %v", err) 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{ newDoc, err := store.CreateDocument(user.ID, "", "Test Note", "📄", "", []domain.DocumentBlock{
{ID: "blk_1", Type: "bullet", Content: "Geheim", Level: 0}, {ID: "blk_1", Type: "bullet", Content: "Geheim", Level: 0},
}, []string{"test"}, false) }, []string{"test"}, false)