feat(admin): edit any user's full profile (PATCH/POST /v1/users/{id} admin + UI modal)
Build backend images / build content-svc (push) Failing after 1m47s
Build backend images / build file-svc (push) Failing after 5m54s
Build backend images / build gateway (push) Failing after 2m8s
Build backend images / build identity-svc (push) Failing after 3m32s
Build backend images / build notification-svc (push) Failing after 12s
Build backend images / build render-svc (push) Failing after 10m27s
Build backend images / build studio-svc (push) Failing after 10s

Identity: admin-only PATCH /v1/users/{id} (reuses UpdateMeAsync) + POST {id}/avatar.
Admin Users panel: «پروفایل» modal to view/edit name/slogan/about/company/website/
country/national-code/birthdate/gender/avatar for any user. Verified admin→other-user edit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-06 22:36:23 +03:30
parent 6ee211fb35
commit ad8796a25d
3 changed files with 134 additions and 0 deletions
@@ -49,6 +49,21 @@ public class UsersController(IUserService userService) : ControllerBase
[FromQuery] int pageSize = 20)
=> Ok(await userService.SearchAsync(q, tenantId, page, pageSize));
[HttpPatch("{userId:guid}")]
[Authorize(Roles = "Admin")]
[ProducesResponseType(typeof(UserResponse), 200)]
[ProducesResponseType(404)]
public async Task<IActionResult> UpdateUser(Guid userId, [FromBody] UpdateUserRequest request)
=> Ok(await userService.UpdateMeAsync(userId, request));
[HttpPost("{userId:guid}/avatar")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> SetUserAvatar(Guid userId, [FromBody] SetAvatarRequest request)
{
await userService.UpdateAvatarAsync(userId, request.AvatarId, request.AvatarUrl);
return Ok();
}
[HttpPost("{userId:guid}/ban")]
[Authorize(Roles = "Admin")]
[ProducesResponseType(204)]
+117
View File
@@ -0,0 +1,117 @@
"use client";
import { useState } from "react";
const inp = "w-full rounded-lg border border-[#262b40] bg-[#0c0e1a] px-3 py-2 text-sm text-gray-100 outline-none focus:border-indigo-500";
const lbl = "mb-1 block text-xs font-medium text-gray-400";
type Profile = {
full_name: string; slogan: string; about_me: string; company_name: string;
website_name: string; country_code: string; national_code: string;
birth_date: string; gender: string; avatar_url: string;
};
const EMPTY: Profile = {
full_name: "", slogan: "", about_me: "", company_name: "", website_name: "",
country_code: "", national_code: "", birth_date: "", gender: "", avatar_url: "",
};
const GENDERS = ["Male", "Female", "Other", "PreferNotToSay"] as const;
/** Admin: edit any user's full profile (name, avatar, bio, company, …). */
export function UserProfileEdit({ row }: { row: Record<string, unknown>; reload?: () => void }) {
const id = String(row.id);
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [f, setF] = useState<Profile>(EMPTY);
const set = (k: keyof Profile, v: string) => setF((p) => ({ ...p, [k]: v }));
const load = async () => {
setOpen(true); setMsg(null);
const r = await fetch(`/api/admin/resource/users/${id}`, { cache: "no-store" }).then((x) => x.json()).catch(() => null);
if (r) {
const s = (v: unknown) => (typeof v === "string" ? v : "");
setF({
full_name: s(r.full_name), slogan: s(r.slogan), about_me: s(r.about_me),
company_name: s(r.company_name), website_name: s(r.website_name),
country_code: s(r.country_code), national_code: s(r.national_code),
birth_date: s(r.birth_date), gender: s(r.gender), avatar_url: s(r.avatar_url),
});
}
};
const save = async () => {
setBusy(true); setMsg(null);
try {
const res = await fetch(`/api/admin/resource/users/${id}`, {
method: "PATCH", headers: { "Content-Type": "application/json" },
body: JSON.stringify({
full_name: f.full_name, slogan: f.slogan, about_me: f.about_me,
company_name: f.company_name, website_name: f.website_name,
country_code: f.country_code, national_code: f.national_code,
birth_date: f.birth_date || null, gender: f.gender || null,
}),
});
if (res.ok && f.avatar_url.trim()) {
await fetch(`/api/admin/resource/users/${id}/avatar`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ avatar_url: f.avatar_url.trim() }),
});
}
const d = await res.json().catch(() => null);
setMsg(res.ok ? "ذخیره شد ✓" : (d?.error?.message ?? d?.error ?? "خطا"));
} catch { setMsg("خطای شبکه"); }
finally { setBusy(false); }
};
return (
<>
<button
className="rounded-lg border border-[#262b40] px-2.5 py-1 text-xs text-gray-300 hover:bg-[#161a2e]"
onClick={() => void load()}
>
پروفایل
</button>
{open && (
<div className="fixed inset-0 z-[60] flex items-stretch justify-center bg-black/70 p-2 sm:p-6" dir="rtl" onClick={() => setOpen(false)}>
<div className="flex max-h-full w-full max-w-lg flex-col rounded-xl border border-[#1e2235] bg-[#0f1120]" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between border-b border-[#1e2235] px-5 py-3">
<h2 className="text-sm font-semibold text-white">ویرایش پروفایل کاربر</h2>
<button className="rounded-lg px-2 py-1 text-gray-400 hover:bg-[#161a2e] hover:text-white" onClick={() => setOpen(false)}></button>
</div>
<div className="flex-1 space-y-3 overflow-y-auto p-5">
<p className="text-xs text-gray-500">{String(row.email ?? "")}</p>
<div className="grid grid-cols-2 gap-3">
<div><label className={lbl}>نام</label><input className={inp} value={f.full_name} onChange={(e) => set("full_name", e.target.value)} /></div>
<div><label className={lbl}>شعار</label><input className={inp} value={f.slogan} onChange={(e) => set("slogan", e.target.value)} /></div>
</div>
<div><label className={lbl}>درباره</label><textarea className={inp} rows={2} value={f.about_me} onChange={(e) => set("about_me", e.target.value)} /></div>
<div className="grid grid-cols-2 gap-3">
<div><label className={lbl}>شرکت</label><input className={inp} value={f.company_name} onChange={(e) => set("company_name", e.target.value)} /></div>
<div><label className={lbl}>وبسایت</label><input className={inp} value={f.website_name} onChange={(e) => set("website_name", e.target.value)} /></div>
<div><label className={lbl}>کشور</label><input className={inp} value={f.country_code} onChange={(e) => set("country_code", e.target.value)} /></div>
<div><label className={lbl}>کد ملی</label><input className={inp} value={f.national_code} onChange={(e) => set("national_code", e.target.value)} /></div>
<div><label className={lbl}>تاریخ تولد</label><input type="date" className={inp} value={f.birth_date} onChange={(e) => set("birth_date", e.target.value)} /></div>
<div>
<label className={lbl}>جنسیت</label>
<select className={inp} value={f.gender} onChange={(e) => set("gender", e.target.value)}>
<option value=""></option>
{GENDERS.map((g) => <option key={g} value={g}>{g}</option>)}
</select>
</div>
</div>
<div><label className={lbl}>آدرس تصویر (URL)</label><input className={inp} value={f.avatar_url} onChange={(e) => set("avatar_url", e.target.value)} placeholder="https://…" /></div>
{msg && <p className="text-sm text-indigo-300">{msg}</p>}
</div>
<div className="border-t border-[#1e2235] px-5 py-3 text-left">
<button className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 disabled:opacity-50" disabled={busy} onClick={() => void save()}>
{busy ? "در حال ذخیره…" : "ذخیره"}
</button>
</div>
</div>
</div>
)}
</>
);
}
+2
View File
@@ -2,6 +2,7 @@
import type { ResourceConfig } from "@/components/admin/AdminResource";
import { UserActions } from "@/components/admin/UserActions";
import { UserProfileEdit } from "@/components/admin/UserProfileEdit";
const badge = (ok: boolean, yes: string, no: string) =>
ok ? (
@@ -280,6 +281,7 @@ export const usersConfig: ResourceConfig = {
],
rowActions: (row, reload) => (
<>
<UserProfileEdit row={row} reload={reload} />
<UserActions row={row} reload={reload} />
{banAction(row, reload)}
</>