added ability to change user info
This commit is contained in:
parent
cddd341bb2
commit
40d49d5e97
22 changed files with 799 additions and 26 deletions
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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue