From 28d3533d923905de66f4cbdce360cc0a1f00f642 Mon Sep 17 00:00:00 2001 From: Rafael Matias Date: Mon, 12 Jan 2026 16:39:25 +0100 Subject: [PATCH] feat: add auth config to health endpoint and improve login page UX Extend /health endpoint to include enabled auth methods in response, allowing the UI to conditionally show login options. The login page now checks API availability, shows appropriate feedback when the server is unreachable, and only displays auth methods that are enabled. --- pkg/api/api.go | 24 ++++- pkg/api/docs/docs.go | 24 +++++ pkg/api/docs/swagger.json | 24 +++++ pkg/api/docs/swagger.yaml | 16 ++++ ui/src/api/client.ts | 11 ++- ui/src/pages/LoginPage.tsx | 181 ++++++++++++++++++++++++++----------- ui/src/types/index.ts | 15 +++ 7 files changed, 238 insertions(+), 57 deletions(-) diff --git a/pkg/api/api.go b/pkg/api/api.go index 709fabf..925488a 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -343,7 +343,19 @@ func (s *server) writeError(w http.ResponseWriter, status int, message string) { // HealthResponse is the response for the health check endpoint. type HealthResponse struct { - Status string `json:"status" example:"ok"` + Status string `json:"status" example:"ok"` + Config HealthConfig `json:"config"` +} + +// HealthConfig contains public configuration information. +type HealthConfig struct { + Auth HealthAuthConfig `json:"auth"` +} + +// HealthAuthConfig indicates which authentication methods are enabled. +type HealthAuthConfig struct { + Basic bool `json:"basic" example:"true"` + GitHub bool `json:"github" example:"false"` } // RateLimitErrorResponse is returned when rate limit is exceeded. @@ -376,7 +388,15 @@ func (s *server) handleOpenAPISpec(w http.ResponseWriter, _ *http.Request) { // @Failure 429 {object} RateLimitErrorResponse "Rate limit exceeded" // @Router /health [get] func (s *server) handleHealth(w http.ResponseWriter, _ *http.Request) { - s.writeJSON(w, http.StatusOK, HealthResponse{Status: "ok"}) + s.writeJSON(w, http.StatusOK, HealthResponse{ + Status: "ok", + Config: HealthConfig{ + Auth: HealthAuthConfig{ + Basic: s.cfg.Auth.Basic.Enabled, + GitHub: s.cfg.Auth.GitHub.Enabled, + }, + }, + }) } // handleStatus godoc diff --git a/pkg/api/docs/docs.go b/pkg/api/docs/docs.go index fd6455b..601021d 100644 --- a/pkg/api/docs/docs.go +++ b/pkg/api/docs/docs.go @@ -2075,9 +2075,33 @@ const docTemplate = `{ } } }, + "pkg_api.HealthAuthConfig": { + "type": "object", + "properties": { + "basic": { + "type": "boolean", + "example": true + }, + "github": { + "type": "boolean", + "example": false + } + } + }, + "pkg_api.HealthConfig": { + "type": "object", + "properties": { + "auth": { + "$ref": "#/definitions/pkg_api.HealthAuthConfig" + } + } + }, "pkg_api.HealthResponse": { "type": "object", "properties": { + "config": { + "$ref": "#/definitions/pkg_api.HealthConfig" + }, "status": { "type": "string", "example": "ok" diff --git a/pkg/api/docs/swagger.json b/pkg/api/docs/swagger.json index aabf027..c359bc6 100644 --- a/pkg/api/docs/swagger.json +++ b/pkg/api/docs/swagger.json @@ -2069,9 +2069,33 @@ } } }, + "pkg_api.HealthAuthConfig": { + "type": "object", + "properties": { + "basic": { + "type": "boolean", + "example": true + }, + "github": { + "type": "boolean", + "example": false + } + } + }, + "pkg_api.HealthConfig": { + "type": "object", + "properties": { + "auth": { + "$ref": "#/definitions/pkg_api.HealthAuthConfig" + } + } + }, "pkg_api.HealthResponse": { "type": "object", "properties": { + "config": { + "$ref": "#/definitions/pkg_api.HealthConfig" + }, "status": { "type": "string", "example": "ok" diff --git a/pkg/api/docs/swagger.yaml b/pkg/api/docs/swagger.yaml index 4a9bc92..8492ab7 100644 --- a/pkg/api/docs/swagger.yaml +++ b/pkg/api/docs/swagger.yaml @@ -322,8 +322,24 @@ definitions: updated_at: type: string type: object + pkg_api.HealthAuthConfig: + properties: + basic: + example: true + type: boolean + github: + example: false + type: boolean + type: object + pkg_api.HealthConfig: + properties: + auth: + $ref: '#/definitions/pkg_api.HealthAuthConfig' + type: object pkg_api.HealthResponse: properties: + config: + $ref: '#/definitions/pkg_api.HealthConfig' status: example: ok type: string diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index 6c7dcb3..1fa3c21 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -10,6 +10,7 @@ import type { HistoryResponse, HistoryStatsResponse, HistoryStatsTimeRange, + HealthResponse, } from '../types'; import { getConfig } from '../config'; @@ -291,8 +292,14 @@ class ApiClient { return this.request('/status'); } - async getHealth(): Promise<{ status: string }> { - const response = await fetch('/health'); + async getHealth(): Promise { + // Health endpoint is at root level, not under /api/v1 + const apiBase = this.getApiBase(); + const baseUrl = apiBase.replace(/\/api\/v1\/?$/, ''); + const response = await fetch(`${baseUrl}/health`); + if (!response.ok) { + throw new Error(`Health check failed: ${response.status}`); + } return response.json(); } diff --git a/ui/src/pages/LoginPage.tsx b/ui/src/pages/LoginPage.tsx index 9cc12eb..603efc8 100644 --- a/ui/src/pages/LoginPage.tsx +++ b/ui/src/pages/LoginPage.tsx @@ -1,16 +1,49 @@ -import { useState, type FormEvent } from 'react'; +import { useState, useEffect, type FormEvent } from 'react'; import { Navigate, useLocation } from 'react-router-dom'; import { useAuthStore } from '../stores/authStore'; import { api } from '../api/client'; +import type { HealthAuthConfig } from '../types'; export function LoginPage() { const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); + const [apiAvailable, setApiAvailable] = useState(null); + const [authConfig, setAuthConfig] = useState(null); const { login, isAuthenticated, isLoading, error, clearError } = useAuthStore(); const location = useLocation(); const from = (location.state as { from?: { pathname: string } })?.from?.pathname || '/'; + // Check API health on mount and periodically + useEffect(() => { + let mounted = true; + + const checkHealth = async () => { + try { + const health = await api.getHealth(); + if (mounted) { + setApiAvailable(true); + setAuthConfig(health.config.auth); + } + } catch { + if (mounted) { + setApiAvailable(false); + setAuthConfig(null); + } + } + }; + + checkHealth(); + + // Poll more frequently when API is unavailable + const interval = setInterval(checkHealth, apiAvailable === false ? 5000 : 30000); + + return () => { + mounted = false; + clearInterval(interval); + }; + }, [apiAvailable]); + if (isAuthenticated) { return ; } @@ -29,6 +62,10 @@ export function LoginPage() { window.location.href = api.getGitHubAuthUrl(); }; + const showBasicAuth = authConfig?.basic ?? false; + const showGitHubAuth = authConfig?.github ?? false; + const noAuthConfigured = authConfig !== null && !showBasicAuth && !showGitHubAuth; + return (
@@ -43,68 +80,106 @@ export function LoginPage() {
-
- {error && ( -
- {error} + {/* API unavailable warning */} + {apiAvailable === false && ( +
+

Unable to connect to API server

+

Please check if the server is running.

+
+ )} + + {/* Loading state while checking API */} + {apiAvailable === null && ( +
+
+ + + + + Connecting to API...
- )} - -
- - setUsername(e.target.value)} - className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-sm text-white placeholder-zinc-500 focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:border-transparent" - placeholder="Enter your username" - required - disabled={isLoading} - />
+ )} -
- - setPassword(e.target.value)} - className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-sm text-white placeholder-zinc-500 focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:border-transparent" - placeholder="Enter your password" - required - disabled={isLoading} - /> + {/* No auth methods configured */} + {noAuthConfigured && ( +
+

No authentication methods configured.

+

Please contact your administrator.

+ )} - - + {/* Basic auth form */} + {showBasicAuth && ( +
+ {error && ( +
+ {error} +
+ )} + +
+ + setUsername(e.target.value)} + className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-sm text-white placeholder-zinc-500 focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:border-transparent" + placeholder="Enter your username" + required + disabled={isLoading || apiAvailable === false} + /> +
-
-
-
-
+
+ + setPassword(e.target.value)} + className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-sm text-white placeholder-zinc-500 focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:border-transparent" + placeholder="Enter your password" + required + disabled={isLoading || apiAvailable === false} + />
-
- Or continue with + + + + )} + + {/* Divider between basic auth and GitHub auth */} + {showBasicAuth && showGitHubAuth && ( +
+
+
+
+
+
+ Or continue with +
+ )} + {/* GitHub auth button */} + {showGitHubAuth && ( -
+ )}
diff --git a/ui/src/types/index.ts b/ui/src/types/index.ts index 1079807..3fac851 100644 --- a/ui/src/types/index.ts +++ b/ui/src/types/index.ts @@ -231,3 +231,18 @@ export interface WSSystemStatus { connected_clients: number; timestamp: string; } + +// Health endpoint types +export interface HealthAuthConfig { + basic: boolean; + github: boolean; +} + +export interface HealthConfig { + auth: HealthAuthConfig; +} + +export interface HealthResponse { + status: string; + config: HealthConfig; +}