feat(sharing): remove code redeem, unify Teilen button and team/user sharing across lists and notes
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 58s

This commit is contained in:
sandrodach committed 2026-10-25 13:51:42 +01:00
1 parent 20c50aa8d6
commit 7c47f245e2
6 files changed
+440 -226

No files matched your search

+178 -170
View File
@@ -5,14 +5,14 @@ import {
Circle, Circle,
Trash2, Trash2,
Users, Users,
Copy,
Check, Check,
Radio, Radio,
Sparkles, Sparkles,
UserPlus, Share2,
User as UserIcon,
X, X,
} from 'lucide-react'; } from 'lucide-react';
import { ListItem, SharedList, Space, User, WSMessage } from '../../lib/types'; import { ListItem, SharedList, Space, Team, User, WSMessage } from '../../lib/types';
import { api, buildWebSocketURL, triggerHaptic } from '../../lib/api'; import { api, buildWebSocketURL, triggerHaptic } from '../../lib/api';
interface SharedListsViewProps { interface SharedListsViewProps {
@@ -75,23 +75,22 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
const [newItemQty, setNewItemQty] = useState(''); const [newItemQty, setNewItemQty] = useState('');
const [wsConnected, setWsConnected] = useState(false); const [wsConnected, setWsConnected] = useState(false);
const [copiedInvite, setCopiedInvite] = useState(false);
const [showNewListModal, setShowNewListModal] = useState(false); const [showNewListModal, setShowNewListModal] = useState(false);
const [newListTitle, setNewListTitle] = useState(''); const [newListTitle, setNewListTitle] = useState('');
const [newListIcon, setNewListIcon] = useState('🛒'); const [newListIcon, setNewListIcon] = useState('🛒');
const [showJoinModal, setShowJoinModal] = useState(false);
const [joinCode, setJoinCode] = useState('');
const [showNewSpaceModal, setShowNewSpaceModal] = useState(false); const [showNewSpaceModal, setShowNewSpaceModal] = useState(false);
const [newSpaceName, setNewSpaceName] = useState(''); const [newSpaceName, setNewSpaceName] = useState('');
const [showMembersModal, setShowMembersModal] = useState(false); const [showShareModal, setShowShareModal] = useState(false);
const [collaborators, setCollaborators] = useState<User[]>([]); const [collaborators, setCollaborators] = useState<User[]>([]);
const [teams, setTeams] = useState<Team[]>([]);
const [addShareType, setAddShareType] = useState<'team' | 'user'>('team');
const [selectedTeamId, setSelectedTeamId] = useState('');
const [inviteTarget, setInviteTarget] = useState(''); const [inviteTarget, setInviteTarget] = useState('');
const [inviteRole, setInviteRole] = useState<'editor' | 'viewer'>('editor'); const [inviteRole, setInviteRole] = useState<'editor' | 'viewer'>('editor');
const [invitingLoading, setInvitingLoading] = useState(false); const [invitingLoading, setInvitingLoading] = useState(false);
const [inviteError, setInviteError] = useState<string | null>(null); const [shareError, setShareError] = useState<string | null>(null);
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
@@ -292,64 +291,46 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
setActiveSpace(created); setActiveSpace(created);
}; };
const handleJoinSpace = async (e: React.FormEvent) => { const handleAddShare = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!joinCode.trim()) return; if (!activeSpace) return;
setInvitingLoading(true);
setShareError(null);
try { try {
const joined = await api.joinSpace(joinCode.trim()); if (addShareType === 'team') {
setJoinCode(''); if (!selectedTeamId) {
setShowJoinModal(false); setShareError('Bitte ein Team oder eine Gruppe auswählen');
setInvitingLoading(false);
return;
}
await api.addSpaceMember(activeSpace.id, {
teamId: selectedTeamId,
role: inviteRole,
});
setSelectedTeamId('');
} else {
const target = inviteTarget.trim();
if (!target) {
setShareError('Bitte einen Benutzer auswählen oder eingeben');
setInvitingLoading(false);
return;
}
const isId = collaborators.some((u) => u.id === target);
await api.addSpaceMember(activeSpace.id, {
userId: isId ? target : undefined,
email: !isId ? target : undefined,
role: inviteRole,
});
setInviteTarget('');
}
const sp = await api.getSpaces(); const sp = await api.getSpaces();
setSpaces(sp); setSpaces(sp);
setActiveSpace(joined); const updated = sp.find((s) => s.id === activeSpace.id);
} catch (err: any) { if (updated) setActiveSpace(updated);
alert(err.message || 'Einladungscode ungültig');
}
};
const copyInviteCode = () => {
if (!activeSpace) return;
navigator.clipboard.writeText(activeSpace.inviteCode);
setCopiedInvite(true);
setTimeout(() => setCopiedInvite(false), 2000);
};
const handleAddMember = async (e: React.FormEvent) => {
e.preventDefault();
if (!activeSpace) return;
const target = inviteTarget.trim();
if (!target) return;
setInvitingLoading(true);
setInviteError(null);
try {
const isId = collaborators.some((u) => u.id === target);
const newMember = await api.addSpaceMember(activeSpace.id, {
userId: isId ? target : undefined,
email: !isId ? target : undefined,
role: inviteRole,
});
setActiveSpace((prev) =>
prev
? {
...prev,
members: [...(prev.members || []).filter((m) => m.userId !== newMember.userId), newMember],
}
: prev
);
setSpaces((prev) =>
prev.map((s) =>
s.id === activeSpace.id
? {
...s,
members: [...(s.members || []).filter((m) => m.userId !== newMember.userId), newMember],
}
: s
)
);
setInviteTarget('');
onRefreshGlobal(); onRefreshGlobal();
} catch (err: any) { } catch (err: any) {
setInviteError(err.message || 'Mitglied konnte nicht eingeladen werden'); setShareError(err.message || 'Freigabe konnte nicht hinzugefügt werden');
} finally { } finally {
setInvitingLoading(false); setInvitingLoading(false);
} }
@@ -359,7 +340,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
if (!activeSpace) return; if (!activeSpace) return;
if (!window.confirm('Dieses Mitglied wirklich aus der Einkaufsliste / dem Bereich entfernen?')) return; if (!window.confirm('Dieses Mitglied wirklich aus der Einkaufsliste / dem Bereich entfernen?')) return;
setInvitingLoading(true); setInvitingLoading(true);
setInviteError(null); setShareError(null);
try { try {
await api.removeSpaceMember(activeSpace.id, targetUserId); await api.removeSpaceMember(activeSpace.id, targetUserId);
setActiveSpace((prev) => setActiveSpace((prev) =>
@@ -382,7 +363,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
); );
onRefreshGlobal(); onRefreshGlobal();
} catch (err: any) { } catch (err: any) {
setInviteError(err.message || 'Mitglied konnte nicht entfernt werden'); setShareError(err.message || 'Mitglied konnte nicht entfernt werden');
} finally { } finally {
setInvitingLoading(false); setInvitingLoading(false);
} }
@@ -457,16 +438,9 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
> >
<Plus className="w-3.5 h-3.5 text-accent" /> Space <Plus className="w-3.5 h-3.5 text-accent" /> Space
</button> </button>
<button
onClick={() => setShowJoinModal((prev) => !prev)}
className="btn-action h-8"
>
<UserPlus className="w-3.5 h-3.5 text-accent" /> Code einlösen
</button>
</div> </div>
{/* Realtime Status & Invite User Button */} {/* Realtime Status & Share List Button */}
{activeSpace && ( {activeSpace && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div <div
@@ -482,16 +456,20 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
<button <button
onClick={() => { onClick={() => {
setShowMembersModal(true); setShowShareModal(true);
api.getUsersDirectory().then(setCollaborators).catch(() => {}); setShareError(null);
Promise.all([
api.getUsersDirectory().then(setCollaborators).catch(() => []),
api.getUserTeams().then(setTeams).catch(() => []),
]);
}} }}
title="User zu dieser Einkaufsliste einladen & verwalten" title="Einkaufsliste teilen & verwalten"
className="btn-action h-8 flex items-center gap-1.5" className="btn-action h-8 px-2.5 text-xs flex items-center gap-1.5"
> >
<UserPlus className="w-3.5 h-3.5 text-accent" /> <Share2 className="w-3.5 h-3.5 text-accent" />
<span>User einladen</span> <span>Teilen</span>
{activeSpace.members && activeSpace.members.length > 1 && ( {activeSpace.members && activeSpace.members.length > 1 && (
<span className="ml-1 px-1.5 py-0.2 bg-surface-2 text-main text-[10px] font-semibold rounded-full border border-border"> <span className="badge-neutral font-mono text-[10px]">
{activeSpace.members.length} {activeSpace.members.length}
</span> </span>
)} )}
@@ -500,7 +478,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
)} )}
</div> </div>
{/* Create or Join Space Modals */} {/* Create Space Modal */}
{showNewSpaceModal && ( {showNewSpaceModal && (
<form <form
onSubmit={handleCreateSpace} onSubmit={handleCreateSpace}
@@ -523,28 +501,6 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
</form> </form>
)} )}
{showJoinModal && (
<form
onSubmit={handleJoinSpace}
className="p-2 panel-surface flex items-center gap-2"
>
<input
type="text"
value={joinCode}
onChange={(e) => setJoinCode(e.target.value)}
placeholder="Invite-Code des Partners eingeben…"
className="flex-1 h-9 px-3 input-field text-sm uppercase"
autoFocus
/>
<button
type="submit"
className="btn-primary h-9 px-4 text-xs sm:text-sm"
>
Beitreten
</button>
</form>
)}
{/* List Tabs within Active Space */} {/* List Tabs within Active Space */}
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
{lists.map((lst) => { {lists.map((lst) => {
@@ -770,14 +726,14 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
</div> </div>
) : null} ) : null}
{/* Space & Shopping List Members Modal */} {/* Space & Shopping List Sharing Modal */}
{showMembersModal && activeSpace && ( {showShareModal && activeSpace && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs">
<div className="panel-surface w-full max-w-lg p-5 shadow-panel rounded-card border border-border space-y-4"> <div className="panel-surface w-full max-w-lg p-5 shadow-panel rounded-card border border-border space-y-4">
{/* Modal Header */} {/* Modal Header */}
<div className="flex items-center justify-between pb-2 border-b border-border"> <div className="flex items-center justify-between pb-2 border-b border-border">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Users className="w-5 h-5 text-accent" /> <Share2 className="w-5 h-5 text-accent" />
<h3 className="text-base font-bold text-main"> <h3 className="text-base font-bold text-main">
Einkaufsliste teilen: {activeSpace.name} Einkaufsliste teilen: {activeSpace.name}
</h3> </h3>
@@ -785,8 +741,8 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
setShowMembersModal(false); setShowShareModal(false);
setInviteError(null); setShareError(null);
}} }}
className="btn-icon" className="btn-icon"
title="Schließen" title="Schließen"
@@ -795,63 +751,133 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
</button> </button>
</div> </div>
{inviteError && ( {shareError && (
<div className="p-2.5 bg-status-danger/10 border border-status-danger/30 text-status-danger rounded text-xs"> <div className="p-2.5 bg-status-danger/10 border border-status-danger/30 text-status-danger rounded text-xs">
{inviteError} {shareError}
</div> </div>
)} )}
{/* Invite User Form */} {/* Sharing Form (Team / Gruppe vs. Einzelner Benutzer) */}
<form onSubmit={handleAddMember} className="space-y-2.5 p-3 bg-panel-elevated border border-border rounded-card"> <form onSubmit={handleAddShare} className="space-y-3 p-3 bg-panel-elevated border border-border rounded-card">
<div className="text-xs font-semibold text-main">User direkt einladen</div> <div className="flex items-center justify-between gap-2">
<div className="flex flex-col sm:flex-row gap-2"> <span className="text-xs font-semibold text-main">Freigabe hinzufügen:</span>
<select <div className="flex items-center gap-1">
value={inviteTarget} <button
onChange={(e) => setInviteTarget(e.target.value)} type="button"
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" onClick={() => {
> setAddShareType('team');
<option value="" className="bg-panel text-muted">User auswählen oder Name/E-Mail eingeben…</option> setInviteTarget('');
{collaborators }}
.filter( className={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1 border transition-colors ${
(u) => addShareType === 'team'
u.id !== currentUser?.id && ? 'bg-panel border-accent text-main'
!(activeSpace.members || []).some((m) => m.userId === u.id) : 'bg-canvas border-border text-muted hover:text-main'
) }`}
.map((u) => ( >
<option key={u.id} value={u.id} className="bg-panel text-main"> <Users className="w-3 h-3 text-accent" />
{u.displayName || u.username || u.email} ({u.username ? `@${u.username}` : u.email}) <span>Team / Gruppe</span>
</option> </button>
))} <button
</select> type="button"
<select onClick={() => {
value={inviteRole} setAddShareType('user');
onChange={(e) => setInviteRole(e.target.value as 'editor' | 'viewer')} setSelectedTeamId('');
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={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1 border transition-colors ${
<option value="editor" className="bg-panel text-main">Bearbeiten</option> addShareType === 'user'
<option value="viewer" className="bg-panel text-main">Nur Lesen</option> ? 'bg-panel border-accent text-main'
</select> : 'bg-canvas border-border text-muted hover:text-main'
}`}
>
<UserIcon className="w-3 h-3 text-accent" />
<span>Einzelner Benutzer</span>
</button>
</div>
</div> </div>
{/* Direct Username / Email input if user not selected from list */} {addShareType === 'team' ? (
{(!inviteTarget || !collaborators.some((u) => u.id === inviteTarget)) && ( <div className="flex flex-col sm:flex-row gap-2">
<input <select
type="text" value={selectedTeamId}
placeholder="Oder Benutzername / E-Mail direkt eingeben…" onChange={(e) => setSelectedTeamId(e.target.value)}
value={inviteTarget} 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"
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" <option value="" className="bg-panel text-muted">-- Team / Gruppe auswählen --</option>
/> {teams.map((t) => (
<option key={t.id} value={t.id} className="bg-panel text-main">
👥 {t.name} ({t.members?.length || 1} Mitglieder)
</option>
))}
</select>
<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"
>
<option value="editor" className="bg-panel text-main">Bearbeiten</option>
<option value="viewer" className="bg-panel text-main">Nur Lesen</option>
</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>
<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"
>
<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"> <div className="flex justify-end pt-1">
<button <button
type="submit" type="submit"
disabled={invitingLoading || !inviteTarget.trim()} disabled={
invitingLoading ||
(addShareType === 'team' ? !selectedTeamId : !inviteTarget.trim())
}
className="btn-primary text-xs h-8 px-4 flex items-center gap-1.5" className="btn-primary text-xs h-8 px-4 flex items-center gap-1.5"
> >
<UserPlus className="w-3.5 h-3.5" /> <Plus className="w-3.5 h-3.5" />
<span>{invitingLoading ? 'Wird hinzugefügt…' : 'Einladen'}</span> <span>
{invitingLoading
? 'Wird geteilt…'
: addShareType === 'team'
? 'Team freigeben'
: 'Benutzer einladen'}
</span>
</button> </button>
</div> </div>
</form> </form>
@@ -859,7 +885,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
{/* Current Space Members List */} {/* Current Space Members List */}
<div className="space-y-2"> <div className="space-y-2">
<div className="text-xs font-semibold text-muted uppercase tracking-wider"> <div className="text-xs font-semibold text-muted uppercase tracking-wider">
Mitglieder in dieser Liste ({activeSpace.members?.length || 1}) Aktive Freigaben & Mitglieder ({activeSpace.members?.length || 1})
</div> </div>
<div className="space-y-1.5 max-h-48 overflow-y-auto"> <div className="space-y-1.5 max-h-48 overflow-y-auto">
{(activeSpace.members || []).map((m) => { {(activeSpace.members || []).map((m) => {
@@ -902,7 +928,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
onClick={() => handleRemoveMember(m.userId)} onClick={() => handleRemoveMember(m.userId)}
disabled={invitingLoading} disabled={invitingLoading}
className="btn-icon text-muted hover:text-status-danger p-1" className="btn-icon text-muted hover:text-status-danger p-1"
title="Mitglied entfernen" title="Freigabe entfernen"
> >
<Trash2 className="w-3.5 h-3.5" /> <Trash2 className="w-3.5 h-3.5" />
</button> </button>
@@ -913,24 +939,6 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
})} })}
</div> </div>
</div> </div>
{/* Invite Code Alternative */}
<div className="pt-2 border-t border-border flex items-center justify-between text-xs text-muted">
<span>Alternativ per Code beitreten:</span>
<button
type="button"
onClick={copyInviteCode}
className="btn-action h-7 text-xs flex items-center gap-1.5"
title="Code kopieren"
>
<span>Code: {activeSpace.inviteCode}</span>
{copiedInvite ? (
<Check className="w-3 h-3 text-status-ok" />
) : (
<Copy className="w-3 h-3 text-muted" />
)}
</button>
</div>
</div> </div>
</div> </div>
)} )}
+145 -52
View File
@@ -48,8 +48,9 @@ import {
Share, Share,
Share2, Share2,
Hash, Hash,
User as UserIcon,
} from 'lucide-react'; } from 'lucide-react';
import { BlockType, Document, DocumentBlock, DocLockInfo, DocShare, TagDetails, TagSummary, User } from '../../lib/types'; import { BlockType, Document, DocumentBlock, DocLockInfo, DocShare, TagDetails, TagSummary, Team, User } from '../../lib/types';
import { api, getToken, triggerHaptic } from '../../lib/api'; import { api, getToken, triggerHaptic } from '../../lib/api';
import { CodeBlockItem } from './CodeBlockItem'; import { CodeBlockItem } from './CodeBlockItem';
@@ -165,6 +166,9 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const [showShareModal, setShowShareModal] = useState(false); const [showShareModal, setShowShareModal] = useState(false);
const [docShares, setDocShares] = useState<DocShare[]>([]); const [docShares, setDocShares] = useState<DocShare[]>([]);
const [collaborators, setCollaborators] = useState<User[]>([]); const [collaborators, setCollaborators] = useState<User[]>([]);
const [shareTeams, setShareTeams] = useState<Team[]>([]);
const [addShareType, setAddShareType] = useState<'team' | 'user'>('team');
const [selectedShareTeamId, setSelectedShareTeamId] = useState('');
const [shareTargetEmailOrId, setShareTargetEmailOrId] = useState(''); const [shareTargetEmailOrId, setShareTargetEmailOrId] = useState('');
const [sharePermission, setSharePermission] = useState<'read' | 'write'>('read'); const [sharePermission, setSharePermission] = useState<'read' | 'write'>('read');
const [sharingLoading, setSharingLoading] = useState(false); const [sharingLoading, setSharingLoading] = useState(false);
@@ -569,12 +573,14 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
setShareError(null); setShareError(null);
setSharingLoading(true); setSharingLoading(true);
try { try {
const [sharesList, usersList] = await Promise.all([ const [sharesList, usersList, teamsList] = await Promise.all([
api.getDocShares(selectedDoc.id).catch(() => []), api.getDocShares(selectedDoc.id).catch(() => []),
api.getCollaborators().catch(() => []), api.getCollaborators().catch(() => []),
api.getUserTeams().catch(() => []),
]); ]);
setDocShares(sharesList); setDocShares(sharesList);
setCollaborators(usersList); setCollaborators(usersList);
setShareTeams(teamsList);
} finally { } finally {
setSharingLoading(false); setSharingLoading(false);
} }
@@ -583,27 +589,43 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const handleAddShare = async (e: React.FormEvent) => { const handleAddShare = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!selectedDoc) return; if (!selectedDoc) return;
const target = shareTargetEmailOrId.trim();
if (!target) return;
setSharingLoading(true); setSharingLoading(true);
setShareError(null); setShareError(null);
try { try {
const isId = collaborators.some((u) => u.id === target); if (addShareType === 'team') {
const newShare = await api.createDocShare(selectedDoc.id, { if (!selectedShareTeamId) {
userId: isId ? target : undefined, setShareError('Bitte ein Team oder eine Gruppe auswählen');
email: !isId ? target : undefined, setSharingLoading(false);
permission: sharePermission, return;
}); }
setDocShares((prev) => [...prev.filter((s) => s.userId !== newShare.userId), newShare]); await api.createDocShare(selectedDoc.id, {
setShareTargetEmailOrId(''); teamId: selectedShareTeamId,
permission: sharePermission,
});
setSelectedShareTeamId('');
} else {
const target = shareTargetEmailOrId.trim();
if (!target) {
setShareError('Bitte einen Benutzer auswählen oder E-Mail eingeben');
setSharingLoading(false);
return;
}
const isId = collaborators.some((u) => u.id === target);
await api.createDocShare(selectedDoc.id, {
userId: isId ? target : undefined,
email: !isId ? target : undefined,
permission: sharePermission,
});
setShareTargetEmailOrId('');
}
const sharesList = await api.getDocShares(selectedDoc.id).catch(() => []);
setDocShares(sharesList);
setSelectedDoc((prev) => setSelectedDoc((prev) =>
prev prev
? { ? {
...prev, ...prev,
sharedWith: [ sharedWith: sharesList,
...(prev.sharedWith || []).filter((s) => s.userId !== newShare.userId),
newShare,
],
} }
: prev : prev
); );
@@ -3608,52 +3630,123 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
{/* If current user is owner: can add shares and manage permissions */} {/* If current user is owner: can add shares and manage permissions */}
{selectedDoc.isOwner !== false ? ( {selectedDoc.isOwner !== false ? (
<div className="space-y-4"> <div className="space-y-4">
<form onSubmit={handleAddShare} className="space-y-2.5 p-3 bg-panel-elevated border border-border rounded-card"> <form onSubmit={handleAddShare} className="space-y-3 p-3 bg-panel-elevated border border-border rounded-card">
<div className="text-xs font-semibold text-main">Neuen Nutzer einladen / berechtigen</div> <div className="flex items-center justify-between gap-2">
<div className="flex flex-col sm:flex-row gap-2"> <span className="text-xs font-semibold text-main">Freigabe hinzufügen:</span>
<select <div className="flex items-center gap-1">
value={shareTargetEmailOrId} <button
onChange={(e) => setShareTargetEmailOrId(e.target.value)} type="button"
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" onClick={() => {
> setAddShareType('team');
<option value="" className="bg-panel text-muted">Nutzer auswählen oder Email eingeben…</option> setShareTargetEmailOrId('');
{collaborators }}
.filter((u) => u.id !== currentUser?.id && !docShares.some((s) => s.userId === u.id)) className={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1 border transition-colors ${
.map((u) => ( addShareType === 'team'
<option key={u.id} value={u.id} className="bg-panel text-main"> ? 'bg-panel border-accent text-main'
{u.displayName || u.email} ({u.email}) : 'bg-canvas border-border text-muted hover:text-main'
</option> }`}
))} >
</select> <Users className="w-3 h-3 text-accent" />
<select <span>Team / Gruppe</span>
value={sharePermission} </button>
onChange={(e) => setSharePermission(e.target.value as 'read' | 'write')} <button
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" type="button"
> onClick={() => {
<option value="read" className="bg-panel text-main">Nur Lesen (read)</option> setAddShareType('user');
<option value="write" className="bg-panel text-main">Schreiben (write)</option> setSelectedShareTeamId('');
</select> }}
className={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1 border transition-colors ${
addShareType === 'user'
? 'bg-panel border-accent text-main'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
<UserIcon className="w-3 h-3 text-accent" />
<span>Einzelner Benutzer</span>
</button>
</div>
</div> </div>
{/* Direct Email fallback if user not in list */} {addShareType === 'team' ? (
{(!shareTargetEmailOrId || !collaborators.some((u) => u.id === shareTargetEmailOrId)) && ( <div className="flex flex-col sm:flex-row gap-2">
<input <select
type="email" value={selectedShareTeamId}
placeholder="Oder E-Mail-Adresse direkt eingeben…" onChange={(e) => setSelectedShareTeamId(e.target.value)}
value={shareTargetEmailOrId} 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"
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" <option value="" className="bg-panel text-muted">-- Team / Gruppe auswählen --</option>
/> {shareTeams.map((t) => (
<option key={t.id} value={t.id} className="bg-panel text-main">
👥 {t.name} ({t.members?.length || 1} Mitglieder)
</option>
))}
</select>
<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"
>
<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>
) : (
<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>
<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"
>
<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"> <div className="flex justify-end pt-1">
<button <button
type="submit" type="submit"
disabled={sharingLoading || !shareTargetEmailOrId.trim()} disabled={
sharingLoading ||
(addShareType === 'team' ? !selectedShareTeamId : !shareTargetEmailOrId.trim())
}
className="btn-primary h-8 px-3 text-xs flex items-center gap-1.5" className="btn-primary h-8 px-3 text-xs flex items-center gap-1.5"
> >
<Plus className="w-3.5 h-3.5" /> <Plus className="w-3.5 h-3.5" />
<span>Freigeben</span> <span>
{sharingLoading
? 'Wird geteilt…'
: addShareType === 'team'
? 'Team freigeben'
: 'Benutzer freigeben'}
</span>
</button> </button>
</div> </div>
</form> </form>
+2 -2
View File
@@ -514,7 +514,7 @@ export const api = {
async createDocShare( async createDocShare(
docId: string, docId: string,
payload: { userId?: string; email?: string; permission: 'read' | 'write' } payload: { userId?: string; email?: string; teamId?: string; permission: 'read' | 'write' }
): Promise<DocShare> { ): Promise<DocShare> {
return request<DocShare>(`/api/v1/documents/${docId}/shares`, { return request<DocShare>(`/api/v1/documents/${docId}/shares`, {
method: 'POST', method: 'POST',
@@ -592,7 +592,7 @@ export const api = {
}); });
}, },
async addSpaceMember(spaceId: string, target: { userId?: string; email?: string; role?: string }): Promise<SpaceMember> { async addSpaceMember(spaceId: string, target: { userId?: string; email?: string; teamId?: string; role?: string }): Promise<SpaceMember> {
return request<SpaceMember>(`/api/v1/spaces/${spaceId}/members`, { return request<SpaceMember>(`/api/v1/spaces/${spaceId}/members`, {
method: 'POST', method: 'POST',
body: JSON.stringify(target), body: JSON.stringify(target),
+59 -2
View File
@@ -921,18 +921,46 @@ func (s *Server) handleCreateDocShare(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
UserID string `json:"userId"` UserID string `json:"userId"`
Email string `json:"email"` Email string `json:"email"`
TeamID string `json:"teamId"`
Permission string `json:"permission"` // read | write Permission string `json:"permission"` // read | write
} }
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
} }
if req.TeamID != "" {
members, err := s.store.GetTeamMembers(req.TeamID)
if err != nil {
writeError(w, http.StatusBadRequest, "Team nicht gefunden")
return
}
var lastShare *domain.DocShare
for _, tm := range members {
if tm.UserID == userID {
continue
}
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})
}
return
}
target := req.UserID target := req.UserID
if target == "" { if target == "" {
target = req.Email target = req.Email
} }
if target == "" { if target == "" {
writeError(w, http.StatusBadRequest, "Benutzer-ID oder E-Mail erforderlich") writeError(w, http.StatusBadRequest, "Benutzer-ID, E-Mail oder Team erforderlich")
return return
} }
@@ -1089,18 +1117,47 @@ func (s *Server) handleAddSpaceMember(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
UserID string `json:"userId"` UserID string `json:"userId"`
Email string `json:"email"` Email string `json:"email"`
TeamID string `json:"teamId"`
Role string `json:"role"` Role string `json:"role"`
} }
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
} }
if req.Role != "owner" && req.Role != "editor" && req.Role != "viewer" {
req.Role = "editor"
}
if req.TeamID != "" {
members, err := s.store.GetTeamMembers(req.TeamID)
if err != nil {
writeError(w, http.StatusBadRequest, "Team nicht gefunden")
return
}
var lastMember *domain.SpaceMember
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})
}
return
}
target := req.UserID target := req.UserID
if target == "" { if target == "" {
target = req.Email target = req.Email
} }
if target == "" { if target == "" {
writeError(w, http.StatusBadRequest, "Benutzer-ID, Benutzername oder E-Mail erforderlich") writeError(w, http.StatusBadRequest, "Benutzer-ID, Benutzername, E-Mail oder Team erforderlich")
return return
} }
+38
View File
@@ -618,6 +618,44 @@ func TestSpaceMembersDirectInvitationAPI(t *testing.T) {
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK for remove member, got %d: %s", rec.Code, rec.Body.String()) t.Fatalf("expected 200 OK for remove member, got %d: %s", rec.Code, rec.Body.String())
} }
// 5. Test adding a team to a space
team, err := store.CreateTeam("", "WG Team", "Haushalt")
if err != nil {
t.Fatalf("failed to create team: %v", err)
}
_ = store.AddTeamMember(team.ID, normalUser.ID, "member")
addTeamBody := []byte(`{"teamId":"` + team.ID + `","role":"editor"}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/spaces/"+createdSpace.ID+"/members", bytes.NewReader(addTeamBody))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated && rec.Code != http.StatusOK {
t.Fatalf("expected 201 or 200 for add space team, got %d: %s", rec.Code, rec.Body.String())
}
// User should see the space again because their team was added
req = httptest.NewRequest(http.MethodGet, "/api/v1/spaces", nil)
req.Header.Set("Authorization", "Bearer "+userToken)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK for user spaces after team add, got %d", rec.Code)
}
userSpaces = nil
_ = json.Unmarshal(rec.Body.Bytes(), &userSpaces)
found = false
for _, s := range userSpaces {
if s.ID == createdSpace.ID {
found = true
break
}
}
if !found {
t.Fatalf("expected space %s to be visible to user via team, got %+v", createdSpace.ID, userSpaces)
}
} }
func TestWorkspaceTagsAPI(t *testing.T) { func TestWorkspaceTagsAPI(t *testing.T) {
+18
View File
@@ -2977,6 +2977,24 @@ func (s *Store) CreateTeam(orgID, name, description string) (*domain.Team, error
if name == "" { if name == "" {
return nil, errors.New("team name required") return nil, errors.New("team name required")
} }
orgID = strings.TrimSpace(orgID)
if orgID == "" {
orgID = "default"
}
var orgExists int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM organizations WHERE id = ?`, orgID).Scan(&orgExists)
if orgExists == 0 {
var ownerID string
_ = s.db.QueryRow(`SELECT id FROM users ORDER BY created_at ASC LIMIT 1`).Scan(&ownerID)
if ownerID != "" {
_, _ = s.db.Exec(
`INSERT OR IGNORE INTO organizations (id, name, owner_id, created_at) VALUES (?, ?, ?, ?)`,
orgID, "Standard", ownerID, domain.NowISO(),
)
}
}
team := &domain.Team{ team := &domain.Team{
ID: domain.NewID("team"), ID: domain.NewID("team"),
OrgID: orgID, OrgID: orgID,