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})`;
+}