More webui layout, navigation, preparations of user/roles/groups/security
This commit is contained in:
@@ -36,7 +36,7 @@ func main() {
|
|||||||
|
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: ":" + port,
|
Addr: ":" + port,
|
||||||
Handler: api.NewRouter(),
|
Handler: api.NewRouter(configDir),
|
||||||
ReadHeaderTimeout: 5 * time.Second,
|
ReadHeaderTimeout: 5 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type groupsResponse struct {
|
||||||
|
Groups []settings.Group `json:"groups"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiErrorResponse struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type upsertGroupRequest struct {
|
||||||
|
Group settings.Group `json:"group"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var allowedPermissions = map[string]struct{}{
|
||||||
|
"nodes.read": {},
|
||||||
|
"nodes.exec": {},
|
||||||
|
"nodes.update": {},
|
||||||
|
"jobs.read": {},
|
||||||
|
"jobs.run": {},
|
||||||
|
"users.manage": {},
|
||||||
|
"secrets.manage": {},
|
||||||
|
"roles.manage": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
func groupsGetHandler(configDir string) http.HandlerFunc {
|
||||||
|
return func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
settingsPayload, err := loadSettingsOrDefault(configDir)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(writer, http.StatusOK, groupsResponse{Groups: settingsPayload.Groups})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func groupsUpsertHandler(configDir string) http.HandlerFunc {
|
||||||
|
return func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
if err := authorizeGroupsWrite(request); err != nil {
|
||||||
|
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload upsertGroupRequest
|
||||||
|
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
|
||||||
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateGroup(payload.Group); err != nil {
|
||||||
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsPayload, err := loadSettingsOrDefault(configDir)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updated := false
|
||||||
|
for index := range settingsPayload.Groups {
|
||||||
|
if settingsPayload.Groups[index].Name == payload.Group.Name {
|
||||||
|
settingsPayload.Groups[index] = payload.Group
|
||||||
|
updated = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !updated {
|
||||||
|
settingsPayload.Groups = append(settingsPayload.Groups, payload.Group)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := settings.SaveSettings(configDir, settingsPayload); err != nil {
|
||||||
|
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(writer, http.StatusOK, groupsResponse{Groups: settingsPayload.Groups})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func groupsDeleteHandler(configDir string) http.HandlerFunc {
|
||||||
|
return func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
if err := authorizeGroupsWrite(request); err != nil {
|
||||||
|
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimSpace(request.URL.Query().Get("name"))
|
||||||
|
if name == "" {
|
||||||
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "missing group name"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsPayload, err := loadSettingsOrDefault(configDir)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining := make([]settings.Group, 0, len(settingsPayload.Groups))
|
||||||
|
for _, group := range settingsPayload.Groups {
|
||||||
|
if group.Name != name {
|
||||||
|
remaining = append(remaining, group)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(remaining) == len(settingsPayload.Groups) {
|
||||||
|
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "group not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsPayload.Groups = remaining
|
||||||
|
if err := settings.SaveSettings(configDir, settingsPayload); err != nil {
|
||||||
|
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(writer, http.StatusOK, groupsResponse{Groups: settingsPayload.Groups})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func authorizeGroupsWrite(_ *http.Request) error {
|
||||||
|
// TODO: integrate authz middleware.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadSettingsOrDefault(configDir string) (settings.Settings, error) {
|
||||||
|
payload, err := settings.LoadSettings(configDir)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return settings.Settings{Groups: []settings.Group{}}, nil
|
||||||
|
}
|
||||||
|
return settings.Settings{}, err
|
||||||
|
}
|
||||||
|
if payload.Groups == nil {
|
||||||
|
payload.Groups = []settings.Group{}
|
||||||
|
}
|
||||||
|
return payload, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateGroup(group settings.Group) error {
|
||||||
|
if strings.TrimSpace(group.Name) == "" {
|
||||||
|
return errors.New("group.name is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch group.ScopeKind {
|
||||||
|
case settings.GroupScopeNode, settings.GroupScopeGroup:
|
||||||
|
// ok
|
||||||
|
default:
|
||||||
|
return errors.New("group.scope_kind must be \"node\" or \"group\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(group.ScopeName) == "" {
|
||||||
|
return errors.New("group.scope_name is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(group.Permissions) == 0 {
|
||||||
|
return errors.New("group.permissions must be non-empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, permission := range group.Permissions {
|
||||||
|
if strings.TrimSpace(permission) == "" {
|
||||||
|
return errors.New("permission strings must be non-empty")
|
||||||
|
}
|
||||||
|
if _, ok := allowedPermissions[permission]; !ok {
|
||||||
|
return errors.New("permission is not in the built-in permission catalog")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type groupsResponseTest struct {
|
||||||
|
Groups []settings.Group `json:"groups"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiErrorResponseTest struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGroupsGetEmptyWhenSettingsMissing(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
configDir := t.TempDir()
|
||||||
|
router := NewRouter(configDir)
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/api/v1/groups", nil)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(recorder, request)
|
||||||
|
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected status %d, got %d", http.StatusOK, recorder.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload groupsResponseTest
|
||||||
|
if err := json.NewDecoder(recorder.Body).Decode(&payload); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.Groups == nil || len(payload.Groups) != 0 {
|
||||||
|
t.Fatalf("expected empty groups list, got %#v", payload.Groups)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGroupsUpsertRejectsInvalidPermissions(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
configDir := t.TempDir()
|
||||||
|
router := NewRouter(configDir)
|
||||||
|
|
||||||
|
body := []byte(`{
|
||||||
|
"group": {
|
||||||
|
"name": "Admins",
|
||||||
|
"scope_kind": "node",
|
||||||
|
"scope_name": "node-1",
|
||||||
|
"permissions": ["nodes.nope"]
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/api/v1/groups", bytes.NewReader(body))
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(recorder, request)
|
||||||
|
|
||||||
|
if recorder.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, recorder.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload apiErrorResponseTest
|
||||||
|
if err := json.NewDecoder(recorder.Body).Decode(&payload); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.Error == "" {
|
||||||
|
t.Fatal("expected error message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGroupsUpsertUpsertsByName(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
configDir := t.TempDir()
|
||||||
|
router := NewRouter(configDir)
|
||||||
|
|
||||||
|
createBody := []byte(`{
|
||||||
|
"group": {
|
||||||
|
"name": "Admins",
|
||||||
|
"scope_kind": "node",
|
||||||
|
"scope_name": "node-1",
|
||||||
|
"permissions": ["nodes.read"]
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
createReq := httptest.NewRequest(http.MethodPost, "/api/v1/groups", bytes.NewReader(createBody))
|
||||||
|
createRec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(createRec, createReq)
|
||||||
|
if createRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected status %d, got %d", http.StatusOK, createRec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateBody := []byte(`{
|
||||||
|
"group": {
|
||||||
|
"name": "Admins",
|
||||||
|
"scope_kind": "node",
|
||||||
|
"scope_name": "node-2",
|
||||||
|
"permissions": ["nodes.exec","nodes.update"]
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
updateReq := httptest.NewRequest(http.MethodPost, "/api/v1/groups", bytes.NewReader(updateBody))
|
||||||
|
updateRec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(updateRec, updateReq)
|
||||||
|
if updateRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected status %d, got %d", http.StatusOK, updateRec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
getReq := httptest.NewRequest(http.MethodGet, "/api/v1/groups", nil)
|
||||||
|
getRec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(getRec, getReq)
|
||||||
|
if getRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected status %d, got %d", http.StatusOK, getRec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var getPayload groupsResponseTest
|
||||||
|
if err := json.NewDecoder(getRec.Body).Decode(&getPayload); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if len(getPayload.Groups) != 1 {
|
||||||
|
t.Fatalf("expected 1 group, got %d", len(getPayload.Groups))
|
||||||
|
}
|
||||||
|
if getPayload.Groups[0].Name != "Admins" {
|
||||||
|
t.Fatalf("expected group name %q, got %q", "Admins", getPayload.Groups[0].Name)
|
||||||
|
}
|
||||||
|
if getPayload.Groups[0].ScopeName != "node-2" {
|
||||||
|
t.Fatalf("expected scope_name %q, got %q", "node-2", getPayload.Groups[0].ScopeName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGroupsDeleteRemovesByName(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
configDir := t.TempDir()
|
||||||
|
router := NewRouter(configDir)
|
||||||
|
|
||||||
|
createBody := []byte(`{
|
||||||
|
"group": {
|
||||||
|
"name": "Admins",
|
||||||
|
"scope_kind": "node",
|
||||||
|
"scope_name": "node-1",
|
||||||
|
"permissions": ["nodes.read"]
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
createReq := httptest.NewRequest(http.MethodPost, "/api/v1/groups", bytes.NewReader(createBody))
|
||||||
|
createRec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(createRec, createReq)
|
||||||
|
if createRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected status %d, got %d", http.StatusOK, createRec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteReq := httptest.NewRequest(http.MethodDelete, "/api/v1/groups?name=Admins", nil)
|
||||||
|
deleteRec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(deleteRec, deleteReq)
|
||||||
|
if deleteRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected status %d, got %d", http.StatusOK, deleteRec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
getReq := httptest.NewRequest(http.MethodGet, "/api/v1/groups", nil)
|
||||||
|
getRec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(getRec, getReq)
|
||||||
|
if getRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected status %d, got %d", http.StatusOK, getRec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var getPayload groupsResponseTest
|
||||||
|
if err := json.NewDecoder(getRec.Body).Decode(&getPayload); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if len(getPayload.Groups) != 0 {
|
||||||
|
t.Fatalf("expected empty groups, got %#v", getPayload.Groups)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGroupsDeleteMissingNameFails(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
configDir := t.TempDir()
|
||||||
|
router := NewRouter(configDir)
|
||||||
|
|
||||||
|
deleteReq := httptest.NewRequest(http.MethodDelete, "/api/v1/groups", nil)
|
||||||
|
deleteRec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(deleteRec, deleteReq)
|
||||||
|
|
||||||
|
if deleteRec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, deleteRec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload apiErrorResponseTest
|
||||||
|
if err := json.NewDecoder(deleteRec.Body).Decode(&payload); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if payload.Error == "" {
|
||||||
|
t.Fatal("expected error message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -10,10 +10,11 @@ import (
|
|||||||
func TestHealthHandler(t *testing.T) {
|
func TestHealthHandler(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
configDir := t.TempDir()
|
||||||
request := httptest.NewRequest(http.MethodGet, "/health", nil)
|
request := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
NewRouter().ServeHTTP(recorder, request)
|
NewRouter(configDir).ServeHTTP(recorder, request)
|
||||||
|
|
||||||
if recorder.Code != http.StatusOK {
|
if recorder.Code != http.StatusOK {
|
||||||
t.Fatalf("expected status %d, got %d", http.StatusOK, recorder.Code)
|
t.Fatalf("expected status %d, got %d", http.StatusOK, recorder.Code)
|
||||||
@@ -34,10 +35,11 @@ func TestHealthHandler(t *testing.T) {
|
|||||||
func TestStatusHandler(t *testing.T) {
|
func TestStatusHandler(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
configDir := t.TempDir()
|
||||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)
|
request := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
NewRouter().ServeHTTP(recorder, request)
|
NewRouter(configDir).ServeHTTP(recorder, request)
|
||||||
|
|
||||||
if recorder.Code != http.StatusOK {
|
if recorder.Code != http.StatusOK {
|
||||||
t.Fatalf("expected status %d, got %d", http.StatusOK, recorder.Code)
|
t.Fatalf("expected status %d, got %d", http.StatusOK, recorder.Code)
|
||||||
@@ -58,10 +60,11 @@ func TestStatusHandler(t *testing.T) {
|
|||||||
func TestCORSPreflight(t *testing.T) {
|
func TestCORSPreflight(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
configDir := t.TempDir()
|
||||||
request := httptest.NewRequest(http.MethodOptions, "/health", nil)
|
request := httptest.NewRequest(http.MethodOptions, "/health", nil)
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
NewRouter().ServeHTTP(recorder, request)
|
NewRouter(configDir).ServeHTTP(recorder, request)
|
||||||
|
|
||||||
if recorder.Code != http.StatusNoContent {
|
if recorder.Code != http.StatusNoContent {
|
||||||
t.Fatalf("expected status %d, got %d", http.StatusNoContent, recorder.Code)
|
t.Fatalf("expected status %d, got %d", http.StatusNoContent, recorder.Code)
|
||||||
|
|||||||
@@ -21,9 +21,12 @@ func withCORS(next http.Handler) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRouter() http.Handler {
|
func NewRouter(configDir string) http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("GET /health", HealthHandler)
|
mux.HandleFunc("GET /health", HealthHandler)
|
||||||
mux.HandleFunc("GET /api/v1/status", StatusHandler)
|
mux.HandleFunc("GET /api/v1/status", StatusHandler)
|
||||||
|
mux.HandleFunc("GET /api/v1/groups", groupsGetHandler(configDir))
|
||||||
|
mux.HandleFunc("POST /api/v1/groups", groupsUpsertHandler(configDir))
|
||||||
|
mux.HandleFunc("DELETE /api/v1/groups", groupsDeleteHandler(configDir))
|
||||||
return withCORS(mux)
|
return withCORS(mux)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,27 @@
|
|||||||
package settings
|
package settings
|
||||||
|
|
||||||
|
// GroupScopeKind identifies what a group scopes to.
|
||||||
|
type GroupScopeKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
GroupScopeNode GroupScopeKind = "node"
|
||||||
|
GroupScopeGroup GroupScopeKind = "group"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Group configures a named permission set for a given scope target.
|
||||||
|
//
|
||||||
|
// Groups are persisted in settings.json and later used by authz middleware.
|
||||||
|
type Group struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
ScopeKind GroupScopeKind `json:"scope_kind"`
|
||||||
|
ScopeName string `json:"scope_name"`
|
||||||
|
Permissions []string `json:"permissions"`
|
||||||
|
}
|
||||||
|
|
||||||
// Settings holds plain (non-secret) base/CLI configuration.
|
// Settings holds plain (non-secret) base/CLI configuration.
|
||||||
type Settings struct {
|
type Settings struct {
|
||||||
LogLevel string `json:"log_level"`
|
LogLevel string `json:"log_level"`
|
||||||
|
Groups []Group `json:"groups"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Secrets holds sensitive configuration encrypted at rest.
|
// Secrets holds sensitive configuration encrypted at rest.
|
||||||
|
|||||||
+166
-1
@@ -120,12 +120,14 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
max-width: 40rem;
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-tabs {
|
.config-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
width: 100%;
|
||||||
gap: 0.15rem 0.25rem;
|
gap: 0.15rem 0.25rem;
|
||||||
border-bottom: 1px solid var(--ctp-surface1);
|
border-bottom: 1px solid var(--ctp-surface1);
|
||||||
}
|
}
|
||||||
@@ -162,6 +164,7 @@
|
|||||||
|
|
||||||
.config-tab-panel {
|
.config-tab-panel {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
max-width: 40rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-field {
|
.config-field {
|
||||||
@@ -210,6 +213,167 @@
|
|||||||
color: var(--ctp-subtext1);
|
color: var(--ctp-subtext1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.roles-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-catalog {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-catalog code {
|
||||||
|
font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas,
|
||||||
|
monospace;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--ctp-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-input {
|
||||||
|
appearance: none;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
border: 1px solid var(--ctp-surface1);
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
background: var(--ctp-mantle);
|
||||||
|
color: var(--ctp-text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-input:focus-visible {
|
||||||
|
outline: 2px solid var(--ctp-mauve);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groups-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
max-width: 40rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groups-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groups-ul {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groups-li {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
border: 1px solid var(--ctp-surface1);
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
background: var(--ctp-surface0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.groups-li-title {
|
||||||
|
font-weight: 650;
|
||||||
|
color: var(--ctp-text);
|
||||||
|
margin-bottom: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groups-li-meta {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--ctp-subtext0);
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groups-delete {
|
||||||
|
appearance: none;
|
||||||
|
border: 1px solid var(--ctp-surface1);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ctp-text);
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
padding: 0.3rem 0.7rem;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groups-delete:hover {
|
||||||
|
background: var(--ctp-mauve-hover);
|
||||||
|
color: var(--ctp-mauve);
|
||||||
|
}
|
||||||
|
|
||||||
|
.groups-delete:focus-visible {
|
||||||
|
outline: 2px solid var(--ctp-mauve);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groups-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groups-error {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-checkboxes {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.6rem 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--ctp-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-option code {
|
||||||
|
font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas,
|
||||||
|
monospace;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groups-save {
|
||||||
|
appearance: none;
|
||||||
|
align-self: flex-start;
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
padding: 0.55rem 1rem;
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
border: 1px solid var(--ctp-mauve);
|
||||||
|
background: var(--ctp-mauve);
|
||||||
|
color: var(--ctp-base);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 650;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groups-save:hover {
|
||||||
|
background: var(--ctp-mauve-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.groups-save:focus-visible {
|
||||||
|
outline: 2px solid var(--ctp-mauve);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.config-actions {
|
.config-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -217,6 +381,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-action-revert,
|
.config-action-revert,
|
||||||
|
|||||||
+99
-6
@@ -7,12 +7,37 @@ import { THEME_STORAGE_KEY } from './theme'
|
|||||||
function stubFetchOk() {
|
function stubFetchOk() {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
'fetch',
|
'fetch',
|
||||||
vi.fn().mockResolvedValue({
|
vi.fn().mockImplementation((input: RequestInfo | URL) => {
|
||||||
ok: true,
|
const url = typeof input === 'string' ? input : input.toString()
|
||||||
json: async () => ({
|
|
||||||
service: 'clustercanvas',
|
if (url.includes('/health')) {
|
||||||
version: '0.1.0',
|
return Promise.resolve({
|
||||||
}),
|
ok: true,
|
||||||
|
json: async () => ({ status: 'ok' }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.includes('/api/v1/status')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
service: 'clustercanvas',
|
||||||
|
version: '0.1.0',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.includes('/api/v1/groups')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ groups: [] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({}),
|
||||||
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -37,12 +62,14 @@ describe('App', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear()
|
localStorage.clear()
|
||||||
delete document.documentElement.dataset.theme
|
delete document.documentElement.dataset.theme
|
||||||
|
window.history.replaceState(null, '', '/')
|
||||||
stubMatchMedia(false)
|
stubMatchMedia(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
localStorage.clear()
|
localStorage.clear()
|
||||||
delete document.documentElement.dataset.theme
|
delete document.documentElement.dataset.theme
|
||||||
|
window.history.replaceState(null, '', '/')
|
||||||
vi.unstubAllGlobals()
|
vi.unstubAllGlobals()
|
||||||
vi.restoreAllMocks()
|
vi.restoreAllMocks()
|
||||||
})
|
})
|
||||||
@@ -85,6 +112,7 @@ describe('App', () => {
|
|||||||
expect(
|
expect(
|
||||||
screen.getByRole('heading', { name: 'VMs', level: 2 }),
|
screen.getByRole('heading', { name: 'VMs', level: 2 }),
|
||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
|
expect(window.location.pathname).toBe('/vms')
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: 'Configuration' }))
|
await user.click(screen.getByRole('button', { name: 'Configuration' }))
|
||||||
expect(
|
expect(
|
||||||
@@ -95,6 +123,39 @@ describe('App', () => {
|
|||||||
'true',
|
'true',
|
||||||
)
|
)
|
||||||
expect(screen.getByLabelText('Theme')).toBeInTheDocument()
|
expect(screen.getByLabelText('Theme')).toBeInTheDocument()
|
||||||
|
expect(window.location.pathname).toBe('/configuration')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates the URL when opening a Configuration tab', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
stubFetchOk()
|
||||||
|
|
||||||
|
render(<App />)
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Configuration' }))
|
||||||
|
await user.click(screen.getByRole('tab', { name: 'Groups' }))
|
||||||
|
|
||||||
|
expect(window.location.pathname).toBe('/configuration/groups')
|
||||||
|
expect(screen.getByRole('tab', { name: 'Groups' })).toHaveAttribute(
|
||||||
|
'aria-selected',
|
||||||
|
'true',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('restores section and config tab from the URL on load', async () => {
|
||||||
|
stubFetchOk()
|
||||||
|
window.history.replaceState(null, '', '/configuration/groups')
|
||||||
|
|
||||||
|
render(<App />)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole('heading', { name: 'Configuration', level: 2 }),
|
||||||
|
).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('tab', { name: 'Groups' })).toHaveAttribute(
|
||||||
|
'aria-selected',
|
||||||
|
'true',
|
||||||
|
)
|
||||||
|
expect(await screen.findByLabelText('nodes.read')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('switches Configuration tabs and shows empty states', async () => {
|
it('switches Configuration tabs and shows empty states', async () => {
|
||||||
@@ -108,6 +169,7 @@ describe('App', () => {
|
|||||||
for (const label of [
|
for (const label of [
|
||||||
'Overview',
|
'Overview',
|
||||||
'Users',
|
'Users',
|
||||||
|
'Roles',
|
||||||
'Groups',
|
'Groups',
|
||||||
'Security',
|
'Security',
|
||||||
'Integrations',
|
'Integrations',
|
||||||
@@ -117,6 +179,37 @@ describe('App', () => {
|
|||||||
expect(screen.getByRole('tab', { name: label })).toBeInTheDocument()
|
expect(screen.getByRole('tab', { name: label })).toBeInTheDocument()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('tab', { name: 'Roles' }))
|
||||||
|
expect(screen.getByRole('tab', { name: 'Roles' })).toHaveAttribute(
|
||||||
|
'aria-selected',
|
||||||
|
'true',
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Built-in permissions that roles can grant/i),
|
||||||
|
).toBeInTheDocument()
|
||||||
|
for (const permission of [
|
||||||
|
'nodes.read',
|
||||||
|
'nodes.exec',
|
||||||
|
'nodes.update',
|
||||||
|
'jobs.read',
|
||||||
|
'jobs.run',
|
||||||
|
'users.manage',
|
||||||
|
'secrets.manage',
|
||||||
|
'roles.manage',
|
||||||
|
]) {
|
||||||
|
expect(screen.getByText(permission)).toBeInTheDocument()
|
||||||
|
}
|
||||||
|
expect(screen.queryByLabelText('Theme')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('tab', { name: 'Groups' }))
|
||||||
|
expect(screen.getByRole('tab', { name: 'Groups' })).toHaveAttribute(
|
||||||
|
'aria-selected',
|
||||||
|
'true',
|
||||||
|
)
|
||||||
|
expect(screen.getByLabelText('nodes.read')).toBeInTheDocument()
|
||||||
|
expect(screen.getByLabelText('roles.manage')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByLabelText('Theme')).not.toBeInTheDocument()
|
||||||
|
|
||||||
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',
|
||||||
|
|||||||
+295
-18
@@ -1,5 +1,20 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { fetchStatus } from './api/client'
|
import {
|
||||||
|
deleteGroup,
|
||||||
|
fetchGroups,
|
||||||
|
fetchStatus,
|
||||||
|
type Group,
|
||||||
|
type GroupScopeKind,
|
||||||
|
upsertGroup,
|
||||||
|
} from './api/client'
|
||||||
|
import {
|
||||||
|
navigateTo,
|
||||||
|
parseLocation,
|
||||||
|
pathFor,
|
||||||
|
subscribeToLocation,
|
||||||
|
type ConfigTabId,
|
||||||
|
type SectionId,
|
||||||
|
} from './navigation'
|
||||||
import {
|
import {
|
||||||
applyThemeFromPreference,
|
applyThemeFromPreference,
|
||||||
getSystemPrefersDark,
|
getSystemPrefersDark,
|
||||||
@@ -9,8 +24,6 @@ import {
|
|||||||
} from './theme'
|
} from './theme'
|
||||||
import './App.css'
|
import './App.css'
|
||||||
|
|
||||||
type SectionId = 'overview' | 'vms' | 'containers' | 'configuration'
|
|
||||||
|
|
||||||
const NAV_ITEMS: ReadonlyArray<{ id: SectionId; label: string }> = [
|
const NAV_ITEMS: ReadonlyArray<{ id: SectionId; label: string }> = [
|
||||||
{ id: 'overview', label: 'Overview' },
|
{ id: 'overview', label: 'Overview' },
|
||||||
{ id: 'vms', label: 'VMs' },
|
{ id: 'vms', label: 'VMs' },
|
||||||
@@ -24,18 +37,10 @@ const SECTION_TITLES: Record<SectionId, string> = {
|
|||||||
configuration: 'Configuration',
|
configuration: 'Configuration',
|
||||||
}
|
}
|
||||||
|
|
||||||
type ConfigTabId =
|
|
||||||
| 'overview'
|
|
||||||
| 'users'
|
|
||||||
| 'groups'
|
|
||||||
| 'security'
|
|
||||||
| 'integrations'
|
|
||||||
| 'network'
|
|
||||||
| 'advanced'
|
|
||||||
|
|
||||||
const CONFIG_TABS: ReadonlyArray<{ id: ConfigTabId; label: string }> = [
|
const CONFIG_TABS: ReadonlyArray<{ id: ConfigTabId; label: string }> = [
|
||||||
{ id: 'overview', label: 'Overview' },
|
{ id: 'overview', label: 'Overview' },
|
||||||
{ id: 'users', label: 'Users' },
|
{ id: 'users', label: 'Users' },
|
||||||
|
{ id: 'roles', label: 'Roles' },
|
||||||
{ id: 'groups', label: 'Groups' },
|
{ id: 'groups', label: 'Groups' },
|
||||||
{ id: 'security', label: 'Security' },
|
{ id: 'security', label: 'Security' },
|
||||||
{ id: 'integrations', label: 'Integrations' },
|
{ id: 'integrations', label: 'Integrations' },
|
||||||
@@ -43,6 +48,17 @@ const CONFIG_TABS: ReadonlyArray<{ id: ConfigTabId; label: string }> = [
|
|||||||
{ id: 'advanced', label: 'Advanced' },
|
{ id: 'advanced', label: 'Advanced' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const DEFAULT_PERMISSIONS = [
|
||||||
|
'nodes.read',
|
||||||
|
'nodes.exec',
|
||||||
|
'nodes.update',
|
||||||
|
'jobs.read',
|
||||||
|
'jobs.run',
|
||||||
|
'users.manage',
|
||||||
|
'secrets.manage',
|
||||||
|
'roles.manage',
|
||||||
|
] as const
|
||||||
|
|
||||||
function CogIcon() {
|
function CogIcon() {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
@@ -122,6 +138,213 @@ function OverviewConfigTab({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function GroupsConfigTab() {
|
||||||
|
const [groups, setGroups] = useState<ReadonlyArray<Group>>([])
|
||||||
|
const [isLoadingGroups, setIsLoadingGroups] = useState(true)
|
||||||
|
const [groupsLoadError, setGroupsLoadError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [draftGroupName, setDraftGroupName] = useState('')
|
||||||
|
const [draftScopeKind, setDraftScopeKind] = useState<GroupScopeKind>('node')
|
||||||
|
const [draftScopeName, setDraftScopeName] = useState('')
|
||||||
|
const [draftPermissions, setDraftPermissions] = useState<string[]>([])
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isCancelled = false
|
||||||
|
|
||||||
|
async function loadGroups() {
|
||||||
|
setIsLoadingGroups(true)
|
||||||
|
setGroupsLoadError(null)
|
||||||
|
try {
|
||||||
|
const response = await fetchGroups()
|
||||||
|
if (!isCancelled) {
|
||||||
|
setGroups(response.groups)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!isCancelled) {
|
||||||
|
setGroupsLoadError(
|
||||||
|
err instanceof Error ? err.message : 'Failed to load groups',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!isCancelled) {
|
||||||
|
setIsLoadingGroups(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadGroups()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isCancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function togglePermission(permission: string, checked: boolean) {
|
||||||
|
setDraftPermissions((prev) => {
|
||||||
|
if (checked) {
|
||||||
|
if (prev.includes(permission)) {
|
||||||
|
return prev
|
||||||
|
}
|
||||||
|
return [...prev, permission]
|
||||||
|
}
|
||||||
|
|
||||||
|
return prev.filter((item) => item !== permission)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveGroup() {
|
||||||
|
setSubmitError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await upsertGroup({
|
||||||
|
name: draftGroupName,
|
||||||
|
scope_kind: draftScopeKind,
|
||||||
|
scope_name: draftScopeName,
|
||||||
|
permissions: draftPermissions,
|
||||||
|
})
|
||||||
|
|
||||||
|
setGroups(response.groups)
|
||||||
|
setDraftGroupName('')
|
||||||
|
setDraftScopeKind('node')
|
||||||
|
setDraftScopeName('')
|
||||||
|
setDraftPermissions([])
|
||||||
|
} catch (err) {
|
||||||
|
setSubmitError(
|
||||||
|
err instanceof Error ? err.message : 'Failed to save group',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteGroup(groupName: string) {
|
||||||
|
setSubmitError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await deleteGroup(groupName)
|
||||||
|
setGroups(response.groups)
|
||||||
|
} catch (err) {
|
||||||
|
setSubmitError(
|
||||||
|
err instanceof Error ? err.message : 'Failed to delete group',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="groups-panel">
|
||||||
|
<div className="groups-list">
|
||||||
|
<p className="config-hint">Configured groups</p>
|
||||||
|
{isLoadingGroups ? (
|
||||||
|
<p className="config-empty">Loading…</p>
|
||||||
|
) : groupsLoadError ? (
|
||||||
|
<p className="config-empty" role="alert">
|
||||||
|
{groupsLoadError}
|
||||||
|
</p>
|
||||||
|
) : groups.length === 0 ? (
|
||||||
|
<p className="config-empty">No groups configured yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="groups-ul">
|
||||||
|
{groups.map((group) => (
|
||||||
|
<li key={group.name} className="groups-li">
|
||||||
|
<div className="groups-li-content">
|
||||||
|
<div className="groups-li-title">{group.name}</div>
|
||||||
|
<div className="groups-li-meta">
|
||||||
|
Scope: {group.scope_kind}: {group.scope_name}
|
||||||
|
</div>
|
||||||
|
<div className="groups-li-meta">
|
||||||
|
Permissions: {group.permissions.join(', ')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="groups-delete"
|
||||||
|
onClick={() => void handleDeleteGroup(group.name)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="groups-form">
|
||||||
|
<p className="config-hint">Create or update a group</p>
|
||||||
|
|
||||||
|
{submitError ? (
|
||||||
|
<p className="config-empty groups-error" role="alert">
|
||||||
|
{submitError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="config-field">
|
||||||
|
<label htmlFor="group-name">Group name</label>
|
||||||
|
<input
|
||||||
|
id="group-name"
|
||||||
|
className="config-input"
|
||||||
|
value={draftGroupName}
|
||||||
|
onChange={(event) => {
|
||||||
|
setDraftGroupName(event.target.value)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="config-field">
|
||||||
|
<label htmlFor="scope-kind">Scope kind</label>
|
||||||
|
<select
|
||||||
|
id="scope-kind"
|
||||||
|
className="theme-select"
|
||||||
|
value={draftScopeKind}
|
||||||
|
onChange={(event) => {
|
||||||
|
setDraftScopeKind(event.target.value as GroupScopeKind)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="node">node</option>
|
||||||
|
<option value="group">group</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="config-field">
|
||||||
|
<label htmlFor="scope-name">Scope name</label>
|
||||||
|
<input
|
||||||
|
id="scope-name"
|
||||||
|
className="config-input"
|
||||||
|
value={draftScopeName}
|
||||||
|
onChange={(event) => {
|
||||||
|
setDraftScopeName(event.target.value)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="config-field">
|
||||||
|
<label>Permissions</label>
|
||||||
|
<div className="permission-checkboxes">
|
||||||
|
{DEFAULT_PERMISSIONS.map((permission) => {
|
||||||
|
const checked = draftPermissions.includes(permission)
|
||||||
|
return (
|
||||||
|
<label key={permission} className="permission-option">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
aria-label={permission}
|
||||||
|
checked={checked}
|
||||||
|
onChange={(event) => {
|
||||||
|
togglePermission(permission, event.target.checked)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<code>{permission}</code>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" className="groups-save" onClick={() => void handleSaveGroup()}>
|
||||||
|
Save group
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function ConfigTabPanelContent({
|
function ConfigTabPanelContent({
|
||||||
activeConfigTab,
|
activeConfigTab,
|
||||||
draftThemePreference,
|
draftThemePreference,
|
||||||
@@ -140,6 +363,27 @@ function ConfigTabPanelContent({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (activeConfigTab === 'roles') {
|
||||||
|
return (
|
||||||
|
<div className="roles-panel">
|
||||||
|
<p className="config-hint">
|
||||||
|
Built-in permissions that roles can grant.
|
||||||
|
</p>
|
||||||
|
<ul className="permission-catalog">
|
||||||
|
{DEFAULT_PERMISSIONS.map((permission) => (
|
||||||
|
<li key={permission}>
|
||||||
|
<code>{permission}</code>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeConfigTab === 'groups') {
|
||||||
|
return <GroupsConfigTab />
|
||||||
|
}
|
||||||
|
|
||||||
if (activeConfigTab === 'security') {
|
if (activeConfigTab === 'security') {
|
||||||
return (
|
return (
|
||||||
<p className="config-empty">
|
<p className="config-empty">
|
||||||
@@ -153,14 +397,16 @@ function ConfigTabPanelContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ConfigurationPanel({
|
function ConfigurationPanel({
|
||||||
|
activeConfigTab,
|
||||||
|
onConfigTabChange,
|
||||||
themePreference,
|
themePreference,
|
||||||
onThemePreferenceChange,
|
onThemePreferenceChange,
|
||||||
}: {
|
}: {
|
||||||
|
activeConfigTab: ConfigTabId
|
||||||
|
onConfigTabChange: (tab: ConfigTabId) => void
|
||||||
themePreference: ThemePreference
|
themePreference: ThemePreference
|
||||||
onThemePreferenceChange: (preference: ThemePreference) => void
|
onThemePreferenceChange: (preference: ThemePreference) => void
|
||||||
}) {
|
}) {
|
||||||
const [activeConfigTab, setActiveConfigTab] =
|
|
||||||
useState<ConfigTabId>('overview')
|
|
||||||
const [draftThemePreference, setDraftThemePreference] =
|
const [draftThemePreference, setDraftThemePreference] =
|
||||||
useState<ThemePreference>(themePreference)
|
useState<ThemePreference>(themePreference)
|
||||||
|
|
||||||
@@ -187,7 +433,7 @@ function ConfigurationPanel({
|
|||||||
aria-selected={isSelected}
|
aria-selected={isSelected}
|
||||||
aria-controls="config-tab-panel"
|
aria-controls="config-tab-panel"
|
||||||
tabIndex={isSelected ? 0 : -1}
|
tabIndex={isSelected ? 0 : -1}
|
||||||
onClick={() => setActiveConfigTab(tab.id)}
|
onClick={() => onConfigTabChange(tab.id)}
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
</button>
|
</button>
|
||||||
@@ -231,10 +477,24 @@ function ConfigurationPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [activeSection, setActiveSection] = useState<SectionId>('overview')
|
const initialLocation = parseLocation(window.location.pathname)
|
||||||
|
const [activeSection, setActiveSection] = useState<SectionId>(
|
||||||
|
initialLocation.section,
|
||||||
|
)
|
||||||
|
const [activeConfigTab, setActiveConfigTab] = useState<ConfigTabId>(
|
||||||
|
initialLocation.configTab,
|
||||||
|
)
|
||||||
const [backendVersion, setBackendVersion] = useState('…')
|
const [backendVersion, setBackendVersion] = useState('…')
|
||||||
const { themePreference, updateThemePreference } = useThemePreference()
|
const { themePreference, updateThemePreference } = useThemePreference()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return subscribeToLocation((pathname) => {
|
||||||
|
const location = parseLocation(pathname)
|
||||||
|
setActiveSection(location.section)
|
||||||
|
setActiveConfigTab(location.configTab)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isCancelled = false
|
let isCancelled = false
|
||||||
|
|
||||||
@@ -258,6 +518,21 @@ function App() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
function goToSection(section: SectionId) {
|
||||||
|
const nextTab = section === 'configuration' ? activeConfigTab : 'overview'
|
||||||
|
setActiveSection(section)
|
||||||
|
if (section !== 'configuration') {
|
||||||
|
setActiveConfigTab('overview')
|
||||||
|
}
|
||||||
|
navigateTo(pathFor(section, section === 'configuration' ? nextTab : 'overview'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToConfigTab(tab: ConfigTabId) {
|
||||||
|
setActiveSection('configuration')
|
||||||
|
setActiveConfigTab(tab)
|
||||||
|
navigateTo(pathFor('configuration', tab))
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
<aside className="sidebar" aria-label="Main menu">
|
<aside className="sidebar" aria-label="Main menu">
|
||||||
@@ -278,7 +553,7 @@ function App() {
|
|||||||
: 'nav-item'
|
: 'nav-item'
|
||||||
}
|
}
|
||||||
aria-current={activeSection === item.id ? 'page' : undefined}
|
aria-current={activeSection === item.id ? 'page' : undefined}
|
||||||
onClick={() => setActiveSection(item.id)}
|
onClick={() => goToSection(item.id)}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</button>
|
</button>
|
||||||
@@ -296,7 +571,7 @@ function App() {
|
|||||||
aria-current={
|
aria-current={
|
||||||
activeSection === 'configuration' ? 'page' : undefined
|
activeSection === 'configuration' ? 'page' : undefined
|
||||||
}
|
}
|
||||||
onClick={() => setActiveSection('configuration')}
|
onClick={() => goToSection('configuration')}
|
||||||
>
|
>
|
||||||
<CogIcon />
|
<CogIcon />
|
||||||
Configuration
|
Configuration
|
||||||
@@ -308,6 +583,8 @@ function App() {
|
|||||||
<h2>{SECTION_TITLES[activeSection]}</h2>
|
<h2>{SECTION_TITLES[activeSection]}</h2>
|
||||||
{activeSection === 'configuration' ? (
|
{activeSection === 'configuration' ? (
|
||||||
<ConfigurationPanel
|
<ConfigurationPanel
|
||||||
|
activeConfigTab={activeConfigTab}
|
||||||
|
onConfigTabChange={goToConfigTab}
|
||||||
themePreference={themePreference}
|
themePreference={themePreference}
|
||||||
onThemePreferenceChange={updateThemePreference}
|
onThemePreferenceChange={updateThemePreference}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { fetchHealth, fetchStatus, getApiBaseUrl } from './client'
|
import {
|
||||||
|
deleteGroup,
|
||||||
|
fetchGroups,
|
||||||
|
fetchHealth,
|
||||||
|
fetchStatus,
|
||||||
|
getApiBaseUrl,
|
||||||
|
upsertGroup,
|
||||||
|
} from './client'
|
||||||
|
|
||||||
describe('getApiBaseUrl', () => {
|
describe('getApiBaseUrl', () => {
|
||||||
it('defaults to localhost:8080 when unset', () => {
|
it('defaults to localhost:8080 when unset', () => {
|
||||||
@@ -68,3 +75,137 @@ describe('fetchStatus', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('fetchGroups', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns parsed groups payload', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
name: 'Admins',
|
||||||
|
scope_kind: 'node',
|
||||||
|
scope_name: 'node-1',
|
||||||
|
permissions: ['nodes.read'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const payload = await fetchGroups(fetchMock)
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith('http://localhost:8080/api/v1/groups')
|
||||||
|
expect(payload.groups).toEqual([
|
||||||
|
{
|
||||||
|
name: 'Admins',
|
||||||
|
scope_kind: 'node',
|
||||||
|
scope_name: 'node-1',
|
||||||
|
permissions: ['nodes.read'],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws when the response is not ok', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 503,
|
||||||
|
json: async () => ({}),
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(fetchGroups(fetchMock)).rejects.toThrow(
|
||||||
|
'groups fetch failed: 503',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('upsertGroup', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends JSON body and returns parsed groups', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ groups: [] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
await upsertGroup(
|
||||||
|
{
|
||||||
|
name: 'Admins',
|
||||||
|
scope_kind: 'node',
|
||||||
|
scope_name: 'node-1',
|
||||||
|
permissions: ['nodes.read'],
|
||||||
|
},
|
||||||
|
fetchMock,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith('http://localhost:8080/api/v1/groups', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
group: {
|
||||||
|
name: 'Admins',
|
||||||
|
scope_kind: 'node',
|
||||||
|
scope_name: 'node-1',
|
||||||
|
permissions: ['nodes.read'],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws when the response is not ok', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 400,
|
||||||
|
json: async () => ({}),
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
upsertGroup(
|
||||||
|
{
|
||||||
|
name: 'Admins',
|
||||||
|
scope_kind: 'node',
|
||||||
|
scope_name: 'node-1',
|
||||||
|
permissions: ['nodes.read'],
|
||||||
|
},
|
||||||
|
fetchMock,
|
||||||
|
),
|
||||||
|
).rejects.toThrow('groups upsert failed: 400')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('deleteGroup', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends DELETE and returns parsed groups', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ groups: [] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
await deleteGroup('Admins', fetchMock)
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
'http://localhost:8080/api/v1/groups?name=Admins',
|
||||||
|
{ method: 'DELETE' },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws when the response is not ok', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
json: async () => ({}),
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(deleteGroup('Admins', fetchMock)).rejects.toThrow(
|
||||||
|
'groups delete failed: 404',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -7,6 +7,19 @@ export type StatusResponse = {
|
|||||||
version: string
|
version: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type GroupScopeKind = 'node' | 'group'
|
||||||
|
|
||||||
|
export type Group = {
|
||||||
|
name: string
|
||||||
|
scope_kind: GroupScopeKind
|
||||||
|
scope_name: string
|
||||||
|
permissions: ReadonlyArray<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GroupsResponse = {
|
||||||
|
groups: ReadonlyArray<Group>
|
||||||
|
}
|
||||||
|
|
||||||
export function getApiBaseUrl(): string {
|
export function getApiBaseUrl(): string {
|
||||||
return import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080'
|
return import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080'
|
||||||
}
|
}
|
||||||
@@ -30,3 +43,46 @@ export async function fetchStatus(
|
|||||||
}
|
}
|
||||||
return (await response.json()) as StatusResponse
|
return (await response.json()) as StatusResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchGroups(
|
||||||
|
fetchImpl: typeof fetch = fetch,
|
||||||
|
): Promise<GroupsResponse> {
|
||||||
|
const response = await fetchImpl(`${getApiBaseUrl()}/api/v1/groups`)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`groups fetch failed: ${response.status}`)
|
||||||
|
}
|
||||||
|
return (await response.json()) as GroupsResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertGroup(
|
||||||
|
group: Group,
|
||||||
|
fetchImpl: typeof fetch = fetch,
|
||||||
|
): Promise<GroupsResponse> {
|
||||||
|
const response = await fetchImpl(`${getApiBaseUrl()}/api/v1/groups`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ group }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`groups upsert failed: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await response.json()) as GroupsResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteGroup(
|
||||||
|
name: string,
|
||||||
|
fetchImpl: typeof fetch = fetch,
|
||||||
|
): Promise<GroupsResponse> {
|
||||||
|
const response = await fetchImpl(
|
||||||
|
`${getApiBaseUrl()}/api/v1/groups?name=${encodeURIComponent(name)}`,
|
||||||
|
{ method: 'DELETE' },
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`groups delete failed: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await response.json()) as GroupsResponse
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { parseLocation, pathFor } from './navigation'
|
||||||
|
|
||||||
|
describe('pathFor', () => {
|
||||||
|
it('maps sections and config tabs to paths', () => {
|
||||||
|
expect(pathFor('overview')).toBe('/')
|
||||||
|
expect(pathFor('vms')).toBe('/vms')
|
||||||
|
expect(pathFor('containers')).toBe('/containers')
|
||||||
|
expect(pathFor('configuration')).toBe('/configuration')
|
||||||
|
expect(pathFor('configuration', 'overview')).toBe('/configuration')
|
||||||
|
expect(pathFor('configuration', 'groups')).toBe('/configuration/groups')
|
||||||
|
expect(pathFor('configuration', 'roles')).toBe('/configuration/roles')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('parseLocation', () => {
|
||||||
|
it('round-trips known paths', () => {
|
||||||
|
for (const path of [
|
||||||
|
'/',
|
||||||
|
'/vms',
|
||||||
|
'/containers',
|
||||||
|
'/configuration',
|
||||||
|
'/configuration/users',
|
||||||
|
'/configuration/roles',
|
||||||
|
'/configuration/groups',
|
||||||
|
'/configuration/security',
|
||||||
|
'/configuration/integrations',
|
||||||
|
'/configuration/network',
|
||||||
|
'/configuration/advanced',
|
||||||
|
]) {
|
||||||
|
const location = parseLocation(path)
|
||||||
|
expect(pathFor(location.section, location.configTab)).toBe(path)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to overview for unknown paths', () => {
|
||||||
|
expect(parseLocation('/nope')).toEqual({
|
||||||
|
section: 'overview',
|
||||||
|
configTab: 'overview',
|
||||||
|
})
|
||||||
|
expect(parseLocation('/configuration/unknown')).toEqual({
|
||||||
|
section: 'overview',
|
||||||
|
configTab: 'overview',
|
||||||
|
})
|
||||||
|
expect(parseLocation('/vms/extra')).toEqual({
|
||||||
|
section: 'overview',
|
||||||
|
configTab: 'overview',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats trailing slashes as the same path', () => {
|
||||||
|
expect(parseLocation('/configuration/groups/')).toEqual({
|
||||||
|
section: 'configuration',
|
||||||
|
configTab: 'groups',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
export type SectionId = 'overview' | 'vms' | 'containers' | 'configuration'
|
||||||
|
|
||||||
|
export type ConfigTabId =
|
||||||
|
| 'overview'
|
||||||
|
| 'users'
|
||||||
|
| 'roles'
|
||||||
|
| 'groups'
|
||||||
|
| 'security'
|
||||||
|
| 'integrations'
|
||||||
|
| 'network'
|
||||||
|
| 'advanced'
|
||||||
|
|
||||||
|
export type AppLocation = {
|
||||||
|
section: SectionId
|
||||||
|
configTab: ConfigTabId
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONFIG_TAB_IDS: ReadonlySet<string> = new Set([
|
||||||
|
'overview',
|
||||||
|
'users',
|
||||||
|
'roles',
|
||||||
|
'groups',
|
||||||
|
'security',
|
||||||
|
'integrations',
|
||||||
|
'network',
|
||||||
|
'advanced',
|
||||||
|
])
|
||||||
|
|
||||||
|
const DEFAULT_LOCATION: AppLocation = {
|
||||||
|
section: 'overview',
|
||||||
|
configTab: 'overview',
|
||||||
|
}
|
||||||
|
|
||||||
|
function isConfigTabId(value: string): value is ConfigTabId {
|
||||||
|
return CONFIG_TAB_IDS.has(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a pathname into section + config tab. Unknown paths fall back to Overview. */
|
||||||
|
export function parseLocation(pathname: string): AppLocation {
|
||||||
|
const normalized = pathname.replace(/\/+$/, '') || '/'
|
||||||
|
const segments = normalized.split('/').filter(Boolean)
|
||||||
|
|
||||||
|
if (segments.length === 0) {
|
||||||
|
return { ...DEFAULT_LOCATION }
|
||||||
|
}
|
||||||
|
|
||||||
|
const [first, second] = segments
|
||||||
|
|
||||||
|
if (first === 'vms' && segments.length === 1) {
|
||||||
|
return { section: 'vms', configTab: 'overview' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (first === 'containers' && segments.length === 1) {
|
||||||
|
return { section: 'containers', configTab: 'overview' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (first === 'configuration') {
|
||||||
|
if (segments.length === 1) {
|
||||||
|
return { section: 'configuration', configTab: 'overview' }
|
||||||
|
}
|
||||||
|
if (segments.length === 2 && isConfigTabId(second)) {
|
||||||
|
return { section: 'configuration', configTab: second }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...DEFAULT_LOCATION }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the pathname for a section (and optional config tab). */
|
||||||
|
export function pathFor(
|
||||||
|
section: SectionId,
|
||||||
|
configTab: ConfigTabId = 'overview',
|
||||||
|
): string {
|
||||||
|
if (section === 'overview') {
|
||||||
|
return '/'
|
||||||
|
}
|
||||||
|
if (section === 'vms') {
|
||||||
|
return '/vms'
|
||||||
|
}
|
||||||
|
if (section === 'containers') {
|
||||||
|
return '/containers'
|
||||||
|
}
|
||||||
|
if (configTab === 'overview') {
|
||||||
|
return '/configuration'
|
||||||
|
}
|
||||||
|
return `/configuration/${configTab}`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LocationListener = (pathname: string) => void
|
||||||
|
|
||||||
|
const locationListeners = new Set<LocationListener>()
|
||||||
|
|
||||||
|
function notifyLocationListeners(pathname: string) {
|
||||||
|
for (const listener of locationListeners) {
|
||||||
|
listener(pathname)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Push a new history entry and notify subscribers (no document reload). */
|
||||||
|
export function navigateTo(path: string): void {
|
||||||
|
if (window.location.pathname === path) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
window.history.pushState(null, '', path)
|
||||||
|
notifyLocationListeners(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe to pathname changes from pushState navigate and browser popstate. */
|
||||||
|
export function subscribeToLocation(listener: LocationListener): () => void {
|
||||||
|
locationListeners.add(listener)
|
||||||
|
|
||||||
|
function handlePopState() {
|
||||||
|
listener(window.location.pathname)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('popstate', handlePopState)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
locationListeners.delete(listener)
|
||||||
|
window.removeEventListener('popstate', handlePopState)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user