From 63deb02d39085302a6ce120f6712bd13b0ff7e81 Mon Sep 17 00:00:00 2001 From: squid Date: Thu, 16 Jul 2026 22:00:02 +0200 Subject: [PATCH] More webui layout, navigation, preparations of user/roles/groups/security --- service/cmd/server/main.go | 2 +- service/internal/api/groups_handlers.go | 184 +++++++++++ service/internal/api/groups_handlers_test.go | 201 ++++++++++++ service/internal/api/handlers_test.go | 9 +- service/internal/api/router.go | 5 +- service/internal/settings/types.go | 19 ++ webui/src/App.css | 167 +++++++++- webui/src/App.test.tsx | 105 ++++++- webui/src/App.tsx | 313 +++++++++++++++++-- webui/src/api/client.test.ts | 143 ++++++++- webui/src/api/client.ts | 56 ++++ webui/src/navigation.test.ts | 57 ++++ webui/src/navigation.ts | 122 ++++++++ 13 files changed, 1352 insertions(+), 31 deletions(-) create mode 100644 service/internal/api/groups_handlers.go create mode 100644 service/internal/api/groups_handlers_test.go create mode 100644 webui/src/navigation.test.ts create mode 100644 webui/src/navigation.ts diff --git a/service/cmd/server/main.go b/service/cmd/server/main.go index 69fe406..66db61e 100644 --- a/service/cmd/server/main.go +++ b/service/cmd/server/main.go @@ -36,7 +36,7 @@ func main() { server := &http.Server{ Addr: ":" + port, - Handler: api.NewRouter(), + Handler: api.NewRouter(configDir), ReadHeaderTimeout: 5 * time.Second, } diff --git a/service/internal/api/groups_handlers.go b/service/internal/api/groups_handlers.go new file mode 100644 index 0000000..566b4c9 --- /dev/null +++ b/service/internal/api/groups_handlers.go @@ -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 +} + diff --git a/service/internal/api/groups_handlers_test.go b/service/internal/api/groups_handlers_test.go new file mode 100644 index 0000000..23511c1 --- /dev/null +++ b/service/internal/api/groups_handlers_test.go @@ -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") + } +} + diff --git a/service/internal/api/handlers_test.go b/service/internal/api/handlers_test.go index 087b9b6..0b2d824 100644 --- a/service/internal/api/handlers_test.go +++ b/service/internal/api/handlers_test.go @@ -10,10 +10,11 @@ import ( func TestHealthHandler(t *testing.T) { t.Parallel() + configDir := t.TempDir() request := httptest.NewRequest(http.MethodGet, "/health", nil) recorder := httptest.NewRecorder() - NewRouter().ServeHTTP(recorder, request) + NewRouter(configDir).ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { 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) { t.Parallel() + configDir := t.TempDir() request := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil) recorder := httptest.NewRecorder() - NewRouter().ServeHTTP(recorder, request) + NewRouter(configDir).ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { 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) { t.Parallel() + configDir := t.TempDir() request := httptest.NewRequest(http.MethodOptions, "/health", nil) recorder := httptest.NewRecorder() - NewRouter().ServeHTTP(recorder, request) + NewRouter(configDir).ServeHTTP(recorder, request) if recorder.Code != http.StatusNoContent { t.Fatalf("expected status %d, got %d", http.StatusNoContent, recorder.Code) diff --git a/service/internal/api/router.go b/service/internal/api/router.go index c405688..df947f2 100644 --- a/service/internal/api/router.go +++ b/service/internal/api/router.go @@ -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.HandleFunc("GET /health", HealthHandler) 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) } diff --git a/service/internal/settings/types.go b/service/internal/settings/types.go index 6fa3091..1026867 100644 --- a/service/internal/settings/types.go +++ b/service/internal/settings/types.go @@ -1,8 +1,27 @@ 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. type Settings struct { LogLevel string `json:"log_level"` + Groups []Group `json:"groups"` } // Secrets holds sensitive configuration encrypted at rest. diff --git a/webui/src/App.css b/webui/src/App.css index 4b7f6f4..daf2c30 100644 --- a/webui/src/App.css +++ b/webui/src/App.css @@ -120,12 +120,14 @@ display: flex; flex-direction: column; gap: 1.25rem; - max-width: 40rem; + width: 100%; + max-width: none; } .config-tabs { display: flex; flex-wrap: wrap; + width: 100%; gap: 0.15rem 0.25rem; border-bottom: 1px solid var(--ctp-surface1); } @@ -162,6 +164,7 @@ .config-tab-panel { min-width: 0; + max-width: 40rem; } .config-field { @@ -210,6 +213,167 @@ 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 { display: flex; flex-wrap: wrap; @@ -217,6 +381,7 @@ gap: 0.6rem; padding-top: 1rem; border-top: 1px solid var(--ctp-surface1); + max-width: 40rem; } .config-action-revert, diff --git a/webui/src/App.test.tsx b/webui/src/App.test.tsx index cd74c63..428c2d2 100644 --- a/webui/src/App.test.tsx +++ b/webui/src/App.test.tsx @@ -7,12 +7,37 @@ import { THEME_STORAGE_KEY } from './theme' function stubFetchOk() { vi.stubGlobal( 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - service: 'clustercanvas', - version: '0.1.0', - }), + vi.fn().mockImplementation((input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input.toString() + + if (url.includes('/health')) { + 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(() => { localStorage.clear() delete document.documentElement.dataset.theme + window.history.replaceState(null, '', '/') stubMatchMedia(false) }) afterEach(() => { localStorage.clear() delete document.documentElement.dataset.theme + window.history.replaceState(null, '', '/') vi.unstubAllGlobals() vi.restoreAllMocks() }) @@ -85,6 +112,7 @@ describe('App', () => { expect( screen.getByRole('heading', { name: 'VMs', level: 2 }), ).toBeInTheDocument() + expect(window.location.pathname).toBe('/vms') await user.click(screen.getByRole('button', { name: 'Configuration' })) expect( @@ -95,6 +123,39 @@ describe('App', () => { 'true', ) 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() + + 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() + + 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 () => { @@ -108,6 +169,7 @@ describe('App', () => { for (const label of [ 'Overview', 'Users', + 'Roles', 'Groups', 'Security', 'Integrations', @@ -117,6 +179,37 @@ describe('App', () => { 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' })) expect(screen.getByRole('tab', { name: 'Security' })).toHaveAttribute( 'aria-selected', diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 7f9a9dd..8a79351 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -1,5 +1,20 @@ 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 { applyThemeFromPreference, getSystemPrefersDark, @@ -9,8 +24,6 @@ import { } from './theme' import './App.css' -type SectionId = 'overview' | 'vms' | 'containers' | 'configuration' - const NAV_ITEMS: ReadonlyArray<{ id: SectionId; label: string }> = [ { id: 'overview', label: 'Overview' }, { id: 'vms', label: 'VMs' }, @@ -24,18 +37,10 @@ const SECTION_TITLES: Record = { configuration: 'Configuration', } -type ConfigTabId = - | 'overview' - | 'users' - | 'groups' - | 'security' - | 'integrations' - | 'network' - | 'advanced' - const CONFIG_TABS: ReadonlyArray<{ id: ConfigTabId; label: string }> = [ { id: 'overview', label: 'Overview' }, { id: 'users', label: 'Users' }, + { id: 'roles', label: 'Roles' }, { id: 'groups', label: 'Groups' }, { id: 'security', label: 'Security' }, { id: 'integrations', label: 'Integrations' }, @@ -43,6 +48,17 @@ const CONFIG_TABS: ReadonlyArray<{ id: ConfigTabId; label: string }> = [ { 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() { return ( >([]) + const [isLoadingGroups, setIsLoadingGroups] = useState(true) + const [groupsLoadError, setGroupsLoadError] = useState(null) + + const [draftGroupName, setDraftGroupName] = useState('') + const [draftScopeKind, setDraftScopeKind] = useState('node') + const [draftScopeName, setDraftScopeName] = useState('') + const [draftPermissions, setDraftPermissions] = useState([]) + const [submitError, setSubmitError] = useState(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 ( +
+
+

Configured groups

+ {isLoadingGroups ? ( +

Loading…

+ ) : groupsLoadError ? ( +

+ {groupsLoadError} +

+ ) : groups.length === 0 ? ( +

No groups configured yet.

+ ) : ( +
    + {groups.map((group) => ( +
  • +
    +
    {group.name}
    +
    + Scope: {group.scope_kind}: {group.scope_name} +
    +
    + Permissions: {group.permissions.join(', ')} +
    +
    + +
  • + ))} +
+ )} +
+ +
+

Create or update a group

+ + {submitError ? ( +

+ {submitError} +

+ ) : null} + +
+ + { + setDraftGroupName(event.target.value) + }} + /> +
+ +
+ + +
+ +
+ + { + setDraftScopeName(event.target.value) + }} + /> +
+ +
+ +
+ {DEFAULT_PERMISSIONS.map((permission) => { + const checked = draftPermissions.includes(permission) + return ( + + ) + })} +
+
+ + +
+
+ ) +} + function ConfigTabPanelContent({ activeConfigTab, draftThemePreference, @@ -140,6 +363,27 @@ function ConfigTabPanelContent({ ) } + if (activeConfigTab === 'roles') { + return ( +
+

+ Built-in permissions that roles can grant. +

+
    + {DEFAULT_PERMISSIONS.map((permission) => ( +
  • + {permission} +
  • + ))} +
+
+ ) + } + + if (activeConfigTab === 'groups') { + return + } + if (activeConfigTab === 'security') { return (

@@ -153,14 +397,16 @@ function ConfigTabPanelContent({ } function ConfigurationPanel({ + activeConfigTab, + onConfigTabChange, themePreference, onThemePreferenceChange, }: { + activeConfigTab: ConfigTabId + onConfigTabChange: (tab: ConfigTabId) => void themePreference: ThemePreference onThemePreferenceChange: (preference: ThemePreference) => void }) { - const [activeConfigTab, setActiveConfigTab] = - useState('overview') const [draftThemePreference, setDraftThemePreference] = useState(themePreference) @@ -187,7 +433,7 @@ function ConfigurationPanel({ aria-selected={isSelected} aria-controls="config-tab-panel" tabIndex={isSelected ? 0 : -1} - onClick={() => setActiveConfigTab(tab.id)} + onClick={() => onConfigTabChange(tab.id)} > {tab.label} @@ -231,10 +477,24 @@ function ConfigurationPanel({ } function App() { - const [activeSection, setActiveSection] = useState('overview') + const initialLocation = parseLocation(window.location.pathname) + const [activeSection, setActiveSection] = useState( + initialLocation.section, + ) + const [activeConfigTab, setActiveConfigTab] = useState( + initialLocation.configTab, + ) const [backendVersion, setBackendVersion] = useState('…') const { themePreference, updateThemePreference } = useThemePreference() + useEffect(() => { + return subscribeToLocation((pathname) => { + const location = parseLocation(pathname) + setActiveSection(location.section) + setActiveConfigTab(location.configTab) + }) + }, []) + useEffect(() => { 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 (