feat: unify sharing UI, redesign lists header, fix team sharing, and add user autocomplete
MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 18s

This commit is contained in:
sandrodach committed 2026-10-25 16:04:08 +01:00
1 parent 6d07352e80
commit 354129b2d4
10 files changed
+973 -317

No files matched your search

+1
View File
@@ -719,6 +719,7 @@ export const App: React.FC = () => {
{activeTab === 'tasks' && (
<TasksView
currentUser={user}
onRefreshStats={refreshAll}
onOpenDocument={handleOpenDocument}
refreshTrigger={refreshRevision}
+205
View File
@@ -0,0 +1,205 @@
import React, { useState, useEffect, useRef } from 'react';
import { Search, X, Check } from 'lucide-react';
import { User } from '../lib/types';
interface UserAutocompleteProps {
users: User[];
selectedUser: User | null;
onSelectUser: (user: User | null) => void;
placeholder?: string;
disabled?: boolean;
excludeUserIds?: string[];
className?: string;
}
export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
users,
selectedUser,
onSelectUser,
placeholder = 'Benutzer suchen (Name, @Benutzername oder E-Mail)…',
disabled = false,
excludeUserIds = [],
className = '',
}) => {
const [query, setQuery] = useState('');
const [isOpen, setIsOpen] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Close dropdown on outside click
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Filter users based on query and exclusions
const filteredUsers = React.useMemo(() => {
const q = query.trim().toLowerCase();
const available = users.filter((u) => !excludeUserIds.includes(u.id));
if (!q) {
return available.slice(0, 15);
}
return available
.filter((u) => {
const nameMatch = (u.displayName || '').toLowerCase().includes(q);
const emailMatch = (u.email || '').toLowerCase().includes(q);
const userMatch = (u.username || '').toLowerCase().includes(q);
return nameMatch || emailMatch || userMatch;
})
.slice(0, 15);
}, [users, query, excludeUserIds]);
const handleSelect = (user: User) => {
onSelectUser(user);
setQuery('');
setIsOpen(false);
setHighlightedIndex(-1);
};
const handleClear = () => {
onSelectUser(null);
setQuery('');
setTimeout(() => inputRef.current?.focus(), 0);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!isOpen) {
if (e.key === 'ArrowDown' || e.key === 'Enter') {
setIsOpen(true);
}
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
setHighlightedIndex((prev) =>
prev < filteredUsers.length - 1 ? prev + 1 : 0
);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setHighlightedIndex((prev) =>
prev > 0 ? prev - 1 : filteredUsers.length - 1
);
} else if (e.key === 'Enter') {
e.preventDefault();
if (highlightedIndex >= 0 && highlightedIndex < filteredUsers.length) {
handleSelect(filteredUsers[highlightedIndex]);
}
} else if (e.key === 'Escape') {
setIsOpen(false);
}
};
return (
<div ref={containerRef} className={`relative ${className}`}>
{selectedUser ? (
<div className="h-8 px-2.5 bg-panel-elevated border border-accent/40 rounded-btn flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-2 min-w-0">
<div className="w-5 h-5 rounded-full bg-accent/20 text-accent font-bold text-[10px] flex items-center justify-center shrink-0">
{(selectedUser.displayName || selectedUser.email || '?')[0].toUpperCase()}
</div>
<span className="font-semibold text-main truncate">
{selectedUser.displayName || selectedUser.email}
</span>
{selectedUser.username && (
<span className="text-[11px] text-muted truncate">
@{selectedUser.username}
</span>
)}
{selectedUser.email && selectedUser.displayName && (
<span className="text-[11px] text-muted truncate hidden sm:inline">
({selectedUser.email})
</span>
)}
</div>
{!disabled && (
<button
type="button"
onClick={handleClear}
className="text-muted hover:text-main p-0.5 rounded hover:bg-canvas transition-colors"
title="Auswahl aufheben"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
) : (
<div className="relative">
<Search className="w-3.5 h-3.5 text-muted absolute left-2.5 top-1/2 -translate-y-1/2 pointer-events-none" />
<input
ref={inputRef}
type="text"
value={query}
disabled={disabled}
onChange={(e) => {
setQuery(e.target.value);
setIsOpen(true);
setHighlightedIndex(-1);
}}
onFocus={() => setIsOpen(true)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="w-full h-8 pl-8 pr-2.5 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-xs placeholder:text-muted transition-colors disabled:opacity-50"
/>
{isOpen && (
<div className="absolute top-full left-0 right-0 z-50 mt-1 max-h-56 overflow-y-auto bg-panel border border-border rounded-card shadow-panel py-1 space-y-0.5">
{filteredUsers.length === 0 ? (
<div className="p-3 text-xs text-muted text-center">
Kein passender Benutzer gefunden
</div>
) : (
filteredUsers.map((u, idx) => {
const isHighlighted = idx === highlightedIndex;
return (
<button
key={u.id}
type="button"
onClick={() => handleSelect(u)}
onMouseEnter={() => setHighlightedIndex(idx)}
className={`w-full px-2.5 py-1.5 flex items-center justify-between gap-2 text-left text-xs transition-colors cursor-pointer ${
isHighlighted
? 'bg-accent/15 text-main font-medium'
: 'hover:bg-panel-elevated text-main'
}`}
>
<div className="flex items-center gap-2 min-w-0">
<div className="w-5 h-5 rounded-full bg-accent/20 text-accent font-bold text-[10px] flex items-center justify-center shrink-0">
{(u.displayName || u.email || '?')[0].toUpperCase()}
</div>
<div className="truncate">
<span className="font-semibold text-main">
{u.displayName || u.email}
</span>
{u.username && (
<span className="ml-1 text-[11px] text-muted">
@{u.username}
</span>
)}
{u.email && u.displayName && (
<span className="ml-1.5 text-[10px] text-muted truncate">
{u.email}
</span>
)}
</div>
</div>
{isHighlighted && (
<Check className="w-3.5 h-3.5 text-accent shrink-0" />
)}
</button>
);
})
)}
</div>
)}
</div>
)}
</div>
);
};
+202 -122
View File
@@ -8,12 +8,14 @@ import {
Check,
Radio,
Sparkles,
Share,
Share2,
User as UserIcon,
X,
} from 'lucide-react';
import { ListItem, SharedList, Space, Team, User, WSMessage } from '../../lib/types';
import { api, buildWebSocketURL, triggerHaptic } from '../../lib/api';
import { UserAutocomplete } from '../UserAutocomplete';
interface SharedListsViewProps {
onRefreshGlobal: () => void;
@@ -87,6 +89,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
const [teams, setTeams] = useState<Team[]>([]);
const [addShareType, setAddShareType] = useState<'team' | 'user'>('team');
const [selectedTeamId, setSelectedTeamId] = useState('');
const [selectedShareUser, setSelectedShareUser] = useState<User | null>(null);
const [inviteTarget, setInviteTarget] = useState('');
const [inviteRole, setInviteRole] = useState<'editor' | 'viewer'>('editor');
const [invitingLoading, setInvitingLoading] = useState(false);
@@ -291,6 +294,26 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
setActiveSpace(created);
};
const openShareModal = async () => {
if (!activeSpace) return;
setShowShareModal(true);
setShareError(null);
setSelectedTeamId('');
setSelectedShareUser(null);
try {
const [usersList, teamsList, sharesList] = await Promise.all([
api.getUsersDirectory ? api.getUsersDirectory().catch(() => api.getCollaborators().catch(() => [])) : api.getCollaborators().catch(() => []),
api.getUserTeams().catch(() => []),
api.getSpaceShares ? api.getSpaceShares(activeSpace.id).catch(() => []) : Promise.resolve([]),
]);
setCollaborators(usersList);
setTeams(teamsList);
setActiveSpace((prev) => (prev ? { ...prev, shares: sharesList } : prev));
} catch {
// ignore
}
};
const handleAddShare = async (e: React.FormEvent) => {
e.preventDefault();
if (!activeSpace) return;
@@ -309,25 +332,27 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
});
setSelectedTeamId('');
} else {
const target = inviteTarget.trim();
if (!target) {
setShareError('Bitte einen Benutzer auswählen oder eingeben');
if (!selectedShareUser) {
setShareError('Bitte einen bestehenden Benutzer auswählen');
setInvitingLoading(false);
return;
}
const isId = collaborators.some((u) => u.id === target);
await api.addSpaceMember(activeSpace.id, {
userId: isId ? target : undefined,
email: !isId ? target : undefined,
userId: selectedShareUser.id,
role: inviteRole,
});
setInviteTarget('');
setSelectedShareUser(null);
}
const sp = await api.getSpaces();
const [sp, freshShares] = await Promise.all([
api.getSpaces(),
api.getSpaceShares ? api.getSpaceShares(activeSpace.id).catch(() => []) : Promise.resolve([]),
]);
setSpaces(sp);
const updated = sp.find((s) => s.id === activeSpace.id);
if (updated) setActiveSpace(updated);
if (updated) {
setActiveSpace({ ...updated, shares: freshShares });
}
onRefreshGlobal();
} catch (err: any) {
setShareError(err.message || 'Freigabe konnte nicht hinzugefügt werden');
@@ -336,34 +361,25 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
}
};
const handleRemoveMember = async (targetUserId: string) => {
const handleRemoveMember = async (targetId: string) => {
if (!activeSpace) return;
if (!window.confirm('Dieses Mitglied wirklich aus der Einkaufsliste / dem Bereich entfernen?')) return;
if (!window.confirm('Freigabe wirklich aufheben?')) return;
setInvitingLoading(true);
setShareError(null);
try {
await api.removeSpaceMember(activeSpace.id, targetUserId);
setActiveSpace((prev) =>
prev
? {
...prev,
members: (prev.members || []).filter((m) => m.userId !== targetUserId),
await api.removeSpaceMember(activeSpace.id, targetId);
const [sp, freshShares] = await Promise.all([
api.getSpaces(),
api.getSpaceShares ? api.getSpaceShares(activeSpace.id).catch(() => []) : Promise.resolve([]),
]);
setSpaces(sp);
const updated = sp.find((s) => s.id === activeSpace.id);
if (updated) {
setActiveSpace({ ...updated, shares: freshShares });
}
: prev
);
setSpaces((prev) =>
prev.map((s) =>
s.id === activeSpace.id
? {
...s,
members: (s.members || []).filter((m) => m.userId !== targetUserId),
}
: s
)
);
onRefreshGlobal();
} catch (err: any) {
setShareError(err.message || 'Mitglied konnte nicht entfernt werden');
setShareError(err.message || 'Freigabe konnte nicht entfernt werden');
} finally {
setInvitingLoading(false);
}
@@ -414,17 +430,18 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
return (
<div className="w-full space-y-3">
{/* Top Space Bar & Realtime Indicator */}
<div className="px-3 py-2 panel-surface flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-1.5">
{/* Spaces Bar */}
<div className="flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-1.5 overflow-x-auto">
<span className="text-muted text-[11px] font-medium mr-1">Bereich:</span>
{spaces.map((sp) => (
<button
key={sp.id}
onClick={() => setActiveSpace(sp)}
className={`h-8 px-3 text-xs sm:text-sm font-medium flex items-center gap-1.5 transition-colors border rounded-btn ${
className={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1 transition-colors border ${
activeSpace?.id === sp.id
? 'bg-accent text-on-accent border-accent'
: 'bg-canvas text-muted hover:text-main border-border'
? 'bg-accent/15 border-accent/40 text-accent font-semibold'
: 'bg-canvas border-border text-muted hover:text-main hover:bg-panel'
}`}
>
<span>{sp.icon}</span>
@@ -434,48 +451,13 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
<button
onClick={() => setShowNewSpaceModal((prev) => !prev)}
className="btn-action h-8"
className="h-7 px-2 text-xs text-muted hover:text-main hover:bg-panel rounded-btn flex items-center gap-1 transition-colors border border-dashed border-border"
title="Neuen Bereich (Space) anlegen"
>
<Plus className="w-3.5 h-3.5 text-accent" /> Space
<Plus className="w-3 h-3 text-accent" />
<span>Space</span>
</button>
</div>
{/* Realtime Status & Share List Button */}
{activeSpace && (
<div className="flex items-center gap-2">
<div
className={`h-8 w-8 flex items-center justify-center rounded-btn ${
wsConnected
? 'badge-ok'
: 'badge-neutral'
}`}
title={wsConnected ? 'Live synchronisiert (Online)' : 'Offline'}
>
<Radio className={`w-4 h-4 ${wsConnected ? 'animate-pulse text-status-ok' : 'text-muted'}`} />
</div>
<button
onClick={() => {
setShowShareModal(true);
setShareError(null);
Promise.all([
api.getUsersDirectory().then(setCollaborators).catch(() => []),
api.getUserTeams().then(setTeams).catch(() => []),
]);
}}
title="Einkaufsliste teilen & verwalten"
className="btn-action h-8 px-2.5 text-xs flex items-center gap-1.5"
>
<Share2 className="w-3.5 h-3.5 text-accent" />
<span>Teilen</span>
{activeSpace.members && activeSpace.members.length > 1 && (
<span className="badge-neutral font-mono text-[10px]">
{activeSpace.members.length}
</span>
)}
</button>
</div>
)}
</div>
{/* Create Space Modal */}
@@ -501,8 +483,10 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
</form>
)}
{/* List Tabs within Active Space */}
<div className="flex flex-wrap items-center gap-1.5">
{/* Top Header: List Tabs & Actions */}
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border pb-2">
{/* List Tabs */}
<div className="flex flex-wrap items-center gap-1">
{lists.map((lst) => {
const openCount = lst.items.filter((i) => !i.isCompleted).length;
const isSelected = activeList?.id === lst.id;
@@ -510,32 +494,94 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
<button
key={lst.id}
onClick={() => setActiveListId(lst.id)}
className={`h-8 px-3 text-xs sm:text-sm font-medium flex items-center gap-1.5 transition-all border rounded-btn ${
className={`h-8 px-3 text-xs font-medium border rounded-btn flex items-center gap-1.5 transition-colors ${
isSelected
? 'bg-accent text-on-accent border-accent'
: 'bg-panel text-muted hover:text-main border-border'
? 'bg-panel border-accent text-main'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
<span>{lst.icon}</span>
<span>{lst.title}</span>
<span className={`px-1.5 py-0.2 text-[10px] font-semibold rounded-pill ${
isSelected ? 'bg-white/20 text-on-accent' : 'badge-neutral'
}`}>
{openCount}
</span>
{openCount > 0 && (
<span className="ml-1 badge-neutral">{openCount}</span>
)}
</button>
);
})}
<button
onClick={() => setShowNewListModal((prev) => !prev)}
className="btn-action h-8"
className="h-8 px-2.5 bg-canvas hover:bg-panel border border-border border-dashed rounded-btn text-xs text-muted hover:text-main flex items-center gap-1 transition-colors"
title="Neue Liste anlegen"
>
<Plus className="w-3.5 h-3.5 text-accent" />
<Plus className="w-3.5 h-3.5" />
<span>Neue Liste</span>
</button>
</div>
{/* Right Header Section: Geteilt Badge, Icon-Only Share Button & Live Sync */}
<div className="flex items-center gap-2">
{activeSpace && (() => {
const isOwner = !activeSpace.ownerId || activeSpace.ownerId === currentUser?.id;
const sharesCount =
(activeSpace.shares?.length || 0) +
(activeSpace.members?.filter((m) => m.userId !== activeSpace.ownerId).length || 0);
const isShared =
(activeSpace.shares && activeSpace.shares.length > 0) ||
(activeSpace.members && activeSpace.members.length > 1);
return (
<div className="flex items-center gap-1.5">
{isShared && (
isOwner ? (
<button
type="button"
onClick={openShareModal}
className="px-2 py-0.5 rounded text-[10px] font-semibold bg-accent/15 text-accent border border-accent/30 flex items-center gap-1 hover:bg-accent/25 hover:border-accent/50 transition-colors cursor-pointer"
title="Teilen-Menü öffnen: Geteilt mit Nutzern/Gruppen anzeigen & verwalten"
>
<Users className="w-2.5 h-2.5" />
<span>Geteilt ({sharesCount > 0 ? sharesCount : activeSpace.members?.length || 1})</span>
</button>
) : (
<button
type="button"
onClick={openShareModal}
className="px-2 py-0.5 rounded text-[10px] font-medium border border-border text-muted flex items-center gap-1 bg-surface-subtle hover:bg-panel-elevated hover:text-main transition-colors cursor-pointer"
title="Teilen & Berechtigungen öffnen"
>
<Users className="w-2.5 h-2.5" />
<span>Geteilt von {activeSpace.ownerName || 'Eigentümer'}</span>
</button>
)
)}
<button
type="button"
onClick={openShareModal}
className="btn-icon"
title="Einkaufsliste teilen und Berechtigungen"
>
<Share className="w-3.5 h-3.5" />
</button>
</div>
);
})()}
{/* Live Sync Status */}
<div
className={`h-8 w-8 flex items-center justify-center rounded-btn border ${
wsConnected
? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-500'
: 'bg-canvas border-border text-muted'
}`}
title={wsConnected ? 'Live synchronisiert (Online)' : 'Offline'}
>
<Radio className={`w-3.5 h-3.5 ${wsConnected ? 'animate-pulse' : ''}`} />
</div>
</div>
</div>
{showNewListModal && (
<form
onSubmit={handleCreateList}
@@ -819,46 +865,32 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
</select>
</div>
) : (
<div className="space-y-2">
<div className="flex flex-col sm:flex-row gap-2">
<select
value={inviteTarget}
onChange={(e) => setInviteTarget(e.target.value)}
className="input-field h-8 px-2.5 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-xs flex-1 cursor-pointer"
>
<option value="" className="bg-panel text-muted">Benutzer auswählen oder eingeben…</option>
{collaborators
.filter(
(u) =>
u.id !== currentUser?.id &&
!(activeSpace.members || []).some((m) => m.userId === u.id)
)
.map((u) => (
<option key={u.id} value={u.id} className="bg-panel text-main">
👤 {u.displayName || u.username || u.email} ({u.username ? `@${u.username}` : u.email})
</option>
))}
</select>
<div className="flex-1">
<UserAutocomplete
users={collaborators}
selectedUser={selectedShareUser}
onSelectUser={setSelectedShareUser}
excludeUserIds={[
currentUser?.id || '',
...(activeSpace.members || []).map((m) => m.userId),
...(activeSpace.shares || [])
.filter((s) => s.targetType === 'user')
.map((s) => s.targetId),
].filter(Boolean)}
disabled={invitingLoading}
placeholder="Benutzer suchen (Name oder @Benutzername)…"
/>
</div>
<select
value={inviteRole}
onChange={(e) => setInviteRole(e.target.value as 'editor' | 'viewer')}
className="input-field h-8 px-2.5 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-xs w-full sm:w-36 cursor-pointer"
className="input-field h-8 px-2.5 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-xs w-full sm:w-36 cursor-pointer shrink-0"
>
<option value="editor" className="bg-panel text-main">Bearbeiten</option>
<option value="viewer" className="bg-panel text-main">Nur Lesen</option>
</select>
</div>
{(!inviteTarget || !collaborators.some((u) => u.id === inviteTarget)) && (
<input
type="text"
placeholder="Oder Benutzername / E-Mail direkt eingeben…"
value={inviteTarget}
onChange={(e) => setInviteTarget(e.target.value)}
className="input-field h-8 px-2.5 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-xs w-full"
/>
)}
</div>
)}
<div className="flex justify-end pt-1">
@@ -866,7 +898,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
type="submit"
disabled={
invitingLoading ||
(addShareType === 'team' ? !selectedTeamId : !inviteTarget.trim())
(addShareType === 'team' ? !selectedTeamId : !selectedShareUser)
}
className="btn-primary text-xs h-8 px-4 flex items-center gap-1.5"
>
@@ -882,12 +914,60 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
</div>
</form>
{/* Current Space Members List */}
{/* Current Space Members & Team Shares List */}
<div className="space-y-2">
<div className="text-xs font-semibold text-muted uppercase tracking-wider">
Aktive Freigaben & Mitglieder ({activeSpace.members?.length || 1})
Aktive Freigaben & Mitglieder (
{((activeSpace.shares || []).filter((s) => s.targetType === 'team').length) +
(activeSpace.members?.length || 1)}
)
</div>
<div className="space-y-1.5 max-h-48 overflow-y-auto">
<div className="space-y-1.5 max-h-56 overflow-y-auto">
{/* Team Shares */}
{(activeSpace.shares || [])
.filter((s) => s.targetType === 'team')
.map((sh) => {
const canRemove =
activeSpace.ownerId === currentUser?.id || currentUser?.role === 'admin';
return (
<div
key={sh.id}
className="p-2.5 bg-canvas border border-border rounded-btn flex items-center justify-between gap-3 text-xs"
>
<div className="flex items-center gap-2 min-w-0">
<div className="w-7 h-7 rounded-btn bg-accent/20 text-accent flex items-center justify-center shrink-0">
<Users className="w-3.5 h-3.5" />
</div>
<div className="truncate">
<div className="font-medium text-main truncate flex items-center gap-1.5">
<span>{sh.targetName || 'Team'}</span>
<span className="badge-neutral text-[9px] uppercase px-1 py-0.2">Gruppe</span>
</div>
<div className="text-[11px] text-muted truncate">Freigabe für die Gruppe</div>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-[10px] px-2 py-0.5 rounded font-semibold bg-surface-2 text-muted">
{sh.role === 'editor' ? 'Bearbeiter' : 'Leser'}
</span>
{canRemove && (
<button
type="button"
onClick={() => handleRemoveMember(sh.id)}
disabled={invitingLoading}
className="btn-icon text-muted hover:text-status-danger p-1"
title="Team-Freigabe entfernen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
);
})}
{/* Individual Members */}
{(activeSpace.members || []).map((m) => {
const isOwner = m.role === 'owner' || m.userId === activeSpace.ownerId;
const isMe = m.userId === currentUser?.id;
+105 -67
View File
@@ -13,6 +13,7 @@ import {
Edit3,
X,
Layers,
Share,
Share2,
Users,
CheckSquare,
@@ -29,6 +30,7 @@ import { TaskList, Thought, SubTask, Team, User, TaskListShare, KanbanColumn } f
import { api, triggerHaptic } from '../../lib/api';
import { TaskDetailModal } from './TaskDetailModal';
import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS } from './ManageColumnsModal';
import { UserAutocomplete } from '../UserAutocomplete';
const getInitials = (name?: string): string => {
if (!name) return '?';
@@ -40,12 +42,14 @@ const getInitials = (name?: string): string => {
};
interface TasksViewProps {
currentUser?: User | null;
onRefreshStats: () => void;
onOpenDocument: (docId: string) => void;
refreshTrigger?: number;
}
export const TasksView: React.FC<TasksViewProps> = ({
currentUser,
onRefreshStats,
onOpenDocument,
refreshTrigger = 0,
@@ -75,6 +79,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
const [sharingTaskList, setSharingTaskList] = useState<TaskList | null>(null);
const [addShareType, setAddShareType] = useState<'team' | 'user'>('team');
const [selectedShareTargetId, setSelectedShareTargetId] = useState('');
const [selectedShareUser, setSelectedShareUser] = useState<User | null>(null);
const [shareLoading, setShareLoading] = useState(false);
// Detail modal state
@@ -577,26 +582,47 @@ export const TasksView: React.FC<TasksViewProps> = ({
}
};
const handleOpenShareModal = (tl: TaskList) => {
const handleOpenShareModal = async (tl: TaskList) => {
setSharingTaskList(tl);
setSelectedShareTargetId('');
setSelectedShareUser(null);
setShowShareListModal(true);
try {
const [freshTeams, freshUsers, freshShares] = await Promise.all([
api.getUserTeams().catch(() => []),
api.getUsersDirectory ? api.getUsersDirectory().catch(() => api.getCollaborators().catch(() => [])) : api.getCollaborators().catch(() => []),
api.getTaskListShares(tl.id).catch(() => []),
]);
setTeams(freshTeams);
setCollaborators(freshUsers);
setSharingTaskList((prev) => (prev && prev.id === tl.id ? { ...prev, shares: freshShares } : prev));
} catch {
// ignore
}
};
const handleAddShare = async () => {
if (!sharingTaskList || !selectedShareTargetId) return;
if (!sharingTaskList) return;
const targetId = addShareType === 'team' ? selectedShareTargetId : selectedShareUser?.id;
if (!targetId) return;
setShareLoading(true);
triggerHaptic(15);
try {
await api.addTaskListShare(sharingTaskList.id, {
targetType: addShareType,
targetId: selectedShareTargetId,
targetId: targetId,
});
const freshLists = await api.getTaskLists();
const [freshLists, freshShares] = await Promise.all([
api.getTaskLists(),
api.getTaskListShares(sharingTaskList.id).catch(() => []),
]);
setTaskLists(freshLists);
const updated = freshLists.find((l) => l.id === sharingTaskList.id);
if (updated) setSharingTaskList(updated);
if (updated) {
setSharingTaskList({ ...updated, shares: freshShares });
}
setSelectedShareTargetId('');
setSelectedShareUser(null);
await loadData();
} catch (err: any) {
alert(err.message || 'Fehler beim Hinzufügen der Freigabe');
@@ -607,14 +633,20 @@ export const TasksView: React.FC<TasksViewProps> = ({
const handleRemoveShare = async (shareId: string) => {
if (!sharingTaskList) return;
if (!window.confirm('Freigabe wirklich aufheben?')) return;
setShareLoading(true);
triggerHaptic(15);
try {
await api.removeTaskListShare(sharingTaskList.id, shareId);
const freshLists = await api.getTaskLists();
const [freshLists, freshShares] = await Promise.all([
api.getTaskLists(),
api.getTaskListShares(sharingTaskList.id).catch(() => []),
]);
setTaskLists(freshLists);
const updated = freshLists.find((l) => l.id === sharingTaskList.id);
if (updated) setSharingTaskList(updated);
if (updated) {
setSharingTaskList({ ...updated, shares: freshShares });
}
await loadData();
} catch (err: any) {
alert(err.message || 'Fehler beim Entfernen der Freigabe');
@@ -869,19 +901,27 @@ export const TasksView: React.FC<TasksViewProps> = ({
{tl.tasksCount !== undefined && tl.tasksCount > 0 && (
<span className="ml-1 badge-neutral">{tl.tasksCount}</span>
)}
{tl.members && tl.members.length > 0 ? (
<div className="flex items-center -space-x-1 ml-0.5" title={`Geteilt (${tl.members.length} Mitglieder)`}>
{tl.members.slice(0, 2).map((m) => (
{(() => {
const isShared = (tl.shares && tl.shares.length > 0) || Boolean(tl.teamId) || (tl.members && tl.members.length > 1);
if (!isShared) return null;
const otherMembers = (tl.members || []).filter((m) => m.userId !== currentUser?.id);
if (otherMembers.length > 0) {
return (
<div className="flex items-center -space-x-1 ml-0.5" title={`Geteilt (${tl.members?.length || 1} Mitglieder)`}>
{otherMembers.slice(0, 2).map((m) => (
<span key={m.userId} className="w-3.5 h-3.5 rounded-pill bg-accent/20 border border-panel text-[8px] font-bold text-accent flex items-center justify-center">
{getInitials(m.displayName || m.email)[0]}
</span>
))}
</div>
) : (tl.teamId || (tl.shares && tl.shares.length > 0)) ? (
);
}
return (
<span title="Geteilt mit Team oder Benutzern">
<Share2 className="w-2.5 h-2.5 text-accent ml-0.5" />
<Users className="w-2.5 h-2.5 text-accent ml-0.5" />
</span>
) : null}
);
})()}
</button>
{selectedListId === tl.id && (
<button
@@ -905,55 +945,52 @@ export const TasksView: React.FC<TasksViewProps> = ({
</button>
</div>
{/* Right Header Section: Sharing, Avatar Stack & View Switcher */}
{/* Right Header Section: Sharing Badge, Icon-Only Button & View Switcher */}
<div className="flex items-center gap-2">
{currentSelectedList && (
{currentSelectedList && (() => {
const isOwner = !currentSelectedList.ownerId || currentSelectedList.ownerId === currentUser?.id;
const sharesCount = (currentSelectedList.shares?.length || 0) > 0
? currentSelectedList.shares.length
: (currentSelectedList.members?.filter((m) => m.userId !== currentSelectedList.ownerId).length || 0);
const isShared = sharesCount > 0 || Boolean(currentSelectedList.teamId) || ((currentSelectedList.members?.length || 0) > 1);
return (
<div className="flex items-center gap-1.5">
{isShared && (
isOwner ? (
<button
type="button"
onClick={() => handleOpenShareModal(currentSelectedList)}
className="btn-action h-8 px-2.5 flex items-center gap-1.5 text-xs"
title={`Liste '${currentSelectedList.title}' mit Team oder Nutzern teilen`}
className="px-2 py-0.5 rounded text-[10px] font-semibold bg-accent/15 text-accent border border-accent/30 flex items-center gap-1 hover:bg-accent/25 hover:border-accent/50 transition-colors cursor-pointer"
title="Teilen-Menü öffnen: Geteilt mit Nutzern/Gruppen anzeigen & verwalten"
>
<Users className="w-3.5 h-3.5 text-accent" />
<span>Teilen</span>
<Users className="w-2.5 h-2.5" />
<span>Geteilt ({sharesCount > 0 ? sharesCount : currentSelectedList.members?.length || 1})</span>
</button>
{/* Avatar Stack */}
{currentSelectedList.members && currentSelectedList.members.length > 0 ? (
<div
onClick={() => handleOpenShareModal(currentSelectedList)}
className="flex items-center -space-x-1.5 cursor-pointer hover:opacity-90 transition-opacity pl-0.5"
title={`Geteilt mit ${currentSelectedList.members.length} Mitgliedern (${currentSelectedList.teamName || 'Team'}). Klick zum Verwalten.`}
>
{currentSelectedList.members.slice(0, 3).map((m) => (
<div
key={m.userId}
className="w-7 h-7 rounded-pill border-2 border-panel bg-accent/20 text-accent flex items-center justify-center text-[10px] font-bold shadow-sm"
title={`${m.displayName || m.email} (${m.role})`}
>
{getInitials(m.displayName || m.email)}
</div>
))}
{currentSelectedList.members.length > 3 && (
<div className="w-7 h-7 rounded-pill border-2 border-panel bg-accent text-on-accent flex items-center justify-center text-[10px] font-bold shadow-sm">
+{currentSelectedList.members.length - 3}
</div>
)}
</div>
) : (
<div
<button
type="button"
onClick={() => handleOpenShareModal(currentSelectedList)}
className="flex items-center cursor-pointer pl-0.5"
title="Privat (nur du). Klick zum Teilen."
className="px-2 py-0.5 rounded text-[10px] font-medium border border-border text-muted flex items-center gap-1 bg-surface-subtle hover:bg-panel-elevated hover:text-main transition-colors cursor-pointer"
title="Teilen & Berechtigungen öffnen"
>
<div className="w-7 h-7 rounded-pill border border-dashed border-border bg-canvas text-muted hover:text-accent hover:border-accent flex items-center justify-center text-[11px] font-bold transition-colors">
🔒
</div>
</div>
<Users className="w-2.5 h-2.5" />
<span>Geteilt von {currentSelectedList.ownerName || 'Eigentümer'}</span>
</button>
)
)}
<button
type="button"
onClick={() => handleOpenShareModal(currentSelectedList)}
className="btn-icon"
title="Aufgabenliste teilen und Berechtigungen"
>
<Share className="w-3.5 h-3.5" />
</button>
</div>
)}
);
})()}
{/* View Switcher: List vs Kanban */}
<div className="flex border border-border rounded-btn bg-canvas overflow-hidden">
@@ -1291,7 +1328,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
type="button"
onClick={() => {
setAddShareType('team');
setSelectedShareTargetId('');
setSelectedShareUser(null);
}}
className={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1 border transition-colors ${
addShareType === 'team'
@@ -1336,26 +1373,27 @@ export const TasksView: React.FC<TasksViewProps> = ({
))}
</select>
) : (
<select
value={selectedShareTargetId}
onChange={(e) => setSelectedShareTargetId(e.target.value)}
className="flex-1 h-8 px-2.5 input-field text-xs bg-panel"
>
<option value="">-- Benutzer auswählen --</option>
{safeCollaborators
.filter((c) => !(sharingTaskList.shares || []).some((s) => s.targetType === 'user' && s.targetId === c.id))
.map((c) => (
<option key={c.id} value={c.id}>
👤 {c.displayName || c.email} ({c.email})
</option>
))}
</select>
<div className="flex-1">
<UserAutocomplete
users={safeCollaborators}
selectedUser={selectedShareUser}
onSelectUser={setSelectedShareUser}
excludeUserIds={[
currentUser?.id || '',
...(sharingTaskList.shares || [])
.filter((s) => s.targetType === 'user')
.map((s) => s.targetId),
].filter(Boolean)}
disabled={shareLoading}
placeholder="Benutzer suchen (Name oder @Benutzername)…"
/>
</div>
)}
<button
type="button"
onClick={handleAddShare}
disabled={!selectedShareTargetId || shareLoading}
className="btn-primary h-8 px-3 text-xs flex items-center gap-1 disabled:opacity-50"
disabled={shareLoading || (addShareType === 'team' ? !selectedShareTargetId : !selectedShareUser)}
className="btn-primary h-8 px-3 text-xs flex items-center gap-1 disabled:opacity-50 shrink-0"
>
<Plus className="w-3.5 h-3.5" />
<span>Freigeben</span>
@@ -53,6 +53,7 @@ import {
import { BlockType, Document, DocumentBlock, DocLockInfo, DocShare, TagDetails, TagSummary, Team, User } from '../../lib/types';
import { api, getToken, triggerHaptic } from '../../lib/api';
import { CodeBlockItem } from './CodeBlockItem';
import { UserAutocomplete } from '../UserAutocomplete';
interface WorkspaceViewProps {
currentUser?: User | null;
@@ -182,7 +183,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const [shareTeams, setShareTeams] = useState<Team[]>([]);
const [addShareType, setAddShareType] = useState<'team' | 'user'>('team');
const [selectedShareTeamId, setSelectedShareTeamId] = useState('');
const [shareTargetEmailOrId, setShareTargetEmailOrId] = useState('');
const [selectedShareUser, setSelectedShareUser] = useState<User | null>(null);
const [sharePermission, setSharePermission] = useState<'read' | 'write'>('read');
const [sharingLoading, setSharingLoading] = useState(false);
const [shareError, setShareError] = useState<string | null>(null);
@@ -625,10 +626,12 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
setShowShareModal(true);
setShareError(null);
setSharingLoading(true);
setSelectedShareUser(null);
setSelectedShareTeamId('');
try {
const [sharesList, usersList, teamsList] = await Promise.all([
api.getDocShares(selectedDoc.id).catch(() => []),
api.getCollaborators().catch(() => []),
api.getUsersDirectory ? api.getUsersDirectory().catch(() => api.getCollaborators().catch(() => [])) : api.getCollaborators().catch(() => []),
api.getUserTeams().catch(() => []),
]);
setDocShares(sharesList);
@@ -657,19 +660,16 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
});
setSelectedShareTeamId('');
} else {
const target = shareTargetEmailOrId.trim();
if (!target) {
setShareError('Bitte einen Benutzer auswählen oder E-Mail eingeben');
if (!selectedShareUser) {
setShareError('Bitte einen bestehenden Benutzer auswählen');
setSharingLoading(false);
return;
}
const isId = collaborators.some((u) => u.id === target);
await api.createDocShare(selectedDoc.id, {
userId: isId ? target : undefined,
email: !isId ? target : undefined,
userId: selectedShareUser.id,
permission: sharePermission,
});
setShareTargetEmailOrId('');
setSelectedShareUser(null);
}
const sharesList = await api.getDocShares(selectedDoc.id).catch(() => []);
@@ -715,18 +715,18 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
}
};
const handleRemoveShare = async (targetUserId: string) => {
const handleRemoveShare = async (targetId: string) => {
if (!selectedDoc) return;
if (!window.confirm('Freigabe für diesen Benutzer wirklich aufheben?')) return;
if (!window.confirm('Freigabe wirklich aufheben?')) return;
setSharingLoading(true);
try {
await api.deleteDocShare(selectedDoc.id, targetUserId);
setDocShares((prev) => prev.filter((s) => s.userId !== targetUserId));
await api.deleteDocShare(selectedDoc.id, targetId);
setDocShares((prev) => prev.filter((s) => (s.targetId || s.userId || s.id) !== targetId && s.userId !== targetId));
setSelectedDoc((prev) =>
prev
? {
...prev,
sharedWith: (prev.sharedWith || []).filter((s) => s.userId !== targetUserId),
sharedWith: (prev.sharedWith || []).filter((s) => (s.targetId || s.userId || s.id) !== targetId && s.userId !== targetId),
}
: prev
);
@@ -4403,7 +4403,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
type="button"
onClick={() => {
setAddShareType('team');
setShareTargetEmailOrId('');
setSelectedShareUser(null);
}}
className={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1 border transition-colors ${
addShareType === 'team'
@@ -4456,43 +4456,29 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
</select>
</div>
) : (
<div className="space-y-2">
<div className="flex flex-col sm:flex-row gap-2">
<select
value={shareTargetEmailOrId}
onChange={(e) => setShareTargetEmailOrId(e.target.value)}
className="input-field h-8 px-2.5 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-xs flex-1 cursor-pointer"
>
<option value="" className="bg-panel text-muted">Nutzer auswählen oder Email eingeben…</option>
{collaborators
.filter((u) => u.id !== currentUser?.id && !docShares.some((s) => s.userId === u.id))
.map((u) => (
<option key={u.id} value={u.id} className="bg-panel text-main">
👤 {u.displayName || u.email} ({u.email})
</option>
))}
</select>
<div className="flex-1">
<UserAutocomplete
users={collaborators}
selectedUser={selectedShareUser}
onSelectUser={setSelectedShareUser}
excludeUserIds={[
currentUser?.id || '',
...docShares.map((s) => s.targetId || s.userId).filter(Boolean),
]}
disabled={sharingLoading}
placeholder="Benutzer suchen (Name oder @Benutzername)…"
/>
</div>
<select
value={sharePermission}
onChange={(e) => setSharePermission(e.target.value as 'read' | 'write')}
className="input-field h-8 px-2.5 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-xs w-full sm:w-40 cursor-pointer"
className="input-field h-8 px-2.5 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-xs w-full sm:w-40 cursor-pointer shrink-0"
>
<option value="read" className="bg-panel text-main">Nur Lesen (read)</option>
<option value="write" className="bg-panel text-main">Schreiben (write)</option>
</select>
</div>
{/* Direct Email fallback if user not in list */}
{(!shareTargetEmailOrId || !collaborators.some((u) => u.id === shareTargetEmailOrId)) && (
<input
type="email"
placeholder="Oder E-Mail-Adresse direkt eingeben…"
value={shareTargetEmailOrId}
onChange={(e) => setShareTargetEmailOrId(e.target.value)}
className="input-field h-8 px-2.5 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-xs w-full"
/>
)}
</div>
)}
<div className="flex justify-end pt-1">
@@ -4500,7 +4486,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
type="submit"
disabled={
sharingLoading ||
(addShareType === 'team' ? !selectedShareTeamId : !shareTargetEmailOrId.trim())
(addShareType === 'team' ? !selectedShareTeamId : !selectedShareUser)
}
className="btn-primary h-8 px-3 text-xs flex items-center gap-1.5"
>
@@ -4525,21 +4511,39 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
</div>
) : (
<div className="space-y-1.5 max-h-56 overflow-y-auto">
{docShares.map((share) => (
{docShares.map((share) => {
const isTeam = share.targetType === 'team' || Boolean(share.teamId) || String(share.targetId || '').startsWith('team_');
const targetKey = share.targetId || share.userId || share.id;
return (
<div
key={share.id || share.userId}
key={share.id || targetKey}
className="p-2.5 bg-panel-elevated border border-border rounded-card flex items-center justify-between gap-3 text-xs"
>
<div className="min-w-0">
<div className="font-semibold text-main truncate">
{share.displayName || share.userEmail || share.userId}
<div className="flex items-center gap-2 min-w-0">
{isTeam ? (
<div className="w-6 h-6 rounded-btn bg-accent/20 text-accent flex items-center justify-center shrink-0">
<Users className="w-3.5 h-3.5" />
</div>
{share.userEmail && share.displayName && (
) : (
<div className="w-6 h-6 rounded-full bg-accent/20 text-accent font-bold text-[10px] flex items-center justify-center shrink-0">
{(share.displayName || share.userEmail || share.targetName || '?')[0].toUpperCase()}
</div>
)}
<div className="min-w-0">
<div className="font-semibold text-main truncate flex items-center gap-1.5">
<span>{share.displayName || share.targetName || share.userEmail || share.userId}</span>
{isTeam && (
<span className="badge-neutral text-[9px] uppercase px-1 py-0.2">Gruppe</span>
)}
</div>
{!isTeam && share.userEmail && share.displayName && (
<div className="text-[11px] text-muted truncate">{share.userEmail}</div>
)}
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{!isTeam && (
<select
value={share.permission}
onChange={(e) => handleUpdateSharePerm(share.userId, e.target.value as 'read' | 'write')}
@@ -4549,10 +4553,16 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
<option value="read" className="bg-panel text-main">Nur Lesen</option>
<option value="write" className="bg-panel text-main">Schreiben</option>
</select>
)}
{isTeam && (
<span className="badge-neutral text-[10px]">
{share.permission === 'write' ? 'Schreiben' : 'Nur Lesen'}
</span>
)}
<button
type="button"
onClick={() => handleRemoveShare(share.userId)}
onClick={() => handleRemoveShare(targetKey)}
className="btn-icon btn-icon-danger h-7 w-7"
title="Freigabe entziehen"
disabled={sharingLoading}
@@ -4561,7 +4571,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
</button>
</div>
</div>
))}
);
})}
</div>
)}
</div>
+8 -2
View File
@@ -589,9 +589,15 @@ export const api = {
return ensureArray(res).map((s) => ({
...s,
members: ensureArray(s.members),
shares: ensureArray(s.shares),
}));
},
async getSpaceShares(spaceId: string): Promise<SpaceShare[]> {
const res = await request<SpaceShare[]>(`/api/v1/spaces/${spaceId}/shares`);
return ensureArray(res);
},
async createSpace(name: string, icon: string, description: string): Promise<Space> {
return request<Space>('/api/v1/spaces', {
method: 'POST',
@@ -606,8 +612,8 @@ export const api = {
});
},
async addSpaceMember(spaceId: string, target: { userId?: string; email?: string; teamId?: string; role?: string }): Promise<SpaceMember> {
return request<SpaceMember>(`/api/v1/spaces/${spaceId}/members`, {
async addSpaceMember(spaceId: string, target: { userId?: string; email?: string; teamId?: string; role?: string }): Promise<any> {
return request<any>(`/api/v1/spaces/${spaceId}/members`, {
method: 'POST',
body: JSON.stringify(target),
});
+16 -1
View File
@@ -162,7 +162,10 @@ export interface DocumentBlock {
export interface DocShare {
id: string;
docId: string;
userId: string;
targetType?: 'user' | 'team';
targetId?: string;
targetName?: string;
userId?: string;
userEmail?: string;
displayName?: string;
permission: 'read' | 'write';
@@ -238,6 +241,17 @@ export interface SpaceMember {
joinedAt: string;
}
export interface SpaceShare {
id: string;
spaceId: string;
targetType: 'team' | 'user';
targetId: string;
targetName?: string;
role: 'editor' | 'viewer';
createdBy?: string;
createdAt: string;
}
export interface Space {
id: string;
name: string;
@@ -247,6 +261,7 @@ export interface Space {
isPersonal: boolean;
ownerId: string;
members?: SpaceMember[];
shares?: SpaceShare[];
listsCount: number;
createdAt: string;
}
+28 -33
View File
@@ -129,6 +129,7 @@ func (s *Server) Router() http.Handler {
r.Get("/api/v1/spaces", s.handleGetSpaces)
r.Post("/api/v1/spaces", s.handleCreateSpace)
r.Post("/api/v1/spaces/join", s.handleJoinSpace)
r.Get("/api/v1/spaces/{id}/shares", s.handleGetSpaceShares)
r.Post("/api/v1/spaces/{id}/members", s.handleAddSpaceMember)
r.Delete("/api/v1/spaces/{id}/members/{userId}", s.handleRemoveSpaceMember)
r.Get("/api/v1/spaces/{id}/lists", s.handleGetSpaceLists)
@@ -953,28 +954,17 @@ func (s *Server) handleCreateDocShare(w http.ResponseWriter, r *http.Request) {
}
if req.TeamID != "" {
members, err := s.store.GetTeamMembers(req.TeamID)
share, err := s.store.CreateDocTeamShare(userID, docID, req.TeamID, req.Permission)
if err != nil {
writeError(w, http.StatusBadRequest, "Team nicht gefunden")
writeError(w, http.StatusBadRequest, err.Error())
return
}
var lastShare *domain.DocShare
members, _ := s.store.GetTeamMembers(req.TeamID)
for _, tm := range members {
if tm.UserID == userID {
continue
s.wsHub.BroadcastToUser(tm.UserID, "DOC_SHARED", share)
}
share, err := s.store.CreateDocShare(userID, docID, tm.UserID, req.Permission)
if err == nil && share != nil {
lastShare = share
s.wsHub.BroadcastToUser(share.UserID, "DOC_SHARED", share)
s.wsHub.BroadcastDocEvent(docID, "DOC_SHARED", share)
}
}
if lastShare != nil {
writeJSON(w, http.StatusCreated, lastShare)
} else {
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
}
writeJSON(w, http.StatusCreated, share)
return
}
@@ -1153,25 +1143,17 @@ func (s *Server) handleAddSpaceMember(w http.ResponseWriter, r *http.Request) {
}
if req.TeamID != "" {
members, err := s.store.GetTeamMembers(req.TeamID)
share, err := s.store.CreateSpaceTeamShare(currentUserID, spaceID, req.TeamID, req.Role)
if err != nil {
writeError(w, http.StatusBadRequest, "Team nicht gefunden")
writeError(w, http.StatusBadRequest, err.Error())
return
}
var lastMember *domain.SpaceMember
members, _ := s.store.GetTeamMembers(req.TeamID)
for _, tm := range members {
member, err := s.store.AddSpaceMember(currentUserID, spaceID, tm.UserID, req.Role)
if err == nil && member != nil {
lastMember = member
s.wsHub.BroadcastToUser(member.UserID, "SPACE_JOINED", map[string]interface{}{"spaceId": spaceID})
s.wsHub.Broadcast(spaceID, "", "MEMBER_ADDED", member)
}
}
if lastMember != nil {
writeJSON(w, http.StatusCreated, lastMember)
} else {
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
s.wsHub.BroadcastToUser(tm.UserID, "SPACE_JOINED", map[string]interface{}{"spaceId": spaceID})
}
s.wsHub.Broadcast(spaceID, "", "SPACE_SHARED", share)
writeJSON(w, http.StatusCreated, share)
return
}
@@ -1196,18 +1178,31 @@ func (s *Server) handleAddSpaceMember(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, member)
}
func (s *Server) handleGetSpaceShares(w http.ResponseWriter, r *http.Request) {
spaceID := chi.URLParam(r, "id")
shares, err := s.store.GetSpaceShares(spaceID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, shares)
}
func (s *Server) handleRemoveSpaceMember(w http.ResponseWriter, r *http.Request) {
currentUserID := auth.GetUserID(r.Context())
spaceID := chi.URLParam(r, "id")
targetUserID := chi.URLParam(r, "userId")
if err := s.store.RemoveSpaceMember(currentUserID, spaceID, targetUserID); err != nil {
// If prefixed with sm_, strip it for individual space member removal
cleanTargetID := strings.TrimPrefix(targetUserID, "sm_")
if err := s.store.RemoveSpaceMember(currentUserID, spaceID, cleanTargetID); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastToUser(targetUserID, "SPACE_LEFT", map[string]interface{}{"spaceId": spaceID})
s.wsHub.Broadcast(spaceID, "", "MEMBER_REMOVED", map[string]interface{}{"spaceId": spaceID, "userId": targetUserID})
s.wsHub.BroadcastToUser(cleanTargetID, "SPACE_LEFT", map[string]interface{}{"spaceId": spaceID})
s.wsHub.Broadcast(spaceID, "", "MEMBER_REMOVED", map[string]interface{}{"spaceId": spaceID, "userId": cleanTargetID})
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
}
+16 -1
View File
@@ -254,7 +254,10 @@ type DocumentBlock struct {
type DocShare struct {
ID string `json:"id"`
DocID string `json:"docId"`
UserID string `json:"userId"`
TargetType string `json:"targetType,omitempty"` // user | team
TargetID string `json:"targetId,omitempty"`
TargetName string `json:"targetName,omitempty"`
UserID string `json:"userId,omitempty"`
UserEmail string `json:"userEmail,omitempty"`
DisplayName string `json:"displayName,omitempty"`
Permission string `json:"permission"` // read | write
@@ -330,6 +333,17 @@ type SpaceMember struct {
JoinedAt string `json:"joinedAt"`
}
type SpaceShare struct {
ID string `json:"id"`
SpaceID string `json:"spaceId"`
TargetType string `json:"targetType"` // team | user
TargetID string `json:"targetId"`
TargetName string `json:"targetName,omitempty"`
Role string `json:"role"` // editor | viewer
CreatedBy string `json:"createdBy"`
CreatedAt string `json:"createdAt"`
}
type Space struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -339,6 +353,7 @@ type Space struct {
IsPersonal bool `json:"isPersonal"`
OwnerID string `json:"ownerId"`
Members []SpaceMember `json:"members"`
Shares []SpaceShare `json:"shares,omitempty"`
ListsCount int `json:"listsCount"`
CreatedAt string `json:"createdAt"`
}
+329 -39
View File
@@ -337,6 +337,14 @@ 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);",
"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_doc ON doc_team_shares(doc_id);",
"CREATE TABLE IF NOT EXISTS space_team_shares (id TEXT PRIMARY KEY, space_id TEXT NOT NULL, team_id TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'editor', created_at TEXT NOT NULL, created_by TEXT NOT NULL, UNIQUE(space_id, team_id));",
"CREATE INDEX IF NOT EXISTS idx_space_team_shares_team ON space_team_shares(team_id);",
"CREATE INDEX IF NOT EXISTS idx_space_team_shares_space ON space_team_shares(space_id);",
}
for _, q := range columnMigrations {
_, _ = s.db.Exec(q)
@@ -1331,19 +1339,26 @@ func (s *Store) GetDocuments(userID string) ([]domain.Document, error) {
}
_ = rows.Close()
// 2. Fetch documents shared with the user (with per-user is_pinned from doc_pins)
// 2. Fetch documents shared with the user directly or via teams (with per-user is_pinned from doc_pins)
sharedRows, err := s.db.Query(
`SELECT d.id, d.user_id, d.space_id, d.title, d.icon, d.content, d.blocks_json, d.tags, d.folder_path,
CASE WHEN p.doc_id IS NOT NULL THEN 1 ELSE 0 END AS is_pinned,
d.is_archived, d.created_at, d.updated_at,
s.permission, u.display_name
COALESCE(s.permission, dts.permission, 'read') AS permission, u.display_name
FROM documents d
JOIN doc_shares s ON d.id = s.doc_id
JOIN users u ON d.user_id = u.id
LEFT JOIN doc_shares s ON d.id = s.doc_id AND s.user_id = ?
LEFT JOIN (
SELECT dts2.doc_id, dts2.permission
FROM doc_team_shares dts2
JOIN team_members tm ON tm.team_id = dts2.team_id
WHERE tm.user_id = ?
) dts ON d.id = dts.doc_id
LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ?
WHERE s.user_id = ? AND d.is_archived = 0
WHERE (s.user_id = ? OR dts.doc_id IS NOT NULL) AND d.is_archived = 0 AND d.user_id != ?
GROUP BY d.id
ORDER BY is_pinned DESC, d.updated_at DESC`,
userID, userID,
userID, userID, userID, userID, userID,
)
if err == nil {
for sharedRows.Next() {
@@ -1404,18 +1419,25 @@ func (s *Store) GetDocumentByID(userID, docID string) (*domain.Document, error)
return doc, nil
}
// Try shared document (with per-user is_pinned)
// Try shared document (direct or team share, with per-user is_pinned)
sharedRow := s.db.QueryRow(
`SELECT d.id, d.user_id, d.space_id, d.title, d.icon, d.content, d.blocks_json, d.tags, d.folder_path,
CASE WHEN p.doc_id IS NOT NULL THEN 1 ELSE 0 END AS is_pinned,
d.is_archived, d.created_at, d.updated_at,
s.permission, u.display_name
COALESCE(s.permission, dts.permission, 'read') AS permission, u.display_name
FROM documents d
JOIN doc_shares s ON d.id = s.doc_id
JOIN users u ON d.user_id = u.id
LEFT JOIN doc_shares s ON d.id = s.doc_id AND s.user_id = ?
LEFT JOIN (
SELECT dts2.doc_id, dts2.permission
FROM doc_team_shares dts2
JOIN team_members tm ON tm.team_id = dts2.team_id
WHERE tm.user_id = ?
) dts ON d.id = dts.doc_id
LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ?
WHERE d.id = ? AND s.user_id = ?`,
userID, docID, userID,
WHERE d.id = ? AND (s.user_id = ? OR dts.doc_id IS NOT NULL)
LIMIT 1`,
userID, userID, userID, docID, userID,
)
d := &domain.Document{}
var blocksStr, tagsStr string
@@ -1630,11 +1652,17 @@ func (s *Store) GetWorkspaceTags(userID string) ([]domain.TagSummary, error) {
FROM doc_block_tags t
JOIN documents d ON t.doc_id = d.id
LEFT JOIN doc_shares s ON s.doc_id = d.id AND s.user_id = ?
WHERE (d.user_id = ? OR s.user_id = ?)
LEFT JOIN (
SELECT dts2.doc_id
FROM doc_team_shares dts2
JOIN team_members tm ON tm.team_id = dts2.team_id
WHERE tm.user_id = ?
) dts ON d.id = dts.doc_id
WHERE (d.user_id = ? OR s.user_id = ? OR dts.doc_id IS NOT NULL)
AND d.is_archived = 0
GROUP BY t.tag
ORDER BY count DESC, t.tag ASC`,
userID, userID, userID,
userID, userID, userID, userID,
)
if err != nil {
return nil, err
@@ -1686,16 +1714,22 @@ func (s *Store) GetTagDetails(userID, tag string) (*domain.TagDetails, error) {
rows, err := s.db.Query(
`SELECT t.block_id, d.id, d.title, d.icon, d.folder_path, d.blocks_json, d.updated_at, d.user_id,
COALESCE(u.display_name, '') as owner_name,
COALESCE(s.permission, '') as shared_permission
COALESCE(s.permission, dts.permission, '') as shared_permission
FROM doc_block_tags t
JOIN documents d ON t.doc_id = d.id
LEFT JOIN users u ON d.user_id = u.id
LEFT JOIN doc_shares s ON s.doc_id = d.id AND s.user_id = ?
WHERE (d.user_id = ? OR s.user_id = ?)
LEFT JOIN (
SELECT dts2.doc_id, dts2.permission
FROM doc_team_shares dts2
JOIN team_members tm ON tm.team_id = dts2.team_id
WHERE tm.user_id = ?
) dts ON d.id = dts.doc_id
WHERE (d.user_id = ? OR s.user_id = ? OR dts.doc_id IS NOT NULL)
AND t.tag = ?
AND d.is_archived = 0
ORDER BY d.updated_at DESC`,
userID, userID, userID, cleanTag,
userID, userID, userID, userID, cleanTag,
)
if err != nil {
return nil, err
@@ -1946,7 +1980,10 @@ func (s *Store) DeleteFolder(userID, folderPath string) ([]string, error) {
}
func (s *Store) GetDocShares(docID string) ([]domain.DocShare, error) {
rows, err := s.db.Query(
shares := make([]domain.DocShare, 0)
// 1. Direct user shares
uRows, err := s.db.Query(
`SELECT s.id, s.doc_id, s.user_id, u.email, u.display_name, s.permission, s.created_by, s.created_at
FROM doc_shares s
JOIN users u ON s.user_id = u.id
@@ -1954,21 +1991,91 @@ func (s *Store) GetDocShares(docID string) ([]domain.DocShare, error) {
ORDER BY s.created_at ASC`,
docID,
)
if err != nil {
return nil, err
}
defer rows.Close()
shares := make([]domain.DocShare, 0)
for rows.Next() {
if err == nil {
for uRows.Next() {
var sh domain.DocShare
if err := rows.Scan(&sh.ID, &sh.DocID, &sh.UserID, &sh.UserEmail, &sh.DisplayName, &sh.Permission, &sh.CreatedBy, &sh.CreatedAt); err == nil {
if err := uRows.Scan(&sh.ID, &sh.DocID, &sh.UserID, &sh.UserEmail, &sh.DisplayName, &sh.Permission, &sh.CreatedBy, &sh.CreatedAt); err == nil {
sh.TargetType = "user"
sh.TargetID = sh.UserID
sh.TargetName = sh.DisplayName
shares = append(shares, sh)
}
}
_ = uRows.Close()
}
// 2. Team shares
tRows, err := s.db.Query(
`SELECT s.id, s.doc_id, s.team_id, t.name, s.permission, s.created_by, s.created_at
FROM doc_team_shares s
JOIN teams t ON s.team_id = t.id
WHERE s.doc_id = ?
ORDER BY s.created_at ASC`,
docID,
)
if err == nil {
for tRows.Next() {
var sh domain.DocShare
var teamID, teamName string
if err := tRows.Scan(&sh.ID, &sh.DocID, &teamID, &teamName, &sh.Permission, &sh.CreatedBy, &sh.CreatedAt); err == nil {
sh.TargetType = "team"
sh.TargetID = teamID
sh.TargetName = teamName
sh.DisplayName = teamName
shares = append(shares, sh)
}
}
_ = tRows.Close()
}
return shares, nil
}
func (s *Store) CreateDocTeamShare(currentUserID, docID, teamID, permission string) (*domain.DocShare, error) {
doc, err := s.GetDocumentByID(currentUserID, docID)
if err != nil {
return nil, err
}
if !doc.IsOwner && doc.Permission != "write" {
return nil, fmt.Errorf("nur der Eigentümer oder Bearbeiter kann Freigaben verwalten")
}
cleanTeamID := strings.TrimSpace(teamID)
var teamName string
err = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, cleanTeamID).Scan(&teamName)
if err != nil {
return nil, fmt.Errorf("team nicht gefunden")
}
if permission != "read" && permission != "write" {
permission = "read"
}
now := domain.NowISO()
shareID := domain.NewID("share")
_, err = s.db.Exec(
`INSERT INTO doc_team_shares (id, doc_id, team_id, permission, created_at, created_by)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(doc_id, team_id) DO UPDATE SET permission = excluded.permission`,
shareID, docID, cleanTeamID, permission, now, currentUserID,
)
if err != nil {
return nil, err
}
return &domain.DocShare{
ID: shareID,
DocID: docID,
TargetType: "team",
TargetID: cleanTeamID,
TargetName: teamName,
DisplayName: teamName,
Permission: permission,
CreatedBy: currentUserID,
CreatedAt: now,
}, nil
}
func (s *Store) CreateDocShare(currentUserID, docID, targetUserIdentifier, permission string) (*domain.DocShare, error) {
doc, err := s.GetDocumentByID(currentUserID, docID)
if err != nil {
@@ -1978,13 +2085,19 @@ func (s *Store) CreateDocShare(currentUserID, docID, targetUserIdentifier, permi
return nil, fmt.Errorf("nur der Eigentümer oder Bearbeiter kann Freigaben verwalten")
}
cleanTarget := strings.TrimSpace(targetUserIdentifier)
// If identifier looks like a team or is prefixed with team_, route to CreateDocTeamShare
if strings.HasPrefix(cleanTarget, "team_") {
return s.CreateDocTeamShare(currentUserID, docID, cleanTarget, permission)
}
if permission != "read" && permission != "write" {
permission = "read"
}
// Resolve target user by ID, username, or Email
var targetUser *domain.User
cleanTarget := strings.TrimSpace(targetUserIdentifier)
if strings.HasPrefix(cleanTarget, "usr_") {
targetUser, _ = s.GetUserByID(cleanTarget)
}
@@ -1995,6 +2108,11 @@ func (s *Store) CreateDocShare(currentUserID, docID, targetUserIdentifier, permi
targetUser, err = s.GetUserByID(cleanTarget)
}
if err != nil || targetUser == nil {
// Check if it's a team ID that doesn't start with team_
var tName string
if s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, cleanTarget).Scan(&tName) == nil {
return s.CreateDocTeamShare(currentUserID, docID, cleanTarget, permission)
}
return nil, fmt.Errorf("benutzer nicht gefunden")
}
@@ -2017,6 +2135,9 @@ func (s *Store) CreateDocShare(currentUserID, docID, targetUserIdentifier, permi
return &domain.DocShare{
ID: shareID,
DocID: docID,
TargetType: "user",
TargetID: targetUser.ID,
TargetName: targetUser.DisplayName,
UserID: targetUser.ID,
UserEmail: targetUser.Email,
DisplayName: targetUser.DisplayName,
@@ -2026,16 +2147,26 @@ func (s *Store) CreateDocShare(currentUserID, docID, targetUserIdentifier, permi
}, nil
}
func (s *Store) DeleteDocShare(currentUserID, docID, targetUserID string) error {
func (s *Store) DeleteDocShare(currentUserID, docID, targetID string) error {
doc, err := s.GetDocumentByID(currentUserID, docID)
if err != nil {
return err
}
if !doc.IsOwner && doc.Permission != "write" && currentUserID != targetUserID {
if !doc.IsOwner && doc.Permission != "write" && currentUserID != targetID {
return fmt.Errorf("keine Berechtigung zum Entfernen der Freigabe")
}
_, err = s.db.Exec(`DELETE FROM doc_shares WHERE doc_id = ? AND user_id = ?`, docID, targetUserID)
cleanTarget := strings.TrimSpace(targetID)
// Try deleting team share first
res, err := s.db.Exec(`DELETE FROM doc_team_shares WHERE doc_id = ? AND (id = ? OR team_id = ?)`, docID, cleanTarget, cleanTarget)
if err == nil {
if rows, _ := res.RowsAffected(); rows > 0 {
return nil
}
}
// Fallback to user share
_, err = s.db.Exec(`DELETE FROM doc_shares WHERE doc_id = ? AND (id = ? OR user_id = ?)`, docID, cleanTarget, cleanTarget)
return err
}
@@ -2186,7 +2317,12 @@ func (s *Store) AddSpaceMember(currentUserID, spaceID, targetUserIdentifier, rol
return nil, errors.New("benutzer nicht gefunden")
}
if role != "owner" && role != "editor" && role != "viewer" {
// Protect existing owner from role downgrade!
var existingRole string
_ = s.db.QueryRow(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, targetUser.ID).Scan(&existingRole)
if existingRole == "owner" {
role = "owner"
} else if role != "owner" && role != "editor" && role != "viewer" {
role = "editor"
}
@@ -2210,6 +2346,135 @@ func (s *Store) AddSpaceMember(currentUserID, spaceID, targetUserIdentifier, rol
}, nil
}
func (s *Store) CreateSpaceTeamShare(currentUserID, spaceID, teamID, role string) (*domain.SpaceShare, error) {
var myRole string
err := s.db.QueryRow(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, currentUserID).Scan(&myRole)
if err != nil || (myRole != "owner" && myRole != "editor") {
return nil, errors.New("keine Berechtigung für diesen Bereich")
}
cleanTeamID := strings.TrimSpace(teamID)
var teamName string
err = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, cleanTeamID).Scan(&teamName)
if err != nil {
return nil, errors.New("team nicht gefunden")
}
if role != "owner" && role != "editor" && role != "viewer" {
role = "editor"
}
now := domain.NowISO()
shareID := domain.NewID("spshare")
_, err = s.db.Exec(
`INSERT INTO space_team_shares (id, space_id, team_id, role, created_at, created_by)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(space_id, team_id) DO UPDATE SET role = excluded.role`,
shareID, spaceID, cleanTeamID, role, now, currentUserID,
)
if err != nil {
return nil, err
}
// Add team members as space members, but preserve owner role!
members, _ := s.GetTeamMembers(cleanTeamID)
for _, tm := range members {
var curRole string
_ = s.db.QueryRow(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, tm.UserID).Scan(&curRole)
if curRole == "owner" {
continue
}
_, _ = s.db.Exec(
`INSERT INTO space_members (space_id, user_id, role, joined_at) VALUES (?, ?, ?, ?)
ON CONFLICT(space_id, user_id) DO UPDATE SET role = excluded.role`,
spaceID, tm.UserID, role, now,
)
}
return &domain.SpaceShare{
ID: shareID,
SpaceID: spaceID,
TargetType: "team",
TargetID: cleanTeamID,
TargetName: teamName,
Role: role,
CreatedBy: currentUserID,
CreatedAt: now,
}, nil
}
func (s *Store) GetSpaceShares(spaceID string) ([]domain.SpaceShare, error) {
shares := make([]domain.SpaceShare, 0)
// 1. Team shares
tRows, err := s.db.Query(
`SELECT s.id, s.space_id, s.team_id, t.name, s.role, s.created_by, s.created_at
FROM space_team_shares s
JOIN teams t ON s.team_id = t.id
WHERE s.space_id = ?
ORDER BY s.created_at ASC`,
spaceID,
)
if err == nil {
for tRows.Next() {
var sh domain.SpaceShare
if err := tRows.Scan(&sh.ID, &sh.SpaceID, &sh.TargetID, &sh.TargetName, &sh.Role, &sh.CreatedBy, &sh.CreatedAt); err == nil {
sh.TargetType = "team"
shares = append(shares, sh)
}
}
_ = tRows.Close()
}
// 2. Individual invited members (non-owner members)
var ownerID string
_ = s.db.QueryRow(`SELECT owner_id FROM spaces WHERE id = ?`, spaceID).Scan(&ownerID)
mRows, err := s.db.Query(
`SELECT sm.user_id, u.display_name, u.email, sm.role, sm.joined_at
FROM space_members sm
JOIN users u ON u.id = sm.user_id
WHERE sm.space_id = ? AND sm.user_id != ?
ORDER BY sm.joined_at ASC`,
spaceID, ownerID,
)
if err == nil {
for mRows.Next() {
var uid, dName, email, role, joinedAt string
if err := mRows.Scan(&uid, &dName, &email, &role, &joinedAt); err == nil {
name := dName
if name == "" {
name = email
}
shares = append(shares, domain.SpaceShare{
ID: "sm_" + uid,
SpaceID: spaceID,
TargetType: "user",
TargetID: uid,
TargetName: name,
Role: role,
CreatedAt: joinedAt,
})
}
}
_ = mRows.Close()
}
return shares, nil
}
func (s *Store) RemoveSpaceTeamShare(currentUserID, spaceID, targetID string) error {
var myRole string
err := s.db.QueryRow(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, currentUserID).Scan(&myRole)
if err != nil || (myRole != "owner" && myRole != "editor") {
return errors.New("keine Berechtigung zum Entfernen der Freigabe")
}
cleanTarget := strings.TrimSpace(targetID)
_, err = s.db.Exec(`DELETE FROM space_team_shares WHERE space_id = ? AND (id = ? OR team_id = ?)`, spaceID, cleanTarget, cleanTarget)
return err
}
func (s *Store) RemoveSpaceMember(currentUserID, spaceID, targetUserID string) error {
var myRole string
err := s.db.QueryRow(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, currentUserID).Scan(&myRole)
@@ -2217,12 +2482,17 @@ func (s *Store) RemoveSpaceMember(currentUserID, spaceID, targetUserID string) e
return errors.New("keine Berechtigung für diesen Bereich")
}
if currentUserID != targetUserID && myRole != "owner" {
cleanTarget := strings.TrimSpace(targetUserID)
if strings.HasPrefix(cleanTarget, "team_") || strings.HasPrefix(cleanTarget, "spshare_") {
return s.RemoveSpaceTeamShare(currentUserID, spaceID, cleanTarget)
}
if currentUserID != cleanTarget && myRole != "owner" {
return errors.New("nur der Eigentümer kann andere Mitglieder entfernen")
}
var targetRole string
_ = s.db.QueryRow(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, targetUserID).Scan(&targetRole)
_ = s.db.QueryRow(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, cleanTarget).Scan(&targetRole)
if targetRole == "owner" {
var ownerCount int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM space_members WHERE space_id = ? AND role = 'owner'`, spaceID).Scan(&ownerCount)
@@ -2231,7 +2501,7 @@ func (s *Store) RemoveSpaceMember(currentUserID, spaceID, targetUserID string) e
}
}
_, err = s.db.Exec(`DELETE FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, targetUserID)
_, err = s.db.Exec(`DELETE FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, cleanTarget)
return err
}
@@ -2239,10 +2509,12 @@ func (s *Store) GetSpacesForUser(userID string) ([]domain.Space, error) {
rows, err := s.db.Query(
`SELECT s.id, s.name, s.icon, s.description, s.invite_code, s.is_personal, s.owner_id, s.created_at
FROM spaces s
JOIN space_members sm ON sm.space_id = s.id
WHERE sm.user_id = ?
LEFT JOIN space_members sm ON sm.space_id = s.id AND sm.user_id = ?
LEFT JOIN space_team_shares sts ON sts.space_id = s.id AND sts.team_id IN (SELECT team_id FROM team_members WHERE user_id = ?)
WHERE sm.user_id = ? OR sts.team_id IS NOT NULL
GROUP BY s.id
ORDER BY s.created_at ASC`,
userID,
userID, userID, userID,
)
if err != nil {
return nil, err
@@ -2261,6 +2533,7 @@ func (s *Store) GetSpacesForUser(userID string) ([]domain.Space, error) {
for i := range spaces {
spaces[i].Members, _ = s.GetSpaceMembers(spaces[i].ID)
spaces[i].Shares, _ = s.GetSpaceShares(spaces[i].ID)
_ = s.db.QueryRow(`SELECT COUNT(*) FROM lists WHERE space_id = ?`, spaces[i].ID).Scan(&spaces[i].ListsCount)
}
return spaces, nil
@@ -2729,12 +3002,15 @@ func (s *Store) AddTaskListShare(taskListID, targetType, targetID, createdBy str
_, 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 NOTHING`,
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,
)
if err != nil {
return nil, err
}
_ = s.db.QueryRow(`SELECT id, created_at FROM task_list_shares WHERE task_list_id = ? AND target_type = ? AND target_id = ?`,
taskListID, targetType, targetID).Scan(&share.ID, &share.CreatedAt)
if targetType == "team" {
_, _ = s.db.Exec(`UPDATE task_lists SET team_id = ? WHERE id = ? AND (team_id = '' OR team_id IS NULL)`, targetID, taskListID)
_ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, targetID).Scan(&share.TargetName)
@@ -3181,14 +3457,28 @@ func (s *Store) GetTeamsForOrg(orgID string) ([]domain.Team, error) {
return teams, nil
}
func (s *Store) GetTeamByID(teamID string) (*domain.Team, error) {
var t domain.Team
err := s.db.QueryRow(
`SELECT id, org_id, name, description, created_at FROM teams WHERE id = ?`,
teamID,
).Scan(&t.ID, &t.OrgID, &t.Name, &t.Description, &t.CreatedAt)
if err != nil {
return nil, err
}
t.Members, _ = s.GetTeamMembers(t.ID)
return &t, nil
}
func (s *Store) GetTeamsForUser(userID string) ([]domain.Team, error) {
rows, err := s.db.Query(
`SELECT DISTINCT t.id, t.org_id, t.name, t.description, t.created_at
FROM teams t
JOIN team_members tm ON tm.team_id = t.id
WHERE tm.user_id = ?
LEFT JOIN team_members tm ON tm.team_id = t.id
LEFT JOIN organizations o ON o.id = t.org_id
WHERE tm.user_id = ? OR o.owner_id = ?
ORDER BY t.created_at ASC`,
userID,
userID, userID,
)
if err != nil {
return []domain.Team{}, err