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
@@ -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).
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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[];
|
||||
|
||||
+36
-6
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
@@ -243,6 +244,15 @@ func writeError(w http.ResponseWriter, status int, message string) {
|
||||
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 {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// 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", "")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
writeStoreError(w, http.StatusBadRequest, err)
|
||||
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)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
writeStoreError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -805,6 +815,17 @@ func (s *Server) handleReorderTasks(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
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)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
writeStoreError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
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) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
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)
|
||||
if err := s.store.DeleteThought(userID, thoughtID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
writeStoreError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
writeStoreError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
s.wsHub.BroadcastToAll("TASK_LIST_UPDATED", tl)
|
||||
@@ -2205,12 +2230,17 @@ func (s *Server) handleCreateTaskListShare(w http.ResponseWriter, r *http.Reques
|
||||
var req struct {
|
||||
TargetType string `json:"targetType"` // team | user
|
||||
TargetID string `json:"targetId"`
|
||||
Permission string `json:"permission"` // editor | viewer
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
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 {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
|
||||
@@ -184,6 +184,7 @@ type TaskListShare struct {
|
||||
TargetName string `json:"targetName,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
AvatarURL string `json:"avatarUrl,omitempty"`
|
||||
Permission string `json:"permission"` // editor | viewer
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
@@ -211,6 +212,7 @@ type TaskList struct {
|
||||
TeamName string `json:"teamName,omitempty"`
|
||||
OwnerName string `json:"ownerName,omitempty"`
|
||||
IsOwner bool `json:"isOwner"`
|
||||
Permission string `json:"permission,omitempty"` // owner | editor | viewer
|
||||
Members []TaskListMember `json:"members,omitempty"`
|
||||
Shares []TaskListShare `json:"shares,omitempty"`
|
||||
Columns []KanbanColumn `json:"columns,omitempty"`
|
||||
|
||||
+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