auth ui flow
This commit is contained in:
parent
d54671b1a6
commit
9b4293c677
14 changed files with 2797 additions and 2336 deletions
11
studio/frontend/.gitignore
vendored
11
studio/frontend/.gitignore
vendored
|
|
@ -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
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
13
studio/frontend/src/app/routes/login.tsx
Normal file
13
studio/frontend/src/app/routes/login.tsx
Normal 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,
|
||||
});
|
||||
15
studio/frontend/src/app/routes/signup.tsx
Normal file
15
studio/frontend/src/app/routes/signup.tsx
Normal 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,
|
||||
});
|
||||
241
studio/frontend/src/features/auth/components/auth-form.tsx
Normal file
241
studio/frontend/src/features/auth/components/auth-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2
studio/frontend/src/features/auth/index.ts
Normal file
2
studio/frontend/src/features/auth/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { LoginPage } from "./login-page";
|
||||
export { SignupPage } from "./signup-page";
|
||||
20
studio/frontend/src/features/auth/login-page.tsx
Normal file
20
studio/frontend/src/features/auth/login-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
studio/frontend/src/features/auth/session.ts
Normal file
37
studio/frontend/src/features/auth/session.ts
Normal 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";
|
||||
}
|
||||
20
studio/frontend/src/features/auth/signup-page.tsx
Normal file
20
studio/frontend/src/features/auth/signup-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue