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
This commit is contained in:
2026-03-17 19:59:15 +01:00
parent b288f0d155
commit 09dd3a5ea6
5 changed files with 91 additions and 9 deletions

View File

@@ -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<string | null>(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) {
<form className="auth-card" onSubmit={onSubmit}>
<div className="auth-brand">
<img className="brand-logo brand-logo-full" src="/NexaVPN_Logo.png" alt="NexaVPN" />
<div>
<div className="auth-brand-copy">
<p className="eyebrow">NexaVPN Admin</p>
<h2>{mode === "login" ? "Sign in" : "Create initial admin"}</h2>
</div>
@@ -88,13 +125,15 @@ export function LoginPage({ onAuthenticated }: LoginPageProps) {
<button className="button" disabled={loading} type="submit">
{loading ? "Please wait..." : mode === "login" ? "Sign in" : "Bootstrap and sign in"}
</button>
<button
className="ghost-button"
type="button"
onClick={() => setMode((current) => (current === "login" ? "bootstrap" : "login"))}
>
{mode === "login" ? "Need initial setup?" : "Already have an admin?"}
</button>
{bootstrapStatusLoaded && bootstrapAvailable ? (
<button
className="ghost-button"
type="button"
onClick={() => setMode((current) => (current === "login" ? "bootstrap" : "login"))}
>
{mode === "login" ? "Need initial setup?" : "Already have an admin?"}
</button>
) : null}
</form>
</div>
);

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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)