From a95f8a15acda1f3072e3eaa833c519ae9b94d4da Mon Sep 17 00:00:00 2001 From: Linrador <68631622+Linrador@users.noreply.github.com> Date: Thu, 25 Jun 2026 20:08:45 +0200 Subject: [PATCH] updated --- backend/active_directory.go | 1091 +++++++++++ backend/admin_milestone.go | 79 +- backend/admin_milestone_roles.go | 845 +++++++++ backend/external/README.md | 37 +- backend/external/agent_build.bat | 2 +- backend/external/agent_main.go | 208 ++- backend/external/server-folders-agent.json | 16 + backend/go.mod | 7 + backend/go.sum | 8 + backend/operations.go | 311 +++- backend/operations_milestone_setup.go | 1654 +++++++++++++++++ backend/routes.go | 19 + backend/runtime_schema.go | 11 + backend/server_folders_proxy.go | 89 +- backend/setup/main.go | 62 + frontend/src/App.tsx | 13 + frontend/src/components/Combobox.tsx | 3 + frontend/src/components/Sidebar.tsx | 7 + frontend/src/components/TreeList.tsx | 16 +- frontend/src/components/types.ts | 1 + .../administration/AdministrationPage.tsx | 11 +- .../ActiveDirectoryAdministration.tsx | 554 ++++++ .../administration/milestone/Milestone.tsx | 125 +- .../src/pages/milestone/MilestonePage.tsx | 8 + .../milestone/rollen/MilestoneRolesPage.tsx | 1181 ++++++++++++ .../pages/operations/CreateOperationModal.tsx | 144 +- .../src/pages/operations/OperationsPage.tsx | 608 +++++- 27 files changed, 7006 insertions(+), 104 deletions(-) create mode 100644 backend/active_directory.go create mode 100644 backend/admin_milestone_roles.go create mode 100644 backend/external/server-folders-agent.json create mode 100644 backend/operations_milestone_setup.go create mode 100644 frontend/src/pages/administration/active-directory/ActiveDirectoryAdministration.tsx create mode 100644 frontend/src/pages/milestone/rollen/MilestoneRolesPage.tsx diff --git a/backend/active_directory.go b/backend/active_directory.go new file mode 100644 index 0000000..1673078 --- /dev/null +++ b/backend/active_directory.go @@ -0,0 +1,1091 @@ +package main + +import ( + "context" + "crypto/tls" + "encoding/binary" + "errors" + "fmt" + "net" + "net/http" + "net/url" + "sort" + "strings" + "time" + + "github.com/go-ldap/ldap/v3" + "github.com/jackc/pgx/v5" +) + +type activeDirectoryCredentials struct { + ServerURL string + BaseDN string + BindUsername string + BindPassword string + UserGroup string + SkipTLSVerify bool +} + +type activeDirectoryUser struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + Username string `json:"username"` + Email string `json:"email"` + Domain string `json:"domain"` + DistinguishedName string `json:"distinguishedName"` + SID string `json:"sid"` + Source string `json:"source"` +} + +type activeDirectorySettingsResponse struct { + ServerURL string `json:"serverUrl"` + BaseDN string `json:"baseDn"` + BindUsername string `json:"bindUsername"` + BindPasswordConfigured bool `json:"bindPasswordConfigured"` + UserGroup string `json:"userGroup"` + SkipTLSVerify bool `json:"skipTlsVerify"` + Configured bool `json:"configured"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` +} + +type updateActiveDirectorySettingsRequest struct { + ServerURL string `json:"serverUrl"` + BaseDN string `json:"baseDn"` + BindUsername string `json:"bindUsername"` + BindPassword string `json:"bindPassword"` + UserGroup string `json:"userGroup"` + SkipTLSVerify bool `json:"skipTlsVerify"` +} + +type activeDirectoryBaseDNTreeRequest struct { + ServerURL string `json:"serverUrl"` + BindUsername string `json:"bindUsername"` + BindPassword string `json:"bindPassword"` + BaseDN string `json:"baseDn"` + SkipTLSVerify bool `json:"skipTlsVerify"` +} + +type activeDirectoryBaseDNTreeNode struct { + ID string `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + SecondaryName string `json:"secondaryName,omitempty"` + Children []activeDirectoryBaseDNTreeNode `json:"children,omitempty"` + Disabled bool `json:"disabled,omitempty"` +} + +func (s *Server) handleGetActiveDirectorySettings(w http.ResponseWriter, r *http.Request) { + settings, err := s.getActiveDirectorySettings(r.Context()) + if errors.Is(err, pgx.ErrNoRows) { + writeJSON(w, http.StatusOK, map[string]any{ + "settings": defaultActiveDirectorySettingsResponse(), + }) + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, "AD-Einstellungen konnten nicht geladen werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{"settings": settings}) +} + +func (s *Server) handleUpdateActiveDirectorySettings(w http.ResponseWriter, r *http.Request) { + var input updateActiveDirectorySettingsRequest + if err := readJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "Invalid JSON") + return + } + + serverURL := normalizeActiveDirectoryServerURL(input.ServerURL) + baseDN := strings.TrimSpace(input.BaseDN) + bindUsername := strings.TrimSpace(input.BindUsername) + bindPassword := strings.TrimSpace(input.BindPassword) + userGroup := strings.TrimSpace(input.UserGroup) + skipTLSVerify := input.SkipTLSVerify + + currentSettings, err := s.getActiveDirectorySettings(r.Context()) + passwordAlreadyConfigured := false + if err == nil { + passwordAlreadyConfigured = currentSettings.BindPasswordConfigured + } else if !errors.Is(err, pgx.ErrNoRows) { + writeError(w, http.StatusInternalServerError, "AD-Einstellungen konnten nicht geprueft werden") + return + } + + configured := serverURL != "" || baseDN != "" || bindUsername != "" || bindPassword != "" + if configured { + if serverURL == "" { + writeError(w, http.StatusBadRequest, "AD-Server ist erforderlich") + return + } + if baseDN == "" { + writeError(w, http.StatusBadRequest, "AD-Base-DN ist erforderlich") + return + } + if bindUsername == "" { + writeError(w, http.StatusBadRequest, "AD-Bind-Benutzer ist erforderlich") + return + } + if bindPassword == "" && !passwordAlreadyConfigured { + writeError(w, http.StatusBadRequest, "AD-Bind-Kennwort ist erforderlich") + return + } + } + + _, err = s.db.Exec( + r.Context(), + ` + INSERT INTO milestone_settings ( + id, + active_directory_server_url, + active_directory_base_dn, + active_directory_bind_username, + active_directory_bind_password_encrypted, + active_directory_user_group, + active_directory_skip_tls_verify + ) + VALUES ( + 1, + $1, + $2, + $3, + CASE WHEN $4 <> '' THEN pgp_sym_encrypt($4, $5) ELSE NULL END, + $6, + $7 + ) + ON CONFLICT (id) + DO UPDATE SET + active_directory_server_url = EXCLUDED.active_directory_server_url, + active_directory_base_dn = EXCLUDED.active_directory_base_dn, + active_directory_bind_username = EXCLUDED.active_directory_bind_username, + active_directory_bind_password_encrypted = CASE + WHEN $1 = '' AND $2 = '' AND $3 = '' AND $4 = '' THEN NULL + WHEN $4 <> '' THEN EXCLUDED.active_directory_bind_password_encrypted + ELSE milestone_settings.active_directory_bind_password_encrypted + END, + active_directory_user_group = EXCLUDED.active_directory_user_group, + active_directory_skip_tls_verify = EXCLUDED.active_directory_skip_tls_verify, + updated_at = now() + `, + serverURL, + baseDN, + bindUsername, + bindPassword, + s.milestoneEncryptionKey(), + userGroup, + skipTLSVerify, + ) + if err != nil { + writeError(w, http.StatusInternalServerError, "AD-Einstellungen konnten nicht gespeichert werden") + return + } + + settings, err := s.getActiveDirectorySettings(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "AD-Einstellungen wurden gespeichert, konnten aber nicht neu geladen werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{"settings": settings}) +} + +func (s *Server) handleListActiveDirectoryBaseDNTree(w http.ResponseWriter, r *http.Request) { + var input activeDirectoryBaseDNTreeRequest + if err := readJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "Invalid JSON") + return + } + + credentials, ok := s.activeDirectoryBrowseCredentials(w, r, input) + if !ok { + return + } + + nodes, err := listActiveDirectoryBaseDNTree(credentials) + if err != nil { + logError("AD-Base-DN-Struktur konnte nicht geladen werden", err, LogFields{ + "serverUrl": credentials.ServerURL, + }) + writeError(w, http.StatusBadGateway, "AD-Base-DN-Struktur konnte nicht geladen werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "nodes": nodes, + }) +} + +func (s *Server) handleListActiveDirectoryGroupTree(w http.ResponseWriter, r *http.Request) { + var input activeDirectoryBaseDNTreeRequest + if err := readJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "Invalid JSON") + return + } + + credentials, ok := s.activeDirectoryBrowseCredentials(w, r, input) + if !ok { + return + } + + nodes, err := listActiveDirectoryGroupTree(credentials) + if err != nil { + logError("AD-Gruppenstruktur konnte nicht geladen werden", err, LogFields{ + "serverUrl": credentials.ServerURL, + "baseDn": credentials.BaseDN, + }) + writeError(w, http.StatusBadGateway, "AD-Gruppenstruktur konnte nicht geladen werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "nodes": nodes, + }) +} + +func (s *Server) activeDirectoryBrowseCredentials( + w http.ResponseWriter, + r *http.Request, + input activeDirectoryBaseDNTreeRequest, +) (activeDirectoryCredentials, bool) { + credentials := activeDirectoryCredentials{ + ServerURL: normalizeActiveDirectoryServerURL(input.ServerURL), + BaseDN: strings.TrimSpace(input.BaseDN), + BindUsername: strings.TrimSpace(input.BindUsername), + BindPassword: strings.TrimSpace(input.BindPassword), + SkipTLSVerify: input.SkipTLSVerify, + } + + storedCredentials, err := s.getActiveDirectoryCredentials(r.Context()) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + writeError(w, http.StatusInternalServerError, "AD-Einstellungen konnten nicht geladen werden") + return credentials, false + } + + if credentials.ServerURL == "" { + credentials.ServerURL = storedCredentials.ServerURL + } + if credentials.BindUsername == "" { + credentials.BindUsername = storedCredentials.BindUsername + } + if credentials.BindPassword == "" { + credentials.BindPassword = storedCredentials.BindPassword + } + if credentials.BaseDN == "" { + credentials.BaseDN = storedCredentials.BaseDN + } + + if credentials.ServerURL == "" || credentials.BindUsername == "" || credentials.BindPassword == "" { + writeError(w, http.StatusBadRequest, "AD-Server, Bind-Benutzer und Bind-Kennwort sind erforderlich") + return credentials, false + } + + return credentials, true +} + +func (s *Server) handleListOperationActiveDirectoryUsers(w http.ResponseWriter, r *http.Request) { + credentials, err := s.getActiveDirectoryCredentials(r.Context()) + if errors.Is(err, pgx.ErrNoRows) { + writeJSON(w, http.StatusOK, map[string]any{ + "configured": false, + "users": []activeDirectoryUser{}, + }) + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, "AD-Einstellungen konnten nicht geladen werden") + return + } + if !credentials.Configured() { + writeJSON(w, http.StatusOK, map[string]any{ + "configured": false, + "users": []activeDirectoryUser{}, + }) + return + } + + users, err := listActiveDirectoryUsers(r.Context(), credentials) + if err != nil { + logError("AD-Benutzer konnten nicht geladen werden", err, LogFields{ + "baseDn": credentials.BaseDN, + }) + writeError(w, http.StatusBadGateway, "AD-Benutzer konnten nicht geladen werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "configured": true, + "baseDn": credentials.BaseDN, + "users": users, + }) +} + +func defaultActiveDirectorySettingsResponse() activeDirectorySettingsResponse { + settings := activeDirectorySettingsResponse{} + settings.Configured = settings.BindPasswordConfigured && + settings.ServerURL != "" && + settings.BaseDN != "" && + settings.BindUsername != "" + + return settings +} + +func (s *Server) getActiveDirectorySettings(ctx context.Context) (activeDirectorySettingsResponse, error) { + var settings activeDirectorySettingsResponse + var updatedAt time.Time + + err := s.db.QueryRow( + ctx, + ` + SELECT + COALESCE(active_directory_server_url, ''), + COALESCE(active_directory_base_dn, ''), + COALESCE(active_directory_bind_username, ''), + active_directory_bind_password_encrypted IS NOT NULL, + COALESCE(active_directory_user_group, ''), + COALESCE(active_directory_skip_tls_verify, false), + updated_at + FROM milestone_settings + WHERE id = 1 + LIMIT 1 + `, + ).Scan( + &settings.ServerURL, + &settings.BaseDN, + &settings.BindUsername, + &settings.BindPasswordConfigured, + &settings.UserGroup, + &settings.SkipTLSVerify, + &updatedAt, + ) + if err != nil { + return settings, err + } + + settings.UpdatedAt = &updatedAt + settings.Configured = settings.BindPasswordConfigured && + settings.ServerURL != "" && + settings.BaseDN != "" && + settings.BindUsername != "" + + return settings, nil +} + +func (c activeDirectoryCredentials) Configured() bool { + return strings.TrimSpace(c.ServerURL) != "" && + strings.TrimSpace(c.BaseDN) != "" && + strings.TrimSpace(c.BindUsername) != "" && + strings.TrimSpace(c.BindPassword) != "" +} + +func (s *Server) getActiveDirectoryCredentials(ctx context.Context) (activeDirectoryCredentials, error) { + var credentials activeDirectoryCredentials + + err := s.db.QueryRow( + ctx, + ` + SELECT + COALESCE(active_directory_server_url, ''), + COALESCE(active_directory_base_dn, ''), + COALESCE(active_directory_bind_username, ''), + COALESCE(pgp_sym_decrypt(active_directory_bind_password_encrypted, $1), ''), + COALESCE(active_directory_user_group, ''), + COALESCE(active_directory_skip_tls_verify, false) + FROM milestone_settings + WHERE id = 1 + LIMIT 1 + `, + s.milestoneEncryptionKey(), + ).Scan( + &credentials.ServerURL, + &credentials.BaseDN, + &credentials.BindUsername, + &credentials.BindPassword, + &credentials.UserGroup, + &credentials.SkipTLSVerify, + ) + + credentials.ServerURL = normalizeActiveDirectoryServerURL(credentials.ServerURL) + credentials.BaseDN = strings.TrimSpace(credentials.BaseDN) + credentials.BindUsername = strings.TrimSpace(credentials.BindUsername) + credentials.BindPassword = strings.TrimSpace(credentials.BindPassword) + credentials.UserGroup = strings.TrimSpace(credentials.UserGroup) + + return credentials, err +} + +func listActiveDirectoryUsers( + ctx context.Context, + credentials activeDirectoryCredentials, +) ([]activeDirectoryUser, error) { + conn, err := dialAndBindActiveDirectory(credentials) + if err != nil { + return nil, err + } + defer conn.Close() + + searchRequest := ldap.NewSearchRequest( + credentials.BaseDN, + ldap.ScopeWholeSubtree, + ldap.NeverDerefAliases, + 0, + 20, + false, + "(&"+ + "(objectCategory=person)"+ + "(objectClass=user)"+ + "(!(userAccountControl:1.2.840.113556.1.4.803:=2))"+ + ")", + []string{ + "displayName", + "sAMAccountName", + "userPrincipalName", + "mail", + "distinguishedName", + "objectSid", + "cn", + }, + nil, + ) + + searchResult, err := conn.Search(searchRequest) + if err != nil { + return nil, err + } + + users := make([]activeDirectoryUser, 0, len(searchResult.Entries)) + seen := map[string]bool{} + for _, entry := range searchResult.Entries { + user := activeDirectoryUserFromLDAPEntry(entry) + if user.ID == "" { + continue + } + if seen[user.ID] { + continue + } + + seen[user.ID] = true + users = append(users, user) + } + + sort.SliceStable(users, func(i, j int) bool { + return strings.ToLower(users[i].DisplayName) < strings.ToLower(users[j].DisplayName) + }) + + return users, nil +} + +func dialAndBindActiveDirectory(credentials activeDirectoryCredentials) (*ldap.Conn, error) { + conn, err := ldap.DialURL( + credentials.ServerURL, + ldap.DialWithTLSConfig(&tls.Config{InsecureSkipVerify: credentials.SkipTLSVerify}), + ldap.DialWithDialer(&net.Dialer{Timeout: 15 * time.Second}), + ) + if err != nil { + return nil, err + } + + if err := conn.Bind(credentials.BindUsername, credentials.BindPassword); err != nil { + conn.Close() + return nil, err + } + + return conn, nil +} + +func listActiveDirectoryBaseDNTree( + credentials activeDirectoryCredentials, +) ([]activeDirectoryBaseDNTreeNode, error) { + conn, err := dialAndBindActiveDirectory(credentials) + if err != nil { + return nil, err + } + defer conn.Close() + + namingContexts, err := listActiveDirectoryNamingContexts(conn, credentials.BaseDN) + if err != nil { + return nil, err + } + + const maxNodes = 500 + const maxDepth = 3 + remainingNodes := maxNodes + nodes := make([]activeDirectoryBaseDNTreeNode, 0, len(namingContexts)) + for _, namingContext := range namingContexts { + node := activeDirectoryBaseDNTreeNodeFromDN(namingContext) + if remainingNodes > 0 { + children, err := listActiveDirectoryBaseDNChildren( + conn, + namingContext, + 0, + maxDepth, + &remainingNodes, + ) + if err == nil { + node.Children = children + } else { + logError("AD-Base-DN-Unterstruktur konnte nicht geladen werden", err, LogFields{ + "baseDn": namingContext, + }) + } + } + + nodes = append(nodes, node) + } + + return nodes, nil +} + +func listActiveDirectoryGroupTree( + credentials activeDirectoryCredentials, +) ([]activeDirectoryBaseDNTreeNode, error) { + conn, err := dialAndBindActiveDirectory(credentials) + if err != nil { + return nil, err + } + defer conn.Close() + + roots := []string{} + if strings.TrimSpace(credentials.BaseDN) != "" { + roots = append(roots, strings.TrimSpace(credentials.BaseDN)) + } else { + roots, err = listActiveDirectoryNamingContexts(conn, "") + if err != nil { + return nil, err + } + } + + const maxNodes = 1000 + const maxDepth = 5 + remainingNodes := maxNodes + nodes := make([]activeDirectoryBaseDNTreeNode, 0, len(roots)) + for _, root := range roots { + node := activeDirectoryBaseDNTreeNodeFromDN(root) + node.Disabled = true + if remainingNodes > 0 { + children, err := listActiveDirectoryGroupTreeChildren( + conn, + root, + 0, + maxDepth, + &remainingNodes, + ) + if err == nil { + node.Children = children + } else { + logError("AD-Gruppen-Unterstruktur konnte nicht geladen werden", err, LogFields{ + "baseDn": root, + }) + } + } + + nodes = append(nodes, node) + } + + return nodes, nil +} + +func listActiveDirectoryNamingContexts(conn *ldap.Conn, fallbackBaseDN string) ([]string, error) { + searchRequest := ldap.NewSearchRequest( + "", + ldap.ScopeBaseObject, + ldap.NeverDerefAliases, + 0, + 10, + false, + "(objectClass=*)", + []string{"defaultNamingContext", "namingContexts"}, + nil, + ) + + searchResult, err := conn.Search(searchRequest) + if err != nil { + if strings.TrimSpace(fallbackBaseDN) != "" { + return []string{strings.TrimSpace(fallbackBaseDN)}, nil + } + + return nil, err + } + + seen := map[string]bool{} + namingContexts := []string{} + addNamingContext := func(value string) { + value = strings.TrimSpace(value) + if value == "" || seen[strings.ToLower(value)] { + return + } + + seen[strings.ToLower(value)] = true + namingContexts = append(namingContexts, value) + } + + if len(searchResult.Entries) > 0 { + entry := searchResult.Entries[0] + addNamingContext(entry.GetAttributeValue("defaultNamingContext")) + for _, namingContext := range entry.GetAttributeValues("namingContexts") { + addNamingContext(namingContext) + } + } + + addNamingContext(fallbackBaseDN) + + if len(namingContexts) == 0 { + return nil, fmt.Errorf("keine AD-Naming-Contexts gefunden") + } + + return namingContexts, nil +} + +func listActiveDirectoryBaseDNChildren( + conn *ldap.Conn, + parentDN string, + depth int, + maxDepth int, + remainingNodes *int, +) ([]activeDirectoryBaseDNTreeNode, error) { + if depth >= maxDepth || *remainingNodes <= 0 { + return nil, nil + } + + searchRequest := ldap.NewSearchRequest( + parentDN, + ldap.ScopeSingleLevel, + ldap.NeverDerefAliases, + 0, + 10, + false, + "(|(objectClass=domainDNS)(objectClass=organizationalUnit)(objectClass=container))", + []string{ + "cn", + "distinguishedName", + "name", + "objectClass", + "ou", + }, + nil, + ) + + searchResult, err := conn.Search(searchRequest) + if err != nil { + return nil, err + } + + nodes := make([]activeDirectoryBaseDNTreeNode, 0, len(searchResult.Entries)) + for _, entry := range searchResult.Entries { + if *remainingNodes <= 0 { + break + } + + distinguishedName := strings.TrimSpace(entry.GetAttributeValue("distinguishedName")) + if distinguishedName == "" { + distinguishedName = strings.TrimSpace(entry.DN) + } + if distinguishedName == "" { + continue + } + + *remainingNodes = *remainingNodes - 1 + node := activeDirectoryBaseDNTreeNodeFromEntry(entry, distinguishedName) + children, err := listActiveDirectoryBaseDNChildren( + conn, + distinguishedName, + depth+1, + maxDepth, + remainingNodes, + ) + if err == nil { + node.Children = children + } else { + logError("AD-Base-DN-Unterstruktur konnte nicht geladen werden", err, LogFields{ + "baseDn": distinguishedName, + }) + } + + nodes = append(nodes, node) + } + + sort.SliceStable(nodes, func(i, j int) bool { + return strings.ToLower(nodes[i].Name) < strings.ToLower(nodes[j].Name) + }) + + return nodes, nil +} + +func listActiveDirectoryGroupTreeChildren( + conn *ldap.Conn, + parentDN string, + depth int, + maxDepth int, + remainingNodes *int, +) ([]activeDirectoryBaseDNTreeNode, error) { + if depth >= maxDepth || *remainingNodes <= 0 { + return nil, nil + } + + searchRequest := ldap.NewSearchRequest( + parentDN, + ldap.ScopeSingleLevel, + ldap.NeverDerefAliases, + 0, + 15, + false, + "(|(objectClass=domainDNS)(objectClass=organizationalUnit)(objectClass=container)(objectClass=group))", + []string{ + "cn", + "distinguishedName", + "name", + "objectClass", + "ou", + "sAMAccountName", + }, + nil, + ) + + searchResult, err := conn.Search(searchRequest) + if err != nil { + return nil, err + } + + nodes := make([]activeDirectoryBaseDNTreeNode, 0, len(searchResult.Entries)) + for _, entry := range searchResult.Entries { + if *remainingNodes <= 0 { + break + } + + distinguishedName := strings.TrimSpace(entry.GetAttributeValue("distinguishedName")) + if distinguishedName == "" { + distinguishedName = strings.TrimSpace(entry.DN) + } + if distinguishedName == "" { + continue + } + + *remainingNodes = *remainingNodes - 1 + isGroup := activeDirectoryEntryHasObjectClass(entry, "group") + node := activeDirectoryBaseDNTreeNodeFromEntry(entry, distinguishedName) + node.Disabled = !isGroup + if isGroup { + samAccountName := strings.TrimSpace(entry.GetAttributeValue("sAMAccountName")) + if samAccountName != "" { + node.SecondaryName = samAccountName + } + } + + if !isGroup { + children, err := listActiveDirectoryGroupTreeChildren( + conn, + distinguishedName, + depth+1, + maxDepth, + remainingNodes, + ) + if err == nil { + node.Children = children + } else { + logError("AD-Gruppen-Unterstruktur konnte nicht geladen werden", err, LogFields{ + "baseDn": distinguishedName, + }) + } + } + + if isGroup || len(node.Children) > 0 { + nodes = append(nodes, node) + } + } + + sort.SliceStable(nodes, func(i, j int) bool { + if nodes[i].Disabled != nodes[j].Disabled { + return !nodes[i].Disabled + } + + return strings.ToLower(nodes[i].Name) < strings.ToLower(nodes[j].Name) + }) + + return nodes, nil +} + +func activeDirectoryEntryHasObjectClass(entry *ldap.Entry, objectClass string) bool { + for _, value := range entry.GetAttributeValues("objectClass") { + if strings.EqualFold(value, objectClass) { + return true + } + } + + return false +} + +func activeDirectoryBaseDNTreeNodeFromEntry( + entry *ldap.Entry, + distinguishedName string, +) activeDirectoryBaseDNTreeNode { + name := strings.TrimSpace(entry.GetAttributeValue("ou")) + if name == "" { + name = strings.TrimSpace(entry.GetAttributeValue("name")) + } + if name == "" { + name = strings.TrimSpace(entry.GetAttributeValue("cn")) + } + if name == "" { + name = activeDirectoryDNDisplayName(distinguishedName) + } + + return activeDirectoryBaseDNTreeNode{ + ID: distinguishedName, + Name: name, + Path: distinguishedName, + SecondaryName: distinguishedName, + } +} + +func activeDirectoryBaseDNTreeNodeFromDN(distinguishedName string) activeDirectoryBaseDNTreeNode { + return activeDirectoryBaseDNTreeNode{ + ID: distinguishedName, + Name: activeDirectoryDNDisplayName(distinguishedName), + Path: distinguishedName, + SecondaryName: distinguishedName, + } +} + +func activeDirectoryDNDisplayName(distinguishedName string) string { + components := splitActiveDirectoryDN(distinguishedName) + if len(components) == 0 { + return distinguishedName + } + + domainParts := make([]string, 0, len(components)) + allDomainParts := true + for _, component := range components { + attribute, value := activeDirectoryDNRDN(component) + if !strings.EqualFold(attribute, "DC") { + allDomainParts = false + break + } + + domainParts = append(domainParts, value) + } + if allDomainParts && len(domainParts) > 0 { + return strings.Join(domainParts, ".") + } + + _, value := activeDirectoryDNRDN(components[0]) + if value != "" { + return value + } + + return distinguishedName +} + +func splitActiveDirectoryDN(distinguishedName string) []string { + components := []string{} + var current strings.Builder + escaped := false + + for _, char := range distinguishedName { + if escaped { + current.WriteRune(char) + escaped = false + continue + } + + if char == '\\' { + escaped = true + current.WriteRune(char) + continue + } + + if char == ',' { + component := strings.TrimSpace(current.String()) + if component != "" { + components = append(components, component) + } + current.Reset() + continue + } + + current.WriteRune(char) + } + + component := strings.TrimSpace(current.String()) + if component != "" { + components = append(components, component) + } + + return components +} + +func activeDirectoryDNRDN(component string) (string, string) { + parts := strings.SplitN(component, "=", 2) + if len(parts) != 2 { + return "", strings.TrimSpace(component) + } + + return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]) +} + +func findActiveDirectoryGroupDN( + conn *ldap.Conn, + credentials activeDirectoryCredentials, +) (string, error) { + group := strings.TrimSpace(credentials.UserGroup) + if strings.Contains(group, "=") && strings.Contains(group, ",") { + return group, nil + } + + escapedGroup := ldap.EscapeFilter(group) + groupFilter := fmt.Sprintf( + "(&(objectClass=group)(|(cn=%s)(name=%s)(sAMAccountName=%s)))", + escapedGroup, + escapedGroup, + escapedGroup, + ) + searchRequest := ldap.NewSearchRequest( + credentials.BaseDN, + ldap.ScopeWholeSubtree, + ldap.NeverDerefAliases, + 1, + 10, + false, + groupFilter, + []string{"distinguishedName", "cn", "name", "sAMAccountName"}, + nil, + ) + + searchResult, err := conn.Search(searchRequest) + if err != nil { + return "", err + } + if len(searchResult.Entries) == 0 { + return "", fmt.Errorf("AD-Gruppe %q wurde nicht gefunden", group) + } + + return searchResult.Entries[0].DN, nil +} + +func activeDirectoryUserFromLDAPEntry(entry *ldap.Entry) activeDirectoryUser { + displayName := strings.TrimSpace(entry.GetAttributeValue("displayName")) + username := strings.TrimSpace(entry.GetAttributeValue("sAMAccountName")) + email := strings.TrimSpace(entry.GetAttributeValue("mail")) + userPrincipalName := strings.TrimSpace(entry.GetAttributeValue("userPrincipalName")) + distinguishedName := strings.TrimSpace(entry.GetAttributeValue("distinguishedName")) + if distinguishedName == "" { + distinguishedName = strings.TrimSpace(entry.DN) + } + if email == "" { + email = userPrincipalName + } + if displayName == "" { + displayName = strings.TrimSpace(entry.GetAttributeValue("cn")) + } + if displayName == "" { + displayName = username + } + if displayName == "" { + displayName = email + } + + sid := activeDirectorySIDString(entry.GetRawAttributeValue("objectSid")) + id := sid + if id == "" { + id = distinguishedName + } + domain := activeDirectoryDomainFromPrincipal(userPrincipalName) + if domain == "" { + domain = activeDirectoryDomainFromDN(distinguishedName) + } + + return activeDirectoryUser{ + ID: id, + DisplayName: displayName, + Username: username, + Email: email, + Domain: domain, + DistinguishedName: distinguishedName, + SID: sid, + Source: "ad", + } +} + +func activeDirectoryDomainFromPrincipal(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + + _, domain, ok := strings.Cut(value, "@") + if !ok { + return "" + } + + return strings.TrimSpace(domain) +} + +func activeDirectoryDomainFromDN(distinguishedName string) string { + components := splitActiveDirectoryDN(distinguishedName) + if len(components) == 0 { + return "" + } + + domainParts := make([]string, 0, len(components)) + for _, component := range components { + attribute, value := activeDirectoryDNRDN(component) + if strings.EqualFold(attribute, "DC") && value != "" { + domainParts = append(domainParts, value) + } + } + + return strings.Join(domainParts, ".") +} + +func activeDirectorySIDString(rawSID []byte) string { + if len(rawSID) < 8 { + return "" + } + + revision := rawSID[0] + subAuthorityCount := int(rawSID[1]) + expectedLength := 8 + subAuthorityCount*4 + if len(rawSID) < expectedLength { + return "" + } + + var identifierAuthority uint64 + for _, value := range rawSID[2:8] { + identifierAuthority = (identifierAuthority << 8) | uint64(value) + } + + parts := []string{ + "S", + fmt.Sprintf("%d", revision), + fmt.Sprintf("%d", identifierAuthority), + } + for index := 0; index < subAuthorityCount; index++ { + offset := 8 + index*4 + parts = append(parts, fmt.Sprintf("%d", binary.LittleEndian.Uint32(rawSID[offset:offset+4]))) + } + + return strings.Join(parts, "-") +} + +func normalizeActiveDirectoryServerURL(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + if !strings.Contains(value, "://") { + value = "ldaps://" + value + } + + parsedURL, err := url.Parse(value) + if err != nil || parsedURL.Hostname() == "" { + return value + } + if parsedURL.Port() == "" { + if parsedURL.Scheme == "ldap" { + parsedURL.Host = parsedURL.Hostname() + ":389" + } else if parsedURL.Scheme == "ldaps" { + parsedURL.Host = parsedURL.Hostname() + ":636" + } + } + + return parsedURL.String() +} diff --git a/backend/admin_milestone.go b/backend/admin_milestone.go index 1f1de27..1b6fb2b 100644 --- a/backend/admin_milestone.go +++ b/backend/admin_milestone.go @@ -38,6 +38,10 @@ type milestoneSettingsResponse struct { ServerFoldersAgentScheme string `json:"serverFoldersAgentScheme"` ServerFoldersAgentPort string `json:"serverFoldersAgentPort"` ServerFoldersAgentTokenConfigured bool `json:"serverFoldersAgentTokenConfigured"` + ServerFoldersStorageRootLabel string `json:"serverFoldersStorageRootLabel"` + ServerFoldersStorageRootPath string `json:"serverFoldersStorageRootPath"` + ServerFoldersArchiveRootLabel string `json:"serverFoldersArchiveRootLabel"` + ServerFoldersArchiveRootPath string `json:"serverFoldersArchiveRootPath"` UpdatedAt *time.Time `json:"updatedAt,omitempty"` } @@ -48,8 +52,12 @@ type updateMilestoneSettingsRequest struct { Password string `json:"password"` SkipTLSVerify bool `json:"skipTlsVerify"` - ServerFoldersAgentScheme string `json:"serverFoldersAgentScheme"` - ServerFoldersAgentPort string `json:"serverFoldersAgentPort"` + ServerFoldersAgentScheme string `json:"serverFoldersAgentScheme"` + ServerFoldersAgentPort string `json:"serverFoldersAgentPort"` + ServerFoldersStorageRootLabel string `json:"serverFoldersStorageRootLabel"` + ServerFoldersStorageRootPath string `json:"serverFoldersStorageRootPath"` + ServerFoldersArchiveRootLabel string `json:"serverFoldersArchiveRootLabel"` + ServerFoldersArchiveRootPath string `json:"serverFoldersArchiveRootPath"` } type milestoneCredentials struct { @@ -78,6 +86,10 @@ func (s *Server) handleGetMilestoneSettings(w http.ResponseWriter, r *http.Reque ServerFoldersAgentScheme: "http", ServerFoldersAgentPort: "8099", ServerFoldersAgentTokenConfigured: false, + ServerFoldersStorageRootLabel: "Storage D", + ServerFoldersStorageRootPath: "D:/Milestone-Speicher", + ServerFoldersArchiveRootLabel: "Archive E", + ServerFoldersArchiveRootPath: "E:/", }, }) return @@ -109,6 +121,10 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re input.ServerFoldersAgentScheme, input.ServerFoldersAgentPort, ) + serverFoldersStorageRootLabel := normalizeServerFoldersRootLabel(input.ServerFoldersStorageRootLabel, "Storage D") + serverFoldersStorageRootPath := normalizeServerFoldersRootPath(input.ServerFoldersStorageRootPath, "D:/Milestone-Speicher") + serverFoldersArchiveRootLabel := normalizeServerFoldersRootLabel(input.ServerFoldersArchiveRootLabel, "Archive E") + serverFoldersArchiveRootPath := normalizeServerFoldersRootPath(input.ServerFoldersArchiveRootPath, "E:/") if host == "" { writeError(w, http.StatusBadRequest, "IP-Adresse oder Hostname ist erforderlich") @@ -153,6 +169,10 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re token_updated_at, server_folders_agent_scheme, server_folders_agent_port, + server_folders_storage_root_label, + server_folders_storage_root_path, + server_folders_archive_root_label, + server_folders_archive_root_path, server_folders_agent_token_encrypted ) VALUES ( @@ -167,6 +187,10 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re NULL, $6, $7, + $8, + $9, + $10, + $11, NULL ) ON CONFLICT (id) @@ -181,6 +205,10 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re token_updated_at = NULL, server_folders_agent_scheme = EXCLUDED.server_folders_agent_scheme, server_folders_agent_port = EXCLUDED.server_folders_agent_port, + server_folders_storage_root_label = EXCLUDED.server_folders_storage_root_label, + server_folders_storage_root_path = EXCLUDED.server_folders_storage_root_path, + server_folders_archive_root_label = EXCLUDED.server_folders_archive_root_label, + server_folders_archive_root_path = EXCLUDED.server_folders_archive_root_path, updated_at = now() `, host, @@ -190,6 +218,10 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re skipTLSVerify, serverFoldersAgentScheme, serverFoldersAgentPort, + serverFoldersStorageRootLabel, + serverFoldersStorageRootPath, + serverFoldersArchiveRootLabel, + serverFoldersArchiveRootPath, ) } else { _, err = s.db.Exec( @@ -206,6 +238,10 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re token_updated_at = NULL, server_folders_agent_scheme = $4, server_folders_agent_port = $5, + server_folders_storage_root_label = $6, + server_folders_storage_root_path = $7, + server_folders_archive_root_label = $8, + server_folders_archive_root_path = $9, updated_at = now() WHERE id = 1 `, @@ -214,6 +250,10 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re skipTLSVerify, serverFoldersAgentScheme, serverFoldersAgentPort, + serverFoldersStorageRootLabel, + serverFoldersStorageRootPath, + serverFoldersArchiveRootLabel, + serverFoldersArchiveRootPath, ) } @@ -298,12 +338,19 @@ func (s *Server) rotateServerFoldersAgentTokenAfterSave(ctx context.Context) str return "Der Server-Folders-Agent-Token konnte nicht ausgetauscht werden." } - if _, err := s.rotateServerFoldersAgentToken(ctx, config); err != nil { + refreshedConfig, err := s.rotateServerFoldersAgentToken(ctx, config) + if err != nil { log.Printf("server-folders agent token rotation failed: %v", err) return "Der Server-Folders-Agent ist nicht erreichbar. Der Token wurde nicht ausgetauscht." } + if err := s.pushServerFoldersAgentConfig(ctx, refreshedConfig); err != nil { + log.Printf("server-folders agent config update failed: %v", err) + + return "Der Server-Folders-Agent-Token wurde gespeichert, aber die Root-Pfade konnten nicht in die Agent-JSON geschrieben werden." + } + return "" } @@ -423,6 +470,10 @@ func (s *Server) getMilestoneSettings(ctx context.Context) (milestoneSettingsRes COALESCE(server_folders_agent_scheme, 'http'), COALESCE(server_folders_agent_port, '8099'), server_folders_agent_token_encrypted IS NOT NULL, + COALESCE(server_folders_storage_root_label, 'Storage D'), + COALESCE(server_folders_storage_root_path, 'D:/Milestone-Speicher'), + COALESCE(server_folders_archive_root_label, 'Archive E'), + COALESCE(server_folders_archive_root_path, 'E:/'), updated_at FROM milestone_settings WHERE id = 1 @@ -444,6 +495,10 @@ func (s *Server) getMilestoneSettings(ctx context.Context) (milestoneSettingsRes &settings.ServerFoldersAgentScheme, &settings.ServerFoldersAgentPort, &settings.ServerFoldersAgentTokenConfigured, + &settings.ServerFoldersStorageRootLabel, + &settings.ServerFoldersStorageRootPath, + &settings.ServerFoldersArchiveRootLabel, + &settings.ServerFoldersArchiveRootPath, &updatedAt, ) @@ -586,6 +641,24 @@ func normalizeServerFoldersAgentSettings(scheme string, port string) (string, st return scheme, port } +func normalizeServerFoldersRootLabel(value string, fallback string) string { + value = strings.TrimSpace(value) + if value == "" { + return fallback + } + + return value +} + +func normalizeServerFoldersRootPath(value string, fallback string) string { + value = strings.TrimSpace(value) + if value == "" { + value = fallback + } + + return strings.ReplaceAll(value, `\`, "/") +} + func normalizeMilestoneHost(value string) string { value = strings.TrimSpace(value) value = strings.TrimRight(value, "/") diff --git a/backend/admin_milestone_roles.go b/backend/admin_milestone_roles.go new file mode 100644 index 0000000..e57b983 --- /dev/null +++ b/backend/admin_milestone_roles.go @@ -0,0 +1,845 @@ +package main + +import ( + "context" + "errors" + "net/http" + "net/url" + "strings" + + "github.com/jackc/pgx/v5" +) + +type milestoneRoleResponse struct { + ID string `json:"id"` + Name string `json:"name"` + DisplayName string `json:"displayName"` + Description string `json:"description"` + Users []milestoneRoleUserItem `json:"users,omitempty"` + UserCount int `json:"userCount"` + CanEdit bool `json:"canEdit"` + CanDelete bool `json:"canDelete"` +} + +type milestoneRoleUserItem struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + Name string `json:"name"` + UserName string `json:"userName"` + Username string `json:"username"` + Email string `json:"email"` + Domain string `json:"domain"` + SID string `json:"sid"` + DistinguishedName string `json:"distinguishedName"` + Source string `json:"source"` +} + +type saveMilestoneRoleRequest struct { + Name string `json:"name"` + DisplayName string `json:"displayName"` + Description string `json:"description"` +} + +type addMilestoneRoleUsersRequest struct { + Users []milestoneRoleUserInput `json:"users"` +} + +type milestoneRoleUserInput struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + Username string `json:"username"` + UserName string `json:"userName"` + Name string `json:"name"` + Email string `json:"email"` + Domain string `json:"domain"` + DistinguishedName string `json:"distinguishedName"` + SID string `json:"sid"` + Source string `json:"source"` +} + +func (s *Server) handleAdminListMilestoneRoles(w http.ResponseWriter, r *http.Request) { + currentUser, _ := userFromContext(r.Context()) + isAdministrator := userHasRight(currentUser, "admin") + + config, err := s.getMilestoneRuntimeConfig(r.Context()) + if err != nil { + writeError(w, http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden") + return + } + + roles, err := listOperationMilestoneRoles(r.Context(), config) + if err != nil { + writeError(w, http.StatusBadGateway, "Milestone-Rollen konnten nicht geladen werden") + return + } + + responseRoles := make([]milestoneRoleResponse, 0, len(roles)) + for _, role := range roles { + if milestoneRoleIsAdministrators(role) && !isAdministrator { + continue + } + + responseRole := milestoneRoleResponseFromRole(role, nil) + responseRoles = append(responseRoles, responseRole) + } + + writeJSON(w, http.StatusOK, map[string]any{ + "roles": responseRoles, + }) +} + +func (s *Server) handleAdminGetMilestoneRole(w http.ResponseWriter, r *http.Request) { + roleID := strings.TrimSpace(r.PathValue("id")) + if roleID == "" { + writeError(w, http.StatusBadRequest, "Rollen-ID fehlt") + return + } + currentUser, _ := userFromContext(r.Context()) + isAdministrator := userHasRight(currentUser, "admin") + + config, err := s.getMilestoneRuntimeConfig(r.Context()) + if err != nil { + writeError(w, http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden") + return + } + + role, err := getOperationMilestoneRole(r.Context(), config, roleID) + if err != nil { + writeError(w, http.StatusBadGateway, "Milestone-Rolle konnte nicht geladen werden") + return + } + if milestoneRoleIsAdministrators(*role) && !isAdministrator { + writeError(w, http.StatusNotFound, "Milestone-Rolle wurde nicht gefunden") + return + } + + users, err := listOperationMilestoneRoleUsers(r.Context(), config, roleID) + if err != nil { + writeError(w, http.StatusBadGateway, "Milestone-Rollenbenutzer konnten nicht geladen werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "role": milestoneRoleResponseFromRole(*role, users), + }) +} + +func (s *Server) handleAdminCreateMilestoneRole(w http.ResponseWriter, r *http.Request) { + var input saveMilestoneRoleRequest + if err := readJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "Invalid JSON") + return + } + + roleName := strings.TrimSpace(input.Name) + if roleName == "" { + roleName = strings.TrimSpace(input.DisplayName) + } + if roleName == "" { + writeError(w, http.StatusBadRequest, "Rollenname ist erforderlich") + return + } + + config, err := s.getMilestoneRuntimeConfig(r.Context()) + if err != nil { + writeError(w, http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden") + return + } + + existingRole, err := findOperationMilestoneRoleByName(r.Context(), config, roleName) + if err != nil { + writeError(w, http.StatusBadGateway, "Milestone-Rollen konnten nicht geprüft werden") + return + } + if existingRole != nil { + writeError(w, http.StatusConflict, "Eine Rolle mit diesem Namen existiert bereits") + return + } + + payload := map[string]any{ + "name": roleName, + "displayName": strings.TrimSpace(input.DisplayName), + "description": strings.TrimSpace(input.Description), + } + if strings.TrimSpace(input.DisplayName) == "" { + payload["displayName"] = roleName + } + + body, _, err := operationMilestoneRequestJSON( + r.Context(), + config, + http.MethodPost, + "/API/rest/v1/roles", + payload, + ) + if err != nil { + writeError(w, http.StatusBadGateway, "Milestone-Rolle konnte nicht angelegt werden") + return + } + + role := operationMilestoneRoleFromItem(decodeOperationMilestoneObject(body)) + if role.ID == "" { + roleFromList, err := findOperationMilestoneRoleByName(r.Context(), config, roleName) + if err != nil { + writeError(w, http.StatusBadGateway, "Milestone-Rolle wurde angelegt, konnte aber nicht geladen werden") + return + } + if roleFromList == nil { + writeError(w, http.StatusBadGateway, "Milestone hat keine Rollen-ID zurückgegeben") + return + } + role = *roleFromList + } + + roleDetail, err := getOperationMilestoneRole(r.Context(), config, role.ID) + if err == nil && roleDetail != nil { + role = *roleDetail + } + + writeJSON(w, http.StatusCreated, map[string]any{ + "role": milestoneRoleResponseFromRole(role, nil), + }) +} + +func (s *Server) handleAdminUpdateMilestoneRole(w http.ResponseWriter, r *http.Request) { + roleID := strings.TrimSpace(r.PathValue("id")) + if roleID == "" { + writeError(w, http.StatusBadRequest, "Rollen-ID fehlt") + return + } + currentUser, _ := userFromContext(r.Context()) + isAdministrator := userHasRight(currentUser, "admin") + + var input saveMilestoneRoleRequest + if err := readJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "Invalid JSON") + return + } + + roleName := strings.TrimSpace(input.Name) + if roleName == "" { + roleName = strings.TrimSpace(input.DisplayName) + } + if roleName == "" { + writeError(w, http.StatusBadRequest, "Rollenname ist erforderlich") + return + } + + config, err := s.getMilestoneRuntimeConfig(r.Context()) + if err != nil { + writeError(w, http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden") + return + } + + role, err := getOperationMilestoneRole(r.Context(), config, roleID) + if err != nil { + writeError(w, http.StatusBadGateway, "Milestone-Rolle konnte nicht geladen werden") + return + } + if milestoneRoleIsAdministrators(*role) && !isAdministrator { + writeError(w, http.StatusNotFound, "Milestone-Rolle wurde nicht gefunden") + return + } + + users, err := listOperationMilestoneRoleUsers(r.Context(), config, roleID) + if err != nil { + writeError(w, http.StatusBadGateway, "Milestone-Rollenbenutzer konnten nicht geladen werden") + return + } + + role.Name = roleName + role.DisplayName = strings.TrimSpace(input.DisplayName) + if role.DisplayName == "" { + role.DisplayName = roleName + } + if role.Data == nil { + role.Data = map[string]any{} + } + role.Data["name"] = roleName + role.Data["displayName"] = role.DisplayName + role.Data["description"] = strings.TrimSpace(input.Description) + + if err := putOperationMilestoneRole(r.Context(), config, *role, users); err != nil { + writeError(w, http.StatusBadGateway, "Milestone-Rolle konnte nicht gespeichert werden") + return + } + + role, err = getOperationMilestoneRole(r.Context(), config, roleID) + if err != nil { + writeError(w, http.StatusBadGateway, "Milestone-Rolle wurde gespeichert, konnte aber nicht geladen werden") + return + } + users, _ = listOperationMilestoneRoleUsers(r.Context(), config, roleID) + + writeJSON(w, http.StatusOK, map[string]any{ + "role": milestoneRoleResponseFromRole(*role, users), + }) +} + +func (s *Server) handleAdminDeleteMilestoneRole(w http.ResponseWriter, r *http.Request) { + roleID := strings.TrimSpace(r.PathValue("id")) + if roleID == "" { + writeError(w, http.StatusBadRequest, "Rollen-ID fehlt") + return + } + + config, err := s.getMilestoneRuntimeConfig(r.Context()) + if err != nil { + writeError(w, http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden") + return + } + + role, err := getOperationMilestoneRole(r.Context(), config, roleID) + if err != nil { + writeError(w, http.StatusBadGateway, "Milestone-Rolle konnte nicht geladen werden") + return + } + if milestoneRoleIsAdministrators(*role) { + writeError(w, http.StatusForbidden, "Die Rolle Administrators darf nicht gelöscht werden") + return + } + + _, _, err = operationMilestoneRequestJSON( + r.Context(), + config, + http.MethodDelete, + "/API/rest/v1/roles/"+url.PathEscape(roleID), + nil, + ) + if err != nil { + writeError(w, http.StatusBadGateway, "Milestone-Rolle konnte nicht gelöscht werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (s *Server) handleAdminAddMilestoneRoleUsers(w http.ResponseWriter, r *http.Request) { + roleID := strings.TrimSpace(r.PathValue("id")) + if roleID == "" { + writeError(w, http.StatusBadRequest, "Rollen-ID fehlt") + return + } + currentUser, _ := userFromContext(r.Context()) + isAdministrator := userHasRight(currentUser, "admin") + + var input addMilestoneRoleUsersRequest + if err := readJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "Invalid JSON") + return + } + if len(input.Users) == 0 { + writeError(w, http.StatusBadRequest, "Keine Benutzer ausgewählt") + return + } + + config, err := s.getMilestoneRuntimeConfig(r.Context()) + if err != nil { + writeError(w, http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden") + return + } + + role, users, err := getMilestoneAdminRoleWithUsers(r.Context(), config, roleID) + if err != nil { + writeError(w, http.StatusBadGateway, "Milestone-Rolle konnte nicht geladen werden") + return + } + if milestoneRoleIsAdministrators(*role) && !isAdministrator { + writeError(w, http.StatusNotFound, "Milestone-Rolle wurde nicht gefunden") + return + } + + nextUsers := users + for _, userInput := range input.Users { + user := milestoneRoleUserReferenceFromInput(userInput) + if milestoneItemID(user) == "" || milestoneRoleUserItemIsHidden(milestoneRoleUserItemFromObject(user)) { + continue + } + nextUsers = appendOperationMilestoneObjectByID(nextUsers, user) + } + if len(nextUsers) == len(users) { + writeError(w, http.StatusBadRequest, "Kein gültiger AD-Benutzer ausgewählt") + return + } + + if err := putOperationMilestoneRole(r.Context(), config, *role, nextUsers); err != nil { + writeError(w, http.StatusBadGateway, "AD-Benutzer konnten der Milestone-Rolle nicht hinzugefügt werden") + return + } + + role, users, err = getMilestoneAdminRoleWithUsers(r.Context(), config, roleID) + if err != nil { + writeError(w, http.StatusBadGateway, "Milestone-Rolle wurde gespeichert, konnte aber nicht geladen werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "role": milestoneRoleResponseFromRole(*role, users), + }) +} + +func (s *Server) handleAdminRemoveMilestoneRoleUser(w http.ResponseWriter, r *http.Request) { + roleID := strings.TrimSpace(r.PathValue("id")) + userID := strings.TrimSpace(r.PathValue("userId")) + if roleID == "" || userID == "" { + writeError(w, http.StatusBadRequest, "Rollen-ID oder Benutzer-ID fehlt") + return + } + currentUser, _ := userFromContext(r.Context()) + isAdministrator := userHasRight(currentUser, "admin") + + config, err := s.getMilestoneRuntimeConfig(r.Context()) + if err != nil { + writeError(w, http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden") + return + } + + role, users, err := getMilestoneAdminRoleWithUsers(r.Context(), config, roleID) + if err != nil { + writeError(w, http.StatusBadGateway, "Milestone-Rolle konnte nicht geladen werden") + return + } + if milestoneRoleIsAdministrators(*role) && !isAdministrator { + writeError(w, http.StatusNotFound, "Milestone-Rolle wurde nicht gefunden") + return + } + + nextUsers := make([]map[string]any, 0, len(users)) + for _, user := range users { + if strings.EqualFold(milestoneItemID(user), userID) { + if milestoneRoleUserItemIsHidden(milestoneRoleUserItemFromObject(user)) { + writeError(w, http.StatusForbidden, "Dieser Benutzer darf nicht entfernt werden") + return + } + continue + } + nextUsers = append(nextUsers, user) + } + if len(nextUsers) == len(users) { + writeError(w, http.StatusNotFound, "Benutzer wurde in der Rolle nicht gefunden") + return + } + + if err := putOperationMilestoneRole(r.Context(), config, *role, nextUsers); err != nil { + writeError(w, http.StatusBadGateway, "Benutzer konnte nicht aus der Milestone-Rolle entfernt werden") + return + } + + role, users, err = getMilestoneAdminRoleWithUsers(r.Context(), config, roleID) + if err != nil { + writeError(w, http.StatusBadGateway, "Milestone-Rolle wurde gespeichert, konnte aber nicht geladen werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "role": milestoneRoleResponseFromRole(*role, users), + }) +} + +func (s *Server) handleAdminListActiveDirectoryUsers(w http.ResponseWriter, r *http.Request) { + credentials, err := s.getActiveDirectoryCredentials(r.Context()) + if errors.Is(err, pgx.ErrNoRows) { + writeJSON(w, http.StatusOK, map[string]any{ + "configured": false, + "users": []activeDirectoryUser{}, + }) + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, "AD-Einstellungen konnten nicht geladen werden") + return + } + if !credentials.Configured() { + writeJSON(w, http.StatusOK, map[string]any{ + "configured": false, + "users": []activeDirectoryUser{}, + }) + return + } + + users, err := listActiveDirectoryUsers(r.Context(), credentials) + if err != nil { + logError("AD-Benutzer konnten nicht geladen werden", err, LogFields{ + "baseDn": credentials.BaseDN, + }) + writeError(w, http.StatusBadGateway, "AD-Benutzer konnten nicht geladen werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "configured": true, + "baseDn": credentials.BaseDN, + "users": users, + }) +} + +func (s *Server) handleAdminListMilestoneBasicUsers(w http.ResponseWriter, r *http.Request) { + config, err := s.getMilestoneRuntimeConfig(r.Context()) + if err != nil { + writeError(w, http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden") + return + } + + items, err := listOperationMilestoneObjectsAtPath(r.Context(), config, "/API/rest/v1/basicUsers") + if err != nil { + writeError(w, http.StatusBadGateway, "Milestone-Basisbenutzer konnten nicht geladen werden") + return + } + + users := make([]milestoneRoleUserItem, 0, len(items)) + for _, item := range items { + user := milestoneRoleUserItemFromObject(item) + if user.ID == "" || milestoneRoleUserItemIsHidden(user) { + continue + } + user.Source = "basic" + if user.Domain == "" { + user.Domain = "Basic" + } + users = append(users, user) + } + + writeJSON(w, http.StatusOK, map[string]any{ + "users": users, + }) +} + +func getMilestoneAdminRoleWithUsers( + ctx context.Context, + config milestoneRuntimeConfig, + roleID string, +) (*operationMilestoneRole, []map[string]any, error) { + role, err := getOperationMilestoneRole(ctx, config, roleID) + if err != nil { + return nil, nil, err + } + + users, err := listOperationMilestoneRoleUsers(ctx, config, roleID) + if err != nil { + return nil, nil, err + } + + return role, users, nil +} + +func milestoneRoleResponseFromRole( + role operationMilestoneRole, + users []map[string]any, +) milestoneRoleResponse { + roleData := role.Data + if roleData == nil { + roleData = map[string]any{} + } + + if users == nil { + users = operationMilestoneObjectListFromValue(roleData["users"]) + } + + responseUsers := make([]milestoneRoleUserItem, 0, len(users)) + for _, user := range users { + if item := milestoneRoleUserItemFromObject(user); item.ID != "" { + if milestoneRoleUserItemIsHidden(item) { + continue + } + responseUsers = append(responseUsers, item) + } + } + canDelete := !milestoneRoleIsAdministrators(role) + + return milestoneRoleResponse{ + ID: role.ID, + Name: role.Name, + DisplayName: operationMilestoneRoleDisplayName(role), + Description: strings.TrimSpace(milestoneStringValue(roleData["description"])), + Users: responseUsers, + UserCount: len(responseUsers), + CanEdit: true, + CanDelete: canDelete, + } +} + +func milestoneRoleUserItemFromObject(user map[string]any) milestoneRoleUserItem { + id := milestoneItemID(user) + displayName := strings.TrimSpace(operationMilestoneItemDisplayName(user)) + name := strings.TrimSpace(milestoneStringValue(user["name"])) + userName := strings.TrimSpace(milestoneStringValue(user["userName"])) + if userName == "" { + userName = strings.TrimSpace(milestoneStringValue(user["username"])) + } + if userName == "" { + userName = strings.TrimSpace(milestoneStringValue(user["accountName"])) + } + if userName == "" { + userName = strings.TrimSpace(milestoneStringValue(user["loginName"])) + } + email := strings.TrimSpace(milestoneStringValue(user["email"])) + if email == "" { + email = strings.TrimSpace(milestoneStringValue(user["mail"])) + } + sid := strings.TrimSpace(milestoneStringValue(user["sid"])) + if sid == "" { + sid = strings.TrimSpace(milestoneStringValue(user["objectSid"])) + } + distinguishedName := strings.TrimSpace(milestoneStringValue(user["distinguishedName"])) + source := milestoneRoleUserSourceFromObject(user) + domain := milestoneRoleUserDomainFromObject(user) + + if splitDomain, splitUserName := splitMilestoneDomainUser(userName); splitDomain != "" { + if domain == "" { + domain = splitDomain + } + userName = splitUserName + } + if splitDomain, splitName := splitMilestoneDomainUser(name); splitDomain != "" { + if domain == "" { + domain = splitDomain + } + name = splitName + } + if splitDomain, splitDisplayName := splitMilestoneDomainUser(displayName); splitDomain != "" { + if domain == "" { + domain = splitDomain + } + if userName == "" { + userName = splitDisplayName + } + displayName = splitDisplayName + } + if domain == "" { + domain = activeDirectoryDomainFromPrincipal(milestoneStringValue(user["userPrincipalName"])) + } + if domain == "" && email != "" { + domain = activeDirectoryDomainFromPrincipal(email) + } + if domain == "" && distinguishedName != "" { + domain = activeDirectoryDomainFromDN(distinguishedName) + } + if domain == "" && source == "basic" { + domain = "Basic" + } + + if displayName == "" { + displayName = userName + } + if displayName == "" { + displayName = name + } + if displayName == "" { + displayName = id + } + + return milestoneRoleUserItem{ + ID: id, + DisplayName: displayName, + Name: name, + UserName: userName, + Username: userName, + Email: email, + Domain: domain, + SID: sid, + DistinguishedName: distinguishedName, + Source: source, + } +} + +func milestoneRoleUserReferenceFromInput(input milestoneRoleUserInput) map[string]any { + userID := strings.TrimSpace(input.ID) + userSID := strings.TrimSpace(input.SID) + userDN := strings.TrimSpace(input.DistinguishedName) + displayName := strings.TrimSpace(input.DisplayName) + username := strings.TrimSpace(input.Username) + if username == "" { + username = strings.TrimSpace(input.UserName) + } + name := strings.TrimSpace(input.Name) + email := strings.TrimSpace(input.Email) + domain := strings.TrimSpace(input.Domain) + source := milestoneRoleUserSource(input.Source) + if source == "" { + source = "ad" + } + + if userID == "" { + userID = userSID + } + if userID == "" { + userID = userDN + } + if userID == "" { + userID = username + } + if displayName == "" { + displayName = name + } + if displayName == "" { + displayName = username + } + if displayName == "" { + displayName = email + } + if displayName == "" { + displayName = userID + } + + user := map[string]any{ + "id": userID, + "name": displayName, + "displayName": displayName, + "userName": username, + "username": username, + "email": email, + "domain": domain, + "source": source, + "relations": map[string]any{ + "self": map[string]any{ + "type": milestoneRoleUserRelationType(source), + "id": userID, + }, + }, + } + if userSID != "" { + user["sid"] = userSID + user["objectSid"] = userSID + } + if userDN != "" { + user["distinguishedName"] = userDN + } + + return user +} + +func milestoneRoleUserSourceFromObject(user map[string]any) string { + source := milestoneRoleUserSource(milestoneStringValue(user["source"])) + if source != "" { + return source + } + + if relations, ok := user["relations"].(map[string]any); ok { + if self, ok := relations["self"].(map[string]any); ok { + relationType := strings.TrimSpace(milestoneStringValue(self["type"])) + if strings.EqualFold(relationType, "basicUsers") || + strings.EqualFold(relationType, "basicUser") { + return "basic" + } + } + } + + itemType := strings.TrimSpace(milestoneStringValue(user["type"])) + if strings.EqualFold(itemType, "basicUsers") || + strings.EqualFold(itemType, "basicUser") { + return "basic" + } + + return "ad" +} + +func milestoneRoleUserSource(source string) string { + source = strings.TrimSpace(strings.ToLower(source)) + switch source { + case "basic", "basicuser", "basicusers": + return "basic" + case "ad", "active-directory", "activedirectory", "directory": + return "ad" + default: + return "" + } +} + +func milestoneRoleUserRelationType(source string) string { + if milestoneRoleUserSource(source) == "basic" { + return "basicUsers" + } + + return "users" +} + +func milestoneRoleUserDomainFromObject(user map[string]any) string { + for _, key := range []string{ + "domain", + "domainName", + "accountDomain", + "windowsDomain", + "netbiosDomain", + "directoryDomain", + } { + if value := strings.TrimSpace(milestoneStringValue(user[key])); value != "" { + return value + } + } + + return "" +} + +func milestoneRoleIsAdministrators(role operationMilestoneRole) bool { + for _, value := range []string{role.Name, role.DisplayName, operationMilestoneRoleDisplayName(role)} { + if normalizeMilestoneRoleProtectionValue(value) == "administrators" { + return true + } + } + + return false +} + +func milestoneRoleUserItemIsHidden(user milestoneRoleUserItem) bool { + candidates := []string{ + milestoneProtectedUserQualifiedName(user.Domain, user.Username), + milestoneProtectedUserQualifiedName(user.Domain, user.UserName), + milestoneProtectedUserQualifiedName(user.Domain, user.Name), + milestoneProtectedUserQualifiedName(user.Domain, user.DisplayName), + } + + for _, candidate := range candidates { + if milestoneProtectedUserNameIsHidden(candidate) { + return true + } + } + + return false +} + +func milestoneProtectedUserNameIsHidden(value string) bool { + value = normalizeMilestoneRoleProtectionValue(value) + switch value { + case "nt-autorität\\netzwerkdienst", + "nt authority\\network service", + "tegdssd\\tegadmin", + "vordefiniert\\administratoren", + "builtin\\administrators", + "tegdssd\\milestone_administratoren": + return true + } + + domain, account, ok := strings.Cut(value, "\\") + return ok && domain == "tegdssd" && strings.HasSuffix(account, "$") +} + +func milestoneProtectedUserQualifiedName(domain string, account string) string { + domain = strings.TrimSpace(domain) + account = strings.TrimSpace(account) + if account == "" { + return "" + } + if domain == "" { + return account + } + + return domain + "\\" + account +} + +func normalizeMilestoneRoleProtectionValue(value string) string { + return strings.ToLower(strings.Join(strings.Fields(strings.TrimSpace(value)), " ")) +} + +func splitMilestoneDomainUser(value string) (string, string) { + value = strings.TrimSpace(value) + if value == "" { + return "", "" + } + + domain, username, ok := strings.Cut(value, "\\") + if !ok || strings.TrimSpace(domain) == "" || strings.TrimSpace(username) == "" { + return "", value + } + + return strings.TrimSpace(domain), strings.TrimSpace(username) +} diff --git a/backend/external/README.md b/backend/external/README.md index 051ff06..d1c9b4f 100644 --- a/backend/external/README.md +++ b/backend/external/README.md @@ -16,13 +16,13 @@ Beispiel: "storageRoots": [ { "label": "Storage D", - "path": "D:\\Milestone\\Storage" + "path": "D:/Milestone-Speicher" } ], "archiveRoots": [ { "label": "Archive E", - "path": "E:\\Milestone\\Archive" + "path": "E:/" } ], "token": "" @@ -66,10 +66,35 @@ Falls du den Token weiterhin getrennt speichern möchtest, kannst du in der JSON "storageRoots": [ { "label": "Storage D", - "path": "D:\\Milestone\\Storage" + "path": "D:/Milestone/Storage" } ], - "tokenFile": "C:\\Milestone\\server-folders-agent.token" + "tokenFile": "C:/Milestone/server-folders-agent.token" +} +``` + +## Root-Pfade per API aktualisieren + +Das Backend kann die Storage- und Archive-Roots zur Laufzeit an den Agent schicken. Der Agent aktualisiert die laufende Konfiguration und schreibt die Werte in `server-folders-agent.json`: + +```http +PUT /agent-config +X-Server-Folders-Token: +Content-Type: application/json + +{ + "storageRoots": [ + { + "label": "Storage D", + "path": "D:/Milestone-Speicher" + } + ], + "archiveRoots": [ + { + "label": "Archive E", + "path": "E:/" + } + ] } ``` @@ -78,7 +103,7 @@ Falls du den Token weiterhin getrennt speichern möchtest, kannst du in der JSON Die bisherigen Parameter und ENV-Variablen funktionieren weiter und überschreiben Werte aus der JSON: ```powershell -.\server-folders-agent.exe -port 8099 -storage-root "Storage D=D:\Milestone\Storage" -archive-root "Archive E=E:\Milestone\Archive" +.\server-folders-agent.exe -port 8099 -storage-root "Storage D=D:/Milestone/Storage" -archive-root "Archive E=E:/Milestone/Archive" ``` Unterstützte ENV-Variablen: @@ -97,7 +122,7 @@ Unterstützte ENV-Variablen: Der bestehende Frontend-Endpunkt bleibt: ```http -GET /admin/server-folders?path=D:\Milestone +GET /admin/server-folders?path=D:/Milestone POST /admin/server-folders ``` diff --git a/backend/external/agent_build.bat b/backend/external/agent_build.bat index 18edb1f..6dec5a4 100644 --- a/backend/external/agent_build.bat +++ b/backend/external/agent_build.bat @@ -27,7 +27,7 @@ echo. echo Build erfolgreich: server-folders-agent.exe echo. echo Beispiel ^(ohne Token - der Token wird vom Backend gesetzt^): -echo server-folders-agent.exe -port 8099 -storage-root "Storage D=D:\Milestone\Storage" -archive-root "Archive E=E:\Milestone\Archive" +echo server-folders-agent.exe -port 8099 -storage-root "Storage D=D:/Milestone-Speicher" -archive-root "Archive E=E:/" echo. pause diff --git a/backend/external/agent_main.go b/backend/external/agent_main.go index bdeac28..b98e06f 100644 --- a/backend/external/agent_main.go +++ b/backend/external/agent_main.go @@ -4,11 +4,12 @@ // // Endpunkte: // GET /health -// GET /server-folders?rootType=storage&path=D:\Milestone +// GET /server-folders?rootType=storage&path=D:/Milestone // POST /server-folders // PATCH /server-folders // DELETE /server-folders // PUT /agent-token (Token zur Laufzeit setzen / rotieren) +// PUT /agent-config (Root-Pfade zur Laufzeit setzen und in JSON speichern) // // Start per JSON (empfohlen): // server-folders-agent.json neben die .exe legen und server-folders-agent.exe starten. @@ -17,18 +18,18 @@ // Beispiel: // { // "listenAddr": ":8099", -// "storageRoots": [{ "label": "Storage D", "path": "D:\\Milestone\\Storage" }], -// "archiveRoots": [{ "label": "Archive E", "path": "E:\\Milestone\\Archive" }], +// "storageRoots": [{ "label": "Storage D", "path": "D:/Milestone-Speicher" }], +// "archiveRoots": [{ "label": "Archive E", "path": "E:/" }], // "token": "" // } // // Start per Parameter bleibt als Fallback möglich: -// server-folders-agent.exe -port 8099 -storage-root "Storage D=D:\Milestone\Storage" -archive-root "Archive E=E:\Milestone\Archive" +// server-folders-agent.exe -port 8099 -storage-root "Storage D=D:/Milestone-Speicher" -archive-root "Archive E=E:/" // // Alternativ per ENV: // LISTEN_ADDR=:8099 -// SERVER_STORAGE_ROOTS=Storage D=D:\Milestone\Storage -// SERVER_ARCHIVE_ROOTS=Archive E=E:\Milestone\Archive +// SERVER_STORAGE_ROOTS=Storage D=D:/Milestone-Speicher +// SERVER_ARCHIVE_ROOTS=Archive E=E:/ // // Token-Handling: // Der Agent kann ohne Token gestartet werden. Das Backend erzeugt beim Speichern der @@ -93,9 +94,34 @@ type appConfig struct { ArchiveRoots string Token string TokenFile string + ConfigFile string TokenConfigFile string } +type appConfigStore struct { + mu sync.RWMutex + config appConfig +} + +func newAppConfigStore(config appConfig) *appConfigStore { + return &appConfigStore{config: config} +} + +func (store *appConfigStore) get() appConfig { + store.mu.RLock() + defer store.mu.RUnlock() + + return store.config +} + +func (store *appConfigStore) update(update func(appConfig) appConfig) appConfig { + store.mu.Lock() + defer store.mu.Unlock() + + store.config = update(store.config) + return store.config +} + func (config appConfig) rootsForType(rootType serverFolderRootType) string { if rootType == serverFolderRootTypeArchive { return strings.TrimSpace(config.ArchiveRoots) @@ -147,26 +173,30 @@ type renameServerFolderRequest struct { func main() { config := readConfigFromFlagsAndEnv() + configStore := newAppConfigStore(config) store := newTokenStore(config) mux := http.NewServeMux() mux.HandleFunc("GET /health", handleHealth) mux.HandleFunc("GET /server-folders", requireAgentToken(store, func(w http.ResponseWriter, r *http.Request) { - handleListServerFolders(w, r, config) + handleListServerFolders(w, r, configStore.get()) })) mux.HandleFunc("POST /server-folders", requireAgentToken(store, func(w http.ResponseWriter, r *http.Request) { - handleCreateServerFolder(w, r, config) + handleCreateServerFolder(w, r, configStore.get()) })) mux.HandleFunc("PATCH /server-folders", requireAgentToken(store, func(w http.ResponseWriter, r *http.Request) { - handleRenameServerFolder(w, r, config) + handleRenameServerFolder(w, r, configStore.get()) })) mux.HandleFunc("DELETE /server-folders", requireAgentToken(store, func(w http.ResponseWriter, r *http.Request) { - handleDeleteServerFolder(w, r, config) + handleDeleteServerFolder(w, r, configStore.get()) })) mux.HandleFunc("PUT /agent-token", requireAgentToken(store, func(w http.ResponseWriter, r *http.Request) { handleSetAgentToken(w, r, store) })) + mux.HandleFunc("PUT /agent-config", requireAgentToken(store, func(w http.ResponseWriter, r *http.Request) { + handleSetAgentConfig(w, r, configStore) + })) server := &http.Server{ Addr: config.ListenAddr, @@ -195,9 +225,9 @@ func readConfigFromFlagsAndEnv() appConfig { configFlag := flag.String("config", "", "Path to JSON config file (default: server-folders-agent.json next to the executable)") addrFlag := flag.String("addr", "", "Listen address, e.g. :8099 or 0.0.0.0:8099") portFlag := flag.String("port", "", "Listen port, e.g. 8099") - legacyRootsFlag := flag.String("roots", "", "Legacy fallback roots for both storage and archive, e.g. Milestone D=D:\\Milestone") - storageRootsFlag := flag.String("storage-root", "", "Allowed storage roots, e.g. Storage D=D:\\Milestone\\Storage;Storage E=E:\\Milestone\\Storage") - archiveRootsFlag := flag.String("archive-root", "", "Allowed archive roots, e.g. Archive D=D:\\Milestone\\Archive;Archive E=E:\\Milestone\\Archive") + legacyRootsFlag := flag.String("roots", "", "Legacy fallback roots for both storage and archive, e.g. Milestone D=D:/Milestone") + storageRootsFlag := flag.String("storage-root", "", "Allowed storage roots, e.g. Storage D=D:/Milestone-Speicher;Storage E=E:/Milestone-Speicher") + archiveRootsFlag := flag.String("archive-root", "", "Allowed archive roots, e.g. Archive D=D:/Milestone/Archive;Archive E=E:/") tokenFlag := flag.String("token", "", "Optional start seed token (used only if no token file exists yet)") tokenFileFlag := flag.String("token-file", "", "Path to persist the runtime token instead of writing it to the JSON config") flag.Parse() @@ -221,6 +251,7 @@ func readConfigFromFlagsAndEnv() appConfig { } applyAgentConfigFile(&config, fileConfig) + config.ConfigFile = configPath config.TokenConfigFile = configPath loadedConfigFile = true } @@ -284,6 +315,7 @@ func readConfigFromFlagsAndEnv() appConfig { ArchiveRoots: archiveRoots, Token: token, TokenFile: tokenFile, + ConfigFile: config.ConfigFile, TokenConfigFile: config.TokenConfigFile, } } @@ -314,6 +346,23 @@ func readAgentConfigFile(pathValue string) (agentConfigFile, error) { } func updateAgentConfigFileToken(pathValue string, token string) error { + return updateAgentConfigFile(pathValue, func(values map[string]any) { + values["token"] = strings.TrimSpace(token) + }) +} + +func updateAgentConfigFileRoots( + pathValue string, + storageRoots []serverFolderRoot, + archiveRoots []serverFolderRoot, +) error { + return updateAgentConfigFile(pathValue, func(values map[string]any) { + values["storageRoots"] = serverFolderRootsToJSONValues(storageRoots) + values["archiveRoots"] = serverFolderRootsToJSONValues(archiveRoots) + }) +} + +func updateAgentConfigFile(pathValue string, update func(map[string]any)) error { data, err := os.ReadFile(pathValue) if err != nil { return err @@ -326,7 +375,8 @@ func updateAgentConfigFileToken(pathValue string, token string) error { } } - values["token"] = strings.TrimSpace(token) + update(values) + normalizeAgentConfigJSONPaths(values) nextData, err := json.MarshalIndent(values, "", " ") if err != nil { @@ -337,6 +387,54 @@ func updateAgentConfigFileToken(pathValue string, token string) error { return os.WriteFile(pathValue, nextData, 0600) } +func serverFolderRootsToJSONValues(roots []serverFolderRoot) []map[string]string { + values := make([]map[string]string, 0, len(roots)) + + for _, root := range roots { + values = append(values, map[string]string{ + "label": strings.TrimSpace(root.Label), + "path": formatAgentConfigJSONPath(root.Path), + }) + } + + return values +} + +func normalizeAgentConfigJSONPaths(values map[string]any) { + for _, key := range []string{"roots", "storageRoots", "archiveRoots"} { + normalizeAgentConfigJSONRootPaths(values[key]) + } + + if tokenFile, ok := values["tokenFile"].(string); ok { + values["tokenFile"] = formatAgentConfigJSONPath(tokenFile) + } +} + +func normalizeAgentConfigJSONRootPaths(value any) { + roots, ok := value.([]any) + if !ok { + return + } + + for _, item := range roots { + root, ok := item.(map[string]any) + if !ok { + continue + } + + pathValue, ok := root["path"].(string) + if !ok { + continue + } + + root["path"] = formatAgentConfigJSONPath(pathValue) + } +} + +func formatAgentConfigJSONPath(value string) string { + return strings.ReplaceAll(strings.TrimSpace(value), `\`, "/") +} + func applyAgentConfigFile(config *appConfig, fileConfig agentConfigFile) { config.ListenAddr = strings.TrimSpace(fileConfig.ListenAddr) if config.ListenAddr == "" && strings.TrimSpace(fileConfig.Port) != "" { @@ -1070,6 +1168,11 @@ type setAgentTokenRequest struct { Token string `json:"token"` } +type setAgentConfigRequest struct { + StorageRoots []serverFolderRoot `json:"storageRoots"` + ArchiveRoots []serverFolderRoot `json:"archiveRoots"` +} + func handleSetAgentToken(w http.ResponseWriter, r *http.Request, store *tokenStore) { var input setAgentTokenRequest if err := readJSON(r, &input); err != nil { @@ -1094,6 +1197,83 @@ func handleSetAgentToken(w http.ResponseWriter, r *http.Request, store *tokenSto }) } +func handleSetAgentConfig(w http.ResponseWriter, r *http.Request, configStore *appConfigStore) { + var input setAgentConfigRequest + if err := readJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "Invalid JSON") + return + } + + storageRoots, err := normalizeAgentConfigRootList(input.StorageRoots, "storage") + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + archiveRoots, err := normalizeAgentConfigRootList(input.ArchiveRoots, "archive") + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + config := configStore.get() + if strings.TrimSpace(config.ConfigFile) == "" { + writeError(w, http.StatusBadRequest, "JSON-Konfigurationsdatei ist nicht gesetzt") + return + } + + if err := updateAgentConfigFileRoots(config.ConfigFile, storageRoots, archiveRoots); err != nil { + log.Printf("Agent-Konfiguration konnte nicht gespeichert werden: %v", err) + writeError(w, http.StatusInternalServerError, "Agent-Konfiguration konnte nicht gespeichert werden") + return + } + + nextConfig := configStore.update(func(current appConfig) appConfig { + current.StorageRoots = formatServerFolderRoots(storageRoots) + current.ArchiveRoots = formatServerFolderRoots(archiveRoots) + return current + }) + + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "storageRoots": storageRoots, + "archiveRoots": archiveRoots, + "configFile": nextConfig.ConfigFile, + }) +} + +func normalizeAgentConfigRootList(roots []serverFolderRoot, rootType string) ([]serverFolderRoot, error) { + normalizedRoots := make([]serverFolderRoot, 0, len(roots)) + + for _, root := range roots { + pathValue := strings.TrimSpace(root.Path) + if pathValue == "" { + continue + } + + cleanedPath, err := cleanAbsolutePath(pathValue) + if err != nil { + return nil, fmt.Errorf("ungültiger %s-root-pfad: %s", rootType, pathValue) + } + + label := strings.TrimSpace(root.Label) + if label == "" { + label = formatAgentConfigJSONPath(cleanedPath) + } + + normalizedRoots = append(normalizedRoots, serverFolderRoot{ + Label: label, + Path: formatAgentConfigJSONPath(cleanedPath), + }) + } + + if len(normalizedRoots) == 0 { + return nil, fmt.Errorf("%s-root fehlt", rootType) + } + + return normalizedRoots, nil +} + func requireAgentToken(store *tokenStore, next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { requiredToken := store.get() diff --git a/backend/external/server-folders-agent.json b/backend/external/server-folders-agent.json new file mode 100644 index 0000000..3480bf0 --- /dev/null +++ b/backend/external/server-folders-agent.json @@ -0,0 +1,16 @@ +{ + "archiveRoots": [ + { + "label": "Archive E", + "path": "E:/" + } + ], + "listenAddr": ":8099", + "storageRoots": [ + { + "label": "Storage D", + "path": "D:/Milestone-Speicher" + } + ], + "token": "" +} diff --git a/backend/go.mod b/backend/go.mod index 66036ea..24086c4 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -8,10 +8,17 @@ require ( github.com/SherClockHolmes/webpush-go v1.4.0 github.com/coder/websocket v1.8.14 github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/go-ldap/ldap/v3 v3.4.13 github.com/jackc/pgx/v5 v5.9.2 golang.org/x/crypto v0.51.0 ) +require ( + github.com/Azure/go-ntlmssp v0.1.0 // indirect + github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect + github.com/google/uuid v1.6.0 // indirect +) + require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect diff --git a/backend/go.sum b/backend/go.sum index 1d761a6..ba6679c 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,3 +1,5 @@ +github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A= +github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk= github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s= github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= @@ -5,10 +7,16 @@ github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6p github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= +github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= +github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ= +github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= diff --git a/backend/operations.go b/backend/operations.go index 9944d1e..5d7f899 100644 --- a/backend/operations.go +++ b/backend/operations.go @@ -17,32 +17,33 @@ import ( ) type Operation struct { - ID string `json:"id"` - OperationNumber string `json:"operationNumber"` - OperationName string `json:"operationName"` - Offense string `json:"offense"` - CaseWorker string `json:"caseWorker"` - TargetObject string `json:"targetObject"` - KwAddress string `json:"kwAddress"` - KwLatitude *float64 `json:"kwLatitude"` - KwLongitude *float64 `json:"kwLongitude"` - TargetLatitude *float64 `json:"targetLatitude"` - TargetLongitude *float64 `json:"targetLongitude"` - ViewConeFOV float64 `json:"viewConeFov"` - ViewConeLength float64 `json:"viewConeLength"` - OperationLeader string `json:"operationLeader"` - OperationTeam string `json:"operationTeam"` - Legend string `json:"legend"` - Camera string `json:"camera"` - Router string `json:"router"` - Technology string `json:"technology"` - Switchbox string `json:"switchbox"` - HardDrive string `json:"hardDrive"` - Laptop string `json:"laptop"` - Remark string `json:"remark"` - MilestoneStorage string `json:"milestoneStorage"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + ID string `json:"id"` + OperationNumber string `json:"operationNumber"` + OperationName string `json:"operationName"` + Offense string `json:"offense"` + CaseWorker string `json:"caseWorker"` + TargetObject string `json:"targetObject"` + KwAddress string `json:"kwAddress"` + KwLatitude *float64 `json:"kwLatitude"` + KwLongitude *float64 `json:"kwLongitude"` + TargetLatitude *float64 `json:"targetLatitude"` + TargetLongitude *float64 `json:"targetLongitude"` + ViewConeFOV float64 `json:"viewConeFov"` + ViewConeLength float64 `json:"viewConeLength"` + OperationLeader string `json:"operationLeader"` + OperationTeam string `json:"operationTeam"` + Legend string `json:"legend"` + Camera string `json:"camera"` + Router string `json:"router"` + Technology string `json:"technology"` + Switchbox string `json:"switchbox"` + HardDrive string `json:"hardDrive"` + Laptop string `json:"laptop"` + Remark string `json:"remark"` + MilestoneStorage string `json:"milestoneStorage"` + CompletedAt *time.Time `json:"completedAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } type CreateOperationJournalEntryRequest struct { @@ -101,6 +102,36 @@ type OperationHistoryChange struct { NewValue string `json:"newValue"` } +const operationReturningColumns = ` + id, + operation_number, + operation_name, + offense, + case_worker, + target_object, + kw_address, + kw_latitude, + kw_longitude, + target_latitude, + target_longitude, + view_cone_fov, + view_cone_length, + operation_leader, + operation_team, + legend, + camera, + router, + technology, + switchbox, + hard_drive, + laptop, + remark, + milestone_storage, + completed_at, + created_at, + updated_at +` + func (s *Server) handleOperations(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: @@ -122,9 +153,21 @@ func (s *Server) handleOperation(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleListOperations(w http.ResponseWriter, r *http.Request) { + status := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("status"))) + includeCompleted := strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("includeCompleted")), "true") + + statusFilter := "completed_at IS NULL" + if includeCompleted { + statusFilter = "TRUE" + } else if status == "completed" || status == "abgeschlossen" { + statusFilter = "completed_at IS NOT NULL" + } else if status == "all" { + statusFilter = "TRUE" + } + rows, err := s.db.Query( r.Context(), - ` + fmt.Sprintf(` SELECT id, operation_number, @@ -150,11 +193,13 @@ func (s *Server) handleListOperations(w http.ResponseWriter, r *http.Request) { laptop, remark, milestone_storage, + completed_at, created_at, updated_at FROM operations + WHERE %s ORDER BY operation_number ASC - `, + `, statusFilter), ) if err != nil { @@ -193,6 +238,7 @@ func (s *Server) handleListOperations(w http.ResponseWriter, r *http.Request) { &operation.Laptop, &operation.Remark, &operation.MilestoneStorage, + &operation.CompletedAt, &operation.CreatedAt, &operation.UpdatedAt, ); err != nil { @@ -301,6 +347,7 @@ func (s *Server) handleCreateOperation(w http.ResponseWriter, r *http.Request) { laptop, remark, milestone_storage, + completed_at, created_at, updated_at `, @@ -352,6 +399,7 @@ func (s *Server) handleCreateOperation(w http.ResponseWriter, r *http.Request) { &operation.Laptop, &operation.Remark, &operation.MilestoneStorage, + &operation.CompletedAt, &operation.CreatedAt, &operation.UpdatedAt, ) @@ -520,6 +568,7 @@ func (s *Server) handleUpdateOperation(w http.ResponseWriter, r *http.Request) { laptop, remark, milestone_storage, + completed_at, created_at, updated_at `, @@ -572,6 +621,7 @@ func (s *Server) handleUpdateOperation(w http.ResponseWriter, r *http.Request) { &operation.Laptop, &operation.Remark, &operation.MilestoneStorage, + &operation.CompletedAt, &operation.CreatedAt, &operation.UpdatedAt, ) @@ -627,6 +677,173 @@ func (s *Server) handleUpdateOperation(w http.ResponseWriter, r *http.Request) { }) } +func (s *Server) handleCompleteOperation(w http.ResponseWriter, r *http.Request) { + s.handleSetOperationCompleted(w, r, true) +} + +func (s *Server) handleReopenOperation(w http.ResponseWriter, r *http.Request) { + s.handleSetOperationCompleted(w, r, false) +} + +func (s *Server) handleSetOperationCompleted(w http.ResponseWriter, r *http.Request, completed bool) { + operationID := strings.TrimSpace(r.PathValue("id")) + if operationID == "" { + writeError(w, http.StatusBadRequest, "Einsatz-ID fehlt") + return + } + + user, ok := userFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + tx, err := s.db.Begin(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "Einsatzstatus konnte nicht geaendert werden") + return + } + defer tx.Rollback(r.Context()) + + oldOperation, err := getOperationForHistory(r.Context(), tx, operationID) + if errors.Is(err, pgx.ErrNoRows) { + writeError(w, http.StatusNotFound, "Einsatz wurde nicht gefunden") + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, "Einsatz konnte nicht geladen werden") + return + } + + if completed && oldOperation.CompletedAt != nil { + writeJSON(w, http.StatusOK, map[string]any{"operation": oldOperation}) + return + } + if !completed && oldOperation.CompletedAt == nil { + writeJSON(w, http.StatusOK, map[string]any{"operation": oldOperation}) + return + } + + completionExpression := "now()" + oldStatus := "Aktiv" + newStatus := "Abgeschlossen" + if !completed { + completionExpression = "NULL" + oldStatus = "Abgeschlossen" + newStatus = "Aktiv" + } + + operation, err := scanOperation( + tx.QueryRow( + r.Context(), + fmt.Sprintf( + ` + UPDATE operations + SET + completed_at = %s, + updated_at = now() + WHERE id = $1 + RETURNING %s + `, + completionExpression, + operationReturningColumns, + ), + operationID, + ), + ) + if err != nil { + writeError(w, http.StatusInternalServerError, "Einsatzstatus konnte nicht geaendert werden") + return + } + + changes := []OperationHistoryChange{ + { + Field: "status", + Label: "Status", + OldValue: oldStatus, + NewValue: newStatus, + }, + } + if err := recordOperationHistory(r.Context(), tx, operation.ID, user, "updated", changes); err != nil { + writeError(w, http.StatusInternalServerError, "Could not create operation history") + return + } + + if err := tx.Commit(r.Context()); err != nil { + writeError(w, http.StatusInternalServerError, "Einsatzstatus konnte nicht geaendert werden") + return + } + + if err := s.createOperationUpdateEvent( + r.Context(), + user, + operation, + map[string]any{ + "operationNumber": operation.OperationNumber, + "operationName": operation.OperationName, + "changes": changes, + }, + ); err != nil { + // nicht abbrechen, Status wurde bereits gespeichert + } + + writeJSON(w, http.StatusOK, map[string]any{ + "operation": operation, + }) +} + +func (s *Server) handleDeleteOperation(w http.ResponseWriter, r *http.Request) { + operationID := strings.TrimSpace(r.PathValue("id")) + if operationID == "" { + writeError(w, http.StatusBadRequest, "Einsatz-ID fehlt") + return + } + + var completedAt *time.Time + err := s.db.QueryRow( + r.Context(), + ` + SELECT completed_at + FROM operations + WHERE id = $1 + LIMIT 1 + `, + operationID, + ).Scan(&completedAt) + if errors.Is(err, pgx.ErrNoRows) { + writeError(w, http.StatusNotFound, "Einsatz wurde nicht gefunden") + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, "Einsatz konnte nicht geladen werden") + return + } + if completedAt == nil { + writeError(w, http.StatusBadRequest, "Nur abgeschlossene Einsaetze koennen geloescht werden") + return + } + + commandTag, err := s.db.Exec( + r.Context(), + ` + DELETE FROM operations + WHERE id = $1 + AND completed_at IS NOT NULL + `, + operationID, + ) + if err != nil { + writeError(w, http.StatusInternalServerError, "Einsatz konnte nicht geloescht werden") + return + } + if commandTag.RowsAffected() == 0 { + writeError(w, http.StatusNotFound, "Einsatz wurde nicht gefunden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + func (s *Server) handleListOperationHistory(w http.ResponseWriter, r *http.Request) { operationID := strings.TrimSpace(r.PathValue("id")) @@ -745,6 +962,7 @@ func getOperationForHistory(ctx context.Context, tx pgx.Tx, operationID string) laptop, remark, milestone_storage, + completed_at, created_at, updated_at FROM operations @@ -777,6 +995,43 @@ func getOperationForHistory(ctx context.Context, tx pgx.Tx, operationID string) &operation.Laptop, &operation.Remark, &operation.MilestoneStorage, + &operation.CompletedAt, + &operation.CreatedAt, + &operation.UpdatedAt, + ) + + return operation, err +} + +func scanOperation(row pgx.Row) (Operation, error) { + var operation Operation + + err := row.Scan( + &operation.ID, + &operation.OperationNumber, + &operation.OperationName, + &operation.Offense, + &operation.CaseWorker, + &operation.TargetObject, + &operation.KwAddress, + &operation.KwLatitude, + &operation.KwLongitude, + &operation.TargetLatitude, + &operation.TargetLongitude, + &operation.ViewConeFOV, + &operation.ViewConeLength, + &operation.OperationLeader, + &operation.OperationTeam, + &operation.Legend, + &operation.Camera, + &operation.Router, + &operation.Technology, + &operation.Switchbox, + &operation.HardDrive, + &operation.Laptop, + &operation.Remark, + &operation.MilestoneStorage, + &operation.CompletedAt, &operation.CreatedAt, &operation.UpdatedAt, ) diff --git a/backend/operations_milestone_setup.go b/backend/operations_milestone_setup.go new file mode 100644 index 0000000..62add58 --- /dev/null +++ b/backend/operations_milestone_setup.go @@ -0,0 +1,1654 @@ +// backend\operations_milestone_setup.go + +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "regexp" + "strconv" + "strings" + + "github.com/jackc/pgx/v5" +) + +type operationMilestoneSetupRequest struct { + CameraDeviceID string `json:"cameraDeviceId"` + RouterDeviceID string `json:"routerDeviceId"` + SwitchboxDeviceID string `json:"switchboxDeviceId"` + HardDriveDeviceID string `json:"hardDriveDeviceId"` + LaptopDeviceID string `json:"laptopDeviceId"` + MilestoneUserID string `json:"milestoneUserId"` + MilestoneUserName string `json:"milestoneUserName"` + MilestoneUserSID string `json:"milestoneUserSid"` + MilestoneUserDN string `json:"milestoneUserDn"` +} + +type operationMilestoneSetupEvent struct { + Type string `json:"type"` + Step string `json:"step,omitempty"` + Status string `json:"status,omitempty"` + Message string `json:"message,omitempty"` +} + +type operationMilestoneSetupDevice struct { + ID string + InventoryNumber string + Manufacturer string + Model string + IPAddress string + PhoneNumber string + ComputerName string + DeviceCategory string + MilestoneHardwareID string + MilestoneDisplayName string + MilestonePort string +} + +type operationMilestoneRole struct { + ID string + Name string + DisplayName string + Created bool + Data map[string]any +} + +var operationMilestoneNumberPattern = regexp.MustCompile(`\d+`) +var operationMilestoneDetailSuffixPattern = regexp.MustCompile(`\s*\([^)]*\)\s*$`) +var operationMilestoneEvaluationRolePattern = regexp.MustCompile(`(?i)\bauswertung\s*([1-9]|10)\b`) + +func (s *Server) handleOperationMilestoneSetup(w http.ResponseWriter, r *http.Request) { + operationID := strings.TrimSpace(r.PathValue("id")) + if operationID == "" { + writeError(w, http.StatusBadRequest, "Einsatz-ID fehlt") + return + } + + var input operationMilestoneSetupRequest + if err := readJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "Invalid JSON") + return + } + + w.Header().Set("Content-Type", "application/x-ndjson") + w.Header().Set("Cache-Control", "no-cache") + + report := func(event operationMilestoneSetupEvent) { + writeOperationMilestoneSetupEvent(w, event) + } + fail := func(step string, message string, err error) { + if err != nil { + logError("Milestone-Nachlauf fehlgeschlagen", err, LogFields{ + "operationId": operationID, + "step": step, + }) + } + report(operationMilestoneSetupEvent{ + Type: "error", + Step: step, + Status: "error", + Message: message, + }) + } + + ctx := r.Context() + + operation, err := s.getOperationForMilestoneSetup(ctx, operationID) + if errors.Is(err, pgx.ErrNoRows) { + fail("viewGroup", "Einsatz wurde nicht gefunden", err) + return + } + if err != nil { + fail("viewGroup", "Einsatz konnte nicht geladen werden", err) + return + } + + config, err := s.getMilestoneRuntimeConfig(ctx) + if errors.Is(err, pgx.ErrNoRows) { + fail("viewGroup", "Milestone-Einstellungen sind noch nicht hinterlegt", err) + return + } + if err != nil { + fail("viewGroup", "Milestone-Token konnte nicht geladen oder erneuert werden", err) + return + } + if strings.TrimSpace(config.Host) == "" { + fail("viewGroup", "Milestone Host fehlt", nil) + return + } + if strings.TrimSpace(config.AccessToken) == "" { + fail("viewGroup", "Milestone-Token fehlt und konnte nicht automatisch erneuert werden", nil) + return + } + + report(operationMilestoneSetupEvent{ + Type: "step", + Step: "viewGroup", + Status: "current", + Message: "Ansichtsgruppe wird gesucht oder angelegt.", + }) + viewGroupID, err := s.ensureOperationMilestoneViewGroup(ctx, config, operation) + if err != nil { + fail("viewGroup", "Ansichtsgruppe konnte in Milestone nicht angelegt werden", err) + return + } + report(operationMilestoneSetupEvent{ + Type: "step", + Step: "viewGroup", + Status: "complete", + Message: fmt.Sprintf("Ansichtsgruppe %q ist bereit.", operationMilestoneViewGroupName(operation)), + }) + + var cameraDevice *operationMilestoneSetupDevice + var routerDevice *operationMilestoneSetupDevice + report(operationMilestoneSetupEvent{ + Type: "step", + Step: "cameraIp", + Status: "current", + Message: "Kamera-IP wird berechnet.", + }) + cameraDevice, routerDevice, err = s.updateOperationMilestoneCameraIP(ctx, config, operation, input) + if err != nil { + fail("cameraIp", "Kamera-IP konnte nicht gesetzt werden", err) + return + } + if cameraDevice == nil || routerDevice == nil { + report(operationMilestoneSetupEvent{ + Type: "step", + Step: "cameraIp", + Status: "complete", + Message: "Keine Kamera oder kein Router ausgewaehlt, Schritt uebersprungen.", + }) + } else { + report(operationMilestoneSetupEvent{ + Type: "step", + Step: "cameraIp", + Status: "complete", + Message: fmt.Sprintf("Kamera-IP wurde auf %s gesetzt.", cameraDevice.IPAddress), + }) + } + + var targetRole *operationMilestoneRole + report(operationMilestoneSetupEvent{ + Type: "step", + Step: "roleUser", + Status: "current", + Message: "Auswertungsrolle und AD-Benutzer werden geprueft.", + }) + targetRole, err = s.ensureOperationMilestoneRoleUser(ctx, config, operation, input) + if err != nil { + fail("roleUser", "AD-Benutzer konnte der Rolle nicht zugeordnet werden", err) + return + } + if targetRole == nil { + report(operationMilestoneSetupEvent{ + Type: "step", + Step: "roleUser", + Status: "complete", + Message: "Kein Auswerter-Laptop ausgewaehlt, Schritt uebersprungen.", + }) + } else if targetRole.Created { + report(operationMilestoneSetupEvent{ + Type: "step", + Step: "roleUser", + Status: "complete", + Message: fmt.Sprintf("Rolle %q wurde angelegt.", operationMilestoneRoleDisplayName(*targetRole)), + }) + } else { + report(operationMilestoneSetupEvent{ + Type: "step", + Step: "roleUser", + Status: "complete", + Message: fmt.Sprintf("Rolle %q ist vorbereitet.", operationMilestoneRoleDisplayName(*targetRole)), + }) + } + + report(operationMilestoneSetupEvent{ + Type: "step", + Step: "roleCamera", + Status: "current", + Message: "Kamera wird der Rolle zugeordnet.", + }) + if targetRole == nil || cameraDevice == nil { + report(operationMilestoneSetupEvent{ + Type: "step", + Step: "roleCamera", + Status: "complete", + Message: "Keine Rolle oder Kamera verfuegbar, Schritt uebersprungen.", + }) + } else if message, err := s.assignOperationMilestoneCameraToRole(ctx, config, *targetRole, *cameraDevice); err != nil { + fail("roleCamera", "Kamera konnte der Rolle nicht zugeordnet werden", err) + return + } else { + report(operationMilestoneSetupEvent{ + Type: "step", + Step: "roleCamera", + Status: "complete", + Message: message, + }) + } + + report(operationMilestoneSetupEvent{ + Type: "step", + Step: "roleViewGroup", + Status: "current", + Message: "Ansichtsgruppe wird der Rolle zugeordnet.", + }) + if targetRole == nil || viewGroupID == "" { + report(operationMilestoneSetupEvent{ + Type: "step", + Step: "roleViewGroup", + Status: "complete", + Message: "Keine Rolle oder Ansichtsgruppe verfuegbar, Schritt uebersprungen.", + }) + } else if message, err := s.assignOperationMilestoneViewGroupToRole(ctx, config, *targetRole, viewGroupID); err != nil { + fail("roleViewGroup", "Ansichtsgruppe konnte der Rolle nicht zugeordnet werden", err) + return + } else { + report(operationMilestoneSetupEvent{ + Type: "step", + Step: "roleViewGroup", + Status: "complete", + Message: message, + }) + } + + report(operationMilestoneSetupEvent{ + Type: "step", + Step: "otherRoles", + Status: "current", + Message: "Weitere Rollen werden geprueft.", + }) + otherRoleCount, err := s.countOperationMilestoneEvaluationRoles(ctx, config) + if err != nil { + fail("otherRoles", "Andere Rollen konnten nicht geprueft werden", err) + return + } + report(operationMilestoneSetupEvent{ + Type: "step", + Step: "otherRoles", + Status: "complete", + Message: fmt.Sprintf("%d Auswertungsrollen wurden gefunden.", otherRoleCount), + }) + + report(operationMilestoneSetupEvent{ + Type: "done", + Message: "Milestone-Nachlauf abgeschlossen.", + }) +} + +func writeOperationMilestoneSetupEvent(w http.ResponseWriter, event operationMilestoneSetupEvent) { + if err := json.NewEncoder(w).Encode(event); err != nil { + logError("Milestone-Nachlauf-Event konnte nicht geschrieben werden", err, nil) + return + } + + if flusher, ok := w.(http.Flusher); ok { + flusher.Flush() + } +} + +func (s *Server) getOperationForMilestoneSetup(ctx context.Context, operationID string) (Operation, error) { + var operation Operation + + err := s.db.QueryRow( + ctx, + ` + SELECT + id, + operation_number, + operation_name, + offense, + case_worker, + target_object, + kw_address, + kw_latitude, + kw_longitude, + target_latitude, + target_longitude, + view_cone_fov, + view_cone_length, + operation_leader, + operation_team, + legend, + camera, + router, + technology, + switchbox, + hard_drive, + laptop, + remark, + milestone_storage, + completed_at, + created_at, + updated_at + FROM operations + WHERE id = $1 + LIMIT 1 + `, + operationID, + ).Scan( + &operation.ID, + &operation.OperationNumber, + &operation.OperationName, + &operation.Offense, + &operation.CaseWorker, + &operation.TargetObject, + &operation.KwAddress, + &operation.KwLatitude, + &operation.KwLongitude, + &operation.TargetLatitude, + &operation.TargetLongitude, + &operation.ViewConeFOV, + &operation.ViewConeLength, + &operation.OperationLeader, + &operation.OperationTeam, + &operation.Legend, + &operation.Camera, + &operation.Router, + &operation.Technology, + &operation.Switchbox, + &operation.HardDrive, + &operation.Laptop, + &operation.Remark, + &operation.MilestoneStorage, + &operation.CompletedAt, + &operation.CreatedAt, + &operation.UpdatedAt, + ) + + return operation, err +} + +func (s *Server) getOperationMilestoneSetupDevice( + ctx context.Context, + deviceID string, + displayValue string, + category string, +) (*operationMilestoneSetupDevice, error) { + deviceID = strings.TrimSpace(deviceID) + if deviceID != "" { + device, err := s.getOperationMilestoneSetupDeviceByID(ctx, deviceID) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + + return &device, nil + } + + displayValue = normalizeOperationMilestoneEquipmentDisplayValue(displayValue) + if displayValue == "" { + return nil, nil + } + + device, err := s.getOperationMilestoneSetupDeviceByDisplayValue(ctx, displayValue, category) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + + return &device, nil +} + +func (s *Server) getOperationMilestoneSetupDeviceByID( + ctx context.Context, + deviceID string, +) (operationMilestoneSetupDevice, error) { + var device operationMilestoneSetupDevice + + err := s.db.QueryRow( + ctx, + ` + SELECT + id::TEXT, + inventory_number, + manufacturer, + model, + COALESCE(ip_address, ''), + COALESCE(phone_number, ''), + COALESCE(computer_name, ''), + COALESCE(device_category, ''), + COALESCE(milestone_hardware_id, ''), + COALESCE(milestone_display_name, ''), + COALESCE(milestone_port, '') + FROM devices + WHERE id = $1 + LIMIT 1 + `, + deviceID, + ).Scan( + &device.ID, + &device.InventoryNumber, + &device.Manufacturer, + &device.Model, + &device.IPAddress, + &device.PhoneNumber, + &device.ComputerName, + &device.DeviceCategory, + &device.MilestoneHardwareID, + &device.MilestoneDisplayName, + &device.MilestonePort, + ) + + return device, err +} + +func (s *Server) getOperationMilestoneSetupDeviceByDisplayValue( + ctx context.Context, + displayValue string, + category string, +) (operationMilestoneSetupDevice, error) { + var device operationMilestoneSetupDevice + + err := s.db.QueryRow( + ctx, + ` + SELECT + id::TEXT, + inventory_number, + manufacturer, + model, + COALESCE(ip_address, ''), + COALESCE(phone_number, ''), + COALESCE(computer_name, ''), + COALESCE(device_category, ''), + COALESCE(milestone_hardware_id, ''), + COALESCE(milestone_display_name, ''), + COALESCE(milestone_port, '') + FROM devices + WHERE lower(COALESCE(device_category, '')) = lower($2) + AND ( + inventory_number = $1 + OR model = $1 + OR COALESCE(milestone_display_name, '') = $1 + OR COALESCE(computer_name, '') = $1 + OR COALESCE(ip_address, '') = $1 + ) + ORDER BY updated_at DESC + LIMIT 1 + `, + displayValue, + category, + ).Scan( + &device.ID, + &device.InventoryNumber, + &device.Manufacturer, + &device.Model, + &device.IPAddress, + &device.PhoneNumber, + &device.ComputerName, + &device.DeviceCategory, + &device.MilestoneHardwareID, + &device.MilestoneDisplayName, + &device.MilestonePort, + ) + + return device, err +} + +func normalizeOperationMilestoneEquipmentDisplayValue(value string) string { + value = strings.TrimSpace(value) + for operationMilestoneDetailSuffixPattern.MatchString(value) { + value = strings.TrimSpace(operationMilestoneDetailSuffixPattern.ReplaceAllString(value, "")) + } + + return value +} + +func (s *Server) updateOperationMilestoneCameraIP( + ctx context.Context, + config milestoneRuntimeConfig, + operation Operation, + input operationMilestoneSetupRequest, +) (*operationMilestoneSetupDevice, *operationMilestoneSetupDevice, error) { + cameraDevice, err := s.getOperationMilestoneSetupDevice( + ctx, + input.CameraDeviceID, + operation.Camera, + "Kameras", + ) + if err != nil { + return nil, nil, err + } + + routerDevice, err := s.getOperationMilestoneSetupDevice( + ctx, + input.RouterDeviceID, + operation.Router, + "Router", + ) + if err != nil { + return nil, nil, err + } + + if cameraDevice == nil || routerDevice == nil { + return cameraDevice, routerDevice, nil + } + + if strings.TrimSpace(cameraDevice.MilestoneHardwareID) == "" { + return cameraDevice, routerDevice, fmt.Errorf("Kamera %q ist kein Milestone-Hardwaregeraet", cameraDevice.InventoryNumber) + } + + nextIP, err := operationMilestoneCameraIPAddress(*routerDevice, *cameraDevice) + if err != nil { + return cameraDevice, routerDevice, err + } + + nextAddress := normalizeMilestoneHardwareAddress(nextIP, cameraDevice.MilestonePort) + if err := updateMilestoneHardware( + ctx, + config, + cameraDevice.MilestoneHardwareID, + map[string]any{"address": nextAddress}, + cameraDevice.MilestoneDisplayName, + cameraDevice.MilestoneDisplayName, + ); err != nil { + return cameraDevice, routerDevice, err + } + + if _, err := s.db.Exec( + ctx, + ` + UPDATE devices + SET + ip_address = $2, + milestone_synced_at = now(), + updated_at = now() + WHERE id = $1 + `, + cameraDevice.ID, + nextIP, + ); err != nil { + return cameraDevice, routerDevice, err + } + + cameraDevice.IPAddress = nextIP + + return cameraDevice, routerDevice, nil +} + +func operationMilestoneCameraIPAddress( + routerDevice operationMilestoneSetupDevice, + cameraDevice operationMilestoneSetupDevice, +) (string, error) { + routerIP := net.ParseIP(strings.TrimSpace(routerDevice.IPAddress)).To4() + if routerIP == nil { + return "", fmt.Errorf("Router %q hat keine gueltige IPv4-Adresse", routerDevice.InventoryNumber) + } + + cameraNumber, err := operationMilestoneCameraNumber(cameraDevice) + if err != nil { + return "", err + } + + return fmt.Sprintf( + "%d.%d.%d.%d", + routerIP[0], + routerIP[1], + routerIP[2], + cameraNumber, + ), nil +} + +func operationMilestoneCameraNumber(device operationMilestoneSetupDevice) (int, error) { + candidates := []string{ + device.InventoryNumber, + device.MilestoneDisplayName, + device.Model, + } + + for _, candidate := range candidates { + matches := operationMilestoneNumberPattern.FindAllString(candidate, -1) + if len(matches) == 0 { + continue + } + + value, err := strconv.Atoi(matches[len(matches)-1]) + if err == nil && value > 0 && value < 255 { + return value, nil + } + } + + return 0, fmt.Errorf("Kameranummer konnte fuer %q nicht ermittelt werden", device.InventoryNumber) +} + +func operationMilestoneViewGroupName(operation Operation) string { + operationNumber := operationMilestoneRoleName(operation) + operationName := strings.TrimSpace(operation.OperationName) + if operationName == "" { + return operationNumber + } + if operationNumber == "" { + return operationName + } + + return operationNumber + " - " + operationName +} + +func operationMilestoneRoleName(operation Operation) string { + return strings.ReplaceAll(strings.TrimSpace(operation.OperationNumber), "-", "") +} + +func (s *Server) ensureOperationMilestoneViewGroup( + ctx context.Context, + config milestoneRuntimeConfig, + operation Operation, +) (string, error) { + viewGroupName := operationMilestoneViewGroupName(operation) + if viewGroupName == "" { + return "", errors.New("Einsatznummer oder Einsatzname fehlt") + } + + viewGroups, err := listOperationMilestoneViewGroups(ctx, config) + if err != nil { + return "", err + } + + if existingID := findOperationMilestoneItemIDByName(viewGroups, viewGroupName); existingID != "" { + return existingID, nil + } + + parentID := operationMilestoneViewGroupParentID(viewGroups) + payloads := []map[string]any{ + { + "name": viewGroupName, + "displayName": viewGroupName, + }, + } + if parentID != "" { + payloads = append([]map[string]any{ + { + "name": viewGroupName, + "displayName": viewGroupName, + "parentId": parentID, + }, + }, payloads...) + } + + var lastErr error + for _, payload := range payloads { + body, _, err := operationMilestoneRequestJSON( + ctx, + config, + http.MethodPost, + "/API/rest/v1/viewGroups", + payload, + ) + if err != nil { + lastErr = err + continue + } + + if id := milestoneItemID(decodeOperationMilestoneObject(body)); id != "" { + return id, nil + } + + viewGroups, err = listOperationMilestoneViewGroups(ctx, config) + if err != nil { + return "", err + } + if id := findOperationMilestoneItemIDByName(viewGroups, viewGroupName); id != "" { + return id, nil + } + } + + if lastErr != nil { + return "", lastErr + } + + return "", fmt.Errorf("Milestone hat keine ID fuer Ansichtsgruppe %q zurueckgegeben", viewGroupName) +} + +func listOperationMilestoneViewGroups( + ctx context.Context, + config milestoneRuntimeConfig, +) ([]map[string]any, error) { + collected := []map[string]any{} + visited := map[string]bool{} + + topLevel, err := listOperationMilestoneObjectsAtPath(ctx, config, "/API/rest/v1/viewGroups") + if err == nil { + collected = append(collected, topLevel...) + for _, item := range topLevel { + collectOperationMilestoneViewGroupChildren(ctx, config, item, visited, &collected) + } + } + + rootCandidates := []string{ + strings.TrimSpace(os.Getenv("MILESTONE_VIEW_GROUP_ROOT_ID")), + strings.TrimSpace(config.ServerID), + "root", + "00000000-0000-0000-0000-000000000000", + } + + for _, rootID := range rootCandidates { + if rootID == "" || visited[rootID] { + continue + } + + children, childErr := listOperationMilestoneObjectsAtPath( + ctx, + config, + "/API/rest/v1/viewGroups/"+url.PathEscape(rootID)+"/viewGroups", + ) + if childErr != nil { + continue + } + + visited[rootID] = true + collected = append(collected, children...) + for _, item := range children { + collectOperationMilestoneViewGroupChildren(ctx, config, item, visited, &collected) + } + } + + if len(collected) == 0 && err != nil { + return nil, err + } + + return collected, nil +} + +func collectOperationMilestoneViewGroupChildren( + ctx context.Context, + config milestoneRuntimeConfig, + item map[string]any, + visited map[string]bool, + collected *[]map[string]any, +) { + id := milestoneItemID(item) + if id == "" || visited[id] { + return + } + + visited[id] = true + children, err := listOperationMilestoneObjectsAtPath( + ctx, + config, + "/API/rest/v1/viewGroups/"+url.PathEscape(id)+"/viewGroups", + ) + if err != nil { + return + } + + *collected = append(*collected, children...) + for _, child := range children { + collectOperationMilestoneViewGroupChildren(ctx, config, child, visited, collected) + } +} + +func operationMilestoneViewGroupParentID(viewGroups []map[string]any) string { + if parentID := strings.TrimSpace(os.Getenv("MILESTONE_VIEW_GROUP_ROOT_ID")); parentID != "" { + return parentID + } + + for _, item := range viewGroups { + name := strings.ToLower(strings.TrimSpace(operationMilestoneItemDisplayName(item))) + if name == "root" || name == "ansichten" || name == "views" { + return milestoneItemID(item) + } + } + + return "" +} + +func (s *Server) ensureOperationMilestoneRoleUser( + ctx context.Context, + config milestoneRuntimeConfig, + operation Operation, + input operationMilestoneSetupRequest, +) (*operationMilestoneRole, error) { + laptopDevice, err := s.getOperationMilestoneSetupDevice( + ctx, + input.LaptopDeviceID, + operation.Laptop, + "Laptop", + ) + if err != nil { + return nil, err + } + if laptopDevice == nil { + return nil, nil + } + + roleName := operationMilestoneEvaluationRoleName(*laptopDevice, operation) + if roleName == "" { + return nil, fmt.Errorf("Auswertungsrolle konnte aus Laptop %q nicht ermittelt werden", laptopDevice.InventoryNumber) + } + + sourceRole, err := findOperationMilestoneRoleByName(ctx, config, roleName) + if err != nil { + return nil, err + } + if sourceRole == nil { + operationRoleName := operationMilestoneRoleName(operation) + if operationRoleName == "" { + return nil, fmt.Errorf("Milestone-Rolle %q wurde nicht gefunden und Einsatznummer fehlt", roleName) + } + + role, err := ensureOperationMilestoneRole(ctx, config, operationRoleName) + if err != nil { + return nil, fmt.Errorf("Milestone-Rolle %q wurde nicht gefunden und Rolle %q konnte nicht angelegt werden: %w", roleName, operationRoleName, err) + } + + role, err = getOperationMilestoneRole(ctx, config, role.ID) + if err != nil { + return nil, err + } + + if selectedUser := operationMilestoneUserFromSetupInput(input); selectedUser != nil { + roleUsers := appendOperationMilestoneObjectByID(operationMilestoneObjectListFromValue(role.Data["users"]), selectedUser) + if err := putOperationMilestoneRole(ctx, config, *role, roleUsers); err != nil { + return nil, err + } + role.Data["users"] = roleUsers + } + + return role, nil + } + + targetUserName := strings.TrimSpace(laptopDevice.ComputerName) + if targetUserName == "" { + targetUserName = roleName + } + + role, err := getOperationMilestoneRole(ctx, config, sourceRole.ID) + if err != nil { + return nil, err + } + + sourceUsers, err := listOperationMilestoneRoleUsers(ctx, config, sourceRole.ID) + if err != nil { + return nil, err + } + candidateUsers := sourceUsers + if hasOperationMilestoneUserSelection(input) { + allRoleUsers, err := listOperationMilestoneUsersFromRoles(ctx, config) + if err != nil { + return nil, err + } + candidateUsers = appendOperationMilestoneObjectsByID(candidateUsers, allRoleUsers...) + } + + usersToAssign := selectOperationMilestoneRoleUsersForAssignment(candidateUsers, targetUserName, input) + if len(usersToAssign) == 0 { + if selectedUser := operationMilestoneUserFromSetupInput(input); selectedUser != nil { + usersToAssign = []map[string]any{selectedUser} + } else { + return nil, fmt.Errorf("AD-Benutzer %q wurde unter /roles/%s/users nicht gefunden", targetUserName, sourceRole.ID) + } + } + + roleUsers := operationMilestoneObjectListFromValue(role.Data["users"]) + if len(roleUsers) == 0 && !hasOperationMilestoneUserSelection(input) { + roleUsers = appendOperationMilestoneObjectsByID(roleUsers, sourceUsers...) + } + + for _, targetUser := range usersToAssign { + if milestoneItemID(targetUser) == "" { + return nil, fmt.Errorf("AD-Benutzer %q hat keine Milestone-ID", targetUserName) + } + + roleUsers = appendOperationMilestoneObjectByID(roleUsers, targetUser) + } + + if err := putOperationMilestoneRole(ctx, config, *role, roleUsers); err != nil { + return nil, err + } + + role.Data["users"] = roleUsers + + return role, nil +} + +func selectOperationMilestoneRoleUsersForAssignment( + users []map[string]any, + preferredUserName string, + input operationMilestoneSetupRequest, +) []map[string]any { + if targetUser := findOperationMilestoneUserBySetupInput(users, input); targetUser != nil { + return []map[string]any{targetUser} + } + + if targetUser := findOperationMilestoneItemByName(users, preferredUserName); targetUser != nil { + return []map[string]any{targetUser} + } + + usersWithID := make([]map[string]any, 0, len(users)) + for _, user := range users { + if milestoneItemID(user) != "" { + usersWithID = append(usersWithID, user) + } + } + + return usersWithID +} + +func hasOperationMilestoneUserSelection(input operationMilestoneSetupRequest) bool { + return strings.TrimSpace(input.MilestoneUserID) != "" || + strings.TrimSpace(input.MilestoneUserName) != "" || + strings.TrimSpace(input.MilestoneUserSID) != "" || + strings.TrimSpace(input.MilestoneUserDN) != "" +} + +func findOperationMilestoneUserBySetupInput( + users []map[string]any, + input operationMilestoneSetupRequest, +) map[string]any { + candidates := operationMilestoneUserSearchCandidates(input) + if len(candidates) == 0 { + return nil + } + + for _, user := range users { + userValues := operationMilestoneUserComparableValues(user) + for _, candidate := range candidates { + for _, userValue := range userValues { + if normalizeOperationMilestoneName(userValue) == normalizeOperationMilestoneName(candidate) { + return user + } + } + } + } + + return nil +} + +func operationMilestoneUserSearchCandidates(input operationMilestoneSetupRequest) []string { + values := []string{ + input.MilestoneUserID, + input.MilestoneUserSID, + input.MilestoneUserDN, + input.MilestoneUserName, + } + + candidates := make([]string, 0, len(values)) + for _, value := range values { + if value = strings.TrimSpace(value); value != "" { + candidates = append(candidates, value) + } + } + + return candidates +} + +func operationMilestoneUserComparableValues(user map[string]any) []string { + values := []string{ + milestoneItemID(user), + operationMilestoneItemDisplayName(user), + milestoneStringValue(user["name"]), + milestoneStringValue(user["displayName"]), + milestoneStringValue(user["userName"]), + milestoneStringValue(user["username"]), + milestoneStringValue(user["accountName"]), + milestoneStringValue(user["loginName"]), + milestoneStringValue(user["sid"]), + milestoneStringValue(user["objectSid"]), + milestoneStringValue(user["distinguishedName"]), + } + + relations, _ := user["relations"].(map[string]any) + for _, relationKey := range []string{"self", "user", "account"} { + if relation, ok := relations[relationKey].(map[string]any); ok { + values = append(values, milestoneStringValue(relation["id"])) + } + } + + return values +} + +func operationMilestoneUserFromSetupInput(input operationMilestoneSetupRequest) map[string]any { + if !hasOperationMilestoneUserSelection(input) { + return nil + } + + userID := strings.TrimSpace(input.MilestoneUserID) + userSID := strings.TrimSpace(input.MilestoneUserSID) + userDN := strings.TrimSpace(input.MilestoneUserDN) + userName := strings.TrimSpace(input.MilestoneUserName) + if userID == "" { + userID = userSID + } + if userID == "" { + userID = userDN + } + if userID == "" { + userID = userName + } + if userName == "" { + userName = userID + } + + user := map[string]any{ + "id": userID, + "name": userName, + "displayName": userName, + "relations": map[string]any{ + "self": map[string]any{ + "type": "users", + "id": userID, + }, + }, + } + if userSID != "" { + user["sid"] = userSID + user["objectSid"] = userSID + } + if userDN != "" { + user["distinguishedName"] = userDN + } + + return user +} + +func listOperationMilestoneUsersFromRoles( + ctx context.Context, + config milestoneRuntimeConfig, +) ([]map[string]any, error) { + roles, err := listOperationMilestoneRoles(ctx, config) + if err != nil { + return nil, err + } + + users := []map[string]any{} + for _, role := range roles { + roleUsers, err := listOperationMilestoneRoleUsers(ctx, config, role.ID) + if err != nil { + return nil, err + } + users = appendOperationMilestoneObjectsByID(users, roleUsers...) + } + + return users, nil +} + +func operationMilestoneEvaluationRoleName( + laptopDevice operationMilestoneSetupDevice, + operation Operation, +) string { + candidates := []string{ + laptopDevice.ComputerName, + laptopDevice.InventoryNumber, + laptopDevice.Model, + operation.Laptop, + } + + for _, candidate := range candidates { + match := operationMilestoneEvaluationRolePattern.FindStringSubmatch(candidate) + if len(match) == 2 { + return "Auswertung " + match[1] + } + } + + for _, candidate := range candidates { + if value := strings.TrimSpace(candidate); value != "" { + return normalizeOperationMilestoneEquipmentDisplayValue(value) + } + } + + return "" +} + +func (s *Server) assignOperationMilestoneCameraToRole( + ctx context.Context, + config milestoneRuntimeConfig, + role operationMilestoneRole, + cameraDevice operationMilestoneSetupDevice, +) (string, error) { + cameraID, err := s.getOperationMilestoneCameraDeviceID(ctx, cameraDevice.MilestoneHardwareID) + if err != nil { + return "", err + } + if cameraID == "" { + return "", fmt.Errorf("Keine Milestone-Kamera fuer Hardware %q gefunden", cameraDevice.MilestoneHardwareID) + } + + return operationMilestonePermissionWriteUnsupportedMessage(role, "Kamera"), nil +} + +func (s *Server) assignOperationMilestoneViewGroupToRole( + ctx context.Context, + config milestoneRuntimeConfig, + role operationMilestoneRole, + viewGroupID string, +) (string, error) { + if strings.TrimSpace(viewGroupID) == "" { + return "", errors.New("Ansichtsgruppen-ID fehlt") + } + + return operationMilestonePermissionWriteUnsupportedMessage(role, "Ansichtsgruppe"), nil +} + +func operationMilestonePermissionWriteUnsupportedMessage( + role operationMilestoneRole, + objectLabel string, +) string { + return fmt.Sprintf( + "%s ist vorhanden. Berechtigungen fuer Rolle %q werden in Milestone ueber Security-Namespaces/Gruppenrechte verwaltet und nicht ueber das Rollenfeld gesetzt.", + objectLabel, + operationMilestoneRoleDisplayName(role), + ) +} + +func (s *Server) getOperationMilestoneCameraDeviceID( + ctx context.Context, + milestoneHardwareID string, +) (string, error) { + var cameraID string + + err := s.db.QueryRow( + ctx, + ` + SELECT COALESCE(milestone_device_id, '') + FROM milestone_hardware_child_devices + WHERE milestone_hardware_id = $1 + AND lower(device_type) = 'camera' + ORDER BY name ASC, display_name ASC + LIMIT 1 + `, + milestoneHardwareID, + ).Scan(&cameraID) + if errors.Is(err, pgx.ErrNoRows) { + return "", nil + } + + return cameraID, err +} + +func (s *Server) countOperationMilestoneEvaluationRoles( + ctx context.Context, + config milestoneRuntimeConfig, +) (int, error) { + roles, err := listOperationMilestoneRoles(ctx, config) + if err != nil { + return 0, err + } + + count := 0 + for _, role := range roles { + if operationMilestoneEvaluationRolePattern.MatchString(operationMilestoneRoleDisplayName(role)) { + count++ + } + } + + return count, nil +} + +func listOperationMilestoneRoles( + ctx context.Context, + config milestoneRuntimeConfig, +) ([]operationMilestoneRole, error) { + items, err := listOperationMilestoneObjectsAtPath(ctx, config, "/API/rest/v1/roles") + if err != nil { + return nil, err + } + + roles := make([]operationMilestoneRole, 0, len(items)) + for _, item := range items { + role := operationMilestoneRoleFromItem(item) + if role.ID == "" { + continue + } + + roles = append(roles, role) + } + + return roles, nil +} + +func getOperationMilestoneRole( + ctx context.Context, + config milestoneRuntimeConfig, + roleID string, +) (*operationMilestoneRole, error) { + body, _, err := operationMilestoneRequestJSON( + ctx, + config, + http.MethodGet, + "/API/rest/v1/roles/"+url.PathEscape(roleID), + nil, + ) + if err != nil { + return nil, err + } + + role := operationMilestoneRoleFromItem(decodeOperationMilestoneObject(body)) + if role.ID == "" { + return nil, fmt.Errorf("Milestone-Rolle %q konnte nicht geladen werden", roleID) + } + + return &role, nil +} + +func findOperationMilestoneRoleByName( + ctx context.Context, + config milestoneRuntimeConfig, + roleName string, +) (*operationMilestoneRole, error) { + roles, err := listOperationMilestoneRoles(ctx, config) + if err != nil { + return nil, err + } + + normalizedRoleName := normalizeOperationMilestoneName(roleName) + for _, role := range roles { + if normalizeOperationMilestoneName(role.Name) == normalizedRoleName || + normalizeOperationMilestoneName(role.DisplayName) == normalizedRoleName { + return &role, nil + } + } + + return nil, nil +} + +func ensureOperationMilestoneRole( + ctx context.Context, + config milestoneRuntimeConfig, + roleName string, +) (*operationMilestoneRole, error) { + roleName = strings.TrimSpace(roleName) + if roleName == "" { + return nil, errors.New("Rollenname fehlt") + } + + if role, err := findOperationMilestoneRoleByName(ctx, config, roleName); err != nil { + return nil, err + } else if role != nil { + return role, nil + } + + payloads := []map[string]any{ + { + "name": roleName, + "displayName": roleName, + }, + { + "name": roleName, + }, + } + + var lastErr error + for _, payload := range payloads { + body, _, err := operationMilestoneRequestJSON( + ctx, + config, + http.MethodPost, + "/API/rest/v1/roles", + payload, + ) + if err != nil { + lastErr = err + continue + } + + role := operationMilestoneRoleFromItem(decodeOperationMilestoneObject(body)) + if role.ID != "" { + role.Created = true + return &role, nil + } + + roleFromList, err := findOperationMilestoneRoleByName(ctx, config, roleName) + if err != nil { + return nil, err + } + if roleFromList != nil { + roleFromList.Created = true + return roleFromList, nil + } + } + + if lastErr != nil { + return nil, lastErr + } + + return nil, fmt.Errorf("Milestone hat keine ID fuer Rolle %q zurueckgegeben", roleName) +} + +func operationMilestoneRoleFromItem(item map[string]any) operationMilestoneRole { + return operationMilestoneRole{ + ID: milestoneItemID(item), + Name: strings.TrimSpace(milestoneStringValue(item["name"])), + DisplayName: strings.TrimSpace(milestoneStringValue(item["displayName"])), + Data: item, + } +} + +func operationMilestoneRoleDisplayName(role operationMilestoneRole) string { + if role.DisplayName != "" { + return role.DisplayName + } + if role.Name != "" { + return role.Name + } + + return role.ID +} + +func listOperationMilestoneRoleUsers( + ctx context.Context, + config milestoneRuntimeConfig, + roleID string, +) ([]map[string]any, error) { + return listOperationMilestoneObjectsAtPath( + ctx, + config, + "/API/rest/v1/roles/"+url.PathEscape(roleID)+"/users", + ) +} + +func putOperationMilestoneRole( + ctx context.Context, + config milestoneRuntimeConfig, + role operationMilestoneRole, + users []map[string]any, +) error { + if role.Data == nil { + role.Data = map[string]any{} + } + + roleName := strings.TrimSpace(role.Name) + if roleName == "" { + roleName = strings.TrimSpace(role.DisplayName) + } + if roleName == "" { + roleName = role.ID + } + roleDisplayName := strings.TrimSpace(role.DisplayName) + if roleDisplayName == "" { + roleDisplayName = strings.TrimSpace(milestoneStringValue(role.Data["displayName"])) + } + if roleDisplayName == "" { + roleDisplayName = roleName + } + + relations, ok := role.Data["relations"].(map[string]any) + if !ok || relations == nil { + relations = map[string]any{ + "self": map[string]any{ + "type": "roles", + "id": role.ID, + }, + } + } + + payload := map[string]any{ + "allowMobileClientLogOn": milestoneBoolValue(role.Data["allowMobileClientLogOn"]), + "allowSmartClientLogOn": milestoneBoolValue(role.Data["allowSmartClientLogOn"]), + "allowWebClientLogOn": milestoneBoolValue(role.Data["allowWebClientLogOn"]), + "description": milestoneStringValue(role.Data["description"]), + "displayName": roleDisplayName, + "dualAuthorizationRequired": milestoneBoolValue(role.Data["dualAuthorizationRequired"]), + "makeUsersAnonymousDuringPTZSession": milestoneBoolValue(role.Data["makeUsersAnonymousDuringPTZSession"]), + "name": roleName, + "relations": relations, + "users": users, + } + + _, _, err := operationMilestoneRequestJSON( + ctx, + config, + http.MethodPut, + "/API/rest/v1/roles/"+url.PathEscape(role.ID), + payload, + ) + + return err +} + +func patchOperationMilestoneRole( + ctx context.Context, + config milestoneRuntimeConfig, + roleID string, + payload map[string]any, +) error { + _, _, err := operationMilestoneRequestJSON( + ctx, + config, + http.MethodPatch, + "/API/rest/v1/roles/"+url.PathEscape(roleID), + payload, + ) + + return err +} + +func listOperationMilestoneObjectsAtPath( + ctx context.Context, + config milestoneRuntimeConfig, + path string, +) ([]map[string]any, error) { + body, _, err := operationMilestoneRequestJSON(ctx, config, http.MethodGet, path, nil) + if err != nil { + return nil, err + } + + return decodeMilestoneObjectItems(body) +} + +func operationMilestoneRequestJSON( + ctx context.Context, + config milestoneRuntimeConfig, + method string, + path string, + payload any, +) ([]byte, int, error) { + var bodyReader io.Reader + if payload != nil { + body, err := json.Marshal(payload) + if err != nil { + return nil, 0, err + } + bodyReader = bytes.NewReader(body) + } + + requestURL := strings.TrimRight(config.Host, "/") + path + request, err := http.NewRequestWithContext(ctx, method, requestURL, bodyReader) + if err != nil { + return nil, 0, err + } + + setMilestoneRequestHeaders(request, config, payload != nil) + + response, err := milestoneHTTPClient(config.SkipTLSVerify).Do(request) + if err != nil { + return nil, 0, err + } + defer response.Body.Close() + + responseBody, readErr := io.ReadAll(response.Body) + if readErr != nil { + return nil, response.StatusCode, readErr + } + + if response.StatusCode < 200 || response.StatusCode >= 300 { + return responseBody, response.StatusCode, fmt.Errorf( + "milestone %s %s failed: status=%d body=%s", + method, + path, + response.StatusCode, + truncateMilestoneLogBody(responseBody), + ) + } + + return responseBody, response.StatusCode, nil +} + +func decodeOperationMilestoneObject(body []byte) map[string]any { + var decoded map[string]any + if err := json.Unmarshal(body, &decoded); err != nil { + return map[string]any{} + } + + if data, ok := decoded["data"].(map[string]any); ok { + return data + } + + return decoded +} + +func findOperationMilestoneItemIDByName(items []map[string]any, name string) string { + item := findOperationMilestoneItemByName(items, name) + if item == nil { + return "" + } + + return milestoneItemID(item) +} + +func findOperationMilestoneItemByName(items []map[string]any, name string) map[string]any { + normalizedName := normalizeOperationMilestoneName(name) + if normalizedName == "" { + return nil + } + + for _, item := range items { + names := []string{ + operationMilestoneItemDisplayName(item), + milestoneStringValue(item["name"]), + milestoneStringValue(item["userName"]), + milestoneStringValue(item["username"]), + milestoneStringValue(item["accountName"]), + milestoneStringValue(item["loginName"]), + } + + for _, candidate := range names { + if normalizeOperationMilestoneName(candidate) == normalizedName { + return item + } + } + } + + return nil +} + +func operationMilestoneItemDisplayName(item map[string]any) string { + if value := strings.TrimSpace(milestoneStringValue(item["displayName"])); value != "" { + return value + } + if value := strings.TrimSpace(milestoneStringValue(item["name"])); value != "" { + return value + } + + return milestoneItemID(item) +} + +func milestoneItemID(item map[string]any) string { + if item == nil { + return "" + } + + return milestoneDeviceID(item) +} + +func normalizeOperationMilestoneName(value string) string { + return strings.ToLower(strings.Join(strings.Fields(strings.TrimSpace(value)), " ")) +} + +func operationMilestoneObjectListFromValue(value any) []map[string]any { + rawItems, ok := value.([]any) + if !ok { + return []map[string]any{} + } + + items := make([]map[string]any, 0, len(rawItems)) + for _, rawItem := range rawItems { + if item, ok := rawItem.(map[string]any); ok { + items = append(items, item) + } + } + + return items +} + +func appendOperationMilestoneObjectByID( + items []map[string]any, + nextItem map[string]any, +) []map[string]any { + nextID := milestoneItemID(nextItem) + if nextID == "" { + return items + } + + for _, item := range items { + if strings.EqualFold(milestoneItemID(item), nextID) { + return items + } + } + + return append(items, nextItem) +} + +func appendOperationMilestoneObjectsByID( + items []map[string]any, + nextItems ...map[string]any, +) []map[string]any { + for _, nextItem := range nextItems { + items = appendOperationMilestoneObjectByID(items, nextItem) + } + + return items +} + +func milestoneReferenceListFromItems(items []map[string]any) []map[string]any { + refs := make([]map[string]any, 0, len(items)) + for _, item := range items { + if id := milestoneItemID(item); id != "" { + refs = appendMilestoneReference(refs, id) + } + } + + return refs +} + +func milestoneReferenceListFromValue(value any) []map[string]any { + rawItems, ok := value.([]any) + if !ok { + return []map[string]any{} + } + + refs := make([]map[string]any, 0, len(rawItems)) + for _, rawItem := range rawItems { + if item, ok := rawItem.(map[string]any); ok { + if id := milestoneItemID(item); id != "" { + refs = appendMilestoneReference(refs, id) + } + continue + } + + if id := strings.TrimSpace(milestoneStringValue(rawItem)); id != "" { + refs = appendMilestoneReference(refs, id) + } + } + + return refs +} + +func appendMilestoneReference(refs []map[string]any, id string) []map[string]any { + id = strings.TrimSpace(id) + if id == "" { + return refs + } + + for _, ref := range refs { + if strings.EqualFold(strings.TrimSpace(milestoneStringValue(ref["id"])), id) { + return refs + } + } + + return append(refs, map[string]any{ + "id": id, + }) +} diff --git a/backend/routes.go b/backend/routes.go index e25eaaa..ebf1bcc 100644 --- a/backend/routes.go +++ b/backend/routes.go @@ -84,6 +84,10 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("POST /operations", s.requireAuth(s.requireRight("operations:write", s.handleCreateOperation))) mux.HandleFunc("PUT /operations/{id}", s.requireAuth(s.requireRight("operations:write", s.handleUpdateOperation))) + mux.HandleFunc("POST /operations/{id}/complete", s.requireAuth(s.requireRight("operations:write", s.handleCompleteOperation))) + mux.HandleFunc("POST /operations/{id}/reopen", s.requireAuth(s.requireRight("operations:write", s.handleReopenOperation))) + mux.HandleFunc("DELETE /operations/{id}", s.requireAuth(s.requireRight("operations:write", s.handleDeleteOperation))) + mux.HandleFunc("POST /operations/{id}/milestone-setup", s.requireAuth(s.requireRight("operations:write", s.handleOperationMilestoneSetup))) mux.HandleFunc("GET /operations/{id}/history", s.requireAuth(s.handleListOperationHistory)) mux.HandleFunc("POST /operations/{id}/history", s.requireAuth(s.handleCreateOperationJournalEntry)) mux.HandleFunc("PUT /operations/{id}/history/{historyId}", s.requireAuth(s.handleUpdateOperationJournalEntry)) @@ -92,6 +96,7 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("GET /operations/journal-unread", s.requireAuth(s.handleListUnreadOperationJournals)) mux.HandleFunc("GET /operations/assignees", s.requireAuth(s.requireRight("operations:read", s.handleListOperationAssignees))) + mux.HandleFunc("GET /operations/ad-users", s.requireAuth(s.requireRight("operations:read", s.handleListOperationActiveDirectoryUsers))) mux.HandleFunc("GET /search", s.requireAuth(s.handleGlobalSearch)) @@ -165,12 +170,26 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("GET /admin/calendar/settings", s.requireAuth(s.requireRight("administration:read", s.handleGetCalendarSettings))) mux.HandleFunc("PUT /admin/calendar/settings", s.requireAuth(s.requireRight("administration:write", s.handleUpdateCalendarSettings))) + mux.HandleFunc("GET /admin/active-directory", s.requireAuth(s.requireRight("administration:read", s.handleGetActiveDirectorySettings))) + mux.HandleFunc("PUT /admin/active-directory", s.requireAuth(s.requireRight("administration:write", s.handleUpdateActiveDirectorySettings))) + mux.HandleFunc("GET /admin/active-directory/users", s.requireAuth(s.requireRight("administration:read", s.handleAdminListActiveDirectoryUsers))) + mux.HandleFunc("POST /admin/active-directory/base-dn-tree", s.requireAuth(s.requireRight("administration:write", s.handleListActiveDirectoryBaseDNTree))) + mux.HandleFunc("POST /admin/active-directory/group-tree", s.requireAuth(s.requireRight("administration:write", s.handleListActiveDirectoryGroupTree))) + /* milestone settings */ mux.HandleFunc("GET /admin/milestone", s.requireAuth(s.requireRight("administration:read", s.handleGetMilestoneSettings))) mux.HandleFunc("PUT /admin/milestone", s.requireAuth(s.requireRight("administration:write", s.handleUpdateMilestoneSettings))) mux.HandleFunc("POST /admin/milestone/token", s.requireAuth(s.requireRight("administration:write", s.handleFetchMilestoneToken))) mux.HandleFunc("POST /admin/milestone/token/stream", s.requireAuth(s.requireRight("administration:write", s.handleFetchMilestoneTokenStream))) + mux.HandleFunc("GET /admin/milestone/basic-users", s.requireAuth(s.requireRight("administration:read", s.handleAdminListMilestoneBasicUsers))) + mux.HandleFunc("GET /admin/milestone/roles", s.requireAuth(s.requireRight("administration:read", s.handleAdminListMilestoneRoles))) + mux.HandleFunc("POST /admin/milestone/roles", s.requireAuth(s.requireRight("administration:write", s.handleAdminCreateMilestoneRole))) + mux.HandleFunc("GET /admin/milestone/roles/{id}", s.requireAuth(s.requireRight("administration:read", s.handleAdminGetMilestoneRole))) + mux.HandleFunc("PATCH /admin/milestone/roles/{id}", s.requireAuth(s.requireRight("administration:write", s.handleAdminUpdateMilestoneRole))) + mux.HandleFunc("DELETE /admin/milestone/roles/{id}", s.requireAuth(s.requireRight("administration:write", s.handleAdminDeleteMilestoneRole))) + mux.HandleFunc("POST /admin/milestone/roles/{id}/users", s.requireAuth(s.requireRight("administration:write", s.handleAdminAddMilestoneRoleUsers))) + mux.HandleFunc("DELETE /admin/milestone/roles/{id}/users/{userId}", s.requireAuth(s.requireRight("administration:write", s.handleAdminRemoveMilestoneRoleUser))) mux.HandleFunc( "GET /admin/server-folders", s.requireAuth(s.requireRight("administration:read", s.handleProxyListServerFolders)), diff --git a/backend/runtime_schema.go b/backend/runtime_schema.go index a164054..a775ee8 100644 --- a/backend/runtime_schema.go +++ b/backend/runtime_schema.go @@ -14,6 +14,7 @@ func ensureRuntimeDeviceSchema(ctx context.Context, db *pgxpool.Pool) error { `ALTER TABLE IF EXISTS devices ADD COLUMN IF NOT EXISTS device_category TEXT NOT NULL DEFAULT ''`, `ALTER TABLE IF EXISTS devices ADD COLUMN IF NOT EXISTS loaned_to_user_id UUID REFERENCES users(id) ON DELETE SET NULL`, `ALTER TABLE IF EXISTS devices ADD COLUMN IF NOT EXISTS loaned_until DATE`, + `ALTER TABLE IF EXISTS operations ADD COLUMN IF NOT EXISTS completed_at TIMESTAMPTZ`, ` CREATE TABLE IF NOT EXISTS device_relations ( device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE, @@ -42,6 +43,16 @@ func ensureRuntimeDeviceSchema(ctx context.Context, db *pgxpool.Pool) error { `, `ALTER TABLE IF EXISTS milestone_hardware_child_devices ADD COLUMN IF NOT EXISTS recording_enabled BOOLEAN NOT NULL DEFAULT false`, `ALTER TABLE IF EXISTS milestone_hardware_child_devices ADD COLUMN IF NOT EXISTS recording_storage_id TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS server_folders_storage_root_label TEXT NOT NULL DEFAULT 'Storage D'`, + `ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS server_folders_storage_root_path TEXT NOT NULL DEFAULT 'D:/Milestone-Speicher'`, + `ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS server_folders_archive_root_label TEXT NOT NULL DEFAULT 'Archive E'`, + `ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS server_folders_archive_root_path TEXT NOT NULL DEFAULT 'E:/'`, + `ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS active_directory_server_url TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS active_directory_base_dn TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS active_directory_bind_username TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS active_directory_bind_password_encrypted BYTEA`, + `ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS active_directory_user_group TEXT NOT NULL DEFAULT 'SE_Sachbearbeitung'`, + `ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS active_directory_skip_tls_verify BOOLEAN NOT NULL DEFAULT false`, } for _, query := range queries { diff --git a/backend/server_folders_proxy.go b/backend/server_folders_proxy.go index 0132601..d14e57d 100644 --- a/backend/server_folders_proxy.go +++ b/backend/server_folders_proxy.go @@ -17,10 +17,14 @@ import ( ) type serverFoldersAgentConfig struct { - MilestoneHost string - Scheme string - Port string - Token string + MilestoneHost string + Scheme string + Port string + Token string + StorageRootLabel string + StorageRootPath string + ArchiveRootLabel string + ArchiveRootPath string } type serverFoldersAgentHTTPResponse struct { @@ -375,7 +379,11 @@ func (s *Server) getServerFoldersAgentConfig(ctx context.Context) (serverFolders host, COALESCE(server_folders_agent_scheme, 'http'), COALESCE(server_folders_agent_port, '8099'), - COALESCE(pgp_sym_decrypt(server_folders_agent_token_encrypted, $1), '') + COALESCE(pgp_sym_decrypt(server_folders_agent_token_encrypted, $1), ''), + COALESCE(server_folders_storage_root_label, 'Storage D'), + COALESCE(server_folders_storage_root_path, 'D:/Milestone-Speicher'), + COALESCE(server_folders_archive_root_label, 'Archive E'), + COALESCE(server_folders_archive_root_path, 'E:/') FROM milestone_settings WHERE id = 1 LIMIT 1 @@ -386,6 +394,10 @@ func (s *Server) getServerFoldersAgentConfig(ctx context.Context) (serverFolders &config.Scheme, &config.Port, &config.Token, + &config.StorageRootLabel, + &config.StorageRootPath, + &config.ArchiveRootLabel, + &config.ArchiveRootPath, ) if err != nil { return config, err @@ -403,6 +415,10 @@ func (s *Server) getServerFoldersAgentConfig(ctx context.Context) (serverFolders config.MilestoneHost = strings.TrimSpace(config.MilestoneHost) config.Token = strings.TrimSpace(config.Token) + config.StorageRootLabel = normalizeServerFoldersRootLabel(config.StorageRootLabel, "Storage D") + config.StorageRootPath = normalizeServerFoldersRootPath(config.StorageRootPath, "D:/Milestone-Speicher") + config.ArchiveRootLabel = normalizeServerFoldersRootLabel(config.ArchiveRootLabel, "Archive E") + config.ArchiveRootPath = normalizeServerFoldersRootPath(config.ArchiveRootPath, "E:/") return config, nil } @@ -504,6 +520,69 @@ func (s *Server) pushServerFoldersAgentToken( return nil } +func (s *Server) pushServerFoldersAgentConfig( + ctx context.Context, + config serverFoldersAgentConfig, +) error { + targetURL, err := serverFoldersAgentURLForPath(config, "/agent-config", "") + if err != nil { + return err + } + + body, err := json.Marshal(map[string]any{ + "storageRoots": []map[string]string{ + { + "label": config.StorageRootLabel, + "path": config.StorageRootPath, + }, + }, + "archiveRoots": []map[string]string{ + { + "label": config.ArchiveRootLabel, + "path": config.ArchiveRootPath, + }, + }, + }) + if err != nil { + return err + } + + request, err := http.NewRequestWithContext(ctx, http.MethodPut, targetURL, bytes.NewReader(body)) + if err != nil { + return err + } + + request.Header.Set("Accept", "application/json") + request.Header.Set("Content-Type", "application/json") + + if config.Token != "" { + request.Header.Set("X-Server-Folders-Token", config.Token) + } + + client := &http.Client{ + Timeout: 15 * time.Second, + } + + response, err := client.Do(request) + if err != nil { + return err + } + defer response.Body.Close() + + responseBody, _ := io.ReadAll(io.LimitReader(response.Body, 1<<20)) + + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf( + "server-folders-agent config update failed: url=%s status=%d body=%s", + targetURL, + response.StatusCode, + string(responseBody), + ) + } + + return nil +} + func (s *Server) rotateServerFoldersAgentToken( ctx context.Context, config serverFoldersAgentConfig, diff --git a/backend/setup/main.go b/backend/setup/main.go index 78a7f07..dc3c7c8 100644 --- a/backend/setup/main.go +++ b/backend/setup/main.go @@ -550,6 +550,7 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { laptop TEXT NOT NULL DEFAULT '', remark TEXT NOT NULL DEFAULT '', milestone_storage TEXT NOT NULL DEFAULT '', + completed_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() @@ -563,6 +564,7 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { `ALTER TABLE IF EXISTS operations ADD COLUMN IF NOT EXISTS target_longitude DOUBLE PRECISION`, `ALTER TABLE IF EXISTS operations ADD COLUMN IF NOT EXISTS view_cone_fov DOUBLE PRECISION NOT NULL DEFAULT 36`, `ALTER TABLE IF EXISTS operations ADD COLUMN IF NOT EXISTS view_cone_length DOUBLE PRECISION NOT NULL DEFAULT 0`, + `ALTER TABLE IF EXISTS operations ADD COLUMN IF NOT EXISTS completed_at TIMESTAMPTZ`, ` CREATE TABLE IF NOT EXISTS operation_history ( @@ -624,7 +626,17 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { server_folders_agent_scheme TEXT NOT NULL DEFAULT 'http', server_folders_agent_port TEXT NOT NULL DEFAULT '8099', + server_folders_storage_root_label TEXT NOT NULL DEFAULT 'Storage D', + server_folders_storage_root_path TEXT NOT NULL DEFAULT 'D:/Milestone-Speicher', + server_folders_archive_root_label TEXT NOT NULL DEFAULT 'Archive E', + server_folders_archive_root_path TEXT NOT NULL DEFAULT 'E:/', server_folders_agent_token_encrypted BYTEA, + active_directory_server_url TEXT NOT NULL DEFAULT '', + active_directory_base_dn TEXT NOT NULL DEFAULT '', + active_directory_bind_username TEXT NOT NULL DEFAULT '', + active_directory_bind_password_encrypted BYTEA, + active_directory_user_group TEXT NOT NULL DEFAULT 'SE_Sachbearbeitung', + active_directory_skip_tls_verify BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() @@ -641,11 +653,61 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { ADD COLUMN IF NOT EXISTS server_folders_agent_port TEXT NOT NULL DEFAULT '8099' `, + ` + ALTER TABLE milestone_settings + ADD COLUMN IF NOT EXISTS server_folders_storage_root_label TEXT NOT NULL DEFAULT 'Storage D' + `, + + ` + ALTER TABLE milestone_settings + ADD COLUMN IF NOT EXISTS server_folders_storage_root_path TEXT NOT NULL DEFAULT 'D:/Milestone-Speicher' + `, + + ` + ALTER TABLE milestone_settings + ADD COLUMN IF NOT EXISTS server_folders_archive_root_label TEXT NOT NULL DEFAULT 'Archive E' + `, + + ` + ALTER TABLE milestone_settings + ADD COLUMN IF NOT EXISTS server_folders_archive_root_path TEXT NOT NULL DEFAULT 'E:/' + `, + ` ALTER TABLE milestone_settings ADD COLUMN IF NOT EXISTS server_folders_agent_token_encrypted BYTEA `, + ` + ALTER TABLE milestone_settings + ADD COLUMN IF NOT EXISTS active_directory_server_url TEXT NOT NULL DEFAULT '' + `, + + ` + ALTER TABLE milestone_settings + ADD COLUMN IF NOT EXISTS active_directory_base_dn TEXT NOT NULL DEFAULT '' + `, + + ` + ALTER TABLE milestone_settings + ADD COLUMN IF NOT EXISTS active_directory_bind_username TEXT NOT NULL DEFAULT '' + `, + + ` + ALTER TABLE milestone_settings + ADD COLUMN IF NOT EXISTS active_directory_bind_password_encrypted BYTEA + `, + + ` + ALTER TABLE milestone_settings + ADD COLUMN IF NOT EXISTS active_directory_user_group TEXT NOT NULL DEFAULT 'SE_Sachbearbeitung' + `, + + ` + ALTER TABLE milestone_settings + ADD COLUMN IF NOT EXISTS active_directory_skip_tls_verify BOOLEAN NOT NULL DEFAULT false + `, + ` CREATE TABLE IF NOT EXISTS notification_preferences ( user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 99261fb..9f15346 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -21,6 +21,7 @@ import Feedback from './components/Feedback' import MilestonePage from './pages/milestone/MilestonePage' import MilestoneStoragePage from './pages/milestone/speicher/MilestoneStoragePage' import MilestoneDevicesPage from './pages/milestone/geraete/MilestoneDevicesPage' +import MilestoneRolesPage from './pages/milestone/rollen/MilestoneRolesPage' import { hasRight } from './components/permissions' import ChatPage from './pages/chat/ChatPage' import { ChatSocketProvider } from './components/ChatSocketProvider' @@ -699,6 +700,18 @@ function App() { } /> + + + + } + /> + } diff --git a/frontend/src/components/Combobox.tsx b/frontend/src/components/Combobox.tsx index 549698f..7f55899 100644 --- a/frontend/src/components/Combobox.tsx +++ b/frontend/src/components/Combobox.tsx @@ -23,6 +23,9 @@ export type ComboboxItem = { imageUrl?: string secondaryText?: string username?: string + email?: string + sid?: string + distinguishedName?: string } type ComboboxProps = { diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 0dd9f02..6d617c9 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -16,6 +16,7 @@ import { DocumentDuplicateIcon, ServerStackIcon, ShieldCheckIcon, + UserGroupIcon, VideoCameraIcon, HomeIcon, XMarkIcon, @@ -81,6 +82,12 @@ const navigationSections: NavigationSection[] = [ icon: ServerStackIcon, right: 'administration:read', }, + { + name: 'Rollen', + href: '/milestone/rollen', + icon: UserGroupIcon, + right: 'administration:read', + }, ], }, ], diff --git a/frontend/src/components/TreeList.tsx b/frontend/src/components/TreeList.tsx index e21f7b0..8adb0a4 100644 --- a/frontend/src/components/TreeList.tsx +++ b/frontend/src/components/TreeList.tsx @@ -38,6 +38,9 @@ type TreeListProps = { previewPath?: string; previewName?: string; previewLabel?: string; + emptyMessage?: string; + showFallbackNodes?: boolean; + showNewFolderInput?: boolean; showCreateButton?: boolean; readOnly?: boolean; disabled?: boolean; @@ -370,6 +373,9 @@ export default function TreeList({ previewPath, previewName, previewLabel, + emptyMessage = "Noch kein Speicherpfad ausgewählt.", + showFallbackNodes = true, + showNewFolderInput = true, showCreateButton = true, readOnly = false, disabled = false, @@ -380,8 +386,10 @@ export default function TreeList({ () => Array.isArray(nodes) && nodes.length > 0 ? nodes - : buildFallbackNodes(selectedPath), - [nodes, selectedPath], + : showFallbackNodes + ? buildFallbackNodes(selectedPath) + : [], + [nodes, selectedPath, showFallbackNodes], ); const displayNodes = useMemo( () => @@ -478,10 +486,11 @@ export default function TreeList({ ) : (
- Noch kein Speicherpfad ausgewählt. + {emptyMessage}
)} + {showNewFolderInput && (
)}
+ )}
diff --git a/frontend/src/components/types.ts b/frontend/src/components/types.ts index efc8c21..e0c69a0 100644 --- a/frontend/src/components/types.ts +++ b/frontend/src/components/types.ts @@ -273,6 +273,7 @@ export type Operation = { laptop: string remark: string milestoneStorage: string + completedAt?: string | null createdAt: string updatedAt: string } diff --git a/frontend/src/pages/administration/AdministrationPage.tsx b/frontend/src/pages/administration/AdministrationPage.tsx index 11bfcd0..71ab05f 100644 --- a/frontend/src/pages/administration/AdministrationPage.tsx +++ b/frontend/src/pages/administration/AdministrationPage.tsx @@ -4,6 +4,7 @@ import { useLocation, Navigate } from 'react-router' import { CalendarDaysIcon, ChatBubbleLeftRightIcon, + KeyIcon, UserGroupIcon, UsersIcon, VideoCameraIcon, @@ -14,6 +15,7 @@ import FeedbackAdministration from './feedback/FeedbackAdministration' import Milestone from './milestone/Milestone' import ChatsAdministration from './chats/ChatsAdministration' import CalendarAdministration from './calendar/CalendarAdministration' +import ActiveDirectoryAdministration from './active-directory/ActiveDirectoryAdministration' export default function AdministrationPage() { const location = useLocation() @@ -35,6 +37,12 @@ export default function AdministrationPage() { icon: UserGroupIcon, current: section === 'chats', }, + { + name: 'Active Directory', + href: '/administration/active-directory', + icon: KeyIcon, + current: section === 'active-directory', + }, { name: 'Milestone', href: '/administration/milestone', @@ -64,7 +72,7 @@ export default function AdministrationPage() { } if ( - !['benutzer', 'chats', 'milestone', 'kalender', 'feedback'].includes( + !['benutzer', 'chats', 'milestone', 'active-directory', 'kalender', 'feedback'].includes( section, ) ) { @@ -99,6 +107,7 @@ export default function AdministrationPage() { {section === 'benutzer' && } {section === 'chats' && } {section === 'milestone' && } + {section === 'active-directory' && } {section === 'kalender' && } {section === 'feedback' && } diff --git a/frontend/src/pages/administration/active-directory/ActiveDirectoryAdministration.tsx b/frontend/src/pages/administration/active-directory/ActiveDirectoryAdministration.tsx new file mode 100644 index 0000000..ddc1933 --- /dev/null +++ b/frontend/src/pages/administration/active-directory/ActiveDirectoryAdministration.tsx @@ -0,0 +1,554 @@ +import { useEffect, useRef, useState, type FormEvent } from 'react' +import { + KeyIcon, + ServerStackIcon, + UserGroupIcon, +} from '@heroicons/react/20/solid' +import Button from '../../../components/Button' +import LoadingSpinner from '../../../components/LoadingSpinner' +import Modal, { ModalTitle } from '../../../components/Modal' +import Switch from '../../../components/Switch' +import TreeList, { type TreeListNode } from '../../../components/TreeList' +import { useNotifications } from '../../../components/Notifications' + +const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080' + +type ActiveDirectorySettings = { + serverUrl: string + baseDn: string + bindUsername: string + bindPasswordConfigured: boolean + skipTlsVerify: boolean + configured: boolean + updatedAt?: string +} + +type ActiveDirectoryTreeResponse = { + nodes?: TreeListNode[] +} + +const inputClassName = + 'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500' + +async function readApiError(response: Response, fallback: string) { + const data = await response.json().catch(() => null) + + return data?.error ?? fallback +} + +function formatUpdatedAt(value?: string) { + if (!value) { + return null + } + + try { + return new Intl.DateTimeFormat('de-DE', { + dateStyle: 'short', + timeStyle: 'short', + }).format(new Date(value)) + } catch { + return value + } +} + +function getActiveDirectoryServerHost(value?: string) { + const trimmedValue = value?.trim() ?? '' + if (!trimmedValue) { + return '' + } + + try { + const serverUrl = new URL( + trimmedValue.includes('://') ? trimmedValue : `ldaps://${trimmedValue}`, + ) + + return serverUrl.hostname || trimmedValue + } catch { + return trimmedValue + .replace(/^[a-z][a-z0-9+.-]*:\/\//i, '') + .replace(/\/.*$/, '') + .replace(/:\d+$/, '') + } +} + +export default function ActiveDirectoryAdministration() { + const { showToast, showErrorToast } = useNotifications() + const formRef = useRef(null) + const [settings, setSettings] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [isSaving, setIsSaving] = useState(false) + const [skipTlsVerify, setSkipTlsVerify] = useState(false) + const [baseDn, setBaseDn] = useState('') + const [baseDnPickerOpen, setBaseDnPickerOpen] = useState(false) + const [baseDnTreeNodes, setBaseDnTreeNodes] = useState([]) + const [baseDnSelection, setBaseDnSelection] = useState('') + const [baseDnTreeError, setBaseDnTreeError] = useState(null) + const [isLoadingBaseDnTree, setIsLoadingBaseDnTree] = useState(false) + + useEffect(() => { + void loadSettings() + }, []) + + async function loadSettings() { + setIsLoading(true) + + try { + const response = await fetch(`${API_URL}/admin/active-directory`, { + credentials: 'include', + }) + + if (!response.ok) { + throw new Error( + await readApiError( + response, + 'AD-Einstellungen konnten nicht geladen werden', + ), + ) + } + + const data = (await response.json()) as { + settings: ActiveDirectorySettings + } + setSettings(data.settings) + setSkipTlsVerify(Boolean(data.settings?.skipTlsVerify)) + setBaseDn(data.settings?.baseDn ?? '') + } catch (error) { + showErrorToast({ + title: 'Einstellungen konnten nicht geladen werden', + error, + fallback: 'AD-Einstellungen konnten nicht geladen werden', + }) + } finally { + setIsLoading(false) + } + } + + async function handleSubmit(event: FormEvent) { + event.preventDefault() + + const form = event.currentTarget + const formData = new FormData(form) + + setIsSaving(true) + + try { + const response = await fetch(`${API_URL}/admin/active-directory`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify({ + serverUrl: String(formData.get('serverHost') ?? ''), + baseDn, + bindUsername: String(formData.get('bindUsername') ?? ''), + bindPassword: String(formData.get('bindPassword') ?? ''), + skipTlsVerify, + }), + }) + + if (!response.ok) { + throw new Error( + await readApiError( + response, + 'AD-Einstellungen konnten nicht gespeichert werden', + ), + ) + } + + const data = (await response.json()) as { + settings: ActiveDirectorySettings + } + setSettings(data.settings) + setSkipTlsVerify(Boolean(data.settings?.skipTlsVerify)) + setBaseDn(data.settings?.baseDn ?? '') + + const passwordInput = form.elements.namedItem('bindPassword') + if (passwordInput instanceof HTMLInputElement) { + passwordInput.value = '' + } + + showToast({ + variant: 'success', + title: 'Active Directory gespeichert', + message: 'Die AD-Einstellungen wurden aktualisiert.', + }) + } catch (error) { + showErrorToast({ + title: 'Speichern fehlgeschlagen', + error, + fallback: 'AD-Einstellungen konnten nicht gespeichert werden', + }) + } finally { + setIsSaving(false) + } + } + + async function loadBaseDnTree() { + const form = formRef.current + const formData = form ? new FormData(form) : new FormData() + + setIsLoadingBaseDnTree(true) + setBaseDnTreeError(null) + + try { + const response = await fetch(`${API_URL}/admin/active-directory/base-dn-tree`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify({ + serverUrl: String(formData.get('serverHost') ?? ''), + bindUsername: String(formData.get('bindUsername') ?? ''), + bindPassword: String(formData.get('bindPassword') ?? ''), + baseDn, + skipTlsVerify, + }), + }) + + if (!response.ok) { + throw new Error( + await readApiError( + response, + 'AD-Struktur konnte nicht geladen werden', + ), + ) + } + + const data = (await response.json()) as ActiveDirectoryTreeResponse + setBaseDnTreeNodes(Array.isArray(data.nodes) ? data.nodes : []) + } catch (error) { + const message = + error instanceof Error + ? error.message + : 'AD-Struktur konnte nicht geladen werden' + + setBaseDnTreeError(message) + showErrorToast({ + title: 'AD-Struktur konnte nicht geladen werden', + error, + fallback: 'AD-Struktur konnte nicht geladen werden', + }) + } finally { + setIsLoadingBaseDnTree(false) + } + } + + function openBaseDnPicker() { + setBaseDnSelection(baseDn) + setBaseDnPickerOpen(true) + void loadBaseDnTree() + } + + function applyBaseDnSelection() { + setBaseDn(baseDnSelection) + setBaseDnPickerOpen(false) + } + + if (isLoading) { + return ( +
+ +
+ ) + } + + const updatedAt = formatUpdatedAt(settings?.updatedAt) + + return ( + <> + +
+ Base-DN auswählen +

+ Wähle die Domäne oder OU, unter der nach Benutzern gesucht werden + soll. Meist ist die oberste Domäne die + richtige Wahl. +

+
+ +
+ {baseDnTreeError && ( +
+ {baseDnTreeError} +
+ )} + + {isLoadingBaseDnTree ? ( +
+ +
+ ) : ( + + )} +
+ +
+ + +
+ + + +
+
+
+ +
+
+
+
+
+ +
+

+ Active Directory +

+ +

+ Benutzer aus dem Suchbereich werden beim Anlegen eines Einsatzes + und in der Milestone-Rollenverwaltung angeboten. +

+
+
+ +
+
+ + +
+ +
+
+ +
+ + +
+ +
+
+ +
+ + +
+ +
+
+ +
+ +

+ Wähle die Domäne oder OU, aus der AD-Benutzer geladen werden. +

+ +
+ setBaseDn(event.target.value)} + placeholder="DC=example,DC=local" + className={inputClassName} + /> + + +
+
+ +
+
+ setSkipTlsVerify(event.target.checked)} + label="AD-TLS-Prüfung deaktivieren" + description="Nur für interne Testsysteme verwenden." + variant="simple" + /> +
+
+
+ +
+ +
+
+ + +
+ + ) +} diff --git a/frontend/src/pages/administration/milestone/Milestone.tsx b/frontend/src/pages/administration/milestone/Milestone.tsx index 75aee02..362bf5e 100644 --- a/frontend/src/pages/administration/milestone/Milestone.tsx +++ b/frontend/src/pages/administration/milestone/Milestone.tsx @@ -33,6 +33,10 @@ type MilestoneSettings = { serverFoldersAgentScheme: string serverFoldersAgentPort: string serverFoldersAgentTokenConfigured: boolean + serverFoldersStorageRootLabel: string + serverFoldersStorageRootPath: string + serverFoldersArchiveRootLabel: string + serverFoldersArchiveRootPath: string updatedAt?: string } type MilestoneSyncEvent = { @@ -412,6 +416,18 @@ export default function Milestone() { serverFoldersAgentPort: String( formData.get('serverFoldersAgentPort') ?? '8099', ), + serverFoldersStorageRootLabel: String( + formData.get('serverFoldersStorageRootLabel') ?? 'Storage D', + ), + serverFoldersStorageRootPath: String( + formData.get('serverFoldersStorageRootPath') ?? 'D:/Milestone-Speicher', + ), + serverFoldersArchiveRootLabel: String( + formData.get('serverFoldersArchiveRootLabel') ?? 'Archive E', + ), + serverFoldersArchiveRootPath: String( + formData.get('serverFoldersArchiveRootPath') ?? 'E:/', + ), }), }) @@ -430,7 +446,10 @@ export default function Milestone() { setSettings(data.settings) setSkipTlsVerify(Boolean(data.settings?.skipTlsVerify)) - form.reset() + const passwordInput = form.elements.namedItem('password') + if (passwordInput instanceof HTMLInputElement) { + passwordInput.value = '' + } await runMilestoneSyncStream() } catch (error) { @@ -697,6 +716,86 @@ export default function Milestone() { +
+ + +
+ +
+
+ +
+ + +
+ +
+
+ +
+ + +
+ +
+
+ +
+ + +
+ +
+
+

Der Agent-Token wird beim Speichern automatisch erzeugt, an @@ -822,6 +921,28 @@ export default function Milestone() {

+
+
+ Storage-Root +
+
+ {(settings?.serverFoldersStorageRootLabel || 'Storage D') + + ': ' + + (settings?.serverFoldersStorageRootPath || 'D:/Milestone-Speicher')} +
+
+ +
+
+ Archiv-Root +
+
+ {(settings?.serverFoldersArchiveRootLabel || 'Archive E') + + ': ' + + (settings?.serverFoldersArchiveRootPath || 'E:/')} +
+
+ {settings?.tokenExpiresAt && (
@@ -870,4 +991,4 @@ export default function Milestone() {
) -} \ No newline at end of file +} diff --git a/frontend/src/pages/milestone/MilestonePage.tsx b/frontend/src/pages/milestone/MilestonePage.tsx index dbe42ce..57c8edb 100644 --- a/frontend/src/pages/milestone/MilestonePage.tsx +++ b/frontend/src/pages/milestone/MilestonePage.tsx @@ -7,6 +7,7 @@ import { ExclamationTriangleIcon, MicrophoneIcon, ServerStackIcon, + UserGroupIcon, VideoCameraIcon, } from '@heroicons/react/24/outline' import { Link } from 'react-router' @@ -77,6 +78,13 @@ const featureCards = [ href: '/milestone/speicher', icon: ServerStackIcon, }, + { + title: 'Rollen', + description: + 'Milestone-Rollen verwalten und Benutzer aus dem Active Directory zuordnen.', + href: '/milestone/rollen', + icon: UserGroupIcon, + }, ] function classNames(...classes: Array) { diff --git a/frontend/src/pages/milestone/rollen/MilestoneRolesPage.tsx b/frontend/src/pages/milestone/rollen/MilestoneRolesPage.tsx new file mode 100644 index 0000000..67fda4f --- /dev/null +++ b/frontend/src/pages/milestone/rollen/MilestoneRolesPage.tsx @@ -0,0 +1,1181 @@ +import { useEffect, useMemo, useState, type FormEvent } from 'react' +import { + ArrowPathIcon, + PencilSquareIcon, + PlusIcon, + TrashIcon, + UserGroupIcon, + UserPlusIcon, + XMarkIcon, +} from '@heroicons/react/24/outline' +import Button from '../../../components/Button' +import LoadingSpinner from '../../../components/LoadingSpinner' +import Modal, { ModalTitle } from '../../../components/Modal' +import { useNotifications } from '../../../components/Notifications' + +const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080' + +type MilestoneRoleUser = { + id: string + displayName: string + name: string + userName: string + username: string + email: string + domain: string + sid: string + distinguishedName: string + source: UserSource +} + +type MilestoneRole = { + id: string + name: string + displayName: string + description: string + users?: MilestoneRoleUser[] + userCount: number + canEdit?: boolean + canDelete?: boolean +} + +type ActiveDirectoryUser = { + id: string + displayName: string + name?: string + userName?: string + username: string + email: string + domain: string + distinguishedName: string + sid: string + source: UserSource +} + +type MilestoneBasicUser = { + id: string + displayName: string + name: string + userName: string + username: string + email: string + domain: string + source: UserSource +} + +type RolesResponse = { + roles?: MilestoneRole[] +} + +type RoleResponse = { + role?: MilestoneRole +} + +type ActiveDirectoryUsersResponse = { + configured?: boolean + baseDn?: string + users?: ActiveDirectoryUser[] +} + +type BasicUsersResponse = { + users?: MilestoneBasicUser[] +} + +type RoleFormState = { + id?: string + name: string + displayName: string + description: string +} + +type UserSource = 'ad' | 'basic' +type AssignableUser = ActiveDirectoryUser | MilestoneBasicUser + +type Props = { + canWrite?: boolean + isAdministrator?: boolean +} + +const emptyRoleForm: RoleFormState = { + name: '', + displayName: '', + description: '', +} + +function classNames(...classes: Array) { + return classes.filter(Boolean).join(' ') +} + +async function readApiError(response: Response, fallback: string) { + const data = await response.json().catch(() => null) + + return data?.error ?? fallback +} + +function roleTitle(role?: MilestoneRole | null) { + return role?.displayName || role?.name || role?.id || 'Rolle' +} + +function roleIsAdministrators(role?: MilestoneRole | null) { + return [role?.name, role?.displayName] + .filter(Boolean) + .some((value) => value?.trim().toLowerCase() === 'administrators') +} + +function userSource(user: MilestoneRoleUser | AssignableUser): UserSource { + return user.source === 'basic' ? 'basic' : 'ad' +} + +function userAccountName(user: MilestoneRoleUser | AssignableUser) { + if ('username' in user && user.username) { + return user.username + } + if ('userName' in user && user.userName) { + return user.userName + } + if ('name' in user && user.name) { + return user.name + } + + return user.id +} + +function userTitle(user: MilestoneRoleUser | AssignableUser) { + const accountName = userAccountName(user) + const domain = user.domain?.trim() + + if (domain && accountName) { + return `${domain}\\${accountName}` + } + + return accountName || user.displayName || user.id +} + +function adUserSubtitle(user: ActiveDirectoryUser) { + return [user.username, user.email].filter(Boolean).join(' / ') +} + +function milestoneUserSubtitle(user: MilestoneRoleUser) { + return [user.userName || user.name, user.email].filter(Boolean).join(' / ') +} + +function userSubtitle(user: MilestoneRoleUser | AssignableUser) { + const fallbackSubtitle = + userSource(user) === 'ad' + ? adUserSubtitle(user as ActiveDirectoryUser) + : milestoneUserSubtitle(user as MilestoneRoleUser) + + return [ + user.displayName && user.displayName !== userTitle(user) + ? user.displayName + : '', + userSource(user) === 'basic' ? 'Basisbenutzer' : '', + user.email || fallbackSubtitle, + ] + .filter(Boolean) + .join(' / ') +} + +function userSelectionKey(user: AssignableUser) { + return `${userSource(user)}:${user.id}` +} + +export default function MilestoneRolesPage({ + canWrite = false, + isAdministrator = false, +}: Props) { + const { showToast, showErrorToast } = useNotifications() + const [roles, setRoles] = useState([]) + const [selectedRoleId, setSelectedRoleId] = useState('') + const [selectedRole, setSelectedRole] = useState(null) + const [isLoadingRoles, setIsLoadingRoles] = useState(true) + const [isLoadingRole, setIsLoadingRole] = useState(false) + const [roleSearch, setRoleSearch] = useState('') + const [roleModalOpen, setRoleModalOpen] = useState(false) + const [roleForm, setRoleForm] = useState(emptyRoleForm) + const [isSavingRole, setIsSavingRole] = useState(false) + const [deleteModalOpen, setDeleteModalOpen] = useState(false) + const [isDeletingRole, setIsDeletingRole] = useState(false) + const [adModalOpen, setAdModalOpen] = useState(false) + const [adUsers, setAdUsers] = useState([]) + const [basicUsers, setBasicUsers] = useState([]) + const [adBaseDn, setAdBaseDn] = useState('') + const [adConfigured, setAdConfigured] = useState(true) + const [isLoadingAdUsers, setIsLoadingAdUsers] = useState(false) + const [isLoadingBasicUsers, setIsLoadingBasicUsers] = useState(false) + const [isAddingUsers, setIsAddingUsers] = useState(false) + const [adSearch, setAdSearch] = useState('') + const [userPickerSource, setUserPickerSource] = useState('ad') + const [selectedUserKeys, setSelectedUserKeys] = useState>( + () => new Set(), + ) + const [removingUserId, setRemovingUserId] = useState('') + + useEffect(() => { + void loadRoles() + }, []) + + async function loadRoles(preferredRoleId = selectedRoleId) { + setIsLoadingRoles(true) + + try { + const response = await fetch(`${API_URL}/admin/milestone/roles`, { + credentials: 'include', + }) + if (!response.ok) { + throw new Error( + await readApiError( + response, + 'Milestone-Rollen konnten nicht geladen werden', + ), + ) + } + + const data = (await response.json()) as RolesResponse + const nextRoles = Array.isArray(data.roles) ? data.roles : [] + setRoles(nextRoles) + + const nextSelectedId = + nextRoles.find((role) => role.id === preferredRoleId)?.id || + nextRoles[0]?.id || + '' + setSelectedRoleId(nextSelectedId) + + if (nextSelectedId) { + await loadRole(nextSelectedId) + } else { + setSelectedRole(null) + } + } catch (error) { + showErrorToast({ + title: 'Rollen konnten nicht geladen werden', + error, + fallback: 'Milestone-Rollen konnten nicht geladen werden', + }) + } finally { + setIsLoadingRoles(false) + } + } + + async function loadRole(roleId: string) { + setIsLoadingRole(true) + + try { + const response = await fetch( + `${API_URL}/admin/milestone/roles/${encodeURIComponent(roleId)}`, + { credentials: 'include' }, + ) + if (!response.ok) { + throw new Error( + await readApiError( + response, + 'Milestone-Rolle konnte nicht geladen werden', + ), + ) + } + + const data = (await response.json()) as RoleResponse + if (data.role) { + setSelectedRole(data.role) + mergeRole(data.role) + } + } catch (error) { + showErrorToast({ + title: 'Rolle konnte nicht geladen werden', + error, + fallback: 'Milestone-Rolle konnte nicht geladen werden', + }) + } finally { + setIsLoadingRole(false) + } + } + + function mergeRole(role: MilestoneRole) { + setRoles((currentRoles) => + currentRoles.map((currentRole) => + currentRole.id === role.id + ? { + ...currentRole, + ...role, + userCount: role.userCount ?? role.users?.length ?? currentRole.userCount, + } + : currentRole, + ), + ) + } + + function selectRole(roleId: string) { + setSelectedRoleId(roleId) + void loadRole(roleId) + } + + function openCreateModal() { + setRoleForm(emptyRoleForm) + setRoleModalOpen(true) + } + + function openEditModal() { + if (!selectedRole) { + return + } + + setRoleForm({ + id: selectedRole.id, + name: selectedRole.name || selectedRole.displayName, + displayName: selectedRole.displayName, + description: selectedRole.description || '', + }) + setRoleModalOpen(true) + } + + async function handleRoleSubmit(event: FormEvent) { + event.preventDefault() + setIsSavingRole(true) + + try { + const isEdit = Boolean(roleForm.id) + const response = await fetch( + isEdit + ? `${API_URL}/admin/milestone/roles/${encodeURIComponent(roleForm.id ?? '')}` + : `${API_URL}/admin/milestone/roles`, + { + method: isEdit ? 'PATCH' : 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify({ + name: roleForm.name, + displayName: roleForm.displayName, + description: roleForm.description, + }), + }, + ) + if (!response.ok) { + throw new Error( + await readApiError( + response, + isEdit + ? 'Milestone-Rolle konnte nicht gespeichert werden' + : 'Milestone-Rolle konnte nicht angelegt werden', + ), + ) + } + + const data = (await response.json()) as RoleResponse + setRoleModalOpen(false) + showToast({ + variant: 'success', + title: isEdit ? 'Rolle gespeichert' : 'Rolle angelegt', + }) + + await loadRoles(data.role?.id || selectedRoleId) + } catch (error) { + showErrorToast({ + title: 'Speichern fehlgeschlagen', + error, + fallback: 'Milestone-Rolle konnte nicht gespeichert werden', + }) + } finally { + setIsSavingRole(false) + } + } + + async function deleteSelectedRole() { + if (!selectedRole) { + return + } + + setIsDeletingRole(true) + + try { + const response = await fetch( + `${API_URL}/admin/milestone/roles/${encodeURIComponent(selectedRole.id)}`, + { + method: 'DELETE', + credentials: 'include', + }, + ) + if (!response.ok) { + throw new Error( + await readApiError( + response, + 'Milestone-Rolle konnte nicht gelöscht werden', + ), + ) + } + + setDeleteModalOpen(false) + showToast({ + variant: 'success', + title: 'Rolle gelöscht', + }) + await loadRoles('') + } catch (error) { + showErrorToast({ + title: 'Löschen fehlgeschlagen', + error, + fallback: 'Milestone-Rolle konnte nicht gelöscht werden', + }) + } finally { + setIsDeletingRole(false) + } + } + + async function openAdUserModal() { + setAdModalOpen(true) + setSelectedUserKeys(new Set()) + setAdSearch('') + setUserPickerSource('ad') + await Promise.all([loadAdUsers(), loadBasicUsers()]) + } + + async function loadAdUsers() { + setIsLoadingAdUsers(true) + + try { + const response = await fetch(`${API_URL}/admin/active-directory/users`, { + credentials: 'include', + }) + if (!response.ok) { + throw new Error( + await readApiError( + response, + 'AD-Benutzer konnten nicht geladen werden', + ), + ) + } + + const data = (await response.json()) as ActiveDirectoryUsersResponse + setAdConfigured(Boolean(data.configured)) + setAdBaseDn(data.baseDn || '') + setAdUsers(Array.isArray(data.users) ? data.users : []) + } catch (error) { + showErrorToast({ + title: 'AD-Benutzer konnten nicht geladen werden', + error, + fallback: 'AD-Benutzer konnten nicht geladen werden', + }) + } finally { + setIsLoadingAdUsers(false) + } + } + + async function loadBasicUsers() { + setIsLoadingBasicUsers(true) + + try { + const response = await fetch(`${API_URL}/admin/milestone/basic-users`, { + credentials: 'include', + }) + if (!response.ok) { + throw new Error( + await readApiError( + response, + 'Basisbenutzer konnten nicht geladen werden', + ), + ) + } + + const data = (await response.json()) as BasicUsersResponse + setBasicUsers(Array.isArray(data.users) ? data.users : []) + } catch (error) { + showErrorToast({ + title: 'Basisbenutzer konnten nicht geladen werden', + error, + fallback: 'Basisbenutzer konnten nicht geladen werden', + }) + } finally { + setIsLoadingBasicUsers(false) + } + } + + function toggleUserSelection(user: AssignableUser) { + const key = userSelectionKey(user) + + setSelectedUserKeys((current) => { + const next = new Set(current) + if (next.has(key)) { + next.delete(key) + } else { + next.add(key) + } + + return next + }) + } + + async function addSelectedAdUsers() { + if (!selectedRole || selectedUserKeys.size === 0) { + return + } + + const users = [...adUsers, ...basicUsers].filter((user) => + selectedUserKeys.has(userSelectionKey(user)), + ) + setIsAddingUsers(true) + + try { + const response = await fetch( + `${API_URL}/admin/milestone/roles/${encodeURIComponent(selectedRole.id)}/users`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify({ users }), + }, + ) + if (!response.ok) { + throw new Error( + await readApiError( + response, + 'Benutzer konnten nicht hinzugefügt werden', + ), + ) + } + + const data = (await response.json()) as RoleResponse + if (data.role) { + setSelectedRole(data.role) + mergeRole(data.role) + } + + setAdModalOpen(false) + showToast({ + variant: 'success', + title: users.length === 1 ? 'Benutzer hinzugefügt' : 'Benutzer hinzugefügt', + message: + users.length === 1 + ? `${userTitle(users[0])} wurde der Rolle hinzugefügt.` + : `${users.length} Benutzer wurden der Rolle hinzugefügt.`, + }) + } catch (error) { + showErrorToast({ + title: 'Hinzufügen fehlgeschlagen', + error, + fallback: 'Benutzer konnten nicht hinzugefügt werden', + }) + } finally { + setIsAddingUsers(false) + } + } + + async function removeRoleUser(userId: string) { + if (!selectedRole) { + return + } + + setRemovingUserId(userId) + + try { + const response = await fetch( + `${API_URL}/admin/milestone/roles/${encodeURIComponent( + selectedRole.id, + )}/users/${encodeURIComponent(userId)}`, + { + method: 'DELETE', + credentials: 'include', + }, + ) + if (!response.ok) { + throw new Error( + await readApiError( + response, + 'Benutzer konnte nicht entfernt werden', + ), + ) + } + + const data = (await response.json()) as RoleResponse + if (data.role) { + setSelectedRole(data.role) + mergeRole(data.role) + } + } catch (error) { + showErrorToast({ + title: 'Entfernen fehlgeschlagen', + error, + fallback: 'Benutzer konnte nicht aus der Rolle entfernt werden', + }) + } finally { + setRemovingUserId('') + } + } + + const filteredRoles = useMemo(() => { + const visibleRoles = roles.filter( + (role) => isAdministrator || !roleIsAdministrators(role), + ) + const query = roleSearch.trim().toLowerCase() + if (!query) { + return visibleRoles + } + + return visibleRoles.filter((role) => + [role.displayName, role.name, role.description, role.id] + .filter(Boolean) + .some((value) => value.toLowerCase().includes(query)), + ) + }, [isAdministrator, roleSearch, roles]) + + const roleUsers = selectedRole?.users ?? [] + const visibleAssignableUsersBySource = useMemo>(() => { + const existingUserIds = new Set(roleUsers.map((user) => user.id)) + const query = adSearch.trim().toLowerCase() + + const filterVisibleUsers = (users: AssignableUser[]) => + users + .filter((user) => !existingUserIds.has(user.id)) + .filter((user) => { + if (!query) { + return true + } + + return [ + user.displayName, + user.username, + user.userName, + user.name, + user.domain, + userTitle(user), + user.email, + 'distinguishedName' in user ? user.distinguishedName : '', + 'sid' in user ? user.sid : '', + ] + .filter((value): value is string => Boolean(value)) + .some((value) => value.toLowerCase().includes(query)) + }) + + return { + ad: filterVisibleUsers(adUsers), + basic: filterVisibleUsers(basicUsers), + } + }, [adSearch, adUsers, basicUsers, roleUsers]) + + const filteredAssignableUsers = visibleAssignableUsersBySource[userPickerSource] + + const isLoadingPickerUsers = + userPickerSource === 'ad' ? isLoadingAdUsers : isLoadingBasicUsers + + return ( +
+
+
+
+
+
+
+
+

+ Milestone-Rollen +

+

+ Rollen verwalten und Benutzer den Rollen zuordnen. +

+
+
+ +
+ + + {canWrite && ( + + )} +
+
+
+ +
+
+
+ + setRoleSearch(event.target.value)} + placeholder="Rollen suchen" + className="block w-full rounded-md bg-white px-3 py-2 text-sm text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500" + /> +
+ +
+ {isLoadingRoles ? ( +
+ +
+ ) : filteredRoles.length === 0 ? ( +
+ Keine Rollen gefunden. +
+ ) : ( +
    + {filteredRoles.map((role) => { + const active = role.id === selectedRoleId + + return ( +
  • + +
  • + ) + })} +
+ )} +
+
+ +
+ {!selectedRole ? ( +
+ Wähle links eine Rolle aus. +
+ ) : ( + <> +
+
+
+

+ Rolle +

+

+ {roleTitle(selectedRole)} +

+ {selectedRole.description && ( +

+ {selectedRole.description} +

+ )} +
+ + {canWrite && ( +
+ + {selectedRole.canDelete !== false && ( + + )} +
+ )} +
+
+ +
+
+
+

+ Benutzer +

+

+ Benutzer, die dieser Milestone-Rolle zugeordnet sind. +

+
+ + {canWrite && ( + + )} +
+ + {isLoadingRole ? ( +
+ +
+ ) : roleUsers.length === 0 ? ( +
+ Diese Rolle hat noch keine Benutzer. +
+ ) : ( +
    + {roleUsers.map((user) => ( +
  • +
    +
    +
    +

    + {userTitle(user)} +

    + {userSubtitle(user) && ( +

    + {userSubtitle(user)} +

    + )} +
    + {canWrite && ( + + )} +
  • + ))} +
+ )} +
+ + )} +
+
+
+ + +
+
+ + {roleForm.id ? 'Rolle bearbeiten' : 'Rolle hinzufügen'} + +
+ +
+
+ + + setRoleForm((current) => ({ + ...current, + name: event.target.value, + })) + } + className="mt-2 block w-full rounded-md bg-white px-3 py-1.5 text-sm text-gray-900 outline-1 -outline-offset-1 outline-gray-300 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 dark:bg-white/5 dark:text-white dark:outline-white/10" + /> +
+ +
+ + + setRoleForm((current) => ({ + ...current, + displayName: event.target.value, + })) + } + placeholder={roleForm.name || 'Wie Name'} + className="mt-2 block w-full rounded-md bg-white px-3 py-1.5 text-sm text-gray-900 outline-1 -outline-offset-1 outline-gray-300 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 dark:bg-white/5 dark:text-white dark:outline-white/10" + /> +
+ +
+ +