presenton/servers/nextjs/components/Auth/AuthGate.tsx

314 lines
12 KiB
TypeScript

"use client";
import { FormEvent, useEffect, useMemo, useState } from "react";
import { ConfigurationInitializer } from "@/app/ConfigurationInitializer";
import Home from "@/components/Home";
import { getApiUrl } from "@/utils/api";
import { formatFastApiDetail, UNAUTHORIZED_DETAIL } from "@/utils/authErrors";
import { toast } from "sonner";
type AuthStatus = {
configured: boolean;
authenticated: boolean;
username: string | null;
};
const initialStatus: AuthStatus = {
configured: false,
authenticated: false,
username: null,
};
export default function AuthGate() {
const [status, setStatus] = useState<AuthStatus>(initialStatus);
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const isSetupMode = useMemo(() => !status.configured, [status.configured]);
useEffect(() => {
void refreshStatus();
}, []);
useEffect(() => {
if (typeof window === "undefined") {
return;
}
const params = new URLSearchParams(window.location.search);
if (params.get("reason") === "unauthorized") {
toast.error("Unauthorized", {
id: "auth-unauthorized-redirect",
description: "Sign in to view this page.",
duration: 5000,
});
window.history.replaceState({}, "", window.location.pathname);
}
}, []);
const refreshStatus = async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetch(getApiUrl("/api/v1/auth/status"), {
method: "GET",
cache: "no-store",
credentials: "include",
});
if (!response.ok) {
throw new Error("Could not load login state");
}
const data = (await response.json()) as AuthStatus;
setStatus({
configured: Boolean(data.configured),
authenticated: Boolean(data.authenticated),
username: data.username ?? null,
});
} catch (fetchError) {
console.error(fetchError);
setError("Could not connect to the login service. Please refresh and try again.");
} finally {
setIsLoading(false);
}
};
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError(null);
const cleanedUsername = username.trim();
if (cleanedUsername.length < 3) {
setError("Username must be at least 3 characters.");
return;
}
if (password.length < 6) {
setError("Password must be at least 6 characters.");
return;
}
if (isSetupMode && password !== confirmPassword) {
setError("Password confirmation does not match.");
return;
}
setIsSubmitting(true);
try {
const response = await fetch(
getApiUrl(isSetupMode ? "/api/v1/auth/setup" : "/api/v1/auth/login"),
{
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
username: cleanedUsername,
password,
}),
}
);
const payload = await response.json();
if (!response.ok) {
const detail = formatFastApiDetail(payload?.detail);
if (response.status === 401) {
setError(detail === UNAUTHORIZED_DETAIL ? UNAUTHORIZED_DETAIL : detail);
} else {
setError(detail || "Login failed. Please try again.");
}
return;
}
if (isSetupMode) {
setStatus({
configured: true,
authenticated: false,
username: (payload as AuthStatus).username ?? cleanedUsername,
});
setPassword("");
setConfirmPassword("");
toast.success("Account created", {
description: "Sign in with your new username and password to continue.",
duration: 6000,
});
return;
}
setStatus({
configured: Boolean((payload as AuthStatus).configured),
authenticated: Boolean((payload as AuthStatus).authenticated),
username: (payload as AuthStatus).username ?? cleanedUsername,
});
setPassword("");
setConfirmPassword("");
} catch (submitError) {
console.error(submitError);
setError("Login service is unavailable. Please try again in a moment.");
} finally {
setIsSubmitting(false);
}
};
if (isLoading) {
return (
<main className="relative min-h-screen overflow-hidden bg-gradient-to-br from-[#E9E8F8] via-[#F5F4FF] to-[#E0DFF7] flex items-center justify-center p-6">
<div
className="pointer-events-none absolute inset-x-0 bottom-0 h-[45%] opacity-90"
style={{
background:
"radial-gradient(50% 50% at 50% 100%, rgba(122, 90, 248, 0.35) 0%, rgba(122, 90, 248, 0) 70%)",
}}
/>
<div className="relative z-10 w-full max-w-md">
<div className="rounded-2xl border border-white/40 bg-white/80 p-8 text-center shadow-xl backdrop-blur-sm">
<img src="/Logo.png" alt="Presenton" className="mx-auto mb-5 h-12 opacity-95" />
<div className="mx-auto mb-4 h-1 w-16 rounded-full bg-gradient-to-r from-[#5146E5] to-[#7C51F8]" />
<h1 className="font-syne text-lg font-semibold text-black">Presenton</h1>
<p className="mt-3 font-syne text-sm text-[#000000CC]">Preparing your workspace</p>
<div className="mt-6 flex justify-center gap-1.5">
<span className="h-2 w-2 animate-pulse rounded-full bg-[#5146E5]" />
<span
className="h-2 w-2 animate-pulse rounded-full bg-[#7C51F8]"
style={{ animationDelay: "0.2s" }}
/>
<span
className="h-2 w-2 animate-pulse rounded-full bg-[#5146E5]"
style={{ animationDelay: "0.4s" }}
/>
</div>
</div>
</div>
</main>
);
}
if (status.authenticated) {
return (
<ConfigurationInitializer>
<Home />
</ConfigurationInitializer>
);
}
return (
<main className="relative flex min-h-screen items-center justify-center overflow-hidden bg-gradient-to-br from-[#E9E8F8] via-[#F5F4FF] to-[#E0DFF7] p-6">
<div
className="pointer-events-none absolute inset-x-0 bottom-0 h-[50%] opacity-95"
style={{
background:
"radial-gradient(50% 50% at 50% 100%, rgba(122, 90, 248, 0.45) 0%, rgba(122, 90, 248, 0) 72%)",
}}
/>
<div className="pointer-events-none absolute -right-32 -top-32 h-[380px] w-[380px] rounded-full bg-[#7C51F8]/20 blur-3xl" />
<div className="pointer-events-none absolute -bottom-40 -left-32 h-[420px] w-[420px] rounded-full bg-[#5146E5]/15 blur-3xl" />
<section className="relative z-10 w-full max-w-xl rounded-2xl border border-[#E1E1E5] bg-white/90 p-7 shadow-xl backdrop-blur-sm sm:p-10">
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-center gap-4">
<div className="flex h-[74px] w-[74px] shrink-0 items-center justify-center rounded-[4px] bg-[#F4F3FF] p-3">
<img src="/logo-with-bg.png" alt="" className="h-10 w-10 object-contain" />
</div>
<div>
<p className="font-syne text-[10px] font-semibold uppercase tracking-[0.14em] text-[#7A5AF8]">
Secure instance
</p>
<h1 className="mt-1 font-syne text-2xl font-semibold leading-tight text-black sm:text-[26px]">
{isSetupMode ? "Create your admin login" : "Sign in to continue"}
</h1>
</div>
</div>
</div>
<p className="font-syne text-base text-[#000000CC] sm:text-lg">
{isSetupMode
? "One-time setup for this deployment. You will use the same username and password on future visits."
: "This deployment is protected. Enter your credentials to open the app."}
</p>
<form onSubmit={handleSubmit} className="mt-8 space-y-5">
<div className="space-y-2">
<label htmlFor="username" className="block font-syne text-sm font-medium text-black">
Username
</label>
<input
id="username"
autoComplete="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder="your-admin-user"
className="w-full rounded-[11px] border border-[#EDEEEF] bg-white px-4 py-3 font-syne text-sm text-black outline-none transition placeholder:text-[#999999] focus:border-[#a49cfc] focus:ring-2 focus:ring-[#5146E5]/20"
disabled={isSubmitting}
/>
</div>
<div className="space-y-2">
<label htmlFor="password" className="block font-syne text-sm font-medium text-black">
Password
</label>
<input
id="password"
type="password"
autoComplete={isSetupMode ? "new-password" : "current-password"}
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="At least 6 characters"
className="w-full rounded-[11px] border border-[#EDEEEF] bg-white px-4 py-3 font-syne text-sm text-black outline-none transition placeholder:text-[#999999] focus:border-[#a49cfc] focus:ring-2 focus:ring-[#5146E5]/20"
disabled={isSubmitting}
/>
</div>
{isSetupMode ? (
<div className="space-y-2">
<label htmlFor="confirmPassword" className="block font-syne text-sm font-medium text-black">
Confirm password
</label>
<input
id="confirmPassword"
type="password"
autoComplete="new-password"
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
placeholder="Re-enter your password"
className="w-full rounded-[11px] border border-[#EDEEEF] bg-white px-4 py-3 font-syne text-sm text-black outline-none transition placeholder:text-[#999999] focus:border-[#a49cfc] focus:ring-2 focus:ring-[#5146E5]/20"
disabled={isSubmitting}
/>
</div>
) : null}
{error ? (
<div className="rounded-[11px] border border-red-200 bg-red-50 px-4 py-3 font-syne text-sm text-red-800">
{error}
</div>
) : null}
{!isSetupMode && status.configured ? (
<p className="font-syne text-sm text-[#494A4D]">
Setup is complete for this instance. Use the username and password you configured.
</p>
) : null}
<button
type="submit"
disabled={isSubmitting}
className="w-full rounded-[58px] border border-[#EDEEEF] bg-[#7C51F8] px-5 py-3 font-syne text-xs font-semibold text-white transition hover:bg-[#6d46e6] disabled:cursor-not-allowed disabled:opacity-60"
>
{isSubmitting
? isSetupMode
? "Saving credentials…"
: "Signing in…"
: isSetupMode
? "Create account"
: "Sign in"}
</button>
</form>
</section>
</main>
);
}