From 09dd3a5ea699f68f094a31232d393e3e413611fa Mon Sep 17 00:00:00 2001 From: nessi Date: Tue, 17 Mar 2026 19:59:15 +0100 Subject: [PATCH] feat: add bootstrap availability check to login page with conditional UI Add useEffect hook to fetch bootstrap status on component mount. Add bootstrapAvailable and bootstrapStatusLoaded state variables to track bootstrap endpoint availability. Hide mode toggle button when bootstrap is unavailable or status hasn't loaded yet. Add auth-brand and auth-brand-copy CSS classes to improve login page layout and branding. Add BootstrapStatus handler and BootstrapAvailable service method to expose bootstrap availability --- admin-web/src/features/auth/LoginPage.tsx | 57 +++++++++++++++++++---- admin-web/src/styles/global.css | 21 +++++++++ backend/internal/auth/handler.go | 12 +++++ backend/internal/auth/service.go | 9 ++++ backend/internal/httpserver/router.go | 1 + 5 files changed, 91 insertions(+), 9 deletions(-) diff --git a/admin-web/src/features/auth/LoginPage.tsx b/admin-web/src/features/auth/LoginPage.tsx index 15110d8..fd1bfaf 100644 --- a/admin-web/src/features/auth/LoginPage.tsx +++ b/admin-web/src/features/auth/LoginPage.tsx @@ -1,4 +1,4 @@ -import { FormEvent, useState } from "react"; +import { FormEvent, useEffect, useState } from "react"; type LoginPageProps = { onAuthenticated: (accessToken: string) => void; @@ -11,9 +11,46 @@ export function LoginPage({ onAuthenticated }: LoginPageProps) { const [username, setUsername] = useState("admin"); const [displayName, setDisplayName] = useState("NexaVPN Admin"); const [password, setPassword] = useState("admin123!"); + const [bootstrapAvailable, setBootstrapAvailable] = useState(false); + const [bootstrapStatusLoaded, setBootstrapStatusLoaded] = useState(false); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); + useEffect(() => { + let cancelled = false; + + void fetch(`${API_BASE}/auth/bootstrap/status`) + .then(async (response) => { + if (!response.ok) { + throw new Error(`Bootstrap status failed: ${response.status}`); + } + return response.json() as Promise<{ bootstrap_available: boolean }>; + }) + .then((payload) => { + if (cancelled) { + return; + } + setBootstrapAvailable(payload.bootstrap_available); + if (!payload.bootstrap_available) { + setMode("login"); + } + }) + .catch(() => { + if (!cancelled) { + setBootstrapAvailable(false); + } + }) + .finally(() => { + if (!cancelled) { + setBootstrapStatusLoaded(true); + } + }); + + return () => { + cancelled = true; + }; + }, []); + async function onSubmit(event: FormEvent) { event.preventDefault(); setLoading(true); @@ -60,7 +97,7 @@ export function LoginPage({ onAuthenticated }: LoginPageProps) {
NexaVPN -
+

NexaVPN Admin

{mode === "login" ? "Sign in" : "Create initial admin"}

@@ -88,13 +125,15 @@ export function LoginPage({ onAuthenticated }: LoginPageProps) { - + {bootstrapStatusLoaded && bootstrapAvailable ? ( + + ) : null}
); diff --git a/admin-web/src/styles/global.css b/admin-web/src/styles/global.css index 4a5b0e5..7c2889d 100644 --- a/admin-web/src/styles/global.css +++ b/admin-web/src/styles/global.css @@ -70,6 +70,22 @@ button { gap: 16px; } +.auth-brand { + align-items: flex-start; + gap: 18px; +} + +.auth-brand-copy { + display: grid; + gap: 8px; + min-width: 0; +} + +.auth-brand-copy h2 { + margin: 0; + line-height: 1.1; +} + .brand-block { align-items: flex-start; } @@ -95,6 +111,11 @@ button { width: min(100%, 220px); } +.auth-brand .brand-logo-full { + width: 200px; + flex: 0 0 auto; +} + .brand-logo-mark { width: 48px; border-radius: 14px; diff --git a/backend/internal/auth/handler.go b/backend/internal/auth/handler.go index 7191292..c41f7fb 100644 --- a/backend/internal/auth/handler.go +++ b/backend/internal/auth/handler.go @@ -82,6 +82,18 @@ func (h *Handler) Bootstrap(w http.ResponseWriter, r *http.Request) { apiutil.JSON(w, http.StatusCreated, user) } +func (h *Handler) BootstrapStatus(w http.ResponseWriter, r *http.Request) { + available, err := h.service.BootstrapAvailable(r.Context()) + if err != nil { + apiutil.Error(w, http.StatusInternalServerError, "bootstrap_status_failed", "unable to determine bootstrap status") + return + } + + apiutil.JSON(w, http.StatusOK, map[string]any{ + "bootstrap_available": available, + }) +} + func (h *Handler) Refresh(w http.ResponseWriter, r *http.Request) { var input RefreshRequest if err := json.NewDecoder(r.Body).Decode(&input); err != nil { diff --git a/backend/internal/auth/service.go b/backend/internal/auth/service.go index 4339df8..adea6eb 100644 --- a/backend/internal/auth/service.go +++ b/backend/internal/auth/service.go @@ -125,6 +125,15 @@ func (s *Service) Logout(ctx context.Context, refreshToken string) error { return s.repo.RevokeRefreshToken(ctx, hashToken(refreshToken)) } +func (s *Service) BootstrapAvailable(ctx context.Context) (bool, error) { + hasUsers, err := s.repo.HasUsers(ctx) + if err != nil { + return false, err + } + + return !hasUsers, nil +} + func (s *Service) BootstrapAdmin(ctx context.Context, username, displayName, password string) (UserView, error) { hasUsers, err := s.repo.HasUsers(ctx) if err != nil { diff --git a/backend/internal/httpserver/router.go b/backend/internal/httpserver/router.go index 645a0c4..7c7ce92 100644 --- a/backend/internal/httpserver/router.go +++ b/backend/internal/httpserver/router.go @@ -32,6 +32,7 @@ func NewRouter(jwtSecret string, handlers Handlers) http.Handler { }) r.Route("/api/v1", func(r chi.Router) { + r.Get("/auth/bootstrap/status", handlers.Auth.BootstrapStatus) r.Post("/auth/bootstrap", handlers.Auth.Bootstrap) r.Post("/auth/login", handlers.Auth.Login) r.Post("/auth/refresh", handlers.Auth.Refresh)