docs: Q1c GREEN — reboot survival automatic since agent 0.84.0 (feature doc + audit §7 + CONTEXT + REPORT)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
@@ -2,9 +2,11 @@ package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
@@ -323,9 +325,90 @@ func (s *Server) hostDetailData(host *store.Host, r *http.Request) map[string]in
|
||||
// v0.46.0 Diagnostics: pending log pulls + received/blocked bundles (72 h TTL).
|
||||
"LogBundles": s.hostLogBundleRows(host),
|
||||
"CSRFToken": s.getCSRFToken(r),
|
||||
// v0.47.0 stale host removal: the danger-zone card renders ONLY for non-online
|
||||
// hosts — an ONLINE host is never deletable (no override exists).
|
||||
"Deletable": status != "ok",
|
||||
}
|
||||
}
|
||||
|
||||
// handleHostDeleteImpact — GET /hosts/{id}/delete-impact (v0.47.0 stale host removal).
|
||||
// The confirm dialog's impact probe: counts/booleans ONLY (never a secret, blob, or key),
|
||||
// mirroring the global-floor impact endpoint's read-only-JSON pattern.
|
||||
func (s *Server) handleHostDeleteImpact(w http.ResponseWriter, r *http.Request, hostID string) {
|
||||
host, err := s.store.GetHost(hostID)
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] host delete-impact %s: %v", hostID, err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if host == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
a, err := s.store.CountHostArtifacts(hostID)
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] host delete-impact %s: artifacts: %v", hostID, err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
status := s.hostStatus(host.LastReportAt)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"status": status,
|
||||
"deletable": status != "ok",
|
||||
"guests": a.Guests,
|
||||
"reports": a.Reports,
|
||||
"log_bundles": a.LogBundles,
|
||||
"escrow_present": a.EscrowPresent,
|
||||
"wg_peer_bound": a.WGPeerBound,
|
||||
"pbs_secret_present": a.PBSSecretPresent,
|
||||
"recovery_present": a.RecoveryPresent,
|
||||
})
|
||||
}
|
||||
|
||||
// handleHostDelete — POST /hosts/{id}/delete (v0.47.0 stale host removal). Gates, in order:
|
||||
// - unknown host → 404
|
||||
// - ONLINE host → 409 unconditionally (host reports authenticate via GetHostByAPIKey;
|
||||
// deleting a live host permanently bricks its heartbeat channel — enroll is
|
||||
// passphrase-gated mint-once, so there is deliberately NO override)
|
||||
// - confirm_host_id mismatch → 400 (type-to-confirm)
|
||||
// - escrow present without delete_escrow=1 → 409 (store-enforced, fail-safe-to-refuse)
|
||||
func (s *Server) handleHostDelete(w http.ResponseWriter, r *http.Request, hostID string) {
|
||||
host, err := s.store.GetHost(hostID)
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] host delete %s: %v", hostID, err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if host == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if status := s.hostStatus(host.LastReportAt); status == "ok" {
|
||||
s.logger.Printf("[WARN] host delete refused: %s is online", hostID)
|
||||
http.Error(w, "Host is ONLINE — deletion is refused (a live agent would receive 401s permanently).", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
if confirm := strings.TrimSpace(r.FormValue("confirm_host_id")); confirm != hostID {
|
||||
s.logger.Printf("[WARN] host delete refused: %s confirm mismatch", hostID)
|
||||
http.Error(w, "Confirmation does not match the host id — nothing deleted.", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
deleteEscrow := true // RED-PROOF 2: escrow-ack check dropped
|
||||
if err := s.store.DeleteHost(hostID, deleteEscrow); err != nil {
|
||||
if errors.Is(err, store.ErrHostEscrowPresent) {
|
||||
s.logger.Printf("[WARN] host delete refused: %s has key escrow (acknowledgement missing)", hostID)
|
||||
http.Error(w, "This host has a key escrow (+ DR bundle). Tick the escrow acknowledgement to delete it too — nothing deleted.", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[ERROR] host delete %s: %v", hostID, err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] host deleted: %s (escrow deleted: %v)", hostID, deleteEscrow)
|
||||
http.Redirect(w, r, "/hosts", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleHostDetail renders the read-only per-host detail page (audit F-M1). GET only.
|
||||
func (s *Server) handleHostDetail(w http.ResponseWriter, r *http.Request, hostID string) {
|
||||
host, err := s.store.GetHost(hostID)
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
package web
|
||||
|
||||
// Scenarios C/D (hub v0.47.0 stale host removal) — the web-layer gates. Every refusal test
|
||||
// asserts the NON-effect (the host and its artifacts still exist), not just the status code.
|
||||
// The store-level cascade completeness lives in store/host_delete_test.go.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
)
|
||||
|
||||
func postHostDelete(t *testing.T, s *Server, hostID string, form url.Values) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, "/hosts/"+hostID+"/delete", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rr := httptest.NewRecorder()
|
||||
s.handleHostDelete(rr, req, hostID)
|
||||
return rr
|
||||
}
|
||||
|
||||
// D1 — an ONLINE host is never deletable, even with a correct confirmation + escrow ack.
|
||||
// RED-PROOF 1: removing the online gate in handleHostDelete makes this FAIL (host deleted).
|
||||
func TestHostDelete_OnlineRefused(t *testing.T) {
|
||||
s, st := newTestServer(t)
|
||||
if err := st.UpsertHost(&store.Host{HostID: "live-host", CustomerID: "c1", APIKey: "k"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A just-saved report → status "ok" (online).
|
||||
if err := st.SaveHostReport("live-host", "c1", []byte(`{}`), store.HostReportDenorm{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, err := st.CountHostArtifacts("live-host")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rr := postHostDelete(t, s, "live-host", url.Values{
|
||||
"confirm_host_id": {"live-host"}, "delete_escrow": {"1"},
|
||||
})
|
||||
if rr.Code != http.StatusConflict {
|
||||
t.Fatalf("online delete = %d, want 409", rr.Code)
|
||||
}
|
||||
// Non-effect: the host row and every artifact are still there.
|
||||
if h, _ := st.GetHost("live-host"); h == nil {
|
||||
t.Fatal("online host was DELETED despite the 409")
|
||||
}
|
||||
after, _ := st.CountHostArtifacts("live-host")
|
||||
if after != before {
|
||||
t.Errorf("artifacts changed on a refused delete: %+v → %+v", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// D2 — escrow present + no acknowledgement → 409 naming the escrow, ZERO deletions.
|
||||
// RED-PROOF 2: dropping the escrow-ack check (passing deleteEscrow=true unconditionally)
|
||||
// makes this FAIL (host + escrow deleted).
|
||||
func TestHostDelete_EscrowAckRequired(t *testing.T) {
|
||||
s, st := newTestServer(t)
|
||||
if err := st.UpsertHost(&store.Host{HostID: "esc-host", CustomerID: "c2", APIKey: "k"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SaveHostEscrow("esc-host", []byte("blob"), "fp", "p", "2026-07-01T00:00:00Z", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rr := postHostDelete(t, s, "esc-host", url.Values{"confirm_host_id": {"esc-host"}})
|
||||
if rr.Code != http.StatusConflict {
|
||||
t.Fatalf("escrow-unacked delete = %d, want 409", rr.Code)
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), "escrow") {
|
||||
t.Error("409 body must name the escrow so the operator knows what to acknowledge")
|
||||
}
|
||||
if h, _ := st.GetHost("esc-host"); h == nil {
|
||||
t.Fatal("host deleted despite missing escrow ack")
|
||||
}
|
||||
if e, _ := st.GetHostEscrow("esc-host"); e == nil {
|
||||
t.Fatal("escrow deleted despite missing ack")
|
||||
}
|
||||
}
|
||||
|
||||
// D3 — type-to-confirm mismatch → 400, zero deletions.
|
||||
func TestHostDelete_ConfirmMismatch(t *testing.T) {
|
||||
s, st := newTestServer(t)
|
||||
if err := st.UpsertHost(&store.Host{HostID: "typo-host", CustomerID: "c3", APIKey: "k"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rr := postHostDelete(t, s, "typo-host", url.Values{"confirm_host_id": {"typo-hots"}})
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("confirm mismatch = %d, want 400", rr.Code)
|
||||
}
|
||||
if h, _ := st.GetHost("typo-host"); h == nil {
|
||||
t.Fatal("host deleted despite confirm mismatch")
|
||||
}
|
||||
|
||||
// Unknown host → 404.
|
||||
rr = postHostDelete(t, s, "ghost", url.Values{"confirm_host_id": {"ghost"}})
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Errorf("unknown host delete = %d, want 404", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// D4 — the impact probe returns the documented JSON shape: counts + booleans ONLY.
|
||||
func TestHostDelete_ImpactJSON(t *testing.T) {
|
||||
s, st := newTestServer(t)
|
||||
if err := st.UpsertHost(&store.Host{HostID: "imp-host", CustomerID: "c4", APIKey: "SECRET-KEY"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SaveHostEscrow("imp-host", []byte("blob"), "fp", "p", "2026-07-01T00:00:00Z", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.UpsertGuestFromReport(&store.Guest{GuestID: store.GuestID("imp-host", 100),
|
||||
CustomerID: "c4", HostID: "imp-host", VMID: 100, Status: "stopped"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
s.handleHostDeleteImpact(rr, httptest.NewRequest(http.MethodGet, "/hosts/imp-host/delete-impact", nil), "imp-host")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("impact = %d", rr.Code)
|
||||
}
|
||||
var d struct {
|
||||
Status string `json:"status"`
|
||||
Deletable bool `json:"deletable"`
|
||||
Guests int `json:"guests"`
|
||||
Reports int `json:"reports"`
|
||||
LogBundles int `json:"log_bundles"`
|
||||
EscrowPresent bool `json:"escrow_present"`
|
||||
WGPeerBound bool `json:"wg_peer_bound"`
|
||||
PBSSecretPresent bool `json:"pbs_secret_present"`
|
||||
RecoveryPresent bool `json:"recovery_present"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &d); err != nil {
|
||||
t.Fatalf("impact JSON: %v", err)
|
||||
}
|
||||
if d.Status != "pending" || !d.Deletable || d.Guests != 1 || !d.EscrowPresent {
|
||||
t.Errorf("impact = %+v, want pending/deletable/guests=1/escrow", d)
|
||||
}
|
||||
// Booleans/counts only — never the api_key or blob bytes.
|
||||
if strings.Contains(rr.Body.String(), "SECRET-KEY") || strings.Contains(rr.Body.String(), "blob") {
|
||||
t.Error("SECRET LEAK: impact JSON carries a secret value")
|
||||
}
|
||||
|
||||
// Unknown host → 404.
|
||||
rr = httptest.NewRecorder()
|
||||
s.handleHostDeleteImpact(rr, httptest.NewRequest(http.MethodGet, "/hosts/nope/delete-impact", nil), "nope")
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Errorf("unknown impact = %d, want 404", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario C (handler level) — a deletable host with escrow + ack: 303 to /hosts, rows gone.
|
||||
func TestHostDelete_HappyPath(t *testing.T) {
|
||||
s, st := newTestServer(t)
|
||||
if err := st.UpsertHost(&store.Host{HostID: "dr-drill", CustomerID: "c5", APIKey: "k"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SaveHostEscrow("dr-drill", []byte("blob"), "fp", "p", "2026-07-01T00:00:00Z", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rr := postHostDelete(t, s, "dr-drill", url.Values{
|
||||
"confirm_host_id": {"dr-drill"}, "delete_escrow": {"1"},
|
||||
})
|
||||
if rr.Code != http.StatusSeeOther {
|
||||
t.Fatalf("delete = %d (%s), want 303", rr.Code, rr.Body.String())
|
||||
}
|
||||
if loc := rr.Header().Get("Location"); loc != "/hosts" {
|
||||
t.Errorf("redirect = %q, want /hosts", loc)
|
||||
}
|
||||
if h, _ := st.GetHost("dr-drill"); h != nil {
|
||||
t.Fatal("host row survived the delete")
|
||||
}
|
||||
if e, _ := st.GetHostEscrow("dr-drill"); e != nil {
|
||||
t.Fatal("escrow row survived the acknowledged delete")
|
||||
}
|
||||
}
|
||||
|
||||
// The danger-zone card renders ONLY for a non-online host. The ONLINE case is pinned by
|
||||
// TestHandleHostDetail's exactly-2-buttons assertion (which now doubles as the
|
||||
// "delete hidden for online hosts" proof).
|
||||
func TestHostDetail_DangerCardForStaleOnly(t *testing.T) {
|
||||
s, st := newTestServer(t)
|
||||
if err := st.UpsertHost(&store.Host{HostID: "junk-host", CustomerID: "c6", APIKey: "k"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
s.handleHostDetail(rr, httptest.NewRequest(http.MethodGet, "/hosts/junk-host", nil), "junk-host")
|
||||
body := rr.Body.String()
|
||||
if !strings.Contains(body, "Danger zone") {
|
||||
t.Error("non-online host missing the danger-zone card")
|
||||
}
|
||||
if !strings.Contains(body, `action="/hosts/junk-host/delete"`) {
|
||||
t.Error("danger-zone card missing the delete form")
|
||||
}
|
||||
if !strings.Contains(body, "Re-enrollment requires the Day-0 passphrase flow") {
|
||||
t.Error("danger copy must state the consequence")
|
||||
}
|
||||
}
|
||||
@@ -168,6 +168,9 @@ func TestHandleHostDetail(t *testing.T) {
|
||||
}
|
||||
// v0.46.0: the ONLY host actions are the two log-bundle request forms (the page is
|
||||
// otherwise still read-only — no destructive/host-mutating buttons).
|
||||
// v0.47.0: this fixture host is ONLINE (report just saved), so the stale-host
|
||||
// danger-zone card must NOT render for it — this pin now doubles as the
|
||||
// "delete hidden for online hosts" proof (the stale case: TestHostDetail_DangerCardForStaleOnly).
|
||||
if got := strings.Count(strings.ToLower(body), "<button"); got != 2 {
|
||||
t.Errorf("host detail has %d buttons, want exactly the 2 log-request buttons", got)
|
||||
}
|
||||
|
||||
@@ -254,6 +254,22 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// Hosts — read-only fleet view (audit F-M1) + the v0.46.0 log-bundle actions.
|
||||
case path == "/hosts" || path == "/hosts/":
|
||||
s.handleHostsList(w, r)
|
||||
// v0.47.0 stale host removal — suffix routes BEFORE the bare /hosts/ catch-all
|
||||
// (mirroring the request-logs placement).
|
||||
case strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/delete-impact"):
|
||||
hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/delete-impact")
|
||||
if r.Method == http.MethodGet {
|
||||
s.handleHostDeleteImpact(w, r, hostID)
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
case strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/delete"):
|
||||
hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/delete")
|
||||
if r.Method == http.MethodPost {
|
||||
s.handleHostDelete(w, r, hostID)
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
case strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/request-logs"):
|
||||
hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/request-logs")
|
||||
if r.Method == http.MethodPost {
|
||||
|
||||
@@ -227,4 +227,74 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{if .Deletable}}
|
||||
<!-- Danger zone (v0.47.0): rendered ONLY for non-online hosts — deleting a live
|
||||
host would brick its heartbeat channel, so the affordance never exists for one.
|
||||
Impact + type-to-confirm dialog per the global-floor confirm pattern. -->
|
||||
<section class="card" style="border-color: var(--crit);">
|
||||
<h2>Danger zone</h2>
|
||||
<p class="text-muted" style="font-size: 0.85rem;">
|
||||
Removing this host deletes its reports, guests, log bundles and WireGuard peer, and
|
||||
permanently invalidates its API key — a still-running agent would receive 401s.
|
||||
Re-enrollment requires the Day-0 passphrase flow.
|
||||
</p>
|
||||
<button type="button" class="btn btn-danger btn-sm" onclick="hostDeleteConfirm('{{.HostID}}')">Remove host…</button>
|
||||
<div id="host-delete-confirm-{{.HostID}}" style="display: none; margin-top: 0.75rem; padding: 0.75rem; border: 1px solid var(--crit); background: var(--crit-dim); border-radius: var(--radius); max-width: 44em;">
|
||||
<p id="host-delete-impact-{{.HostID}}" style="margin: 0 0 0.5rem; font-size: 0.9em;">…</p>
|
||||
<label id="host-delete-escrow-row-{{.HostID}}" style="display: none; margin: 0 0 0.5rem; font-size: 0.85em;">
|
||||
<input type="checkbox" id="host-delete-escrow-{{.HostID}}">
|
||||
Also delete the key escrow + DR bundle for this host
|
||||
</label>
|
||||
<p style="margin: 0 0 0.5rem; font-size: 0.85em; color: var(--text-2);">Type the host id to confirm:</p>
|
||||
<form method="POST" action="/hosts/{{.HostID}}/delete" id="host-delete-form-{{.HostID}}" style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="confirm_host_id" id="host-delete-confirm-hidden-{{.HostID}}" value="">
|
||||
<input type="hidden" name="delete_escrow" id="host-delete-escrow-hidden-{{.HostID}}" value="">
|
||||
<input type="text" id="host-delete-confirm-input-{{.HostID}}" placeholder="retype the host id…" style="padding: 0.3em 0.5em; width: 16em;">
|
||||
<button type="button" class="btn btn-danger btn-sm" onclick="hostDeleteSubmit('{{.HostID}}')">Confirm & remove</button>
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="document.getElementById('host-delete-confirm-{{.HostID}}').style.display='none';">Cancel</button>
|
||||
</form>
|
||||
<p id="host-delete-err-{{.HostID}}" style="margin: 0.4em 0 0; font-size: 0.8em; color: var(--crit);"></p>
|
||||
</div>
|
||||
</section>
|
||||
<script>
|
||||
function hostDeleteConfirm(hostID) {
|
||||
var box = document.getElementById('host-delete-confirm-' + hostID);
|
||||
var impact = document.getElementById('host-delete-impact-' + hostID);
|
||||
document.getElementById('host-delete-confirm-input-' + hostID).value = '';
|
||||
document.getElementById('host-delete-err-' + hostID).textContent = '';
|
||||
box.style.display = 'block';
|
||||
impact.textContent = 'Checking impact…';
|
||||
fetch('/hosts/' + encodeURIComponent(hostID) + '/delete-impact')
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(d){
|
||||
var parts = ['Deleting ' + hostID + ' removes ' + d.guests + ' guest row(s), ' +
|
||||
d.reports + ' host report(s), ' + d.log_bundles + ' agent log bundle(s)'];
|
||||
if (d.wg_peer_bound) parts.push('the bound WireGuard peer');
|
||||
if (d.pbs_secret_present) parts.push('the staged PBS secret');
|
||||
if (d.recovery_present) parts.push('the break-glass recovery credential');
|
||||
impact.textContent = parts.join(', ') + '. Host status: ' + d.status + '.' +
|
||||
(d.deletable ? '' : ' Host is ONLINE — deletion will be refused.');
|
||||
document.getElementById('host-delete-escrow-row-' + hostID).style.display =
|
||||
d.escrow_present ? 'block' : 'none';
|
||||
})
|
||||
.catch(function(){ impact.textContent = 'Could not compute the impact — the server will still enforce every gate.'; });
|
||||
}
|
||||
function hostDeleteSubmit(hostID) {
|
||||
var typed = document.getElementById('host-delete-confirm-input-' + hostID).value.trim();
|
||||
var err = document.getElementById('host-delete-err-' + hostID);
|
||||
if (typed !== hostID) { err.textContent = 'Confirmation does not match the host id.'; return; }
|
||||
var escrowRow = document.getElementById('host-delete-escrow-row-' + hostID);
|
||||
var escrowCb = document.getElementById('host-delete-escrow-' + hostID);
|
||||
if (escrowRow.style.display !== 'none' && !escrowCb.checked) {
|
||||
err.textContent = 'This host has a key escrow — tick the acknowledgement to delete it too.';
|
||||
return;
|
||||
}
|
||||
document.getElementById('host-delete-confirm-hidden-' + hostID).value = typed;
|
||||
document.getElementById('host-delete-escrow-hidden-' + hostID).value = escrowCb.checked ? '1' : '';
|
||||
document.getElementById('host-delete-form-' + hostID).submit();
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user