feat(auth,settings,tasks): add first-boot setup, real login/invites, full-page settings & admin views, multi-channel alarms, and fix null slice crashes
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
50ac0c8f64
commit
7802eb2cf0
17 files changed
+4391
-1368
No files matched your search
+602
-81
@@ -2,10 +2,12 @@ package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -33,7 +35,7 @@ func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS
|
||||
wsHub: wsHub,
|
||||
staticFS: staticFS,
|
||||
}
|
||||
go srv.startDiscordAlarmTicker()
|
||||
go srv.startAlarmTicker()
|
||||
return srv
|
||||
}
|
||||
|
||||
@@ -51,8 +53,10 @@ func (s *Server) Router() http.Handler {
|
||||
MaxAge: 300,
|
||||
}))
|
||||
|
||||
// Public Health & Webhook Routes
|
||||
// Public Health, Setup & Auth Routes
|
||||
r.Get("/api/v1/health", s.handleHealth)
|
||||
r.Get("/api/v1/auth/status", s.handleAuthStatus)
|
||||
r.Post("/api/v1/auth/setup", s.handleFirstBootSetup)
|
||||
r.Post("/api/v1/auth/register", s.handleRegister)
|
||||
r.Post("/api/v1/auth/login", s.handleLogin)
|
||||
r.Post("/api/v1/auth/quickstart", s.handleQuickStart)
|
||||
@@ -65,6 +69,8 @@ func (s *Server) Router() http.Handler {
|
||||
|
||||
r.Get("/api/v1/auth/me", s.handleMe)
|
||||
r.Patch("/api/v1/auth/settings", s.handleUpdateUserSettings)
|
||||
r.Post("/api/v1/auth/alarms/test", s.handleTestUserAlarm)
|
||||
r.Get("/api/v1/users/directory", s.handleGetUsersDirectory)
|
||||
r.Get("/api/v1/stats", s.handleStats)
|
||||
r.Get("/api/v1/search", s.handleSearch)
|
||||
r.Get("/api/v1/export", s.handleExport)
|
||||
@@ -111,9 +117,11 @@ func (s *Server) Router() http.Handler {
|
||||
// Organizations & Teams
|
||||
r.Get("/api/v1/organizations", s.handleGetOrganizations)
|
||||
r.Post("/api/v1/organizations", s.handleCreateOrganization)
|
||||
r.Delete("/api/v1/organizations/{id}", s.handleDeleteOrganization)
|
||||
r.Get("/api/v1/organizations/{id}/teams", s.handleGetOrgTeams)
|
||||
r.Post("/api/v1/organizations/{id}/teams", s.handleCreateOrgTeam)
|
||||
r.Get("/api/v1/teams", s.handleGetUserTeams)
|
||||
r.Delete("/api/v1/teams/{id}", s.handleDeleteTeam)
|
||||
r.Post("/api/v1/teams/{id}/members", s.handleAddTeamMember)
|
||||
r.Delete("/api/v1/teams/{id}/members/{userId}", s.handleRemoveTeamMember)
|
||||
|
||||
@@ -122,16 +130,26 @@ func (s *Server) Router() http.Handler {
|
||||
r.Post("/api/v1/keys", s.handleCreateAPIKey)
|
||||
r.Delete("/api/v1/keys/{id}", s.handleDeleteAPIKey)
|
||||
|
||||
// Admin Panel Routes
|
||||
// Staff Routes (Server-Admin & Team-Admin)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.adminMiddleware)
|
||||
r.Use(s.staffMiddleware)
|
||||
r.Get("/api/v1/admin/users", s.handleAdminGetUsers)
|
||||
r.Post("/api/v1/admin/users", s.handleAdminCreateUser)
|
||||
r.Patch("/api/v1/admin/users/{id}", s.handleAdminUpdateUser)
|
||||
r.Put("/api/v1/admin/users/{id}/password", s.handleAdminResetPassword)
|
||||
r.Delete("/api/v1/admin/users/{id}", s.handleAdminDeleteUser)
|
||||
|
||||
r.Get("/api/v1/admin/invites", s.handleAdminGetInvites)
|
||||
r.Post("/api/v1/admin/invites", s.handleAdminCreateInvite)
|
||||
r.Delete("/api/v1/admin/invites/{id}", s.handleAdminDeleteInvite)
|
||||
})
|
||||
|
||||
// Server-Admin Strictly Protected Routes
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.adminMiddleware)
|
||||
r.Get("/api/v1/admin/settings", s.handleAdminGetSettings)
|
||||
r.Put("/api/v1/admin/settings", s.handleAdminUpdateSettings)
|
||||
r.Post("/api/v1/admin/smtp/test", s.handleAdminTestSMTP)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -144,12 +162,10 @@ func (s *Server) Router() http.Handler {
|
||||
path = "index.html"
|
||||
}
|
||||
if _, err := fs.Stat(s.staticFS, path); err != nil {
|
||||
// Never return index.html for missing JS/CSS assets
|
||||
if strings.HasPrefix(path, "assets/") || strings.HasSuffix(path, ".js") || strings.HasSuffix(path, ".css") {
|
||||
http.NotFound(w, req)
|
||||
return
|
||||
}
|
||||
// SPA client-side routing fallback to index.html
|
||||
req.URL.Path = "/"
|
||||
path = "index.html"
|
||||
}
|
||||
@@ -186,21 +202,18 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
||||
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
if strings.HasPrefix(tokenStr, "mindsift_sec_") {
|
||||
user, err := s.store.ValidateAPIKeyAndGetUser(tokenStr)
|
||||
if err == nil && user != nil {
|
||||
if err == nil && user != nil && user.IsActive {
|
||||
next.ServeHTTP(w, r.WithContext(auth.WithUserID(r.Context(), user.ID)))
|
||||
return
|
||||
}
|
||||
}
|
||||
claims, err := auth.ValidateToken(s.cfg.JWTSecret, tokenStr)
|
||||
if err == nil && claims != nil {
|
||||
userID := claims.UserID
|
||||
if _, userErr := s.store.GetUserByID(userID); userErr != nil {
|
||||
if defUser, defErr := s.store.EnsureDefaultUser(); defErr == nil && defUser != nil {
|
||||
userID = defUser.ID
|
||||
}
|
||||
user, userErr := s.store.GetUserByID(claims.UserID)
|
||||
if userErr == nil && user != nil && user.IsActive {
|
||||
next.ServeHTTP(w, r.WithContext(auth.WithUserID(r.Context(), user.ID)))
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(auth.WithUserID(r.Context(), userID)))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,13 +221,29 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
||||
apiKey := r.Header.Get("X-API-Key")
|
||||
if apiKey != "" {
|
||||
user, err := s.store.ValidateAPIKeyAndGetUser(apiKey)
|
||||
if err == nil && user != nil {
|
||||
if err == nil && user != nil && user.IsActive {
|
||||
next.ServeHTTP(w, r.WithContext(auth.WithUserID(r.Context(), user.ID)))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
writeError(w, http.StatusUnauthorized, "Nicht autorisiert – bitte einloggen")
|
||||
writeError(w, http.StatusUnauthorized, "Nicht autorisiert – bitte anmelden")
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) staffMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
if userID == "" {
|
||||
writeError(w, http.StatusUnauthorized, "Nicht autorisiert")
|
||||
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")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -227,7 +256,7 @@ func (s *Server) adminMiddleware(next http.Handler) http.Handler {
|
||||
}
|
||||
user, err := s.store.GetUserByID(userID)
|
||||
if err != nil || user == nil || user.Role != "admin" {
|
||||
writeError(w, http.StatusForbidden, "Administrator-Rechte erforderlich")
|
||||
writeError(w, http.StatusForbidden, "Server-Administrator-Rechte erforderlich")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
@@ -244,20 +273,98 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
sysSettings, _ := s.store.GetSystemSettings()
|
||||
if !s.cfg.AllowRegistration || (sysSettings != nil && !sysSettings.AllowRegistration) {
|
||||
count, _ := s.store.CountUsers()
|
||||
if count > 0 {
|
||||
writeError(w, http.StatusForbidden, "Registrierung ist auf dieser Instanz deaktiviert")
|
||||
return
|
||||
func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
|
||||
count, _ := s.store.CountUsers()
|
||||
sys, _ := s.store.GetSystemSettings()
|
||||
allowReg := false
|
||||
instanceName := "MindSift"
|
||||
if sys != nil {
|
||||
allowReg = sys.AllowRegistration
|
||||
if sys.InstanceName != "" {
|
||||
instanceName = sys.InstanceName
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"needsSetup": count == 0,
|
||||
"allowRegistration": allowReg,
|
||||
"instanceName": instanceName,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleFirstBootSetup(w http.ResponseWriter, r *http.Request) {
|
||||
count, err := s.store.CountUsers()
|
||||
if err != nil || count > 0 {
|
||||
writeError(w, http.StatusForbidden, "Ersteinrichtung wurde bereits abgeschlossen")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
DisplayName string `json:"displayName"`
|
||||
InstanceName string `json:"instanceName"`
|
||||
AllowRegistration bool `json:"allowRegistration"`
|
||||
SharingMode string `json:"sharingMode"`
|
||||
SeedSampleData bool `json:"seedSampleData"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Email) == "" || len(req.Password) < 4 {
|
||||
writeError(w, http.StatusBadRequest, "E-Mail und Passwort (mind. 4 Zeichen) erforderlich")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.DisplayName) == "" {
|
||||
req.DisplayName = "Administrator"
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Passwort-Hashing")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := s.store.CreateUser(req.Email, hash, req.DisplayName, "admin")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
sys, _ := s.store.GetSystemSettings()
|
||||
if sys != nil {
|
||||
if strings.TrimSpace(req.InstanceName) != "" {
|
||||
sys.InstanceName = strings.TrimSpace(req.InstanceName)
|
||||
}
|
||||
sys.AllowRegistration = req.AllowRegistration
|
||||
if req.SharingMode == "open" || req.SharingMode == "teams_only" || req.SharingMode == "strict_private" {
|
||||
sys.SharingMode = req.SharingMode
|
||||
}
|
||||
_ = s.store.UpdateSystemSettings(sys)
|
||||
}
|
||||
|
||||
if req.SeedSampleData {
|
||||
_ = s.store.SeedStarterContent(user)
|
||||
}
|
||||
|
||||
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||
"token": token,
|
||||
"user": user,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
DisplayName string `json:"displayName"`
|
||||
InviteCode string `json:"inviteCode"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
|
||||
@@ -271,25 +378,50 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
req.DisplayName = strings.Split(req.Email, "@")[0]
|
||||
}
|
||||
|
||||
count, _ := s.store.CountUsers()
|
||||
role := "member"
|
||||
var validInvite *domain.UserInvite
|
||||
|
||||
if strings.TrimSpace(req.InviteCode) != "" {
|
||||
inv, err := s.store.GetUserInviteByCode(req.InviteCode)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
if inv.Email != "" && !strings.EqualFold(inv.Email, strings.TrimSpace(req.Email)) {
|
||||
writeError(w, http.StatusForbidden, "Dieser Einladungscode ist an eine andere E-Mail-Adresse gebunden")
|
||||
return
|
||||
}
|
||||
validInvite = inv
|
||||
role = inv.Role
|
||||
} else if count == 0 {
|
||||
role = "admin"
|
||||
} else {
|
||||
sysSettings, _ := s.store.GetSystemSettings()
|
||||
if sysSettings == nil || !sysSettings.AllowRegistration {
|
||||
writeError(w, http.StatusForbidden, "Öffentliche Registrierung ist deaktiviert – bitte nutze einen Einladungscode vom Administrator")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Passwort-Hashing")
|
||||
return
|
||||
}
|
||||
|
||||
count, _ := s.store.CountUsers()
|
||||
role := "member"
|
||||
if count == 0 {
|
||||
role = "admin"
|
||||
}
|
||||
|
||||
user, err := s.store.CreateUser(req.Email, hash, req.DisplayName, role)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusConflict, "E-Mail-Adresse existiert bereits")
|
||||
return
|
||||
}
|
||||
|
||||
_ = s.store.SeedStarterContent(user)
|
||||
if validInvite != nil {
|
||||
_ = s.store.MarkUserInviteUsed(validInvite.ID, user.ID)
|
||||
if validInvite.TeamID != "" {
|
||||
_ = s.store.AddTeamMember(validInvite.TeamID, user.ID, "member")
|
||||
}
|
||||
}
|
||||
|
||||
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName)
|
||||
if err != nil {
|
||||
@@ -336,22 +468,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleQuickStart(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := s.store.EnsureDefaultUser()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Standard-Profil konnte nicht geladen werden")
|
||||
return
|
||||
}
|
||||
|
||||
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Token-Fehler")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"token": token,
|
||||
"user": user,
|
||||
})
|
||||
writeError(w, http.StatusForbidden, "Automatischer Gast-Login ist deaktiviert – bitte anmelden")
|
||||
}
|
||||
|
||||
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -984,16 +1101,19 @@ func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, exportData)
|
||||
}
|
||||
|
||||
// --- USER SETTINGS ---
|
||||
// --- USER SETTINGS & ALARMS ---
|
||||
|
||||
func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
var req struct {
|
||||
DisplayName *string `json:"displayName"`
|
||||
Email *string `json:"email"`
|
||||
Password *string `json:"password"`
|
||||
DiscordWebhookURL *string `json:"discordWebhookUrl"`
|
||||
NotifyBeforeMinutes *int `json:"notifyBeforeMinutes"`
|
||||
DisplayName *string `json:"displayName"`
|
||||
Email *string `json:"email"`
|
||||
Password *string `json:"password"`
|
||||
AlarmChannels *[]string `json:"alarmChannels"`
|
||||
AlarmEmail *string `json:"alarmEmail"`
|
||||
DiscordWebhookURL *string `json:"discordWebhookUrl"`
|
||||
CustomWebhookURL *string `json:"customWebhookUrl"`
|
||||
NotifyBeforeMinutes *int `json:"notifyBeforeMinutes"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
@@ -1007,7 +1127,18 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
_ = s.store.UpdateUserPassword(userID, hash)
|
||||
}
|
||||
u, err := s.store.UpdateUser(userID, req.DisplayName, req.Email, nil, nil, req.DiscordWebhookURL, req.NotifyBeforeMinutes)
|
||||
u, err := s.store.UpdateUserFull(
|
||||
userID,
|
||||
req.DisplayName,
|
||||
req.Email,
|
||||
nil,
|
||||
nil,
|
||||
req.AlarmChannels,
|
||||
req.AlarmEmail,
|
||||
req.DiscordWebhookURL,
|
||||
req.CustomWebhookURL,
|
||||
req.NotifyBeforeMinutes,
|
||||
)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
@@ -1015,6 +1146,86 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request
|
||||
writeJSON(w, http.StatusOK, u)
|
||||
}
|
||||
|
||||
func (s *Server) handleTestUserAlarm(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
user, err := s.store.GetUserByID(userID)
|
||||
if err != nil || user == nil {
|
||||
writeError(w, http.StatusNotFound, "Benutzer nicht gefunden")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Channel string `json:"channel"` // email | discord | webhook
|
||||
Target string `json:"target"` // optional override URL or email
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
|
||||
channel := strings.ToLower(strings.TrimSpace(req.Channel))
|
||||
target := strings.TrimSpace(req.Target)
|
||||
|
||||
switch channel {
|
||||
case "discord":
|
||||
url := target
|
||||
if url == "" {
|
||||
url = user.DiscordWebhookURL
|
||||
}
|
||||
if url == "" {
|
||||
writeError(w, http.StatusBadRequest, "Bitte eine Discord Webhook URL angeben")
|
||||
return
|
||||
}
|
||||
if err := s.sendDiscordNotification(url, "Test-Alarm von MindSift", "Dein Discord-Alarmkanal funktioniert einwandfrei!", domain.NowISO()[:10], "normal"); err != nil {
|
||||
writeError(w, http.StatusBadGateway, "Discord-Fehler: "+err.Error())
|
||||
return
|
||||
}
|
||||
case "webhook":
|
||||
url := target
|
||||
if url == "" {
|
||||
url = user.CustomWebhookURL
|
||||
}
|
||||
if url == "" {
|
||||
writeError(w, http.StatusBadRequest, "Bitte eine Webhook-URL angeben")
|
||||
return
|
||||
}
|
||||
if err := s.sendCustomWebhookNotification(url, "test_alarm", "Test-Alarm von MindSift", "Dein eigener Webhook empfängt MindSift-Alarme!", domain.NowISO()[:10], "normal"); err != nil {
|
||||
writeError(w, http.StatusBadGateway, "Webhook-Fehler: "+err.Error())
|
||||
return
|
||||
}
|
||||
case "email":
|
||||
toEmail := target
|
||||
if toEmail == "" {
|
||||
toEmail = user.AlarmEmail
|
||||
}
|
||||
if toEmail == "" {
|
||||
toEmail = user.Email
|
||||
}
|
||||
if err := s.sendEmailNotification(toEmail, "⏰ MindSift Test-Alarm", "Hallo "+user.DisplayName+",\n\ndein E-Mail-Alarmkanal in MindSift ist erfolgreich eingerichtet und einsatzbereit.\n\nViele Grüße\nDeine MindSift Instanz"); err != nil {
|
||||
writeError(w, http.StatusBadGateway, "E-Mail-Versand fehlgeschlagen: "+err.Error())
|
||||
return
|
||||
}
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, "Unbekannter Alarm-Kanal (erlaubt: email, discord, webhook)")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"sent": true,
|
||||
"channel": channel,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleGetUsersDirectory(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
users, err := s.store.GetDiscoverableUsers(userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, users)
|
||||
}
|
||||
|
||||
// --- TASK LISTS ---
|
||||
|
||||
func (s *Server) handleGetTaskLists(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -1107,6 +1318,15 @@ func (s *Server) handleCreateOrganization(w http.ResponseWriter, r *http.Request
|
||||
writeJSON(w, http.StatusCreated, org)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteOrganization(w http.ResponseWriter, r *http.Request) {
|
||||
orgID := chi.URLParam(r, "id")
|
||||
if err := s.store.DeleteOrganization(orgID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleGetOrgTeams(w http.ResponseWriter, r *http.Request) {
|
||||
orgID := chi.URLParam(r, "id")
|
||||
teams, err := s.store.GetTeamsForOrg(orgID)
|
||||
@@ -1118,6 +1338,7 @@ func (s *Server) handleGetOrgTeams(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateOrgTeam(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
orgID := chi.URLParam(r, "id")
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
@@ -1132,9 +1353,21 @@ func (s *Server) handleCreateOrgTeam(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
// Automatically add creator to team
|
||||
_ = s.store.AddTeamMember(team.ID, userID, "team_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
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleGetUserTeams(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
teams, err := s.store.GetTeamsForUser(userID)
|
||||
@@ -1149,17 +1382,32 @@ func (s *Server) handleAddTeamMember(w http.ResponseWriter, r *http.Request) {
|
||||
teamID := 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
|
||||
}
|
||||
if err := s.store.AddTeamMember(teamID, req.UserID, req.Role); err != nil {
|
||||
targetUserID := strings.TrimSpace(req.UserID)
|
||||
if targetUserID == "" && strings.TrimSpace(req.Email) != "" {
|
||||
u, err := s.store.GetUserByEmail(req.Email)
|
||||
if err != nil || u == nil {
|
||||
writeError(w, http.StatusNotFound, "Kein Benutzer mit dieser E-Mail gefunden")
|
||||
return
|
||||
}
|
||||
targetUserID = u.ID
|
||||
}
|
||||
if targetUserID == "" {
|
||||
writeError(w, http.StatusBadRequest, "Benutzer-ID oder E-Mail erforderlich")
|
||||
return
|
||||
}
|
||||
if err := s.store.AddTeamMember(teamID, targetUserID, req.Role); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"added": true})
|
||||
members, _ := s.store.GetTeamMembers(teamID)
|
||||
writeJSON(w, http.StatusOK, members)
|
||||
}
|
||||
|
||||
func (s *Server) handleRemoveTeamMember(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -1172,7 +1420,7 @@ func (s *Server) handleRemoveTeamMember(w http.ResponseWriter, r *http.Request)
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"removed": true})
|
||||
}
|
||||
|
||||
// --- ADMIN PANEL ---
|
||||
// --- ADMIN & STAFF PANEL ---
|
||||
|
||||
func (s *Server) handleAdminGetUsers(w http.ResponseWriter, r *http.Request) {
|
||||
users, err := s.store.GetAllUsers()
|
||||
@@ -1184,6 +1432,9 @@ func (s *Server) handleAdminGetUsers(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
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"`
|
||||
@@ -1198,6 +1449,14 @@ func (s *Server) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "E-Mail und Passwort erforderlich")
|
||||
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")
|
||||
@@ -1212,7 +1471,21 @@ func (s *Server) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
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"`
|
||||
@@ -1224,6 +1497,11 @@ func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
}
|
||||
|
||||
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())
|
||||
@@ -1233,7 +1511,15 @@ func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
@@ -1260,6 +1546,12 @@ func (s *Server) handleAdminDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
@@ -1267,12 +1559,60 @@ func (s *Server) handleAdminDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
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"`
|
||||
TeamID string `json:"teamId"`
|
||||
ValidDays int `json:"validDays"`
|
||||
}
|
||||
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.TeamID, req.ValidDays)
|
||||
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
|
||||
}
|
||||
settings.SMTPConfiguredViaEnv = strings.TrimSpace(s.cfg.SMTPHost) != ""
|
||||
writeJSON(w, http.StatusOK, settings)
|
||||
}
|
||||
|
||||
@@ -1286,17 +1626,180 @@ func (s *Server) handleAdminUpdateSettings(w http.ResponseWriter, r *http.Reques
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
settings.SMTPConfiguredViaEnv = strings.TrimSpace(s.cfg.SMTPHost) != ""
|
||||
writeJSON(w, http.StatusOK, settings)
|
||||
}
|
||||
|
||||
// --- DISCORD ALARMS BACKGROUND SCHEDULER ---
|
||||
func (s *Server) handleAdminTestSMTP(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
user, _ := s.store.GetUserByID(userID)
|
||||
|
||||
func (s *Server) startDiscordAlarmTicker() {
|
||||
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.sendEmailNotification(toEmail, "MindSift SMTP-Server Test", "Die SMTP-Konfiguration deiner MindSift-Instanz funktioniert einwandfrei!"); err != nil {
|
||||
writeError(w, http.StatusBadGateway, "SMTP-Fehler: "+err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"sent": true,
|
||||
"toEmail": toEmail,
|
||||
})
|
||||
}
|
||||
|
||||
// --- MULTI-CHANNEL ALARM DISPATCHER (EMAIL, DISCORD, CUSTOM WEBHOOK) ---
|
||||
|
||||
func (s *Server) sendEmailNotification(toEmail, subject, bodyText string) error {
|
||||
sys, _ := s.store.GetSystemSettings()
|
||||
host := ""
|
||||
port := "587"
|
||||
user := ""
|
||||
pass := ""
|
||||
from := ""
|
||||
useTLS := true
|
||||
|
||||
if sys != nil && strings.TrimSpace(sys.SMTPHost) != "" {
|
||||
host = strings.TrimSpace(sys.SMTPHost)
|
||||
port = strings.TrimSpace(sys.SMTPPort)
|
||||
user = strings.TrimSpace(sys.SMTPUser)
|
||||
pass = sys.SMTPPass
|
||||
from = strings.TrimSpace(sys.SMTPFrom)
|
||||
useTLS = sys.SMTPTLS
|
||||
} else if strings.TrimSpace(s.cfg.SMTPHost) != "" {
|
||||
host = strings.TrimSpace(s.cfg.SMTPHost)
|
||||
port = strings.TrimSpace(s.cfg.SMTPPort)
|
||||
user = strings.TrimSpace(s.cfg.SMTPUser)
|
||||
pass = s.cfg.SMTPPass
|
||||
from = strings.TrimSpace(s.cfg.SMTPFrom)
|
||||
useTLS = s.cfg.SMTPTLS
|
||||
}
|
||||
|
||||
if host == "" {
|
||||
return fmt.Errorf("kein SMTP-Server konfiguriert (bitte in den Administratoreinstellungen oder per Docker-Env hinterlegen)")
|
||||
}
|
||||
if port == "" {
|
||||
port = "587"
|
||||
}
|
||||
if from == "" {
|
||||
if user != "" && strings.Contains(user, "@") {
|
||||
from = user
|
||||
} else {
|
||||
from = "noreply@mindsift.local"
|
||||
}
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf("%s:%s", host, port)
|
||||
msg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=\"utf-8\"\r\n\r\n%s\r\n", from, toEmail, subject, bodyText)
|
||||
|
||||
var smtpAuth smtp.Auth
|
||||
if user != "" || pass != "" {
|
||||
smtpAuth = smtp.PlainAuth("", user, pass, host)
|
||||
}
|
||||
|
||||
if port == "465" && useTLS {
|
||||
tlsCfg := &tls.Config{ServerName: host}
|
||||
conn, err := tls.Dial("tcp", addr, tlsCfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
client, err := smtp.NewClient(conn, host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Quit()
|
||||
if smtpAuth != nil {
|
||||
if err := client.Auth(smtpAuth); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := client.Mail(from); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := client.Rcpt(toEmail); err != nil {
|
||||
return err
|
||||
}
|
||||
wc, err := client.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = wc.Write([]byte(msg))
|
||||
_ = wc.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
return smtp.SendMail(addr, smtpAuth, from, []string{toEmail}, []byte(msg))
|
||||
}
|
||||
|
||||
func (s *Server) sendDiscordNotification(webhookURL, title, description, dueDate, priority string) error {
|
||||
httpClient := &http.Client{Timeout: 10 * time.Second}
|
||||
payload := map[string]interface{}{
|
||||
"content": "⏰ **MindSift Aufgaben-Alarm**",
|
||||
"embeds": []map[string]interface{}{
|
||||
{
|
||||
"title": title,
|
||||
"description": fmt.Sprintf("%s\n\n**Fällig:** %s | **Priorität:** %s", description, dueDate, priority),
|
||||
"color": 2450411,
|
||||
"footer": map[string]string{
|
||||
"text": "MindSift Alarm-System",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := httpClient.Post(webhookURL, "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("HTTP Status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) sendCustomWebhookNotification(webhookURL, event, title, notes, dueDate, priority string) error {
|
||||
httpClient := &http.Client{Timeout: 10 * time.Second}
|
||||
payload := map[string]interface{}{
|
||||
"event": event,
|
||||
"title": title,
|
||||
"notes": notes,
|
||||
"dueDate": dueDate,
|
||||
"priority": priority,
|
||||
"timestamp": domain.NowISO(),
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := httpClient.Post(webhookURL, "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("HTTP Status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) startAlarmTicker() {
|
||||
ticker := time.NewTicker(60 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
httpClient := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
for range ticker.C {
|
||||
alarms, err := s.store.GetDueTasksForAlarm()
|
||||
if err != nil || len(alarms) == 0 {
|
||||
@@ -1306,29 +1809,47 @@ func (s *Server) startDiscordAlarmTicker() {
|
||||
today := time.Now().Format("2006-01-02")
|
||||
|
||||
for _, a := range alarms {
|
||||
if a.DueDate <= today && a.DiscordWebhookURL != "" {
|
||||
payload := map[string]interface{}{
|
||||
"content": fmt.Sprintf("⏰ **MindSift Aufgaben-Erinnerung**"),
|
||||
"embeds": []map[string]interface{}{
|
||||
{
|
||||
"title": a.Title,
|
||||
"description": fmt.Sprintf("Diese Aufgabe ist fällig am **%s** (Priorität: %s)", a.DueDate, a.Priority),
|
||||
"color": 3066993,
|
||||
"footer": map[string]string{
|
||||
"text": "MindSift Reminder",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
body, mErr := json.Marshal(payload)
|
||||
if mErr == nil {
|
||||
resp, pErr := httpClient.Post(a.DiscordWebhookURL, "application/json", bytes.NewReader(body))
|
||||
if pErr == nil {
|
||||
_ = resp.Body.Close()
|
||||
_ = s.store.MarkTaskAlarmSent(a.TaskID)
|
||||
if a.DueDate > today {
|
||||
continue
|
||||
}
|
||||
if len(a.AlarmChannels) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
sentAny := false
|
||||
desc := a.Notes
|
||||
if desc == "" {
|
||||
desc = "Aufgabe ist jetzt fällig."
|
||||
}
|
||||
|
||||
for _, ch := range a.AlarmChannels {
|
||||
switch ch {
|
||||
case "discord":
|
||||
if a.DiscordWebhookURL != "" {
|
||||
if err := s.sendDiscordNotification(a.DiscordWebhookURL, a.Title, desc, a.DueDate, a.Priority); err == nil {
|
||||
sentAny = true
|
||||
}
|
||||
}
|
||||
case "webhook":
|
||||
if a.CustomWebhookURL != "" {
|
||||
if err := s.sendCustomWebhookNotification(a.CustomWebhookURL, "task_due", a.Title, desc, a.DueDate, a.Priority); err == nil {
|
||||
sentAny = true
|
||||
}
|
||||
}
|
||||
case "email":
|
||||
if a.AlarmEmail != "" {
|
||||
emailBody := fmt.Sprintf("Hallo,\n\ndeine Aufgabe in MindSift ist fällig:\n\nAufgabe: %s\nFällig am: %s\nPriorität: %s\nNotizen: %s\n", a.Title, a.DueDate, a.Priority, desc)
|
||||
if err := s.sendEmailNotification(a.AlarmEmail, "⏰ Aufgabe fällig: "+a.Title, emailBody); err == nil {
|
||||
sentAny = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if sentAny {
|
||||
_ = s.store.MarkTaskAlarmSent(a.TaskID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+162
-18
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
@@ -29,14 +30,15 @@ func setupTestServer(t *testing.T) (*Server, *storage.Store, string) {
|
||||
|
||||
user, err := store.EnsureDefaultUser()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to ensure default user: %v", err)
|
||||
t.Fatalf("failed to ensure test user: %v", err)
|
||||
}
|
||||
_ = store.SeedStarterContent(user)
|
||||
|
||||
cfg := &config.Config{
|
||||
Port: "8080",
|
||||
DatabasePath: dbPath,
|
||||
JWTSecret: "test-jwt-secret-key",
|
||||
AllowRegistration: true,
|
||||
AllowRegistration: false,
|
||||
}
|
||||
hub := ws.NewHub()
|
||||
srv := NewServer(cfg, store, hub, nil)
|
||||
@@ -49,6 +51,137 @@ func setupTestServer(t *testing.T) (*Server, *storage.Store, string) {
|
||||
return srv, store, token
|
||||
}
|
||||
|
||||
func TestFirstBootSetupAndInviteRegistrationAndNonNullSlices(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "fresh_mindsift.db")
|
||||
store, err := storage.NewStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create fresh store: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
cfg := &config.Config{
|
||||
Port: "8080",
|
||||
DatabasePath: dbPath,
|
||||
JWTSecret: "test-jwt-secret-key",
|
||||
AllowRegistration: false,
|
||||
}
|
||||
srv := NewServer(cfg, store, ws.NewHub(), nil)
|
||||
router := srv.Router()
|
||||
|
||||
// 1. Fresh DB must report needsSetup = true
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/status", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK from /api/v1/auth/status, got %d", rec.Code)
|
||||
}
|
||||
var statusResp struct {
|
||||
NeedsSetup bool `json:"needsSetup"`
|
||||
AllowRegistration bool `json:"allowRegistration"`
|
||||
InstanceName string `json:"instanceName"`
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &statusResp)
|
||||
if !statusResp.NeedsSetup {
|
||||
t.Fatalf("expected needsSetup=true on fresh database, got false")
|
||||
}
|
||||
|
||||
// 2. Complete First-Boot Setup (without sample data)
|
||||
setupBody := []byte(`{
|
||||
"email": "owner@example.com",
|
||||
"password": "secretpassword",
|
||||
"displayName": "Server Owner",
|
||||
"instanceName": "My MindSift",
|
||||
"allowRegistration": false,
|
||||
"sharingMode": "teams_only",
|
||||
"seedSampleData": false
|
||||
}`)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/setup", bytes.NewReader(setupBody))
|
||||
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/auth/setup, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var setupRes struct {
|
||||
Token string `json:"token"`
|
||||
User domain.User `json:"user"`
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &setupRes)
|
||||
if setupRes.User.Role != "admin" || setupRes.Token == "" {
|
||||
t.Fatalf("expected admin user and token, got %+v", setupRes)
|
||||
}
|
||||
|
||||
// 3. Verify empty endpoints return [] and never null (preventing React crashes)
|
||||
arrayEndpoints := []string{
|
||||
"/api/v1/task-lists",
|
||||
"/api/v1/tasks",
|
||||
"/api/v1/organizations",
|
||||
"/api/v1/teams",
|
||||
"/api/v1/spaces",
|
||||
"/api/v1/lists",
|
||||
}
|
||||
for _, ep := range arrayEndpoints {
|
||||
req = httptest.NewRequest(http.MethodGet, ep, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setupRes.Token)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET %s returned %d", ep, rec.Code)
|
||||
}
|
||||
trimmed := strings.TrimSpace(rec.Body.String())
|
||||
if trimmed == "null" || !strings.HasPrefix(trimmed, "[") {
|
||||
t.Fatalf("GET %s returned %q instead of JSON array []", ep, trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Public registration without invite must be rejected (403)
|
||||
regBody := []byte(`{"email":"member@example.com","password":"password123","displayName":"Member"}`)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(regBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 Forbidden when registration is disabled, got %d", rec.Code)
|
||||
}
|
||||
|
||||
// 5. Create an Invite as Admin and register with that Invite Code
|
||||
invBody := []byte(`{"role":"team_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")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201 Created for invite, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var createdInvite domain.UserInvite
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &createdInvite)
|
||||
if createdInvite.Code == "" {
|
||||
t.Fatalf("expected invite code, got empty")
|
||||
}
|
||||
|
||||
regWithInviteBody, _ := json.Marshal(map[string]string{
|
||||
"email": "invited@example.com",
|
||||
"password": "password123",
|
||||
"displayName": "Invited Team Admin",
|
||||
"inviteCode": createdInvite.Code,
|
||||
})
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(regWithInviteBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201 Created when registering with valid invite code, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var invitedRes struct {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentInitialLoadAndListsNoDeadlock(t *testing.T) {
|
||||
srv, _, token := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
@@ -81,27 +214,31 @@ func TestConcurrentInitialLoadAndListsNoDeadlock(t *testing.T) {
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestStaleTokenFallbackAndQuickDumpToTriageAndDirectTargets(t *testing.T) {
|
||||
srv, store, _ := setupTestServer(t)
|
||||
func TestQuickDumpToTriageAndDirectTargets(t *testing.T) {
|
||||
srv, store, token := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
|
||||
// Generate a stale token from a previous container run (non-existent user ID)
|
||||
staleToken, err := auth.GenerateToken("test-jwt-secret-key", "usr_stale_from_old_container", "admin@mindsift.local", "Sandro")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate stale token: %v", err)
|
||||
// Verify stale token from non-existent user is rejected (401)
|
||||
staleToken, _ := auth.GenerateToken("test-jwt-secret-key", "usr_non_existent", "old@mindsift.local", "Old")
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+staleToken)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 Unauthorized for stale token, got %d", rec.Code)
|
||||
}
|
||||
|
||||
// 1. Capture Quick Dump (default target: inbox) using stale token
|
||||
// 1. Capture Quick Dump (default target: inbox)
|
||||
dumpPayload := map[string]string{
|
||||
"content": "Schneller Quick Dump Testgedanke #test",
|
||||
"priority": "high",
|
||||
"target": "inbox",
|
||||
}
|
||||
body, _ := json.Marshal(dumpPayload)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/inbox", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+staleToken)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/inbox", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusCreated {
|
||||
@@ -125,7 +262,7 @@ func TestStaleTokenFallbackAndQuickDumpToTriageAndDirectTargets(t *testing.T) {
|
||||
}
|
||||
body, _ = json.Marshal(taskPayload)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/inbox", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+staleToken)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
@@ -154,7 +291,7 @@ func TestStaleTokenFallbackAndQuickDumpToTriageAndDirectTargets(t *testing.T) {
|
||||
}
|
||||
body, _ = json.Marshal(listPayload)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/inbox", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+staleToken)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
@@ -175,7 +312,7 @@ func TestStaleTokenFallbackAndQuickDumpToTriageAndDirectTargets(t *testing.T) {
|
||||
}
|
||||
body, _ = json.Marshal(triagePayload)
|
||||
req = httptest.NewRequest(http.MethodPatch, "/api/v1/triage/"+createdDump.ID, bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+staleToken)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
@@ -219,10 +356,13 @@ func TestNewFullFeatures(t *testing.T) {
|
||||
t.Fatalf("expected 200 OK for GET /api/v1/task-lists, got %d", rec.Code)
|
||||
}
|
||||
|
||||
// 2. Test User Settings: Update Discord Webhook & Profile
|
||||
// 2. Test User Settings: Update Multi-Channel Alarms & Profile
|
||||
settingsPayload := map[string]interface{}{
|
||||
"displayName": "Sandro D.",
|
||||
"displayName": "Admin User",
|
||||
"alarmChannels": []string{"email", "discord", "webhook"},
|
||||
"alarmEmail": "alarms@example.com",
|
||||
"discordWebhookUrl": "https://discord.com/api/webhooks/test/123",
|
||||
"customWebhookUrl": "https://ntfy.sh/my-topic",
|
||||
"notifyBeforeMinutes": 15,
|
||||
}
|
||||
body, _ = json.Marshal(settingsPayload)
|
||||
@@ -235,6 +375,11 @@ func TestNewFullFeatures(t *testing.T) {
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK for PATCH /api/v1/auth/settings, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var updatedUser domain.User
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &updatedUser)
|
||||
if len(updatedUser.AlarmChannels) != 3 || updatedUser.AlarmEmail != "alarms@example.com" {
|
||||
t.Fatalf("expected 3 alarm channels and alarmEmail, got %+v", updatedUser)
|
||||
}
|
||||
|
||||
// 3. Test Admin routes (default user is admin)
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/users", nil)
|
||||
@@ -261,7 +406,6 @@ func TestNewFullFeatures(t *testing.T) {
|
||||
"notes": "Detailnotizen für die Aufgabe",
|
||||
"taskListId": createdTaskList.ID,
|
||||
}
|
||||
body, _ = json.Marshal(taskUpdatePayload)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/inbox", bytes.NewReader([]byte(`{"content":"Kanban Task Test","target":"task"}`)))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
Reference in new issue
Block a user