feat(lists): add direct user invitation to shared lists and streamline live-sync indicator
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 56s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 56s
This commit is contained in:
1 parent
6e09888a44
commit
7bc5fbe001
7 files changed
+579
-28
No files matched your search
@@ -217,7 +217,7 @@ export const App: React.FC = () => {
|
|||||||
if (!msg) return;
|
if (!msg) return;
|
||||||
if (
|
if (
|
||||||
typeof msg.event === 'string' &&
|
typeof msg.event === 'string' &&
|
||||||
(msg.event.startsWith('TASK_') || msg.event.startsWith('DOC_'))
|
(msg.event.startsWith('TASK_') || msg.event.startsWith('DOC_') || msg.event.startsWith('SPACE_'))
|
||||||
) {
|
) {
|
||||||
api.getMe().then((res) => {
|
api.getMe().then((res) => {
|
||||||
setStats(res.stats);
|
setStats(res.stats);
|
||||||
@@ -593,7 +593,7 @@ export const App: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'lists' && (
|
{activeTab === 'lists' && (
|
||||||
<SharedListsView onRefreshGlobal={refreshAll} />
|
<SharedListsView onRefreshGlobal={refreshAll} currentUser={user} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'user_settings' && (
|
{activeTab === 'user_settings' && (
|
||||||
|
|||||||
@@ -10,17 +10,19 @@ import {
|
|||||||
Radio,
|
Radio,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { ListItem, SharedList, Space, WSMessage } from '../../lib/types';
|
import { ListItem, SharedList, Space, User, WSMessage } from '../../lib/types';
|
||||||
import { api, buildWebSocketURL, triggerHaptic } from '../../lib/api';
|
import { api, buildWebSocketURL, triggerHaptic } from '../../lib/api';
|
||||||
|
|
||||||
interface SharedListsViewProps {
|
interface SharedListsViewProps {
|
||||||
onRefreshGlobal: () => void;
|
onRefreshGlobal: () => void;
|
||||||
|
currentUser?: User | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CATEGORIES = ['Obst & Gemüse', 'Kühlregal', 'Vorrat & Konserven', 'Backwaren', 'Getränke', 'Drogerie', 'Sonstiges'];
|
const CATEGORIES = ['Obst & Gemüse', 'Kühlregal', 'Vorrat & Konserven', 'Backwaren', 'Getränke', 'Drogerie', 'Sonstiges'];
|
||||||
|
|
||||||
export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGlobal }) => {
|
export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGlobal, currentUser }) => {
|
||||||
const [spaces, setSpaces] = useState<Space[]>([]);
|
const [spaces, setSpaces] = useState<Space[]>([]);
|
||||||
const [activeSpace, setActiveSpace] = useState<Space | null>(null);
|
const [activeSpace, setActiveSpace] = useState<Space | null>(null);
|
||||||
const [lists, setLists] = useState<SharedList[]>([]);
|
const [lists, setLists] = useState<SharedList[]>([]);
|
||||||
@@ -31,7 +33,6 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
const [newItemCategory, setNewItemCategory] = useState('');
|
const [newItemCategory, setNewItemCategory] = useState('');
|
||||||
|
|
||||||
const [wsConnected, setWsConnected] = useState(false);
|
const [wsConnected, setWsConnected] = useState(false);
|
||||||
const [onlineUsers, setOnlineUsers] = useState(1);
|
|
||||||
const [copiedInvite, setCopiedInvite] = useState(false);
|
const [copiedInvite, setCopiedInvite] = useState(false);
|
||||||
|
|
||||||
const [showNewListModal, setShowNewListModal] = useState(false);
|
const [showNewListModal, setShowNewListModal] = useState(false);
|
||||||
@@ -43,13 +44,25 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
const [showNewSpaceModal, setShowNewSpaceModal] = useState(false);
|
const [showNewSpaceModal, setShowNewSpaceModal] = useState(false);
|
||||||
const [newSpaceName, setNewSpaceName] = useState('');
|
const [newSpaceName, setNewSpaceName] = useState('');
|
||||||
|
|
||||||
|
const [showMembersModal, setShowMembersModal] = useState(false);
|
||||||
|
const [collaborators, setCollaborators] = useState<User[]>([]);
|
||||||
|
const [inviteTarget, setInviteTarget] = useState('');
|
||||||
|
const [inviteRole, setInviteRole] = useState<'editor' | 'viewer'>('editor');
|
||||||
|
const [invitingLoading, setInvitingLoading] = useState(false);
|
||||||
|
const [inviteError, setInviteError] = useState<string | null>(null);
|
||||||
|
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
|
||||||
const loadSpaces = async () => {
|
const loadSpaces = async () => {
|
||||||
const sp = await api.getSpaces();
|
const sp = await api.getSpaces();
|
||||||
setSpaces(sp);
|
setSpaces(sp);
|
||||||
if (sp.length > 0 && !activeSpace) {
|
if (sp.length > 0) {
|
||||||
|
if (!activeSpace) {
|
||||||
setActiveSpace(sp[0]);
|
setActiveSpace(sp[0]);
|
||||||
|
} else {
|
||||||
|
const found = sp.find((s) => s.id === activeSpace.id);
|
||||||
|
if (found) setActiveSpace(found);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -90,9 +103,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
ws.onmessage = (evt) => {
|
ws.onmessage = (evt) => {
|
||||||
try {
|
try {
|
||||||
const msg: WSMessage = JSON.parse(evt.data);
|
const msg: WSMessage = JSON.parse(evt.data);
|
||||||
if (msg.event === 'USER_PRESENCE' && msg.payload?.onlineCount) {
|
if (msg.event === 'ITEM_CREATED' && msg.list_id) {
|
||||||
setOnlineUsers(msg.payload.onlineCount);
|
|
||||||
} else if (msg.event === 'ITEM_CREATED' && msg.list_id) {
|
|
||||||
const newItem: ListItem = msg.payload;
|
const newItem: ListItem = msg.payload;
|
||||||
setLists((prev) =>
|
setLists((prev) =>
|
||||||
prev.map((l) => {
|
prev.map((l) => {
|
||||||
@@ -125,6 +136,8 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
setLists((prev) => prev.map((l) => (l.id === msg.list_id ? updatedList : l)));
|
setLists((prev) => prev.map((l) => (l.id === msg.list_id ? updatedList : l)));
|
||||||
} else if (msg.event === 'LIST_CREATED' || msg.event === 'LIST_DELETED') {
|
} else if (msg.event === 'LIST_CREATED' || msg.event === 'LIST_DELETED') {
|
||||||
loadListsForSpace(activeSpace.id);
|
loadListsForSpace(activeSpace.id);
|
||||||
|
} else if (msg.event === 'MEMBER_ADDED' || msg.event === 'MEMBER_REMOVED') {
|
||||||
|
loadSpaces();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore malformed frames
|
// ignore malformed frames
|
||||||
@@ -139,6 +152,23 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
};
|
};
|
||||||
}, [activeSpace?.id]);
|
}, [activeSpace?.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleGlobalWs = (e: any) => {
|
||||||
|
const msg = e.detail;
|
||||||
|
if (!msg) return;
|
||||||
|
if (
|
||||||
|
msg.event === 'SPACE_JOINED' ||
|
||||||
|
msg.event === 'SPACE_LEFT' ||
|
||||||
|
msg.event === 'MEMBER_ADDED' ||
|
||||||
|
msg.event === 'MEMBER_REMOVED'
|
||||||
|
) {
|
||||||
|
loadSpaces();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('mindsift:ws', handleGlobalWs);
|
||||||
|
return () => window.removeEventListener('mindsift:ws', handleGlobalWs);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const activeList = lists.find((l) => l.id === activeListId) || lists[0] || null;
|
const activeList = lists.find((l) => l.id === activeListId) || lists[0] || null;
|
||||||
|
|
||||||
const handleAddItem = async (e: React.FormEvent) => {
|
const handleAddItem = async (e: React.FormEvent) => {
|
||||||
@@ -245,6 +275,80 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
setTimeout(() => setCopiedInvite(false), 2000);
|
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();
|
||||||
|
} catch (err: any) {
|
||||||
|
setInviteError(err.message || 'Mitglied konnte nicht eingeladen werden');
|
||||||
|
} finally {
|
||||||
|
setInvitingLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveMember = async (targetUserId: string) => {
|
||||||
|
if (!activeSpace) return;
|
||||||
|
if (!window.confirm('Dieses Mitglied wirklich aus der Einkaufsliste / dem Bereich entfernen?')) return;
|
||||||
|
setInvitingLoading(true);
|
||||||
|
setInviteError(null);
|
||||||
|
try {
|
||||||
|
await api.removeSpaceMember(activeSpace.id, targetUserId);
|
||||||
|
setActiveSpace((prev) =>
|
||||||
|
prev
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
members: (prev.members || []).filter((m) => m.userId !== targetUserId),
|
||||||
|
}
|
||||||
|
: prev
|
||||||
|
);
|
||||||
|
setSpaces((prev) =>
|
||||||
|
prev.map((s) =>
|
||||||
|
s.id === activeSpace.id
|
||||||
|
? {
|
||||||
|
...s,
|
||||||
|
members: (s.members || []).filter((m) => m.userId !== targetUserId),
|
||||||
|
}
|
||||||
|
: s
|
||||||
|
)
|
||||||
|
);
|
||||||
|
onRefreshGlobal();
|
||||||
|
} catch (err: any) {
|
||||||
|
setInviteError(err.message || 'Mitglied konnte nicht entfernt werden');
|
||||||
|
} finally {
|
||||||
|
setInvitingLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const openItems = activeList?.items.filter((i) => !i.isCompleted) || [];
|
const openItems = activeList?.items.filter((i) => !i.isCompleted) || [];
|
||||||
const completedItems = activeList?.items.filter((i) => i.isCompleted) || [];
|
const completedItems = activeList?.items.filter((i) => i.isCompleted) || [];
|
||||||
|
|
||||||
@@ -283,31 +387,34 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Realtime Status & Invite Code Pill */}
|
{/* Realtime Status & Invite User Button */}
|
||||||
{activeSpace && (
|
{activeSpace && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
className={`h-8 px-3 text-xs flex items-center gap-1.5 rounded-btn ${
|
className={`h-8 w-8 flex items-center justify-center rounded-btn ${
|
||||||
wsConnected
|
wsConnected
|
||||||
? 'badge-ok'
|
? 'badge-ok'
|
||||||
: 'badge-neutral'
|
: 'badge-neutral'
|
||||||
}`}
|
}`}
|
||||||
|
title={wsConnected ? 'Live synchronisiert (Online)' : 'Offline'}
|
||||||
>
|
>
|
||||||
<Radio className={`w-3.5 h-3.5 ${wsConnected ? 'animate-pulse' : ''}`} />
|
<Radio className={`w-4 h-4 ${wsConnected ? 'animate-pulse text-status-ok' : 'text-muted'}`} />
|
||||||
<span>{wsConnected ? `Echtzeit-Sync (${onlineUsers} aktiv)` : 'Offline-Puffer'}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={copyInviteCode}
|
onClick={() => {
|
||||||
title="Einladungscode für Partner oder Familie kopieren"
|
setShowMembersModal(true);
|
||||||
className="btn-action h-8"
|
api.getUsersDirectory().then(setCollaborators).catch(() => {});
|
||||||
|
}}
|
||||||
|
title="User zu dieser Einkaufsliste einladen & verwalten"
|
||||||
|
className="btn-action h-8 flex items-center gap-1.5"
|
||||||
>
|
>
|
||||||
<Users className="w-3.5 h-3.5 text-accent" />
|
<UserPlus className="w-3.5 h-3.5 text-accent" />
|
||||||
<span>Code: {activeSpace.inviteCode}</span>
|
<span>User einladen</span>
|
||||||
{copiedInvite ? (
|
{activeSpace.members && activeSpace.members.length > 1 && (
|
||||||
<Check className="w-3.5 h-3.5 text-status-ok" />
|
<span className="ml-1 px-1.5 py-0.2 bg-surface-2 text-main text-[10px] font-semibold rounded-full border border-border">
|
||||||
) : (
|
{activeSpace.members.length}
|
||||||
<Copy className="w-3 h-3 text-muted" />
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -571,6 +678,171 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/* Space & Shopping List Members Modal */}
|
||||||
|
{showMembersModal && 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" />
|
||||||
|
<h3 className="text-base font-bold text-main">
|
||||||
|
Einkaufsliste teilen: {activeSpace.name}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowMembersModal(false);
|
||||||
|
setInviteError(null);
|
||||||
|
}}
|
||||||
|
className="btn-icon"
|
||||||
|
title="Schließen"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{inviteError && (
|
||||||
|
<div className="p-2.5 bg-status-danger/10 border border-status-danger/30 text-status-danger rounded text-xs">
|
||||||
|
{inviteError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Invite User Form */}
|
||||||
|
<form onSubmit={handleAddMember} className="space-y-2.5 p-3 bg-canvas 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-base text-xs flex-1"
|
||||||
|
>
|
||||||
|
<option value="">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}>
|
||||||
|
{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-base text-xs w-full sm:w-36"
|
||||||
|
>
|
||||||
|
<option value="editor">Bearbeiten</option>
|
||||||
|
<option value="viewer">Nur Lesen</option>
|
||||||
|
</select>
|
||||||
|
</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-base text-xs w-full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end pt-1">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={invitingLoading || !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>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* 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})
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5 max-h-48 overflow-y-auto">
|
||||||
|
{(activeSpace.members || []).map((m) => {
|
||||||
|
const isOwner = m.role === 'owner' || m.userId === activeSpace.ownerId;
|
||||||
|
const isMe = m.userId === currentUser?.id;
|
||||||
|
const canRemove =
|
||||||
|
(activeSpace.ownerId === currentUser?.id || currentUser?.role === 'admin') &&
|
||||||
|
!isOwner;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={m.userId}
|
||||||
|
className="p-2.5 bg-canvas border border-border rounded-btn flex items-center justify-between gap-3 text-xs"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<div className="w-7 h-7 rounded-full bg-surface-2 flex items-center justify-center text-xs font-bold text-accent shrink-0">
|
||||||
|
{(m.displayName || m.email || '?')[0].toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="truncate">
|
||||||
|
<div className="font-medium text-main truncate">
|
||||||
|
{m.displayName || m.email} {isMe && <span className="text-muted">(Du)</span>}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-muted truncate">{m.email}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<span
|
||||||
|
className={`text-[10px] px-2 py-0.5 rounded font-semibold ${
|
||||||
|
isOwner
|
||||||
|
? 'bg-accent/15 text-accent'
|
||||||
|
: 'bg-surface-2 text-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isOwner ? 'Eigentümer' : m.role === 'editor' ? 'Bearbeiter' : 'Leser'}
|
||||||
|
</span>
|
||||||
|
{canRemove && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemoveMember(m.userId)}
|
||||||
|
disabled={invitingLoading}
|
||||||
|
className="btn-icon text-muted hover:text-status-danger p-1"
|
||||||
|
title="Mitglied entfernen"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
PriorityLevel,
|
PriorityLevel,
|
||||||
SharedList,
|
SharedList,
|
||||||
Space,
|
Space,
|
||||||
|
SpaceMember,
|
||||||
SystemSettings,
|
SystemSettings,
|
||||||
Team,
|
Team,
|
||||||
TaskList,
|
TaskList,
|
||||||
@@ -517,6 +518,19 @@ export const api = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async addSpaceMember(spaceId: string, target: { userId?: string; email?: string; role?: string }): Promise<SpaceMember> {
|
||||||
|
return request<SpaceMember>(`/api/v1/spaces/${spaceId}/members`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(target),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async removeSpaceMember(spaceId: string, userId: string): Promise<void> {
|
||||||
|
await request(`/api/v1/spaces/${spaceId}/members/${userId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
async getSpaceLists(spaceId: string): Promise<SharedList[]> {
|
async getSpaceLists(spaceId: string): Promise<SharedList[]> {
|
||||||
const res = await request<SharedList[]>(`/api/v1/spaces/${spaceId}/lists`);
|
const res = await request<SharedList[]>(`/api/v1/spaces/${spaceId}/lists`);
|
||||||
return ensureArray(res).map((l) => ({
|
return ensureArray(res).map((l) => ({
|
||||||
|
|||||||
@@ -117,6 +117,8 @@ func (s *Server) Router() http.Handler {
|
|||||||
r.Get("/api/v1/spaces", s.handleGetSpaces)
|
r.Get("/api/v1/spaces", s.handleGetSpaces)
|
||||||
r.Post("/api/v1/spaces", s.handleCreateSpace)
|
r.Post("/api/v1/spaces", s.handleCreateSpace)
|
||||||
r.Post("/api/v1/spaces/join", s.handleJoinSpace)
|
r.Post("/api/v1/spaces/join", s.handleJoinSpace)
|
||||||
|
r.Post("/api/v1/spaces/{id}/members", s.handleAddSpaceMember)
|
||||||
|
r.Delete("/api/v1/spaces/{id}/members/{userId}", s.handleRemoveSpaceMember)
|
||||||
r.Get("/api/v1/spaces/{id}/lists", s.handleGetSpaceLists)
|
r.Get("/api/v1/spaces/{id}/lists", s.handleGetSpaceLists)
|
||||||
r.Post("/api/v1/spaces/{id}/lists", s.handleCreateList)
|
r.Post("/api/v1/spaces/{id}/lists", s.handleCreateList)
|
||||||
|
|
||||||
@@ -981,9 +983,61 @@ func (s *Server) handleJoinSpace(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.wsHub.BroadcastToUser(userID, "SPACE_JOINED", map[string]interface{}{"spaceId": sp.ID})
|
||||||
|
s.wsHub.Broadcast(sp.ID, "", "MEMBER_ADDED", map[string]interface{}{"spaceId": sp.ID, "userId": userID})
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, sp)
|
writeJSON(w, http.StatusOK, sp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAddSpaceMember(w http.ResponseWriter, r *http.Request) {
|
||||||
|
currentUserID := auth.GetUserID(r.Context())
|
||||||
|
spaceID := chi.URLParam(r, "id")
|
||||||
|
var req struct {
|
||||||
|
UserID string `json:"userId"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target := req.UserID
|
||||||
|
if target == "" {
|
||||||
|
target = req.Email
|
||||||
|
}
|
||||||
|
if target == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "Benutzer-ID, Benutzername oder E-Mail erforderlich")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
member, err := s.store.AddSpaceMember(currentUserID, spaceID, target, req.Role)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.wsHub.BroadcastToUser(member.UserID, "SPACE_JOINED", map[string]interface{}{"spaceId": spaceID})
|
||||||
|
s.wsHub.Broadcast(spaceID, "", "MEMBER_ADDED", member)
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, member)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleRemoveSpaceMember(w http.ResponseWriter, r *http.Request) {
|
||||||
|
currentUserID := auth.GetUserID(r.Context())
|
||||||
|
spaceID := chi.URLParam(r, "id")
|
||||||
|
targetUserID := chi.URLParam(r, "userId")
|
||||||
|
|
||||||
|
if err := s.store.RemoveSpaceMember(currentUserID, spaceID, targetUserID); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.wsHub.BroadcastToUser(targetUserID, "SPACE_LEFT", map[string]interface{}{"spaceId": spaceID})
|
||||||
|
s.wsHub.Broadcast(spaceID, "", "MEMBER_REMOVED", map[string]interface{}{"spaceId": spaceID, "userId": targetUserID})
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleGetSpaceLists(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleGetSpaceLists(w http.ResponseWriter, r *http.Request) {
|
||||||
spaceID := chi.URLParam(r, "id")
|
spaceID := chi.URLParam(r, "id")
|
||||||
lists, err := s.store.GetListsForSpace(spaceID)
|
lists, err := s.store.GetListsForSpace(spaceID)
|
||||||
|
|||||||
@@ -518,3 +518,104 @@ func TestLoginWithUsernameAndDemoMode(t *testing.T) {
|
|||||||
t.Fatalf("expected 401 Unauthorized for wrong password, got %d", rec.Code)
|
t.Fatalf("expected 401 Unauthorized for wrong password, got %d", rec.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSpaceMembersDirectInvitationAPI(t *testing.T) {
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "space_members_test.db")
|
||||||
|
store, err := storage.NewStore(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create store: %v", err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
|
||||||
|
if err := store.EnsureDemoAccounts(); err != nil {
|
||||||
|
t.Fatalf("failed to seed demo accounts: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Port: "8080",
|
||||||
|
DatabasePath: dbPath,
|
||||||
|
JWTSecret: "test-secret",
|
||||||
|
}
|
||||||
|
srv := NewServer(cfg, store, ws.NewHub(), nil)
|
||||||
|
router := srv.Router()
|
||||||
|
|
||||||
|
adminUser, err := store.GetUserByLogin("admin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to get admin user: %v", err)
|
||||||
|
}
|
||||||
|
normalUser, err := store.GetUserByLogin("user")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to get normal user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
adminToken, err := auth.GenerateToken(cfg.JWTSecret, adminUser.ID, adminUser.Email, adminUser.DisplayName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to generate token: %v", err)
|
||||||
|
}
|
||||||
|
userToken, err := auth.GenerateToken(cfg.JWTSecret, normalUser.ID, normalUser.Email, normalUser.DisplayName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to generate token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Admin creates a space
|
||||||
|
createSpaceBody := []byte(`{"name":"Einkauf WG","icon":"🛒","description":"Gemeinsame Einkaufsliste"}`)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/spaces", bytes.NewReader(createSpaceBody))
|
||||||
|
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 {
|
||||||
|
t.Fatalf("expected 201 Created from /api/v1/spaces, got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var createdSpace domain.Space
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &createdSpace)
|
||||||
|
if createdSpace.ID == "" {
|
||||||
|
t.Fatalf("expected created space with ID, got %+v", createdSpace)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Admin invites normalUser by username "user"
|
||||||
|
addMemberBody := []byte(`{"userId":"user","role":"editor"}`)
|
||||||
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/spaces/"+createdSpace.ID+"/members", bytes.NewReader(addMemberBody))
|
||||||
|
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 {
|
||||||
|
t.Fatalf("expected 201 Created from add space member, got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var newMember domain.SpaceMember
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &newMember)
|
||||||
|
if newMember.UserID != normalUser.ID {
|
||||||
|
t.Fatalf("expected member userId %s, got %s", normalUser.ID, newMember.UserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. User checks their spaces; the shared space must be listed
|
||||||
|
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, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
var userSpaces []domain.Space
|
||||||
|
_ = 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, got %+v", createdSpace.ID, userSpaces)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Admin removes user from space
|
||||||
|
req = httptest.NewRequest(http.MethodDelete, "/api/v1/spaces/"+createdSpace.ID+"/members/"+normalUser.ID, nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200 OK for remove member, got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -606,14 +606,25 @@ func (s *Store) GetDiscoverableUsers(viewerID string) ([]domain.User, error) {
|
|||||||
return []domain.User{*viewer}, nil
|
return []domain.User{*viewer}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// teams_only: users sharing at least one team or organization with viewer
|
// teams_only: if no teams exist yet, allow discovering users on this instance
|
||||||
|
var teamCount int
|
||||||
|
_ = s.db.QueryRow(`SELECT COUNT(*) FROM teams`).Scan(&teamCount)
|
||||||
|
if teamCount == 0 {
|
||||||
|
return s.GetAllUsers()
|
||||||
|
}
|
||||||
|
|
||||||
|
// users sharing at least one team, organization or space with viewer
|
||||||
rows, err := s.db.Query(
|
rows, err := s.db.Query(
|
||||||
`SELECT DISTINCT `+userSelectCols+` FROM users WHERE id = ? OR id IN (
|
`SELECT DISTINCT `+userSelectCols+` FROM users WHERE id = ? OR id IN (
|
||||||
SELECT tm2.user_id FROM team_members tm1
|
SELECT tm2.user_id FROM team_members tm1
|
||||||
JOIN team_members tm2 ON tm1.team_id = tm2.team_id
|
JOIN team_members tm2 ON tm1.team_id = tm2.team_id
|
||||||
WHERE tm1.user_id = ?
|
WHERE tm1.user_id = ?
|
||||||
|
UNION
|
||||||
|
SELECT sm2.user_id FROM space_members sm1
|
||||||
|
JOIN space_members sm2 ON sm1.space_id = sm2.space_id
|
||||||
|
WHERE sm1.user_id = ?
|
||||||
)`,
|
)`,
|
||||||
viewerID, viewerID,
|
viewerID, viewerID, viewerID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return []domain.User{*viewer}, nil
|
return []domain.User{*viewer}, nil
|
||||||
@@ -1460,12 +1471,16 @@ func (s *Store) CreateDocShare(currentUserID, docID, targetUserIdentifier, permi
|
|||||||
permission = "read"
|
permission = "read"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve target user by ID or Email
|
// Resolve target user by ID, username, or Email
|
||||||
var targetUser *domain.User
|
var targetUser *domain.User
|
||||||
cleanTarget := strings.TrimSpace(targetUserIdentifier)
|
cleanTarget := strings.TrimSpace(targetUserIdentifier)
|
||||||
if strings.Contains(cleanTarget, "@") {
|
if strings.HasPrefix(cleanTarget, "usr_") {
|
||||||
targetUser, err = s.GetUserByEmail(cleanTarget)
|
targetUser, _ = s.GetUserByID(cleanTarget)
|
||||||
} else {
|
}
|
||||||
|
if targetUser == nil {
|
||||||
|
targetUser, _ = s.GetUserByLogin(cleanTarget)
|
||||||
|
}
|
||||||
|
if targetUser == nil {
|
||||||
targetUser, err = s.GetUserByID(cleanTarget)
|
targetUser, err = s.GetUserByID(cleanTarget)
|
||||||
}
|
}
|
||||||
if err != nil || targetUser == nil {
|
if err != nil || targetUser == nil {
|
||||||
@@ -1636,6 +1651,79 @@ func (s *Store) GetSpaceMembers(spaceID string) ([]domain.SpaceMember, error) {
|
|||||||
return members, nil
|
return members, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) AddSpaceMember(currentUserID, spaceID, targetUserIdentifier, role string) (*domain.SpaceMember, error) {
|
||||||
|
// 1. Check if current user is member of space
|
||||||
|
var myRole string
|
||||||
|
err := s.db.QueryRow(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, currentUserID).Scan(&myRole)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("keine Berechtigung für diesen Bereich")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Resolve target user by ID, username or email
|
||||||
|
cleanTarget := strings.TrimSpace(targetUserIdentifier)
|
||||||
|
var targetUser *domain.User
|
||||||
|
if strings.HasPrefix(cleanTarget, "usr_") {
|
||||||
|
targetUser, _ = s.GetUserByID(cleanTarget)
|
||||||
|
}
|
||||||
|
if targetUser == nil {
|
||||||
|
targetUser, _ = s.GetUserByLogin(cleanTarget)
|
||||||
|
}
|
||||||
|
if targetUser == nil {
|
||||||
|
targetUser, err = s.GetUserByID(cleanTarget)
|
||||||
|
}
|
||||||
|
if err != nil || targetUser == nil {
|
||||||
|
return nil, errors.New("benutzer nicht gefunden")
|
||||||
|
}
|
||||||
|
|
||||||
|
if role != "owner" && role != "editor" && role != "viewer" {
|
||||||
|
role = "editor"
|
||||||
|
}
|
||||||
|
|
||||||
|
now := domain.NowISO()
|
||||||
|
_, err = s.db.Exec(
|
||||||
|
`INSERT INTO space_members (space_id, user_id, role, joined_at) VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(space_id, user_id) DO UPDATE SET role = excluded.role`,
|
||||||
|
spaceID, targetUser.ID, role, now,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &domain.SpaceMember{
|
||||||
|
SpaceID: spaceID,
|
||||||
|
UserID: targetUser.ID,
|
||||||
|
DisplayName: targetUser.DisplayName,
|
||||||
|
Email: targetUser.Email,
|
||||||
|
Role: role,
|
||||||
|
JoinedAt: now,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) RemoveSpaceMember(currentUserID, spaceID, targetUserID string) error {
|
||||||
|
var myRole string
|
||||||
|
err := s.db.QueryRow(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, currentUserID).Scan(&myRole)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("keine Berechtigung für diesen Bereich")
|
||||||
|
}
|
||||||
|
|
||||||
|
if currentUserID != targetUserID && myRole != "owner" {
|
||||||
|
return errors.New("nur der Eigentümer kann andere Mitglieder entfernen")
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetRole string
|
||||||
|
_ = s.db.QueryRow(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, targetUserID).Scan(&targetRole)
|
||||||
|
if targetRole == "owner" {
|
||||||
|
var ownerCount int
|
||||||
|
_ = s.db.QueryRow(`SELECT COUNT(*) FROM space_members WHERE space_id = ? AND role = 'owner'`, spaceID).Scan(&ownerCount)
|
||||||
|
if ownerCount <= 1 {
|
||||||
|
return errors.New("der letzte Eigentümer kann den Bereich nicht verlassen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.db.Exec(`DELETE FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, targetUserID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) GetSpacesForUser(userID string) ([]domain.Space, error) {
|
func (s *Store) GetSpacesForUser(userID string) ([]domain.Space, error) {
|
||||||
rows, err := s.db.Query(
|
rows, err := s.db.Query(
|
||||||
`SELECT s.id, s.name, s.icon, s.description, s.invite_code, s.is_personal, s.owner_id, s.created_at
|
`SELECT s.id, s.name, s.icon, s.description, s.invite_code, s.is_personal, s.owner_id, s.created_at
|
||||||
|
|||||||
@@ -65,6 +65,28 @@ func TestStoreLifecycle(t *testing.T) {
|
|||||||
t.Fatalf("ToggleListItem failed: %v", err)
|
t.Fatalf("ToggleListItem failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Test AddSpaceMember & RemoveSpaceMember
|
||||||
|
u2, err := store.CreateUser("collab_space@example.com", "hash", "Collab User", "member")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUser u2 failed: %v", err)
|
||||||
|
}
|
||||||
|
member, err := store.AddSpaceMember(user.ID, sp.ID, u2.ID, "editor")
|
||||||
|
if err != nil || member.UserID != u2.ID {
|
||||||
|
t.Fatalf("AddSpaceMember failed: %v", err)
|
||||||
|
}
|
||||||
|
members, err := store.GetSpaceMembers(sp.ID)
|
||||||
|
if err != nil || len(members) != 2 {
|
||||||
|
t.Fatalf("expected 2 members, got %d", len(members))
|
||||||
|
}
|
||||||
|
err = store.RemoveSpaceMember(user.ID, sp.ID, u2.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RemoveSpaceMember failed: %v", err)
|
||||||
|
}
|
||||||
|
membersAfter, _ := store.GetSpaceMembers(sp.ID)
|
||||||
|
if len(membersAfter) != 1 {
|
||||||
|
t.Fatalf("expected 1 member after remove, got %d", len(membersAfter))
|
||||||
|
}
|
||||||
|
|
||||||
// 4. Test Webhook API Key generation & validation
|
// 4. Test Webhook API Key generation & validation
|
||||||
apiKey, err := store.CreateAPIKey(user.ID, "Home Assistant")
|
apiKey, err := store.CreateAPIKey(user.ID, "Home Assistant")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in new issue
Block a user