studio: simplify auth UX to password-only login (#4305)

* feat(studio): switch to password-only login and simplify first-time setup

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fix: align change-password button state with validation rules

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Roland Tannous <115670425+rolandtannous@users.noreply.github.com>
This commit is contained in:
Lee Jackson 2026-03-15 23:02:58 +00:00 committed by GitHub
commit 7b1ea88739
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -7,8 +7,8 @@ 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 type { SyntheticEvent } from "react";
import { refreshSession } from "../api";
// Bootstrap credentials injected into index.html by the backend
@ -35,7 +35,6 @@ type AuthMode = "login" | "change-password";
type AuthStatusResponse = {
initialized: boolean;
default_username: string;
requires_password_change: boolean;
};
@ -72,13 +71,16 @@ type AuthFormProps = {
mode: AuthMode;
};
const HIDDEN_LOGIN_USERNAME = "unsloth";
export function AuthForm({ mode }: AuthFormProps): ReactElement | null {
const navigate = useNavigate();
const isLoginMode = mode === "login";
const [showPassword, setShowPassword] = useState(false);
const [username, setUsername] = useState("unsloth");
const username = HIDDEN_LOGIN_USERNAME;
const [password, setPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [loading, setLoading] = useState(false);
const [statusLoading, setStatusLoading] = useState(true);
const [initialized, setInitialized] = useState<boolean | null>(null);
@ -98,7 +100,6 @@ export function AuthForm({ mode }: AuthFormProps): ReactElement | null {
const result = (await response.json()) as AuthStatusResponse;
if (!canceled) {
setInitialized(result.initialized);
setUsername(result.default_username);
setRequiresPasswordChange(result.requires_password_change);
// Redirect between login ↔ change-password based on server state
@ -152,9 +153,6 @@ export function AuthForm({ mode }: AuthFormProps): ReactElement | null {
if (!isLoginMode && !password) {
setPassword(bootstrap.password);
}
if (bootstrap.username) {
setUsername(bootstrap.username);
}
}
}, []);
@ -171,27 +169,46 @@ export function AuthForm({ mode }: AuthFormProps): ReactElement | null {
} else if (!isLoginMode && !requiresPasswordChange && !mustChangePassword()) {
helperText = "Password already updated. Use the login screen.";
}
const title = isLoginMode ? "Welcome back" : "Update your admin password";
const subtitle = isLoginMode
? "Sign in with the seeded admin account"
: "Use the default admin credentials, then choose a new password";
const title = isLoginMode ? "Welcome back" : "Setup your account";
const subtitle = isLoginMode
? "Sign in with your password."
: "Choose your new password.";
const submitLabel = isLoginMode ? "Login" : "Change password";
const showSwitchLink = !isLoginMode;
const switchText = "Password already changed? ";
const switchText = "Password already setup? ";
const switchLinkTo = "/login";
const switchLinkText = "Back to login";
const currentPassword = password || window.__UNSLOTH_BOOTSTRAP__?.password || "";
const invalidChangePasswordForm =
!isLoginMode &&
(newPassword.length < 8 || newPassword !== confirmPassword || currentPassword === newPassword);
const showPasswordMismatchWarning =
!isLoginMode &&
newPassword.length > 0 &&
confirmPassword.length > 0 &&
newPassword !== confirmPassword;
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
async function handleSubmit(event: SyntheticEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
if (!isLoginMode && newPassword.length < 8) {
setError("New password must be at least 8 characters.");
return;
}
if (!isLoginMode && password === newPassword) {
setError("New password must be different from the default password.");
return;
if (!isLoginMode) {
if (!currentPassword) {
setError("Unable to initialize setup. Reload the page and try again.");
return;
}
if (newPassword.length < 8) {
setError("New password must be at least 8 characters.");
return;
}
if (newPassword !== confirmPassword) {
setError("Passwords do not match.");
return;
}
if (currentPassword === newPassword) {
setError("New password must be different from your current password.");
return;
}
}
setLoading(true);
@ -213,7 +230,7 @@ export function AuthForm({ mode }: AuthFormProps): ReactElement | null {
}
if (!accessToken) {
const bootstrapToken = await loginWithPassword(username, password);
const bootstrapToken = await loginWithPassword(username, currentPassword);
storeAuthTokens(
bootstrapToken.access_token,
bootstrapToken.refresh_token,
@ -230,7 +247,7 @@ export function AuthForm({ mode }: AuthFormProps): ReactElement | null {
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({
current_password: password,
current_password: currentPassword,
new_password: newPassword,
}),
});
@ -281,65 +298,90 @@ export function AuthForm({ mode }: AuthFormProps): ReactElement | null {
<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="unsloth"
value={username}
onChange={(event) => setUsername(event.target.value)}
required
disabled={!isLoginMode}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">{isLoginMode ? "Password" : "Current password"}</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
className="pr-10"
autoComplete={isLoginMode ? "current-password" : "current-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>
{isLoginMode && (
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
className="pr-10"
autoComplete="current-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 ? null : (
<p className="text-xs text-muted-foreground">Confirm the seeded password before choosing a new one.</p>
)}
</div>
)}
{!isLoginMode && (
<div className="space-y-2">
<Label htmlFor="new-password">New password</Label>
<Input
id="new-password"
type="password"
autoComplete="new-password"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
minLength={8}
required
/>
<p className="text-xs text-muted-foreground">Must be at least 8 characters and different from the default password.</p>
</div>
<>
<div className="space-y-2">
<Label htmlFor="new-password">New password</Label>
<div className="relative">
<Input
id="new-password"
type={showPassword ? "text" : "password"}
className="pr-10"
autoComplete="new-password"
value={newPassword}
onChange={(event) => setNewPassword(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>
<div className="space-y-2">
<Label htmlFor="confirm-password">Confirm password</Label>
<Input
id="confirm-password"
type="password"
autoComplete="new-password"
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
minLength={8}
required
/>
</div>
<p
className={`min-h-4 text-xs ${
showPasswordMismatchWarning ? "text-destructive" : "text-muted-foreground"
}`}
aria-live="polite"
>
{showPasswordMismatchWarning
? "Please ensure passwords match."
: "Must be at least 8 characters."}
</p>
</>
)}
{helperText && (
@ -354,8 +396,8 @@ export function AuthForm({ mode }: AuthFormProps): ReactElement | null {
loading ||
statusLoading ||
blockedByState ||
password.length < 8 ||
(!isLoginMode && newPassword.length < 8)
(isLoginMode && password.length < 8) ||
invalidChangePasswordForm
}
>
{loading ? "Please wait..." : submitLabel}