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 <wasimysdev@gmail.com>
This commit is contained in:
parent
1fc8bf53c7
commit
494e0e6fe4
6 changed files with 377 additions and 1 deletions
|
|
@ -15,6 +15,7 @@ export {
|
|||
mustChangePassword,
|
||||
resetOnboardingDone,
|
||||
setMustChangePassword,
|
||||
storeAuthTokens,
|
||||
} from "./session";
|
||||
export {
|
||||
clearTauriAuthFailure,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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<typeof useT>;
|
||||
|
||||
function stringField(payload: Record<string, unknown>, key: string): string {
|
||||
const value = payload[key];
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function booleanField(payload: Record<string, unknown>, 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<string | null> {
|
||||
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<Response> {
|
||||
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<Record<string, unknown>> {
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(o) => {
|
||||
if (submitting && !o) {
|
||||
return;
|
||||
}
|
||||
setOpen(o);
|
||||
if (!o) {
|
||||
reset();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild={true}>
|
||||
<Button variant="outline" size="sm" className="h-8">
|
||||
{t("settings.general.passwordDialog.trigger")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent
|
||||
className="sm:max-w-sm"
|
||||
showCloseButton={!submitting}
|
||||
onEscapeKeyDown={(event) => {
|
||||
if (submitting) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
onInteractOutside={(event) => {
|
||||
if (submitting) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<form onSubmit={submit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("settings.general.passwordDialog.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("settings.general.passwordDialog.description", {
|
||||
minLength: MIN_PASSWORD_LENGTH,
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cp-current">
|
||||
{t("settings.general.passwordDialog.currentPassword")}
|
||||
</Label>
|
||||
<Input
|
||||
id="cp-current"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
minLength={MIN_PASSWORD_LENGTH}
|
||||
disabled={submitting}
|
||||
/>
|
||||
{currentTooShort ? (
|
||||
<p className="text-xs text-destructive" aria-live="polite">
|
||||
{t("settings.general.passwordDialog.currentTooShort", {
|
||||
minLength: MIN_PASSWORD_LENGTH,
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cp-new">
|
||||
{t("settings.general.passwordDialog.newPassword")}
|
||||
</Label>
|
||||
<Input
|
||||
id="cp-new"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={next}
|
||||
onChange={(e) => setNext(e.target.value)}
|
||||
minLength={MIN_PASSWORD_LENGTH}
|
||||
disabled={submitting}
|
||||
/>
|
||||
{nextTooShort || samePassword ? (
|
||||
<p className="text-xs text-destructive" aria-live="polite">
|
||||
{nextTooShort
|
||||
? t("settings.general.passwordDialog.newTooShort", {
|
||||
minLength: MIN_PASSWORD_LENGTH,
|
||||
})
|
||||
: t("settings.general.passwordDialog.samePassword")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cp-confirm">
|
||||
{t("settings.general.passwordDialog.confirmPassword")}
|
||||
</Label>
|
||||
<Input
|
||||
id="cp-confirm"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
minLength={MIN_PASSWORD_LENGTH}
|
||||
disabled={submitting}
|
||||
/>
|
||||
{mismatch ? (
|
||||
<p className="text-xs text-destructive" aria-live="polite">
|
||||
{t("settings.general.passwordDialog.mismatch")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="mt-5">
|
||||
<Button type="submit" disabled={disabled}>
|
||||
{submitting
|
||||
? t("settings.general.passwordDialog.updating")
|
||||
: t("settings.general.passwordDialog.update")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -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() {
|
|||
</button>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
{/* 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 : (
|
||||
<SettingsRow
|
||||
label={t("settings.general.password")}
|
||||
description={t("settings.general.passwordDescription")}
|
||||
>
|
||||
<ChangePasswordDialog />
|
||||
</SettingsRow>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
{modelsFolder ? (
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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: "根据第一条消息生成简短标题。",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue