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
|
## 📋 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">
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in new issue
Block a user