Skip to content

Commit 179f4a8

Browse files
authored
feat: add auth config to health endpoint and improve login page UX (#13)
## Summary - Extend `/health` endpoint to include `config.auth.basic` and `config.auth.github` fields indicating which authentication methods are enabled - Update login page to check API availability on mount and show a warning if the server is unreachable - Conditionally render login options based on enabled auth methods (only show username/password form if basic auth is enabled, only show GitHub button if GitHub auth is enabled) ## Test plan - [x] Verify `/health` endpoint returns auth config matching the server configuration - [x] With API running: login page shows appropriate auth methods based on config - [x] With API stopped: login page shows "Unable to connect to API server" warning - [x] With only basic auth enabled: only username/password form is shown - [x] With only GitHub auth enabled: only GitHub button is shown - [x] With both enabled: both options shown with divider - [x] With neither enabled: "No authentication methods configured" message shown
1 parent d6f26a3 commit 179f4a8

7 files changed

Lines changed: 238 additions & 57 deletions

File tree

pkg/api/api.go

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -343,7 +343,19 @@ func (s *server) writeError(w http.ResponseWriter, status int, message string) {
343343

344344
// HealthResponse is the response for the health check endpoint.
345345
type HealthResponse struct {
346-
Status string `json:"status" example:"ok"`
346+
Status string `json:"status" example:"ok"`
347+
Config HealthConfig `json:"config"`
348+
}
349+
350+
// HealthConfig contains public configuration information.
351+
type HealthConfig struct {
352+
Auth HealthAuthConfig `json:"auth"`
353+
}
354+
355+
// HealthAuthConfig indicates which authentication methods are enabled.
356+
type HealthAuthConfig struct {
357+
Basic bool `json:"basic" example:"true"`
358+
GitHub bool `json:"github" example:"false"`
347359
}
348360

349361
// RateLimitErrorResponse is returned when rate limit is exceeded.
@@ -376,7 +388,15 @@ func (s *server) handleOpenAPISpec(w http.ResponseWriter, _ *http.Request) {
376388
// @Failure 429 {object} RateLimitErrorResponse "Rate limit exceeded"
377389
// @Router /health [get]
378390
func (s *server) handleHealth(w http.ResponseWriter, _ *http.Request) {
379-
s.writeJSON(w, http.StatusOK, HealthResponse{Status: "ok"})
391+
s.writeJSON(w, http.StatusOK, HealthResponse{
392+
Status: "ok",
393+
Config: HealthConfig{
394+
Auth: HealthAuthConfig{
395+
Basic: s.cfg.Auth.Basic.Enabled,
396+
GitHub: s.cfg.Auth.GitHub.Enabled,
397+
},
398+
},
399+
})
380400
}
381401

382402
// handleStatus godoc

pkg/api/docs/docs.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2075,9 +2075,33 @@ const docTemplate = `{
20752075
}
20762076
}
20772077
},
2078+
"pkg_api.HealthAuthConfig": {
2079+
"type": "object",
2080+
"properties": {
2081+
"basic": {
2082+
"type": "boolean",
2083+
"example": true
2084+
},
2085+
"github": {
2086+
"type": "boolean",
2087+
"example": false
2088+
}
2089+
}
2090+
},
2091+
"pkg_api.HealthConfig": {
2092+
"type": "object",
2093+
"properties": {
2094+
"auth": {
2095+
"$ref": "#/definitions/pkg_api.HealthAuthConfig"
2096+
}
2097+
}
2098+
},
20782099
"pkg_api.HealthResponse": {
20792100
"type": "object",
20802101
"properties": {
2102+
"config": {
2103+
"$ref": "#/definitions/pkg_api.HealthConfig"
2104+
},
20812105
"status": {
20822106
"type": "string",
20832107
"example": "ok"

pkg/api/docs/swagger.json

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2069,9 +2069,33 @@
20692069
}
20702070
}
20712071
},
2072+
"pkg_api.HealthAuthConfig": {
2073+
"type": "object",
2074+
"properties": {
2075+
"basic": {
2076+
"type": "boolean",
2077+
"example": true
2078+
},
2079+
"github": {
2080+
"type": "boolean",
2081+
"example": false
2082+
}
2083+
}
2084+
},
2085+
"pkg_api.HealthConfig": {
2086+
"type": "object",
2087+
"properties": {
2088+
"auth": {
2089+
"$ref": "#/definitions/pkg_api.HealthAuthConfig"
2090+
}
2091+
}
2092+
},
20722093
"pkg_api.HealthResponse": {
20732094
"type": "object",
20742095
"properties": {
2096+
"config": {
2097+
"$ref": "#/definitions/pkg_api.HealthConfig"
2098+
},
20752099
"status": {
20762100
"type": "string",
20772101
"example": "ok"

pkg/api/docs/swagger.yaml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,8 +322,24 @@ definitions:
322322
updated_at:
323323
type: string
324324
type: object
325+
pkg_api.HealthAuthConfig:
326+
properties:
327+
basic:
328+
example: true
329+
type: boolean
330+
github:
331+
example: false
332+
type: boolean
333+
type: object
334+
pkg_api.HealthConfig:
335+
properties:
336+
auth:
337+
$ref: '#/definitions/pkg_api.HealthAuthConfig'
338+
type: object
325339
pkg_api.HealthResponse:
326340
properties:
341+
config:
342+
$ref: '#/definitions/pkg_api.HealthConfig'
327343
status:
328344
example: ok
329345
type: string

ui/src/api/client.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
HistoryResponse,
1111
HistoryStatsResponse,
1212
HistoryStatsTimeRange,
13+
HealthResponse,
1314
} from '../types';
1415
import { getConfig } from '../config';
1516

@@ -291,8 +292,14 @@ class ApiClient {
291292
return this.request<SystemStatus>('/status');
292293
}
293294

294-
async getHealth(): Promise<{ status: string }> {
295-
const response = await fetch('/health');
295+
async getHealth(): Promise<HealthResponse> {
296+
// Health endpoint is at root level, not under /api/v1
297+
const apiBase = this.getApiBase();
298+
const baseUrl = apiBase.replace(/\/api\/v1\/?$/, '');
299+
const response = await fetch(`${baseUrl}/health`);
300+
if (!response.ok) {
301+
throw new Error(`Health check failed: ${response.status}`);
302+
}
296303
return response.json();
297304
}
298305

ui/src/pages/LoginPage.tsx

Lines changed: 128 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,49 @@
1-
import { useState, type FormEvent } from 'react';
1+
import { useState, useEffect, type FormEvent } from 'react';
22
import { Navigate, useLocation } from 'react-router-dom';
33
import { useAuthStore } from '../stores/authStore';
44
import { api } from '../api/client';
5+
import type { HealthAuthConfig } from '../types';
56

67
export function LoginPage() {
78
const [username, setUsername] = useState('');
89
const [password, setPassword] = useState('');
10+
const [apiAvailable, setApiAvailable] = useState<boolean | null>(null);
11+
const [authConfig, setAuthConfig] = useState<HealthAuthConfig | null>(null);
912
const { login, isAuthenticated, isLoading, error, clearError } = useAuthStore();
1013
const location = useLocation();
1114

1215
const from = (location.state as { from?: { pathname: string } })?.from?.pathname || '/';
1316

17+
// Check API health on mount and periodically
18+
useEffect(() => {
19+
let mounted = true;
20+
21+
const checkHealth = async () => {
22+
try {
23+
const health = await api.getHealth();
24+
if (mounted) {
25+
setApiAvailable(true);
26+
setAuthConfig(health.config.auth);
27+
}
28+
} catch {
29+
if (mounted) {
30+
setApiAvailable(false);
31+
setAuthConfig(null);
32+
}
33+
}
34+
};
35+
36+
checkHealth();
37+
38+
// Poll more frequently when API is unavailable
39+
const interval = setInterval(checkHealth, apiAvailable === false ? 5000 : 30000);
40+
41+
return () => {
42+
mounted = false;
43+
clearInterval(interval);
44+
};
45+
}, [apiAvailable]);
46+
1447
if (isAuthenticated) {
1548
return <Navigate to={from} replace />;
1649
}
@@ -29,6 +62,10 @@ export function LoginPage() {
2962
window.location.href = api.getGitHubAuthUrl();
3063
};
3164

65+
const showBasicAuth = authConfig?.basic ?? false;
66+
const showGitHubAuth = authConfig?.github ?? false;
67+
const noAuthConfigured = authConfig !== null && !showBasicAuth && !showGitHubAuth;
68+
3269
return (
3370
<div className="min-h-dvh flex items-center justify-center bg-zinc-950 px-4">
3471
<div className="w-full max-w-sm">
@@ -43,68 +80,106 @@ export function LoginPage() {
4380
</div>
4481

4582
<div className="bg-zinc-900 rounded-lg p-6 border border-zinc-800">
46-
<form onSubmit={handleSubmit} className="space-y-4">
47-
{error && (
48-
<div className="bg-red-500/10 border border-red-500/20 text-red-400 px-4 py-3 rounded-sm text-sm">
49-
{error}
83+
{/* API unavailable warning */}
84+
{apiAvailable === false && (
85+
<div className="bg-red-500/10 border border-red-500/20 text-red-400 px-4 py-3 rounded-sm text-sm mb-4">
86+
<p className="font-medium">Unable to connect to API server</p>
87+
<p className="mt-1 text-red-400/80">Please check if the server is running.</p>
88+
</div>
89+
)}
90+
91+
{/* Loading state while checking API */}
92+
{apiAvailable === null && (
93+
<div className="flex items-center justify-center py-8">
94+
<div className="flex items-center gap-2 text-zinc-400">
95+
<svg className="size-5 animate-spin" fill="none" viewBox="0 0 24 24">
96+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
97+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
98+
</svg>
99+
<span>Connecting to API...</span>
50100
</div>
51-
)}
52-
53-
<div>
54-
<label htmlFor="username" className="block text-sm font-medium text-zinc-300 mb-1">
55-
Username
56-
</label>
57-
<input
58-
id="username"
59-
type="text"
60-
value={username}
61-
onChange={(e) => setUsername(e.target.value)}
62-
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"
63-
placeholder="Enter your username"
64-
required
65-
disabled={isLoading}
66-
/>
67101
</div>
102+
)}
68103

69-
<div>
70-
<label htmlFor="password" className="block text-sm font-medium text-zinc-300 mb-1">
71-
Password
72-
</label>
73-
<input
74-
id="password"
75-
type="password"
76-
value={password}
77-
onChange={(e) => setPassword(e.target.value)}
78-
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"
79-
placeholder="Enter your password"
80-
required
81-
disabled={isLoading}
82-
/>
104+
{/* No auth methods configured */}
105+
{noAuthConfigured && (
106+
<div className="text-center py-4 text-zinc-400">
107+
<p>No authentication methods configured.</p>
108+
<p className="text-sm mt-1">Please contact your administrator.</p>
83109
</div>
110+
)}
84111

85-
<button
86-
type="submit"
87-
disabled={isLoading}
88-
className="w-full py-2 px-4 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-600/50 text-white font-medium rounded-sm transition-colors focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-zinc-900"
89-
>
90-
{isLoading ? 'Signing in...' : 'Sign in'}
91-
</button>
92-
</form>
112+
{/* Basic auth form */}
113+
{showBasicAuth && (
114+
<form onSubmit={handleSubmit} className="space-y-4">
115+
{error && (
116+
<div className="bg-red-500/10 border border-red-500/20 text-red-400 px-4 py-3 rounded-sm text-sm">
117+
{error}
118+
</div>
119+
)}
120+
121+
<div>
122+
<label htmlFor="username" className="block text-sm font-medium text-zinc-300 mb-1">
123+
Username
124+
</label>
125+
<input
126+
id="username"
127+
type="text"
128+
value={username}
129+
onChange={(e) => setUsername(e.target.value)}
130+
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"
131+
placeholder="Enter your username"
132+
required
133+
disabled={isLoading || apiAvailable === false}
134+
/>
135+
</div>
93136

94-
<div className="mt-6">
95-
<div className="relative">
96-
<div className="absolute inset-0 flex items-center">
97-
<div className="w-full border-t border-zinc-700" />
137+
<div>
138+
<label htmlFor="password" className="block text-sm font-medium text-zinc-300 mb-1">
139+
Password
140+
</label>
141+
<input
142+
id="password"
143+
type="password"
144+
value={password}
145+
onChange={(e) => setPassword(e.target.value)}
146+
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"
147+
placeholder="Enter your password"
148+
required
149+
disabled={isLoading || apiAvailable === false}
150+
/>
98151
</div>
99-
<div className="relative flex justify-center text-sm">
100-
<span className="px-2 bg-zinc-900 text-zinc-500">Or continue with</span>
152+
153+
<button
154+
type="submit"
155+
disabled={isLoading || apiAvailable === false}
156+
className="w-full py-2 px-4 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-600/50 text-white font-medium rounded-sm transition-colors focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-zinc-900"
157+
>
158+
{isLoading ? 'Signing in...' : 'Sign in'}
159+
</button>
160+
</form>
161+
)}
162+
163+
{/* Divider between basic auth and GitHub auth */}
164+
{showBasicAuth && showGitHubAuth && (
165+
<div className="mt-6">
166+
<div className="relative">
167+
<div className="absolute inset-0 flex items-center">
168+
<div className="w-full border-t border-zinc-700" />
169+
</div>
170+
<div className="relative flex justify-center text-sm">
171+
<span className="px-2 bg-zinc-900 text-zinc-500">Or continue with</span>
172+
</div>
101173
</div>
102174
</div>
175+
)}
103176

177+
{/* GitHub auth button */}
178+
{showGitHubAuth && (
104179
<button
105180
onClick={handleGitHubLogin}
106-
disabled={isLoading}
107-
className="mt-4 w-full py-2 px-4 bg-zinc-800 hover:bg-zinc-700 disabled:bg-zinc-800/50 text-white font-medium rounded-sm transition-colors border border-zinc-700 flex items-center justify-center gap-2"
181+
disabled={isLoading || apiAvailable === false}
182+
className={`w-full py-2 px-4 bg-zinc-800 hover:bg-zinc-700 disabled:bg-zinc-800/50 text-white font-medium rounded-sm transition-colors border border-zinc-700 flex items-center justify-center gap-2 ${showBasicAuth ? 'mt-4' : ''}`}
108183
>
109184
<svg className="size-5" fill="currentColor" viewBox="0 0 24 24">
110185
<path
@@ -113,9 +188,9 @@ export function LoginPage() {
113188
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
114189
/>
115190
</svg>
116-
GitHub
191+
Sign in with GitHub
117192
</button>
118-
</div>
193+
)}
119194
</div>
120195
</div>
121196
</div>

0 commit comments

Comments
 (0)