added ability to change user info
This commit is contained in:
parent
cddd341bb2
commit
40d49d5e97
22 changed files with 799 additions and 26 deletions
|
|
@ -94,6 +94,10 @@ func (s *server) routes() http.Handler {
|
|||
mux.Handle("POST /api/actions/set-channel-flags", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelFlagsAPI)))
|
||||
mux.Handle("POST /api/actions/set-support", s.requireAuthAPI(http.HandlerFunc(s.handleSetSupportAPI)))
|
||||
mux.Handle("POST /api/actions/set-account-username", s.requireAuthAPI(http.HandlerFunc(s.handleSetUsernameAPI)))
|
||||
mux.Handle("POST /api/actions/set-account-profile", s.requireAuthAPI(http.HandlerFunc(s.handleSetProfileAPI)))
|
||||
mux.Handle("POST /api/actions/set-account-phone", s.requireAuthAPI(http.HandlerFunc(s.handleSetPhoneAPI)))
|
||||
mux.Handle("POST /api/actions/set-account-avatar", s.requireAuthAPI(http.HandlerFunc(s.handleSetAccountAvatarAPI)))
|
||||
mux.Handle("POST /api/actions/set-account-login-email", s.requireAuthAPI(http.HandlerFunc(s.handleSetLoginEmailAPI)))
|
||||
mux.Handle("POST /api/actions/set-account-color", s.requireAuthAPI(http.HandlerFunc(s.handleSetUserColorAPI)))
|
||||
mux.Handle("POST /api/actions/set-account-emoji-status", s.requireAuthAPI(http.HandlerFunc(s.handleSetUserEmojiStatusAPI)))
|
||||
mux.Handle("POST /api/actions/set-channel-settings", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelSettingsAPI)))
|
||||
|
|
@ -1153,6 +1157,118 @@ func (s *server) handleSetUsernameAPI(w http.ResponseWriter, r *http.Request) {
|
|||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type setProfileAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
UserID int64 `json:"user_id"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
}
|
||||
|
||||
func (s *server) handleSetProfileAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body setProfileAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.SetProfileRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-profile"),
|
||||
UserID: body.UserID,
|
||||
FirstName: body.FirstName,
|
||||
LastName: body.LastName,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/accounts/set-profile", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type setPhoneAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Phone string `json:"phone"`
|
||||
}
|
||||
|
||||
func (s *server) handleSetPhoneAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body setPhoneAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.SetPhoneRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-phone"),
|
||||
UserID: body.UserID,
|
||||
Phone: body.Phone,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/accounts/set-phone", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type setLoginEmailAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
func (s *server) handleSetLoginEmailAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body setLoginEmailAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.SetLoginEmailRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-login-email"),
|
||||
UserID: body.UserID,
|
||||
Email: body.Email,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/accounts/set-login-email", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type setAccountAvatarAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
UserID int64 `json:"user_id"`
|
||||
}
|
||||
|
||||
func (s *server) handleSetAccountAvatarAPI(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
r.Body = http.MaxBytesReader(w, r.Body, admin.MaxAccountAvatarBytes+(1<<20))
|
||||
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error())
|
||||
return
|
||||
}
|
||||
if r.MultipartForm != nil {
|
||||
defer r.MultipartForm.RemoveAll()
|
||||
}
|
||||
var body setAccountAvatarAPIRequest
|
||||
dec := json.NewDecoder(strings.NewReader(r.FormValue("metadata")))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&body); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid metadata: "+err.Error())
|
||||
return
|
||||
}
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "avatar file is required")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(file, admin.MaxAccountAvatarBytes+1))
|
||||
if err != nil || len(data) == 0 || int64(len(data)) > admin.MaxAccountAvatarBytes {
|
||||
writeAPIError(w, http.StatusBadRequest, "avatar file is empty or too large")
|
||||
return
|
||||
}
|
||||
req := admin.SetAccountAvatarRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-avatar"),
|
||||
UserID: body.UserID,
|
||||
FileName: header.Filename,
|
||||
}
|
||||
result, err := s.callAdminMultipart(r.Context(), "/v1/accounts/set-avatar", req, header.Filename, data)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type setUserColorAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
|
|
|
|||
1
cmd/telesrv-admin/web/dist/assets/index-BpSP1ojC.css
vendored
Normal file
1
cmd/telesrv-admin/web/dist/assets/index-BpSP1ojC.css
vendored
Normal file
File diff suppressed because one or more lines are too long
10
cmd/telesrv-admin/web/dist/assets/index-CKkpOZxZ.js
vendored
Normal file
10
cmd/telesrv-admin/web/dist/assets/index-CKkpOZxZ.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
cmd/telesrv-admin/web/dist/index.html
vendored
4
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -23,8 +23,8 @@
|
|||
})();
|
||||
</script>
|
||||
|
||||
<script type="module" crossorigin src="/assets/index-DAm5-kIE.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D5eZOKdH.css">
|
||||
<script type="module" crossorigin src="/assets/index-CKkpOZxZ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BpSP1ojC.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1413,6 +1413,7 @@ func run(logger *zap.Logger) error {
|
|||
Rating: ratingService,
|
||||
Verification: verificationService,
|
||||
BotVerification: botVerificationService,
|
||||
Account: accountService,
|
||||
})
|
||||
// The RPC edge owns the tg.* projection cache and the standard non-PTS
|
||||
// updateUser/updateChannel refresh, so committed registry mutations are
|
||||
|
|
|
|||
|
|
@ -33,6 +33,10 @@ const (
|
|||
ActionSetUsername = "account.set_username"
|
||||
ActionSetUserColor = "account.set_color"
|
||||
ActionSetUserEmojiStatus = "account.set_emoji_status"
|
||||
ActionSetProfile = "account.set_profile"
|
||||
ActionSetPhone = "account.set_phone"
|
||||
ActionSetLoginEmail = "account.set_login_email"
|
||||
ActionSetAccountAvatar = "account.set_avatar"
|
||||
ActionSetChannelUsername = "channel.set_username"
|
||||
ActionSetChannelSettings = "channel.set_settings"
|
||||
ActionSetChannelColor = "channel.set_color"
|
||||
|
|
@ -209,6 +213,18 @@ type UsersService interface {
|
|||
UpdateUsername(ctx context.Context, userID int64, username string) (domain.User, error)
|
||||
UpdateColor(ctx context.Context, userID int64, forProfile bool, color domain.PeerColor) (domain.User, error)
|
||||
UpdateEmojiStatus(ctx context.Context, userID int64, status domain.UserEmojiStatus) (domain.User, error)
|
||||
UpdateProfile(ctx context.Context, userID int64, update domain.UserProfileUpdate) (domain.User, error)
|
||||
// SetPhone force-sets a user's phone number (no code verification).
|
||||
SetPhone(ctx context.Context, userID int64, phone string) (domain.User, error)
|
||||
}
|
||||
|
||||
// AccountService carries the login-email factor (account_passwords table),
|
||||
// a separate concern from UsersService's users-table fields.
|
||||
type AccountService interface {
|
||||
// SetLoginEmail force-sets a user's login/signup email, no OTP required.
|
||||
SetLoginEmail(ctx context.Context, userID int64, email string) error
|
||||
// ClearLoginEmail removes the login email factor entirely.
|
||||
ClearLoginEmail(ctx context.Context, userID int64) error
|
||||
}
|
||||
|
||||
type StarsService interface {
|
||||
|
|
@ -281,6 +297,11 @@ type OfficialGiftsSource interface {
|
|||
type AvatarResolver interface {
|
||||
CurrentProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind) (domain.Photo, bool, error)
|
||||
GetFile(ctx context.Context, req domain.FileDownloadRequest) (domain.FileChunk, bool, error)
|
||||
// ValidateAvatarUpload is a pure check (no store writes), used by a dry-run
|
||||
// preview before CreateAvatarFromBytes actually materializes the avatar.
|
||||
ValidateAvatarUpload(data []byte) bool
|
||||
CreateAvatarFromBytes(ctx context.Context, data []byte, ownerUserID int64) (domain.Photo, error)
|
||||
SetCurrentProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, photoID int64, date int) (domain.Photo, bool, error)
|
||||
}
|
||||
|
||||
// StickerSetsService is the admin-console management surface over sticker/custom-emoji
|
||||
|
|
@ -393,6 +414,9 @@ type Dependencies struct {
|
|||
// BotVerification is the third-party mechanism, wired separately from
|
||||
// Verification: the two never read each other's state.
|
||||
BotVerification BotVerificationService
|
||||
// Account carries the login-email factor -- a separate app service from
|
||||
// Users, since login email lives in account_passwords, not users.
|
||||
Account AccountService
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
|
|
@ -422,6 +446,7 @@ type Service struct {
|
|||
rating AccountRatingService
|
||||
verification VerificationService
|
||||
botVerification BotVerificationService
|
||||
account AccountService
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
|
|
@ -506,6 +531,9 @@ func (s *Service) Configure(deps Dependencies) *Service {
|
|||
if deps.BotVerification != nil {
|
||||
s.botVerification = deps.BotVerification
|
||||
}
|
||||
if deps.Account != nil {
|
||||
s.account = deps.Account
|
||||
}
|
||||
if deps.Now != nil {
|
||||
s.now = deps.Now
|
||||
}
|
||||
|
|
@ -889,6 +917,37 @@ type SetUsernameRequest struct {
|
|||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
// SetProfileRequest updates first/last name. Both are always sent (not
|
||||
// pointer/omitempty): the admin form always shows and submits both fields
|
||||
// together, so there is no "leave unset" case to represent here.
|
||||
type SetProfileRequest struct {
|
||||
CommandMeta
|
||||
UserID int64 `json:"user_id"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
}
|
||||
|
||||
type SetPhoneRequest struct {
|
||||
CommandMeta
|
||||
UserID int64 `json:"user_id"`
|
||||
Phone string `json:"phone"`
|
||||
}
|
||||
|
||||
// SetLoginEmailRequest force-sets (or, if Email is empty, clears) a user's
|
||||
// login/signup email.
|
||||
type SetLoginEmailRequest struct {
|
||||
CommandMeta
|
||||
UserID int64 `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type SetAccountAvatarRequest struct {
|
||||
CommandMeta
|
||||
UserID int64 `json:"user_id"`
|
||||
FileName string `json:"file_name"`
|
||||
Data []byte `json:"-"`
|
||||
}
|
||||
|
||||
type SetChannelUsernameRequest struct {
|
||||
CommandMeta
|
||||
ChannelID int64 `json:"channel_id"`
|
||||
|
|
@ -1594,6 +1653,151 @@ func (s *Service) SetUsername(ctx context.Context, req SetUsernameRequest) (Comm
|
|||
})
|
||||
}
|
||||
|
||||
// SetProfile force-sets a user's first and last name.
|
||||
func (s *Service) SetProfile(ctx context.Context, req SetProfileRequest) (CommandResult, error) {
|
||||
if req.UserID <= 0 {
|
||||
return CommandResult{}, fmt.Errorf("user_id is required")
|
||||
}
|
||||
if s == nil || s.users == nil {
|
||||
return CommandResult{}, fmt.Errorf("admin user dependency is not configured")
|
||||
}
|
||||
firstName := strings.TrimSpace(req.FirstName)
|
||||
lastName := strings.TrimSpace(req.LastName)
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionSetProfile, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
u, found, err := s.users.AdminUser(ctx, req.UserID)
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
if !found {
|
||||
return CommandResult{}, domain.ErrUserNotFound
|
||||
}
|
||||
details := map[string]any{
|
||||
"previous_first_name": u.FirstName, "previous_last_name": u.LastName,
|
||||
"new_first_name": firstName, "new_last_name": lastName,
|
||||
}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "dry-run completed", Details: details}, nil
|
||||
}
|
||||
updated, err := s.users.UpdateProfile(ctx, req.UserID, domain.UserProfileUpdate{
|
||||
FirstName: firstName, HasFirstName: true,
|
||||
LastName: lastName, HasLastName: true,
|
||||
})
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
if err := s.notifyUserChanged(ctx, updated); err != nil {
|
||||
details["notify_error"] = err.Error()
|
||||
}
|
||||
return CommandResult{Message: "profile updated", Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// SetPhone force-sets a user's phone number. Rejects a collision with
|
||||
// another account's phone (checked by the users service before writing,
|
||||
// backed by the users_phone_unique_idx constraint as well).
|
||||
func (s *Service) SetPhone(ctx context.Context, req SetPhoneRequest) (CommandResult, error) {
|
||||
if req.UserID <= 0 {
|
||||
return CommandResult{}, fmt.Errorf("user_id is required")
|
||||
}
|
||||
if s == nil || s.users == nil {
|
||||
return CommandResult{}, fmt.Errorf("admin user dependency is not configured")
|
||||
}
|
||||
phone := strings.TrimSpace(req.Phone)
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionSetPhone, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
u, found, err := s.users.AdminUser(ctx, req.UserID)
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
if !found {
|
||||
return CommandResult{}, domain.ErrUserNotFound
|
||||
}
|
||||
details := map[string]any{"previous_phone": u.Phone, "new_phone": phone}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "dry-run completed", Details: details}, nil
|
||||
}
|
||||
updated, err := s.users.SetPhone(ctx, req.UserID, phone)
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
details["updated_phone"] = updated.Phone
|
||||
if err := s.notifyUserChanged(ctx, updated); err != nil {
|
||||
details["notify_error"] = err.Error()
|
||||
}
|
||||
return CommandResult{Message: "phone updated", Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// SetLoginEmail force-sets (or, if Email is empty, clears) a user's
|
||||
// login/signup email. Rejects a collision with another account's login
|
||||
// email (checked by the account service before writing, backed by the
|
||||
// account_passwords_login_email_lower_unique_idx constraint as well).
|
||||
func (s *Service) SetLoginEmail(ctx context.Context, req SetLoginEmailRequest) (CommandResult, error) {
|
||||
if req.UserID <= 0 {
|
||||
return CommandResult{}, fmt.Errorf("user_id is required")
|
||||
}
|
||||
if s == nil || s.account == nil {
|
||||
return CommandResult{}, fmt.Errorf("admin account dependency is not configured")
|
||||
}
|
||||
email := strings.TrimSpace(req.Email)
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionSetLoginEmail, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
details := map[string]any{"new_login_email": email}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "dry-run completed", Details: details}, nil
|
||||
}
|
||||
var err error
|
||||
if email == "" {
|
||||
err = s.account.ClearLoginEmail(ctx, req.UserID)
|
||||
} else {
|
||||
err = s.account.SetLoginEmail(ctx, req.UserID, email)
|
||||
}
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
message := "login email updated"
|
||||
if email == "" {
|
||||
message = "login email cleared"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// SetAccountAvatar force-sets a user's current profile photo from raw
|
||||
// uploaded image bytes, reusing the same avatar rendition pipeline
|
||||
// (s/a/c sizes) as photos.uploadProfilePhoto.
|
||||
func (s *Service) SetAccountAvatar(ctx context.Context, req SetAccountAvatarRequest) (CommandResult, error) {
|
||||
if req.UserID <= 0 {
|
||||
return CommandResult{}, fmt.Errorf("user_id is required")
|
||||
}
|
||||
if s == nil || s.photos == nil {
|
||||
return CommandResult{}, fmt.Errorf("admin photos dependency is not configured")
|
||||
}
|
||||
if len(req.Data) == 0 || len(req.Data) > MaxAccountAvatarBytes || !s.photos.ValidateAvatarUpload(req.Data) {
|
||||
return CommandResult{}, domain.ErrPhotoInvalid
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionSetAccountAvatar, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
details := map[string]any{"file_name": req.FileName, "bytes": len(req.Data)}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "avatar validated", Details: details}, nil
|
||||
}
|
||||
photo, err := s.photos.CreateAvatarFromBytes(ctx, req.Data, req.UserID)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
if _, _, err := s.photos.SetCurrentProfilePhotoKind(ctx, domain.PeerTypeUser, req.UserID, domain.ProfilePhotoKindProfile, photo.ID, int(time.Now().Unix())); err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
details["photo_id"] = photo.ID
|
||||
if s.users != nil {
|
||||
if u, found, uerr := s.users.AdminUser(ctx, req.UserID); uerr == nil && found {
|
||||
if nerr := s.notifyUserChanged(ctx, u); nerr != nil {
|
||||
details["notify_error"] = nerr.Error()
|
||||
}
|
||||
}
|
||||
}
|
||||
return CommandResult{Message: "avatar updated", Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// SetUserColor force-sets or clears a user's name/profile color.
|
||||
func (s *Service) SetUserColor(ctx context.Context, req SetUserColorRequest) (CommandResult, error) {
|
||||
if req.UserID <= 0 {
|
||||
|
|
@ -2649,7 +2853,9 @@ func (s *Service) OfficialStarGifts(ctx context.Context) ([]officialgifts.GiftSu
|
|||
return s.officialGifts.List(ctx)
|
||||
}
|
||||
|
||||
const maxAccountAvatarBytes = 4 << 20
|
||||
// MaxAccountAvatarBytes bounds both reading (AccountAvatar) and writing
|
||||
// (SetAccountAvatar) a user's profile photo through the admin console.
|
||||
const MaxAccountAvatarBytes = 4 << 20
|
||||
|
||||
// AccountAvatar returns an account's current profile photo bytes and detected
|
||||
// MIME type, mirroring internal/web's public avatar serving (same size
|
||||
|
|
@ -2674,17 +2880,17 @@ func (s *Service) AccountAvatar(ctx context.Context, userID int64) ([]byte, stri
|
|||
if len(data) == 0 {
|
||||
chunk, found, err := s.photos.GetFile(ctx, domain.FileDownloadRequest{
|
||||
LocationKey: fmt.Sprintf("photo:%d:%s", photo.ID, size.Type),
|
||||
Limit: maxAccountAvatarBytes + 1,
|
||||
Limit: MaxAccountAvatarBytes + 1,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", false, err
|
||||
}
|
||||
if !found || chunk.Total <= 0 || chunk.Total > maxAccountAvatarBytes || int64(len(chunk.Bytes)) != chunk.Total {
|
||||
if !found || chunk.Total <= 0 || chunk.Total > MaxAccountAvatarBytes || int64(len(chunk.Bytes)) != chunk.Total {
|
||||
return nil, "", false, nil
|
||||
}
|
||||
data = chunk.Bytes
|
||||
}
|
||||
if len(data) == 0 || len(data) > maxAccountAvatarBytes {
|
||||
if len(data) == 0 || len(data) > MaxAccountAvatarBytes {
|
||||
return nil, "", false, nil
|
||||
}
|
||||
detected := http.DetectContentType(data)
|
||||
|
|
@ -2707,7 +2913,7 @@ func bestAccountPhotoSize(sizes []domain.PhotoSize) (domain.PhotoSize, []byte, b
|
|||
var inline []byte
|
||||
switch size.Kind {
|
||||
case domain.PhotoSizeKindCached:
|
||||
if len(size.Bytes) == 0 || len(size.Bytes) > maxAccountAvatarBytes {
|
||||
if len(size.Bytes) == 0 || len(size.Bytes) > MaxAccountAvatarBytes {
|
||||
continue
|
||||
}
|
||||
inline = size.Bytes
|
||||
|
|
|
|||
|
|
@ -835,6 +835,39 @@ func (f *fakeUsersService) UpdateUsername(_ context.Context, userID int64, usern
|
|||
return u, nil
|
||||
}
|
||||
|
||||
func (f *fakeUsersService) UpdateProfile(_ context.Context, userID int64, update domain.UserProfileUpdate) (domain.User, error) {
|
||||
u, ok := f.users[userID]
|
||||
if !ok {
|
||||
return domain.User{}, domain.ErrUserNotFound
|
||||
}
|
||||
if update.HasFirstName {
|
||||
u.FirstName = update.FirstName
|
||||
}
|
||||
if update.HasLastName {
|
||||
u.LastName = update.LastName
|
||||
}
|
||||
if update.HasAbout {
|
||||
u.About = update.About
|
||||
}
|
||||
f.users[userID] = u
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (f *fakeUsersService) SetPhone(_ context.Context, userID int64, phone string) (domain.User, error) {
|
||||
u, ok := f.users[userID]
|
||||
if !ok {
|
||||
return domain.User{}, domain.ErrUserNotFound
|
||||
}
|
||||
for id, existing := range f.users {
|
||||
if id != userID && existing.Phone == phone {
|
||||
return domain.User{}, domain.ErrPhoneNumberOccupied
|
||||
}
|
||||
}
|
||||
u.Phone = phone
|
||||
f.users[userID] = u
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (f *fakeUsersService) UpdateColor(_ context.Context, userID int64, forProfile bool, color domain.PeerColor) (domain.User, error) {
|
||||
u, ok := f.users[userID]
|
||||
if !ok {
|
||||
|
|
|
|||
|
|
@ -53,6 +53,10 @@ type Service interface {
|
|||
DeleteBot(ctx context.Context, req admin.DeleteBotRequest) (admin.CommandResult, error)
|
||||
SetSupport(ctx context.Context, req admin.SetSupportRequest) (admin.CommandResult, error)
|
||||
SetUsername(ctx context.Context, req admin.SetUsernameRequest) (admin.CommandResult, error)
|
||||
SetProfile(ctx context.Context, req admin.SetProfileRequest) (admin.CommandResult, error)
|
||||
SetPhone(ctx context.Context, req admin.SetPhoneRequest) (admin.CommandResult, error)
|
||||
SetLoginEmail(ctx context.Context, req admin.SetLoginEmailRequest) (admin.CommandResult, error)
|
||||
SetAccountAvatar(ctx context.Context, req admin.SetAccountAvatarRequest) (admin.CommandResult, error)
|
||||
SetUserColor(ctx context.Context, req admin.SetUserColorRequest) (admin.CommandResult, error)
|
||||
SetUserEmojiStatus(ctx context.Context, req admin.SetUserEmojiStatusRequest) (admin.CommandResult, error)
|
||||
SetChannelSettings(ctx context.Context, req admin.SetChannelSettingsRequest) (admin.CommandResult, error)
|
||||
|
|
@ -192,6 +196,10 @@ func (s *Server) routes() http.Handler {
|
|||
mux.HandleFunc("POST /v1/accounts/set-flags", s.authenticated(s.handleSetUserFlags))
|
||||
mux.HandleFunc("POST /v1/accounts/set-support", s.authenticated(s.handleSetSupport))
|
||||
mux.HandleFunc("POST /v1/accounts/set-username", s.authenticated(s.handleSetUsername))
|
||||
mux.HandleFunc("POST /v1/accounts/set-profile", s.authenticated(s.handleSetProfile))
|
||||
mux.HandleFunc("POST /v1/accounts/set-phone", s.authenticated(s.handleSetPhone))
|
||||
mux.HandleFunc("POST /v1/accounts/set-login-email", s.authenticated(s.handleSetLoginEmail))
|
||||
mux.HandleFunc("POST /v1/accounts/set-avatar", s.authenticated(s.handleSetAccountAvatar))
|
||||
mux.HandleFunc("POST /v1/accounts/set-color", s.authenticated(s.handleSetUserColor))
|
||||
mux.HandleFunc("POST /v1/accounts/set-emoji-status", s.authenticated(s.handleSetUserEmojiStatus))
|
||||
mux.HandleFunc("POST /v1/accounts/revoke-sessions", s.authenticated(s.handleRevokeSessions))
|
||||
|
|
@ -384,6 +392,67 @@ func (s *Server) handleSetUsername(w http.ResponseWriter, r *http.Request) {
|
|||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetProfile(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.SetProfileRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.SetProfile(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetPhone(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.SetPhoneRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.SetPhone(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetLoginEmail(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.SetLoginEmailRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.SetLoginEmail(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetAccountAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
r.Body = http.MaxBytesReader(w, r.Body, admin.MaxAccountAvatarBytes+(1<<20))
|
||||
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error())
|
||||
return
|
||||
}
|
||||
if r.MultipartForm != nil {
|
||||
defer r.MultipartForm.RemoveAll()
|
||||
}
|
||||
var req admin.SetAccountAvatarRequest
|
||||
dec := json.NewDecoder(strings.NewReader(r.FormValue("metadata")))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid metadata: "+err.Error())
|
||||
return
|
||||
}
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "avatar file is required")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(file, admin.MaxAccountAvatarBytes+1))
|
||||
if err != nil || len(data) == 0 || int64(len(data)) > admin.MaxAccountAvatarBytes {
|
||||
writeError(w, http.StatusBadRequest, "avatar file is empty or too large")
|
||||
return
|
||||
}
|
||||
req.FileName = header.Filename
|
||||
req.Data = data
|
||||
result, err := s.svc.SetAccountAvatar(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetUserColor(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.SetUserColorRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
|
|
|
|||
|
|
@ -483,6 +483,22 @@ func (fakeService) SetUsername(_ context.Context, req admin.SetUsernameRequest)
|
|||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) SetProfile(_ context.Context, req admin.SetProfileRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) SetPhone(_ context.Context, req admin.SetPhoneRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) SetLoginEmail(_ context.Context, req admin.SetLoginEmailRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) SetAccountAvatar(_ context.Context, req admin.SetAccountAvatarRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) SetUserColor(_ context.Context, req admin.SetUserColorRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -140,6 +140,28 @@ func (s *Service) GetDocument(ctx context.Context, id int64) (domain.Document, b
|
|||
return s.media.GetDocument(ctx, id)
|
||||
}
|
||||
|
||||
// ValidateAvatarUpload is a pure check (decodes only the image header) so a
|
||||
// dry-run preview can validate bytes before SetAccountAvatar/CreateAvatarFromBytes
|
||||
// actually renders and stores the avatar's s/a/c size set.
|
||||
func (s *Service) ValidateAvatarUpload(data []byte) bool {
|
||||
if len(data) == 0 {
|
||||
return false
|
||||
}
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
|
||||
return err == nil && cfg.Width > 0 && cfg.Height > 0
|
||||
}
|
||||
|
||||
// CreateAvatarFromBytes stores already-in-hand image bytes as an avatar Photo
|
||||
// ('s'/'a'/'c' sizes), for callers that skip the chunked upload.saveFilePart
|
||||
// transfer regular clients use (e.g. the admin console, which already has the
|
||||
// full file from a browser upload).
|
||||
func (s *Service) CreateAvatarFromBytes(ctx context.Context, data []byte, ownerUserID int64) (domain.Photo, error) {
|
||||
if len(data) == 0 {
|
||||
return domain.Photo{}, domain.ErrPhotoInvalid
|
||||
}
|
||||
return s.createAvatarPhoto(ctx, data, ownerUserID)
|
||||
}
|
||||
|
||||
// CreateAvatarFromUpload 把已上传文件组装成头像 Photo('s'/'a'/'c' 尺寸,'a'/'c' 匹配
|
||||
// InputPeerPhotoFileLocation big/small 与 channelFull 下载路径),不绑定 profile_photos。用于频道 editPhoto。
|
||||
func (s *Service) CreateAvatarFromUpload(ctx context.Context, file domain.UploadedFileRef) (domain.Photo, error) {
|
||||
|
|
|
|||
|
|
@ -253,6 +253,35 @@ func (s *Service) UpdateUsername(ctx context.Context, userID int64, username str
|
|||
return s.projectOne(ctx, self.ID, u)
|
||||
}
|
||||
|
||||
// SetPhone force-sets a user's phone number (admin use -- no code
|
||||
// verification, unlike the user-facing verified change-phone flow in
|
||||
// internal/app/account). Pre-checks availability via ByPhone before writing,
|
||||
// on top of the store's own unique-constraint backstop.
|
||||
func (s *Service) SetPhone(ctx context.Context, userID int64, phone string) (domain.User, error) {
|
||||
self, err := s.loadSelf(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
phone = domain.NormalizePhone(strings.TrimSpace(phone))
|
||||
if !domain.ValidPhone(phone) {
|
||||
return domain.User{}, domain.ErrPhoneNumberInvalid
|
||||
}
|
||||
if phone == self.Phone {
|
||||
return s.projectOne(ctx, self.ID, self)
|
||||
}
|
||||
if existing, found, err := s.users.ByPhone(ctx, phone); err != nil {
|
||||
return domain.User{}, err
|
||||
} else if found && existing.ID != self.ID {
|
||||
return domain.User{}, domain.ErrPhoneNumberOccupied
|
||||
}
|
||||
u, err := s.users.UpdatePhone(ctx, self.ID, phone)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
s.refreshCachedUsers(ctx, u)
|
||||
return s.projectOne(ctx, self.ID, u)
|
||||
}
|
||||
|
||||
// UpdateProfile 修改当前用户的基础资料。未设置的字段保持原值。
|
||||
func (s *Service) UpdateProfile(ctx context.Context, userID int64, update domain.UserProfileUpdate) (domain.User, error) {
|
||||
self, err := s.loadSelf(ctx, userID)
|
||||
|
|
|
|||
|
|
@ -245,6 +245,25 @@ func (s *UserStore) UpdateProfile(_ context.Context, userID int64, firstName, la
|
|||
return u, nil
|
||||
}
|
||||
|
||||
func (s *UserStore) UpdatePhone(_ context.Context, userID int64, phone string) (domain.User, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
u, ok := s.byID[userID]
|
||||
if !ok || u.Deleted {
|
||||
return domain.User{}, domain.ErrUserNotFound
|
||||
}
|
||||
if phone != "" {
|
||||
for id, existing := range s.byID {
|
||||
if id != userID && existing.Phone == phone {
|
||||
return domain.User{}, domain.ErrPhoneNumberOccupied
|
||||
}
|
||||
}
|
||||
}
|
||||
u.Phone = phone
|
||||
s.byID[userID] = u
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *UserStore) UpdateBirthday(_ context.Context, userID int64, birthday domain.Birthday) (domain.User, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
|
|
|||
|
|
@ -220,6 +220,26 @@ func (s *UserStore) UpdateProfile(ctx context.Context, userID int64, firstName,
|
|||
return userFromModel(row), nil
|
||||
}
|
||||
|
||||
// UpdatePhone force-sets a user's phone number. Used only by the admin
|
||||
// panel -- the user-facing change-phone flow (internal/app/account) requires
|
||||
// a verified code and lives in internal/store/postgres/phone_change.go.
|
||||
func (s *UserStore) UpdatePhone(ctx context.Context, userID int64, phone string) (domain.User, error) {
|
||||
row, err := s.q.UpdateUserPhone(ctx, sqlcgen.UpdateUserPhoneParams{
|
||||
ID: userID,
|
||||
Phone: phone,
|
||||
})
|
||||
if err != nil {
|
||||
if isUniqueConstraint(err, "users_phone_unique_idx") {
|
||||
return domain.User{}, domain.ErrPhoneNumberOccupied
|
||||
}
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.User{}, domain.ErrUserNotFound
|
||||
}
|
||||
return domain.User{}, fmt.Errorf("update user phone: %w", err)
|
||||
}
|
||||
return userFromModel(row), nil
|
||||
}
|
||||
|
||||
func (s *UserStore) UpdateUsername(ctx context.Context, userID int64, username string) (domain.User, error) {
|
||||
username = strings.TrimSpace(strings.TrimPrefix(username, "@"))
|
||||
usernameLower := strings.ToLower(username)
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ type UserStore interface {
|
|||
Search(ctx context.Context, currentUserID int64, query, phoneQuery string, limit int) (domain.UserSearchResult, error)
|
||||
UpdateProfile(ctx context.Context, userID int64, firstName, lastName, about string) (domain.User, error)
|
||||
UpdateUsername(ctx context.Context, userID int64, username string) (domain.User, error)
|
||||
// UpdatePhone force-sets a user's phone number (admin use -- no code
|
||||
// verification, unlike the user-facing verified change-phone flow).
|
||||
UpdatePhone(ctx context.Context, userID int64, phone string) (domain.User, error)
|
||||
UpdateLastSeen(ctx context.Context, userID int64, lastSeenAt int) error
|
||||
// Create 创建用户并返回分配了 ID 的副本。
|
||||
Create(ctx context.Context, u domain.User) (domain.User, error)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue