From e37302f73c855ea451ad15e0a1e1628ed1361bde Mon Sep 17 00:00:00 2001 From: Sandro Date: Sun, 25 Oct 2026 17:14:15 +0100 Subject: [PATCH] feat: overhaul groups management, remove global team_admin and isolate group administration --- BRAIN.md | 13 +- frontend/src/App.tsx | 54 +- .../features/settings/AdminSettingsView.tsx | 330 +-------- .../features/settings/UserSettingsView.tsx | 2 - frontend/src/features/teams/GroupsView.tsx | 654 ++++++++++++++++++ frontend/src/lib/api.ts | 18 + frontend/src/lib/types.ts | 6 +- internal/api/server.go | 127 +++- internal/api/server_test.go | 8 +- internal/domain/models.go | 8 +- internal/storage/sqlite.go | 104 ++- 11 files changed, 936 insertions(+), 388 deletions(-) create mode 100644 frontend/src/features/teams/GroupsView.tsx diff --git a/BRAIN.md b/BRAIN.md index facbfd1..a899806 100644 --- a/BRAIN.md +++ b/BRAIN.md @@ -31,13 +31,14 @@ - [x] **Ersteinrichtung (First-Boot Setup Wizard), Echter Login & Einladungscodes (`SetupAndAuthView.tsx`, `server.go`, `sqlite.go`):** - Kein hartcodierter Auto-Seed-Benutzer (`admin@mindsift.local` / `"Sandro"`) und kein automatischer `quickStart`-Login mehr beim Seitenaufruf. - Beim ersten Start (`0` Benutzer in der Datenbank) erkennt `GET /api/v1/auth/status` automatisch `needsSetup: true` und zeigt den **Ersteinrichtungs-Assistenten** (`POST /api/v1/auth/setup`) zum Anlegen des ersten Server-Admin-Kontos und der Instanz-Grundeinstellungen. - - Öffentliche Selbstregistrierung ist standardmäßig deaktiviert (`allowRegistration: false`). Neue Benutzer können nur über einen **Einladungscode** (`MS-XXXXXXXX`, Tabelle `user_invites`) oder direkt durch einen Admin (`admin` / `team_admin`) angelegt werden. -- [x] **Profile-Dropdown & Vollbild-Einstellungsseiten statt kleinem Modal (`App.tsx`, `UserSettingsView.tsx`, `AdminSettingsView.tsx`):** + - Öffentliche Selbstregistrierung ist standardmäßig deaktiviert (`allowRegistration: false`). Neue Benutzer können nur über einen **Einladungscode** (`MS-XXXXXXXX`, Tabelle `user_invites`) oder direkt durch einen Admin (`admin`) angelegt werden. +- [x] **Profile-Dropdown & Vollbild-Einstellungsseiten (`App.tsx`, `UserSettingsView.tsx`, `GroupsView.tsx`, `AdminSettingsView.tsx`):** - Klick auf den Benutzer oben rechts öffnet ein eckiges, monochromes Dropdown-Menü: - **Kontoeinstellungen** (`UserSettingsView.tsx` — Vollbild-Ansicht für Profil & Passwort, Darstellung & Themes, Multi-Channel-Alarme, Private Webhooks & Daten-Export). - - **Administration** (`AdminSettingsView.tsx` — nur sichtbar für `admin` und `team_admin`; Vollbild-Ansicht für Benutzer, Rollen, Einladungscodes, Organisationen & Teams, Server-Isolation und SMTP-Mailserver). + - **Gruppen** (`GroupsView.tsx` — für alle Benutzer; Self-Service Erstellung und Verwaltung von Gruppen, Mitgliedern und Rollen). + - **Administration** (`AdminSettingsView.tsx` — nur sichtbar für `admin`; Vollbild-Ansicht für Benutzer, Einladungscodes, Server-Isolation und SMTP-Mailserver). - **Abmelden** (bereinigt JWT-Token und führt zurück zum Login-Bildschirm). - - 3-stufiges Rollensystem: `admin` (Server-Admin), `team_admin` (Team-Admin mit Berechtigung zum Einladen und Verwalten von Teams) und `member` (Mitglied). + - Klares Rollensystem: Global `admin` (Server-Admin) und `member` (Standard-Benutzer). Innerhalb von Gruppen: `admin` (Gruppen-Admin) und `member` (Gruppen-Mitglied). - Layout-Fix im Benutzer-Erstellungsformular (`grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3` mit `min-w-0`), sodass das Rollen-Dropdown niemals über den Container hinausragt. - [x] **Multi-Channel-Alarme: E-Mail (SMTP), Discord & Eigener Webhook (`UserSettingsView.tsx`, `AdminSettingsView.tsx`, `server.go`):** - Tab heißt klar **Alarme** mit frei kombinierbaren Zielen (`alarmChannels`: `'email'`, `'discord'`, `'webhook'`). @@ -178,7 +179,7 @@ MindSift speichert alle Daten lokal und hochperformant in SQLite (`/data/mindsif 1. **`users`** - `id TEXT PRIMARY KEY`, `email TEXT UNIQUE`, `password_hash TEXT`, `display_name TEXT` - - `role TEXT DEFAULT 'member'` (`admin`, `team_admin`, `member`) + - `role TEXT DEFAULT 'member'` (`admin`, `member`) - `is_active INTEGER DEFAULT 1`, `created_at DATETIME`, `updated_at DATETIME` 2. **`system_settings`** (Instanzweite globale Konfiguration, 1 Row mit `id = 'singleton'`) - `sharing_mode TEXT DEFAULT 'teams_only'` (`strict_private`, `teams_only`, `open`) @@ -187,7 +188,7 @@ MindSift speichert alle Daten lokal und hochperformant in SQLite (`/data/mindsif - `smtp_host TEXT`, `smtp_port INTEGER`, `smtp_user TEXT`, `smtp_pass TEXT`, `smtp_from TEXT`, `smtp_tls INTEGER` 3. **`user_invites`** (Einladungscodes für neue Registrierungen) - `id TEXT PRIMARY KEY`, `code TEXT UNIQUE` (`MS-XXXXXXXX`), `email TEXT` (optional gebunden) - - `role TEXT DEFAULT 'member'`, `team_id TEXT` (optional direkte Team-Zuweisung) + - `role TEXT DEFAULT 'member'` (`admin`, `member`) - `created_by TEXT`, `used_by TEXT`, `expires_at DATETIME`, `created_at DATETIME` 4. **`organizations` & `organization_teams` & `team_members`** - Strukturierung von Benutzern in Abteilungen, Familien oder Projekt-Teams. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a6c499f..9c5deac 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -17,6 +17,7 @@ import { Download, Smartphone, Share, + Users, } from 'lucide-react'; import { AuthStatus, DashboardStats, SharedList, Thought, User } from './lib/types'; import { @@ -42,12 +43,14 @@ import { SetupAndAuthView } from './features/auth/SetupAndAuthView'; import { UserSettingsView } from './features/settings/UserSettingsView'; import { AdminSettingsView } from './features/settings/AdminSettingsView'; import { HelpView } from './features/help/HelpView'; +import { GroupsView } from './features/teams/GroupsView'; type ActiveTab = | 'triage' | 'tasks' | 'workspace' | 'lists' + | 'groups' | 'user_settings' | 'admin_settings' | 'help'; @@ -403,13 +406,8 @@ export const App: React.FC = () => { ); } - const isStaff = user.role === 'admin' || user.role === 'team_admin'; - const roleLabel = - user.role === 'admin' - ? 'Server-Admin' - : user.role === 'team_admin' - ? 'Team-Admin' - : 'Mitglied'; + const isServerAdmin = user.role === 'admin'; + const roleLabel = isServerAdmin ? 'Server-Admin' : 'Mitglied'; return (
@@ -572,6 +570,20 @@ export const App: React.FC = () => { Kontoeinstellungen + + - {isStaff && ( + {isServerAdmin && ( + + - {isStaff && ( + {isServerAdmin && (
{statusMsg && ( @@ -401,21 +325,6 @@ export const AdminSettingsView: React.FC = ({ - - {isServerAdmin && ( <> - - - - - {/* Teams & Groups List */} -
-
-

- Bestehende Gruppen & Teams ({allTeams.length}) -

-
- - {allTeams.length === 0 ? ( -
- -

Noch keine Teams oder Gruppen vorhanden.

-

- Lege oben deine erste Gruppe an (z. B. "Familie" oder "Arbeit"), um Notizen und Listen gemeinsam zu nutzen. -

-
- ) : ( - allTeams.map((team) => { - const members = Array.isArray(team.members) ? team.members : []; - const availableUsers = users.filter( - (u) => !members.some((m) => m.userId === u.id) - ); - return ( -
-
-
-
- -
-
-
- {team.name} - - {members.length} {members.length === 1 ? 'Mitglied' : 'Mitglieder'} - -
- {team.description && ( -

- {team.description} -

- )} -
-
- -
- - {/* Members Chips */} -
-

- Mitglieder ({members.length}): -

- {members.length === 0 ? ( -

- Noch keine Mitglieder in dieser Gruppe. -

- ) : ( -
- {members.map((m) => ( -
- - {m.displayName} - - - ({m.role === 'team_admin' ? 'Gruppen-Admin' : 'Mitglied'}) - - -
- ))} -
- )} -
- - {/* Add User to Team Row */} -
- - - - - -
-
- ); - }) - )} -
- - )} {/* ================================================================= */} {/* SECTION 3: SERVER & SHARING-ISOLATION (SERVER-ADMIN ONLY) */} diff --git a/frontend/src/features/settings/UserSettingsView.tsx b/frontend/src/features/settings/UserSettingsView.tsx index 9a96aa5..532b4d0 100644 --- a/frontend/src/features/settings/UserSettingsView.tsx +++ b/frontend/src/features/settings/UserSettingsView.tsx @@ -236,8 +236,6 @@ export const UserSettingsView: React.FC = ({ const roleBadgeLabel = currentUser.role === 'admin' ? 'Server-Admin' - : currentUser.role === 'team_admin' - ? 'Team-Admin' : 'Mitglied'; return ( diff --git a/frontend/src/features/teams/GroupsView.tsx b/frontend/src/features/teams/GroupsView.tsx new file mode 100644 index 0000000..fc88cf2 --- /dev/null +++ b/frontend/src/features/teams/GroupsView.tsx @@ -0,0 +1,654 @@ +import React, { useEffect, useState } from 'react'; +import { + Users, + Plus, + Trash2, + ArrowLeft, + Check, + AlertCircle, + UserPlus, + Shield, + Pencil, + X, + LogOut, +} from 'lucide-react'; +import { Team, User } from '../../lib/types'; +import { api, triggerHaptic } from '../../lib/api'; +import { UserAutocomplete } from '../UserAutocomplete'; + +interface GroupsViewProps { + currentUser: User; + onBack: () => void; +} + +export const GroupsView: React.FC = ({ currentUser, onBack }) => { + const [teams, setTeams] = useState([]); + const [directoryUsers, setDirectoryUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [statusMsg, setStatusMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null); + + // Create Group Modal State + const [showCreateModal, setShowCreateModal] = useState(false); + const [newGroupName, setNewGroupName] = useState(''); + const [newGroupDesc, setNewGroupDesc] = useState(''); + const [creating, setCreating] = useState(false); + + // Edit Group State + const [editingTeamId, setEditingTeamId] = useState(null); + const [editName, setEditName] = useState(''); + const [editDesc, setEditDesc] = useState(''); + + // Add Member State per Team + const [selectedUserByTeam, setSelectedUserByTeam] = useState>({}); + const [memberRoleByTeam, setMemberRoleByTeam] = useState>({}); + + const loadData = async () => { + try { + const [userTeams, allUsers] = await Promise.all([ + api.getUserTeams(), + api.getUsersDirectory().catch(() => []), + ]); + setTeams(userTeams); + setDirectoryUsers(allUsers); + } catch (err: any) { + setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Laden der Gruppen' }); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadData(); + }, []); + + const handleCreateGroup = async (e: React.FormEvent) => { + e.preventDefault(); + const name = newGroupName.trim(); + if (!name) return; + + setCreating(true); + setStatusMsg(null); + triggerHaptic(15); + + try { + const created = await api.createTeam(name, newGroupDesc.trim()); + setNewGroupName(''); + setNewGroupDesc(''); + setShowCreateModal(false); + await loadData(); + setStatusMsg({ type: 'ok', text: `Gruppe "${created.name}" erfolgreich erstellt.` }); + } catch (err: any) { + setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Erstellen der Gruppe' }); + } finally { + setCreating(false); + } + }; + + const handleStartEdit = (team: Team) => { + setEditingTeamId(team.id); + setEditName(team.name); + setEditDesc(team.description || ''); + }; + + const handleSaveEdit = async (teamId: string) => { + const name = editName.trim(); + if (!name) return; + + setStatusMsg(null); + triggerHaptic(10); + try { + await api.updateTeam(teamId, name, editDesc.trim()); + setEditingTeamId(null); + await loadData(); + setStatusMsg({ type: 'ok', text: 'Gruppe erfolgreich aktualisiert.' }); + } catch (err: any) { + setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Aktualisieren der Gruppe' }); + } + }; + + const handleDeleteTeam = async (team: Team) => { + if (!window.confirm(`Gruppe "${team.name}" wirklich löschen? Alle Freigaben für diese Gruppe werden entfernt.`)) { + return; + } + setStatusMsg(null); + triggerHaptic(15); + try { + await api.deleteTeam(team.id); + await loadData(); + setStatusMsg({ type: 'ok', text: `Gruppe "${team.name}" gelöscht.` }); + } catch (err: any) { + setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Löschen der Gruppe' }); + } + }; + + const handleAddMember = async (teamId: string) => { + const user = selectedUserByTeam[teamId]; + if (!user) return; + const role = memberRoleByTeam[teamId] || 'member'; + + setStatusMsg(null); + triggerHaptic(10); + try { + await api.addTeamMember(teamId, { userId: user.id, role }); + setSelectedUserByTeam((prev) => ({ ...prev, [teamId]: null })); + await loadData(); + setStatusMsg({ type: 'ok', text: `${user.displayName} wurde zur Gruppe hinzugefügt.` }); + } catch (err: any) { + setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Hinzufügen des Mitglieds' }); + } + }; + + const handleChangeMemberRole = async (teamId: string, memberUserId: string, newRole: 'admin' | 'member') => { + setStatusMsg(null); + triggerHaptic(10); + try { + await api.updateTeamMemberRole(teamId, memberUserId, newRole); + await loadData(); + setStatusMsg({ + type: 'ok', + text: `Rolle erfolgreich zu "${newRole === 'admin' ? 'Gruppen-Admin' : 'Mitglied'}" geändert.`, + }); + } catch (err: any) { + setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Ändern der Rolle' }); + } + }; + + const handleRemoveMember = async (teamId: string, memberUserId: string, memberName: string) => { + const isSelf = memberUserId === currentUser.id; + const msg = isSelf + ? 'Möchtest du diese Gruppe wirklich verlassen?' + : `Mitglied "${memberName}" wirklich aus der Gruppe entfernen?`; + + if (!window.confirm(msg)) return; + + setStatusMsg(null); + triggerHaptic(10); + try { + await api.removeTeamMember(teamId, memberUserId); + await loadData(); + setStatusMsg({ + type: 'ok', + text: isSelf ? 'Du hast die Gruppe verlassen.' : `Mitglied "${memberName}" wurde entfernt.`, + }); + } catch (err: any) { + setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Entfernen des Mitglieds' }); + } + }; + + // Group teams into managed (admin) and member-only + const managedTeams = teams.filter((t) => + (t.members || []).some((m) => m.userId === currentUser.id && m.role === 'admin') + ); + + const memberTeams = teams.filter( + (t) => + (t.members || []).some((m) => m.userId === currentUser.id && m.role === 'member') && + !managedTeams.some((mt) => mt.id === t.id) + ); + + return ( +
+ {/* Page Header */} +
+
+ +
+

+ + Gruppen & Teams + + {teams.length} + +

+

+ Erstelle und verwalte Gruppen (z. B. Familie, WG oder Projektteams) für gemeinsame Notizen, Aufgaben und Listen. +

+
+
+ + +
+ + {/* Status Messages */} + {statusMsg && ( +
+
+ {statusMsg.type === 'ok' ? ( + + ) : ( + + )} + {statusMsg.text} +
+ +
+ )} + + {/* Modal: Neue Gruppe erstellen */} + {showCreateModal && ( +
setShowCreateModal(false)} + > +
e.stopPropagation()} + > +
+
+
+ +
+

Neue Gruppe erstellen

+
+ +
+ +
+
+ + setNewGroupName(e.target.value)} + placeholder="z. B. Familie, WG, Arbeit, Projekt X…" + required + autoFocus + className="w-full h-8 px-2.5 input-field text-xs" + /> +
+ +
+ + setNewGroupDesc(e.target.value)} + placeholder="z. B. Gemeinsame Einkaufslisten & Notizen…" + className="w-full h-8 px-2.5 input-field text-xs" + /> +
+ +
+

+ + Du wirst Gruppen-Admin +

+

+ Als Ersteller kannst du weitere Mitglieder hinzufügen, Rollen zuweisen und die Gruppe verwalten. +

+
+ +
+ + +
+
+
+
+ )} + + {/* Main Content Area */} + {loading ? ( +
Lade Gruppen…
+ ) : teams.length === 0 ? ( +
+
+ +
+
+

Noch keine Gruppen vorhanden

+

+ Erstelle deine erste Gruppe, um Aufgaben, Einkaufslisten oder Dokumente gemeinsam mit anderen zu nutzen. +

+
+ +
+ ) : ( +
+ {/* SECTION 1: Gruppen, die ich verwalte */} +
+
+

+ + Gruppen, die ich verwalte ({managedTeams.length}) +

+
+ + {managedTeams.length === 0 ? ( +
+ Du bist derzeit noch in keiner Gruppe als Admin eingetragen. Klicke oben auf "Neue Gruppe", um eine eigene Gruppe zu erstellen. +
+ ) : ( +
+ {managedTeams.map((team) => { + const members = team.members || []; + const isEditing = editingTeamId === team.id; + const memberUserIds = members.map((m) => m.userId); + + return ( +
+ {/* Team Header */} +
+
+
+ +
+ + {isEditing ? ( +
+ setEditName(e.target.value)} + placeholder="Gruppenname" + className="w-full h-8 px-2.5 input-field text-xs font-bold" + /> + setEditDesc(e.target.value)} + placeholder="Optionale Beschreibung" + className="w-full h-7 px-2.5 input-field text-xs" + /> +
+ + +
+
+ ) : ( +
+
+ + {team.name} + + + Gruppen-Admin + + + {members.length} {members.length === 1 ? 'Mitglied' : 'Mitglieder'} + +
+ {team.description && ( +

+ {team.description} +

+ )} +
+ )} +
+ + {!isEditing && ( +
+ + +
+ )} +
+ + {/* Members List */} +
+

+ Mitglieder ({members.length}) +

+
+ {members.map((m) => { + const isSelf = m.userId === currentUser.id; + const isAdmin = m.role === 'admin'; + + return ( +
+
+
+ {(m.displayName || 'U').slice(0, 1).toUpperCase()} +
+
+

+ {m.displayName} {isSelf && (Du)} +

+

+ {m.email} +

+
+
+ +
+ + + +
+
+ ); + })} +
+
+ + {/* Add Member Row */} +
+
+ + setSelectedUserByTeam((prev) => ({ ...prev, [team.id]: u })) + } + excludeUserIds={memberUserIds} + placeholder="Benutzer suchen zum Hinzufügen…" + /> +
+ + + + +
+
+ ); + })} +
+ )} +
+ + {/* SECTION 2: Meine Mitgliedschaften (als Mitglied) */} + {memberTeams.length > 0 && ( +
+

+ + Meine Mitgliedschaften ({memberTeams.length}) +

+ +
+ {memberTeams.map((team) => { + const members = team.members || []; + const admins = members.filter((m) => m.role === 'admin'); + + return ( +
+
+
+
+ +
+
+
+ {team.name} + + {members.length} {members.length === 1 ? 'Mitglied' : 'Mitglieder'} + +
+ {team.description && ( +

{team.description}

+ )} +
+
+ + +
+ + {/* Admins & Members list in read-only mode */} +
+

+ Gruppen-Admins:{' '} + + {admins.map((a) => a.displayName).join(', ') || 'Keiner'} + +

+

+ Alle Mitglieder:{' '} + + {members.map((m) => m.displayName).join(', ')} + +

+
+
+ ); + })} +
+
+ )} +
+ )} +
+ ); +}; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 339bb8f..192a715 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -723,6 +723,13 @@ export const api = { }); }, + async updateTeam(teamId: string, name: string, description = ''): Promise { + return request(`/api/v1/teams/${teamId}`, { + method: 'PUT', + body: JSON.stringify({ name, description }), + }); + }, + async deleteTeam(teamId: string): Promise { await request(`/api/v1/teams/${teamId}`, { method: 'DELETE' }); }, @@ -742,6 +749,17 @@ export const api = { }); }, + async updateTeamMemberRole( + teamId: string, + userId: string, + role: 'admin' | 'member' + ): Promise { + await request(`/api/v1/teams/${teamId}/members/${userId}`, { + method: 'PUT', + body: JSON.stringify({ role }), + }); + }, + async removeTeamMember(teamId: string, userId: string): Promise { await request(`/api/v1/teams/${teamId}/members/${userId}`, { method: 'DELETE' }); }, diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 9e29915..577829f 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -1,6 +1,6 @@ export type ThoughtStatus = 'inbox' | 'kept' | 'discarded' | 'converted'; export type PriorityLevel = 'low' | 'normal' | 'high' | 'urgent'; -export type UserRole = 'admin' | 'team_admin' | 'member'; +export type UserRole = 'admin' | 'member'; export interface AuthStatus { needsSetup: boolean; @@ -119,7 +119,7 @@ export interface Organization { export interface Team { id: string; - orgId: string; + orgId?: string; name: string; description: string; members?: TeamMember[]; @@ -131,7 +131,7 @@ export interface TeamMember { userId: string; displayName: string; email: string; - role: 'owner' | 'team_admin' | 'editor' | 'member' | 'viewer'; + role: 'admin' | 'member'; joinedAt: string; } diff --git a/internal/api/server.go b/internal/api/server.go index 4028973..b3c6046 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -150,8 +150,10 @@ func (s *Server) Router() http.Handler { r.Post("/api/v1/organizations/{id}/teams", s.handleCreateOrgTeam) r.Get("/api/v1/teams", s.handleGetUserTeams) r.Post("/api/v1/teams", s.handleCreateTeamDirect) + r.Put("/api/v1/teams/{id}", s.handleUpdateTeam) r.Delete("/api/v1/teams/{id}", s.handleDeleteTeam) r.Post("/api/v1/teams/{id}/members", s.handleAddTeamMember) + r.Put("/api/v1/teams/{id}/members/{userId}", s.handleUpdateTeamMemberRole) r.Delete("/api/v1/teams/{id}/members/{userId}", s.handleRemoveTeamMember) // API Keys Management @@ -268,8 +270,8 @@ func (s *Server) staffMiddleware(next http.Handler) http.Handler { return } user, err := s.store.GetUserByID(userID) - if err != nil || user == nil || (user.Role != "admin" && user.Role != "team_admin") { - writeError(w, http.StatusForbidden, "Admin- oder Team-Admin-Berechtigung erforderlich") + if err != nil || user == nil || user.Role != "admin" { + writeError(w, http.StatusForbidden, "Admin-Berechtigung erforderlich") return } next.ServeHTTP(w, r) @@ -1829,18 +1831,20 @@ func (s *Server) handleCreateOrgTeam(w http.ResponseWriter, r *http.Request) { return } // Automatically add creator to team - _ = s.store.AddTeamMember(team.ID, userID, "team_admin") + _ = s.store.AddTeamMember(team.ID, userID, "admin") team.Members, _ = s.store.GetTeamMembers(team.ID) writeJSON(w, http.StatusCreated, team) } -func (s *Server) handleDeleteTeam(w http.ResponseWriter, r *http.Request) { - teamID := chi.URLParam(r, "id") - if err := s.store.DeleteTeam(teamID); err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return +func (s *Server) isTeamAdmin(userID, teamID string) bool { + if userID == "" || teamID == "" { + return false } - writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) + role, err := s.store.GetTeamMemberRole(teamID, userID) + if err != nil { + return false + } + return role == "admin" } func (s *Server) handleCreateTeamDirect(w http.ResponseWriter, r *http.Request) { @@ -1868,22 +1872,56 @@ func (s *Server) handleCreateTeamDirect(w http.ResponseWriter, r *http.Request) writeError(w, http.StatusBadRequest, err.Error()) return } - // Automatically add creator to team as team_admin - _ = s.store.AddTeamMember(team.ID, userID, "team_admin") + // Automatically add creator to team as group admin + _ = s.store.AddTeamMember(team.ID, userID, "admin") team.Members, _ = s.store.GetTeamMembers(team.ID) writeJSON(w, http.StatusCreated, team) } +func (s *Server) handleUpdateTeam(w http.ResponseWriter, r *http.Request) { + userID := auth.GetUserID(r.Context()) + teamID := chi.URLParam(r, "id") + if !s.isTeamAdmin(userID, teamID) { + writeError(w, http.StatusForbidden, "Nur Gruppen-Administratoren können die Gruppe bearbeiten") + return + } + var req struct { + Name string `json:"name"` + Description string `json:"description"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "Ungültiges JSON") + return + } + name := strings.TrimSpace(req.Name) + if name == "" { + writeError(w, http.StatusBadRequest, "Name für das Team / die Gruppe ist erforderlich") + return + } + updated, err := s.store.UpdateTeam(teamID, name, req.Description) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + writeJSON(w, http.StatusOK, updated) +} + +func (s *Server) handleDeleteTeam(w http.ResponseWriter, r *http.Request) { + userID := auth.GetUserID(r.Context()) + teamID := chi.URLParam(r, "id") + if !s.isTeamAdmin(userID, teamID) { + writeError(w, http.StatusForbidden, "Nur Gruppen-Administratoren können die Gruppe löschen") + return + } + if err := s.store.DeleteTeam(teamID); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) +} + func (s *Server) handleGetUserTeams(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) - user, _ := s.store.GetUserByID(userID) - if (r.URL.Query().Get("all") == "true") || (user != nil && (user.Role == "admin" || user.Role == "team_admin")) { - teams, err := s.store.GetAllTeams() - if err == nil { - writeJSON(w, http.StatusOK, teams) - return - } - } teams, err := s.store.GetTeamsForUser(userID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) @@ -1893,7 +1931,12 @@ func (s *Server) handleGetUserTeams(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleAddTeamMember(w http.ResponseWriter, r *http.Request) { + userID := auth.GetUserID(r.Context()) teamID := chi.URLParam(r, "id") + if !s.isTeamAdmin(userID, teamID) { + writeError(w, http.StatusForbidden, "Nur Gruppen-Administratoren können Mitglieder hinzufügen") + return + } var req struct { UserID string `json:"userId"` Email string `json:"email"` @@ -1916,7 +1959,39 @@ func (s *Server) handleAddTeamMember(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "Benutzer-ID oder E-Mail erforderlich") return } - if err := s.store.AddTeamMember(teamID, targetUserID, req.Role); err != nil { + role := strings.ToLower(strings.TrimSpace(req.Role)) + if role != "admin" && role != "member" { + role = "member" + } + if err := s.store.AddTeamMember(teamID, targetUserID, role); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + members, _ := s.store.GetTeamMembers(teamID) + writeJSON(w, http.StatusOK, members) +} + +func (s *Server) handleUpdateTeamMemberRole(w http.ResponseWriter, r *http.Request) { + userID := auth.GetUserID(r.Context()) + teamID := chi.URLParam(r, "id") + memberUserID := chi.URLParam(r, "userId") + if !s.isTeamAdmin(userID, teamID) { + writeError(w, http.StatusForbidden, "Nur Gruppen-Administratoren können Mitgliedsrollen ändern") + return + } + var req struct { + Role string `json:"role"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "Ungültiges JSON") + return + } + role := strings.ToLower(strings.TrimSpace(req.Role)) + if role != "admin" && role != "member" { + writeError(w, http.StatusBadRequest, "Ungültige Rolle (erlaubt sind: admin, member)") + return + } + if err := s.store.UpdateTeamMemberRole(teamID, memberUserID, role); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } @@ -1925,10 +2000,20 @@ func (s *Server) handleAddTeamMember(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleRemoveTeamMember(w http.ResponseWriter, r *http.Request) { + userID := auth.GetUserID(r.Context()) teamID := chi.URLParam(r, "id") memberUserID := chi.URLParam(r, "userId") + + isSelf := userID == memberUserID + isAdmin := s.isTeamAdmin(userID, teamID) + + if !isSelf && !isAdmin { + writeError(w, http.StatusForbidden, "Nicht berechtigt, dieses Mitglied zu entfernen") + return + } + if err := s.store.RemoveTeamMember(teamID, memberUserID); err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) + writeError(w, http.StatusBadRequest, err.Error()) return } writeJSON(w, http.StatusOK, map[string]bool{"removed": true}) diff --git a/internal/api/server_test.go b/internal/api/server_test.go index e591a18..3568ce4 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -145,7 +145,7 @@ func TestFirstBootSetupAndInviteRegistrationAndNonNullSlices(t *testing.T) { } // 5. Create an Invite as Admin and register with that Invite Code - invBody := []byte(`{"role":"team_admin","validDays":7}`) + invBody := []byte(`{"role":"admin","validDays":7}`) req = httptest.NewRequest(http.MethodPost, "/api/v1/admin/invites", bytes.NewReader(invBody)) req.Header.Set("Authorization", "Bearer "+setupRes.Token) req.Header.Set("Content-Type", "application/json") @@ -163,7 +163,7 @@ func TestFirstBootSetupAndInviteRegistrationAndNonNullSlices(t *testing.T) { regWithInviteBody, _ := json.Marshal(map[string]string{ "email": "invited@example.com", "password": "password123", - "displayName": "Invited Team Admin", + "displayName": "Invited Admin", "inviteCode": createdInvite.Code, }) req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(regWithInviteBody)) @@ -177,8 +177,8 @@ func TestFirstBootSetupAndInviteRegistrationAndNonNullSlices(t *testing.T) { User domain.User `json:"user"` } _ = json.Unmarshal(rec.Body.Bytes(), &invitedRes) - if invitedRes.User.Role != "team_admin" { - t.Fatalf("expected invited user to receive role 'team_admin', got %q", invitedRes.User.Role) + if invitedRes.User.Role != "admin" { + t.Fatalf("expected invited user to receive role 'admin', got %q", invitedRes.User.Role) } } diff --git a/internal/domain/models.go b/internal/domain/models.go index 2bab8ef..0921c86 100644 --- a/internal/domain/models.go +++ b/internal/domain/models.go @@ -98,7 +98,7 @@ type User struct { Username string `json:"username,omitempty"` PasswordHash string `json:"-"` DisplayName string `json:"displayName"` - Role string `json:"role"` // admin | team_admin | member + Role string `json:"role"` // admin | member IsActive bool `json:"isActive"` AlarmChannels []string `json:"alarmChannels"` // email | discord | webhook AlarmEmail string `json:"alarmEmail"` @@ -112,7 +112,7 @@ type UserInvite struct { ID string `json:"id"` Code string `json:"code"` Email string `json:"email,omitempty"` - Role string `json:"role"` // member | team_admin | admin + Role string `json:"role"` // member | admin TeamID string `json:"teamId,omitempty"` TeamName string `json:"teamName,omitempty"` CreatedBy string `json:"createdBy"` @@ -212,7 +212,7 @@ type Organization struct { type Team struct { ID string `json:"id"` - OrgID string `json:"orgId"` + OrgID string `json:"orgId,omitempty"` Name string `json:"name"` Description string `json:"description"` Members []TeamMember `json:"members"` @@ -224,7 +224,7 @@ type TeamMember struct { UserID string `json:"userId"` DisplayName string `json:"displayName"` Email string `json:"email"` - Role string `json:"role"` // team_admin | member | owner + Role string `json:"role"` // admin | member JoinedAt string `json:"joinedAt"` } diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index 0bc22ad..0425e44 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -178,7 +178,7 @@ func (s *Store) migrate() error { CREATE TABLE IF NOT EXISTS team_members ( team_id TEXT NOT NULL, user_id TEXT NOT NULL, - role TEXT NOT NULL DEFAULT 'editor', + role TEXT NOT NULL DEFAULT 'member', joined_at TEXT NOT NULL, PRIMARY KEY (team_id, user_id), FOREIGN KEY(team_id) REFERENCES teams(id) ON DELETE CASCADE, @@ -391,6 +391,11 @@ func (s *Store) migrate() error { } } + // Clean up legacy roles in team_members and users + _, _ = s.db.Exec(`UPDATE team_members SET role = 'admin' WHERE role IN ('team_admin', 'owner')`) + _, _ = s.db.Exec(`UPDATE team_members SET role = 'member' WHERE role NOT IN ('admin', 'member')`) + _, _ = s.db.Exec(`UPDATE users SET role = 'member' WHERE role = 'team_admin'`) + // Reindex block tags if table is newly added or empty var tagCount int _ = s.db.QueryRow(`SELECT COUNT(*) FROM doc_block_tags`).Scan(&tagCount) @@ -534,7 +539,7 @@ func (s *Store) CreateUser(email, passwordHash, displayName, role string) (*doma } func (s *Store) CreateUserWithUsername(email, username, passwordHash, displayName, role string) (*domain.User, error) { - if role != "admin" && role != "team_admin" && role != "member" { + if role != "admin" && role != "member" { role = "member" } cleanEmail := strings.ToLower(strings.TrimSpace(email)) @@ -674,20 +679,13 @@ func (s *Store) GetDiscoverableUsers(viewerID string) ([]domain.User, error) { mode = sys.SharingMode } - if viewer.Role == "admin" || viewer.Role == "team_admin" || mode == "open" { + if viewer.Role == "admin" || mode == "open" || mode == "teams_only" { return s.GetAllUsers() } if mode == "strict_private" { return []domain.User{*viewer}, nil } - // 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( `SELECT DISTINCT `+userSelectCols+` FROM users WHERE id = ? OR id IN ( @@ -742,7 +740,7 @@ func (s *Store) UpdateUserFull( if email != nil { u.Email = strings.ToLower(strings.TrimSpace(*email)) } - if role != nil && (*role == "admin" || *role == "team_admin" || *role == "member") { + if role != nil && (*role == "admin" || *role == "member") { u.Role = *role } if isActive != nil { @@ -3313,7 +3311,7 @@ func (s *Store) GetOrganizationsForUser(userID string) ([]domain.Organization, e var rows *sql.Rows var err error - if viewer != nil && (viewer.Role == "admin" || viewer.Role == "team_admin") { + if viewer != nil && viewer.Role == "admin" { rows, err = s.db.Query( `SELECT id, name, owner_id, created_at FROM organizations ORDER BY created_at ASC`, ) @@ -3474,11 +3472,10 @@ func (s *Store) GetTeamsForUser(userID string) ([]domain.Team, error) { rows, err := s.db.Query( `SELECT DISTINCT t.id, t.org_id, t.name, t.description, t.created_at FROM teams t - LEFT JOIN team_members tm ON tm.team_id = t.id - LEFT JOIN organizations o ON o.id = t.org_id - WHERE tm.user_id = ? OR o.owner_id = ? + JOIN team_members tm ON tm.team_id = t.id + WHERE tm.user_id = ? ORDER BY t.created_at ASC`, - userID, userID, + userID, ) if err != nil { return []domain.Team{}, err @@ -3501,8 +3498,39 @@ func (s *Store) GetTeamsForUser(userID string) ([]domain.Team, error) { return teams, nil } +func (s *Store) UpdateTeam(teamID, name, description string) (*domain.Team, error) { + name = strings.TrimSpace(name) + if name == "" { + return nil, errors.New("Name für die Gruppe ist erforderlich") + } + _, err := s.db.Exec(`UPDATE teams SET name = ?, description = ? WHERE id = ?`, name, strings.TrimSpace(description), teamID) + if err != nil { + return nil, err + } + return s.GetTeamByID(teamID) +} + +func (s *Store) GetTeamMemberRole(teamID, userID string) (string, error) { + var role string + err := s.db.QueryRow(`SELECT role FROM team_members WHERE team_id = ? AND user_id = ?`, teamID, userID).Scan(&role) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + return "", err + } + return role, nil +} + +func (s *Store) CountTeamAdmins(teamID string) (int, error) { + var count int + err := s.db.QueryRow(`SELECT COUNT(*) FROM team_members WHERE team_id = ? AND role = 'admin'`, teamID).Scan(&count) + return count, err +} + func (s *Store) AddTeamMember(teamID, userID, role string) error { - if role == "" { + role = strings.ToLower(strings.TrimSpace(role)) + if role != "admin" && role != "member" { role = "member" } _, err := s.db.Exec( @@ -3513,8 +3541,46 @@ func (s *Store) AddTeamMember(teamID, userID, role string) error { return err } +func (s *Store) UpdateTeamMemberRole(teamID, userID, role string) error { + role = strings.ToLower(strings.TrimSpace(role)) + if role != "admin" && role != "member" { + return errors.New("Ungültige Rolle (erlaubt sind: admin, member)") + } + currentRole, err := s.GetTeamMemberRole(teamID, userID) + if err != nil { + return err + } + if currentRole == "" { + return errors.New("Benutzer ist kein Mitglied dieser Gruppe") + } + if currentRole == "admin" && role != "admin" { + adminCount, err := s.CountTeamAdmins(teamID) + if err != nil { + return err + } + if adminCount <= 1 { + return errors.New("Die Gruppe muss mindestens einen Gruppen-Admin behalten") + } + } + _, err = s.db.Exec(`UPDATE team_members SET role = ? WHERE team_id = ? AND user_id = ?`, role, teamID, userID) + return err +} + func (s *Store) RemoveTeamMember(teamID, userID string) error { - _, err := s.db.Exec(`DELETE FROM team_members WHERE team_id = ? AND user_id = ?`, teamID, userID) + currentRole, err := s.GetTeamMemberRole(teamID, userID) + if err != nil { + return err + } + if currentRole == "admin" { + adminCount, err := s.CountTeamAdmins(teamID) + if err != nil { + return err + } + if adminCount <= 1 { + return errors.New("Du bist der letzte Gruppen-Admin. Bitte ernenne zuerst einen anderen Admin oder lösche die Gruppe.") + } + } + _, err = s.db.Exec(`DELETE FROM team_members WHERE team_id = ? AND user_id = ?`, teamID, userID) return err } @@ -3545,7 +3611,7 @@ func (s *Store) GetTeamMembers(teamID string) ([]domain.TeamMember, error) { // --- USER INVITES --- func (s *Store) CreateUserInvite(createdBy, email, role, teamID string, validDays int) (*domain.UserInvite, error) { - if role != "admin" && role != "team_admin" && role != "member" { + if role != "admin" && role != "member" { role = "member" } if validDays <= 0 || validDays > 365 {