updated
This commit is contained in:
parent
50eff1e5d4
commit
a95f8a15ac
1091
backend/active_directory.go
Normal file
1091
backend/active_directory.go
Normal file
File diff suppressed because it is too large
Load Diff
@ -38,6 +38,10 @@ type milestoneSettingsResponse struct {
|
|||||||
ServerFoldersAgentScheme string `json:"serverFoldersAgentScheme"`
|
ServerFoldersAgentScheme string `json:"serverFoldersAgentScheme"`
|
||||||
ServerFoldersAgentPort string `json:"serverFoldersAgentPort"`
|
ServerFoldersAgentPort string `json:"serverFoldersAgentPort"`
|
||||||
ServerFoldersAgentTokenConfigured bool `json:"serverFoldersAgentTokenConfigured"`
|
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"`
|
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
||||||
}
|
}
|
||||||
@ -48,8 +52,12 @@ type updateMilestoneSettingsRequest struct {
|
|||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
SkipTLSVerify bool `json:"skipTlsVerify"`
|
SkipTLSVerify bool `json:"skipTlsVerify"`
|
||||||
|
|
||||||
ServerFoldersAgentScheme string `json:"serverFoldersAgentScheme"`
|
ServerFoldersAgentScheme string `json:"serverFoldersAgentScheme"`
|
||||||
ServerFoldersAgentPort string `json:"serverFoldersAgentPort"`
|
ServerFoldersAgentPort string `json:"serverFoldersAgentPort"`
|
||||||
|
ServerFoldersStorageRootLabel string `json:"serverFoldersStorageRootLabel"`
|
||||||
|
ServerFoldersStorageRootPath string `json:"serverFoldersStorageRootPath"`
|
||||||
|
ServerFoldersArchiveRootLabel string `json:"serverFoldersArchiveRootLabel"`
|
||||||
|
ServerFoldersArchiveRootPath string `json:"serverFoldersArchiveRootPath"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type milestoneCredentials struct {
|
type milestoneCredentials struct {
|
||||||
@ -78,6 +86,10 @@ func (s *Server) handleGetMilestoneSettings(w http.ResponseWriter, r *http.Reque
|
|||||||
ServerFoldersAgentScheme: "http",
|
ServerFoldersAgentScheme: "http",
|
||||||
ServerFoldersAgentPort: "8099",
|
ServerFoldersAgentPort: "8099",
|
||||||
ServerFoldersAgentTokenConfigured: false,
|
ServerFoldersAgentTokenConfigured: false,
|
||||||
|
ServerFoldersStorageRootLabel: "Storage D",
|
||||||
|
ServerFoldersStorageRootPath: "D:/Milestone-Speicher",
|
||||||
|
ServerFoldersArchiveRootLabel: "Archive E",
|
||||||
|
ServerFoldersArchiveRootPath: "E:/",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@ -109,6 +121,10 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re
|
|||||||
input.ServerFoldersAgentScheme,
|
input.ServerFoldersAgentScheme,
|
||||||
input.ServerFoldersAgentPort,
|
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 == "" {
|
if host == "" {
|
||||||
writeError(w, http.StatusBadRequest, "IP-Adresse oder Hostname ist erforderlich")
|
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,
|
token_updated_at,
|
||||||
server_folders_agent_scheme,
|
server_folders_agent_scheme,
|
||||||
server_folders_agent_port,
|
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
|
server_folders_agent_token_encrypted
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
@ -167,6 +187,10 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re
|
|||||||
NULL,
|
NULL,
|
||||||
$6,
|
$6,
|
||||||
$7,
|
$7,
|
||||||
|
$8,
|
||||||
|
$9,
|
||||||
|
$10,
|
||||||
|
$11,
|
||||||
NULL
|
NULL
|
||||||
)
|
)
|
||||||
ON CONFLICT (id)
|
ON CONFLICT (id)
|
||||||
@ -181,6 +205,10 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re
|
|||||||
token_updated_at = NULL,
|
token_updated_at = NULL,
|
||||||
server_folders_agent_scheme = EXCLUDED.server_folders_agent_scheme,
|
server_folders_agent_scheme = EXCLUDED.server_folders_agent_scheme,
|
||||||
server_folders_agent_port = EXCLUDED.server_folders_agent_port,
|
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()
|
updated_at = now()
|
||||||
`,
|
`,
|
||||||
host,
|
host,
|
||||||
@ -190,6 +218,10 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re
|
|||||||
skipTLSVerify,
|
skipTLSVerify,
|
||||||
serverFoldersAgentScheme,
|
serverFoldersAgentScheme,
|
||||||
serverFoldersAgentPort,
|
serverFoldersAgentPort,
|
||||||
|
serverFoldersStorageRootLabel,
|
||||||
|
serverFoldersStorageRootPath,
|
||||||
|
serverFoldersArchiveRootLabel,
|
||||||
|
serverFoldersArchiveRootPath,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
_, err = s.db.Exec(
|
_, err = s.db.Exec(
|
||||||
@ -206,6 +238,10 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re
|
|||||||
token_updated_at = NULL,
|
token_updated_at = NULL,
|
||||||
server_folders_agent_scheme = $4,
|
server_folders_agent_scheme = $4,
|
||||||
server_folders_agent_port = $5,
|
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()
|
updated_at = now()
|
||||||
WHERE id = 1
|
WHERE id = 1
|
||||||
`,
|
`,
|
||||||
@ -214,6 +250,10 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re
|
|||||||
skipTLSVerify,
|
skipTLSVerify,
|
||||||
serverFoldersAgentScheme,
|
serverFoldersAgentScheme,
|
||||||
serverFoldersAgentPort,
|
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."
|
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)
|
log.Printf("server-folders agent token rotation failed: %v", err)
|
||||||
|
|
||||||
return "Der Server-Folders-Agent ist nicht erreichbar. Der Token wurde nicht ausgetauscht."
|
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 ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -423,6 +470,10 @@ func (s *Server) getMilestoneSettings(ctx context.Context) (milestoneSettingsRes
|
|||||||
COALESCE(server_folders_agent_scheme, 'http'),
|
COALESCE(server_folders_agent_scheme, 'http'),
|
||||||
COALESCE(server_folders_agent_port, '8099'),
|
COALESCE(server_folders_agent_port, '8099'),
|
||||||
server_folders_agent_token_encrypted IS NOT NULL,
|
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
|
updated_at
|
||||||
FROM milestone_settings
|
FROM milestone_settings
|
||||||
WHERE id = 1
|
WHERE id = 1
|
||||||
@ -444,6 +495,10 @@ func (s *Server) getMilestoneSettings(ctx context.Context) (milestoneSettingsRes
|
|||||||
&settings.ServerFoldersAgentScheme,
|
&settings.ServerFoldersAgentScheme,
|
||||||
&settings.ServerFoldersAgentPort,
|
&settings.ServerFoldersAgentPort,
|
||||||
&settings.ServerFoldersAgentTokenConfigured,
|
&settings.ServerFoldersAgentTokenConfigured,
|
||||||
|
&settings.ServerFoldersStorageRootLabel,
|
||||||
|
&settings.ServerFoldersStorageRootPath,
|
||||||
|
&settings.ServerFoldersArchiveRootLabel,
|
||||||
|
&settings.ServerFoldersArchiveRootPath,
|
||||||
&updatedAt,
|
&updatedAt,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -586,6 +641,24 @@ func normalizeServerFoldersAgentSettings(scheme string, port string) (string, st
|
|||||||
return scheme, port
|
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 {
|
func normalizeMilestoneHost(value string) string {
|
||||||
value = strings.TrimSpace(value)
|
value = strings.TrimSpace(value)
|
||||||
value = strings.TrimRight(value, "/")
|
value = strings.TrimRight(value, "/")
|
||||||
|
|||||||
845
backend/admin_milestone_roles.go
Normal file
845
backend/admin_milestone_roles.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
37
backend/external/README.md
vendored
37
backend/external/README.md
vendored
@ -16,13 +16,13 @@ Beispiel:
|
|||||||
"storageRoots": [
|
"storageRoots": [
|
||||||
{
|
{
|
||||||
"label": "Storage D",
|
"label": "Storage D",
|
||||||
"path": "D:\\Milestone\\Storage"
|
"path": "D:/Milestone-Speicher"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"archiveRoots": [
|
"archiveRoots": [
|
||||||
{
|
{
|
||||||
"label": "Archive E",
|
"label": "Archive E",
|
||||||
"path": "E:\\Milestone\\Archive"
|
"path": "E:/"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"token": ""
|
"token": ""
|
||||||
@ -66,10 +66,35 @@ Falls du den Token weiterhin getrennt speichern möchtest, kannst du in der JSON
|
|||||||
"storageRoots": [
|
"storageRoots": [
|
||||||
{
|
{
|
||||||
"label": "Storage D",
|
"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: <aktueller-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:
|
Die bisherigen Parameter und ENV-Variablen funktionieren weiter und überschreiben Werte aus der JSON:
|
||||||
|
|
||||||
```powershell
|
```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:
|
Unterstützte ENV-Variablen:
|
||||||
@ -97,7 +122,7 @@ Unterstützte ENV-Variablen:
|
|||||||
Der bestehende Frontend-Endpunkt bleibt:
|
Der bestehende Frontend-Endpunkt bleibt:
|
||||||
|
|
||||||
```http
|
```http
|
||||||
GET /admin/server-folders?path=D:\Milestone
|
GET /admin/server-folders?path=D:/Milestone
|
||||||
POST /admin/server-folders
|
POST /admin/server-folders
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
2
backend/external/agent_build.bat
vendored
2
backend/external/agent_build.bat
vendored
@ -27,7 +27,7 @@ echo.
|
|||||||
echo Build erfolgreich: server-folders-agent.exe
|
echo Build erfolgreich: server-folders-agent.exe
|
||||||
echo.
|
echo.
|
||||||
echo Beispiel ^(ohne Token - der Token wird vom Backend gesetzt^):
|
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.
|
echo.
|
||||||
|
|
||||||
pause
|
pause
|
||||||
|
|||||||
208
backend/external/agent_main.go
vendored
208
backend/external/agent_main.go
vendored
@ -4,11 +4,12 @@
|
|||||||
//
|
//
|
||||||
// Endpunkte:
|
// Endpunkte:
|
||||||
// GET /health
|
// GET /health
|
||||||
// GET /server-folders?rootType=storage&path=D:\Milestone
|
// GET /server-folders?rootType=storage&path=D:/Milestone
|
||||||
// POST /server-folders
|
// POST /server-folders
|
||||||
// PATCH /server-folders
|
// PATCH /server-folders
|
||||||
// DELETE /server-folders
|
// DELETE /server-folders
|
||||||
// PUT /agent-token (Token zur Laufzeit setzen / rotieren)
|
// PUT /agent-token (Token zur Laufzeit setzen / rotieren)
|
||||||
|
// PUT /agent-config (Root-Pfade zur Laufzeit setzen und in JSON speichern)
|
||||||
//
|
//
|
||||||
// Start per JSON (empfohlen):
|
// Start per JSON (empfohlen):
|
||||||
// server-folders-agent.json neben die .exe legen und server-folders-agent.exe starten.
|
// server-folders-agent.json neben die .exe legen und server-folders-agent.exe starten.
|
||||||
@ -17,18 +18,18 @@
|
|||||||
// Beispiel:
|
// Beispiel:
|
||||||
// {
|
// {
|
||||||
// "listenAddr": ":8099",
|
// "listenAddr": ":8099",
|
||||||
// "storageRoots": [{ "label": "Storage D", "path": "D:\\Milestone\\Storage" }],
|
// "storageRoots": [{ "label": "Storage D", "path": "D:/Milestone-Speicher" }],
|
||||||
// "archiveRoots": [{ "label": "Archive E", "path": "E:\\Milestone\\Archive" }],
|
// "archiveRoots": [{ "label": "Archive E", "path": "E:/" }],
|
||||||
// "token": ""
|
// "token": ""
|
||||||
// }
|
// }
|
||||||
//
|
//
|
||||||
// Start per Parameter bleibt als Fallback möglich:
|
// 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:
|
// Alternativ per ENV:
|
||||||
// LISTEN_ADDR=:8099
|
// LISTEN_ADDR=:8099
|
||||||
// SERVER_STORAGE_ROOTS=Storage D=D:\Milestone\Storage
|
// SERVER_STORAGE_ROOTS=Storage D=D:/Milestone-Speicher
|
||||||
// SERVER_ARCHIVE_ROOTS=Archive E=E:\Milestone\Archive
|
// SERVER_ARCHIVE_ROOTS=Archive E=E:/
|
||||||
//
|
//
|
||||||
// Token-Handling:
|
// Token-Handling:
|
||||||
// Der Agent kann ohne Token gestartet werden. Das Backend erzeugt beim Speichern der
|
// Der Agent kann ohne Token gestartet werden. Das Backend erzeugt beim Speichern der
|
||||||
@ -93,9 +94,34 @@ type appConfig struct {
|
|||||||
ArchiveRoots string
|
ArchiveRoots string
|
||||||
Token string
|
Token string
|
||||||
TokenFile string
|
TokenFile string
|
||||||
|
ConfigFile string
|
||||||
TokenConfigFile 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 {
|
func (config appConfig) rootsForType(rootType serverFolderRootType) string {
|
||||||
if rootType == serverFolderRootTypeArchive {
|
if rootType == serverFolderRootTypeArchive {
|
||||||
return strings.TrimSpace(config.ArchiveRoots)
|
return strings.TrimSpace(config.ArchiveRoots)
|
||||||
@ -147,26 +173,30 @@ type renameServerFolderRequest struct {
|
|||||||
func main() {
|
func main() {
|
||||||
config := readConfigFromFlagsAndEnv()
|
config := readConfigFromFlagsAndEnv()
|
||||||
|
|
||||||
|
configStore := newAppConfigStore(config)
|
||||||
store := newTokenStore(config)
|
store := newTokenStore(config)
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
mux.HandleFunc("GET /health", handleHealth)
|
mux.HandleFunc("GET /health", handleHealth)
|
||||||
mux.HandleFunc("GET /server-folders", requireAgentToken(store, func(w http.ResponseWriter, r *http.Request) {
|
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) {
|
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) {
|
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) {
|
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) {
|
mux.HandleFunc("PUT /agent-token", requireAgentToken(store, func(w http.ResponseWriter, r *http.Request) {
|
||||||
handleSetAgentToken(w, r, store)
|
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{
|
server := &http.Server{
|
||||||
Addr: config.ListenAddr,
|
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)")
|
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")
|
addrFlag := flag.String("addr", "", "Listen address, e.g. :8099 or 0.0.0.0:8099")
|
||||||
portFlag := flag.String("port", "", "Listen port, e.g. 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")
|
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")
|
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:\\Milestone\\Archive")
|
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)")
|
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")
|
tokenFileFlag := flag.String("token-file", "", "Path to persist the runtime token instead of writing it to the JSON config")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
@ -221,6 +251,7 @@ func readConfigFromFlagsAndEnv() appConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
applyAgentConfigFile(&config, fileConfig)
|
applyAgentConfigFile(&config, fileConfig)
|
||||||
|
config.ConfigFile = configPath
|
||||||
config.TokenConfigFile = configPath
|
config.TokenConfigFile = configPath
|
||||||
loadedConfigFile = true
|
loadedConfigFile = true
|
||||||
}
|
}
|
||||||
@ -284,6 +315,7 @@ func readConfigFromFlagsAndEnv() appConfig {
|
|||||||
ArchiveRoots: archiveRoots,
|
ArchiveRoots: archiveRoots,
|
||||||
Token: token,
|
Token: token,
|
||||||
TokenFile: tokenFile,
|
TokenFile: tokenFile,
|
||||||
|
ConfigFile: config.ConfigFile,
|
||||||
TokenConfigFile: config.TokenConfigFile,
|
TokenConfigFile: config.TokenConfigFile,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -314,6 +346,23 @@ func readAgentConfigFile(pathValue string) (agentConfigFile, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func updateAgentConfigFileToken(pathValue string, token string) 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)
|
data, err := os.ReadFile(pathValue)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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, "", " ")
|
nextData, err := json.MarshalIndent(values, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -337,6 +387,54 @@ func updateAgentConfigFileToken(pathValue string, token string) error {
|
|||||||
return os.WriteFile(pathValue, nextData, 0600)
|
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) {
|
func applyAgentConfigFile(config *appConfig, fileConfig agentConfigFile) {
|
||||||
config.ListenAddr = strings.TrimSpace(fileConfig.ListenAddr)
|
config.ListenAddr = strings.TrimSpace(fileConfig.ListenAddr)
|
||||||
if config.ListenAddr == "" && strings.TrimSpace(fileConfig.Port) != "" {
|
if config.ListenAddr == "" && strings.TrimSpace(fileConfig.Port) != "" {
|
||||||
@ -1070,6 +1168,11 @@ type setAgentTokenRequest struct {
|
|||||||
Token string `json:"token"`
|
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) {
|
func handleSetAgentToken(w http.ResponseWriter, r *http.Request, store *tokenStore) {
|
||||||
var input setAgentTokenRequest
|
var input setAgentTokenRequest
|
||||||
if err := readJSON(r, &input); err != nil {
|
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 {
|
func requireAgentToken(store *tokenStore, next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
requiredToken := store.get()
|
requiredToken := store.get()
|
||||||
|
|||||||
16
backend/external/server-folders-agent.json
vendored
Normal file
16
backend/external/server-folders-agent.json
vendored
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"archiveRoots": [
|
||||||
|
{
|
||||||
|
"label": "Archive E",
|
||||||
|
"path": "E:/"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"listenAddr": ":8099",
|
||||||
|
"storageRoots": [
|
||||||
|
{
|
||||||
|
"label": "Storage D",
|
||||||
|
"path": "D:/Milestone-Speicher"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"token": ""
|
||||||
|
}
|
||||||
@ -8,10 +8,17 @@ require (
|
|||||||
github.com/SherClockHolmes/webpush-go v1.4.0
|
github.com/SherClockHolmes/webpush-go v1.4.0
|
||||||
github.com/coder/websocket v1.8.14
|
github.com/coder/websocket v1.8.14
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
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
|
github.com/jackc/pgx/v5 v5.9.2
|
||||||
golang.org/x/crypto v0.51.0
|
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 (
|
require (
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
|||||||
@ -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 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
|
||||||
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
|
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
|
||||||
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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.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 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
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/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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
|||||||
@ -17,32 +17,33 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Operation struct {
|
type Operation struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
OperationNumber string `json:"operationNumber"`
|
OperationNumber string `json:"operationNumber"`
|
||||||
OperationName string `json:"operationName"`
|
OperationName string `json:"operationName"`
|
||||||
Offense string `json:"offense"`
|
Offense string `json:"offense"`
|
||||||
CaseWorker string `json:"caseWorker"`
|
CaseWorker string `json:"caseWorker"`
|
||||||
TargetObject string `json:"targetObject"`
|
TargetObject string `json:"targetObject"`
|
||||||
KwAddress string `json:"kwAddress"`
|
KwAddress string `json:"kwAddress"`
|
||||||
KwLatitude *float64 `json:"kwLatitude"`
|
KwLatitude *float64 `json:"kwLatitude"`
|
||||||
KwLongitude *float64 `json:"kwLongitude"`
|
KwLongitude *float64 `json:"kwLongitude"`
|
||||||
TargetLatitude *float64 `json:"targetLatitude"`
|
TargetLatitude *float64 `json:"targetLatitude"`
|
||||||
TargetLongitude *float64 `json:"targetLongitude"`
|
TargetLongitude *float64 `json:"targetLongitude"`
|
||||||
ViewConeFOV float64 `json:"viewConeFov"`
|
ViewConeFOV float64 `json:"viewConeFov"`
|
||||||
ViewConeLength float64 `json:"viewConeLength"`
|
ViewConeLength float64 `json:"viewConeLength"`
|
||||||
OperationLeader string `json:"operationLeader"`
|
OperationLeader string `json:"operationLeader"`
|
||||||
OperationTeam string `json:"operationTeam"`
|
OperationTeam string `json:"operationTeam"`
|
||||||
Legend string `json:"legend"`
|
Legend string `json:"legend"`
|
||||||
Camera string `json:"camera"`
|
Camera string `json:"camera"`
|
||||||
Router string `json:"router"`
|
Router string `json:"router"`
|
||||||
Technology string `json:"technology"`
|
Technology string `json:"technology"`
|
||||||
Switchbox string `json:"switchbox"`
|
Switchbox string `json:"switchbox"`
|
||||||
HardDrive string `json:"hardDrive"`
|
HardDrive string `json:"hardDrive"`
|
||||||
Laptop string `json:"laptop"`
|
Laptop string `json:"laptop"`
|
||||||
Remark string `json:"remark"`
|
Remark string `json:"remark"`
|
||||||
MilestoneStorage string `json:"milestoneStorage"`
|
MilestoneStorage string `json:"milestoneStorage"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CompletedAt *time.Time `json:"completedAt,omitempty"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateOperationJournalEntryRequest struct {
|
type CreateOperationJournalEntryRequest struct {
|
||||||
@ -101,6 +102,36 @@ type OperationHistoryChange struct {
|
|||||||
NewValue string `json:"newValue"`
|
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) {
|
func (s *Server) handleOperations(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case http.MethodGet:
|
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) {
|
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(
|
rows, err := s.db.Query(
|
||||||
r.Context(),
|
r.Context(),
|
||||||
`
|
fmt.Sprintf(`
|
||||||
SELECT
|
SELECT
|
||||||
id,
|
id,
|
||||||
operation_number,
|
operation_number,
|
||||||
@ -150,11 +193,13 @@ func (s *Server) handleListOperations(w http.ResponseWriter, r *http.Request) {
|
|||||||
laptop,
|
laptop,
|
||||||
remark,
|
remark,
|
||||||
milestone_storage,
|
milestone_storage,
|
||||||
|
completed_at,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
FROM operations
|
FROM operations
|
||||||
|
WHERE %s
|
||||||
ORDER BY operation_number ASC
|
ORDER BY operation_number ASC
|
||||||
`,
|
`, statusFilter),
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -193,6 +238,7 @@ func (s *Server) handleListOperations(w http.ResponseWriter, r *http.Request) {
|
|||||||
&operation.Laptop,
|
&operation.Laptop,
|
||||||
&operation.Remark,
|
&operation.Remark,
|
||||||
&operation.MilestoneStorage,
|
&operation.MilestoneStorage,
|
||||||
|
&operation.CompletedAt,
|
||||||
&operation.CreatedAt,
|
&operation.CreatedAt,
|
||||||
&operation.UpdatedAt,
|
&operation.UpdatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@ -301,6 +347,7 @@ func (s *Server) handleCreateOperation(w http.ResponseWriter, r *http.Request) {
|
|||||||
laptop,
|
laptop,
|
||||||
remark,
|
remark,
|
||||||
milestone_storage,
|
milestone_storage,
|
||||||
|
completed_at,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
`,
|
`,
|
||||||
@ -352,6 +399,7 @@ func (s *Server) handleCreateOperation(w http.ResponseWriter, r *http.Request) {
|
|||||||
&operation.Laptop,
|
&operation.Laptop,
|
||||||
&operation.Remark,
|
&operation.Remark,
|
||||||
&operation.MilestoneStorage,
|
&operation.MilestoneStorage,
|
||||||
|
&operation.CompletedAt,
|
||||||
&operation.CreatedAt,
|
&operation.CreatedAt,
|
||||||
&operation.UpdatedAt,
|
&operation.UpdatedAt,
|
||||||
)
|
)
|
||||||
@ -520,6 +568,7 @@ func (s *Server) handleUpdateOperation(w http.ResponseWriter, r *http.Request) {
|
|||||||
laptop,
|
laptop,
|
||||||
remark,
|
remark,
|
||||||
milestone_storage,
|
milestone_storage,
|
||||||
|
completed_at,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
`,
|
`,
|
||||||
@ -572,6 +621,7 @@ func (s *Server) handleUpdateOperation(w http.ResponseWriter, r *http.Request) {
|
|||||||
&operation.Laptop,
|
&operation.Laptop,
|
||||||
&operation.Remark,
|
&operation.Remark,
|
||||||
&operation.MilestoneStorage,
|
&operation.MilestoneStorage,
|
||||||
|
&operation.CompletedAt,
|
||||||
&operation.CreatedAt,
|
&operation.CreatedAt,
|
||||||
&operation.UpdatedAt,
|
&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) {
|
func (s *Server) handleListOperationHistory(w http.ResponseWriter, r *http.Request) {
|
||||||
operationID := strings.TrimSpace(r.PathValue("id"))
|
operationID := strings.TrimSpace(r.PathValue("id"))
|
||||||
|
|
||||||
@ -745,6 +962,7 @@ func getOperationForHistory(ctx context.Context, tx pgx.Tx, operationID string)
|
|||||||
laptop,
|
laptop,
|
||||||
remark,
|
remark,
|
||||||
milestone_storage,
|
milestone_storage,
|
||||||
|
completed_at,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
FROM operations
|
FROM operations
|
||||||
@ -777,6 +995,43 @@ func getOperationForHistory(ctx context.Context, tx pgx.Tx, operationID string)
|
|||||||
&operation.Laptop,
|
&operation.Laptop,
|
||||||
&operation.Remark,
|
&operation.Remark,
|
||||||
&operation.MilestoneStorage,
|
&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.CreatedAt,
|
||||||
&operation.UpdatedAt,
|
&operation.UpdatedAt,
|
||||||
)
|
)
|
||||||
|
|||||||
1654
backend/operations_milestone_setup.go
Normal file
1654
backend/operations_milestone_setup.go
Normal file
File diff suppressed because it is too large
Load Diff
@ -84,6 +84,10 @@ func (s *Server) Routes() http.Handler {
|
|||||||
mux.HandleFunc("POST /operations", s.requireAuth(s.requireRight("operations:write", s.handleCreateOperation)))
|
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("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("GET /operations/{id}/history", s.requireAuth(s.handleListOperationHistory))
|
||||||
mux.HandleFunc("POST /operations/{id}/history", s.requireAuth(s.handleCreateOperationJournalEntry))
|
mux.HandleFunc("POST /operations/{id}/history", s.requireAuth(s.handleCreateOperationJournalEntry))
|
||||||
mux.HandleFunc("PUT /operations/{id}/history/{historyId}", s.requireAuth(s.handleUpdateOperationJournalEntry))
|
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/journal-unread", s.requireAuth(s.handleListUnreadOperationJournals))
|
||||||
|
|
||||||
mux.HandleFunc("GET /operations/assignees", s.requireAuth(s.requireRight("operations:read", s.handleListOperationAssignees)))
|
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))
|
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("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("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 */
|
/* milestone settings */
|
||||||
|
|
||||||
mux.HandleFunc("GET /admin/milestone", s.requireAuth(s.requireRight("administration:read", s.handleGetMilestoneSettings)))
|
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("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", 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("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(
|
mux.HandleFunc(
|
||||||
"GET /admin/server-folders",
|
"GET /admin/server-folders",
|
||||||
s.requireAuth(s.requireRight("administration:read", s.handleProxyListServerFolders)),
|
s.requireAuth(s.requireRight("administration:read", s.handleProxyListServerFolders)),
|
||||||
|
|||||||
@ -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 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_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 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 (
|
CREATE TABLE IF NOT EXISTS device_relations (
|
||||||
device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
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_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_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 {
|
for _, query := range queries {
|
||||||
|
|||||||
@ -17,10 +17,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type serverFoldersAgentConfig struct {
|
type serverFoldersAgentConfig struct {
|
||||||
MilestoneHost string
|
MilestoneHost string
|
||||||
Scheme string
|
Scheme string
|
||||||
Port string
|
Port string
|
||||||
Token string
|
Token string
|
||||||
|
StorageRootLabel string
|
||||||
|
StorageRootPath string
|
||||||
|
ArchiveRootLabel string
|
||||||
|
ArchiveRootPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
type serverFoldersAgentHTTPResponse struct {
|
type serverFoldersAgentHTTPResponse struct {
|
||||||
@ -375,7 +379,11 @@ func (s *Server) getServerFoldersAgentConfig(ctx context.Context) (serverFolders
|
|||||||
host,
|
host,
|
||||||
COALESCE(server_folders_agent_scheme, 'http'),
|
COALESCE(server_folders_agent_scheme, 'http'),
|
||||||
COALESCE(server_folders_agent_port, '8099'),
|
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
|
FROM milestone_settings
|
||||||
WHERE id = 1
|
WHERE id = 1
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
@ -386,6 +394,10 @@ func (s *Server) getServerFoldersAgentConfig(ctx context.Context) (serverFolders
|
|||||||
&config.Scheme,
|
&config.Scheme,
|
||||||
&config.Port,
|
&config.Port,
|
||||||
&config.Token,
|
&config.Token,
|
||||||
|
&config.StorageRootLabel,
|
||||||
|
&config.StorageRootPath,
|
||||||
|
&config.ArchiveRootLabel,
|
||||||
|
&config.ArchiveRootPath,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return config, err
|
return config, err
|
||||||
@ -403,6 +415,10 @@ func (s *Server) getServerFoldersAgentConfig(ctx context.Context) (serverFolders
|
|||||||
|
|
||||||
config.MilestoneHost = strings.TrimSpace(config.MilestoneHost)
|
config.MilestoneHost = strings.TrimSpace(config.MilestoneHost)
|
||||||
config.Token = strings.TrimSpace(config.Token)
|
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
|
return config, nil
|
||||||
}
|
}
|
||||||
@ -504,6 +520,69 @@ func (s *Server) pushServerFoldersAgentToken(
|
|||||||
return nil
|
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(
|
func (s *Server) rotateServerFoldersAgentToken(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
config serverFoldersAgentConfig,
|
config serverFoldersAgentConfig,
|
||||||
|
|||||||
@ -550,6 +550,7 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
|
|||||||
laptop TEXT NOT NULL DEFAULT '',
|
laptop TEXT NOT NULL DEFAULT '',
|
||||||
remark TEXT NOT NULL DEFAULT '',
|
remark TEXT NOT NULL DEFAULT '',
|
||||||
milestone_storage TEXT NOT NULL DEFAULT '',
|
milestone_storage TEXT NOT NULL DEFAULT '',
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
updated_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 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_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 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 (
|
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_scheme TEXT NOT NULL DEFAULT 'http',
|
||||||
server_folders_agent_port TEXT NOT NULL DEFAULT '8099',
|
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,
|
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(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
updated_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'
|
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
|
ALTER TABLE milestone_settings
|
||||||
ADD COLUMN IF NOT EXISTS server_folders_agent_token_encrypted BYTEA
|
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 (
|
CREATE TABLE IF NOT EXISTS notification_preferences (
|
||||||
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import Feedback from './components/Feedback'
|
|||||||
import MilestonePage from './pages/milestone/MilestonePage'
|
import MilestonePage from './pages/milestone/MilestonePage'
|
||||||
import MilestoneStoragePage from './pages/milestone/speicher/MilestoneStoragePage'
|
import MilestoneStoragePage from './pages/milestone/speicher/MilestoneStoragePage'
|
||||||
import MilestoneDevicesPage from './pages/milestone/geraete/MilestoneDevicesPage'
|
import MilestoneDevicesPage from './pages/milestone/geraete/MilestoneDevicesPage'
|
||||||
|
import MilestoneRolesPage from './pages/milestone/rollen/MilestoneRolesPage'
|
||||||
import { hasRight } from './components/permissions'
|
import { hasRight } from './components/permissions'
|
||||||
import ChatPage from './pages/chat/ChatPage'
|
import ChatPage from './pages/chat/ChatPage'
|
||||||
import { ChatSocketProvider } from './components/ChatSocketProvider'
|
import { ChatSocketProvider } from './components/ChatSocketProvider'
|
||||||
@ -699,6 +700,18 @@ function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/milestone/rollen"
|
||||||
|
element={
|
||||||
|
<RequireRight user={user} right="administration:read">
|
||||||
|
<MilestoneRolesPage
|
||||||
|
canWrite={hasRight(user, 'administration:write')}
|
||||||
|
isAdministrator={hasRight(user, 'admin')}
|
||||||
|
/>
|
||||||
|
</RequireRight>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="/einsaetze/milestone-speicher"
|
path="/einsaetze/milestone-speicher"
|
||||||
element={<Navigate to="/milestone/speicher" replace />}
|
element={<Navigate to="/milestone/speicher" replace />}
|
||||||
|
|||||||
@ -23,6 +23,9 @@ export type ComboboxItem = {
|
|||||||
imageUrl?: string
|
imageUrl?: string
|
||||||
secondaryText?: string
|
secondaryText?: string
|
||||||
username?: string
|
username?: string
|
||||||
|
email?: string
|
||||||
|
sid?: string
|
||||||
|
distinguishedName?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type ComboboxProps = {
|
type ComboboxProps = {
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import {
|
|||||||
DocumentDuplicateIcon,
|
DocumentDuplicateIcon,
|
||||||
ServerStackIcon,
|
ServerStackIcon,
|
||||||
ShieldCheckIcon,
|
ShieldCheckIcon,
|
||||||
|
UserGroupIcon,
|
||||||
VideoCameraIcon,
|
VideoCameraIcon,
|
||||||
HomeIcon,
|
HomeIcon,
|
||||||
XMarkIcon,
|
XMarkIcon,
|
||||||
@ -81,6 +82,12 @@ const navigationSections: NavigationSection[] = [
|
|||||||
icon: ServerStackIcon,
|
icon: ServerStackIcon,
|
||||||
right: 'administration:read',
|
right: 'administration:read',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Rollen',
|
||||||
|
href: '/milestone/rollen',
|
||||||
|
icon: UserGroupIcon,
|
||||||
|
right: 'administration:read',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@ -38,6 +38,9 @@ type TreeListProps = {
|
|||||||
previewPath?: string;
|
previewPath?: string;
|
||||||
previewName?: string;
|
previewName?: string;
|
||||||
previewLabel?: string;
|
previewLabel?: string;
|
||||||
|
emptyMessage?: string;
|
||||||
|
showFallbackNodes?: boolean;
|
||||||
|
showNewFolderInput?: boolean;
|
||||||
showCreateButton?: boolean;
|
showCreateButton?: boolean;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@ -370,6 +373,9 @@ export default function TreeList({
|
|||||||
previewPath,
|
previewPath,
|
||||||
previewName,
|
previewName,
|
||||||
previewLabel,
|
previewLabel,
|
||||||
|
emptyMessage = "Noch kein Speicherpfad ausgewählt.",
|
||||||
|
showFallbackNodes = true,
|
||||||
|
showNewFolderInput = true,
|
||||||
showCreateButton = true,
|
showCreateButton = true,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
@ -380,8 +386,10 @@ export default function TreeList({
|
|||||||
() =>
|
() =>
|
||||||
Array.isArray(nodes) && nodes.length > 0
|
Array.isArray(nodes) && nodes.length > 0
|
||||||
? nodes
|
? nodes
|
||||||
: buildFallbackNodes(selectedPath),
|
: showFallbackNodes
|
||||||
[nodes, selectedPath],
|
? buildFallbackNodes(selectedPath)
|
||||||
|
: [],
|
||||||
|
[nodes, selectedPath, showFallbackNodes],
|
||||||
);
|
);
|
||||||
const displayNodes = useMemo(
|
const displayNodes = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@ -478,10 +486,11 @@ export default function TreeList({
|
|||||||
</ul>
|
</ul>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-md border border-dashed border-gray-200 px-3 py-4 text-sm text-gray-500 dark:border-white/10 dark:text-gray-400">
|
<div className="rounded-md border border-dashed border-gray-200 px-3 py-4 text-sm text-gray-500 dark:border-white/10 dark:text-gray-400">
|
||||||
Noch kein Speicherpfad ausgewählt.
|
{emptyMessage}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showNewFolderInput && (
|
||||||
<div className="mt-2 rounded-md border border-dashed border-gray-300 bg-gray-50 p-2 dark:border-white/10 dark:bg-white/5">
|
<div className="mt-2 rounded-md border border-dashed border-gray-300 bg-gray-50 p-2 dark:border-white/10 dark:bg-white/5">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<FolderPlusIcon
|
<FolderPlusIcon
|
||||||
@ -528,6 +537,7 @@ export default function TreeList({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -273,6 +273,7 @@ export type Operation = {
|
|||||||
laptop: string
|
laptop: string
|
||||||
remark: string
|
remark: string
|
||||||
milestoneStorage: string
|
milestoneStorage: string
|
||||||
|
completedAt?: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { useLocation, Navigate } from 'react-router'
|
|||||||
import {
|
import {
|
||||||
CalendarDaysIcon,
|
CalendarDaysIcon,
|
||||||
ChatBubbleLeftRightIcon,
|
ChatBubbleLeftRightIcon,
|
||||||
|
KeyIcon,
|
||||||
UserGroupIcon,
|
UserGroupIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
VideoCameraIcon,
|
VideoCameraIcon,
|
||||||
@ -14,6 +15,7 @@ import FeedbackAdministration from './feedback/FeedbackAdministration'
|
|||||||
import Milestone from './milestone/Milestone'
|
import Milestone from './milestone/Milestone'
|
||||||
import ChatsAdministration from './chats/ChatsAdministration'
|
import ChatsAdministration from './chats/ChatsAdministration'
|
||||||
import CalendarAdministration from './calendar/CalendarAdministration'
|
import CalendarAdministration from './calendar/CalendarAdministration'
|
||||||
|
import ActiveDirectoryAdministration from './active-directory/ActiveDirectoryAdministration'
|
||||||
|
|
||||||
export default function AdministrationPage() {
|
export default function AdministrationPage() {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
@ -35,6 +37,12 @@ export default function AdministrationPage() {
|
|||||||
icon: UserGroupIcon,
|
icon: UserGroupIcon,
|
||||||
current: section === 'chats',
|
current: section === 'chats',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Active Directory',
|
||||||
|
href: '/administration/active-directory',
|
||||||
|
icon: KeyIcon,
|
||||||
|
current: section === 'active-directory',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Milestone',
|
name: 'Milestone',
|
||||||
href: '/administration/milestone',
|
href: '/administration/milestone',
|
||||||
@ -64,7 +72,7 @@ export default function AdministrationPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!['benutzer', 'chats', 'milestone', 'kalender', 'feedback'].includes(
|
!['benutzer', 'chats', 'milestone', 'active-directory', 'kalender', 'feedback'].includes(
|
||||||
section,
|
section,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
@ -99,6 +107,7 @@ export default function AdministrationPage() {
|
|||||||
{section === 'benutzer' && <UsersAdministration />}
|
{section === 'benutzer' && <UsersAdministration />}
|
||||||
{section === 'chats' && <ChatsAdministration />}
|
{section === 'chats' && <ChatsAdministration />}
|
||||||
{section === 'milestone' && <Milestone />}
|
{section === 'milestone' && <Milestone />}
|
||||||
|
{section === 'active-directory' && <ActiveDirectoryAdministration />}
|
||||||
{section === 'kalender' && <CalendarAdministration />}
|
{section === 'kalender' && <CalendarAdministration />}
|
||||||
{section === 'feedback' && <FeedbackAdministration />}
|
{section === 'feedback' && <FeedbackAdministration />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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<HTMLFormElement | null>(null)
|
||||||
|
const [settings, setSettings] = useState<ActiveDirectorySettings | null>(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<TreeListNode[]>([])
|
||||||
|
const [baseDnSelection, setBaseDnSelection] = useState('')
|
||||||
|
const [baseDnTreeError, setBaseDnTreeError] = useState<string | null>(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<HTMLFormElement>) {
|
||||||
|
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 (
|
||||||
|
<div className="rounded-lg bg-white p-8 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
|
||||||
|
<LoadingSpinner
|
||||||
|
size="md"
|
||||||
|
label="AD-Einstellungen werden geladen..."
|
||||||
|
className="text-indigo-600 dark:text-indigo-400"
|
||||||
|
center
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedAt = formatUpdatedAt(settings?.updatedAt)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal
|
||||||
|
open={baseDnPickerOpen}
|
||||||
|
setOpen={setBaseDnPickerOpen}
|
||||||
|
panelClassName="max-w-2xl"
|
||||||
|
>
|
||||||
|
<div className="border-b border-gray-200 px-6 py-4 dark:border-white/10">
|
||||||
|
<ModalTitle>Base-DN auswählen</ModalTitle>
|
||||||
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Wähle die Domäne oder OU, unter der nach Benutzern gesucht werden
|
||||||
|
soll. Meist ist die oberste Domäne die
|
||||||
|
richtige Wahl.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 py-5">
|
||||||
|
{baseDnTreeError && (
|
||||||
|
<div className="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
||||||
|
{baseDnTreeError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoadingBaseDnTree ? (
|
||||||
|
<div className="rounded-lg border border-gray-200 p-8 dark:border-white/10">
|
||||||
|
<LoadingSpinner
|
||||||
|
size="md"
|
||||||
|
label="AD-Struktur wird geladen..."
|
||||||
|
className="text-indigo-600 dark:text-indigo-400"
|
||||||
|
center
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<TreeList
|
||||||
|
selectedPath={baseDnSelection}
|
||||||
|
onSelectedPathChange={setBaseDnSelection}
|
||||||
|
nodes={baseDnTreeNodes}
|
||||||
|
pathPlaceholder="DC=example,DC=local"
|
||||||
|
pathInputReadOnly
|
||||||
|
showFallbackNodes={false}
|
||||||
|
showNewFolderInput={false}
|
||||||
|
emptyMessage="Keine Base-DN gefunden."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between gap-3 border-t border-gray-200 px-6 py-4 dark:border-white/10">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
color="gray"
|
||||||
|
isLoading={isLoadingBaseDnTree}
|
||||||
|
onClick={() => void loadBaseDnTree()}
|
||||||
|
>
|
||||||
|
Neu laden
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
color="gray"
|
||||||
|
onClick={() => setBaseDnPickerOpen(false)}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
color="indigo"
|
||||||
|
disabled={!baseDnSelection}
|
||||||
|
onClick={applyBaseDnSelection}
|
||||||
|
>
|
||||||
|
Übernehmen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1fr)_32rem]">
|
||||||
|
<form
|
||||||
|
ref={formRef}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="rounded-lg bg-white p-4 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-x-3">
|
||||||
|
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-indigo-50 text-indigo-600 ring-1 ring-indigo-600/10 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-400/20">
|
||||||
|
<UserGroupIcon aria-hidden="true" className="size-5" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
Active Directory
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Benutzer aus dem Suchbereich werden beim Anlegen eines Einsatzes
|
||||||
|
und in der Milestone-Rollenverwaltung angeboten.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<label
|
||||||
|
htmlFor="active-directory-server-url"
|
||||||
|
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
AD-Server
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="mt-2">
|
||||||
|
<input
|
||||||
|
id="active-directory-server-url"
|
||||||
|
name="serverHost"
|
||||||
|
type="text"
|
||||||
|
defaultValue={getActiveDirectoryServerHost(settings?.serverUrl)}
|
||||||
|
placeholder="dc.example.local"
|
||||||
|
className={inputClassName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="active-directory-bind-username"
|
||||||
|
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
Bind-Benutzer
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="mt-2">
|
||||||
|
<input
|
||||||
|
id="active-directory-bind-username"
|
||||||
|
name="bindUsername"
|
||||||
|
type="text"
|
||||||
|
defaultValue={settings?.bindUsername ?? ''}
|
||||||
|
placeholder="DOMAIN\\service-user"
|
||||||
|
autoComplete="off"
|
||||||
|
className={inputClassName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="active-directory-bind-password"
|
||||||
|
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
Bind-Kennwort{' '}
|
||||||
|
{settings?.bindPasswordConfigured
|
||||||
|
? '(leer lassen = unverändert)'
|
||||||
|
: ''}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="mt-2">
|
||||||
|
<input
|
||||||
|
id="active-directory-bind-password"
|
||||||
|
name="bindPassword"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder={
|
||||||
|
settings?.bindPasswordConfigured
|
||||||
|
? 'Kennwort ist bereits gespeichert'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
className={inputClassName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<label
|
||||||
|
htmlFor="active-directory-base-dn"
|
||||||
|
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
Suchbereich
|
||||||
|
</label>
|
||||||
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Wähle die Domäne oder OU, aus der AD-Benutzer geladen werden.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-2 grid grid-cols-[minmax(0,1fr)_auto] gap-2">
|
||||||
|
<input
|
||||||
|
id="active-directory-base-dn"
|
||||||
|
name="baseDn"
|
||||||
|
type="text"
|
||||||
|
value={baseDn}
|
||||||
|
onChange={(event) => setBaseDn(event.target.value)}
|
||||||
|
placeholder="DC=example,DC=local"
|
||||||
|
className={inputClassName}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
color="indigo"
|
||||||
|
onClick={openBaseDnPicker}
|
||||||
|
>
|
||||||
|
Auswählen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-end">
|
||||||
|
<div className="w-full rounded-md bg-gray-50 p-3 ring-1 ring-gray-200 dark:bg-white/5 dark:ring-white/10">
|
||||||
|
<Switch
|
||||||
|
checked={skipTlsVerify}
|
||||||
|
onChange={(event) => setSkipTlsVerify(event.target.checked)}
|
||||||
|
label="AD-TLS-Prüfung deaktivieren"
|
||||||
|
description="Nur für interne Testsysteme verwenden."
|
||||||
|
variant="simple"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
color="indigo"
|
||||||
|
isLoading={isSaving}
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<aside className="rounded-lg bg-white p-4 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
|
||||||
|
<h3 className="flex items-center gap-x-2 text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
<ServerStackIcon aria-hidden="true" className="size-5 text-gray-400" />
|
||||||
|
Status
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<dl className="mt-4 grid grid-cols-1 gap-x-6 gap-y-4 text-sm sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<dt className="text-gray-500 dark:text-gray-400">
|
||||||
|
Active Directory
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-1 flex items-center gap-x-2 font-medium text-gray-900 dark:text-white">
|
||||||
|
<UserGroupIcon aria-hidden="true" className="size-4 text-gray-400" />
|
||||||
|
{settings?.configured ? 'Konfiguriert' : 'Nicht konfiguriert'}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<dt className="text-gray-500 dark:text-gray-400">
|
||||||
|
Bind-Kennwort
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-1 flex items-center gap-x-2 font-medium text-gray-900 dark:text-white">
|
||||||
|
<KeyIcon aria-hidden="true" className="size-4 text-gray-400" />
|
||||||
|
{settings?.bindPasswordConfigured
|
||||||
|
? 'Gespeichert'
|
||||||
|
: 'Nicht gespeichert'}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0">
|
||||||
|
<dt className="text-gray-500 dark:text-gray-400">
|
||||||
|
Server
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-1 break-all font-medium text-gray-900 dark:text-white">
|
||||||
|
{settings?.serverUrl || 'Nicht gespeichert'}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 sm:col-span-2">
|
||||||
|
<dt className="text-gray-500 dark:text-gray-400">
|
||||||
|
Suchbereich
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-1 break-all font-medium text-gray-900 dark:text-white">
|
||||||
|
{settings?.baseDn || 'Nicht gespeichert'}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{updatedAt && (
|
||||||
|
<div>
|
||||||
|
<dt className="text-gray-500 dark:text-gray-400">
|
||||||
|
Aktualisiert
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-1 font-medium text-gray-900 dark:text-white">
|
||||||
|
{updatedAt}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -33,6 +33,10 @@ type MilestoneSettings = {
|
|||||||
serverFoldersAgentScheme: string
|
serverFoldersAgentScheme: string
|
||||||
serverFoldersAgentPort: string
|
serverFoldersAgentPort: string
|
||||||
serverFoldersAgentTokenConfigured: boolean
|
serverFoldersAgentTokenConfigured: boolean
|
||||||
|
serverFoldersStorageRootLabel: string
|
||||||
|
serverFoldersStorageRootPath: string
|
||||||
|
serverFoldersArchiveRootLabel: string
|
||||||
|
serverFoldersArchiveRootPath: string
|
||||||
updatedAt?: string
|
updatedAt?: string
|
||||||
}
|
}
|
||||||
type MilestoneSyncEvent = {
|
type MilestoneSyncEvent = {
|
||||||
@ -412,6 +416,18 @@ export default function Milestone() {
|
|||||||
serverFoldersAgentPort: String(
|
serverFoldersAgentPort: String(
|
||||||
formData.get('serverFoldersAgentPort') ?? '8099',
|
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)
|
setSettings(data.settings)
|
||||||
setSkipTlsVerify(Boolean(data.settings?.skipTlsVerify))
|
setSkipTlsVerify(Boolean(data.settings?.skipTlsVerify))
|
||||||
form.reset()
|
const passwordInput = form.elements.namedItem('password')
|
||||||
|
if (passwordInput instanceof HTMLInputElement) {
|
||||||
|
passwordInput.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
await runMilestoneSyncStream()
|
await runMilestoneSyncStream()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -697,6 +716,86 @@ export default function Milestone() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="server-folders-storage-root-label"
|
||||||
|
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
Storage-Label
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="mt-2">
|
||||||
|
<input
|
||||||
|
id="server-folders-storage-root-label"
|
||||||
|
name="serverFoldersStorageRootLabel"
|
||||||
|
type="text"
|
||||||
|
defaultValue={settings?.serverFoldersStorageRootLabel || 'Storage D'}
|
||||||
|
placeholder="Storage D"
|
||||||
|
className={inputClassName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="server-folders-storage-root-path"
|
||||||
|
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
Storage-Pfad
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="mt-2">
|
||||||
|
<input
|
||||||
|
id="server-folders-storage-root-path"
|
||||||
|
name="serverFoldersStorageRootPath"
|
||||||
|
type="text"
|
||||||
|
defaultValue={settings?.serverFoldersStorageRootPath || 'D:/Milestone-Speicher'}
|
||||||
|
placeholder="D:/Milestone-Speicher"
|
||||||
|
className={inputClassName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="server-folders-archive-root-label"
|
||||||
|
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
Archiv-Label
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="mt-2">
|
||||||
|
<input
|
||||||
|
id="server-folders-archive-root-label"
|
||||||
|
name="serverFoldersArchiveRootLabel"
|
||||||
|
type="text"
|
||||||
|
defaultValue={settings?.serverFoldersArchiveRootLabel || 'Archive E'}
|
||||||
|
placeholder="Archive E"
|
||||||
|
className={inputClassName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="server-folders-archive-root-path"
|
||||||
|
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
Archiv-Pfad
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="mt-2">
|
||||||
|
<input
|
||||||
|
id="server-folders-archive-root-path"
|
||||||
|
name="serverFoldersArchiveRootPath"
|
||||||
|
type="text"
|
||||||
|
defaultValue={settings?.serverFoldersArchiveRootPath || 'E:/'}
|
||||||
|
placeholder="E:/"
|
||||||
|
className={inputClassName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
<p className="rounded-md bg-gray-50 p-3 text-xs text-gray-500 ring-1 ring-gray-200 dark:bg-white/5 dark:text-gray-400 dark:ring-white/10">
|
<p className="rounded-md bg-gray-50 p-3 text-xs text-gray-500 ring-1 ring-gray-200 dark:bg-white/5 dark:text-gray-400 dark:ring-white/10">
|
||||||
Der Agent-Token wird beim Speichern automatisch erzeugt, an
|
Der Agent-Token wird beim Speichern automatisch erzeugt, an
|
||||||
@ -822,6 +921,28 @@ export default function Milestone() {
|
|||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0">
|
||||||
|
<dt className="text-gray-500 dark:text-gray-400">
|
||||||
|
Storage-Root
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-1 break-all font-medium text-gray-900 dark:text-white">
|
||||||
|
{(settings?.serverFoldersStorageRootLabel || 'Storage D') +
|
||||||
|
': ' +
|
||||||
|
(settings?.serverFoldersStorageRootPath || 'D:/Milestone-Speicher')}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0">
|
||||||
|
<dt className="text-gray-500 dark:text-gray-400">
|
||||||
|
Archiv-Root
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-1 break-all font-medium text-gray-900 dark:text-white">
|
||||||
|
{(settings?.serverFoldersArchiveRootLabel || 'Archive E') +
|
||||||
|
': ' +
|
||||||
|
(settings?.serverFoldersArchiveRootPath || 'E:/')}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
{settings?.tokenExpiresAt && (
|
{settings?.tokenExpiresAt && (
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-gray-500 dark:text-gray-400">
|
<dt className="text-gray-500 dark:text-gray-400">
|
||||||
@ -870,4 +991,4 @@ export default function Milestone() {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
MicrophoneIcon,
|
MicrophoneIcon,
|
||||||
ServerStackIcon,
|
ServerStackIcon,
|
||||||
|
UserGroupIcon,
|
||||||
VideoCameraIcon,
|
VideoCameraIcon,
|
||||||
} from '@heroicons/react/24/outline'
|
} from '@heroicons/react/24/outline'
|
||||||
import { Link } from 'react-router'
|
import { Link } from 'react-router'
|
||||||
@ -77,6 +78,13 @@ const featureCards = [
|
|||||||
href: '/milestone/speicher',
|
href: '/milestone/speicher',
|
||||||
icon: ServerStackIcon,
|
icon: ServerStackIcon,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Rollen',
|
||||||
|
description:
|
||||||
|
'Milestone-Rollen verwalten und Benutzer aus dem Active Directory zuordnen.',
|
||||||
|
href: '/milestone/rollen',
|
||||||
|
icon: UserGroupIcon,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
function classNames(...classes: Array<string | false | null | undefined>) {
|
function classNames(...classes: Array<string | false | null | undefined>) {
|
||||||
|
|||||||
1181
frontend/src/pages/milestone/rollen/MilestoneRolesPage.tsx
Normal file
1181
frontend/src/pages/milestone/rollen/MilestoneRolesPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -92,6 +92,22 @@ type OperationAssigneesResponse = {
|
|||||||
officers?: OperationAssigneeUser[]
|
officers?: OperationAssigneeUser[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OperationActiveDirectoryUser = {
|
||||||
|
id: string
|
||||||
|
displayName: string
|
||||||
|
username: string
|
||||||
|
email: string
|
||||||
|
domain: string
|
||||||
|
distinguishedName: string
|
||||||
|
sid: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type OperationActiveDirectoryUsersResponse = {
|
||||||
|
configured?: boolean
|
||||||
|
baseDn?: string
|
||||||
|
users?: OperationActiveDirectoryUser[]
|
||||||
|
}
|
||||||
|
|
||||||
const equipmentCategories = {
|
const equipmentCategories = {
|
||||||
cameras: 'Kameras',
|
cameras: 'Kameras',
|
||||||
routers: 'Router',
|
routers: 'Router',
|
||||||
@ -215,6 +231,21 @@ function mapOperationUserToComboboxItem(user: OperationAssigneeUser): ComboboxIt
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mapActiveDirectoryUserToComboboxItem(user: OperationActiveDirectoryUser): ComboboxItem {
|
||||||
|
const accountName =
|
||||||
|
user.domain && user.username ? `${user.domain}\\${user.username}` : user.username
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
name: accountName || user.displayName || user.email || user.id,
|
||||||
|
secondaryText: [user.displayName, user.email].filter(Boolean).join(' / '),
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
sid: user.sid,
|
||||||
|
distinguishedName: user.distinguishedName,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function readApiError(response: Response, fallback: string) {
|
async function readApiError(response: Response, fallback: string) {
|
||||||
const data = await response.json().catch(() => null)
|
const data = await response.json().catch(() => null)
|
||||||
|
|
||||||
@ -581,6 +612,11 @@ export default function CreateOperationModal({
|
|||||||
const [selectedOperationLeader, setSelectedOperationLeader] = useState<ComboboxItem | null>(null)
|
const [selectedOperationLeader, setSelectedOperationLeader] = useState<ComboboxItem | null>(null)
|
||||||
const [selectedOperationTeam, setSelectedOperationTeam] = useState<ComboboxItem[]>([])
|
const [selectedOperationTeam, setSelectedOperationTeam] = useState<ComboboxItem[]>([])
|
||||||
const [isLoadingOperationAssignees, setIsLoadingOperationAssignees] = useState(false)
|
const [isLoadingOperationAssignees, setIsLoadingOperationAssignees] = useState(false)
|
||||||
|
const [milestoneUserOptions, setMilestoneUserOptions] = useState<ComboboxItem[]>([])
|
||||||
|
const [selectedMilestoneUser, setSelectedMilestoneUser] = useState<ComboboxItem | null>(null)
|
||||||
|
const [isLoadingMilestoneUsers, setIsLoadingMilestoneUsers] = useState(false)
|
||||||
|
const [milestoneUsersConfigured, setMilestoneUsersConfigured] = useState(false)
|
||||||
|
const [milestoneUsersError, setMilestoneUsersError] = useState<string | null>(null)
|
||||||
|
|
||||||
const [equipmentDevices, setEquipmentDevices] = useState<Device[]>([])
|
const [equipmentDevices, setEquipmentDevices] = useState<Device[]>([])
|
||||||
const [isLoadingDevices, setIsLoadingDevices] = useState(false)
|
const [isLoadingDevices, setIsLoadingDevices] = useState(false)
|
||||||
@ -668,6 +704,11 @@ export default function CreateOperationModal({
|
|||||||
setFormValues(initialFormValues)
|
setFormValues(initialFormValues)
|
||||||
setSelectedOperationLeader(null)
|
setSelectedOperationLeader(null)
|
||||||
setSelectedOperationTeam([])
|
setSelectedOperationTeam([])
|
||||||
|
setMilestoneUserOptions([])
|
||||||
|
setSelectedMilestoneUser(null)
|
||||||
|
setIsLoadingMilestoneUsers(false)
|
||||||
|
setMilestoneUsersConfigured(false)
|
||||||
|
setMilestoneUsersError(null)
|
||||||
setSelectedCameraDevice(null)
|
setSelectedCameraDevice(null)
|
||||||
setSelectedRouterDevice(null)
|
setSelectedRouterDevice(null)
|
||||||
setSelectedSwitchboxDevice(null)
|
setSelectedSwitchboxDevice(null)
|
||||||
@ -699,6 +740,7 @@ export default function CreateOperationModal({
|
|||||||
const abortController = new AbortController()
|
const abortController = new AbortController()
|
||||||
|
|
||||||
void loadOperationAssignees(abortController.signal)
|
void loadOperationAssignees(abortController.signal)
|
||||||
|
void loadMilestoneUsers(abortController.signal)
|
||||||
void loadEquipmentDevices(abortController.signal)
|
void loadEquipmentDevices(abortController.signal)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@ -753,6 +795,48 @@ export default function CreateOperationModal({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadMilestoneUsers(signal?: AbortSignal) {
|
||||||
|
setIsLoadingMilestoneUsers(true)
|
||||||
|
setMilestoneUsersError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/operations/ad-users`, {
|
||||||
|
credentials: 'include',
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
await readApiError(response, 'AD-Benutzer konnten nicht geladen werden'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as OperationActiveDirectoryUsersResponse
|
||||||
|
const configured = Boolean(data.configured)
|
||||||
|
|
||||||
|
setMilestoneUsersConfigured(configured)
|
||||||
|
setMilestoneUserOptions((data.users ?? []).map(mapActiveDirectoryUserToComboboxItem))
|
||||||
|
|
||||||
|
if (!configured) {
|
||||||
|
setMilestoneUsersError('AD ist in den Milestone-Einstellungen noch nicht konfiguriert.')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setMilestoneUsersError(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'AD-Benutzer konnten nicht geladen werden',
|
||||||
|
)
|
||||||
|
setMilestoneUsersConfigured(false)
|
||||||
|
setMilestoneUserOptions([])
|
||||||
|
} finally {
|
||||||
|
setIsLoadingMilestoneUsers(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadEquipmentDevices(signal?: AbortSignal) {
|
async function loadEquipmentDevices(signal?: AbortSignal) {
|
||||||
setIsLoadingDevices(true)
|
setIsLoadingDevices(true)
|
||||||
|
|
||||||
@ -911,6 +995,13 @@ export default function CreateOperationModal({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleMilestoneUserChange(item: ComboboxItem | null) {
|
||||||
|
setSelectedMilestoneUser(item)
|
||||||
|
if (milestoneUsersError) {
|
||||||
|
setMilestoneUsersError(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function findEquipmentDevice(item: ComboboxItem | null) {
|
function findEquipmentDevice(item: ComboboxItem | null) {
|
||||||
if (!item || item.id == null) {
|
if (!item || item.id == null) {
|
||||||
return undefined
|
return undefined
|
||||||
@ -1223,13 +1314,22 @@ export default function CreateOperationModal({
|
|||||||
<input type="hidden" name="viewConeLength" value={viewConeLength} />
|
<input type="hidden" name="viewConeLength" value={viewConeLength} />
|
||||||
<input type="hidden" name="operationLeader" value={formValues.operationLeader} />
|
<input type="hidden" name="operationLeader" value={formValues.operationLeader} />
|
||||||
<input type="hidden" name="operationTeam" value={formValues.operationTeam} />
|
<input type="hidden" name="operationTeam" value={formValues.operationTeam} />
|
||||||
|
<input type="hidden" name="milestoneUserId" value={selectedMilestoneUser?.id ?? ''} />
|
||||||
|
<input type="hidden" name="milestoneUserName" value={selectedMilestoneUser?.name ?? ''} />
|
||||||
|
<input type="hidden" name="milestoneUserSid" value={selectedMilestoneUser?.sid ?? ''} />
|
||||||
|
<input type="hidden" name="milestoneUserDn" value={selectedMilestoneUser?.distinguishedName ?? ''} />
|
||||||
<input type="hidden" name="legend" value={formValues.legend} />
|
<input type="hidden" name="legend" value={formValues.legend} />
|
||||||
<input type="hidden" name="camera" value={formValues.camera} />
|
<input type="hidden" name="camera" value={formValues.camera} />
|
||||||
|
<input type="hidden" name="cameraDeviceId" value={selectedCameraDevice?.id ?? ''} />
|
||||||
<input type="hidden" name="router" value={formValues.router} />
|
<input type="hidden" name="router" value={formValues.router} />
|
||||||
|
<input type="hidden" name="routerDeviceId" value={selectedRouterDevice?.id ?? ''} />
|
||||||
<input type="hidden" name="technology" value={formValues.technology} />
|
<input type="hidden" name="technology" value={formValues.technology} />
|
||||||
<input type="hidden" name="switchbox" value={formValues.switchbox} />
|
<input type="hidden" name="switchbox" value={formValues.switchbox} />
|
||||||
|
<input type="hidden" name="switchboxDeviceId" value={selectedSwitchboxDevice?.id ?? ''} />
|
||||||
<input type="hidden" name="hardDrive" value={formValues.hardDrive} />
|
<input type="hidden" name="hardDrive" value={formValues.hardDrive} />
|
||||||
|
<input type="hidden" name="hardDriveDeviceId" value={selectedHardDriveDevice?.id ?? ''} />
|
||||||
<input type="hidden" name="laptop" value={formValues.laptop} />
|
<input type="hidden" name="laptop" value={formValues.laptop} />
|
||||||
|
<input type="hidden" name="laptopDeviceId" value={selectedLaptopDevice?.id ?? ''} />
|
||||||
<input type="hidden" name="remark" value={formValues.remark} />
|
<input type="hidden" name="remark" value={formValues.remark} />
|
||||||
<input type="hidden" name="milestoneStorage" value={selectedMilestoneStorage ? (selectedMilestoneStorage.displayName || selectedMilestoneStorage.name) : ''} />
|
<input type="hidden" name="milestoneStorage" value={selectedMilestoneStorage ? (selectedMilestoneStorage.displayName || selectedMilestoneStorage.name) : ''} />
|
||||||
|
|
||||||
@ -1486,6 +1586,42 @@ export default function CreateOperationModal({
|
|||||||
disabled={isLoadingOperationAssignees}
|
disabled={isLoadingOperationAssignees}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-white/10 dark:bg-white/[0.03]">
|
||||||
|
<div className="mb-3">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
Milestone-Auswerter
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
AD-Benutzer aus dem konfigurierten Suchbereich für die Milestone-Rolle.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Combobox
|
||||||
|
items={milestoneUserOptions}
|
||||||
|
value={selectedMilestoneUser}
|
||||||
|
onChange={handleMilestoneUserChange}
|
||||||
|
variant="secondary"
|
||||||
|
placeholder={
|
||||||
|
isLoadingMilestoneUsers
|
||||||
|
? 'AD-Benutzer werden geladen...'
|
||||||
|
: 'AD-Benutzer auswählen'
|
||||||
|
}
|
||||||
|
emptyText={
|
||||||
|
milestoneUsersConfigured
|
||||||
|
? 'Keine AD-Benutzer im Suchbereich gefunden.'
|
||||||
|
: 'AD ist in den Milestone-Einstellungen noch nicht konfiguriert.'
|
||||||
|
}
|
||||||
|
disabled={isLoadingMilestoneUsers || !milestoneUsersConfigured}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{milestoneUsersError && (
|
||||||
|
<p className="mt-2 text-xs text-amber-700 dark:text-amber-300">
|
||||||
|
{milestoneUsersError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -1961,7 +2097,7 @@ export default function CreateOperationModal({
|
|||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-3">
|
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-4">
|
||||||
<ReviewItem label="Sachbearbeitung" value={formValues.caseWorker} />
|
<ReviewItem label="Sachbearbeitung" value={formValues.caseWorker} />
|
||||||
|
|
||||||
<ReviewPersonItem
|
<ReviewPersonItem
|
||||||
@ -1975,6 +2111,12 @@ export default function CreateOperationModal({
|
|||||||
items={selectedOperationTeam}
|
items={selectedOperationTeam}
|
||||||
fallbackValue={formValues.operationTeam}
|
fallbackValue={formValues.operationTeam}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ReviewPersonItem
|
||||||
|
label="Milestone-Auswerter"
|
||||||
|
item={selectedMilestoneUser}
|
||||||
|
fallbackValue=""
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -10,14 +10,19 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import {
|
import {
|
||||||
|
ArrowUturnLeftIcon,
|
||||||
ChatBubbleLeftEllipsisIcon,
|
ChatBubbleLeftEllipsisIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
MapPinIcon,
|
MapPinIcon,
|
||||||
PencilSquareIcon,
|
PencilSquareIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
Squares2X2Icon,
|
Squares2X2Icon,
|
||||||
TableCellsIcon,
|
TableCellsIcon,
|
||||||
|
TrashIcon,
|
||||||
WrenchScrewdriverIcon,
|
WrenchScrewdriverIcon,
|
||||||
|
XMarkIcon,
|
||||||
} from '@heroicons/react/20/solid'
|
} from '@heroicons/react/20/solid'
|
||||||
|
import { DialogTitle } from '@headlessui/react'
|
||||||
import {
|
import {
|
||||||
CircleMarker,
|
CircleMarker,
|
||||||
GeoJSON,
|
GeoJSON,
|
||||||
@ -37,6 +42,8 @@ import type { Operation, OperationHistoryEntry, User } from '../../components/ty
|
|||||||
import CreateOperationModal from './CreateOperationModal'
|
import CreateOperationModal from './CreateOperationModal'
|
||||||
import NotificationDot from '../../components/NotificationDot'
|
import NotificationDot from '../../components/NotificationDot'
|
||||||
import Avatar from '../../components/Avatar'
|
import Avatar from '../../components/Avatar'
|
||||||
|
import Modal from '../../components/Modal'
|
||||||
|
import TaskList, { type TaskListStep } from '../../components/TaskList'
|
||||||
|
|
||||||
type OperationsPageProps = {
|
type OperationsPageProps = {
|
||||||
unreadJournalOperationIds?: Set<string>
|
unreadJournalOperationIds?: Set<string>
|
||||||
@ -46,6 +53,7 @@ type OperationsPageProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type OperationsView = 'table' | 'cards' | 'map' | 'heatmap'
|
type OperationsView = 'table' | 'cards' | 'map' | 'heatmap'
|
||||||
|
type OperationListScope = 'active' | 'completed'
|
||||||
|
|
||||||
type OperationSortDirection = 'asc' | 'desc'
|
type OperationSortDirection = 'asc' | 'desc'
|
||||||
|
|
||||||
@ -76,6 +84,164 @@ type OperationAssigneesResponse = {
|
|||||||
officers?: OperationAssigneeUser[]
|
officers?: OperationAssigneeUser[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OperationMilestoneSetupStepId =
|
||||||
|
| 'viewGroup'
|
||||||
|
| 'cameraIp'
|
||||||
|
| 'roleUser'
|
||||||
|
| 'roleCamera'
|
||||||
|
| 'roleViewGroup'
|
||||||
|
| 'otherRoles'
|
||||||
|
|
||||||
|
type OperationMilestoneSetupEvent = {
|
||||||
|
type: 'step' | 'done' | 'error'
|
||||||
|
step?: OperationMilestoneSetupStepId
|
||||||
|
status?: 'current' | 'complete' | 'error'
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type OperationMilestoneSetupPayload = {
|
||||||
|
cameraDeviceId: string
|
||||||
|
routerDeviceId: string
|
||||||
|
switchboxDeviceId: string
|
||||||
|
hardDriveDeviceId: string
|
||||||
|
laptopDeviceId: string
|
||||||
|
milestoneUserId: string
|
||||||
|
milestoneUserName: string
|
||||||
|
milestoneUserSid: string
|
||||||
|
milestoneUserDn: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const operationMilestoneSetupStepDefinitions: Array<{
|
||||||
|
id: OperationMilestoneSetupStepId
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
id: 'viewGroup',
|
||||||
|
name: 'Ansichtsgruppe anlegen',
|
||||||
|
description: 'Milestone-Ansichtsgruppe fuer den Einsatz wird geprueft.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cameraIp',
|
||||||
|
name: 'Kamera-IP setzen',
|
||||||
|
description: 'Kamera-IP wird aus Router-Subnetz und Kameranummer berechnet.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'roleUser',
|
||||||
|
name: 'AD-Benutzer zuordnen',
|
||||||
|
description: 'Benutzer des Auswerterechners wird der passenden Rolle hinzugefuegt.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'roleCamera',
|
||||||
|
name: 'Kamera der Rolle zuordnen',
|
||||||
|
description: 'Milestone-Kamera wird fuer die Auswertungsrolle freigegeben.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'roleViewGroup',
|
||||||
|
name: 'Ansichtsgruppe der Rolle zuordnen',
|
||||||
|
description: 'Die Einsatz-Ansichtsgruppe wird der Rolle zugewiesen.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'otherRoles',
|
||||||
|
name: 'Andere Rollen pruefen',
|
||||||
|
description: 'Weitere Rollen werden fuer den Einsatz-Nachlauf geprueft.',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function createOperationMilestoneSetupSteps(): TaskListStep[] {
|
||||||
|
return operationMilestoneSetupStepDefinitions.map((step, index) => ({
|
||||||
|
...step,
|
||||||
|
status: index === 0 ? 'current' : 'upcoming',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateOperationMilestoneSetupStepsForEvent(
|
||||||
|
steps: TaskListStep[],
|
||||||
|
event: OperationMilestoneSetupEvent,
|
||||||
|
): TaskListStep[] {
|
||||||
|
if (event.type === 'done') {
|
||||||
|
return steps.map((step) =>
|
||||||
|
step.status === 'error'
|
||||||
|
? step
|
||||||
|
: {
|
||||||
|
...step,
|
||||||
|
status: 'complete',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event.step) {
|
||||||
|
return steps
|
||||||
|
}
|
||||||
|
|
||||||
|
const stepIndex = steps.findIndex((step) => step.id === event.step)
|
||||||
|
|
||||||
|
if (stepIndex < 0) {
|
||||||
|
return steps
|
||||||
|
}
|
||||||
|
|
||||||
|
return steps.map((step, index) => {
|
||||||
|
if (index === stepIndex) {
|
||||||
|
return {
|
||||||
|
...step,
|
||||||
|
status:
|
||||||
|
event.type === 'error' || event.status === 'error'
|
||||||
|
? 'error'
|
||||||
|
: event.status === 'complete'
|
||||||
|
? 'complete'
|
||||||
|
: 'current',
|
||||||
|
description: event.message || step.description,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.status === 'complete' && index === stepIndex + 1 && step.status === 'upcoming') {
|
||||||
|
return {
|
||||||
|
...step,
|
||||||
|
status: 'current',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.status === 'current' && step.status === 'current') {
|
||||||
|
return {
|
||||||
|
...step,
|
||||||
|
status: index < stepIndex ? 'complete' : 'upcoming',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return step
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function markCurrentOperationMilestoneSetupStepAsError(
|
||||||
|
steps: TaskListStep[],
|
||||||
|
message: string,
|
||||||
|
): TaskListStep[] {
|
||||||
|
const currentStepIndex = steps.findIndex((step) => step.status === 'current')
|
||||||
|
const upcomingStepIndex = steps.findIndex((step) => step.status === 'upcoming')
|
||||||
|
const failedStepIndex =
|
||||||
|
currentStepIndex >= 0
|
||||||
|
? currentStepIndex
|
||||||
|
: upcomingStepIndex >= 0
|
||||||
|
? upcomingStepIndex
|
||||||
|
: steps.length - 1
|
||||||
|
|
||||||
|
return steps.map((step, index) =>
|
||||||
|
index === failedStepIndex
|
||||||
|
? {
|
||||||
|
...step,
|
||||||
|
status: 'error',
|
||||||
|
description: message,
|
||||||
|
}
|
||||||
|
: step,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readApiError(response: Response, fallback: string) {
|
||||||
|
const data = await response.json().catch(() => null)
|
||||||
|
|
||||||
|
return data?.error ?? fallback
|
||||||
|
}
|
||||||
|
|
||||||
function getEquipmentItems(operation: Operation) {
|
function getEquipmentItems(operation: Operation) {
|
||||||
return [
|
return [
|
||||||
{ label: 'Kamera', value: operation.camera },
|
{ label: 'Kamera', value: operation.camera },
|
||||||
@ -756,12 +922,23 @@ export default function OperationsPage({
|
|||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
const [isJournalSubmitting, setIsJournalSubmitting] = useState(false)
|
const [isJournalSubmitting, setIsJournalSubmitting] = useState(false)
|
||||||
|
const [operationScope, setOperationScope] = useState<OperationListScope>('active')
|
||||||
|
const [operationActionId, setOperationActionId] = useState<string | null>(null)
|
||||||
|
const [operationActionError, setOperationActionError] = useState<string | null>(null)
|
||||||
|
|
||||||
const [isCreateOperationModalOpen, setIsCreateOperationModalOpen] = useState(false)
|
const [isCreateOperationModalOpen, setIsCreateOperationModalOpen] = useState(false)
|
||||||
const [isOperationModalOpen, setIsOperationModalOpen] = useState(false)
|
const [isOperationModalOpen, setIsOperationModalOpen] = useState(false)
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const [saveError, setSaveError] = useState<string | null>(null)
|
const [saveError, setSaveError] = useState<string | null>(null)
|
||||||
const [editingOperation, setEditingOperation] = useState<Operation | null>(null)
|
const [editingOperation, setEditingOperation] = useState<Operation | null>(null)
|
||||||
|
const [milestoneSetupModalOpen, setMilestoneSetupModalOpen] = useState(false)
|
||||||
|
const [milestoneSetupSteps, setMilestoneSetupSteps] = useState<TaskListStep[]>(
|
||||||
|
() => createOperationMilestoneSetupSteps(),
|
||||||
|
)
|
||||||
|
const [milestoneSetupError, setMilestoneSetupError] = useState<string | null>(null)
|
||||||
|
const [milestoneSetupCompleted, setMilestoneSetupCompleted] = useState(false)
|
||||||
|
const [isMilestoneSetupRunning, setIsMilestoneSetupRunning] = useState(false)
|
||||||
|
const [milestoneSetupOperation, setMilestoneSetupOperation] = useState<Operation | null>(null)
|
||||||
|
|
||||||
const [operationHistory, setOperationHistory] = useState<OperationHistoryEntry[]>([])
|
const [operationHistory, setOperationHistory] = useState<OperationHistoryEntry[]>([])
|
||||||
const [isHistoryLoading, setIsHistoryLoading] = useState(false)
|
const [isHistoryLoading, setIsHistoryLoading] = useState(false)
|
||||||
@ -781,7 +958,6 @@ export default function OperationsPage({
|
|||||||
const closeCleanupTimeoutRef = useRef<number | null>(null)
|
const closeCleanupTimeoutRef = useRef<number | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadOperations()
|
|
||||||
void loadOperationAssignees()
|
void loadOperationAssignees()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@ -791,6 +967,10 @@ export default function OperationsPage({
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadOperations()
|
||||||
|
}, [operationScope])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleOperationNotification() {
|
function handleOperationNotification() {
|
||||||
void loadOperations()
|
void loadOperations()
|
||||||
@ -856,7 +1036,11 @@ export default function OperationsPage({
|
|||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_URL}/operations`, {
|
const query =
|
||||||
|
operationScope === 'completed'
|
||||||
|
? '?status=completed'
|
||||||
|
: ''
|
||||||
|
const response = await fetch(`${API_URL}/operations${query}`, {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -1011,6 +1195,12 @@ export default function OperationsPage({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleOperationScopeChange(nextScope: string) {
|
||||||
|
setOperationScope(nextScope as OperationListScope)
|
||||||
|
setFocusedMapOperationId(null)
|
||||||
|
setOperationActionError(null)
|
||||||
|
}
|
||||||
|
|
||||||
function focusOperationAddressesOnMap(operationId: string) {
|
function focusOperationAddressesOnMap(operationId: string) {
|
||||||
setFocusedMapOperationId(operationId)
|
setFocusedMapOperationId(operationId)
|
||||||
setView('map')
|
setView('map')
|
||||||
@ -1032,6 +1222,7 @@ export default function OperationsPage({
|
|||||||
setOperationHistory([])
|
setOperationHistory([])
|
||||||
setHistoryError(null)
|
setHistoryError(null)
|
||||||
setIsHistoryLoading(false)
|
setIsHistoryLoading(false)
|
||||||
|
setOperationScope('active')
|
||||||
setIsCreateOperationModalOpen(true)
|
setIsCreateOperationModalOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1073,6 +1264,197 @@ export default function OperationsPage({
|
|||||||
void loadOperationHistory(operation.id)
|
void loadOperationHistory(operation.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleOperationMilestoneSetupEvent(event: OperationMilestoneSetupEvent) {
|
||||||
|
setMilestoneSetupSteps((currentSteps) =>
|
||||||
|
updateOperationMilestoneSetupStepsForEvent(currentSteps, event),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (event.type === 'done') {
|
||||||
|
setMilestoneSetupCompleted(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === 'error') {
|
||||||
|
const message = event.message || 'Milestone-Nachlauf fehlgeschlagen'
|
||||||
|
|
||||||
|
setMilestoneSetupError(message)
|
||||||
|
if (!event.step) {
|
||||||
|
setMilestoneSetupSteps((currentSteps) =>
|
||||||
|
markCurrentOperationMilestoneSetupStepAsError(currentSteps, message),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runOperationMilestoneSetup(
|
||||||
|
operation: Operation,
|
||||||
|
payload: OperationMilestoneSetupPayload,
|
||||||
|
) {
|
||||||
|
setMilestoneSetupOperation(operation)
|
||||||
|
setMilestoneSetupModalOpen(true)
|
||||||
|
setMilestoneSetupCompleted(false)
|
||||||
|
setMilestoneSetupError(null)
|
||||||
|
setMilestoneSetupSteps(createOperationMilestoneSetupSteps())
|
||||||
|
setIsMilestoneSetupRunning(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/operations/${operation.id}/milestone-setup`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
const message = await readApiError(
|
||||||
|
response,
|
||||||
|
'Milestone-Nachlauf konnte nicht gestartet werden',
|
||||||
|
)
|
||||||
|
|
||||||
|
setMilestoneSetupError(message)
|
||||||
|
setMilestoneSetupSteps((currentSteps) =>
|
||||||
|
markCurrentOperationMilestoneSetupStepAsError(currentSteps, message),
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
|
||||||
|
let buffer = ''
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read()
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true })
|
||||||
|
|
||||||
|
const lines = buffer.split('\n')
|
||||||
|
buffer = lines.pop() ?? ''
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmedLine = line.trim()
|
||||||
|
|
||||||
|
if (!trimmedLine) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
handleOperationMilestoneSetupEvent(
|
||||||
|
JSON.parse(trimmedLine) as OperationMilestoneSetupEvent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingLine = buffer.trim()
|
||||||
|
|
||||||
|
if (remainingLine) {
|
||||||
|
handleOperationMilestoneSetupEvent(
|
||||||
|
JSON.parse(remainingLine) as OperationMilestoneSetupEvent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Milestone-Nachlauf konnte nicht abgeschlossen werden'
|
||||||
|
|
||||||
|
setMilestoneSetupError(message)
|
||||||
|
setMilestoneSetupSteps((currentSteps) =>
|
||||||
|
markCurrentOperationMilestoneSetupStepAsError(currentSteps, message),
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setIsMilestoneSetupRunning(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateOperationCompletion(operation: Operation, completed: boolean) {
|
||||||
|
setOperationActionId(operation.id)
|
||||||
|
setOperationActionError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_URL}/operations/${operation.id}/${completed ? 'complete' : 'reopen'}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
await readApiError(
|
||||||
|
response,
|
||||||
|
completed
|
||||||
|
? 'Einsatz konnte nicht abgeschlossen werden'
|
||||||
|
: 'Einsatz konnte nicht wieder geoeffnet werden',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadOperations()
|
||||||
|
|
||||||
|
if (view === 'heatmap' || heatmapOperations.length > 0) {
|
||||||
|
await loadHeatmapOperations()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setOperationActionError(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: completed
|
||||||
|
? 'Einsatz konnte nicht abgeschlossen werden'
|
||||||
|
: 'Einsatz konnte nicht wieder geoeffnet werden',
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setOperationActionId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCompletedOperation(operation: Operation) {
|
||||||
|
if (!operation.completedAt) {
|
||||||
|
setOperationActionError('Nur abgeschlossene Einsaetze koennen geloescht werden.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
`Abgeschlossenen Einsatz ${operation.operationNumber || operation.operationName || ''} wirklich loeschen?`,
|
||||||
|
)
|
||||||
|
if (!confirmed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setOperationActionId(operation.id)
|
||||||
|
setOperationActionError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/operations/${operation.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
await readApiError(response, 'Einsatz konnte nicht geloescht werden'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadOperations()
|
||||||
|
} catch (error) {
|
||||||
|
setOperationActionError(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Einsatz konnte nicht geloescht werden',
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setOperationActionId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSaveOperation(event: FormEvent<HTMLFormElement>) {
|
async function handleSaveOperation(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (!canWriteOperations) {
|
if (!canWriteOperations) {
|
||||||
@ -1083,6 +1465,17 @@ export default function OperationsPage({
|
|||||||
const form = event.currentTarget
|
const form = event.currentTarget
|
||||||
const formData = new FormData(form)
|
const formData = new FormData(form)
|
||||||
const operationToEdit = editingOperation
|
const operationToEdit = editingOperation
|
||||||
|
const milestoneSetupPayload: OperationMilestoneSetupPayload = {
|
||||||
|
cameraDeviceId: String(formData.get('cameraDeviceId') ?? ''),
|
||||||
|
routerDeviceId: String(formData.get('routerDeviceId') ?? ''),
|
||||||
|
switchboxDeviceId: String(formData.get('switchboxDeviceId') ?? ''),
|
||||||
|
hardDriveDeviceId: String(formData.get('hardDriveDeviceId') ?? ''),
|
||||||
|
laptopDeviceId: String(formData.get('laptopDeviceId') ?? ''),
|
||||||
|
milestoneUserId: String(formData.get('milestoneUserId') ?? ''),
|
||||||
|
milestoneUserName: String(formData.get('milestoneUserName') ?? ''),
|
||||||
|
milestoneUserSid: String(formData.get('milestoneUserSid') ?? ''),
|
||||||
|
milestoneUserDn: String(formData.get('milestoneUserDn') ?? ''),
|
||||||
|
}
|
||||||
const optionalNumber = (name: string) => {
|
const optionalNumber = (name: string) => {
|
||||||
const rawValue = String(formData.get(name) ?? '').trim()
|
const rawValue = String(formData.get(name) ?? '').trim()
|
||||||
if (!rawValue) {
|
if (!rawValue) {
|
||||||
@ -1148,6 +1541,9 @@ export default function OperationsPage({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as { operation?: Operation }
|
||||||
|
const createdOperation = operationToEdit ? null : data.operation ?? null
|
||||||
|
|
||||||
form.reset()
|
form.reset()
|
||||||
setIsOperationModalOpen(false)
|
setIsOperationModalOpen(false)
|
||||||
setIsCreateOperationModalOpen(false)
|
setIsCreateOperationModalOpen(false)
|
||||||
@ -1158,6 +1554,10 @@ export default function OperationsPage({
|
|||||||
if (view === 'heatmap' || heatmapOperations.length > 0) {
|
if (view === 'heatmap' || heatmapOperations.length > 0) {
|
||||||
await loadHeatmapOperations()
|
await loadHeatmapOperations()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (createdOperation) {
|
||||||
|
void runOperationMilestoneSetup(createdOperation, milestoneSetupPayload)
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setSaveError(
|
setSaveError(
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
@ -1346,6 +1746,73 @@ export default function OperationsPage({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderOperationActions(operation: Operation, compact = false) {
|
||||||
|
if (!canWriteOperations) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCompleted = Boolean(operation.completedAt)
|
||||||
|
const isBusy = operationActionId === operation.id
|
||||||
|
|
||||||
|
if (isCompleted) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
color="gray"
|
||||||
|
size="sm"
|
||||||
|
disabled={isBusy}
|
||||||
|
onClick={() => void updateOperationCompletion(operation, false)}
|
||||||
|
leadingIcon={<ArrowUturnLeftIcon aria-hidden="true" className="size-4" />}
|
||||||
|
>
|
||||||
|
Wieder öffnen
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
color="red"
|
||||||
|
size="sm"
|
||||||
|
isLoading={isBusy}
|
||||||
|
onClick={() => void deleteCompletedOperation(operation)}
|
||||||
|
leadingIcon={<TrashIcon aria-hidden="true" className="size-4" />}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
color="indigo"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => openEditModal(operation)}
|
||||||
|
leadingIcon={<PencilSquareIcon aria-hidden="true" className="size-4" />}
|
||||||
|
>
|
||||||
|
Bearbeiten
|
||||||
|
<span className="sr-only">, {operation.operationNumber}</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={compact ? 'secondary' : 'soft'}
|
||||||
|
color="emerald"
|
||||||
|
size="sm"
|
||||||
|
isLoading={isBusy}
|
||||||
|
onClick={() => void updateOperationCompletion(operation, true)}
|
||||||
|
leadingIcon={<CheckCircleIcon aria-hidden="true" className="size-4" />}
|
||||||
|
>
|
||||||
|
Abschließen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const columns: TableColumn<Operation>[] = [
|
const columns: TableColumn<Operation>[] = [
|
||||||
{
|
{
|
||||||
key: 'operation',
|
key: 'operation',
|
||||||
@ -1612,7 +2079,9 @@ export default function OperationsPage({
|
|||||||
if (operations.length === 0) {
|
if (operations.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-dashed border-gray-200 px-4 py-10 text-center text-sm text-gray-500 dark:border-white/10 dark:text-gray-400">
|
<div className="rounded-lg border border-dashed border-gray-200 px-4 py-10 text-center text-sm text-gray-500 dark:border-white/10 dark:text-gray-400">
|
||||||
Keine Einsätze vorhanden.
|
{operationScope === 'completed'
|
||||||
|
? 'Keine abgeschlossenen Einsätze vorhanden.'
|
||||||
|
: 'Keine aktiven Einsätze vorhanden.'}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -1661,23 +2130,7 @@ export default function OperationsPage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canWriteOperations && (
|
{renderOperationActions(operation)}
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
color="indigo"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => openEditModal(operation)}
|
|
||||||
leadingIcon={
|
|
||||||
<PencilSquareIcon aria-hidden="true" className="size-4" />
|
|
||||||
}
|
|
||||||
className="shrink-0 bg-white/80 dark:bg-white/10"
|
|
||||||
>
|
|
||||||
Bearbeiten
|
|
||||||
<span className="sr-only">
|
|
||||||
, {operation.operationNumber}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-end gap-3">
|
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-end gap-3">
|
||||||
@ -1778,7 +2231,7 @@ export default function OperationsPage({
|
|||||||
: 'h-full min-h-0 overflow-y-auto px-4 py-10 sm:px-6 lg:px-8'
|
: 'h-full min-h-0 overflow-y-auto px-4 py-10 sm:px-6 lg:px-8'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(error || (view === 'heatmap' && heatmapError)) && (
|
{(error || operationActionError || (view === 'heatmap' && heatmapError)) && (
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
isMapView
|
isMapView
|
||||||
@ -1786,7 +2239,7 @@ export default function OperationsPage({
|
|||||||
: 'mb-6 rounded-md bg-red-50 p-4 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300'
|
: 'mb-6 rounded-md bg-red-50 p-4 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{view === 'heatmap' ? heatmapError : error}
|
{operationActionError || (view === 'heatmap' ? heatmapError : error)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -1805,11 +2258,32 @@ export default function OperationsPage({
|
|||||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
{isLoading
|
{isLoading
|
||||||
? 'Einsätze werden geladen...'
|
? 'Einsätze werden geladen...'
|
||||||
: 'Eine Liste aller Einsätze inklusive Einsatznummer, Einsatzname, Delikt, Sachbearbeitung, Zielobjekt und Technik.'}
|
: operationScope === 'completed'
|
||||||
|
? 'Abgeschlossene Einsätze bleiben einsehbar und können bei Bedarf gelöscht werden.'
|
||||||
|
: 'Aktive Einsätze inklusive Einsatznummer, Einsatzname, Delikt, Sachbearbeitung, Zielobjekt und Technik.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex items-center justify-between gap-x-3 sm:mt-0 sm:ml-16 sm:flex-none">
|
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 sm:mt-0 sm:ml-16 sm:flex-none">
|
||||||
|
<ButtonGroup
|
||||||
|
ariaLabel="Einsatzstatus filtern"
|
||||||
|
size="sm"
|
||||||
|
value={operationScope}
|
||||||
|
onChange={handleOperationScopeChange}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
id: 'active',
|
||||||
|
label: 'Aktiv',
|
||||||
|
icon: <WrenchScrewdriverIcon aria-hidden="true" className="size-5" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'completed',
|
||||||
|
label: 'Abgeschlossen',
|
||||||
|
icon: <CheckCircleIcon aria-hidden="true" className="size-5" />,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
<ButtonGroup
|
<ButtonGroup
|
||||||
ariaLabel="Ansicht wechseln"
|
ariaLabel="Ansicht wechseln"
|
||||||
size="sm"
|
size="sm"
|
||||||
@ -1860,7 +2334,11 @@ export default function OperationsPage({
|
|||||||
sortKey={sortKey}
|
sortKey={sortKey}
|
||||||
sortDirection={sortDirection}
|
sortDirection={sortDirection}
|
||||||
onSort={handleSort}
|
onSort={handleSort}
|
||||||
emptyText="Keine Einsätze vorhanden."
|
emptyText={
|
||||||
|
operationScope === 'completed'
|
||||||
|
? 'Keine abgeschlossenen Einsätze vorhanden.'
|
||||||
|
: 'Keine aktiven Einsätze vorhanden.'
|
||||||
|
}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
loadingContent={
|
loadingContent={
|
||||||
<LoadingSpinner
|
<LoadingSpinner
|
||||||
@ -1872,19 +2350,7 @@ export default function OperationsPage({
|
|||||||
}
|
}
|
||||||
rowActions={
|
rowActions={
|
||||||
canWriteOperations
|
canWriteOperations
|
||||||
? (operation) => (
|
? (operation) => renderOperationActions(operation, true)
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
color="indigo"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => openEditModal(operation)}
|
|
||||||
leadingIcon={<PencilSquareIcon aria-hidden="true" className="size-4" />}
|
|
||||||
>
|
|
||||||
Bearbeiten
|
|
||||||
<span className="sr-only">, {operation.operationNumber}</span>
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@ -1896,7 +2362,7 @@ export default function OperationsPage({
|
|||||||
operations={operations}
|
operations={operations}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
onEdit={openEditModal}
|
onEdit={openEditModal}
|
||||||
canEdit={canWriteOperations}
|
canEdit={canWriteOperations && operationScope === 'active'}
|
||||||
mode="markers"
|
mode="markers"
|
||||||
focusOperationId={focusedMapOperationId}
|
focusOperationId={focusedMapOperationId}
|
||||||
className="h-full"
|
className="h-full"
|
||||||
@ -1908,13 +2374,75 @@ export default function OperationsPage({
|
|||||||
operations={heatmapOperations}
|
operations={heatmapOperations}
|
||||||
isLoading={isHeatmapLoading}
|
isLoading={isHeatmapLoading}
|
||||||
onEdit={openEditModal}
|
onEdit={openEditModal}
|
||||||
canEdit={canWriteOperations}
|
canEdit={canWriteOperations && operationScope === 'active'}
|
||||||
mode="heatmap"
|
mode="heatmap"
|
||||||
className="h-full"
|
className="h-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={milestoneSetupModalOpen}
|
||||||
|
setOpen={(open) => {
|
||||||
|
if (!open && isMilestoneSetupRunning) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setMilestoneSetupModalOpen(open)
|
||||||
|
}}
|
||||||
|
zIndexClassName="z-[9999]"
|
||||||
|
panelClassName="max-w-xl"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between border-b border-gray-200 px-6 py-4 dark:border-white/10">
|
||||||
|
<div>
|
||||||
|
<DialogTitle className="text-base font-semibold text-gray-900 dark:text-white">
|
||||||
|
Milestone-Nachlauf
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{milestoneSetupOperation
|
||||||
|
? `Einsatz ${milestoneSetupOperation.operationNumber} wird in Milestone vorbereitet.`
|
||||||
|
: 'Der Einsatz wird in Milestone vorbereitet.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={isMilestoneSetupRunning}
|
||||||
|
onClick={() => setMilestoneSetupModalOpen(false)}
|
||||||
|
className="rounded-md text-gray-400 hover:text-gray-500 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:text-gray-300"
|
||||||
|
>
|
||||||
|
<span className="sr-only">Schliessen</span>
|
||||||
|
<XMarkIcon aria-hidden="true" className="size-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 py-5">
|
||||||
|
<TaskList
|
||||||
|
steps={milestoneSetupSteps}
|
||||||
|
ariaLabel="Milestone-Nachlauf"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{milestoneSetupError && (
|
||||||
|
<p className="mt-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
||||||
|
{milestoneSetupError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end border-t border-gray-200 px-6 py-4 dark:border-white/10">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
color="gray"
|
||||||
|
disabled={isMilestoneSetupRunning}
|
||||||
|
onClick={() => setMilestoneSetupModalOpen(false)}
|
||||||
|
>
|
||||||
|
{milestoneSetupCompleted || milestoneSetupError ? 'Schliessen' : 'Bitte warten...'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<CreateOperationModal
|
<CreateOperationModal
|
||||||
open={isCreateOperationModalOpen}
|
open={isCreateOperationModalOpen}
|
||||||
setOpen={(open) => {
|
setOpen={(open) => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user