This commit is contained in:
Linrador 2026-06-25 20:08:45 +02:00
parent 50eff1e5d4
commit a95f8a15ac
27 changed files with 7006 additions and 104 deletions

1091
backend/active_directory.go Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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, "/")

View 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)
}

View File

@ -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
``` ```

View File

@ -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

View File

@ -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()

View File

@ -0,0 +1,16 @@
{
"archiveRoots": [
{
"label": "Archive E",
"path": "E:/"
}
],
"listenAddr": ":8099",
"storageRoots": [
{
"label": "Storage D",
"path": "D:/Milestone-Speicher"
}
],
"token": ""
}

View File

@ -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

View File

@ -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=

View File

@ -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,
) )

File diff suppressed because it is too large Load Diff

View File

@ -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)),

View File

@ -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 {

View File

@ -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,

View File

@ -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,

View File

@ -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 />}

View File

@ -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 = {

View File

@ -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',
},
], ],
}, },
], ],

View File

@ -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>

View File

@ -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
} }

View File

@ -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>

View File

@ -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>
</>
)
}

View File

@ -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>
</> </>
) )
} }

View File

@ -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>) {

File diff suppressed because it is too large Load Diff

View File

@ -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>

View File

@ -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) => {