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:
@@ -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
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user