added ability to change user info

This commit is contained in:
onysd 2026-08-05 21:19:50 +03:00
parent cddd341bb2
commit 40d49d5e97
22 changed files with 799 additions and 26 deletions

View file

@ -230,6 +230,7 @@ export const api = {
stickerSetDocuments: (setID: string) => request<{ document_ids: string[] }>(`/api/stickers/${encodeURIComponent(setID)}/documents`),
stickerDocumentAnimationURL: (documentID: string) => `/api/stickers/documents/${encodeURIComponent(documentID)}/animation`,
createStickerSet: (form: FormData) => request<CommandResult>("/api/actions/create-sticker-set", { method: "POST", body: form }),
setAccountAvatar: (form: FormData) => request<CommandResult>("/api/actions/set-account-avatar", { method: "POST", body: form }),
addStickerToSet: (form: FormData) => request<CommandResult>("/api/actions/add-sticker-to-set", { method: "POST", body: form }),
defaultGifts: () => request<DefaultGiftListResponse>("/api/default-gifts"),
defaultGiftAnimation: (id: number) => request<Record<string, unknown>>(`/api/default-gifts/${id}/animation`),

View file

@ -0,0 +1,88 @@
import { ImagePlus, Loader2, Upload, X } from "lucide-react";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { api, errorMessage } from "../api";
import { Alert } from "./ui";
// AccountAvatarModal uploads a new profile photo for an account. It follows
// CreateStickerSetModal's shape (a single reason field + direct execute,
// rather than ActionButton's JSON dry-run/confirm flow) since the payload is
// a multipart file upload, not a plain JSON body.
export function AccountAvatarModal({ userID, onClose, onDone }: { userID: number; onClose: () => void; onDone: () => void }) {
const [file, setFile] = useState<File | null>(null);
const [previewURL, setPreviewURL] = useState("");
const [reason, setReason] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!file) {
setPreviewURL("");
return;
}
const url = URL.createObjectURL(file);
setPreviewURL(url);
return () => URL.revokeObjectURL(url);
}, [file]);
async function submit() {
if (!file) {
setError("Choose an image file first.");
return;
}
if (!reason.trim()) {
setError("Please enter an operation reason");
return;
}
setBusy(true);
setError("");
try {
const form = new FormData();
form.set("metadata", JSON.stringify({ command_id: "", reason: reason.trim(), confirm: true, user_id: userID }));
form.set("file", file, file.name);
const result = await api.setAccountAvatar(form);
if (result.error) {
setError(result.error);
return;
}
onDone();
onClose();
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
return createPortal(
<div className="modal-backdrop" role="presentation">
<section className="modal command-modal" role="dialog" aria-modal="true" aria-label={"Change avatar"}>
<div className="modal-head">
<div>
<div className="eyebrow">{"Account"}</div>
<h2>{"Change avatar"}</h2>
</div>
<button className="icon-btn" type="button" onClick={onClose} disabled={busy} aria-label={"Close"}><X size={15} /></button>
</div>
<div className="command-body">
<label className={`gift-file-picker ${file ? "has-file" : ""}`}>
<input type="file" accept="image/png,image/jpeg,image/webp" onChange={(event) => setFile(event.target.files?.[0] ?? null)} />
{previewURL ? <img className="gift-file-icon" src={previewURL} alt="" style={{ objectFit: "cover" }} /> : <ImagePlus size={22} />}
<span className="gift-file-copy"><span className="gift-field-label">{"New avatar"}</span><strong>{file ? file.name : "Choose a JPEG, PNG, or WebP image"}</strong></span>
<span className="gift-file-action">{file ? "Change file" : "Choose file"}</span>
</label>
<label className="gift-reason-field"><span>{"Audit reason"}</span><input value={reason} placeholder={"Briefly describe why this avatar is being changed"} onChange={(event) => setReason(event.target.value)} /></label>
{error && <Alert>{error}</Alert>}
</div>
<div className="modal-actions">
<button className="btn" type="button" onClick={onClose} disabled={busy}>{"Close"}</button>
<button className="btn primary" type="button" onClick={submit} disabled={busy}>
{busy ? <Loader2 className="spin" size={15} /> : <Upload size={15} />}
{"Upload avatar"}
</button>
</div>
</section>
</div>,
document.body
);
}

View file

@ -39,19 +39,24 @@ export function Avatar({
firstName,
lastName,
username = "",
size = 34
size = 34,
refreshKey
}: {
userID: number;
firstName: string;
lastName: string;
username?: string;
size?: number;
// refreshKey busts the browser's cached image (Cache-Control: max-age=300)
// right after an admin-driven avatar change, so the new photo shows up
// immediately instead of the stale cached one.
refreshKey?: number | string;
}) {
const [failed, setFailed] = useState(false);
useEffect(() => {
setFailed(false);
}, [userID]);
}, [userID, refreshKey]);
if (failed) {
const [from, to] = avatarGradient(userID);
@ -68,7 +73,7 @@ export function Avatar({
return (
<img
className="avatar-photo-img"
src={`/api/accounts/${userID}/avatar`}
src={`/api/accounts/${userID}/avatar${refreshKey !== undefined ? `?v=${encodeURIComponent(String(refreshKey))}` : ""}`}
alt=""
loading="lazy"
style={{ width: size, height: size }}

View file

@ -1,4 +1,4 @@
import { AtSign, LifeBuoy, Palette, Settings2, Smile } from "lucide-react";
import { AtSign, LifeBuoy, Mail, Palette, Phone, Settings2, Smile, UserRound } from "lucide-react";
import { useEffect, useState } from "react";
import { ActionButton } from "./ActionButton";
import { toInt } from "../lib/format";
@ -47,6 +47,92 @@ export function UsernameAction({ idKey, id, path, current, onDone }: {
);
}
// ProfileNameAction force-sets a user's first and last name.
export function ProfileNameAction({ id, path, currentFirstName, currentLastName, onDone }: {
id: number;
path: string;
currentFirstName: string;
currentLastName: string;
onDone: () => void;
}) {
const [firstName, setFirstName] = useState(currentFirstName);
const [lastName, setLastName] = useState(currentLastName);
return (
<div className="attr-block">
<label className="duration-field">
<span>{"First name"}</span>
<input value={firstName} onChange={(e) => setFirstName(e.target.value)} placeholder="First name" />
</label>
<label className="duration-field">
<span>{"Last name"}</span>
<input value={lastName} onChange={(e) => setLastName(e.target.value)} placeholder="Last name" />
</label>
<ActionButton
label={"Set name"}
icon={<UserRound size={15} />}
tone="neutral"
path={path}
payload={() => ({ user_id: id, first_name: firstName.trim(), last_name: lastName.trim() })}
onDone={onDone}
/>
</div>
);
}
// PhoneAction force-sets a user's phone number. The backend rejects a value
// already tied to another account.
export function PhoneAction({ id, path, current, onDone }: {
id: number;
path: string;
current: string;
onDone: () => void;
}) {
const [phone, setPhone] = useState(current);
return (
<div className="attr-block">
<label className="duration-field">
<span>{"Phone number"}</span>
<input value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="15551234567" />
</label>
<ActionButton
label={"Set phone"}
icon={<Phone size={15} />}
tone="warn"
path={path}
payload={() => ({ user_id: id, phone: phone.trim() })}
onDone={onDone}
/>
</div>
);
}
// LoginEmailAction sets, or (left empty) clears, the login/signup email
// factor. The backend rejects an email already tied to another account.
export function LoginEmailAction({ id, path, current, onDone }: {
id: number;
path: string;
current: string;
onDone: () => void;
}) {
const [email, setEmail] = useState(current);
return (
<div className="attr-block">
<label className="duration-field">
<span>{"Login email"}</span>
<input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="name@example.com (empty clears it)" type="email" />
</label>
<ActionButton
label={email.trim() ? "Set login email" : "Clear login email"}
icon={<Mail size={15} />}
tone="warn"
path={path}
payload={() => ({ user_id: id, email: email.trim() })}
onDone={onDone}
/>
</div>
);
}
// ColorAction sets or clears a name/profile color (Layer 228 peer color).
export function ColorAction({ idKey, id, path, onDone }: {
idKey: IDKey;

View file

@ -1,12 +1,13 @@
import { ArrowLeft, BadgeCheck, CircleAlert, MonitorSmartphone, ScrollText, Settings2, Sparkles, Star, UserRound } from "lucide-react";
import { ArrowLeft, BadgeCheck, CircleAlert, ImagePlus, MonitorSmartphone, ScrollText, Settings2, Sparkles, Star, UserRound } from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { api, errorMessage } from "../api";
import { AccountAvatarModal } from "../components/AccountAvatarModal";
import { ActionButton } from "../components/ActionButton";
import { Avatar } from "../components/Avatar";
import { AuthorizationTable } from "../components/AuthorizationTable";
import { Alert, AuditTable, Badge, LoadingSurface, PageFrame, SectionHead, Summary, UsernameCell } from "../components/ui";
import { ScamFakeActions, ScamFakeBadges } from "../components/flags";
import { ColorAction, EmojiStatusAction, SupportAction, UsernameAction } from "../components/attributes";
import { ColorAction, EmojiStatusAction, LoginEmailAction, PhoneAction, ProfileNameAction, SupportAction, UsernameAction } from "../components/attributes";
import { displayName, displayPhone, displayUsername, formatDate, formatUnix, toInt } from "../lib/format";
import type { Navigate } from "../routing";
import type { AccountDetail } from "../types";
@ -22,6 +23,8 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
const [starsAmount, setStarsAmount] = useState("1000");
const [freezeUntil, setFreezeUntil] = useState(() => toDateTimeLocal(new Date(Date.now() + 7 * 86400_000)));
const [freezeAppealURL, setFreezeAppealURL] = useState("");
const [avatarModalOpen, setAvatarModalOpen] = useState(false);
const [avatarVersion, setAvatarVersion] = useState(0);
async function load() {
setBusy(true);
@ -70,7 +73,18 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
>
<section className="entity-head">
<div className="entity-head-main">
<Avatar userID={account.ID} firstName={account.FirstName} lastName={account.LastName} username={account.Username} size={64} />
<div className="avatar-edit-slot">
<Avatar userID={account.ID} firstName={account.FirstName} lastName={account.LastName} username={account.Username} size={64} refreshKey={avatarVersion || undefined} />
<button
className="icon-btn avatar-edit-btn"
type="button"
aria-label={"Change avatar"}
title={"Change avatar"}
onClick={() => setAvatarModalOpen(true)}
>
<ImagePlus size={13} />
</button>
</div>
<div>
<div className="entity-title">{displayName(account)}</div>
<div className="entity-subtitle">{displayUsername(account.Username) || "No username"} · {displayPhone(account.Phone) || "No phone"}</div>
@ -257,6 +271,21 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
<UsernameAction idKey="user_id" id={account.ID} path="/api/actions/set-account-username" current={account.Username} onDone={load} />
</section>
<section className="section-block">
<SectionHead title={"Name"} />
<ProfileNameAction id={account.ID} path="/api/actions/set-account-profile" currentFirstName={account.FirstName} currentLastName={account.LastName} onDone={load} />
</section>
<section className="section-block">
<SectionHead title={"Phone Number"} />
<PhoneAction id={account.ID} path="/api/actions/set-account-phone" current={account.Phone} onDone={load} />
</section>
<section className="section-block">
<SectionHead title={"Login Email"} text={"The email used for sign-in / password-recovery, not a contact address."} />
<LoginEmailAction id={account.ID} path="/api/actions/set-account-login-email" current={account.LoginEmail} onDone={load} />
</section>
<section className="section-block">
<SectionHead title={"Profile Color"} />
<ColorAction idKey="user_id" id={account.ID} path="/api/actions/set-account-color" onDone={load} />
@ -274,6 +303,17 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
</section>
</div>
)}
{avatarModalOpen && (
<AccountAvatarModal
userID={account.ID}
onClose={() => setAvatarModalOpen(false)}
onDone={() => {
setAvatarVersion((v) => v + 1);
void load();
}}
/>
)}
</PageFrame>
);
}

View file

@ -33,6 +33,25 @@
flex-shrink: 0;
}
.avatar-edit-slot {
position: relative;
flex-shrink: 0;
}
.avatar-edit-btn {
position: absolute;
right: -4px;
bottom: -4px;
width: 24px;
height: 24px;
padding: 0;
color: var(--brand);
background: var(--panel);
border: 1px solid var(--line-strong);
border-radius: 999px;
box-shadow: 0 1px 3px rgba(0, 0, 0, .2);
}
.avatar-edit-btn:hover { background: var(--brand-tint); border-color: var(--brand); }
.entity-title {
color: var(--heading);
font-size: 20px;