diff --git a/internal/app/handlers.go b/internal/app/handlers.go index 73fe987..b9da980 100644 --- a/internal/app/handlers.go +++ b/internal/app/handlers.go @@ -1250,10 +1250,8 @@ func (h *Handlers) UserUpdatePost(w http.ResponseWriter, r *http.Request) { var ( existingAllowed sql.NullString - existingPriv sql.NullString - existingPSK sql.NullString ) - err := h.svc.DB.QueryRow(`SELECT allowed_ips, private_key, preshared_key FROM vpn_users WHERE id = ? LIMIT 1`, id).Scan(&existingAllowed, &existingPriv, &existingPSK) + err := h.svc.DB.QueryRow(`SELECT allowed_ips FROM vpn_users WHERE id = ? LIMIT 1`, id).Scan(&existingAllowed) if err != nil { http.NotFound(w, r) return @@ -1295,36 +1293,33 @@ func (h *Handlers) UserUpdatePost(w http.ResponseWriter, r *http.Request) { isEnabled = 1 } - // Allow setting keys only if they were not previously set (parity with original UI behavior). + // Allow updating keys and PSK at any time (client configs must be updated to match). privateKey := strings.TrimSpace(r.FormValue("private_key")) publicKey := strings.TrimSpace(r.FormValue("public_key")) psk := strings.TrimSpace(r.FormValue("preshared_key")) - updatePriv := any(nil) - updatePub := any(nil) - updatePSK := any(nil) + var updatePriv any = nil + var updatePub any = nil + var updatePSK any = nil - if !existingPriv.Valid || strings.TrimSpace(existingPriv.String) == "" { - if privateKey != "" { - derivedPub, err := wireguard.DerivePublicKey(privateKey) - if err != nil { - w.Header().Set("HX-Trigger", `{"toast":{"type":"danger","message":"Invalid private key."}}`) - http.Error(w, "invalid private key", http.StatusBadRequest) - return - } - if publicKey != "" && publicKey != derivedPub { - w.Header().Set("HX-Trigger", `{"toast":{"type":"danger","message":"Public key does not match this private key."}}`) - http.Error(w, "public key mismatch", http.StatusBadRequest) - return - } - updatePriv = privateKey - updatePub = derivedPub + if privateKey != "" { + derivedPub, err := wireguard.DerivePublicKey(privateKey) + if err != nil { + w.Header().Set("HX-Trigger", `{"toast":{"type":"danger","message":"Invalid private key."}}`) + http.Error(w, "invalid private key", http.StatusBadRequest) + return } - } - if !existingPSK.Valid || strings.TrimSpace(existingPSK.String) == "" { - if psk != "" { - updatePSK = psk + if publicKey != "" && publicKey != derivedPub { + w.Header().Set("HX-Trigger", `{"toast":{"type":"danger","message":"Public key does not match this private key."}}`) + http.Error(w, "public key mismatch", http.StatusBadRequest) + return } + updatePriv = privateKey + updatePub = derivedPub + } + + if psk != "" { + updatePSK = psk } _, err = h.svc.DB.Exec(` diff --git a/internal/view/format.go b/internal/view/format.go index 850b76c..4a1ba1a 100644 --- a/internal/view/format.go +++ b/internal/view/format.go @@ -38,3 +38,7 @@ func humanBytes(n any) string { return fmt.Sprintf("%.2f TiB", v/float64(tib)) } +func addInt64(a, b int64) int64 { + return a + b +} + diff --git a/internal/view/templates/base.tmpl b/internal/view/templates/base.tmpl index dd1075f..7759ea4 100644 --- a/internal/view/templates/base.tmpl +++ b/internal/view/templates/base.tmpl @@ -209,6 +209,73 @@ if (npMainSwapTarget(evt.detail)) setPageLoading(false); }); + async function modalGenerateKeys() { + const res = await fetch('/api/users/generate-keys', { method: 'POST', credentials: 'same-origin' }); + if (!res.ok) return; + const j = await res.json(); + var pk = document.getElementById('modal_private_key'); + var pub = document.getElementById('modal_public_key'); + if (pk) pk.value = j.privateKey || ''; + if (pub) pub.value = j.publicKey || ''; + try { lucide.createIcons(); } catch (e) {} + } + + async function modalDerivePublicKey() { + var pkEl = document.getElementById('modal_private_key'); + var pubEl = document.getElementById('modal_public_key'); + if (!pkEl || !pubEl) return; + var priv = pkEl.value || ''; + if (!priv) { + pubEl.value = ''; + return; + } + const res = await fetch('/api/users/derive-public-key', { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ privateKey: priv }) + }); + if (!res.ok) { + pubEl.value = ''; + return; + } + const j = await res.json(); + pubEl.value = j.publicKey || ''; + } + + async function modalGeneratePSK() { + const res = await fetch('/api/users/generate-psk', { method: 'POST', credentials: 'same-origin' }); + if (!res.ok) return; + const j = await res.json(); + var psk = document.getElementById('modal_psk'); + if (psk) psk.value = j.presharedKey || ''; + try { lucide.createIcons(); } catch (e) {} + } + + function modalCopy(id) { + var el = document.getElementById(id); + if (!el) return; + var val = el.value || ''; + if (!val && el.disabled) { + val = el.getAttribute('value') || ''; + } + navigator.clipboard.writeText(val).catch(function () {}); + } + + function modalTogglePk() { + modalToggleSecret('modal_private_key'); + } + + function modalTogglePsk() { + modalToggleSecret('modal_psk'); + } + + function modalToggleSecret(id) { + var el = document.getElementById(id); + if (!el) return; + el.type = (el.type === 'password') ? 'text' : 'password'; + } + var __bwChart = null; async function initBandwidthChart() { var canvas = document.getElementById('bandwidth-chart'); diff --git a/internal/view/templates/partials/add_user_modal.tmpl b/internal/view/templates/partials/add_user_modal.tmpl index 9bf33d8..622a258 100644 --- a/internal/view/templates/partials/add_user_modal.tmpl +++ b/internal/view/templates/partials/add_user_modal.tmpl @@ -181,55 +181,6 @@ } }, 0); })(); - - async function modalGenerateKeys() { - const res = await fetch('/api/users/generate-keys', { method: 'POST', credentials: 'same-origin' }); - if (!res.ok) return; - const j = await res.json(); - document.getElementById('modal_private_key').value = j.privateKey || ''; - document.getElementById('modal_public_key').value = j.publicKey || ''; - try { lucide.createIcons(); } catch(e) {} - } - - async function modalDerivePublicKey() { - const priv = document.getElementById('modal_private_key').value || ''; - if (!priv) { - document.getElementById('modal_public_key').value = ''; - return; - } - const res = await fetch('/api/users/derive-public-key', { - method: 'POST', - credentials: 'same-origin', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ privateKey: priv }) - }); - if (!res.ok) { - document.getElementById('modal_public_key').value = ''; - return; - } - const j = await res.json(); - document.getElementById('modal_public_key').value = j.publicKey || ''; - } - - async function modalGeneratePSK() { - const res = await fetch('/api/users/generate-psk', { method: 'POST', credentials: 'same-origin' }); - if (!res.ok) return; - const j = await res.json(); - document.getElementById('modal_psk').value = j.presharedKey || ''; - try { lucide.createIcons(); } catch(e) {} - } - - function modalCopy(id) { - const el = document.getElementById(id); - if (!el) return; - navigator.clipboard.writeText(el.value || '').catch(function(){}); - } - - function modalTogglePk() { - const el = document.getElementById('modal_private_key'); - if (!el) return; - el.type = (el.type === 'password') ? 'text' : 'password'; - } {{end}} diff --git a/internal/view/templates/partials/edit_user_modal.tmpl b/internal/view/templates/partials/edit_user_modal.tmpl index 23ec22a..c017535 100644 --- a/internal/view/templates/partials/edit_user_modal.tmpl +++ b/internal/view/templates/partials/edit_user_modal.tmpl @@ -58,8 +58,7 @@ -
- {{if .PrivateKey}}Private key cannot be changed after it has been set.{{else if .ImportedNoPrivKey}}Imported peer: paste the client private key that matches the stored public key. Generate Keys is disabled so the peer is not accidentally rotated.{{else}}WireGuard private key (auto-generated or manually entered){{end}} + {{if .ImportedNoPrivKey}}Imported peer: paste the client private key or generate a new key pair (this updates the stored public key).{{else if .PrivateKey}}Use the eye icon to reveal the stored key. Generating new keys rotates this peer — clients must update their config.{{else}}WireGuard private key (auto-generated or manually entered){{end}}
@@ -106,8 +105,7 @@ +
+ + +
- {{if .PresharedKey}}Preshared key cannot be changed after it has been set.{{else}}Optional: Adds an extra layer of symmetric encryption for post-quantum security{{end}} + Optional: adds an extra layer of symmetric encryption. Generate or edit anytime — clients must match the saved PSK.
@@ -217,7 +221,7 @@ try { lucide.createIcons(); } catch (e) {} const pk = document.getElementById('modal_private_key'); - if (pk && !pk.disabled) { + if (pk) { let t = null; pk.addEventListener('input', function () { if (t) clearTimeout(t); diff --git a/internal/view/templates/users.tmpl b/internal/view/templates/users.tmpl index 661a597..b4075c0 100644 --- a/internal/view/templates/users.tmpl +++ b/internal/view/templates/users.tmpl @@ -56,22 +56,40 @@ tr.users-row:hover { background-color: rgba(148, 163, 184, 0.065) !important; } + th.users-sortable { + cursor: pointer; + user-select: none; + white-space: nowrap; + } + th.users-sortable:hover { + color: #e5e7eb; + } + th.users-sortable .users-sort-icon { + display: inline-block; + margin-left: 0.35rem; + opacity: 0.35; + vertical-align: middle; + } + th.users-sortable.users-sort-active .users-sort-icon { + opacity: 1; + color: #34d399; + }
- +
- - - - - - + + + + + + - + {{if not .Users}} @@ -82,6 +100,10 @@ class="users-row" data-username="{{$u.Username}}" data-ip="{{$u.IPAddress}}" + data-status="{{if $u.IsEnabled}}1{{else}}0{{end}}" + data-usage="{{addInt64 $u.TotalRxBytes $u.TotalTxBytes}}" + data-remaining-days="{{if $u.HasRemainingDays}}{{$u.RemainingDaysValue}}{{else}}9223372036854775807{{end}}" + data-remaining-traffic="{{if $u.HasRemainingTraffic}}{{$u.RemainingTrafficBytes}}{{else}}9223372036854775807{{end}}" >
UsernameIP AddressStatusTotal UsageRemaining DaysRemaining TrafficUsernameIP AddressStatusTotal UsageRemaining DaysRemaining Traffic Connection Actions
No users found.
@@ -198,22 +220,101 @@ {{end}} diff --git a/internal/view/view.go b/internal/view/view.go index 0bbe80d..99dd958 100644 --- a/internal/view/view.go +++ b/internal/view/view.go @@ -42,6 +42,7 @@ func parse() (*template.Template, error) { funcMap := template.FuncMap{ "humanBytes": humanBytes, "humanUptime": humanUptime, + "addInt64": addInt64, "lower": strings.ToLower, "hasPrefix": strings.HasPrefix, "initial": func(s string) string {