Add Configuration Actions tab for shell and script action definitions.
Admins can manage built-in and custom actions with placeholders, ENV vars, and actions.create/update/delete permissions.
This commit is contained in:
@@ -0,0 +1,261 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
|
||||||
|
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type actionsResponse struct {
|
||||||
|
Actions []settings.Action `json:"actions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type createActionRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Kind settings.ActionKind `json:"kind"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Env []settings.ActionEnvVar `json:"env"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type patchActionRequest struct {
|
||||||
|
Name *string `json:"name"`
|
||||||
|
Description *string `json:"description"`
|
||||||
|
Body *string `json:"body"`
|
||||||
|
Env *[]settings.ActionEnvVar `json:"env"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var envVarNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errActionNameRequired = errors.New("action name is required")
|
||||||
|
errActionBodyRequired = errors.New("action body is required")
|
||||||
|
errActionKindInvalid = errors.New("action kind must be shell or script")
|
||||||
|
errActionNotFound = errors.New("action not found")
|
||||||
|
errActionBuiltinRO = errors.New("built-in actions cannot be modified")
|
||||||
|
errActionIDRequired = errors.New("action id is required")
|
||||||
|
)
|
||||||
|
|
||||||
|
func actionsGetHandler(configDir string) http.HandlerFunc {
|
||||||
|
return func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
store, err := settings.LoadActionsOrSeed(configDir)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(writer, http.StatusOK, actionsResponse{Actions: store.Actions})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func actionsCreateHandler(configDir string) http.HandlerFunc {
|
||||||
|
return func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
if err := authorizeActionsCreate(request, configDir); err != nil {
|
||||||
|
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload createActionRequest
|
||||||
|
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
|
||||||
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
action, err := buildCustomAction(payload)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
store, err := settings.LoadActionsOrSeed(configDir)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
store.Actions = append(store.Actions, action)
|
||||||
|
if err := settings.SaveActions(configDir, store); err != nil {
|
||||||
|
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(writer, http.StatusOK, actionsResponse{Actions: store.Actions})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func actionsPatchHandler(configDir string) http.HandlerFunc {
|
||||||
|
return func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
if err := authorizeActionsUpdate(request, configDir); err != nil {
|
||||||
|
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
actionID := strings.TrimSpace(request.URL.Query().Get("id"))
|
||||||
|
if actionID == "" {
|
||||||
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: errActionIDRequired.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload patchActionRequest
|
||||||
|
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
|
||||||
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
store, err := settings.LoadActionsOrSeed(configDir)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
index := findActionIndex(store.Actions, actionID)
|
||||||
|
if index < 0 {
|
||||||
|
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: errActionNotFound.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if store.Actions[index].Builtin {
|
||||||
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: errActionBuiltinRO.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updated := store.Actions[index]
|
||||||
|
if payload.Name != nil {
|
||||||
|
updated.Name = strings.TrimSpace(*payload.Name)
|
||||||
|
}
|
||||||
|
if payload.Description != nil {
|
||||||
|
updated.Description = strings.TrimSpace(*payload.Description)
|
||||||
|
}
|
||||||
|
if payload.Body != nil {
|
||||||
|
updated.Body = strings.TrimSpace(*payload.Body)
|
||||||
|
}
|
||||||
|
if payload.Env != nil {
|
||||||
|
updated.Env = *payload.Env
|
||||||
|
}
|
||||||
|
updated.UpdatedAt = time.Now().UTC()
|
||||||
|
|
||||||
|
if err := validateActionFields(updated.Name, updated.Kind, updated.Body, updated.Env); err != nil {
|
||||||
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if updated.Env == nil {
|
||||||
|
updated.Env = []settings.ActionEnvVar{}
|
||||||
|
}
|
||||||
|
|
||||||
|
store.Actions[index] = updated
|
||||||
|
if err := settings.SaveActions(configDir, store); err != nil {
|
||||||
|
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(writer, http.StatusOK, actionsResponse{Actions: store.Actions})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func actionsDeleteHandler(configDir string) http.HandlerFunc {
|
||||||
|
return func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
if err := authorizeActionsDelete(request, configDir); err != nil {
|
||||||
|
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
actionID := strings.TrimSpace(request.URL.Query().Get("id"))
|
||||||
|
if actionID == "" {
|
||||||
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: errActionIDRequired.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
store, err := settings.LoadActionsOrSeed(configDir)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
index := findActionIndex(store.Actions, actionID)
|
||||||
|
if index < 0 {
|
||||||
|
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: errActionNotFound.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if store.Actions[index].Builtin {
|
||||||
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: errActionBuiltinRO.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
store.Actions = append(store.Actions[:index], store.Actions[index+1:]...)
|
||||||
|
if err := settings.SaveActions(configDir, store); err != nil {
|
||||||
|
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(writer, http.StatusOK, actionsResponse{Actions: store.Actions})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCustomAction(payload createActionRequest) (settings.Action, error) {
|
||||||
|
name := strings.TrimSpace(payload.Name)
|
||||||
|
description := strings.TrimSpace(payload.Description)
|
||||||
|
body := strings.TrimSpace(payload.Body)
|
||||||
|
env := payload.Env
|
||||||
|
if env == nil {
|
||||||
|
env = []settings.ActionEnvVar{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateActionFields(name, payload.Kind, body, env); err != nil {
|
||||||
|
return settings.Action{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
actionID, err := auth.NewUUID()
|
||||||
|
if err != nil {
|
||||||
|
return settings.Action{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
return settings.Action{
|
||||||
|
ID: actionID,
|
||||||
|
Name: name,
|
||||||
|
Description: description,
|
||||||
|
Kind: payload.Kind,
|
||||||
|
Body: body,
|
||||||
|
Env: env,
|
||||||
|
Builtin: false,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateActionFields(
|
||||||
|
name string,
|
||||||
|
kind settings.ActionKind,
|
||||||
|
body string,
|
||||||
|
env []settings.ActionEnvVar,
|
||||||
|
) error {
|
||||||
|
if name == "" {
|
||||||
|
return errActionNameRequired
|
||||||
|
}
|
||||||
|
if body == "" {
|
||||||
|
return errActionBodyRequired
|
||||||
|
}
|
||||||
|
if kind != settings.ActionKindShell && kind != settings.ActionKindScript {
|
||||||
|
return errActionKindInvalid
|
||||||
|
}
|
||||||
|
for index := range env {
|
||||||
|
env[index].Name = strings.TrimSpace(env[index].Name)
|
||||||
|
if env[index].Name == "" || !envVarNamePattern.MatchString(env[index].Name) {
|
||||||
|
return errors.New("environment variable name must match [A-Za-z_][A-Za-z0-9_]*")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findActionIndex(actions []settings.Action, actionID string) int {
|
||||||
|
for index := range actions {
|
||||||
|
if actions[index].ID == actionID {
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type actionsResponseTest struct {
|
||||||
|
Actions []settings.Action `json:"actions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActionsGetSeedsBuiltins(t *testing.T) {
|
||||||
|
configDir := t.TempDir()
|
||||||
|
cookie := seedCompletedSetup(t, configDir)
|
||||||
|
router := NewRouter(configDir)
|
||||||
|
|
||||||
|
request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/actions", nil), cookie)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(recorder, request)
|
||||||
|
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected status %d, got %d body=%s", http.StatusOK, recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload actionsResponseTest
|
||||||
|
if err := json.NewDecoder(recorder.Body).Decode(&payload); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if len(payload.Actions) != 3 {
|
||||||
|
t.Fatalf("expected 3 builtins, got %d", len(payload.Actions))
|
||||||
|
}
|
||||||
|
for _, action := range payload.Actions {
|
||||||
|
if !action.Builtin {
|
||||||
|
t.Fatalf("expected builtin, got %#v", action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActionsCreatePatchDelete(t *testing.T) {
|
||||||
|
configDir := t.TempDir()
|
||||||
|
cookie := seedCompletedSetup(t, configDir)
|
||||||
|
router := NewRouter(configDir)
|
||||||
|
|
||||||
|
createBody := []byte(`{
|
||||||
|
"name": "Echo host",
|
||||||
|
"description": "Print the target IP",
|
||||||
|
"kind": "shell",
|
||||||
|
"body": "echo {{node.ip}}",
|
||||||
|
"env": [{"name": "APP_HOME", "value": "/opt/app"}]
|
||||||
|
}`)
|
||||||
|
createReq := withSession(httptest.NewRequest(http.MethodPost, "/api/v1/actions", bytes.NewReader(createBody)), cookie)
|
||||||
|
createRec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(createRec, createReq)
|
||||||
|
if createRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("create status %d body=%s", createRec.Code, createRec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var created actionsResponseTest
|
||||||
|
if err := json.NewDecoder(createRec.Body).Decode(&created); err != nil {
|
||||||
|
t.Fatalf("decode create: %v", err)
|
||||||
|
}
|
||||||
|
if len(created.Actions) != 4 {
|
||||||
|
t.Fatalf("expected 4 actions after create, got %d", len(created.Actions))
|
||||||
|
}
|
||||||
|
custom := created.Actions[3]
|
||||||
|
if custom.Builtin || custom.Name != "Echo host" || custom.Kind != settings.ActionKindShell {
|
||||||
|
t.Fatalf("custom = %#v", custom)
|
||||||
|
}
|
||||||
|
|
||||||
|
patchBody := []byte(`{"name":"Echo target","body":"echo {{node.host}}"}`)
|
||||||
|
patchReq := withSession(
|
||||||
|
httptest.NewRequest(http.MethodPatch, "/api/v1/actions?id="+custom.ID, bytes.NewReader(patchBody)),
|
||||||
|
cookie,
|
||||||
|
)
|
||||||
|
patchRec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(patchRec, patchReq)
|
||||||
|
if patchRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("patch status %d body=%s", patchRec.Code, patchRec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var patched actionsResponseTest
|
||||||
|
if err := json.NewDecoder(patchRec.Body).Decode(&patched); err != nil {
|
||||||
|
t.Fatalf("decode patch: %v", err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, action := range patched.Actions {
|
||||||
|
if action.ID == custom.ID {
|
||||||
|
found = true
|
||||||
|
if action.Name != "Echo target" || action.Body != "echo {{node.host}}" {
|
||||||
|
t.Fatalf("patched = %#v", action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("patched action missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteReq := withSession(
|
||||||
|
httptest.NewRequest(http.MethodDelete, "/api/v1/actions?id="+custom.ID, nil),
|
||||||
|
cookie,
|
||||||
|
)
|
||||||
|
deleteRec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(deleteRec, deleteReq)
|
||||||
|
if deleteRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("delete status %d body=%s", deleteRec.Code, deleteRec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var deleted actionsResponseTest
|
||||||
|
if err := json.NewDecoder(deleteRec.Body).Decode(&deleted); err != nil {
|
||||||
|
t.Fatalf("decode delete: %v", err)
|
||||||
|
}
|
||||||
|
if len(deleted.Actions) != 3 {
|
||||||
|
t.Fatalf("expected 3 after delete, got %d", len(deleted.Actions))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActionsRejectBuiltinMutationAndInvalidInput(t *testing.T) {
|
||||||
|
configDir := t.TempDir()
|
||||||
|
cookie := seedCompletedSetup(t, configDir)
|
||||||
|
router := NewRouter(configDir)
|
||||||
|
|
||||||
|
getReq := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/actions", nil), cookie)
|
||||||
|
getRec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(getRec, getReq)
|
||||||
|
var listed actionsResponseTest
|
||||||
|
if err := json.NewDecoder(getRec.Body).Decode(&listed); err != nil {
|
||||||
|
t.Fatalf("decode list: %v", err)
|
||||||
|
}
|
||||||
|
builtinID := listed.Actions[0].ID
|
||||||
|
|
||||||
|
patchBody := []byte(`{"name":"Nope"}`)
|
||||||
|
patchReq := withSession(
|
||||||
|
httptest.NewRequest(http.MethodPatch, "/api/v1/actions?id="+builtinID, bytes.NewReader(patchBody)),
|
||||||
|
cookie,
|
||||||
|
)
|
||||||
|
patchRec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(patchRec, patchReq)
|
||||||
|
if patchRec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected builtin patch 400, got %d", patchRec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteReq := withSession(
|
||||||
|
httptest.NewRequest(http.MethodDelete, "/api/v1/actions?id="+builtinID, nil),
|
||||||
|
cookie,
|
||||||
|
)
|
||||||
|
deleteRec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(deleteRec, deleteReq)
|
||||||
|
if deleteRec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected builtin delete 400, got %d", deleteRec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
badCreate := []byte(`{"name":"x","kind":"shell","body":"echo","env":[{"name":"bad-name","value":"1"}]}`)
|
||||||
|
badReq := withSession(httptest.NewRequest(http.MethodPost, "/api/v1/actions", bytes.NewReader(badCreate)), cookie)
|
||||||
|
badRec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(badRec, badReq)
|
||||||
|
if badRec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected invalid env 400, got %d", badRec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidKind := []byte(`{"name":"x","kind":"nope","body":"echo"}`)
|
||||||
|
kindReq := withSession(httptest.NewRequest(http.MethodPost, "/api/v1/actions", bytes.NewReader(invalidKind)), cookie)
|
||||||
|
kindRec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(kindRec, kindReq)
|
||||||
|
if kindRec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected invalid kind 400, got %d", kindRec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActionsMutationsRequirePermissions(t *testing.T) {
|
||||||
|
configDir := t.TempDir()
|
||||||
|
cookie := seedCompletedSetup(t, configDir)
|
||||||
|
router := NewRouter(configDir)
|
||||||
|
|
||||||
|
// Strip action permissions from Administrators.
|
||||||
|
settingsPayload, err := settings.LoadSettings(configDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadSettings: %v", err)
|
||||||
|
}
|
||||||
|
for index := range settingsPayload.Groups {
|
||||||
|
if settingsPayload.Groups[index].Name != settings.AdministratorsGroupName {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered := make([]string, 0, len(settingsPayload.Groups[index].Permissions))
|
||||||
|
for _, permission := range settingsPayload.Groups[index].Permissions {
|
||||||
|
if permission == "actions.create" || permission == "actions.update" || permission == "actions.delete" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, permission)
|
||||||
|
}
|
||||||
|
settingsPayload.Groups[index].Permissions = filtered
|
||||||
|
}
|
||||||
|
if err := settings.SaveSettings(configDir, settingsPayload); err != nil {
|
||||||
|
t.Fatalf("SaveSettings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
createBody := []byte(`{"name":"x","kind":"shell","body":"echo"}`)
|
||||||
|
createReq := withSession(httptest.NewRequest(http.MethodPost, "/api/v1/actions", bytes.NewReader(createBody)), cookie)
|
||||||
|
createRec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(createRec, createReq)
|
||||||
|
if createRec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("expected create 403, got %d body=%s", createRec.Code, createRec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
patchReq := withSession(
|
||||||
|
httptest.NewRequest(http.MethodPatch, "/api/v1/actions?id=custom", bytes.NewReader([]byte(`{"name":"y"}`))),
|
||||||
|
cookie,
|
||||||
|
)
|
||||||
|
patchRec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(patchRec, patchReq)
|
||||||
|
if patchRec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("expected patch 403, got %d", patchRec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteReq := withSession(httptest.NewRequest(http.MethodDelete, "/api/v1/actions?id=custom", nil), cookie)
|
||||||
|
deleteRec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(deleteRec, deleteReq)
|
||||||
|
if deleteRec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("expected delete 403, got %d", deleteRec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,9 @@ var errUsersManageRequired = errors.New("permission users.manage required")
|
|||||||
var errNodesReadRequired = errors.New("permission nodes.read required")
|
var errNodesReadRequired = errors.New("permission nodes.read required")
|
||||||
var errNodesExecRequired = errors.New("permission nodes.exec required")
|
var errNodesExecRequired = errors.New("permission nodes.exec required")
|
||||||
var errNodesDeleteRequired = errors.New("permission nodes.delete required")
|
var errNodesDeleteRequired = errors.New("permission nodes.delete required")
|
||||||
|
var errActionsCreateRequired = errors.New("permission actions.create required")
|
||||||
|
var errActionsUpdateRequired = errors.New("permission actions.update required")
|
||||||
|
var errActionsDeleteRequired = errors.New("permission actions.delete required")
|
||||||
|
|
||||||
// permissionsForUser returns the union of permissions from the user's groups.
|
// permissionsForUser returns the union of permissions from the user's groups.
|
||||||
func permissionsForUser(user settings.UserCredential, groups []settings.Group) map[string]struct{} {
|
func permissionsForUser(user settings.UserCredential, groups []settings.Group) map[string]struct{} {
|
||||||
@@ -49,6 +52,22 @@ func permissionList(user settings.UserCredential, groups []settings.Group) []str
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func authorizeNamedPermission(request *http.Request, configDir string, permission string, missing error) error {
|
||||||
|
user, ok := UserFromContext(request.Context())
|
||||||
|
if !ok {
|
||||||
|
return errors.New("authentication required")
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := loadSettingsOrDefault(configDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !userHasPermission(user, payload.Groups, permission) {
|
||||||
|
return missing
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (app *App) authorizeUsersWrite(request *http.Request) error {
|
func (app *App) authorizeUsersWrite(request *http.Request) error {
|
||||||
user, ok := UserFromContext(request.Context())
|
user, ok := UserFromContext(request.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -112,3 +131,15 @@ func (app *App) authorizeNodesDelete(request *http.Request) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func authorizeActionsCreate(request *http.Request, configDir string) error {
|
||||||
|
return authorizeNamedPermission(request, configDir, "actions.create", errActionsCreateRequired)
|
||||||
|
}
|
||||||
|
|
||||||
|
func authorizeActionsUpdate(request *http.Request, configDir string) error {
|
||||||
|
return authorizeNamedPermission(request, configDir, "actions.update", errActionsUpdateRequired)
|
||||||
|
}
|
||||||
|
|
||||||
|
func authorizeActionsDelete(request *http.Request, configDir string) error {
|
||||||
|
return authorizeNamedPermission(request, configDir, "actions.delete", errActionsDeleteRequired)
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ var allowedPermissions = map[string]struct{}{
|
|||||||
"nodes.delete": {},
|
"nodes.delete": {},
|
||||||
"jobs.read": {},
|
"jobs.read": {},
|
||||||
"jobs.run": {},
|
"jobs.run": {},
|
||||||
|
"actions.create": {},
|
||||||
|
"actions.update": {},
|
||||||
|
"actions.delete": {},
|
||||||
"users.manage": {},
|
"users.manage": {},
|
||||||
"secrets.manage": {},
|
"secrets.manage": {},
|
||||||
"roles.manage": {},
|
"roles.manage": {},
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ func NewRouter(configDir string) http.Handler {
|
|||||||
mux.HandleFunc("GET /api/v1/groups", groupsGetHandler(configDir))
|
mux.HandleFunc("GET /api/v1/groups", groupsGetHandler(configDir))
|
||||||
mux.HandleFunc("POST /api/v1/groups", groupsUpsertHandler(configDir))
|
mux.HandleFunc("POST /api/v1/groups", groupsUpsertHandler(configDir))
|
||||||
mux.HandleFunc("DELETE /api/v1/groups", groupsDeleteHandler(configDir))
|
mux.HandleFunc("DELETE /api/v1/groups", groupsDeleteHandler(configDir))
|
||||||
|
mux.HandleFunc("GET /api/v1/actions", actionsGetHandler(configDir))
|
||||||
|
mux.HandleFunc("POST /api/v1/actions", actionsCreateHandler(configDir))
|
||||||
|
mux.HandleFunc("PATCH /api/v1/actions", actionsPatchHandler(configDir))
|
||||||
|
mux.HandleFunc("DELETE /api/v1/actions", actionsDeleteHandler(configDir))
|
||||||
mux.HandleFunc("GET /api/v1/security", securityGetHandler(configDir))
|
mux.HandleFunc("GET /api/v1/security", securityGetHandler(configDir))
|
||||||
mux.HandleFunc("PUT /api/v1/security", securityPutHandler(configDir))
|
mux.HandleFunc("PUT /api/v1/security", securityPutHandler(configDir))
|
||||||
mux.HandleFunc("GET /api/v1/network", networkGetHandler(configDir))
|
mux.HandleFunc("GET /api/v1/network", networkGetHandler(configDir))
|
||||||
|
|||||||
@@ -453,6 +453,9 @@ func allPermissionList() []string {
|
|||||||
"nodes.delete",
|
"nodes.delete",
|
||||||
"jobs.read",
|
"jobs.read",
|
||||||
"jobs.run",
|
"jobs.run",
|
||||||
|
"actions.create",
|
||||||
|
"actions.update",
|
||||||
|
"actions.delete",
|
||||||
"users.manage",
|
"users.manage",
|
||||||
"secrets.manage",
|
"secrets.manage",
|
||||||
"roles.manage",
|
"roles.manage",
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package settings
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Stable IDs for seeded built-in actions.
|
||||||
|
const (
|
||||||
|
BuiltinActionUptimeID = "00000000-0000-4000-8000-000000000001"
|
||||||
|
BuiltinActionDiskUsageID = "00000000-0000-4000-8000-000000000002"
|
||||||
|
BuiltinActionSysInfoID = "00000000-0000-4000-8000-000000000003"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultBuiltinActions returns the read-only seed actions.
|
||||||
|
func DefaultBuiltinActions() []Action {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
return []Action{
|
||||||
|
{
|
||||||
|
ID: BuiltinActionUptimeID,
|
||||||
|
Name: "Uptime",
|
||||||
|
Description: "Show how long the target system has been running.",
|
||||||
|
Kind: ActionKindShell,
|
||||||
|
Body: "uptime",
|
||||||
|
Env: []ActionEnvVar{},
|
||||||
|
Builtin: true,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: BuiltinActionDiskUsageID,
|
||||||
|
Name: "Disk usage",
|
||||||
|
Description: "Show filesystem disk space usage on the target.",
|
||||||
|
Kind: ActionKindShell,
|
||||||
|
Body: "df -h",
|
||||||
|
Env: []ActionEnvVar{},
|
||||||
|
Builtin: true,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: BuiltinActionSysInfoID,
|
||||||
|
Name: "System info",
|
||||||
|
Description: "Print kernel and OS release details from the target.",
|
||||||
|
Kind: ActionKindScript,
|
||||||
|
Body: `uname -a
|
||||||
|
if [ -f /etc/os-release ]; then
|
||||||
|
. /etc/os-release
|
||||||
|
echo "OS: ${NAME:-unknown} ${VERSION:-}"
|
||||||
|
fi`,
|
||||||
|
Env: []ActionEnvVar{},
|
||||||
|
Builtin: true,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadActions reads and parses actions.json from dir.
|
||||||
|
func LoadActions(dir string) (ActionStore, error) {
|
||||||
|
path := filepath.Join(dir, ActionsFileName)
|
||||||
|
payload, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return ActionStore{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var store ActionStore
|
||||||
|
if err := json.Unmarshal(payload, &store); err != nil {
|
||||||
|
return ActionStore{}, fmt.Errorf("parse actions: %w", err)
|
||||||
|
}
|
||||||
|
if store.Actions == nil {
|
||||||
|
store.Actions = []Action{}
|
||||||
|
}
|
||||||
|
for index := range store.Actions {
|
||||||
|
if store.Actions[index].Env == nil {
|
||||||
|
store.Actions[index].Env = []ActionEnvVar{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return store, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadActionsOrSeed returns seeded builtins (and persists them) when actions.json is missing.
|
||||||
|
func LoadActionsOrSeed(dir string) (ActionStore, error) {
|
||||||
|
store, err := LoadActions(dir)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
seeded := ActionStore{Actions: DefaultBuiltinActions()}
|
||||||
|
if saveErr := SaveActions(dir, seeded); saveErr != nil {
|
||||||
|
return ActionStore{}, saveErr
|
||||||
|
}
|
||||||
|
return seeded, nil
|
||||||
|
}
|
||||||
|
return ActionStore{}, err
|
||||||
|
}
|
||||||
|
return store, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveActions writes actions.json to dir with mode 0640.
|
||||||
|
func SaveActions(dir string, store ActionStore) error {
|
||||||
|
if err := ensureConfigDir(dir); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if store.Actions == nil {
|
||||||
|
store.Actions = []Action{}
|
||||||
|
}
|
||||||
|
for index := range store.Actions {
|
||||||
|
if store.Actions[index].Env == nil {
|
||||||
|
store.Actions[index].Env = []ActionEnvVar{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := json.MarshalIndent(store, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encode actions: %w", err)
|
||||||
|
}
|
||||||
|
payload = append(payload, '\n')
|
||||||
|
|
||||||
|
path := filepath.Join(dir, ActionsFileName)
|
||||||
|
if err := os.WriteFile(path, payload, 0o640); err != nil {
|
||||||
|
return fmt.Errorf("write actions: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package settings
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadActionsOrSeedCreatesBuiltins(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
store, err := LoadActionsOrSeed(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadActionsOrSeed: %v", err)
|
||||||
|
}
|
||||||
|
if len(store.Actions) != 3 {
|
||||||
|
t.Fatalf("expected 3 builtins, got %d", len(store.Actions))
|
||||||
|
}
|
||||||
|
for _, action := range store.Actions {
|
||||||
|
if !action.Builtin {
|
||||||
|
t.Fatalf("expected builtin action, got %#v", action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(dir, ActionsFileName)
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
t.Fatalf("expected actions.json to exist: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reloaded, err := LoadActions(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadActions: %v", err)
|
||||||
|
}
|
||||||
|
if len(reloaded.Actions) != 3 {
|
||||||
|
t.Fatalf("reloaded len = %d", len(reloaded.Actions))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActionsRoundTrip(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
store := ActionStore{
|
||||||
|
Actions: append(DefaultBuiltinActions(), Action{
|
||||||
|
ID: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
|
||||||
|
Name: "Custom",
|
||||||
|
Description: "A custom shell action",
|
||||||
|
Kind: ActionKindShell,
|
||||||
|
Body: "echo hello",
|
||||||
|
Env: []ActionEnvVar{
|
||||||
|
{Name: "APP_HOME", Value: "/opt/app"},
|
||||||
|
},
|
||||||
|
Builtin: false,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
if err := SaveActions(dir, store); err != nil {
|
||||||
|
t.Fatalf("SaveActions: %v", err)
|
||||||
|
}
|
||||||
|
loaded, err := LoadActions(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadActions: %v", err)
|
||||||
|
}
|
||||||
|
if len(loaded.Actions) != 4 {
|
||||||
|
t.Fatalf("loaded len = %d", len(loaded.Actions))
|
||||||
|
}
|
||||||
|
custom := loaded.Actions[3]
|
||||||
|
if custom.Name != "Custom" || len(custom.Env) != 1 || custom.Env[0].Name != "APP_HOME" {
|
||||||
|
t.Fatalf("custom = %#v", custom)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ const (
|
|||||||
NodesFileName = "nodes.json"
|
NodesFileName = "nodes.json"
|
||||||
NodeKeysFileName = "node-keys.enc"
|
NodeKeysFileName = "node-keys.enc"
|
||||||
NodeAuditFileName = "node-audit.json"
|
NodeAuditFileName = "node-audit.json"
|
||||||
|
ActionsFileName = "actions.json"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ResolveDir returns the config directory using precedence:
|
// ResolveDir returns the config directory using precedence:
|
||||||
|
|||||||
@@ -112,6 +112,38 @@ type NodeStore struct {
|
|||||||
Nodes []Node `json:"nodes"`
|
Nodes []Node `json:"nodes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ActionKind identifies how an action body is interpreted when run on a node.
|
||||||
|
type ActionKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ActionKindShell ActionKind = "shell"
|
||||||
|
ActionKindScript ActionKind = "script"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ActionEnvVar is one environment variable applied before an action runs.
|
||||||
|
type ActionEnvVar struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action is a named shell command or script that can run on managed nodes.
|
||||||
|
type Action struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Kind ActionKind `json:"kind"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Env []ActionEnvVar `json:"env"`
|
||||||
|
Builtin bool `json:"builtin"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActionStore is the plain JSON payload in actions.json.
|
||||||
|
type ActionStore struct {
|
||||||
|
Actions []Action `json:"actions"`
|
||||||
|
}
|
||||||
|
|
||||||
// NodeKeyEntry holds private key material for one node, keyed by node UUID.
|
// NodeKeyEntry holds private key material for one node, keyed by node UUID.
|
||||||
type NodeKeyEntry struct {
|
type NodeKeyEntry struct {
|
||||||
NodeID string `json:"node_id"`
|
NodeID string `json:"node_id"`
|
||||||
|
|||||||
+197
-9
@@ -133,7 +133,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
max-width: 40rem;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-section {
|
.profile-section {
|
||||||
@@ -300,14 +300,14 @@
|
|||||||
|
|
||||||
.config-tab-panel {
|
.config-tab-panel {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: 40rem;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-field {
|
.config-field {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
max-width: 28rem;
|
max-width: 36rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-field label {
|
.config-field label {
|
||||||
@@ -347,7 +347,6 @@
|
|||||||
|
|
||||||
.config-empty {
|
.config-empty {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
max-width: 36rem;
|
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: var(--ctp-subtext1);
|
color: var(--ctp-subtext1);
|
||||||
}
|
}
|
||||||
@@ -432,7 +431,6 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
max-width: 40rem;
|
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,7 +446,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.9rem;
|
gap: 0.9rem;
|
||||||
max-width: 36rem;
|
width: 100%;
|
||||||
|
max-width: 48rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resource-add-form .field {
|
.resource-add-form .field {
|
||||||
@@ -566,14 +565,14 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
max-width: 40rem;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.security-panel {
|
.security-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.9rem;
|
gap: 0.9rem;
|
||||||
max-width: 40rem;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.groups-list {
|
.groups-list {
|
||||||
@@ -652,11 +651,13 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.groups-edit:disabled,
|
||||||
.groups-delete:disabled {
|
.groups-delete:disabled {
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.groups-edit:disabled:hover,
|
||||||
.groups-delete:disabled:hover {
|
.groups-delete:disabled:hover {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--ctp-text);
|
color: var(--ctp-text);
|
||||||
@@ -732,11 +733,198 @@
|
|||||||
background: var(--ctp-mauve-hover);
|
background: var(--ctp-mauve-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.groups-save:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groups-save:disabled:hover {
|
||||||
|
background: var(--ctp-mauve);
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
.groups-save:focus-visible {
|
.groups-save:focus-visible {
|
||||||
outline: 2px solid var(--ctp-mauve);
|
outline: 2px solid var(--ctp-mauve);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.actions-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-layout {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-list-header {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-list-header .config-hint {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-body-preview {
|
||||||
|
font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas,
|
||||||
|
monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-placeholders {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex: 0 0 24.75rem;
|
||||||
|
max-width: 27rem;
|
||||||
|
padding: 0.75rem 0.9rem;
|
||||||
|
border: 1px solid var(--ctp-surface1);
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
background: var(--ctp-mantle);
|
||||||
|
position: sticky;
|
||||||
|
top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 52rem) {
|
||||||
|
.actions-layout {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-placeholders {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
max-width: none;
|
||||||
|
width: 100%;
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-placeholders-intro {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-placeholder-list {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-placeholder-list li {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--ctp-subtext0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-placeholder-list code {
|
||||||
|
font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas,
|
||||||
|
monospace;
|
||||||
|
color: var(--ctp-text);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-placeholder-example {
|
||||||
|
color: var(--ctp-overlay1);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-type-modal {
|
||||||
|
width: min(28rem, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-editor-modal {
|
||||||
|
width: min(36rem, 100%);
|
||||||
|
max-height: min(90vh, 40rem);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-type-choices {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.65rem;
|
||||||
|
margin: 0.75rem 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-type-choice {
|
||||||
|
appearance: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.35rem;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 0.9rem;
|
||||||
|
border: 1px solid var(--ctp-surface1);
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
background: var(--ctp-surface0);
|
||||||
|
color: var(--ctp-text);
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-type-choice:hover {
|
||||||
|
border-color: var(--ctp-mauve);
|
||||||
|
background: var(--ctp-mantle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-type-choice:focus-visible {
|
||||||
|
outline: 2px solid var(--ctp-mauve);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-type-choice strong {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-type-choice span {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--ctp-subtext0);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-script-input {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 8rem;
|
||||||
|
font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas,
|
||||||
|
monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-env-rows {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-env-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr) auto;
|
||||||
|
gap: 0.4rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.config-actions {
|
.config-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -744,7 +932,7 @@
|
|||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
padding-top: 1rem;
|
padding-top: 1rem;
|
||||||
border-top: 1px solid var(--ctp-surface1);
|
border-top: 1px solid var(--ctp-surface1);
|
||||||
max-width: 40rem;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-action-revert,
|
.config-action-revert,
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ function stubFetchOk() {
|
|||||||
'nodes.delete',
|
'nodes.delete',
|
||||||
'jobs.read',
|
'jobs.read',
|
||||||
'jobs.run',
|
'jobs.run',
|
||||||
|
'actions.create',
|
||||||
|
'actions.update',
|
||||||
|
'actions.delete',
|
||||||
'users.manage',
|
'users.manage',
|
||||||
'secrets.manage',
|
'secrets.manage',
|
||||||
'roles.manage',
|
'roles.manage',
|
||||||
@@ -74,6 +77,9 @@ function stubFetchOk() {
|
|||||||
'nodes.delete',
|
'nodes.delete',
|
||||||
'jobs.read',
|
'jobs.read',
|
||||||
'jobs.run',
|
'jobs.run',
|
||||||
|
'actions.create',
|
||||||
|
'actions.update',
|
||||||
|
'actions.delete',
|
||||||
'users.manage',
|
'users.manage',
|
||||||
'secrets.manage',
|
'secrets.manage',
|
||||||
'roles.manage',
|
'roles.manage',
|
||||||
@@ -145,6 +151,38 @@ function stubFetchOk() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (url.includes('/api/v1/actions')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
id: '00000000-0000-4000-8000-000000000001',
|
||||||
|
name: 'Uptime',
|
||||||
|
description: 'Show how long the target system has been running.',
|
||||||
|
kind: 'shell',
|
||||||
|
body: 'uptime',
|
||||||
|
env: [],
|
||||||
|
builtin: true,
|
||||||
|
created_at: '2026-01-01T00:00:00.000Z',
|
||||||
|
updated_at: '2026-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'custom-1',
|
||||||
|
name: 'Echo host',
|
||||||
|
description: 'Print the target IP',
|
||||||
|
kind: 'shell',
|
||||||
|
body: 'echo {{node.ip}}',
|
||||||
|
env: [{ name: 'APP_HOME', value: '/opt/app' }],
|
||||||
|
builtin: false,
|
||||||
|
created_at: '2026-01-02T00:00:00.000Z',
|
||||||
|
updated_at: '2026-01-02T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (url.includes('/api/v1/nodes')) {
|
if (url.includes('/api/v1/nodes')) {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -355,6 +393,7 @@ describe('App', () => {
|
|||||||
'Users',
|
'Users',
|
||||||
'Roles',
|
'Roles',
|
||||||
'Groups',
|
'Groups',
|
||||||
|
'Actions',
|
||||||
'Security',
|
'Security',
|
||||||
'Integrations',
|
'Integrations',
|
||||||
'Network',
|
'Network',
|
||||||
@@ -378,6 +417,9 @@ describe('App', () => {
|
|||||||
'nodes.delete',
|
'nodes.delete',
|
||||||
'jobs.read',
|
'jobs.read',
|
||||||
'jobs.run',
|
'jobs.run',
|
||||||
|
'actions.create',
|
||||||
|
'actions.update',
|
||||||
|
'actions.delete',
|
||||||
'users.manage',
|
'users.manage',
|
||||||
'secrets.manage',
|
'secrets.manage',
|
||||||
'roles.manage',
|
'roles.manage',
|
||||||
@@ -395,6 +437,33 @@ describe('App', () => {
|
|||||||
expect(screen.getByLabelText('roles.manage')).toBeInTheDocument()
|
expect(screen.getByLabelText('roles.manage')).toBeInTheDocument()
|
||||||
expect(screen.queryByLabelText('Theme')).not.toBeInTheDocument()
|
expect(screen.queryByLabelText('Theme')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('tab', { name: 'Actions' }))
|
||||||
|
expect(screen.getByRole('tab', { name: 'Actions' })).toHaveAttribute(
|
||||||
|
'aria-selected',
|
||||||
|
'true',
|
||||||
|
)
|
||||||
|
expect(await screen.findByText('Uptime')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Echo host')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('{{cc.host}}')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('{{secret.NAME}}')).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: 'Add Action…' }),
|
||||||
|
).toBeInTheDocument()
|
||||||
|
const actionDeleteButtons = screen.getAllByRole('button', { name: 'Delete' })
|
||||||
|
expect(actionDeleteButtons[0]).toBeDisabled()
|
||||||
|
expect(actionDeleteButtons[1]).toBeEnabled()
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Add Action…' }))
|
||||||
|
expect(
|
||||||
|
screen.getByRole('heading', { name: 'Add Action' }),
|
||||||
|
).toBeInTheDocument()
|
||||||
|
await user.click(screen.getByRole('button', { name: /Shell Command/i }))
|
||||||
|
expect(
|
||||||
|
screen.getByRole('heading', { name: 'New shell command' }),
|
||||||
|
).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('Command')).toBeInTheDocument()
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||||
|
|
||||||
await user.click(screen.getByRole('tab', { name: 'Security' }))
|
await user.click(screen.getByRole('tab', { name: 'Security' }))
|
||||||
expect(screen.getByRole('tab', { name: 'Security' })).toHaveAttribute(
|
expect(screen.getByRole('tab', { name: 'Security' })).toHaveAttribute(
|
||||||
'aria-selected',
|
'aria-selected',
|
||||||
|
|||||||
@@ -5,12 +5,15 @@ import {
|
|||||||
confirmMyTOTP,
|
confirmMyTOTP,
|
||||||
createNode,
|
createNode,
|
||||||
createUser,
|
createUser,
|
||||||
|
createAction,
|
||||||
|
deleteAction,
|
||||||
deleteGroup,
|
deleteGroup,
|
||||||
deleteNode,
|
deleteNode,
|
||||||
deleteUser,
|
deleteUser,
|
||||||
DEFAULT_NETWORK_SETTINGS,
|
DEFAULT_NETWORK_SETTINGS,
|
||||||
DEFAULT_SECURITY_SETTINGS,
|
DEFAULT_SECURITY_SETTINGS,
|
||||||
disableMyTOTP,
|
disableMyTOTP,
|
||||||
|
fetchActions,
|
||||||
fetchGroups,
|
fetchGroups,
|
||||||
fetchMe,
|
fetchMe,
|
||||||
fetchNetwork,
|
fetchNetwork,
|
||||||
@@ -21,10 +24,14 @@ import {
|
|||||||
fetchStatus,
|
fetchStatus,
|
||||||
fetchUsers,
|
fetchUsers,
|
||||||
isReauthRequiredError,
|
isReauthRequiredError,
|
||||||
|
patchAction,
|
||||||
patchUser,
|
patchUser,
|
||||||
reauth,
|
reauth,
|
||||||
testNodeSSH,
|
testNodeSSH,
|
||||||
type AccessMode,
|
type AccessMode,
|
||||||
|
type Action,
|
||||||
|
type ActionEnvVar,
|
||||||
|
type ActionKind,
|
||||||
type Group,
|
type Group,
|
||||||
type GroupScopeKind,
|
type GroupScopeKind,
|
||||||
type MeResponse,
|
type MeResponse,
|
||||||
@@ -91,6 +98,7 @@ const CONFIG_TABS: ReadonlyArray<{ id: ConfigTabId; label: string }> = [
|
|||||||
{ id: 'users', label: 'Users' },
|
{ id: 'users', label: 'Users' },
|
||||||
{ id: 'roles', label: 'Roles' },
|
{ id: 'roles', label: 'Roles' },
|
||||||
{ id: 'groups', label: 'Groups' },
|
{ id: 'groups', label: 'Groups' },
|
||||||
|
{ id: 'actions', label: 'Actions' },
|
||||||
{ id: 'security', label: 'Security' },
|
{ id: 'security', label: 'Security' },
|
||||||
{ id: 'integrations', label: 'Integrations' },
|
{ id: 'integrations', label: 'Integrations' },
|
||||||
{ id: 'network', label: 'Network' },
|
{ id: 'network', label: 'Network' },
|
||||||
@@ -116,6 +124,9 @@ const DEFAULT_PERMISSIONS = [
|
|||||||
'nodes.delete',
|
'nodes.delete',
|
||||||
'jobs.read',
|
'jobs.read',
|
||||||
'jobs.run',
|
'jobs.run',
|
||||||
|
'actions.create',
|
||||||
|
'actions.update',
|
||||||
|
'actions.delete',
|
||||||
'users.manage',
|
'users.manage',
|
||||||
'secrets.manage',
|
'secrets.manage',
|
||||||
'roles.manage',
|
'roles.manage',
|
||||||
@@ -984,6 +995,542 @@ function OverviewConfigTab({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ACTION_PLACEHOLDERS: ReadonlyArray<{
|
||||||
|
token: string
|
||||||
|
description: string
|
||||||
|
example: string
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
token: '{{cc.host}}',
|
||||||
|
description: 'ClusterCanvas public hostname',
|
||||||
|
example: 'canvas.example.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: '{{cc.ip}}',
|
||||||
|
description: 'ClusterCanvas listen / resolved IP',
|
||||||
|
example: '192.168.1.10',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: '{{node.host}}',
|
||||||
|
description: 'Target node hostname / address as stored',
|
||||||
|
example: 'vm-web-01',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: '{{node.ip}}',
|
||||||
|
description: 'Target node host IP',
|
||||||
|
example: '10.0.0.5',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: '{{node.username}}',
|
||||||
|
description: 'SSH username on the target node',
|
||||||
|
example: 'root',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: '{{env.NAME}}',
|
||||||
|
description: 'Value of an ENV var defined on this action',
|
||||||
|
example: '{{env.APP_HOME}} → /opt/app',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: '{{secret.NAME}}',
|
||||||
|
description:
|
||||||
|
'Reserved for the future Secrets page (not resolved yet)',
|
||||||
|
example: '{{secret.db_password}}',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function emptyEnvRow(): ActionEnvVar {
|
||||||
|
return { name: '', value: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionsConfigTab() {
|
||||||
|
const [actions, setActions] = useState<Action[]>([])
|
||||||
|
const [me, setMe] = useState<MeResponse | null>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null)
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||||
|
const [showTypePicker, setShowTypePicker] = useState(false)
|
||||||
|
const [editorKind, setEditorKind] = useState<ActionKind | null>(null)
|
||||||
|
const [editingActionId, setEditingActionId] = useState<string | null>(null)
|
||||||
|
const [draftName, setDraftName] = useState('')
|
||||||
|
const [draftDescription, setDraftDescription] = useState('')
|
||||||
|
const [draftBody, setDraftBody] = useState('')
|
||||||
|
const [draftEnv, setDraftEnv] = useState<ActionEnvVar[]>([emptyEnvRow()])
|
||||||
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
|
||||||
|
const isEditing = editingActionId !== null
|
||||||
|
const showEditor = editorKind !== null
|
||||||
|
const canCreateActions = Boolean(me?.permissions?.includes('actions.create'))
|
||||||
|
const canUpdateActions = Boolean(me?.permissions?.includes('actions.update'))
|
||||||
|
const canDeleteActions = Boolean(me?.permissions?.includes('actions.delete'))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isCancelled = false
|
||||||
|
|
||||||
|
async function loadActions() {
|
||||||
|
setIsLoading(true)
|
||||||
|
setLoadError(null)
|
||||||
|
try {
|
||||||
|
const [actionsResponse, meResponse] = await Promise.all([
|
||||||
|
fetchActions(),
|
||||||
|
fetchMe(),
|
||||||
|
])
|
||||||
|
if (!isCancelled) {
|
||||||
|
setActions([...actionsResponse.actions])
|
||||||
|
setMe(meResponse)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!isCancelled) {
|
||||||
|
setLoadError(
|
||||||
|
err instanceof Error ? err.message : 'Failed to load actions',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!isCancelled) {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadActions()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isCancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function clearEditor() {
|
||||||
|
setShowTypePicker(false)
|
||||||
|
setEditorKind(null)
|
||||||
|
setEditingActionId(null)
|
||||||
|
setDraftName('')
|
||||||
|
setDraftDescription('')
|
||||||
|
setDraftBody('')
|
||||||
|
setDraftEnv([emptyEnvRow()])
|
||||||
|
setSubmitError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTypePicker() {
|
||||||
|
clearEditor()
|
||||||
|
setShowTypePicker(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCreate(kind: ActionKind) {
|
||||||
|
setShowTypePicker(false)
|
||||||
|
setEditorKind(kind)
|
||||||
|
setEditingActionId(null)
|
||||||
|
setDraftName('')
|
||||||
|
setDraftDescription('')
|
||||||
|
setDraftBody('')
|
||||||
|
setDraftEnv([emptyEnvRow()])
|
||||||
|
setSubmitError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(action: Action) {
|
||||||
|
if (action.builtin || !canUpdateActions) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setShowTypePicker(false)
|
||||||
|
setEditorKind(action.kind)
|
||||||
|
setEditingActionId(action.id)
|
||||||
|
setDraftName(action.name)
|
||||||
|
setDraftDescription(action.description)
|
||||||
|
setDraftBody(action.body)
|
||||||
|
setDraftEnv(
|
||||||
|
action.env.length > 0
|
||||||
|
? action.env.map((envVar) => ({ ...envVar }))
|
||||||
|
: [emptyEnvRow()],
|
||||||
|
)
|
||||||
|
setSubmitError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateEnvRow(index: number, field: 'name' | 'value', value: string) {
|
||||||
|
setDraftEnv((previous) =>
|
||||||
|
previous.map((row, rowIndex) =>
|
||||||
|
rowIndex === index ? { ...row, [field]: value } : row,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addEnvRow() {
|
||||||
|
setDraftEnv((previous) => [...previous, emptyEnvRow()])
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeEnvRow(index: number) {
|
||||||
|
setDraftEnv((previous) => {
|
||||||
|
if (previous.length <= 1) {
|
||||||
|
return [emptyEnvRow()]
|
||||||
|
}
|
||||||
|
return previous.filter((_, rowIndex) => rowIndex !== index)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedEnv(): ActionEnvVar[] {
|
||||||
|
return draftEnv
|
||||||
|
.map((row) => ({
|
||||||
|
name: row.name.trim(),
|
||||||
|
value: row.value,
|
||||||
|
}))
|
||||||
|
.filter((row) => row.name !== '' || row.value.trim() !== '')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveAction() {
|
||||||
|
if (!editorKind) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSubmitError(null)
|
||||||
|
setIsSaving(true)
|
||||||
|
try {
|
||||||
|
const env = normalizedEnv()
|
||||||
|
const response = isEditing
|
||||||
|
? await patchAction(editingActionId!, {
|
||||||
|
name: draftName,
|
||||||
|
description: draftDescription,
|
||||||
|
body: draftBody,
|
||||||
|
env,
|
||||||
|
})
|
||||||
|
: await createAction({
|
||||||
|
name: draftName,
|
||||||
|
description: draftDescription,
|
||||||
|
kind: editorKind,
|
||||||
|
body: draftBody,
|
||||||
|
env,
|
||||||
|
})
|
||||||
|
setActions([...response.actions])
|
||||||
|
clearEditor()
|
||||||
|
} catch (err) {
|
||||||
|
setSubmitError(
|
||||||
|
err instanceof Error ? err.message : 'Failed to save action',
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteAction(action: Action) {
|
||||||
|
if (action.builtin || !canDeleteActions) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSubmitError(null)
|
||||||
|
try {
|
||||||
|
const response = await deleteAction(action.id)
|
||||||
|
setActions([...response.actions])
|
||||||
|
if (editingActionId === action.id) {
|
||||||
|
clearEditor()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setSubmitError(
|
||||||
|
err instanceof Error ? err.message : 'Failed to delete action',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="actions-panel">
|
||||||
|
<p className="config-hint">
|
||||||
|
Pre-defined and custom actions for managed nodes. Placeholders and ENV
|
||||||
|
vars are expanded when an action runs.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{submitError && !showEditor ? (
|
||||||
|
<p className="config-empty groups-error" role="alert">
|
||||||
|
{submitError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="actions-layout">
|
||||||
|
<div className="actions-list">
|
||||||
|
<div className="actions-list-header">
|
||||||
|
<p className="config-hint">Configured actions</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="groups-save"
|
||||||
|
disabled={!canCreateActions}
|
||||||
|
title={
|
||||||
|
canCreateActions ? undefined : 'Requires actions.create'
|
||||||
|
}
|
||||||
|
onClick={() => openTypePicker()}
|
||||||
|
>
|
||||||
|
Add Action…
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="config-empty">Loading…</p>
|
||||||
|
) : loadError ? (
|
||||||
|
<p className="config-empty" role="alert">
|
||||||
|
{loadError}
|
||||||
|
</p>
|
||||||
|
) : actions.length === 0 ? (
|
||||||
|
<p className="config-empty">No actions configured yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="groups-ul">
|
||||||
|
{actions.map((action) => {
|
||||||
|
const isSelected = editingActionId === action.id
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={action.id}
|
||||||
|
className={
|
||||||
|
isSelected ? 'groups-li groups-li-selected' : 'groups-li'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="groups-li-content">
|
||||||
|
<div className="groups-li-title">
|
||||||
|
{action.name}
|
||||||
|
<span className="user-badge">{action.kind}</span>
|
||||||
|
{action.builtin ? (
|
||||||
|
<span className="user-badge">built-in</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="groups-li-meta">
|
||||||
|
{action.description || 'No description'}
|
||||||
|
</div>
|
||||||
|
<div className="groups-li-meta">
|
||||||
|
<code className="actions-body-preview">
|
||||||
|
{action.body.length > 80
|
||||||
|
? `${action.body.slice(0, 80)}…`
|
||||||
|
: action.body}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="groups-li-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="groups-edit"
|
||||||
|
disabled={action.builtin || !canUpdateActions}
|
||||||
|
title={
|
||||||
|
action.builtin
|
||||||
|
? 'Built-in actions cannot be edited'
|
||||||
|
: canUpdateActions
|
||||||
|
? undefined
|
||||||
|
: 'Requires actions.update'
|
||||||
|
}
|
||||||
|
onClick={() => startEdit(action)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="groups-delete"
|
||||||
|
disabled={action.builtin || !canDeleteActions}
|
||||||
|
title={
|
||||||
|
action.builtin
|
||||||
|
? 'Built-in actions cannot be deleted'
|
||||||
|
: canDeleteActions
|
||||||
|
? undefined
|
||||||
|
: 'Requires actions.delete'
|
||||||
|
}
|
||||||
|
onClick={() => void handleDeleteAction(action)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="actions-placeholders">
|
||||||
|
<p className="config-hint">Placeholders</p>
|
||||||
|
<p className="config-hint actions-placeholders-intro">
|
||||||
|
Use these tokens in the command/script body or ENV values. ENV vars
|
||||||
|
are applied before the action runs.
|
||||||
|
</p>
|
||||||
|
<ul className="actions-placeholder-list">
|
||||||
|
{ACTION_PLACEHOLDERS.map((entry) => (
|
||||||
|
<li key={entry.token}>
|
||||||
|
<code>{entry.token}</code>
|
||||||
|
<span>{entry.description}</span>
|
||||||
|
<span className="actions-placeholder-example">
|
||||||
|
Example: {entry.example}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showTypePicker ? (
|
||||||
|
<div className="reauth-backdrop" role="presentation">
|
||||||
|
<div
|
||||||
|
className="reauth-modal actions-type-modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="action-type-title"
|
||||||
|
>
|
||||||
|
<h2 id="action-type-title">Add Action</h2>
|
||||||
|
<p className="config-hint">Choose the type of action to create.</p>
|
||||||
|
<div className="actions-type-choices">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="actions-type-choice"
|
||||||
|
onClick={() => startCreate('shell')}
|
||||||
|
>
|
||||||
|
<strong>Shell Command</strong>
|
||||||
|
<span>
|
||||||
|
A single command run over SSH on the target node (e.g.{' '}
|
||||||
|
<code>uptime</code>, <code>df -h</code>).
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="actions-type-choice"
|
||||||
|
onClick={() => startCreate('script')}
|
||||||
|
>
|
||||||
|
<strong>Script</strong>
|
||||||
|
<span>
|
||||||
|
A multi-line shell script uploaded and run on the target node.
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="reauth-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="config-action-revert"
|
||||||
|
onClick={() => clearEditor()}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showEditor && editorKind ? (
|
||||||
|
<div className="reauth-backdrop" role="presentation">
|
||||||
|
<div
|
||||||
|
className="reauth-modal actions-editor-modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="action-editor-title"
|
||||||
|
>
|
||||||
|
<h2 id="action-editor-title">
|
||||||
|
{isEditing
|
||||||
|
? `Edit ${editorKind === 'shell' ? 'shell command' : 'script'}`
|
||||||
|
: `New ${editorKind === 'shell' ? 'shell command' : 'script'}`}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{submitError ? (
|
||||||
|
<p className="config-empty groups-error" role="alert">
|
||||||
|
{submitError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="config-field">
|
||||||
|
<label htmlFor="action-name">Name</label>
|
||||||
|
<input
|
||||||
|
id="action-name"
|
||||||
|
className="config-input"
|
||||||
|
value={draftName}
|
||||||
|
onChange={(event) => setDraftName(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="config-field">
|
||||||
|
<label htmlFor="action-description">Description</label>
|
||||||
|
<input
|
||||||
|
id="action-description"
|
||||||
|
className="config-input"
|
||||||
|
value={draftDescription}
|
||||||
|
onChange={(event) => setDraftDescription(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="config-field">
|
||||||
|
<label htmlFor="action-body">
|
||||||
|
{editorKind === 'shell' ? 'Command' : 'Script'}
|
||||||
|
</label>
|
||||||
|
{editorKind === 'shell' ? (
|
||||||
|
<input
|
||||||
|
id="action-body"
|
||||||
|
className="config-input"
|
||||||
|
value={draftBody}
|
||||||
|
placeholder="echo {{node.ip}}"
|
||||||
|
onChange={(event) => setDraftBody(event.target.value)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<textarea
|
||||||
|
id="action-body"
|
||||||
|
className="config-input actions-script-input"
|
||||||
|
value={draftBody}
|
||||||
|
rows={8}
|
||||||
|
placeholder={"#!/bin/sh\necho {{node.host}}"}
|
||||||
|
onChange={(event) => setDraftBody(event.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="config-field">
|
||||||
|
<label>Environment variables</label>
|
||||||
|
<p className="config-hint">
|
||||||
|
Applied before the command/script runs. Values may use
|
||||||
|
placeholders.
|
||||||
|
</p>
|
||||||
|
<div className="actions-env-rows">
|
||||||
|
{draftEnv.map((row, index) => (
|
||||||
|
<div key={`env-${index}`} className="actions-env-row">
|
||||||
|
<input
|
||||||
|
className="config-input"
|
||||||
|
aria-label={`ENV name ${index + 1}`}
|
||||||
|
placeholder="NAME"
|
||||||
|
value={row.name}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateEnvRow(index, 'name', event.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="config-input"
|
||||||
|
aria-label={`ENV value ${index + 1}`}
|
||||||
|
placeholder="value or {{node.ip}}"
|
||||||
|
value={row.value}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateEnvRow(index, 'value', event.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="groups-delete"
|
||||||
|
aria-label={`Remove ENV row ${index + 1}`}
|
||||||
|
onClick={() => removeEnvRow(index)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="groups-new"
|
||||||
|
onClick={() => addEnvRow()}
|
||||||
|
>
|
||||||
|
Add ENV variable
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="reauth-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="config-action-revert"
|
||||||
|
onClick={() => clearEditor()}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="config-action-save"
|
||||||
|
disabled={isSaving}
|
||||||
|
onClick={() => void handleSaveAction()}
|
||||||
|
>
|
||||||
|
{isSaving ? 'Saving…' : isEditing ? 'Save changes' : 'Save action'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function GroupsConfigTab() {
|
function GroupsConfigTab() {
|
||||||
const [groups, setGroups] = useState<ReadonlyArray<Group>>([])
|
const [groups, setGroups] = useState<ReadonlyArray<Group>>([])
|
||||||
const [isLoadingGroups, setIsLoadingGroups] = useState(true)
|
const [isLoadingGroups, setIsLoadingGroups] = useState(true)
|
||||||
@@ -1740,6 +2287,10 @@ function ConfigTabPanelContent({
|
|||||||
return <GroupsConfigTab />
|
return <GroupsConfigTab />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (activeConfigTab === 'actions') {
|
||||||
|
return <ActionsConfigTab />
|
||||||
|
}
|
||||||
|
|
||||||
if (activeConfigTab === 'users') {
|
if (activeConfigTab === 'users') {
|
||||||
return <UsersConfigTab />
|
return <UsersConfigTab />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import {
|
import {
|
||||||
|
createAction,
|
||||||
|
deleteAction,
|
||||||
deleteGroup,
|
deleteGroup,
|
||||||
deleteNode,
|
deleteNode,
|
||||||
deleteUser,
|
deleteUser,
|
||||||
|
fetchActions,
|
||||||
fetchGroups,
|
fetchGroups,
|
||||||
fetchHealth,
|
fetchHealth,
|
||||||
fetchNetwork,
|
fetchNetwork,
|
||||||
@@ -11,6 +14,7 @@ import {
|
|||||||
fetchStatus,
|
fetchStatus,
|
||||||
fetchUsers,
|
fetchUsers,
|
||||||
getApiBaseUrl,
|
getApiBaseUrl,
|
||||||
|
patchAction,
|
||||||
saveNetwork,
|
saveNetwork,
|
||||||
saveSecurity,
|
saveSecurity,
|
||||||
upsertGroup,
|
upsertGroup,
|
||||||
@@ -622,3 +626,93 @@ describe('nodes API', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('actions API', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fetches actions', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
id: 'a1',
|
||||||
|
name: 'Uptime',
|
||||||
|
description: 'uptime',
|
||||||
|
kind: 'shell',
|
||||||
|
body: 'uptime',
|
||||||
|
env: [],
|
||||||
|
builtin: true,
|
||||||
|
created_at: '2026-01-01T00:00:00.000Z',
|
||||||
|
updated_at: '2026-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const payload = await fetchActions(fetchMock)
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
'http://localhost:8080/api/v1/actions',
|
||||||
|
expect.objectContaining({ credentials: 'include' }),
|
||||||
|
)
|
||||||
|
expect(payload.actions).toHaveLength(1)
|
||||||
|
expect(payload.actions[0]?.name).toBe('Uptime')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates an action', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ actions: [] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
await createAction(
|
||||||
|
{
|
||||||
|
name: 'Echo',
|
||||||
|
description: 'echo ip',
|
||||||
|
kind: 'shell',
|
||||||
|
body: 'echo {{node.ip}}',
|
||||||
|
env: [{ name: 'APP_HOME', value: '/opt/app' }],
|
||||||
|
},
|
||||||
|
fetchMock,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
'http://localhost:8080/api/v1/actions',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: 'Echo',
|
||||||
|
description: 'echo ip',
|
||||||
|
kind: 'shell',
|
||||||
|
body: 'echo {{node.ip}}',
|
||||||
|
env: [{ name: 'APP_HOME', value: '/opt/app' }],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('patches and deletes an action', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ actions: [] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
await patchAction('custom-1', { name: 'Renamed', body: 'echo hi' }, fetchMock)
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
'http://localhost:8080/api/v1/actions?id=custom-1',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ name: 'Renamed', body: 'echo hi' }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await deleteAction('custom-1', fetchMock)
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
'http://localhost:8080/api/v1/actions?id=custom-1',
|
||||||
|
expect.objectContaining({ method: 'DELETE' }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -760,3 +760,103 @@ export async function fetchNodeLogs(
|
|||||||
}
|
}
|
||||||
return (await response.json()) as NodeLogsResponse
|
return (await response.json()) as NodeLogsResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ActionKind = 'shell' | 'script'
|
||||||
|
|
||||||
|
export type ActionEnvVar = {
|
||||||
|
name: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Action = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
kind: ActionKind
|
||||||
|
body: string
|
||||||
|
env: ReadonlyArray<ActionEnvVar>
|
||||||
|
builtin: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ActionsResponse = {
|
||||||
|
actions: ReadonlyArray<Action>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateActionRequest = {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
kind: ActionKind
|
||||||
|
body: string
|
||||||
|
env: ReadonlyArray<ActionEnvVar>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PatchActionRequest = {
|
||||||
|
name?: string
|
||||||
|
description?: string
|
||||||
|
body?: string
|
||||||
|
env?: ReadonlyArray<ActionEnvVar>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchActions(
|
||||||
|
fetchImpl: typeof fetch = fetch,
|
||||||
|
): Promise<ActionsResponse> {
|
||||||
|
const response = await apiFetch('/api/v1/actions', {}, fetchImpl)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readErrorMessage(response))
|
||||||
|
}
|
||||||
|
return (await response.json()) as ActionsResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAction(
|
||||||
|
action: CreateActionRequest,
|
||||||
|
fetchImpl: typeof fetch = fetch,
|
||||||
|
): Promise<ActionsResponse> {
|
||||||
|
const response = await apiFetch(
|
||||||
|
'/api/v1/actions',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(action),
|
||||||
|
},
|
||||||
|
fetchImpl,
|
||||||
|
)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readErrorMessage(response))
|
||||||
|
}
|
||||||
|
return (await response.json()) as ActionsResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patchAction(
|
||||||
|
id: string,
|
||||||
|
patch: PatchActionRequest,
|
||||||
|
fetchImpl: typeof fetch = fetch,
|
||||||
|
): Promise<ActionsResponse> {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`/api/v1/actions?id=${encodeURIComponent(id)}`,
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
},
|
||||||
|
fetchImpl,
|
||||||
|
)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readErrorMessage(response))
|
||||||
|
}
|
||||||
|
return (await response.json()) as ActionsResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAction(
|
||||||
|
id: string,
|
||||||
|
fetchImpl: typeof fetch = fetch,
|
||||||
|
): Promise<ActionsResponse> {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`/api/v1/actions?id=${encodeURIComponent(id)}`,
|
||||||
|
{ method: 'DELETE' },
|
||||||
|
fetchImpl,
|
||||||
|
)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readErrorMessage(response))
|
||||||
|
}
|
||||||
|
return (await response.json()) as ActionsResponse
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ describe('pathFor', () => {
|
|||||||
expect(pathFor('configuration')).toBe('/configuration')
|
expect(pathFor('configuration')).toBe('/configuration')
|
||||||
expect(pathFor('configuration', 'overview')).toBe('/configuration')
|
expect(pathFor('configuration', 'overview')).toBe('/configuration')
|
||||||
expect(pathFor('configuration', 'groups')).toBe('/configuration/groups')
|
expect(pathFor('configuration', 'groups')).toBe('/configuration/groups')
|
||||||
|
expect(pathFor('configuration', 'actions')).toBe('/configuration/actions')
|
||||||
expect(pathFor('configuration', 'roles')).toBe('/configuration/roles')
|
expect(pathFor('configuration', 'roles')).toBe('/configuration/roles')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -39,6 +40,7 @@ describe('parseLocation', () => {
|
|||||||
'/configuration/users',
|
'/configuration/users',
|
||||||
'/configuration/roles',
|
'/configuration/roles',
|
||||||
'/configuration/groups',
|
'/configuration/groups',
|
||||||
|
'/configuration/actions',
|
||||||
'/configuration/security',
|
'/configuration/security',
|
||||||
'/configuration/integrations',
|
'/configuration/integrations',
|
||||||
'/configuration/network',
|
'/configuration/network',
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export type ConfigTabId =
|
|||||||
| 'users'
|
| 'users'
|
||||||
| 'roles'
|
| 'roles'
|
||||||
| 'groups'
|
| 'groups'
|
||||||
|
| 'actions'
|
||||||
| 'security'
|
| 'security'
|
||||||
| 'integrations'
|
| 'integrations'
|
||||||
| 'network'
|
| 'network'
|
||||||
@@ -34,6 +35,7 @@ const CONFIG_TAB_IDS: ReadonlySet<string> = new Set([
|
|||||||
'users',
|
'users',
|
||||||
'roles',
|
'roles',
|
||||||
'groups',
|
'groups',
|
||||||
|
'actions',
|
||||||
'security',
|
'security',
|
||||||
'integrations',
|
'integrations',
|
||||||
'network',
|
'network',
|
||||||
|
|||||||
Reference in New Issue
Block a user