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:
@@ -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,6 +125,7 @@ export function LoginPage({ onAuthenticated }: LoginPageProps) {
|
||||
<button className="button" disabled={loading} type="submit">
|
||||
{loading ? "Please wait..." : mode === "login" ? "Sign in" : "Bootstrap and sign in"}
|
||||
</button>
|
||||
{bootstrapStatusLoaded && bootstrapAvailable ? (
|
||||
<button
|
||||
className="ghost-button"
|
||||
type="button"
|
||||
@@ -95,6 +133,7 @@ export function LoginPage({ onAuthenticated }: LoginPageProps) {
|
||||
>
|
||||
{mode === "login" ? "Need initial setup?" : "Already have an admin?"}
|
||||
</button>
|
||||
) : null}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user