From 7c47f245e22d2a0fa4d5b830262555ce944e85c9 Mon Sep 17 00:00:00 2001 From: Sandro Date: Sun, 25 Oct 2026 13:51:42 +0100 Subject: [PATCH] feat(sharing): remove code redeem, unify Teilen button and team/user sharing across lists and notes --- .../src/features/lists/SharedListsView.tsx | 348 +++++++++--------- .../src/features/workspace/WorkspaceView.tsx | 197 +++++++--- frontend/src/lib/api.ts | 4 +- internal/api/server.go | 61 ++- internal/api/server_test.go | 38 ++ internal/storage/sqlite.go | 18 + 6 files changed, 440 insertions(+), 226 deletions(-) diff --git a/frontend/src/features/lists/SharedListsView.tsx b/frontend/src/features/lists/SharedListsView.tsx index e1bdbff..52202da 100644 --- a/frontend/src/features/lists/SharedListsView.tsx +++ b/frontend/src/features/lists/SharedListsView.tsx @@ -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 = ({ 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([]); + const [teams, setTeams] = useState([]); + 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(null); + const [shareError, setShareError] = useState(null); const wsRef = useRef(null); @@ -292,64 +291,46 @@ export const SharedListsView: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ onRefreshGloba > Space - - - {/* Realtime Status & Invite User Button */} + {/* Realtime Status & Share List Button */} {activeSpace && (
= ({ onRefreshGloba
- {/* Create or Join Space Modals */} + {/* Create Space Modal */} {showNewSpaceModal && (
= ({ onRefreshGloba
)} - {showJoinModal && ( -
- setJoinCode(e.target.value)} - placeholder="Invite-Code des Partners eingeben…" - className="flex-1 h-9 px-3 input-field text-sm uppercase" - autoFocus - /> - -
- )} - {/* List Tabs within Active Space */}
{lists.map((lst) => { @@ -770,14 +726,14 @@ export const SharedListsView: React.FC = ({ onRefreshGloba
) : null} - {/* Space & Shopping List Members Modal */} - {showMembersModal && activeSpace && ( + {/* Space & Shopping List Sharing Modal */} + {showShareModal && activeSpace && (
{/* Modal Header */}
- +

Einkaufsliste teilen: {activeSpace.name}

@@ -785,8 +741,8 @@ export const SharedListsView: React.FC = ({ onRefreshGloba
- {inviteError && ( + {shareError && (
- {inviteError} + {shareError}
)} - {/* Invite User Form */} -
-
User direkt einladen
-
- - + {/* Sharing Form (Team / Gruppe vs. Einzelner Benutzer) */} + +
+ Freigabe hinzufĂĽgen: +
+ + +
- {/* Direct Username / Email input if user not selected from list */} - {(!inviteTarget || !collaborators.some((u) => u.id === inviteTarget)) && ( - 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' ? ( +
+ + +
+ ) : ( +
+
+ + +
+ + {(!inviteTarget || !collaborators.some((u) => u.id === inviteTarget)) && ( + 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" + /> + )} +
)}
@@ -859,7 +885,7 @@ export const SharedListsView: React.FC = ({ onRefreshGloba {/* Current Space Members List */}
- Mitglieder in dieser Liste ({activeSpace.members?.length || 1}) + Aktive Freigaben & Mitglieder ({activeSpace.members?.length || 1})
{(activeSpace.members || []).map((m) => { @@ -902,7 +928,7 @@ export const SharedListsView: React.FC = ({ onRefreshGloba onClick={() => handleRemoveMember(m.userId)} disabled={invitingLoading} className="btn-icon text-muted hover:text-status-danger p-1" - title="Mitglied entfernen" + title="Freigabe entfernen" > @@ -913,24 +939,6 @@ export const SharedListsView: React.FC = ({ onRefreshGloba })}
- - {/* Invite Code Alternative */} -
- Alternativ per Code beitreten: - -
)} diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index 3354430..4b7df01 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -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 = ({ const [showShareModal, setShowShareModal] = useState(false); const [docShares, setDocShares] = useState([]); const [collaborators, setCollaborators] = useState([]); + const [shareTeams, setShareTeams] = useState([]); + 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 = ({ 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 = ({ 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 = ({ {/* If current user is owner: can add shares and manage permissions */} {selectedDoc.isOwner !== false ? (
-
-
Neuen Nutzer einladen / berechtigen
-
- - + +
+ Freigabe hinzufĂĽgen: +
+ + +
- {/* Direct Email fallback if user not in list */} - {(!shareTargetEmailOrId || !collaborators.some((u) => u.id === shareTargetEmailOrId)) && ( - 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' ? ( +
+ + +
+ ) : ( +
+
+ + +
+ + {/* Direct Email fallback if user not in list */} + {(!shareTargetEmailOrId || !collaborators.some((u) => u.id === shareTargetEmailOrId)) && ( + 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" + /> + )} +
)}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 2b7dd05..4675cc1 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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 { return request(`/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 { + async addSpaceMember(spaceId: string, target: { userId?: string; email?: string; teamId?: string; role?: string }): Promise { return request(`/api/v1/spaces/${spaceId}/members`, { method: 'POST', body: JSON.stringify(target), diff --git a/internal/api/server.go b/internal/api/server.go index 0d62217..2291e2c 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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 } diff --git a/internal/api/server_test.go b/internal/api/server_test.go index d8020d2..e591a18 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -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) { diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index 44cfc21..e67c646 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -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,