update gui

This commit is contained in:
2026-07-28 20:45:34 -05:00
parent 8dcee4121b
commit 510d5490f9
13 changed files with 606 additions and 40 deletions
+257 -25
View File
@@ -2,6 +2,7 @@ package app
import (
"context"
"database/sql"
"errors"
"fmt"
"net/http"
@@ -10,6 +11,8 @@ import (
"time"
)
const maxAuthorizationDays = 90
func (s *Server) handlePortal(w http.ResponseWriter, r *http.Request) {
if hasAuditQuery(r) {
http.Redirect(w, r, "/portal/audit?"+r.URL.RawQuery, http.StatusSeeOther)
@@ -50,12 +53,18 @@ func (s *Server) handlePortal(w http.ResponseWriter, r *http.Request) {
Actions: actions,
NewCode: r.URL.Query().Get("code"),
DefaultHostLimit: s.cfg.DefaultHostLimit,
DefaultDuration: strconv.Itoa(int(s.cfg.DefaultDuration.Hours())),
DefaultDays: durationDays(s.cfg.DefaultDuration),
MaxHostLimit: s.cfg.MaxHostLimit,
Notice: r.URL.Query().Get("notice"),
CurrentView: "codes",
})
}
func durationDays(duration time.Duration) int {
const day = 24 * time.Hour
return int((duration + day - 1) / day)
}
func hasAuditQuery(r *http.Request) bool {
query := r.URL.Query()
for _, field := range []string{
@@ -342,16 +351,19 @@ func (s *Server) listAuthorizations(r *http.Request) ([]authorizationRecord, err
), '')
FROM authorizations a
WHERE a.created_at > datetime('now', '-30 days')
OR (a.revoked_at IS NULL AND a.expires_at > CURRENT_TIMESTAMP)
ORDER BY a.created_at DESC
LIMIT 100`,
)
if err != nil {
return nil, err
}
defer rows.Close()
var records []authorizationRecord
for rows.Next() {
var record authorizationRecord
record := authorizationRecord{
PackageIDs: make(map[uint64]bool),
ActionSlugs: make(map[string]bool),
}
if err := rows.Scan(
&record.ID,
&record.CodeHint,
@@ -369,7 +381,76 @@ func (s *Server) listAuthorizations(r *http.Request) ([]authorizationRecord, err
}
records = append(records, record)
}
return records, rows.Err()
if err := rows.Err(); err != nil {
_ = rows.Close()
return nil, err
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := s.loadAuthorizationSelections(r.Context(), records); err != nil {
return nil, err
}
return records, nil
}
func (s *Server) loadAuthorizationSelections(
ctx context.Context,
records []authorizationRecord,
) error {
indices := make(map[uint64]int, len(records))
for index := range records {
indices[records[index].ID] = index
}
if len(indices) == 0 {
return nil
}
packageRows, err := s.db.QueryContext(
ctx,
`SELECT authorization_id, package_id FROM authorization_packages`,
)
if err != nil {
return err
}
for packageRows.Next() {
var authorizationID uint64
var packageID uint64
if err := packageRows.Scan(&authorizationID, &packageID); err != nil {
_ = packageRows.Close()
return err
}
if index, ok := indices[authorizationID]; ok {
records[index].PackageIDs[packageID] = true
}
}
if err := packageRows.Err(); err != nil {
_ = packageRows.Close()
return err
}
if err := packageRows.Close(); err != nil {
return err
}
actionRows, err := s.db.QueryContext(
ctx,
`SELECT authorization_id, action_slug FROM authorization_actions`,
)
if err != nil {
return err
}
defer actionRows.Close()
for actionRows.Next() {
var authorizationID uint64
var actionSlug string
if err := actionRows.Scan(&authorizationID, &actionSlug); err != nil {
return err
}
if index, ok := indices[authorizationID]; ok {
records[index].ActionSlugs[actionSlug] = true
}
}
return actionRows.Err()
}
func (s *Server) handleCreateAuthorization(w http.ResponseWriter, r *http.Request) {
@@ -379,9 +460,9 @@ func (s *Server) handleCreateAuthorization(w http.ResponseWriter, r *http.Reques
http.Error(w, "invalid host limit", http.StatusBadRequest)
return
}
durationHours, err := strconv.Atoi(r.FormValue("duration_hours"))
if err != nil || durationHours < 1 || durationHours > 24 {
http.Error(w, "duration must be between 1 and 24 hours", http.StatusBadRequest)
durationDays, err := strconv.Atoi(r.FormValue("duration_days"))
if err != nil || durationDays < 1 || durationDays > maxAuthorizationDays {
http.Error(w, "duration must be between 1 and 90 days", http.StatusBadRequest)
return
}
packageIDs := r.Form["package_id"]
@@ -396,7 +477,7 @@ func (s *Server) handleCreateAuthorization(w http.ResponseWriter, r *http.Reques
return
}
codeHash := hashValue(code)
expiresAt := time.Now().UTC().Add(time.Duration(durationHours) * time.Hour)
expiresAt := time.Now().UTC().Add(time.Duration(durationDays) * 24 * time.Hour)
customerLabel := strings.TrimSpace(r.FormValue("customer_label"))
if len(customerLabel) > 255 {
http.Error(w, "customer label is too long", http.StatusBadRequest)
@@ -421,14 +502,41 @@ func (s *Server) handleCreateAuthorization(w http.ResponseWriter, r *http.Reques
return
}
authorizationID, _ := result.LastInsertId()
if status, err := saveAuthorizationSelections(
r.Context(), tx, uint64(authorizationID), packageIDs, actionSlugs,
); err != nil {
http.Error(w, err.Error(), status)
return
}
if err := tx.Commit(); err != nil {
http.Error(w, "unable to save authorization", http.StatusInternalServerError)
return
}
id := uint64(authorizationID)
_ = s.audit(r.Context(), "authorization_created", tech.Login, &id, "", "", s.clientIP(r),
fmt.Sprintf("host_limit=%d duration_days=%d customer=%q", hostLimit, durationDays, customerLabel))
http.Redirect(w, r, "/portal?code="+code, http.StatusSeeOther)
}
func saveAuthorizationSelections(
ctx context.Context,
tx *sql.Tx,
authorizationID uint64,
packageIDs []string,
actionSlugs []string,
) (int, error) {
seenPackages := make(map[uint64]bool)
for _, rawID := range packageIDs {
packageID, err := strconv.ParseUint(rawID, 10, 64)
if err != nil {
http.Error(w, "invalid package selection", http.StatusBadRequest)
return
return http.StatusBadRequest, errors.New("invalid package selection")
}
if seenPackages[packageID] {
continue
}
seenPackages[packageID] = true
result, err := tx.ExecContext(
r.Context(),
ctx,
`INSERT INTO authorization_packages
(authorization_id, package_id, package_slug, display_name,
package_name, package_version, file_name, sha256)
@@ -439,35 +547,148 @@ func (s *Server) handleCreateAuthorization(w http.ResponseWriter, r *http.Reques
authorizationID, packageID,
)
if err != nil {
http.Error(w, "unable to authorize package", http.StatusInternalServerError)
return
return http.StatusInternalServerError, errors.New("unable to authorize package")
}
affected, _ := result.RowsAffected()
if affected != 1 {
http.Error(w, "selected package is unavailable", http.StatusBadRequest)
return
return http.StatusBadRequest, errors.New("selected package is unavailable")
}
}
seenActions := make(map[string]bool)
for _, actionSlug := range actionSlugs {
actionSlug = strings.TrimSpace(actionSlug)
if !validSlug(actionSlug) {
http.Error(w, "invalid installer action", http.StatusBadRequest)
return
return http.StatusBadRequest, errors.New("invalid installer action")
}
if seenActions[actionSlug] {
continue
}
seenActions[actionSlug] = true
result, err := tx.ExecContext(
r.Context(),
ctx,
`INSERT INTO authorization_actions (authorization_id, action_slug)
SELECT ?, slug FROM installer_actions
WHERE slug = ? AND enabled = TRUE`,
authorizationID, actionSlug,
)
if err != nil {
http.Error(w, "unable to authorize installer action", http.StatusInternalServerError)
return
return http.StatusInternalServerError, errors.New("unable to authorize installer action")
}
affected, _ := result.RowsAffected()
if affected != 1 {
http.Error(w, "selected installer action is unavailable", http.StatusBadRequest)
return http.StatusBadRequest, errors.New("selected installer action is unavailable")
}
}
return 0, nil
}
func (s *Server) handleUpdateAuthorization(w http.ResponseWriter, r *http.Request) {
tech, _ := s.currentTechnician(r)
id, err := parseUintPath(r, "id")
if err != nil {
http.Error(w, "invalid authorization", http.StatusBadRequest)
return
}
hostLimit, err := strconv.Atoi(r.FormValue("host_limit"))
if err != nil || hostLimit < 1 || hostLimit > s.cfg.MaxHostLimit {
http.Error(w, "invalid host limit", http.StatusBadRequest)
return
}
extendDays, err := strconv.Atoi(r.FormValue("extend_days"))
if err != nil || extendDays < 0 || extendDays > maxAuthorizationDays {
http.Error(w, "extension must be between 0 and 90 days", http.StatusBadRequest)
return
}
packageIDs := r.Form["package_id"]
actionSlugs := r.Form["action_slug"]
if len(packageIDs) == 0 && len(actionSlugs) == 0 {
http.Error(w, "select at least one package or installer action", http.StatusBadRequest)
return
}
tx, err := s.db.BeginTx(r.Context(), nil)
if err != nil {
http.Error(w, "unable to update authorization", http.StatusInternalServerError)
return
}
defer tx.Rollback()
var oldHostLimit int
var hostCount int
var expiresAt time.Time
var revokedAt sql.NullTime
err = tx.QueryRowContext(
r.Context(),
`SELECT a.host_limit,
(SELECT COUNT(*) FROM authorization_hosts h
WHERE h.authorization_id = a.id),
a.expires_at, a.revoked_at
FROM authorizations a
WHERE a.id = ?`,
id,
).Scan(&oldHostLimit, &hostCount, &expiresAt, &revokedAt)
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "authorization not found", http.StatusNotFound)
return
}
if err != nil {
http.Error(w, "unable to update authorization", http.StatusInternalServerError)
return
}
if revokedAt.Valid || !time.Now().Before(expiresAt) {
http.Error(w, "closed authorizations cannot be edited", http.StatusBadRequest)
return
}
if hostLimit < hostCount {
http.Error(
w,
fmt.Sprintf("host limit cannot be lower than %d enrolled hosts", hostCount),
http.StatusBadRequest,
)
return
}
newExpiresAt := expiresAt.Add(time.Duration(extendDays) * 24 * time.Hour)
if _, err := tx.ExecContext(
r.Context(),
`DELETE FROM authorization_packages WHERE authorization_id = ?`,
id,
); err != nil {
http.Error(w, "unable to update authorized packages", http.StatusInternalServerError)
return
}
if _, err := tx.ExecContext(
r.Context(),
`DELETE FROM authorization_actions WHERE authorization_id = ?`,
id,
); err != nil {
http.Error(w, "unable to update installer actions", http.StatusInternalServerError)
return
}
if status, err := saveAuthorizationSelections(
r.Context(), tx, id, packageIDs, actionSlugs,
); err != nil {
http.Error(w, err.Error(), status)
return
}
if _, err := tx.ExecContext(
r.Context(),
`UPDATE authorizations SET host_limit = ?, expires_at = ? WHERE id = ?`,
hostLimit, newExpiresAt, id,
); err != nil {
http.Error(w, "unable to update authorization", http.StatusInternalServerError)
return
}
if extendDays > 0 {
if _, err := tx.ExecContext(
r.Context(),
`UPDATE download_sessions
SET expires_at = ?
WHERE authorization_id = ? AND revoked_at IS NULL`,
newExpiresAt, id,
); err != nil {
http.Error(w, "unable to extend download sessions", http.StatusInternalServerError)
return
}
}
@@ -475,10 +696,21 @@ func (s *Server) handleCreateAuthorization(w http.ResponseWriter, r *http.Reques
http.Error(w, "unable to save authorization", http.StatusInternalServerError)
return
}
id := uint64(authorizationID)
_ = s.audit(r.Context(), "authorization_created", tech.Login, &id, "", "", s.clientIP(r),
fmt.Sprintf("host_limit=%d duration_hours=%d customer=%q", hostLimit, durationHours, customerLabel))
http.Redirect(w, r, "/portal?code="+code, http.StatusSeeOther)
_ = s.audit(
r.Context(),
"authorization_updated",
tech.Login,
&id,
"",
"",
s.clientIP(r),
fmt.Sprintf(
"host_limit=%d->%d extend_days=%d packages=%d actions=%d",
oldHostLimit, hostLimit, extendDays, len(packageIDs), len(actionSlugs),
),
)
http.Redirect(w, r, "/portal?notice=Authorization+updated", http.StatusSeeOther)
}
func (s *Server) handleRevokeAuthorization(w http.ResponseWriter, r *http.Request) {
+209
View File
@@ -0,0 +1,209 @@
package app
import (
"database/sql"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
"time"
_ "modernc.org/sqlite"
)
func newAuthorizationTestServer(t *testing.T) (*Server, string) {
t.Helper()
db, err := sql.Open("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
if err != nil {
t.Fatal(err)
}
db.SetMaxOpenConns(1)
t.Cleanup(func() { _ = db.Close() })
migration, err := os.ReadFile("../../migrations/001_initial.sql")
if err != nil {
t.Fatal(err)
}
if _, err := db.Exec(string(migration)); err != nil {
t.Fatal(err)
}
sessionToken := "authorization-test-session"
sessionHash := hashValue(sessionToken)
if _, err := db.Exec(
`INSERT INTO technician_sessions
(token_hash, csrf_token, gitea_login, display_name, expires_at)
VALUES (?, 'csrf-token', 'taiadmin', 'TAI Admin', ?)`,
sessionHash[:], time.Now().UTC().Add(time.Hour),
); err != nil {
t.Fatal(err)
}
return &Server{
db: db,
cfg: Config{
MaxHostLimit: 25,
CookieSecret: []byte("0123456789abcdef0123456789abcdef"),
},
}, sessionToken
}
func seedAuthorizationForUpdate(t *testing.T, server *Server) time.Time {
t.Helper()
for _, values := range [][]any{
{1, "sentinelone-linux", "SentinelOne", "sentinelone-linux", "1.0", "s1.deb", strings.Repeat("a", 64), true},
{2, "support-tool", "Support Tool", "support-tool", "2.0", "support.deb", strings.Repeat("b", 64), true},
} {
if _, err := server.db.Exec(
`INSERT INTO packages
(id, slug, display_name, package_name, package_version,
file_name, sha256, enabled)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
values...,
); err != nil {
t.Fatal(err)
}
}
expiresAt := time.Now().UTC().Add(48 * time.Hour).Truncate(time.Second)
if _, err := server.db.Exec(
`INSERT INTO authorizations
(id, code_hash, code_hint, created_by, customer_label, host_limit, expires_at)
VALUES (1, ?, 'ABCDE', 'taiadmin', 'Test migration', 3, ?)`,
make([]byte, 32), expiresAt,
); err != nil {
t.Fatal(err)
}
if _, err := server.db.Exec(
`INSERT INTO authorization_packages
(authorization_id, package_id, package_slug, display_name,
package_name, package_version, file_name, sha256)
SELECT 1, id, slug, display_name, package_name, package_version,
file_name, sha256
FROM packages WHERE id = 1`,
); err != nil {
t.Fatal(err)
}
if _, err := server.db.Exec(
`INSERT INTO authorization_hosts
(id, authorization_id, host_fingerprint, hostname)
VALUES
(1, 1, ?, 'pve01'),
(2, 1, ?, 'pve02')`,
make([]byte, 32), bytesOf(1, 32),
); err != nil {
t.Fatal(err)
}
if _, err := server.db.Exec(
`INSERT INTO download_sessions
(token_hash, authorization_id, authorization_host_id, expires_at)
VALUES (?, 1, 1, ?)`,
make([]byte, 32), expiresAt,
); err != nil {
t.Fatal(err)
}
return expiresAt
}
func authorizationUpdateRequest(
t *testing.T,
server *Server,
sessionToken string,
values url.Values,
) *httptest.ResponseRecorder {
t.Helper()
request := httptest.NewRequest(
http.MethodPost,
"/portal/authorizations/1/update",
strings.NewReader(values.Encode()),
)
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.AddCookie(&http.Cookie{Name: sessionCookieName, Value: sessionToken})
request.SetPathValue("id", "1")
response := httptest.NewRecorder()
server.handleUpdateAuthorization(response, request)
return response
}
func TestUpdateAuthorizationExtendsAndReplacesAccess(t *testing.T) {
server, sessionToken := newAuthorizationTestServer(t)
oldExpiresAt := seedAuthorizationForUpdate(t, server)
response := authorizationUpdateRequest(t, server, sessionToken, url.Values{
"csrf_token": {"csrf-token"},
"host_limit": {"6"},
"extend_days": {"7"},
"package_id": {"2"},
"action_slug": {"install-rmm"},
})
if response.Code != http.StatusSeeOther {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
var hostLimit int
var expiresAt time.Time
if err := server.db.QueryRow(
`SELECT host_limit, expires_at FROM authorizations WHERE id = 1`,
).Scan(&hostLimit, &expiresAt); err != nil {
t.Fatal(err)
}
if hostLimit != 6 {
t.Fatalf("host limit = %d, want 6", hostLimit)
}
wantExpiresAt := oldExpiresAt.Add(7 * 24 * time.Hour)
if !expiresAt.Equal(wantExpiresAt) {
t.Fatalf("expiration = %s, want %s", expiresAt, wantExpiresAt)
}
var selectedPackage uint64
if err := server.db.QueryRow(
`SELECT package_id FROM authorization_packages WHERE authorization_id = 1`,
).Scan(&selectedPackage); err != nil {
t.Fatal(err)
}
if selectedPackage != 2 {
t.Fatalf("selected package = %d, want 2", selectedPackage)
}
var selectedAction string
if err := server.db.QueryRow(
`SELECT action_slug FROM authorization_actions WHERE authorization_id = 1`,
).Scan(&selectedAction); err != nil {
t.Fatal(err)
}
if selectedAction != "install-rmm" {
t.Fatalf("selected action = %q, want install-rmm", selectedAction)
}
var sessionExpiresAt time.Time
if err := server.db.QueryRow(
`SELECT expires_at FROM download_sessions WHERE authorization_id = 1`,
).Scan(&sessionExpiresAt); err != nil {
t.Fatal(err)
}
if !sessionExpiresAt.Equal(wantExpiresAt) {
t.Fatalf("session expiration = %s, want %s", sessionExpiresAt, wantExpiresAt)
}
}
func TestUpdateAuthorizationRejectsLimitBelowEnrolledHosts(t *testing.T) {
server, sessionToken := newAuthorizationTestServer(t)
seedAuthorizationForUpdate(t, server)
response := authorizationUpdateRequest(t, server, sessionToken, url.Values{
"csrf_token": {"csrf-token"},
"host_limit": {"1"},
"extend_days": {"0"},
"package_id": {"1"},
})
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", response.Code, http.StatusBadRequest)
}
}
func bytesOf(value byte, size int) []byte {
result := make([]byte, size)
for index := range result {
result[index] = value
}
return result
}
+14 -2
View File
@@ -76,8 +76,8 @@ func LoadConfig() (Config, error) {
}
cfg.CookieSecure = cfg.PublicURL.Scheme == "https"
cfg.DefaultDuration, err = time.ParseDuration(
envDefault("TAPM_DEFAULT_DURATION", "3h"),
cfg.DefaultDuration, err = parseAuthorizationDuration(
envDefault("TAPM_DEFAULT_DURATION", "7d"),
)
if err != nil || cfg.DefaultDuration <= 0 {
return cfg, fmt.Errorf("TAPM_DEFAULT_DURATION must be positive")
@@ -145,6 +145,18 @@ func LoadConfig() (Config, error) {
return cfg, nil
}
func parseAuthorizationDuration(value string) (time.Duration, error) {
value = strings.TrimSpace(value)
if strings.HasSuffix(value, "d") {
days, err := strconv.Atoi(strings.TrimSuffix(value, "d"))
if err != nil || days < 1 {
return 0, fmt.Errorf("invalid day duration")
}
return time.Duration(days) * 24 * time.Hour, nil
}
return time.ParseDuration(value)
}
func parseAbsoluteURL(name string, allowInsecureHTTP bool) (*url.URL, error) {
return parseURLValue(name, os.Getenv(name), allowInsecureHTTP)
}
+25
View File
@@ -3,6 +3,7 @@ package app
import (
"strings"
"testing"
"time"
)
func setRequiredConfigEnvironment(t *testing.T, scheme string) {
@@ -63,3 +64,27 @@ func TestConfigAcceptsExplicitInternalGiteaURL(t *testing.T) {
t.Fatalf("GiteaInternalURL = %q, want configured private URL", got)
}
}
func TestConfigAcceptsDayBasedAuthorizationDuration(t *testing.T) {
setRequiredConfigEnvironment(t, "https")
t.Setenv("TAPM_DEFAULT_DURATION", "7d")
cfg, err := LoadConfig()
if err != nil {
t.Fatal(err)
}
if cfg.DefaultDuration != 7*24*time.Hour {
t.Fatalf("DefaultDuration = %s, want 7 days", cfg.DefaultDuration)
}
}
func TestConfigKeepsLegacyHourDurationCompatibility(t *testing.T) {
setRequiredConfigEnvironment(t, "https")
t.Setenv("TAPM_DEFAULT_DURATION", "3h")
cfg, err := LoadConfig()
if err != nil {
t.Fatal(err)
}
if cfg.DefaultDuration != 3*time.Hour {
t.Fatalf("DefaultDuration = %s, want 3 hours", cfg.DefaultDuration)
}
}
+5 -1
View File
@@ -66,6 +66,8 @@ type authorizationRecord struct {
RevokedAt sql.NullTime
Packages string
Actions string
PackageIDs map[uint64]bool
ActionSlugs map[string]bool
}
type auditRecord struct {
@@ -103,7 +105,8 @@ type pageData struct {
FleetHosts []fleetHostRecord
NewCode string
DefaultHostLimit int
DefaultDuration string
DefaultDays int
MaxHostLimit int
Error string
Notice string
GiteaLoginURL string
@@ -200,6 +203,7 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("GET /portal/audit", s.requireTechnician(s.handleAuditPortal))
mux.HandleFunc("GET /portal/hosts", s.requireTechnician(s.handleFleetPortal))
mux.HandleFunc("POST /portal/authorizations", s.requireTechnician(s.handleCreateAuthorization))
mux.HandleFunc("POST /portal/authorizations/{id}/update", s.requireTechnician(s.handleUpdateAuthorization))
mux.HandleFunc("POST /portal/authorizations/{id}/revoke", s.requireTechnician(s.handleRevokeAuthorization))
mux.HandleFunc("POST /portal/packages", s.requireTechnician(s.handleUpsertPackage))
mux.HandleFunc("POST /portal/packages/upload", s.requireTechnician(s.handleUploadPackage))
+9 -5
View File
@@ -30,12 +30,16 @@ func TestPortalTemplateExecutes(t *testing.T) {
Enabled: true,
}},
Authorizations: []authorizationRecord{{
ID: 1,
CreatedBy: "taiadmin",
ExpiresAt: time.Now().Add(time.Hour),
Packages: "SentinelOne Linux Agent",
Actions: "Install RMM",
ID: 1,
CreatedBy: "taiadmin",
ExpiresAt: time.Now().Add(time.Hour),
Packages: "SentinelOne Linux Agent",
Actions: "Install RMM",
PackageIDs: map[uint64]bool{1: true},
ActionSlugs: map[string]bool{"install-rmm": true},
}},
DefaultDays: 7,
MaxHostLimit: 25,
})
if err != nil {
t.Fatal(err)
+6
View File
@@ -212,6 +212,12 @@ legend { margin-bottom: 8px; }
}
.authorization.active .status, .status.enabled { color: var(--accent-dark); border-color: rgba(105, 145, 64, .42); background: rgba(185, 217, 130, .25); }
.code-hint { color: var(--amber); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.authorization-editor { margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--line); }
.authorization-editor summary { color: var(--accent-dark); cursor: pointer; font-weight: 800; }
.authorization-editor[open] summary { margin-bottom: 18px; }
.authorization-editor fieldset { padding: 14px; }
.authorization-editor .button { justify-self: start; }
.authorization-revoke { margin-top: 14px; }
dl { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px 24px; margin: 18px 0; }
dl div { min-width: 0; }
dt { color: var(--muted); font-size: .7rem; text-transform: uppercase; }
+59 -5
View File
@@ -37,7 +37,7 @@
<div class="policy-card">
<span>Default policy</span>
<strong>{{.DefaultHostLimit}} hosts</strong>
<strong>{{.DefaultDuration}} hours</strong>
<strong>{{.DefaultDays}} days</strong>
</div>
</section>
@@ -60,11 +60,11 @@
<div class="field-row">
<label>
Maximum hosts
<input name="host_limit" type="number" min="1" max="25" value="{{.DefaultHostLimit}}" required>
<input name="host_limit" type="number" min="1" max="{{.MaxHostLimit}}" value="{{.DefaultHostLimit}}" required>
</label>
<label>
Duration in hours
<input name="duration_hours" type="number" min="1" max="24" value="{{.DefaultDuration}}" required>
Duration in days
<input name="duration_days" type="number" min="1" max="90" value="{{.DefaultDays}}" required>
</label>
</div>
@@ -116,6 +116,7 @@
<div class="authorization-list">
{{range .Authorizations}}
{{$authorization := .}}
<article class="authorization {{if isActive .}}active{{else}}closed{{end}}">
<div class="authorization-top">
<div>
@@ -132,7 +133,60 @@
<div><dt>Created by</dt><dd>{{.CreatedBy}}</dd></div>
</dl>
{{if isActive .}}
<form action="/portal/authorizations/{{.ID}}/revoke" method="post">
<details class="authorization-editor">
<summary>Edit access</summary>
<form action="/portal/authorizations/{{.ID}}/update" method="post" class="stack compact">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<div class="field-row">
<label>
Maximum hosts
<input name="host_limit" type="number" min="1" max="{{$.MaxHostLimit}}" value="{{.HostLimit}}" required>
</label>
<label>
Extend by days
<input name="extend_days" type="number" min="0" max="90" value="0" required>
</label>
</div>
<small class="form-help">Use 0 to keep the current expiration. Extensions are added to the existing deadline.</small>
<fieldset>
<legend>Allowed packages</legend>
{{range $.Packages}}
{{if .Enabled}}
<label class="package-choice">
<input type="checkbox" name="package_id" value="{{.ID}}" {{if index $authorization.PackageIDs .ID}}checked{{end}}>
<span>
<strong>{{.DisplayName}}</strong>
<small>{{.PackageVersion}}</small>
</span>
</label>
{{end}}
{{else}}
<p class="empty">No packages have been enabled yet.</p>
{{end}}
</fieldset>
<fieldset>
<legend>Allowed installer actions</legend>
{{range $.Actions}}
{{if .Enabled}}
<label class="package-choice">
<input type="checkbox" name="action_slug" value="{{.Slug}}" {{if index $authorization.ActionSlugs .Slug}}checked{{end}}>
<span>
<strong>{{.DisplayName}}</strong>
<small>Authorization gate</small>
</span>
</label>
{{end}}
{{else}}
<p class="empty">No installer actions have been enabled.</p>
{{end}}
</fieldset>
<button class="button secondary" type="submit">Save authorization</button>
</form>
</details>
<form class="authorization-revoke" action="/portal/authorizations/{{.ID}}/revoke" method="post">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button class="text-button danger" type="submit">Revoke now</button>
</form>