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,
Trash2,
Users,
Copy,
Check,
Radio,
Sparkles,
UserPlus,
Share2,
User as UserIcon,
X,
} 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';
interface SharedListsViewProps {
@@ -75,23 +75,22 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
const [newItemQty, setNewItemQty] = useState('');
const [wsConnected, setWsConnected] = useState(false);
const [copiedInvite, setCopiedInvite] = useState(false);
const [showNewListModal, setShowNewListModal] = useState(false);
const [newListTitle, setNewListTitle] = useState('');
const [newListIcon, setNewListIcon] = useState('🛒');
const [showJoinModal, setShowJoinModal] = useState(false);
const [joinCode, setJoinCode] = useState('');
const [showNewSpaceModal, setShowNewSpaceModal] = useState(false);
const [newSpaceName, setNewSpaceName] = useState('');
const [showMembersModal, setShowMembersModal] = useState(false);
const [showShareModal, setShowShareModal] = useState(false);
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 [inviteRole, setInviteRole] = useState<'editor' | 'viewer'>('editor');
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);
@@ -292,64 +291,46 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
setActiveSpace(created);
};
const handleJoinSpace = async (e: React.FormEvent) => {
const handleAddShare = async (e: React.FormEvent) => {
e.preventDefault();
if (!joinCode.trim()) return;
if (!activeSpace) return;
setInvitingLoading(true);
setShareError(null);
try {
const joined = await api.joinSpace(joinCode.trim());
setJoinCode('');
setShowJoinModal(false);
if (addShareType === 'team') {
if (!selectedTeamId) {
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();
setSpaces(sp);
setActiveSpace(joined);
} catch (err: any) {
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('');
const updated = sp.find((s) => s.id === activeSpace.id);
if (updated) setActiveSpace(updated);
onRefreshGlobal();
} catch (err: any) {
setInviteError(err.message || 'Mitglied konnte nicht eingeladen werden');
setShareError(err.message || 'Freigabe konnte nicht hinzugefügt werden');
} finally {
setInvitingLoading(false);
}
@@ -359,7 +340,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
if (!activeSpace) return;
if (!window.confirm('Dieses Mitglied wirklich aus der Einkaufsliste / dem Bereich entfernen?')) return;
setInvitingLoading(true);
setInviteError(null);
setShareError(null);
try {
await api.removeSpaceMember(activeSpace.id, targetUserId);
setActiveSpace((prev) =>
@@ -382,7 +363,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
);
onRefreshGlobal();
} catch (err: any) {
setInviteError(err.message || 'Mitglied konnte nicht entfernt werden');
setShareError(err.message || 'Mitglied konnte nicht entfernt werden');
} finally {
setInvitingLoading(false);
}
@@ -457,16 +438,9 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
>
<Plus className="w-3.5 h-3.5 text-accent" /> Space
</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>
{/* Realtime Status & Invite User Button */}
{/* Realtime Status & Share List Button */}
{activeSpace && (
<div className="flex items-center gap-2">
<div
@@ -482,16 +456,20 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
<button
onClick={() => {
setShowMembersModal(true);
api.getUsersDirectory().then(setCollaborators).catch(() => {});
setShowShareModal(true);
setShareError(null);
Promise.all([
api.getUsersDirectory().then(setCollaborators).catch(() => []),
api.getUserTeams().then(setTeams).catch(() => []),
]);
}}
title="User zu dieser Einkaufsliste einladen & verwalten"
className="btn-action h-8 flex items-center gap-1.5"
title="Einkaufsliste teilen & verwalten"
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" />
<span>User einladen</span>
<Share2 className="w-3.5 h-3.5 text-accent" />
<span>Teilen</span>
{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}
</span>
)}
@@ -500,7 +478,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
)}
</div>
{/* Create or Join Space Modals */}
{/* Create Space Modal */}
{showNewSpaceModal && (
<form
onSubmit={handleCreateSpace}
@@ -523,28 +501,6 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
</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 */}
<div className="flex flex-wrap items-center gap-1.5">
{lists.map((lst) => {
@@ -770,14 +726,14 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
</div>
) : null}
{/* Space & Shopping List Members Modal */}
{showMembersModal && activeSpace && (
{/* Space & Shopping List Sharing Modal */}
{showShareModal && activeSpace && (
<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">
{/* Modal Header */}
<div className="flex items-center justify-between pb-2 border-b border-border">
<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">
Einkaufsliste teilen: {activeSpace.name}
</h3>
@@ -785,8 +741,8 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
<button
type="button"
onClick={() => {
setShowMembersModal(false);
setInviteError(null);
setShowShareModal(false);
setShareError(null);
}}
className="btn-icon"
title="Schließen"
@@ -795,63 +751,133 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
</button>
</div>
{inviteError && (
{shareError && (
<div className="p-2.5 bg-status-danger/10 border border-status-danger/30 text-status-danger rounded text-xs">
{inviteError}
{shareError}
</div>
)}
{/* Invite User Form */}
<form onSubmit={handleAddMember} className="space-y-2.5 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 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">User auswählen oder Name/E-Mail 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>
{/* Sharing Form (Team / Gruppe vs. Einzelner Benutzer) */}
<form onSubmit={handleAddShare} className="space-y-3 p-3 bg-panel-elevated border border-border rounded-card">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-semibold text-main">Freigabe hinzufügen:</span>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => {
setAddShareType('team');
setInviteTarget('');
}}
className={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1 border transition-colors ${
addShareType === 'team'
? 'bg-panel border-accent text-main'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
<Users className="w-3 h-3 text-accent" />
<span>Team / Gruppe</span>
</button>
<button
type="button"
onClick={() => {
setAddShareType('user');
setSelectedTeamId('');
}}
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>
{/* Direct Username / Email input if user not selected from list */}
{(!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"
/>
{addShareType === 'team' ? (
<div className="flex flex-col sm:flex-row gap-2">
<select
value={selectedTeamId}
onChange={(e) => setSelectedTeamId(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">-- 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">
<button
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"
>
<UserPlus className="w-3.5 h-3.5" />
<span>{invitingLoading ? 'Wird hinzugefügt…' : 'Einladen'}</span>
<Plus className="w-3.5 h-3.5" />
<span>
{invitingLoading
? 'Wird geteilt…'
: addShareType === 'team'
? 'Team freigeben'
: 'Benutzer einladen'}
</span>
</button>
</div>
</form>
@@ -859,7 +885,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
{/* Current Space Members List */}
<div className="space-y-2">
<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 className="space-y-1.5 max-h-48 overflow-y-auto">
{(activeSpace.members || []).map((m) => {
@@ -902,7 +928,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
onClick={() => handleRemoveMember(m.userId)}
disabled={invitingLoading}
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" />
</button>
@@ -913,24 +939,6 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
})}
</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>
)}
+145 -52
View File
@@ -48,8 +48,9 @@ import {
Share,
Share2,
Hash,
User as UserIcon,
} 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 { CodeBlockItem } from './CodeBlockItem';
@@ -165,6 +166,9 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const [showShareModal, setShowShareModal] = useState(false);
const [docShares, setDocShares] = useState<DocShare[]>([]);
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 [sharePermission, setSharePermission] = useState<'read' | 'write'>('read');
const [sharingLoading, setSharingLoading] = useState(false);
@@ -569,12 +573,14 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
setShareError(null);
setSharingLoading(true);
try {
const [sharesList, usersList] = await Promise.all([
const [sharesList, usersList, teamsList] = await Promise.all([
api.getDocShares(selectedDoc.id).catch(() => []),
api.getCollaborators().catch(() => []),
api.getUserTeams().catch(() => []),
]);
setDocShares(sharesList);
setCollaborators(usersList);
setShareTeams(teamsList);
} finally {
setSharingLoading(false);
}
@@ -583,27 +589,43 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const handleAddShare = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedDoc) return;
const target = shareTargetEmailOrId.trim();
if (!target) return;
setSharingLoading(true);
setShareError(null);
try {
const isId = collaborators.some((u) => u.id === target);
const newShare = await api.createDocShare(selectedDoc.id, {
userId: isId ? target : undefined,
email: !isId ? target : undefined,
permission: sharePermission,
});
setDocShares((prev) => [...prev.filter((s) => s.userId !== newShare.userId), newShare]);
setShareTargetEmailOrId('');
if (addShareType === 'team') {
if (!selectedShareTeamId) {
setShareError('Bitte ein Team oder eine Gruppe auswählen');
setSharingLoading(false);
return;
}
await api.createDocShare(selectedDoc.id, {
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) =>
prev
? {
...prev,
sharedWith: [
...(prev.sharedWith || []).filter((s) => s.userId !== newShare.userId),
newShare,
],
sharedWith: sharesList,
}
: prev
);
@@ -3608,52 +3630,123 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
{/* If current user is owner: can add shares and manage permissions */}
{selectedDoc.isOwner !== false ? (
<div className="space-y-4">
<form onSubmit={handleAddShare} className="space-y-2.5 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 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>
<form onSubmit={handleAddShare} className="space-y-3 p-3 bg-panel-elevated border border-border rounded-card">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-semibold text-main">Freigabe hinzufügen:</span>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => {
setAddShareType('team');
setShareTargetEmailOrId('');
}}
className={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1 border transition-colors ${
addShareType === 'team'
? 'bg-panel border-accent text-main'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
<Users className="w-3 h-3 text-accent" />
<span>Team / Gruppe</span>
</button>
<button
type="button"
onClick={() => {
setAddShareType('user');
setSelectedShareTeamId('');
}}
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>
{/* 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"
/>
{addShareType === 'team' ? (
<div className="flex flex-col sm:flex-row gap-2">
<select
value={selectedShareTeamId}
onChange={(e) => setSelectedShareTeamId(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">-- 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">
<button
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"
>
<Plus className="w-3.5 h-3.5" />
<span>Freigeben</span>
<span>
{sharingLoading
? 'Wird geteilt…'
: addShareType === 'team'
? 'Team freigeben'
: 'Benutzer freigeben'}
</span>
</button>
</div>
</form>
+2 -2
View File
@@ -514,7 +514,7 @@ export const api = {
async createDocShare(
docId: string,
payload: { userId?: string; email?: string; permission: 'read' | 'write' }
payload: { userId?: string; email?: string; teamId?: string; permission: 'read' | 'write' }
): Promise<DocShare> {
return request<DocShare>(`/api/v1/documents/${docId}/shares`, {
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`, {
method: 'POST',
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 {
UserID string `json:"userId"`
Email string `json:"email"`
TeamID string `json:"teamId"`
Permission string `json:"permission"` // read | write
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
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
if target == "" {
target = req.Email
}
if target == "" {
writeError(w, http.StatusBadRequest, "Benutzer-ID oder E-Mail erforderlich")
writeError(w, http.StatusBadRequest, "Benutzer-ID, E-Mail oder Team erforderlich")
return
}
@@ -1089,18 +1117,47 @@ func (s *Server) handleAddSpaceMember(w http.ResponseWriter, r *http.Request) {
var req struct {
UserID string `json:"userId"`
Email string `json:"email"`
TeamID string `json:"teamId"`
Role string `json:"role"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
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
if target == "" {
target = req.Email
}
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
}
+38
View File
@@ -618,6 +618,44 @@ func TestSpaceMembersDirectInvitationAPI(t *testing.T) {
if rec.Code != http.StatusOK {
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) {
+18
View File
@@ -2977,6 +2977,24 @@ func (s *Store) CreateTeam(orgID, name, description string) (*domain.Team, error
if name == "" {
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{
ID: domain.NewID("team"),
OrgID: orgID,