Merge pull request #23 from unslothai/feature/auth-ui

auth setup for the client and auth guard checks
This commit is contained in:
Wasim Yousef Said 2026-02-11 05:05:07 -08:00 committed by GitHub
commit eddb2d5405
22 changed files with 2935 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

@ -0,0 +1,23 @@
import { redirect } from "@tanstack/react-router";
import {
getPostAuthRoute,
hasAuthToken,
hasRefreshToken,
refreshSession,
} from "@/features/auth";
async function hasActiveSession(): Promise<boolean> {
if (hasAuthToken()) return true;
if (!hasRefreshToken()) return false;
return refreshSession();
}
export async function requireAuth(): Promise<void> {
if (await hasActiveSession()) return;
throw redirect({ to: "/login" });
}
export async function requireGuest(): Promise<void> {
if (!(await hasActiveSession())) return;
throw redirect({ to: getPostAuthRoute() });
}

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

@ -1,5 +1,6 @@
import { createRoute } from "@tanstack/react-router";
import { lazy } from "react";
import { requireAuth } from "../auth-guards";
import { Route as rootRoute } from "./__root";
const ChatPage = lazy(() =>
@ -9,5 +10,6 @@ const ChatPage = lazy(() =>
export const Route = createRoute({
getParentRoute: () => rootRoute,
path: "/chat",
beforeLoad: () => requireAuth(),
component: ChatPage,
});

View file

@ -1,5 +1,6 @@
import { createRoute } from "@tanstack/react-router";
import { lazy } from "react";
import { requireAuth } from "../auth-guards";
import { Route as rootRoute } from "./__root";
const ExportPage = lazy(() =>
@ -11,5 +12,6 @@ const ExportPage = lazy(() =>
export const Route = createRoute({
getParentRoute: () => rootRoute,
path: "/export",
beforeLoad: () => requireAuth(),
component: ExportPage,
});

View file

@ -7,11 +7,13 @@ import {
CardTitle,
} from "@/components/ui/card";
import { createRoute } from "@tanstack/react-router";
import { requireAuth } from "../auth-guards";
import { Route as rootRoute } from "./__root";
export const Route = createRoute({
getParentRoute: () => rootRoute,
path: "/grid-test",
beforeLoad: () => requireAuth(),
component: GridTestPage,
});

View file

@ -1,10 +1,12 @@
import { ComponentExample } from "@/components/component-example";
import { createRoute } from "@tanstack/react-router";
import { requireAuth } from "../auth-guards";
import { Route as rootRoute } from "./__root";
export const Route = createRoute({
getParentRoute: () => rootRoute,
path: "/",
beforeLoad: () => requireAuth(),
component: HomePage,
});

View file

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

View file

@ -1,5 +1,6 @@
import { createRoute } from "@tanstack/react-router";
import { lazy } from "react";
import { requireAuth } from "../auth-guards";
import { Route as rootRoute } from "./__root";
const WizardLayout = lazy(() =>
@ -11,5 +12,6 @@ const WizardLayout = lazy(() =>
export const Route = createRoute({
getParentRoute: () => rootRoute,
path: "/onboarding",
beforeLoad: () => requireAuth(),
component: WizardLayout,
});

View file

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

View file

@ -1,5 +1,6 @@
import { createRoute } from "@tanstack/react-router";
import { lazy } from "react";
import { requireAuth } from "../auth-guards";
import { Route as rootRoute } from "./__root";
const StudioPage = lazy(() =>
@ -11,5 +12,6 @@ const StudioPage = lazy(() =>
export const Route = createRoute({
getParentRoute: () => rootRoute,
path: "/studio",
beforeLoad: () => requireAuth(),
component: StudioPage,
});

View file

@ -0,0 +1,66 @@
import {
clearAuthTokens,
getAuthToken,
getRefreshToken,
storeAuthTokens,
} from "./session";
type RefreshResponse = {
access_token: string;
refresh_token: string;
};
export async function refreshSession(): Promise<boolean> {
const refreshToken = getRefreshToken();
if (!refreshToken) return false;
try {
const response = await fetch("/api/auth/refresh", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: refreshToken }),
});
if (!response.ok) {
clearAuthTokens();
return false;
}
const payload = (await response.json()) as RefreshResponse;
storeAuthTokens(payload.access_token, payload.refresh_token);
return true;
} catch {
return false;
}
}
export async function authFetch(
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> {
const headers = new Headers(init?.headers);
const accessToken = getAuthToken();
if (accessToken) {
headers.set("Authorization", `Bearer ${accessToken}`);
}
const response = await fetch(input, { ...init, headers });
if (response.status !== 401) return response;
const refreshed = await refreshSession();
if (!refreshed) return response;
const retryHeaders = new Headers(init?.headers);
const newToken = getAuthToken();
if (newToken) {
retryHeaders.set("Authorization", `Bearer ${newToken}`);
} else {
clearAuthTokens();
}
return fetch(input, { ...init, headers: retryHeaders });
}
export function logout(): void {
clearAuthTokens();
}

View file

@ -0,0 +1,240 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Link, useNavigate } from "@tanstack/react-router";
import { Eye, EyeOff } from "lucide-react";
import { useEffect, useState } from "react";
import type { FormEvent } from "react";
import type { ReactElement } from "react";
import { refreshSession } from "../api";
import {
getPostAuthRoute,
hasAuthToken,
hasRefreshToken,
resetOnboardingDone,
storeAuthTokens,
} from "../session";
type AuthMode = "login" | "signup";
type AuthStatusResponse = {
initialized: boolean;
};
type TokenResponse = {
access_token: string;
refresh_token: string;
};
type AuthFormProps = {
mode: AuthMode;
};
export function AuthForm({ mode }: AuthFormProps): ReactElement | null {
const navigate = useNavigate();
const [showPassword, setShowPassword] = useState(false);
const [username, setUsername] = useState("admin");
const [setupToken, setSetupToken] = useState("");
const [password, setPassword] = 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 (hasRefreshToken()) {
const refreshed = await refreshSession();
if (refreshed) {
if (!canceled) setStatusLoading(false);
navigate({ to: getPostAuthRoute() });
return;
}
}
if (hasAuthToken()) {
if (!canceled) setStatusLoading(false);
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 (!isLoginMode && !setupToken.trim()) {
setError("Setup token required.");
return;
}
setLoading(true);
try {
const endpoint = isLoginMode ? "/api/auth/login" : "/api/auth/setup";
const payload: { username: string; password: string; setup_token?: string } = {
username: username.trim(),
password,
};
if (!isLoginMode) {
payload.setup_token = setupToken.trim();
}
const response = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
let message = "Auth failed.";
const errorPayload = (await response
.json()
.catch(() => null)) as { detail?: string } | null;
if (errorPayload?.detail) message = errorPayload.detail;
throw new Error(message);
}
const token = (await response.json()) as TokenResponse;
if (!isLoginMode) resetOnboardingDone();
storeAuthTokens(token.access_token, token.refresh_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="setup-token">Setup token</Label>
<Input
id="setup-token"
autoComplete="off"
placeholder="Paste token from backend console"
value={setupToken}
onChange={(event) => setSetupToken(event.target.value)}
required
/>
</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,10 @@
export { LoginPage } from "./login-page";
export { SignupPage } from "./signup-page";
export { refreshSession } from "./api";
export {
getPostAuthRoute,
hasAuthToken,
hasRefreshToken,
isOnboardingDone,
markOnboardingDone,
} from "./session";

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,63 @@
export const AUTH_TOKEN_KEY = "unsloth_auth_token";
export const AUTH_REFRESH_TOKEN_KEY = "unsloth_auth_refresh_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 hasRefreshToken(): boolean {
if (!canUseStorage()) return false;
return Boolean(localStorage.getItem(AUTH_REFRESH_TOKEN_KEY));
}
export function getAuthToken(): string | null {
if (!canUseStorage()) return null;
return localStorage.getItem(AUTH_TOKEN_KEY);
}
export function getRefreshToken(): string | null {
if (!canUseStorage()) return null;
return localStorage.getItem(AUTH_REFRESH_TOKEN_KEY);
}
export function storeAuthTokens(
accessToken: string,
refreshToken: string,
): void {
if (!canUseStorage()) return;
localStorage.setItem(AUTH_TOKEN_KEY, accessToken);
localStorage.setItem(AUTH_REFRESH_TOKEN_KEY, refreshToken);
}
export function clearAuthTokens(): void {
if (!canUseStorage()) return;
localStorage.removeItem(AUTH_TOKEN_KEY);
localStorage.removeItem(AUTH_REFRESH_TOKEN_KEY);
}
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";
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";
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}>