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
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 58s
This commit is contained in:
1 parent
20c50aa8d6
commit
7c47f245e2
6 files changed
+440
-226
No files matched your search
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in new issue
Block a user