diff --git a/studio/frontend/src/components/app-sidebar.tsx b/studio/frontend/src/components/app-sidebar.tsx index 278bb3fe64..8433ae9daf 100644 --- a/studio/frontend/src/components/app-sidebar.tsx +++ b/studio/frontend/src/components/app-sidebar.tsx @@ -49,6 +49,7 @@ import { Edit03Icon, Globe02Icon, HelpCircleIcon, + Logout01Icon, Search01Icon, PowerIcon, PencilEdit02Icon, @@ -77,6 +78,7 @@ import { import { useSettingsDialogStore } from "@/features/settings"; import { useEffectiveProfile, UserAvatar } from "@/features/profile"; import { usePlatformStore } from "@/config/env"; +import { clearAuthTokens, logout } from "@/features/auth"; import { TOUR_OPEN_EVENT } from "@/features/tour"; import { deleteTrainingRun, @@ -757,6 +759,21 @@ export function AppSidebar() { Help + { + // Best-effort server-side revocation; ignore network errors + // so the local clear path still runs and the user lands on /login. + try { + await logout(); + } catch { + clearAuthTokens(); + } + void navigate({ to: "/login" }); + }} + > + + Log out + setShutdownOpen(true)}> Shutdown diff --git a/studio/frontend/src/features/auth/api.ts b/studio/frontend/src/features/auth/api.ts index 8f52c2187c..98c5757d2a 100644 --- a/studio/frontend/src/features/auth/api.ts +++ b/studio/frontend/src/features/auth/api.ts @@ -7,6 +7,7 @@ import { getAuthToken, getRefreshToken, mustChangePassword, + setMustChangePassword, storeAuthTokens, } from "./session"; @@ -93,34 +94,45 @@ async function retryWithTauriAutoAuth( return null; } +// Singleflight: the backend consumes the refresh token atomically, so +// concurrent callers must share one in-flight promise (loser would 401). +let refreshInflight: Promise | null = null; +// Bumped by logout(); a refresh that resolves after logout drops its +// new tokens instead of silently re-auth-ing the SPA. +let logoutGeneration = 0; + export async function refreshSession(): Promise { - const refreshToken = getRefreshToken(); - if (!refreshToken) return false; - - try { - const response = await fetchWithTauriNetworkRetry( - apiUrl("/api/auth/refresh"), - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ refresh_token: refreshToken }), - }, - ); - - if (!response.ok) { - clearAuthTokens(); + if (refreshInflight) return refreshInflight; + const startGeneration = logoutGeneration; + refreshInflight = (async () => { + const refreshToken = getRefreshToken(); + if (!refreshToken) return false; + try { + const response = await fetchWithTauriNetworkRetry( + apiUrl("/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; + if (startGeneration !== logoutGeneration) return false; + storeAuthTokens(payload.access_token, payload.refresh_token); + setMustChangePassword(payload.must_change_password ?? false); + return true; + } catch { return false; } - - const payload = (await response.json()) as RefreshResponse; - storeAuthTokens( - payload.access_token, - payload.refresh_token, - payload.must_change_password, - ); - return true; - } catch { - return false; + })(); + try { + return await refreshInflight; + } finally { + refreshInflight = null; } } @@ -179,6 +191,32 @@ export async function authFetch( return retryWithCurrentToken(resolvedInput, init); } -export function logout(): void { - clearAuthTokens(); +async function postLogout(accessToken: string | null): Promise { + try { + return await fetchWithTauriNetworkRetry(apiUrl("/api/auth/logout"), { + method: "POST", + headers: accessToken + ? { Authorization: `Bearer ${accessToken}` } + : undefined, + }); + } catch { + return null; + } +} + +export async function logout(): Promise { + // Server-side revoke. If the access token is expired the 401 fires + // BEFORE revoke runs; rotate via the refresh token and retry so the + // refresh family is actually revoked. Generation bump in finally + // invalidates any in-flight refresh from before this call. + try { + let response = await postLogout(getAuthToken()); + if (response && response.status === 401 && getRefreshToken()) { + const refreshed = await refreshSession(); + if (refreshed) response = await postLogout(getAuthToken()); + } + } finally { + logoutGeneration += 1; + clearAuthTokens(); + } } diff --git a/studio/frontend/src/features/auth/components/auth-form.tsx b/studio/frontend/src/features/auth/components/auth-form.tsx index 090a3081a4..de0a1df995 100644 --- a/studio/frontend/src/features/auth/components/auth-form.tsx +++ b/studio/frontend/src/features/auth/components/auth-form.tsx @@ -79,6 +79,7 @@ export function AuthForm({ mode }: AuthFormProps): ReactElement | null { const navigate = useNavigate(); const isLoginMode = mode === "login"; const [showPassword, setShowPassword] = useState(false); + const [showNewPassword, setShowNewPassword] = useState(false); const username = HIDDEN_LOGIN_USERNAME; const [password, setPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); @@ -237,7 +238,6 @@ export function AuthForm({ mode }: AuthFormProps): ReactElement | null { storeAuthTokens( bootstrapToken.access_token, bootstrapToken.refresh_token, - bootstrapToken.must_change_password, ); setMustChangePassword(bootstrapToken.must_change_password); accessToken = bootstrapToken.access_token; @@ -274,11 +274,7 @@ export function AuthForm({ mode }: AuthFormProps): ReactElement | null { } else { setMustChangePassword(token.must_change_password); } - storeAuthTokens( - token.access_token, - token.refresh_token, - token.must_change_password, - ); + storeAuthTokens(token.access_token, token.refresh_token); navigate({ to: getPostAuthRoute() }); } catch (err: unknown) { let msg = err instanceof Error ? err.message : "Auth failed."; @@ -341,12 +337,45 @@ export function AuthForm({ mode }: AuthFormProps): ReactElement | null { {!isLoginMode && ( <> +
+ +
+ setPassword(event.target.value)} + minLength={8} + required + placeholder={ + window.__UNSLOTH_BOOTSTRAP__?.password + ? "Pre-filled with first-boot password" + : undefined + } + /> + +
+
setShowPassword((prev) => !prev)} + onClick={() => setShowNewPassword((prev) => !prev)} > - {showPassword ? ( + {showNewPassword ? ( ) : ( diff --git a/studio/frontend/src/features/auth/index.ts b/studio/frontend/src/features/auth/index.ts index 9cc1599195..9baad33e0e 100644 --- a/studio/frontend/src/features/auth/index.ts +++ b/studio/frontend/src/features/auth/index.ts @@ -3,8 +3,9 @@ export { LoginPage } from "./login-page"; export { ChangePasswordPage } from "./change-password-page"; -export { authFetch, refreshSession } from "./api"; +export { authFetch, logout, refreshSession } from "./api"; export { + clearAuthTokens, getAuthToken, getPostAuthRoute, hasAuthToken, diff --git a/studio/frontend/src/features/auth/session.ts b/studio/frontend/src/features/auth/session.ts index 49a2722bdf..1e3234590a 100644 --- a/studio/frontend/src/features/auth/session.ts +++ b/studio/frontend/src/features/auth/session.ts @@ -38,12 +38,13 @@ export function getRefreshToken(): string | null { export function storeAuthTokens( accessToken: string, refreshToken: string, - mustChangePassword = false, ): void { + // Callers set must_change_password via setMustChangePassword(). Routing it + // through here would let CodeQL trace the boolean to localStorage and flag + // the (deliberate) JWT writes as sensitive-info storage. if (!canUseStorage()) return; localStorage.setItem(AUTH_TOKEN_KEY, accessToken); localStorage.setItem(AUTH_REFRESH_TOKEN_KEY, refreshToken); - localStorage.setItem(AUTH_MUST_CHANGE_PASSWORD_KEY, String(mustChangePassword)); } export function clearAuthTokens(): void { @@ -53,14 +54,22 @@ export function clearAuthTokens(): void { localStorage.removeItem(AUTH_MUST_CHANGE_PASSWORD_KEY); } +// Encode the flag as key presence (literal "1" or absence) so localStorage +// receives a constant, not a derived boolean. Breaks the CodeQL data flow +// from TokenResponse.must_change_password into localStorage.setItem; the +// stored value is a route hint (/change-password vs /chat), not a secret. export function mustChangePassword(): boolean { if (!canUseStorage()) return false; - return localStorage.getItem(AUTH_MUST_CHANGE_PASSWORD_KEY) === "true"; + return localStorage.getItem(AUTH_MUST_CHANGE_PASSWORD_KEY) !== null; } export function setMustChangePassword(required: boolean): void { if (!canUseStorage()) return; - localStorage.setItem(AUTH_MUST_CHANGE_PASSWORD_KEY, String(required)); + if (required) { + localStorage.setItem(AUTH_MUST_CHANGE_PASSWORD_KEY, "1"); + } else { + localStorage.removeItem(AUTH_MUST_CHANGE_PASSWORD_KEY); + } } export function isOnboardingDone(): boolean { diff --git a/studio/frontend/src/features/auth/tauri-auto-auth.ts b/studio/frontend/src/features/auth/tauri-auto-auth.ts index a0199f9ac3..44884796d3 100644 --- a/studio/frontend/src/features/auth/tauri-auto-auth.ts +++ b/studio/frontend/src/features/auth/tauri-auto-auth.ts @@ -6,6 +6,7 @@ import { hasAuthToken, hasRefreshToken, mustChangePassword, + setMustChangePassword, storeAuthTokens, } from "./session"; import { refreshSession } from "./api"; @@ -72,7 +73,8 @@ async function doTauriAutoAuth(options: TauriAutoAuthOptions): Promise try { const { invoke } = await import("@tauri-apps/api/core"); const tokens = await invoke("desktop_auth"); - storeAuthTokens(tokens.access_token, tokens.refresh_token, false); + storeAuthTokens(tokens.access_token, tokens.refresh_token); + setMustChangePassword(false); clearTauriAuthFailure(); return true; } catch (error) { diff --git a/studio/frontend/src/features/chat/api/chat-api.ts b/studio/frontend/src/features/chat/api/chat-api.ts index ec50a3a8d5..f842144723 100644 --- a/studio/frontend/src/features/chat/api/chat-api.ts +++ b/studio/frontend/src/features/chat/api/chat-api.ts @@ -2,6 +2,7 @@ // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 import { authFetch } from "@/features/auth"; +import { formatFastApiDetail } from "@/lib/format-fastapi-error"; import type { AudioGenerationResponse, GgufVariantsResponse, @@ -17,21 +18,12 @@ import type { } from "../types/api"; function parseErrorText(status: number, body: unknown): string { - if ( - body && - typeof body === "object" && - "detail" in body && - typeof body.detail === "string" - ) { - return body.detail; - } - if ( - body && - typeof body === "object" && - "message" in body && - typeof body.message === "string" - ) { - return body.message; + if (body && typeof body === "object") { + const detail = (body as { detail?: unknown }).detail; + const formatted = formatFastApiDetail(detail); + if (formatted) return formatted; + const message = (body as { message?: unknown }).message; + if (typeof message === "string" && message) return message; } return `Request failed (${status})`; } diff --git a/studio/frontend/src/features/chat/api/openai-containers.ts b/studio/frontend/src/features/chat/api/openai-containers.ts index 29d292f7cf..6463b5dc7b 100644 --- a/studio/frontend/src/features/chat/api/openai-containers.ts +++ b/studio/frontend/src/features/chat/api/openai-containers.ts @@ -10,6 +10,7 @@ */ import { authFetch } from "@/features/auth"; +import { readFastApiError } from "@/lib/format-fastapi-error"; import { encryptProviderApiKey } from "./providers-api"; export interface OpenAIContainerSummary { @@ -42,13 +43,7 @@ function fromRaw(raw: RawSummary): OpenAIContainerSummary { } async function parseError(response: Response): Promise { - try { - const body = (await response.json()) as { detail?: string }; - if (body && typeof body.detail === "string") return body.detail; - } catch { - /* fall through */ - } - return `HTTP ${response.status}`; + return readFastApiError(response, "HTTP"); } interface AuthInputs { diff --git a/studio/frontend/src/features/chat/api/providers-api.ts b/studio/frontend/src/features/chat/api/providers-api.ts index e76e24a627..e0faac27b4 100644 --- a/studio/frontend/src/features/chat/api/providers-api.ts +++ b/studio/frontend/src/features/chat/api/providers-api.ts @@ -3,6 +3,7 @@ import forge from "node-forge"; import { authFetch } from "@/features/auth"; +import { formatFastApiDetail } from "@/lib/format-fastapi-error"; export interface ProviderRegistryEntry { provider_type: string; @@ -40,21 +41,12 @@ export interface ProviderTestResult { } function parseErrorText(status: number, body: unknown): string { - if ( - body && - typeof body === "object" && - "detail" in body && - typeof body.detail === "string" - ) { - return body.detail; - } - if ( - body && - typeof body === "object" && - "message" in body && - typeof body.message === "string" - ) { - return body.message; + if (body && typeof body === "object") { + const detail = (body as { detail?: unknown }).detail; + const formatted = formatFastApiDetail(detail); + if (formatted) return formatted; + const message = (body as { message?: unknown }).message; + if (typeof message === "string" && message) return message; } return `Request failed (${status})`; } diff --git a/studio/frontend/src/features/export/api/export-api.ts b/studio/frontend/src/features/export/api/export-api.ts index 450691ddfd..56f8c8129b 100644 --- a/studio/frontend/src/features/export/api/export-api.ts +++ b/studio/frontend/src/features/export/api/export-api.ts @@ -2,15 +2,9 @@ // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 import { authFetch } from "@/features/auth"; +import { readFastApiError } from "@/lib/format-fastapi-error"; -async function readError(response: Response): Promise { - try { - const payload = (await response.json()) as { detail?: string; message?: string }; - return payload.detail || payload.message || `Request failed (${response.status})`; - } catch { - return `Request failed (${response.status})`; - } -} +const readError = (r: Response): Promise => readFastApiError(r); async function parseJson(response: Response): Promise { if (!response.ok) { diff --git a/studio/frontend/src/features/recipe-studio/api/index.ts b/studio/frontend/src/features/recipe-studio/api/index.ts index 273d4aea8d..06b4bc1f8b 100644 --- a/studio/frontend/src/features/recipe-studio/api/index.ts +++ b/studio/frontend/src/features/recipe-studio/api/index.ts @@ -2,6 +2,7 @@ // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 import { authFetch } from "@/features/auth"; +import { formatFastApiDetail, readFastApiError } from "@/lib/format-fastapi-error"; const DEFAULT_BASE = "/api/data-recipe"; @@ -202,12 +203,18 @@ async function parseErrorResponse(response: Response): Promise { } try { const parsed = JSON.parse(text) as { - detail?: string; + detail?: unknown; message?: string; // biome-ignore lint/style/useNamingConvention: api schema raw_detail?: string; }; - return parsed.detail ?? parsed.message ?? parsed.raw_detail ?? text; + // Use ||, not ??: an array detail is truthy but not nullish, and + // formatFastApiDetail returns null when it cannot flatten the value. + const formatted = formatFastApiDetail(parsed.detail); + if (formatted) return formatted; + if (typeof parsed.message === "string" && parsed.message) return parsed.message; + if (typeof parsed.raw_detail === "string" && parsed.raw_detail) return parsed.raw_detail; + return text; } catch { return text; } @@ -449,22 +456,17 @@ export async function uploadUnstructuredFile( ); if (res.status === 413) { - const detail = await res.json().catch(() => ({ detail: "File too large" })); return { file_id: "", filename: file.name, size_bytes: file.size, status: "error", - error: - typeof detail.detail === "string" ? detail.detail : "File too large", + error: await readFastApiError(res, "File too large"), }; } if (!res.ok) { - const detail = await res.json().catch(() => ({ detail: "Upload failed" })); - throw new Error( - typeof detail.detail === "string" ? detail.detail : "Upload failed", - ); + throw new Error(await readFastApiError(res, "Upload failed")); } return res.json(); diff --git a/studio/frontend/src/features/training/api/datasets-api.ts b/studio/frontend/src/features/training/api/datasets-api.ts index c56aec03be..0b4f90c56c 100644 --- a/studio/frontend/src/features/training/api/datasets-api.ts +++ b/studio/frontend/src/features/training/api/datasets-api.ts @@ -7,6 +7,7 @@ import type { UploadDatasetResponse, } from "../types/datasets"; import { authFetch } from "@/features/auth"; +import { readFastApiError } from "@/lib/format-fastapi-error"; type CheckDatasetFormatArgs = { datasetName: string; @@ -36,8 +37,7 @@ export async function checkDatasetFormat({ }); if (!res.ok) { - const body = await res.json().catch(() => null); - throw new Error(body?.detail || `Request failed (${res.status})`); + throw new Error(await readFastApiError(res)); } return res.json(); @@ -55,8 +55,7 @@ export async function uploadTrainingDataset( }); if (!res.ok) { - const body = await res.json().catch(() => null); - throw new Error(body?.detail || `Upload failed (${res.status})`); + throw new Error(await readFastApiError(res, "Upload failed")); } return res.json(); @@ -107,8 +106,7 @@ export async function aiAssistMapping({ }); if (!res.ok) { - const body = await res.json().catch(() => null); - throw new Error(body?.detail || `AI assist failed (${res.status})`); + throw new Error(await readFastApiError(res, "AI assist failed")); } return res.json(); @@ -117,8 +115,7 @@ export async function aiAssistMapping({ export async function listLocalDatasets(): Promise { const res = await authFetch("/api/datasets/local"); if (!res.ok) { - const body = await res.json().catch(() => null); - throw new Error(body?.detail || `Request failed (${res.status})`); + throw new Error(await readFastApiError(res)); } return res.json(); } diff --git a/studio/frontend/src/features/training/api/history-api.ts b/studio/frontend/src/features/training/api/history-api.ts index e886e35626..8fde83bc8d 100644 --- a/studio/frontend/src/features/training/api/history-api.ts +++ b/studio/frontend/src/features/training/api/history-api.ts @@ -2,6 +2,7 @@ // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 import { authFetch } from "@/features/auth"; +import { readFastApiError } from "@/lib/format-fastapi-error"; import type { TrainingRunDeleteResponse, TrainingRunDetailResponse, @@ -9,14 +10,7 @@ import type { TrainingRunSummary, } from "../types/history"; -async function readError(response: Response): Promise { - try { - const payload = (await response.json()) as { detail?: string; message?: string }; - return payload.detail || payload.message || `Request failed (${response.status})`; - } catch { - return `Request failed (${response.status})`; - } -} +const readError = (r: Response): Promise => readFastApiError(r); async function parseJson(response: Response): Promise { if (!response.ok) { diff --git a/studio/frontend/src/features/training/api/train-api.ts b/studio/frontend/src/features/training/api/train-api.ts index 781dbe6139..af3ea347c2 100644 --- a/studio/frontend/src/features/training/api/train-api.ts +++ b/studio/frontend/src/features/training/api/train-api.ts @@ -2,6 +2,7 @@ // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 import { authFetch } from "@/features/auth"; +import { readFastApiError } from "@/lib/format-fastapi-error"; import type { TrainingStartRequest, TrainingStartResponse, @@ -17,45 +18,7 @@ function isAbortError(error: unknown): boolean { return error instanceof DOMException && error.name === "AbortError"; } -type FastApiValidationError = { - loc?: unknown[]; - msg?: string; -}; - -function formatDetail(detail: unknown): string | null { - if (typeof detail === "string" && detail) return detail; - if (!Array.isArray(detail)) return null; - const parts = detail - .map((entry) => { - if (!entry || typeof entry !== "object") return ""; - const { loc, msg } = entry as FastApiValidationError; - const path = Array.isArray(loc) - ? loc.filter((segment) => segment !== "body").join(".") - : ""; - const message = typeof msg === "string" ? msg : ""; - if (path && message) return `${path}: ${message}`; - return path || message; - }) - .filter(Boolean); - return parts.length > 0 ? parts.join("; ") : null; -} - -async function readError(response: Response): Promise { - try { - const payload = (await response.json()) as { - detail?: unknown; - message?: string; - }; - const formattedDetail = formatDetail(payload.detail); - if (formattedDetail) return formattedDetail; - if (typeof payload.message === "string" && payload.message) { - return payload.message; - } - return `Request failed (${response.status})`; - } catch { - return `Request failed (${response.status})`; - } -} +const readError = (r: Response): Promise => readFastApiError(r); async function parseJson(response: Response): Promise { if (!response.ok) { diff --git a/studio/frontend/src/lib/format-fastapi-error.ts b/studio/frontend/src/lib/format-fastapi-error.ts new file mode 100644 index 0000000000..ed4a5ece7d --- /dev/null +++ b/studio/frontend/src/lib/format-fastapi-error.ts @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. + +/** + * Render a FastAPI error response body into a human-readable string. + * + * FastAPI emits 422s as `{detail: Array<{loc, msg, type, input?}>}`. The + * naive `body.detail || body.message` pattern truthy-coerces the array + * and stringifies it as `[object Object]`, which is unhelpful and was + * exactly the regression #5409 fixed inside `train-api.ts`. Lift the + * helper here so chat, export, history, datasets, and recipe-studio + * can all share it. + * + * Falls back through: array detail -> string detail -> message -> null. + */ + +export type FastApiValidationError = { + loc?: unknown[]; + msg?: string; +}; + +export function formatFastApiDetail(detail: unknown): string | null { + if (typeof detail === "string" && detail) return detail; + if (!Array.isArray(detail)) return null; + const parts = detail + .map((entry) => { + if (!entry || typeof entry !== "object") return ""; + const { loc, msg } = entry as FastApiValidationError; + const path = Array.isArray(loc) + ? loc.filter((segment) => segment !== "body").join(".") + : ""; + const message = typeof msg === "string" ? msg : ""; + if (path && message) return `${path}: ${message}`; + return path || message; + }) + .filter(Boolean); + return parts.length > 0 ? parts.join("; ") : null; +} + +/** + * Convert a Response (likely a non-ok response from a fetch) into the + * best human-readable error message available. Used by *-api.ts wrappers + * so toast text reads as `field: msg` instead of `[object Object]` or + * `Request failed (422)`. + */ +export async function readFastApiError( + response: Response, + fallbackPrefix: string = "Request failed", +): Promise { + try { + const payload = (await response.json()) as { + detail?: unknown; + message?: string; + }; + const formatted = formatFastApiDetail(payload.detail); + if (formatted) return formatted; + if (typeof payload.message === "string" && payload.message) { + return payload.message; + } + } catch { + // fall through + } + return `${fallbackPrefix} (${response.status})`; +}