feat: overhaul groups management, remove global team_admin and isolate group administration
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s
This commit is contained in:
1 parent
60c302e23e
commit
e37302f73c
11 files changed
+936
-388
No files matched your search
+106
-21
@@ -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})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
+85
-19
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user