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)