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 = {
|
type LoginPageProps = {
|
||||||
onAuthenticated: (accessToken: string) => void;
|
onAuthenticated: (accessToken: string) => void;
|
||||||
@@ -11,9 +11,46 @@ export function LoginPage({ onAuthenticated }: LoginPageProps) {
|
|||||||
const [username, setUsername] = useState("admin");
|
const [username, setUsername] = useState("admin");
|
||||||
const [displayName, setDisplayName] = useState("NexaVPN Admin");
|
const [displayName, setDisplayName] = useState("NexaVPN Admin");
|
||||||
const [password, setPassword] = useState("admin123!");
|
const [password, setPassword] = useState("admin123!");
|
||||||
|
const [bootstrapAvailable, setBootstrapAvailable] = useState(false);
|
||||||
|
const [bootstrapStatusLoaded, setBootstrapStatusLoaded] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
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) {
|
async function onSubmit(event: FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -60,7 +97,7 @@ export function LoginPage({ onAuthenticated }: LoginPageProps) {
|
|||||||
<form className="auth-card" onSubmit={onSubmit}>
|
<form className="auth-card" onSubmit={onSubmit}>
|
||||||
<div className="auth-brand">
|
<div className="auth-brand">
|
||||||
<img className="brand-logo brand-logo-full" src="/NexaVPN_Logo.png" alt="NexaVPN" />
|
<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>
|
<p className="eyebrow">NexaVPN Admin</p>
|
||||||
<h2>{mode === "login" ? "Sign in" : "Create initial admin"}</h2>
|
<h2>{mode === "login" ? "Sign in" : "Create initial admin"}</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -88,13 +125,15 @@ export function LoginPage({ onAuthenticated }: LoginPageProps) {
|
|||||||
<button className="button" disabled={loading} type="submit">
|
<button className="button" disabled={loading} type="submit">
|
||||||
{loading ? "Please wait..." : mode === "login" ? "Sign in" : "Bootstrap and sign in"}
|
{loading ? "Please wait..." : mode === "login" ? "Sign in" : "Bootstrap and sign in"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
{bootstrapStatusLoaded && bootstrapAvailable ? (
|
||||||
className="ghost-button"
|
<button
|
||||||
type="button"
|
className="ghost-button"
|
||||||
onClick={() => setMode((current) => (current === "login" ? "bootstrap" : "login"))}
|
type="button"
|
||||||
>
|
onClick={() => setMode((current) => (current === "login" ? "bootstrap" : "login"))}
|
||||||
{mode === "login" ? "Need initial setup?" : "Already have an admin?"}
|
>
|
||||||
</button>
|
{mode === "login" ? "Need initial setup?" : "Already have an admin?"}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -70,6 +70,22 @@ button {
|
|||||||
gap: 16px;
|
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 {
|
.brand-block {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
@@ -95,6 +111,11 @@ button {
|
|||||||
width: min(100%, 220px);
|
width: min(100%, 220px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auth-brand .brand-logo-full {
|
||||||
|
width: 200px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.brand-logo-mark {
|
.brand-logo-mark {
|
||||||
width: 48px;
|
width: 48px;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
|
|||||||
@@ -82,6 +82,18 @@ func (h *Handler) Bootstrap(w http.ResponseWriter, r *http.Request) {
|
|||||||
apiutil.JSON(w, http.StatusCreated, user)
|
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) {
|
func (h *Handler) Refresh(w http.ResponseWriter, r *http.Request) {
|
||||||
var input RefreshRequest
|
var input RefreshRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
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))
|
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) {
|
func (s *Service) BootstrapAdmin(ctx context.Context, username, displayName, password string) (UserView, error) {
|
||||||
hasUsers, err := s.repo.HasUsers(ctx)
|
hasUsers, err := s.repo.HasUsers(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ func NewRouter(jwtSecret string, handlers Handlers) http.Handler {
|
|||||||
})
|
})
|
||||||
|
|
||||||
r.Route("/api/v1", func(r chi.Router) {
|
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/bootstrap", handlers.Auth.Bootstrap)
|
||||||
r.Post("/auth/login", handlers.Auth.Login)
|
r.Post("/auth/login", handlers.Auth.Login)
|
||||||
r.Post("/auth/refresh", handlers.Auth.Refresh)
|
r.Post("/auth/refresh", handlers.Auth.Refresh)
|
||||||
|
|||||||
Reference in New Issue
Block a user