auth ui flow

This commit is contained in:
shine1i 2026-02-07 16:14:02 +01:00
commit 9b4293c677
14 changed files with 2797 additions and 2336 deletions

View file

@ -8,11 +8,12 @@ pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
.env
.env.*
dist
dist-ssr
test/
*.local
.env
.env.*
# Editor directories and files
.vscode/*

File diff suppressed because it is too large Load diff

View file

@ -23,8 +23,11 @@
"@hugeicons/core-free-icons": "^3.1.1",
"@hugeicons/react": "^1.1.4",
"@huggingface/hub": "^2.8.0",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@streamdown/cjk": "^1.0.1",
"@streamdown/code": "^1.0.1",
"@streamdown/math": "^1.0.1",
@ -46,6 +49,7 @@
"lucide-react": "^0.563.0",
"mammoth": "^1.11.0",
"motion": "^12.29.2",
"next": "^16.1.6",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "^19.2.0",

View file

@ -3,13 +3,17 @@ import { Route as rootRoute } from "./routes/__root";
import { Route as chatRoute } from "./routes/chat";
import { Route as gridTestRoute } from "./routes/grid-test";
import { Route as homeRoute } from "./routes/home";
import { Route as loginRoute } from "./routes/login";
import { Route as onboardingRoute } from "./routes/onboarding";
import { Route as exportRoute } from "./routes/export";
import { Route as signupRoute } from "./routes/signup";
import { Route as studioRoute } from "./routes/studio";
const routeTree = rootRoute.addChildren([
homeRoute,
onboardingRoute,
loginRoute,
signupRoute,
gridTestRoute,
studioRoute,
chatRoute,

View file

@ -12,7 +12,7 @@ export const Route = createRootRoute({
component: RootLayout,
});
const HIDDEN_NAVBAR_ROUTES = ["/onboarding"];
const HIDDEN_NAVBAR_ROUTES = ["/onboarding", "/login", "/signup"];
function RootLayout() {
const pathname = useRouterState({ select: (s) => s.location.pathname });

View file

@ -0,0 +1,13 @@
import { createRoute } from "@tanstack/react-router";
import { lazy } from "react";
import { Route as rootRoute } from "./__root";
const LoginPage = lazy(() =>
import("@/features/auth/login-page").then((m) => ({ default: m.LoginPage })),
);
export const Route = createRoute({
getParentRoute: () => rootRoute,
path: "/login",
component: LoginPage,
});

View file

@ -0,0 +1,15 @@
import { createRoute } from "@tanstack/react-router";
import { lazy } from "react";
import { Route as rootRoute } from "./__root";
const SignupPage = lazy(() =>
import("@/features/auth/signup-page").then((m) => ({
default: m.SignupPage,
})),
);
export const Route = createRoute({
getParentRoute: () => rootRoute,
path: "/signup",
component: SignupPage,
});

View file

@ -0,0 +1,241 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
getPostAuthRoute,
hasAuthToken,
resetOnboardingDone,
storeAuthToken,
} from "@/features/auth/session";
import { Link, useNavigate } from "@tanstack/react-router";
import { Eye, EyeOff } from "lucide-react";
import { useEffect, useState } from "react";
import type { FormEvent } from "react";
type AuthMode = "login" | "signup";
type AuthStatusResponse = {
initialized: boolean;
};
type TokenResponse = {
access_token: string;
};
type AuthFormProps = {
mode: AuthMode;
};
export function AuthForm({ mode }: AuthFormProps) {
const navigate = useNavigate();
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [username, setUsername] = useState("admin");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [loading, setLoading] = useState(false);
const [statusLoading, setStatusLoading] = useState(true);
const [initialized, setInitialized] = useState<boolean | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let canceled = false;
async function initializeAuthForm(): Promise<void> {
if (hasAuthToken()) {
navigate({ to: getPostAuthRoute() });
return;
}
try {
const response = await fetch("/api/auth/status");
if (!response.ok) throw new Error("Failed to load auth status.");
const result = (await response.json()) as AuthStatusResponse;
if (!canceled) setInitialized(result.initialized);
} catch (err: unknown) {
if (!canceled) {
setError(err instanceof Error ? err.message : "Failed to load.");
}
} finally {
if (!canceled) setStatusLoading(false);
}
}
void initializeAuthForm();
return () => {
canceled = true;
};
}, [navigate]);
const blockedByState =
(mode === "login" && initialized === false) ||
(mode === "signup" && initialized === true);
const isLoginMode = mode === "login";
let helperText: string | null = null;
if (isLoginMode && initialized === false) {
helperText = "Auth not initialized. go setup first.";
} else if (!isLoginMode && initialized === true) {
helperText = "Auth already initialized. use login.";
}
const title = isLoginMode ? "Welcome back" : "Welcome to Unsloth Studio!";
const subtitle = isLoginMode
? "Sign in to continue"
: "Create first admin account";
const submitLabel = isLoginMode ? "Login" : "Create account";
const switchText = isLoginMode ? "Need setup first? " : "Already initialized? ";
const switchLinkTo = isLoginMode ? "/signup" : "/login";
const switchLinkText = isLoginMode ? "Setup account" : "Login";
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
if (mode === "signup" && password !== confirmPassword) {
setError("Passwords not match.");
return;
}
setLoading(true);
try {
const endpoint = mode === "login" ? "/api/auth/login" : "/api/auth/setup";
const response = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: username.trim(),
password,
}),
});
if (!response.ok) {
let message = "Auth failed.";
try {
const payload = (await response.json()) as { detail?: string };
if (payload.detail) message = payload.detail;
} catch {}
throw new Error(message);
}
const token = (await response.json()) as TokenResponse;
if (!isLoginMode) resetOnboardingDone();
storeAuthToken(token.access_token);
navigate({ to: getPostAuthRoute() });
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Auth failed.");
} finally {
setLoading(false);
}
}
if (statusLoading && initialized === null && error === null) return null;
return (
<div className="w-full max-w-sm space-y-6">
<div className="space-y-1.5 text-center">
<img
src="/Sloth emojis/large sloth wave.png"
alt="Unsloth waving mascot"
className="mx-auto mb-2 h-20 w-20 object-contain"
/>
<h2 className="text-2xl font-semibold text-foreground">{title}</h2>
<p className="text-muted-foreground">{subtitle}</p>
</div>
<form className="space-y-5" onSubmit={handleSubmit}>
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
autoComplete="username"
placeholder="admin"
value={username}
onChange={(event) => setUsername(event.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
className="pr-10"
autoComplete={
mode === "login" ? "current-password" : "new-password"
}
value={password}
onChange={(event) => setPassword(event.target.value)}
minLength={8}
required
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full px-3 text-muted-foreground hover:bg-transparent"
onClick={() => setShowPassword((prev) => !prev)}
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
</div>
{!isLoginMode && (
<div className="space-y-2">
<Label htmlFor="confirm-password">Confirm password</Label>
<div className="relative">
<Input
id="confirm-password"
type={showConfirmPassword ? "text" : "password"}
className="pr-10"
autoComplete="new-password"
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
minLength={8}
required
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full px-3 text-muted-foreground hover:bg-transparent"
onClick={() => setShowConfirmPassword((prev) => !prev)}
>
{showConfirmPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
</div>
)}
{helperText && (
<p className="text-center text-sm text-amber-600">{helperText}</p>
)}
{error && <p className="text-center text-sm text-destructive">{error}</p>}
<Button
type="submit"
className="w-full"
disabled={loading || statusLoading || blockedByState}
>
{loading ? "Please wait..." : submitLabel}
</Button>
</form>
<p className="text-center text-sm text-muted-foreground">
{switchText}
<Link to={switchLinkTo} className="text-primary hover:underline">
{switchLinkText}
</Link>
</p>
</div>
);
}

View file

@ -0,0 +1,2 @@
export { LoginPage } from "./login-page";
export { SignupPage } from "./signup-page";

View file

@ -0,0 +1,20 @@
import { LightRays } from "@/components/ui/light-rays";
import { AuthForm } from "./components/auth-form";
export function LoginPage() {
return (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background px-6 py-10 md:px-10">
<LightRays
count={6}
color="rgba(34, 197, 94, 0.25)"
blur={34}
speed={15}
length="70vh"
style={{ opacity: 0.4 }}
/>
<div className="relative z-10 w-full max-w-sm">
<AuthForm mode="login" />
</div>
</div>
);
}

View file

@ -0,0 +1,37 @@
export const AUTH_TOKEN_KEY = "unsloth_auth_token";
export const ONBOARDING_DONE_KEY = "unsloth_onboarding_done";
type PostAuthRoute = "/onboarding" | "/studio";
function canUseStorage(): boolean {
return typeof window !== "undefined";
}
export function hasAuthToken(): boolean {
if (!canUseStorage()) return false;
return Boolean(localStorage.getItem(AUTH_TOKEN_KEY));
}
export function storeAuthToken(accessToken: string): void {
if (!canUseStorage()) return;
localStorage.setItem(AUTH_TOKEN_KEY, accessToken);
}
export function isOnboardingDone(): boolean {
if (!canUseStorage()) return false;
return localStorage.getItem(ONBOARDING_DONE_KEY) === "true";
}
export function markOnboardingDone(): void {
if (!canUseStorage()) return;
localStorage.setItem(ONBOARDING_DONE_KEY, "true");
}
export function resetOnboardingDone(): void {
if (!canUseStorage()) return;
localStorage.removeItem(ONBOARDING_DONE_KEY);
}
export function getPostAuthRoute(): PostAuthRoute {
return isOnboardingDone() ? "/studio" : "/onboarding";
}

View file

@ -0,0 +1,20 @@
import { LightRays } from "@/components/ui/light-rays";
import { AuthForm } from "./components/auth-form";
export function SignupPage() {
return (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background px-6 py-10 md:px-10">
<LightRays
count={6}
color="rgba(34, 197, 94, 0.25)"
blur={34}
speed={15}
length="70vh"
style={{ opacity: 0.4 }}
/>
<div className="relative z-10 w-full max-w-sm">
<AuthForm mode="signup" />
</div>
</div>
);
}

View file

@ -1,5 +1,6 @@
import { Button } from "@/components/ui/button";
import { STEPS } from "@/config/training";
import { markOnboardingDone } from "@/features/auth/session";
import { useWizardStore } from "@/stores/training";
import { ArrowLeft02Icon, ArrowRight02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
@ -33,7 +34,10 @@ export function WizardFooter() {
</Button>
{isLast ? (
<Button
onClick={() => navigate({ to: "/studio" })}
onClick={() => {
markOnboardingDone();
navigate({ to: "/studio" });
}}
disabled={!canProceed}
className="px-4 !pr-4"
>

View file

@ -5,6 +5,7 @@ import { Suspense, lazy, useEffect, useRef, useState } from "react";
import type { ConfettiRef } from "@/components/ui/confetti";
import { STEPS } from "@/config/training";
import { isOnboardingDone, markOnboardingDone } from "@/features/auth/session";
import { useWizardStore } from "@/stores/training";
import { SplashScreen } from "./splash-screen";
import { WizardContent } from "./wizard-content";
@ -23,6 +24,12 @@ export function WizardLayout() {
const hasFiredRef = useRef(false);
const isFinalStep = currentStep === STEPS.length;
useEffect(() => {
if (isOnboardingDone()) {
navigate({ to: "/studio" });
}
}, [navigate]);
useEffect(() => {
if (isFinalStep && !hasFiredRef.current) {
hasFiredRef.current = true;
@ -51,7 +58,10 @@ export function WizardLayout() {
{showSplash && (
<SplashScreen
onStartOnboarding={() => setShowSplash(false)}
onGoToStudio={() => navigate({ to: "/studio" })}
onGoToStudio={() => {
markOnboardingDone();
navigate({ to: "/studio" });
}}
/>
)}
<Suspense fallback={null}>