package api import ( "encoding/json" "net/http" "strings" "github.com/go-chi/chi/v5" "github.com/sandrodach/mindsift/internal/auth" "github.com/sandrodach/mindsift/internal/domain" "github.com/sandrodach/mindsift/internal/storage" ) // --- ORGANIZATIONS & TEAMS --- func (s *Server) handleAdminGetOrganizations(w http.ResponseWriter, r *http.Request) { orgs, err := s.store.GetOrganizations() if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, orgs) } func (s *Server) handleAdminCreateOrganization(w http.ResponseWriter, r *http.Request) { var req struct { Name string `json:"name"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Ungültiges JSON") return } org, err := s.store.CreateOrganization(req.Name) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } writeJSON(w, http.StatusCreated, org) } func (s *Server) handleAdminRenameOrganization(w http.ResponseWriter, r *http.Request) { var req struct { Name string `json:"name"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Ungültiges JSON") return } if err := s.store.RenameOrganization(chi.URLParam(r, "id"), req.Name); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } writeJSON(w, http.StatusOK, map[string]bool{"updated": true}) } func (s *Server) handleAdminDeleteOrganization(w http.ResponseWriter, r *http.Request) { if err := s.store.DeleteOrganization(chi.URLParam(r, "id")); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) } func (s *Server) handleAdminSetUserOrganization(w http.ResponseWriter, r *http.Request) { var req struct { OrganizationID string `json:"organizationId"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Ungültiges JSON") return } if err := s.store.SetUserOrganization(chi.URLParam(r, "id"), req.OrganizationID); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } writeJSON(w, http.StatusOK, map[string]bool{"updated": true}) } // handleAdminSharingImpact counts the shares and group memberships a switch to ?mode= would remove. func (s *Server) handleAdminSharingImpact(w http.ResponseWriter, r *http.Request) { n, err := s.store.PruneSharing(r.URL.Query().Get("mode"), true) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, map[string]int64{"affected": n}) } // --- ADMIN & STAFF PANEL --- func (s *Server) handleAdminGetUsers(w http.ResponseWriter, r *http.Request) { users, err := s.store.GetAllUsers() if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } pending := s.store.PendingRegistrationIDs() unverified := s.store.UnverifiedEmailIDs() for i := range users { users[i].PendingApproval = pending[users[i].ID] users[i].EmailUnverified = unverified[users[i].ID] } writeJSON(w, http.StatusOK, users) } func (s *Server) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) { callerID := auth.GetUserID(r.Context()) caller, _ := s.store.GetUserByID(callerID) var req struct { Email string `json:"email"` Password string `json:"password"` DisplayName string `json:"displayName"` Role string `json:"role"` OrganizationID string `json:"organizationId"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Ungültiges JSON") return } if req.Email == "" { writeError(w, http.StatusBadRequest, "E-Mail-Adresse erforderlich") return } if err := auth.ValidatePassword(req.Password); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } // Only full server admin can create another server admin if req.Role == "admin" && (caller == nil || caller.Role != "admin") { req.Role = "member" } if strings.TrimSpace(req.DisplayName) == "" { req.DisplayName = strings.Split(req.Email, "@")[0] } hash, err := auth.HashPassword(req.Password) if err != nil { writeError(w, http.StatusInternalServerError, "Passwort-Hash fehlgeschlagen") return } u, err := s.store.CreateUser(req.Email, hash, req.DisplayName, req.Role) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } if req.OrganizationID != "" { if err := s.store.SetUserOrganization(u.ID, req.OrganizationID); err == nil { u.OrganizationID = req.OrganizationID } } writeJSON(w, http.StatusCreated, u) } func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) { callerID := auth.GetUserID(r.Context()) caller, _ := s.store.GetUserByID(callerID) targetUserID := chi.URLParam(r, "id") targetUser, err := s.store.GetUserByID(targetUserID) if err != nil || targetUser == nil { writeError(w, http.StatusNotFound, "Benutzer nicht gefunden") return } // Team-Admins cannot modify Server-Admins if caller != nil && caller.Role != "admin" && targetUser.Role == "admin" { writeError(w, http.StatusForbidden, "Nur Server-Administratoren können Server-Admin-Konten bearbeiten") return } var req struct { DisplayName *string `json:"displayName"` Email *string `json:"email"` Role *string `json:"role"` IsActive *bool `json:"isActive"` DiscordWebhookURL *string `json:"discordWebhookUrl"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Ungültiges JSON") return } if req.Role != nil && *req.Role == "admin" && (caller == nil || caller.Role != "admin") { writeError(w, http.StatusForbidden, "Nur Server-Administratoren können die Rolle Server-Admin vergeben") return } if req.DiscordWebhookURL != nil { if err := validateWebhookURL(*req.DiscordWebhookURL, true, s.allowPrivateWebhooks()); err != nil { writeError(w, http.StatusBadRequest, "Discord-Webhook: "+err.Error()) return } } u, err := s.store.UpdateUser(targetUserID, req.DisplayName, req.Email, req.Role, req.IsActive, req.DiscordWebhookURL, nil) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } if req.IsActive != nil && !*req.IsActive { s.wsHub.DisconnectUser(targetUserID) } if req.IsActive != nil && *req.IsActive { // Activating by hand counts as approval and vouches for the address s.store.ClearPendingRegistration(targetUserID) s.store.ClearEmailVerification(targetUserID) } writeJSON(w, http.StatusOK, u) } func (s *Server) handleAdminResetPassword(w http.ResponseWriter, r *http.Request) { callerID := auth.GetUserID(r.Context()) caller, _ := s.store.GetUserByID(callerID) targetUserID := chi.URLParam(r, "id") targetUser, _ := s.store.GetUserByID(targetUserID) if caller != nil && caller.Role != "admin" && targetUser != nil && targetUser.Role == "admin" { writeError(w, http.StatusForbidden, "Keine Berechtigung") return } var req struct { Password string `json:"password"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Neues Passwort erforderlich") return } if err := auth.ValidatePassword(req.Password); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } hash, err := auth.HashPassword(req.Password) if err != nil { writeError(w, http.StatusInternalServerError, "Passwort-Hash fehlgeschlagen") return } s.loginGuard.unlockAccount(targetUserID) if err := s.store.UpdateUserPassword(targetUserID, hash); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } s.wsHub.DisconnectUser(targetUserID) writeJSON(w, http.StatusOK, map[string]bool{"success": true}) } func (s *Server) handleAdminDeleteUser(w http.ResponseWriter, r *http.Request) { targetUserID := chi.URLParam(r, "id") currUserID := auth.GetUserID(r.Context()) if targetUserID == currUserID { writeError(w, http.StatusBadRequest, "Sie können Ihren eigenen Benutzer nicht löschen") return } caller, _ := s.store.GetUserByID(currUserID) targetUser, _ := s.store.GetUserByID(targetUserID) if caller != nil && caller.Role != "admin" && targetUser != nil && targetUser.Role == "admin" { writeError(w, http.StatusForbidden, "Keine Berechtigung zum Löschen eines Server-Admins") return } if err := s.store.DeleteUser(targetUserID); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } s.removeUserBackups(targetUserID) s.store.ClearPendingRegistration(targetUserID) writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) } // --- INVITES MANAGEMENT --- func (s *Server) handleAdminGetInvites(w http.ResponseWriter, r *http.Request) { invites, err := s.store.GetUserInvites() if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, invites) } func (s *Server) handleAdminCreateInvite(w http.ResponseWriter, r *http.Request) { callerID := auth.GetUserID(r.Context()) caller, _ := s.store.GetUserByID(callerID) var req struct { Email string `json:"email"` Role string `json:"role"` OrganizationID string `json:"organizationId"` ValidDays int `json:"validDays"` MaxUses int `json:"maxUses"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Ungültiges JSON") return } if req.Role == "admin" && (caller == nil || caller.Role != "admin") { req.Role = "member" } inv, err := s.store.CreateUserInvite(callerID, req.Email, req.Role, req.OrganizationID, req.ValidDays, req.MaxUses) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } writeJSON(w, http.StatusCreated, inv) } func (s *Server) handleAdminDeleteInvite(w http.ResponseWriter, r *http.Request) { inviteID := chi.URLParam(r, "id") if err := s.store.DeleteUserInvite(inviteID); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) } // --- SERVER SETTINGS & SMTP --- func (s *Server) handleAdminGetSettings(w http.ResponseWriter, r *http.Request) { settings, err := s.store.GetSystemSettings() if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, s.settingsForBrowser(settings)) } // settingsForBrowser hides the SMTP password; the admin only sees whether one is stored. func (s *Server) settingsForBrowser(settings *domain.SystemSettings) *domain.SystemSettings { out := *settings out.SMTPPassSet = out.SMTPPass != "" out.SMTPPass = "" out.SMTPConfiguredViaEnv = strings.TrimSpace(s.cfg.SMTPHost) != "" // What the environment fixes is shown as in effect and locked in the form if s.cfg.RegistrationMode != "" { out.RegistrationMode = s.cfg.RegistrationMode out.AllowRegistration = out.RegistrationMode == storage.RegistrationOpen out.RegistrationConfiguredViaEnv = true } if s.cfg.RegistrationDomains != "" { out.RegistrationDomains = strings.Join(storage.ParseEmailDomains(s.cfg.RegistrationDomains), ", ") out.RegistrationConfiguredViaEnv = true } if s.cfg.RegistrationVerifyEmail != "" { out.RegistrationVerifyEmail = s.cfg.RegistrationVerifyEmail == "true" out.RegistrationVerifyViaEnv = true } out.EmailLinksAvailable = s.emailLinksAvailable() return &out } func (s *Server) handleAdminUpdateSettings(w http.ResponseWriter, r *http.Request) { var settings domain.SystemSettings if err := json.NewDecoder(r.Body).Decode(&settings); err != nil { writeError(w, http.StatusBadRequest, "Ungültiges JSON") return } // Empty means unchanged (the browser never has it); without SMTP user the password goes too if settings.SMTPPass == "" && strings.TrimSpace(settings.SMTPUser) != "" { if cur, err := s.store.GetSystemSettings(); err == nil { settings.SMTPPass = cur.SMTPPass } } // Registration fixed by the environment cannot be changed here (the stored value stays as it was) if s.cfg.RegistrationMode != "" || s.cfg.RegistrationDomains != "" { if cur, err := s.store.GetSystemSettings(); err == nil { if s.cfg.RegistrationMode != "" { settings.RegistrationMode, settings.AllowRegistration = cur.RegistrationMode, cur.AllowRegistration } if s.cfg.RegistrationDomains != "" { settings.RegistrationDomains = cur.RegistrationDomains } } } if s.cfg.RegistrationVerifyEmail != "" { if cur, err := s.store.GetSystemSettings(); err == nil { settings.RegistrationVerifyEmail = cur.RegistrationVerifyEmail } } previousMode := s.store.SharingMode() if err := s.store.UpdateSystemSettings(&settings); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } // A stricter mode takes away what is no longer allowed (shares, group memberships, assignments) if settings.SharingMode != previousMode { if _, err := s.store.PruneSharing(settings.SharingMode, false); err != nil { writeError(w, http.StatusInternalServerError, "Freigaben konnten nicht angepasst werden: "+err.Error()) return } } writeJSON(w, http.StatusOK, s.settingsForBrowser(&settings)) } func (s *Server) handleAdminTestSMTP(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) user, _ := s.store.GetUserByID(userID) var req struct { ToEmail string `json:"toEmail"` } _ = json.NewDecoder(r.Body).Decode(&req) toEmail := strings.TrimSpace(req.ToEmail) if toEmail == "" && user != nil { toEmail = user.Email } if toEmail == "" { writeError(w, http.StatusBadRequest, "Ziel-E-Mail-Adresse erforderlich") return } if err := s.sendMailContent(toEmail, "MindSift: SMTP-Test", mailContent{ Preheader: "Die SMTP-Konfiguration funktioniert", Greeting: "Es funktioniert!", Intro: "Die SMTP-Konfiguration deiner MindSift-Instanz ist richtig eingerichtet: Alarme, Tageszusammenfassungen und Passwort-Links kommen an.", Footer: "Diese Test-E-Mail wurde in den Administratoreinstellungen ausgelöst.", }); err != nil { writeError(w, http.StatusBadGateway, "SMTP-Fehler: "+err.Error()) return } writeJSON(w, http.StatusOK, map[string]interface{}{ "sent": true, "toEmail": toEmail, }) }