Files
notify/frontend/src/components/SettingsPanel.tsx
Michael Dong 86d3a8c419 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>
2026-02-05 18:30:57 +08:00

297 lines
9.9 KiB
TypeScript

"use client";
import { useEffect, useState, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import Avatar from "@/components/ui/avatar";
import { api } from "@/lib/api";
import { useTranslation } from "@/lib/i18n";
import { useUser } from "@/lib/user-context";
type Me = {
username?: string;
avatar?: string | null;
timezone: string;
barkUrl?: string | null;
inappEnabled: boolean;
barkEnabled: boolean;
};
const SettingsPanel = () => {
const t = useTranslation();
const { refreshUser } = useUser();
const fileInputRef = useRef<HTMLInputElement>(null);
const [form, setForm] = useState<Me>({
timezone: "Asia/Shanghai",
barkUrl: "",
avatar: null,
inappEnabled: true,
barkEnabled: false,
});
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
.getMe()
.then((data) => setForm(data as Me))
.catch(() => null);
}, []);
const save = async () => {
await api.updateSettings({
avatar: form.avatar || null,
timezone: form.timezone,
barkUrl: form.barkUrl || null,
inappEnabled: form.inappEnabled,
barkEnabled: form.barkEnabled,
});
await refreshUser();
setSaved(true);
setTimeout(() => setSaved(false), 1500);
};
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
// 验证文件类型
const allowedTypes = ["image/jpeg", "image/png", "image/gif", "image/webp"];
if (!allowedTypes.includes(file.type)) {
alert("不支持的文件格式,请上传 jpg、png、gif 或 webp 格式的图片");
return;
}
// 验证文件大小 (5MB)
if (file.size > 5 * 1024 * 1024) {
alert("文件大小不能超过 5MB");
return;
}
try {
setUploading(true);
const result = await api.uploadAvatar(file);
setForm({ ...form, avatar: result.avatarUrl });
await refreshUser();
} catch (error) {
console.error("上传头像失败:", error);
alert("上传头像失败,请重试");
} finally {
setUploading(false);
}
};
const handleRemoveAvatar = () => {
setForm({ ...form, avatar: null });
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
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>
<CardTitle>{t("settings")}</CardTitle>
<CardDescription>{t("settingsDesc")}</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Avatar Section */}
<div className="space-y-2">
<Label>{t("avatar")}</Label>
<div className="flex items-center gap-4">
<Avatar username={form.username} src={form.avatar} size="lg" />
<div className="flex flex-col gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileChange}
className="hidden"
id="avatar-upload"
/>
<Button
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
>
{uploading ? t("uploading") : t("uploadAvatar")}
</Button>
{form.avatar && (
<Button
variant="outline"
size="sm"
onClick={handleRemoveAvatar}
>
{t("removeAvatar")}
</Button>
)}
<p className="text-xs text-slate-500">{t("avatarHint")}</p>
</div>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="barkUrl">{t("barkPushUrl")}</Label>
<Input
id="barkUrl"
value={form.barkUrl ?? ""}
onChange={(event) => setForm({ ...form, barkUrl: event.target.value })}
placeholder="https://your.bark.server"
/>
</div>
<div className="space-y-2">
<Label htmlFor="timezone">{t("timezone")}</Label>
<Input
id="timezone"
value={form.timezone}
onChange={(event) => setForm({ ...form, timezone: event.target.value })}
placeholder="Asia/Shanghai"
/>
</div>
<div className="space-y-2">
<Label>{t("notificationChannels")}</Label>
<div className="rounded-lg bg-slate-50/80">
<div className="flex items-center justify-between px-4 py-3">
<div>
<div className="text-sm font-medium text-slate-800">{t("webNotifications")}</div>
<div className="text-xs text-slate-500">{t("webNotificationsDesc")}</div>
</div>
<Checkbox
id="inapp"
checked={form.inappEnabled}
onCheckedChange={(checked) =>
setForm({ ...form, inappEnabled: checked === true })
}
/>
</div>
<div className="mx-4 h-px bg-slate-200/50" />
<div className="flex items-center justify-between px-4 py-3">
<div>
<div className="text-sm font-medium text-slate-800">{t("barkAlerts")}</div>
<div className="text-xs text-slate-500">{t("barkAlertsDesc")}</div>
</div>
<Checkbox
id="bark"
checked={form.barkEnabled}
onCheckedChange={(checked) =>
setForm({ ...form, barkEnabled: checked === true })
}
/>
</div>
</div>
</div>
<div className="flex items-center gap-3">
<Button variant="outline" onClick={save}>
{t("save")}
</Button>
<Button variant="outline" type="button">
{t("cancel")}
</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>
);
};
export default SettingsPanel;