added ability to change user info
This commit is contained in:
parent
cddd341bb2
commit
40d49d5e97
22 changed files with 799 additions and 26 deletions
|
|
@ -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`),
|
||||
|
|
|
|||
88
cmd/telesrv-admin/web/src/components/AccountAvatarModal.tsx
Normal file
88
cmd/telesrv-admin/web/src/components/AccountAvatarModal.tsx
Normal 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
|
||||
);
|
||||
}
|
||||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue