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:
@@ -5,6 +5,7 @@ use sea_orm::{ActiveModelTrait, EntityTrait, Set};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::Write;
|
||||
use uuid::Uuid;
|
||||
use bcrypt;
|
||||
|
||||
use crate::app_data::AppData;
|
||||
use crate::entity::user;
|
||||
@@ -33,6 +34,13 @@ pub struct UpdateSettingsRequest {
|
||||
pub bark_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ChangePasswordRequest {
|
||||
pub current_password: String,
|
||||
pub new_password: String,
|
||||
}
|
||||
|
||||
#[get("")]
|
||||
async fn get_me(
|
||||
app_data: web::Data<AppData>,
|
||||
@@ -208,9 +216,46 @@ async fn upload_avatar(
|
||||
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 {
|
||||
web::scope("/api/me")
|
||||
.service(get_me)
|
||||
.service(update_settings)
|
||||
.service(upload_avatar)
|
||||
.service(change_password)
|
||||
}
|
||||
|
||||
@@ -34,6 +34,14 @@ const SettingsPanel = () => {
|
||||
});
|
||||
const [saved, setSaved] = 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(() => {
|
||||
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 (
|
||||
<Card className="bg-white">
|
||||
<CardHeader>
|
||||
@@ -195,6 +240,53 @@ const SettingsPanel = () => {
|
||||
</Button>
|
||||
{saved && <div className="text-sm text-primary">{t("saved")}</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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -67,6 +67,9 @@ export const api = {
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
// Password management
|
||||
changePassword: (payload: { currentPassword: string; newPassword: string }) =>
|
||||
request("/api/me/password", { method: "PUT", body: JSON.stringify(payload) }),
|
||||
// Invite management
|
||||
getInvites: () => request("/api/invites"),
|
||||
createInvite: (payload: { maxUses?: number; expiresInDays?: number }) =>
|
||||
|
||||
@@ -40,7 +40,7 @@ export const I18nProvider = ({ children }: { children: ReactNode }) => {
|
||||
|
||||
const t = useCallback(
|
||||
(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) {
|
||||
Object.entries(params).forEach(([paramKey, value]) => {
|
||||
text = text.replace(`{${paramKey}}`, String(value));
|
||||
|
||||
@@ -120,6 +120,17 @@ export const translations = {
|
||||
barkAlerts: "Bark 推送",
|
||||
barkAlertsDesc: "Bark 推送开关",
|
||||
|
||||
// Change Password
|
||||
changePassword: "修改密码",
|
||||
currentPassword: "当前密码",
|
||||
newPassword: "新密码",
|
||||
confirmNewPassword: "确认新密码",
|
||||
passwordChanged: "密码修改成功",
|
||||
passwordMismatch: "两次输入的密码不一致",
|
||||
passwordTooShort: "密码至少需要6个字符",
|
||||
currentPasswordRequired: "请输入当前密码",
|
||||
newPasswordRequired: "请输入新密码",
|
||||
|
||||
// Recurrence
|
||||
hourly: "每小时",
|
||||
daily: "每天",
|
||||
@@ -333,6 +344,17 @@ export const translations = {
|
||||
barkAlerts: "Bark Alerts",
|
||||
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
|
||||
hourly: "Hourly",
|
||||
daily: "Daily",
|
||||
|
||||
Reference in New Issue
Block a user