mirror of
https://github.com/kernelkit/infix.git
synced 2026-07-22 01:13:00 +02:00
webui: add Configure mode with System, Users, and NACM pages
Implements the configure accordion section with a candidate datastore workflow: opening Configure copies running → candidate (Enter), each card saves directly to candidate, Apply copies candidate → running atomically, and Abort discards by resetting candidate from running. - RESTCONF client: add Put, Patch, Delete, GetDatastore, PutDatastore, CopyDatastore, and a shared doRequest/writeJSON helper - Handlers: ConfigureHandler (enter/apply/abort), ConfigureSystemHandler (hostname, clock, NTP, DNS, motd, editor), ConfigureUsersHandler (add/delete user, shell, password, SSH keys, NACM group membership) - Status > NACM page: read-only group permission matrix derived from ietf-netconf-acm groups + rule-list mappings - Sticky Apply/Abort toolbar with custom confirm dialog; server returns HX-Redirect:/ so HTMX does a full-page reload, keeping sidebar in sync - Accordion persistence fixed: Configure excluded from localStorage so it never auto-reopens after Abort/Apply; restore no longer closes an accordion that contains the active page - Password hashing by shelling out to mkpasswd(1) Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/kernelkit/webui/internal/restconf"
|
||||
)
|
||||
|
||||
// ConfigureHandler manages the candidate datastore lifecycle.
|
||||
type ConfigureHandler struct {
|
||||
RC restconf.Fetcher
|
||||
}
|
||||
|
||||
// Enter copies running → candidate, initialising a fresh edit session.
|
||||
// Called when the user opens the Configure accordion.
|
||||
// POST /configure/enter
|
||||
func (h *ConfigureHandler) Enter(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.RC.CopyDatastore(r.Context(), "running", "candidate"); err != nil {
|
||||
log.Printf("configure enter: %v", err)
|
||||
http.Error(w, "Could not initialise candidate datastore", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// Apply copies candidate → running, activating all staged changes atomically.
|
||||
// POST /configure/apply
|
||||
func (h *ConfigureHandler) Apply(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.RC.CopyDatastore(r.Context(), "candidate", "running"); err != nil {
|
||||
log.Printf("configure apply: %v", err)
|
||||
http.Error(w, "Could not apply configuration: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
w.Header().Set("HX-Redirect", "/")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// Abort copies running → candidate, discarding all staged changes.
|
||||
// POST /configure/abort
|
||||
func (h *ConfigureHandler) Abort(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.RC.CopyDatastore(r.Context(), "running", "candidate"); err != nil {
|
||||
log.Printf("configure abort: %v", err)
|
||||
// Best-effort reset; redirect regardless so the user can get out.
|
||||
}
|
||||
w.Header().Set("HX-Redirect", "/")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ApplyAndSave copies candidate → running then running → startup in one step.
|
||||
// POST /configure/apply-and-save
|
||||
func (h *ConfigureHandler) ApplyAndSave(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.RC.CopyDatastore(r.Context(), "candidate", "running"); err != nil {
|
||||
log.Printf("configure apply-and-save: %v", err)
|
||||
http.Error(w, "Could not apply configuration: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if err := h.RC.CopyDatastore(r.Context(), "running", "startup"); err != nil {
|
||||
log.Printf("configure apply-and-save (save): %v", err)
|
||||
http.Error(w, "Could not save configuration: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
w.Header().Set("HX-Redirect", "/")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/kernelkit/webui/internal/restconf"
|
||||
)
|
||||
|
||||
// ─── RESTCONF JSON types (candidate datastore) ────────────────────────────────
|
||||
|
||||
type cfgSystemWrapper struct {
|
||||
System cfgSystemJSON `json:"ietf-system:system"`
|
||||
}
|
||||
|
||||
type cfgSystemJSON struct {
|
||||
Contact string `json:"contact"`
|
||||
Hostname string `json:"hostname"`
|
||||
Location string `json:"location"`
|
||||
Clock cfgClockJSON `json:"clock"`
|
||||
NTP cfgNTPJSON `json:"ntp"`
|
||||
DNS cfgDNSJSON `json:"dns-resolver"`
|
||||
MotdBanner []byte `json:"infix-system:motd-banner,omitempty"`
|
||||
TextEditor string `json:"infix-system:text-editor,omitempty"`
|
||||
}
|
||||
|
||||
type cfgClockJSON struct {
|
||||
TimezoneName string `json:"timezone-name"`
|
||||
}
|
||||
|
||||
type cfgNTPJSON struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Servers []cfgNTPServerJSON `json:"server"`
|
||||
}
|
||||
|
||||
type cfgNTPServerJSON struct {
|
||||
Name string `json:"name"`
|
||||
UDP cfgNTPUDPJSON `json:"udp"`
|
||||
Prefer bool `json:"prefer"`
|
||||
}
|
||||
|
||||
type cfgNTPUDPJSON struct {
|
||||
Address string `json:"address"`
|
||||
Port uint16 `json:"port,omitempty"`
|
||||
}
|
||||
|
||||
type cfgDNSJSON struct {
|
||||
Search []string `json:"search"`
|
||||
Servers []cfgDNSServerJSON `json:"server"`
|
||||
}
|
||||
|
||||
type cfgDNSServerJSON struct {
|
||||
Name string `json:"name"`
|
||||
UDPAndTCP cfgDNSAddrJSON `json:"udp-and-tcp"`
|
||||
}
|
||||
|
||||
type cfgDNSAddrJSON struct {
|
||||
Address string `json:"address"`
|
||||
Port uint16 `json:"port,omitempty"`
|
||||
}
|
||||
|
||||
// ─── Template data ────────────────────────────────────────────────────────────
|
||||
|
||||
type cfgSystemPageData struct {
|
||||
PageData
|
||||
Error string
|
||||
Hostname string
|
||||
Contact string
|
||||
Location string
|
||||
Timezone string
|
||||
NTP cfgNTPJSON
|
||||
DNS cfgDNSJSON
|
||||
MotdBanner string // decoded from YANG binary
|
||||
TextEditor string // e.g. "infix-system:emacs"
|
||||
}
|
||||
|
||||
// ─── Handler ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// ConfigureSystemHandler serves the Configure > System page.
|
||||
type ConfigureSystemHandler struct {
|
||||
Template *template.Template
|
||||
RC restconf.Fetcher
|
||||
}
|
||||
|
||||
const candidatePath = "/ds/ietf-datastores:candidate"
|
||||
|
||||
// Overview renders the Configure > System page reading from the candidate datastore.
|
||||
// GET /configure/system
|
||||
func (h *ConfigureSystemHandler) Overview(w http.ResponseWriter, r *http.Request) {
|
||||
data := cfgSystemPageData{
|
||||
PageData: newPageData(r, "configure-system", "Configure: System"),
|
||||
}
|
||||
|
||||
var raw cfgSystemWrapper
|
||||
if err := h.RC.Get(r.Context(), candidatePath+"/ietf-system:system", &raw); err != nil {
|
||||
var rcErr *restconf.Error
|
||||
if errors.As(err, &rcErr) && rcErr.StatusCode == http.StatusNotFound {
|
||||
// Candidate not initialised — read from running as fallback.
|
||||
if fallErr := h.RC.Get(r.Context(), "/data/ietf-system:system", &raw); fallErr != nil {
|
||||
var rcFall *restconf.Error
|
||||
if !errors.As(fallErr, &rcFall) || rcFall.StatusCode != http.StatusNotFound {
|
||||
log.Printf("configure system (running fallback): %v", fallErr)
|
||||
data.Error = "Could not read system configuration"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Printf("configure system: %v", err)
|
||||
data.Error = "Could not read candidate configuration"
|
||||
}
|
||||
}
|
||||
if data.Error == "" {
|
||||
s := raw.System
|
||||
data.Hostname = s.Hostname
|
||||
data.Contact = s.Contact
|
||||
data.Location = s.Location
|
||||
data.Timezone = s.Clock.TimezoneName
|
||||
data.NTP = s.NTP
|
||||
data.DNS = s.DNS
|
||||
data.MotdBanner = string(s.MotdBanner)
|
||||
data.TextEditor = s.TextEditor
|
||||
}
|
||||
|
||||
tmplName := "configure-system.html"
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
tmplName = "content"
|
||||
}
|
||||
if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil {
|
||||
log.Printf("template error: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// SaveIdentity patches hostname / contact / location to the candidate datastore.
|
||||
// POST /configure/system/identity
|
||||
func (h *ConfigureSystemHandler) SaveIdentity(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
body := map[string]any{
|
||||
"ietf-system:system": map[string]any{
|
||||
"hostname": r.FormValue("hostname"),
|
||||
"contact": r.FormValue("contact"),
|
||||
"location": r.FormValue("location"),
|
||||
},
|
||||
}
|
||||
if err := h.RC.Patch(r.Context(), candidatePath+"/ietf-system:system", body); err != nil {
|
||||
log.Printf("configure system identity: %v", err)
|
||||
renderSaveError(w, err)
|
||||
return
|
||||
}
|
||||
renderSaved(w, "Identity saved")
|
||||
}
|
||||
|
||||
// SaveClock patches the timezone to the candidate datastore.
|
||||
// POST /configure/system/clock
|
||||
func (h *ConfigureSystemHandler) SaveClock(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
body := map[string]any{
|
||||
"ietf-system:system": map[string]any{
|
||||
"clock": map[string]any{
|
||||
"timezone-name": r.FormValue("timezone"),
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := h.RC.Patch(r.Context(), candidatePath+"/ietf-system:system", body); err != nil {
|
||||
log.Printf("configure system clock: %v", err)
|
||||
renderSaveError(w, err)
|
||||
return
|
||||
}
|
||||
renderSaved(w, "Clock saved")
|
||||
}
|
||||
|
||||
// SaveNTP replaces the NTP server list in the candidate datastore.
|
||||
// PUT /configure/system/ntp
|
||||
func (h *ConfigureSystemHandler) SaveNTP(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
servers := parseNTPServers(r)
|
||||
ntp := map[string]any{"enabled": true}
|
||||
if len(servers) > 0 {
|
||||
ntp["server"] = servers
|
||||
}
|
||||
body := map[string]any{"ietf-system:ntp": ntp}
|
||||
if err := h.RC.Put(r.Context(), candidatePath+"/ietf-system:system/ntp", body); err != nil {
|
||||
log.Printf("configure system ntp: %v", err)
|
||||
renderSaveError(w, err)
|
||||
return
|
||||
}
|
||||
renderSaved(w, "NTP saved")
|
||||
}
|
||||
|
||||
// SaveDNS replaces the DNS resolver config in the candidate datastore.
|
||||
// PUT /configure/system/dns
|
||||
func (h *ConfigureSystemHandler) SaveDNS(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
search := parseSearchList(r)
|
||||
servers := parseDNSServers(r)
|
||||
|
||||
// Omit empty lists entirely — sending null for a YANG list is invalid.
|
||||
dnsResolver := map[string]any{}
|
||||
if len(search) > 0 {
|
||||
dnsResolver["search"] = search
|
||||
}
|
||||
if len(servers) > 0 {
|
||||
dnsResolver["server"] = servers
|
||||
}
|
||||
body := map[string]any{
|
||||
"ietf-system:dns-resolver": dnsResolver,
|
||||
}
|
||||
if err := h.RC.Put(r.Context(), candidatePath+"/ietf-system:system/dns-resolver", body); err != nil {
|
||||
log.Printf("configure system dns: %v", err)
|
||||
renderSaveError(w, err)
|
||||
return
|
||||
}
|
||||
renderSaved(w, "DNS saved")
|
||||
}
|
||||
|
||||
// ─── Form parsing helpers ─────────────────────────────────────────────────────
|
||||
|
||||
// parseNTPServers extracts NTP server entries from form values.
|
||||
// Fields: ntp_name_N, ntp_addr_N, ntp_port_N, ntp_prefer_N (checkbox).
|
||||
func parseNTPServers(r *http.Request) []cfgNTPServerJSON {
|
||||
var servers []cfgNTPServerJSON
|
||||
for i := 0; ; i++ {
|
||||
name := strings.TrimSpace(r.FormValue("ntp_name_" + strconv.Itoa(i)))
|
||||
if name == "" {
|
||||
break
|
||||
}
|
||||
addr := strings.TrimSpace(r.FormValue("ntp_addr_" + strconv.Itoa(i)))
|
||||
port, _ := strconv.ParseUint(r.FormValue("ntp_port_"+strconv.Itoa(i)), 10, 16)
|
||||
prefer := r.FormValue("ntp_prefer_"+strconv.Itoa(i)) == "on"
|
||||
srv := cfgNTPServerJSON{
|
||||
Name: name,
|
||||
UDP: cfgNTPUDPJSON{Address: addr, Port: uint16(port)},
|
||||
Prefer: prefer,
|
||||
}
|
||||
servers = append(servers, srv)
|
||||
}
|
||||
return servers
|
||||
}
|
||||
|
||||
// parseSearchList extracts DNS search domains from form values.
|
||||
// Fields: dns_search_N (one per domain).
|
||||
func parseSearchList(r *http.Request) []string {
|
||||
var search []string
|
||||
for i := 0; ; i++ {
|
||||
v := strings.TrimSpace(r.FormValue("dns_search_" + strconv.Itoa(i)))
|
||||
if v == "" {
|
||||
break
|
||||
}
|
||||
search = append(search, v)
|
||||
}
|
||||
return search
|
||||
}
|
||||
|
||||
// parseDNSServers extracts DNS server entries from form values.
|
||||
// Fields: dns_name_N, dns_addr_N, dns_port_N.
|
||||
func parseDNSServers(r *http.Request) []cfgDNSServerJSON {
|
||||
var servers []cfgDNSServerJSON
|
||||
for i := 0; ; i++ {
|
||||
name := strings.TrimSpace(r.FormValue("dns_name_" + strconv.Itoa(i)))
|
||||
if name == "" {
|
||||
break
|
||||
}
|
||||
addr := strings.TrimSpace(r.FormValue("dns_addr_" + strconv.Itoa(i)))
|
||||
port, _ := strconv.ParseUint(r.FormValue("dns_port_"+strconv.Itoa(i)), 10, 16)
|
||||
srv := cfgDNSServerJSON{
|
||||
Name: name,
|
||||
UDPAndTCP: cfgDNSAddrJSON{Address: addr, Port: uint16(port)},
|
||||
}
|
||||
servers = append(servers, srv)
|
||||
}
|
||||
return servers
|
||||
}
|
||||
|
||||
// SavePreferences patches infix-system augmented fields (motd-banner, text-editor).
|
||||
// POST /configure/system/preferences
|
||||
func (h *ConfigureSystemHandler) SavePreferences(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
sysPatch := map[string]any{}
|
||||
if motd := r.FormValue("motd_banner"); motd != "" {
|
||||
sysPatch["infix-system:motd-banner"] = []byte(motd)
|
||||
}
|
||||
if editor := r.FormValue("text_editor"); editor != "" {
|
||||
sysPatch["infix-system:text-editor"] = editor
|
||||
}
|
||||
if len(sysPatch) == 0 {
|
||||
renderSaved(w, "Preferences saved")
|
||||
return
|
||||
}
|
||||
|
||||
body := map[string]any{"ietf-system:system": sysPatch}
|
||||
if err := h.RC.Patch(r.Context(), candidatePath+"/ietf-system:system", body); err != nil {
|
||||
log.Printf("configure system preferences: %v", err)
|
||||
renderSaveError(w, err)
|
||||
return
|
||||
}
|
||||
renderSaved(w, "Preferences saved")
|
||||
}
|
||||
|
||||
// ─── Response helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
// renderSaved writes a success indicator for HTMX to swap into the Save button.
|
||||
func renderSaved(w http.ResponseWriter, msg string) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Header().Set("HX-Trigger", `{"cfgSaved":"`+msg+`"}`)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// renderSaveError writes an inline error for HTMX. HX-Trigger ensures forms with
|
||||
// hx-swap="none" still receive the cfgError event (body swap alone would be silenced).
|
||||
// RESTCONF errors surface their server-side Message; all other errors fall back to
|
||||
// err.Error() so handler-level validation messages reach the user instead of being
|
||||
// flattened to a generic "Save failed".
|
||||
func renderSaveError(w http.ResponseWriter, err error) {
|
||||
msg := "Save failed"
|
||||
if re, ok := err.(*restconf.Error); ok && re.Message != "" {
|
||||
msg = re.Message
|
||||
} else if err != nil && err.Error() != "" {
|
||||
msg = err.Error()
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
// Encode the message safely for HTML output.
|
||||
b, _ := json.Marshal(msg)
|
||||
_ = b // used below via template-escaped string
|
||||
w.Write([]byte(`<span class="cfg-save-error">` + template.HTMLEscapeString(msg) + `</span>`))
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/kernelkit/webui/internal/restconf"
|
||||
"github.com/kernelkit/webui/internal/schema"
|
||||
)
|
||||
|
||||
// ─── RESTCONF JSON types ──────────────────────────────────────────────────────
|
||||
|
||||
// cfgAuthWrapper reads the authentication container via the full system path,
|
||||
// which avoids sub-path encoding ambiguities and matches what configure-system uses.
|
||||
type cfgAuthWrapper struct {
|
||||
System struct {
|
||||
Auth cfgAuthJSON `json:"authentication"`
|
||||
} `json:"ietf-system:system"`
|
||||
}
|
||||
|
||||
type cfgAuthJSON struct {
|
||||
Users []cfgUserJSON `json:"user"`
|
||||
}
|
||||
|
||||
type cfgUserJSON struct {
|
||||
Name string `json:"name"`
|
||||
Password string `json:"password,omitempty"`
|
||||
Shell string `json:"infix-system:shell,omitempty"`
|
||||
AuthorizedKeys []cfgKeyJSON `json:"authorized-key,omitempty"`
|
||||
}
|
||||
|
||||
type cfgKeyJSON struct {
|
||||
Name string `json:"name"`
|
||||
Algorithm string `json:"algorithm"`
|
||||
KeyData []byte `json:"key-data"`
|
||||
}
|
||||
|
||||
// ─── Display helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
type cfgUserDisplay struct {
|
||||
cfgUserJSON
|
||||
ShellLabel string
|
||||
KeyCount int
|
||||
}
|
||||
|
||||
type cfgGroupDisplay struct {
|
||||
nacmGroupJSON
|
||||
MembersSummary string
|
||||
Available []string // users not currently in this group
|
||||
}
|
||||
|
||||
// ─── Template data ────────────────────────────────────────────────────────────
|
||||
|
||||
type cfgUsersPageData struct {
|
||||
PageData
|
||||
Loading bool
|
||||
Users []cfgUserDisplay
|
||||
Groups []cfgGroupDisplay
|
||||
Error string
|
||||
ShellOptions []schema.IdentityOption
|
||||
}
|
||||
|
||||
// ─── Handler ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// ConfigureUsersHandler serves the Configure > Users page.
|
||||
type ConfigureUsersHandler struct {
|
||||
Template *template.Template
|
||||
RC restconf.Fetcher
|
||||
Schema *schema.Cache
|
||||
}
|
||||
|
||||
const authPath = candidatePath + "/ietf-system:system/authentication"
|
||||
const nacmGroupsPath = candidatePath + "/ietf-netconf-acm:nacm/groups"
|
||||
|
||||
// Overview renders the Configure > Users page reading from the candidate.
|
||||
// GET /configure/users
|
||||
func (h *ConfigureUsersHandler) Overview(w http.ResponseWriter, r *http.Request) {
|
||||
data := cfgUsersPageData{
|
||||
PageData: newPageData(r, "configure-users", "Configure: Users & Groups"),
|
||||
}
|
||||
|
||||
// Read via the full system path (same as configure-system) to avoid
|
||||
// sub-path encoding issues. Fall back to running if candidate is empty.
|
||||
sysPath := candidatePath + "/ietf-system:system"
|
||||
var raw cfgAuthWrapper
|
||||
if err := h.RC.Get(r.Context(), sysPath, &raw); err != nil {
|
||||
if !restconf.IsNotFound(err) {
|
||||
log.Printf("configure users: %v", err)
|
||||
data.Error = "Could not read user configuration"
|
||||
} else if fallErr := h.RC.Get(r.Context(), "/data/ietf-system:system", &raw); fallErr != nil && !restconf.IsNotFound(fallErr) {
|
||||
// Candidate not initialised — fall back to running.
|
||||
log.Printf("configure users (running fallback): %v", fallErr)
|
||||
data.Error = "Could not read user configuration"
|
||||
}
|
||||
}
|
||||
const shellPath = "/ietf-system:system/authentication/user/infix-system:shell"
|
||||
mgr := h.Schema.Manager()
|
||||
data.Loading = mgr == nil
|
||||
if mgr != nil {
|
||||
data.ShellOptions = schema.OptionsFor(mgr, shellPath)
|
||||
}
|
||||
for _, u := range raw.System.Auth.Users {
|
||||
data.Users = append(data.Users, cfgUserDisplay{
|
||||
cfgUserJSON: u,
|
||||
ShellLabel: schema.StripModulePrefix(u.Shell),
|
||||
KeyCount: len(u.AuthorizedKeys),
|
||||
})
|
||||
}
|
||||
|
||||
groups, err := h.fetchAllGroups(r.Context())
|
||||
if err != nil {
|
||||
log.Printf("configure users groups: %v", err)
|
||||
}
|
||||
allNames := make([]string, 0, len(data.Users))
|
||||
for _, u := range data.Users {
|
||||
allNames = append(allNames, u.Name)
|
||||
}
|
||||
for _, g := range groups {
|
||||
memberSet := make(map[string]bool, len(g.UserNames))
|
||||
for _, u := range g.UserNames {
|
||||
memberSet[u] = true
|
||||
}
|
||||
avail := make([]string, 0)
|
||||
for _, u := range allNames {
|
||||
if !memberSet[u] {
|
||||
avail = append(avail, u)
|
||||
}
|
||||
}
|
||||
summary := strings.Join(g.UserNames, ", ")
|
||||
if summary == "" {
|
||||
summary = "(none)"
|
||||
}
|
||||
data.Groups = append(data.Groups, cfgGroupDisplay{
|
||||
nacmGroupJSON: g,
|
||||
MembersSummary: summary,
|
||||
Available: avail,
|
||||
})
|
||||
}
|
||||
|
||||
tmplName := "configure-users.html"
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
tmplName = "content"
|
||||
}
|
||||
if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil {
|
||||
log.Printf("template error: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// AddUser creates a new user in the candidate datastore.
|
||||
// POST /configure/users
|
||||
func (h *ConfigureUsersHandler) AddUser(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(r.FormValue("username"))
|
||||
password := r.FormValue("password")
|
||||
shell := r.FormValue("shell")
|
||||
if name == "" {
|
||||
renderSaveError(w, fmt.Errorf("username is required"))
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := HashPassword(password)
|
||||
if err != nil {
|
||||
log.Printf("configure users add: hash: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
user := map[string]any{
|
||||
"ietf-system:user": []map[string]any{{
|
||||
"name": name,
|
||||
"password": hash,
|
||||
"infix-system:shell": shell,
|
||||
}},
|
||||
}
|
||||
path := authPath + "/user=" + url.PathEscape(name)
|
||||
if err := h.RC.Put(r.Context(), path, user); err != nil {
|
||||
log.Printf("configure users add %q: %v", name, err)
|
||||
renderSaveError(w, err)
|
||||
return
|
||||
}
|
||||
renderSavedRedirect(w, "User added", "/configure/users")
|
||||
}
|
||||
|
||||
// DeleteUser removes a user from the candidate datastore.
|
||||
// DELETE /configure/users/{name}
|
||||
func (h *ConfigureUsersHandler) DeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
path := authPath + "/user=" + url.PathEscape(name)
|
||||
if err := h.RC.Delete(r.Context(), path); err != nil {
|
||||
log.Printf("configure users delete %q: %v", name, err)
|
||||
renderSaveError(w, err)
|
||||
return
|
||||
}
|
||||
renderSavedRedirect(w, "User deleted", "/configure/users")
|
||||
}
|
||||
|
||||
// UpdateShell changes a user's login shell in the candidate datastore.
|
||||
// POST /configure/users/{name}/shell
|
||||
func (h *ConfigureUsersHandler) UpdateShell(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := r.PathValue("name")
|
||||
shell := r.FormValue("shell")
|
||||
body := map[string]any{
|
||||
"ietf-system:user": []map[string]any{{
|
||||
"name": name,
|
||||
"infix-system:shell": shell,
|
||||
}},
|
||||
}
|
||||
path := authPath + "/user=" + url.PathEscape(name)
|
||||
if err := h.RC.Patch(r.Context(), path, body); err != nil {
|
||||
log.Printf("configure users shell %q: %v", name, err)
|
||||
renderSaveError(w, err)
|
||||
return
|
||||
}
|
||||
renderSaved(w, "Shell updated")
|
||||
}
|
||||
|
||||
// ChangePassword sets a new hashed password for a user in the candidate.
|
||||
// POST /configure/users/{name}/password
|
||||
func (h *ConfigureUsersHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := r.PathValue("name")
|
||||
password := r.FormValue("password")
|
||||
if password == "" {
|
||||
renderSaveError(w, fmt.Errorf("password cannot be empty"))
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := HashPassword(password)
|
||||
if err != nil {
|
||||
log.Printf("configure users password %q: hash: %v", name, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
body := map[string]any{
|
||||
"ietf-system:user": []map[string]any{{
|
||||
"name": name,
|
||||
"password": hash,
|
||||
}},
|
||||
}
|
||||
path := authPath + "/user=" + url.PathEscape(name)
|
||||
if err := h.RC.Patch(r.Context(), path, body); err != nil {
|
||||
log.Printf("configure users password %q: %v", name, err)
|
||||
renderSaveError(w, err)
|
||||
return
|
||||
}
|
||||
renderSaved(w, "Password changed")
|
||||
}
|
||||
|
||||
// AddKey adds an SSH authorized key for a user in the candidate.
|
||||
// POST /configure/users/{name}/keys
|
||||
func (h *ConfigureUsersHandler) AddKey(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := r.PathValue("name")
|
||||
keyName := strings.TrimSpace(r.FormValue("key_name"))
|
||||
keyLine := strings.TrimSpace(r.FormValue("key_data"))
|
||||
|
||||
if keyName == "" || keyLine == "" {
|
||||
renderSaveError(w, fmt.Errorf("key name and public key are required"))
|
||||
return
|
||||
}
|
||||
|
||||
// Parse "algorithm base64data [comment]" from an OpenSSH public key line.
|
||||
parts := strings.Fields(keyLine)
|
||||
if len(parts) < 2 {
|
||||
renderSaveError(w, fmt.Errorf("invalid SSH public key format"))
|
||||
return
|
||||
}
|
||||
algorithm := parts[0]
|
||||
keyBytes, err := base64.StdEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
renderSaveError(w, fmt.Errorf("invalid SSH key data: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
// PATCH at the system root so libyang has full ancestor-key context.
|
||||
// Patching at user=admin or authorized-key leaves libyang without the
|
||||
// parent list-key context and produces "List requires N keys" errors.
|
||||
body := map[string]any{
|
||||
"ietf-system:system": map[string]any{
|
||||
"authentication": map[string]any{
|
||||
"user": []map[string]any{{
|
||||
"name": name,
|
||||
"authorized-key": []map[string]any{{
|
||||
"name": keyName,
|
||||
"algorithm": algorithm,
|
||||
"key-data": keyBytes,
|
||||
}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
path := candidatePath + "/ietf-system:system"
|
||||
if err := h.RC.Patch(r.Context(), path, body); err != nil {
|
||||
log.Printf("configure users key add %q/%q: %v", name, keyName, err)
|
||||
renderSaveError(w, err)
|
||||
return
|
||||
}
|
||||
renderSavedRedirect(w, "SSH key added", "/configure/users")
|
||||
}
|
||||
|
||||
// DeleteKey removes an SSH authorized key for a user in the candidate.
|
||||
// DELETE /configure/users/{name}/keys/{keyname}
|
||||
//
|
||||
// Direct DELETE to the authorized-key path fails when the key name contains
|
||||
// characters like '@' that libyang interprets as module@revision syntax in path
|
||||
// predicates. Work around by GET + filter + PUT at the user level instead.
|
||||
func (h *ConfigureUsersHandler) DeleteKey(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
keyName := r.PathValue("keyname")
|
||||
|
||||
sysPath := candidatePath + "/ietf-system:system"
|
||||
var raw cfgAuthWrapper
|
||||
if err := h.RC.Get(r.Context(), sysPath, &raw); err != nil {
|
||||
log.Printf("configure users key delete %q/%q: GET: %v", name, keyName, err)
|
||||
renderSaveError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
var userEntry map[string]any
|
||||
for _, u := range raw.System.Auth.Users {
|
||||
if u.Name != name {
|
||||
continue
|
||||
}
|
||||
filteredKeys := make([]map[string]any, 0, len(u.AuthorizedKeys))
|
||||
found := false
|
||||
for _, k := range u.AuthorizedKeys {
|
||||
if k.Name == keyName {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
filteredKeys = append(filteredKeys, map[string]any{
|
||||
"name": k.Name,
|
||||
"algorithm": k.Algorithm,
|
||||
"key-data": k.KeyData,
|
||||
})
|
||||
}
|
||||
if !found {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
userEntry = map[string]any{
|
||||
"name": u.Name,
|
||||
"authorized-key": filteredKeys,
|
||||
}
|
||||
if u.Password != "" {
|
||||
userEntry["password"] = u.Password
|
||||
}
|
||||
if u.Shell != "" {
|
||||
userEntry["infix-system:shell"] = u.Shell
|
||||
}
|
||||
break
|
||||
}
|
||||
if userEntry == nil {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
// PUT at the user level replaces only this user's entry (including its key
|
||||
// list), which avoids the path-predicate Syntax error while not touching
|
||||
// other users or system config.
|
||||
putPath := authPath + "/user=" + url.PathEscape(name)
|
||||
body := map[string]any{
|
||||
"ietf-system:user": []map[string]any{userEntry},
|
||||
}
|
||||
if err := h.RC.Put(r.Context(), putPath, body); err != nil {
|
||||
log.Printf("configure users key delete %q/%q: PUT: %v", name, keyName, err)
|
||||
renderSaveError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// fetchAllGroups reads NACM groups from candidate, falling back to running on 404.
|
||||
func (h *ConfigureUsersHandler) fetchAllGroups(ctx context.Context) ([]nacmGroupJSON, error) {
|
||||
var raw nacmWrapper
|
||||
if err := h.RC.Get(ctx, candidatePath+"/ietf-netconf-acm:nacm", &raw); err != nil {
|
||||
if !restconf.IsNotFound(err) {
|
||||
return nil, err
|
||||
}
|
||||
if err2 := h.RC.Get(ctx, "/data/ietf-netconf-acm:nacm", &raw); err2 != nil && !restconf.IsNotFound(err2) {
|
||||
return nil, err2
|
||||
}
|
||||
}
|
||||
return raw.NACM.Groups.Group, nil
|
||||
}
|
||||
|
||||
// putGroupMembers overwrites the member list of a single NACM group.
|
||||
func (h *ConfigureUsersHandler) putGroupMembers(ctx context.Context, groupName string, members []string) error {
|
||||
body := map[string]any{
|
||||
"ietf-netconf-acm:group": []map[string]any{{
|
||||
"name": groupName,
|
||||
"user-name": members,
|
||||
}},
|
||||
}
|
||||
path := nacmGroupsPath + "/group=" + url.PathEscape(groupName)
|
||||
return h.RC.Put(ctx, path, body)
|
||||
}
|
||||
|
||||
// AddGroupMembers adds one or more users to a NACM group, moving them out of
|
||||
// any previous group to maintain the one-group-per-user invariant.
|
||||
// POST /configure/users/groups/{name}/members
|
||||
func (h *ConfigureUsersHandler) AddGroupMembers(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
groupName := r.PathValue("name")
|
||||
toAdd := r.Form["members"]
|
||||
if len(toAdd) == 0 {
|
||||
renderSavedRedirect(w, "No users selected", "/configure/users")
|
||||
return
|
||||
}
|
||||
|
||||
groups, err := h.fetchAllGroups(r.Context())
|
||||
if err != nil {
|
||||
renderSaveError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
toAddSet := make(map[string]bool, len(toAdd))
|
||||
for _, u := range toAdd {
|
||||
toAddSet[u] = true
|
||||
}
|
||||
|
||||
updates := make(map[string][]string)
|
||||
var current []string
|
||||
for _, g := range groups {
|
||||
if g.Name == groupName {
|
||||
current = g.UserNames
|
||||
continue
|
||||
}
|
||||
changed := false
|
||||
members := make([]string, 0, len(g.UserNames))
|
||||
for _, u := range g.UserNames {
|
||||
if toAddSet[u] {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
members = append(members, u)
|
||||
}
|
||||
if changed {
|
||||
updates[g.Name] = members
|
||||
}
|
||||
}
|
||||
|
||||
inTarget := make(map[string]bool, len(current))
|
||||
for _, u := range current {
|
||||
inTarget[u] = true
|
||||
}
|
||||
newTarget := append([]string{}, current...)
|
||||
for _, u := range toAdd {
|
||||
if !inTarget[u] {
|
||||
newTarget = append(newTarget, u)
|
||||
}
|
||||
}
|
||||
updates[groupName] = newTarget
|
||||
|
||||
for gName, members := range updates {
|
||||
if err := h.putGroupMembers(r.Context(), gName, members); err != nil {
|
||||
log.Printf("configure users groups add: write %q: %v", gName, err)
|
||||
renderSaveError(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
renderSavedRedirect(w, "Members updated", "/configure/users")
|
||||
}
|
||||
|
||||
// RemoveGroupMember removes a single user from a NACM group.
|
||||
// DELETE /configure/users/groups/{name}/members/{user}
|
||||
func (h *ConfigureUsersHandler) RemoveGroupMember(w http.ResponseWriter, r *http.Request) {
|
||||
groupName := r.PathValue("name")
|
||||
userName := r.PathValue("user")
|
||||
|
||||
groups, err := h.fetchAllGroups(r.Context())
|
||||
if err != nil {
|
||||
renderSaveError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
newMembers := make([]string, 0)
|
||||
found := false
|
||||
for _, g := range groups {
|
||||
if g.Name != groupName {
|
||||
continue
|
||||
}
|
||||
for _, u := range g.UserNames {
|
||||
if u == userName {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
newMembers = append(newMembers, u)
|
||||
}
|
||||
break
|
||||
}
|
||||
if !found {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.putGroupMembers(r.Context(), groupName, newMembers); err != nil {
|
||||
log.Printf("configure users groups remove %q/%q: %v", groupName, userName, err)
|
||||
renderSaveError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// HashPassword returns a yescrypt crypt hash of password using mkpasswd(1).
|
||||
// mkpasswd is available on Infix target systems and uses the system's libcrypt,
|
||||
// so the output matches whatever the device expects (default: yescrypt $y$).
|
||||
func HashPassword(password string) (string, error) {
|
||||
path, err := exec.LookPath("mkpasswd")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("mkpasswd not found: %w", err)
|
||||
}
|
||||
cmd := exec.Command(path, "--method=yescrypt", "--password-fd=0")
|
||||
cmd.Stdin = strings.NewReader(password)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("mkpasswd: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/kernelkit/webui/internal/restconf"
|
||||
)
|
||||
|
||||
// ─── RESTCONF JSON types ──────────────────────────────────────────────────────
|
||||
|
||||
type nacmWrapper struct {
|
||||
NACM nacmJSON `json:"ietf-netconf-acm:nacm"`
|
||||
}
|
||||
|
||||
type nacmJSON struct {
|
||||
EnableNACM bool `json:"enable-nacm"`
|
||||
ReadDefault string `json:"read-default"`
|
||||
WriteDefault string `json:"write-default"`
|
||||
ExecDefault string `json:"exec-default"`
|
||||
DeniedOperations uint32 `json:"denied-operations"`
|
||||
DeniedDataWrites uint32 `json:"denied-data-writes"`
|
||||
DeniedNotifications uint32 `json:"denied-notifications"`
|
||||
Groups nacmGroupsJSON `json:"groups"`
|
||||
RuleList []nacmRuleListJSON `json:"rule-list"`
|
||||
}
|
||||
|
||||
type nacmGroupsJSON struct {
|
||||
Group []nacmGroupJSON `json:"group"`
|
||||
}
|
||||
|
||||
type nacmGroupJSON struct {
|
||||
Name string `json:"name"`
|
||||
UserNames []string `json:"user-name"`
|
||||
}
|
||||
|
||||
type nacmRuleListJSON struct {
|
||||
Name string `json:"name"`
|
||||
Group []string `json:"group"`
|
||||
Rule []nacmRuleJSON `json:"rule"`
|
||||
}
|
||||
|
||||
type nacmRuleJSON struct {
|
||||
Name string `json:"name"`
|
||||
ModuleName string `json:"module-name"`
|
||||
Path string `json:"path"`
|
||||
AccessOperations string `json:"access-operations"`
|
||||
Action string `json:"action"`
|
||||
}
|
||||
|
||||
type nacmAuthWrapper struct {
|
||||
System struct {
|
||||
Authentication struct {
|
||||
User []nacmUserJSON `json:"user"`
|
||||
} `json:"authentication"`
|
||||
} `json:"ietf-system:system"`
|
||||
}
|
||||
|
||||
type nacmUserJSON struct {
|
||||
Name string `json:"name"`
|
||||
Password string `json:"password"`
|
||||
Shell string `json:"infix-system:shell"`
|
||||
AuthorizedKey []interface{} `json:"authorized-key"`
|
||||
}
|
||||
|
||||
// ─── Template data ────────────────────────────────────────────────────────────
|
||||
|
||||
type nacmPageData struct {
|
||||
PageData
|
||||
Error string
|
||||
|
||||
// Summary card
|
||||
Enabled string
|
||||
ReadDefault string
|
||||
WriteDefault string
|
||||
ExecDefault string
|
||||
DeniedOperations uint32
|
||||
DeniedDataWrites uint32
|
||||
DeniedNotifications uint32
|
||||
|
||||
// Permission matrix
|
||||
Matrix []nacmGroupPerm
|
||||
|
||||
// Users and Groups tables
|
||||
Users []nacmUserEntry
|
||||
Groups []nacmGroupEntry
|
||||
}
|
||||
|
||||
type nacmGroupPerm struct {
|
||||
Name string
|
||||
Read nacmCell
|
||||
Write nacmCell
|
||||
Exec nacmCell
|
||||
Restrictions []string
|
||||
}
|
||||
|
||||
type nacmCell struct {
|
||||
Class string // "nacm-full" | "nacm-restricted" | "nacm-denied"
|
||||
Symbol string // "✓" | "⚠" | "✗"
|
||||
}
|
||||
|
||||
type nacmUserEntry struct {
|
||||
Name string
|
||||
Shell string
|
||||
Login string
|
||||
}
|
||||
|
||||
type nacmGroupEntry struct {
|
||||
Name string
|
||||
Members string
|
||||
}
|
||||
|
||||
// ─── Handler ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// NACMHandler serves the NACM page.
|
||||
type NACMHandler struct {
|
||||
Template *template.Template
|
||||
RC *restconf.Client
|
||||
}
|
||||
|
||||
// Overview renders the NACM page (GET /nacm).
|
||||
func (h *NACMHandler) Overview(w http.ResponseWriter, r *http.Request) {
|
||||
data := nacmPageData{
|
||||
PageData: newPageData(r, "nacm", "NACM"),
|
||||
}
|
||||
|
||||
var nacmRaw nacmWrapper
|
||||
nacmErr := h.RC.Get(r.Context(), "/data/ietf-netconf-acm:nacm", &nacmRaw)
|
||||
|
||||
var authRaw nacmAuthWrapper
|
||||
authErr := h.RC.Get(r.Context(), "/data/ietf-system:system/authentication", &authRaw)
|
||||
|
||||
if nacmErr != nil {
|
||||
log.Printf("restconf nacm: %v", nacmErr)
|
||||
data.Error = "Could not fetch NACM data"
|
||||
} else {
|
||||
n := nacmRaw.NACM
|
||||
if n.ReadDefault == "" {
|
||||
n.ReadDefault = "permit"
|
||||
}
|
||||
if n.WriteDefault == "" {
|
||||
n.WriteDefault = "deny"
|
||||
}
|
||||
if n.ExecDefault == "" {
|
||||
n.ExecDefault = "permit"
|
||||
}
|
||||
|
||||
if n.EnableNACM {
|
||||
data.Enabled = "yes"
|
||||
} else {
|
||||
data.Enabled = "no"
|
||||
}
|
||||
data.ReadDefault = n.ReadDefault
|
||||
data.WriteDefault = n.WriteDefault
|
||||
data.ExecDefault = n.ExecDefault
|
||||
data.DeniedOperations = n.DeniedOperations
|
||||
data.DeniedDataWrites = n.DeniedDataWrites
|
||||
data.DeniedNotifications = n.DeniedNotifications
|
||||
|
||||
data.Matrix = analyzeNACMPermissions(n)
|
||||
|
||||
for _, g := range n.Groups.Group {
|
||||
data.Groups = append(data.Groups, nacmGroupEntry{
|
||||
Name: g.Name,
|
||||
Members: strings.Join(g.UserNames, " "),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if authErr != nil {
|
||||
log.Printf("restconf nacm auth: %v", authErr)
|
||||
} else {
|
||||
for _, u := range authRaw.System.Authentication.User {
|
||||
data.Users = append(data.Users, buildNACMUserEntry(u))
|
||||
}
|
||||
}
|
||||
|
||||
tmplName := "nacm.html"
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
tmplName = "content"
|
||||
}
|
||||
if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil {
|
||||
log.Printf("template error: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Permission matrix logic ──────────────────────────────────────────────────
|
||||
// Mirrors cli_pretty._analyze_group_permissions exactly.
|
||||
|
||||
func analyzeNACMPermissions(n nacmJSON) []nacmGroupPerm {
|
||||
readDefault := n.ReadDefault == "permit"
|
||||
writeDefault := n.WriteDefault == "permit"
|
||||
execDefault := n.ExecDefault == "permit"
|
||||
|
||||
// Collect deny rules that apply to "*" (all groups) — these become restrictions.
|
||||
var globalDenials []nacmRuleJSON
|
||||
for _, rl := range n.RuleList {
|
||||
for _, g := range rl.Group {
|
||||
if g == "*" {
|
||||
for _, rule := range rl.Rule {
|
||||
if rule.Action == "deny" {
|
||||
globalDenials = append(globalDenials, rule)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var results []nacmGroupPerm
|
||||
|
||||
for _, group := range n.Groups.Group {
|
||||
canRead := readDefault
|
||||
canWrite := writeDefault
|
||||
canExec := execDefault
|
||||
hasPermitAll := false
|
||||
var restrictions []string
|
||||
|
||||
// Process rule-lists in order; only those that name this group specifically.
|
||||
for _, rl := range n.RuleList {
|
||||
applies := false
|
||||
for _, g := range rl.Group {
|
||||
if g == group.Name {
|
||||
applies = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !applies {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, rule := range rl.Rule {
|
||||
action := rule.Action
|
||||
accessOps := strings.ToLower(rule.AccessOperations)
|
||||
moduleName := rule.ModuleName
|
||||
|
||||
// permit-all: module-name="*" AND access-operations="*"
|
||||
if action == "permit" && moduleName == "*" && accessOps == "*" {
|
||||
hasPermitAll = true
|
||||
break
|
||||
}
|
||||
|
||||
// Explicit deny for write/exec operations
|
||||
if action == "deny" && moduleName == "*" {
|
||||
if strings.Contains(accessOps, "create") &&
|
||||
strings.Contains(accessOps, "update") &&
|
||||
strings.Contains(accessOps, "delete") {
|
||||
canWrite = false
|
||||
}
|
||||
if strings.Contains(accessOps, "exec") {
|
||||
canExec = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hasPermitAll {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// permit-all overrides everything, including unfavourable global defaults.
|
||||
if hasPermitAll {
|
||||
canRead = true
|
||||
canWrite = true
|
||||
canExec = true
|
||||
}
|
||||
|
||||
// Global denials create restrictions for groups that don't have permit-all.
|
||||
if !hasPermitAll {
|
||||
seen := map[string]bool{}
|
||||
for _, rule := range globalDenials {
|
||||
var restriction string
|
||||
if rule.Path != "" {
|
||||
parts := strings.Split(strings.TrimRight(rule.Path, "/"), "/")
|
||||
restriction = parts[len(parts)-1]
|
||||
} else if rule.ModuleName != "" {
|
||||
restriction = strings.TrimPrefix(rule.ModuleName, "ietf-")
|
||||
}
|
||||
if restriction != "" && !seen[restriction] {
|
||||
seen[restriction] = true
|
||||
restrictions = append(restrictions, restriction)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results = append(results, nacmGroupPerm{
|
||||
Name: group.Name,
|
||||
Read: makeNACMCell(canRead, len(restrictions) > 0),
|
||||
Write: makeNACMCell(canWrite, len(restrictions) > 0),
|
||||
Exec: makeNACMCell(canExec, len(restrictions) > 0),
|
||||
Restrictions: restrictions,
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func makeNACMCell(hasAccess, hasRestrictions bool) nacmCell {
|
||||
if !hasAccess {
|
||||
return nacmCell{Class: "nacm-denied", Symbol: "✗"}
|
||||
}
|
||||
if hasRestrictions {
|
||||
return nacmCell{Class: "nacm-restricted", Symbol: "⚠"}
|
||||
}
|
||||
return nacmCell{Class: "nacm-full", Symbol: "✓"}
|
||||
}
|
||||
|
||||
// ─── User entry helper ────────────────────────────────────────────────────────
|
||||
|
||||
func buildNACMUserEntry(u nacmUserJSON) nacmUserEntry {
|
||||
shell := u.Shell
|
||||
if idx := strings.LastIndex(shell, ":"); idx >= 0 {
|
||||
shell = shell[idx+1:]
|
||||
}
|
||||
if shell == "" || shell == "false" {
|
||||
shell = "-"
|
||||
}
|
||||
|
||||
var login string
|
||||
hasPassword := u.Password != ""
|
||||
hasKeys := len(u.AuthorizedKey) > 0
|
||||
switch {
|
||||
case hasPassword && hasKeys:
|
||||
login = "password+key"
|
||||
case hasPassword:
|
||||
login = "password"
|
||||
case hasKeys:
|
||||
login = "key"
|
||||
default:
|
||||
login = "-"
|
||||
}
|
||||
|
||||
return nacmUserEntry{
|
||||
Name: u.Name,
|
||||
Shell: shell,
|
||||
Login: login,
|
||||
}
|
||||
}
|
||||
@@ -59,74 +59,117 @@ func NewClient(baseURL string, insecureTLS bool) *Client {
|
||||
}
|
||||
}
|
||||
|
||||
// doRequest builds and executes an HTTP request against the RESTCONF server,
|
||||
// setting Accept and Basic Auth from the context. The caller must close Body.
|
||||
func (c *Client) doRequest(ctx context.Context, method, path string) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/yang-data+json")
|
||||
creds := CredentialsFromContext(ctx)
|
||||
req.SetBasicAuth(creds.Username, creds.Password)
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("restconf request failed: %w", err)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Get fetches a RESTCONF resource, decoding the JSON response into target.
|
||||
// User credentials are taken from the request context (set by auth middleware).
|
||||
func (c *Client) Get(ctx context.Context, path string, target any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/yang-data+json")
|
||||
|
||||
creds := CredentialsFromContext(ctx)
|
||||
req.SetBasicAuth(creds.Username, creds.Password)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("restconf request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return parseError(resp)
|
||||
}
|
||||
|
||||
return json.NewDecoder(resp.Body).Decode(target)
|
||||
}
|
||||
|
||||
// Post sends a POST request to a RESTCONF RPC endpoint.
|
||||
// Used for operations like system-restart that return no body.
|
||||
func (c *Client) Post(ctx context.Context, path string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, nil)
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/yang-data+json")
|
||||
|
||||
creds := CredentialsFromContext(ctx)
|
||||
req.SetBasicAuth(creds.Username, creds.Password)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("restconf request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
|
||||
return parseError(resp)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PostJSON sends a POST request with a JSON body to a RESTCONF RPC endpoint.
|
||||
// Used for RPCs that require input parameters (e.g. install-bundle).
|
||||
func (c *Client) PostJSON(ctx context.Context, path string, body any) error {
|
||||
return c.writeJSON(ctx, http.MethodPost, path, body)
|
||||
}
|
||||
|
||||
// Put replaces a RESTCONF config resource with the given value.
|
||||
func (c *Client) Put(ctx context.Context, path string, body any) error {
|
||||
return c.writeJSON(ctx, http.MethodPut, path, body)
|
||||
}
|
||||
|
||||
// Patch merges the given value into a RESTCONF config resource.
|
||||
func (c *Client) Patch(ctx context.Context, path string, body any) error {
|
||||
return c.writeJSON(ctx, http.MethodPatch, path, body)
|
||||
}
|
||||
|
||||
// Delete removes a RESTCONF config resource.
|
||||
func (c *Client) Delete(ctx context.Context, path string) error {
|
||||
resp, err := c.doRequest(ctx, http.MethodDelete, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
|
||||
return parseError(resp)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDatastore fetches the full contents of the named NMDA datastore as raw JSON.
|
||||
// datastore is one of "running", "candidate", "operational", "startup", "factory-default".
|
||||
func (c *Client) GetDatastore(ctx context.Context, datastore string) (json.RawMessage, error) {
|
||||
return c.GetRaw(ctx, "/ds/ietf-datastores:"+datastore)
|
||||
}
|
||||
|
||||
// PutDatastore replaces the named NMDA datastore with the given raw JSON.
|
||||
func (c *Client) PutDatastore(ctx context.Context, datastore string, body json.RawMessage) error {
|
||||
return c.writeJSON(ctx, http.MethodPut, "/ds/ietf-datastores:"+datastore, body)
|
||||
}
|
||||
|
||||
// CopyDatastore copies the full contents of src into dst.
|
||||
// Mirrors the Infamy test framework's copy() operation.
|
||||
func (c *Client) CopyDatastore(ctx context.Context, src, dst string) error {
|
||||
data, err := c.GetDatastore(ctx, src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("copy datastore: read %s: %w", src, err)
|
||||
}
|
||||
if err := c.PutDatastore(ctx, dst, data); err != nil {
|
||||
return fmt.Errorf("copy datastore: write %s: %w", dst, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeJSON encodes body as JSON and sends it with the given HTTP method.
|
||||
func (c *Client) writeJSON(ctx context.Context, method, path string, body any) error {
|
||||
var buf bytes.Buffer
|
||||
if err := json.NewEncoder(&buf).Encode(body); err != nil {
|
||||
return fmt.Errorf("encoding request body: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, &buf)
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, &buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/yang-data+json")
|
||||
req.Header.Set("Accept", "application/yang-data+json")
|
||||
|
||||
creds := CredentialsFromContext(ctx)
|
||||
req.SetBasicAuth(creds.Username, creds.Password)
|
||||
|
||||
@@ -136,35 +179,24 @@ func (c *Client) PostJSON(ctx context.Context, path string, body any) error {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
|
||||
if resp.StatusCode != http.StatusOK &&
|
||||
resp.StatusCode != http.StatusCreated &&
|
||||
resp.StatusCode != http.StatusNoContent {
|
||||
return parseError(resp)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRaw fetches a RESTCONF resource and returns the raw JSON bytes.
|
||||
func (c *Client) GetRaw(ctx context.Context, path string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/yang-data+json")
|
||||
|
||||
creds := CredentialsFromContext(ctx)
|
||||
req.SetBasicAuth(creds.Username, creds.Password)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("restconf request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, parseError(resp)
|
||||
}
|
||||
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
package restconf
|
||||
|
||||
import "context"
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// Fetcher is the RESTCONF client interface used by handlers.
|
||||
// *Client satisfies this interface; testutil.MockFetcher provides a test double.
|
||||
@@ -11,6 +14,14 @@ type Fetcher interface {
|
||||
GetRaw(ctx context.Context, path string) ([]byte, error)
|
||||
Post(ctx context.Context, path string) error
|
||||
PostJSON(ctx context.Context, path string, body any) error
|
||||
|
||||
Put(ctx context.Context, path string, body any) error
|
||||
Patch(ctx context.Context, path string, body any) error
|
||||
Delete(ctx context.Context, path string) error
|
||||
|
||||
GetDatastore(ctx context.Context, datastore string) (json.RawMessage, error)
|
||||
PutDatastore(ctx context.Context, datastore string, body json.RawMessage) error
|
||||
CopyDatastore(ctx context.Context, src, dst string) error
|
||||
}
|
||||
|
||||
var _ Fetcher = (*Client)(nil)
|
||||
|
||||
@@ -81,6 +81,10 @@ func New(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nacmTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/nacm.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
servicesTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/services.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -89,6 +93,14 @@ func New(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfgSysTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", "pages/configure-system.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfgUsersTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", "pages/configure-users.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
login := &auth.LoginHandler{
|
||||
Store: store,
|
||||
@@ -130,8 +142,12 @@ func New(
|
||||
ntp := &handlers.NTPHandler{Template: ntpTmpl, RC: rc}
|
||||
lldp := &handlers.LLDPHandler{Template: lldpTmpl, RC: rc}
|
||||
mdns := &handlers.MDNSHandler{Template: mdnsTmpl, RC: rc}
|
||||
nacm := &handlers.NACMHandler{Template: nacmTmpl, RC: rc}
|
||||
services := &handlers.ServicesHandler{Template: servicesTmpl, RC: rc}
|
||||
containers := &handlers.ContainersHandler{Template: containersTmpl, RC: rc}
|
||||
cfg := &handlers.ConfigureHandler{RC: rc}
|
||||
cfgSys := &handlers.ConfigureSystemHandler{Template: cfgSysTmpl, RC: rc}
|
||||
cfgUsers := &handlers.ConfigureUsersHandler{Template: cfgUsersTmpl, RC: rc}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
@@ -164,9 +180,31 @@ func New(
|
||||
mux.HandleFunc("GET /ntp", ntp.Overview)
|
||||
mux.HandleFunc("GET /lldp", lldp.Overview)
|
||||
mux.HandleFunc("GET /mdns", mdns.Overview)
|
||||
mux.HandleFunc("GET /nacm", nacm.Overview)
|
||||
mux.HandleFunc("GET /services", services.Overview)
|
||||
mux.HandleFunc("GET /containers", containers.Overview)
|
||||
|
||||
// Configure routes.
|
||||
mux.HandleFunc("POST /configure/enter", cfg.Enter)
|
||||
mux.HandleFunc("POST /configure/apply", cfg.Apply)
|
||||
mux.HandleFunc("POST /configure/apply-and-save", cfg.ApplyAndSave)
|
||||
mux.HandleFunc("POST /configure/abort", cfg.Abort)
|
||||
mux.HandleFunc("GET /configure/system", cfgSys.Overview)
|
||||
mux.HandleFunc("POST /configure/system/identity", cfgSys.SaveIdentity)
|
||||
mux.HandleFunc("POST /configure/system/clock", cfgSys.SaveClock)
|
||||
mux.HandleFunc("PUT /configure/system/ntp", cfgSys.SaveNTP)
|
||||
mux.HandleFunc("PUT /configure/system/dns", cfgSys.SaveDNS)
|
||||
mux.HandleFunc("POST /configure/system/preferences", cfgSys.SavePreferences)
|
||||
mux.HandleFunc("GET /configure/users", cfgUsers.Overview)
|
||||
mux.HandleFunc("POST /configure/users", cfgUsers.AddUser)
|
||||
mux.HandleFunc("DELETE /configure/users/{name}", cfgUsers.DeleteUser)
|
||||
mux.HandleFunc("POST /configure/users/{name}/shell", cfgUsers.UpdateShell)
|
||||
mux.HandleFunc("POST /configure/users/{name}/password", cfgUsers.ChangePassword)
|
||||
mux.HandleFunc("POST /configure/users/{name}/keys", cfgUsers.AddKey)
|
||||
mux.HandleFunc("DELETE /configure/users/{name}/keys/{keyname}", cfgUsers.DeleteKey)
|
||||
mux.HandleFunc("POST /configure/users/groups/{name}/members", cfgUsers.AddGroupMembers)
|
||||
mux.HandleFunc("DELETE /configure/users/groups/{name}/members/{user}", cfgUsers.RemoveGroupMember)
|
||||
|
||||
handler := authMiddleware(store, mux)
|
||||
handler = csrfMiddleware(handler)
|
||||
handler = securityHeadersMiddleware(handler)
|
||||
|
||||
@@ -93,3 +93,17 @@ func (m *MockFetcher) PostJSON(_ context.Context, path string, _ any) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockFetcher) Put(_ context.Context, _ string, _ any) error { return nil }
|
||||
func (m *MockFetcher) Patch(_ context.Context, _ string, _ any) error { return nil }
|
||||
func (m *MockFetcher) Delete(_ context.Context, _ string) error { return nil }
|
||||
|
||||
func (m *MockFetcher) GetDatastore(_ context.Context, _ string) (json.RawMessage, error) {
|
||||
return json.RawMessage("{}"), nil
|
||||
}
|
||||
|
||||
func (m *MockFetcher) PutDatastore(_ context.Context, _ string, _ json.RawMessage) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockFetcher) CopyDatastore(_ context.Context, _, _ string) error { return nil }
|
||||
|
||||
@@ -1159,6 +1159,45 @@ details[open].nav-group-top > summary.nav-group-summary-top::before {
|
||||
.light .svc-stopped { color: #ca8a04; }
|
||||
.light .svc-error { color: #dc2626; }
|
||||
|
||||
/* ─── NACM matrix ─────────────────────────────────────────────────────────── */
|
||||
.nacm-matrix .nacm-col { text-align: center; width: 5rem; }
|
||||
.nacm-group { font-family: var(--font-mono); font-size: 0.85rem; }
|
||||
.nacm-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.nacm-full { background: rgba(34, 197, 94, 0.2); color: #16a34a; }
|
||||
.nacm-restricted { background: rgba(234, 179, 8, 0.2); color: #854d0e; }
|
||||
.nacm-denied { background: rgba(239, 68, 68, 0.2); color: #dc2626; }
|
||||
.nacm-restrictions-row td { padding-top: 0; }
|
||||
.nacm-restrictions { font-size: 0.75rem; color: var(--fg-muted); font-style: italic; }
|
||||
.nacm-legend {
|
||||
padding: 0.5rem 1rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--fg-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.nacm-legend .nacm-cell { width: 1.4rem; height: 1.4rem; font-size: 0.8rem; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.nacm-full { background: rgba(34, 197, 94, 0.2); color: #86efac; }
|
||||
.nacm-restricted { background: rgba(234, 179, 8, 0.2); color: #fde047; }
|
||||
.nacm-denied { background: rgba(239, 68, 68, 0.2); color: #fca5a5; }
|
||||
}
|
||||
.dark .nacm-full { background: rgba(34, 197, 94, 0.2); color: #86efac; }
|
||||
.dark .nacm-restricted { background: rgba(234, 179, 8, 0.2); color: #fde047; }
|
||||
.dark .nacm-denied { background: rgba(239, 68, 68, 0.2); color: #fca5a5; }
|
||||
.light .nacm-full { background: rgba(34, 197, 94, 0.15); color: #16a34a; }
|
||||
.light .nacm-restricted { background: rgba(234, 179, 8, 0.15); color: #854d0e; }
|
||||
.light .nacm-denied { background: rgba(239, 68, 68, 0.15); color: #dc2626; }
|
||||
|
||||
/* Sub-heading within a card body (e.g. "Active Leases" in DHCP) */
|
||||
.section-subtitle {
|
||||
font-size: 0.75rem;
|
||||
@@ -1875,3 +1914,157 @@ details.fw-hint[open] summary::before { transform: rotate(90deg); }
|
||||
@media (max-width: 480px) {
|
||||
.col-priority-3 { display: none; }
|
||||
}
|
||||
|
||||
/* ─── Confirm dialog ──────────────────────────────────────────────────────── */
|
||||
#confirm-dialog {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
color: var(--fg);
|
||||
padding: 1.5rem 2rem 1.25rem;
|
||||
min-width: 20rem;
|
||||
max-width: 90vw;
|
||||
box-shadow: var(--shadow);
|
||||
/* Centre in the viewport regardless of scroll position */
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
margin: 0;
|
||||
}
|
||||
#confirm-dialog::backdrop {
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.dialog-message {
|
||||
font-size: 0.95rem;
|
||||
margin: 0 0 1.25rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.btn-outline:hover { background: var(--border); }
|
||||
.btn-danger-outline {
|
||||
background: transparent;
|
||||
color: var(--danger);
|
||||
border: 1px solid var(--danger);
|
||||
}
|
||||
.btn-danger-outline:hover { background: rgba(220, 38, 38, 0.08); }
|
||||
|
||||
.btn-accept {
|
||||
background: var(--success);
|
||||
color: #fff;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.btn-accept:hover { background: var(--green-600); }
|
||||
|
||||
/* ─── Configure toolbar ───────────────────────────────────────────────────── */
|
||||
.configure-toolbar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: var(--sidebar-width, 220px);
|
||||
right: 0;
|
||||
z-index: 200;
|
||||
background: var(--surface);
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 0.75rem 1.5rem;
|
||||
box-shadow: 0 -2px 8px rgba(0,0,0,0.08);
|
||||
}
|
||||
.configure-toolbar-inner {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
max-width: 960px;
|
||||
}
|
||||
/* Push page content above the toolbar */
|
||||
.configure-page { padding-bottom: 4rem; }
|
||||
|
||||
/* ─── Editable form inputs inside cards ───────────────────────────────────── */
|
||||
.cfg-input {
|
||||
width: 100%;
|
||||
padding: 0.4rem 0.6rem;
|
||||
background: var(--surface);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.cfg-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
.cfg-input-sm { max-width: 8rem; }
|
||||
.cfg-input-mono { font-family: var(--font-mono); font-size: 0.85rem; }
|
||||
|
||||
/* Editable table rows */
|
||||
.cfg-table td { padding: 0.45rem 0.75rem; vertical-align: middle; }
|
||||
.cfg-table td:last-child { width: 3rem; text-align: center; }
|
||||
.btn-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
color: var(--fg-muted);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s, color 0.1s;
|
||||
padding: 0;
|
||||
}
|
||||
.btn-icon:hover { background: var(--border); color: var(--fg); }
|
||||
.btn-icon-danger:hover { color: var(--danger); background: rgba(220,38,38,0.08); }
|
||||
.btn-add-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--primary);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
.btn-add-row:hover { text-decoration: underline; }
|
||||
|
||||
/* Inline forms inside users table */
|
||||
.user-shell-form, .user-add-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.user-shell-form .cfg-input { flex: 1; min-width: 0; }
|
||||
.user-add-inline .cfg-input { width: auto; flex: 1; min-width: 8rem; }
|
||||
|
||||
/* Card-level save feedback */
|
||||
.cfg-card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.6rem 1rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.cfg-save-status {
|
||||
font-size: 0.8rem;
|
||||
color: var(--fg-muted);
|
||||
min-height: 1.2em;
|
||||
}
|
||||
.cfg-save-status.saved { color: #16a34a; }
|
||||
.cfg-save-error { font-size: 0.8rem; color: var(--danger); }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.cfg-save-status.saved { color: #86efac; }
|
||||
}
|
||||
.dark .cfg-save-status.saved { color: #86efac; }
|
||||
.light .cfg-save-status.saved { color: #16a34a; }
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 358 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/>
|
||||
<path d="M12 2v2M12 20v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M2 12h2M20 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 428 B |
@@ -0,0 +1,24 @@
|
||||
{{define "configure-toolbar"}}
|
||||
<div class="configure-toolbar">
|
||||
<div class="configure-toolbar-inner">
|
||||
<button type="button" class="btn btn-primary cfg-apply-btn"
|
||||
hx-post="/configure/apply"
|
||||
hx-swap="none"
|
||||
hx-confirm="Apply staged changes to running configuration? Changes will be lost on reboot unless saved.">
|
||||
Apply
|
||||
</button>
|
||||
<button type="button" class="btn btn-accept cfg-apply-save-btn"
|
||||
hx-post="/configure/apply-and-save"
|
||||
hx-swap="none"
|
||||
hx-confirm="Apply staged changes and save to startup configuration?">
|
||||
Apply & Save
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger-outline cfg-abort-btn"
|
||||
hx-post="/configure/abort"
|
||||
hx-swap="none"
|
||||
hx-confirm="Discard all staged changes and return to Status?">
|
||||
Abort
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -73,6 +73,13 @@
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<dialog id="confirm-dialog" aria-modal="true">
|
||||
<p class="dialog-message" id="dialog-message"></p>
|
||||
<div class="dialog-actions">
|
||||
<button class="btn btn-primary" id="dialog-confirm" autofocus>Confirm</button>
|
||||
<button class="btn btn-outline" id="dialog-cancel">Cancel</button>
|
||||
</div>
|
||||
</dialog>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
@@ -167,8 +167,31 @@
|
||||
|
||||
</details>
|
||||
|
||||
<details class="nav-group-top">
|
||||
<details class="nav-group-top" id="nav-configure"
|
||||
{{if or (eq .ActivePage "configure-system") (eq .ActivePage "configure-users")}}open{{end}}>
|
||||
<summary class="nav-group-summary-top">Configure</summary>
|
||||
<ul class="nav-group-items">
|
||||
<li>
|
||||
<a href="/configure/system"
|
||||
hx-get="/configure/system"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
class="nav-link {{if eq .ActivePage "configure-system"}}active{{end}}">
|
||||
<span class="nav-icon" style="--icon: url('/assets/img/icons/system.svg')"></span>
|
||||
System
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/configure/users"
|
||||
hx-get="/configure/users"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
class="nav-link {{if eq .ActivePage "configure-users"}}active{{end}}">
|
||||
<span class="nav-icon" style="--icon: url('/assets/img/icons/nacm.svg')"></span>
|
||||
Users
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
<details class="nav-group-top">
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
{{define "configure-system.html"}}
|
||||
{{template "base" .}}
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="configure-page">
|
||||
{{if .Error}}
|
||||
<div class="alert alert-error">{{.Error}}</div>
|
||||
{{end}}
|
||||
|
||||
<section class="info-grid">
|
||||
|
||||
{{/* ── Identity ─────────────────────────────────────────────────────── */}}
|
||||
<form class="info-card"
|
||||
hx-post="/configure/system/identity"
|
||||
hx-swap="none">
|
||||
<div class="card-header">Identity</div>
|
||||
<table class="info-table">
|
||||
<tr>
|
||||
<th>Hostname</th>
|
||||
<td><input class="cfg-input" type="text" name="hostname" value="{{.Hostname}}" required></td>
|
||||
<td class="cfg-reset-col"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Contact</th>
|
||||
<td><input class="cfg-input" type="text" name="contact" value="{{.Contact}}"></td>
|
||||
<td class="cfg-reset-col">
|
||||
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
|
||||
hx-delete="/configure/leaf?path=/ietf-system:system/contact&redirect=/configure/system"
|
||||
hx-swap="none"
|
||||
hx-confirm="Reset contact to its YANG default?">↺</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Location</th>
|
||||
<td><input class="cfg-input" type="text" name="location" value="{{.Location}}"></td>
|
||||
<td class="cfg-reset-col">
|
||||
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
|
||||
hx-delete="/configure/leaf?path=/ietf-system:system/location&redirect=/configure/system"
|
||||
hx-swap="none"
|
||||
hx-confirm="Reset location to its YANG default?">↺</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="cfg-card-footer">
|
||||
<button class="btn btn-primary" type="submit">Save</button>
|
||||
<span class="cfg-save-status"></span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{/* ── Clock ───────────────────────────────────────────────────────── */}}
|
||||
<form class="info-card"
|
||||
hx-post="/configure/system/clock"
|
||||
hx-swap="none">
|
||||
<div class="card-header">Clock</div>
|
||||
<table class="info-table">
|
||||
<tr>
|
||||
<th>Timezone</th>
|
||||
<td>
|
||||
<input class="cfg-input" type="text" name="timezone" value="{{.Timezone}}"
|
||||
list="tz-list" placeholder="e.g. Europe/Stockholm" autocomplete="off">
|
||||
</td>
|
||||
<td class="cfg-reset-col">
|
||||
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
|
||||
hx-delete="/configure/leaf?path=/ietf-system:system/clock/timezone-name&redirect=/configure/system"
|
||||
hx-swap="none"
|
||||
hx-confirm="Reset timezone to its YANG default?">↺</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="cfg-card-footer">
|
||||
<button class="btn btn-primary" type="submit">Save</button>
|
||||
<span class="cfg-save-status"></span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{/* ── NTP servers ──────────────────────────────────────────────────── */}}
|
||||
<form class="info-card info-grid-span-2"
|
||||
hx-put="/configure/system/ntp"
|
||||
hx-swap="none">
|
||||
<div class="card-header">NTP Servers</div>
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table cfg-table" id="ntp-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name / Key</th>
|
||||
<th>Address</th>
|
||||
<th class="cfg-input-sm">Port</th>
|
||||
<th>Prefer</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="ntp-tbody">
|
||||
{{range $i, $s := .NTP.Servers}}
|
||||
<tr>
|
||||
<td><input class="cfg-input" type="text"
|
||||
name="ntp_name_{{$i}}" value="{{$s.Name}}" required></td>
|
||||
<td><input class="cfg-input" type="text"
|
||||
name="ntp_addr_{{$i}}" value="{{$s.UDP.Address}}"></td>
|
||||
<td><input class="cfg-input cfg-input-sm" type="number" min="1" max="65535"
|
||||
name="ntp_port_{{$i}}" value="{{if $s.UDP.Port}}{{$s.UDP.Port}}{{end}}"
|
||||
placeholder="123"></td>
|
||||
<td style="text-align:center">
|
||||
<input type="checkbox" name="ntp_prefer_{{$i}}" {{if $s.Prefer}}checked{{end}}>
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="btn-icon btn-icon-danger cfg-delete-row"
|
||||
title="Remove">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="cfg-card-footer">
|
||||
<button type="button" class="btn-add-row" data-table="ntp-tbody"
|
||||
data-template="ntp">+ Add server</button>
|
||||
<button class="btn btn-primary" type="submit">Save</button>
|
||||
<span class="cfg-save-status"></span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{/* ── DNS ─────────────────────────────────────────────────────────── */}}
|
||||
<form class="info-card info-grid-span-2"
|
||||
hx-put="/configure/system/dns"
|
||||
hx-swap="none">
|
||||
<div class="card-header">DNS</div>
|
||||
|
||||
<div class="section-subtitle">Search domains</div>
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table cfg-table" id="dns-search-table">
|
||||
<thead>
|
||||
<tr><th>Domain</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody id="dns-search-tbody">
|
||||
{{range $i, $d := .DNS.Search}}
|
||||
<tr>
|
||||
<td><input class="cfg-input" type="text"
|
||||
name="dns_search_{{$i}}" value="{{$d}}"></td>
|
||||
<td>
|
||||
<button type="button" class="btn-icon btn-icon-danger cfg-delete-row" title="Remove">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style="padding: 0 0.75rem;">
|
||||
<button type="button" class="btn-add-row" data-table="dns-search-tbody"
|
||||
data-template="dns-search">+ Add domain</button>
|
||||
</div>
|
||||
|
||||
<div class="section-subtitle" style="margin-top:0.75rem">Servers</div>
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table cfg-table" id="dns-server-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name / Key</th>
|
||||
<th>Address</th>
|
||||
<th class="cfg-input-sm">Port</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="dns-server-tbody">
|
||||
{{range $i, $s := .DNS.Servers}}
|
||||
<tr>
|
||||
<td><input class="cfg-input" type="text"
|
||||
name="dns_name_{{$i}}" value="{{$s.Name}}" required></td>
|
||||
<td><input class="cfg-input" type="text"
|
||||
name="dns_addr_{{$i}}" value="{{$s.UDPAndTCP.Address}}"></td>
|
||||
<td><input class="cfg-input cfg-input-sm" type="number" min="1" max="65535"
|
||||
name="dns_port_{{$i}}" value="{{if $s.UDPAndTCP.Port}}{{$s.UDPAndTCP.Port}}{{end}}"
|
||||
placeholder="53"></td>
|
||||
<td>
|
||||
<button type="button" class="btn-icon btn-icon-danger cfg-delete-row" title="Remove">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="cfg-card-footer">
|
||||
<button type="button" class="btn-add-row" data-table="dns-server-tbody"
|
||||
data-template="dns-server">+ Add server</button>
|
||||
<button class="btn btn-primary" type="submit">Save</button>
|
||||
<span class="cfg-save-status"></span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{/* ── Preferences ─────────────────────────────────────────────────── */}}
|
||||
<form class="info-card info-grid-span-2"
|
||||
hx-post="/configure/system/preferences"
|
||||
hx-swap="none">
|
||||
<div class="card-header">Preferences</div>
|
||||
<table class="info-table">
|
||||
<tr>
|
||||
<th>Text editor</th>
|
||||
<td>
|
||||
<select class="cfg-input" name="text_editor">
|
||||
<option value="">— not set —</option>
|
||||
<option value="infix-system:emacs" {{if eq .TextEditor "infix-system:emacs" }}selected{{end}}>Emacs</option>
|
||||
<option value="infix-system:nano" {{if eq .TextEditor "infix-system:nano" }}selected{{end}}>Nano</option>
|
||||
<option value="infix-system:vi" {{if eq .TextEditor "infix-system:vi" }}selected{{end}}>Vi</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="cfg-reset-col">
|
||||
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
|
||||
hx-delete="/configure/leaf?path=/ietf-system:system/infix-system:text-editor&redirect=/configure/system"
|
||||
hx-swap="none"
|
||||
hx-confirm="Reset text editor to its YANG default?">↺</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Login banner</th>
|
||||
<td>
|
||||
<textarea class="cfg-input cfg-input-mono" name="motd_banner" rows="4"
|
||||
placeholder="Displayed after SSH/console login…">{{.MotdBanner}}</textarea>
|
||||
</td>
|
||||
<td class="cfg-reset-col">
|
||||
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
|
||||
hx-delete="/configure/leaf?path=/ietf-system:system/infix-system:motd-banner&redirect=/configure/system"
|
||||
hx-swap="none"
|
||||
hx-confirm="Reset login banner to its YANG default?">↺</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="cfg-card-footer">
|
||||
<button class="btn btn-primary" type="submit">Save</button>
|
||||
<span class="cfg-save-status"></span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</section>
|
||||
|
||||
{{/* ── Timezone autocomplete datalist ──────────────────────────────────── */}}
|
||||
<datalist id="tz-list">
|
||||
<option>Africa/Abidjan</option><option>Africa/Accra</option><option>Africa/Addis_Ababa</option>
|
||||
<option>Africa/Algiers</option><option>Africa/Asmara</option><option>Africa/Bamako</option>
|
||||
<option>Africa/Bangui</option><option>Africa/Banjul</option><option>Africa/Bissau</option>
|
||||
<option>Africa/Blantyre</option><option>Africa/Brazzaville</option><option>Africa/Bujumbura</option>
|
||||
<option>Africa/Cairo</option><option>Africa/Casablanca</option><option>Africa/Ceuta</option>
|
||||
<option>Africa/Conakry</option><option>Africa/Dakar</option><option>Africa/Dar_es_Salaam</option>
|
||||
<option>Africa/Djibouti</option><option>Africa/Douala</option><option>Africa/El_Aaiun</option>
|
||||
<option>Africa/Freetown</option><option>Africa/Gaborone</option><option>Africa/Harare</option>
|
||||
<option>Africa/Johannesburg</option><option>Africa/Juba</option><option>Africa/Kampala</option>
|
||||
<option>Africa/Khartoum</option><option>Africa/Kigali</option><option>Africa/Kinshasa</option>
|
||||
<option>Africa/Lagos</option><option>Africa/Libreville</option><option>Africa/Lome</option>
|
||||
<option>Africa/Luanda</option><option>Africa/Lubumbashi</option><option>Africa/Lusaka</option>
|
||||
<option>Africa/Malabo</option><option>Africa/Maputo</option><option>Africa/Maseru</option>
|
||||
<option>Africa/Mbabane</option><option>Africa/Mogadishu</option><option>Africa/Monrovia</option>
|
||||
<option>Africa/Nairobi</option><option>Africa/Ndjamena</option><option>Africa/Niamey</option>
|
||||
<option>Africa/Nouakchott</option><option>Africa/Ouagadougou</option><option>Africa/Porto-Novo</option>
|
||||
<option>Africa/Sao_Tome</option><option>Africa/Tripoli</option><option>Africa/Tunis</option>
|
||||
<option>Africa/Windhoek</option>
|
||||
<option>America/Adak</option><option>America/Anchorage</option><option>America/Anguilla</option>
|
||||
<option>America/Antigua</option><option>America/Araguaina</option>
|
||||
<option>America/Argentina/Buenos_Aires</option><option>America/Argentina/Catamarca</option>
|
||||
<option>America/Argentina/Cordoba</option><option>America/Argentina/Jujuy</option>
|
||||
<option>America/Argentina/La_Rioja</option><option>America/Argentina/Mendoza</option>
|
||||
<option>America/Argentina/Rio_Gallegos</option><option>America/Argentina/Salta</option>
|
||||
<option>America/Argentina/San_Juan</option><option>America/Argentina/San_Luis</option>
|
||||
<option>America/Argentina/Tucuman</option><option>America/Argentina/Ushuaia</option>
|
||||
<option>America/Aruba</option><option>America/Asuncion</option><option>America/Atikokan</option>
|
||||
<option>America/Bahia</option><option>America/Bahia_Banderas</option>
|
||||
<option>America/Barbados</option><option>America/Belem</option><option>America/Belize</option>
|
||||
<option>America/Blanc-Sablon</option><option>America/Boa_Vista</option>
|
||||
<option>America/Bogota</option><option>America/Boise</option>
|
||||
<option>America/Cambridge_Bay</option><option>America/Campo_Grande</option>
|
||||
<option>America/Cancun</option><option>America/Caracas</option><option>America/Cayenne</option>
|
||||
<option>America/Cayman</option><option>America/Chicago</option><option>America/Chihuahua</option>
|
||||
<option>America/Costa_Rica</option><option>America/Creston</option><option>America/Cuiaba</option>
|
||||
<option>America/Curacao</option><option>America/Danmarkshavn</option><option>America/Dawson</option>
|
||||
<option>America/Dawson_Creek</option><option>America/Denver</option><option>America/Detroit</option>
|
||||
<option>America/Dominica</option><option>America/Edmonton</option><option>America/Eirunepe</option>
|
||||
<option>America/El_Salvador</option><option>America/Fortaleza</option>
|
||||
<option>America/Glace_Bay</option><option>America/Godthab</option>
|
||||
<option>America/Goose_Bay</option><option>America/Grand_Turk</option>
|
||||
<option>America/Grenada</option><option>America/Guadeloupe</option>
|
||||
<option>America/Guatemala</option><option>America/Guayaquil</option><option>America/Guyana</option>
|
||||
<option>America/Halifax</option><option>America/Havana</option><option>America/Hermosillo</option>
|
||||
<option>America/Indiana/Indianapolis</option><option>America/Indiana/Knox</option>
|
||||
<option>America/Indiana/Marengo</option><option>America/Indiana/Petersburg</option>
|
||||
<option>America/Indiana/Tell_City</option><option>America/Indiana/Vevay</option>
|
||||
<option>America/Indiana/Vincennes</option><option>America/Indiana/Winamac</option>
|
||||
<option>America/Inuvik</option><option>America/Iqaluit</option><option>America/Jamaica</option>
|
||||
<option>America/Juneau</option><option>America/Kentucky/Louisville</option>
|
||||
<option>America/Kentucky/Monticello</option><option>America/Kralendijk</option>
|
||||
<option>America/La_Paz</option><option>America/Lima</option><option>America/Los_Angeles</option>
|
||||
<option>America/Lower_Princes</option><option>America/Maceio</option>
|
||||
<option>America/Managua</option><option>America/Manaus</option><option>America/Marigot</option>
|
||||
<option>America/Martinique</option><option>America/Matamoros</option>
|
||||
<option>America/Mazatlan</option><option>America/Menominee</option><option>America/Merida</option>
|
||||
<option>America/Metlakatla</option><option>America/Mexico_City</option>
|
||||
<option>America/Miquelon</option><option>America/Moncton</option>
|
||||
<option>America/Monterrey</option><option>America/Montevideo</option>
|
||||
<option>America/Montserrat</option><option>America/Nassau</option>
|
||||
<option>America/New_York</option><option>America/Nipigon</option><option>America/Nome</option>
|
||||
<option>America/Noronha</option><option>America/North_Dakota/Beulah</option>
|
||||
<option>America/North_Dakota/Center</option><option>America/North_Dakota/New_Salem</option>
|
||||
<option>America/Ojinaga</option><option>America/Panama</option>
|
||||
<option>America/Pangnirtung</option><option>America/Paramaribo</option>
|
||||
<option>America/Phoenix</option><option>America/Port-au-Prince</option>
|
||||
<option>America/Port_of_Spain</option><option>America/Porto_Velho</option>
|
||||
<option>America/Puerto_Rico</option><option>America/Rainy_River</option>
|
||||
<option>America/Rankin_Inlet</option><option>America/Recife</option>
|
||||
<option>America/Regina</option><option>America/Resolute</option>
|
||||
<option>America/Rio_Branco</option><option>America/Santa_Isabel</option>
|
||||
<option>America/Santarem</option><option>America/Santiago</option>
|
||||
<option>America/Santo_Domingo</option><option>America/Sao_Paulo</option>
|
||||
<option>America/Scoresbysund</option><option>America/Sitka</option>
|
||||
<option>America/St_Barthelemy</option><option>America/St_Johns</option>
|
||||
<option>America/St_Kitts</option><option>America/St_Lucia</option>
|
||||
<option>America/St_Thomas</option><option>America/St_Vincent</option>
|
||||
<option>America/Swift_Current</option><option>America/Tegucigalpa</option>
|
||||
<option>America/Thule</option><option>America/Thunder_Bay</option>
|
||||
<option>America/Tijuana</option><option>America/Toronto</option><option>America/Tortola</option>
|
||||
<option>America/Vancouver</option><option>America/Whitehorse</option>
|
||||
<option>America/Winnipeg</option><option>America/Yakutat</option>
|
||||
<option>America/Yellowknife</option>
|
||||
<option>Antarctica/Casey</option><option>Antarctica/Davis</option>
|
||||
<option>Antarctica/DumontDUrville</option><option>Antarctica/Macquarie</option>
|
||||
<option>Antarctica/Mawson</option><option>Antarctica/McMurdo</option>
|
||||
<option>Antarctica/Palmer</option><option>Antarctica/Rothera</option>
|
||||
<option>Antarctica/Syowa</option><option>Antarctica/Vostok</option>
|
||||
<option>Arctic/Longyearbyen</option>
|
||||
<option>Asia/Aden</option><option>Asia/Almaty</option><option>Asia/Amman</option>
|
||||
<option>Asia/Anadyr</option><option>Asia/Aqtau</option><option>Asia/Aqtobe</option>
|
||||
<option>Asia/Ashgabat</option><option>Asia/Baghdad</option><option>Asia/Bahrain</option>
|
||||
<option>Asia/Baku</option><option>Asia/Bangkok</option><option>Asia/Beirut</option>
|
||||
<option>Asia/Bishkek</option><option>Asia/Brunei</option><option>Asia/Choibalsan</option>
|
||||
<option>Asia/Chongqing</option><option>Asia/Colombo</option><option>Asia/Damascus</option>
|
||||
<option>Asia/Dhaka</option><option>Asia/Dili</option><option>Asia/Dubai</option>
|
||||
<option>Asia/Dushanbe</option><option>Asia/Gaza</option><option>Asia/Harbin</option>
|
||||
<option>Asia/Hebron</option><option>Asia/Ho_Chi_Minh</option><option>Asia/Hong_Kong</option>
|
||||
<option>Asia/Hovd</option><option>Asia/Irkutsk</option><option>Asia/Jakarta</option>
|
||||
<option>Asia/Jayapura</option><option>Asia/Jerusalem</option><option>Asia/Kabul</option>
|
||||
<option>Asia/Kamchatka</option><option>Asia/Karachi</option><option>Asia/Kashgar</option>
|
||||
<option>Asia/Kathmandu</option><option>Asia/Khandyga</option><option>Asia/Kolkata</option>
|
||||
<option>Asia/Krasnoyarsk</option><option>Asia/Kuala_Lumpur</option><option>Asia/Kuching</option>
|
||||
<option>Asia/Kuwait</option><option>Asia/Macau</option><option>Asia/Magadan</option>
|
||||
<option>Asia/Makassar</option><option>Asia/Manila</option><option>Asia/Muscat</option>
|
||||
<option>Asia/Nicosia</option><option>Asia/Novokuznetsk</option><option>Asia/Novosibirsk</option>
|
||||
<option>Asia/Omsk</option><option>Asia/Oral</option><option>Asia/Phnom_Penh</option>
|
||||
<option>Asia/Pontianak</option><option>Asia/Pyongyang</option><option>Asia/Qatar</option>
|
||||
<option>Asia/Qyzylorda</option><option>Asia/Rangoon</option><option>Asia/Riyadh</option>
|
||||
<option>Asia/Sakhalin</option><option>Asia/Samarkand</option><option>Asia/Seoul</option>
|
||||
<option>Asia/Shanghai</option><option>Asia/Singapore</option><option>Asia/Taipei</option>
|
||||
<option>Asia/Tashkent</option><option>Asia/Tbilisi</option><option>Asia/Tehran</option>
|
||||
<option>Asia/Thimphu</option><option>Asia/Tokyo</option><option>Asia/Ulaanbaatar</option>
|
||||
<option>Asia/Urumqi</option><option>Asia/Ust-Nera</option><option>Asia/Vientiane</option>
|
||||
<option>Asia/Vladivostok</option><option>Asia/Yakutsk</option><option>Asia/Yekaterinburg</option>
|
||||
<option>Asia/Yerevan</option>
|
||||
<option>Atlantic/Azores</option><option>Atlantic/Bermuda</option><option>Atlantic/Canary</option>
|
||||
<option>Atlantic/Cape_Verde</option><option>Atlantic/Faroe</option>
|
||||
<option>Atlantic/Madeira</option><option>Atlantic/Reykjavik</option>
|
||||
<option>Atlantic/South_Georgia</option><option>Atlantic/St_Helena</option>
|
||||
<option>Atlantic/Stanley</option>
|
||||
<option>Australia/Adelaide</option><option>Australia/Brisbane</option>
|
||||
<option>Australia/Broken_Hill</option><option>Australia/Currie</option>
|
||||
<option>Australia/Darwin</option><option>Australia/Eucla</option>
|
||||
<option>Australia/Hobart</option><option>Australia/Lindeman</option>
|
||||
<option>Australia/Lord_Howe</option><option>Australia/Melbourne</option>
|
||||
<option>Australia/Perth</option><option>Australia/Sydney</option>
|
||||
<option>Europe/Amsterdam</option><option>Europe/Andorra</option><option>Europe/Athens</option>
|
||||
<option>Europe/Belgrade</option><option>Europe/Berlin</option><option>Europe/Bratislava</option>
|
||||
<option>Europe/Brussels</option><option>Europe/Bucharest</option><option>Europe/Budapest</option>
|
||||
<option>Europe/Busingen</option><option>Europe/Chisinau</option><option>Europe/Copenhagen</option>
|
||||
<option>Europe/Dublin</option><option>Europe/Gibraltar</option><option>Europe/Guernsey</option>
|
||||
<option>Europe/Helsinki</option><option>Europe/Isle_of_Man</option><option>Europe/Istanbul</option>
|
||||
<option>Europe/Jersey</option><option>Europe/Kaliningrad</option><option>Europe/Kiev</option>
|
||||
<option>Europe/Lisbon</option><option>Europe/Ljubljana</option><option>Europe/London</option>
|
||||
<option>Europe/Luxembourg</option><option>Europe/Madrid</option><option>Europe/Malta</option>
|
||||
<option>Europe/Mariehamn</option><option>Europe/Minsk</option><option>Europe/Monaco</option>
|
||||
<option>Europe/Moscow</option><option>Europe/Nicosia</option><option>Europe/Oslo</option>
|
||||
<option>Europe/Paris</option><option>Europe/Podgorica</option><option>Europe/Prague</option>
|
||||
<option>Europe/Riga</option><option>Europe/Rome</option><option>Europe/Samara</option>
|
||||
<option>Europe/San_Marino</option><option>Europe/Sarajevo</option>
|
||||
<option>Europe/Simferopol</option><option>Europe/Skopje</option><option>Europe/Sofia</option>
|
||||
<option>Europe/Stockholm</option><option>Europe/Tallinn</option><option>Europe/Tirane</option>
|
||||
<option>Europe/Uzhgorod</option><option>Europe/Vaduz</option><option>Europe/Vatican</option>
|
||||
<option>Europe/Vienna</option><option>Europe/Vilnius</option><option>Europe/Volgograd</option>
|
||||
<option>Europe/Warsaw</option><option>Europe/Zagreb</option><option>Europe/Zaporozhye</option>
|
||||
<option>Europe/Zurich</option>
|
||||
<option>Indian/Antananarivo</option><option>Indian/Chagos</option>
|
||||
<option>Indian/Christmas</option><option>Indian/Cocos</option><option>Indian/Comoro</option>
|
||||
<option>Indian/Kerguelen</option><option>Indian/Mahe</option><option>Indian/Maldives</option>
|
||||
<option>Indian/Mauritius</option><option>Indian/Mayotte</option><option>Indian/Reunion</option>
|
||||
<option>Pacific/Apia</option><option>Pacific/Auckland</option><option>Pacific/Chatham</option>
|
||||
<option>Pacific/Chuuk</option><option>Pacific/Easter</option><option>Pacific/Efate</option>
|
||||
<option>Pacific/Enderbury</option><option>Pacific/Fakaofo</option><option>Pacific/Fiji</option>
|
||||
<option>Pacific/Funafuti</option><option>Pacific/Galapagos</option>
|
||||
<option>Pacific/Gambier</option><option>Pacific/Guadalcanal</option><option>Pacific/Guam</option>
|
||||
<option>Pacific/Honolulu</option><option>Pacific/Johnston</option>
|
||||
<option>Pacific/Kiritimati</option><option>Pacific/Kosrae</option>
|
||||
<option>Pacific/Kwajalein</option><option>Pacific/Majuro</option>
|
||||
<option>Pacific/Marquesas</option><option>Pacific/Midway</option><option>Pacific/Nauru</option>
|
||||
<option>Pacific/Niue</option><option>Pacific/Norfolk</option><option>Pacific/Noumea</option>
|
||||
<option>Pacific/Pago_Pago</option><option>Pacific/Palau</option><option>Pacific/Pitcairn</option>
|
||||
<option>Pacific/Pohnpei</option><option>Pacific/Port_Moresby</option>
|
||||
<option>Pacific/Rarotonga</option><option>Pacific/Saipan</option><option>Pacific/Tahiti</option>
|
||||
<option>Pacific/Tarawa</option><option>Pacific/Tongatapu</option><option>Pacific/Wake</option>
|
||||
<option>Pacific/Wallis</option>
|
||||
</datalist>
|
||||
|
||||
{{template "configure-toolbar" .}}
|
||||
</div>
|
||||
|
||||
{{end}}
|
||||
@@ -0,0 +1,261 @@
|
||||
{{define "configure-users.html"}}
|
||||
{{template "base" .}}
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="configure-page">
|
||||
{{if .Error}}
|
||||
<div class="alert alert-error">{{.Error}}</div>
|
||||
{{end}}
|
||||
|
||||
<section class="info-grid">
|
||||
|
||||
{{/* ── Users ───────────────────────────────────────────────────────── */}}
|
||||
<section class="info-card info-grid-span-2">
|
||||
<div class="card-header">Users</div>
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table cfg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Shell</th>
|
||||
<th style="text-align:center">SSH Keys</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range $i, $u := .Users}}
|
||||
<tr>
|
||||
<td>
|
||||
<button class="key-row-toggle" type="button"
|
||||
aria-expanded="false" data-target="user-{{$i}}">
|
||||
<span class="key-row-arrow" aria-hidden="true">▶</span>{{.Name}}
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<form hx-post="/configure/users/{{.Name}}/shell" hx-swap="none"
|
||||
class="user-shell-form">
|
||||
<select class="cfg-input" name="shell">
|
||||
<option value="infix-system:clish" {{if eq .Shell "infix-system:clish"}}selected{{end}}>CLI Shell</option>
|
||||
<option value="infix-system:bash" {{if eq .Shell "infix-system:bash" }}selected{{end}}>Bash</option>
|
||||
<option value="infix-system:sh" {{if eq .Shell "infix-system:sh" }}selected{{end}}>Sh</option>
|
||||
<option value="infix-system:false" {{if eq .Shell "infix-system:false"}}selected{{end}}>Disabled</option>
|
||||
</select>
|
||||
<button class="btn btn-primary btn-sm" type="submit">Save</button>
|
||||
<span class="cfg-save-status"></span>
|
||||
</form>
|
||||
</td>
|
||||
<td style="text-align:center">{{.KeyCount}}</td>
|
||||
<td style="text-align:right">
|
||||
<button type="button" class="btn-icon btn-icon-danger"
|
||||
hx-delete="/configure/users/{{.Name}}"
|
||||
hx-confirm="Delete user {{.Name}}?"
|
||||
hx-swap="none"
|
||||
title="Delete user">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="key-detail-row" id="key-detail-user-{{$i}}">
|
||||
<td colspan="4" class="key-detail-cell">
|
||||
<div class="key-detail-body">
|
||||
|
||||
{{/* Change password */}}
|
||||
<form hx-post="/configure/users/{{.Name}}/password" hx-swap="none">
|
||||
<div class="section-subtitle">Change Password</div>
|
||||
<table class="info-table">
|
||||
<tr>
|
||||
<th>New password</th>
|
||||
<td><input class="cfg-input" type="password" name="password"
|
||||
autocomplete="new-password"
|
||||
placeholder="Leave blank to keep current"></td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="cfg-card-footer" style="padding-left:0">
|
||||
<button class="btn btn-primary btn-sm" type="submit">Change Password</button>
|
||||
<span class="cfg-save-status"></span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{/* Authorized SSH keys */}}
|
||||
<div>
|
||||
<div class="section-subtitle">Authorized SSH Keys ({{.KeyCount}})</div>
|
||||
{{if .AuthorizedKeys}}
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table cfg-table">
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Algorithm</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .AuthorizedKeys}}
|
||||
<tr>
|
||||
<td>{{.Name}}</td>
|
||||
<td>{{.Algorithm}}</td>
|
||||
<td style="text-align:right">
|
||||
<button type="button" class="btn-icon btn-icon-danger"
|
||||
hx-delete="/configure/users/{{$u.Name}}/keys/{{.Name}}"
|
||||
hx-confirm="Remove key {{.Name}}?"
|
||||
hx-swap="none"
|
||||
title="Remove key">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
<form hx-post="/configure/users/{{.Name}}/keys" hx-swap="none"
|
||||
style="margin-top:0.5rem">
|
||||
<table class="info-table">
|
||||
<tr>
|
||||
<th>Key name</th>
|
||||
<td><input class="cfg-input" type="text" name="key_name"
|
||||
placeholder="e.g. laptop"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Public key</th>
|
||||
<td><textarea class="cfg-input cfg-input-mono" name="key_data" rows="3"
|
||||
placeholder="ssh-rsa AAAA… or ssh-ed25519 AAAA…"></textarea></td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="cfg-card-footer" style="padding-left:0">
|
||||
<button class="btn btn-primary btn-sm" type="submit">Add Key</button>
|
||||
<span class="cfg-save-status"></span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
|
||||
{{/* Hidden add-user row — revealed by the + Add user button */}}
|
||||
<tr id="add-user-row" hidden>
|
||||
<td colspan="4" class="key-detail-cell">
|
||||
<form hx-post="/configure/users" hx-swap="none" class="user-add-inline">
|
||||
<input class="cfg-input" type="text" name="username" required
|
||||
pattern="[a-zA-Z0-9_][a-zA-Z0-9_.-]*" placeholder="Username">
|
||||
<input class="cfg-input" type="password" name="password" required
|
||||
autocomplete="new-password" placeholder="Password">
|
||||
<select class="cfg-input" name="shell">
|
||||
<option value="infix-system:clish">CLI Shell</option>
|
||||
<option value="infix-system:bash">Bash</option>
|
||||
<option value="infix-system:sh">Sh</option>
|
||||
<option value="infix-system:false">Disabled</option>
|
||||
</select>
|
||||
<button class="btn btn-primary btn-sm" type="submit">Add</button>
|
||||
<button type="button" class="btn btn-sm" data-hide="add-user-row">Cancel</button>
|
||||
<span class="cfg-save-status"></span>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="cfg-card-footer">
|
||||
<button type="button" class="btn-add-row" data-show="add-user-row">+ Add user</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
{{/* ── Groups ─────────────────────────────────────────────────────── */}}
|
||||
<section class="info-card info-grid-span-2">
|
||||
<div class="card-header">Groups</div>
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table cfg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Group</th>
|
||||
<th>Members</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range $i, $g := .Groups}}
|
||||
<tr>
|
||||
<td>
|
||||
<button class="key-row-toggle" type="button"
|
||||
aria-expanded="false" data-target="group-{{$i}}">
|
||||
<span class="key-row-arrow" aria-hidden="true">▶</span>{{.Name}}
|
||||
</button>
|
||||
</td>
|
||||
<td>{{.MembersSummary}}</td>
|
||||
</tr>
|
||||
<tr class="key-detail-row" id="key-detail-group-{{$i}}">
|
||||
<td colspan="2" class="key-detail-cell">
|
||||
<div class="key-detail-body">
|
||||
|
||||
<div class="section-subtitle">Members</div>
|
||||
{{if .UserNames}}
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table cfg-table">
|
||||
<thead><tr><th>Username</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .UserNames}}
|
||||
<tr>
|
||||
<td>{{.}}</td>
|
||||
<td style="text-align:right">
|
||||
<button type="button" class="btn-icon btn-icon-danger"
|
||||
hx-delete="/configure/users/groups/{{$g.Name}}/members/{{.}}"
|
||||
hx-confirm="Remove {{.}} from group {{$g.Name}}?"
|
||||
hx-target="closest tr"
|
||||
hx-swap="delete"
|
||||
title="Remove from group">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<p style="color:var(--text-muted);font-size:0.85rem">No members.</p>
|
||||
{{end}}
|
||||
|
||||
{{if .Available}}
|
||||
<form hx-post="/configure/users/groups/{{.Name}}/members" hx-swap="none"
|
||||
style="margin-top:0.75rem">
|
||||
<div class="section-subtitle">Add Members</div>
|
||||
<select class="cfg-input" name="members" multiple size="4">
|
||||
{{range .Available}}<option value="{{.}}">{{.}}</option>{{end}}
|
||||
</select>
|
||||
<div class="cfg-card-footer" style="padding-left:0">
|
||||
<button class="btn btn-primary btn-sm" type="submit">Add Selected</button>
|
||||
<span class="cfg-save-status"></span>
|
||||
</div>
|
||||
</form>
|
||||
{{end}}
|
||||
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</section>
|
||||
|
||||
{{template "configure-toolbar" .}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,117 @@
|
||||
{{define "nacm.html"}}
|
||||
{{template "base" .}}
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
{{if .Error}}
|
||||
<div class="alert alert-error">{{.Error}}</div>
|
||||
{{end}}
|
||||
|
||||
<section class="info-grid">
|
||||
|
||||
{{if not .Error}}
|
||||
<div class="info-card">
|
||||
<div class="card-header">NACM Settings</div>
|
||||
<table class="info-table">
|
||||
<tr><th>Enabled</th><td>{{.Enabled}}</td></tr>
|
||||
<tr><th>Default read</th><td>{{.ReadDefault}}</td></tr>
|
||||
<tr><th>Default write</th><td>{{.WriteDefault}}</td></tr>
|
||||
<tr><th>Default exec</th><td>{{.ExecDefault}}</td></tr>
|
||||
<tr><th>Denied operations</th><td>{{.DeniedOperations}}</td></tr>
|
||||
<tr><th>Denied writes</th><td>{{.DeniedDataWrites}}</td></tr>
|
||||
<tr><th>Denied notifications</th><td>{{.DeniedNotifications}}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Matrix}}
|
||||
<div class="info-card">
|
||||
<div class="card-header">Group Permissions</div>
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table nacm-matrix">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Group</th>
|
||||
<th class="nacm-col">Read</th>
|
||||
<th class="nacm-col">Write</th>
|
||||
<th class="nacm-col">Exec</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Matrix}}
|
||||
<tr>
|
||||
<td class="nacm-group">{{.Name}}</td>
|
||||
<td class="nacm-col"><span class="nacm-cell {{.Read.Class}}">{{.Read.Symbol}}</span></td>
|
||||
<td class="nacm-col"><span class="nacm-cell {{.Write.Class}}">{{.Write.Symbol}}</span></td>
|
||||
<td class="nacm-col"><span class="nacm-cell {{.Exec.Class}}">{{.Exec.Symbol}}</span></td>
|
||||
</tr>
|
||||
{{if .Restrictions}}
|
||||
<tr class="nacm-restrictions-row">
|
||||
<td></td>
|
||||
<td colspan="3" class="nacm-restrictions">Restricted: {{range $i, $r := .Restrictions}}{{if $i}}, {{end}}{{$r}}{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="nacm-legend">
|
||||
<span class="nacm-cell nacm-full">✓</span> Full
|
||||
<span class="nacm-cell nacm-restricted">⚠</span> Restricted
|
||||
<span class="nacm-cell nacm-denied">✗</span> Denied
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Users}}
|
||||
<div class="info-card">
|
||||
<div class="card-header">Users</div>
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Shell</th>
|
||||
<th>Login</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Users}}
|
||||
<tr>
|
||||
<td>{{.Name}}</td>
|
||||
<td>{{.Shell}}</td>
|
||||
<td>{{.Login}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Groups}}
|
||||
<div class="info-card">
|
||||
<div class="card-header">Groups</div>
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Group</th>
|
||||
<th>Members</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Groups}}
|
||||
<tr>
|
||||
<td>{{.Name}}</td>
|
||||
<td>{{.Members}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
</section>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user