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 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 }) =>
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user