feat: add user profile management and admin user deletion
This commit is contained in:
parent
57c854a3f2
commit
5b9d74acb7
@ -7,7 +7,7 @@ import { PreviewNotice, StatusCard } from "@/components/ui";
|
|||||||
|
|
||||||
const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL ?? "/api/v1";
|
const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL ?? "/api/v1";
|
||||||
|
|
||||||
type User = { email: string; username: string; role: "user" | "admin"; plan: "free" | "vip"; status: string; email_verified: boolean; phone_verified: boolean };
|
type User = { email: string | null; phone: string | null; username: string; role: "user" | "admin"; plan: "free" | "vip"; status: string; email_verified: boolean; phone_verified: boolean };
|
||||||
type Usage = { period_start: string; period_end: string; limit: number; adjustment: number; used: number; reserved: number; available: number; plan: string };
|
type Usage = { period_start: string; period_end: string; limit: number; adjustment: number; used: number; reserved: number; available: number; plan: string };
|
||||||
|
|
||||||
export function AccountPanel() {
|
export function AccountPanel() {
|
||||||
@ -16,6 +16,7 @@ export function AccountPanel() {
|
|||||||
const [usage, setUsage] = useState<Usage | null>(null);
|
const [usage, setUsage] = useState<Usage | null>(null);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [passwordMessage, setPasswordMessage] = useState("");
|
const [passwordMessage, setPasswordMessage] = useState("");
|
||||||
|
const [profileMessage, setProfileMessage] = useState("");
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const csrfResponse = await fetch(`${apiBase}/auth/csrf`, { credentials: "include" });
|
const csrfResponse = await fetch(`${apiBase}/auth/csrf`, { credentials: "include" });
|
||||||
@ -40,21 +41,29 @@ export function AccountPanel() {
|
|||||||
async function changePassword(event: FormEvent<HTMLFormElement>) {
|
async function changePassword(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const form = event.currentTarget;
|
const form = event.currentTarget;
|
||||||
setPasswordMessage("");
|
|
||||||
const data = new FormData(form);
|
const data = new FormData(form);
|
||||||
const response = await fetch(`${apiBase}/auth/password/change`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf }, body: JSON.stringify({ current_password: data.get("current_password"), new_password: data.get("new_password") }) });
|
const response = await fetch(`${apiBase}/auth/password/change`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf }, body: JSON.stringify({ current_password: data.get("current_password"), new_password: data.get("new_password") }) });
|
||||||
const body = await response.json().catch(() => ({}));
|
const body = await response.json().catch(() => ({}));
|
||||||
if (!response.ok) setPasswordMessage(body.error?.message ?? "密码修改失败");
|
if (!response.ok) setPasswordMessage(body.error?.message ?? body.detail?.message ?? "密码修改失败");
|
||||||
else { setPasswordMessage("密码已修改,其他会话已撤销"); form.reset(); }
|
else { setPasswordMessage("密码已修改,其他会话已撤销"); form.reset(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateProfile(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
const data = new FormData(event.currentTarget);
|
||||||
|
const response = await fetch(`${apiBase}/auth/profile`, { method: "PATCH", credentials: "include", headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf }, body: JSON.stringify({ username: data.get("username"), email: data.get("email") || null, phone: data.get("phone") || null }) });
|
||||||
|
const body = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) setProfileMessage(body.error?.message ?? body.detail?.message ?? "资料保存失败");
|
||||||
|
else { setUser(body); setProfileMessage("资料已保存;如修改邮箱或手机号,请重新完成验证"); }
|
||||||
|
}
|
||||||
|
|
||||||
if (error) return <div className="mt-8"><PreviewNotice>{error}。<Link className="text-cyan underline" href="/login">前往登录</Link></PreviewNotice></div>;
|
if (error) return <div className="mt-8"><PreviewNotice>{error}。<Link className="text-cyan underline" href="/login">前往登录</Link></PreviewNotice></div>;
|
||||||
if (!user || !usage) return <div className="mt-8"><StatusCard title="正在加载账户" description="正在从业务 API 读取当前用户和额度。" tone="info" /></div>;
|
if (!user || !usage) return <div className="mt-8"><StatusCard title="正在加载账户" description="正在从业务 API 读取当前用户和额度。" tone="info" /></div>;
|
||||||
|
|
||||||
return <>
|
return <>
|
||||||
<div className="mt-8 grid gap-5 md:grid-cols-2 lg:grid-cols-4"><StatusCard title="账户状态" description={user.status === "active" ? "正常" : "已停用"} tone="success" /><StatusCard title="会员计划" description={usage.plan.toUpperCase()} /><StatusCard title="可用额度" description={`${usage.available.toLocaleString()} 字符`} tone="info" /><StatusCard title="验证状态" description={`邮箱 ${user.email_verified ? "已验证" : "未验证"} · 手机 ${user.phone_verified ? "已验证" : "未验证"}`} tone="warning" /></div>
|
<div className="mt-8 grid gap-5 md:grid-cols-2 lg:grid-cols-4"><StatusCard title="账户状态" description={user.status === "active" ? "正常" : "已停用"} tone="success" /><StatusCard title="会员计划" description={usage.plan.toUpperCase()} /><StatusCard title="可用额度" description={`${usage.available.toLocaleString()} 字符`} tone="info" /><StatusCard title="验证状态" description={`邮箱 ${user.email_verified ? "已验证" : "未验证"} · 手机 ${user.phone_verified ? "已验证" : "未验证"}`} tone="warning" /></div>
|
||||||
<div className="mt-8 grid gap-6 lg:grid-cols-[0.8fr_1.2fr]">
|
<div className="mt-8 grid gap-6 lg:grid-cols-[0.8fr_1.2fr]">
|
||||||
<section className="rounded-2xl border border-line bg-panel/60 p-6"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-subtle">Profile</p><h2 className="mt-3 text-xl font-semibold">基本资料</h2><dl className="mt-6 space-y-5 text-sm"><div className="flex justify-between gap-4 border-b border-line pb-4"><dt className="text-muted">用户名</dt><dd className="text-copy">{user.username}</dd></div><div className="flex justify-between gap-4 border-b border-line pb-4"><dt className="text-muted">邮箱</dt><dd className="text-copy">{user.email}</dd></div><div className="flex justify-between gap-4 border-b border-line pb-4"><dt className="text-muted">角色</dt><dd className="text-copy">{user.role}</dd></div>{user.role === "admin" ? <><div className="flex justify-between gap-4 border-b border-line pb-4"><dt className="text-muted">业务后台</dt><dd><Link className="text-cyan hover:text-copy" href="/admin">进入管理后台</Link></dd></div><div className="flex justify-between gap-4 border-b border-line pb-4"><dt className="text-muted">内容管理</dt><dd><a className="text-cyan hover:text-copy" href="/cms/admin">进入 CMS</a></dd></div></> : null}<div className="flex justify-between gap-4"><dt className="text-muted">额度周期</dt><dd className="text-right text-subtle">{new Date(usage.period_start).toLocaleDateString()} 至 {new Date(usage.period_end).toLocaleDateString()}</dd></div></dl><button className="button-ghost mt-7 w-full" onClick={logout} type="button">退出登录 <ArrowRight /></button></section>
|
<section className="rounded-2xl border border-line bg-panel/60 p-6"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-subtle">Profile</p><h2 className="mt-3 text-xl font-semibold">基本资料</h2><form className="mt-6 space-y-4" onSubmit={updateProfile}><div><label className="field-label" htmlFor="profile-username">用户名</label><input className="field-input" id="profile-username" name="username" required defaultValue={user.username} /></div><div><label className="field-label" htmlFor="profile-email">邮箱</label><input className="field-input" id="profile-email" name="email" type="email" defaultValue={user.email ?? ""} /></div><div><label className="field-label" htmlFor="profile-phone">手机号</label><input className="field-input" id="profile-phone" name="phone" defaultValue={user.phone ?? ""} /></div>{profileMessage ? <p className="text-sm text-muted" role="status">{profileMessage}</p> : null}<button className="button-secondary w-full" type="submit">保存资料</button></form>{user.role === "admin" ? <div className="mt-6 space-y-4 border-t border-line pt-5"><div className="flex justify-between gap-4 text-sm"><dt className="text-muted">业务后台</dt><dd><Link className="text-cyan hover:text-copy" href="/admin">进入管理后台</Link></dd></div><div className="flex justify-between gap-4 text-sm"><dt className="text-muted">内容管理</dt><dd><a className="text-cyan hover:text-copy" href="/cms/admin">进入 CMS</a></dd></div></div> : null}<button className="button-ghost mt-7 w-full" onClick={logout} type="button">退出登录 <ArrowRight /></button></section>
|
||||||
<div className="space-y-6"><section className="rounded-2xl border border-line bg-panel/60 p-6"><div className="flex items-center justify-between gap-4"><div><p className="text-xs font-semibold uppercase tracking-[0.18em] text-subtle">Usage</p><h2 className="mt-3 text-xl font-semibold">额度明细</h2></div><Link className="text-sm text-cyan hover:text-copy" href="/tts">打开工作台</Link></div><div className="mt-6 grid gap-4 sm:grid-cols-3"><StatusCard title="周期上限" description={usage.limit.toLocaleString()} /><StatusCard title="已使用" description={usage.used.toLocaleString()} /><StatusCard title="冻结" description={usage.reserved.toLocaleString()} /></div></section><section className="rounded-2xl border border-line bg-panel/60 p-6"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-subtle">Security</p><h2 className="mt-3 text-xl font-semibold">修改密码</h2><form className="mt-5 grid gap-4 sm:grid-cols-2" onSubmit={changePassword}><div><label className="field-label" htmlFor="current-password">当前密码</label><input className="field-input" id="current-password" name="current_password" required type="password" /></div><div><label className="field-label" htmlFor="new-password">新密码</label><input className="field-input" id="new-password" minLength={8} name="new_password" required type="password" /></div>{passwordMessage ? <p className="sm:col-span-2 text-sm text-muted" role="status">{passwordMessage}</p> : null}<button className="button-secondary sm:col-span-2" type="submit">更新密码</button></form></section></div>
|
<div className="space-y-6"><section className="rounded-2xl border border-line bg-panel/60 p-6"><div className="flex items-center justify-between gap-4"><div><p className="text-xs font-semibold uppercase tracking-[0.18em] text-subtle">Usage</p><h2 className="mt-3 text-xl font-semibold">额度明细</h2></div><Link className="text-sm text-cyan hover:text-copy" href="/tts">打开工作台</Link></div><div className="mt-6 grid gap-4 sm:grid-cols-3"><StatusCard title="周期上限" description={usage.limit.toLocaleString()} /><StatusCard title="已使用" description={usage.used.toLocaleString()} /><StatusCard title="冻结" description={usage.reserved.toLocaleString()} /></div></section><section className="rounded-2xl border border-line bg-panel/60 p-6"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-subtle">Security</p><h2 className="mt-3 text-xl font-semibold">修改密码</h2><form className="mt-5 grid gap-4 sm:grid-cols-2" onSubmit={changePassword}><div><label className="field-label" htmlFor="current-password">当前密码</label><input className="field-input" id="current-password" name="current_password" required type="password" /></div><div><label className="field-label" htmlFor="new-password">新密码</label><input className="field-input" id="new-password" minLength={8} name="new_password" required type="password" /></div>{passwordMessage ? <p className="sm:col-span-2 text-sm text-muted" role="status">{passwordMessage}</p> : null}<button className="button-secondary sm:col-span-2" type="submit">更新密码</button></form></section></div>
|
||||||
</div>
|
</div>
|
||||||
</>;
|
</>;
|
||||||
|
|||||||
@ -1,15 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { FormEvent, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
adjustQuota,
|
adjustQuota,
|
||||||
changeUserStatus,
|
changeUserStatus,
|
||||||
|
createUser,
|
||||||
|
deleteUser,
|
||||||
fetchCsrf,
|
fetchCsrf,
|
||||||
grantMembership,
|
grantMembership,
|
||||||
getUserDetail,
|
getUserDetail,
|
||||||
getUserQuota,
|
getUserQuota,
|
||||||
listUsers,
|
listUsers,
|
||||||
revokeMembership,
|
revokeMembership,
|
||||||
|
updateUserProfile,
|
||||||
type AdminUser,
|
type AdminUser,
|
||||||
type AdminUserDetail,
|
type AdminUserDetail,
|
||||||
} from "@/lib/admin";
|
} from "@/lib/admin";
|
||||||
@ -29,6 +32,7 @@ export function UsersPanel() {
|
|||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [selected, setSelected] = useState<AdminUserDetail | null>(null);
|
const [selected, setSelected] = useState<AdminUserDetail | null>(null);
|
||||||
const [notice, setNotice] = useState("");
|
const [notice, setNotice] = useState("");
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
|
||||||
async function load(pageNum = page) {
|
async function load(pageNum = page) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -69,6 +73,9 @@ export function UsersPanel() {
|
|||||||
<StatusCard title="总页数" description={`${pages} 页`} />
|
<StatusCard title="总页数" description={`${pages} 页`} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button className="button-secondary" onClick={() => setShowCreate((value) => !value)} type="button">{showCreate ? "取消添加" : "手动添加用户"}</button>
|
||||||
|
{showCreate ? <CreateUserPanel onCreated={() => { setShowCreate(false); setNotice("用户已创建"); void load(1); }} /> : null}
|
||||||
|
|
||||||
<div className="rounded-2xl border border-line bg-panel/50">
|
<div className="rounded-2xl border border-line bg-panel/50">
|
||||||
<div className="grid gap-3 border-b border-line p-5 sm:grid-cols-[1fr_1fr_1fr_auto_auto]">
|
<div className="grid gap-3 border-b border-line p-5 sm:grid-cols-[1fr_1fr_1fr_auto_auto]">
|
||||||
<input aria-label="按邮箱筛选" className="field-input" onChange={(e) => setEmail(e.target.value)} placeholder="按邮箱筛选" value={email} />
|
<input aria-label="按邮箱筛选" className="field-input" onChange={(e) => setEmail(e.target.value)} placeholder="按邮箱筛选" value={email} />
|
||||||
@ -109,8 +116,8 @@ export function UsersPanel() {
|
|||||||
rows.map((row) => (
|
rows.map((row) => (
|
||||||
<tr className="hover:bg-panel/40" key={row.id}>
|
<tr className="hover:bg-panel/40" key={row.id}>
|
||||||
<td className="px-5 py-3">
|
<td className="px-5 py-3">
|
||||||
<p className="font-semibold text-copy">{row.email}</p>
|
<p className="font-semibold text-copy">{row.username}</p>
|
||||||
<p className="text-xs text-subtle">{row.phone ?? "无手机号"}</p>
|
<p className="text-xs text-subtle">{row.email ?? "无邮箱"} · {row.phone ?? "无手机号"}</p>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-3 text-cyan">{row.effective_plan.toUpperCase()}</td>
|
<td className="px-5 py-3 text-cyan">{row.effective_plan.toUpperCase()}</td>
|
||||||
<td className="px-5 py-3">{row.role}</td>
|
<td className="px-5 py-3">{row.role}</td>
|
||||||
@ -156,6 +163,11 @@ function UserDetailPanel({ detail, onRefresh, setNotice, onClose }: { detail: Ad
|
|||||||
const [amount, setAmount] = useState("100");
|
const [amount, setAmount] = useState("100");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
|
const [username, setUsername] = useState(detail.user.username);
|
||||||
|
const [email, setEmail] = useState(detail.user.email ?? "");
|
||||||
|
const [phone, setPhone] = useState(detail.user.phone ?? "");
|
||||||
|
const [emailVerified, setEmailVerified] = useState(detail.user.email_verified);
|
||||||
|
const [phoneVerified, setPhoneVerified] = useState(detail.user.phone_verified);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void fetchCsrf().then(setCsrf);
|
void fetchCsrf().then(setCsrf);
|
||||||
@ -197,11 +209,24 @@ function UserDetailPanel({ detail, onRefresh, setNotice, onClose }: { detail: Ad
|
|||||||
return run(() => adjustQuota(detail.user.id, amt, reason, `admin-${Date.now()}-${Math.random()}`, csrf), "额度已调整");
|
return run(() => adjustQuota(detail.user.id, amt, reason, `admin-${Date.now()}-${Math.random()}`, csrf), "额度已调整");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function saveProfile() {
|
||||||
|
if (!username.trim()) { setMessage("用户名不能为空"); return; }
|
||||||
|
if (!email.trim() && !phone.trim()) { setMessage("邮箱或手机号至少填写一项"); return; }
|
||||||
|
return run(() => updateUserProfile(detail.user.id, { username: username.trim(), email: email.trim() || null, phone: phone.trim() || null, email_verified: emailVerified, phone_verified: phoneVerified, reason: reason.trim() }, csrf), "用户信息已保存");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeUser() {
|
||||||
|
if (!reason.trim()) { setMessage("请填写原因"); return; }
|
||||||
|
if (!window.confirm("确定删除该用户?删除后不可恢复。")) return;
|
||||||
|
setBusy(true);
|
||||||
|
try { await deleteUser(detail.user.id, reason, csrf); setNotice("用户已删除"); onClose(); } catch (e) { setMessage(e instanceof Error ? e.message : "删除失败"); } finally { setBusy(false); }
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-line bg-panel/60 p-6">
|
<div className="rounded-2xl border border-line bg-panel/60 p-6">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xl font-semibold">{detail.user.email}</h3>
|
<h3 className="text-xl font-semibold">{detail.user.username}</h3>
|
||||||
<p className="mt-1 text-sm text-subtle">有效 Plan:{detail.effective_plan.toUpperCase()} · 状态:{detail.user.status === "active" ? "正常" : "已禁用"}</p>
|
<p className="mt-1 text-sm text-subtle">有效 Plan:{detail.effective_plan.toUpperCase()} · 状态:{detail.user.status === "active" ? "正常" : "已禁用"}</p>
|
||||||
</div>
|
</div>
|
||||||
<button className="button-ghost" onClick={onClose} type="button">关闭</button>
|
<button className="button-ghost" onClick={onClose} type="button">关闭</button>
|
||||||
@ -251,6 +276,20 @@ function UserDetailPanel({ detail, onRefresh, setNotice, onClose }: { detail: Ad
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 rounded-xl border border-line bg-panel/40 p-4">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-subtle">用户信息</p>
|
||||||
|
<div className="mt-4 grid gap-4 md:grid-cols-3">
|
||||||
|
<div><label className="field-label" htmlFor="admin-username">用户名</label><input className="field-input" id="admin-username" onChange={(e) => setUsername(e.target.value)} value={username} /></div>
|
||||||
|
<div><label className="field-label" htmlFor="admin-email">邮箱</label><input className="field-input" id="admin-email" onChange={(e) => setEmail(e.target.value)} type="email" value={email} /></div>
|
||||||
|
<div><label className="field-label" htmlFor="admin-phone">手机号</label><input className="field-input" id="admin-phone" onChange={(e) => setPhone(e.target.value)} value={phone} /></div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex flex-wrap gap-5 text-sm">
|
||||||
|
<label className="flex items-center gap-2"><input checked={emailVerified} onChange={(e) => setEmailVerified(e.target.checked)} type="checkbox" />邮箱已验证</label>
|
||||||
|
<label className="flex items-center gap-2"><input checked={phoneVerified} onChange={(e) => setPhoneVerified(e.target.checked)} type="checkbox" />手机已验证</label>
|
||||||
|
</div>
|
||||||
|
<button className="button-secondary mt-4 disabled:opacity-50" disabled={busy || !csrf} onClick={() => void saveProfile()} type="button">保存用户信息</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 space-y-4">
|
<div className="mt-6 space-y-4">
|
||||||
<div><label className="field-label" htmlFor="admin-reason">操作原因(必填)</label><textarea className="field-input" id="admin-reason" onChange={(e) => setReason(e.target.value)} placeholder="例如:运营调整 / 违规禁用" rows={2} value={reason} /></div>
|
<div><label className="field-label" htmlFor="admin-reason">操作原因(必填)</label><textarea className="field-input" id="admin-reason" onChange={(e) => setReason(e.target.value)} placeholder="例如:运营调整 / 违规禁用" rows={2} value={reason} /></div>
|
||||||
{message ? <p className="rounded-lg border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning" role="status">{message}</p> : null}
|
{message ? <p className="rounded-lg border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning" role="status">{message}</p> : null}
|
||||||
@ -266,6 +305,7 @@ function UserDetailPanel({ detail, onRefresh, setNotice, onClose }: { detail: Ad
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input aria-label="额度调整量" className="field-input w-24" onChange={(e) => setAmount(e.target.value)} value={amount} />
|
<input aria-label="额度调整量" className="field-input w-24" onChange={(e) => setAmount(e.target.value)} value={amount} />
|
||||||
<button className="button-secondary disabled:opacity-50" disabled={busy || !csrf} onClick={() => void doAdjust()} type="button">调整额度</button>
|
<button className="button-secondary disabled:opacity-50" disabled={busy || !csrf} onClick={() => void doAdjust()} type="button">调整额度</button>
|
||||||
|
<button className="button-ghost text-danger disabled:opacity-50" disabled={busy || !csrf} onClick={() => void removeUser()} type="button">删除用户</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-subtle">调整额度不能使可用额度低于已用;同一操作不可重复提交。</p>
|
<p className="text-xs text-subtle">调整额度不能使可用额度低于已用;同一操作不可重复提交。</p>
|
||||||
@ -273,3 +313,22 @@ function UserDetailPanel({ detail, onRefresh, setNotice, onClose }: { detail: Ad
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CreateUserPanel({ onCreated }: { onCreated: () => void }) {
|
||||||
|
const [csrf, setCsrf] = useState("");
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
useEffect(() => { void fetchCsrf().then(setCsrf); }, []);
|
||||||
|
|
||||||
|
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
const data = new FormData(event.currentTarget);
|
||||||
|
setBusy(true); setMessage("");
|
||||||
|
try {
|
||||||
|
await createUser({ username: String(data.get("username") ?? ""), email: String(data.get("email") ?? "").trim() || null, phone: String(data.get("phone") ?? "").trim() || null, password: String(data.get("password") ?? ""), email_verified: data.get("email_verified") === "on", phone_verified: data.get("phone_verified") === "on", reason: String(data.get("reason") ?? "") }, csrf);
|
||||||
|
onCreated();
|
||||||
|
} catch (e) { setMessage(e instanceof Error ? e.message : "创建失败"); } finally { setBusy(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return <form className="rounded-2xl border border-line bg-panel/50 p-5" onSubmit={submit}><p className="font-semibold">手动添加用户</p><div className="mt-4 grid gap-4 md:grid-cols-3"><input className="field-input" name="username" placeholder="用户名" required /><input className="field-input" name="email" placeholder="邮箱(可选)" type="email" /><input className="field-input" name="phone" placeholder="手机号(可选)" /><input className="field-input" minLength={8} name="password" placeholder="初始密码" required type="password" /><input className="field-input" name="reason" placeholder="创建原因(必填)" required /></div><div className="mt-4 flex flex-wrap gap-5 text-sm"><label className="flex items-center gap-2"><input name="email_verified" type="checkbox" />邮箱已验证</label><label className="flex items-center gap-2"><input name="phone_verified" type="checkbox" />手机已验证</label></div>{message ? <p className="mt-3 text-sm text-danger" role="status">{message}</p> : null}<button className="button-secondary mt-4 disabled:opacity-50" disabled={busy || !csrf} type="submit">创建用户</button></form>;
|
||||||
|
}
|
||||||
|
|||||||
15
lib/admin.ts
15
lib/admin.ts
@ -4,7 +4,8 @@ const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL ?? "/api/v1";
|
|||||||
|
|
||||||
export type AdminUser = {
|
export type AdminUser = {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
username: string;
|
||||||
|
email: string | null;
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
role: "user" | "admin";
|
role: "user" | "admin";
|
||||||
plan: string;
|
plan: string;
|
||||||
@ -69,6 +70,18 @@ export async function getUserDetail(id: string): Promise<AdminUserDetail> {
|
|||||||
return request(`/admin/users/${id}`);
|
return request(`/admin/users/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createUser(payload: { username: string; email: string | null; phone: string | null; password: string; email_verified: boolean; phone_verified: boolean; reason: string }, csrf: string) {
|
||||||
|
return request(`/admin/users`, { method: "POST", headers: { "X-CSRF-Token": csrf }, body: JSON.stringify(payload) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUserProfile(id: string, payload: { username: string; email: string | null; phone: string | null; email_verified: boolean; phone_verified: boolean; reason: string }, csrf: string) {
|
||||||
|
return request(`/admin/users/${id}`, { method: "PATCH", headers: { "X-CSRF-Token": csrf }, body: JSON.stringify(payload) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUser(id: string, reason: string, csrf: string) {
|
||||||
|
return request(`/admin/users/${id}`, { method: "DELETE", headers: { "X-CSRF-Token": csrf }, body: JSON.stringify({ reason }) });
|
||||||
|
}
|
||||||
|
|
||||||
export async function changeUserStatus(id: string, status: "active" | "disabled", reason: string, csrf: string): Promise<{ id: string; status: string; sessions_revoked: number }> {
|
export async function changeUserStatus(id: string, status: "active" | "disabled", reason: string, csrf: string): Promise<{ id: string; status: string; sessions_revoked: number }> {
|
||||||
return request(`/admin/users/${id}/status`, { method: "PATCH", headers: { "X-CSRF-Token": csrf }, body: JSON.stringify({ status, reason }) });
|
return request(`/admin/users/${id}/status`, { method: "PATCH", headers: { "X-CSRF-Token": csrf }, body: JSON.stringify({ status, reason }) });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,6 +22,8 @@ from psycopg.types.json import Json
|
|||||||
from .config import settings
|
from .config import settings
|
||||||
from .db import get_connection
|
from .db import get_connection
|
||||||
from .schemas import (
|
from .schemas import (
|
||||||
|
AdminUserCreateRequest,
|
||||||
|
AdminUserUpdateRequest,
|
||||||
AuthResponse,
|
AuthResponse,
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
MembershipRequest,
|
MembershipRequest,
|
||||||
@ -38,6 +40,7 @@ from .schemas import (
|
|||||||
TtsTaskRequest,
|
TtsTaskRequest,
|
||||||
TtsTaskPublic,
|
TtsTaskPublic,
|
||||||
TtsVoicePublic,
|
TtsVoicePublic,
|
||||||
|
UserProfileUpdateRequest,
|
||||||
normalize_email,
|
normalize_email,
|
||||||
)
|
)
|
||||||
from .security import (
|
from .security import (
|
||||||
@ -490,6 +493,29 @@ def change_password(payload: PasswordChangeRequest, request: Request, connection
|
|||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/api/v1/auth/profile", dependencies=[Depends(require_csrf)])
|
||||||
|
def update_profile(payload: UserProfileUpdateRequest, connection: Connection = Depends(get_connection), user: dict = Depends(current_user)):
|
||||||
|
email = normalize_email(str(payload.email)) if payload.email else None
|
||||||
|
phone = payload.phone
|
||||||
|
username = payload.username.strip().lower()
|
||||||
|
try:
|
||||||
|
updated = connection.execute(
|
||||||
|
"""
|
||||||
|
UPDATE users SET username = %s, email = %s, phone = %s,
|
||||||
|
email_verified = CASE WHEN email IS DISTINCT FROM %s THEN false ELSE email_verified END,
|
||||||
|
phone_verified = CASE WHEN phone IS DISTINCT FROM %s THEN false ELSE phone_verified END,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = %s RETURNING *
|
||||||
|
""",
|
||||||
|
(username, email, phone, email, phone, user["id"]),
|
||||||
|
).fetchone()
|
||||||
|
connection.commit()
|
||||||
|
except psycopg.errors.UniqueViolation:
|
||||||
|
connection.rollback()
|
||||||
|
raise error("PROFILE_UPDATE_CONFLICT", "用户名、邮箱或手机号已被占用", 409)
|
||||||
|
return user_public(connection, updated)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/account/usage")
|
@app.get("/api/v1/account/usage")
|
||||||
def account_usage(user: dict = Depends(current_user), connection: Connection = Depends(get_connection)):
|
def account_usage(user: dict = Depends(current_user), connection: Connection = Depends(get_connection)):
|
||||||
quota = ensure_quota(connection, user["id"], effective_plan(connection, user))
|
quota = ensure_quota(connection, user["id"], effective_plan(connection, user))
|
||||||
@ -677,7 +703,7 @@ def download_tts_audio(task_id: UUID, user: dict = Depends(current_user), connec
|
|||||||
return audio_response(task_id, user, connection, True)
|
return audio_response(task_id, user, connection, True)
|
||||||
|
|
||||||
|
|
||||||
USER_COLUMNS = "id, email, phone, role, plan, status, email_verified, phone_verified, created_at, last_login_at, updated_at"
|
USER_COLUMNS = "id, username, email, phone, role, plan, status, email_verified, phone_verified, created_at, last_login_at, updated_at"
|
||||||
|
|
||||||
|
|
||||||
def quota_view_for(quota: dict) -> dict[str, Any]:
|
def quota_view_for(quota: dict) -> dict[str, Any]:
|
||||||
@ -757,6 +783,60 @@ def admin_user_detail(user_id: UUID, user: dict = Depends(require_admin), connec
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/v1/admin/users", dependencies=[Depends(require_csrf)])
|
||||||
|
def admin_create_user(payload: AdminUserCreateRequest, connection: Connection = Depends(get_connection), actor: dict = Depends(require_admin)):
|
||||||
|
email = normalize_email(str(payload.email)) if payload.email else None
|
||||||
|
phone = payload.phone
|
||||||
|
username = payload.username.strip().lower()
|
||||||
|
try:
|
||||||
|
created = connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO users(username, email, phone, password_hash, email_verified, phone_verified)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s)
|
||||||
|
RETURNING id, username, email, phone, role, plan, status, email_verified, phone_verified, created_at, last_login_at, updated_at
|
||||||
|
""",
|
||||||
|
(username, email, phone, hash_password(payload.password), payload.email_verified, payload.phone_verified),
|
||||||
|
).fetchone()
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO admin_audit_logs(actor_id, action, target_type, target_id, before_value, after_value, reason) VALUES (%s, 'user_create', 'user', %s, %s, %s, %s)",
|
||||||
|
(actor["id"], created["id"], Json({}), Json({"username": username, "email": email, "phone": phone, "email_verified": payload.email_verified, "phone_verified": payload.phone_verified}), payload.reason),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
except psycopg.errors.UniqueViolation:
|
||||||
|
connection.rollback()
|
||||||
|
raise error("USER_CREATE_CONFLICT", "用户名、邮箱或手机号已被占用", 409)
|
||||||
|
return dict(created)
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/api/v1/admin/users/{user_id}", dependencies=[Depends(require_csrf)])
|
||||||
|
def admin_update_user(user_id: UUID, payload: AdminUserUpdateRequest, connection: Connection = Depends(get_connection), actor: dict = Depends(require_admin)):
|
||||||
|
target = connection.execute("SELECT * FROM users WHERE id = %s", (user_id,)).fetchone()
|
||||||
|
if not target:
|
||||||
|
raise error("NOT_FOUND", "用户不存在", 404)
|
||||||
|
email = normalize_email(str(payload.email)) if payload.email else None
|
||||||
|
phone = payload.phone
|
||||||
|
username = payload.username.strip().lower()
|
||||||
|
try:
|
||||||
|
updated = connection.execute(
|
||||||
|
"""
|
||||||
|
UPDATE users
|
||||||
|
SET username = %s, email = %s, phone = %s, email_verified = %s, phone_verified = %s, updated_at = now()
|
||||||
|
WHERE id = %s
|
||||||
|
RETURNING id, username, email, phone, role, plan, status, email_verified, phone_verified, created_at, last_login_at, updated_at
|
||||||
|
""",
|
||||||
|
(username, email, phone, payload.email_verified, payload.phone_verified, user_id),
|
||||||
|
).fetchone()
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO admin_audit_logs(actor_id, action, target_type, target_id, before_value, after_value, reason) VALUES (%s, 'user_update', 'user', %s, %s, %s, %s)",
|
||||||
|
(actor["id"], user_id, Json({"username": target["username"], "email": target["email"], "phone": target["phone"], "email_verified": target["email_verified"], "phone_verified": target["phone_verified"]}), Json({"username": username, "email": email, "phone": phone, "email_verified": payload.email_verified, "phone_verified": payload.phone_verified}), payload.reason),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
except psycopg.errors.UniqueViolation:
|
||||||
|
connection.rollback()
|
||||||
|
raise error("USER_UPDATE_CONFLICT", "用户名、邮箱或手机号已被占用", 409)
|
||||||
|
return dict(updated)
|
||||||
|
|
||||||
|
|
||||||
@app.patch("/api/v1/admin/users/{user_id}/status", dependencies=[Depends(require_csrf)])
|
@app.patch("/api/v1/admin/users/{user_id}/status", dependencies=[Depends(require_csrf)])
|
||||||
def admin_status(user_id: UUID, payload: StatusRequest, connection: Connection = Depends(get_connection), actor: dict = Depends(require_admin)):
|
def admin_status(user_id: UUID, payload: StatusRequest, connection: Connection = Depends(get_connection), actor: dict = Depends(require_admin)):
|
||||||
target = connection.execute("SELECT * FROM users WHERE id = %s", (user_id,)).fetchone()
|
target = connection.execute("SELECT * FROM users WHERE id = %s", (user_id,)).fetchone()
|
||||||
@ -777,6 +857,23 @@ def admin_status(user_id: UUID, payload: StatusRequest, connection: Connection =
|
|||||||
return {"id": updated["id"], "status": updated["status"], "sessions_revoked": revoked_count}
|
return {"id": updated["id"], "status": updated["status"], "sessions_revoked": revoked_count}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/v1/admin/users/{user_id}", dependencies=[Depends(require_csrf)])
|
||||||
|
def admin_delete_user(user_id: UUID, payload: MembershipRevokeRequest, connection: Connection = Depends(get_connection), actor: dict = Depends(require_admin)):
|
||||||
|
target = connection.execute("SELECT * FROM users WHERE id = %s", (user_id,)).fetchone()
|
||||||
|
if not target:
|
||||||
|
raise error("NOT_FOUND", "用户不存在", 404)
|
||||||
|
if target["id"] == actor["id"]:
|
||||||
|
raise error("SELF_DELETE_FORBIDDEN", "不能删除当前登录的管理员账号", 409)
|
||||||
|
if target["role"] == "admin":
|
||||||
|
count = connection.execute("SELECT count(*) FROM users WHERE role = 'admin' AND status = 'active'").fetchone()["count"]
|
||||||
|
if count <= 1 and target["status"] == "active":
|
||||||
|
raise error("LAST_ADMIN_PROTECTED", "不能删除最后一个可用管理员", 409)
|
||||||
|
connection.execute("INSERT INTO admin_audit_logs(actor_id, action, target_type, target_id, before_value, after_value, reason) VALUES (%s, 'user_delete', 'user', %s, %s, %s, %s)", (actor["id"], user_id, Json({"username": target["username"], "email": target["email"], "phone": target["phone"], "role": target["role"]}), Json({"deleted": True}), payload.reason))
|
||||||
|
connection.execute("DELETE FROM users WHERE id = %s", (user_id,))
|
||||||
|
connection.commit()
|
||||||
|
return {"id": user_id, "status": "deleted"}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/admin/users/{user_id}/membership")
|
@app.get("/api/v1/admin/users/{user_id}/membership")
|
||||||
def admin_membership_view(user_id: UUID, user: dict = Depends(require_admin), connection: Connection = Depends(get_connection)):
|
def admin_membership_view(user_id: UUID, user: dict = Depends(require_admin), connection: Connection = Depends(get_connection)):
|
||||||
target = connection.execute("SELECT id, role, plan FROM users WHERE id = %s", (user_id,)).fetchone()
|
target = connection.execute("SELECT id, role, plan FROM users WHERE id = %s", (user_id,)).fetchone()
|
||||||
|
|||||||
@ -49,6 +49,85 @@ class PasswordChangeRequest(BaseModel):
|
|||||||
new_password: str = Field(min_length=8, max_length=128)
|
new_password: str = Field(min_length=8, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUserCreateRequest(BaseModel):
|
||||||
|
username: str = Field(min_length=3, max_length=32, pattern=r"^[A-Za-z0-9_\-]+$")
|
||||||
|
email: EmailStr | None = None
|
||||||
|
phone: str | None = Field(default=None, min_length=11, max_length=20)
|
||||||
|
password: str = Field(min_length=8, max_length=128)
|
||||||
|
email_verified: bool = False
|
||||||
|
phone_verified: bool = False
|
||||||
|
reason: str = Field(min_length=1, max_length=500)
|
||||||
|
|
||||||
|
@field_validator("phone")
|
||||||
|
@classmethod
|
||||||
|
def normalize_phone(cls, value: str | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
normalized = value.strip().replace(" ", "").replace("-", "")
|
||||||
|
if normalized.startswith("+86"):
|
||||||
|
normalized = normalized[3:]
|
||||||
|
if not normalized.isdigit() or len(normalized) != 11 or not normalized.startswith("1"):
|
||||||
|
raise ValueError("请输入有效的中国大陆手机号")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_contact(self) -> "AdminUserCreateRequest":
|
||||||
|
if not self.email and not self.phone:
|
||||||
|
raise ValueError("邮箱或手机号至少填写一项")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUserUpdateRequest(BaseModel):
|
||||||
|
username: str = Field(min_length=3, max_length=32, pattern=r"^[A-Za-z0-9_\-]+$")
|
||||||
|
email: EmailStr | None = None
|
||||||
|
phone: str | None = Field(default=None, min_length=11, max_length=20)
|
||||||
|
email_verified: bool = False
|
||||||
|
phone_verified: bool = False
|
||||||
|
reason: str = Field(min_length=1, max_length=500)
|
||||||
|
|
||||||
|
@field_validator("phone")
|
||||||
|
@classmethod
|
||||||
|
def normalize_phone(cls, value: str | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
normalized = value.strip().replace(" ", "").replace("-", "")
|
||||||
|
if normalized.startswith("+86"):
|
||||||
|
normalized = normalized[3:]
|
||||||
|
if not normalized.isdigit() or len(normalized) != 11 or not normalized.startswith("1"):
|
||||||
|
raise ValueError("请输入有效的中国大陆手机号")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_contact(self) -> "AdminUserUpdateRequest":
|
||||||
|
if not self.email and not self.phone:
|
||||||
|
raise ValueError("邮箱或手机号至少填写一项")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfileUpdateRequest(BaseModel):
|
||||||
|
username: str = Field(min_length=3, max_length=32, pattern=r"^[A-Za-z0-9_\-]+$")
|
||||||
|
email: EmailStr | None = None
|
||||||
|
phone: str | None = Field(default=None, min_length=11, max_length=20)
|
||||||
|
|
||||||
|
@field_validator("phone")
|
||||||
|
@classmethod
|
||||||
|
def normalize_phone(cls, value: str | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
normalized = value.strip().replace(" ", "").replace("-", "")
|
||||||
|
if normalized.startswith("+86"):
|
||||||
|
normalized = normalized[3:]
|
||||||
|
if not normalized.isdigit() or len(normalized) != 11 or not normalized.startswith("1"):
|
||||||
|
raise ValueError("请输入有效的中国大陆手机号")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_contact(self) -> "UserProfileUpdateRequest":
|
||||||
|
if not self.email and not self.phone:
|
||||||
|
raise ValueError("邮箱或手机号至少填写一项")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class UserPublic(BaseModel):
|
class UserPublic(BaseModel):
|
||||||
id: UUID
|
id: UUID
|
||||||
username: str
|
username: str
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user