Add change password feature to settings page

- Add PUT /api/me/password endpoint in backend
- Add changePassword API function in frontend
- Add change password form UI in SettingsPanel
- Add i18n translations for change password (zh/en)
- Fix TypeScript type error in i18n context

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Michael Dong
2026-02-05 18:30:57 +08:00
parent 403843acfd
commit 86d3a8c419
5 changed files with 163 additions and 1 deletions

View File

@@ -5,6 +5,7 @@ use sea_orm::{ActiveModelTrait, EntityTrait, Set};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::io::Write; use std::io::Write;
use uuid::Uuid; use uuid::Uuid;
use bcrypt;
use crate::app_data::AppData; use crate::app_data::AppData;
use crate::entity::user; use crate::entity::user;
@@ -33,6 +34,13 @@ pub struct UpdateSettingsRequest {
pub bark_enabled: Option<bool>, pub bark_enabled: Option<bool>,
} }
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChangePasswordRequest {
pub current_password: String,
pub new_password: String,
}
#[get("")] #[get("")]
async fn get_me( async fn get_me(
app_data: web::Data<AppData>, app_data: web::Data<AppData>,
@@ -208,9 +216,46 @@ async fn upload_avatar(
Ok(HttpResponse::Ok().json(UploadAvatarResponse { avatar_url })) Ok(HttpResponse::Ok().json(UploadAvatarResponse { avatar_url }))
} }
#[put("/password")]
async fn change_password(
app_data: web::Data<AppData>,
auth: AuthUser,
body: web::Json<ChangePasswordRequest>,
) -> Result<impl Responder, ApiError> {
if body.new_password.len() < 6 {
return Err(ApiError::BadRequest("New password must be at least 6 characters".to_string()));
}
let user = user::Entity::find_by_id(auth.user_id)
.one(&app_data.db)
.await?
.ok_or_else(|| ApiError::NotFound("User not found".to_string()))?;
// Verify current password
let valid = bcrypt::verify(&body.current_password, &user.password_hash)
.map_err(|_| ApiError::Internal("Password verification failed".to_string()))?;
if !valid {
return Err(ApiError::BadRequest("Current password is incorrect".to_string()));
}
// Hash new password
let new_password_hash = bcrypt::hash(&body.new_password, 10)
.map_err(|_| ApiError::Internal("Password hashing failed".to_string()))?;
// Update password
let mut active: user::ActiveModel = user.into();
active.password_hash = Set(new_password_hash);
active.updated_at = Set(chrono::Utc::now().fixed_offset());
active.update(&app_data.db).await?;
Ok(HttpResponse::Ok().json(serde_json::json!({"success": true})))
}
pub fn routes() -> Scope { pub fn routes() -> Scope {
web::scope("/api/me") web::scope("/api/me")
.service(get_me) .service(get_me)
.service(update_settings) .service(update_settings)
.service(upload_avatar) .service(upload_avatar)
.service(change_password)
} }

View File

@@ -34,6 +34,14 @@ const SettingsPanel = () => {
}); });
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [passwordForm, setPasswordForm] = useState({
currentPassword: "",
newPassword: "",
confirmPassword: "",
});
const [passwordError, setPasswordError] = useState("");
const [passwordSuccess, setPasswordSuccess] = useState(false);
const [changingPassword, setChangingPassword] = useState(false);
useEffect(() => { useEffect(() => {
api api
@@ -92,6 +100,43 @@ const SettingsPanel = () => {
} }
}; };
const handleChangePassword = async () => {
setPasswordError("");
setPasswordSuccess(false);
if (!passwordForm.currentPassword) {
setPasswordError(t("currentPasswordRequired"));
return;
}
if (!passwordForm.newPassword) {
setPasswordError(t("newPasswordRequired"));
return;
}
if (passwordForm.newPassword.length < 6) {
setPasswordError(t("passwordTooShort"));
return;
}
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
setPasswordError(t("passwordMismatch"));
return;
}
try {
setChangingPassword(true);
await api.changePassword({
currentPassword: passwordForm.currentPassword,
newPassword: passwordForm.newPassword,
});
setPasswordSuccess(true);
setPasswordForm({ currentPassword: "", newPassword: "", confirmPassword: "" });
setTimeout(() => setPasswordSuccess(false), 3000);
} catch (error) {
setPasswordError(error instanceof Error ? error.message : "Failed to change password");
} finally {
setChangingPassword(false);
}
};
return ( return (
<Card className="bg-white"> <Card className="bg-white">
<CardHeader> <CardHeader>
@@ -195,6 +240,53 @@ const SettingsPanel = () => {
</Button> </Button>
{saved && <div className="text-sm text-primary">{t("saved")}</div>} {saved && <div className="text-sm text-primary">{t("saved")}</div>}
</div> </div>
{/* Change Password Section */}
<div className="mt-8 pt-6 border-t border-slate-200">
<h3 className="text-lg font-medium mb-4">{t("changePassword")}</h3>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="currentPassword">{t("currentPassword")}</Label>
<Input
id="currentPassword"
type="password"
value={passwordForm.currentPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, currentPassword: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="newPassword">{t("newPassword")}</Label>
<Input
id="newPassword"
type="password"
value={passwordForm.newPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, newPassword: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">{t("confirmNewPassword")}</Label>
<Input
id="confirmPassword"
type="password"
value={passwordForm.confirmPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, confirmPassword: e.target.value })}
/>
</div>
{passwordError && (
<div className="text-sm text-red-500">{passwordError}</div>
)}
{passwordSuccess && (
<div className="text-sm text-green-600">{t("passwordChanged")}</div>
)}
<Button
variant="outline"
onClick={handleChangePassword}
disabled={changingPassword}
>
{changingPassword ? t("loading") : t("changePassword")}
</Button>
</div>
</div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -67,6 +67,9 @@ export const api = {
} }
return res.json(); return res.json();
}, },
// Password management
changePassword: (payload: { currentPassword: string; newPassword: string }) =>
request("/api/me/password", { method: "PUT", body: JSON.stringify(payload) }),
// Invite management // Invite management
getInvites: () => request("/api/invites"), getInvites: () => request("/api/invites"),
createInvite: (payload: { maxUses?: number; expiresInDays?: number }) => createInvite: (payload: { maxUses?: number; expiresInDays?: number }) =>

View File

@@ -40,7 +40,7 @@ export const I18nProvider = ({ children }: { children: ReactNode }) => {
const t = useCallback( const t = useCallback(
(key: TranslationKey, params?: Record<string, string | number>): string => { (key: TranslationKey, params?: Record<string, string | number>): string => {
let text = translations[locale][key] || translations.zh[key] || key; let text: string = translations[locale][key] || translations.zh[key] || key;
if (params) { if (params) {
Object.entries(params).forEach(([paramKey, value]) => { Object.entries(params).forEach(([paramKey, value]) => {
text = text.replace(`{${paramKey}}`, String(value)); text = text.replace(`{${paramKey}}`, String(value));

View File

@@ -120,6 +120,17 @@ export const translations = {
barkAlerts: "Bark 推送", barkAlerts: "Bark 推送",
barkAlertsDesc: "Bark 推送开关", barkAlertsDesc: "Bark 推送开关",
// Change Password
changePassword: "修改密码",
currentPassword: "当前密码",
newPassword: "新密码",
confirmNewPassword: "确认新密码",
passwordChanged: "密码修改成功",
passwordMismatch: "两次输入的密码不一致",
passwordTooShort: "密码至少需要6个字符",
currentPasswordRequired: "请输入当前密码",
newPasswordRequired: "请输入新密码",
// Recurrence // Recurrence
hourly: "每小时", hourly: "每小时",
daily: "每天", daily: "每天",
@@ -333,6 +344,17 @@ export const translations = {
barkAlerts: "Bark Alerts", barkAlerts: "Bark Alerts",
barkAlertsDesc: "Bark push notification toggle", barkAlertsDesc: "Bark push notification toggle",
// Change Password
changePassword: "Change Password",
currentPassword: "Current Password",
newPassword: "New Password",
confirmNewPassword: "Confirm New Password",
passwordChanged: "Password changed successfully",
passwordMismatch: "Passwords do not match",
passwordTooShort: "Password must be at least 6 characters",
currentPasswordRequired: "Please enter current password",
newPasswordRequired: "Please enter new password",
// Recurrence // Recurrence
hourly: "Hourly", hourly: "Hourly",
daily: "Daily", daily: "Daily",