From 494e0e6fe4a50aa2082bada63bc0f1864b11c14b Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Mon, 22 Jun 2026 07:49:13 -0700 Subject: [PATCH] studio: let users change their password from Settings (#6520) * studio: let users change their password from Settings The only day-to-day way to change credentials was the destructive console command 'unsloth studio reset-password' (it deletes auth.db); the in-app change-password page is the forced first-login flow and bounces non-forced users to /login. Add a Change password control to Settings > General > Account: a small dialog that takes the current and new password and calls the existing POST /api/auth/change-password, then stores the rotated tokens it returns. Username changes remain out of scope. The dialog uses authFetch, so an expired access token is refreshed and the request retried instead of failing with a spurious expired-token error for a user who left Studio open past the token lifetime. The row is hidden in the Tauri desktop app, which authenticates via desktop auto-auth with a generated secret: there is no user-entered password to change there, and changing it would clear the desktop secret. * studio: harden settings password change * studio: harden settings password dialog UX --------- Co-authored-by: wasimysaid --- studio/frontend/src/features/auth/index.ts | 1 + .../src/features/native-intents/index.ts | 1 + .../components/change-password-dialog.tsx | 323 ++++++++++++++++++ .../features/settings/tabs/general-tab.tsx | 14 +- studio/frontend/src/i18n/locales/en.ts | 21 ++ studio/frontend/src/i18n/locales/zh-CN.ts | 18 + 6 files changed, 377 insertions(+), 1 deletion(-) create mode 100644 studio/frontend/src/features/settings/components/change-password-dialog.tsx diff --git a/studio/frontend/src/features/auth/index.ts b/studio/frontend/src/features/auth/index.ts index 9baad33e0e..f33991b6b7 100644 --- a/studio/frontend/src/features/auth/index.ts +++ b/studio/frontend/src/features/auth/index.ts @@ -15,6 +15,7 @@ export { mustChangePassword, resetOnboardingDone, setMustChangePassword, + storeAuthTokens, } from "./session"; export { clearTauriAuthFailure, diff --git a/studio/frontend/src/features/native-intents/index.ts b/studio/frontend/src/features/native-intents/index.ts index 1e62dc26e5..a82c39a9ec 100644 --- a/studio/frontend/src/features/native-intents/index.ts +++ b/studio/frontend/src/features/native-intents/index.ts @@ -3,6 +3,7 @@ export { NativeModelChip } from "./components/native-model-chip"; export { NativeModelDropOverlay } from "./components/native-model-drop-overlay"; +export { openModelsDir } from "./api"; export { useNativeIntentStore } from "./store"; export type { NativeIntent } from "./types"; export { useChooseNativeModel } from "./use-native-dialogs"; diff --git a/studio/frontend/src/features/settings/components/change-password-dialog.tsx b/studio/frontend/src/features/settings/components/change-password-dialog.tsx new file mode 100644 index 0000000000..cd30d37d5d --- /dev/null +++ b/studio/frontend/src/features/settings/components/change-password-dialog.tsx @@ -0,0 +1,323 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + getAuthToken, + refreshSession, + setMustChangePassword, + storeAuthTokens, +} from "@/features/auth"; +import { useT } from "@/i18n"; +import { apiUrl } from "@/lib/api-base"; +import { toast } from "@/lib/toast"; +import { type FormEvent, useState } from "react"; + +const MIN_PASSWORD_LENGTH = 8; +const WRONG_CURRENT_PASSWORD_DETAIL = "Current password is incorrect"; + +type T = ReturnType; + +function stringField(payload: Record, key: string): string { + const value = payload[key]; + return typeof value === "string" ? value : ""; +} + +function booleanField(payload: Record, key: string): boolean { + return payload[key] === true; +} + +function changePasswordBody( + currentPassword: string, + nextPassword: string, +): string { + return JSON.stringify( + Object.fromEntries([ + ["current_password", currentPassword], + ["new_password", nextPassword], + ]), + ); +} + +function hasStartedTooShortPassword(value: string): boolean { + return value.length > 0 && value.length < MIN_PASSWORD_LENGTH; +} + +function hasReusablePassword(currentPassword: string, nextPassword: string) { + return ( + currentPassword.length >= MIN_PASSWORD_LENGTH && + nextPassword.length >= MIN_PASSWORD_LENGTH && + currentPassword === nextPassword + ); +} + +function passwordValidationMessage( + t: T, + currentPassword: string, + nextPassword: string, + confirmPassword: string, +): string { + if (currentPassword.length < MIN_PASSWORD_LENGTH) { + return t("settings.general.passwordDialog.currentTooShort", { + minLength: MIN_PASSWORD_LENGTH, + }); + } + if (nextPassword.length < MIN_PASSWORD_LENGTH) { + return t("settings.general.passwordDialog.newTooShort", { + minLength: MIN_PASSWORD_LENGTH, + }); + } + if (nextPassword !== confirmPassword) { + return t("settings.general.passwordDialog.mismatch"); + } + if (currentPassword === nextPassword) { + return t("settings.general.passwordDialog.samePassword"); + } + return ""; +} + +async function unauthorizedDetail(response: Response): Promise { + if (response.status !== 401) { + return null; + } + const payload = (await response + .clone() + .json() + .catch(() => null)) as { + detail?: string; + } | null; + return payload?.detail ?? null; +} + +function postChangePassword( + currentPassword: string, + nextPassword: string, +): Promise { + const headers = new Headers({ "Content-Type": "application/json" }); + const token = getAuthToken(); + if (token) { + headers.set("Authorization", `Bearer ${token}`); + } + return fetch(apiUrl("/api/auth/change-password"), { + method: "POST", + headers, + body: changePasswordBody(currentPassword, nextPassword), + }); +} + +async function requestPasswordChange( + currentPassword: string, + nextPassword: string, +): Promise> { + let response = await postChangePassword(currentPassword, nextPassword); + const detail = await unauthorizedDetail(response); + if (response.status === 401 && detail !== WRONG_CURRENT_PASSWORD_DETAIL) { + // Retry token/session 401s, but never turn the endpoint's + // "wrong current password" validation into a session refresh/logout. + if (await refreshSession()) { + response = await postChangePassword(currentPassword, nextPassword); + } + } + if (!response.ok) { + const payload = (await response.json().catch(() => null)) as { + detail?: string; + } | null; + throw new Error(payload?.detail || ""); + } + return (await response.json()) as Record; +} + +/** + * Change the signed-in account's password from Settings, reusing the existing + * POST /api/auth/change-password endpoint. The forced first-login flow lives at + * /change-password and bounces non-forced users to /login, so day-to-day changes + * need their own self-contained entry point here. + */ +export function ChangePasswordDialog() { + const t = useT(); + const [open, setOpen] = useState(false); + const [current, setCurrent] = useState(""); + const [next, setNext] = useState(""); + const [confirm, setConfirm] = useState(""); + const [submitting, setSubmitting] = useState(false); + + const reset = () => { + setCurrent(""); + setNext(""); + setConfirm(""); + }; + + const currentTooShort = hasStartedTooShortPassword(current); + const nextTooShort = hasStartedTooShortPassword(next); + const mismatch = confirm.length > 0 && next !== confirm; + const samePassword = hasReusablePassword(current, next); + const validationMessage = passwordValidationMessage( + t, + current, + next, + confirm, + ); + const disabled = submitting || Boolean(validationMessage); + + async function submit(event: FormEvent) { + event.preventDefault(); + if (validationMessage) { + toast.error(validationMessage); + return; + } + setSubmitting(true); + try { + const data = await requestPasswordChange(current, next); + const accessToken = stringField(data, "access_token"); + const refreshToken = stringField(data, "refresh_token"); + if (!(accessToken && refreshToken)) { + throw new Error(t("settings.general.passwordDialog.updateFailed")); + } + // The endpoint rotates the JWT secret and returns fresh tokens. + storeAuthTokens(accessToken, refreshToken); + setMustChangePassword(booleanField(data, "must_change_password")); + toast.success(t("settings.general.passwordDialog.updated")); + reset(); + setOpen(false); + } catch (err) { + toast.error( + err instanceof Error && err.message + ? err.message + : t("settings.general.passwordDialog.updateFailed"), + ); + } finally { + setSubmitting(false); + } + } + + return ( + { + if (submitting && !o) { + return; + } + setOpen(o); + if (!o) { + reset(); + } + }} + > + + + + { + if (submitting) { + event.preventDefault(); + } + }} + onInteractOutside={(event) => { + if (submitting) { + event.preventDefault(); + } + }} + > +
+ + + {t("settings.general.passwordDialog.title")} + + + {t("settings.general.passwordDialog.description", { + minLength: MIN_PASSWORD_LENGTH, + })} + + +
+
+ + setCurrent(e.target.value)} + minLength={MIN_PASSWORD_LENGTH} + disabled={submitting} + /> + {currentTooShort ? ( +

+ {t("settings.general.passwordDialog.currentTooShort", { + minLength: MIN_PASSWORD_LENGTH, + })} +

+ ) : null} +
+
+ + setNext(e.target.value)} + minLength={MIN_PASSWORD_LENGTH} + disabled={submitting} + /> + {nextTooShort || samePassword ? ( +

+ {nextTooShort + ? t("settings.general.passwordDialog.newTooShort", { + minLength: MIN_PASSWORD_LENGTH, + }) + : t("settings.general.passwordDialog.samePassword")} +

+ ) : null} +
+
+ + setConfirm(e.target.value)} + minLength={MIN_PASSWORD_LENGTH} + disabled={submitting} + /> + {mismatch ? ( +

+ {t("settings.general.passwordDialog.mismatch")} +

+ ) : null} +
+
+ + + +
+
+
+ ); +} diff --git a/studio/frontend/src/features/settings/tabs/general-tab.tsx b/studio/frontend/src/features/settings/tabs/general-tab.tsx index 975eeb69f4..6e34b3b23d 100644 --- a/studio/frontend/src/features/settings/tabs/general-tab.tsx +++ b/studio/frontend/src/features/settings/tabs/general-tab.tsx @@ -14,7 +14,7 @@ import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; import { usePlatformStore } from "@/config/env"; import { isTauri } from "@/lib/api-base"; -import { openModelsDir } from "@/features/native-intents/api"; +import { openModelsDir } from "@/features/native-intents"; import { copyToClipboard } from "@/lib/copy-to-clipboard"; import { toast } from "@/lib/toast"; import { loadModelsFolder, type ModelsFolder } from "../api/models-folder"; @@ -39,6 +39,7 @@ import { loadUploadLimitSettings, updateUploadLimitSettings, } from "../api/upload-limit"; +import { ChangePasswordDialog } from "../components/change-password-dialog"; import { SettingsRow } from "../components/settings-row"; import { SettingsSection } from "../components/settings-section"; import { StudioVersionSection } from "../components/studio-version-section"; @@ -339,6 +340,17 @@ export function GeneralTab() { + {/* The desktop app authenticates via desktop auto-auth with a generated + secret, so there is no user-entered password to change here (and + changing it would clear the desktop secret). Web only. */} + {isTauri ? null : ( + + + + )} {modelsFolder ? ( diff --git a/studio/frontend/src/i18n/locales/en.ts b/studio/frontend/src/i18n/locales/en.ts index 1ea352f081..ddb8286b73 100644 --- a/studio/frontend/src/i18n/locales/en.ts +++ b/studio/frontend/src/i18n/locales/en.ts @@ -105,6 +105,27 @@ export const en = { "Used to load gated models and push artifacts.", hideToken: "Hide token", showToken: "Show token", + password: "Password", + passwordDescription: "Change the password for this Studio account.", + passwordDialog: { + trigger: "Change password", + title: "Change password", + description: + "Enter your current password and choose a new one (at least {minLength} characters).", + currentPassword: "Current password", + newPassword: "New password", + confirmPassword: "Confirm new password", + currentTooShort: + "Current password must be at least {minLength} characters.", + newTooShort: "New password must be at least {minLength} characters.", + mismatch: "Passwords do not match.", + samePassword: + "New password must be different from your current password.", + update: "Update password", + updating: "Updating...", + updated: "Password updated.", + updateFailed: "Password update failed.", + }, chatDefaults: "Chat defaults", autoTitleNewChats: "Auto-title new chats", autoTitleNewChatsDescription: diff --git a/studio/frontend/src/i18n/locales/zh-CN.ts b/studio/frontend/src/i18n/locales/zh-CN.ts index c87ea1b312..c89b1daa46 100644 --- a/studio/frontend/src/i18n/locales/zh-CN.ts +++ b/studio/frontend/src/i18n/locales/zh-CN.ts @@ -104,6 +104,24 @@ export const zhCN = { huggingFaceTokenDescription: "用于加载受限模型和推送产物。", hideToken: "隐藏 token", showToken: "显示 token", + password: "密码", + passwordDescription: "更改此 Studio 账号的密码。", + passwordDialog: { + trigger: "更改密码", + title: "更改密码", + description: "输入当前密码并选择新密码(至少 {minLength} 个字符)。", + currentPassword: "当前密码", + newPassword: "新密码", + confirmPassword: "确认新密码", + currentTooShort: "当前密码至少需要 {minLength} 个字符。", + newTooShort: "新密码至少需要 {minLength} 个字符。", + mismatch: "两次输入的密码不一致。", + samePassword: "新密码必须与当前密码不同。", + update: "更新密码", + updating: "正在更新...", + updated: "密码已更新。", + updateFailed: "密码更新失败。", + }, chatDefaults: "聊天默认设置", autoTitleNewChats: "自动为新聊天命名", autoTitleNewChatsDescription: "根据第一条消息生成简短标题。",