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

@@ -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<TaskDetailModalProps> = ({
@@ -60,6 +62,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
onConvertToDoc,
onOpenDocument,
onDeleteTask,
readOnly = false,
}) => {
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 gap-2 min-w-0">
<button
disabled={readOnly}
onClick={() => {
triggerHaptic(15);
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');
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
? 'bg-status-ok border-status-ok text-on-accent'
: '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]" />
</button>
<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>
{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' && (
<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" />
@@ -220,7 +230,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
<FileText className="w-4 h-4 sm:w-3.5 sm:h-3.5" />
<span>In Notiz öffnen</span>
</button>
) : (
) : !readOnly && (
<button
type="button"
onClick={() => {
@@ -234,6 +244,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
<span className="hidden sm:inline">Als Notiz vertiefen</span>
</button>
)}
{!readOnly && (
<button
type="button"
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" />
<span className="sm:hidden font-medium">Löschen</span>
</button>
)}
<button
type="button"
onClick={onClose}
@@ -259,8 +271,8 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
</div>
</div>
{/* Scrollable Body */}
<div className="p-4 overflow-y-auto space-y-4 flex-1">
{/* Scrollable Body (all controls disabled for read-only shares) */}
<fieldset disabled={readOnly} className="p-4 overflow-y-auto space-y-4 flex-1 min-w-0 border-0 m-0">
{/* Task Title */}
<div>
{isEditingTitle ? (
@@ -303,9 +315,11 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
</div>
) : (
<div
onClick={() => 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'}
>
<h2
className={`text-base font-semibold break-words leading-snug ${
@@ -314,9 +328,11 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
>
{task.content}
</h2>
<span className="text-[10px] text-muted opacity-0 group-hover:opacity-100 transition-opacity">
Klicken zum Umbenennen
</span>
{!readOnly && (
<span className="text-[10px] text-muted opacity-0 group-hover:opacity-100 transition-opacity">
Klicken zum Umbenennen
</span>
)}
</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"
>
<span>#{tag}</span>
<button
type="button"
onClick={() => handleRemoveTag(tag)}
className="text-muted hover:text-status-err"
>
<X className="w-3 h-3" />
</button>
{!readOnly && (
<button
type="button"
onClick={() => handleRemoveTag(tag)}
className="text-muted hover:text-status-err"
>
<X className="w-3 h-3" />
</button>
)}
</span>
))}
{!readOnly && (
<form onSubmit={handleAddTagSubmit} className="inline-flex items-center gap-1">
<input
type="text"
@@ -533,6 +552,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
className="h-6 w-24 px-1.5 text-xs input-field"
/>
</form>
)}
</div>
</div>
@@ -587,6 +607,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
{st.title}
</span>
</div>
{!readOnly && (
<button
type="button"
onClick={() => onDeleteSubtask(task, st.id)}
@@ -594,12 +615,14 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
>
<Trash2 className="w-3 h-3" />
</button>
)}
</div>
))}
</div>
)}
{/* Inline add subtask form */}
{!readOnly && (
<form onSubmit={handleAddSubtaskSubmit} className="flex items-center gap-1.5">
<input
type="text"
@@ -617,6 +640,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
<span>Hinzufügen</span>
</button>
</form>
)}
</div>
{/* Notes Section */}
@@ -626,7 +650,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
<FileText className="w-3.5 h-3.5 text-accent" />
<span>Detailnotizen:</span>
</label>
{!isEditingNotes && task.notes && (
{!readOnly && !isEditingNotes && task.notes && (
<button
type="button"
onClick={() => {
@@ -673,14 +697,19 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
) : task.notes ? (
<div
onClick={() => {
if (readOnly) return;
setNotesDraft(task.notes || '');
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"
title="Klicken zum Bearbeiten"
className={`p-3 bg-canvas border border-border rounded-card text-xs text-muted leading-relaxed transition-colors ${
readOnly ? '' : 'cursor-pointer hover:border-strong'
}`}
title={readOnly ? undefined : 'Klicken zum Bearbeiten'}
>
<MarkdownViewer content={task.notes} />
</div>
) : readOnly ? (
<p className="text-xs text-faint">Keine Detailnotizen.</p>
) : (
<button
type="button"
@@ -695,7 +724,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
</button>
)}
</div>
</div>
</fieldset>
{/* Modal Footer */}
<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 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<TasksViewProps> = ({
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<TasksViewProps> = ({
useEffect(() => {
setPrimaryAction(() => {
setMobileCreateListId(selectedListId === 'all' ? '' : selectedListId);
setMobileCreateListId(selectedListId === 'all' || isSelectedListReadOnly ? '' : selectedListId);
setShowMobileCreateModal(true);
});
@@ -876,7 +883,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
icon: <Plus className="w-4 h-4" />,
variant: 'accent',
onClick: () => {
setMobileCreateListId(selectedListId === 'all' ? '' : selectedListId);
setMobileCreateListId(selectedListId === 'all' || isSelectedListReadOnly ? '' : selectedListId);
setShowMobileCreateModal(true);
},
},
@@ -965,7 +972,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
},
];
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<TasksViewProps> = ({
kanbanTasks.length,
safeTaskLists,
currentSelectedList,
isSelectedListReadOnly,
setPrimaryAction,
setGroups,
]);
@@ -1071,13 +1079,14 @@ export const TasksView: React.FC<TasksViewProps> = ({
// 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 (
<div
key={task.id}
draggable
draggable={!readOnly}
onDragStart={(e) => handleTaskDragStart(e, task)}
onDragEnd={() => {
setDraggingTaskId(null);
@@ -1091,7 +1100,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
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<TasksViewProps> = ({
<button
type="button"
draggable={false}
disabled={readOnly}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
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
? 'bg-status-ok border-status-ok text-on-accent'
: '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]" />
</button>
@@ -1169,6 +1181,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
{/* Kanban status selector pill (Desktop only - on mobile done via TaskDetailModal) */}
<select
value={task.kanbanStatus || (isCompleted ? 'done' : 'todo')}
disabled={readOnly}
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"
title="Status / Zustand ändern"
@@ -1184,8 +1197,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
{task.priority === 'urgent' && (
<button
type="button"
disabled={readOnly}
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)"
>
🔥 Dringend
@@ -1194,8 +1208,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
{task.priority === 'high' && (
<button
type="button"
disabled={readOnly}
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)"
>
⚠️ Wichtig
@@ -1266,10 +1281,12 @@ export const TasksView: React.FC<TasksViewProps> = ({
setShowDetailModal(true);
}}
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>
{!readOnly && (
<>
<button
type="button"
draggable={false}
@@ -1288,6 +1305,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</>
)}
</div>
</div>
);
@@ -1479,7 +1498,17 @@ export const TasksView: React.FC<TasksViewProps> = ({
)}
</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) */}
{!isSelectedListReadOnly && (
<form
onSubmit={handleQuickAddTask}
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>
</div>
</form>
)}
{/* Mobile Active Filter Badge Banner (minimal and clean) */}
{(selectedTag || groupBy !== 'none' || sortBy !== 'custom') && (
@@ -1598,6 +1628,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
{/* Group By & Sort By Dropdowns & Manage Columns button */}
<div className="flex items-center gap-2 flex-shrink-0 ml-auto">
{viewMode === 'kanban' ? (
!isSelectedListReadOnly && (
<button
onClick={() => setShowManageColumnsModal(true)}
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" />
<span>Spalten anpassen</span>
</button>
)
) : (
<>
{/* Group By */}
@@ -2002,18 +2034,17 @@ export const TasksView: React.FC<TasksViewProps> = ({
targetName: sh.targetName || (sh.targetType === 'team' ? 'Gruppe' : sh.email || 'Benutzer'),
targetEmail: sh.email,
avatarUrl: sh.avatarUrl,
permission: 'editor' as const,
role: 'editor',
permission: sh.permission === 'viewer' ? 'viewer' as const : 'editor' as const,
role: sh.permission === 'viewer' ? 'viewer' : 'editor',
createdAt: sh.createdAt,
}))}
teams={safeTeams}
collaborators={safeCollaborators}
allowRoleSelection={false}
fixedPermission="editor"
onAddShare={async (targetType, targetId) => {
onAddShare={async (targetType, targetId, permission) => {
await api.addTaskListShare(sharingTaskList.id, {
targetType,
targetId,
permission,
});
const [freshLists, freshShares] = await Promise.all([
api.getTaskLists(),
@@ -2074,6 +2105,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
onDeleteSubtask={handleDeleteSubtask}
onConvertToDoc={handleConvertToDoc}
onDeleteTask={handleDeletePermanent}
readOnly={detailTask ? isTaskReadOnly(detailTask) : false}
/>
{/* VIEW MODES: LIST OR KANBAN */}
@@ -2130,11 +2162,12 @@ export const TasksView: React.FC<TasksViewProps> = ({
{colTasks.map((task) => {
const isDragging = draggingTaskId === task.id;
const isTarget = dragOverTaskTarget?.taskId === task.id;
const readOnly = isTaskReadOnly(task);
return (
<div
key={task.id}
draggable
draggable={!readOnly}
onDragStart={(e) => handleTaskDragStart(e, task)}
onDragEnd={() => {
setDraggingTaskId(null);
@@ -2148,7 +2181,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
setDetailTaskId(task.id);
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' : ''
}`}
>
@@ -2167,6 +2202,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
>
{task.content}
</p>
{!readOnly && (
<div className="hidden sm:flex items-center gap-1 flex-shrink-0" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => handleConvertToDoc(task)}
@@ -2183,6 +2219,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
<Trash2 className="w-3 h-3" />
</button>
</div>
)}
</div>
{/* Subtasks progress */}
@@ -2217,8 +2254,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
{task.priority === 'urgent' && (
<button
type="button"
disabled={readOnly}
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)"
>
🔥 Dringend
@@ -2227,8 +2265,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
{task.priority === 'high' && (
<button
type="button"
disabled={readOnly}
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)"
>
⚠️ Wichtig
@@ -2266,6 +2305,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
</div>
{/* Column Navigation controls: Move Left / Move Right */}
{!readOnly && (
<div
className="flex items-center justify-between pt-1 border-t border-border text-[10px]"
onClick={(e) => e.stopPropagation()}
@@ -2292,6 +2332,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
<ArrowRight className="w-3 h-3" />
</button>
</div>
)}
</div>
);
})}
@@ -2304,6 +2345,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
</div>
{/* Quick Add Task to Column */}
{!isSelectedListReadOnly && (
<div className="p-2 border-t border-border bg-panel">
<button
type="button"
@@ -2314,6 +2356,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
<span>Aufgabe hinzufügen</span>
</button>
</div>
)}
</div>
);
})}
+1 -1
View File
@@ -430,7 +430,7 @@ export const api = {
async addTaskListShare(
id: string,
payload: { targetType: 'team' | 'user'; targetId: string }
payload: { targetType: 'team' | 'user'; targetId: string; permission?: 'editor' | 'viewer' }
): Promise<TaskListShare> {
return request<TaskListShare>(`/api/v1/task-lists/${id}/shares`, {
method: 'POST',
+2
View File
@@ -83,6 +83,7 @@ export interface TaskListShare {
targetName?: string;
email?: string;
avatarUrl?: string;
permission?: 'editor' | 'viewer';
createdAt: string;
}
@@ -104,6 +105,7 @@ export interface TaskList {
ownerId?: string;
ownerName?: string;
isOwner?: boolean;
permission?: 'owner' | 'editor' | 'viewer';
members?: TaskListMember[];
shares?: TaskListShare[];
columns?: KanbanColumn[];