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: "根据第一条消息生成简短标题。",