feat: proxy routes page, OIDC login fix, NPM test connection, webhook URL fix, and UX improvements

- Add /proxies page showing deploy-managed proxy routes with project/stage links, search, and status
- Add GET /api/proxies endpoint joining instances with project/stage names
- Add POST /api/settings/npm/test endpoint for NPM connection validation
- Add GET /api/auth/mode public endpoint for auth mode detection
- Add NPM Test Connection button with validation on save
- Fix OIDC SSO button only shown when auth_mode is oidc
- Fix webhook URL showing empty when domain not set (fallback to request host)
- Fix quick deploy double-tag (image:latest:latest) by splitting tag from image URL
- Fix trim() errors on number inputs in deploy and settings forms
- Fix NPM client auto-append /api to base URL
- Sanitize NPM test error messages (no raw HTML)
- Remove healthcheck field from Quick Deploy form
- Fix env vars placeholder newline
- Make domain field optional in settings
- Set polling interval minimum to 60s
- Add Proxies and Events to sidebar navigation
- Fix SSL cert name flash on NPM settings page
- Fix empty state icon on proxies page
This commit is contained in:
2026-04-05 01:27:54 +03:00
parent 1aa9c3f0e9
commit 187e302f4a
18 changed files with 525 additions and 63 deletions
+10
View File
@@ -30,6 +30,16 @@ func (s *Server) rateLimitedLogin(rl *rateLimiter) http.HandlerFunc {
}
}
// authMode handles GET /api/auth/mode — public endpoint returning the auth mode.
func (s *Server) authMode(w http.ResponseWriter, r *http.Request) {
as, err := s.store.GetAuthSettings()
if err != nil {
respondJSON(w, http.StatusOK, map[string]string{"auth_mode": "local"})
return
}
respondJSON(w, http.StatusOK, map[string]string{"auth_mode": as.AuthMode})
}
// login handles POST /api/auth/login.
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
var req auth.LoginRequest
+11 -2
View File
@@ -116,11 +116,20 @@ func (s *Server) quickDeploy(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusBadRequest, "image is required")
return
}
// Split tag from image if the image URL contains one (e.g., "registry/app:v1").
if req.Tag == "" {
req.Tag = "latest"
imageRef, tag := splitImageTag(req.Image)
if tag != "" {
req.Image = imageRef
req.Tag = tag
} else {
req.Tag = "latest"
}
}
if req.Name == "" {
// Derive name from image.
// Derive name from image (without tag).
parts := strings.Split(req.Image, "/")
req.Name = parts[len(parts)-1]
}
+26
View File
@@ -0,0 +1,26 @@
package api
import (
"log/slog"
"net/http"
)
// listProxyRoutes handles GET /api/proxies.
// Returns all proxy-enabled instances with project and stage names.
func (s *Server) listProxyRoutes(w http.ResponseWriter, r *http.Request) {
settings, err := s.store.GetSettings()
if err != nil {
slog.Error("failed to get settings for proxy routes", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
routes, err := s.store.ListProxyRoutes(settings.Domain)
if err != nil {
slog.Error("failed to list proxy routes", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
respondJSON(w, http.StatusOK, routes)
}
+5
View File
@@ -169,6 +169,7 @@ func (s *Server) Router() chi.Router {
r.Use(limitBody)
// Public auth endpoints (no auth required).
r.Get("/auth/mode", s.authMode)
r.Post("/auth/login", s.rateLimitedLogin(loginLimiter))
r.Get("/auth/oidc/login", s.oidcLogin)
r.Get("/auth/oidc/callback", s.oidcCallback)
@@ -185,6 +186,7 @@ func (s *Server) Router() chi.Router {
r.Get("/health", s.getHealth)
r.Get("/auth/me", s.currentUser)
r.Post("/auth/logout", s.logout)
r.Get("/proxies", s.listProxyRoutes)
r.Get("/projects", s.listProjects)
r.Route("/projects/{id}", func(r chi.Router) {
r.Get("/", s.getProject)
@@ -290,6 +292,9 @@ func (s *Server) Router() chi.Router {
r.Get("/settings/webhook-url", s.getWebhookURL)
r.Post("/settings/webhook-url/regenerate", s.regenerateWebhookSecret)
// NPM connection test.
r.Post("/settings/npm/test", s.testNpmConnection)
// DNS management endpoints.
r.Post("/settings/dns/test", s.testDNSConnection)
r.Post("/settings/dns/zones", s.listDNSZones)
+84 -5
View File
@@ -258,8 +258,15 @@ func (s *Server) getWebhookURL(w http.ResponseWriter, r *http.Request) {
}
webhookURL := ""
if settings.WebhookSecret != "" && settings.Domain != "" {
webhookURL = fmt.Sprintf("https://%s/api/webhook/%s", settings.Domain, settings.WebhookSecret)
if settings.WebhookSecret != "" {
host := settings.Domain
scheme := "https"
if host == "" {
// Fall back to request host for dev/local setups.
host = r.Host
scheme = "http"
}
webhookURL = fmt.Sprintf("%s://%s/api/webhook/%s", scheme, host, settings.WebhookSecret)
}
respondJSON(w, http.StatusOK, map[string]string{
@@ -281,10 +288,13 @@ func (s *Server) regenerateWebhookSecret(w http.ResponseWriter, r *http.Request)
return
}
webhookURL := ""
if settings.Domain != "" {
webhookURL = fmt.Sprintf("https://%s/api/webhook/%s", settings.Domain, secret)
host := settings.Domain
scheme := "https"
if host == "" {
host = r.Host
scheme = "http"
}
webhookURL := fmt.Sprintf("%s://%s/api/webhook/%s", scheme, host, secret)
respondJSON(w, http.StatusOK, map[string]string{
"webhook_url": webhookURL,
@@ -504,6 +514,75 @@ type dnsTestRequest struct {
ZoneID string `json:"zone_id"`
}
// testNpmConnection handles POST /api/settings/npm/test.
// Tests connectivity and authentication to the NPM API.
func (s *Server) testNpmConnection(w http.ResponseWriter, r *http.Request) {
var req struct {
URL string `json:"npm_url"`
Email string `json:"npm_email"`
Password string `json:"npm_password"`
}
if !decodeJSON(w, r, &req) {
return
}
// Use provided values, fall back to stored settings.
settings, err := s.store.GetSettings()
if err != nil {
slog.Error("failed to get settings", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
npmURL := req.URL
if npmURL == "" {
npmURL = settings.NpmURL
}
if npmURL == "" {
respondError(w, http.StatusBadRequest, "NPM URL is required")
return
}
email := req.Email
if email == "" {
email = settings.NpmEmail
}
password := req.Password
if password == "" && settings.NpmPassword != "" {
decrypted, err := crypto.Decrypt(s.encKey, settings.NpmPassword)
if err != nil {
respondError(w, http.StatusBadRequest, "failed to decrypt stored NPM password")
return
}
password = decrypted
}
if email == "" || password == "" {
respondError(w, http.StatusBadRequest, "NPM email and password are required")
return
}
// Test connectivity.
client := npm.New(npmURL)
ctx := r.Context()
if err := client.Ping(ctx); err != nil {
slog.Warn("npm test: ping failed", "url", npmURL, "error", err)
respondError(w, http.StatusBadGateway, "Cannot reach NPM at "+npmURL)
return
}
// Test authentication.
if err := client.Authenticate(ctx, email, password); err != nil {
slog.Warn("npm test: auth failed", "url", npmURL, "error", err)
respondError(w, http.StatusBadGateway, "NPM authentication failed — check email and password")
return
}
respondJSON(w, http.StatusOK, map[string]string{"status": "connected"})
}
// testDNSConnection handles POST /api/settings/dns/test.
func (s *Server) testDNSConnection(w http.ResponseWriter, r *http.Request) {
var req dnsTestRequest