feat(auth): add HTMX support for auth flows and improve error handling
This commit is contained in:
parent
291b8f4863
commit
9ea3274ade
14 changed files with 538 additions and 273 deletions
|
|
@ -141,6 +141,7 @@ func main() {
|
||||||
|
|
||||||
// Audit logger
|
// Audit logger
|
||||||
auditLogger := storage.NewAuditLogger(db)
|
auditLogger := storage.NewAuditLogger(db)
|
||||||
|
auditLogger.OnWrite = sseBroker.Notify
|
||||||
authHandlers.SetAuditLogger(auditLogger)
|
authHandlers.SetAuditLogger(auditLogger)
|
||||||
|
|
||||||
// Debug logger
|
// Debug logger
|
||||||
|
|
@ -149,6 +150,7 @@ func main() {
|
||||||
debugDataDir = filepath.Dir(cfg.Database.Path)
|
debugDataDir = filepath.Dir(cfg.Database.Path)
|
||||||
}
|
}
|
||||||
debugLogger := storage.NewDebugLogger(db, cfg.Debug.Enabled, debugDataDir)
|
debugLogger := storage.NewDebugLogger(db, cfg.Debug.Enabled, debugDataDir)
|
||||||
|
debugLogger.OnWrite = sseBroker.Notify
|
||||||
|
|
||||||
// Seed default admin
|
// Seed default admin
|
||||||
seedDefaultAdmin(cfg, authStore)
|
seedDefaultAdmin(cfg, authStore)
|
||||||
|
|
@ -248,6 +250,11 @@ func main() {
|
||||||
r.Post("/api/auth/setup", authHandlers.Setup)
|
r.Post("/api/auth/setup", authHandlers.Setup)
|
||||||
r.Post("/api/auth/login/totp", authHandlers.LoginTOTP)
|
r.Post("/api/auth/login/totp", authHandlers.LoginTOTP)
|
||||||
|
|
||||||
|
// Favicon (prevent 401 noise in browser console)
|
||||||
|
r.Get("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
})
|
||||||
|
|
||||||
// Root redirect
|
// Root redirect
|
||||||
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
http.Redirect(w, r, "/dashboard", http.StatusFound)
|
http.Redirect(w, r, "/dashboard", http.StatusFound)
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -124,7 +125,13 @@ func (h *Handlers) Status(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handlers) Setup(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) Setup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
htmx := isHTMX(r)
|
||||||
|
|
||||||
if h.store.HasAnyUser() {
|
if h.store.HasAnyUser() {
|
||||||
|
if htmx {
|
||||||
|
writeHTMXError(w, "already initialized")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeError(w, http.StatusBadRequest, "already initialized")
|
writeError(w, http.StatusBadRequest, "already initialized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -134,27 +141,48 @@ func (h *Handlers) Setup(w http.ResponseWriter, r *http.Request) {
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
if htmx {
|
||||||
|
writeHTMXError(w, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.Username == "" || req.Password == "" {
|
if req.Username == "" || req.Password == "" {
|
||||||
|
if htmx {
|
||||||
|
writeHTMXError(w, "username and password required")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeError(w, http.StatusBadRequest, "username and password required")
|
writeError(w, http.StatusBadRequest, "username and password required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(req.Password) < 8 {
|
if len(req.Password) < 8 {
|
||||||
|
if htmx {
|
||||||
|
writeHTMXError(w, "password must be at least 8 characters")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeError(w, http.StatusBadRequest, "password must be at least 8 characters")
|
writeError(w, http.StatusBadRequest, "password must be at least 8 characters")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := h.store.CreateUser(req.Username, req.Password, true)
|
user, err := h.store.CreateUser(req.Username, req.Password, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "failed to create user: "+err.Error())
|
msg := "failed to create user: " + err.Error()
|
||||||
|
if htmx {
|
||||||
|
writeHTMXError(w, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, http.StatusInternalServerError, msg)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-login
|
// Auto-login
|
||||||
sessionID, err := h.store.CreateSession(user.ID, 7*24*time.Hour)
|
sessionID, err := h.store.CreateSession(user.ID, 7*24*time.Hour)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if htmx {
|
||||||
|
writeHTMXError(w, "failed to create session")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeError(w, http.StatusInternalServerError, "failed to create session")
|
writeError(w, http.StatusInternalServerError, "failed to create session")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -162,6 +190,10 @@ func (h *Handlers) Setup(w http.ResponseWriter, r *http.Request) {
|
||||||
h.audit(r, "auth.setup", "user", fmt.Sprintf("%d", user.ID), "initial setup")
|
h.audit(r, "auth.setup", "user", fmt.Sprintf("%d", user.ID), "initial setup")
|
||||||
|
|
||||||
h.setSessionCookie(w, sessionID)
|
h.setSessionCookie(w, sessionID)
|
||||||
|
if htmx {
|
||||||
|
writeHTMXRedirect(w, "/dashboard")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeJSON(w, map[string]any{
|
writeJSON(w, map[string]any{
|
||||||
"user": map[string]any{
|
"user": map[string]any{
|
||||||
"id": user.ID,
|
"id": user.ID,
|
||||||
|
|
@ -172,11 +204,17 @@ func (h *Handlers) Setup(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handlers) Login(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
|
htmx := isHTMX(r)
|
||||||
|
|
||||||
ip := r.RemoteAddr
|
ip := r.RemoteAddr
|
||||||
if fwd := r.Header.Get("X-Real-IP"); fwd != "" {
|
if fwd := r.Header.Get("X-Real-IP"); fwd != "" {
|
||||||
ip = fwd
|
ip = fwd
|
||||||
}
|
}
|
||||||
if !h.loginLimiter.allow(ip) {
|
if !h.loginLimiter.allow(ip) {
|
||||||
|
if htmx {
|
||||||
|
writeHTMXError(w, "too many login attempts, try again in a minute")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeError(w, http.StatusTooManyRequests, "too many login attempts, try again in a minute")
|
writeError(w, http.StatusTooManyRequests, "too many login attempts, try again in a minute")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -186,17 +224,29 @@ func (h *Handlers) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
if htmx {
|
||||||
|
writeHTMXError(w, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := h.store.GetUserByUsername(req.Username)
|
user, err := h.store.GetUserByUsername(req.Username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if htmx {
|
||||||
|
writeHTMXError(w, "invalid credentials")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeError(w, http.StatusUnauthorized, "invalid credentials")
|
writeError(w, http.StatusUnauthorized, "invalid credentials")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !h.store.CheckPassword(user, req.Password) {
|
if !h.store.CheckPassword(user, req.Password) {
|
||||||
|
if htmx {
|
||||||
|
writeHTMXError(w, "invalid credentials")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeError(w, http.StatusUnauthorized, "invalid credentials")
|
writeError(w, http.StatusUnauthorized, "invalid credentials")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -212,12 +262,27 @@ func (h *Handlers) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
MaxAge: 300, // 5 minutes
|
MaxAge: 300, // 5 minutes
|
||||||
})
|
})
|
||||||
|
if htmx {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
fmt.Fprint(w, `<form id="totp-form" hx-post="/api/auth/login/totp" hx-target="#login-form-area" hx-swap="innerHTML" hx-ext="json-enc">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Enter your 6-digit authenticator code</label>
|
||||||
|
<input type="text" name="code" required pattern="[0-9]{6}" maxlength="6" autocomplete="one-time-code" inputmode="numeric" style="text-align:center; font-size:1.5rem; letter-spacing:0.3em;" autofocus>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary">Verify</button>
|
||||||
|
</form>`)
|
||||||
|
return
|
||||||
|
}
|
||||||
writeJSON(w, map[string]any{"require_totp": true})
|
writeJSON(w, map[string]any{"require_totp": true})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionID, err := h.store.CreateSession(user.ID, 7*24*time.Hour)
|
sessionID, err := h.store.CreateSession(user.ID, 7*24*time.Hour)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if htmx {
|
||||||
|
writeHTMXError(w, "failed to create session")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeError(w, http.StatusInternalServerError, "failed to create session")
|
writeError(w, http.StatusInternalServerError, "failed to create session")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -225,6 +290,10 @@ func (h *Handlers) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
h.audit(r, "auth.login", "user", fmt.Sprintf("%d", user.ID), user.Username)
|
h.audit(r, "auth.login", "user", fmt.Sprintf("%d", user.ID), user.Username)
|
||||||
|
|
||||||
h.setSessionCookie(w, sessionID)
|
h.setSessionCookie(w, sessionID)
|
||||||
|
if htmx {
|
||||||
|
writeHTMXRedirect(w, "/dashboard")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeJSON(w, map[string]any{
|
writeJSON(w, map[string]any{
|
||||||
"require_totp": false,
|
"require_totp": false,
|
||||||
"user": map[string]any{
|
"user": map[string]any{
|
||||||
|
|
@ -236,14 +305,24 @@ func (h *Handlers) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handlers) LoginTOTP(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) LoginTOTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
htmx := isHTMX(r)
|
||||||
|
|
||||||
cookie, err := r.Cookie("llmgw_pending")
|
cookie, err := r.Cookie("llmgw_pending")
|
||||||
if err != nil || cookie.Value == "" {
|
if err != nil || cookie.Value == "" {
|
||||||
|
if htmx {
|
||||||
|
writeHTMXError(w, "no pending login")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeError(w, http.StatusBadRequest, "no pending login")
|
writeError(w, http.StatusBadRequest, "no pending login")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userID, err := h.verifyPendingToken(cookie.Value)
|
userID, err := h.verifyPendingToken(cookie.Value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if htmx {
|
||||||
|
writeHTMXError(w, "invalid or expired pending login")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeError(w, http.StatusBadRequest, "invalid or expired pending login")
|
writeError(w, http.StatusBadRequest, "invalid or expired pending login")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -252,17 +331,29 @@ func (h *Handlers) LoginTOTP(w http.ResponseWriter, r *http.Request) {
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
if htmx {
|
||||||
|
writeHTMXError(w, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := h.store.GetUserByID(userID)
|
user, err := h.store.GetUserByID(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if htmx {
|
||||||
|
writeHTMXError(w, "user not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeError(w, http.StatusBadRequest, "user not found")
|
writeError(w, http.StatusBadRequest, "user not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !ValidateTOTPCode(user.TOTPSecret, req.Code) {
|
if !ValidateTOTPCode(user.TOTPSecret, req.Code) {
|
||||||
|
if htmx {
|
||||||
|
writeHTMXError(w, "invalid TOTP code")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeError(w, http.StatusUnauthorized, "invalid TOTP code")
|
writeError(w, http.StatusUnauthorized, "invalid TOTP code")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -278,11 +369,19 @@ func (h *Handlers) LoginTOTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
sessionID, err := h.store.CreateSession(user.ID, 7*24*time.Hour)
|
sessionID, err := h.store.CreateSession(user.ID, 7*24*time.Hour)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if htmx {
|
||||||
|
writeHTMXError(w, "failed to create session")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeError(w, http.StatusInternalServerError, "failed to create session")
|
writeError(w, http.StatusInternalServerError, "failed to create session")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h.setSessionCookie(w, sessionID)
|
h.setSessionCookie(w, sessionID)
|
||||||
|
if htmx {
|
||||||
|
writeHTMXRedirect(w, "/dashboard")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeJSON(w, map[string]any{
|
writeJSON(w, map[string]any{
|
||||||
"user": map[string]any{
|
"user": map[string]any{
|
||||||
"id": user.ID,
|
"id": user.ID,
|
||||||
|
|
@ -308,6 +407,10 @@ func (h *Handlers) Logout(w http.ResponseWriter, r *http.Request) {
|
||||||
MaxAge: -1,
|
MaxAge: -1,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if isHTMX(r) {
|
||||||
|
writeHTMXRedirect(w, "/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeJSON(w, map[string]string{"status": "ok"})
|
writeJSON(w, map[string]string{"status": "ok"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -350,6 +453,7 @@ func (h *Handlers) TOTPSetup(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handlers) TOTPVerify(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) TOTPVerify(w http.ResponseWriter, r *http.Request) {
|
||||||
|
htmx := isHTMX(r)
|
||||||
user := UserFromContext(r.Context())
|
user := UserFromContext(r.Context())
|
||||||
if user == nil {
|
if user == nil {
|
||||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||||
|
|
@ -360,6 +464,10 @@ func (h *Handlers) TOTPVerify(w http.ResponseWriter, r *http.Request) {
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
if htmx {
|
||||||
|
writeHTMXError(w, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -367,30 +475,53 @@ func (h *Handlers) TOTPVerify(w http.ResponseWriter, r *http.Request) {
|
||||||
// Re-fetch user to get latest TOTP secret
|
// Re-fetch user to get latest TOTP secret
|
||||||
user, err := h.store.GetUserByID(user.ID)
|
user, err := h.store.GetUserByID(user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if htmx {
|
||||||
|
writeHTMXError(w, "failed to fetch user")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeError(w, http.StatusInternalServerError, "failed to fetch user")
|
writeError(w, http.StatusInternalServerError, "failed to fetch user")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if user.TOTPSecret == "" {
|
if user.TOTPSecret == "" {
|
||||||
|
if htmx {
|
||||||
|
writeHTMXError(w, "TOTP not set up yet")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeError(w, http.StatusBadRequest, "TOTP not set up, call /api/auth/totp/setup first")
|
writeError(w, http.StatusBadRequest, "TOTP not set up, call /api/auth/totp/setup first")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !ValidateTOTPCode(user.TOTPSecret, req.Code) {
|
if !ValidateTOTPCode(user.TOTPSecret, req.Code) {
|
||||||
|
if htmx {
|
||||||
|
writeHTMXError(w, "invalid TOTP code")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeError(w, http.StatusBadRequest, "invalid TOTP code")
|
writeError(w, http.StatusBadRequest, "invalid TOTP code")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.store.EnableTOTP(user.ID); err != nil {
|
if err := h.store.EnableTOTP(user.ID); err != nil {
|
||||||
|
if htmx {
|
||||||
|
writeHTMXError(w, "failed to enable TOTP")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeError(w, http.StatusInternalServerError, "failed to enable TOTP")
|
writeError(w, http.StatusInternalServerError, "failed to enable TOTP")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h.audit(r, "totp.enable", "user", fmt.Sprintf("%d", user.ID), "")
|
h.audit(r, "totp.enable", "user", fmt.Sprintf("%d", user.ID), "")
|
||||||
|
if htmx {
|
||||||
|
// Trigger settings page reload to show updated TOTP status
|
||||||
|
w.Header().Set("HX-Trigger", "settingsRefresh")
|
||||||
|
writeHTMXSuccess(w, "Two-factor authentication enabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeJSON(w, map[string]string{"status": "totp_enabled"})
|
writeJSON(w, map[string]string{"status": "totp_enabled"})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handlers) TOTPDisable(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) TOTPDisable(w http.ResponseWriter, r *http.Request) {
|
||||||
|
htmx := isHTMX(r)
|
||||||
user := UserFromContext(r.Context())
|
user := UserFromContext(r.Context())
|
||||||
if user == nil {
|
if user == nil {
|
||||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||||
|
|
@ -398,11 +529,20 @@ func (h *Handlers) TOTPDisable(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.store.DisableTOTP(user.ID); err != nil {
|
if err := h.store.DisableTOTP(user.ID); err != nil {
|
||||||
|
if htmx {
|
||||||
|
writeHTMXError(w, "failed to disable TOTP")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeError(w, http.StatusInternalServerError, "failed to disable TOTP")
|
writeError(w, http.StatusInternalServerError, "failed to disable TOTP")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h.audit(r, "totp.disable", "user", fmt.Sprintf("%d", user.ID), "")
|
h.audit(r, "totp.disable", "user", fmt.Sprintf("%d", user.ID), "")
|
||||||
|
if htmx {
|
||||||
|
w.Header().Set("HX-Trigger", "settingsRefresh")
|
||||||
|
writeHTMXSuccess(w, "Two-factor authentication disabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeJSON(w, map[string]string{"status": "totp_disabled"})
|
writeJSON(w, map[string]string{"status": "totp_disabled"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -437,31 +577,53 @@ func (h *Handlers) ListUsers(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handlers) CreateUser(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) CreateUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
htmx := isHTMX(r)
|
||||||
var req struct {
|
var req struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
IsAdmin bool `json:"is_admin"`
|
IsAdmin bool `json:"is_admin"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
if htmx {
|
||||||
|
writeHTMXError(w, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.Username == "" || req.Password == "" {
|
if req.Username == "" || req.Password == "" {
|
||||||
|
if htmx {
|
||||||
|
writeHTMXError(w, "username and password required")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeError(w, http.StatusBadRequest, "username and password required")
|
writeError(w, http.StatusBadRequest, "username and password required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(req.Password) < 8 {
|
if len(req.Password) < 8 {
|
||||||
|
if htmx {
|
||||||
|
writeHTMXError(w, "password must be at least 8 characters")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeError(w, http.StatusBadRequest, "password must be at least 8 characters")
|
writeError(w, http.StatusBadRequest, "password must be at least 8 characters")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := h.store.CreateUser(req.Username, req.Password, req.IsAdmin)
|
user, err := h.store.CreateUser(req.Username, req.Password, req.IsAdmin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "failed to create user: "+err.Error())
|
msg := "failed to create user: " + err.Error()
|
||||||
|
if htmx {
|
||||||
|
writeHTMXError(w, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, http.StatusInternalServerError, msg)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h.audit(r, "user.create", "user", fmt.Sprintf("%d", user.ID), user.Username)
|
h.audit(r, "user.create", "user", fmt.Sprintf("%d", user.ID), user.Username)
|
||||||
|
if htmx {
|
||||||
|
writeHTMXRefresh(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
writeJSON(w, map[string]any{
|
writeJSON(w, map[string]any{
|
||||||
"id": user.ID,
|
"id": user.ID,
|
||||||
"username": user.Username,
|
"username": user.Username,
|
||||||
|
|
@ -470,9 +632,14 @@ func (h *Handlers) CreateUser(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handlers) DeleteUser(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) DeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
htmx := isHTMX(r)
|
||||||
idStr := chi.URLParam(r, "id")
|
idStr := chi.URLParam(r, "id")
|
||||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if htmx {
|
||||||
|
writeHTMXError(w, "invalid user ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeError(w, http.StatusBadRequest, "invalid user ID")
|
writeError(w, http.StatusBadRequest, "invalid user ID")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -480,16 +647,28 @@ func (h *Handlers) DeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||||
// Prevent deleting yourself
|
// Prevent deleting yourself
|
||||||
user := UserFromContext(r.Context())
|
user := UserFromContext(r.Context())
|
||||||
if user != nil && user.ID == id {
|
if user != nil && user.ID == id {
|
||||||
|
if htmx {
|
||||||
|
writeHTMXError(w, "cannot delete yourself")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeError(w, http.StatusBadRequest, "cannot delete yourself")
|
writeError(w, http.StatusBadRequest, "cannot delete yourself")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.store.DeleteUser(id); err != nil {
|
if err := h.store.DeleteUser(id); err != nil {
|
||||||
|
if htmx {
|
||||||
|
writeHTMXError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h.audit(r, "user.delete", "user", idStr, "")
|
h.audit(r, "user.delete", "user", idStr, "")
|
||||||
|
if htmx {
|
||||||
|
writeHTMXRefresh(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
writeJSON(w, map[string]string{"status": "deleted"})
|
writeJSON(w, map[string]string{"status": "deleted"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -520,6 +699,7 @@ func (h *Handlers) ListTokens(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handlers) CreateToken(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) CreateToken(w http.ResponseWriter, r *http.Request) {
|
||||||
|
htmx := isHTMX(r)
|
||||||
user := UserFromContext(r.Context())
|
user := UserFromContext(r.Context())
|
||||||
if user == nil {
|
if user == nil {
|
||||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||||
|
|
@ -532,10 +712,18 @@ func (h *Handlers) CreateToken(w http.ResponseWriter, r *http.Request) {
|
||||||
DailyBudgetUSD float64 `json:"daily_budget_usd"`
|
DailyBudgetUSD float64 `json:"daily_budget_usd"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
if htmx {
|
||||||
|
writeHTMXError(w, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.Name == "" {
|
if req.Name == "" {
|
||||||
|
if htmx {
|
||||||
|
writeHTMXError(w, "name is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeError(w, http.StatusBadRequest, "name is required")
|
writeError(w, http.StatusBadRequest, "name is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -546,11 +734,25 @@ func (h *Handlers) CreateToken(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
plainKey, token, err := h.store.CreateAPIToken(user.ID, req.Name, req.RateLimitRPM, req.DailyBudgetUSD)
|
plainKey, token, err := h.store.CreateAPIToken(user.ID, req.Name, req.RateLimitRPM, req.DailyBudgetUSD)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "failed to create token: "+err.Error())
|
msg := "failed to create token: " + err.Error()
|
||||||
|
if htmx {
|
||||||
|
writeHTMXError(w, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, http.StatusInternalServerError, msg)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h.audit(r, "token.create", "token", fmt.Sprintf("%d", token.ID), req.Name)
|
h.audit(r, "token.create", "token", fmt.Sprintf("%d", token.ID), req.Name)
|
||||||
|
if htmx {
|
||||||
|
// Return HTML with the key display and trigger page refresh
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Header().Set("HX-Trigger", "tokenCreated")
|
||||||
|
escaped := template.HTMLEscapeString(plainKey)
|
||||||
|
fmt.Fprintf(w, `<div class="success-msg" style="margin-bottom:12px;">Token created! Copy the key below — it won't be shown again.</div>
|
||||||
|
<div class="token-key"><code id="new-token-key">%s</code><button class="copy-btn" onclick="navigator.clipboard.writeText(document.getElementById('new-token-key').textContent)">Copy</button></div>`, escaped)
|
||||||
|
return
|
||||||
|
}
|
||||||
writeJSON(w, map[string]any{
|
writeJSON(w, map[string]any{
|
||||||
"key": plainKey,
|
"key": plainKey,
|
||||||
"token": token,
|
"token": token,
|
||||||
|
|
@ -558,6 +760,7 @@ func (h *Handlers) CreateToken(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handlers) DeleteToken(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) DeleteToken(w http.ResponseWriter, r *http.Request) {
|
||||||
|
htmx := isHTMX(r)
|
||||||
user := UserFromContext(r.Context())
|
user := UserFromContext(r.Context())
|
||||||
if user == nil {
|
if user == nil {
|
||||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||||
|
|
@ -567,6 +770,10 @@ func (h *Handlers) DeleteToken(w http.ResponseWriter, r *http.Request) {
|
||||||
idStr := chi.URLParam(r, "id")
|
idStr := chi.URLParam(r, "id")
|
||||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if htmx {
|
||||||
|
writeHTMXError(w, "invalid token ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeError(w, http.StatusBadRequest, "invalid token ID")
|
writeError(w, http.StatusBadRequest, "invalid token ID")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -575,27 +782,44 @@ func (h *Handlers) DeleteToken(w http.ResponseWriter, r *http.Request) {
|
||||||
if !user.IsAdmin {
|
if !user.IsAdmin {
|
||||||
token, err := h.store.GetAPIToken(id)
|
token, err := h.store.GetAPIToken(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if htmx {
|
||||||
|
writeHTMXError(w, "token not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeError(w, http.StatusNotFound, "token not found")
|
writeError(w, http.StatusNotFound, "token not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if token.UserID != user.ID {
|
if token.UserID != user.ID {
|
||||||
|
if htmx {
|
||||||
|
writeHTMXError(w, "not your token")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeError(w, http.StatusForbidden, "not your token")
|
writeError(w, http.StatusForbidden, "not your token")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.store.DeleteAPIToken(id); err != nil {
|
if err := h.store.DeleteAPIToken(id); err != nil {
|
||||||
|
if htmx {
|
||||||
|
writeHTMXError(w, "failed to delete token")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeError(w, http.StatusInternalServerError, "failed to delete token")
|
writeError(w, http.StatusInternalServerError, "failed to delete token")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h.audit(r, "token.delete", "token", idStr, "")
|
h.audit(r, "token.delete", "token", idStr, "")
|
||||||
|
if htmx {
|
||||||
|
writeHTMXRefresh(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
writeJSON(w, map[string]string{"status": "deleted"})
|
writeJSON(w, map[string]string{"status": "deleted"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Self-service endpoints
|
// Self-service endpoints
|
||||||
|
|
||||||
func (h *Handlers) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||||
|
htmx := isHTMX(r)
|
||||||
user := UserFromContext(r.Context())
|
user := UserFromContext(r.Context())
|
||||||
if user == nil {
|
if user == nil {
|
||||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||||
|
|
@ -607,10 +831,18 @@ func (h *Handlers) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||||
NewPassword string `json:"new_password"`
|
NewPassword string `json:"new_password"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
if htmx {
|
||||||
|
writeHTMXError(w, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.NewPassword == "" || len(req.NewPassword) < 8 {
|
if req.NewPassword == "" || len(req.NewPassword) < 8 {
|
||||||
|
if htmx {
|
||||||
|
writeHTMXError(w, "new password must be at least 8 characters")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeError(w, http.StatusBadRequest, "new password must be at least 8 characters")
|
writeError(w, http.StatusBadRequest, "new password must be at least 8 characters")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -618,25 +850,42 @@ func (h *Handlers) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||||
// Re-fetch user to get password hash
|
// Re-fetch user to get password hash
|
||||||
user, err := h.store.GetUserByID(user.ID)
|
user, err := h.store.GetUserByID(user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if htmx {
|
||||||
|
writeHTMXError(w, "failed to fetch user")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeError(w, http.StatusInternalServerError, "failed to fetch user")
|
writeError(w, http.StatusInternalServerError, "failed to fetch user")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !h.store.CheckPassword(user, req.CurrentPassword) {
|
if !h.store.CheckPassword(user, req.CurrentPassword) {
|
||||||
|
if htmx {
|
||||||
|
writeHTMXError(w, "current password is incorrect")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeError(w, http.StatusUnauthorized, "current password is incorrect")
|
writeError(w, http.StatusUnauthorized, "current password is incorrect")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.store.UpdatePassword(user.ID, req.NewPassword); err != nil {
|
if err := h.store.UpdatePassword(user.ID, req.NewPassword); err != nil {
|
||||||
|
if htmx {
|
||||||
|
writeHTMXError(w, "failed to update password")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeError(w, http.StatusInternalServerError, "failed to update password")
|
writeError(w, http.StatusInternalServerError, "failed to update password")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h.audit(r, "password.change", "user", fmt.Sprintf("%d", user.ID), "")
|
h.audit(r, "password.change", "user", fmt.Sprintf("%d", user.ID), "")
|
||||||
|
if htmx {
|
||||||
|
writeHTMXSuccess(w, "Password updated")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeJSON(w, map[string]string{"status": "password_updated"})
|
writeJSON(w, map[string]string{"status": "password_updated"})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handlers) ChangeUsername(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) ChangeUsername(w http.ResponseWriter, r *http.Request) {
|
||||||
|
htmx := isHTMX(r)
|
||||||
user := UserFromContext(r.Context())
|
user := UserFromContext(r.Context())
|
||||||
if user == nil {
|
if user == nil {
|
||||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||||
|
|
@ -647,10 +896,18 @@ func (h *Handlers) ChangeUsername(w http.ResponseWriter, r *http.Request) {
|
||||||
NewUsername string `json:"new_username"`
|
NewUsername string `json:"new_username"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
if htmx {
|
||||||
|
writeHTMXError(w, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.NewUsername == "" {
|
if req.NewUsername == "" {
|
||||||
|
if htmx {
|
||||||
|
writeHTMXError(w, "username is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeError(w, http.StatusBadRequest, "username is required")
|
writeError(w, http.StatusBadRequest, "username is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -658,20 +915,33 @@ func (h *Handlers) ChangeUsername(w http.ResponseWriter, r *http.Request) {
|
||||||
// Check uniqueness
|
// Check uniqueness
|
||||||
existing, err := h.store.GetUserByUsername(req.NewUsername)
|
existing, err := h.store.GetUserByUsername(req.NewUsername)
|
||||||
if err == nil && existing.ID != user.ID {
|
if err == nil && existing.ID != user.ID {
|
||||||
|
if htmx {
|
||||||
|
writeHTMXError(w, "username already taken")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeError(w, http.StatusConflict, "username already taken")
|
writeError(w, http.StatusConflict, "username already taken")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.store.UpdateUsername(user.ID, req.NewUsername); err != nil {
|
if err := h.store.UpdateUsername(user.ID, req.NewUsername); err != nil {
|
||||||
|
if htmx {
|
||||||
|
writeHTMXError(w, "failed to update username")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeError(w, http.StatusInternalServerError, "failed to update username")
|
writeError(w, http.StatusInternalServerError, "failed to update username")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h.audit(r, "username.change", "user", fmt.Sprintf("%d", user.ID), req.NewUsername)
|
h.audit(r, "username.change", "user", fmt.Sprintf("%d", user.ID), req.NewUsername)
|
||||||
|
if htmx {
|
||||||
|
writeHTMXSuccess(w, "Username updated")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeJSON(w, map[string]string{"status": "username_updated"})
|
writeJSON(w, map[string]string{"status": "username_updated"})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handlers) ChangeEmail(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) ChangeEmail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
htmx := isHTMX(r)
|
||||||
user := UserFromContext(r.Context())
|
user := UserFromContext(r.Context())
|
||||||
if user == nil {
|
if user == nil {
|
||||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||||
|
|
@ -682,15 +952,27 @@ func (h *Handlers) ChangeEmail(w http.ResponseWriter, r *http.Request) {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
if htmx {
|
||||||
|
writeHTMXError(w, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.store.UpdateEmail(user.ID, req.Email); err != nil {
|
if err := h.store.UpdateEmail(user.ID, req.Email); err != nil {
|
||||||
|
if htmx {
|
||||||
|
writeHTMXError(w, "failed to update email")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeError(w, http.StatusInternalServerError, "failed to update email")
|
writeError(w, http.StatusInternalServerError, "failed to update email")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if htmx {
|
||||||
|
writeHTMXSuccess(w, "Email updated")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeJSON(w, map[string]string{"status": "email_updated"})
|
writeJSON(w, map[string]string{"status": "email_updated"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -749,6 +1031,30 @@ func (h *Handlers) verifyPendingToken(token string) (int64, error) {
|
||||||
return userID, nil
|
return userID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isHTMX(r *http.Request) bool {
|
||||||
|
return r.Header.Get("HX-Request") == "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeHTMXError(w http.ResponseWriter, msg string) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
fmt.Fprintf(w, `<div class="error-msg">%s</div>`, template.HTMLEscapeString(msg))
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeHTMXSuccess(w http.ResponseWriter, msg string) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
fmt.Fprintf(w, `<div class="success-msg">%s</div>`, template.HTMLEscapeString(msg))
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeHTMXRedirect(w http.ResponseWriter, url string) {
|
||||||
|
w.Header().Set("HX-Redirect", url)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeHTMXRefresh(w http.ResponseWriter) {
|
||||||
|
w.Header().Set("HX-Refresh", "true")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, v any) {
|
func writeJSON(w http.ResponseWriter, v any) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(v)
|
json.NewEncoder(w).Encode(v)
|
||||||
|
|
|
||||||
|
|
@ -721,19 +721,47 @@ func (s *StatsAPI) DebugLogByRequestID(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateConfig validates the config file at the stored path.
|
// ValidateConfig validates the config file at the stored path.
|
||||||
|
// Returns HTML for HTMX requests, JSON otherwise.
|
||||||
func (s *StatsAPI) ValidateConfig(w http.ResponseWriter, r *http.Request) {
|
func (s *StatsAPI) ValidateConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
if s.configPath == "" {
|
if s.configPath == "" {
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
if r.Header.Get("HX-Request") == "true" {
|
||||||
writeJSON(w, map[string]any{"valid": false, "errors": []string{"config path not set"}})
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Write([]byte(`<div class="error-msg">Config path not set</div>`))
|
||||||
|
} else {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
writeJSON(w, map[string]any{"valid": false, "errors": []string{"config path not set"}})
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data, err := os.ReadFile(s.configPath)
|
data, err := os.ReadFile(s.configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
msg := "failed to read config: " + err.Error()
|
||||||
writeJSON(w, map[string]any{"valid": false, "errors": []string{"failed to read config: " + err.Error()}})
|
if r.Header.Get("HX-Request") == "true" {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Write([]byte(`<div class="error-msg">` + msg + `</div>`))
|
||||||
|
} else {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
writeJSON(w, map[string]any{"valid": false, "errors": []string{msg}})
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
errs := config.ValidateBytes(data)
|
errs := config.ValidateBytes(data)
|
||||||
|
|
||||||
|
if r.Header.Get("HX-Request") == "true" {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if len(errs) > 0 {
|
||||||
|
html := `<div class="error-msg">Configuration errors:<ul style="margin:4px 0 0 16px;">`
|
||||||
|
for _, e := range errs {
|
||||||
|
html += "<li>" + e + "</li>"
|
||||||
|
}
|
||||||
|
html += "</ul></div>"
|
||||||
|
w.Write([]byte(html))
|
||||||
|
} else {
|
||||||
|
w.Write([]byte(`<div class="success-msg">Configuration is valid.</div>`))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if len(errs) > 0 {
|
if len(errs) > 0 {
|
||||||
writeJSON(w, map[string]any{"valid": false, "errors": errs})
|
writeJSON(w, map[string]any{"valid": false, "errors": errs})
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@
|
||||||
</script>
|
</script>
|
||||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||||
<script src="https://unpkg.com/htmx-ext-json-enc@2.0.3/json-enc.js"></script>
|
<script src="https://unpkg.com/htmx-ext-json-enc@2.0.3/json-enc.js"></script>
|
||||||
<script src="https://unpkg.com/htmx-ext-sse@2.2.2/sse.js"></script>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
|
|
@ -294,15 +293,50 @@ window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', fu
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
<button class="theme-toggle" onclick="toggleTheme()" id="theme-btn">Switch Theme</button>
|
<button class="theme-toggle" onclick="toggleTheme()" id="theme-btn">Switch Theme</button>
|
||||||
<div class="user-info">{{.User.Username}}</div>
|
<div class="user-info">{{.User.Username}}</div>
|
||||||
<a href="#" hx-post="/api/auth/logout" hx-swap="none" onclick="setTimeout(()=>window.location='/login',100)">Logout</a>
|
<a href="#" hx-post="/api/auth/logout" hx-swap="none">Logout</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="main">
|
<div class="main">
|
||||||
|
<div id="sse-source" style="display:none;"></div>
|
||||||
<div id="content">
|
<div id="content">
|
||||||
{{template "content" .}}
|
{{template "content" .}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
|
// SSE auto-refresh: reload current page content when server sends refresh event
|
||||||
|
(function() {
|
||||||
|
var refreshable = ['/dashboard', '/logs', '/models', '/debug', '/audit'];
|
||||||
|
var source = null;
|
||||||
|
var retryDelay = 1000;
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
if (source) { source.close(); source = null; }
|
||||||
|
source = new EventSource('/api/events');
|
||||||
|
source.addEventListener('refresh', function() {
|
||||||
|
var path = window.location.pathname;
|
||||||
|
for (var i = 0; i < refreshable.length; i++) {
|
||||||
|
if (path === refreshable[i]) {
|
||||||
|
htmx.ajax('GET', path + window.location.search, {target: '#content', swap: 'innerHTML'});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
source.onopen = function() { retryDelay = 1000; };
|
||||||
|
source.onerror = function() {
|
||||||
|
source.close();
|
||||||
|
source = null;
|
||||||
|
// Exponential backoff, max 30s
|
||||||
|
setTimeout(connect, retryDelay);
|
||||||
|
retryDelay = Math.min(retryDelay * 2, 30000);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
connect();
|
||||||
|
// Reconnect on HTMX page navigation (in case connection was lost)
|
||||||
|
document.body.addEventListener('htmx:pushedIntoHistory', function() {
|
||||||
|
if (!source || source.readyState === EventSource.CLOSED) connect();
|
||||||
|
});
|
||||||
|
})();
|
||||||
// Update active sidebar link on HTMX navigation
|
// Update active sidebar link on HTMX navigation
|
||||||
document.body.addEventListener('htmx:pushedIntoHistory', function(e) {
|
document.body.addEventListener('htmx:pushedIntoHistory', function(e) {
|
||||||
document.querySelectorAll('.sidebar-nav a').forEach(function(a) {
|
document.querySelectorAll('.sidebar-nav a').forEach(function(a) {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Login - LLM Gateway</title>
|
<title>Login - LLM Gateway</title>
|
||||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||||
|
<script src="https://unpkg.com/htmx-ext-json-enc@2.0.1/json-enc.js"></script>
|
||||||
<style>
|
<style>
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
|
||||||
|
|
@ -18,72 +19,26 @@
|
||||||
.btn-primary { display: block; width: 100%; padding: 10px 20px; border-radius: 6px; border: none; cursor: pointer; font-size: 0.9rem; font-weight: 500; background: #3b82f6; color: #fff; }
|
.btn-primary { display: block; width: 100%; padding: 10px 20px; border-radius: 6px; border: none; cursor: pointer; font-size: 0.9rem; font-weight: 500; background: #3b82f6; color: #fff; }
|
||||||
.btn-primary:hover { background: #2563eb; }
|
.btn-primary:hover { background: #2563eb; }
|
||||||
.error-msg { background: #7f1d1d40; border: 1px solid #991b1b; color: #fca5a5; padding: 10px; border-radius: 6px; margin-bottom: 16px; font-size: 0.85rem; }
|
.error-msg { background: #7f1d1d40; border: 1px solid #991b1b; color: #fca5a5; padding: 10px; border-radius: 6px; margin-bottom: 16px; font-size: 0.85rem; }
|
||||||
.hidden { display: none !important; }
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="auth-box">
|
<div class="auth-box">
|
||||||
<h1>LLM Gateway</h1>
|
<h1>LLM Gateway</h1>
|
||||||
<div id="login-error"></div>
|
<div id="login-form-area">
|
||||||
<form id="login-form" onsubmit="doLogin(event)">
|
<form hx-post="/api/auth/login" hx-target="#login-form-area" hx-swap="innerHTML" hx-ext="json-enc">
|
||||||
<div class="form-group">
|
<div id="login-error"></div>
|
||||||
<label>Username</label>
|
<div class="form-group">
|
||||||
<input type="text" id="login-username" required autocomplete="username">
|
<label>Username</label>
|
||||||
</div>
|
<input type="text" name="username" required autocomplete="username">
|
||||||
<div class="form-group">
|
</div>
|
||||||
<label>Password</label>
|
<div class="form-group">
|
||||||
<input type="password" id="login-password" required autocomplete="current-password">
|
<label>Password</label>
|
||||||
</div>
|
<input type="password" name="password" required autocomplete="current-password">
|
||||||
<button type="submit" class="btn-primary">Sign In</button>
|
</div>
|
||||||
</form>
|
<button type="submit" class="btn-primary">Sign In</button>
|
||||||
<form id="totp-form" class="hidden" onsubmit="doLoginTOTP(event)">
|
</form>
|
||||||
<div class="form-group">
|
</div>
|
||||||
<label>Enter your 6-digit authenticator code</label>
|
|
||||||
<input type="text" id="login-totp-code" required pattern="[0-9]{6}" maxlength="6" autocomplete="one-time-code" inputmode="numeric" style="text-align:center; font-size:1.5rem; letter-spacing:0.3em;">
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn-primary">Verify</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
<script>
|
|
||||||
function showError(msg) {
|
|
||||||
document.getElementById('login-error').innerHTML = '<div class="error-msg">' + msg + '</div>';
|
|
||||||
}
|
|
||||||
async function doLogin(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/auth/login', {
|
|
||||||
method: 'POST', credentials: 'same-origin',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({
|
|
||||||
username: document.getElementById('login-username').value,
|
|
||||||
password: document.getElementById('login-password').value
|
|
||||||
})
|
|
||||||
});
|
|
||||||
const data = await resp.json();
|
|
||||||
if (!resp.ok) { showError(data.error || 'Login failed'); return; }
|
|
||||||
if (data.require_totp) {
|
|
||||||
document.getElementById('login-form').classList.add('hidden');
|
|
||||||
document.getElementById('totp-form').classList.remove('hidden');
|
|
||||||
document.getElementById('login-totp-code').focus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
window.location.href = '/dashboard';
|
|
||||||
} catch (e) { showError(e.message); }
|
|
||||||
}
|
|
||||||
async function doLoginTOTP(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/auth/login/totp', {
|
|
||||||
method: 'POST', credentials: 'same-origin',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({ code: document.getElementById('login-totp-code').value })
|
|
||||||
});
|
|
||||||
const data = await resp.json();
|
|
||||||
if (!resp.ok) { showError(data.error || 'Invalid code'); return; }
|
|
||||||
window.location.href = '/dashboard';
|
|
||||||
} catch (e) { showError(e.message); }
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div hx-ext="sse" sse-connect="/api/events" hx-get="/dashboard" hx-trigger="sse:refresh" hx-target="#content" hx-swap="innerHTML">
|
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1>Dashboard</h1>
|
<h1>Dashboard</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -228,5 +227,4 @@ function loadCostBreakdown(groupBy, btn) {
|
||||||
}
|
}
|
||||||
loadCostBreakdown('model');
|
loadCostBreakdown('model');
|
||||||
</script>
|
</script>
|
||||||
</div>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,11 @@
|
||||||
<div class="section" style="display:flex;align-items:center;gap:16px;padding:12px 16px;">
|
<div class="section" style="display:flex;align-items:center;gap:16px;padding:12px 16px;">
|
||||||
<span style="font-size:0.9rem;font-weight:600;">Debug Mode</span>
|
<span style="font-size:0.9rem;font-weight:600;">Debug Mode</span>
|
||||||
<label class="toggle-switch">
|
<label class="toggle-switch">
|
||||||
<input type="checkbox" id="debug-toggle" {{if .DebugEnabled}}checked{{end}} onchange="toggleDebug(this.checked)">
|
<input type="checkbox" id="debug-toggle" {{if .DebugEnabled}}checked{{end}}
|
||||||
|
hx-post="/api/debug/toggle" hx-swap="none" hx-ext="json-enc"
|
||||||
|
hx-vals='js:{enabled: document.getElementById("debug-toggle").checked}'
|
||||||
|
hx-trigger="change"
|
||||||
|
hx-on::after-request="htmx.ajax('GET', '/debug', {target: '#content', swap: 'innerHTML'})">
|
||||||
<span class="toggle-slider"></span>
|
<span class="toggle-slider"></span>
|
||||||
</label>
|
</label>
|
||||||
<span id="debug-status" style="font-size:0.8rem;color:var(--text-muted)">{{if .DebugEnabled}}Enabled — requests are being logged{{else}}Disabled{{end}}</span>
|
<span id="debug-status" style="font-size:0.8rem;color:var(--text-muted)">{{if .DebugEnabled}}Enabled — requests are being logged{{else}}Disabled{{end}}</span>
|
||||||
|
|
@ -77,16 +81,6 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function toggleDebug(enabled) {
|
|
||||||
fetch('/api/debug/toggle', {
|
|
||||||
method: 'POST',
|
|
||||||
credentials: 'same-origin',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({enabled: enabled})
|
|
||||||
}).then(function() {
|
|
||||||
htmx.ajax('GET', '/debug', {target: '#content', swap: 'innerHTML'});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function toggleDebugExpand(id) {
|
function toggleDebugExpand(id) {
|
||||||
var el = document.getElementById(id);
|
var el = document.getElementById(id);
|
||||||
if (el) el.classList.toggle('show');
|
if (el) el.classList.toggle('show');
|
||||||
|
|
|
||||||
|
|
@ -6,20 +6,22 @@
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2>Profile</h2>
|
<h2>Profile</h2>
|
||||||
<div id="profile-msg"></div>
|
<div id="profile-msg"></div>
|
||||||
<form onsubmit="changeUsername(event)" style="max-width:400px;margin-bottom:16px;">
|
<form hx-put="/api/auth/me/username" hx-target="#profile-msg" hx-swap="innerHTML" hx-ext="json-enc"
|
||||||
|
style="max-width:400px;margin-bottom:16px;">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Username</label>
|
<label>Username</label>
|
||||||
<div style="display:flex;gap:8px;">
|
<div style="display:flex;gap:8px;">
|
||||||
<input type="text" id="settings-username" value="{{.User.Username}}" required>
|
<input type="text" name="new_username" value="{{.User.Username}}" required>
|
||||||
<button type="submit" class="btn btn-sm btn-primary" style="white-space:nowrap;">Update</button>
|
<button type="submit" class="btn btn-sm btn-primary" style="white-space:nowrap;">Update</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<form onsubmit="changeEmail(event)" style="max-width:400px;">
|
<form hx-put="/api/auth/me/email" hx-target="#profile-msg" hx-swap="innerHTML" hx-ext="json-enc"
|
||||||
|
style="max-width:400px;">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Email</label>
|
<label>Email</label>
|
||||||
<div style="display:flex;gap:8px;">
|
<div style="display:flex;gap:8px;">
|
||||||
<input type="email" id="settings-email" value="{{.User.Email}}" placeholder="optional">
|
<input type="email" name="email" value="{{.User.Email}}" placeholder="optional">
|
||||||
<button type="submit" class="btn btn-sm btn-primary" style="white-space:nowrap;">Update</button>
|
<button type="submit" class="btn btn-sm btn-primary" style="white-space:nowrap;">Update</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -29,14 +31,15 @@
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2>Change Password</h2>
|
<h2>Change Password</h2>
|
||||||
<div id="password-msg"></div>
|
<div id="password-msg"></div>
|
||||||
<form onsubmit="changePassword(event)" style="max-width:400px;">
|
<form id="password-form" hx-put="/api/auth/me/password" hx-target="#password-msg" hx-swap="innerHTML" hx-ext="json-enc"
|
||||||
|
style="max-width:400px;">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Current Password</label>
|
<label>Current Password</label>
|
||||||
<input type="password" id="current-password" required autocomplete="current-password">
|
<input type="password" name="current_password" required autocomplete="current-password">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>New Password (min 8 characters)</label>
|
<label>New Password (min 8 characters)</label>
|
||||||
<input type="password" id="new-password" required minlength="8" autocomplete="new-password">
|
<input type="password" name="new_password" required minlength="8" autocomplete="new-password">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Confirm New Password</label>
|
<label>Confirm New Password</label>
|
||||||
|
|
@ -51,7 +54,9 @@
|
||||||
<div id="totp-status">
|
<div id="totp-status">
|
||||||
{{if .User.TOTPEnabled}}
|
{{if .User.TOTPEnabled}}
|
||||||
<p style="color:#4ade80;margin-bottom:12px;">Two-factor authentication is <strong>enabled</strong>.</p>
|
<p style="color:#4ade80;margin-bottom:12px;">Two-factor authentication is <strong>enabled</strong>.</p>
|
||||||
<button class="btn btn-sm btn-danger" onclick="disableTOTP()">Disable 2FA</button>
|
<button class="btn btn-sm btn-danger"
|
||||||
|
hx-delete="/api/auth/totp" hx-target="#totp-status" hx-swap="innerHTML"
|
||||||
|
hx-confirm="Disable two-factor authentication?">Disable 2FA</button>
|
||||||
{{else}}
|
{{else}}
|
||||||
<p style="color:#94a3b8;margin-bottom:12px;">Two-factor authentication is <strong>not enabled</strong>.</p>
|
<p style="color:#94a3b8;margin-bottom:12px;">Two-factor authentication is <strong>not enabled</strong>.</p>
|
||||||
<button class="btn btn-sm btn-primary" onclick="setupTOTP()">Enable 2FA</button>
|
<button class="btn btn-sm btn-primary" onclick="setupTOTP()">Enable 2FA</button>
|
||||||
|
|
@ -61,9 +66,10 @@
|
||||||
<p style="color:#94a3b8;font-size:0.85rem;margin-bottom:12px;">Scan this QR code with your authenticator app, then enter the code below to verify.</p>
|
<p style="color:#94a3b8;font-size:0.85rem;margin-bottom:12px;">Scan this QR code with your authenticator app, then enter the code below to verify.</p>
|
||||||
<div id="totp-qr" style="text-align:center;margin:16px 0;"></div>
|
<div id="totp-qr" style="text-align:center;margin:16px 0;"></div>
|
||||||
<div id="totp-secret-display" style="text-align:center;margin:8px 0;font-family:monospace;color:#94a3b8;font-size:0.8rem;"></div>
|
<div id="totp-secret-display" style="text-align:center;margin:8px 0;font-family:monospace;color:#94a3b8;font-size:0.8rem;"></div>
|
||||||
<form onsubmit="verifyTOTP(event)" style="max-width:300px;margin:0 auto;">
|
<form hx-post="/api/auth/totp/verify" hx-target="#totp-status" hx-swap="innerHTML" hx-ext="json-enc"
|
||||||
|
style="max-width:300px;margin:0 auto;">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<input type="text" id="totp-verify-code" required pattern="[0-9]{6}" maxlength="6" placeholder="Enter 6-digit code" autocomplete="one-time-code" inputmode="numeric" style="text-align:center;font-size:1.2rem;letter-spacing:0.2em;">
|
<input type="text" name="code" required pattern="[0-9]{6}" maxlength="6" placeholder="Enter 6-digit code" autocomplete="one-time-code" inputmode="numeric" style="text-align:center;font-size:1.2rem;letter-spacing:0.2em;">
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary">Verify & Enable</button>
|
<button type="submit" class="btn btn-primary">Verify & Enable</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -74,71 +80,49 @@
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2>Config Validation</h2>
|
<h2>Config Validation</h2>
|
||||||
<p style="color:#94a3b8;font-size:0.85rem;margin-bottom:12px;">Validate the current gateway configuration file for errors.</p>
|
<p style="color:#94a3b8;font-size:0.85rem;margin-bottom:12px;">Validate the current gateway configuration file for errors.</p>
|
||||||
<button class="btn btn-sm btn-primary" onclick="validateConfig()">Validate Config</button>
|
<button class="btn btn-sm btn-primary"
|
||||||
|
hx-get="/api/config/validate"
|
||||||
|
hx-target="#config-validation-result"
|
||||||
|
hx-swap="innerHTML">Validate Config</button>
|
||||||
<div id="config-validation-result" style="margin-top:12px;"></div>
|
<div id="config-validation-result" style="margin-top:12px;"></div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/qrious@4.0.2/dist/qrious.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/qrious@4.0.2/dist/qrious.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
function showMsg(id, msg, isError) {
|
// Password confirm validation
|
||||||
document.getElementById(id).innerHTML = '<div class="' + (isError ? 'error-msg' : 'success-msg') + '">' + msg + '</div>';
|
document.body.addEventListener('htmx:confirm', function(e) {
|
||||||
setTimeout(function() { document.getElementById(id).innerHTML = ''; }, 5000);
|
var form = e.target;
|
||||||
}
|
if (form.id !== 'password-form') return;
|
||||||
|
var np = form.querySelector('[name=new_password]').value;
|
||||||
async function changeUsername(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
try {
|
|
||||||
var resp = await fetch('/api/auth/me/username', {
|
|
||||||
method: 'PUT', credentials: 'same-origin',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({ new_username: document.getElementById('settings-username').value })
|
|
||||||
});
|
|
||||||
var data = await resp.json();
|
|
||||||
if (!resp.ok) { showMsg('profile-msg', data.error||'Failed', true); return; }
|
|
||||||
showMsg('profile-msg', 'Username updated', false);
|
|
||||||
} catch (e) { showMsg('profile-msg', e.message, true); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function changeEmail(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
try {
|
|
||||||
var resp = await fetch('/api/auth/me/email', {
|
|
||||||
method: 'PUT', credentials: 'same-origin',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({ email: document.getElementById('settings-email').value })
|
|
||||||
});
|
|
||||||
var data = await resp.json();
|
|
||||||
if (!resp.ok) { showMsg('profile-msg', data.error||'Failed', true); return; }
|
|
||||||
showMsg('profile-msg', 'Email updated', false);
|
|
||||||
} catch (e) { showMsg('profile-msg', e.message, true); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function changePassword(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
var np = document.getElementById('new-password').value;
|
|
||||||
if (np !== document.getElementById('new-password2').value) {
|
if (np !== document.getElementById('new-password2').value) {
|
||||||
showMsg('password-msg', 'Passwords do not match', true);
|
e.preventDefault();
|
||||||
return;
|
document.getElementById('password-msg').innerHTML = '<div class="error-msg">Passwords do not match</div>';
|
||||||
}
|
}
|
||||||
try {
|
});
|
||||||
var resp = await fetch('/api/auth/me/password', {
|
|
||||||
method: 'PUT', credentials: 'same-origin',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({
|
|
||||||
current_password: document.getElementById('current-password').value,
|
|
||||||
new_password: np
|
|
||||||
})
|
|
||||||
});
|
|
||||||
var data = await resp.json();
|
|
||||||
if (!resp.ok) { showMsg('password-msg', data.error||'Failed', true); return; }
|
|
||||||
showMsg('password-msg', 'Password updated', false);
|
|
||||||
document.getElementById('current-password').value = '';
|
|
||||||
document.getElementById('new-password').value = '';
|
|
||||||
document.getElementById('new-password2').value = '';
|
|
||||||
} catch (e) { showMsg('password-msg', e.message, true); }
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Clear password fields after successful change
|
||||||
|
document.body.addEventListener('htmx:afterRequest', function(e) {
|
||||||
|
if (e.target.id === 'password-form' && e.detail.successful) {
|
||||||
|
e.target.querySelectorAll('input[type=password]').forEach(function(i) { i.value = ''; });
|
||||||
|
document.getElementById('new-password2').value = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-clear messages after 5 seconds
|
||||||
|
document.body.addEventListener('htmx:afterSwap', function(e) {
|
||||||
|
var target = e.detail.target;
|
||||||
|
if (target.id === 'profile-msg' || target.id === 'password-msg') {
|
||||||
|
setTimeout(function() { target.innerHTML = ''; }, 5000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reload settings page when TOTP status changes
|
||||||
|
document.body.addEventListener('settingsRefresh', function() {
|
||||||
|
htmx.ajax('GET', '/settings', {target: '#content', swap: 'innerHTML'});
|
||||||
|
});
|
||||||
|
|
||||||
|
// TOTP setup - needs JS for QR code rendering
|
||||||
async function setupTOTP() {
|
async function setupTOTP() {
|
||||||
try {
|
try {
|
||||||
var resp = await fetch('/api/auth/totp/setup', { method: 'POST', credentials: 'same-origin' });
|
var resp = await fetch('/api/auth/totp/setup', { method: 'POST', credentials: 'same-origin' });
|
||||||
|
|
@ -153,43 +137,5 @@ async function setupTOTP() {
|
||||||
qrDiv.appendChild(canvas);
|
qrDiv.appendChild(canvas);
|
||||||
} catch (e) { alert(e.message); }
|
} catch (e) { alert(e.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function verifyTOTP(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
try {
|
|
||||||
var resp = await fetch('/api/auth/totp/verify', {
|
|
||||||
method: 'POST', credentials: 'same-origin',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({ code: document.getElementById('totp-verify-code').value })
|
|
||||||
});
|
|
||||||
var data = await resp.json();
|
|
||||||
if (!resp.ok) { alert(data.error||'Invalid code'); return; }
|
|
||||||
htmx.ajax('GET', '/settings', {target: '#content', swap: 'innerHTML'});
|
|
||||||
} catch (e) { alert(e.message); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function disableTOTP() {
|
|
||||||
if (!confirm('Disable two-factor authentication?')) return;
|
|
||||||
try {
|
|
||||||
var resp = await fetch('/api/auth/totp', { method: 'DELETE', credentials: 'same-origin' });
|
|
||||||
if (!resp.ok) { var d = await resp.json(); alert(d.error||'Failed'); return; }
|
|
||||||
htmx.ajax('GET', '/settings', {target: '#content', swap: 'innerHTML'});
|
|
||||||
} catch (e) { alert(e.message); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function validateConfig() {
|
|
||||||
var el = document.getElementById('config-validation-result');
|
|
||||||
el.innerHTML = '<span style="color:#94a3b8;">Validating...</span>';
|
|
||||||
try {
|
|
||||||
var resp = await fetch('/api/config/validate', { credentials: 'same-origin' });
|
|
||||||
var data = await resp.json();
|
|
||||||
if (data.valid) {
|
|
||||||
el.innerHTML = '<div class="success-msg">Configuration is valid.</div>';
|
|
||||||
} else {
|
|
||||||
var errs = (data.errors||[]).map(function(e) { return '<li>' + e + '</li>'; }).join('');
|
|
||||||
el.innerHTML = '<div class="error-msg">Configuration errors:<ul style="margin:4px 0 0 16px;">' + errs + '</ul></div>';
|
|
||||||
}
|
|
||||||
} catch (e) { el.innerHTML = '<div class="error-msg">' + e.message + '</div>'; }
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,7 @@
|
||||||
<button class="btn btn-sm btn-primary" onclick="showCreateTokenModal()">Create Token</button>
|
<button class="btn btn-sm btn-primary" onclick="showCreateTokenModal()">Create Token</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="new-token-display" style="display:none; margin-bottom:16px;">
|
<div id="new-token-display" style="display:none; margin-bottom:16px;"></div>
|
||||||
<div class="success-msg">Token created! Copy the key below - it won't be shown again.</div>
|
|
||||||
<div class="token-key"><code id="new-token-key"></code><button class="copy-btn" onclick="navigator.clipboard.writeText(document.getElementById('new-token-key').textContent)">Copy</button></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2>Static Tokens <span style="font-size:0.75rem;color:var(--text-muted);font-weight:400;">(from config, managed via environment variables)</span></h2>
|
<h2>Static Tokens <span style="font-size:0.75rem;color:var(--text-muted);font-weight:400;">(from config, managed via environment variables)</span></h2>
|
||||||
|
|
@ -70,7 +67,9 @@
|
||||||
</td>
|
</td>
|
||||||
<td>{{formatTime .CreatedAt}}</td>
|
<td>{{formatTime .CreatedAt}}</td>
|
||||||
<td>{{if gt .LastUsedAt 0}}{{formatTime .LastUsedAt}}{{else}}never{{end}}</td>
|
<td>{{if gt .LastUsedAt 0}}{{formatTime .LastUsedAt}}{{else}}never{{end}}</td>
|
||||||
<td><button class="btn btn-sm btn-danger" onclick="deleteToken({{.ID}})">Revoke</button></td>
|
<td><button class="btn btn-sm btn-danger"
|
||||||
|
hx-delete="/api/tokens/{{.ID}}" hx-swap="none"
|
||||||
|
hx-confirm="Revoke this API token? This cannot be undone.">Revoke</button></td>
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}{{end}}
|
{{end}}{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
@ -82,7 +81,8 @@
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
<h2>Create API Token</h2>
|
<h2>Create API Token</h2>
|
||||||
<div id="create-token-error"></div>
|
<div id="create-token-error"></div>
|
||||||
<form onsubmit="doCreateToken(event)">
|
<form hx-post="/api/tokens" hx-target="#new-token-display" hx-swap="innerHTML" hx-ext="json-enc"
|
||||||
|
hx-vals='js:{name: document.getElementById("token-name").value, rate_limit_rpm: parseInt(document.getElementById("token-rpm").value) || 0, daily_budget_usd: parseFloat(document.getElementById("token-budget").value) || 0}'>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Token Name</label>
|
<label>Token Name</label>
|
||||||
<input type="text" id="token-name" required placeholder="e.g. my-app">
|
<input type="text" id="token-name" required placeholder="e.g. my-app">
|
||||||
|
|
@ -118,33 +118,25 @@ function closeModal() {
|
||||||
document.getElementById('modal-create-token').addEventListener('click', function(e) {
|
document.getElementById('modal-create-token').addEventListener('click', function(e) {
|
||||||
if (e.target === this) closeModal();
|
if (e.target === this) closeModal();
|
||||||
});
|
});
|
||||||
async function doCreateToken(e) {
|
|
||||||
e.preventDefault();
|
// After token creation: show key, close modal, refresh token list
|
||||||
try {
|
document.body.addEventListener('tokenCreated', function() {
|
||||||
var resp = await fetch('/api/tokens', {
|
closeModal();
|
||||||
method: 'POST', credentials: 'same-origin',
|
document.getElementById('new-token-display').style.display = 'block';
|
||||||
headers: {'Content-Type': 'application/json'},
|
// Refresh the token list after a short delay
|
||||||
body: JSON.stringify({
|
setTimeout(function() {
|
||||||
name: document.getElementById('token-name').value,
|
|
||||||
rate_limit_rpm: parseInt(document.getElementById('token-rpm').value),
|
|
||||||
daily_budget_usd: parseFloat(document.getElementById('token-budget').value)
|
|
||||||
})
|
|
||||||
});
|
|
||||||
var data = await resp.json();
|
|
||||||
if (!resp.ok) { document.getElementById('create-token-error').innerHTML = '<div class="error-msg">' + (data.error||'Failed') + '</div>'; return; }
|
|
||||||
closeModal();
|
|
||||||
document.getElementById('new-token-key').textContent = data.key;
|
|
||||||
document.getElementById('new-token-display').style.display = 'block';
|
|
||||||
htmx.ajax('GET', '/tokens', {target: '#content', swap: 'innerHTML'});
|
htmx.ajax('GET', '/tokens', {target: '#content', swap: 'innerHTML'});
|
||||||
} catch (e) { document.getElementById('create-token-error').innerHTML = '<div class="error-msg">' + e.message + '</div>'; }
|
}, 100);
|
||||||
}
|
});
|
||||||
async function deleteToken(id) {
|
|
||||||
if (!confirm('Revoke this API token? This cannot be undone.')) return;
|
// Handle token create errors (non-200 responses swap into error div)
|
||||||
try {
|
document.body.addEventListener('htmx:beforeSwap', function(e) {
|
||||||
var resp = await fetch('/api/tokens/' + id, { method: 'DELETE', credentials: 'same-origin' });
|
if (e.detail.target.id === 'new-token-display' && !e.detail.isError && !e.detail.xhr.getResponseHeader('HX-Trigger')) {
|
||||||
if (!resp.ok) { var d = await resp.json(); alert(d.error||'Failed'); return; }
|
// Error response - redirect to error div
|
||||||
htmx.ajax('GET', '/tokens', {target: '#content', swap: 'innerHTML'});
|
if (e.detail.xhr.status >= 400) {
|
||||||
} catch (e) { alert(e.message); }
|
e.detail.target = document.getElementById('create-token-error');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,9 @@
|
||||||
<td><span class="badge {{if .IsAdmin}}badge-admin{{else}}badge-user{{end}}">{{if .IsAdmin}}Admin{{else}}User{{end}}</span></td>
|
<td><span class="badge {{if .IsAdmin}}badge-admin{{else}}badge-user{{end}}">{{if .IsAdmin}}Admin{{else}}User{{end}}</span></td>
|
||||||
<td>{{if .TOTPEnabled}}<span class="badge badge-totp">Enabled</span>{{else}}Off{{end}}</td>
|
<td>{{if .TOTPEnabled}}<span class="badge badge-totp">Enabled</span>{{else}}Off{{end}}</td>
|
||||||
<td>{{formatTime .CreatedAt}}</td>
|
<td>{{formatTime .CreatedAt}}</td>
|
||||||
<td>{{if ne .ID $.User.ID}}<button class="btn btn-sm btn-danger" onclick="deleteUser({{.ID}})">Delete</button>{{end}}</td>
|
<td>{{if ne .ID $.User.ID}}<button class="btn btn-sm btn-danger"
|
||||||
|
hx-delete="/api/auth/users/{{.ID}}" hx-swap="none"
|
||||||
|
hx-confirm="Delete this user? All their sessions and tokens will be removed.">Delete</button>{{end}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
@ -27,7 +29,8 @@
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
<h2>Create User</h2>
|
<h2>Create User</h2>
|
||||||
<div id="create-user-error"></div>
|
<div id="create-user-error"></div>
|
||||||
<form onsubmit="doCreateUser(event)">
|
<form hx-post="/api/auth/users" hx-target="#create-user-error" hx-swap="innerHTML" hx-ext="json-enc"
|
||||||
|
hx-vals='js:{username: document.getElementById("new-user-username").value, password: document.getElementById("new-user-password").value, is_admin: document.getElementById("new-user-admin").checked}'>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Username</label>
|
<label>Username</label>
|
||||||
<input type="text" id="new-user-username" required>
|
<input type="text" id="new-user-username" required>
|
||||||
|
|
@ -63,31 +66,5 @@ function closeUserModal() {
|
||||||
document.getElementById('modal-create-user').addEventListener('click', function(e) {
|
document.getElementById('modal-create-user').addEventListener('click', function(e) {
|
||||||
if (e.target === this) closeUserModal();
|
if (e.target === this) closeUserModal();
|
||||||
});
|
});
|
||||||
async function doCreateUser(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
try {
|
|
||||||
var resp = await fetch('/api/auth/users', {
|
|
||||||
method: 'POST', credentials: 'same-origin',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({
|
|
||||||
username: document.getElementById('new-user-username').value,
|
|
||||||
password: document.getElementById('new-user-password').value,
|
|
||||||
is_admin: document.getElementById('new-user-admin').checked
|
|
||||||
})
|
|
||||||
});
|
|
||||||
var data = await resp.json();
|
|
||||||
if (!resp.ok) { document.getElementById('create-user-error').innerHTML = '<div class="error-msg">' + (data.error||'Failed') + '</div>'; return; }
|
|
||||||
closeUserModal();
|
|
||||||
htmx.ajax('GET', '/users', {target: '#content', swap: 'innerHTML'});
|
|
||||||
} catch (e) { document.getElementById('create-user-error').innerHTML = '<div class="error-msg">' + e.message + '</div>'; }
|
|
||||||
}
|
|
||||||
async function deleteUser(id) {
|
|
||||||
if (!confirm('Delete this user? All their sessions and tokens will be removed.')) return;
|
|
||||||
try {
|
|
||||||
var resp = await fetch('/api/auth/users/' + id, { method: 'DELETE', credentials: 'same-origin' });
|
|
||||||
if (!resp.ok) { var d = await resp.json(); alert(d.error||'Failed'); return; }
|
|
||||||
htmx.ajax('GET', '/users', {target: '#content', swap: 'innerHTML'});
|
|
||||||
} catch (e) { alert(e.message); }
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Setup - LLM Gateway</title>
|
<title>Setup - LLM Gateway</title>
|
||||||
|
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||||
|
<script src="https://unpkg.com/htmx-ext-json-enc@2.0.1/json-enc.js"></script>
|
||||||
<style>
|
<style>
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
|
||||||
|
|
@ -25,14 +27,14 @@
|
||||||
<h1>LLM Gateway Setup</h1>
|
<h1>LLM Gateway Setup</h1>
|
||||||
<p class="subtitle">Create the first admin account</p>
|
<p class="subtitle">Create the first admin account</p>
|
||||||
<div id="setup-error"></div>
|
<div id="setup-error"></div>
|
||||||
<form onsubmit="doSetup(event)">
|
<form hx-post="/api/auth/setup" hx-target="#setup-error" hx-swap="innerHTML" hx-ext="json-enc">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Username</label>
|
<label>Username</label>
|
||||||
<input type="text" id="setup-username" required autocomplete="username">
|
<input type="text" name="username" required autocomplete="username">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Password (min 8 characters)</label>
|
<label>Password (min 8 characters)</label>
|
||||||
<input type="password" id="setup-password" required minlength="8" autocomplete="new-password">
|
<input type="password" name="password" required minlength="8" autocomplete="new-password">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Confirm Password</label>
|
<label>Confirm Password</label>
|
||||||
|
|
@ -42,30 +44,15 @@
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
function showError(msg) {
|
document.body.addEventListener('htmx:confirm', function(e) {
|
||||||
document.getElementById('setup-error').innerHTML = '<div class="error-msg">' + msg + '</div>';
|
var form = e.target;
|
||||||
}
|
if (!form.querySelector || !form.querySelector('#setup-password2')) return;
|
||||||
async function doSetup(e) {
|
var pw = form.querySelector('[name=password]').value;
|
||||||
e.preventDefault();
|
|
||||||
const pw = document.getElementById('setup-password').value;
|
|
||||||
if (pw !== document.getElementById('setup-password2').value) {
|
if (pw !== document.getElementById('setup-password2').value) {
|
||||||
showError('Passwords do not match');
|
e.preventDefault();
|
||||||
return;
|
document.getElementById('setup-error').innerHTML = '<div class="error-msg">Passwords do not match</div>';
|
||||||
}
|
}
|
||||||
try {
|
});
|
||||||
const resp = await fetch('/api/auth/setup', {
|
|
||||||
method: 'POST', credentials: 'same-origin',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({
|
|
||||||
username: document.getElementById('setup-username').value,
|
|
||||||
password: pw
|
|
||||||
})
|
|
||||||
});
|
|
||||||
const data = await resp.json();
|
|
||||||
if (!resp.ok) { showError(data.error || 'Setup failed'); return; }
|
|
||||||
window.location.href = '/dashboard';
|
|
||||||
} catch (e) { showError(e.message); }
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"llm-gateway/internal/provider"
|
"llm-gateway/internal/provider"
|
||||||
|
"llm-gateway/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (h *Handler) handleStream(w http.ResponseWriter, r *http.Request, req *provider.ChatRequest, routes []provider.Route, tokenName, requestID string, modelTimeouts *provider.ModelTimeouts) {
|
func (h *Handler) handleStream(w http.ResponseWriter, r *http.Request, req *provider.ChatRequest, routes []provider.Route, tokenName, requestID string, modelTimeouts *provider.ModelTimeouts) {
|
||||||
|
|
@ -85,6 +86,10 @@ func (h *Handler) handleStream(w http.ResponseWriter, r *http.Request, req *prov
|
||||||
scanner := bufio.NewScanner(body)
|
scanner := bufio.NewScanner(body)
|
||||||
scanner.Buffer(make([]byte, 64*1024), 256*1024)
|
scanner.Buffer(make([]byte, 64*1024), 256*1024)
|
||||||
|
|
||||||
|
// Capture streamed lines for debug logging
|
||||||
|
debugEnabled := h.debugLogger != nil && h.debugLogger.IsEnabled()
|
||||||
|
var debugLines []string
|
||||||
|
|
||||||
scanDone := make(chan struct{})
|
scanDone := make(chan struct{})
|
||||||
go func() {
|
go func() {
|
||||||
defer close(scanDone)
|
defer close(scanDone)
|
||||||
|
|
@ -116,6 +121,10 @@ func (h *Handler) handleStream(w http.ResponseWriter, r *http.Request, req *prov
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if debugEnabled {
|
||||||
|
debugLines = append(debugLines, line)
|
||||||
|
}
|
||||||
|
|
||||||
w.Write([]byte(line + "\n"))
|
w.Write([]byte(line + "\n"))
|
||||||
flusher.Flush()
|
flusher.Flush()
|
||||||
}
|
}
|
||||||
|
|
@ -138,6 +147,32 @@ func (h *Handler) handleStream(w http.ResponseWriter, r *http.Request, req *prov
|
||||||
if h.healthTracker != nil {
|
if h.healthTracker != nil {
|
||||||
h.healthTracker.Record(route.Provider.Name(), latency, nil)
|
h.healthTracker.Record(route.Provider.Name(), latency, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Debug logging for streaming requests
|
||||||
|
if debugEnabled && len(debugLines) > 0 {
|
||||||
|
respBody := strings.Join(debugLines, "\n")
|
||||||
|
reqBody, _ := json.Marshal(req)
|
||||||
|
reqBodyStr := string(reqBody)
|
||||||
|
if h.cfg.Debug.MaxBodyBytes > 0 {
|
||||||
|
if len(reqBodyStr) > h.cfg.Debug.MaxBodyBytes {
|
||||||
|
reqBodyStr = reqBodyStr[:h.cfg.Debug.MaxBodyBytes]
|
||||||
|
}
|
||||||
|
if len(respBody) > h.cfg.Debug.MaxBodyBytes {
|
||||||
|
respBody = respBody[:h.cfg.Debug.MaxBodyBytes]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.debugLogger.Log(storage.DebugLogEntry{
|
||||||
|
RequestID: requestID,
|
||||||
|
TokenName: tokenName,
|
||||||
|
Model: req.Model,
|
||||||
|
Provider: route.Provider.Name(),
|
||||||
|
RequestBody: reqBodyStr,
|
||||||
|
ResponseBody: respBody,
|
||||||
|
RequestHeaders: formatHeaders(r.Header),
|
||||||
|
ResponseStatus: http.StatusOK,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,8 @@ type AuditEntry struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuditLogger struct {
|
type AuditLogger struct {
|
||||||
db *DB
|
db *DB
|
||||||
|
OnWrite func()
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAuditLogger(db *DB) *AuditLogger {
|
func NewAuditLogger(db *DB) *AuditLogger {
|
||||||
|
|
@ -38,6 +39,8 @@ func (a *AuditLogger) Log(entry AuditEntry) {
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("ERROR: audit log: %v", err)
|
log.Printf("ERROR: audit log: %v", err)
|
||||||
|
} else if a.OnWrite != nil {
|
||||||
|
a.OnWrite()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ type DebugLogger struct {
|
||||||
db *DB
|
db *DB
|
||||||
enabled atomic.Bool
|
enabled atomic.Bool
|
||||||
dataDir string
|
dataDir string
|
||||||
|
OnWrite func()
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDebugLogger(db *DB, enabled bool, dataDir string) *DebugLogger {
|
func NewDebugLogger(db *DB, enabled bool, dataDir string) *DebugLogger {
|
||||||
|
|
@ -105,6 +106,8 @@ func (d *DebugLogger) Log(entry DebugLogEntry) {
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("ERROR: debug log db insert: %v", err)
|
log.Printf("ERROR: debug log db insert: %v", err)
|
||||||
|
} else if d.OnWrite != nil {
|
||||||
|
d.OnWrite()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue