Merge remote-tracking branch 'upstream/main' into merge-gramsrv-9106877
This commit is contained in:
commit
ac6a50c5ff
697 changed files with 100880 additions and 8052 deletions
|
|
@ -1,12 +1,16 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { api, APIError } from "./api";
|
||||
import { api } from "./api";
|
||||
import { BootScreen, Shell } from "./components/Layout";
|
||||
import { LoginPage } from "./pages/LoginPage";
|
||||
import { PermissionsProvider } from "./permissions";
|
||||
import { Routes } from "./pages/Routes";
|
||||
import { currentRoute, type RouteState } from "./routing";
|
||||
import type { AdminSession } from "./types";
|
||||
|
||||
export function App() {
|
||||
const [actor, setActor] = useState<string | null | undefined>(undefined);
|
||||
// One GET /api/session at boot carries both the actor and the permission set the
|
||||
// signed session was issued with.
|
||||
const [session, setSession] = useState<AdminSession | null | undefined>(undefined);
|
||||
const [route, setRoute] = useState<RouteState>(() => currentRoute());
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -17,14 +21,10 @@ export function App() {
|
|||
|
||||
useEffect(() => {
|
||||
api.session()
|
||||
.then((session) => setActor(session.actor))
|
||||
.catch((error) => {
|
||||
if (error instanceof APIError && error.status === 401) {
|
||||
setActor(null);
|
||||
return;
|
||||
}
|
||||
setActor(null);
|
||||
});
|
||||
.then((next) => setSession(next))
|
||||
// A 401 and an unreachable backend both end at the login screen; there is
|
||||
// nothing the panel can render without a session.
|
||||
.catch(() => setSession(null));
|
||||
}, []);
|
||||
|
||||
const navigate = (href: string) => {
|
||||
|
|
@ -32,17 +32,19 @@ export function App() {
|
|||
setRoute(currentRoute());
|
||||
};
|
||||
|
||||
if (actor === undefined) {
|
||||
if (session === undefined) {
|
||||
return <BootScreen />;
|
||||
}
|
||||
|
||||
if (actor === null) {
|
||||
return <LoginPage onLogin={setActor} />;
|
||||
if (session === null) {
|
||||
return <LoginPage onLogin={setSession} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Shell actor={actor} route={route} navigate={navigate} onLogout={() => setActor(null)}>
|
||||
<Routes route={route} navigate={navigate} />
|
||||
</Shell>
|
||||
<PermissionsProvider permissions={session.permissions ?? []}>
|
||||
<Shell actor={session.actor} route={route} navigate={navigate} onLogout={() => setSession(null)}>
|
||||
<Routes route={route} navigate={navigate} />
|
||||
</Shell>
|
||||
</PermissionsProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,40 @@
|
|||
import type {
|
||||
AccountDetail,
|
||||
AccountListResponse,
|
||||
AccountRatingDetail,
|
||||
AccountRatingListResponse,
|
||||
AccountStatsResponse,
|
||||
AdminLoginResult,
|
||||
AdminSession,
|
||||
BotDetail,
|
||||
BotListResponse,
|
||||
BotVerificationCountsResponse,
|
||||
BotVerifierListResponse,
|
||||
ChannelDetail,
|
||||
CustomVerificationListResponse,
|
||||
CustomVerificationRequestDetail,
|
||||
CustomVerificationRequestListResponse,
|
||||
VerificationIconListResponse,
|
||||
EmojiListResponse,
|
||||
ChannelListResponse,
|
||||
CollectibleUsernameDetail,
|
||||
CollectibleUsernameListResponse,
|
||||
CommandResult,
|
||||
GroupMessageDetail,
|
||||
GroupMessageListResponse,
|
||||
MessageDetail,
|
||||
MessageListResponse,
|
||||
DefaultGiftListResponse,
|
||||
ModerationCaseDetail,
|
||||
ModerationCaseRow,
|
||||
ModerationReport,
|
||||
OfficialStarGiftListResponse,
|
||||
StarGiftCollectiblePreview,
|
||||
StarGiftListResponse,
|
||||
StickerSetListResponse
|
||||
StickerSetListResponse,
|
||||
VerificationApplicationDetail,
|
||||
VerificationApplicationListResponse,
|
||||
VerificationCountsResponse
|
||||
} from "./types";
|
||||
|
||||
export class APIError extends Error {
|
||||
|
|
@ -28,12 +46,81 @@ export class APIError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
// The backend publishes the CSRF token in a deliberately readable cookie and
|
||||
// refuses every mutating request whose X-CSRF-Token header does not repeat it
|
||||
// (cmd/telesrv-admin/security.go). Echoing it here — inside request<T> — is what
|
||||
// keeps a new endpoint from silently shipping without the header.
|
||||
const csrfCookieName = "telesrv_admin_csrf";
|
||||
const csrfHeaderName = "X-CSRF-Token";
|
||||
|
||||
// Login answers with the token in the body as well as in Set-Cookie. Keeping the
|
||||
// body value is the fallback for the window where the browser has not applied
|
||||
// the cookie yet, or where the cookie is not readable back to the script.
|
||||
let issuedCSRFToken = "";
|
||||
|
||||
export function rememberCSRFToken(token: string | undefined): void {
|
||||
issuedCSRFToken = (token ?? "").trim();
|
||||
}
|
||||
|
||||
function readCSRFCookie(): string {
|
||||
if (typeof document === "undefined") return "";
|
||||
for (const chunk of document.cookie.split(";")) {
|
||||
const entry = chunk.trim();
|
||||
const separator = entry.indexOf("=");
|
||||
if (separator <= 0 || entry.slice(0, separator) !== csrfCookieName) continue;
|
||||
try {
|
||||
return decodeURIComponent(entry.slice(separator + 1));
|
||||
} catch {
|
||||
return entry.slice(separator + 1);
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// The cookie wins: it is the value the server compares against, and it survives
|
||||
// a page reload that the in-memory copy does not.
|
||||
export function csrfToken(): string {
|
||||
return readCSRFCookie() || issuedCSRFToken;
|
||||
}
|
||||
|
||||
// Same classification the backend uses: GET/HEAD/OPTIONS are safe, everything
|
||||
// else carries a token.
|
||||
function mutatingMethod(method: string | undefined): boolean {
|
||||
const verb = (method ?? "GET").toUpperCase();
|
||||
return verb !== "GET" && verb !== "HEAD" && verb !== "OPTIONS";
|
||||
}
|
||||
|
||||
function plainHeaders(source: HeadersInit | undefined): Record<string, string> {
|
||||
if (!source) return {};
|
||||
if (source instanceof Headers) {
|
||||
const out: Record<string, string> = {};
|
||||
source.forEach((value, key) => {
|
||||
out[key] = value;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
if (Array.isArray(source)) {
|
||||
return Object.fromEntries(source);
|
||||
}
|
||||
return { ...source };
|
||||
}
|
||||
|
||||
async function request<T>(url: string, init: RequestInit = {}): Promise<T> {
|
||||
const isForm = typeof FormData !== "undefined" && init.body instanceof FormData;
|
||||
// A multipart body must keep the boundary the browser generates, so its
|
||||
// Content-Type is left alone; the CSRF header is added either way.
|
||||
const headers: Record<string, string> = isForm ? {} : { "Content-Type": "application/json" };
|
||||
Object.assign(headers, plainHeaders(init.headers));
|
||||
if (mutatingMethod(init.method)) {
|
||||
const token = csrfToken();
|
||||
if (token) {
|
||||
headers[csrfHeaderName] = token;
|
||||
}
|
||||
}
|
||||
const response = await fetch(url, {
|
||||
credentials: "same-origin",
|
||||
headers: isForm ? init.headers : { "Content-Type": "application/json", ...(init.headers ?? {}) },
|
||||
...init
|
||||
...init,
|
||||
headers
|
||||
});
|
||||
const text = await response.text();
|
||||
const data = text ? JSON.parse(text) : null;
|
||||
|
|
@ -52,11 +139,16 @@ export function errorMessage(error: unknown): string {
|
|||
}
|
||||
|
||||
export const api = {
|
||||
session: () => request<{ actor: string }>("/api/session"),
|
||||
login: (secret: string) => request<{ actor: string }>("/api/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ secret })
|
||||
}),
|
||||
session: () => request<AdminSession>("/api/session"),
|
||||
login: async (secret: string) => {
|
||||
const result = await request<AdminLoginResult>("/api/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ secret })
|
||||
});
|
||||
// Stashed here rather than in the caller so no login path can forget it.
|
||||
rememberCSRFToken(result.csrf_token);
|
||||
return result;
|
||||
},
|
||||
logout: () => request<{ ok: boolean }>("/api/logout", { method: "POST", body: "{}" }),
|
||||
accounts: (params: URLSearchParams) => request<AccountListResponse>(`/api/accounts?${params.toString()}`),
|
||||
accountStats: () => request<AccountStatsResponse>("/api/accounts/stats"),
|
||||
|
|
@ -65,6 +157,36 @@ export const api = {
|
|||
channel: (id: number) => request<ChannelDetail>(`/api/channels/${id}`),
|
||||
bots: (params: URLSearchParams) => request<BotListResponse>(`/api/bots?${params.toString()}`),
|
||||
bot: (id: number) => request<BotDetail>(`/api/bots/${id}`),
|
||||
collectibleUsernames: (params: URLSearchParams) =>
|
||||
request<CollectibleUsernameListResponse>(`/api/collectible-usernames?${params.toString()}`),
|
||||
collectibleUsername: (id: string) =>
|
||||
request<CollectibleUsernameDetail>(`/api/collectible-usernames/${encodeURIComponent(id)}`),
|
||||
accountRatings: (params: URLSearchParams) =>
|
||||
request<AccountRatingListResponse>(`/api/account-ratings?${params.toString()}`),
|
||||
accountRating: (userID: string) =>
|
||||
request<AccountRatingDetail>(`/api/account-ratings/${encodeURIComponent(userID)}`),
|
||||
verificationApplications: (params: URLSearchParams) =>
|
||||
request<VerificationApplicationListResponse>(`/api/verification/applications?${params.toString()}`),
|
||||
// The application id is an int64 decimal string end to end, so it is never
|
||||
// parsed into a number on the way to the URL.
|
||||
verificationApplication: (id: string) =>
|
||||
request<VerificationApplicationDetail>(`/api/verification/applications/${encodeURIComponent(id)}`),
|
||||
verificationCounts: () => request<VerificationCountsResponse>("/api/verification/counts"),
|
||||
// Third-party verification lives under its own prefix: the two mechanisms share
|
||||
// no state, so they share no route either.
|
||||
botVerifiers: (params: URLSearchParams) =>
|
||||
request<BotVerifierListResponse>(`/api/botverification/verifiers?${params.toString()}`),
|
||||
verificationIcons: (params: URLSearchParams) =>
|
||||
request<VerificationIconListResponse>(`/api/botverification/icons?${params.toString()}`),
|
||||
customVerifications: (params: URLSearchParams) =>
|
||||
request<CustomVerificationListResponse>(`/api/botverification/marks?${params.toString()}`),
|
||||
customVerificationRequests: (params: URLSearchParams) =>
|
||||
request<CustomVerificationRequestListResponse>(`/api/botverification/requests?${params.toString()}`),
|
||||
// The application id is an int64 decimal string end to end, so it is never parsed
|
||||
// into a number on the way to the URL.
|
||||
customVerificationRequest: (id: string) =>
|
||||
request<CustomVerificationRequestDetail>(`/api/botverification/requests/${encodeURIComponent(id)}`),
|
||||
botVerificationCounts: () => request<BotVerificationCountsResponse>("/api/botverification/counts"),
|
||||
emoji: (params: URLSearchParams) => request<EmojiListResponse>(`/api/emoji?${params.toString()}`),
|
||||
emojiAnimation: (documentID: string) => request<Record<string, unknown>>(`/api/emoji/${encodeURIComponent(documentID)}/animation`),
|
||||
messages: (params: URLSearchParams) => request<MessageListResponse>(`/api/messages?${params.toString()}`),
|
||||
|
|
@ -77,6 +199,27 @@ export const api = {
|
|||
const params = new URLSearchParams({ channel_id: String(channelID), msg_id: String(msgID) });
|
||||
return request<GroupMessageDetail>(`/api/messages/groups/detail?${params.toString()}`);
|
||||
},
|
||||
moderationCases: (params: URLSearchParams) =>
|
||||
request<{ cases: ModerationCaseRow[] }>(`/api/moderation/cases?${params.toString()}`),
|
||||
moderationCase: (id: number) =>
|
||||
request<ModerationCaseDetail>(`/api/moderation/cases/${id}`),
|
||||
moderationReport: (id: number) =>
|
||||
request<ModerationReport>(`/api/moderation/reports/${id}`),
|
||||
claimModerationCase: (id: number, expectedVersion: number) =>
|
||||
request<ModerationCaseRow>(`/api/moderation/cases/${id}/claim`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ expected_version: expectedVersion })
|
||||
}),
|
||||
decideModerationCase: (id: number, payload: Record<string, unknown>) =>
|
||||
request<{ created: boolean; case: ModerationCaseDetail }>(`/api/moderation/cases/${id}/decide`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
}),
|
||||
reviewModerationAppeal: (caseID: number, appealID: number, payload: Record<string, unknown>) =>
|
||||
request<{ created: boolean; case: ModerationCaseDetail }>(`/api/moderation/cases/${caseID}/appeals/${appealID}/review`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
}),
|
||||
gifts: () => request<StarGiftListResponse>("/api/gifts"),
|
||||
stickerSets: (kind: string) => request<StickerSetListResponse>(`/api/stickers?kind=${encodeURIComponent(kind)}`),
|
||||
stickerSetDocuments: (setID: string) => request<{ document_ids: string[] }>(`/api/stickers/${encodeURIComponent(setID)}/documents`),
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import type { ReactNode } from "react";
|
|||
import { useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { useI18n } from "../i18n";
|
||||
import type { CommandResult } from "../types";
|
||||
import { Alert, JsonBlock } from "./ui";
|
||||
|
||||
|
|
@ -16,7 +15,9 @@ export function ActionButton({
|
|||
icon,
|
||||
compact = false,
|
||||
tone = "danger",
|
||||
onDone
|
||||
disabled = false,
|
||||
onDone,
|
||||
onError
|
||||
}: {
|
||||
label: string;
|
||||
path: string;
|
||||
|
|
@ -24,9 +25,16 @@ export function ActionButton({
|
|||
icon?: ReactNode;
|
||||
compact?: boolean;
|
||||
tone?: ActionTone;
|
||||
// disabled keeps a form from opening the confirm flow at all while its own
|
||||
// validation is unhappy, so the operator fixes the field instead of reading a
|
||||
// backend rejection.
|
||||
disabled?: boolean;
|
||||
onDone?: () => void;
|
||||
// onError lets a page react to a failure the operator cannot fix by editing the
|
||||
// form — an optimistic-locking 409, say — and replace the raw backend text with
|
||||
// an explanation by returning it.
|
||||
onError?: (error: unknown) => string | undefined;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [reason, setReason] = useState("");
|
||||
const [result, setResult] = useState<CommandResult | null>(null);
|
||||
|
|
@ -41,7 +49,7 @@ export function ActionButton({
|
|||
|
||||
async function run(confirm: boolean) {
|
||||
if (!reason.trim()) {
|
||||
setError(t("action.reasonRequired"));
|
||||
setError("Please enter an operation reason");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
|
|
@ -54,7 +62,7 @@ export function ActionButton({
|
|||
onDone?.();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
setError(onError?.(err) || errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
|
@ -75,6 +83,7 @@ export function ActionButton({
|
|||
<button
|
||||
className={triggerClass}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
reset();
|
||||
setOpen(true);
|
||||
|
|
@ -88,29 +97,29 @@ export function ActionButton({
|
|||
<section className="modal command-modal" role="dialog" aria-modal="true" aria-label={label}>
|
||||
<div className="modal-head">
|
||||
<div>
|
||||
<div className="eyebrow">{t("action.flow")}</div>
|
||||
<div className="eyebrow">{"Action Flow"}</div>
|
||||
<h2>{label}</h2>
|
||||
</div>
|
||||
<button className="icon-btn" type="button" onClick={() => setOpen(false)} aria-label={t("action.close")}><X size={15} /></button>
|
||||
<button className="icon-btn" type="button" onClick={() => setOpen(false)} aria-label={"Close"}><X size={15} /></button>
|
||||
</div>
|
||||
<div className="command-body">
|
||||
<div className="command-steps">
|
||||
<div className={`command-step ${reason.trim() ? "done" : "active"}`}>
|
||||
<span>1</span><strong>{t("action.stepReason")}</strong>
|
||||
<span>1</span><strong>{"Enter reason"}</strong>
|
||||
</div>
|
||||
<div className={`command-step ${result?.dry_run ? "done" : reason.trim() ? "active" : ""}`}>
|
||||
<span>2</span><strong>{t("action.stepDryRun")}</strong>
|
||||
<span>2</span><strong>{"Dry-run check"}</strong>
|
||||
</div>
|
||||
<div className={`command-step ${result && !result.dry_run && !result.error ? "done" : canConfirm ? "active" : ""}`}>
|
||||
<span>3</span><strong>{t("action.stepConfirm")}</strong>
|
||||
<span>3</span><strong>{"Confirm execution"}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<label className="form-field">
|
||||
<span>{t("action.reason")}</span>
|
||||
<textarea value={reason} onChange={(event) => setReason(event.target.value)} rows={3} placeholder={t("action.reasonPlaceholder")} />
|
||||
<span>{"Operation reason"}</span>
|
||||
<textarea value={reason} onChange={(event) => setReason(event.target.value)} rows={3} placeholder={"Describe why this operation is being performed"} />
|
||||
</label>
|
||||
<div className="command-preview">
|
||||
<div className="preview-head"><FileJson size={14} /> {t("action.requestPreview")}</div>
|
||||
<div className="preview-head"><FileJson size={14} /> {"Request preview"}</div>
|
||||
<JsonBlock value={JSON.stringify(previewPayload, null, 2)} />
|
||||
</div>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
|
|
@ -118,25 +127,25 @@ export function ActionButton({
|
|||
<div className="result-box">
|
||||
<div className="result-title">
|
||||
{result.error ? <CircleAlert size={16} /> : <CheckCircle2 size={16} />}
|
||||
<strong>{result.message || result.error || t("action.result")}</strong>
|
||||
<strong>{result.message || result.error || "Action result"}</strong>
|
||||
</div>
|
||||
<div className="result-line"><span>{t("action.commandID")}</span><strong>{result.command_id}</strong></div>
|
||||
<div className="result-line"><span>{t("action.status")}</span><strong>{result.status}</strong></div>
|
||||
<div className="result-line"><span>{t("action.dryRun")}</span><strong>{result.dry_run ? t("common.yes") : t("common.no")}</strong></div>
|
||||
<div className="result-line"><span>{"Command ID"}</span><strong>{result.command_id}</strong></div>
|
||||
<div className="result-line"><span>{"Status"}</span><strong>{result.status}</strong></div>
|
||||
<div className="result-line"><span>{"Dry-run"}</span><strong>{result.dry_run ? "Yes" : "No"}</strong></div>
|
||||
<div className="result-message">{result.message || result.error}</div>
|
||||
{result.details && <JsonBlock value={JSON.stringify(result.details, null, 2)} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="btn" type="button" onClick={() => setOpen(false)}>{t("common.close")}</button>
|
||||
<button className="btn" type="button" onClick={() => setOpen(false)}>{"Close"}</button>
|
||||
<button className="btn icon-text" type="button" onClick={() => run(false)} disabled={busy}>
|
||||
{busy ? <Loader2 size={15} className="spin" /> : <Play size={15} />}
|
||||
{result ? t("action.runAgain") : t("action.runDry")}
|
||||
{result ? "Run dry-run again" : "Run dry-run first"}
|
||||
</button>
|
||||
<button className="btn danger icon-text" type="button" onClick={() => run(true)} disabled={busy || !canConfirm}>
|
||||
<CheckCircle2 size={15} />
|
||||
{t("action.confirm")}
|
||||
{"Confirm execution"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,11 @@
|
|||
import { Cable, LogOut, ShieldCheck } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { formatDate } from "../lib/format";
|
||||
import { useI18n } from "../i18n";
|
||||
import type { AuthorizationRow } from "../types";
|
||||
import { ActionButton } from "./ActionButton";
|
||||
import { EmptyRow } from "./ui";
|
||||
|
||||
export function AuthorizationTable({ rows, userID, onDone }: { rows: AuthorizationRow[]; userID: number; onDone: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const [removedHashes, setRemovedHashes] = useState<Set<number>>(() => new Set());
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -30,11 +28,11 @@ export function AuthorizationTable({ rows, userID, onDone }: { rows: Authorizati
|
|||
<table className="data-table authorization-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("auth.device")}</th>
|
||||
<th>{t("auth.platform")}</th>
|
||||
<th>{t("auth.ip")}</th>
|
||||
<th>{t("auth.lastActive")}</th>
|
||||
<th className="device-actions-head">{t("common.actions")}</th>
|
||||
<th>{"Device"}</th>
|
||||
<th>{"Platform"}</th>
|
||||
<th>{"IP"}</th>
|
||||
<th>{"Last active"}</th>
|
||||
<th className="device-actions-head">{"Actions"}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -47,7 +45,7 @@ export function AuthorizationTable({ rows, userID, onDone }: { rows: Authorizati
|
|||
<td className="device-actions-cell">
|
||||
<div className="device-actions">
|
||||
<ActionButton
|
||||
label={t("auth.revokeCurrent")}
|
||||
label={"Revoke current"}
|
||||
icon={<LogOut size={13} />}
|
||||
compact
|
||||
path="/api/actions/revoke-sessions"
|
||||
|
|
@ -55,7 +53,7 @@ export function AuthorizationTable({ rows, userID, onDone }: { rows: Authorizati
|
|||
onDone={() => afterRevoke((previous) => new Set([...previous, row.Hash]))}
|
||||
/>
|
||||
<ActionButton
|
||||
label={t("auth.keepCurrent")}
|
||||
label={"Keep current"}
|
||||
icon={<ShieldCheck size={13} />}
|
||||
compact
|
||||
path="/api/actions/revoke-sessions"
|
||||
|
|
@ -72,7 +70,7 @@ export function AuthorizationTable({ rows, userID, onDone }: { rows: Authorizati
|
|||
</div>
|
||||
<div className="danger-zone">
|
||||
<ActionButton
|
||||
label={t("auth.revokeAll")}
|
||||
label={"Revoke all devices"}
|
||||
icon={<Cable size={15} />}
|
||||
path="/api/actions/revoke-sessions"
|
||||
payload={() => ({ user_id: userID, revoke_all: true })}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import { Check, Loader2, Search, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { useI18n } from "../i18n";
|
||||
import { channelKind, displayName, displayPhone, displayUsername } from "../lib/format";
|
||||
import type { AccountRow, ChannelRow } from "../types";
|
||||
import type { AccountRow, BotRow, ChannelRow } from "../types";
|
||||
import { Badge } from "./ui";
|
||||
|
||||
export function UserPicker({
|
||||
|
|
@ -15,7 +14,6 @@ export function UserPicker({
|
|||
value: AccountRow | null;
|
||||
onChange: (row: AccountRow | null) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [query, setQuery] = useState("");
|
||||
const [rows, setRows] = useState<AccountRow[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
|
@ -48,7 +46,7 @@ export function UserPicker({
|
|||
<span>{label}</span>
|
||||
{value ? (
|
||||
<button className="link-button" type="button" onClick={() => onChange(null)}>
|
||||
<X size={13} /> {t("common.clear")}
|
||||
<X size={13} /> {"Clear"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -73,10 +71,10 @@ export function UserPicker({
|
|||
void search();
|
||||
}
|
||||
}}
|
||||
placeholder={t("picker.userPlaceholder")}
|
||||
placeholder={"Search user_id / phone / username"}
|
||||
/>
|
||||
<button className="btn compact-btn" type="button" onClick={search} disabled={busy}>
|
||||
{busy ? <Loader2 size={14} className="spin" /> : t("common.search")}
|
||||
{busy ? <Loader2 size={14} className="spin" /> : "Search"}
|
||||
</button>
|
||||
</div>
|
||||
{error && <div className="picker-error">{error}</div>}
|
||||
|
|
@ -91,10 +89,106 @@ export function UserPicker({
|
|||
<span className="mono">{row.ID}</span>
|
||||
<strong>{displayName(row)}</strong>
|
||||
<span>{displayUsername(row.Username) || displayPhone(row.Phone) || "-"}</span>
|
||||
{row.Verified ? <Badge tone="good">{t("picker.verified")}</Badge> : <Badge>{t("picker.regular")}</Badge>}
|
||||
{row.Verified ? <Badge tone="good">{"Verified"}</Badge> : <Badge>{"Regular"}</Badge>}
|
||||
</button>
|
||||
))}
|
||||
{rows.length === 0 && !busy ? <div className="picker-empty">{t("common.noResults")}</div> : null}
|
||||
{rows.length === 0 && !busy ? <div className="picker-empty">{"No results"}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// BotPicker is the same widget over /api/bots. Verifier status is granted to a bot
|
||||
// account, and an operator knows the handle rather than the id, so the grant form
|
||||
// resolves it here instead of asking for a raw number.
|
||||
export function BotPicker({
|
||||
label,
|
||||
value,
|
||||
onChange
|
||||
}: {
|
||||
label: string;
|
||||
value: BotRow | null;
|
||||
onChange: (row: BotRow | null) => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [rows, setRows] = useState<BotRow[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function search() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
const params = new URLSearchParams({ limit: "20" });
|
||||
if (query.trim()) {
|
||||
params.set("q", query.trim().replace(/^@/, ""));
|
||||
}
|
||||
try {
|
||||
const result = await api.bots(params);
|
||||
setRows(result.rows ?? []);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void search();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="entity-picker">
|
||||
<div className="picker-head">
|
||||
<span>{label}</span>
|
||||
{value ? (
|
||||
<button className="link-button" type="button" onClick={() => onChange(null)}>
|
||||
<X size={13} /> {"Clear"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{value ? (
|
||||
<div className="selected-entity">
|
||||
<Check size={15} />
|
||||
<div>
|
||||
<strong>{value.FirstName || "-"}</strong>
|
||||
<span className="mono">{value.ID}</span>
|
||||
</div>
|
||||
<span>{displayUsername(value.Username) || "-"}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="picker-search">
|
||||
<Search size={15} />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
void search();
|
||||
}
|
||||
}}
|
||||
placeholder={"Bot username or id"}
|
||||
/>
|
||||
<button className="btn compact-btn" type="button" onClick={search} disabled={busy}>
|
||||
{busy ? <Loader2 size={14} className="spin" /> : "Search"}
|
||||
</button>
|
||||
</div>
|
||||
{error && <div className="picker-error">{error}</div>}
|
||||
<div className="picker-results">
|
||||
{rows.map((row) => (
|
||||
<button
|
||||
key={row.ID}
|
||||
className={`picker-row ${value?.ID === row.ID ? "selected" : ""}`}
|
||||
type="button"
|
||||
onClick={() => onChange(row)}
|
||||
>
|
||||
<span className="mono">{row.ID}</span>
|
||||
<strong>{row.FirstName || "-"}</strong>
|
||||
<span>{displayUsername(row.Username) || "-"}</span>
|
||||
{row.System ? <Badge tone="warn">{"System"}</Badge> : <Badge>{"Regular"}</Badge>}
|
||||
</button>
|
||||
))}
|
||||
{rows.length === 0 && !busy ? <div className="picker-empty">{"No results"}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -109,7 +203,6 @@ export function ChannelPicker({
|
|||
value: ChannelRow | null;
|
||||
onChange: (row: ChannelRow | null) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [query, setQuery] = useState("");
|
||||
const [rows, setRows] = useState<ChannelRow[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
|
@ -142,7 +235,7 @@ export function ChannelPicker({
|
|||
<span>{label}</span>
|
||||
{value ? (
|
||||
<button className="link-button" type="button" onClick={() => onChange(null)}>
|
||||
<X size={13} /> {t("common.clear")}
|
||||
<X size={13} /> {"Clear"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -153,7 +246,7 @@ export function ChannelPicker({
|
|||
<strong>{value.Title || "-"}</strong>
|
||||
<span className="mono">{value.ID}</span>
|
||||
</div>
|
||||
<span>{displayUsername(value.Username) || channelKind(value, t)}</span>
|
||||
<span>{displayUsername(value.Username) || channelKind(value)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="picker-search">
|
||||
|
|
@ -167,10 +260,10 @@ export function ChannelPicker({
|
|||
void search();
|
||||
}
|
||||
}}
|
||||
placeholder={t("picker.channelPlaceholder")}
|
||||
placeholder={"Search channel_id / username / title"}
|
||||
/>
|
||||
<button className="btn compact-btn" type="button" onClick={search} disabled={busy}>
|
||||
{busy ? <Loader2 size={14} className="spin" /> : t("common.search")}
|
||||
{busy ? <Loader2 size={14} className="spin" /> : "Search"}
|
||||
</button>
|
||||
</div>
|
||||
{error && <div className="picker-error">{error}</div>}
|
||||
|
|
@ -184,11 +277,11 @@ export function ChannelPicker({
|
|||
>
|
||||
<span className="mono">{row.ID}</span>
|
||||
<strong>{row.Title || "-"}</strong>
|
||||
<span>{displayUsername(row.Username) || channelKind(row, t)}</span>
|
||||
{row.Verified ? <Badge tone="good">{t("picker.verified")}</Badge> : <Badge>{channelKind(row, t)}</Badge>}
|
||||
<span>{displayUsername(row.Username) || channelKind(row)}</span>
|
||||
{row.Verified ? <Badge tone="good">{"Verified"}</Badge> : <Badge>{channelKind(row)}</Badge>}
|
||||
</button>
|
||||
))}
|
||||
{rows.length === 0 && !busy ? <div className="picker-empty">{t("common.noResults")}</div> : null}
|
||||
{rows.length === 0 && !busy ? <div className="picker-empty">{"No results"}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import {
|
||||
AtSign,
|
||||
BadgeCheck,
|
||||
Bot,
|
||||
ChevronDown,
|
||||
Database,
|
||||
|
|
@ -7,8 +9,11 @@ import {
|
|||
MessageSquareText,
|
||||
Server,
|
||||
Shield,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
Smile,
|
||||
Stamp,
|
||||
Trophy,
|
||||
Users,
|
||||
Gift,
|
||||
Sticker,
|
||||
|
|
@ -16,20 +21,19 @@ import {
|
|||
} from "lucide-react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { api } from "../api";
|
||||
import { useI18n } from "../i18n";
|
||||
import { permissionBotVerificationReview, permissionVerificationReview, useCan } from "../permissions";
|
||||
import { type Navigate, type RouteState, routeSubtitle, routeTitle } from "../routing";
|
||||
import { ThemeSwitch } from "../theme";
|
||||
import { AppLink } from "./AppLink";
|
||||
|
||||
export function BootScreen() {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="boot-screen">
|
||||
<div className="brand compact brand-elevated">
|
||||
<span className="brand-mark"><img src="/logo.png" alt="OwpenGram" /></span>
|
||||
<span>
|
||||
<strong>OwpenGram</strong>
|
||||
<small>{t("app.adminConsole")}</small>
|
||||
<small>{"Admin Console"}</small>
|
||||
</span>
|
||||
</div>
|
||||
<div className="loader-bar" />
|
||||
|
|
@ -50,7 +54,12 @@ export function Shell({
|
|||
onLogout: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
// The verification queue is hidden for a session without verification.review:
|
||||
// the entry would only lead to a 403 (and the route itself is gated as well).
|
||||
const canReviewVerification = useCan(permissionVerificationReview);
|
||||
// Same reasoning for the third-party queue, which has its own right: the two
|
||||
// sections are granted independently, so one entry can be visible without the other.
|
||||
const canReviewBotVerification = useCan(permissionBotVerificationReview);
|
||||
const messagesActive = route.path.startsWith("/messages");
|
||||
const [messagesOpen, setMessagesOpen] = useState(messagesActive);
|
||||
|
||||
|
|
@ -72,19 +81,28 @@ export function Shell({
|
|||
<span className="brand-mark"><img src="/logo.png" alt="OwpenGram" /></span>
|
||||
<span>
|
||||
<strong>OwpenGram</strong>
|
||||
<small>{t("app.adminConsole")}</small>
|
||||
<small>{"Admin Console"}</small>
|
||||
</span>
|
||||
</AppLink>
|
||||
<div className="sidebar-label">{t("layout.navigation")}</div>
|
||||
<nav className="nav-list" aria-label={t("layout.primaryNav")}>
|
||||
<NavLink icon={<LayoutDashboard size={16} />} href="/" route={route} navigate={navigate}>{t("layout.dashboard")}</NavLink>
|
||||
<NavLink icon={<Users size={16} />} href="/accounts" route={route} navigate={navigate}>{t("layout.accounts")}</NavLink>
|
||||
<NavLink icon={<ShieldCheck size={16} />} href="/channels" route={route} navigate={navigate}>{t("layout.channels")}</NavLink>
|
||||
<NavLink icon={<Bot size={16} />} href="/bots" route={route} navigate={navigate}>{t("layout.bots")}</NavLink>
|
||||
<NavLink icon={<Gift size={16} />} href="/gifts" route={route} navigate={navigate}>{t("layout.gifts")}</NavLink>
|
||||
<NavLink icon={<Send size={16} />} href="/give-gifts" route={route} navigate={navigate}>{t("layout.giveGifts")}</NavLink>
|
||||
<NavLink icon={<Sticker size={16} />} href="/stickers" route={route} navigate={navigate}>{t("layout.stickers")}</NavLink>
|
||||
<NavLink icon={<Smile size={16} />} href="/emoji" route={route} navigate={navigate}>{t("layout.emoji")}</NavLink>
|
||||
<div className="sidebar-label">{"Navigation"}</div>
|
||||
<nav className="nav-list" aria-label={"Primary navigation"}>
|
||||
<NavLink icon={<LayoutDashboard size={16} />} href="/" route={route} navigate={navigate}>{"Overview"}</NavLink>
|
||||
<NavLink icon={<Users size={16} />} href="/accounts" route={route} navigate={navigate}>{"Accounts"}</NavLink>
|
||||
<NavLink icon={<ShieldCheck size={16} />} href="/channels" route={route} navigate={navigate}>{"Supergroups / Channels"}</NavLink>
|
||||
<NavLink icon={<Bot size={16} />} href="/bots" route={route} navigate={navigate}>{"Bots"}</NavLink>
|
||||
<NavLink icon={<ShieldAlert size={16} />} href="/moderation" route={route} navigate={navigate}>{"Reports / Moderation"}</NavLink>
|
||||
{canReviewVerification && (
|
||||
<NavLink icon={<BadgeCheck size={16} />} href="/verification" route={route} navigate={navigate}>{"Verification"}</NavLink>
|
||||
)}
|
||||
{canReviewBotVerification && (
|
||||
<NavLink icon={<Stamp size={16} />} href="/bot-verification" route={route} navigate={navigate}>{"Third-party marks"}</NavLink>
|
||||
)}
|
||||
<NavLink icon={<AtSign size={16} />} href="/collectible-usernames" route={route} navigate={navigate}>{"NFT Usernames"}</NavLink>
|
||||
<NavLink icon={<Trophy size={16} />} href="/account-ratings" route={route} navigate={navigate}>{"Account Rating"}</NavLink>
|
||||
<NavLink icon={<Gift size={16} />} href="/gifts" route={route} navigate={navigate}>{"Star Gifts"}</NavLink>
|
||||
<NavLink icon={<Send size={16} />} href="/give-gifts" route={route} navigate={navigate}>{"Give Gifts"}</NavLink>
|
||||
<NavLink icon={<Sticker size={16} />} href="/stickers" route={route} navigate={navigate}>{"Stickers"}</NavLink>
|
||||
<NavLink icon={<Smile size={16} />} href="/emoji" route={route} navigate={navigate}>{"Emoji"}</NavLink>
|
||||
<div className={`nav-section ${messagesActive ? "active" : ""} ${messagesOpen ? "open" : ""}`}>
|
||||
<button
|
||||
className="nav-section-toggle"
|
||||
|
|
@ -93,7 +111,7 @@ export function Shell({
|
|||
onClick={() => setMessagesOpen((open) => !open)}
|
||||
>
|
||||
<MessageSquareText size={16} />
|
||||
<span>{t("layout.messages")}</span>
|
||||
<span>{"Messages"}</span>
|
||||
<ChevronDown className="nav-section-chevron" size={15} />
|
||||
</button>
|
||||
{messagesOpen && (
|
||||
|
|
@ -104,7 +122,7 @@ export function Shell({
|
|||
navigate={navigate}
|
||||
activeWhen={(path) => path === "/messages" || path === "/messages/detail" || path.startsWith("/messages/private")}
|
||||
>
|
||||
{t("layout.privateMessages")}
|
||||
{"Private"}
|
||||
</NavLink>
|
||||
<NavLink
|
||||
href="/messages/groups"
|
||||
|
|
@ -112,30 +130,30 @@ export function Shell({
|
|||
navigate={navigate}
|
||||
activeWhen={(path) => path.startsWith("/messages/groups")}
|
||||
>
|
||||
{t("layout.groupMessages")}
|
||||
{"Groups"}
|
||||
</NavLink>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
<div className="sidebar-status">
|
||||
<div className="sidebar-label">{t("layout.runtime")}</div>
|
||||
<div className="runtime-row"><Server size={14} /><span>{t("layout.adminBackend")}</span><strong>{t("layout.ready")}</strong></div>
|
||||
<div className="runtime-row"><Database size={14} /><span>{t("layout.pgRead")}</span><strong>{t("layout.readOnly")}</strong></div>
|
||||
<div className="runtime-row"><Shield size={14} /><span>{t("layout.writeOps")}</span><strong>{t("layout.dryRun")}</strong></div>
|
||||
<div className="sidebar-label">{"Runtime"}</div>
|
||||
<div className="runtime-row"><Server size={14} /><span>{"Admin backend"}</span><strong>{"Ready"}</strong></div>
|
||||
<div className="runtime-row"><Database size={14} /><span>{"PG read"}</span><strong>{"Read-only"}</strong></div>
|
||||
<div className="runtime-row"><Shield size={14} /><span>{"Write operations"}</span><strong>{"Dry-run"}</strong></div>
|
||||
</div>
|
||||
</aside>
|
||||
<div className="workspace">
|
||||
<header className="topbar">
|
||||
<div>
|
||||
<div className="eyebrow">{routeSubtitle(route.path, t)}</div>
|
||||
<h1>{routeTitle(route.path, t)}</h1>
|
||||
<div className="eyebrow">{routeSubtitle(route.path)}</div>
|
||||
<h1>{routeTitle(route.path)}</h1>
|
||||
</div>
|
||||
<div className="topbar-actions">
|
||||
<ThemeSwitch />
|
||||
<span className="actor-pill">{t("layout.actor", { actor })}</span>
|
||||
<button className="btn ghost icon-text" type="button" onClick={logout} title={t("layout.logout")}>
|
||||
<LogOut size={16} /> {t("layout.logout")}
|
||||
<span className="actor-pill">{`Actor: ${actor}`}</span>
|
||||
<button className="btn ghost icon-text" type="button" onClick={logout} title={"Log out"}>
|
||||
<LogOut size={16} /> {"Log out"}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { AtSign, LifeBuoy, Palette, Settings2, Smile } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ActionButton } from "./ActionButton";
|
||||
import { useI18n } from "../i18n";
|
||||
import { toInt } from "../lib/format";
|
||||
import type { ChannelRow } from "../types";
|
||||
|
||||
|
|
@ -9,10 +8,9 @@ type IDKey = "user_id" | "channel_id";
|
|||
|
||||
// SupportAction toggles the official-support flag (users/bots only).
|
||||
export function SupportAction({ id, support, onDone }: { id: number; support: boolean; onDone: () => void }) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<ActionButton
|
||||
label={support ? t("attr.clearSupport") : t("attr.setSupport")}
|
||||
label={support ? "Clear support" : "Mark as support"}
|
||||
icon={<LifeBuoy size={15} />}
|
||||
tone="neutral"
|
||||
path="/api/actions/set-support"
|
||||
|
|
@ -30,16 +28,15 @@ export function UsernameAction({ idKey, id, path, current, onDone }: {
|
|||
current: string;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [username, setUsername] = useState(current.replace(/^@/, ""));
|
||||
return (
|
||||
<div className="attr-block">
|
||||
<label className="duration-field">
|
||||
<span>{t("attr.username")}</span>
|
||||
<span>{"Username"}</span>
|
||||
<input value={username} onChange={(e) => setUsername(e.target.value)} placeholder="username" />
|
||||
</label>
|
||||
<ActionButton
|
||||
label={t("attr.setUsername")}
|
||||
label={"Set username"}
|
||||
icon={<AtSign size={15} />}
|
||||
tone="neutral"
|
||||
path={path}
|
||||
|
|
@ -57,25 +54,24 @@ export function ColorAction({ idKey, id, path, onDone }: {
|
|||
path: string;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [forProfile, setForProfile] = useState(false);
|
||||
const [hasColor, setHasColor] = useState(true);
|
||||
const [color, setColor] = useState("0");
|
||||
const [bgEmoji, setBgEmoji] = useState("");
|
||||
return (
|
||||
<div className="attr-block">
|
||||
<label className="checkline"><input type="checkbox" checked={forProfile} onChange={(e) => setForProfile(e.target.checked)} /> {t("attr.forProfile")}</label>
|
||||
<label className="checkline"><input type="checkbox" checked={hasColor} onChange={(e) => setHasColor(e.target.checked)} /> {t("attr.hasColor")}</label>
|
||||
<label className="checkline"><input type="checkbox" checked={forProfile} onChange={(e) => setForProfile(e.target.checked)} /> {"Profile color"}</label>
|
||||
<label className="checkline"><input type="checkbox" checked={hasColor} onChange={(e) => setHasColor(e.target.checked)} /> {"Enable color"}</label>
|
||||
<label className="duration-field">
|
||||
<span>{t("attr.colorIndex")}</span>
|
||||
<span>{"Color index"}</span>
|
||||
<input type="number" min="0" max="20" value={color} onChange={(e) => setColor(e.target.value)} />
|
||||
</label>
|
||||
<label className="duration-field">
|
||||
<span>{t("attr.bgEmojiID")}</span>
|
||||
<span>{"Background emoji ID"}</span>
|
||||
<input value={bgEmoji} onChange={(e) => setBgEmoji(e.target.value)} placeholder="0" />
|
||||
</label>
|
||||
<ActionButton
|
||||
label={t("attr.setColor")}
|
||||
label={"Set color"}
|
||||
icon={<Palette size={15} />}
|
||||
tone="neutral"
|
||||
path={path}
|
||||
|
|
@ -99,21 +95,20 @@ export function EmojiStatusAction({ idKey, id, path, onDone }: {
|
|||
path: string;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [documentID, setDocumentID] = useState("");
|
||||
const [until, setUntil] = useState("0");
|
||||
return (
|
||||
<div className="attr-block">
|
||||
<label className="duration-field">
|
||||
<span>{t("attr.emojiDocID")}</span>
|
||||
<span>{"Emoji document ID"}</span>
|
||||
<input value={documentID} onChange={(e) => setDocumentID(e.target.value)} placeholder="0 = clear" />
|
||||
</label>
|
||||
<label className="duration-field">
|
||||
<span>{t("attr.emojiUntil")}</span>
|
||||
<span>{"Until (unix, 0 = permanent)"}</span>
|
||||
<input type="number" min="0" value={until} onChange={(e) => setUntil(e.target.value)} />
|
||||
</label>
|
||||
<ActionButton
|
||||
label={t("attr.setEmojiStatus")}
|
||||
label={"Set emoji status"}
|
||||
icon={<Smile size={15} />}
|
||||
tone="neutral"
|
||||
path={path}
|
||||
|
|
@ -126,7 +121,6 @@ export function EmojiStatusAction({ idKey, id, path, onDone }: {
|
|||
|
||||
// ChannelSettingsAction force-applies moderation settings to a channel/supergroup.
|
||||
export function ChannelSettingsAction({ channel, onDone }: { channel: ChannelRow; onDone: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const [gigagroup, setGigagroup] = useState(channel.Gigagroup);
|
||||
const [antispam, setAntispam] = useState(channel.AntiSpam);
|
||||
const [hidden, setHidden] = useState(channel.ParticipantsHidden);
|
||||
|
|
@ -163,18 +157,18 @@ export function ChannelSettingsAction({ channel, onDone }: { channel: ChannelRow
|
|||
}
|
||||
return (
|
||||
<div className="attr-block">
|
||||
<label className="checkline"><input type="checkbox" checked={gigagroup} onChange={(e) => setGigagroup(e.target.checked)} /> {t("attr.gigagroup")}</label>
|
||||
<label className="checkline"><input type="checkbox" checked={antispam} onChange={(e) => setAntispam(e.target.checked)} /> {t("attr.antispam")}</label>
|
||||
<label className="checkline"><input type="checkbox" checked={hidden} onChange={(e) => setHidden(e.target.checked)} /> {t("attr.participantsHidden")}</label>
|
||||
<label className="checkline"><input type="checkbox" checked={noforwards} onChange={(e) => setNoforwards(e.target.checked)} /> {t("attr.noforwards")}</label>
|
||||
<label className="checkline"><input type="checkbox" checked={joinToSend} onChange={(e) => setJoinToSend(e.target.checked)} /> {t("attr.joinToSend")}</label>
|
||||
<label className="checkline"><input type="checkbox" checked={joinRequest} onChange={(e) => setJoinRequest(e.target.checked)} /> {t("attr.joinRequest")}</label>
|
||||
<label className="checkline"><input type="checkbox" checked={gigagroup} onChange={(e) => setGigagroup(e.target.checked)} /> {"Gigagroup"}</label>
|
||||
<label className="checkline"><input type="checkbox" checked={antispam} onChange={(e) => setAntispam(e.target.checked)} /> {"Aggressive anti-spam"}</label>
|
||||
<label className="checkline"><input type="checkbox" checked={hidden} onChange={(e) => setHidden(e.target.checked)} /> {"Hide members"}</label>
|
||||
<label className="checkline"><input type="checkbox" checked={noforwards} onChange={(e) => setNoforwards(e.target.checked)} /> {"Restrict forwarding"}</label>
|
||||
<label className="checkline"><input type="checkbox" checked={joinToSend} onChange={(e) => setJoinToSend(e.target.checked)} /> {"Join to send messages"}</label>
|
||||
<label className="checkline"><input type="checkbox" checked={joinRequest} onChange={(e) => setJoinRequest(e.target.checked)} /> {"Join by request"}</label>
|
||||
<label className="duration-field">
|
||||
<span>{t("attr.slowmode")}</span>
|
||||
<span>{"Slowmode (seconds)"}</span>
|
||||
<input type="number" min="0" max="86400" value={slowmode} onChange={(e) => setSlowmode(e.target.value)} />
|
||||
</label>
|
||||
<ActionButton
|
||||
label={t("attr.applySettings")}
|
||||
label={"Apply settings"}
|
||||
icon={<Settings2 size={15} />}
|
||||
tone="warn"
|
||||
path="/api/actions/set-channel-settings"
|
||||
|
|
|
|||
|
|
@ -1,18 +1,16 @@
|
|||
import { ShieldAlert, ShieldX } from "lucide-react";
|
||||
import { useI18n } from "../i18n";
|
||||
import { ActionButton } from "./ActionButton";
|
||||
import { Badge } from "./ui";
|
||||
|
||||
// ScamFakeBadges renders the SCAM/FAKE moderation labels when set.
|
||||
export function ScamFakeBadges({ scam, fake }: { scam: boolean; fake: boolean }) {
|
||||
const { t } = useI18n();
|
||||
if (!scam && !fake) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{scam && <Badge tone="danger">{t("flags.scam")}</Badge>}
|
||||
{fake && <Badge tone="danger">{t("flags.fake")}</Badge>}
|
||||
{scam && <Badge tone="danger">{"SCAM"}</Badge>}
|
||||
{fake && <Badge tone="danger">{"FAKE"}</Badge>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -35,11 +33,10 @@ export function ScamFakeActions({
|
|||
fake: boolean;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="action-stack">
|
||||
<ActionButton
|
||||
label={scam ? t("flags.clearScam") : t("flags.setScam")}
|
||||
label={scam ? "Clear SCAM" : "Mark as SCAM"}
|
||||
icon={<ShieldAlert size={15} />}
|
||||
tone="danger"
|
||||
path={path}
|
||||
|
|
@ -47,7 +44,7 @@ export function ScamFakeActions({
|
|||
onDone={onDone}
|
||||
/>
|
||||
<ActionButton
|
||||
label={fake ? t("flags.clearFake") : t("flags.setFake")}
|
||||
label={fake ? "Clear FAKE" : "Mark as FAKE"}
|
||||
icon={<ShieldX size={15} />}
|
||||
tone="danger"
|
||||
path={path}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { CircleAlert } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useI18n } from "../i18n";
|
||||
import { formatDate } from "../lib/format";
|
||||
import type { AuditLogRow } from "../types";
|
||||
import { displayUsername, formatDate } from "../lib/format";
|
||||
import type { AccountUsername, AuditLogRow } from "../types";
|
||||
|
||||
type Tone = "neutral" | "good" | "danger" | "warn";
|
||||
|
||||
|
|
@ -92,11 +91,10 @@ export function Summary({ label, value, mono = false }: { label: string; value:
|
|||
}
|
||||
|
||||
export function AuditTable({ rows }: { rows: AuditLogRow[] }) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead><tr><th>{t("audit.id")}</th><th>{t("audit.commandID")}</th><th>{t("audit.action")}</th><th>{t("audit.actor")}</th><th>{t("audit.status")}</th><th>{t("audit.dryRun")}</th><th>{t("audit.reason")}</th><th>{t("audit.time")}</th></tr></thead>
|
||||
<thead><tr><th>{"ID"}</th><th>{"Command ID"}</th><th>{"Action"}</th><th>{"Actor"}</th><th>{"Status"}</th><th>{"Dry-run"}</th><th>{"Reason"}</th><th>{"Time"}</th></tr></thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.ID}>
|
||||
|
|
@ -105,7 +103,7 @@ export function AuditTable({ rows }: { rows: AuditLogRow[] }) {
|
|||
<td>{row.Action}</td>
|
||||
<td>{row.Actor}</td>
|
||||
<td>{row.Status}</td>
|
||||
<td>{row.DryRun ? t("common.yes") : t("common.no")}</td>
|
||||
<td>{row.DryRun ? "Yes" : "No"}</td>
|
||||
<td className="truncate">{row.Reason}</td>
|
||||
<td>{formatDate(row.CreatedAt)}</td>
|
||||
</tr>
|
||||
|
|
@ -118,8 +116,7 @@ export function AuditTable({ rows }: { rows: AuditLogRow[] }) {
|
|||
}
|
||||
|
||||
export function EmptyRow({ colSpan }: { colSpan: number }) {
|
||||
const { t } = useI18n();
|
||||
return <tr><td colSpan={colSpan} className="empty-cell">{t("common.noResults")}</td></tr>;
|
||||
return <tr><td colSpan={colSpan} className="empty-cell">{"No results"}</td></tr>;
|
||||
}
|
||||
|
||||
export function LoadingSurface({ label }: { label: string }) {
|
||||
|
|
@ -129,3 +126,32 @@ export function LoadingSurface({ label }: { label: string }) {
|
|||
export function JsonBlock({ value }: { value: string }) {
|
||||
return <pre className="json-block">{value || "{}"}</pre>;
|
||||
}
|
||||
|
||||
// UsernameCell renders a peer's editable username with its collectible usernames
|
||||
// branching off underneath, in the order clients project them.
|
||||
//
|
||||
// An inactive collectible is shown rather than hidden: the peer still owns it, it
|
||||
// just does not resolve publicly, and an operator looking for "where did that name
|
||||
// go" needs to see it. It is marked instead of dropped.
|
||||
// Pass an empty username to render the branch on its own, which is what the
|
||||
// detail header does: it already shows the editable slot on the line above.
|
||||
export function UsernameCell({ username, collectibles }: { username?: string; collectibles?: AccountUsername[] | null }) {
|
||||
const main = displayUsername(username ?? "");
|
||||
const branch = collectibles ?? [];
|
||||
if (branch.length === 0) {
|
||||
return <>{main || "-"}</>;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{main}
|
||||
<ul className="username-branch">
|
||||
{branch.map((item) => (
|
||||
<li key={item.Username} className={item.Active ? "" : "inactive"}>
|
||||
<span>{displayUsername(item.Username)}</span>
|
||||
{!item.Active && <em>{"inactive"}</em>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,4 @@
|
|||
import type { AccountRow, ChannelRow } from "../types";
|
||||
import type { TFunction } from "../i18n";
|
||||
|
||||
export function displayPhone(value: string): string {
|
||||
const phone = value.trim();
|
||||
|
|
@ -17,17 +16,11 @@ export function displayName(row: Pick<AccountRow, "FirstName" | "LastName">): st
|
|||
return `${row.FirstName || ""} ${row.LastName || ""}`.trim() || "-";
|
||||
}
|
||||
|
||||
export function channelKind(ch: ChannelRow, t?: TFunction): string {
|
||||
const translate = t ?? ((key: string) => ({
|
||||
"channel.kind.broadcast": "Channel",
|
||||
"channel.kind.forum": "Supergroup / Forum",
|
||||
"channel.kind.megagroup": "Supergroup",
|
||||
"channel.kind.generic": "Channel / Group"
|
||||
})[key] ?? key);
|
||||
if (ch.Broadcast && !ch.Megagroup) return translate("channel.kind.broadcast");
|
||||
if (ch.Megagroup && ch.Forum) return translate("channel.kind.forum");
|
||||
if (ch.Megagroup) return translate("channel.kind.megagroup");
|
||||
return translate("channel.kind.generic");
|
||||
export function channelKind(ch: ChannelRow): string {
|
||||
if (ch.Broadcast && !ch.Megagroup) return "Channel";
|
||||
if (ch.Megagroup && ch.Forum) return "Supergroup / Forum";
|
||||
if (ch.Megagroup) return "Supergroup";
|
||||
return "Channel / Group";
|
||||
}
|
||||
|
||||
export function formatDate(value: string): string {
|
||||
|
|
@ -44,12 +37,131 @@ export function formatUnix(value: number): string {
|
|||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
// safeHttpURL vets a link an applicant typed. Only http(s) is turned into an
|
||||
// anchor: a submitted string may just as well be javascript:, data: or a bare
|
||||
// word, and must stay inert text in that case. The parsed href is returned so a
|
||||
// malformed authority cannot slip through the prefix test.
|
||||
export function safeHttpURL(value: string): string {
|
||||
const raw = (value ?? "").trim();
|
||||
if (!/^https?:\/\//i.test(raw)) return "";
|
||||
try {
|
||||
const parsed = new URL(raw);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return "";
|
||||
return parsed.href;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function toInt(value: string): number {
|
||||
if (!value.trim()) return 0;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
// int64 values arrive as JSON strings; keep parsing tolerant so an unexpected
|
||||
// empty string or "null" never renders as NaN.
|
||||
export function toNumeric(value: string): number {
|
||||
const raw = (value ?? "").trim();
|
||||
if (!raw) return 0;
|
||||
const parsed = Number(raw);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
export function formatQuantity(value: string): string {
|
||||
const raw = (value ?? "").trim();
|
||||
if (!raw) return "0";
|
||||
const parsed = Number(raw);
|
||||
return Number.isFinite(parsed) ? parsed.toLocaleString() : raw;
|
||||
}
|
||||
|
||||
// Currency scaling for fragment.collectibleInfo.
|
||||
//
|
||||
// The wire format is integer smallest units: core.telegram.org says amount is
|
||||
// "Total price in the smallest units of the currency (integer, not
|
||||
// float/double)" -- $1.45 is 145 -- and crypto_amount likewise, so TON is
|
||||
// nanotons (1 TON = 1e9). Clients divide by that exponent before drawing the
|
||||
// price, which is why a panel that both stores and shows the raw integer makes an
|
||||
// operator type 900 for "900 TON" and Telegram Desktop then renders 0.0000009.
|
||||
//
|
||||
// Everything the operator reads or types in the panel is therefore in whole
|
||||
// currency units, and these helpers are the only conversion boundary.
|
||||
const currencyExponents: Record<string, number> = {
|
||||
// Stars have no subunit: an XTR amount is a count of stars.
|
||||
XTR: 0,
|
||||
// Nanotons.
|
||||
TON: 9,
|
||||
// Fiat minor units.
|
||||
USD: 2,
|
||||
EUR: 2,
|
||||
RUB: 2
|
||||
};
|
||||
|
||||
export function currencyExponent(currency: string): number {
|
||||
const key = (currency ?? "").trim().toUpperCase();
|
||||
// Two decimals is the ISO 4217 default, and it is what an unknown fiat code
|
||||
// most likely is; guessing 0 would silently multiply a price by 100.
|
||||
return key in currencyExponents ? currencyExponents[key] : 2;
|
||||
}
|
||||
|
||||
// formatCurrencyAmount renders smallest units as whole currency units. It works
|
||||
// on the decimal string rather than a JS number so a nanoton amount beyond
|
||||
// Number.MAX_SAFE_INTEGER is not rounded on the way to the screen.
|
||||
export function formatCurrencyAmount(value: string, currency: string): string {
|
||||
const raw = (value ?? "").trim();
|
||||
if (!raw) return "0";
|
||||
if (!/^-?\d+$/.test(raw)) return raw;
|
||||
const exponent = currencyExponent(currency);
|
||||
const negative = raw.startsWith("-");
|
||||
const digits = (negative ? raw.slice(1) : raw).replace(/^0+(?=\d)/, "");
|
||||
const padded = digits.padStart(exponent + 1, "0");
|
||||
const whole = padded.slice(0, padded.length - exponent) || "0";
|
||||
let fraction = exponent > 0 ? padded.slice(padded.length - exponent) : "";
|
||||
// Fiat keeps its two decimals the way a client draws them ($10.00); a
|
||||
// nine-decimal crypto amount would just be a wall of zeros, so trim those.
|
||||
if (exponent > 2) fraction = fraction.replace(/0+$/, "");
|
||||
const sign = negative ? "-" : "";
|
||||
return fraction ? `${sign}${groupDigits(whole)}.${fraction}` : `${sign}${groupDigits(whole)}`;
|
||||
}
|
||||
|
||||
// groupDigits inserts thousands separators without going through a JS number, so
|
||||
// a value past Number.MAX_SAFE_INTEGER keeps every digit.
|
||||
function groupDigits(digits: string): string {
|
||||
return digits.replace(/\B(?=(\d{3})+(?!\d))/g, " ");
|
||||
}
|
||||
|
||||
// formatCurrency is formatCurrencyAmount with the code appended, which is the
|
||||
// shape every price cell in the panel wants.
|
||||
export function formatCurrency(value: string, currency: string): string {
|
||||
const code = (currency ?? "").trim().toUpperCase();
|
||||
const amount = formatCurrencyAmount(value, code);
|
||||
return code ? `${amount} ${code}` : amount;
|
||||
}
|
||||
|
||||
// toSmallestUnits turns what the operator typed -- whole currency units, with an
|
||||
// optional fraction -- into the integer decimal string the API expects. It
|
||||
// returns null for anything that is not a plain non-negative amount, or that
|
||||
// carries more decimals than the currency has, so the form can refuse instead of
|
||||
// silently truncating a price.
|
||||
export function toSmallestUnits(value: string, currency: string): string | null {
|
||||
const raw = (value ?? "").trim().replace(/\s+/g, "").replace(",", ".");
|
||||
if (!raw) return "0";
|
||||
if (!/^\d*(\.\d*)?$/.test(raw) || raw === "." ) return null;
|
||||
const exponent = currencyExponent(currency);
|
||||
const [wholePart, fractionPart = ""] = raw.split(".");
|
||||
if (fractionPart.length > exponent) return null;
|
||||
const digits = `${wholePart || "0"}${fractionPart.padEnd(exponent, "0")}`.replace(/^0+(?=\d)/, "");
|
||||
return digits === "" ? "0" : digits;
|
||||
}
|
||||
|
||||
export function formatSigned(value: string): string {
|
||||
const raw = (value ?? "").trim();
|
||||
if (!raw) return "0";
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed)) return raw;
|
||||
return parsed > 0 ? `+${parsed.toLocaleString()}` : parsed.toLocaleString();
|
||||
}
|
||||
|
||||
export function parseIDs(value: string, invalidMessage = "msg ids invalid"): number[] {
|
||||
const ids = value
|
||||
.split(/[\s,]+/)
|
||||
|
|
|
|||
|
|
@ -1,16 +1,13 @@
|
|||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import { I18nProvider } from "./i18n";
|
||||
import { ThemeProvider } from "./theme";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider>
|
||||
<I18nProvider>
|
||||
<App />
|
||||
</I18nProvider>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,16 +3,14 @@ import { useEffect, useState } from "react";
|
|||
import { api, errorMessage } from "../api";
|
||||
import { ActionButton } from "../components/ActionButton";
|
||||
import { AuthorizationTable } from "../components/AuthorizationTable";
|
||||
import { Alert, AuditTable, Badge, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
|
||||
import { Alert, AuditTable, Badge, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary, UsernameCell } from "../components/ui";
|
||||
import { ScamFakeActions, ScamFakeBadges } from "../components/flags";
|
||||
import { ColorAction, EmojiStatusAction, SupportAction, UsernameAction } from "../components/attributes";
|
||||
import { useI18n } from "../i18n";
|
||||
import { displayName, displayPhone, displayUsername, formatDate, formatUnix, toInt } from "../lib/format";
|
||||
import type { Navigate } from "../routing";
|
||||
import type { AccountDetail } from "../types";
|
||||
|
||||
export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navigate }) {
|
||||
const { t } = useI18n();
|
||||
const [detail, setDetail] = useState<AccountDetail | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
|
@ -48,15 +46,15 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
|
|||
return <Alert>{error}</Alert>;
|
||||
}
|
||||
if (!detail) {
|
||||
return <LoadingSurface label={busy ? t("account.loadingDetail") : t("account.waitingData")} />;
|
||||
return <LoadingSurface label={busy ? "Loading account detail" : "Waiting for data"} />;
|
||||
}
|
||||
|
||||
const account = detail.Account;
|
||||
return (
|
||||
<PageFrame
|
||||
title={t("account.detailTitle", { id: account.ID })}
|
||||
eyebrow={t("account.profile")}
|
||||
actions={<button className="btn icon-text" onClick={() => navigate("/accounts")}><ArrowLeft size={15} /> {t("common.backToList")}</button>}
|
||||
title={`Account #${account.ID}`}
|
||||
eyebrow={"Account Profile"}
|
||||
actions={<button className="btn icon-text" onClick={() => navigate("/accounts")}><ArrowLeft size={15} /> {"Back to list"}</button>}
|
||||
>
|
||||
<SplitLayout
|
||||
main={
|
||||
|
|
@ -64,56 +62,61 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
|
|||
<section className="entity-head">
|
||||
<div>
|
||||
<div className="entity-title">{displayName(account)}</div>
|
||||
<div className="entity-subtitle">{displayUsername(account.Username) || t("account.noUsername")} · {displayPhone(account.Phone) || t("account.noPhone")}</div>
|
||||
<div className="entity-subtitle">{displayUsername(account.Username) || "No username"} · {displayPhone(account.Phone) || "No phone"}</div>
|
||||
{account.Collectibles?.length > 0 && (
|
||||
<div className="entity-subtitle">
|
||||
<UsernameCell username="" collectibles={account.Collectibles} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="entity-badges">
|
||||
{account.PremiumUntil > 0 ? <Badge tone="good">{t("account.premium")}</Badge> : <Badge>{t("account.notPremium")}</Badge>}
|
||||
{detail.Verified ? <Badge tone="good">{t("common.verified")}</Badge> : <Badge>{t("account.notVerified")}</Badge>}
|
||||
{account.PremiumUntil > 0 ? <Badge tone="good">{"Premium"}</Badge> : <Badge>{"Not premium"}</Badge>}
|
||||
{detail.Verified ? <Badge tone="good">{"Verified"}</Badge> : <Badge>{"Not verified"}</Badge>}
|
||||
<ScamFakeBadges scam={detail.Scam} fake={detail.Fake} />
|
||||
{account.Frozen ? <Badge tone="danger">{t("account.accountFrozen")}</Badge> : <Badge>{t("account.accountActive")}</Badge>}
|
||||
{account.Frozen ? <Badge tone="danger">{"Account frozen"}</Badge> : <Badge>{"Account active"}</Badge>}
|
||||
</div>
|
||||
</section>
|
||||
<div className="summary-grid">
|
||||
<Summary label={t("account.userID")} value={String(account.ID)} mono />
|
||||
<Summary label={t("account.lastActive")} value={formatUnix(detail.LastSeenAt) || "-"} />
|
||||
<Summary label={t("account.premiumUntil")} value={account.PremiumUntil > 0 ? formatUnix(account.PremiumUntil) : t("common.none")} />
|
||||
<Summary label={t("account.starsBalance")} value={`${detail.StarsBalance} / ${detail.StarsGranted ? t("account.startingGrantApplied") : t("account.startingGrantPending")}`} />
|
||||
<Summary label={t("common.updatedAt")} value={formatDate(account.UpdatedAt) || "-"} />
|
||||
<Summary label={t("account.activeSessions")} value={String(detail.Authorizations.length)} />
|
||||
<Summary label={t("account.accountFlags")} value={`support=${detail.Support} bot=${detail.Bot}`} />
|
||||
<Summary label={t("account.restriction")} value={detail.HasRestriction ? detail.Restriction.Reason || t("account.restricted") : t("common.none")} />
|
||||
<Summary label={t("account.freezeSince")} value={detail.Restriction.Since ? formatDate(detail.Restriction.Since) : t("common.none")} />
|
||||
<Summary label={t("account.freezeUntil")} value={detail.Restriction.Until ? formatDate(detail.Restriction.Until) : t("common.none")} />
|
||||
<Summary label={t("account.freezeAppealURL")} value={detail.Restriction.AppealURL || t("common.none")} />
|
||||
<Summary label={t("account.createdAt")} value={formatDate(account.CreatedAt) || "-"} />
|
||||
<Summary label={"User ID"} value={String(account.ID)} mono />
|
||||
<Summary label={"Last active"} value={formatUnix(detail.LastSeenAt) || "-"} />
|
||||
<Summary label={"Premium expires"} value={account.PremiumUntil > 0 ? formatUnix(account.PremiumUntil) : "None"} />
|
||||
<Summary label={"Stars balance"} value={`${detail.StarsBalance} / ${detail.StarsGranted ? "initial grant applied" : "initial grant pending"}`} />
|
||||
<Summary label={"Updated"} value={formatDate(account.UpdatedAt) || "-"} />
|
||||
<Summary label={"Authorized devices"} value={String(detail.Authorizations.length)} />
|
||||
<Summary label={"Account flags"} value={`support=${detail.Support} bot=${detail.Bot}`} />
|
||||
<Summary label={"Restriction"} value={detail.HasRestriction ? detail.Restriction.Reason || "Restricted" : "None"} />
|
||||
<Summary label={"Frozen since"} value={detail.Restriction.Since ? formatDate(detail.Restriction.Since) : "None"} />
|
||||
<Summary label={"Appeal deadline"} value={detail.Restriction.Until ? formatDate(detail.Restriction.Until) : "None"} />
|
||||
<Summary label={"Appeal URL"} value={detail.Restriction.AppealURL || "None"} />
|
||||
<Summary label={"Created"} value={formatDate(account.CreatedAt) || "-"} />
|
||||
</div>
|
||||
{detail.About && <p className="about-text">{detail.About}</p>}
|
||||
<section className="section-block">
|
||||
<SectionHead title={t("account.authorizationsTitle")} text={t("account.authorizationsCount", { count: detail.Authorizations.length })} />
|
||||
<SectionHead title={"Authorized Devices"} text={`${detail.Authorizations.length} authorizations`} />
|
||||
<AuthorizationTable rows={detail.Authorizations} userID={account.ID} onDone={load} />
|
||||
</section>
|
||||
<section className="section-block">
|
||||
<SectionHead title={t("account.recentAdminOps")} text={t("account.recent30Audit")} />
|
||||
<SectionHead title={"Recent Admin Actions"} text={"Last 30 audit rows"} />
|
||||
<AuditTable rows={detail.AuditLogs} />
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
side={
|
||||
<section className="action-dock">
|
||||
<div className="dock-title">{t("account.actionDock")}</div>
|
||||
<div className="dock-title">{"Account Actions"}</div>
|
||||
<label className="duration-field">
|
||||
<span>{t("account.freezeUntil")}</span>
|
||||
<span>{"Appeal deadline"}</span>
|
||||
<input
|
||||
aria-label={t("account.freezeUntilAria")}
|
||||
aria-label={"Freeze appeal deadline"}
|
||||
value={freezeUntil}
|
||||
onChange={(event) => setFreezeUntil(event.target.value)}
|
||||
type="datetime-local"
|
||||
/>
|
||||
</label>
|
||||
<label className="duration-field">
|
||||
<span>{t("account.freezeAppealURL")}</span>
|
||||
<span>{"Appeal URL"}</span>
|
||||
<input
|
||||
aria-label={t("account.freezeAppealURLAria")}
|
||||
aria-label={"Freeze appeal URL"}
|
||||
value={freezeAppealURL}
|
||||
onChange={(event) => setFreezeAppealURL(event.target.value)}
|
||||
type="url"
|
||||
|
|
@ -121,7 +124,7 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
|
|||
/>
|
||||
</label>
|
||||
<ActionButton
|
||||
label={account.Frozen ? t("account.updateFreeze") : t("account.freezeAccount")}
|
||||
label={account.Frozen ? "Update freeze" : "Freeze account"}
|
||||
icon={<CircleAlert size={15} />}
|
||||
path="/api/actions/set-frozen"
|
||||
payload={() => ({
|
||||
|
|
@ -134,7 +137,7 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
|
|||
/>
|
||||
{account.Frozen && (
|
||||
<ActionButton
|
||||
label={t("account.unfreezeAccount")}
|
||||
label={"Unfreeze account"}
|
||||
icon={<CircleAlert size={15} />}
|
||||
path="/api/actions/set-frozen"
|
||||
payload={() => ({ user_id: account.ID, frozen: false })}
|
||||
|
|
@ -142,9 +145,9 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
|
|||
/>
|
||||
)}
|
||||
<label className="duration-field">
|
||||
<span>{t("account.premiumMonths")}</span>
|
||||
<span>{"Premium duration (months)"}</span>
|
||||
<input
|
||||
aria-label={t("account.premiumMonthsAria")}
|
||||
aria-label={"Set premium duration in months"}
|
||||
value={months}
|
||||
onChange={(event) => setMonths(event.target.value)}
|
||||
type="number"
|
||||
|
|
@ -154,7 +157,7 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
|
|||
</label>
|
||||
<div className="action-stack">
|
||||
<ActionButton
|
||||
label={t("account.setPremium")}
|
||||
label={"Set premium"}
|
||||
icon={<Sparkles size={15} />}
|
||||
tone="warn"
|
||||
path="/api/actions/grant-premium"
|
||||
|
|
@ -162,7 +165,7 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
|
|||
onDone={load}
|
||||
/>
|
||||
<ActionButton
|
||||
label={t("account.clearPremium")}
|
||||
label={"Clear premium"}
|
||||
icon={<Sparkles size={15} />}
|
||||
tone="warn"
|
||||
path="/api/actions/grant-premium"
|
||||
|
|
@ -170,9 +173,9 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
|
|||
onDone={load}
|
||||
/>
|
||||
<label className="duration-field">
|
||||
<span>{t("account.starsAmount")}</span>
|
||||
<span>{"Stars to grant"}</span>
|
||||
<input
|
||||
aria-label={t("account.starsAmountAria")}
|
||||
aria-label={"Set Stars amount to grant"}
|
||||
value={starsAmount}
|
||||
onChange={(event) => setStarsAmount(event.target.value)}
|
||||
type="number"
|
||||
|
|
@ -181,7 +184,7 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
|
|||
/>
|
||||
</label>
|
||||
<ActionButton
|
||||
label={t("account.grantStars")}
|
||||
label={"Grant Stars"}
|
||||
icon={<Star size={15} />}
|
||||
tone="warn"
|
||||
path="/api/actions/grant-stars"
|
||||
|
|
@ -189,7 +192,7 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
|
|||
onDone={load}
|
||||
/>
|
||||
<ActionButton
|
||||
label={detail.Verified ? t("account.clearVerified") : t("account.setVerified")}
|
||||
label={detail.Verified ? "Clear verified" : "Set verified"}
|
||||
icon={<BadgeCheck size={15} />}
|
||||
tone="warn"
|
||||
path="/api/actions/set-verified"
|
||||
|
|
@ -198,7 +201,7 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
|
|||
/>
|
||||
</div>
|
||||
<ScamFakeActions idKey="user_id" id={account.ID} path="/api/actions/set-account-flags" scam={detail.Scam} fake={detail.Fake} onDone={load} />
|
||||
<div className="dock-title">{t("attr.attributes")}</div>
|
||||
<div className="dock-title">{"Attributes"}</div>
|
||||
<SupportAction id={account.ID} support={detail.Support} onDone={load} />
|
||||
<UsernameAction idKey="user_id" id={account.ID} path="/api/actions/set-account-username" current={account.Username} onDone={load} />
|
||||
<ColorAction idKey="user_id" id={account.ID} path="/api/actions/set-account-color" onDone={load} />
|
||||
|
|
|
|||
265
cmd/telesrv-admin/web/src/pages/AccountRatingDetailPage.tsx
Normal file
265
cmd/telesrv-admin/web/src/pages/AccountRatingDetailPage.tsx
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
import { ArrowLeft, Calculator, RefreshCw, SlidersHorizontal, User } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { ActionButton } from "../components/ActionButton";
|
||||
import { Alert, Badge, EmptyRow, LoadingSurface, Metric, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
|
||||
import { displayUsername, formatDate, formatQuantity, formatSigned, toNumeric } from "../lib/format";
|
||||
import type { Navigate } from "../routing";
|
||||
import type { AccountRatingDetail, AccountRatingEventKind, AccountRatingRow } from "../types";
|
||||
import { LevelBadge, RatingProgress, levelProgress } from "./AccountRatingsPage";
|
||||
|
||||
export function AccountRatingDetailPage({ userID, navigate }: { userID: string; navigate: Navigate }) {
|
||||
const [detail, setDetail] = useState<AccountRatingDetail | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [adjustment, setAdjustment] = useState("");
|
||||
|
||||
async function load() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
setDetail(await api.accountRating(userID));
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [userID]);
|
||||
|
||||
if (error && !detail) {
|
||||
return <Alert>{error}</Alert>;
|
||||
}
|
||||
if (!detail) {
|
||||
return <LoadingSurface label={busy ? "Loading account rating…" : "Waiting for data"} />;
|
||||
}
|
||||
|
||||
const rating = detail.rating;
|
||||
const events = detail.events ?? [];
|
||||
const pending = toNumeric(rating.PendingStars);
|
||||
const progress = levelProgress(rating);
|
||||
// user_id / amount are `,string` int64 fields on the backend, so they stay
|
||||
// decimal strings and never pass through a float.
|
||||
const payloadUserID = rating.UserID || userID;
|
||||
|
||||
return (
|
||||
<PageFrame
|
||||
title={`Rating of ${displayUsername(rating.Username) || rating.FirstName || rating.UserID}`}
|
||||
eyebrow={"Rating / Component breakdown"}
|
||||
actions={
|
||||
<>
|
||||
<button className="btn icon-text" type="button" onClick={() => navigate("/account-ratings")}>
|
||||
<ArrowLeft size={15} /> {"Back to list"}
|
||||
</button>
|
||||
<button className="btn icon-text" type="button" onClick={load} disabled={busy}>
|
||||
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"Refresh"}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<SplitLayout
|
||||
main={
|
||||
<div className="stacked-sections">
|
||||
<section className="entity-head">
|
||||
<div>
|
||||
<div className="entity-title">{displayUsername(rating.Username) || rating.FirstName || "Unnamed bot"}</div>
|
||||
<div className="entity-subtitle">{"User ID"}: {rating.UserID}</div>
|
||||
</div>
|
||||
<div className="entity-badges">
|
||||
<LevelBadge level={rating.Level} />
|
||||
{pending !== 0 && <Badge tone="warn">{`Pending ${formatSigned(rating.PendingStars)}`}</Badge>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="metric-row">
|
||||
<Metric label={"Points"} value={formatQuantity(rating.Stars)} mono />
|
||||
<Metric label={"Level"} value={String(rating.Level)} tone="good" />
|
||||
<Metric
|
||||
label={"Next level threshold"}
|
||||
value={rating.HasNextLevel ? formatQuantity(rating.NextLevelStars) : "Max level reached"}
|
||||
mono={rating.HasNextLevel}
|
||||
/>
|
||||
<Metric
|
||||
label={"Points to next level"}
|
||||
value={rating.HasNextLevel ? formatQuantity(String(progress.remaining)) : "-"}
|
||||
mono
|
||||
tone={rating.HasNextLevel && progress.percent >= 80 ? "good" : "neutral"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="section-block">
|
||||
<SectionHead title={"How the rating adds up"} text={"Contribution of every source: stars, activity, moderation penalties and manual corrections."} />
|
||||
<Breakdown rating={rating} />
|
||||
<div className="summary-grid">
|
||||
<Summary label={"Current level threshold"} value={formatQuantity(rating.CurrentLevelStars)} mono />
|
||||
<Summary
|
||||
label={"Next level threshold"}
|
||||
value={rating.HasNextLevel ? formatQuantity(rating.NextLevelStars) : "Max level reached"}
|
||||
mono={rating.HasNextLevel}
|
||||
/>
|
||||
<Summary label={"Computed"} value={formatDate(rating.ComputedAt) || "-"} />
|
||||
<Summary label={"Updated"} value={formatDate(rating.UpdatedAt) || "-"} />
|
||||
</div>
|
||||
<div className="progress-wide">
|
||||
<RatingProgress row={rating} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{pending !== 0 && (
|
||||
<section className="section-block">
|
||||
<SectionHead title={"Pending points"} text={"Already earned, but counted towards the rating only on the date below."} />
|
||||
<div className="summary-grid">
|
||||
<Summary label={"Pending"} value={formatSigned(rating.PendingStars)} mono />
|
||||
<Summary label={"Applied on"} value={formatDate(rating.PendingDate) || "-"} />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="section-block">
|
||||
<SectionHead title={"Rating events"} text={"Every rating change with its source, actor and reason."} />
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{"ID"}</th>
|
||||
<th>{"Source"}</th>
|
||||
<th>{"Change"}</th>
|
||||
<th>{"Reason"}</th>
|
||||
<th>{"Actor"}</th>
|
||||
<th>{"Time"}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{events.map((row) => (
|
||||
<tr key={row.ID}>
|
||||
<td className="mono">{row.ID}</td>
|
||||
<td><EventKind kind={row.Kind} /></td>
|
||||
<td className="mono">{formatSigned(row.Amount)}</td>
|
||||
<td className="truncate">{row.Reason || "-"}</td>
|
||||
<td>{row.Actor || "-"}</td>
|
||||
<td>{formatDate(row.CreatedAt) || "-"}</td>
|
||||
</tr>
|
||||
))}
|
||||
{events.length === 0 && <EmptyRow colSpan={6} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
side={
|
||||
<section className="action-dock">
|
||||
<div className="dock-title">{"Rating operations"}</div>
|
||||
<button className="btn icon-text" type="button" onClick={() => navigate(`/accounts/${rating.UserID}`)}>
|
||||
<User size={15} /> {"Open account"}
|
||||
</button>
|
||||
<div className="action-stack">
|
||||
<ActionButton
|
||||
label={"Recompute"}
|
||||
icon={<Calculator size={15} />}
|
||||
tone="neutral"
|
||||
path="/api/actions/recompute-account-rating"
|
||||
payload={() => ({ user_id: payloadUserID })}
|
||||
onDone={load}
|
||||
/>
|
||||
</div>
|
||||
<p className="bot-create-note">{"Rebuilds the rating from stars, activity, penalties and manual corrections."}</p>
|
||||
<div className="dock-title">{"Manual correction"}</div>
|
||||
<label className="duration-field">
|
||||
<span>{"Value (negative allowed)"}</span>
|
||||
<input
|
||||
value={adjustment}
|
||||
onChange={(event) => setAdjustment(event.target.value)}
|
||||
type="number"
|
||||
step="1"
|
||||
placeholder="-500"
|
||||
/>
|
||||
</label>
|
||||
<div className="action-stack">
|
||||
<ActionButton
|
||||
label={"Apply correction"}
|
||||
icon={<SlidersHorizontal size={15} />}
|
||||
tone="warn"
|
||||
path="/api/actions/adjust-account-rating"
|
||||
payload={() => ({
|
||||
user_id: payloadUserID,
|
||||
amount: String(Number.parseInt(adjustment.trim() || "0", 10) || 0)
|
||||
})}
|
||||
onDone={() => {
|
||||
setAdjustment("");
|
||||
void load();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="bot-create-note">{"The value is added to the manual component; a negative number lowers the rating."}</p>
|
||||
</section>
|
||||
}
|
||||
/>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function Breakdown({ rating }: { rating: AccountRatingRow }) {
|
||||
// PenaltyComponent is stored as a positive magnitude and subtracted by the
|
||||
// scorer, so it is shown (and summed) as a negative contribution.
|
||||
const components = [
|
||||
{ key: "stars", label: "Stars", hint: "Purchased and received stars", value: toNumeric(rating.StarsComponent) },
|
||||
{ key: "activity", label: "Activity", hint: "Messages, sessions and long-term engagement", value: toNumeric(rating.ActivityComponent) },
|
||||
{ key: "penalty", label: "Penalties", hint: "Moderation decisions and restrictions", value: -toNumeric(rating.PenaltyComponent) },
|
||||
{ key: "manual", label: "Manual corrections", hint: "Adjustments made by admins", value: toNumeric(rating.ManualComponent) }
|
||||
];
|
||||
const scale = Math.max(1, ...components.map((item) => Math.abs(item.value)));
|
||||
// The score is clamped at zero, and a delayed increase sits in PendingStars
|
||||
// instead of the score, so both cases are expected rather than drift.
|
||||
const sum = Math.max(0, components.reduce((total, item) => total + item.value, 0));
|
||||
const total = toNumeric(rating.Stars);
|
||||
const pending = toNumeric(rating.PendingStars);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="breakdown-list">
|
||||
{components.map((item) => {
|
||||
const percent = Math.min(100, (Math.abs(item.value) / scale) * 100);
|
||||
const tone = item.value < 0 ? "danger" : item.value > 0 ? "good" : "";
|
||||
return (
|
||||
<div className="breakdown-row" key={item.key}>
|
||||
<div className="breakdown-label">
|
||||
<strong>{item.label}</strong>
|
||||
<small>{item.hint}</small>
|
||||
</div>
|
||||
<div className={`progress-bar ${tone}`} role="img" aria-label={String(item.value)}>
|
||||
<span style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
<div className={`breakdown-value mono ${tone}`}>{formatSigned(String(item.value))}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="breakdown-row total">
|
||||
<div className="breakdown-label"><strong>{"Total rating"}</strong></div>
|
||||
<div className="breakdown-value mono">{formatQuantity(rating.Stars)}</div>
|
||||
</div>
|
||||
</div>
|
||||
{pending === 0 && sum !== total && (
|
||||
<Alert>{`Components add up to ${formatQuantity(String(sum))} while the stored rating is ${formatQuantity(rating.Stars)}. Recompute to resolve the drift.`}</Alert>
|
||||
)}
|
||||
{pending !== 0 && <p className="bot-create-note">{`Components already include ${formatSigned(rating.PendingStars)} that reaches the score only on the date below.`}</p>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const ratingKindLabels: Record<AccountRatingEventKind, string> = {
|
||||
stars: "Stars",
|
||||
activity: "Activity",
|
||||
moderation: "Moderation",
|
||||
manual: "Manual",
|
||||
recompute: "Recompute"
|
||||
};
|
||||
|
||||
function EventKind({ kind }: { kind: AccountRatingEventKind }) {
|
||||
const tone = kind === "moderation" ? "danger" : kind === "manual" ? "warn" : kind === "recompute" ? "neutral" : "good";
|
||||
return <Badge tone={tone}>{ratingKindLabels[kind]}</Badge>;
|
||||
}
|
||||
163
cmd/telesrv-admin/web/src/pages/AccountRatingsPage.tsx
Normal file
163
cmd/telesrv-admin/web/src/pages/AccountRatingsPage.tsx
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import { ChevronDown, ChevronRight, Loader2, RefreshCw, Search, Trophy } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
|
||||
import { displayUsername, formatDate, formatQuantity, toNumeric } from "../lib/format";
|
||||
import type { Navigate } from "../routing";
|
||||
import type { AccountRatingRow } from "../types";
|
||||
|
||||
export function AccountRatingsPage({ navigate }: { navigate: Navigate }) {
|
||||
const [minLevel, setMinLevel] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [limit, setLimit] = useState("50");
|
||||
const [rows, setRows] = useState<AccountRatingRow[]>([]);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [cursor, setCursor] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function load(next = false) {
|
||||
// One free-text field: the backend matches a username prefix (editable or
|
||||
// collectible), a first/last name prefix, and a bare number as the user id.
|
||||
const wanted = search.trim();
|
||||
setBusy(true);
|
||||
setError("");
|
||||
const params = new URLSearchParams({ limit });
|
||||
if (minLevel.trim()) params.set("min_level", minLevel.trim());
|
||||
if (wanted) params.set("q", wanted);
|
||||
if (next && cursor) params.set("before_id", cursor);
|
||||
try {
|
||||
const result = await api.accountRatings(params);
|
||||
const page = result.rows ?? [];
|
||||
setRows((current) => (next ? [...current, ...page] : page));
|
||||
setCursor(result.next_before_id ?? "");
|
||||
setHasMore(Boolean(result.has_more));
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load(false);
|
||||
}, []);
|
||||
|
||||
const topLevel = rows.reduce((max, row) => Math.max(max, row.Level), 0);
|
||||
const pendingCount = rows.filter((row) => toNumeric(row.PendingStars) !== 0).length;
|
||||
const avgLevel = rows.length > 0
|
||||
? (rows.reduce((sum, row) => sum + row.Level, 0) / rows.length).toFixed(1)
|
||||
: "0";
|
||||
|
||||
return (
|
||||
<PageFrame
|
||||
title={"Account rating leaderboard"}
|
||||
eyebrow={"Rating / Leaderboard"}
|
||||
actions={
|
||||
<button className="btn icon-text" type="button" onClick={() => load(false)} disabled={busy}>
|
||||
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"Refresh"}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="metric-row">
|
||||
<Metric label={"Loaded rows"} value={String(rows.length)} />
|
||||
<Metric label={"Top level"} value={String(topLevel)} tone="good" />
|
||||
<Metric label={"Average level"} value={avgLevel} />
|
||||
<Metric label={"With pending points"} value={String(pendingCount)} tone={pendingCount ? "warn" : "neutral"} />
|
||||
</div>
|
||||
|
||||
<QueryPanel>
|
||||
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
|
||||
<label className="searchbox">
|
||||
<Search size={15} />
|
||||
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder={"Search by username, name or user ID"} />
|
||||
</label>
|
||||
<label className="field-inline">
|
||||
<span>{"Min level"}</span>
|
||||
<input className="small-input" value={minLevel} onChange={(event) => setMinLevel(event.target.value)} type="number" min="0" placeholder="0" />
|
||||
</label>
|
||||
<label className="field-inline">
|
||||
<span>{"Limit"}</span>
|
||||
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="200" />
|
||||
</label>
|
||||
<button className="btn primary icon-text" type="submit" disabled={busy}>
|
||||
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {"Search"}
|
||||
</button>
|
||||
</form>
|
||||
</QueryPanel>
|
||||
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{"User ID"}</th>
|
||||
<th>{"Username"}</th>
|
||||
<th>{"Level"}</th>
|
||||
<th>{"Points"}</th>
|
||||
<th>{"Progress to next level"}</th>
|
||||
<th>{"Pending"}</th>
|
||||
<th>{"Computed"}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.UserID}>
|
||||
<td className="mono">{row.UserID}</td>
|
||||
<td>{displayUsername(row.Username) || row.FirstName || "-"}</td>
|
||||
<td><LevelBadge level={row.Level} /></td>
|
||||
<td className="mono">{formatQuantity(row.Stars)}</td>
|
||||
<td><RatingProgress row={row} /></td>
|
||||
<td className="mono">{toNumeric(row.PendingStars) !== 0 ? formatQuantity(row.PendingStars) : "-"}</td>
|
||||
<td>{formatDate(row.ComputedAt) || "-"}</td>
|
||||
<td>
|
||||
<button className="row-link" type="button" onClick={() => navigate(`/account-ratings/${row.UserID}`)}>
|
||||
<Trophy size={14} /> {"Details"} <ChevronRight size={14} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows.length === 0 && <EmptyRow colSpan={8} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{hasMore && (
|
||||
<div className="toolbar">
|
||||
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
|
||||
{busy ? <Loader2 size={15} className="spin" /> : <ChevronDown size={15} />} {"Load more"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export function LevelBadge({ level }: { level: number }) {
|
||||
const tone = level >= 10 ? "good" : level >= 5 ? "warn" : "neutral";
|
||||
return <Badge tone={tone}>{`Level ${level}`}</Badge>;
|
||||
}
|
||||
|
||||
export function levelProgress(row: AccountRatingRow): { percent: number; remaining: number; target: number; stars: number } {
|
||||
const stars = toNumeric(row.Stars);
|
||||
const current = toNumeric(row.CurrentLevelStars);
|
||||
const target = toNumeric(row.NextLevelStars);
|
||||
const span = target - current;
|
||||
const percent = span > 0 ? Math.min(100, Math.max(0, ((stars - current) / span) * 100)) : 0;
|
||||
return { percent, remaining: Math.max(0, target - stars), target, stars };
|
||||
}
|
||||
|
||||
export function RatingProgress({ row }: { row: AccountRatingRow }) {
|
||||
if (!row.HasNextLevel) {
|
||||
return <span className="progress-note">{"Max level reached"}</span>;
|
||||
}
|
||||
const { percent, remaining, target } = levelProgress(row);
|
||||
return (
|
||||
<div className="progress-cell">
|
||||
<div className="progress-bar" role="img" aria-label={`${Math.round(percent)}%`}>
|
||||
<span style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
<small>{`${formatQuantity(String(remaining))} left to reach ${formatQuantity(String(target))}`}</small>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,10 +2,9 @@ import { ChevronLeft, ChevronRight, Loader2, RefreshCw, Search } from "lucide-re
|
|||
import { useEffect, useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { Avatar } from "../components/Avatar";
|
||||
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
|
||||
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel, UsernameCell } from "../components/ui";
|
||||
import { ScamFakeBadges } from "../components/flags";
|
||||
import { useI18n } from "../i18n";
|
||||
import { displayName, displayPhone, displayUsername, formatDate, formatUnix } from "../lib/format";
|
||||
import { displayName, displayPhone, formatDate, formatUnix } from "../lib/format";
|
||||
import { accountMetrics } from "../lib/metrics";
|
||||
import type { Navigate } from "../routing";
|
||||
import type { AccountListResponse, AccountStatsResponse } from "../types";
|
||||
|
|
@ -16,7 +15,6 @@ type AccountPageSize = 10 | 20 | 50 | 100;
|
|||
const zeroCursor: Cursor = { beforeID: 0, beforeActiveUS: 0 };
|
||||
|
||||
export function AccountsPage({ navigate }: { navigate: Navigate }) {
|
||||
const { t } = useI18n();
|
||||
const [q, setQ] = useState("");
|
||||
const [limit, setLimit] = useState<AccountPageSize>(50);
|
||||
const [data, setData] = useState<AccountListResponse | null>(null);
|
||||
|
|
@ -98,8 +96,8 @@ export function AccountsPage({ navigate }: { navigate: Navigate }) {
|
|||
|
||||
return (
|
||||
<PageFrame
|
||||
title={t("account.pageTitle")}
|
||||
eyebrow={data?.listing === false ? t("account.queryResults") : t("account.recentActive")}
|
||||
title={"Accounts"}
|
||||
eyebrow={data?.listing === false ? "Search results" : "Recently active accounts"}
|
||||
actions={
|
||||
<button
|
||||
className="btn"
|
||||
|
|
@ -110,24 +108,24 @@ export function AccountsPage({ navigate }: { navigate: Navigate }) {
|
|||
}}
|
||||
disabled={busy}
|
||||
>
|
||||
<RefreshCw size={15} /> {t("common.refresh")}
|
||||
<RefreshCw size={15} /> {"Refresh"}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="metric-row">
|
||||
<Metric label={t("account.totalUsers")} value={stats ? String(stats.total) : "…"} />
|
||||
<Metric label={t("account.onlineNow")} value={stats ? String(stats.online) : "…"} tone="good" />
|
||||
<Metric label={t("account.onlineDevices")} value={String(metrics.devices)} />
|
||||
<Metric label={"Total users"} value={stats ? String(stats.total) : "…"} />
|
||||
<Metric label={"Online now"} value={stats ? String(stats.online) : "…"} tone="good" />
|
||||
<Metric label={"Online device records"} value={String(metrics.devices)} />
|
||||
</div>
|
||||
<QueryPanel>
|
||||
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void loadFresh(); }}>
|
||||
<label className="searchbox">
|
||||
<Search size={15} />
|
||||
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={t("account.searchPlaceholder")} />
|
||||
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={"User ID / phone / username"} />
|
||||
</label>
|
||||
<label className="gift-page-size">
|
||||
<span>{t("common.limit")}</span>
|
||||
<span>{"Limit"}</span>
|
||||
<select value={String(limit)} onChange={(event) => setLimit(Number(event.target.value) as AccountPageSize)}>
|
||||
<option value="10">10</option>
|
||||
<option value="20">20</option>
|
||||
|
|
@ -136,13 +134,13 @@ export function AccountsPage({ navigate }: { navigate: Navigate }) {
|
|||
</select>
|
||||
</label>
|
||||
<button className="btn primary icon-text" type="submit" disabled={busy}>
|
||||
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {t("common.search")}
|
||||
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {"Search"}
|
||||
</button>
|
||||
<button className="btn icon-text" type="button" onClick={() => void loadPrev()} disabled={!canGoPrev}>
|
||||
<ChevronLeft size={15} /> {t("common.previous")}
|
||||
<ChevronLeft size={15} /> {"Previous page"}
|
||||
</button>
|
||||
<button className="btn icon-text" type="button" onClick={() => void loadNext()} disabled={!canGoNext}>
|
||||
<ChevronRight size={15} /> {t("common.next")}
|
||||
<ChevronRight size={15} /> {"Next page"}
|
||||
</button>
|
||||
</form>
|
||||
</QueryPanel>
|
||||
|
|
@ -151,17 +149,17 @@ export function AccountsPage({ navigate }: { navigate: Navigate }) {
|
|||
<thead>
|
||||
<tr>
|
||||
<th className="avatar-col"></th>
|
||||
<th>{t("account.userID")}</th>
|
||||
<th>{t("account.phone")}</th>
|
||||
<th>{t("common.username")}</th>
|
||||
<th>{t("common.name")}</th>
|
||||
<th>{t("account.loginEmail")}</th>
|
||||
<th>{t("common.device")}</th>
|
||||
<th>{t("account.lastActive")}</th>
|
||||
<th>{t("account.premium")}</th>
|
||||
<th>{t("common.verified")}</th>
|
||||
<th>{t("account.frozen")}</th>
|
||||
<th>{t("common.updatedAt")}</th>
|
||||
<th>{"User ID"}</th>
|
||||
<th>{"Phone"}</th>
|
||||
<th>{"Username"}</th>
|
||||
<th>{"Name"}</th>
|
||||
<th>{"Login email"}</th>
|
||||
<th>{"Device"}</th>
|
||||
<th>{"Last active"}</th>
|
||||
<th>{"Premium"}</th>
|
||||
<th>{"Verified"}</th>
|
||||
<th>{"Frozen"}</th>
|
||||
<th>{"Updated"}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -171,16 +169,16 @@ export function AccountsPage({ navigate }: { navigate: Navigate }) {
|
|||
<td className="avatar-col"><Avatar userID={row.ID} firstName={row.FirstName} lastName={row.LastName} username={row.Username} /></td>
|
||||
<td className="mono">{row.ID}</td>
|
||||
<td>{displayPhone(row.Phone)}</td>
|
||||
<td>{displayUsername(row.Username)}</td>
|
||||
<td><UsernameCell username={row.Username} collectibles={row.Collectibles} /></td>
|
||||
<td>{displayName(row)}</td>
|
||||
<td>{row.LoginEmail || <span className="muted-cell">{t("common.none")}</span>}</td>
|
||||
<td>{row.LoginEmail || <span className="muted-cell">{"None"}</span>}</td>
|
||||
<td>{row.DeviceCount}</td>
|
||||
<td>{formatDate(row.LastActiveAt)}</td>
|
||||
<td>{row.PremiumUntil > 0 ? <Badge tone="good">{t("account.premium")} {formatUnix(row.PremiumUntil)}</Badge> : <Badge>{t("common.none")}</Badge>}</td>
|
||||
<td>{row.Verified ? <Badge tone="good">{t("common.verified")}</Badge> : <Badge>{t("account.notVerified")}</Badge>} <ScamFakeBadges scam={row.Scam} fake={row.Fake} /></td>
|
||||
<td>{row.Frozen ? <Badge tone="danger">{t("account.frozen")}</Badge> : <Badge>{t("common.normal")}</Badge>}</td>
|
||||
<td>{row.PremiumUntil > 0 ? <Badge tone="good">{"Premium"} {formatUnix(row.PremiumUntil)}</Badge> : <Badge>{"None"}</Badge>}</td>
|
||||
<td>{row.Verified ? <Badge tone="good">{"Verified"}</Badge> : <Badge>{"Not verified"}</Badge>} <ScamFakeBadges scam={row.Scam} fake={row.Fake} /></td>
|
||||
<td>{row.Frozen ? <Badge tone="danger">{"Frozen"}</Badge> : <Badge>{"Normal"}</Badge>}</td>
|
||||
<td>{formatDate(row.UpdatedAt)}</td>
|
||||
<td><button className="row-link" onClick={() => navigate(`/accounts/${row.ID}`)}>{t("common.detail")} <ChevronRight size={14} /></button></td>
|
||||
<td><button className="row-link" onClick={() => navigate(`/accounts/${row.ID}`)}>{"Details"} <ChevronRight size={14} /></button></td>
|
||||
</tr>
|
||||
))}
|
||||
{(!data || data.rows.length === 0) && <EmptyRow colSpan={12} />}
|
||||
|
|
|
|||
|
|
@ -5,13 +5,11 @@ import { ActionButton } from "../components/ActionButton";
|
|||
import { Alert, AuditTable, Badge, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
|
||||
import { ScamFakeActions, ScamFakeBadges } from "../components/flags";
|
||||
import { ColorAction, EmojiStatusAction, UsernameAction } from "../components/attributes";
|
||||
import { useI18n } from "../i18n";
|
||||
import { displayUsername, formatDate } from "../lib/format";
|
||||
import type { Navigate } from "../routing";
|
||||
import type { BotDetail } from "../types";
|
||||
|
||||
export function BotDetailPage({ id, navigate }: { id: number; navigate: Navigate }) {
|
||||
const { t } = useI18n();
|
||||
const [detail, setDetail] = useState<BotDetail | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
|
@ -36,51 +34,51 @@ export function BotDetailPage({ id, navigate }: { id: number; navigate: Navigate
|
|||
return <Alert>{error}</Alert>;
|
||||
}
|
||||
if (!detail) {
|
||||
return <LoadingSurface label={busy ? t("bots.loadingDetail") : t("account.waitingData")} />;
|
||||
return <LoadingSurface label={busy ? "Loading bot detail" : "Waiting for data"} />;
|
||||
}
|
||||
|
||||
const bot = detail.Bot;
|
||||
return (
|
||||
<PageFrame
|
||||
title={t("bots.detailTitle", { id: bot.ID })}
|
||||
eyebrow={t("bots.profile")}
|
||||
actions={<button className="btn icon-text" onClick={() => navigate("/bots")}><ArrowLeft size={15} /> {t("common.backToList")}</button>}
|
||||
title={`Bot #${bot.ID}`}
|
||||
eyebrow={"Bot Profile"}
|
||||
actions={<button className="btn icon-text" onClick={() => navigate("/bots")}><ArrowLeft size={15} /> {"Back to list"}</button>}
|
||||
>
|
||||
<SplitLayout
|
||||
main={
|
||||
<div className="stacked-sections">
|
||||
<section className="entity-head">
|
||||
<div>
|
||||
<div className="entity-title">{bot.FirstName || t("bots.unnamed")}</div>
|
||||
<div className="entity-subtitle">{displayUsername(bot.Username) || t("account.noUsername")}</div>
|
||||
<div className="entity-title">{bot.FirstName || "Unnamed bot"}</div>
|
||||
<div className="entity-subtitle">{displayUsername(bot.Username) || "No username"}</div>
|
||||
</div>
|
||||
<div className="entity-badges">
|
||||
<Badge tone={bot.System ? "warn" : "neutral"}>{bot.System ? t("bots.system") : t("bots.user")}</Badge>
|
||||
{bot.Verified ? <Badge tone="good">{t("common.verified")}</Badge> : <Badge>{t("account.notVerified")}</Badge>}
|
||||
<Badge tone={bot.System ? "warn" : "neutral"}>{bot.System ? "System" : "User"}</Badge>
|
||||
{bot.Verified ? <Badge tone="good">{"Verified"}</Badge> : <Badge>{"Not verified"}</Badge>}
|
||||
<ScamFakeBadges scam={bot.Scam} fake={bot.Fake} />
|
||||
</div>
|
||||
</section>
|
||||
<div className="summary-grid">
|
||||
<Summary label={t("bots.botID")} value={String(bot.ID)} mono />
|
||||
<Summary label={t("bots.owner")} value={bot.OwnerUserID > 0 ? `${bot.OwnerUserID} ${displayUsername(detail.OwnerUsername)}`.trim() : t("common.none")} />
|
||||
<Summary label={t("bots.type")} value={bot.System ? t("bots.system") : t("bots.user")} />
|
||||
<Summary label={t("common.updatedAt")} value={formatDate(bot.UpdatedAt) || "-"} />
|
||||
<Summary label={t("account.createdAt")} value={formatDate(bot.CreatedAt) || "-"} />
|
||||
<Summary label={"Bot ID"} value={String(bot.ID)} mono />
|
||||
<Summary label={"Owner"} value={bot.OwnerUserID > 0 ? `${bot.OwnerUserID} ${displayUsername(detail.OwnerUsername)}`.trim() : "None"} />
|
||||
<Summary label={"Type"} value={bot.System ? "System" : "User"} />
|
||||
<Summary label={"Updated"} value={formatDate(bot.UpdatedAt) || "-"} />
|
||||
<Summary label={"Created"} value={formatDate(bot.CreatedAt) || "-"} />
|
||||
</div>
|
||||
{detail.About && <p className="about-text">{detail.About}</p>}
|
||||
{detail.Description && detail.Description.trim() !== detail.About.trim() && <p className="about-text">{detail.Description}</p>}
|
||||
<section className="section-block">
|
||||
<SectionHead title={t("account.recentAdminOps")} text={t("account.recent30Audit")} />
|
||||
<SectionHead title={"Recent Admin Actions"} text={"Last 30 audit rows"} />
|
||||
<AuditTable rows={detail.AuditLogs} />
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
side={
|
||||
<section className="action-dock">
|
||||
<div className="dock-title">{t("bots.actionDock")}</div>
|
||||
<div className="dock-title">{"Bot Actions"}</div>
|
||||
<div className="action-stack">
|
||||
<ActionButton
|
||||
label={bot.Verified ? t("account.clearVerified") : t("account.setVerified")}
|
||||
label={bot.Verified ? "Clear verified" : "Set verified"}
|
||||
icon={<BadgeCheck size={15} />}
|
||||
tone="neutral"
|
||||
path="/api/actions/set-verified"
|
||||
|
|
@ -89,23 +87,23 @@ export function BotDetailPage({ id, navigate }: { id: number; navigate: Navigate
|
|||
/>
|
||||
</div>
|
||||
<ScamFakeActions idKey="user_id" id={bot.ID} path="/api/actions/set-account-flags" scam={bot.Scam} fake={bot.Fake} onDone={load} />
|
||||
<div className="dock-title">{t("attr.attributes")}</div>
|
||||
<div className="dock-title">{"Attributes"}</div>
|
||||
<UsernameAction idKey="user_id" id={bot.ID} path="/api/actions/set-account-username" current={bot.Username} onDone={load} />
|
||||
<ColorAction idKey="user_id" id={bot.ID} path="/api/actions/set-account-color" onDone={load} />
|
||||
<EmojiStatusAction idKey="user_id" id={bot.ID} path="/api/actions/set-account-emoji-status" onDone={load} />
|
||||
{bot.System ? (
|
||||
<p className="bot-create-note">{t("bots.systemHint")}</p>
|
||||
<p className="bot-create-note">{"System bots are built in and cannot be deleted."}</p>
|
||||
) : (
|
||||
<div className="danger-zone">
|
||||
<ActionButton
|
||||
label={t("bots.delete")}
|
||||
label={"Delete bot"}
|
||||
icon={<Trash2 size={15} />}
|
||||
tone="danger"
|
||||
path="/api/actions/delete-bot"
|
||||
payload={() => ({ bot_user_id: bot.ID })}
|
||||
onDone={() => navigate("/bots")}
|
||||
/>
|
||||
<p className="bot-create-note">{t("bots.deleteHint")}</p>
|
||||
<p className="bot-create-note">{"Permanently deletes this user-created bot and invalidates its token. This cannot be undone."}</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
|
|
|||
966
cmd/telesrv-admin/web/src/pages/BotVerificationPage.tsx
Normal file
966
cmd/telesrv-admin/web/src/pages/BotVerificationPage.tsx
Normal file
|
|
@ -0,0 +1,966 @@
|
|||
import {
|
||||
Ban,
|
||||
BadgeCheck,
|
||||
Building2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
Plus,
|
||||
Power,
|
||||
PowerOff,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Stamp,
|
||||
Sticker,
|
||||
Trash2
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { api, APIError, errorMessage } from "../api";
|
||||
import { ActionButton } from "../components/ActionButton";
|
||||
import { BotPicker } from "../components/EntityPicker";
|
||||
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel, SectionHead } from "../components/ui";
|
||||
import { displayUsername, formatDate } from "../lib/format";
|
||||
import {
|
||||
permissionBotVerificationManage,
|
||||
permissionVerificationReview,
|
||||
usePermissions
|
||||
} from "../permissions";
|
||||
import type { Navigate } from "../routing";
|
||||
import type {
|
||||
BotRow,
|
||||
BotVerificationPeerType,
|
||||
BotVerifierRow,
|
||||
CustomVerificationRequestRow,
|
||||
CustomVerificationRequestStatus,
|
||||
CustomVerificationRow,
|
||||
VerificationIconRow
|
||||
} from "../types";
|
||||
|
||||
type Tab = "requests" | "verifiers" | "icons" | "marks";
|
||||
type StatusFilter = "all" | CustomVerificationRequestStatus;
|
||||
type PeerTypeFilter = "all" | BotVerificationPeerType;
|
||||
|
||||
const statuses: CustomVerificationRequestStatus[] = ["pending", "approved", "rejected", "revoked"];
|
||||
const peerTypes: BotVerificationPeerType[] = ["user", "channel"];
|
||||
|
||||
export const statusLabels: Record<CustomVerificationRequestStatus, string> = {
|
||||
pending: "Pending",
|
||||
approved: "Approved",
|
||||
rejected: "Rejected",
|
||||
revoked: "Mark revoked"
|
||||
};
|
||||
|
||||
export const peerTypeLabels: Record<BotVerificationPeerType, string> = {
|
||||
user: "Account",
|
||||
channel: "Channel"
|
||||
};
|
||||
|
||||
// The section owns four different objects — applications, verifiers, the icon
|
||||
// catalogue and the granted marks — and mixing them into one table would hide which
|
||||
// row an action addresses. They are separate tabs over one shared verifier/icon
|
||||
// load: the roster feeds three of the four filters, so it is fetched once here
|
||||
// rather than per tab.
|
||||
export function BotVerificationPage({ navigate }: { navigate: Navigate }) {
|
||||
const { can } = usePermissions();
|
||||
const canManage = can(permissionBotVerificationManage);
|
||||
const canSeeOfficial = can(permissionVerificationReview);
|
||||
const [tab, setTab] = useState<Tab>("requests");
|
||||
const [verifiers, setVerifiers] = useState<BotVerifierRow[]>([]);
|
||||
const [icons, setIcons] = useState<VerificationIconRow[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
const [rosterDenied, setRosterDenied] = useState(false);
|
||||
|
||||
async function loadRoster() {
|
||||
setError("");
|
||||
setRosterDenied(false);
|
||||
try {
|
||||
const [verifierResult, iconResult] = await Promise.all([
|
||||
api.botVerifiers(new URLSearchParams({ limit: "200" })),
|
||||
api.verificationIcons(new URLSearchParams({ limit: "200" }))
|
||||
]);
|
||||
setVerifiers(verifierResult.rows ?? []);
|
||||
setIcons(iconResult.rows ?? []);
|
||||
} catch (err) {
|
||||
// A 403 here is not a fault to alarm about: it means the session may review
|
||||
// applications but not see the roster. Saying so beats an empty table that
|
||||
// reads as "no verifiers configured".
|
||||
if (err instanceof APIError && err.status === 403) {
|
||||
setVerifiers([]);
|
||||
setIcons([]);
|
||||
setRosterDenied(true);
|
||||
return;
|
||||
}
|
||||
setError(errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadRoster();
|
||||
}, []);
|
||||
|
||||
const tabs: Array<{ key: Tab; label: string; icon: ReactNode }> = [
|
||||
{ key: "requests", label: "Applications", icon: <Stamp size={15} /> },
|
||||
{ key: "verifiers", label: "Verifiers", icon: <Building2 size={15} /> },
|
||||
{ key: "icons", label: "Icon catalogue", icon: <Sticker size={15} /> },
|
||||
{ key: "marks", label: "Granted marks", icon: <BadgeCheck size={15} /> }
|
||||
];
|
||||
|
||||
return (
|
||||
<PageFrame
|
||||
title={"Third-party verification"}
|
||||
eyebrow={"Third-party verification / Verifiers, icons, marks"}
|
||||
actions={
|
||||
canSeeOfficial ? (
|
||||
<button className="btn icon-text" type="button" onClick={() => navigate("/verification")}>
|
||||
<ExternalLink size={15} /> {"Official verification"}
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{rosterDenied && <Alert>{"The server refused the verifier roster and the icon catalogue for this session (403), so both lists are empty here — applications can still be reviewed."}</Alert>}
|
||||
{/* The one thing an operator has to understand before touching anything here:
|
||||
this is a verifier company's own icon, not the platform checkmark. */}
|
||||
<section className="section-block">
|
||||
<SectionHead title={"A verifier company's icon — not the official checkmark"} text={"A third-party mark is a verifier bot's own icon, drawn right BEFORE the name of an account, a bot or a channel, plus one line of description in the profile. It says “this verifier vouches for this peer”, and nothing more."} />
|
||||
<p className="bot-create-note">{"The icon is a custom emoji document. The client fetches it through messages.getCustomEmojiDocuments, so a document id that resolves to nothing renders as no badge at all — which is why marks are granted from the catalogue below rather than from a typed number."}</p>
|
||||
<p className="bot-create-note">{"The official checkmark is a different mechanism, granted by the platform in the Verification section. The two are stored, shown and taken away separately, and neither one implies the other."}</p>
|
||||
{!canManage && <p className="bot-create-note">{"This session can read the section and decide applications, but not change verifiers or the icon catalogue — that needs the botverification.manage permission."}</p>}
|
||||
</section>
|
||||
|
||||
<div className="toolbar" role="group" aria-label={"Third-party verification"}>
|
||||
{tabs.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
className={`btn icon-text ${tab === item.key ? "primary" : ""}`}
|
||||
type="button"
|
||||
aria-pressed={tab === item.key}
|
||||
onClick={() => setTab(item.key)}
|
||||
>
|
||||
{item.icon} {item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "requests" && <RequestsBlock navigate={navigate} verifiers={verifiers} />}
|
||||
{tab === "verifiers" && (
|
||||
<VerifiersBlock
|
||||
verifiers={verifiers}
|
||||
icons={icons}
|
||||
canManage={canManage}
|
||||
onChanged={loadRoster}
|
||||
navigate={navigate}
|
||||
/>
|
||||
)}
|
||||
{tab === "icons" && (
|
||||
<IconsBlock icons={icons} verifiers={verifiers} canManage={canManage} onChanged={loadRoster} />
|
||||
)}
|
||||
{tab === "marks" && <MarksBlock verifiers={verifiers} canManage={canManage} navigate={navigate} />}
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Applications
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function RequestsBlock({ navigate, verifiers }: { navigate: Navigate; verifiers: BotVerifierRow[] }) {
|
||||
const [status, setStatus] = useState<StatusFilter>("pending");
|
||||
const [verifierBotID, setVerifierBotID] = useState("");
|
||||
const [peerType, setPeerType] = useState<PeerTypeFilter>("all");
|
||||
const [q, setQ] = useState("");
|
||||
const [limit, setLimit] = useState("50");
|
||||
const [rows, setRows] = useState<CustomVerificationRequestRow[]>([]);
|
||||
const [counts, setCounts] = useState<Record<string, string>>({});
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [cursor, setCursor] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// One free-text field: the backend matches the application id, the peer id and a
|
||||
// username (applicant or peer), so "@durov", "42" and a peer id all work without a
|
||||
// mode switch.
|
||||
async function load(next = false) {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
const params = new URLSearchParams({ limit });
|
||||
if (status !== "all") params.set("status", status);
|
||||
if (verifierBotID) params.set("verifier_bot_id", verifierBotID);
|
||||
if (peerType !== "all") params.set("peer_type", peerType);
|
||||
if (q.trim()) params.set("q", q.trim().replace(/^@/, ""));
|
||||
if (next && cursor) params.set("before_id", cursor);
|
||||
try {
|
||||
const result = await api.customVerificationRequests(params);
|
||||
const page = result.rows ?? [];
|
||||
setRows((current) => (next ? [...current, ...page] : page));
|
||||
setCursor(result.next_before_id ?? "");
|
||||
setHasMore(Boolean(result.has_more));
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
// The counts describe the whole queue, not the current page, so they are fetched
|
||||
// separately from the keyset listing.
|
||||
async function loadCounts() {
|
||||
try {
|
||||
const result = await api.botVerificationCounts();
|
||||
setCounts(result.counts ?? {});
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load(false);
|
||||
void loadCounts();
|
||||
}, []);
|
||||
|
||||
function refresh() {
|
||||
void load(false);
|
||||
void loadCounts();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="section-block">
|
||||
<SectionHead
|
||||
title={"Application queue"}
|
||||
text={"Applications filed with a verifier bot by the owner of the peer. The counters cover the whole queue, not the page below."}
|
||||
action={
|
||||
<button className="btn icon-text" type="button" onClick={refresh} disabled={busy}>
|
||||
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"Refresh"}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="metric-row">
|
||||
{statuses.map((item) => (
|
||||
<Metric
|
||||
key={item}
|
||||
label={statusLabels[item]}
|
||||
value={counts[item] ?? "0"}
|
||||
mono
|
||||
tone={countTone(item, counts[item] ?? "0")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<QueryPanel>
|
||||
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
|
||||
<label className="searchbox">
|
||||
<Search size={15} />
|
||||
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={"Application id, peer id, username or title"} />
|
||||
</label>
|
||||
<label className="field-inline">
|
||||
<span>{"Status"}</span>
|
||||
<select value={status} onChange={(event) => setStatus(event.target.value as StatusFilter)}>
|
||||
<option value="all">{"All statuses"}</option>
|
||||
{statuses.map((item) => (
|
||||
<option key={item} value={item}>{statusLabels[item]}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field-inline">
|
||||
<span>{"Verifier"}</span>
|
||||
<VerifierOptions value={verifierBotID} verifiers={verifiers} onChange={setVerifierBotID} />
|
||||
</label>
|
||||
<label className="field-inline">
|
||||
<span>{"Peer type"}</span>
|
||||
<select value={peerType} onChange={(event) => setPeerType(event.target.value as PeerTypeFilter)}>
|
||||
<option value="all">{"All types"}</option>
|
||||
{peerTypes.map((item) => (
|
||||
<option key={item} value={item}>{peerTypeLabels[item]}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field-inline">
|
||||
<span>{"Limit"}</span>
|
||||
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="200" />
|
||||
</label>
|
||||
<button className="btn primary icon-text" type="submit" disabled={busy}>
|
||||
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {"Search"}
|
||||
</button>
|
||||
</form>
|
||||
</QueryPanel>
|
||||
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{"ID"}</th>
|
||||
<th>{"Verifier"}</th>
|
||||
<th>{"Peer"}</th>
|
||||
<th>{"Applicant"}</th>
|
||||
<th>{"Stated reason"}</th>
|
||||
<th>{"Status"}</th>
|
||||
<th>{"Filed"}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.ID}>
|
||||
<td className="mono">
|
||||
<button className="row-link" type="button" onClick={() => navigate(`/bot-verification/${row.ID}`)}>
|
||||
#{row.ID}
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<strong>{displayUsername(row.VerifierBotUsername) || row.VerifierBotID}</strong>
|
||||
<div className="entity-subtitle mono">{row.VerifierBotID}</div>
|
||||
</td>
|
||||
<td>
|
||||
<strong>{peerLabel(row)}</strong>
|
||||
<div className="entity-subtitle mono">
|
||||
{peerTypeLabels[row.PeerType]} · {row.PeerID}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{displayUsername(row.ApplicantUsername) || "-"}
|
||||
<div className="entity-subtitle mono">{row.ApplicantUserID}</div>
|
||||
</td>
|
||||
<td className="truncate">{row.Reason || "-"}</td>
|
||||
<td><RequestStatusBadge status={row.Status} /></td>
|
||||
<td>{formatDate(row.CreatedAt) || "-"}</td>
|
||||
<td>
|
||||
<button className="row-link" type="button" onClick={() => navigate(`/bot-verification/${row.ID}`)}>
|
||||
<Stamp size={14} /> {"Details"} <ChevronRight size={14} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows.length === 0 && <EmptyRow colSpan={8} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{hasMore && (
|
||||
<div className="toolbar">
|
||||
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
|
||||
{busy ? <Loader2 size={15} className="spin" /> : <ChevronDown size={15} />} {"Load more"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Verifiers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function VerifiersBlock({
|
||||
verifiers,
|
||||
icons,
|
||||
canManage,
|
||||
onChanged,
|
||||
navigate
|
||||
}: {
|
||||
verifiers: BotVerifierRow[];
|
||||
icons: VerificationIconRow[];
|
||||
canManage: boolean;
|
||||
onChanged: () => void;
|
||||
navigate: Navigate;
|
||||
}) {
|
||||
const [bot, setBot] = useState<BotRow | null>(null);
|
||||
// editing carries the bot id of the row being updated: the grant endpoint is an
|
||||
// upsert, and version is the optimistic lock of the row it overwrites. A fresh
|
||||
// grant sends "0", which is what "there is no row yet" means.
|
||||
const [editing, setEditing] = useState<BotVerifierRow | null>(null);
|
||||
const [iconDocumentID, setIconDocumentID] = useState("");
|
||||
const [company, setCompany] = useState("");
|
||||
const [defaultDescription, setDefaultDescription] = useState("");
|
||||
const [canModify, setCanModify] = useState(false);
|
||||
const activeIcons = icons.filter((icon) => icon.Active);
|
||||
// A verifier can hold an icon the operator has since retired. Editing that row must
|
||||
// not silently swap the icon just because the select has no matching option, so the
|
||||
// current document is kept in the list and labelled instead.
|
||||
const iconOptions: Array<{ value: string; label: string }> = activeIcons.map((icon) => ({
|
||||
value: icon.DocumentID,
|
||||
label: `${icon.Name} · ${icon.DocumentID}`
|
||||
}));
|
||||
if (iconDocumentID && !iconOptions.some((option) => option.value === iconDocumentID)) {
|
||||
const retired = icons.find((icon) => icon.DocumentID === iconDocumentID);
|
||||
iconOptions.unshift({
|
||||
value: iconDocumentID,
|
||||
label: `${retired?.Name ?? iconDocumentID} · ${iconDocumentID} (${"Retired"})`
|
||||
});
|
||||
}
|
||||
|
||||
function startEdit(row: BotVerifierRow) {
|
||||
setEditing(row);
|
||||
setBot(null);
|
||||
setIconDocumentID(row.IconDocumentID);
|
||||
setCompany(row.CompanyName);
|
||||
setDefaultDescription(row.DefaultDescription);
|
||||
setCanModify(row.CanModifyCustomDescription);
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
setEditing(null);
|
||||
setBot(null);
|
||||
setIconDocumentID("");
|
||||
setCompany("");
|
||||
setDefaultDescription("");
|
||||
setCanModify(false);
|
||||
}
|
||||
|
||||
// int64 fields go out as decimal strings (the backend tags them `,string`), which
|
||||
// is also the shape they arrived in, so nothing is re-parsed on the way back.
|
||||
function grantPayload(): Record<string, unknown> {
|
||||
const botID = editing ? editing.BotID : bot ? String(bot.ID) : "0";
|
||||
return {
|
||||
bot_id: botID,
|
||||
icon_document_id: iconDocumentID || "0",
|
||||
company_name: company.trim(),
|
||||
default_description: defaultDescription.trim(),
|
||||
can_modify_custom_description: canModify,
|
||||
version: editing ? editing.Version : "0"
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{canManage && (
|
||||
<section className="section-block">
|
||||
<SectionHead
|
||||
title={editing ? "Update verifier" : "Grant verifier status"}
|
||||
text={"The bot gets an icon from the catalogue and a company name to vouch under. The same call updates an existing verifier, which is why it carries a version."}
|
||||
action={
|
||||
editing ? (
|
||||
<button className="btn icon-text" type="button" onClick={resetForm}>
|
||||
{"Cancel update"}
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
{editing ? (
|
||||
<p className="bot-create-note">
|
||||
{`Updating ${displayUsername(editing.BotUsername) || editing.BotID} — version ${editing.Version} is sent as the optimistic lock, so a row somebody else changed meanwhile is refused instead of overwritten.`}
|
||||
</p>
|
||||
) : (
|
||||
<BotPicker label={"Bot"} value={bot} onChange={setBot} />
|
||||
)}
|
||||
<div className="bot-create-fields">
|
||||
<label className="duration-field">
|
||||
<span>{"Icon from the catalogue"}</span>
|
||||
<select value={iconDocumentID} onChange={(event) => setIconDocumentID(event.target.value)}>
|
||||
<option value="">{"Pick an icon"}</option>
|
||||
{iconOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="duration-field">
|
||||
<span>{"Company"}</span>
|
||||
<input
|
||||
value={company}
|
||||
onChange={(event) => setCompany(event.target.value)}
|
||||
placeholder={"Acme Verification Ltd"}
|
||||
/>
|
||||
</label>
|
||||
<label className="duration-field">
|
||||
<span>{"Default description"}</span>
|
||||
<input
|
||||
value={defaultDescription}
|
||||
onChange={(event) => setDefaultDescription(event.target.value)}
|
||||
placeholder={"Verified by Acme"}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="checkline">
|
||||
<input type="checkbox" checked={canModify} onChange={(event) => setCanModify(event.target.checked)} />
|
||||
{"The verifier may replace the description per peer"}
|
||||
</label>
|
||||
<p className="bot-create-note">{"This is botVerifierSettings.can_modify_custom_description: with it off, every mark this verifier grants carries the default description above, whatever the applicant asked for."}</p>
|
||||
{activeIcons.length === 0 && <Alert>{"The catalogue has no active icon, so there is nothing to grant. Add one in the icon catalogue first."}</Alert>}
|
||||
<div className="bot-create-actions">
|
||||
<span className="bot-create-note">{"The bot can mark peers as soon as the row exists and is enabled."}</span>
|
||||
<ActionButton
|
||||
label={editing ? "Update verifier" : "Grant verifier status"}
|
||||
icon={<Plus size={15} />}
|
||||
tone="neutral"
|
||||
path="/api/actions/grant-bot-verifier"
|
||||
payload={grantPayload}
|
||||
onDone={() => {
|
||||
resetForm();
|
||||
onChanged();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="section-block">
|
||||
<SectionHead
|
||||
title={"Verifier bots"}
|
||||
text={"Bots allowed to hand out their own mark. Verifier status is granted per deployment, so every row here is a badge printer an operator switched on by hand."}
|
||||
action={
|
||||
<button className="btn icon-text" type="button" onClick={onChanged}>
|
||||
<RefreshCw size={15} /> {"Refresh"}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{"Bot"}</th>
|
||||
<th>{"Company"}</th>
|
||||
<th>{"Icon"}</th>
|
||||
<th>{"Own description"}</th>
|
||||
<th>{"Status"}</th>
|
||||
<th>{"Marks"}</th>
|
||||
<th>{"Granted by"}</th>
|
||||
<th>{"Updated"}</th>
|
||||
{canManage && <th></th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{verifiers.map((row) => (
|
||||
<tr key={row.BotID}>
|
||||
<td>
|
||||
<button className="row-link" type="button" onClick={() => navigate(`/bots/${row.BotID}`)}>
|
||||
<strong>{displayUsername(row.BotUsername) || row.BotName || row.BotID}</strong>
|
||||
</button>
|
||||
<div className="entity-subtitle mono">{row.BotID}</div>
|
||||
</td>
|
||||
<td>
|
||||
<strong>{row.CompanyName || "-"}</strong>
|
||||
<div className="entity-subtitle truncate">{row.DefaultDescription || "Not set"}</div>
|
||||
</td>
|
||||
<td>
|
||||
{row.IconName || "-"}
|
||||
<div className="entity-subtitle mono">{row.IconDocumentID}</div>
|
||||
</td>
|
||||
<td>{row.CanModifyCustomDescription ? "Yes" : "No"}</td>
|
||||
<td>
|
||||
{row.Enabled
|
||||
? <Badge tone="good">{"Enabled"}</Badge>
|
||||
: <Badge tone="warn">{"disabled"}</Badge>}
|
||||
</td>
|
||||
<td className="mono">{String(row.MarkCount ?? "0")}</td>
|
||||
<td>
|
||||
{row.GrantedBy || "-"}
|
||||
<div className="entity-subtitle truncate">{row.GrantReason || "-"}</div>
|
||||
</td>
|
||||
<td>{formatDate(row.UpdatedAt) || "-"}</td>
|
||||
{canManage && (
|
||||
<td>
|
||||
<div className="row-actions">
|
||||
<button className="btn compact-btn" type="button" onClick={() => startEdit(row)}>
|
||||
{"Edit"}
|
||||
</button>
|
||||
<ActionButton
|
||||
label={row.Enabled ? "Disable" : "Enable"}
|
||||
icon={row.Enabled ? <PowerOff size={14} /> : <Power size={14} />}
|
||||
tone={row.Enabled ? "warn" : "neutral"}
|
||||
compact
|
||||
path="/api/actions/set-bot-verifier-enabled"
|
||||
payload={() => ({ bot_id: row.BotID, enabled: !row.Enabled })}
|
||||
onDone={onChanged}
|
||||
/>
|
||||
<ActionButton
|
||||
label={"Revoke status"}
|
||||
icon={<Trash2 size={14} />}
|
||||
tone="danger"
|
||||
compact
|
||||
path="/api/actions/revoke-bot-verifier"
|
||||
payload={() => ({ bot_id: row.BotID })}
|
||||
onDone={onChanged}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
{verifiers.length === 0 && <EmptyRow colSpan={canManage ? 9 : 8} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="bot-create-note">{"Disabling is the per-verifier kill switch: the marks already granted keep rendering, but the bot can no longer mark anything new and its settings stop being projected into botInfo."}</p>
|
||||
<p className="bot-create-note">{"Revoking verifier status removes the row and every mark this verifier granted — the icon disappears from all of its peers at once."}</p>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Icon catalogue
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function IconsBlock({
|
||||
icons,
|
||||
verifiers,
|
||||
canManage,
|
||||
onChanged
|
||||
}: {
|
||||
icons: VerificationIconRow[];
|
||||
verifiers: BotVerifierRow[];
|
||||
canManage: boolean;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [documentID, setDocumentID] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [ownerBotID, setOwnerBotID] = useState("");
|
||||
|
||||
// owner_bot_id is omitted entirely for a shared entry rather than sent as "" —
|
||||
// `,string,omitempty` cannot decode an empty string.
|
||||
function iconPayload(): Record<string, unknown> {
|
||||
const payload: Record<string, unknown> = {
|
||||
document_id: documentID.trim() || "0",
|
||||
name: name.trim()
|
||||
};
|
||||
if (ownerBotID) payload.owner_bot_id = ownerBotID;
|
||||
return payload;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{canManage && (
|
||||
<section className="section-block">
|
||||
<SectionHead title={"Add or rename an icon"} text={"The document id has to name a real custom emoji document on this deployment; the Emoji section lists them with their ids. Adding an id that already exists renames it instead of duplicating it."} />
|
||||
<div className="bot-create-fields">
|
||||
<label className="duration-field">
|
||||
<span>{"Document ID"}</span>
|
||||
<input
|
||||
value={documentID}
|
||||
onChange={(event) => setDocumentID(event.target.value)}
|
||||
inputMode="numeric"
|
||||
placeholder="5361371319611781774"
|
||||
/>
|
||||
</label>
|
||||
<label className="duration-field">
|
||||
<span>{"Name"}</span>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder={"Acme blue tick"}
|
||||
/>
|
||||
</label>
|
||||
<label className="duration-field">
|
||||
<span>{"Owner"}</span>
|
||||
<select value={ownerBotID} onChange={(event) => setOwnerBotID(event.target.value)}>
|
||||
<option value="">{"Shared"}</option>
|
||||
{verifiers.map((row) => (
|
||||
<option key={row.BotID} value={row.BotID}>
|
||||
{`${row.CompanyName || row.BotID} · ${displayUsername(row.BotUsername) || row.BotID}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<p className="bot-create-note">{"A document id that resolves to nothing produces an invisible badge: the peer is marked in the database and the client draws nothing."}</p>
|
||||
<p className="bot-create-note">{"A shared icon may be granted to any verifier; picking an owner reserves it for that one bot."}</p>
|
||||
<div className="bot-create-actions">
|
||||
<span className="bot-create-note">{"Adding an icon grants nothing by itself — it only makes the document available to grant."}</span>
|
||||
<ActionButton
|
||||
label={"Save icon"}
|
||||
icon={<Plus size={15} />}
|
||||
tone="neutral"
|
||||
path="/api/actions/upsert-verification-icon"
|
||||
payload={iconPayload}
|
||||
onDone={() => {
|
||||
setDocumentID("");
|
||||
setName("");
|
||||
setOwnerBotID("");
|
||||
onChanged();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="section-block">
|
||||
<SectionHead
|
||||
title={"Icon catalogue"}
|
||||
text={"The custom emoji documents a verifier may mark with. Nothing else can be used as an icon, so the catalogue is where a wrong badge is prevented rather than fixed."}
|
||||
action={
|
||||
<button className="btn icon-text" type="button" onClick={onChanged}>
|
||||
<RefreshCw size={15} /> {"Refresh"}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{"Document ID"}</th>
|
||||
<th>{"Name"}</th>
|
||||
<th>{"Owner"}</th>
|
||||
<th>{"Status"}</th>
|
||||
<th>{"Verifiers using it"}</th>
|
||||
<th>{"Filed"}</th>
|
||||
{canManage && <th></th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{icons.map((row) => (
|
||||
<tr key={row.ID}>
|
||||
<td className="mono">{row.DocumentID}</td>
|
||||
<td><strong>{row.Name || "-"}</strong></td>
|
||||
<td>
|
||||
{row.OwnerBotID && row.OwnerBotID !== "0"
|
||||
? <>
|
||||
{displayUsername(row.OwnerBotUsername) || row.OwnerBotID}
|
||||
<div className="entity-subtitle mono">{row.OwnerBotID}</div>
|
||||
</>
|
||||
: <Badge>{"Shared"}</Badge>}
|
||||
</td>
|
||||
<td>
|
||||
{row.Active
|
||||
? <Badge tone="good">{"Active"}</Badge>
|
||||
: <Badge tone="warn">{"Retired"}</Badge>}
|
||||
</td>
|
||||
<td className="mono">{String(row.UsedByVerifiers ?? "0")}</td>
|
||||
<td>{formatDate(row.CreatedAt) || "-"}</td>
|
||||
{canManage && (
|
||||
<td>
|
||||
<div className="row-actions">
|
||||
<ActionButton
|
||||
label={row.Active ? "Retire" : "Activate"}
|
||||
icon={row.Active ? <PowerOff size={14} /> : <Power size={14} />}
|
||||
tone={row.Active ? "warn" : "neutral"}
|
||||
compact
|
||||
path="/api/actions/set-verification-icon-active"
|
||||
payload={() => ({ icon_id: row.ID, active: !row.Active })}
|
||||
onDone={onChanged}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
{icons.length === 0 && <EmptyRow colSpan={canManage ? 7 : 6} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="bot-create-note">{"Retiring an icon stops it from being granted to anybody new. Marks already carrying it keep it: the icon is copied onto the mark when it is granted."}</p>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Granted marks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function MarksBlock({
|
||||
verifiers,
|
||||
canManage,
|
||||
navigate
|
||||
}: {
|
||||
verifiers: BotVerifierRow[];
|
||||
canManage: boolean;
|
||||
navigate: Navigate;
|
||||
}) {
|
||||
const [verifierBotID, setVerifierBotID] = useState("");
|
||||
const [peerType, setPeerType] = useState<PeerTypeFilter>("all");
|
||||
const [q, setQ] = useState("");
|
||||
const [limit, setLimit] = useState("50");
|
||||
const [rows, setRows] = useState<CustomVerificationRow[]>([]);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [cursor, setCursor] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function load(next = false) {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
const params = new URLSearchParams({ limit });
|
||||
if (verifierBotID) params.set("verifier_bot_id", verifierBotID);
|
||||
if (peerType !== "all") params.set("peer_type", peerType);
|
||||
if (q.trim()) params.set("q", q.trim().replace(/^@/, ""));
|
||||
if (next && cursor) params.set("before_id", cursor);
|
||||
try {
|
||||
const result = await api.customVerifications(params);
|
||||
const page = result.rows ?? [];
|
||||
setRows((current) => (next ? [...current, ...page] : page));
|
||||
setCursor(result.next_before_id ?? "");
|
||||
setHasMore(Boolean(result.has_more));
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="section-block">
|
||||
<SectionHead
|
||||
title={"Granted marks"}
|
||||
text={"Every peer currently carrying a third-party mark, whoever granted it — an operator decision, the verifier bot itself, or the peer's owner through bots.setCustomVerification."}
|
||||
action={
|
||||
<button className="btn icon-text" type="button" onClick={() => load(false)} disabled={busy}>
|
||||
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"Refresh"}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
</section>
|
||||
|
||||
<QueryPanel>
|
||||
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
|
||||
<label className="searchbox">
|
||||
<Search size={15} />
|
||||
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={"Peer id, username or title"} />
|
||||
</label>
|
||||
<label className="field-inline">
|
||||
<span>{"Verifier"}</span>
|
||||
<VerifierOptions value={verifierBotID} verifiers={verifiers} onChange={setVerifierBotID} />
|
||||
</label>
|
||||
<label className="field-inline">
|
||||
<span>{"Peer type"}</span>
|
||||
<select value={peerType} onChange={(event) => setPeerType(event.target.value as PeerTypeFilter)}>
|
||||
<option value="all">{"All types"}</option>
|
||||
{peerTypes.map((item) => (
|
||||
<option key={item} value={item}>{peerTypeLabels[item]}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field-inline">
|
||||
<span>{"Limit"}</span>
|
||||
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="200" />
|
||||
</label>
|
||||
<button className="btn primary icon-text" type="submit" disabled={busy}>
|
||||
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {"Search"}
|
||||
</button>
|
||||
</form>
|
||||
</QueryPanel>
|
||||
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{"ID"}</th>
|
||||
<th>{"Verifier"}</th>
|
||||
<th>{"Peer"}</th>
|
||||
<th>{"Description"}</th>
|
||||
<th>{"Icon"}</th>
|
||||
<th>{"Filed"}</th>
|
||||
{canManage && <th></th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.ID}>
|
||||
<td className="mono">#{row.ID}</td>
|
||||
<td>
|
||||
<strong>{row.CompanyName || displayUsername(row.VerifierBotUsername) || row.VerifierBotID}</strong>
|
||||
<div className="entity-subtitle mono">
|
||||
{displayUsername(row.VerifierBotUsername) || row.VerifierBotID}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<button className="row-link" type="button" onClick={() => navigate(peerHref(row.PeerType, row.PeerID))}>
|
||||
<strong>{peerLabel(row)}</strong>
|
||||
</button>
|
||||
<div className="entity-subtitle mono">
|
||||
{peerTypeLabels[row.PeerType]} · {row.PeerID}
|
||||
</div>
|
||||
</td>
|
||||
<td className="truncate">{row.Description || "Not set"}</td>
|
||||
<td className="mono">{row.IconDocumentID}</td>
|
||||
<td>{formatDate(row.CreatedAt) || "-"}</td>
|
||||
{canManage && (
|
||||
<td>
|
||||
<div className="row-actions">
|
||||
<ActionButton
|
||||
label={"Remove mark"}
|
||||
icon={<Ban size={14} />}
|
||||
tone="danger"
|
||||
compact
|
||||
path="/api/actions/revoke-custom-verification"
|
||||
payload={() => ({
|
||||
verifier_bot_id: row.VerifierBotID,
|
||||
peer_type: row.PeerType,
|
||||
peer_id: row.PeerID
|
||||
})}
|
||||
onDone={() => load(false)}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
{rows.length === 0 && <EmptyRow colSpan={canManage ? 7 : 6} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="bot-create-note">{"Removing a mark clears the icon and the description from the peer. The application it came from keeps its history."}</p>
|
||||
{hasMore && (
|
||||
<div className="toolbar">
|
||||
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
|
||||
{busy ? <Loader2 size={15} className="spin" /> : <ChevronDown size={15} />} {"Load more"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared bits
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The verifier filter lists the roster rather than asking for a bot id: a company
|
||||
// name is what an operator reads in the queue, and a disabled verifier still owns
|
||||
// rows worth filtering by, so it stays in the list and is labelled instead.
|
||||
function VerifierOptions({
|
||||
value,
|
||||
verifiers,
|
||||
onChange
|
||||
}: {
|
||||
value: string;
|
||||
verifiers: BotVerifierRow[];
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<select value={value} onChange={(event) => onChange(event.target.value)}>
|
||||
<option value="">{"All verifiers"}</option>
|
||||
{verifiers.map((row) => (
|
||||
<option key={row.BotID} value={row.BotID}>
|
||||
{`${row.CompanyName || row.BotID} · ${displayUsername(row.BotUsername) || row.BotID}`
|
||||
+ (row.Enabled ? "" : ` (${"disabled"})`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
export function RequestStatusBadge({ status }: { status: CustomVerificationRequestStatus }) {
|
||||
return <Badge tone={statusTone(status)}>{statusLabels[status]}</Badge>;
|
||||
}
|
||||
|
||||
export function statusTone(status: CustomVerificationRequestStatus): "neutral" | "good" | "warn" | "danger" {
|
||||
if (status === "approved") return "good";
|
||||
if (status === "pending") return "warn";
|
||||
if (status === "rejected") return "danger";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
// pending is the only status that waits for somebody, so it is the only one
|
||||
// highlighted — and only while something actually sits in it.
|
||||
function countTone(status: CustomVerificationRequestStatus, count: string): "neutral" | "good" | "warn" {
|
||||
if (status === "pending") return count !== "0" && count !== "" ? "warn" : "neutral";
|
||||
return status === "approved" ? "good" : "neutral";
|
||||
}
|
||||
|
||||
export function peerLabel(row: { PeerUsername: string; PeerTitle: string; PeerID: string }): string {
|
||||
return displayUsername(row.PeerUsername) || row.PeerTitle || `#${row.PeerID}`;
|
||||
}
|
||||
|
||||
// The panel page that owns the peer type. A third-party mark can sit on an ordinary
|
||||
// account or on a bot — both are user rows, so both open the account page.
|
||||
export function peerHref(peerType: BotVerificationPeerType, peerID: string): string {
|
||||
return peerType === "channel" ? `/channels/${peerID}` : `/accounts/${peerID}`;
|
||||
}
|
||||
358
cmd/telesrv-admin/web/src/pages/BotVerificationRequestPage.tsx
Normal file
358
cmd/telesrv-admin/web/src/pages/BotVerificationRequestPage.tsx
Normal file
|
|
@ -0,0 +1,358 @@
|
|||
import {
|
||||
ArrowLeft,
|
||||
BadgeCheck,
|
||||
Ban,
|
||||
Building2,
|
||||
CheckCircle2,
|
||||
ExternalLink,
|
||||
RefreshCw,
|
||||
ShieldOff,
|
||||
Stamp,
|
||||
User,
|
||||
XCircle
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { api, APIError, errorMessage } from "../api";
|
||||
import { ActionButton } from "../components/ActionButton";
|
||||
import { Alert, Badge, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
|
||||
import { displayUsername, formatDate } from "../lib/format";
|
||||
import type { Navigate } from "../routing";
|
||||
import type { BotVerifierRow, CustomVerificationRequestDetail } from "../types";
|
||||
import { RequestStatusBadge, peerHref, peerLabel, peerTypeLabels, statusLabels } from "./BotVerificationPage";
|
||||
|
||||
export function BotVerificationRequestPage({ id, navigate }: { id: string; navigate: Navigate }) {
|
||||
const [detail, setDetail] = useState<CustomVerificationRequestDetail | null>(null);
|
||||
const [note, setNote] = useState("");
|
||||
const [conflict, setConflict] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function load() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
setDetail(await api.customVerificationRequest(id));
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
setConflict(false);
|
||||
void load();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [id]);
|
||||
|
||||
// 409 is the one failure the operator cannot fix by editing the form: another
|
||||
// admin decided against the version this page read. The panel says so in plain
|
||||
// words and reloads, so the next attempt carries the current version.
|
||||
function handleActionError(err: unknown): string | undefined {
|
||||
if (err instanceof APIError && err.status === 409) {
|
||||
setConflict(true);
|
||||
void load();
|
||||
return "Another admin has already changed this application. The data has been reloaded — check the status before deciding again.";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (error && !detail) {
|
||||
return <Alert>{error}</Alert>;
|
||||
}
|
||||
if (!detail) {
|
||||
return <LoadingSurface label={"Loading the application…"} />;
|
||||
}
|
||||
|
||||
const request = detail.request;
|
||||
const verifier = liveVerifier(detail.verifier);
|
||||
const markActive = detail.mark_active;
|
||||
const canDecide = request.Status === "pending";
|
||||
const canRevoke = request.Status === "approved";
|
||||
const trimmedNote = note.trim();
|
||||
// What the mark would actually say: the applicant's wording only when this
|
||||
// verifier is allowed to override its own default, otherwise the default. Same
|
||||
// rule the backend applies (BotVerifierSettings.DescriptionFor), shown here so a
|
||||
// reviewer is not surprised by the text that ends up in the profile.
|
||||
const requestedDescription = request.RequestedDescription.trim();
|
||||
const descriptionAllowed = Boolean(verifier?.CanModifyCustomDescription) && requestedDescription !== "";
|
||||
const effectiveDescription = descriptionAllowed
|
||||
? requestedDescription
|
||||
: (verifier?.DefaultDescription ?? "").trim();
|
||||
|
||||
// version is the optimistic-locking token: it goes with every decision, as the
|
||||
// decimal string it arrived as, so a stale page cannot overwrite a fresh one.
|
||||
function decisionPayload(): Record<string, unknown> {
|
||||
const payload: Record<string, unknown> = { version: request.Version };
|
||||
if (trimmedNote) payload.internal_note = trimmedNote;
|
||||
return payload;
|
||||
}
|
||||
|
||||
function afterDecision() {
|
||||
setNote("");
|
||||
setConflict(false);
|
||||
void load();
|
||||
}
|
||||
|
||||
return (
|
||||
<PageFrame
|
||||
title={`Application #${request.ID}`}
|
||||
eyebrow={"Third-party verification / Review"}
|
||||
actions={
|
||||
<>
|
||||
<button className="btn icon-text" type="button" onClick={() => navigate("/bot-verification")}>
|
||||
<ArrowLeft size={15} /> {"Back to list"}
|
||||
</button>
|
||||
<button className="btn icon-text" type="button" onClick={refresh} disabled={busy}>
|
||||
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"Refresh"}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{conflict && <Alert>{"Another admin has already changed this application. The data has been reloaded — check the status before deciding again."}</Alert>}
|
||||
<SplitLayout
|
||||
main={
|
||||
<div className="stacked-sections">
|
||||
<section className="entity-head">
|
||||
<div>
|
||||
<div className="entity-title">{peerLabel(request)}</div>
|
||||
<div className="entity-subtitle mono">
|
||||
#{request.ID} · {peerTypeLabels[request.PeerType]}:{request.PeerID} · v{request.Version}
|
||||
</div>
|
||||
</div>
|
||||
<div className="entity-badges">
|
||||
<RequestStatusBadge status={request.Status} />
|
||||
{markActive
|
||||
? <Badge tone="good"><BadgeCheck size={12} /> {"Mark is live"}</Badge>
|
||||
: <Badge tone="neutral">{"No mark on the peer"}</Badge>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Repeated on the detail page on purpose: the decision an operator is
|
||||
about to take grants a company's icon, not the platform badge. */}
|
||||
<section className="section-block">
|
||||
<SectionHead title={"A verifier company's icon — not the official checkmark"} text={"A third-party mark is a verifier bot's own icon, drawn right BEFORE the name of an account, a bot or a channel, plus one line of description in the profile. It says “this verifier vouches for this peer”, and nothing more."} />
|
||||
<p className="bot-create-note">{"The icon is a custom emoji document. The client fetches it through messages.getCustomEmojiDocuments, so a document id that resolves to nothing renders as no badge at all — which is why marks are granted from the catalogue below rather than from a typed number."}</p>
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<SectionHead
|
||||
title={"Verifier"}
|
||||
text={"The company whose icon the peer would carry, as its row stands right now."}
|
||||
action={
|
||||
<button className="btn icon-text" type="button" onClick={() => navigate(`/bots/${request.VerifierBotID}`)}>
|
||||
<Building2 size={15} /> {"Open verifier bot"}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<div className="summary-grid">
|
||||
<Summary label={"Company"} value={verifier?.CompanyName || "-"} />
|
||||
<Summary label={"Bot"} value={displayUsername(request.VerifierBotUsername) || "-"} />
|
||||
<Summary label={"Verifier bot ID"} value={request.VerifierBotID} mono />
|
||||
<Summary label={"Document ID"} value={verifier?.IconDocumentID || "-"} mono />
|
||||
<Summary label={"Name"} value={verifier?.IconName || "-"} />
|
||||
<Summary
|
||||
label={"Own description"}
|
||||
value={verifier?.CanModifyCustomDescription ? "Yes" : "No"}
|
||||
/>
|
||||
</div>
|
||||
<FieldBlock label={"Default description"}>
|
||||
{verifier?.DefaultDescription
|
||||
? <p className="about-text">{verifier.DefaultDescription}</p>
|
||||
: <p className="bot-create-note">{"Not set"}</p>}
|
||||
</FieldBlock>
|
||||
{!verifier && <Alert>{"The verifier row is gone: its status was revoked after this application was filed. There is no icon to grant, so the application can only be rejected."}</Alert>}
|
||||
{verifier && !verifier.Enabled && <Alert>{"This verifier is disabled. It cannot mark anything new until an operator enables it again."}</Alert>}
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<SectionHead
|
||||
title={"Peer"}
|
||||
text={"The account, bot or channel the icon would be attached to."}
|
||||
action={
|
||||
<button
|
||||
className="btn icon-text"
|
||||
type="button"
|
||||
onClick={() => navigate(peerHref(request.PeerType, request.PeerID))}
|
||||
>
|
||||
<ExternalLink size={15} /> {"Open peer"}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<div className="summary-grid">
|
||||
<Summary label={"Type"} value={peerTypeLabels[request.PeerType]} />
|
||||
<Summary label={"Username"} value={displayUsername(request.PeerUsername) || "-"} />
|
||||
<Summary label={"Title"} value={request.PeerTitle || "-"} />
|
||||
<Summary label={"Peer ID"} value={request.PeerID} mono />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<SectionHead
|
||||
title={"Applicant"}
|
||||
text={"Who filed the application with the verifier bot."}
|
||||
action={
|
||||
<button
|
||||
className="btn icon-text"
|
||||
type="button"
|
||||
onClick={() => navigate(`/accounts/${request.ApplicantUserID}`)}
|
||||
>
|
||||
<User size={15} /> {"Open account"}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<div className="summary-grid">
|
||||
<Summary label={"Username"} value={displayUsername(request.ApplicantUsername) || "-"} />
|
||||
<Summary label={"User ID"} value={request.ApplicantUserID} mono />
|
||||
<Summary label={"Filed"} value={formatDate(request.CreatedAt) || "-"} />
|
||||
<Summary label={"Updated"} value={formatDate(request.UpdatedAt) || "-"} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<SectionHead title={"Application"} text={"What the applicant wrote, rendered as plain text."} />
|
||||
<div className="stacked-sections">
|
||||
<div className="summary-grid">
|
||||
<Summary label={"Correlation ID"} value={request.CorrelationID || "-"} mono />
|
||||
<Summary label={"Status"} value={statusLabels[request.Status]} />
|
||||
</div>
|
||||
<FieldBlock label={"Stated reason"}>
|
||||
{request.Reason
|
||||
? <p className="about-text">{request.Reason}</p>
|
||||
: <p className="bot-create-note">{"Not set"}</p>}
|
||||
</FieldBlock>
|
||||
<FieldBlock label={"Requested description"}>
|
||||
{requestedDescription
|
||||
? <p className="about-text">{requestedDescription}</p>
|
||||
: <p className="bot-create-note">{"Not set"}</p>}
|
||||
</FieldBlock>
|
||||
<FieldBlock label={"Description the mark would carry"}>
|
||||
{effectiveDescription
|
||||
? <p className="about-text">{effectiveDescription}</p>
|
||||
: <p className="bot-create-note">{"Not set"}</p>}
|
||||
</FieldBlock>
|
||||
<p className="bot-create-note">{"Resolved the same way the backend resolves it: the applicant's wording only when this verifier may set its own description, otherwise the verifier's default."}</p>
|
||||
{requestedDescription !== "" && !descriptionAllowed && (
|
||||
<p className="bot-create-note">{"This verifier may not set a per-peer description, so the requested wording is ignored and the default is applied."}</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<SectionHead title={"Decision"} text={"What was decided, by whom, and with which wording."} />
|
||||
<div className="stacked-sections">
|
||||
<div className="summary-grid">
|
||||
<Summary label={"Decided by"} value={request.DecidedBy || "-"} />
|
||||
<Summary label={"Approved"} value={formatDate(request.ApprovedAt) || "-"} />
|
||||
<Summary label={"Rejected"} value={formatDate(request.RejectedAt) || "-"} />
|
||||
<Summary label={"Version (optimistic lock)"} value={request.Version} mono />
|
||||
</div>
|
||||
<FieldBlock label={"Decision reason"}>
|
||||
{request.DecisionReason
|
||||
? <p className="about-text">{request.DecisionReason}</p>
|
||||
: <p className="bot-create-note">{"No decision yet"}</p>}
|
||||
</FieldBlock>
|
||||
{/* The internal note is the operator handover text and is labelled as
|
||||
admin-only wherever it appears. */}
|
||||
<FieldBlock label={`${"Internal note"} · ${"admins only"}`}>
|
||||
{request.InternalNote
|
||||
? <p className="about-text">{request.InternalNote}</p>
|
||||
: <p className="bot-create-note">{"Not set"}</p>}
|
||||
</FieldBlock>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
side={
|
||||
<section className="action-dock">
|
||||
<div className="dock-title"><Stamp size={14} /> {"Decision"}</div>
|
||||
{!canDecide && !canRevoke && <p className="bot-create-note">{"This status has no available actions."}</p>}
|
||||
{(canDecide || canRevoke) && (
|
||||
<>
|
||||
<label className="duration-field">
|
||||
<span>{"Internal note"}</span>
|
||||
<textarea
|
||||
value={note}
|
||||
onChange={(event) => setNote(event.target.value)}
|
||||
rows={3}
|
||||
placeholder={"Handover note for other admins"}
|
||||
/>
|
||||
</label>
|
||||
<p className="bot-create-note">{"Optional. Stored with the decision and visible to admins only — never sent to the applicant."}</p>
|
||||
</>
|
||||
)}
|
||||
{canDecide && (
|
||||
<>
|
||||
{!verifier && <Alert>{"The verifier row is gone: its status was revoked after this application was filed. There is no icon to grant, so the application can only be rejected."}</Alert>}
|
||||
{verifier && !verifier.Enabled && <Alert>{"This verifier is disabled. It cannot mark anything new until an operator enables it again."}</Alert>}
|
||||
{markActive && <p className="bot-create-note">{"This peer already carries this verifier's mark; approving refreshes the description and records the decision."}</p>}
|
||||
<div className="action-stack">
|
||||
<ActionButton
|
||||
label={"Approve"}
|
||||
icon={<CheckCircle2 size={15} />}
|
||||
tone="neutral"
|
||||
path={`/api/botverification/requests/${request.ID}/approve`}
|
||||
payload={decisionPayload}
|
||||
onDone={afterDecision}
|
||||
onError={handleActionError}
|
||||
/>
|
||||
<ActionButton
|
||||
label={"Reject"}
|
||||
icon={<XCircle size={15} />}
|
||||
tone="warn"
|
||||
path={`/api/botverification/requests/${request.ID}/reject`}
|
||||
payload={decisionPayload}
|
||||
onDone={afterDecision}
|
||||
onError={handleActionError}
|
||||
/>
|
||||
</div>
|
||||
<p className="bot-create-note">{"Puts the verifier's icon before the peer's name and its description in the profile, and messages the applicant."}</p>
|
||||
<p className="bot-create-note">{"The reason is mandatory: it is the wording the applicant is told, so write what exactly was missing."}</p>
|
||||
</>
|
||||
)}
|
||||
{canRevoke && (
|
||||
<>
|
||||
<div className="dock-title"><ShieldOff size={14} /> {"Danger zone"}</div>
|
||||
<div className="danger-zone">
|
||||
<ActionButton
|
||||
label={"Revoke mark"}
|
||||
icon={<Ban size={15} />}
|
||||
tone="danger"
|
||||
path={`/api/botverification/requests/${request.ID}/revoke`}
|
||||
payload={decisionPayload}
|
||||
onDone={afterDecision}
|
||||
onError={handleActionError}
|
||||
/>
|
||||
<p className="bot-create-note">{"Takes the icon and the description off the peer and closes the application as revoked. The official checkmark, if the peer has one, is untouched."}</p>
|
||||
{!markActive && <p className="bot-create-note">{"The peer carries no mark right now — revoking only closes the application."}</p>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
}
|
||||
/>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldBlock({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="duration-field">
|
||||
<span>{label}</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A verifier whose row was revoked after the application was filed can come back as
|
||||
// null or as a zeroed record, depending on how the backend renders "gone". Both mean
|
||||
// the same thing to a reviewer, so they collapse into one absent value here.
|
||||
function liveVerifier(row: BotVerifierRow | null): BotVerifierRow | null {
|
||||
if (!row) return null;
|
||||
if (!row.BotID || row.BotID === "0") return null;
|
||||
return row;
|
||||
}
|
||||
|
|
@ -4,13 +4,11 @@ import { api, errorMessage } from "../api";
|
|||
import { ActionButton } from "../components/ActionButton";
|
||||
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
|
||||
import { ScamFakeBadges } from "../components/flags";
|
||||
import { useI18n } from "../i18n";
|
||||
import { displayUsername, formatDate, toInt } from "../lib/format";
|
||||
import type { Navigate } from "../routing";
|
||||
import type { BotListResponse } from "../types";
|
||||
|
||||
export function BotsPage({ navigate }: { navigate: Navigate }) {
|
||||
const { t } = useI18n();
|
||||
const [q, setQ] = useState("");
|
||||
const [limit, setLimit] = useState("50");
|
||||
const [data, setData] = useState<BotListResponse | null>(null);
|
||||
|
|
@ -52,31 +50,31 @@ export function BotsPage({ navigate }: { navigate: Navigate }) {
|
|||
|
||||
return (
|
||||
<PageFrame
|
||||
title={t("bots.pageTitle")}
|
||||
eyebrow={data?.listing === false ? t("bots.queryResults") : t("bots.recent")}
|
||||
title={"Bots"}
|
||||
eyebrow={data?.listing === false ? "Search results" : "Recently created bots"}
|
||||
actions={
|
||||
<button className="btn" type="button" onClick={() => load(false)} disabled={busy}>
|
||||
<RefreshCw size={15} /> {t("common.refresh")}
|
||||
<RefreshCw size={15} /> {"Refresh"}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="metric-row">
|
||||
<Metric label={t("bots.currentPage")} value={String(rows.length)} />
|
||||
<Metric label={t("common.verified")} value={String(verified)} tone="good" />
|
||||
<Metric label={t("bots.system")} value={String(systemCount)} />
|
||||
<Metric label={"Bots on page"} value={String(rows.length)} />
|
||||
<Metric label={"Verified"} value={String(verified)} tone="good" />
|
||||
<Metric label={"System"} value={String(systemCount)} />
|
||||
</div>
|
||||
|
||||
<section className="section-block">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h2>{t("bots.createTitle")}</h2>
|
||||
<p>{t("bots.createHint")}</p>
|
||||
<h2>{"Create a system bot"}</h2>
|
||||
<p>{"Provision a bot account owned by the given user. The token is shown once after confirmation."}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bot-create-fields">
|
||||
<label className="duration-field">
|
||||
<span>{t("bots.ownerUserID")}</span>
|
||||
<span>{"Owner user ID"}</span>
|
||||
<input
|
||||
value={ownerID}
|
||||
onChange={(event) => setOwnerID(event.target.value)}
|
||||
|
|
@ -86,18 +84,18 @@ export function BotsPage({ navigate }: { navigate: Navigate }) {
|
|||
/>
|
||||
</label>
|
||||
<label className="duration-field">
|
||||
<span>{t("bots.name")}</span>
|
||||
<input value={botName} onChange={(event) => setBotName(event.target.value)} placeholder={t("bots.namePlaceholder")} maxLength={64} />
|
||||
<span>{"Display name"}</span>
|
||||
<input value={botName} onChange={(event) => setBotName(event.target.value)} placeholder={"e.g. Service Bot"} maxLength={64} />
|
||||
</label>
|
||||
<label className="duration-field">
|
||||
<span>{t("bots.username")}</span>
|
||||
<span>{"Username"}</span>
|
||||
<input value={botUsername} onChange={(event) => setBotUsername(event.target.value)} placeholder="my_service_bot" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="bot-create-actions">
|
||||
<span className="bot-create-note">{t("bots.usernameHint")}</span>
|
||||
<span className="bot-create-note">{"Username must be 5-32 characters and end with 'bot'."}</span>
|
||||
<ActionButton
|
||||
label={t("bots.create")}
|
||||
label={"Create bot"}
|
||||
icon={<Plus size={15} />}
|
||||
tone="neutral"
|
||||
path="/api/actions/create-bot"
|
||||
|
|
@ -115,18 +113,18 @@ export function BotsPage({ navigate }: { navigate: Navigate }) {
|
|||
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
|
||||
<label className="searchbox">
|
||||
<Search size={15} />
|
||||
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={t("bots.searchPlaceholder")} />
|
||||
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={"Bot ID / username"} />
|
||||
</label>
|
||||
<label className="field-inline">
|
||||
<span>{t("common.limit")}</span>
|
||||
<span>{"Limit"}</span>
|
||||
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="100" />
|
||||
</label>
|
||||
<button className="btn primary icon-text" type="submit" disabled={busy}>
|
||||
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {t("common.search")}
|
||||
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {"Search"}
|
||||
</button>
|
||||
{data?.listing && data.has_more && (
|
||||
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
|
||||
<ChevronRight size={15} /> {t("messages.nextPage")}
|
||||
<ChevronRight size={15} /> {"Next page"}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
|
|
@ -136,13 +134,13 @@ export function BotsPage({ navigate }: { navigate: Navigate }) {
|
|||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("bots.botID")}</th>
|
||||
<th>{t("common.username")}</th>
|
||||
<th>{t("common.name")}</th>
|
||||
<th>{t("bots.owner")}</th>
|
||||
<th>{t("common.verified")}</th>
|
||||
<th>{t("bots.type")}</th>
|
||||
<th>{t("account.createdAt")}</th>
|
||||
<th>{"Bot ID"}</th>
|
||||
<th>{"Username"}</th>
|
||||
<th>{"Name"}</th>
|
||||
<th>{"Owner"}</th>
|
||||
<th>{"Verified"}</th>
|
||||
<th>{"Type"}</th>
|
||||
<th>{"Created"}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -153,10 +151,10 @@ export function BotsPage({ navigate }: { navigate: Navigate }) {
|
|||
<td>{displayUsername(row.Username) || "-"}</td>
|
||||
<td>{row.FirstName || "-"}</td>
|
||||
<td className="mono">{row.OwnerUserID > 0 ? row.OwnerUserID : "-"}</td>
|
||||
<td>{row.Verified ? <Badge tone="good"><BadgeCheck size={12} /> {t("common.verified")}</Badge> : <Badge>{t("account.notVerified")}</Badge>} <ScamFakeBadges scam={row.Scam} fake={row.Fake} /></td>
|
||||
<td>{row.System ? <Badge tone="warn">{t("bots.system")}</Badge> : <Badge>{t("bots.user")}</Badge>}</td>
|
||||
<td>{row.Verified ? <Badge tone="good"><BadgeCheck size={12} /> {"Verified"}</Badge> : <Badge>{"Not verified"}</Badge>} <ScamFakeBadges scam={row.Scam} fake={row.Fake} /></td>
|
||||
<td>{row.System ? <Badge tone="warn">{"System"}</Badge> : <Badge>{"User"}</Badge>}</td>
|
||||
<td>{formatDate(row.CreatedAt)}</td>
|
||||
<td><button className="row-link" onClick={() => navigate(`/bots/${row.ID}`)}><Bot size={14} /> {t("common.detail")} <ChevronRight size={14} /></button></td>
|
||||
<td><button className="row-link" onClick={() => navigate(`/bots/${row.ID}`)}><Bot size={14} /> {"Details"} <ChevronRight size={14} /></button></td>
|
||||
</tr>
|
||||
))}
|
||||
{rows.length === 0 && <EmptyRow colSpan={8} />}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { useEffect, useState } from "react";
|
|||
import { api, errorMessage } from "../api";
|
||||
import { ActionButton } from "../components/ActionButton";
|
||||
import { Alert, AuditTable, Badge, JsonBlock, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
|
||||
import { useI18n } from "../i18n";
|
||||
import { ScamFakeActions, ScamFakeBadges } from "../components/flags";
|
||||
import { ChannelSettingsAction, ColorAction, EmojiStatusAction, UsernameAction } from "../components/attributes";
|
||||
import { channelKind, displayUsername, formatDate, formatUnix } from "../lib/format";
|
||||
|
|
@ -11,7 +10,6 @@ import type { Navigate } from "../routing";
|
|||
import type { ChannelDetail } from "../types";
|
||||
|
||||
export function ChannelDetailPage({ id, navigate }: { id: number; navigate: Navigate }) {
|
||||
const { t } = useI18n();
|
||||
const [detail, setDetail] = useState<ChannelDetail | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
|
|
@ -32,15 +30,15 @@ export function ChannelDetailPage({ id, navigate }: { id: number; navigate: Navi
|
|||
return <Alert>{error}</Alert>;
|
||||
}
|
||||
if (!detail) {
|
||||
return <LoadingSurface label={t("channel.loadingDetail")} />;
|
||||
return <LoadingSurface label={"Loading channel detail"} />;
|
||||
}
|
||||
|
||||
const ch = detail.Channel;
|
||||
return (
|
||||
<PageFrame
|
||||
title={`${channelKind(ch, t)} #${ch.ID}`}
|
||||
eyebrow={t("channel.detailProfile")}
|
||||
actions={<button className="btn icon-text" onClick={() => navigate("/channels")}><ArrowLeft size={15} /> {t("common.backToList")}</button>}
|
||||
title={`${channelKind(ch)} #${ch.ID}`}
|
||||
eyebrow={"Channel Profile"}
|
||||
actions={<button className="btn icon-text" onClick={() => navigate("/channels")}><ArrowLeft size={15} /> {"Back to list"}</button>}
|
||||
>
|
||||
<SplitLayout
|
||||
main={
|
||||
|
|
@ -48,41 +46,41 @@ export function ChannelDetailPage({ id, navigate }: { id: number; navigate: Navi
|
|||
<section className="entity-head">
|
||||
<div>
|
||||
<div className="entity-title">{ch.Title || "-"}</div>
|
||||
<div className="entity-subtitle">{displayUsername(ch.Username) || t("account.noUsername")} · {t("channel.creator", { id: ch.CreatorUserID })}</div>
|
||||
<div className="entity-subtitle">{displayUsername(ch.Username) || "No username"} · {`Creator ${ch.CreatorUserID}`}</div>
|
||||
</div>
|
||||
<div className="entity-badges">
|
||||
<Badge>{channelKind(ch, t)}</Badge>
|
||||
{ch.Verified ? <Badge tone="good">{t("common.verified")}</Badge> : <Badge>{t("account.notVerified")}</Badge>}
|
||||
<Badge>{channelKind(ch)}</Badge>
|
||||
{ch.Verified ? <Badge tone="good">{"Verified"}</Badge> : <Badge>{"Not verified"}</Badge>}
|
||||
<ScamFakeBadges scam={ch.Scam} fake={ch.Fake} />
|
||||
{ch.Deleted ? <Badge tone="danger">{t("common.deleted")}</Badge> : <Badge>{t("common.valid")}</Badge>}
|
||||
{ch.Deleted ? <Badge tone="danger">{"Deleted"}</Badge> : <Badge>{"Valid"}</Badge>}
|
||||
</div>
|
||||
</section>
|
||||
<div className="summary-grid">
|
||||
<Summary label={t("channel.channelID")} value={String(ch.ID)} mono />
|
||||
<Summary label={"Channel ID"} value={String(ch.ID)} mono />
|
||||
<Summary label="access_hash" value={String(ch.AccessHash)} mono />
|
||||
<Summary label={t("common.members")} value={`${ch.ParticipantsCount} / ${t("common.admins")} ${ch.AdminsCount}`} />
|
||||
<Summary label={t("channel.governance")} value={t("channel.governanceValue", { banned: ch.BannedCount, kicked: ch.KickedCount })} />
|
||||
<Summary label={t("channel.flags")} value={`broadcast=${ch.Broadcast} megagroup=${ch.Megagroup} forum=${ch.Forum}`} />
|
||||
<Summary label={"Members"} value={`${ch.ParticipantsCount} / ${"Admins"} ${ch.AdminsCount}`} />
|
||||
<Summary label={"Moderation"} value={`Banned ${ch.BannedCount} / Kicked ${ch.KickedCount}`} />
|
||||
<Summary label={"Channel flags"} value={`broadcast=${ch.Broadcast} megagroup=${ch.Megagroup} forum=${ch.Forum}`} />
|
||||
<Summary label="top / pinned / PTS" value={`${ch.TopMessageID} / ${ch.PinnedMessageID} / ${ch.PTS}`} />
|
||||
<Summary label={t("account.createdAt")} value={formatUnix(ch.Date) || "-"} />
|
||||
<Summary label={t("common.updatedAt")} value={formatDate(ch.UpdatedAt) || "-"} />
|
||||
<Summary label={"Created"} value={formatUnix(ch.Date) || "-"} />
|
||||
<Summary label={"Updated"} value={formatDate(ch.UpdatedAt) || "-"} />
|
||||
</div>
|
||||
{ch.About && <p className="about-text">{ch.About}</p>}
|
||||
<section className="section-block">
|
||||
<SectionHead title={t("account.recentAdminOps")} text={t("account.recent30Audit")} />
|
||||
<SectionHead title={"Recent Admin Actions"} text={"Last 30 audit rows"} />
|
||||
<AuditTable rows={detail.AuditLogs} />
|
||||
</section>
|
||||
<section className="section-block">
|
||||
<SectionHead title={t("channel.rawRow")} text={t("channel.rawRowText")} />
|
||||
<SectionHead title={"Channel Raw Row"} text={"Database read-only snapshot"} />
|
||||
<JsonBlock value={detail.ChannelJSON} />
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
side={
|
||||
<section className="action-dock">
|
||||
<div className="dock-title">{t("channel.actionDock")}</div>
|
||||
<div className="dock-title">{"Channel Actions"}</div>
|
||||
<ActionButton
|
||||
label={ch.Verified ? t("channel.clearVerified") : t("channel.setVerified")}
|
||||
label={ch.Verified ? "Clear verified" : "Set verified"}
|
||||
icon={<BadgeCheck size={15} />}
|
||||
tone="warn"
|
||||
path="/api/actions/set-channel-verified"
|
||||
|
|
@ -90,9 +88,9 @@ export function ChannelDetailPage({ id, navigate }: { id: number; navigate: Navi
|
|||
onDone={load}
|
||||
/>
|
||||
<ScamFakeActions idKey="channel_id" id={ch.ID} path="/api/actions/set-channel-flags" scam={ch.Scam} fake={ch.Fake} onDone={load} />
|
||||
<div className="dock-title">{t("attr.settings")}</div>
|
||||
<div className="dock-title">{"Settings"}</div>
|
||||
<ChannelSettingsAction channel={ch} onDone={load} />
|
||||
<div className="dock-title">{t("attr.attributes")}</div>
|
||||
<div className="dock-title">{"Attributes"}</div>
|
||||
<UsernameAction idKey="channel_id" id={ch.ID} path="/api/actions/set-channel-username" current={ch.Username} onDone={load} />
|
||||
<ColorAction idKey="channel_id" id={ch.ID} path="/api/actions/set-channel-color" onDone={load} />
|
||||
<EmojiStatusAction idKey="channel_id" id={ch.ID} path="/api/actions/set-channel-emoji-status" onDone={load} />
|
||||
|
|
|
|||
|
|
@ -3,14 +3,12 @@ import { useEffect, useState } from "react";
|
|||
import { api, errorMessage } from "../api";
|
||||
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
|
||||
import { ScamFakeBadges } from "../components/flags";
|
||||
import { useI18n } from "../i18n";
|
||||
import { channelKind, displayUsername, formatDate } from "../lib/format";
|
||||
import { channelMetrics } from "../lib/metrics";
|
||||
import type { Navigate } from "../routing";
|
||||
import type { ChannelListResponse } from "../types";
|
||||
|
||||
export function ChannelsPage({ navigate }: { navigate: Navigate }) {
|
||||
const { t } = useI18n();
|
||||
const [q, setQ] = useState("");
|
||||
const [limit, setLimit] = useState("50");
|
||||
const [data, setData] = useState<ChannelListResponse | null>(null);
|
||||
|
|
@ -50,37 +48,37 @@ export function ChannelsPage({ navigate }: { navigate: Navigate }) {
|
|||
|
||||
return (
|
||||
<PageFrame
|
||||
title={t("channel.pageTitle")}
|
||||
eyebrow={data?.listing === false ? t("account.queryResults") : t("channel.recentUpdated")}
|
||||
title={"Supergroups and Channels"}
|
||||
eyebrow={data?.listing === false ? "Search results" : "Recently updated"}
|
||||
actions={
|
||||
<button className="btn" type="button" onClick={() => load(false)} disabled={busy}>
|
||||
<RefreshCw size={15} /> {t("common.refresh")}
|
||||
<RefreshCw size={15} /> {"Refresh"}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="metric-row">
|
||||
<Metric label={t("channel.currentPage")} value={String(data?.rows.length ?? 0)} />
|
||||
<Metric label={t("channel.megagroups")} value={String(metrics.megagroups)} />
|
||||
<Metric label={t("channel.broadcasts")} value={String(metrics.broadcasts)} />
|
||||
<Metric label={t("channel.verifiedCount")} value={String(metrics.verified)} tone="good" />
|
||||
<Metric label={"Entities on page"} value={String(data?.rows.length ?? 0)} />
|
||||
<Metric label={"Supergroups"} value={String(metrics.megagroups)} />
|
||||
<Metric label={"Channels"} value={String(metrics.broadcasts)} />
|
||||
<Metric label={"Verified"} value={String(metrics.verified)} tone="good" />
|
||||
</div>
|
||||
<QueryPanel>
|
||||
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
|
||||
<label className="searchbox">
|
||||
<Search size={15} />
|
||||
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={t("channel.searchPlaceholder")} />
|
||||
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={"Channel ID / username / title"} />
|
||||
</label>
|
||||
<label className="field-inline">
|
||||
<span>{t("common.limit")}</span>
|
||||
<span>{"Limit"}</span>
|
||||
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="100" />
|
||||
</label>
|
||||
<button className="btn primary icon-text" type="submit" disabled={busy}>
|
||||
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {t("common.search")}
|
||||
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {"Search"}
|
||||
</button>
|
||||
{data?.listing && data.has_more && (
|
||||
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
|
||||
<ChevronRight size={15} /> {t("messages.nextPage")}
|
||||
<ChevronRight size={15} /> {"Next page"}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
|
|
@ -89,15 +87,15 @@ export function ChannelsPage({ navigate }: { navigate: Navigate }) {
|
|||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("channel.channelID")}</th>
|
||||
<th>{t("channel.kind")}</th>
|
||||
<th>{t("common.username")}</th>
|
||||
<th>{t("channel.title")}</th>
|
||||
<th>{t("common.members")}</th>
|
||||
<th>{t("common.admins")}</th>
|
||||
<th>{"Channel ID"}</th>
|
||||
<th>{"Kind"}</th>
|
||||
<th>{"Username"}</th>
|
||||
<th>{"Title"}</th>
|
||||
<th>{"Members"}</th>
|
||||
<th>{"Admins"}</th>
|
||||
<th>PTS</th>
|
||||
<th>{t("common.verified")}</th>
|
||||
<th>{t("common.updatedAt")}</th>
|
||||
<th>{"Verified"}</th>
|
||||
<th>{"Updated"}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -105,15 +103,15 @@ export function ChannelsPage({ navigate }: { navigate: Navigate }) {
|
|||
{data?.rows.map((row) => (
|
||||
<tr key={row.ID}>
|
||||
<td className="mono">{row.ID}</td>
|
||||
<td>{channelKind(row, t)}</td>
|
||||
<td>{channelKind(row)}</td>
|
||||
<td>{displayUsername(row.Username)}</td>
|
||||
<td>{row.Title}</td>
|
||||
<td>{row.ParticipantsCount}</td>
|
||||
<td>{row.AdminsCount}</td>
|
||||
<td>{row.PTS}</td>
|
||||
<td>{row.Verified ? <Badge tone="good">{t("common.verified")}</Badge> : <Badge>{t("account.notVerified")}</Badge>} <ScamFakeBadges scam={row.Scam} fake={row.Fake} /></td>
|
||||
<td>{row.Verified ? <Badge tone="good">{"Verified"}</Badge> : <Badge>{"Not verified"}</Badge>} <ScamFakeBadges scam={row.Scam} fake={row.Fake} /></td>
|
||||
<td>{formatDate(row.UpdatedAt)}</td>
|
||||
<td><button className="row-link" onClick={() => navigate(`/channels/${row.ID}`)}>{t("common.detail")} <ChevronRight size={14} /></button></td>
|
||||
<td><button className="row-link" onClick={() => navigate(`/channels/${row.ID}`)}>{"Details"} <ChevronRight size={14} /></button></td>
|
||||
</tr>
|
||||
))}
|
||||
{(!data || data.rows.length === 0) && <EmptyRow colSpan={10} />}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,257 @@
|
|||
import { ArrowLeft, ArrowLeftRight, ExternalLink, Flame, Trash2, RefreshCw, Undo2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { ActionButton } from "../components/ActionButton";
|
||||
import { ChannelPicker, UserPicker } from "../components/EntityPicker";
|
||||
import { Alert, Badge, EmptyRow, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
|
||||
import { displayUsername, formatCurrency, formatDate } from "../lib/format";
|
||||
import type { Navigate } from "../routing";
|
||||
import type {
|
||||
AccountRow,
|
||||
ChannelRow,
|
||||
CollectibleUsernameDetail,
|
||||
CollectibleUsernameTransferKind
|
||||
} from "../types";
|
||||
import { UsernameStatus, ownerLabel, priceLabel } from "./CollectibleUsernamesPage";
|
||||
|
||||
type RecipientKind = "user" | "channel";
|
||||
|
||||
export function CollectibleUsernameDetailPage({ id, navigate }: { id: string; navigate: Navigate }) {
|
||||
const [detail, setDetail] = useState<CollectibleUsernameDetail | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [recipientKind, setRecipientKind] = useState<RecipientKind>("user");
|
||||
const [recipientUser, setRecipientUser] = useState<AccountRow | null>(null);
|
||||
const [recipientChannel, setRecipientChannel] = useState<ChannelRow | null>(null);
|
||||
|
||||
async function load() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
setDetail(await api.collectibleUsername(id));
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [id]);
|
||||
|
||||
if (error && !detail) {
|
||||
return <Alert>{error}</Alert>;
|
||||
}
|
||||
if (!detail) {
|
||||
return <LoadingSurface label={busy ? "Loading collectible username…" : "Waiting for data"} />;
|
||||
}
|
||||
|
||||
const asset = detail.asset;
|
||||
const transfers = detail.transfers ?? [];
|
||||
const vaultLabel = "Vault";
|
||||
const hasOwner = Boolean(asset.OwnerPeerType) && asset.OwnerPeerID !== "" && asset.OwnerPeerID !== "0";
|
||||
const burned = asset.Status === "burned";
|
||||
|
||||
function openOwner() {
|
||||
if (!hasOwner) return;
|
||||
navigate(asset.OwnerPeerType === "channel" ? `/channels/${asset.OwnerPeerID}` : `/accounts/${asset.OwnerPeerID}`);
|
||||
}
|
||||
|
||||
// Peer ids travel as decimal strings to match the backend `,string` tags.
|
||||
function transferPayload(): Record<string, unknown> {
|
||||
const payload: Record<string, unknown> = { username: asset.Username };
|
||||
if (recipientKind === "user" && recipientUser) payload.to_user_id = String(recipientUser.ID);
|
||||
if (recipientKind === "channel" && recipientChannel) payload.to_channel_id = String(recipientChannel.ID);
|
||||
return payload;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageFrame
|
||||
title={`Collectible ${displayUsername(asset.Username)}`}
|
||||
eyebrow={"NFT usernames / Asset"}
|
||||
actions={
|
||||
<>
|
||||
<button className="btn icon-text" type="button" onClick={() => navigate("/collectible-usernames")}>
|
||||
<ArrowLeft size={15} /> {"Back to list"}
|
||||
</button>
|
||||
<button className="btn icon-text" type="button" onClick={load} disabled={busy}>
|
||||
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"Refresh"}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<SplitLayout
|
||||
main={
|
||||
<div className="stacked-sections">
|
||||
<section className="entity-head">
|
||||
<div>
|
||||
<div className="entity-title">{displayUsername(asset.Username)}</div>
|
||||
<div className="entity-subtitle">{`Asset #${asset.ID}`}</div>
|
||||
</div>
|
||||
<div className="entity-badges">
|
||||
<UsernameStatus status={asset.Status} />
|
||||
<Badge tone={asset.TransferCount > 0 ? "warn" : "neutral"}>
|
||||
{`${asset.TransferCount} transfers`}
|
||||
</Badge>
|
||||
{asset.Status === "owned" && (
|
||||
<Badge tone={asset.RegistryActive ? "good" : "warn"}>
|
||||
{asset.RegistryActive ? "Active in profile" : "Hidden in profile"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
<div className="summary-grid">
|
||||
<Summary label={"Owner"} value={ownerLabel(asset, vaultLabel)} />
|
||||
<Summary label={"Price"} value={priceLabel(asset)} mono />
|
||||
<Summary label={"Purchase date (UTC)"} value={formatDate(asset.PurchaseDate) || "-"} />
|
||||
<Summary
|
||||
label={"Original owner"}
|
||||
value={peerLabel(asset.OriginalOwnerPeerType, asset.OriginalOwnerPeerID, vaultLabel, asset.OriginalOwnerUsername)}
|
||||
/>
|
||||
<Summary label={"Transfers"} value={String(asset.TransferCount)} mono />
|
||||
<Summary label={"Created"} value={formatDate(asset.CreatedAt) || "-"} />
|
||||
<Summary label={"Updated"} value={formatDate(asset.UpdatedAt) || "-"} />
|
||||
</div>
|
||||
<div className="toolbar">
|
||||
{hasOwner && (
|
||||
<button className="row-link" type="button" onClick={openOwner}>
|
||||
{asset.OwnerPeerType === "channel" ? "Open owner channel" : "Open owner account"}
|
||||
</button>
|
||||
)}
|
||||
{asset.URL && (
|
||||
<a className="row-link" href={asset.URL} target="_blank" rel="noreferrer noopener">
|
||||
<ExternalLink size={14} /> {"Open marketplace page"}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!burned && (
|
||||
<section className="section-block">
|
||||
<SectionHead title={"Transfer ownership"} text={"Pick the recipient; the transfer is appended to the provenance history."} />
|
||||
<div className="toolbar" role="group" aria-label={"Recipient type"}>
|
||||
<button type="button" className={`btn ${recipientKind === "user" ? "primary" : ""}`} onClick={() => setRecipientKind("user")}>
|
||||
{"To user"}
|
||||
</button>
|
||||
<button type="button" className={`btn ${recipientKind === "channel" ? "primary" : ""}`} onClick={() => setRecipientKind("channel")}>
|
||||
{"To channel"}
|
||||
</button>
|
||||
</div>
|
||||
{recipientKind === "user"
|
||||
? <UserPicker label={"To user"} value={recipientUser} onChange={setRecipientUser} />
|
||||
: <ChannelPicker label={"To channel"} value={recipientChannel} onChange={setRecipientChannel} />}
|
||||
<div className="bot-create-actions">
|
||||
<span className="bot-create-note">{"The current owner loses the username immediately after confirmation."}</span>
|
||||
<ActionButton
|
||||
label={"Transfer"}
|
||||
icon={<ArrowLeftRight size={15} />}
|
||||
tone="warn"
|
||||
path="/api/actions/transfer-collectible-username"
|
||||
payload={transferPayload}
|
||||
onDone={load}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="section-block">
|
||||
<SectionHead title={"Provenance history"} text={"Mint, transfer, revoke and burn events in chronological order."} />
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{"ID"}</th>
|
||||
<th>{"Event"}</th>
|
||||
<th>{"From"}</th>
|
||||
<th>{"To"}</th>
|
||||
<th>{"Price"}</th>
|
||||
<th>{"Actor"}</th>
|
||||
<th>{"Reason"}</th>
|
||||
<th>{"Time"}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{transfers.map((row) => (
|
||||
<tr key={row.ID}>
|
||||
<td className="mono">{row.ID}</td>
|
||||
<td><TransferKind kind={row.Kind} /></td>
|
||||
<td className="mono">{peerLabel(row.FromPeerType, row.FromPeerID, vaultLabel, row.FromUsername)}</td>
|
||||
<td className="mono">{peerLabel(row.ToPeerType, row.ToPeerID, vaultLabel, row.ToUsername)}</td>
|
||||
<td className="mono">{row.Amount && row.Amount !== "0" ? formatCurrency(row.Amount, row.Currency) : "-"}</td>
|
||||
<td>{row.Actor || "-"}</td>
|
||||
<td className="truncate">{row.Reason || "-"}</td>
|
||||
<td>{formatDate(row.CreatedAt) || "-"}</td>
|
||||
</tr>
|
||||
))}
|
||||
{transfers.length === 0 && <EmptyRow colSpan={8} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
side={
|
||||
<section className="action-dock">
|
||||
<div className="dock-title">{"Asset operations"}</div>
|
||||
{burned ? (
|
||||
<p className="bot-create-note">{"This username is burned — no further operations are possible."}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="action-stack">
|
||||
<ActionButton
|
||||
label={"Revoke to vault"}
|
||||
icon={<Undo2 size={15} />}
|
||||
tone="warn"
|
||||
path="/api/actions/revoke-collectible-username"
|
||||
payload={() => ({ username: asset.Username, burn: false })}
|
||||
onDone={load}
|
||||
/>
|
||||
</div>
|
||||
<p className="bot-create-note">{"Takes the username away from its owner and returns it to the vault; it can be issued again later."}</p>
|
||||
<div className="danger-zone">
|
||||
<ActionButton
|
||||
label={"Burn permanently"}
|
||||
icon={<Flame size={15} />}
|
||||
tone="danger"
|
||||
path="/api/actions/revoke-collectible-username"
|
||||
payload={() => ({ username: asset.Username, burn: true })}
|
||||
onDone={load}
|
||||
/>
|
||||
<p className="bot-create-note">{"Irreversible: the username is destroyed and can never be issued again."}</p>
|
||||
<ActionButton
|
||||
label={"Delete record"}
|
||||
icon={<Trash2 size={15} />}
|
||||
tone="danger"
|
||||
path="/api/actions/delete-collectible-username"
|
||||
payload={() => ({ username: asset.Username })}
|
||||
onDone={() => navigate("/collectible-usernames")}
|
||||
/>
|
||||
<p className="bot-create-note">{"Erases the asset and its ownership history, and frees the username for a fresh issue. Use this for a username issued by mistake; a burn keeps the history instead."}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
}
|
||||
/>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
const usernameKindLabels: Record<CollectibleUsernameTransferKind, string> = {
|
||||
mint: "Mint",
|
||||
transfer: "Transfer",
|
||||
burn: "Burn",
|
||||
revoke: "Revoke"
|
||||
};
|
||||
|
||||
function TransferKind({ kind }: { kind: CollectibleUsernameTransferKind }) {
|
||||
const tone = kind === "burn" ? "danger" : kind === "revoke" ? "warn" : kind === "mint" ? "good" : "neutral";
|
||||
return <Badge tone={tone}>{usernameKindLabels[kind]}</Badge>;
|
||||
}
|
||||
|
||||
function peerLabel(type: string, peerID: string, vaultLabel: string, username = ""): string {
|
||||
if (!type || peerID === "" || peerID === "0") return vaultLabel;
|
||||
const handle = displayUsername(username);
|
||||
return handle ? `${handle} · ${type}:${peerID}` : `${type}:${peerID}`;
|
||||
}
|
||||
302
cmd/telesrv-admin/web/src/pages/CollectibleUsernamesPage.tsx
Normal file
302
cmd/telesrv-admin/web/src/pages/CollectibleUsernamesPage.tsx
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
import { AtSign, ChevronDown, ChevronRight, Flame, Loader2, Plus, RefreshCw, Search, Vault } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { ActionButton } from "../components/ActionButton";
|
||||
import { ChannelPicker, UserPicker } from "../components/EntityPicker";
|
||||
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel, SectionHead } from "../components/ui";
|
||||
import { currencyExponent, displayUsername, formatCurrency, formatDate, toSmallestUnits } from "../lib/format";
|
||||
import type { Navigate } from "../routing";
|
||||
import type {
|
||||
AccountRow,
|
||||
ChannelRow,
|
||||
CollectibleCurrency,
|
||||
CollectibleUsernameRow,
|
||||
CollectibleUsernameStatus
|
||||
} from "../types";
|
||||
|
||||
type StatusFilter = "all" | CollectibleUsernameStatus;
|
||||
type OwnerKind = "vault" | "user" | "channel";
|
||||
|
||||
export function CollectibleUsernamesPage({ navigate }: { navigate: Navigate }) {
|
||||
const [status, setStatus] = useState<StatusFilter>("all");
|
||||
const [q, setQ] = useState("");
|
||||
const [limit, setLimit] = useState("50");
|
||||
const [rows, setRows] = useState<CollectibleUsernameRow[]>([]);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [cursor, setCursor] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// Mint form state.
|
||||
const [ownerKind, setOwnerKind] = useState<OwnerKind>("vault");
|
||||
const [owner, setOwner] = useState<AccountRow | null>(null);
|
||||
const [ownerChannel, setOwnerChannel] = useState<ChannelRow | null>(null);
|
||||
const [mintUsername, setMintUsername] = useState("");
|
||||
const [currency, setCurrency] = useState<CollectibleCurrency>("XTR");
|
||||
const [amount, setAmount] = useState("");
|
||||
const [cryptoCurrency, setCryptoCurrency] = useState("");
|
||||
const [cryptoAmount, setCryptoAmount] = useState("");
|
||||
const [url, setUrl] = useState("");
|
||||
const [purchaseDate, setPurchaseDate] = useState("");
|
||||
const [purchaseTime, setPurchaseTime] = useState("");
|
||||
|
||||
async function load(next = false) {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
const params = new URLSearchParams({ limit });
|
||||
if (status !== "all") params.set("status", status);
|
||||
if (q.trim()) params.set("q", q.trim().replace(/^@/, ""));
|
||||
if (next && cursor) params.set("before_id", cursor);
|
||||
try {
|
||||
const result = await api.collectibleUsernames(params);
|
||||
const page = result.rows ?? [];
|
||||
setRows((current) => (next ? [...current, ...page] : page));
|
||||
setCursor(result.next_before_id ?? "");
|
||||
setHasMore(Boolean(result.has_more));
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load(false);
|
||||
}, []);
|
||||
|
||||
const vaultCount = rows.filter((row) => row.Status === "vault").length;
|
||||
const ownedCount = rows.filter((row) => row.Status === "owned").length;
|
||||
const burnedCount = rows.filter((row) => row.Status === "burned").length;
|
||||
|
||||
// int64 request fields are sent as decimal strings (the backend tags them
|
||||
// `,string`); purchase_date is Unix seconds. Optional owner keys are omitted
|
||||
// entirely rather than sent empty, because `,string,omitempty` cannot decode "".
|
||||
// Both amounts are typed in whole currency units and converted here: the API
|
||||
// and fragment.collectibleInfo carry smallest units, so 900 TON has to leave
|
||||
// the panel as 900000000000 nanotons or clients render 0.0000009.
|
||||
const minorAmount = toSmallestUnits(amount, currency);
|
||||
const minorCryptoAmount = cryptoCurrency ? toSmallestUnits(cryptoAmount, cryptoCurrency) : "0";
|
||||
const amountInvalid = minorAmount === null;
|
||||
const cryptoAmountInvalid = minorCryptoAmount === null;
|
||||
|
||||
function mintPayload(): Record<string, unknown> {
|
||||
const payload: Record<string, unknown> = {
|
||||
username: mintUsername.trim().replace(/^@/, ""),
|
||||
currency,
|
||||
amount: minorAmount ?? "0"
|
||||
};
|
||||
if (ownerKind === "user" && owner) payload.owner_user_id = String(owner.ID);
|
||||
if (ownerKind === "channel" && ownerChannel) payload.owner_channel_id = String(ownerChannel.ID);
|
||||
// The backend accepts either no crypto leg at all, or TON with a positive
|
||||
// nanoton amount — never a currency without an amount.
|
||||
if (cryptoCurrency) {
|
||||
payload.crypto_currency = cryptoCurrency;
|
||||
payload.crypto_amount = minorCryptoAmount ?? "0";
|
||||
}
|
||||
if (url.trim()) payload.url = url.trim();
|
||||
if (purchaseDate) {
|
||||
// fragment.collectibleInfo.purchase_date is a unix timestamp, and the date has
|
||||
// always been read as UTC here. The time follows the same clock rather than the
|
||||
// operator's local one, so adding it cannot silently shift what a date-only
|
||||
// entry used to mean; the field label says UTC.
|
||||
const parsed = Date.parse(`${purchaseDate}T${purchaseTime || "00:00"}:00Z`);
|
||||
if (Number.isFinite(parsed)) payload.purchase_date = Math.floor(parsed / 1000);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageFrame
|
||||
title={"Collectible usernames"}
|
||||
eyebrow={"NFT usernames / Registry"}
|
||||
actions={
|
||||
<button className="btn icon-text" type="button" onClick={() => load(false)} disabled={busy}>
|
||||
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"Refresh"}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="metric-row">
|
||||
<Metric label={"Loaded rows"} value={String(rows.length)} />
|
||||
<Metric label={"In vault"} value={String(vaultCount)} />
|
||||
<Metric label={"Held by owners"} value={String(ownedCount)} tone="good" />
|
||||
<Metric label={"Burned"} value={String(burnedCount)} tone={burnedCount ? "danger" : "neutral"} />
|
||||
</div>
|
||||
|
||||
<section className="section-block">
|
||||
<SectionHead title={"Mint a collectible username"} text={"Creates the asset together with its purchase record. Keep the owner as vault to mint it unassigned."} />
|
||||
<div className="toolbar" role="group" aria-label={"Owner type"}>
|
||||
<button type="button" className={`btn ${ownerKind === "vault" ? "primary" : ""}`} onClick={() => setOwnerKind("vault")}>
|
||||
<Vault size={15} /> {"Vault (no owner)"}
|
||||
</button>
|
||||
<button type="button" className={`btn ${ownerKind === "user" ? "primary" : ""}`} onClick={() => setOwnerKind("user")}>
|
||||
{"User owner"}
|
||||
</button>
|
||||
<button type="button" className={`btn ${ownerKind === "channel" ? "primary" : ""}`} onClick={() => setOwnerKind("channel")}>
|
||||
{"Channel owner"}
|
||||
</button>
|
||||
</div>
|
||||
{ownerKind === "user" && <UserPicker label={"User owner"} value={owner} onChange={setOwner} />}
|
||||
{ownerKind === "channel" && <ChannelPicker label={"Channel owner"} value={ownerChannel} onChange={setOwnerChannel} />}
|
||||
<div className="bot-create-fields">
|
||||
<label className="duration-field">
|
||||
<span>{"Username"}</span>
|
||||
<input value={mintUsername} onChange={(event) => setMintUsername(event.target.value)} placeholder="durov" />
|
||||
</label>
|
||||
<label className="duration-field">
|
||||
<span>{"Currency"}</span>
|
||||
<select value={currency} onChange={(event) => setCurrency(event.target.value as CollectibleCurrency)}>
|
||||
<option value="XTR">XTR</option>
|
||||
<option value="TON">TON</option>
|
||||
<option value="USD">USD</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="duration-field">
|
||||
<span>{`Amount (${currency})`}</span>
|
||||
<input value={amount} onChange={(event) => setAmount(event.target.value)} inputMode="decimal" placeholder="1000" />
|
||||
</label>
|
||||
<label className="duration-field">
|
||||
<span>{"Crypto currency"}</span>
|
||||
<select value={cryptoCurrency} onChange={(event) => setCryptoCurrency(event.target.value)}>
|
||||
<option value="">{"None"}</option>
|
||||
<option value="TON">TON</option>
|
||||
</select>
|
||||
</label>
|
||||
{cryptoCurrency !== "" && (
|
||||
<label className="duration-field">
|
||||
<span>{`Crypto amount (${cryptoCurrency})`}</span>
|
||||
<input value={cryptoAmount} onChange={(event) => setCryptoAmount(event.target.value)} inputMode="decimal" placeholder="12.5" />
|
||||
</label>
|
||||
)}
|
||||
<label className="duration-field">
|
||||
<span>{"Marketplace URL"}</span>
|
||||
<input value={url} onChange={(event) => setUrl(event.target.value)} placeholder="https://fragment.com/username/durov" />
|
||||
</label>
|
||||
<label className="duration-field">
|
||||
<span>{"Purchase date (UTC)"}</span>
|
||||
<input value={purchaseDate} onChange={(event) => setPurchaseDate(event.target.value)} type="date" />
|
||||
</label>
|
||||
<label className="duration-field">
|
||||
<span>{"Purchase time (UTC)"}</span>
|
||||
<input
|
||||
value={purchaseTime}
|
||||
onChange={(event) => setPurchaseTime(event.target.value)}
|
||||
type="time"
|
||||
step={60}
|
||||
disabled={!purchaseDate}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p className="bot-create-note">
|
||||
{`Amounts are typed in whole ${currency} and stored as the smallest units the API and fragment.collectibleInfo carry, so clients render the price you meant. Up to ${String(currencyExponent(currency))} decimal places. Clients will show: ${formatCurrency(minorAmount ?? "0", currency)}.`}
|
||||
</p>
|
||||
{amountInvalid && <Alert>{`That is not a valid ${currency} amount: digits only, with at most ${String(currencyExponent(currency))} decimal places.`}</Alert>}
|
||||
{cryptoCurrency !== "" && cryptoAmountInvalid && (
|
||||
<Alert>{`That is not a valid ${cryptoCurrency} amount: digits only, with at most ${String(currencyExponent(cryptoCurrency))} decimal places.`}</Alert>
|
||||
)}
|
||||
<div className="bot-create-actions">
|
||||
<span className="bot-create-note">{"Username, currency and amount are required; the dry-run checks availability first."}</span>
|
||||
<ActionButton
|
||||
disabled={amountInvalid || cryptoAmountInvalid}
|
||||
label={"Mint username"}
|
||||
icon={<Plus size={15} />}
|
||||
tone="neutral"
|
||||
path="/api/actions/mint-collectible-username"
|
||||
payload={mintPayload}
|
||||
onDone={() => load(false)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<QueryPanel>
|
||||
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
|
||||
<label className="searchbox">
|
||||
<Search size={15} />
|
||||
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={"Search by username"} />
|
||||
</label>
|
||||
<label className="field-inline">
|
||||
<span>{"Status"}</span>
|
||||
<select value={status} onChange={(event) => setStatus(event.target.value as StatusFilter)}>
|
||||
<option value="all">{"All statuses"}</option>
|
||||
<option value="vault">{"Vault"}</option>
|
||||
<option value="owned">{"Owned"}</option>
|
||||
<option value="burned">{"Burned"}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="field-inline">
|
||||
<span>{"Limit"}</span>
|
||||
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="200" />
|
||||
</label>
|
||||
<button className="btn primary icon-text" type="submit" disabled={busy}>
|
||||
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {"Search"}
|
||||
</button>
|
||||
</form>
|
||||
</QueryPanel>
|
||||
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{"Username"}</th>
|
||||
<th>{"Status"}</th>
|
||||
<th>{"Owner"}</th>
|
||||
<th>{"Price"}</th>
|
||||
<th>{"Purchase date (UTC)"}</th>
|
||||
<th>{"Transfers"}</th>
|
||||
<th>{"Updated"}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.ID}>
|
||||
<td><strong>{displayUsername(row.Username)}</strong></td>
|
||||
<td><UsernameStatus status={row.Status} /></td>
|
||||
<td>{ownerLabel(row, "Vault")}</td>
|
||||
<td className="mono">{priceLabel(row)}</td>
|
||||
<td>{formatDate(row.PurchaseDate) || "-"}</td>
|
||||
<td className="mono">{row.TransferCount}</td>
|
||||
<td>{formatDate(row.UpdatedAt) || "-"}</td>
|
||||
<td>
|
||||
<button className="row-link" type="button" onClick={() => navigate(`/collectible-usernames/${row.ID}`)}>
|
||||
<AtSign size={14} /> {"Details"} <ChevronRight size={14} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows.length === 0 && <EmptyRow colSpan={8} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{hasMore && (
|
||||
<div className="toolbar">
|
||||
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
|
||||
{busy ? <Loader2 size={15} className="spin" /> : <ChevronDown size={15} />} {"Load more"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export function UsernameStatus({ status }: { status: CollectibleUsernameStatus }) {
|
||||
if (status === "owned") return <Badge tone="good">{"Owned"}</Badge>;
|
||||
if (status === "burned") return <Badge tone="danger"><Flame size={12} /> {"Burned"}</Badge>;
|
||||
return <Badge><Vault size={12} /> {"Vault"}</Badge>;
|
||||
}
|
||||
|
||||
export function ownerLabel(row: CollectibleUsernameRow, vaultLabel: string): string {
|
||||
if (!row.OwnerPeerType || row.OwnerPeerID === "" || row.OwnerPeerID === "0") return vaultLabel;
|
||||
const name = displayUsername(row.OwnerUsername) || row.OwnerName || row.OwnerPeerID;
|
||||
return `${name} · ${row.OwnerPeerType}:${row.OwnerPeerID}`;
|
||||
}
|
||||
|
||||
// priceLabel renders what a Telegram client will draw, not the stored integer:
|
||||
// both legs are smallest units on the wire (see formatCurrency).
|
||||
export function priceLabel(row: CollectibleUsernameRow): string {
|
||||
const base = formatCurrency(row.Amount, row.Currency);
|
||||
if (row.CryptoCurrency && row.CryptoAmount && row.CryptoAmount !== "0") {
|
||||
return `${base} (${formatCurrency(row.CryptoAmount, row.CryptoCurrency)})`;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
|
@ -3,14 +3,11 @@ import { useState } from "react";
|
|||
import { createPortal } from "react-dom";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { Alert } from "../components/ui";
|
||||
import { useI18n } from "../i18n";
|
||||
|
||||
// Real Telegram sticker/emoji packs are created with at least one item, so
|
||||
// this form collects the title/short name plus a single starting file —
|
||||
// exactly like CreateStickerSet's domain-level requirement. More stickers
|
||||
// get added afterward from the pack's own preview modal.
|
||||
export function CreateStickerSetModal({ kind, onClose, onCreated }: { kind: "stickers" | "emoji"; onClose: () => void; onCreated: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const noun = kind === "emoji" ? "emoji" : "sticker";
|
||||
const [title, setTitle] = useState("");
|
||||
const [shortName, setShortName] = useState("");
|
||||
|
|
@ -22,11 +19,11 @@ export function CreateStickerSetModal({ kind, onClose, onCreated }: { kind: "sti
|
|||
|
||||
async function submit() {
|
||||
if (!title.trim() || !shortName.trim() || !emoji.trim() || !file) {
|
||||
setError(t("stickers.createFieldsRequired", { noun }));
|
||||
setError(`Title, short name, emoji and a first ${noun} file are required.`);
|
||||
return;
|
||||
}
|
||||
if (!reason.trim()) {
|
||||
setError(t("action.reasonRequired"));
|
||||
setError("Please enter an operation reason");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
|
|
@ -50,33 +47,33 @@ export function CreateStickerSetModal({ kind, onClose, onCreated }: { kind: "sti
|
|||
|
||||
return createPortal(
|
||||
<div className="modal-backdrop" role="presentation">
|
||||
<section className="modal command-modal" role="dialog" aria-modal="true" aria-label={t("stickers.createTitle", { noun })}>
|
||||
<section className="modal command-modal" role="dialog" aria-modal="true" aria-label={`Create a new ${noun} pack`}>
|
||||
<div className="modal-head">
|
||||
<div>
|
||||
<div className="eyebrow">{t("stickers.createEyebrow")}</div>
|
||||
<h2>{t("stickers.createTitle", { noun })}</h2>
|
||||
<div className="eyebrow">{"New set"}</div>
|
||||
<h2>{`Create a new ${noun} pack`}</h2>
|
||||
</div>
|
||||
<button className="icon-btn" type="button" onClick={onClose} disabled={busy} aria-label={t("action.close")}><X size={15} /></button>
|
||||
<button className="icon-btn" type="button" onClick={onClose} disabled={busy} aria-label={"Close"}><X size={15} /></button>
|
||||
</div>
|
||||
<div className="command-body">
|
||||
<div className="gift-fields-grid">
|
||||
<label><span>{t("stickers.title")}</span><input value={title} maxLength={64} onChange={(event) => setTitle(event.target.value)} /></label>
|
||||
<label><span>{t("stickers.shortName")}</span><input value={shortName} maxLength={32} onChange={(event) => setShortName(event.target.value)} placeholder={t("stickers.shortNamePlaceholder")} /></label>
|
||||
<label><span>{t("stickers.emoji")}</span><input value={emoji} onChange={(event) => setEmoji(event.target.value)} placeholder={t("stickers.emojiPlaceholder")} /></label>
|
||||
<label><span>{"Title"}</span><input value={title} maxLength={64} onChange={(event) => setTitle(event.target.value)} /></label>
|
||||
<label><span>{"Short name"}</span><input value={shortName} maxLength={32} onChange={(event) => setShortName(event.target.value)} placeholder={"lowercase_short_name"} /></label>
|
||||
<label><span>{"Emoji"}</span><input value={emoji} onChange={(event) => setEmoji(event.target.value)} placeholder={"e.g. 😀"} /></label>
|
||||
</div>
|
||||
<label className={`gift-file-picker ${file ? "has-file" : ""}`}>
|
||||
<input type="file" accept=".tgs,.json,.webp,application/json,application/x-tgsticker,image/webp" onChange={(event) => setFile(event.target.files?.[0] ?? null)} />
|
||||
<span className="gift-file-copy"><span className="gift-field-label">{t("stickers.firstSticker", { noun })}</span><strong>{file ? file.name : t("stickers.filePrompt")}</strong></span>
|
||||
<span className="gift-file-action">{file ? t("gifts.changeFile") : t("gifts.chooseFile")}</span>
|
||||
<span className="gift-file-copy"><span className="gift-field-label">{`First ${noun}`}</span><strong>{file ? file.name : "Choose a TGS, Lottie JSON, or WebP file"}</strong></span>
|
||||
<span className="gift-file-action">{file ? "Change file" : "Choose file"}</span>
|
||||
</label>
|
||||
<label className="gift-reason-field"><span>{t("gifts.reason")}</span><input value={reason} placeholder={t("gifts.reasonPlaceholder")} onChange={(event) => setReason(event.target.value)} /></label>
|
||||
<label className="gift-reason-field"><span>{"Audit reason"}</span><input value={reason} placeholder={"Briefly describe why this gift is being imported"} 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}>{t("common.close")}</button>
|
||||
<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} />}
|
||||
{t("stickers.create", { noun })}
|
||||
{`Create ${noun} pack`}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -2,34 +2,32 @@ import { CheckCircle2, ChevronRight, Clock3, FileJson, KeyRound, MessageSquareTe
|
|||
import type { ReactNode } from "react";
|
||||
import { AppLink } from "../components/AppLink";
|
||||
import { StatusItem } from "../components/ui";
|
||||
import { useI18n } from "../i18n";
|
||||
import type { Navigate } from "../routing";
|
||||
|
||||
export function Dashboard({ navigate }: { navigate: Navigate }) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="dashboard-layout">
|
||||
<section className="overview-band">
|
||||
<div>
|
||||
<div className="eyebrow">{t("dashboard.eyebrow")}</div>
|
||||
<h2>{t("dashboard.title")}</h2>
|
||||
<div className="eyebrow">{"Runtime Overview"}</div>
|
||||
<h2>{"Console Overview"}</h2>
|
||||
</div>
|
||||
<div className="overview-metrics">
|
||||
<StatusItem label={t("dashboard.readPath")} value={t("dashboard.readPathValue")} tone="neutral" />
|
||||
<StatusItem label={t("dashboard.writePath")} value="Admin API" tone="good" />
|
||||
<StatusItem label={t("dashboard.executionPolicy")} value={t("dashboard.dryRunFirst")} tone="warn" />
|
||||
<StatusItem label={"Read path"} value={"PG read-only"} tone="neutral" />
|
||||
<StatusItem label={"Write path"} value="Admin API" tone="good" />
|
||||
<StatusItem label={"Execution policy"} value={"Dry-run first"} tone="warn" />
|
||||
</div>
|
||||
</section>
|
||||
<div className="command-grid">
|
||||
<Launcher icon={<Users />} title={t("route.accounts")} text={t("dashboard.accountsText")} href="/accounts" navigate={navigate} />
|
||||
<Launcher icon={<ShieldCheck />} title={t("route.channels")} text={t("dashboard.channelsText")} href="/channels" navigate={navigate} />
|
||||
<Launcher icon={<MessageSquareText />} title={t("route.messages")} text={t("dashboard.messagesText")} href="/messages" navigate={navigate} />
|
||||
<Launcher icon={<Users />} title={"Accounts"} text={"Account status, premium, verification, sessions."} href="/accounts" navigate={navigate} />
|
||||
<Launcher icon={<ShieldCheck />} title={"Supergroups and Channels"} text={"Public entities, member counts, verification state."} href="/channels" navigate={navigate} />
|
||||
<Launcher icon={<MessageSquareText />} title={"Message Audit"} text={"Message boxes, updates, outbox state."} href="/messages" navigate={navigate} />
|
||||
</div>
|
||||
<section className="work-strip">
|
||||
<div className="strip-item"><CheckCircle2 size={16} /><span>{t("dashboard.strip.dryRun")}</span></div>
|
||||
<div className="strip-item"><KeyRound size={16} /><span>{t("dashboard.strip.token")}</span></div>
|
||||
<div className="strip-item"><Clock3 size={16} /><span>{t("dashboard.strip.pagination")}</span></div>
|
||||
<div className="strip-item"><FileJson size={16} /><span>{t("dashboard.strip.snapshot")}</span></div>
|
||||
<div className="strip-item"><CheckCircle2 size={16} /><span>{"All dangerous actions start with dry-run"}</span></div>
|
||||
<div className="strip-item"><KeyRound size={16} /><span>{"Browser never stores internal tokens"}</span></div>
|
||||
<div className="strip-item"><Clock3 size={16} /><span>{"Lists use cursor pagination"}</span></div>
|
||||
<div className="strip-item"><FileJson size={16} /><span>{"Detail pages retain raw state snapshots"}</span></div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { useEffect, useState } from "react";
|
|||
import { api, errorMessage } from "../api";
|
||||
import { StaticLottie } from "../components/StaticLottie";
|
||||
import { Alert, Metric, PageFrame, QueryPanel } from "../components/ui";
|
||||
import { useI18n } from "../i18n";
|
||||
import type { EmojiListResponse, EmojiRow } from "../types";
|
||||
|
||||
function formatBytes(value: number): string {
|
||||
|
|
@ -40,7 +39,6 @@ function EmojiPreview({ row }: { row: EmojiRow }) {
|
|||
}
|
||||
|
||||
function EmojiCard({ row }: { row: EmojiRow }) {
|
||||
const { t } = useI18n();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function copy() {
|
||||
|
|
@ -58,18 +56,17 @@ function EmojiCard({ row }: { row: EmojiRow }) {
|
|||
<div className="emoji-preview"><EmojiPreview row={row} /></div>
|
||||
<div className="emoji-meta">
|
||||
<span className="emoji-alt">{row.Alt || "—"}</span>
|
||||
<button className="emoji-id" type="button" onClick={copy} title={t("emoji.copyID")}>
|
||||
<button className="emoji-id" type="button" onClick={copy} title={"Copy document ID"}>
|
||||
<span className="mono">{row.DocumentID}</span>
|
||||
{copied ? <Check size={12} /> : <Copy size={12} />}
|
||||
</button>
|
||||
<span className="emoji-sub">{row.SetTitle || t("emoji.noSet")} · {formatBytes(row.Size)}</span>
|
||||
<span className="emoji-sub">{row.SetTitle || "No set"} · {formatBytes(row.Size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmojiPage() {
|
||||
const { t } = useI18n();
|
||||
const [q, setQ] = useState("");
|
||||
const [data, setData] = useState<EmojiListResponse | null>(null);
|
||||
const [cursor, setCursor] = useState(0);
|
||||
|
|
@ -104,37 +101,37 @@ export function EmojiPage() {
|
|||
|
||||
return (
|
||||
<PageFrame
|
||||
title={t("emoji.pageTitle")}
|
||||
eyebrow={data?.listing === false ? t("emoji.queryResults") : t("emoji.recent")}
|
||||
title={"Custom Emoji"}
|
||||
eyebrow={data?.listing === false ? "Search results" : "Custom emoji catalog"}
|
||||
actions={
|
||||
<button className="btn" type="button" onClick={() => load(false)} disabled={busy}>
|
||||
<RefreshCw size={15} /> {t("common.refresh")}
|
||||
<RefreshCw size={15} /> {"Refresh"}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="metric-row">
|
||||
<Metric label={t("emoji.currentPage")} value={String(rows.length)} />
|
||||
<Metric label={"Emoji on page"} value={String(rows.length)} />
|
||||
</div>
|
||||
<QueryPanel>
|
||||
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
|
||||
<label className="searchbox">
|
||||
<Search size={15} />
|
||||
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={t("emoji.searchPlaceholder")} />
|
||||
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={"Document ID or emoji"} />
|
||||
</label>
|
||||
<button className="btn primary icon-text" type="submit" disabled={busy}>
|
||||
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {t("common.search")}
|
||||
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {"Search"}
|
||||
</button>
|
||||
{data?.listing && data.has_more && (
|
||||
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
|
||||
<ChevronRight size={15} /> {t("messages.nextPage")}
|
||||
<ChevronRight size={15} /> {"Next page"}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
</QueryPanel>
|
||||
<p className="about-text">{t("emoji.hint")}</p>
|
||||
<p className="about-text">{"Document IDs here can be pasted into the Emoji status field on account, bot and channel profiles."}</p>
|
||||
{rows.length === 0 ? (
|
||||
<div className="empty-panel">{t("common.noResults")}</div>
|
||||
<div className="empty-panel">{"No results"}</div>
|
||||
) : (
|
||||
<div className="emoji-grid">
|
||||
{rows.map((row) => <EmojiCard key={row.DocumentID} row={row} />)}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
|||
import { createPortal } from "react-dom";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { Alert, Badge } from "../components/ui";
|
||||
import { useI18n } from "../i18n";
|
||||
import type { CommandResult, StarGiftCollectibleAttributeRow, StarGiftCollectiblePreview, StarGiftRow } from "../types";
|
||||
|
||||
type AnimationData = Record<string, unknown>;
|
||||
|
|
@ -106,8 +105,25 @@ async function parseAnimationFile(file: File): Promise<AnimationData> {
|
|||
const colorNumber = (value: string) => Number.parseInt(value.replace("#", ""), 16);
|
||||
const rarityLabel = (attribute: StarGiftCollectibleAttributeRow) => attribute.rarity_kind === "permille" ? `${attribute.rarity_permille}‰` : attribute.rarity_kind;
|
||||
|
||||
const collectibleGroupLabels: Record<"models" | "patterns", string> = {
|
||||
models: "Models",
|
||||
patterns: "Patterns"
|
||||
};
|
||||
|
||||
const collectibleAttributeLabels: Record<"model" | "pattern" | "backdrop", string> = {
|
||||
model: "Model",
|
||||
pattern: "Pattern",
|
||||
backdrop: "Backdrop"
|
||||
};
|
||||
|
||||
const collectibleColorLabels: Record<"center" | "edge" | "pattern" | "text", string> = {
|
||||
center: "Center",
|
||||
edge: "Edge",
|
||||
pattern: "Pattern",
|
||||
text: "Text"
|
||||
};
|
||||
|
||||
export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: StarGiftRow; onClose: () => void; onPublished: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const [active, setActive] = useState<StarGiftCollectiblePreview | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
|
@ -160,11 +176,11 @@ export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: St
|
|||
}
|
||||
|
||||
function buildForm(confirm: boolean, commandID = "") {
|
||||
if (!reason.trim()) throw new Error(t("action.reasonRequired"));
|
||||
if (models.length < 2 || patterns.length < 2 || backdrops.length < 2) throw new Error(t("collectibles.minimumAttributes"));
|
||||
if (!reason.trim()) throw new Error("Please enter an operation reason");
|
||||
if (models.length < 2 || patterns.length < 2 || backdrops.length < 2) throw new Error("Models, patterns, and backdrops must each contain at least two attributes.");
|
||||
const backdropIDs = backdrops.map((row) => Number(row.backdropID));
|
||||
if (new Set(backdropIDs).size !== backdropIDs.length) throw new Error(t("collectibles.duplicateBackdropID"));
|
||||
for (const row of [...models, ...patterns]) if (!row.file) throw new Error(t("collectibles.fileRequired"));
|
||||
if (new Set(backdropIDs).size !== backdropIDs.length) throw new Error("Backdrop IDs must be unique within the pool.");
|
||||
for (const row of [...models, ...patterns]) if (!row.file) throw new Error("Every model and pattern needs a TGS or Lottie file.");
|
||||
const form = new FormData();
|
||||
const animatedMetadata = (rows: AnimatedDraft[]) => rows.map((row) => ({ name: row.name.trim(), rarity_permille: Number(row.rarity), sort_order: Number(row.sortOrder), file_key: row.key }));
|
||||
form.set("metadata", JSON.stringify({
|
||||
|
|
@ -200,18 +216,18 @@ export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: St
|
|||
const renderAnimatedRows = (kind: "models" | "patterns", rows: AnimatedDraft[], setRows: (rows: AnimatedDraft[]) => void) => (
|
||||
<section className="collectible-section">
|
||||
<div className="collectible-section-head">
|
||||
<div><strong>{t(`collectibles.${kind}`)}</strong><span>{t("collectibles.rarityHint")}</span></div>
|
||||
<div className="collectible-section-tools"><Badge tone={rarityTotals[kind] > 0 ? "good" : "neutral"}>{rarityTotals[kind]}‰</Badge><button className="btn compact-btn" type="button" onClick={() => { setRows(rebalanceRarity([...rows, newAnimated(kind === "models" ? "model" : "pattern", rows.length)])); invalidate(); }}><Plus size={13} />{t("collectibles.addAttribute")}</button></div>
|
||||
<div><strong>{collectibleGroupLabels[kind]}</strong><span>{"Permille values are relative regular-upgrade weights; their total does not need to equal 1000."}</span></div>
|
||||
<div className="collectible-section-tools"><Badge tone={rarityTotals[kind] > 0 ? "good" : "neutral"}>{rarityTotals[kind]}‰</Badge><button className="btn compact-btn" type="button" onClick={() => { setRows(rebalanceRarity([...rows, newAnimated(kind === "models" ? "model" : "pattern", rows.length)])); invalidate(); }}><Plus size={13} />{"Add"}</button></div>
|
||||
</div>
|
||||
<div className="collectible-rows">
|
||||
{rows.map((row, index) => <div className="collectible-row animated" key={row.key}>
|
||||
<div className="collectible-row-index">{index + 1}</div>
|
||||
<label><span>{t("common.name")}</span><input value={row.name} maxLength={128} onChange={(e) => updateAnimated(kind, row.key, { name: e.target.value })} /></label>
|
||||
<label><span>{t("collectibles.rarity")}</span><input type="number" min="1" max="1000" value={row.rarity} onChange={(e) => updateAnimated(kind, row.key, { rarity: e.target.value })} /></label>
|
||||
<label><span>{t("gifts.sortOrder")}</span><input type="number" value={row.sortOrder} onChange={(e) => updateAnimated(kind, row.key, { sortOrder: e.target.value })} /></label>
|
||||
<label className="collectible-file"><span>{t("gifts.animation")}</span><input type="file" accept=".tgs,.json,.lottie,application/json,application/x-tgsticker" onChange={(e) => void chooseFile(kind, row, e.target.files?.[0] ?? null)} /><em><FileJson2 size={13} />{row.file?.name ?? t("gifts.chooseFile")}</em></label>
|
||||
<label><span>{"Name"}</span><input value={row.name} maxLength={128} onChange={(e) => updateAnimated(kind, row.key, { name: e.target.value })} /></label>
|
||||
<label><span>{"Rarity ‰"}</span><input type="number" min="1" max="1000" value={row.rarity} onChange={(e) => updateAnimated(kind, row.key, { rarity: e.target.value })} /></label>
|
||||
<label><span>{"Sort order"}</span><input type="number" value={row.sortOrder} onChange={(e) => updateAnimated(kind, row.key, { sortOrder: e.target.value })} /></label>
|
||||
<label className="collectible-file"><span>{"Animation file"}</span><input type="file" accept=".tgs,.json,.lottie,application/json,application/x-tgsticker" onChange={(e) => void chooseFile(kind, row, e.target.files?.[0] ?? null)} /><em><FileJson2 size={13} />{row.file?.name ?? "Choose file"}</em></label>
|
||||
<div className="collectible-inline-preview">{row.animation ? <AnimationPreview data={row.animation} compact /> : <Sparkles size={16} />}</div>
|
||||
<button className="icon-btn danger" type="button" disabled={rows.length <= 2} onClick={() => { setRows(rebalanceRarity(rows.filter((value) => value.key !== row.key))); invalidate(); }} aria-label={t("collectibles.remove")}><Trash2 size={14} /></button>
|
||||
<button className="icon-btn danger" type="button" disabled={rows.length <= 2} onClick={() => { setRows(rebalanceRarity(rows.filter((value) => value.key !== row.key))); invalidate(); }} aria-label={"Remove attribute"}><Trash2 size={14} /></button>
|
||||
{row.fileError && <span className="collectible-file-error">{row.fileError}</span>}
|
||||
</div>)}
|
||||
</div>
|
||||
|
|
@ -219,51 +235,51 @@ export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: St
|
|||
);
|
||||
|
||||
return createPortal(<div className="modal-backdrop" role="presentation">
|
||||
<section className="modal command-modal collectible-modal" role="dialog" aria-modal="true" aria-label={t("collectibles.title", { id: gift.GiftID })}>
|
||||
<section className="modal command-modal collectible-modal" role="dialog" aria-modal="true" aria-label={`Collectible pool · Gift #${gift.GiftID}`}>
|
||||
<div className="modal-head">
|
||||
<div><div className="eyebrow">{t("collectibles.eyebrow")}</div><h2>{t("collectibles.title", { id: gift.GiftID })}</h2><p>{gift.Title || `Gift #${gift.GiftID}`}</p></div>
|
||||
<button className="icon-btn" type="button" onClick={onClose} disabled={busy} aria-label={t("action.close")}><X size={15} /></button>
|
||||
<div><div className="eyebrow">{"Unique gift attributes"}</div><h2>{`Collectible pool · Gift #${gift.GiftID}`}</h2><p>{gift.Title || `Gift #${gift.GiftID}`}</p></div>
|
||||
<button className="icon-btn" type="button" onClick={onClose} disabled={busy} aria-label={"Close"}><X size={15} /></button>
|
||||
</div>
|
||||
<div className="command-body collectible-modal-body">
|
||||
{loading ? <div className="collectible-loading"><Loader2 className="spin" />{t("common.loading")}</div> : active?.found ? <section className="collectible-active">
|
||||
<div className="collectible-active-head"><div><Gem size={18} /><div><strong>{t("collectibles.activeRevision", { revision: active.revision ?? 0 })}</strong><span>{active.slug_prefix} · ⭐ {active.upgrade_stars} · {active.issued} / {active.supply_total}</span></div></div><Badge tone="good">{t("collectibles.published")}</Badge></div>
|
||||
{loading ? <div className="collectible-loading"><Loader2 className="spin" />{"Loading"}</div> : active?.found ? <section className="collectible-active">
|
||||
<div className="collectible-active-head"><div><Gem size={18} /><div><strong>{`Published revision ${active.revision ?? 0}`}</strong><span>{active.slug_prefix} · ⭐ {active.upgrade_stars} · {active.issued} / {active.supply_total}</span></div></div><Badge tone="good">{"Published"}</Badge></div>
|
||||
<div className="collectible-active-grid">
|
||||
{[...(active.models ?? []), ...(active.patterns ?? [])].map((attribute) => <article key={`${attribute.kind}-${attribute.id}`}><RemoteAnimation giftID={gift.GiftID} attribute={attribute} /><div><strong>{attribute.name}{attribute.crafted && <Badge>crafted</Badge>}</strong><span>{t(`collectibles.${attribute.kind}`)} · {rarityLabel(attribute)}</span></div></article>)}
|
||||
{(active.backdrops ?? []).map((attribute) => <article key={`backdrop-${attribute.id}`}><div className="collectible-backdrop-preview" style={{ background: `radial-gradient(circle, #${(attribute.center_color ?? 0).toString(16).padStart(6, "0")}, #${(attribute.edge_color ?? 0).toString(16).padStart(6, "0")})`, color: `#${(attribute.text_color ?? 0xffffff).toString(16).padStart(6, "0")}` }}>Aa</div><div><strong>{attribute.name}</strong><span>{t("collectibles.backdrop")} · {rarityLabel(attribute)}</span></div></article>)}
|
||||
{[...(active.models ?? []), ...(active.patterns ?? [])].map((attribute) => <article key={`${attribute.kind}-${attribute.id}`}><RemoteAnimation giftID={gift.GiftID} attribute={attribute} /><div><strong>{attribute.name}{attribute.crafted && <Badge>crafted</Badge>}</strong><span>{collectibleAttributeLabels[attribute.kind]} · {rarityLabel(attribute)}</span></div></article>)}
|
||||
{(active.backdrops ?? []).map((attribute) => <article key={`backdrop-${attribute.id}`}><div className="collectible-backdrop-preview" style={{ background: `radial-gradient(circle, #${(attribute.center_color ?? 0).toString(16).padStart(6, "0")}, #${(attribute.edge_color ?? 0).toString(16).padStart(6, "0")})`, color: `#${(attribute.text_color ?? 0xffffff).toString(16).padStart(6, "0")}` }}>Aa</div><div><strong>{attribute.name}</strong><span>{"Backdrop"} · {rarityLabel(attribute)}</span></div></article>)}
|
||||
</div>
|
||||
</section> : <div className="collectible-empty"><Gem size={22} /><div><strong>{t("collectibles.noPool")}</strong><span>{t("collectibles.noPoolHint")}</span></div></div>}
|
||||
</section> : <div className="collectible-empty"><Gem size={22} /><div><strong>{"No collectible pool published"}</strong><span>{"Publish models, patterns and backdrops to enable upgrades."}</span></div></div>}
|
||||
|
||||
<section className="collectible-definition">
|
||||
<div className="collectible-definition-head"><div><strong>{t("collectibles.publishNew")}</strong><span>{t("collectibles.immutableHint")}</span></div><div className="gift-format-chips"><span>TGS</span><span>Lottie JSON</span></div></div>
|
||||
<div className="collectible-definition-head"><div><strong>{"Publish a new immutable revision"}</strong><span>{"Dry-run checks every file and rarity total before the revision becomes active."}</span></div><div className="gift-format-chips"><span>TGS</span><span>Lottie JSON</span></div></div>
|
||||
<div className="gift-fields-grid collectible-main-fields">
|
||||
<label><span>{t("collectibles.upgradeStars")}</span><input type="number" min="1" value={upgradeStars} onChange={(e) => { setUpgradeStars(e.target.value); invalidate(); }} /></label>
|
||||
<label><span>{t("collectibles.supply")}</span><input type="number" min="1" value={supplyTotal} onChange={(e) => { setSupplyTotal(e.target.value); invalidate(); }} /></label>
|
||||
<label><span>{t("collectibles.slug")}</span><input value={slugPrefix} maxLength={48} onChange={(e) => { setSlugPrefix(e.target.value.toLowerCase()); invalidate(); }} /></label>
|
||||
<label><span>{t("gifts.reason")}</span><input value={reason} maxLength={1000} placeholder={t("gifts.reasonPlaceholder")} onChange={(e) => setReason(e.target.value)} /></label>
|
||||
<label><span>{"Upgrade price in Stars"}</span><input type="number" min="1" value={upgradeStars} onChange={(e) => { setUpgradeStars(e.target.value); invalidate(); }} /></label>
|
||||
<label><span>{"Unique supply"}</span><input type="number" min="1" value={supplyTotal} onChange={(e) => { setSupplyTotal(e.target.value); invalidate(); }} /></label>
|
||||
<label><span>{"Public slug prefix"}</span><input value={slugPrefix} maxLength={48} onChange={(e) => { setSlugPrefix(e.target.value.toLowerCase()); invalidate(); }} /></label>
|
||||
<label><span>{"Audit reason"}</span><input value={reason} maxLength={1000} placeholder={"Briefly describe why this gift is being imported"} onChange={(e) => setReason(e.target.value)} /></label>
|
||||
</div>
|
||||
{renderAnimatedRows("models", models, setModels)}
|
||||
{renderAnimatedRows("patterns", patterns, setPatterns)}
|
||||
<section className="collectible-section">
|
||||
<div className="collectible-section-head"><div><strong>{t("collectibles.backdrops")}</strong><span>{t("collectibles.colorHint")}</span></div><div className="collectible-section-tools"><Badge tone={rarityTotals.backdrops > 0 ? "good" : "neutral"}>{rarityTotals.backdrops}‰</Badge><button className="btn compact-btn" type="button" onClick={() => { setBackdrops(rebalanceRarity([...backdrops, newBackdrop(backdrops)])); invalidate(); }}><Plus size={13} />{t("collectibles.addAttribute")}</button></div></div>
|
||||
<div className="collectible-section-head"><div><strong>{"Backdrops"}</strong><span>{"Colors are stored as 24-bit RGB values."}</span></div><div className="collectible-section-tools"><Badge tone={rarityTotals.backdrops > 0 ? "good" : "neutral"}>{rarityTotals.backdrops}‰</Badge><button className="btn compact-btn" type="button" onClick={() => { setBackdrops(rebalanceRarity([...backdrops, newBackdrop(backdrops)])); invalidate(); }}><Plus size={13} />{"Add"}</button></div></div>
|
||||
<div className="collectible-rows">{backdrops.map((row, index) => <div className="collectible-row backdrop" key={row.key}>
|
||||
<div className="collectible-row-index">{index + 1}</div>
|
||||
<label><span>{t("common.name")}</span><input value={row.name} maxLength={128} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, name: e.target.value } : value)); invalidate(); }} /></label>
|
||||
<label><span>{t("collectibles.backdropID")}</span><input type="number" min="0" value={row.backdropID} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, backdropID: e.target.value } : value)); invalidate(); }} /></label>
|
||||
<label><span>{t("collectibles.rarity")}</span><input type="number" min="1" max="1000" value={row.rarity} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, rarity: e.target.value } : value)); invalidate(); }} /></label>
|
||||
<label><span>{t("gifts.sortOrder")}</span><input type="number" value={row.sortOrder} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, sortOrder: e.target.value } : value)); invalidate(); }} /></label>
|
||||
{(["center", "edge", "pattern", "text"] as const).map((field) => <label className="collectible-color" key={field}><span>{t(`collectibles.color.${field}`)}</span><input type="color" value={row[field]} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, [field]: e.target.value } : value)); invalidate(); }} /></label>)}
|
||||
<label><span>{"Name"}</span><input value={row.name} maxLength={128} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, name: e.target.value } : value)); invalidate(); }} /></label>
|
||||
<label><span>{"Backdrop ID"}</span><input type="number" min="0" value={row.backdropID} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, backdropID: e.target.value } : value)); invalidate(); }} /></label>
|
||||
<label><span>{"Rarity ‰"}</span><input type="number" min="1" max="1000" value={row.rarity} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, rarity: e.target.value } : value)); invalidate(); }} /></label>
|
||||
<label><span>{"Sort order"}</span><input type="number" value={row.sortOrder} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, sortOrder: e.target.value } : value)); invalidate(); }} /></label>
|
||||
{(["center", "edge", "pattern", "text"] as const).map((field) => <label className="collectible-color" key={field}><span>{collectibleColorLabels[field]}</span><input type="color" value={row[field]} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, [field]: e.target.value } : value)); invalidate(); }} /></label>)}
|
||||
<div className="collectible-backdrop-preview" style={{ background: `radial-gradient(circle, ${row.center}, ${row.edge})`, color: row.text }}>Aa</div>
|
||||
<button className="icon-btn danger" type="button" disabled={backdrops.length <= 2} onClick={() => { setBackdrops(rebalanceRarity(backdrops.filter((value) => value.key !== row.key))); invalidate(); }} aria-label={t("collectibles.remove")}><Trash2 size={14} /></button>
|
||||
<button className="icon-btn danger" type="button" disabled={backdrops.length <= 2} onClick={() => { setBackdrops(rebalanceRarity(backdrops.filter((value) => value.key !== row.key))); invalidate(); }} aria-label={"Remove attribute"}><Trash2 size={14} /></button>
|
||||
</div>)}</div>
|
||||
</section>
|
||||
</section>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{preview && <div className="gift-validation"><div className="gift-validation-head"><CheckCircle2 size={17} /><div><strong>{t("collectibles.validationReady")}</strong><span>{t("collectibles.validationHint")}</span></div></div><pre>{JSON.stringify(preview.details, null, 2)}</pre></div>}
|
||||
{preview && <div className="gift-validation"><div className="gift-validation-head"><CheckCircle2 size={17} /><div><strong>{"Attribute pool is valid"}</strong><span>{"Review the normalized assets, then publish this immutable revision."}</span></div></div><pre>{JSON.stringify(preview.details, null, 2)}</pre></div>}
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="btn" type="button" onClick={onClose} disabled={busy}>{t("common.close")}</button>
|
||||
<button className="btn" type="button" onClick={validate} disabled={busy}>{busy ? <Loader2 className="spin" size={15} /> : <ShieldCheck size={15} />}{t("gifts.validate")}</button>
|
||||
<button className="btn primary" type="button" onClick={publish} disabled={busy || !preview}><Upload size={15} />{t("collectibles.publish")}</button>
|
||||
<button className="btn" type="button" onClick={onClose} disabled={busy}>{"Close"}</button>
|
||||
<button className="btn" type="button" onClick={validate} disabled={busy}>{busy ? <Loader2 className="spin" size={15} /> : <ShieldCheck size={15} />}{"Dry-run validation"}</button>
|
||||
<button className="btn primary" type="button" onClick={publish} disabled={busy || !preview}><Upload size={15} />{"Publish revision"}</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>, document.body);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { createPortal } from "react-dom";
|
|||
import { api, APIError, errorMessage } from "../api";
|
||||
import { ActionButton } from "../components/ActionButton";
|
||||
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
|
||||
import { useI18n } from "../i18n";
|
||||
import { formatDate } from "../lib/format";
|
||||
import type { CommandResult, DefaultGiftRow, OfficialStarGiftRow, StarGiftRow } from "../types";
|
||||
import { GiftCollectiblesModal } from "./GiftCollectiblesModal";
|
||||
|
|
@ -13,6 +12,13 @@ import { GiftCollectiblesModal } from "./GiftCollectiblesModal";
|
|||
type OfficialGiftCategory = "all" | "upgrade" | "craft" | "basic";
|
||||
type GiftPageSize = 10 | 20 | 50 | 100 | "all";
|
||||
|
||||
const officialCategoryLabels: Record<OfficialGiftCategory, string> = {
|
||||
all: "All",
|
||||
upgrade: "Upgradable",
|
||||
craft: "Craftable",
|
||||
basic: "Not upgradable"
|
||||
};
|
||||
|
||||
// The demo pool only has 3 placeholder gifts left after pruning to one per
|
||||
// capability tier (Spark/Star/Coin); hide the tab until real custom designs
|
||||
// replace them. Flip back to true to re-enable.
|
||||
|
|
@ -105,7 +111,6 @@ function OfficialLottiePreview({ sourceGiftID }: { sourceGiftID: string }) {
|
|||
}
|
||||
|
||||
export function GiftsPage() {
|
||||
const { t } = useI18n();
|
||||
const [gifts, setGifts] = useState<StarGiftRow[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
|
|
@ -236,7 +241,7 @@ export function GiftsPage() {
|
|||
|
||||
async function bulkSetEnabled(nextEnabled: boolean) {
|
||||
if (!bulkReason.trim()) {
|
||||
setBulkError(t("action.reasonRequired"));
|
||||
setBulkError("Please enter an operation reason");
|
||||
return;
|
||||
}
|
||||
setBulkBusy(true);
|
||||
|
|
@ -257,7 +262,7 @@ export function GiftsPage() {
|
|||
}
|
||||
setBulkBusy(false);
|
||||
if (failed > 0) {
|
||||
setBulkError(t("gifts.bulkStatusFailed", { failed, total: ids.length }));
|
||||
setBulkError(`${failed} of ${ids.length} failed`);
|
||||
} else {
|
||||
setSelected(new Set());
|
||||
setBulkReason("");
|
||||
|
|
@ -266,8 +271,8 @@ export function GiftsPage() {
|
|||
}
|
||||
|
||||
function uploadForm(confirm: boolean, commandID = "") {
|
||||
if (!file) throw new Error(t("gifts.fileRequired"));
|
||||
if (!reason.trim()) throw new Error(t("action.reasonRequired"));
|
||||
if (!file) throw new Error("Choose a TGS or Lottie file first");
|
||||
if (!reason.trim()) throw new Error("Please enter an operation reason");
|
||||
const form = new FormData();
|
||||
form.set("metadata", JSON.stringify({
|
||||
command_id: commandID,
|
||||
|
|
@ -285,14 +290,14 @@ export function GiftsPage() {
|
|||
}
|
||||
|
||||
function defaultPayload(confirm: boolean, commandID = "") {
|
||||
if (!selectedDefaultID) throw new Error(t("gifts.defaultRequired"));
|
||||
if (!reason.trim()) throw new Error(t("action.reasonRequired"));
|
||||
if (!selectedDefaultID) throw new Error("Choose a default gift first");
|
||||
if (!reason.trim()) throw new Error("Please enter an operation reason");
|
||||
return { command_id: commandID, reason: reason.trim(), confirm, id: selectedDefaultID };
|
||||
}
|
||||
|
||||
function officialPayload(confirm: boolean, commandID = "") {
|
||||
if (!sourceGiftID) throw new Error(t("gifts.officialRequired"));
|
||||
if (!reason.trim()) throw new Error(t("action.reasonRequired"));
|
||||
if (!sourceGiftID) throw new Error("Choose an official gift first");
|
||||
if (!reason.trim()) throw new Error("Please enter an operation reason");
|
||||
return {
|
||||
command_id: commandID, reason: reason.trim(), confirm,
|
||||
source_gift_id: sourceGiftID, gift_id: giftID, title: title.trim(),
|
||||
|
|
@ -304,7 +309,7 @@ export function GiftsPage() {
|
|||
|
||||
function chooseOfficial(gift: OfficialStarGiftRow) {
|
||||
setSourceGiftID(gift.source_gift_id);
|
||||
setTitle(gift.title || t("gifts.officialUnnamed", { id: gift.source_gift_id }));
|
||||
setTitle(gift.title || `Unnamed official gift #${gift.source_gift_id}`);
|
||||
setStars(String(gift.stars));
|
||||
setConvertStars(String(gift.convert_stars));
|
||||
setIncludeCollectible(gift.can_upgrade);
|
||||
|
|
@ -343,7 +348,7 @@ export function GiftsPage() {
|
|||
|
||||
async function runBulkImport() {
|
||||
if (!bulkImportOpen) return;
|
||||
if (!bulkImportReason.trim()) { setBulkImportError(t("action.reasonRequired")); return; }
|
||||
if (!bulkImportReason.trim()) { setBulkImportError("Please enter an operation reason"); return; }
|
||||
const source = bulkImportOpen;
|
||||
setBulkImportBusy(true); setBulkImportError(""); setBulkImportResult(null);
|
||||
setBulkImportProgress({ done: 0, total: bulkImportItems.length });
|
||||
|
|
@ -440,60 +445,60 @@ export function GiftsPage() {
|
|||
: Boolean(file);
|
||||
|
||||
return (
|
||||
<PageFrame title={t("gifts.pageTitle")} eyebrow={t("gifts.eyebrow")} actions={<>
|
||||
<button className="btn" type="button" onClick={() => load()} disabled={busy}><RefreshCw size={15} /> {t("common.refresh")}</button>
|
||||
<button className="btn primary" type="button" onClick={startImport}><Plus size={15} /> {t("gifts.add")}</button>
|
||||
<PageFrame title={"Star Gift Catalog"} eyebrow={"Catalog, immutable revisions and animation assets"} actions={<>
|
||||
<button className="btn" type="button" onClick={() => load()} disabled={busy}><RefreshCw size={15} /> {"Refresh"}</button>
|
||||
<button className="btn primary" type="button" onClick={startImport}><Plus size={15} /> {"Add gift"}</button>
|
||||
</>}>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="metric-row gift-metrics">
|
||||
<Metric label={t("gifts.total")} value={String(gifts.length)} />
|
||||
<Metric label={t("gifts.enabled")} value={String(gifts.filter((gift) => gift.Enabled).length)} tone="good" />
|
||||
<Metric label={t("gifts.received")} value={gifts.reduce((sum, gift) => sum + BigInt(gift.ReceivedCount), 0n).toString()} />
|
||||
<Metric label={t("gifts.formats")} value="TGS / Lottie" />
|
||||
<Metric label={"Catalog entries"} value={String(gifts.length)} />
|
||||
<Metric label={"Enabled"} value={String(gifts.filter((gift) => gift.Enabled).length)} tone="good" />
|
||||
<Metric label={"Received gifts"} value={gifts.reduce((sum, gift) => sum + BigInt(gift.ReceivedCount), 0n).toString()} />
|
||||
<Metric label={"Accepted formats"} value="TGS / Lottie" />
|
||||
</div>
|
||||
<QueryPanel>
|
||||
<div className="toolbar">
|
||||
<label className="searchbox"><Search size={15} /><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={t("gifts.searchPlaceholder")} /></label>
|
||||
<label className="gift-page-size"><span>{t("gifts.perPage")}</span>
|
||||
<label className="searchbox"><Search size={15} /><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={"Search gift ID, title or format"} /></label>
|
||||
<label className="gift-page-size"><span>{"Per page"}</span>
|
||||
<select value={String(pageSize)} onChange={(event) => setPageSize(event.target.value === "all" ? "all" : (Number(event.target.value) as GiftPageSize))}>
|
||||
<option value="10">10</option>
|
||||
<option value="20">20</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
<option value="all">{t("gifts.perPageAll")}</option>
|
||||
<option value="all">{"All"}</option>
|
||||
</select>
|
||||
</label>
|
||||
<span className="gift-list-summary">{t("gifts.listSummary", { shown: visibleGifts.length, total: gifts.length })}</span>
|
||||
<span className="gift-list-summary">{`Showing ${visibleGifts.length} of ${gifts.length}`}</span>
|
||||
</div>
|
||||
</QueryPanel>
|
||||
{selected.size > 0 && <div className="gift-bulk-toolbar">
|
||||
<span className="gift-bulk-count">{t("gifts.bulkSelected", { count: selected.size })}</span>
|
||||
<label className="gift-reason-field gift-bulk-reason"><span>{t("gifts.reason")}</span><input value={bulkReason} placeholder={t("gifts.reasonPlaceholder")} onChange={(e) => setBulkReason(e.target.value)} /></label>
|
||||
<span className="gift-bulk-count">{`${selected.size} selected`}</span>
|
||||
<label className="gift-reason-field gift-bulk-reason"><span>{"Audit reason"}</span><input value={bulkReason} placeholder={"Briefly describe why this gift is being imported"} onChange={(e) => setBulkReason(e.target.value)} /></label>
|
||||
<button className="btn" type="button" onClick={() => bulkSetEnabled(true)} disabled={bulkBusy}>
|
||||
{bulkBusy ? <Loader2 className="spin" size={14} /> : <CheckCircle2 size={14} />} {t("gifts.bulkEnable")}
|
||||
{bulkBusy ? <Loader2 className="spin" size={14} /> : <CheckCircle2 size={14} />} {"Enable selected"}
|
||||
</button>
|
||||
<button className="btn" type="button" onClick={() => bulkSetEnabled(false)} disabled={bulkBusy}>
|
||||
{bulkBusy ? <Loader2 className="spin" size={14} /> : <Pause size={14} />} {t("gifts.bulkDisable")}
|
||||
{bulkBusy ? <Loader2 className="spin" size={14} /> : <Pause size={14} />} {"Disable selected"}
|
||||
</button>
|
||||
<button className="btn" type="button" onClick={() => { setSelected(new Set()); setBulkError(""); }} disabled={bulkBusy}>{t("common.close")}</button>
|
||||
<button className="btn" type="button" onClick={() => { setSelected(new Set()); setBulkError(""); }} disabled={bulkBusy}>{"Close"}</button>
|
||||
{bulkError && <span className="gift-bulk-error">{bulkError}</span>}
|
||||
</div>}
|
||||
<div className="table-wrap gift-table-wrap">
|
||||
<table className="data-table gift-table">
|
||||
<thead><tr><th className="gift-select-col"><input type="checkbox" checked={allVisibleSelected} onChange={toggleSelectAllVisible} aria-label={t("gifts.bulkSelectAll")} /></th><th>{t("gifts.animation")}</th><th>{t("gifts.idRevision")}</th><th>{t("gifts.title")}</th><th>{t("gifts.price")}</th><th>{t("gifts.source")}</th><th>{t("gifts.received")}</th><th>{t("common.status")}</th><th>{t("common.updatedAt")}</th><th>{t("common.actions")}</th></tr></thead>
|
||||
<thead><tr><th className="gift-select-col"><input type="checkbox" checked={allVisibleSelected} onChange={toggleSelectAllVisible} aria-label={"Select all visible gifts"} /></th><th>{"Animation file"}</th><th>{"ID / Revision"}</th><th>{"Display title"}</th><th>{"Price / Conversion"}</th><th>{"Source"}</th><th>{"Received gifts"}</th><th>{"Status"}</th><th>{"Updated"}</th><th>{"Actions"}</th></tr></thead>
|
||||
<tbody>
|
||||
{pagedGifts.map((gift) => (
|
||||
<tr className={gift.Enabled ? "" : "gift-row-disabled"} key={gift.GiftID}>
|
||||
<td className="gift-select-col"><input type="checkbox" checked={selected.has(gift.GiftID)} onChange={() => toggleSelected(gift.GiftID)} aria-label={t("gifts.bulkSelectOne", { id: gift.GiftID })} /></td>
|
||||
<td className="gift-select-col"><input type="checkbox" checked={selected.has(gift.GiftID)} onChange={() => toggleSelected(gift.GiftID)} aria-label={`Select gift ${gift.GiftID}`} /></td>
|
||||
<td><LottiePreview giftID={gift.GiftID} revision={gift.Revision} compact /></td>
|
||||
<td className="mono">{gift.GiftID} / {gift.Revision}</td>
|
||||
<td><strong className="gift-table-title">{gift.Title || `Gift #${gift.GiftID}`}</strong><span className="gift-sort-order">{t("gifts.sortOrder")}: {gift.SortOrder}</span></td>
|
||||
<td><strong className="gift-table-title">{gift.Title || `Gift #${gift.GiftID}`}</strong><span className="gift-sort-order">{"Sort order"}: {gift.SortOrder}</span></td>
|
||||
<td><strong className="gift-table-price">⭐ {gift.Stars}</strong><span className="gift-convert-price">→ {gift.ConvertStars}</span></td>
|
||||
<td><Badge>{gift.SourceFormat}</Badge><span className="gift-source-size">{formatBytes(gift.AnimationSize)}</span></td>
|
||||
<td>{gift.ReceivedCount}</td>
|
||||
<td><Badge tone={gift.Enabled ? "good" : "neutral"}>{gift.Enabled ? t("common.enabled") : t("common.disabled")}</Badge></td>
|
||||
<td><Badge tone={gift.Enabled ? "good" : "neutral"}>{gift.Enabled ? "Enabled" : "Disabled"}</Badge></td>
|
||||
<td>{formatDate(gift.UpdatedAt)}</td>
|
||||
<td><div className="gift-table-actions"><button className="btn compact-btn collectible-button" type="button" onClick={() => setCollectibleGift(gift)}><Gem size={13} />{t("collectibles.manage")}</button><button className="btn compact-btn" type="button" onClick={() => startRevision(gift)}>{t("gifts.replace")}</button><ActionButton compact tone="neutral" label={gift.Enabled ? t("gifts.disable") : t("gifts.enable")} path="/api/actions/set-gift-enabled" payload={() => ({ gift_id: gift.GiftID, enabled: !gift.Enabled })} onDone={() => void load()} /></div></td>
|
||||
<td><div className="gift-table-actions"><button className="btn compact-btn collectible-button" type="button" onClick={() => setCollectibleGift(gift)}><Gem size={13} />{"Attribute pool"}</button><button className="btn compact-btn" type="button" onClick={() => startRevision(gift)}>{"New revision"}</button><ActionButton compact tone="neutral" label={gift.Enabled ? "Disable" : "Enable"} path="/api/actions/set-gift-enabled" payload={() => ({ gift_id: gift.GiftID, enabled: !gift.Enabled })} onDone={() => void load()} /></div></td>
|
||||
</tr>
|
||||
))}
|
||||
{pagedGifts.length === 0 && <EmptyRow colSpan={10} />}
|
||||
|
|
@ -501,45 +506,45 @@ export function GiftsPage() {
|
|||
</table>
|
||||
</div>
|
||||
{pageSize !== "all" && visibleGifts.length > 0 && <div className="gift-pager">
|
||||
<span className="gift-pager-range">{t("gifts.pageRange", { start: pageRangeStart, end: pageRangeEnd, total: visibleGifts.length })}</span>
|
||||
<span className="gift-pager-range">{`Showing ${pageRangeStart}-${pageRangeEnd} of ${visibleGifts.length}`}</span>
|
||||
<div className="gift-pager-controls">
|
||||
<button className="btn compact-btn" type="button" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={currentPage <= 1}>
|
||||
<ChevronLeft size={14} /> {t("gifts.pagePrev")}
|
||||
<ChevronLeft size={14} /> {"Previous"}
|
||||
</button>
|
||||
<span className="gift-pager-page">{t("gifts.pageOf", { page: currentPage, total: totalPages })}</span>
|
||||
<span className="gift-pager-page">{`Page ${currentPage} of ${totalPages}`}</span>
|
||||
<button className="btn compact-btn" type="button" onClick={() => setPage((p) => Math.min(totalPages, p + 1))} disabled={currentPage >= totalPages}>
|
||||
{t("gifts.pageNext")} <ChevronRight size={14} />
|
||||
{"Next"} <ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{importOpen && createPortal(
|
||||
<div className="modal-backdrop" role="presentation">
|
||||
<section className="modal command-modal gift-import-modal" role="dialog" aria-modal="true" aria-label={giftID !== "0" ? t("gifts.newRevision", { id: giftID }) : t("gifts.importTitle")}>
|
||||
<section className="modal command-modal gift-import-modal" role="dialog" aria-modal="true" aria-label={giftID !== "0" ? `Create revision for gift #${giftID}` : "Import a Star Gift"}>
|
||||
<div className="modal-head">
|
||||
<div><div className="eyebrow">{t("gifts.importEyebrow")}</div><h2>{giftID !== "0" ? t("gifts.newRevision", { id: giftID }) : t("gifts.importTitle")}</h2></div>
|
||||
<button className="icon-btn" type="button" onClick={() => setImportOpen(false)} disabled={busy} aria-label={t("action.close")}><X size={15} /></button>
|
||||
<div><div className="eyebrow">{"Gift catalog operation"}</div><h2>{giftID !== "0" ? `Create revision for gift #${giftID}` : "Import a Star Gift"}</h2></div>
|
||||
<button className="icon-btn" type="button" onClick={() => setImportOpen(false)} disabled={busy} aria-label={"Close"}><X size={15} /></button>
|
||||
</div>
|
||||
<div className="command-body gift-import-modal-body">
|
||||
<div className="command-steps">
|
||||
<div className={`command-step ${step1Done ? "done" : "active"}`}><span>1</span><strong>{t("gifts.stepDetails")}</strong></div>
|
||||
<div className={`command-step ${preview ? "done" : step1Done ? "active" : ""}`}><span>2</span><strong>{t("gifts.stepValidate")}</strong></div>
|
||||
<div className={`command-step ${preview ? "active" : ""}`}><span>3</span><strong>{t("gifts.stepImport")}</strong></div>
|
||||
<div className={`command-step ${step1Done ? "done" : "active"}`}><span>1</span><strong>{"File and details"}</strong></div>
|
||||
<div className={`command-step ${preview ? "done" : step1Done ? "active" : ""}`}><span>2</span><strong>{"Dry-run validation"}</strong></div>
|
||||
<div className={`command-step ${preview ? "active" : ""}`}><span>3</span><strong>{"Confirm import"}</strong></div>
|
||||
</div>
|
||||
{giftID === "0" && <div className="gift-source-tabs">
|
||||
{SHOW_DEFAULT_GIFTS_TAB && <button className={`btn ${importSource === "default" ? "primary" : ""}`} type="button" onClick={() => { setImportSource("default"); setPreview(null); }}>{t("gifts.defaultSource")}</button>}
|
||||
<button className={`btn ${importSource === "official" ? "primary" : ""}`} type="button" onClick={() => { setImportSource("official"); setPreview(null); }}>{t("gifts.officialSource")}</button>
|
||||
<button className={`btn ${importSource === "file" ? "primary" : ""}`} type="button" onClick={() => { setImportSource("file"); setPreview(null); }}>{t("gifts.fileSource")}</button>
|
||||
{SHOW_DEFAULT_GIFTS_TAB && <button className={`btn ${importSource === "default" ? "primary" : ""}`} type="button" onClick={() => { setImportSource("default"); setPreview(null); }}>{"Default gifts"}</button>}
|
||||
<button className={`btn ${importSource === "official" ? "primary" : ""}`} type="button" onClick={() => { setImportSource("official"); setPreview(null); }}>{"Official snapshot"}</button>
|
||||
<button className={`btn ${importSource === "file" ? "primary" : ""}`} type="button" onClick={() => { setImportSource("file"); setPreview(null); }}>{"Upload file"}</button>
|
||||
</div>}
|
||||
{importSource === "default" && giftID === "0" && SHOW_DEFAULT_GIFTS_TAB ? <section className="official-gift-picker">
|
||||
<div className="gift-import-note"><span>{t("gifts.defaultHint")}</span><div className="gift-format-chips"><span>{defaultGifts.length}</span><span>OwpenGram</span></div></div>
|
||||
<div className="gift-import-note"><span>{"Import our built-in original OwpenGram gifts. Complete collectible pools (upgrade + craft) are imported atomically."}</span><div className="gift-format-chips"><span>{defaultGifts.length}</span><span>OwpenGram</span></div></div>
|
||||
<div className="official-gift-bulk-import">
|
||||
<button className="btn" type="button" onClick={() => openBulkImport("default")}>
|
||||
<Upload size={14} /> {t("gifts.importAllDefault")}
|
||||
<Upload size={14} /> {"Import all default gifts"}
|
||||
</button>
|
||||
</div>
|
||||
<label className="gift-switch"><input type="checkbox" checked={enabled} onChange={(e) => { setEnabled(e.target.checked); setPreview(null); }} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{t("gifts.enableAfterImport")}</span></label>
|
||||
<div className="official-gift-list" role="listbox" aria-label={t("gifts.defaultSelect")}>
|
||||
<label className="gift-switch"><input type="checkbox" checked={enabled} onChange={(e) => { setEnabled(e.target.checked); setPreview(null); }} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{"Enable after import"}</span></label>
|
||||
<div className="official-gift-list" role="listbox" aria-label={"Choose a default gift"}>
|
||||
{defaultGifts.map((gift) => {
|
||||
const isSelected = gift.id === selectedDefaultID;
|
||||
return <button key={gift.id} className={`official-gift-option ${isSelected ? "selected" : ""}`}
|
||||
|
|
@ -549,105 +554,105 @@ export function GiftsPage() {
|
|||
<span className="mono">⭐ {gift.stars}</span>
|
||||
</span>
|
||||
<span className="official-gift-option-meta">
|
||||
<span>{t("gifts.officialAttributes", { count: defaultGiftAttributeCount(gift) })}</span>
|
||||
{gift.limited && <span>{t("gifts.limited", { total: gift.availability })}</span>}
|
||||
{gift.require_premium && <span>{t("gifts.premium")}</span>}
|
||||
<span>{`${defaultGiftAttributeCount(gift)} attributes`}</span>
|
||||
{gift.limited && <span>{`Limited · ${gift.availability}`}</span>}
|
||||
{gift.require_premium && <span>{"Premium only"}</span>}
|
||||
</span>
|
||||
<span className="official-gift-capabilities">
|
||||
<span className={gift.upgradeable ? "yes" : "no"}>{gift.upgradeable ? t("gifts.canUpgrade") : t("gifts.cannotUpgrade")}</span>
|
||||
<span className={gift.craftable ? "craft" : "no"}>{gift.craftable ? t("gifts.canCraft") : t("gifts.cannotCraft")}</span>
|
||||
<span className={gift.upgradeable ? "yes" : "no"}>{gift.upgradeable ? "Can upgrade" : "Cannot upgrade"}</span>
|
||||
<span className={gift.craftable ? "craft" : "no"}>{gift.craftable ? "Can Craft" : "Cannot Craft"}</span>
|
||||
</span>
|
||||
</button>;
|
||||
})}
|
||||
{defaultGifts.length === 0 && <div className="official-gift-empty">{t("gifts.defaultEmpty")}</div>}
|
||||
{defaultGifts.length === 0 && <div className="official-gift-empty">{"No default gifts are available."}</div>}
|
||||
</div>
|
||||
{selectedDefault && <div className="official-gift-selected">
|
||||
<DefaultLottiePreview id={selectedDefault.id} />
|
||||
<div><strong>{selectedDefault.title}</strong><span className="mono">⭐ {selectedDefault.stars} → {selectedDefault.convert_stars}</span><small>{selectedDefault.model_count} {t("collectibles.models")} · {selectedDefault.pattern_count} {t("collectibles.patterns")} · {selectedDefault.backdrop_count} {t("collectibles.backdrops")}</small><span className="official-gift-capabilities"><span className={selectedDefault.upgradeable ? "yes" : "no"}>{selectedDefault.upgradeable ? t("gifts.canUpgrade") : t("gifts.cannotUpgrade")}</span><span className={selectedDefault.craftable ? "craft" : "no"}>{selectedDefault.craftable ? t("gifts.canCraft") : t("gifts.cannotCraft")}</span></span></div>
|
||||
<div><strong>{selectedDefault.title}</strong><span className="mono">⭐ {selectedDefault.stars} → {selectedDefault.convert_stars}</span><small>{selectedDefault.model_count} {"Models"} · {selectedDefault.pattern_count} {"Patterns"} · {selectedDefault.backdrop_count} {"Backdrops"}</small><span className="official-gift-capabilities"><span className={selectedDefault.upgradeable ? "yes" : "no"}>{selectedDefault.upgradeable ? "Can upgrade" : "Cannot upgrade"}</span><span className={selectedDefault.craftable ? "craft" : "no"}>{selectedDefault.craftable ? "Can Craft" : "Cannot Craft"}</span></span></div>
|
||||
</div>}
|
||||
</section> : importSource === "official" && giftID === "0" ? <section className="official-gift-picker">
|
||||
<div className="gift-import-note"><span>{t("gifts.officialHint")}</span><div className="gift-format-chips"><span>{officialGifts.length}</span><span>SHA-256</span></div></div>
|
||||
<div className="gift-import-note"><span>{"Choose a verified gift from data/official-gifts. Complete collectible pools are imported atomically."}</span><div className="gift-format-chips"><span>{officialGifts.length}</span><span>SHA-256</span></div></div>
|
||||
<div className="official-gift-bulk-import">
|
||||
<button className="btn" type="button" onClick={() => openBulkImport("official")}>
|
||||
<Upload size={14} /> {t("gifts.importAllOfficial")}
|
||||
<Upload size={14} /> {"Import all official gifts"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="official-gift-tools">
|
||||
<label className="searchbox"><Search size={15} /><input value={officialQuery} onChange={(e) => setOfficialQuery(e.target.value)} placeholder={t("gifts.officialSearch")} /></label>
|
||||
<span>{t("gifts.officialResults", { shown: visibleOfficial.length, total: officialGifts.length })}</span>
|
||||
<label className="searchbox"><Search size={15} /><input value={officialQuery} onChange={(e) => setOfficialQuery(e.target.value)} placeholder={"Search official gift ID or title"} /></label>
|
||||
<span>{`Showing ${visibleOfficial.length} of ${officialGifts.length}`}</span>
|
||||
</div>
|
||||
<div className="official-gift-categories" role="group" aria-label={t("gifts.officialCategoryLabel")}>
|
||||
<div className="official-gift-categories" role="group" aria-label={"Official gift capability category"}>
|
||||
{(["all", "upgrade", "craft", "basic"] as const).map((category) => (
|
||||
<button key={category} className={officialCategory === category ? "active" : ""} type="button"
|
||||
aria-pressed={officialCategory === category} onClick={() => setOfficialCategory(category)}>
|
||||
{t(`gifts.officialCategory.${category}`)}<span>{officialCategoryCounts[category]}</span>
|
||||
{officialCategoryLabels[category]}<span>{officialCategoryCounts[category]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="official-gift-list" role="listbox" aria-label={t("gifts.officialSelect")}>
|
||||
<div className="official-gift-list" role="listbox" aria-label={"Choose an official gift"}>
|
||||
{visibleOfficial.map((gift) => {
|
||||
const isSelected = gift.source_gift_id === sourceGiftID;
|
||||
return <button key={gift.source_gift_id} className={`official-gift-option ${isSelected ? "selected" : ""}`}
|
||||
type="button" role="option" aria-selected={isSelected} onClick={() => chooseOfficial(gift)}>
|
||||
<span className="official-gift-option-head">
|
||||
<strong>{gift.title || t("gifts.officialUnnamed", { id: gift.source_gift_id })}</strong>
|
||||
<strong>{gift.title || `Unnamed official gift #${gift.source_gift_id}`}</strong>
|
||||
<span className="mono">#{gift.source_gift_id}</span>
|
||||
</span>
|
||||
<span className="official-gift-option-meta">
|
||||
<span>⭐ {gift.stars}</span>
|
||||
<span>{t("gifts.officialAttributes", { count: officialGiftAttributeCount(gift) })}</span>
|
||||
<span>{`${officialGiftAttributeCount(gift)} attributes`}</span>
|
||||
</span>
|
||||
<span className="official-gift-capabilities">
|
||||
<span className={gift.can_upgrade ? "yes" : "no"}>{gift.can_upgrade ? t("gifts.canUpgrade") : t("gifts.cannotUpgrade")}</span>
|
||||
<span className={gift.can_craft ? "craft" : "no"}>{gift.can_craft ? t("gifts.canCraft") : t("gifts.cannotCraft")}</span>
|
||||
<span className={gift.can_upgrade ? "yes" : "no"}>{gift.can_upgrade ? "Can upgrade" : "Cannot upgrade"}</span>
|
||||
<span className={gift.can_craft ? "craft" : "no"}>{gift.can_craft ? "Can Craft" : "Cannot Craft"}</span>
|
||||
</span>
|
||||
</button>;
|
||||
})}
|
||||
{visibleOfficial.length === 0 && <div className="official-gift-empty">{t("gifts.officialEmpty")}</div>}
|
||||
{visibleOfficial.length === 0 && <div className="official-gift-empty">{"No official gifts match this category and search."}</div>}
|
||||
</div>
|
||||
{selectedOfficial && <div className="official-gift-selected">
|
||||
<OfficialLottiePreview sourceGiftID={selectedOfficial.source_gift_id} />
|
||||
<div><strong>{selectedOfficial.title || t("gifts.officialUnnamed", { id: selectedOfficial.source_gift_id })}</strong><span className="mono">{selectedOfficial.source_gift_id}</span><small>{selectedOfficial.model_count} {t("collectibles.models")} · {selectedOfficial.pattern_count} {t("collectibles.patterns")} · {selectedOfficial.backdrop_count} {t("collectibles.backdrops")}</small><span className="official-gift-capabilities"><span className={selectedOfficial.can_upgrade ? "yes" : "no"}>{selectedOfficial.can_upgrade ? t("gifts.canUpgrade") : t("gifts.cannotUpgrade")}</span><span className={selectedOfficial.can_craft ? "craft" : "no"}>{selectedOfficial.can_craft ? t("gifts.canCraft") : t("gifts.cannotCraft")}</span></span></div>
|
||||
<div><strong>{selectedOfficial.title || `Unnamed official gift #${selectedOfficial.source_gift_id}`}</strong><span className="mono">{selectedOfficial.source_gift_id}</span><small>{selectedOfficial.model_count} {"Models"} · {selectedOfficial.pattern_count} {"Patterns"} · {selectedOfficial.backdrop_count} {"Backdrops"}</small><span className="official-gift-capabilities"><span className={selectedOfficial.can_upgrade ? "yes" : "no"}>{selectedOfficial.can_upgrade ? "Can upgrade" : "Cannot upgrade"}</span><span className={selectedOfficial.can_craft ? "craft" : "no"}>{selectedOfficial.can_craft ? "Can Craft" : "Cannot Craft"}</span></span></div>
|
||||
</div>}
|
||||
{selectedOfficial?.can_upgrade && <>
|
||||
<label className="gift-switch"><input type="checkbox" checked={includeCollectible} onChange={(e) => { setIncludeCollectible(e.target.checked); setPreview(null); }} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{t("gifts.includeCollectible")}</span></label>
|
||||
<label className="gift-switch"><input type="checkbox" checked={includeCollectible} onChange={(e) => { setIncludeCollectible(e.target.checked); setPreview(null); }} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{"Import the complete collectible pool, including crafted models"}</span></label>
|
||||
{includeCollectible && <div className="gift-fields-grid">
|
||||
<label><span>{t("collectibles.upgradeStars")}</span><input type="number" min="1" value={upgradeStars} onChange={(e) => { setUpgradeStars(e.target.value); setPreview(null); }} /></label>
|
||||
<label><span>{t("collectibles.supply")}</span><input type="number" min="1" value={supplyTotal} onChange={(e) => { setSupplyTotal(e.target.value); setPreview(null); }} /></label>
|
||||
<label><span>{t("collectibles.slug")}</span><input value={slugPrefix} maxLength={48} onChange={(e) => { setSlugPrefix(e.target.value.toLowerCase()); setPreview(null); }} /></label>
|
||||
<label><span>{"Upgrade price in Stars"}</span><input type="number" min="1" value={upgradeStars} onChange={(e) => { setUpgradeStars(e.target.value); setPreview(null); }} /></label>
|
||||
<label><span>{"Unique supply"}</span><input type="number" min="1" value={supplyTotal} onChange={(e) => { setSupplyTotal(e.target.value); setPreview(null); }} /></label>
|
||||
<label><span>{"Public slug prefix"}</span><input value={slugPrefix} maxLength={48} onChange={(e) => { setSlugPrefix(e.target.value.toLowerCase()); setPreview(null); }} /></label>
|
||||
</div>}
|
||||
</>}
|
||||
<div className="gift-fields-grid">
|
||||
<label><span>{t("gifts.title")}</span><input value={title} maxLength={128} placeholder={t("gifts.titlePlaceholder")} onChange={(e) => { setTitle(e.target.value); setPreview(null); }} /></label>
|
||||
<label><span>{t("gifts.stars")}</span><input type="number" min="1" value={stars} onChange={(e) => { setStars(e.target.value); setPreview(null); }} /></label>
|
||||
<label><span>{t("gifts.convertStars")}</span><input type="number" min="0" value={convertStars} onChange={(e) => { setConvertStars(e.target.value); setPreview(null); }} /></label>
|
||||
<label><span>{t("gifts.sortOrder")}</span><input type="number" value={sortOrder} onChange={(e) => { setSortOrder(e.target.value); setPreview(null); }} /></label>
|
||||
<label><span>{"Display title"}</span><input value={title} maxLength={128} placeholder={"e.g. Celebration Star"} onChange={(e) => { setTitle(e.target.value); setPreview(null); }} /></label>
|
||||
<label><span>{"Price in Stars"}</span><input type="number" min="1" value={stars} onChange={(e) => { setStars(e.target.value); setPreview(null); }} /></label>
|
||||
<label><span>{"Conversion Stars"}</span><input type="number" min="0" value={convertStars} onChange={(e) => { setConvertStars(e.target.value); setPreview(null); }} /></label>
|
||||
<label><span>{"Sort order"}</span><input type="number" value={sortOrder} onChange={(e) => { setSortOrder(e.target.value); setPreview(null); }} /></label>
|
||||
</div>
|
||||
<label className="gift-switch"><input type="checkbox" checked={enabled} onChange={(e) => { setEnabled(e.target.checked); setPreview(null); }} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{t("gifts.enableAfterImport")}</span></label>
|
||||
<label className="gift-switch"><input type="checkbox" checked={enabled} onChange={(e) => { setEnabled(e.target.checked); setPreview(null); }} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{"Enable after import"}</span></label>
|
||||
</section> : <>
|
||||
<div className="gift-import-note"><span>{t("gifts.importHint")}</span><div className="gift-format-chips" aria-label={t("gifts.formats")}><span>TGS</span><span>Lottie JSON</span></div></div>
|
||||
<div className="gift-import-note"><span>{"Upload TGS or plain Lottie JSON. Lottie is normalized and compressed to TGS."}</span><div className="gift-format-chips" aria-label={"Accepted formats"}><span>TGS</span><span>Lottie JSON</span></div></div>
|
||||
<label className={`gift-file-picker ${file ? "has-file" : ""}`}>
|
||||
<input type="file" accept=".tgs,.json,.lottie,application/json,application/x-tgsticker" onChange={(e) => { setFile(e.target.files?.[0] ?? null); setPreview(null); }} />
|
||||
<span className="gift-file-icon"><FileJson2 size={22} /></span>
|
||||
<span className="gift-file-copy"><span className="gift-field-label">{t("gifts.animation")}</span><strong>{file ? file.name : t("gifts.filePrompt")}</strong><small>{file ? formatBytes(file.size) : t("gifts.fileHint")}</small></span>
|
||||
<span className="gift-file-action">{file ? t("gifts.changeFile") : t("gifts.chooseFile")}</span>
|
||||
<span className="gift-file-copy"><span className="gift-field-label">{"Animation file"}</span><strong>{file ? file.name : "Drop or choose a TGS / Lottie file"}</strong><small>{file ? formatBytes(file.size) : "TGS, JSON or Lottie · validated before import"}</small></span>
|
||||
<span className="gift-file-action">{file ? "Change file" : "Choose file"}</span>
|
||||
</label>
|
||||
<div className="gift-fields-grid">
|
||||
<label><span>{t("gifts.title")}</span><input value={title} maxLength={128} placeholder={t("gifts.titlePlaceholder")} onChange={(e) => { setTitle(e.target.value); setPreview(null); }} /></label>
|
||||
<label><span>{t("gifts.stars")}</span><input type="number" min="1" value={stars} onChange={(e) => { setStars(e.target.value); setPreview(null); }} /></label>
|
||||
<label><span>{t("gifts.convertStars")}</span><input type="number" min="0" value={convertStars} onChange={(e) => { setConvertStars(e.target.value); setPreview(null); }} /></label>
|
||||
<label><span>{t("gifts.sortOrder")}</span><input type="number" value={sortOrder} onChange={(e) => { setSortOrder(e.target.value); setPreview(null); }} /></label>
|
||||
<label><span>{"Display title"}</span><input value={title} maxLength={128} placeholder={"e.g. Celebration Star"} onChange={(e) => { setTitle(e.target.value); setPreview(null); }} /></label>
|
||||
<label><span>{"Price in Stars"}</span><input type="number" min="1" value={stars} onChange={(e) => { setStars(e.target.value); setPreview(null); }} /></label>
|
||||
<label><span>{"Conversion Stars"}</span><input type="number" min="0" value={convertStars} onChange={(e) => { setConvertStars(e.target.value); setPreview(null); }} /></label>
|
||||
<label><span>{"Sort order"}</span><input type="number" value={sortOrder} onChange={(e) => { setSortOrder(e.target.value); setPreview(null); }} /></label>
|
||||
</div>
|
||||
<label className="gift-switch"><input type="checkbox" checked={enabled} onChange={(e) => { setEnabled(e.target.checked); setPreview(null); }} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{t("gifts.enableAfterImport")}</span></label>
|
||||
<label className="gift-switch"><input type="checkbox" checked={enabled} onChange={(e) => { setEnabled(e.target.checked); setPreview(null); }} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{"Enable after import"}</span></label>
|
||||
</>}
|
||||
<label className="gift-reason-field"><span>{t("gifts.reason")}</span><input value={reason} placeholder={t("gifts.reasonPlaceholder")} onChange={(e) => setReason(e.target.value)} /></label>
|
||||
<label className="gift-reason-field"><span>{"Audit reason"}</span><input value={reason} placeholder={"Briefly describe why this gift is being imported"} onChange={(e) => setReason(e.target.value)} /></label>
|
||||
{importError && <Alert>{importError}</Alert>}
|
||||
{preview && <div className="gift-validation"><div className="gift-validation-head"><CheckCircle2 size={17} /><div><strong>{t("gifts.validationReady")}</strong><span>{t("gifts.validationHint")}</span></div></div><pre>{JSON.stringify(preview.details, null, 2)}</pre></div>}
|
||||
{preview && <div className="gift-validation"><div className="gift-validation-head"><CheckCircle2 size={17} /><div><strong>{"Validation passed"}</strong><span>{"Review the normalized metadata, then confirm the import."}</span></div></div><pre>{JSON.stringify(preview.details, null, 2)}</pre></div>}
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="btn" type="button" onClick={() => setImportOpen(false)} disabled={busy}>{t("common.close")}</button>
|
||||
<button className="btn" type="button" onClick={validateImport} disabled={busy}>{busy ? <Loader2 className="spin" size={15} /> : <ShieldCheck size={15} />}{t("gifts.validate")}</button>
|
||||
<button className="btn primary" type="button" onClick={confirmImport} disabled={busy || !preview}><Upload size={15} />{t("gifts.confirmImport")}</button>
|
||||
<button className="btn" type="button" onClick={() => setImportOpen(false)} disabled={busy}>{"Close"}</button>
|
||||
<button className="btn" type="button" onClick={validateImport} disabled={busy}>{busy ? <Loader2 className="spin" size={15} /> : <ShieldCheck size={15} />}{"Dry-run validation"}</button>
|
||||
<button className="btn primary" type="button" onClick={confirmImport} disabled={busy || !preview}><Upload size={15} />{"Confirm import"}</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>,
|
||||
|
|
@ -656,29 +661,29 @@ export function GiftsPage() {
|
|||
{bulkImportOpen && createPortal(
|
||||
<div className="modal-backdrop" role="presentation">
|
||||
<section className="modal command-modal gift-bulk-import-modal" role="dialog" aria-modal="true"
|
||||
aria-label={bulkImportOpen === "default" ? t("gifts.importAllDefault") : t("gifts.importAllOfficial")}>
|
||||
aria-label={bulkImportOpen === "default" ? "Import all default gifts" : "Import all official gifts"}>
|
||||
<div className="modal-head">
|
||||
<div><div className="eyebrow">{t("gifts.importEyebrow")}</div><h2>{bulkImportOpen === "default" ? t("gifts.importAllDefault") : t("gifts.importAllOfficial")}</h2></div>
|
||||
<button className="icon-btn" type="button" onClick={closeBulkImport} disabled={bulkImportBusy} aria-label={t("action.close")}><X size={15} /></button>
|
||||
<div><div className="eyebrow">{"Gift catalog operation"}</div><h2>{bulkImportOpen === "default" ? "Import all default gifts" : "Import all official gifts"}</h2></div>
|
||||
<button className="icon-btn" type="button" onClick={closeBulkImport} disabled={bulkImportBusy} aria-label={"Close"}><X size={15} /></button>
|
||||
</div>
|
||||
<div className="command-body">
|
||||
<div className="gift-import-note"><span>{t("gifts.bulkImportCount", { count: bulkImportItems.length })}</span></div>
|
||||
<label className="gift-switch"><input type="checkbox" checked={bulkImportEnabled} disabled={bulkImportBusy} onChange={(e) => setBulkImportEnabled(e.target.checked)} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{t("gifts.enableAfterImport")}</span></label>
|
||||
<label className="gift-reason-field"><span>{t("gifts.reason")}</span><input value={bulkImportReason} placeholder={t("gifts.reasonPlaceholder")} disabled={bulkImportBusy} onChange={(e) => setBulkImportReason(e.target.value)} /></label>
|
||||
<div className="gift-import-note"><span>{`${bulkImportItems.length} gifts available to import`}</span></div>
|
||||
<label className="gift-switch"><input type="checkbox" checked={bulkImportEnabled} disabled={bulkImportBusy} onChange={(e) => setBulkImportEnabled(e.target.checked)} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{"Enable after import"}</span></label>
|
||||
<label className="gift-reason-field"><span>{"Audit reason"}</span><input value={bulkImportReason} placeholder={"Briefly describe why this gift is being imported"} disabled={bulkImportBusy} onChange={(e) => setBulkImportReason(e.target.value)} /></label>
|
||||
{bulkImportBusy && <div className="gift-bulk-import-progress">
|
||||
<div className="gift-bulk-import-progress-bar"><div style={{ width: `${bulkImportProgress.total ? Math.round((bulkImportProgress.done / bulkImportProgress.total) * 100) : 0}%` }} /></div>
|
||||
<span>{t("gifts.importingProgress", { done: bulkImportProgress.done, total: bulkImportProgress.total })}</span>
|
||||
<span>{`Importing ${bulkImportProgress.done} of ${bulkImportProgress.total}`}</span>
|
||||
</div>}
|
||||
{bulkImportError && <Alert>{bulkImportError}</Alert>}
|
||||
{bulkImportResult && <div className="gift-validation">
|
||||
<div className="gift-validation-head"><CheckCircle2 size={17} /><div><strong>{t("gifts.bulkImportDone")}</strong><span>{t("gifts.bulkImportSummary", { imported: bulkImportResult.imported, skipped: bulkImportResult.skipped, failed: bulkImportResult.failed })}</span></div></div>
|
||||
<div className="gift-validation-head"><CheckCircle2 size={17} /><div><strong>{"Import complete"}</strong><span>{`Imported ${bulkImportResult.imported}, skipped ${bulkImportResult.skipped}, failed ${bulkImportResult.failed}`}</span></div></div>
|
||||
{bulkImportResult.errors.length > 0 && <pre>{bulkImportResult.errors.join("\n")}</pre>}
|
||||
</div>}
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="btn" type="button" onClick={closeBulkImport} disabled={bulkImportBusy}>{t("common.close")}</button>
|
||||
<button className="btn" type="button" onClick={closeBulkImport} disabled={bulkImportBusy}>{"Close"}</button>
|
||||
<button className="btn primary" type="button" onClick={runBulkImport} disabled={bulkImportBusy || bulkImportItems.length === 0}>
|
||||
{bulkImportBusy ? <Loader2 className="spin" size={15} /> : <Upload size={15} />} {t("gifts.startBulkImport")}
|
||||
{bulkImportBusy ? <Loader2 className="spin" size={15} /> : <Upload size={15} />} {"Start import"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { useEffect, useMemo, useState } from "react";
|
|||
import { api, errorMessage } from "../api";
|
||||
import { ChannelPicker, UserPicker } from "../components/EntityPicker";
|
||||
import { Alert, JsonBlock } from "../components/ui";
|
||||
import { useI18n } from "../i18n";
|
||||
import type { AccountRow, ChannelRow, CommandResult, StarGiftCollectibleAttributeRow, StarGiftCollectiblePreview, StarGiftRow } from "../types";
|
||||
|
||||
const SYSTEM_SENDER = "777000";
|
||||
|
|
@ -16,7 +15,6 @@ function attrLabel(attr: StarGiftCollectibleAttributeRow): string {
|
|||
}
|
||||
|
||||
export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const [kind, setKind] = useState<RecipientKind>("user");
|
||||
const [user, setUser] = useState<AccountRow | null>(null);
|
||||
const [channel, setChannel] = useState<ChannelRow | null>(null);
|
||||
|
|
@ -82,11 +80,11 @@ export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: ()
|
|||
|
||||
async function run(confirm: boolean) {
|
||||
if (recipientID <= 0) {
|
||||
setError(t("giveGift.recipientRequired"));
|
||||
setError("Select a recipient first");
|
||||
return;
|
||||
}
|
||||
if (!reason.trim()) {
|
||||
setError(t("action.reasonRequired"));
|
||||
setError("Please enter an operation reason");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
|
|
@ -114,34 +112,34 @@ export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: ()
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="give-gift-tabs" role="group" aria-label={t("giveGift.recipientKind")}>
|
||||
<div className="give-gift-tabs" role="group" aria-label={"Recipient type"}>
|
||||
<button type="button" className={`btn ${kind === "user" ? "primary" : ""}`} onClick={() => { setKind("user"); setResult(null); }}>
|
||||
<User size={15} /> {t("giveGift.recipientUser")}
|
||||
<User size={15} /> {"User"}
|
||||
</button>
|
||||
<button type="button" className={`btn ${kind === "channel" ? "primary" : ""}`} onClick={() => { setKind("channel"); setUpgrade(false); setResult(null); }}>
|
||||
<Users size={15} /> {t("giveGift.recipientChannel")}
|
||||
<Users size={15} /> {"Channel"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{kind === "user"
|
||||
? <UserPicker label={t("giveGift.pickUser")} value={user} onChange={(row) => { setUser(row); setResult(null); }} />
|
||||
: <ChannelPicker label={t("giveGift.pickChannel")} value={channel} onChange={(row) => { setChannel(row); setResult(null); }} />}
|
||||
? <UserPicker label={"Recipient user"} value={user} onChange={(row) => { setUser(row); setResult(null); }} />
|
||||
: <ChannelPicker label={"Recipient channel"} value={channel} onChange={(row) => { setChannel(row); setResult(null); }} />}
|
||||
|
||||
<label className="form-field">
|
||||
<span>{t("giveGift.sender")}</span>
|
||||
<span>{"Sender account ID"}</span>
|
||||
<input value={SYSTEM_SENDER} disabled readOnly />
|
||||
<small className="field-hint">{t("giveGift.senderHint")}</small>
|
||||
<small className="field-hint">{"Gifts are always sent from the system account 777000 (Telesrv)."}</small>
|
||||
</label>
|
||||
|
||||
<label className="form-field">
|
||||
<span>{t("giveGift.message")}</span>
|
||||
<textarea value={message} rows={2} maxLength={128} onChange={(event) => { setMessage(event.target.value); setResult(null); }} placeholder={t("giveGift.messagePlaceholder")} />
|
||||
<span>{"Attached message (optional)"}</span>
|
||||
<textarea value={message} rows={2} maxLength={128} onChange={(event) => { setMessage(event.target.value); setResult(null); }} placeholder={"Shown with the gift"} />
|
||||
</label>
|
||||
|
||||
<label className="gift-switch">
|
||||
<input type="checkbox" checked={hideName} onChange={(event) => { setHideName(event.target.checked); setResult(null); }} />
|
||||
<span className="gift-switch-track" aria-hidden="true"><span /></span>
|
||||
<span>{t("giveGift.hideName")}</span>
|
||||
<span>{"Hide sender name from recipient"}</span>
|
||||
</label>
|
||||
|
||||
{kind === "user" && (
|
||||
|
|
@ -149,30 +147,30 @@ export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: ()
|
|||
<label className="gift-switch">
|
||||
<input type="checkbox" checked={upgrade} onChange={(event) => { setUpgrade(event.target.checked); if (!event.target.checked) { setModelID("0"); setPatternID("0"); setBackdropID("0"); } setResult(null); }} />
|
||||
<span className="gift-switch-track" aria-hidden="true"><span /></span>
|
||||
<span>{t("giveGift.upgrade")}</span>
|
||||
<span>{"Deliver as upgraded collectible"}</span>
|
||||
</label>
|
||||
{upgrade && <p className="give-gift-upgrade-note">{t("giveGift.upgradeNote")}</p>}
|
||||
{upgrade && <p className="give-gift-upgrade-note">{"The gift is minted as a unique collectible. Pick specific attributes below, or leave them on Random to draw from the published pool. The collectible number is assigned automatically. Requires a published collectible upgrade with remaining supply."}</p>}
|
||||
{upgrade && previewError && <Alert>{previewError}</Alert>}
|
||||
{upgrade && preview && (
|
||||
<div className="gift-fields-grid give-gift-attrs">
|
||||
<label>
|
||||
<span>{t("giveGift.model")}</span>
|
||||
<span>{"Model"}</span>
|
||||
<select value={modelID} onChange={(event) => { setModelID(event.target.value); setResult(null); }}>
|
||||
<option value="0">{t("giveGift.random")}</option>
|
||||
<option value="0">{"Random"}</option>
|
||||
{(preview.models ?? []).map((attr) => <option key={attr.id} value={attr.id}>{attrLabel(attr)}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>{t("giveGift.pattern")}</span>
|
||||
<span>{"Pattern"}</span>
|
||||
<select value={patternID} onChange={(event) => { setPatternID(event.target.value); setResult(null); }}>
|
||||
<option value="0">{t("giveGift.random")}</option>
|
||||
<option value="0">{"Random"}</option>
|
||||
{(preview.patterns ?? []).map((attr) => <option key={attr.id} value={attr.id}>{attrLabel(attr)}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>{t("giveGift.backdrop")}</span>
|
||||
<span>{"Backdrop"}</span>
|
||||
<select value={backdropID} onChange={(event) => { setBackdropID(event.target.value); setResult(null); }}>
|
||||
<option value="0">{t("giveGift.random")}</option>
|
||||
<option value="0">{"Random"}</option>
|
||||
{(preview.backdrops ?? []).map((attr) => <option key={attr.id} value={attr.id}>{attrLabel(attr)}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
|
|
@ -182,12 +180,12 @@ export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: ()
|
|||
)}
|
||||
|
||||
<label className="form-field">
|
||||
<span>{t("action.reason")}</span>
|
||||
<textarea value={reason} rows={2} onChange={(event) => setReason(event.target.value)} placeholder={t("action.reasonPlaceholder")} />
|
||||
<span>{"Operation reason"}</span>
|
||||
<textarea value={reason} rows={2} onChange={(event) => setReason(event.target.value)} placeholder={"Describe why this operation is being performed"} />
|
||||
</label>
|
||||
|
||||
<div className="command-preview">
|
||||
<div className="preview-head">{t("action.requestPreview")}</div>
|
||||
<div className="preview-head">{"Request preview"}</div>
|
||||
<JsonBlock value={JSON.stringify(previewPayload, null, 2)} />
|
||||
</div>
|
||||
|
||||
|
|
@ -196,11 +194,11 @@ export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: ()
|
|||
<div className="result-box">
|
||||
<div className="result-title">
|
||||
{result.error ? <CircleAlert size={16} /> : <CheckCircle2 size={16} />}
|
||||
<strong>{result.message || result.error || t("action.result")}</strong>
|
||||
<strong>{result.message || result.error || "Action result"}</strong>
|
||||
</div>
|
||||
<div className="result-line"><span>{t("action.commandID")}</span><strong>{result.command_id}</strong></div>
|
||||
<div className="result-line"><span>{t("action.status")}</span><strong>{result.status}</strong></div>
|
||||
<div className="result-line"><span>{t("action.dryRun")}</span><strong>{result.dry_run ? t("common.yes") : t("common.no")}</strong></div>
|
||||
<div className="result-line"><span>{"Command ID"}</span><strong>{result.command_id}</strong></div>
|
||||
<div className="result-line"><span>{"Status"}</span><strong>{result.status}</strong></div>
|
||||
<div className="result-line"><span>{"Dry-run"}</span><strong>{result.dry_run ? "Yes" : "No"}</strong></div>
|
||||
{result.details && <JsonBlock value={JSON.stringify(result.details, null, 2)} />}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -208,11 +206,11 @@ export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: ()
|
|||
<div className="give-gift-form-actions">
|
||||
<button className="btn icon-text" type="button" onClick={() => run(false)} disabled={busy}>
|
||||
{busy ? <Loader2 size={15} className="spin" /> : <Play size={15} />}
|
||||
{result ? t("action.runAgain") : t("action.runDry")}
|
||||
{result ? "Run dry-run again" : "Run dry-run first"}
|
||||
</button>
|
||||
<button className="btn primary icon-text" type="button" onClick={() => run(true)} disabled={busy || !canConfirm}>
|
||||
<Gift size={15} />
|
||||
{t("giveGift.confirm")}
|
||||
{"Give gift"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,12 +3,10 @@ import { useEffect, useMemo, useState } from "react";
|
|||
import { api, errorMessage } from "../api";
|
||||
import { StaticLottie } from "../components/StaticLottie";
|
||||
import { Alert, Badge, PageFrame } from "../components/ui";
|
||||
import { useI18n } from "../i18n";
|
||||
import type { StarGiftRow } from "../types";
|
||||
import { GiveGiftForm } from "./GiveGiftForm";
|
||||
|
||||
export function GiveGiftsPage() {
|
||||
const { t } = useI18n();
|
||||
const [gifts, setGifts] = useState<StarGiftRow[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
const [selected, setSelected] = useState<StarGiftRow | null>(null);
|
||||
|
|
@ -40,18 +38,18 @@ export function GiveGiftsPage() {
|
|||
}, [gifts, query]);
|
||||
|
||||
return (
|
||||
<PageFrame title={t("giveGifts.pageTitle")} eyebrow={t("giveGifts.eyebrow")} actions={
|
||||
<button className="btn" type="button" onClick={() => load()} disabled={busy}><RefreshCw size={15} /> {t("common.refresh")}</button>
|
||||
<PageFrame title={"Give Gifts"} eyebrow={"Grant catalog gifts to any user or channel"} actions={
|
||||
<button className="btn" type="button" onClick={() => load()} disabled={busy}><RefreshCw size={15} /> {"Refresh"}</button>
|
||||
}>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<p className="give-gift-upgrade-note">{t("giveGifts.hint")}</p>
|
||||
<p className="give-gift-upgrade-note">{"Pick a gift to grant. Delivery is free of charge and sent from the system account 777000 (Telesrv) by default."}</p>
|
||||
<div className="give-gift-layout">
|
||||
<section className="give-gift-picker">
|
||||
<div className="give-gift-picker-head">
|
||||
<label className="searchbox"><Search size={15} /><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={t("giveGifts.searchPlaceholder")} /></label>
|
||||
<span className="gift-list-summary">{t("gifts.listSummary", { shown: visible.length, total: gifts.length })}</span>
|
||||
<label className="searchbox"><Search size={15} /><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={"Search by title or gift ID"} /></label>
|
||||
<span className="gift-list-summary">{`Showing ${visible.length} of ${gifts.length}`}</span>
|
||||
</div>
|
||||
<div className="give-gift-picker-list" role="listbox" aria-label={t("giveGifts.pickGift")}>
|
||||
<div className="give-gift-picker-list" role="listbox" aria-label={"Select a gift"}>
|
||||
{visible.map((gift) => {
|
||||
const active = selected?.GiftID === gift.GiftID;
|
||||
return (
|
||||
|
|
@ -64,18 +62,18 @@ export function GiveGiftsPage() {
|
|||
<span className="mono">#{gift.GiftID}</span>
|
||||
</span>
|
||||
<span className="give-gift-option-price">
|
||||
{gift.Enabled ? <Badge>⭐ {gift.Stars}</Badge> : <Badge tone="neutral">{t("common.disabled")}</Badge>}
|
||||
{gift.Enabled ? <Badge>⭐ {gift.Stars}</Badge> : <Badge tone="neutral">{"Disabled"}</Badge>}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{visible.length === 0 && !busy && <div className="official-gift-empty">{t("common.noResults")}</div>}
|
||||
{visible.length === 0 && !busy && <div className="official-gift-empty">{"No results"}</div>}
|
||||
</div>
|
||||
</section>
|
||||
<section className="give-gift-panel">
|
||||
{selected
|
||||
? <GiveGiftForm key={selected.GiftID} gift={selected} onDone={() => void load()} />
|
||||
: <div className="give-gift-empty-panel"><Gift size={26} /><p>{t("giveGifts.selectPrompt")}</p></div>}
|
||||
: <div className="give-gift-empty-panel"><Gift size={26} /><p>{"Select a gift from the list to start."}</p></div>}
|
||||
</section>
|
||||
</div>
|
||||
</PageFrame>
|
||||
|
|
|
|||
|
|
@ -2,13 +2,11 @@ import { ArrowLeft } from "lucide-react";
|
|||
import { useEffect, useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { Alert, Badge, EmptyRow, JsonBlock, LoadingSurface, PageFrame, SectionHead, Summary } from "../components/ui";
|
||||
import { useI18n } from "../i18n";
|
||||
import { formatUnix } from "../lib/format";
|
||||
import type { Navigate } from "../routing";
|
||||
import type { GroupMessageDetail } from "../types";
|
||||
|
||||
export function GroupMessageDetailPage({ channelID, msgID, navigate }: { channelID: number; msgID: number; navigate: Navigate }) {
|
||||
const { t } = useI18n();
|
||||
const [detail, setDetail] = useState<GroupMessageDetail | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
|
|
@ -29,48 +27,48 @@ export function GroupMessageDetailPage({ channelID, msgID, navigate }: { channel
|
|||
return <Alert>{error}</Alert>;
|
||||
}
|
||||
if (!detail) {
|
||||
return <LoadingSurface label={t("common.loading")} />;
|
||||
return <LoadingSurface label={"Loading"} />;
|
||||
}
|
||||
|
||||
const msg = detail.Message;
|
||||
return (
|
||||
<PageFrame
|
||||
title={t("messages.groupDetailTitle", { id: msg.ID })}
|
||||
eyebrow={t("messages.detailEyebrow")}
|
||||
actions={<button className="btn icon-text" onClick={() => navigate("/messages/groups")}><ArrowLeft size={15} /> {t("messages.backGroup")}</button>}
|
||||
title={`Group Message #${msg.ID}`}
|
||||
eyebrow={"Message Detail"}
|
||||
actions={<button className="btn icon-text" onClick={() => navigate("/messages/groups")}><ArrowLeft size={15} /> {"Back to group messages"}</button>}
|
||||
>
|
||||
<div className="stacked-sections">
|
||||
<section className="entity-head">
|
||||
<div>
|
||||
<div className="entity-title">{t("messages.channelGroupTitle", { id: msg.ChannelID })}</div>
|
||||
<div className="entity-subtitle">{t("messages.senderSubtitle", { sender: msg.SenderUserID, date: formatUnix(msg.Date) })}</div>
|
||||
<div className="entity-title">{`Channel / Group ${msg.ChannelID}`}</div>
|
||||
<div className="entity-subtitle">{`Sender ${msg.SenderUserID} · ${formatUnix(msg.Date)}`}</div>
|
||||
</div>
|
||||
<div className="entity-badges">
|
||||
{msg.Deleted ? <Badge tone="danger">{t("common.deleted")}</Badge> : <Badge>{t("common.survived")}</Badge>}
|
||||
{msg.Pinned && <Badge tone="warn">{t("messages.pinned")}</Badge>}
|
||||
{msg.Post && <Badge>{t("messages.channelPost")}</Badge>}
|
||||
{msg.Deleted ? <Badge tone="danger">{"Deleted"}</Badge> : <Badge>{"Live"}</Badge>}
|
||||
{msg.Pinned && <Badge tone="warn">{"Pinned"}</Badge>}
|
||||
{msg.Post && <Badge>{"Channel post"}</Badge>}
|
||||
<Badge>pts {msg.PTS}</Badge>
|
||||
</div>
|
||||
</section>
|
||||
<div className="summary-grid">
|
||||
<Summary label={t("common.messageId")} value={String(msg.ID)} mono />
|
||||
<Summary label={t("messages.channelGroup")} value={String(msg.ChannelID)} mono />
|
||||
<Summary label={"Message ID"} value={String(msg.ID)} mono />
|
||||
<Summary label={"Channel / Group"} value={String(msg.ChannelID)} mono />
|
||||
<Summary label="From Peer" value={`${msg.FromPeerType}:${msg.FromPeerID}`} mono />
|
||||
<Summary label={t("common.views")} value={String(msg.ViewsCount)} />
|
||||
<Summary label={"Views"} value={String(msg.ViewsCount)} />
|
||||
</div>
|
||||
<section className="section-block">
|
||||
<SectionHead title={t("messages.channelMessageRow")} text={t("messages.channelMessagesSnapshot")} />
|
||||
<SectionHead title={"Channel Message Row"} text={"channel_messages read-only snapshot"} />
|
||||
<JsonBlock value={detail.MessageJSON} />
|
||||
</section>
|
||||
<section className="section-block">
|
||||
<SectionHead title={t("messages.channelRow")} text={t("messages.channelSnapshot")} />
|
||||
<SectionHead title={"Channel Row"} text={"channels read-only snapshot"} />
|
||||
<JsonBlock value={detail.ChannelJSON} />
|
||||
</section>
|
||||
<section className="section-block">
|
||||
<SectionHead title={t("messages.channelUpdateEvents")} text={t("messages.channelEventsSource")} />
|
||||
<SectionHead title={"Channel Update Events"} text={"durable channel_update_events"} />
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead><tr><th>PTS</th><th>{t("common.count")}</th><th>{t("common.type")}</th><th>{t("common.messageId")}</th><th>{t("common.sender")}</th><th>{t("common.time")}</th></tr></thead>
|
||||
<thead><tr><th>PTS</th><th>{"Count"}</th><th>{"Type"}</th><th>{"Message ID"}</th><th>{"Sender"}</th><th>{"Time"}</th></tr></thead>
|
||||
<tbody>
|
||||
{detail.UpdateEvents.map((row) => (
|
||||
<tr key={`${row.PTS}-${row.Type}-${row.MessageID}`}>
|
||||
|
|
@ -88,12 +86,12 @@ export function GroupMessageDetailPage({ channelID, msgID, navigate }: { channel
|
|||
</div>
|
||||
</section>
|
||||
<section className="section-block">
|
||||
<SectionHead title={t("messages.eventJson")} />
|
||||
<SectionHead title={"Event JSON"} />
|
||||
<div className="raw-grid">
|
||||
{detail.UpdateEvents.map((row) => (
|
||||
<JsonBlock key={`${row.PTS}-${row.Type}-json`} value={row.JSON} />
|
||||
))}
|
||||
{detail.UpdateEvents.length === 0 && <div className="empty-panel">{t("common.noResults")}</div>}
|
||||
{detail.UpdateEvents.length === 0 && <div className="empty-panel">{"No results"}</div>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,13 +3,11 @@ import { useState } from "react";
|
|||
import { api, errorMessage } from "../api";
|
||||
import { ChannelPicker } from "../components/EntityPicker";
|
||||
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
|
||||
import { useI18n } from "../i18n";
|
||||
import { channelKind, formatUnix } from "../lib/format";
|
||||
import type { Navigate } from "../routing";
|
||||
import type { ChannelRow, GroupMessageListResponse } from "../types";
|
||||
|
||||
export function GroupMessagesPage({ navigate }: { navigate: Navigate }) {
|
||||
const { t } = useI18n();
|
||||
const [channel, setChannel] = useState<ChannelRow | null>(null);
|
||||
const [beforeDate, setBeforeDate] = useState("");
|
||||
const [beforeID, setBeforeID] = useState("");
|
||||
|
|
@ -20,7 +18,7 @@ export function GroupMessagesPage({ navigate }: { navigate: Navigate }) {
|
|||
async function load(next = false) {
|
||||
setError("");
|
||||
if (!channel) {
|
||||
setError(t("messages.selectChannel"));
|
||||
setError("Search and select a supergroup or channel first");
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams({
|
||||
|
|
@ -54,38 +52,38 @@ export function GroupMessagesPage({ navigate }: { navigate: Navigate }) {
|
|||
const rows = data?.rows ?? [];
|
||||
|
||||
return (
|
||||
<PageFrame title={t("messages.groupTitle")} eyebrow={t("messages.groupEyebrow")}>
|
||||
<PageFrame title={"Group Messages"} eyebrow={"Supergroup / channel messages"}>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<QueryPanel>
|
||||
<div className="message-selector-grid single">
|
||||
<ChannelPicker label={t("messages.channelGroup")} value={channel} onChange={changeChannel} />
|
||||
<ChannelPicker label={"Channel / Group"} value={channel} onChange={changeChannel} />
|
||||
</div>
|
||||
<form className="toolbar message-query" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
|
||||
<input value={beforeDate} onChange={(event) => setBeforeDate(event.target.value)} placeholder={t("messages.beforeDatePlaceholder")} />
|
||||
<input value={beforeID} onChange={(event) => setBeforeID(event.target.value)} placeholder={t("messages.beforeIDPlaceholder")} />
|
||||
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} placeholder={t("messages.limitPlaceholder")} />
|
||||
<button className="btn primary icon-text" type="submit"><Search size={15} /> {t("messages.searchMessages")}</button>
|
||||
{rows.length ? <button className="btn icon-text" type="button" onClick={() => load(true)}><ChevronRight size={15} /> {t("messages.nextPage")}</button> : null}
|
||||
<input value={beforeDate} onChange={(event) => setBeforeDate(event.target.value)} placeholder={"before_date cursor"} />
|
||||
<input value={beforeID} onChange={(event) => setBeforeID(event.target.value)} placeholder={"before_msg_id cursor"} />
|
||||
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} placeholder={"limit <= 100"} />
|
||||
<button className="btn primary icon-text" type="submit"><Search size={15} /> {"Search messages"}</button>
|
||||
{rows.length ? <button className="btn icon-text" type="button" onClick={() => load(true)}><ChevronRight size={15} /> {"Next page"}</button> : null}
|
||||
</form>
|
||||
</QueryPanel>
|
||||
<div className="metric-row">
|
||||
<Metric label={t("messages.currentPage")} value={String(rows.length)} />
|
||||
<Metric label={t("messages.mediaCount")} value={String(rows.filter((row) => row.Media && row.Media !== "{}").length)} />
|
||||
<Metric label={t("messages.channelPosts")} value={String(rows.filter((row) => row.Post).length)} />
|
||||
<Metric label={t("messages.channelGroup")} value={channel ? `${channel.Title || channelKind(channel, t)} (${channel.ID})` : "-"} />
|
||||
<Metric label={"Messages on page"} value={String(rows.length)} />
|
||||
<Metric label={"With media"} value={String(rows.filter((row) => row.Media && row.Media !== "{}").length)} />
|
||||
<Metric label={"Channel posts"} value={String(rows.filter((row) => row.Post).length)} />
|
||||
<Metric label={"Channel / Group"} value={channel ? `${channel.Title || channelKind(channel)} (${channel.ID})` : "-"} />
|
||||
</div>
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("common.messageId")}</th>
|
||||
<th>{t("common.time")}</th>
|
||||
<th>{t("common.sender")}</th>
|
||||
<th>{"Message ID"}</th>
|
||||
<th>{"Time"}</th>
|
||||
<th>{"Sender"}</th>
|
||||
<th>From Peer</th>
|
||||
<th>PTS</th>
|
||||
<th>{t("common.views")}</th>
|
||||
<th>{t("common.status")}</th>
|
||||
<th>{t("messages.body")}</th>
|
||||
<th>{"Views"}</th>
|
||||
<th>{"Status"}</th>
|
||||
<th>{"Body"}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -99,7 +97,7 @@ export function GroupMessagesPage({ navigate }: { navigate: Navigate }) {
|
|||
<td>{row.PTS}</td>
|
||||
<td>{row.ViewsCount}</td>
|
||||
<td>
|
||||
{row.Deleted ? <Badge tone="danger">{t("common.deleted")}</Badge> : row.Pinned ? <Badge tone="warn">{t("messages.pinned")}</Badge> : <Badge>{t("common.survived")}</Badge>}
|
||||
{row.Deleted ? <Badge tone="danger">{"Deleted"}</Badge> : row.Pinned ? <Badge tone="warn">{"Pinned"}</Badge> : <Badge>{"Live"}</Badge>}
|
||||
</td>
|
||||
<td className="truncate">{row.Body}</td>
|
||||
<td>
|
||||
|
|
@ -107,7 +105,7 @@ export function GroupMessagesPage({ navigate }: { navigate: Navigate }) {
|
|||
className="row-link"
|
||||
onClick={() => navigate(`/messages/groups/detail?channel_id=${row.ChannelID}&msg_id=${row.ID}`)}
|
||||
>
|
||||
{t("common.detail")} <ChevronRight size={14} />
|
||||
{"Details"} <ChevronRight size={14} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
|||
|
|
@ -2,11 +2,10 @@ import type { FormEvent } from "react";
|
|||
import { useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { Alert } from "../components/ui";
|
||||
import { useI18n } from "../i18n";
|
||||
import { ThemeSwitch } from "../theme";
|
||||
import type { AdminSession } from "../types";
|
||||
|
||||
export function LoginPage({ onLogin }: { onLogin: (actor: string) => void }) {
|
||||
const { t } = useI18n();
|
||||
export function LoginPage({ onLogin }: { onLogin: (session: AdminSession) => void }) {
|
||||
const [secret, setSecret] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
|
@ -16,8 +15,10 @@ export function LoginPage({ onLogin }: { onLogin: (actor: string) => void }) {
|
|||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
// The login answer carries the permission set and the CSRF token; api.login
|
||||
// remembers the token, the session state keeps the rights.
|
||||
const result = await api.login(secret);
|
||||
onLogin(result.actor);
|
||||
onLogin({ actor: result.actor, permissions: result.permissions ?? [] });
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
|
|
@ -38,22 +39,22 @@ export function LoginPage({ onLogin }: { onLogin: (actor: string) => void }) {
|
|||
<span className="brand-mark"><img src="/logo.png" alt="OwpenGram" /></span>
|
||||
<span>
|
||||
<strong>OwpenGram</strong>
|
||||
<small>{t("app.adminConsole")}</small>
|
||||
<small>{"Admin Console"}</small>
|
||||
</span>
|
||||
</div>
|
||||
<div className="login-head-actions">
|
||||
<ThemeSwitch />
|
||||
<span className="login-chip">{t("app.localAccess")}</span>
|
||||
<span className="login-chip">{"Local access"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="login-copy">
|
||||
<h1>{t("login.heading")}</h1>
|
||||
<p>{t("login.body")}</p>
|
||||
<h1>{"Operations Admin"}</h1>
|
||||
<p>{"Enter credentials to open the console."}</p>
|
||||
</div>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<form className="form-stack" onSubmit={submit}>
|
||||
<label>
|
||||
<span>{t("login.secret")}</span>
|
||||
<span>{"Admin password or token"}</span>
|
||||
<input
|
||||
autoFocus
|
||||
type="password"
|
||||
|
|
@ -63,7 +64,7 @@ export function LoginPage({ onLogin }: { onLogin: (actor: string) => void }) {
|
|||
/>
|
||||
</label>
|
||||
<button className="btn primary full" type="submit" disabled={busy}>
|
||||
{busy ? t("login.submitting") : t("login.submit")}
|
||||
{busy ? "Logging in" : "Log in"}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -3,13 +3,11 @@ import { useEffect, useState } from "react";
|
|||
import { api, errorMessage } from "../api";
|
||||
import { ActionButton } from "../components/ActionButton";
|
||||
import { Alert, Badge, EmptyRow, JsonBlock, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
|
||||
import { useI18n } from "../i18n";
|
||||
import { formatDate, formatUnix } from "../lib/format";
|
||||
import type { Navigate } from "../routing";
|
||||
import type { MessageDetail } from "../types";
|
||||
|
||||
export function MessageDetailPage({ ownerUserID, msgID, navigate }: { ownerUserID: number; msgID: number; navigate: Navigate }) {
|
||||
const { t } = useI18n();
|
||||
const [detail, setDetail] = useState<MessageDetail | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
|
|
@ -30,55 +28,55 @@ export function MessageDetailPage({ ownerUserID, msgID, navigate }: { ownerUserI
|
|||
return <Alert>{error}</Alert>;
|
||||
}
|
||||
if (!detail) {
|
||||
return <LoadingSurface label={t("common.loading")} />;
|
||||
return <LoadingSurface label={"Loading"} />;
|
||||
}
|
||||
|
||||
const msg = detail.Message;
|
||||
return (
|
||||
<PageFrame
|
||||
title={t("messages.privateDetailTitle", { id: msg.BoxID })}
|
||||
eyebrow={t("messages.detailEyebrow")}
|
||||
actions={<button className="btn icon-text" onClick={() => navigate("/messages/private")}><ArrowLeft size={15} /> {t("messages.backPrivate")}</button>}
|
||||
title={`Message #${msg.BoxID}`}
|
||||
eyebrow={"Message Detail"}
|
||||
actions={<button className="btn icon-text" onClick={() => navigate("/messages/private")}><ArrowLeft size={15} /> {"Back to private messages"}</button>}
|
||||
>
|
||||
<SplitLayout
|
||||
main={
|
||||
<div className="stacked-sections">
|
||||
<section className="entity-head">
|
||||
<div>
|
||||
<div className="entity-title">{t("messages.ownerPeerTitle", { owner: msg.OwnerUserID, peer: msg.PeerID })}</div>
|
||||
<div className="entity-subtitle">{t("messages.senderSubtitle", { sender: msg.FromUserID, date: formatUnix(msg.Date) })}</div>
|
||||
<div className="entity-title">{`Owner ${msg.OwnerUserID} · Peer ${msg.PeerID}`}</div>
|
||||
<div className="entity-subtitle">{`Sender ${msg.FromUserID} · ${formatUnix(msg.Date)}`}</div>
|
||||
</div>
|
||||
<div className="entity-badges">
|
||||
{msg.Deleted ? <Badge tone="danger">{t("common.deleted")}</Badge> : <Badge>{t("common.survived")}</Badge>}
|
||||
{msg.Deleted ? <Badge tone="danger">{"Deleted"}</Badge> : <Badge>{"Live"}</Badge>}
|
||||
<Badge>pts {msg.PTS}</Badge>
|
||||
<Badge>{msg.Outgoing ? t("messages.outgoing") : t("messages.incoming")}</Badge>
|
||||
<Badge>{msg.Outgoing ? "Outgoing" : "Incoming"}</Badge>
|
||||
</div>
|
||||
</section>
|
||||
<div className="summary-grid">
|
||||
<Summary label={t("messages.boxID")} value={String(msg.BoxID)} mono />
|
||||
<Summary label={t("messages.privateMessageID")} value={String(msg.PrivateMessageID)} mono />
|
||||
<Summary label={t("messages.messageSender")} value={String(msg.MessageSenderID)} mono />
|
||||
<Summary label={t("common.time")} value={formatUnix(msg.Date)} />
|
||||
<Summary label={"Message box ID"} value={String(msg.BoxID)} mono />
|
||||
<Summary label={"Private message ID"} value={String(msg.PrivateMessageID)} mono />
|
||||
<Summary label={"Message sender"} value={String(msg.MessageSenderID)} mono />
|
||||
<Summary label={"Time"} value={formatUnix(msg.Date)} />
|
||||
</div>
|
||||
<section className="section-block">
|
||||
<SectionHead title={t("messages.messageBox")} text={t("messages.messageBoxesSnapshot")} />
|
||||
<SectionHead title={"Message Box"} text={"message_boxes read-only snapshot"} />
|
||||
<JsonBlock value={detail.MessageJSON} />
|
||||
</section>
|
||||
<div className="raw-grid">
|
||||
<section className="section-block">
|
||||
<SectionHead title={t("messages.dialogRow")} text={t("messages.dialogSnapshot")} />
|
||||
<SectionHead title={"Dialog Row"} text={"dialogs read-only snapshot"} />
|
||||
<JsonBlock value={detail.DialogJSON} />
|
||||
</section>
|
||||
<section className="section-block">
|
||||
<SectionHead title={t("messages.privateRow")} text={t("messages.privateSnapshot")} />
|
||||
<SectionHead title={"Private Message Row"} text={"private_messages read-only snapshot"} />
|
||||
<JsonBlock value={detail.PrivateJSON} />
|
||||
</section>
|
||||
</div>
|
||||
<section className="section-block">
|
||||
<SectionHead title={t("messages.userUpdateEvents")} text={t("messages.userEventsSource")} />
|
||||
<SectionHead title={"Update Events"} text={"durable user_update_events"} />
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead><tr><th>PTS</th><th>{t("common.count")}</th><th>{t("common.type")}</th><th>{t("common.time")}</th></tr></thead>
|
||||
<thead><tr><th>PTS</th><th>{"Count"}</th><th>{"Type"}</th><th>{"Time"}</th></tr></thead>
|
||||
<tbody>
|
||||
{detail.UpdateEvents.map((row) => <tr key={`${row.PTS}-${row.Type}`}><td>{row.PTS}</td><td>{row.PTSCount}</td><td>{row.Type}</td><td>{formatUnix(row.Date)}</td></tr>)}
|
||||
{detail.UpdateEvents.length === 0 && <EmptyRow colSpan={4} />}
|
||||
|
|
@ -87,10 +85,10 @@ export function MessageDetailPage({ ownerUserID, msgID, navigate }: { ownerUserI
|
|||
</div>
|
||||
</section>
|
||||
<section className="section-block">
|
||||
<SectionHead title={t("messages.dispatchOutbox")} text={t("messages.outboxSource")} />
|
||||
<SectionHead title={"Dispatch Queue"} text={"online/offline dispatch_outbox"} />
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead><tr><th>ID</th><th>{t("account.userID")}</th><th>PTS</th><th>{t("common.type")}</th><th>{t("common.status")}</th><th>{t("messages.attempts")}</th><th>{t("common.updatedAt")}</th></tr></thead>
|
||||
<thead><tr><th>ID</th><th>{"User ID"}</th><th>PTS</th><th>{"Type"}</th><th>{"Status"}</th><th>{"Attempts"}</th><th>{"Updated"}</th></tr></thead>
|
||||
<tbody>
|
||||
{detail.Outbox.map((row) => <tr key={row.ID}><td>{row.ID}</td><td>{row.TargetUserID}</td><td>{row.PTS}</td><td>{row.EventType}</td><td>{row.Status}</td><td>{row.Attempts}</td><td>{formatDate(row.UpdatedAt)}</td></tr>)}
|
||||
{detail.Outbox.length === 0 && <EmptyRow colSpan={7} />}
|
||||
|
|
@ -102,9 +100,9 @@ export function MessageDetailPage({ ownerUserID, msgID, navigate }: { ownerUserI
|
|||
}
|
||||
side={
|
||||
<section className="action-dock">
|
||||
<div className="dock-title">{t("common.operations")}</div>
|
||||
<div className="dock-title">{"Operations"}</div>
|
||||
<ActionButton
|
||||
label={t("messages.deleteThis")}
|
||||
label={"Delete this message"}
|
||||
icon={<Trash2 size={15} />}
|
||||
path="/api/actions/delete-messages"
|
||||
payload={() => ({ owner_user_id: msg.OwnerUserID, peer_id: msg.PeerID, ids: [msg.BoxID], revoke: true })}
|
||||
|
|
|
|||
|
|
@ -4,13 +4,11 @@ import { api, errorMessage } from "../api";
|
|||
import { ActionButton } from "../components/ActionButton";
|
||||
import { UserPicker } from "../components/EntityPicker";
|
||||
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
|
||||
import { useI18n } from "../i18n";
|
||||
import { displayName, formatUnix, parseIDs, toInt } from "../lib/format";
|
||||
import type { Navigate } from "../routing";
|
||||
import type { AccountRow, MessageListResponse } from "../types";
|
||||
|
||||
export function MessagesPage({ navigate }: { navigate: Navigate }) {
|
||||
const { t } = useI18n();
|
||||
const [owner, setOwner] = useState<AccountRow | null>(null);
|
||||
const [peer, setPeer] = useState<AccountRow | null>(null);
|
||||
const [beforeDate, setBeforeDate] = useState("");
|
||||
|
|
@ -27,7 +25,7 @@ export function MessagesPage({ navigate }: { navigate: Navigate }) {
|
|||
async function load(next = false) {
|
||||
setError("");
|
||||
if (!owner || !peer) {
|
||||
setError(t("messages.selectPrivatePeers"));
|
||||
setError("Search and select the owner user and peer user first");
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams({
|
||||
|
|
@ -67,46 +65,46 @@ export function MessagesPage({ navigate }: { navigate: Navigate }) {
|
|||
}
|
||||
|
||||
return (
|
||||
<PageFrame title={t("messages.privateTitle")} eyebrow={t("messages.privateEyebrow")}>
|
||||
<PageFrame title={"Private Messages"} eyebrow={"Private message boxes"}>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<QueryPanel>
|
||||
<div className="message-selector-grid">
|
||||
<UserPicker label={t("messages.ownerUser")} value={owner} onChange={changeOwner} />
|
||||
<UserPicker label={t("messages.peerUser")} value={peer} onChange={changePeer} />
|
||||
<UserPicker label={"Owner user"} value={owner} onChange={changeOwner} />
|
||||
<UserPicker label={"Peer user"} value={peer} onChange={changePeer} />
|
||||
</div>
|
||||
<form className="toolbar message-query" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
|
||||
<input value={beforeDate} onChange={(event) => setBeforeDate(event.target.value)} placeholder={t("messages.beforeDatePlaceholder")} />
|
||||
<input value={beforeID} onChange={(event) => setBeforeID(event.target.value)} placeholder={t("messages.beforeIDPlaceholder")} />
|
||||
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} placeholder={t("messages.limitPlaceholder")} />
|
||||
<button className="btn primary icon-text" type="submit"><Search size={15} /> {t("messages.searchMessages")}</button>
|
||||
{data?.rows.length ? <button className="btn icon-text" type="button" onClick={() => load(true)}><ChevronRight size={15} /> {t("messages.nextPage")}</button> : null}
|
||||
<input value={beforeDate} onChange={(event) => setBeforeDate(event.target.value)} placeholder={"before_date cursor"} />
|
||||
<input value={beforeID} onChange={(event) => setBeforeID(event.target.value)} placeholder={"before_msg_id cursor"} />
|
||||
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} placeholder={"limit <= 100"} />
|
||||
<button className="btn primary icon-text" type="submit"><Search size={15} /> {"Search messages"}</button>
|
||||
{data?.rows.length ? <button className="btn icon-text" type="button" onClick={() => load(true)}><ChevronRight size={15} /> {"Next page"}</button> : null}
|
||||
</form>
|
||||
</QueryPanel>
|
||||
<div className="metric-row">
|
||||
<Metric label={t("messages.currentPage")} value={String(data?.rows.length ?? 0)} />
|
||||
<Metric label={t("messages.deleted")} value={String((data?.rows ?? []).filter((row) => row.Deleted).length)} tone="danger" />
|
||||
<Metric label={t("messages.outgoing")} value={String((data?.rows ?? []).filter((row) => row.Outgoing).length)} />
|
||||
<Metric label={t("messages.ownerPeer")} value={owner && peer ? `${displayName(owner)} / ${displayName(peer)}` : "-"} />
|
||||
<Metric label={"Messages on page"} value={String(data?.rows.length ?? 0)} />
|
||||
<Metric label={"Deleted"} value={String((data?.rows ?? []).filter((row) => row.Deleted).length)} tone="danger" />
|
||||
<Metric label={"Outgoing"} value={String((data?.rows ?? []).filter((row) => row.Outgoing).length)} />
|
||||
<Metric label={"Owner / Peer"} value={owner && peer ? `${displayName(owner)} / ${displayName(peer)}` : "-"} />
|
||||
</div>
|
||||
<div className="operation-row">
|
||||
<div className="operation-box">
|
||||
<div className="operation-title"><Trash2 size={15} /> {t("messages.deleteSelected")}</div>
|
||||
<input value={ids} onChange={(event) => setIDs(event.target.value)} placeholder={t("messages.idsPlaceholder")} />
|
||||
<label className="checkline"><input type="checkbox" checked={revoke} onChange={(event) => setRevoke(event.target.checked)} /> {t("messages.revoke")}</label>
|
||||
<ActionButton path="/api/actions/delete-messages" label={t("messages.previewDelete")} payload={() => ({
|
||||
<div className="operation-title"><Trash2 size={15} /> {"Delete selected messages"}</div>
|
||||
<input value={ids} onChange={(event) => setIDs(event.target.value)} placeholder={"Message IDs, comma separated"} />
|
||||
<label className="checkline"><input type="checkbox" checked={revoke} onChange={(event) => setRevoke(event.target.checked)} /> {"Revoke for both sides"}</label>
|
||||
<ActionButton path="/api/actions/delete-messages" label={"Dry-run delete"} payload={() => ({
|
||||
owner_user_id: owner?.ID ?? 0,
|
||||
peer_id: peer?.ID ?? 0,
|
||||
ids: parseIDs(ids, t("messages.msgIDsInvalid")),
|
||||
ids: parseIDs(ids, "Message IDs are invalid"),
|
||||
revoke
|
||||
})} />
|
||||
</div>
|
||||
<div className="operation-box">
|
||||
<div className="operation-title"><History size={15} /> {t("messages.clearHistory")}</div>
|
||||
<input value={maxID} onChange={(event) => setMaxID(event.target.value)} placeholder={t("messages.maxIDPlaceholder")} />
|
||||
<input value={maxBatches} onChange={(event) => setMaxBatches(event.target.value)} placeholder={t("messages.maxBatchesPlaceholder")} />
|
||||
<label className="checkline"><input type="checkbox" checked={revoke} onChange={(event) => setRevoke(event.target.checked)} /> {t("messages.revoke")}</label>
|
||||
<label className="checkline"><input type="checkbox" checked={justClear} onChange={(event) => setJustClear(event.target.checked)} /> {t("messages.justClear")}</label>
|
||||
<ActionButton path="/api/actions/delete-history" label={t("messages.previewClearHistory")} payload={() => ({
|
||||
<div className="operation-title"><History size={15} /> {"Clear private history"}</div>
|
||||
<input value={maxID} onChange={(event) => setMaxID(event.target.value)} placeholder={"max_id cutoff"} />
|
||||
<input value={maxBatches} onChange={(event) => setMaxBatches(event.target.value)} placeholder={"max_batches"} />
|
||||
<label className="checkline"><input type="checkbox" checked={revoke} onChange={(event) => setRevoke(event.target.checked)} /> {"Revoke for both sides"}</label>
|
||||
<label className="checkline"><input type="checkbox" checked={justClear} onChange={(event) => setJustClear(event.target.checked)} /> {"Clear only this side"}</label>
|
||||
<ActionButton path="/api/actions/delete-history" label={"Dry-run clear history"} payload={() => ({
|
||||
owner_user_id: owner?.ID ?? 0,
|
||||
peer_id: peer?.ID ?? 0,
|
||||
max_id: toInt(maxID),
|
||||
|
|
@ -120,13 +118,13 @@ export function MessagesPage({ navigate }: { navigate: Navigate }) {
|
|||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("common.messageId")}</th>
|
||||
<th>{t("common.time")}</th>
|
||||
<th>{t("common.sender")}</th>
|
||||
<th>{t("messages.direction")}</th>
|
||||
<th>{"Message ID"}</th>
|
||||
<th>{"Time"}</th>
|
||||
<th>{"Sender"}</th>
|
||||
<th>{"Direction"}</th>
|
||||
<th>PTS</th>
|
||||
<th>{t("common.status")}</th>
|
||||
<th>{t("messages.body")}</th>
|
||||
<th>{"Status"}</th>
|
||||
<th>{"Body"}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -136,16 +134,16 @@ export function MessagesPage({ navigate }: { navigate: Navigate }) {
|
|||
<td className="mono">{row.BoxID}</td>
|
||||
<td>{formatUnix(row.Date)}</td>
|
||||
<td className="mono">{row.FromUserID}</td>
|
||||
<td>{row.Outgoing ? t("messages.outgoing") : t("messages.incoming")}</td>
|
||||
<td>{row.Outgoing ? "Outgoing" : "Incoming"}</td>
|
||||
<td>{row.PTS}</td>
|
||||
<td>{row.Deleted ? <Badge tone="danger">{t("common.deleted")}</Badge> : <Badge>{t("common.survived")}</Badge>}</td>
|
||||
<td>{row.Deleted ? <Badge tone="danger">{"Deleted"}</Badge> : <Badge>{"Live"}</Badge>}</td>
|
||||
<td className="truncate">{row.Body}</td>
|
||||
<td>
|
||||
<button
|
||||
className="row-link"
|
||||
onClick={() => navigate(`/messages/private/detail?owner_user_id=${row.OwnerUserID}&msg_id=${row.BoxID}`)}
|
||||
>
|
||||
{t("common.detail")} <ChevronRight size={14} />
|
||||
{"Details"} <ChevronRight size={14} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
|||
402
cmd/telesrv-admin/web/src/pages/ModerationCaseDetailPage.tsx
Normal file
402
cmd/telesrv-admin/web/src/pages/ModerationCaseDetailPage.tsx
Normal file
|
|
@ -0,0 +1,402 @@
|
|||
import { ArrowLeft, CheckCircle2, RefreshCw, ShieldCheck } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { Alert, JsonBlock, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
|
||||
import { formatDate } from "../lib/format";
|
||||
import type { Navigate } from "../routing";
|
||||
import type { ModerationCaseDetail, ModerationReport } from "../types";
|
||||
import {
|
||||
CaseSeverity,
|
||||
CaseStatus,
|
||||
moderationEnumLabel,
|
||||
moderationTargetLabel
|
||||
} from "./ModerationCasesPage";
|
||||
|
||||
type DecisionPreset = "no_violation" | "scam" | "fake" | "freeze" | "scam_freeze" | "fake_freeze" | "delete_messages" | "delete_account";
|
||||
|
||||
export function ModerationCaseDetailPage({ id, navigate }: { id: number; navigate: Navigate }) {
|
||||
const [detail, setDetail] = useState<ModerationCaseDetail | null>(null);
|
||||
const [report, setReport] = useState<ModerationReport | null>(null);
|
||||
const [reason, setReason] = useState("");
|
||||
const [preset, setPreset] = useState<DecisionPreset>("no_violation");
|
||||
const [messageIDs, setMessageIDs] = useState("");
|
||||
const [ownerUserID, setOwnerUserID] = useState("");
|
||||
const [revokeMessages, setRevokeMessages] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
function selectReport(next: ModerationReport | null) {
|
||||
setReport(next);
|
||||
if (!next) return;
|
||||
const ids = next.Items
|
||||
.filter((item) => item.Kind === "message")
|
||||
.map((item) => Number(item.ItemID))
|
||||
.filter((value) => Number.isSafeInteger(value) && value > 0);
|
||||
setMessageIDs(ids.join(", "));
|
||||
setOwnerUserID(String(next.ReporterUserID));
|
||||
}
|
||||
|
||||
async function load() {
|
||||
setError("");
|
||||
try {
|
||||
const next = await api.moderationCase(id);
|
||||
setDetail(next);
|
||||
const reportID = next.ReportIDs[0];
|
||||
selectReport(reportID ? await api.moderationReport(reportID) : null);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [id]);
|
||||
|
||||
const selectedActions = useMemo(
|
||||
() => actionsForPreset(
|
||||
preset,
|
||||
detail?.Case.Target.Type,
|
||||
parseMessageIDs(messageIDs),
|
||||
Number(ownerUserID),
|
||||
revokeMessages
|
||||
),
|
||||
[preset, detail?.Case.Target.Type, messageIDs, ownerUserID, revokeMessages]
|
||||
);
|
||||
const appealRemedy = useMemo(
|
||||
() => detail ? requiredAppealRemedy(detail) : { actions: [], label: "None", blocked: false },
|
||||
[detail]
|
||||
);
|
||||
|
||||
async function claim() {
|
||||
if (!detail) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await api.claimModerationCase(id, detail.Case.Version);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function decide() {
|
||||
if (!detail || !reason.trim()) {
|
||||
setError("A review reason is required.");
|
||||
return;
|
||||
}
|
||||
if (preset === "delete_messages" && selectedActions.length === 0) {
|
||||
setError(detail.Case.Target.Type === "user"
|
||||
? "Private-message deletion requires valid evidence message IDs and the reporter's owner_user_id."
|
||||
: "Channel-message deletion requires at least one valid evidence message ID.");
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(`Submit the “${decisionPresetLabel(preset)}” decision? The action will run through the durable action queue.`)) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await api.decideModerationCase(id, {
|
||||
expected_version: detail.Case.Version,
|
||||
reason: reason.trim(),
|
||||
kind: preset === "no_violation" ? "no_violation" : "violation",
|
||||
actions: selectedActions
|
||||
});
|
||||
setDetail(result.case);
|
||||
setReason("");
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function reviewAppeal(appealID: number, granted: boolean) {
|
||||
if (!detail || !reason.trim()) {
|
||||
setError("An appeal review reason is required.");
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(granted ? "Grant this appeal?" : "Deny this appeal?")) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await api.reviewModerationAppeal(id, appealID, {
|
||||
expected_version: detail.Case.Version,
|
||||
reason: reason.trim(),
|
||||
granted,
|
||||
actions: granted ? appealRemedy.actions : []
|
||||
});
|
||||
setDetail(result.case);
|
||||
setReason("");
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (error && !detail) return <Alert>{error}</Alert>;
|
||||
if (!detail) return <LoadingSurface label={"Loading moderation case…"} />;
|
||||
const item = detail.Case;
|
||||
const canClaim = item.Status === "open" || item.Status === "in_review" || item.Status === "appeal_review";
|
||||
const canDecide = (item.Status === "in_review" || item.Status === "action_failed") && Boolean(item.AssignedTo);
|
||||
const canSubmitDecision = canDecide && (item.Status !== "action_failed" || preset !== "no_violation");
|
||||
const pendingAppeal = detail.Appeals.find((appeal) => appeal.Status === "pending");
|
||||
|
||||
return (
|
||||
<PageFrame
|
||||
title={`Review case #${item.ID}`}
|
||||
eyebrow={"Moderation / Case detail"}
|
||||
actions={
|
||||
<>
|
||||
<button className="btn icon-text" onClick={() => navigate("/moderation")}>
|
||||
<ArrowLeft size={15} /> {"Back to queue"}
|
||||
</button>
|
||||
<button className="btn icon-text" onClick={load}>
|
||||
<RefreshCw size={15} /> {"Refresh"}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<SplitLayout
|
||||
main={
|
||||
<div className="stacked-sections">
|
||||
<section className="entity-head">
|
||||
<div>
|
||||
<div className="entity-title">{moderationTargetLabel(item.Target.Type, item.Target.ID)}</div>
|
||||
<div className="entity-subtitle">
|
||||
{`Version ${item.Version} · Updated ${formatDate(item.UpdatedAt)}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="entity-badges">
|
||||
<CaseStatus status={item.Status} />
|
||||
<CaseSeverity value={item.Severity} />
|
||||
</div>
|
||||
</section>
|
||||
<div className="summary-grid">
|
||||
<Summary label={"Target"} value={moderationTargetLabel(item.Target.Type, item.Target.ID)} mono />
|
||||
<Summary
|
||||
label={"Reports"}
|
||||
value={`${item.ReportCount} reports from ${item.DistinctReporterCount} reporters`}
|
||||
/>
|
||||
<Summary label={"Reviewer"} value={item.AssignedTo || "-"} />
|
||||
<Summary
|
||||
label={"First / latest report"}
|
||||
value={`${formatDate(item.FirstReportAt)} / ${formatDate(item.LastReportAt)}`}
|
||||
/>
|
||||
</div>
|
||||
<section className="section-block">
|
||||
<SectionHead title={"Report evidence"} text={"Shows up to the latest 100 reports; snapshots are frozen when reports are admitted."} />
|
||||
<div className="toolbar">
|
||||
{detail.ReportIDs.map((reportID) => (
|
||||
<button className="btn" key={reportID} onClick={async () => selectReport(await api.moderationReport(reportID))}>
|
||||
#{reportID}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{report && (
|
||||
<>
|
||||
<div className="summary-grid">
|
||||
<Summary
|
||||
label={"Source / Reason"}
|
||||
value={`${moderationEnumLabel("source", report.Source)} / ${moderationEnumLabel("reason", report.Reason)}`}
|
||||
/>
|
||||
<Summary label={"Reporter"} value={String(report.ReporterUserID)} mono />
|
||||
<Summary label={"Option"} value={report.Option} mono />
|
||||
<Summary label={"Time"} value={formatDate(report.CreatedAt)} />
|
||||
</div>
|
||||
{report.Comment && <p className="about-text">{report.Comment}</p>}
|
||||
<JsonBlock value={JSON.stringify(report, null, 2)} />
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
<section className="section-block">
|
||||
<SectionHead title={"Decision and action audit"} text={"Actions run idempotently through a lease worker; failures retain their error and attempt count."} />
|
||||
<JsonBlock value={JSON.stringify({ decisions: detail.Decisions, actions: detail.Actions }, null, 2)} />
|
||||
</section>
|
||||
{detail.Appeals.length > 0 && (
|
||||
<section className="section-block">
|
||||
<SectionHead title={"Appeals"} />
|
||||
<JsonBlock value={JSON.stringify(detail.Appeals, null, 2)} />
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
side={
|
||||
<section className="action-dock">
|
||||
<div className="dock-title">{"Case actions"}</div>
|
||||
{canClaim && (
|
||||
<button className="btn primary icon-text" disabled={busy} onClick={claim}>
|
||||
<ShieldCheck size={15} /> {item.AssignedTo ? "Renew claim" : "Claim case"}
|
||||
</button>
|
||||
)}
|
||||
<label className="field">
|
||||
<span>{"Review reason"}</span>
|
||||
<textarea value={reason} onChange={(event) => setReason(event.target.value)} rows={5} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{"Decision template"}</span>
|
||||
<select value={preset} onChange={(event) => setPreset(event.target.value as DecisionPreset)}>
|
||||
<option value="no_violation">{"No violation (dismiss report)"}</option>
|
||||
<option value="scam">{"Mark as SCAM"}</option>
|
||||
<option value="fake">{"Mark as FAKE"}</option>
|
||||
<option value="freeze">{"Freeze account"}</option>
|
||||
<option value="scam_freeze">{"SCAM + freeze"}</option>
|
||||
<option value="fake_freeze">{"FAKE + freeze"}</option>
|
||||
<option value="delete_messages">{"Delete messages covered by evidence"}</option>
|
||||
<option value="delete_account">{"Delete account"}</option>
|
||||
</select>
|
||||
</label>
|
||||
{preset === "delete_messages" && (
|
||||
<>
|
||||
<label className="field">
|
||||
<span>{"Evidence message IDs (comma-separated)"}</span>
|
||||
<input value={messageIDs} onChange={(event) => setMessageIDs(event.target.value)} placeholder="101, 102" />
|
||||
</label>
|
||||
{item.Target.Type === "user" && (
|
||||
<>
|
||||
<label className="field">
|
||||
<span>{"Private-chat owner_user_id"}</span>
|
||||
<input value={ownerUserID} onChange={(event) => setOwnerUserID(event.target.value)} inputMode="numeric" />
|
||||
</label>
|
||||
<label className="field checkbox-field">
|
||||
<input type="checkbox" checked={revokeMessages} onChange={(event) => setRevokeMessages(event.target.checked)} />
|
||||
<span>{"Revoke for both sides"}</span>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
<Alert>{"The server will verify again that every message ID exists in this case's immutable report evidence."}</Alert>
|
||||
</>
|
||||
)}
|
||||
{item.Status === "action_failed" && preset === "no_violation" && (
|
||||
<Alert>{"The action was partially executed and cannot be changed directly to no violation. Select a new action to retry while retaining the previous failure audit."}</Alert>
|
||||
)}
|
||||
{canDecide && (
|
||||
<button className="btn danger icon-text" disabled={busy || !canSubmitDecision} onClick={decide}>
|
||||
<CheckCircle2 size={15} /> {item.Status === "action_failed"
|
||||
? "Retry action"
|
||||
: "Submit decision"}
|
||||
</button>
|
||||
)}
|
||||
{pendingAppeal && item.AssignedTo && (
|
||||
<>
|
||||
<div className="dock-title">{`Appeal review #${pendingAppeal.ID}`}</div>
|
||||
<Summary label={"Automatic remedy after approval"} value={appealRemedy.label} />
|
||||
{appealRemedy.blocked && (
|
||||
<Alert>{"The case contains a completed irreversible deletion. It cannot be marked as approved and restored; deny it or escalate for manual handling."}</Alert>
|
||||
)}
|
||||
<button className="btn" disabled={busy} onClick={() => reviewAppeal(pendingAppeal.ID, false)}>
|
||||
{"Deny appeal"}
|
||||
</button>
|
||||
<button className="btn primary" disabled={busy || appealRemedy.blocked} onClick={() => reviewAppeal(pendingAppeal.ID, true)}>
|
||||
{"Grant appeal"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
}
|
||||
/>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function actionsForPreset(
|
||||
preset: DecisionPreset,
|
||||
targetType: string | undefined,
|
||||
messageIDs: number[],
|
||||
ownerUserID: number,
|
||||
revoke: boolean
|
||||
): Array<{ kind: string; payload: Record<string, unknown> }> {
|
||||
switch (preset) {
|
||||
case "scam":
|
||||
return [{ kind: "mark_scam", payload: {} }];
|
||||
case "fake":
|
||||
return [{ kind: "mark_fake", payload: {} }];
|
||||
case "freeze":
|
||||
return [{ kind: "freeze_account", payload: {} }];
|
||||
case "scam_freeze":
|
||||
return [{ kind: "mark_scam", payload: {} }, { kind: "freeze_account", payload: {} }];
|
||||
case "fake_freeze":
|
||||
return [{ kind: "mark_fake", payload: {} }, { kind: "freeze_account", payload: {} }];
|
||||
case "delete_messages":
|
||||
if (messageIDs.length === 0) return [];
|
||||
if (targetType === "channel") {
|
||||
return [{ kind: "delete_channel_message", payload: { ids: messageIDs } }];
|
||||
}
|
||||
if (targetType === "user" && Number.isSafeInteger(ownerUserID) && ownerUserID > 0) {
|
||||
return [{ kind: "delete_private_message", payload: { owner_user_id: ownerUserID, ids: messageIDs, revoke } }];
|
||||
}
|
||||
return [];
|
||||
case "delete_account":
|
||||
return [{ kind: "delete_account", payload: {} }];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function parseMessageIDs(raw: string): number[] {
|
||||
const values = raw
|
||||
.split(/[,\s]+/)
|
||||
.filter(Boolean)
|
||||
.map(Number);
|
||||
if (values.length === 0 || values.some((value) => !Number.isSafeInteger(value) || value <= 0)) return [];
|
||||
return [...new Set(values)];
|
||||
}
|
||||
|
||||
function requiredAppealRemedy(detail: ModerationCaseDetail): {
|
||||
actions: Array<{ kind: string; payload: Record<string, unknown> }>;
|
||||
label: string;
|
||||
blocked: boolean;
|
||||
} {
|
||||
let flagsActive = false;
|
||||
let freezeActive = false;
|
||||
let irreversible = false;
|
||||
for (const action of [...detail.Actions].sort((left, right) => left.ID - right.ID)) {
|
||||
if (action.Status !== "succeeded") continue;
|
||||
switch (action.Kind) {
|
||||
case "mark_scam":
|
||||
case "mark_fake":
|
||||
flagsActive = true;
|
||||
break;
|
||||
case "clear_peer_flags":
|
||||
flagsActive = false;
|
||||
break;
|
||||
case "freeze_account":
|
||||
freezeActive = true;
|
||||
break;
|
||||
case "unfreeze_account":
|
||||
freezeActive = false;
|
||||
break;
|
||||
case "delete_private_message":
|
||||
case "delete_channel_message":
|
||||
case "delete_account":
|
||||
irreversible = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const actions: Array<{ kind: string; payload: Record<string, unknown> }> = [];
|
||||
const labels: string[] = [];
|
||||
if (flagsActive) {
|
||||
actions.push({ kind: "clear_peer_flags", payload: {} });
|
||||
labels.push("Clear SCAM / FAKE");
|
||||
}
|
||||
if (freezeActive) {
|
||||
actions.push({ kind: "unfreeze_account", payload: {} });
|
||||
labels.push("Unfreeze account");
|
||||
}
|
||||
return { actions, label: labels.join(" + ") || "No recovery action needed", blocked: irreversible };
|
||||
}
|
||||
|
||||
function decisionPresetLabel(preset: DecisionPreset): string {
|
||||
const labels: Record<DecisionPreset, string> = {
|
||||
no_violation: "No violation (dismiss report)",
|
||||
scam: "Mark as SCAM",
|
||||
fake: "Mark as FAKE",
|
||||
freeze: "Freeze account",
|
||||
scam_freeze: "SCAM + freeze",
|
||||
fake_freeze: "FAKE + freeze",
|
||||
delete_messages: "Delete messages covered by evidence",
|
||||
delete_account: "Delete account"
|
||||
};
|
||||
return labels[preset];
|
||||
}
|
||||
209
cmd/telesrv-admin/web/src/pages/ModerationCasesPage.tsx
Normal file
209
cmd/telesrv-admin/web/src/pages/ModerationCasesPage.tsx
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
import { ChevronRight, RefreshCw, ShieldAlert } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
|
||||
import { formatDate } from "../lib/format";
|
||||
import type { Navigate } from "../routing";
|
||||
import type { ModerationCaseRow } from "../types";
|
||||
|
||||
const defaultStatuses = "open,in_review,action_pending,action_failed,appeal_review";
|
||||
const allStatuses = "open,in_review,action_pending,action_failed,resolved,dismissed,appeal_review";
|
||||
const statusFilterOptions = [
|
||||
{ value: defaultStatuses, label: "Active queue" },
|
||||
{ value: allStatuses, label: "All statuses" },
|
||||
{ value: "open", label: "Open" },
|
||||
{ value: "in_review", label: "In review" },
|
||||
{ value: "action_pending", label: "Action pending" },
|
||||
{ value: "action_failed", label: "Action failed" },
|
||||
{ value: "appeal_review", label: "Appeal review" },
|
||||
{ value: "resolved", label: "Resolved" },
|
||||
{ value: "dismissed", label: "Dismissed" }
|
||||
];
|
||||
|
||||
export function ModerationCasesPage({ navigate }: { navigate: Navigate }) {
|
||||
const [statuses, setStatuses] = useState(defaultStatuses);
|
||||
const [assignedTo, setAssignedTo] = useState("");
|
||||
const [rows, setRows] = useState<ModerationCaseRow[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function load() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const params = new URLSearchParams({ statuses, limit: "100" });
|
||||
if (assignedTo.trim()) params.set("assigned_to", assignedTo.trim());
|
||||
setRows((await api.moderationCases(params)).cases);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
const pendingActions = rows.filter((row) => row.Status === "action_pending" || row.Status === "action_failed").length;
|
||||
const critical = rows.filter((row) => row.Severity === 4).length;
|
||||
|
||||
return (
|
||||
<PageFrame
|
||||
title={"Reports and Moderation"}
|
||||
eyebrow={"Moderation / Cases"}
|
||||
actions={
|
||||
<button className="btn icon-text" type="button" onClick={load} disabled={busy}>
|
||||
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"Refresh"}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="metric-row">
|
||||
<Metric label={"Current queue"} value={String(rows.length)} />
|
||||
<Metric label={"Critical cases"} value={String(critical)} tone={critical ? "danger" : "neutral"} />
|
||||
<Metric label={"Pending / failed actions"} value={String(pendingActions)} tone={pendingActions ? "warn" : "good"} />
|
||||
</div>
|
||||
<QueryPanel>
|
||||
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(); }}>
|
||||
<label className="field-inline">
|
||||
<span>{"Status"}</span>
|
||||
<select
|
||||
aria-label={"Case status filter"}
|
||||
value={statuses}
|
||||
onChange={(event) => setStatuses(event.target.value)}
|
||||
>
|
||||
{statusFilterOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field-inline">
|
||||
<span>{"Reviewer"}</span>
|
||||
<input
|
||||
value={assignedTo}
|
||||
onChange={(event) => setAssignedTo(event.target.value)}
|
||||
placeholder={"Leave blank for all"}
|
||||
/>
|
||||
</label>
|
||||
<button className="btn primary icon-text" type="submit" disabled={busy}>
|
||||
<ShieldAlert size={15} /> {"Search"}
|
||||
</button>
|
||||
</form>
|
||||
</QueryPanel>
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{"Case"}</th>
|
||||
<th>{"Target"}</th>
|
||||
<th>{"Status"}</th>
|
||||
<th>{"Severity"}</th>
|
||||
<th>{"Reports / Reporters"}</th>
|
||||
<th>{"Reviewer"}</th>
|
||||
<th>{"Latest report"}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.ID}>
|
||||
<td className="mono">#{row.ID}</td>
|
||||
<td className="mono">{moderationTargetLabel(row.Target.Type, row.Target.ID)}</td>
|
||||
<td><CaseStatus status={row.Status} /></td>
|
||||
<td><CaseSeverity value={row.Severity} /></td>
|
||||
<td>{row.ReportCount} / {row.DistinctReporterCount}</td>
|
||||
<td>{row.AssignedTo || "-"}</td>
|
||||
<td>{formatDate(row.LastReportAt)}</td>
|
||||
<td>
|
||||
<button className="row-link" onClick={() => navigate(`/moderation/${row.ID}`)}>
|
||||
{"Review"} <ChevronRight size={14} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows.length === 0 && <EmptyRow colSpan={8} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export function CaseStatus({ status }: { status: string }) {
|
||||
const tone = status === "resolved" || status === "dismissed"
|
||||
? "good"
|
||||
: status === "action_failed"
|
||||
? "danger"
|
||||
: status === "action_pending"
|
||||
? "warn"
|
||||
: "neutral";
|
||||
return <Badge tone={tone}>{moderationEnumLabel("status", status)}</Badge>;
|
||||
}
|
||||
|
||||
const severityLabels: Record<string, string> = {
|
||||
low: "Low",
|
||||
medium: "Medium",
|
||||
high: "High",
|
||||
critical: "Critical"
|
||||
};
|
||||
|
||||
export function CaseSeverity({ value }: { value: number }) {
|
||||
const keys = ["", "low", "medium", "high", "critical"];
|
||||
const key = keys[value];
|
||||
return (
|
||||
<Badge tone={value >= 4 ? "danger" : value >= 3 ? "warn" : "neutral"}>
|
||||
{key ? severityLabels[key] : value}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
const moderationLabels: Record<string, Record<string, string>> = {
|
||||
status: {
|
||||
open: "Open",
|
||||
in_review: "In review",
|
||||
action_pending: "Action pending",
|
||||
action_failed: "Action failed",
|
||||
appeal_review: "Appeal review",
|
||||
resolved: "Resolved",
|
||||
dismissed: "Dismissed"
|
||||
},
|
||||
targetType: {
|
||||
channel: "Channel",
|
||||
chat: "Group",
|
||||
user: "Account"
|
||||
},
|
||||
source: {
|
||||
account_peer: "Account / peer",
|
||||
antispam_false_positive: "Anti-spam false positive",
|
||||
channel_spam: "Channel spam",
|
||||
encrypted_spam: "Encrypted-chat spam",
|
||||
ephemeral: "Ephemeral media",
|
||||
messages: "Messages",
|
||||
messages_spam: "Message spam",
|
||||
profile_photo: "Profile photo",
|
||||
reaction: "Reaction",
|
||||
sponsored: "Sponsored message",
|
||||
story: "Story"
|
||||
},
|
||||
reason: {
|
||||
child_abuse: "Child abuse",
|
||||
copyright: "Copyright",
|
||||
fake: "Fake",
|
||||
geo_irrelevant: "Location-irrelevant",
|
||||
illegal_drugs: "Illegal drugs",
|
||||
other: "Other",
|
||||
personal_details: "Personal details",
|
||||
pornography: "Pornography",
|
||||
spam: "Spam",
|
||||
violence: "Violence"
|
||||
}
|
||||
};
|
||||
|
||||
export function moderationEnumLabel(group: string, value: string): string {
|
||||
return moderationLabels[group]?.[value] ?? value;
|
||||
}
|
||||
|
||||
export function moderationTargetLabel(type: string, id: number): string {
|
||||
return `${moderationEnumLabel("targetType", type)} #${id}`;
|
||||
}
|
||||
|
|
@ -1,6 +1,10 @@
|
|||
import { type Navigate, type RouteState } from "../routing";
|
||||
import { AccountDetailPage } from "./AccountDetailPage";
|
||||
import { AccountRatingDetailPage } from "./AccountRatingDetailPage";
|
||||
import { AccountRatingsPage } from "./AccountRatingsPage";
|
||||
import { AccountsPage } from "./AccountsPage";
|
||||
import { CollectibleUsernameDetailPage } from "./CollectibleUsernameDetailPage";
|
||||
import { CollectibleUsernamesPage } from "./CollectibleUsernamesPage";
|
||||
import { ChannelDetailPage } from "./ChannelDetailPage";
|
||||
import { ChannelsPage } from "./ChannelsPage";
|
||||
import { BotDetailPage } from "./BotDetailPage";
|
||||
|
|
@ -13,11 +17,73 @@ import { MessagesPage } from "./MessagesPage";
|
|||
import { GiftsPage } from "./GiftsPage";
|
||||
import { StickerSetsPage } from "./StickerSetsPage";
|
||||
import { GiveGiftsPage } from "./GiveGiftsPage";
|
||||
import { ModerationCaseDetailPage } from "./ModerationCaseDetailPage";
|
||||
import { ModerationCasesPage } from "./ModerationCasesPage";
|
||||
import { BotVerificationPage } from "./BotVerificationPage";
|
||||
import { BotVerificationRequestPage } from "./BotVerificationRequestPage";
|
||||
import { VerificationDetailPage } from "./VerificationDetailPage";
|
||||
import { VerificationPage } from "./VerificationPage";
|
||||
import {
|
||||
PermissionGate,
|
||||
permissionBotVerificationReview,
|
||||
permissionVerificationReview
|
||||
} from "../permissions";
|
||||
|
||||
export function Routes({ route, navigate }: { route: RouteState; navigate: Navigate }) {
|
||||
const accountID = route.path.match(/^\/accounts\/(\d+)$/)?.[1];
|
||||
const channelID = route.path.match(/^\/channels\/(\d+)$/)?.[1];
|
||||
const botID = route.path.match(/^\/bots\/(\d+)$/)?.[1];
|
||||
const moderationCaseID = route.path.match(/^\/moderation\/(\d+)$/)?.[1];
|
||||
// int64 ids stay strings so large values never lose precision.
|
||||
const collectibleUsernameID = route.path.match(/^\/collectible-usernames\/(\d+)$/)?.[1];
|
||||
const ratingUserID = route.path.match(/^\/account-ratings\/(\d+)$/)?.[1];
|
||||
const verificationID = route.path.match(/^\/verification\/(\d+)$/)?.[1];
|
||||
// Third-party verification: a separate section with its own rights, matched before
|
||||
// the official one so neither prefix can shadow the other.
|
||||
const botVerificationRequestID = route.path.match(/^\/bot-verification\/(\d+)$/)?.[1];
|
||||
if (botVerificationRequestID) {
|
||||
return (
|
||||
<PermissionGate permission={permissionBotVerificationReview}>
|
||||
<BotVerificationRequestPage id={botVerificationRequestID} navigate={navigate} />
|
||||
</PermissionGate>
|
||||
);
|
||||
}
|
||||
if (route.path === "/bot-verification") {
|
||||
return (
|
||||
<PermissionGate permission={permissionBotVerificationReview}>
|
||||
<BotVerificationPage navigate={navigate} />
|
||||
</PermissionGate>
|
||||
);
|
||||
}
|
||||
// The detail match has to be tested before the exact "/verification" branch, and
|
||||
// the whole section is wrapped in the permission gate so a direct URL explains
|
||||
// itself instead of rendering an empty queue.
|
||||
if (verificationID) {
|
||||
return (
|
||||
<PermissionGate permission={permissionVerificationReview}>
|
||||
<VerificationDetailPage id={verificationID} navigate={navigate} />
|
||||
</PermissionGate>
|
||||
);
|
||||
}
|
||||
if (route.path === "/verification") {
|
||||
return (
|
||||
<PermissionGate permission={permissionVerificationReview}>
|
||||
<VerificationPage navigate={navigate} />
|
||||
</PermissionGate>
|
||||
);
|
||||
}
|
||||
if (collectibleUsernameID) {
|
||||
return <CollectibleUsernameDetailPage id={collectibleUsernameID} navigate={navigate} />;
|
||||
}
|
||||
if (ratingUserID) {
|
||||
return <AccountRatingDetailPage userID={ratingUserID} navigate={navigate} />;
|
||||
}
|
||||
if (route.path === "/collectible-usernames") {
|
||||
return <CollectibleUsernamesPage navigate={navigate} />;
|
||||
}
|
||||
if (route.path === "/account-ratings") {
|
||||
return <AccountRatingsPage navigate={navigate} />;
|
||||
}
|
||||
if (accountID) {
|
||||
return <AccountDetailPage id={Number(accountID)} navigate={navigate} />;
|
||||
}
|
||||
|
|
@ -27,6 +93,9 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
|
|||
if (botID) {
|
||||
return <BotDetailPage id={Number(botID)} navigate={navigate} />;
|
||||
}
|
||||
if (moderationCaseID) {
|
||||
return <ModerationCaseDetailPage id={Number(moderationCaseID)} navigate={navigate} />;
|
||||
}
|
||||
if (route.path === "/accounts") {
|
||||
return <AccountsPage navigate={navigate} />;
|
||||
}
|
||||
|
|
@ -36,6 +105,9 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
|
|||
if (route.path === "/bots") {
|
||||
return <BotsPage navigate={navigate} />;
|
||||
}
|
||||
if (route.path === "/moderation") {
|
||||
return <ModerationCasesPage navigate={navigate} />;
|
||||
}
|
||||
if (route.path === "/emoji") {
|
||||
return <StickerSetsPage kind="emoji" />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { api, errorMessage } from "../api";
|
|||
import { ActionButton } from "../components/ActionButton";
|
||||
import { StickerDocumentPreview } from "../components/StickerDocumentPreview";
|
||||
import { Alert } from "../components/ui";
|
||||
import { useI18n } from "../i18n";
|
||||
import type { StickerSetRow } from "../types";
|
||||
|
||||
// Cells per modal page. Each page fully replaces the previous one (rather
|
||||
|
|
@ -15,7 +14,6 @@ import type { StickerSetRow } from "../types";
|
|||
const PAGE_SIZE = 24;
|
||||
|
||||
export function StickerSetPreviewModal({ set, onClose }: { set: StickerSetRow; onClose: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const noun = set.Kind === "emoji" ? "emoji" : "sticker";
|
||||
const [documentIDs, setDocumentIDs] = useState<string[] | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
|
@ -52,19 +50,19 @@ export function StickerSetPreviewModal({ set, onClose }: { set: StickerSetRow; o
|
|||
<section className="modal command-modal sticker-preview-modal" role="dialog" aria-modal="true" aria-label={set.Title || `#${set.ID}`}>
|
||||
<div className="modal-head">
|
||||
<div>
|
||||
<div className="eyebrow">{t("stickers.previewEyebrow")}</div>
|
||||
<div className="eyebrow">{"Set contents"}</div>
|
||||
<h2>{set.Title || `#${set.ID}`}</h2>
|
||||
</div>
|
||||
<button className="icon-btn" type="button" onClick={onClose} aria-label={t("action.close")}><X size={15} /></button>
|
||||
<button className="icon-btn" type="button" onClick={onClose} aria-label={"Close"}><X size={15} /></button>
|
||||
</div>
|
||||
<div className="command-body">
|
||||
<AddStickerForm setID={set.ID} noun={noun} onAdded={load} />
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{!error && documentIDs === null && (
|
||||
<div className="loading-line"><Loader2 className="spin" size={18} /> {t("common.loading")}</div>
|
||||
<div className="loading-line"><Loader2 className="spin" size={18} /> {"Loading"}</div>
|
||||
)}
|
||||
{documentIDs !== null && total === 0 && !error && (
|
||||
<div className="empty-panel">{t("stickers.previewEmpty")}</div>
|
||||
<div className="empty-panel">{"This set has no documents."}</div>
|
||||
)}
|
||||
{pageItems.length > 0 && (
|
||||
<div className="sticker-doc-grid" key={currentPage}>
|
||||
|
|
@ -74,7 +72,7 @@ export function StickerSetPreviewModal({ set, onClose }: { set: StickerSetRow; o
|
|||
<ActionButton
|
||||
compact
|
||||
tone="danger"
|
||||
label={t("stickers.removeSticker", { noun })}
|
||||
label={"Remove"}
|
||||
icon={<Trash2 size={12} />}
|
||||
path="/api/actions/remove-sticker-from-set"
|
||||
payload={() => ({ set_id: set.ID, document_id: documentID })}
|
||||
|
|
@ -86,14 +84,14 @@ export function StickerSetPreviewModal({ set, onClose }: { set: StickerSetRow; o
|
|||
)}
|
||||
{total > PAGE_SIZE && (
|
||||
<div className="gift-pager">
|
||||
<span className="gift-pager-range">{t("gifts.pageRange", { start: rangeStart, end: rangeEnd, total })}</span>
|
||||
<span className="gift-pager-range">{`Showing ${rangeStart}-${rangeEnd} of ${total}`}</span>
|
||||
<div className="gift-pager-controls">
|
||||
<button className="btn compact-btn" type="button" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={currentPage <= 1}>
|
||||
<ChevronLeft size={14} /> {t("gifts.pagePrev")}
|
||||
<ChevronLeft size={14} /> {"Previous"}
|
||||
</button>
|
||||
<span className="gift-pager-page">{t("gifts.pageOf", { page: currentPage, total: totalPages })}</span>
|
||||
<span className="gift-pager-page">{`Page ${currentPage} of ${totalPages}`}</span>
|
||||
<button className="btn compact-btn" type="button" onClick={() => setPage((p) => Math.min(totalPages, p + 1))} disabled={currentPage >= totalPages}>
|
||||
{t("gifts.pageNext")} <ChevronRight size={14} />
|
||||
{"Next"} <ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -110,7 +108,6 @@ export function StickerSetPreviewModal({ set, onClose }: { set: StickerSetRow; o
|
|||
// step): unlike destructive actions, materializing one sticker document is
|
||||
// low-risk and reversible via the per-cell Remove button.
|
||||
function AddStickerForm({ setID, noun, onAdded }: { setID: string; noun: string; onAdded: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [emoji, setEmoji] = useState("");
|
||||
const [reason, setReason] = useState("");
|
||||
|
|
@ -119,15 +116,15 @@ function AddStickerForm({ setID, noun, onAdded }: { setID: string; noun: string;
|
|||
|
||||
async function submit() {
|
||||
if (!file) {
|
||||
setError(t("stickers.fileRequired", { noun }));
|
||||
setError(`Choose a ${noun} file first`);
|
||||
return;
|
||||
}
|
||||
if (!emoji.trim()) {
|
||||
setError(t("stickers.emojiRequired"));
|
||||
setError("An emoji is required.");
|
||||
return;
|
||||
}
|
||||
if (!reason.trim()) {
|
||||
setError(t("action.reasonRequired"));
|
||||
setError("Please enter an operation reason");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
|
|
@ -152,12 +149,12 @@ function AddStickerForm({ setID, noun, onAdded }: { setID: string; noun: string;
|
|||
<div className="sticker-add-form">
|
||||
<label className={`gift-file-picker compact ${file ? "has-file" : ""}`}>
|
||||
<input type="file" accept=".tgs,.json,.webp,application/json,application/x-tgsticker,image/webp" onChange={(event) => setFile(event.target.files?.[0] ?? null)} />
|
||||
<span className="gift-file-copy"><strong>{file ? file.name : t("stickers.filePrompt")}</strong></span>
|
||||
<span className="gift-file-copy"><strong>{file ? file.name : "Choose a TGS, Lottie JSON, or WebP file"}</strong></span>
|
||||
</label>
|
||||
<input className="small-input" value={emoji} onChange={(event) => setEmoji(event.target.value)} placeholder={t("stickers.emojiPlaceholder")} />
|
||||
<input className="small-input" value={reason} onChange={(event) => setReason(event.target.value)} placeholder={t("action.reasonPlaceholder")} />
|
||||
<input className="small-input" value={emoji} onChange={(event) => setEmoji(event.target.value)} placeholder={"e.g. 😀"} />
|
||||
<input className="small-input" value={reason} onChange={(event) => setReason(event.target.value)} placeholder={"Describe why this operation is being performed"} />
|
||||
<button className="btn primary compact-btn" type="button" onClick={submit} disabled={busy}>
|
||||
{busy ? <Loader2 className="spin" size={14} /> : <Plus size={14} />} {t("stickers.addSticker", { noun })}
|
||||
{busy ? <Loader2 className="spin" size={14} /> : <Plus size={14} />} {`Add ${noun}`}
|
||||
</button>
|
||||
{error && <span className="sticker-add-form-error">{error}</span>}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { api, errorMessage } from "../api";
|
|||
import { ActionButton } from "../components/ActionButton";
|
||||
import { StickerDocumentPreview } from "../components/StickerDocumentPreview";
|
||||
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
|
||||
import { useI18n } from "../i18n";
|
||||
import type { StickerSetRow } from "../types";
|
||||
import { CreateStickerSetModal } from "./CreateStickerSetModal";
|
||||
import { StickerSetPreviewModal } from "./StickerSetPreviewModal";
|
||||
|
|
@ -16,7 +15,6 @@ type StickerPageSize = 10 | 20 | 50 | 100 | "all";
|
|||
// filtered out server-side and never reach this page; they aren't meant to be
|
||||
// hand-edited.
|
||||
export function StickerSetsPage({ kind }: { kind: "stickers" | "emoji" }) {
|
||||
const { t } = useI18n();
|
||||
const [sets, setSets] = useState<StickerSetRow[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
|
@ -28,8 +26,10 @@ export function StickerSetsPage({ kind }: { kind: "stickers" | "emoji" }) {
|
|||
const [previewSet, setPreviewSet] = useState<StickerSetRow | null>(null);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
||||
const pageTitleKey = kind === "emoji" ? "stickers.emojiPageTitle" : "stickers.pageTitle";
|
||||
const eyebrowKey = kind === "emoji" ? "stickers.emojiEyebrow" : "stickers.eyebrow";
|
||||
const pageTitle = kind === "emoji" ? "Emoji" : "Stickers";
|
||||
const eyebrow = kind === "emoji"
|
||||
? "Custom-emoji packs — system packs aren't shown here, they're not hand-edited"
|
||||
: "Sticker packs — system packs (dice, animated emoji, gifts) aren't shown here, they're not hand-edited";
|
||||
const noun = kind === "emoji" ? "emoji" : "sticker";
|
||||
|
||||
async function load() {
|
||||
|
|
@ -76,33 +76,33 @@ export function StickerSetsPage({ kind }: { kind: "stickers" | "emoji" }) {
|
|||
|
||||
return (
|
||||
<PageFrame
|
||||
title={t(pageTitleKey)}
|
||||
eyebrow={t(eyebrowKey)}
|
||||
title={pageTitle}
|
||||
eyebrow={eyebrow}
|
||||
actions={
|
||||
<>
|
||||
<button className="btn" type="button" onClick={() => load()} disabled={busy}>
|
||||
<RefreshCw size={15} /> {t("common.refresh")}
|
||||
<RefreshCw size={15} /> {"Refresh"}
|
||||
</button>
|
||||
<button className="btn primary" type="button" onClick={() => setCreateOpen(true)}>
|
||||
<Plus size={15} /> {t("stickers.create", { noun })}
|
||||
<Plus size={15} /> {`Create ${noun} pack`}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="metric-row">
|
||||
<Metric label={t("stickers.total")} value={String(counts.total)} />
|
||||
<Metric label={t("stickers.official")} value={String(counts.official)} tone="good" />
|
||||
<Metric label={t("stickers.archived")} value={String(counts.archived)} tone={counts.archived > 0 ? "warn" : "neutral"} />
|
||||
<Metric label={"Total sets"} value={String(counts.total)} />
|
||||
<Metric label={"Official"} value={String(counts.official)} tone="good" />
|
||||
<Metric label={"Archived"} value={String(counts.archived)} tone={counts.archived > 0 ? "warn" : "neutral"} />
|
||||
</div>
|
||||
<QueryPanel>
|
||||
<div className="toolbar">
|
||||
<label className="searchbox">
|
||||
<Search size={15} />
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={t("stickers.searchPlaceholder")} />
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={"Search set ID, short name or title"} />
|
||||
</label>
|
||||
<label className="gift-page-size">
|
||||
<span>{t("gifts.perPage")}</span>
|
||||
<span>{"Per page"}</span>
|
||||
<select
|
||||
value={String(pageSize)}
|
||||
onChange={(event) => setPageSize(event.target.value === "all" ? "all" : (Number(event.target.value) as StickerPageSize))}
|
||||
|
|
@ -111,25 +111,25 @@ export function StickerSetsPage({ kind }: { kind: "stickers" | "emoji" }) {
|
|||
<option value="20">20</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
<option value="all">{t("gifts.perPageAll")}</option>
|
||||
<option value="all">{"All"}</option>
|
||||
</select>
|
||||
</label>
|
||||
<span className="gift-list-summary">{t("stickers.listSummary", { shown: visible.length, total: sets.length })}</span>
|
||||
<span className="gift-list-summary">{`Showing ${visible.length} of ${sets.length}`}</span>
|
||||
</div>
|
||||
</QueryPanel>
|
||||
<div className="table-wrap gift-table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("stickers.logo")}</th>
|
||||
<th>{t("stickers.id")}</th>
|
||||
<th>{t("stickers.shortName")}</th>
|
||||
<th>{t("stickers.title")}</th>
|
||||
<th>{t("stickers.count")}</th>
|
||||
<th>{t("stickers.official")}</th>
|
||||
<th>{t("common.status")}</th>
|
||||
<th>{t("stickers.sortOrder")}</th>
|
||||
<th>{t("common.actions")}</th>
|
||||
<th>{"Logo"}</th>
|
||||
<th>{"ID"}</th>
|
||||
<th>{"Short name"}</th>
|
||||
<th>{"Title"}</th>
|
||||
<th>{"Documents"}</th>
|
||||
<th>{"Official"}</th>
|
||||
<th>{"Status"}</th>
|
||||
<th>{"Sort order"}</th>
|
||||
<th>{"Actions"}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -143,7 +143,7 @@ export function StickerSetsPage({ kind }: { kind: "stickers" | "emoji" }) {
|
|||
)}
|
||||
</td>
|
||||
<td className="mono">{set.ID}</td>
|
||||
<td className="mono">{set.ShortName || <span className="muted-cell">{t("common.none")}</span>}</td>
|
||||
<td className="mono">{set.ShortName || <span className="muted-cell">{"None"}</span>}</td>
|
||||
<td>
|
||||
<div className="sort-order-editor">
|
||||
<input
|
||||
|
|
@ -154,7 +154,7 @@ export function StickerSetsPage({ kind }: { kind: "stickers" | "emoji" }) {
|
|||
<ActionButton
|
||||
compact
|
||||
tone="neutral"
|
||||
label={t("stickers.saveTitle")}
|
||||
label={"Save"}
|
||||
path="/api/actions/rename-sticker-set"
|
||||
payload={() => ({ set_id: set.ID, title: (titleDrafts[set.ID] ?? set.Title).trim() })}
|
||||
onDone={() => void load()}
|
||||
|
|
@ -162,8 +162,8 @@ export function StickerSetsPage({ kind }: { kind: "stickers" | "emoji" }) {
|
|||
</div>
|
||||
</td>
|
||||
<td>{set.Count}</td>
|
||||
<td>{set.Official ? <Badge tone="good">{t("common.yes")}</Badge> : <Badge>{t("common.no")}</Badge>}</td>
|
||||
<td>{set.Archived ? <Badge tone="danger">{t("stickers.archived")}</Badge> : <Badge tone="good">{t("common.enabled")}</Badge>}</td>
|
||||
<td>{set.Official ? <Badge tone="good">{"Yes"}</Badge> : <Badge>{"No"}</Badge>}</td>
|
||||
<td>{set.Archived ? <Badge tone="danger">{"Archived"}</Badge> : <Badge tone="good">{"Enabled"}</Badge>}</td>
|
||||
<td>
|
||||
<div className="sort-order-editor">
|
||||
<input
|
||||
|
|
@ -175,7 +175,7 @@ export function StickerSetsPage({ kind }: { kind: "stickers" | "emoji" }) {
|
|||
<ActionButton
|
||||
compact
|
||||
tone="neutral"
|
||||
label={t("stickers.saveOrder")}
|
||||
label={"Save"}
|
||||
path="/api/actions/set-sticker-set-sort-order"
|
||||
payload={() => ({ set_id: set.ID, sort_order: Number(orderDrafts[set.ID] ?? set.SortOrder) })}
|
||||
onDone={() => void load()}
|
||||
|
|
@ -185,12 +185,12 @@ export function StickerSetsPage({ kind }: { kind: "stickers" | "emoji" }) {
|
|||
<td>
|
||||
<div className="gift-table-actions">
|
||||
<button className="btn compact-btn" type="button" onClick={() => setPreviewSet(set)}>
|
||||
<Eye size={13} /> {t("stickers.view")}
|
||||
<Eye size={13} /> {"View"}
|
||||
</button>
|
||||
<ActionButton
|
||||
compact
|
||||
tone="neutral"
|
||||
label={set.Archived ? t("stickers.unarchive") : t("stickers.archive")}
|
||||
label={set.Archived ? "Unarchive" : "Archive"}
|
||||
path="/api/actions/set-sticker-set-archived"
|
||||
payload={() => ({ set_id: set.ID, archived: !set.Archived })}
|
||||
onDone={() => void load()}
|
||||
|
|
@ -198,7 +198,7 @@ export function StickerSetsPage({ kind }: { kind: "stickers" | "emoji" }) {
|
|||
<ActionButton
|
||||
compact
|
||||
tone="danger"
|
||||
label={t("stickers.delete")}
|
||||
label={"Delete"}
|
||||
path="/api/actions/delete-sticker-set"
|
||||
payload={() => ({ set_id: set.ID })}
|
||||
onDone={() => void load()}
|
||||
|
|
@ -213,14 +213,14 @@ export function StickerSetsPage({ kind }: { kind: "stickers" | "emoji" }) {
|
|||
</div>
|
||||
{pageSize !== "all" && visible.length > 0 && (
|
||||
<div className="gift-pager">
|
||||
<span className="gift-pager-range">{t("gifts.pageRange", { start: rangeStart, end: rangeEnd, total: visible.length })}</span>
|
||||
<span className="gift-pager-range">{`Showing ${rangeStart}-${rangeEnd} of ${visible.length}`}</span>
|
||||
<div className="gift-pager-controls">
|
||||
<button className="btn compact-btn" type="button" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={currentPage <= 1}>
|
||||
<ChevronLeft size={14} /> {t("gifts.pagePrev")}
|
||||
<ChevronLeft size={14} /> {"Previous"}
|
||||
</button>
|
||||
<span className="gift-pager-page">{t("gifts.pageOf", { page: currentPage, total: totalPages })}</span>
|
||||
<span className="gift-pager-page">{`Page ${currentPage} of ${totalPages}`}</span>
|
||||
<button className="btn compact-btn" type="button" onClick={() => setPage((p) => Math.min(totalPages, p + 1))} disabled={currentPage >= totalPages}>
|
||||
{t("gifts.pageNext")} <ChevronRight size={14} />
|
||||
{"Next"} <ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
426
cmd/telesrv-admin/web/src/pages/VerificationDetailPage.tsx
Normal file
426
cmd/telesrv-admin/web/src/pages/VerificationDetailPage.tsx
Normal file
|
|
@ -0,0 +1,426 @@
|
|||
import {
|
||||
ArrowLeft,
|
||||
BadgeCheck,
|
||||
Ban,
|
||||
CheckCircle2,
|
||||
ExternalLink,
|
||||
Handshake,
|
||||
RefreshCw,
|
||||
ShieldOff,
|
||||
User,
|
||||
XCircle
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { api, APIError, errorMessage } from "../api";
|
||||
import { ActionButton } from "../components/ActionButton";
|
||||
import { Alert, Badge, EmptyRow, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
|
||||
import { displayUsername, formatDate, safeHttpURL } from "../lib/format";
|
||||
import { permissionVerificationRevoke, usePermissions } from "../permissions";
|
||||
import type { Navigate } from "../routing";
|
||||
import type { VerificationApplicationDetail, VerificationEventKind } from "../types";
|
||||
import {
|
||||
VerificationStatusBadge,
|
||||
targetHref,
|
||||
targetLabel,
|
||||
verificationStatusLabels,
|
||||
verificationTargetTypeLabels
|
||||
} from "./VerificationPage";
|
||||
|
||||
const verificationEventKindLabels: Record<VerificationEventKind, string> = {
|
||||
created: "Created",
|
||||
updated: "Updated",
|
||||
submitted: "Submitted",
|
||||
claimed: "Claimed",
|
||||
approved: "Approved",
|
||||
rejected: "Rejected",
|
||||
cancelled: "Cancelled",
|
||||
revoked: "Badge revoked",
|
||||
notified: "Applicant notified"
|
||||
};
|
||||
|
||||
export function VerificationDetailPage({ id, navigate }: { id: string; navigate: Navigate }) {
|
||||
const { can } = usePermissions();
|
||||
const [detail, setDetail] = useState<VerificationApplicationDetail | null>(null);
|
||||
const [note, setNote] = useState("");
|
||||
const [conflict, setConflict] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function load() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
setDetail(await api.verificationApplication(id));
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
setConflict(false);
|
||||
void load();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [id]);
|
||||
|
||||
// 409 is the one failure the operator cannot fix by editing the form: another
|
||||
// reviewer decided against the version this page read. The panel says so in
|
||||
// plain words and reloads, so the next attempt carries the current version.
|
||||
function handleActionError(err: unknown): string | undefined {
|
||||
if (err instanceof APIError && err.status === 409) {
|
||||
setConflict(true);
|
||||
void load();
|
||||
return "Another admin has already changed this application. The data has been reloaded — check the status before deciding again.";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (error && !detail) {
|
||||
return <Alert>{error}</Alert>;
|
||||
}
|
||||
if (!detail) {
|
||||
return <LoadingSurface label={"Loading the application…"} />;
|
||||
}
|
||||
|
||||
const app = detail.application;
|
||||
const events = detail.events ?? [];
|
||||
const controls = detail.applicant_controls_target;
|
||||
const verified = detail.target_verified;
|
||||
const canClaim = app.Status === "submitted";
|
||||
const canDecide = app.Status === "submitted" || app.Status === "in_review";
|
||||
const canRevoke = app.Status === "approved" && can(permissionVerificationRevoke);
|
||||
const trimmedNote = note.trim();
|
||||
|
||||
// version is the optimistic-locking token: it goes with every decision, as the
|
||||
// decimal string it arrived as, so a stale page cannot overwrite a fresh one.
|
||||
function decisionPayload(): Record<string, unknown> {
|
||||
const payload: Record<string, unknown> = { version: app.Version };
|
||||
if (trimmedNote) payload.internal_note = trimmedNote;
|
||||
return payload;
|
||||
}
|
||||
|
||||
function afterDecision() {
|
||||
setNote("");
|
||||
setConflict(false);
|
||||
void load();
|
||||
}
|
||||
|
||||
return (
|
||||
<PageFrame
|
||||
title={`Application #${app.ID}`}
|
||||
eyebrow={"Verification / Review"}
|
||||
actions={
|
||||
<>
|
||||
<button className="btn icon-text" type="button" onClick={() => navigate("/verification")}>
|
||||
<ArrowLeft size={15} /> {"Back to list"}
|
||||
</button>
|
||||
<button className="btn icon-text" type="button" onClick={refresh} disabled={busy}>
|
||||
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"Refresh"}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{conflict && <Alert>{"Another admin has already changed this application. The data has been reloaded — check the status before deciding again."}</Alert>}
|
||||
<SplitLayout
|
||||
main={
|
||||
<div className="stacked-sections">
|
||||
<section className="entity-head">
|
||||
<div>
|
||||
<div className="entity-title">{targetLabel(app)}</div>
|
||||
<div className="entity-subtitle mono">
|
||||
#{app.ID} · {verificationTargetTypeLabels[app.TargetType]}:{app.TargetID} · v{app.Version}
|
||||
</div>
|
||||
</div>
|
||||
<div className="entity-badges">
|
||||
<VerificationStatusBadge status={app.Status} />
|
||||
{verified && <Badge tone="good"><BadgeCheck size={12} /> {"Badge already on"}</Badge>}
|
||||
<Badge tone={controls ? "good" : "danger"}>
|
||||
{controls ? "Control confirmed" : "No control over the target"}
|
||||
</Badge>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<SectionHead
|
||||
title={"Target"}
|
||||
text={"The peer the badge would be attached to, as it exists right now."}
|
||||
action={
|
||||
<button className="btn icon-text" type="button" onClick={() => navigate(targetHref(app))}>
|
||||
<ExternalLink size={15} /> {"Open target"}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<div className="summary-grid">
|
||||
<Summary label={"Type"} value={verificationTargetTypeLabels[app.TargetType]} />
|
||||
<Summary label={"Username"} value={displayUsername(app.TargetUsername) || "-"} />
|
||||
<Summary label={"Title"} value={app.TargetTitle || "-"} />
|
||||
<Summary label={"Peer ID"} value={app.TargetID} mono />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<SectionHead
|
||||
title={"Applicant"}
|
||||
text={"Who filed the application and whether they still hold rights on the target."}
|
||||
action={
|
||||
<button className="btn icon-text" type="button" onClick={() => navigate(`/accounts/${app.ApplicantUserID}`)}>
|
||||
<User size={15} /> {"Open account"}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<div className="summary-grid">
|
||||
<Summary label={"Username"} value={displayUsername(app.ApplicantUsername) || "-"} />
|
||||
<Summary label={"Name"} value={app.ApplicantName || "-"} />
|
||||
<Summary label={"User ID"} value={app.ApplicantUserID} mono />
|
||||
<Summary label={"Submitted"} value={formatDate(app.SubmittedAt) || "-"} />
|
||||
</div>
|
||||
{controls
|
||||
? <p className="bot-create-note">{"The applicant controls the target right now — checked against the live records, not against the submission snapshot."}</p>
|
||||
: <Alert>{"The applicant no longer controls the target. Approving would hand the badge to someone who does not hold the peer — normally a reason to reject."}</Alert>}
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<SectionHead title={"Application"} text={"Everything the applicant submitted, rendered as plain text."} />
|
||||
<div className="stacked-sections">
|
||||
<div className="summary-grid">
|
||||
<Summary label={"Category"} value={app.Category || "-"} />
|
||||
<Summary label={"Correlation ID"} value={app.CorrelationID || "-"} mono />
|
||||
<Summary label={"Created"} value={formatDate(app.CreatedAt) || "-"} />
|
||||
<Summary label={"Updated"} value={formatDate(app.UpdatedAt) || "-"} />
|
||||
</div>
|
||||
<FieldBlock label={"Description"}>
|
||||
{app.Description
|
||||
? <p className="about-text">{app.Description}</p>
|
||||
: <p className="bot-create-note">{"Not provided"}</p>}
|
||||
</FieldBlock>
|
||||
<FieldBlock label={"Official website"}>
|
||||
{app.OfficialWebsite
|
||||
? <div className="about-text"><SafeLink value={app.OfficialWebsite} /></div>
|
||||
: <p className="bot-create-note">{"Not provided"}</p>}
|
||||
</FieldBlock>
|
||||
<FieldBlock label={"Social links"}>
|
||||
<LinkList values={app.SocialLinks} />
|
||||
</FieldBlock>
|
||||
<FieldBlock label={"Press coverage"}>
|
||||
<LinkList values={app.PressLinks} />
|
||||
</FieldBlock>
|
||||
<FieldBlock label={"Applicant comment"}>
|
||||
{app.AdditionalNote
|
||||
? <p className="about-text">{app.AdditionalNote}</p>
|
||||
: <p className="bot-create-note">{"Not provided"}</p>}
|
||||
</FieldBlock>
|
||||
<p className="bot-create-note">{"Only http:// and https:// links are clickable and open in a new tab; anything else is shown as text."}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<SectionHead title={"Decision"} text={"What was decided, by whom, and with which wording."} />
|
||||
<div className="stacked-sections">
|
||||
<div className="summary-grid">
|
||||
<Summary label={"Reviewer"} value={app.ReviewerAdminID || "-"} />
|
||||
<Summary label={"Decided"} value={formatDate(app.ReviewedAt) || "-"} />
|
||||
<Summary label={"Status"} value={verificationStatusLabels[app.Status]} />
|
||||
<Summary label={"Version (optimistic lock)"} value={app.Version} mono />
|
||||
</div>
|
||||
<FieldBlock label={"Decision reason"}>
|
||||
{app.DecisionReason
|
||||
? <p className="about-text">{app.DecisionReason}</p>
|
||||
: <p className="bot-create-note">{"No decision yet"}</p>}
|
||||
</FieldBlock>
|
||||
{/* The internal note is the reviewer handover text and is labelled
|
||||
as admin-only wherever it appears. */}
|
||||
<FieldBlock label={`${"Internal note"} · ${"admins only"}`}>
|
||||
{app.InternalNote
|
||||
? <p className="about-text">{app.InternalNote}</p>
|
||||
: <p className="bot-create-note">{"Not provided"}</p>}
|
||||
</FieldBlock>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<SectionHead title={"History"} text={"Immutable trail of every status transition, with actor and reason."} />
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{"Event"}</th>
|
||||
<th>{"From → to"}</th>
|
||||
<th>{"Actor"}</th>
|
||||
<th>{"Reason"}</th>
|
||||
<th>{"Internal note"}</th>
|
||||
<th>{"Time"}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{events.map((row) => (
|
||||
<tr key={row.ID}>
|
||||
<td><EventKind kind={row.Kind} /></td>
|
||||
<td className="mono">
|
||||
{row.FromStatus || "-"} → {row.ToStatus || "-"}
|
||||
</td>
|
||||
<td>{row.Actor || "-"}</td>
|
||||
<td className="truncate">{row.Reason || "-"}</td>
|
||||
<td className="truncate">{row.Note || "-"}</td>
|
||||
<td>{formatDate(row.CreatedAt) || "-"}</td>
|
||||
</tr>
|
||||
))}
|
||||
{events.length === 0 && <EmptyRow colSpan={6} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
side={
|
||||
<section className="action-dock">
|
||||
<div className="dock-title">{"Review actions"}</div>
|
||||
{!canClaim && !canDecide && !canRevoke && (
|
||||
<p className="bot-create-note">{"This status has no available actions."}</p>
|
||||
)}
|
||||
{canClaim && (
|
||||
<>
|
||||
<div className="action-stack">
|
||||
<ActionButton
|
||||
label={"Take into review"}
|
||||
icon={<Handshake size={15} />}
|
||||
tone="neutral"
|
||||
path={`/api/verification/applications/${app.ID}/claim`}
|
||||
payload={() => ({ version: app.Version })}
|
||||
onDone={afterDecision}
|
||||
onError={handleActionError}
|
||||
/>
|
||||
</div>
|
||||
<p className="bot-create-note">{"Assigns the application to you and moves it to in review, so two reviewers never work on the same one."}</p>
|
||||
</>
|
||||
)}
|
||||
{/* One optional note field feeds every decision on this page,
|
||||
including a revoke. */}
|
||||
{(canDecide || canRevoke) && (
|
||||
<>
|
||||
<label className="duration-field">
|
||||
<span>{"Internal note"}</span>
|
||||
<textarea
|
||||
value={note}
|
||||
onChange={(event) => setNote(event.target.value)}
|
||||
rows={3}
|
||||
placeholder={"Handover note for other reviewers"}
|
||||
/>
|
||||
</label>
|
||||
<p className="bot-create-note">{"Optional. Stored with the decision and visible to admins only — never sent to the applicant."}</p>
|
||||
</>
|
||||
)}
|
||||
{canDecide && (
|
||||
<>
|
||||
{!controls && <Alert>{"The applicant no longer controls the target. Approving would hand the badge to someone who does not hold the peer — normally a reason to reject."}</Alert>}
|
||||
{verified && <p className="bot-create-note">{"The target already carries the badge; approving only records the decision."}</p>}
|
||||
<div className="action-stack">
|
||||
<ActionButton
|
||||
label={"Approve"}
|
||||
icon={<CheckCircle2 size={15} />}
|
||||
tone="neutral"
|
||||
path={`/api/verification/applications/${app.ID}/approve`}
|
||||
payload={decisionPayload}
|
||||
onDone={afterDecision}
|
||||
onError={handleActionError}
|
||||
/>
|
||||
<ActionButton
|
||||
label={"Reject"}
|
||||
icon={<XCircle size={15} />}
|
||||
tone="warn"
|
||||
path={`/api/verification/applications/${app.ID}/reject`}
|
||||
payload={decisionPayload}
|
||||
onDone={afterDecision}
|
||||
onError={handleActionError}
|
||||
/>
|
||||
</div>
|
||||
<p className="bot-create-note">{"Grants the official badge to the target and closes the application."}</p>
|
||||
<p className="bot-create-note">{"The reason is mandatory: it is the wording the applicant is told, so write what exactly was missing."}</p>
|
||||
</>
|
||||
)}
|
||||
{canRevoke && (
|
||||
<>
|
||||
<div className="dock-title"><ShieldOff size={14} /> {"Danger zone"}</div>
|
||||
<div className="danger-zone">
|
||||
<ActionButton
|
||||
label={"Revoke verification"}
|
||||
icon={<Ban size={15} />}
|
||||
tone="danger"
|
||||
path="/api/actions/revoke-verification"
|
||||
payload={() => {
|
||||
// Revoke addresses the peer, not the application: the
|
||||
// approved application stays approved as history.
|
||||
const payload: Record<string, unknown> = {
|
||||
target_type: app.TargetType,
|
||||
target_id: app.TargetID
|
||||
};
|
||||
if (trimmedNote) payload.internal_note = trimmedNote;
|
||||
return payload;
|
||||
}}
|
||||
onDone={afterDecision}
|
||||
onError={handleActionError}
|
||||
/>
|
||||
<p className="bot-create-note">{"Clears the badge from the target. The approved application stays in history."}</p>
|
||||
{!verified && <p className="bot-create-note">{"The target carries no badge right now — there is nothing to revoke."}</p>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
}
|
||||
/>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldBlock({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="duration-field">
|
||||
<span>{label}</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Applicant-supplied text is rendered as ordinary React children (escaped by
|
||||
// React) and only ever linked when it is an http(s) URL. No markup from a
|
||||
// submission reaches the DOM.
|
||||
function SafeLink({ value }: { value: string }) {
|
||||
const href = safeHttpURL(value);
|
||||
if (!href) {
|
||||
return <span className="mono">{value}</span>;
|
||||
}
|
||||
return (
|
||||
<a className="row-link" href={href} target="_blank" rel="noopener noreferrer">
|
||||
{value} <ExternalLink size={13} />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkList({ values }: { values: string[] | null }) {
|
||||
const links = (values ?? []).filter((item) => item.trim() !== "");
|
||||
if (links.length === 0) {
|
||||
return <p className="bot-create-note">{"Not provided"}</p>;
|
||||
}
|
||||
return (
|
||||
<div className="about-text">
|
||||
{links.map((item, index) => (
|
||||
<div key={`${index}-${item}`}><SafeLink value={item} /></div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EventKind({ kind }: { kind: VerificationEventKind }) {
|
||||
const tone = kind === "approved"
|
||||
? "good"
|
||||
: kind === "rejected" || kind === "revoked" || kind === "cancelled"
|
||||
? "danger"
|
||||
: kind === "submitted" || kind === "claimed"
|
||||
? "warn"
|
||||
: "neutral";
|
||||
return <Badge tone={tone}>{verificationEventKindLabels[kind]}</Badge>;
|
||||
}
|
||||
245
cmd/telesrv-admin/web/src/pages/VerificationPage.tsx
Normal file
245
cmd/telesrv-admin/web/src/pages/VerificationPage.tsx
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
import { BadgeCheck, ChevronDown, ChevronRight, Loader2, RefreshCw, Search, ShieldCheck } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
|
||||
import { displayUsername, formatDate } from "../lib/format";
|
||||
import type { Navigate } from "../routing";
|
||||
import type {
|
||||
VerificationApplicationRow,
|
||||
VerificationStatus,
|
||||
VerificationTargetType
|
||||
} from "../types";
|
||||
|
||||
type StatusFilter = "all" | VerificationStatus;
|
||||
type TargetFilter = "all" | VerificationTargetType;
|
||||
|
||||
const statuses: VerificationStatus[] = ["draft", "submitted", "in_review", "approved", "rejected", "cancelled"];
|
||||
const targetTypes: VerificationTargetType[] = ["bot", "channel", "supergroup", "user"];
|
||||
|
||||
export const verificationStatusLabels: Record<VerificationStatus, string> = {
|
||||
draft: "Draft",
|
||||
submitted: "Submitted",
|
||||
in_review: "In review",
|
||||
approved: "Approved",
|
||||
rejected: "Rejected",
|
||||
cancelled: "Cancelled"
|
||||
};
|
||||
|
||||
export const verificationTargetTypeLabels: Record<VerificationTargetType, string> = {
|
||||
bot: "Bot",
|
||||
channel: "Channel",
|
||||
supergroup: "Supergroup",
|
||||
user: "User"
|
||||
};
|
||||
|
||||
export function VerificationPage({ navigate }: { navigate: Navigate }) {
|
||||
const [status, setStatus] = useState<StatusFilter>("all");
|
||||
const [targetType, setTargetType] = useState<TargetFilter>("all");
|
||||
const [reviewer, setReviewer] = useState("");
|
||||
const [q, setQ] = useState("");
|
||||
const [limit, setLimit] = useState("50");
|
||||
const [rows, setRows] = useState<VerificationApplicationRow[]>([]);
|
||||
const [counts, setCounts] = useState<Record<string, string>>({});
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [cursor, setCursor] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// One free-text field: the backend matches the application id, the target peer
|
||||
// id and a username (applicant or target), so "@durov", "42" and a peer id all
|
||||
// work without a mode switch.
|
||||
async function load(next = false) {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
const params = new URLSearchParams({ limit });
|
||||
if (status !== "all") params.set("status", status);
|
||||
if (targetType !== "all") params.set("target_type", targetType);
|
||||
if (reviewer.trim()) params.set("reviewer", reviewer.trim());
|
||||
if (q.trim()) params.set("q", q.trim().replace(/^@/, ""));
|
||||
if (next && cursor) params.set("before_id", cursor);
|
||||
try {
|
||||
const result = await api.verificationApplications(params);
|
||||
const page = result.rows ?? [];
|
||||
setRows((current) => (next ? [...current, ...page] : page));
|
||||
setCursor(result.next_before_id ?? "");
|
||||
setHasMore(Boolean(result.has_more));
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
// The counts are the whole queue, not the current page, so they are fetched
|
||||
// separately from the keyset listing.
|
||||
async function loadCounts() {
|
||||
try {
|
||||
const result = await api.verificationCounts();
|
||||
setCounts(result.counts ?? {});
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load(false);
|
||||
void loadCounts();
|
||||
}, []);
|
||||
|
||||
function refresh() {
|
||||
void load(false);
|
||||
void loadCounts();
|
||||
}
|
||||
|
||||
return (
|
||||
<PageFrame
|
||||
title={"Verification queue"}
|
||||
eyebrow={"Verification / Queue"}
|
||||
actions={
|
||||
<button className="btn icon-text" type="button" onClick={refresh} disabled={busy}>
|
||||
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"Refresh"}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="metric-row">
|
||||
{statuses.map((item) => (
|
||||
<Metric
|
||||
key={item}
|
||||
label={verificationStatusLabels[item]}
|
||||
value={counts[item] ?? "0"}
|
||||
mono
|
||||
tone={statusMetricTone(item, counts[item] ?? "0")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<QueryPanel>
|
||||
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
|
||||
<label className="searchbox">
|
||||
<Search size={15} />
|
||||
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={"Application id, peer id, username or title"} />
|
||||
</label>
|
||||
<label className="field-inline">
|
||||
<span>{"Status"}</span>
|
||||
<select value={status} onChange={(event) => setStatus(event.target.value as StatusFilter)}>
|
||||
<option value="all">{"All statuses"}</option>
|
||||
{statuses.map((item) => (
|
||||
<option key={item} value={item}>{verificationStatusLabels[item]}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field-inline">
|
||||
<span>{"Target type"}</span>
|
||||
<select value={targetType} onChange={(event) => setTargetType(event.target.value as TargetFilter)}>
|
||||
<option value="all">{"All types"}</option>
|
||||
{targetTypes.map((item) => (
|
||||
<option key={item} value={item}>{verificationTargetTypeLabels[item]}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field-inline">
|
||||
<span>{"Reviewer"}</span>
|
||||
<input value={reviewer} onChange={(event) => setReviewer(event.target.value)} placeholder={"Any reviewer"} />
|
||||
</label>
|
||||
<label className="field-inline">
|
||||
<span>{"Limit"}</span>
|
||||
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="200" />
|
||||
</label>
|
||||
<button className="btn primary icon-text" type="submit" disabled={busy}>
|
||||
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {"Search"}
|
||||
</button>
|
||||
</form>
|
||||
</QueryPanel>
|
||||
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{"ID"}</th>
|
||||
<th>{"Target"}</th>
|
||||
<th>{"Applicant"}</th>
|
||||
<th>{"Category"}</th>
|
||||
<th>{"Status"}</th>
|
||||
<th>{"Submitted"}</th>
|
||||
<th>{"Reviewer"}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.ID}>
|
||||
<td className="mono">
|
||||
<button className="row-link" type="button" onClick={() => navigate(`/verification/${row.ID}`)}>
|
||||
#{row.ID}
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<strong>{targetLabel(row)}</strong>
|
||||
<div className="entity-subtitle mono">
|
||||
{verificationTargetTypeLabels[row.TargetType]} · {row.TargetID}
|
||||
</div>
|
||||
{row.TargetVerified && (
|
||||
<Badge tone="good"><BadgeCheck size={12} /> {"Badge already on"}</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{displayUsername(row.ApplicantUsername) || row.ApplicantName || "-"}
|
||||
<div className="entity-subtitle mono">{row.ApplicantUserID}</div>
|
||||
</td>
|
||||
<td>{row.Category || "-"}</td>
|
||||
<td><VerificationStatusBadge status={row.Status} /></td>
|
||||
<td>{formatDate(row.SubmittedAt) || "-"}</td>
|
||||
<td>{row.ReviewerAdminID || "-"}</td>
|
||||
<td>
|
||||
<button className="row-link" type="button" onClick={() => navigate(`/verification/${row.ID}`)}>
|
||||
<ShieldCheck size={14} /> {"Details"} <ChevronRight size={14} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows.length === 0 && <EmptyRow colSpan={8} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{hasMore && (
|
||||
<div className="toolbar">
|
||||
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
|
||||
{busy ? <Loader2 size={15} className="spin" /> : <ChevronDown size={15} />} {"Load more"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export function VerificationStatusBadge({ status }: { status: VerificationStatus }) {
|
||||
return <Badge tone={statusTone(status)}>{verificationStatusLabels[status]}</Badge>;
|
||||
}
|
||||
|
||||
export function statusTone(status: VerificationStatus): "neutral" | "good" | "warn" | "danger" {
|
||||
if (status === "approved") return "good";
|
||||
if (status === "submitted" || status === "in_review") return "warn";
|
||||
if (status === "rejected") return "danger";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
// submitted and in_review are the two statuses that need a reviewer; they are
|
||||
// highlighted only while something actually sits in them.
|
||||
function statusMetricTone(status: VerificationStatus, count: string): "neutral" | "good" | "warn" {
|
||||
const waiting = status === "submitted" || status === "in_review";
|
||||
if (!waiting) return status === "approved" ? "good" : "neutral";
|
||||
return count !== "0" && count !== "" ? "warn" : "neutral";
|
||||
}
|
||||
|
||||
export function targetLabel(row: VerificationApplicationRow): string {
|
||||
return displayUsername(row.TargetUsername) || row.TargetTitle || `#${row.TargetID}`;
|
||||
}
|
||||
|
||||
// The panel page that owns the target peer type, so a reviewer can inspect the
|
||||
// live record rather than only the submission snapshot.
|
||||
export function targetHref(row: VerificationApplicationRow): string {
|
||||
if (row.TargetType === "bot") return `/bots/${row.TargetID}`;
|
||||
if (row.TargetType === "user") return `/accounts/${row.TargetID}`;
|
||||
return `/channels/${row.TargetID}`;
|
||||
}
|
||||
72
cmd/telesrv-admin/web/src/permissions.tsx
Normal file
72
cmd/telesrv-admin/web/src/permissions.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { ShieldOff } from "lucide-react";
|
||||
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
||||
import { Alert, PageFrame } from "./components/ui";
|
||||
// Permission names exactly as the backend spells them
|
||||
// (cmd/telesrv-admin/security.go). "*" is the wildcard an operator configures for
|
||||
// a full-access session.
|
||||
export const permissionAll = "*";
|
||||
export const permissionVerificationReview = "verification.review";
|
||||
export const permissionVerificationRevoke = "verification.revoke";
|
||||
// Third-party verification is a separate mechanism and therefore a separate pair of
|
||||
// rights: review reads the section and decides applications, manage owns the
|
||||
// verifier roster, the icon catalogue and taking a granted mark away.
|
||||
export const permissionBotVerificationReview = "botverification.review";
|
||||
export const permissionBotVerificationManage = "botverification.manage";
|
||||
|
||||
// GET /api/session is read once at boot; the panel keeps the answer here so a
|
||||
// section the session may not use is hidden instead of rendered into a 403. This
|
||||
// is a convenience for the operator, not a security boundary: every route is
|
||||
// checked again server-side.
|
||||
const PermissionsContext = createContext<readonly string[]>([]);
|
||||
|
||||
export function PermissionsProvider({
|
||||
permissions,
|
||||
children
|
||||
}: {
|
||||
permissions: readonly string[];
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <PermissionsContext.Provider value={permissions}>{children}</PermissionsContext.Provider>;
|
||||
}
|
||||
|
||||
export function usePermissions(): { permissions: readonly string[]; can: (permission: string) => boolean } {
|
||||
const permissions = useContext(PermissionsContext);
|
||||
return useMemo(
|
||||
() => ({
|
||||
permissions,
|
||||
can: (permission: string) => permissions.includes(permissionAll) || permissions.includes(permission)
|
||||
}),
|
||||
[permissions]
|
||||
);
|
||||
}
|
||||
|
||||
export function useCan(permission: string): boolean {
|
||||
return usePermissions().can(permission);
|
||||
}
|
||||
|
||||
// PermissionGate is what a direct URL hits: without the right the operator gets
|
||||
// an explanation naming the missing permission, not an empty table that looks
|
||||
// like "no data".
|
||||
export function PermissionGate({ permission, children }: { permission: string; children: ReactNode }) {
|
||||
const { can } = usePermissions();
|
||||
if (can(permission)) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
return <PermissionDenied permission={permission} />;
|
||||
}
|
||||
|
||||
export function PermissionDenied({ permission }: { permission: string }) {
|
||||
return (
|
||||
<PageFrame title={"Not enough rights"} eyebrow={"Console / Access"}>
|
||||
<Alert>{`This session was not granted the ${permission} permission, so the section stays closed.`}</Alert>
|
||||
<section className="section-block">
|
||||
<div className="entity-head">
|
||||
<div>
|
||||
<div className="entity-title"><ShieldOff size={16} /> {"Section unavailable"}</div>
|
||||
<div className="entity-subtitle">{"Ask an operator to add the permission to TELESRV_ADMIN_UI_PERMISSIONS and sign in again."}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
import type { TFunction } from "./i18n";
|
||||
|
||||
export type Navigate = (href: string) => void;
|
||||
|
||||
export type RouteState = {
|
||||
|
|
@ -16,28 +14,40 @@ export function currentRoute(): RouteState {
|
|||
};
|
||||
}
|
||||
|
||||
export function routeTitle(pathname: string, t: TFunction): string {
|
||||
if (pathname.startsWith("/accounts")) return t("route.accounts");
|
||||
if (pathname.startsWith("/channels")) return t("route.channels");
|
||||
if (pathname.startsWith("/bots")) return t("route.bots");
|
||||
if (pathname.startsWith("/emoji")) return t("route.emoji");
|
||||
if (pathname.startsWith("/messages")) return t("route.messages");
|
||||
if (pathname.startsWith("/give-gifts")) return t("route.giveGifts");
|
||||
if (pathname.startsWith("/gifts")) return t("route.gifts");
|
||||
if (pathname.startsWith("/stickers")) return t("route.stickers");
|
||||
if (pathname.startsWith("/emoji")) return t("route.emoji");
|
||||
return t("route.dashboard");
|
||||
export function routeTitle(pathname: string): string {
|
||||
// Third-party verification is tested before the official section and before
|
||||
// "/bots": three different prefixes that all read as "verification of a bot".
|
||||
if (pathname.startsWith("/bot-verification")) return "Third-party verification";
|
||||
if (pathname.startsWith("/verification")) return "Official Verification";
|
||||
if (pathname.startsWith("/collectible-usernames")) return "Collectible Usernames";
|
||||
if (pathname.startsWith("/account-ratings")) return "Account Rating";
|
||||
if (pathname.startsWith("/accounts")) return "Accounts";
|
||||
if (pathname.startsWith("/channels")) return "Supergroups and Channels";
|
||||
if (pathname.startsWith("/bots")) return "Bots";
|
||||
if (pathname.startsWith("/moderation")) return "Reports and Moderation";
|
||||
if (pathname.startsWith("/emoji")) return "Emoji";
|
||||
if (pathname.startsWith("/messages")) return "Message Audit";
|
||||
if (pathname.startsWith("/give-gifts")) return "Give Gifts";
|
||||
if (pathname.startsWith("/gifts")) return "Star Gifts";
|
||||
if (pathname.startsWith("/stickers")) return "Stickers";
|
||||
if (pathname.startsWith("/emoji")) return "Emoji";
|
||||
return "Operations Console";
|
||||
}
|
||||
|
||||
export function routeSubtitle(pathname: string, t: TFunction): string {
|
||||
if (pathname.startsWith("/accounts")) return t("route.accountsSubtitle");
|
||||
if (pathname.startsWith("/channels")) return t("route.channelsSubtitle");
|
||||
if (pathname.startsWith("/bots")) return t("route.botsSubtitle");
|
||||
if (pathname.startsWith("/emoji")) return t("route.emojiSubtitle");
|
||||
if (pathname.startsWith("/messages")) return t("route.messagesSubtitle");
|
||||
if (pathname.startsWith("/give-gifts")) return t("route.giveGiftsSubtitle");
|
||||
if (pathname.startsWith("/gifts")) return t("route.giftsSubtitle");
|
||||
if (pathname.startsWith("/stickers")) return t("route.stickersSubtitle");
|
||||
if (pathname.startsWith("/emoji")) return t("route.emojiSubtitle");
|
||||
return t("route.dashboardSubtitle");
|
||||
export function routeSubtitle(pathname: string): string {
|
||||
if (pathname.startsWith("/bot-verification")) return "Console / Third-party verification";
|
||||
if (pathname.startsWith("/verification")) return "Console / Verification";
|
||||
if (pathname.startsWith("/collectible-usernames")) return "Console / Collectible usernames";
|
||||
if (pathname.startsWith("/account-ratings")) return "Console / Account rating";
|
||||
if (pathname.startsWith("/accounts")) return "Console / Accounts";
|
||||
if (pathname.startsWith("/channels")) return "Console / Channels";
|
||||
if (pathname.startsWith("/bots")) return "Console / Bots";
|
||||
if (pathname.startsWith("/moderation")) return "Console / Moderation";
|
||||
if (pathname.startsWith("/emoji")) return "Console / Emoji";
|
||||
if (pathname.startsWith("/messages")) return "Console / Messages";
|
||||
if (pathname.startsWith("/give-gifts")) return "Console / Give Gifts";
|
||||
if (pathname.startsWith("/gifts")) return "Console / Star Gifts";
|
||||
if (pathname.startsWith("/stickers")) return "Console / Stickers";
|
||||
if (pathname.startsWith("/emoji")) return "Console / Emoji";
|
||||
return "Console / Overview";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -185,6 +185,7 @@ body {
|
|||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -325,6 +325,7 @@
|
|||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
color: var(--text);
|
||||
background: var(--input-bg);
|
||||
|
|
@ -339,12 +340,33 @@ textarea::placeholder {
|
|||
color: var(--muted-2);
|
||||
}
|
||||
|
||||
input {
|
||||
input,
|
||||
select {
|
||||
width: 190px;
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
select {
|
||||
min-width: 220px;
|
||||
height: 34px;
|
||||
padding: 0 30px 0 10px;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
cursor: pointer;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239aa4b2' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 10px center;
|
||||
}
|
||||
|
||||
select:disabled {
|
||||
color: var(--muted-2);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 9px 10px;
|
||||
|
|
@ -352,6 +374,7 @@ textarea {
|
|||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
border-color: var(--brand);
|
||||
box-shadow: 0 0 0 3px var(--focus);
|
||||
|
|
@ -627,3 +650,43 @@ textarea:focus {
|
|||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
/* Level progress bars (account rating leaderboard and detail). */
|
||||
.progress-cell {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 130px;
|
||||
}
|
||||
|
||||
.progress-cell small,
|
||||
.progress-note {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: var(--panel-subtle);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.progress-bar > span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--brand-2);
|
||||
}
|
||||
|
||||
.progress-bar.good > span {
|
||||
background: var(--good);
|
||||
}
|
||||
|
||||
.progress-bar.danger > span {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.progress-wide .progress-cell {
|
||||
min-width: 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -103,7 +103,8 @@
|
|||
font-weight: 800;
|
||||
}
|
||||
|
||||
.duration-field input {
|
||||
.duration-field input,
|
||||
.duration-field select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
|
@ -126,6 +127,16 @@
|
|||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
/* A .dock-title already draws the rule under itself, so a .danger-zone placed
|
||||
directly after one must not draw a second: the verification and bot-verification
|
||||
detail docks label the zone with a dock-title and rendered two lines 10px apart
|
||||
above the revoke button. */
|
||||
.dock-title + .danger-zone {
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.authorization-block {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
|
|
@ -693,7 +704,8 @@
|
|||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.attr-block .duration-field input {
|
||||
.attr-block .duration-field input,
|
||||
.duration-field select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
|
@ -793,3 +805,113 @@
|
|||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Account rating component breakdown. */
|
||||
.breakdown-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.breakdown-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(140px, 260px) 1fr minmax(80px, auto);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 9px 10px;
|
||||
background: var(--panel-subtle);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.breakdown-row.total {
|
||||
grid-template-columns: 1fr minmax(80px, auto);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.breakdown-label {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.breakdown-label strong {
|
||||
color: var(--text);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.breakdown-label small {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.breakdown-value {
|
||||
color: var(--text);
|
||||
font-weight: 800;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.breakdown-value.good {
|
||||
color: var(--good);
|
||||
}
|
||||
|
||||
.breakdown-value.danger {
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.breakdown-row,
|
||||
.breakdown-row.total {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.breakdown-value {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
/* Collectible usernames branching off the peer's editable one. The guide is drawn
|
||||
with borders rather than a "↳" character so it lines up at any font size and is
|
||||
not read out by a screen reader as punctuation. */
|
||||
.username-branch {
|
||||
margin: 2px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.username-branch li {
|
||||
position: relative;
|
||||
padding-left: 14px;
|
||||
color: var(--text-soft);
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.username-branch li::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 3px;
|
||||
width: 6px;
|
||||
height: 11px;
|
||||
border-left: 1px solid var(--line-strong, var(--line));
|
||||
border-bottom: 1px solid var(--line-strong, var(--line));
|
||||
content: "";
|
||||
}
|
||||
|
||||
.username-branch li.inactive {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.username-branch li.inactive span {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.username-branch li em {
|
||||
margin-left: 6px;
|
||||
font-size: 10px;
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { useI18n } from "./i18n";
|
||||
|
||||
export type Theme = "light" | "dark";
|
||||
|
||||
|
|
@ -70,9 +69,8 @@ export function useTheme(): ThemeContextValue {
|
|||
|
||||
export function ThemeSwitch() {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { t } = useI18n();
|
||||
const nextIsDark = theme === "light";
|
||||
const label = t(nextIsDark ? "theme.switchToDark" : "theme.switchToLight");
|
||||
const label = nextIsDark ? "Switch to dark theme" : "Switch to light theme";
|
||||
return (
|
||||
<button
|
||||
className="theme-toggle"
|
||||
|
|
|
|||
|
|
@ -1,9 +1,19 @@
|
|||
// AccountUsername is one collectible username the peer holds. Active mirrors the
|
||||
// username#b4073647 flag: an inactive collectible is owned but does not resolve.
|
||||
export type AccountUsername = {
|
||||
Username: string;
|
||||
Active: boolean;
|
||||
};
|
||||
|
||||
export type AccountRow = {
|
||||
ID: number;
|
||||
Phone: string;
|
||||
Username: string;
|
||||
FirstName: string;
|
||||
LastName: string;
|
||||
// Collectible usernames in projection order; never includes the editable slot
|
||||
// above. Always an array, so it can be iterated unconditionally.
|
||||
Collectibles: AccountUsername[];
|
||||
CreatedAt: string;
|
||||
UpdatedAt: string;
|
||||
Frozen: boolean;
|
||||
|
|
@ -263,6 +273,85 @@ export type OfficialStarGiftRow = {
|
|||
|
||||
export type OfficialStarGiftListResponse = { gifts: OfficialStarGiftRow[] };
|
||||
|
||||
export type ModerationPeer = {
|
||||
Type: "user" | "channel";
|
||||
ID: number;
|
||||
};
|
||||
|
||||
export type ModerationCaseRow = {
|
||||
ID: number;
|
||||
Target: ModerationPeer;
|
||||
Status: string;
|
||||
Severity: number;
|
||||
AssignedTo: string;
|
||||
Version: number;
|
||||
ReportCount: number;
|
||||
DistinctReporterCount: number;
|
||||
FirstReportAt: string;
|
||||
LastReportAt: string;
|
||||
CreatedAt: string;
|
||||
UpdatedAt: string;
|
||||
};
|
||||
|
||||
export type ModerationDecision = {
|
||||
ID: number;
|
||||
CaseID: number;
|
||||
AppealID: number;
|
||||
Kind: string;
|
||||
Actor: string;
|
||||
Reason: string;
|
||||
CommandID: string;
|
||||
CreatedAt: string;
|
||||
};
|
||||
|
||||
export type ModerationAction = {
|
||||
ID: number;
|
||||
CaseID: number;
|
||||
DecisionID: number;
|
||||
Kind: string;
|
||||
Payload: Record<string, unknown>;
|
||||
Status: string;
|
||||
Attempts: number;
|
||||
LastError: string;
|
||||
CommandID: string;
|
||||
CreatedAt: string;
|
||||
UpdatedAt: string;
|
||||
};
|
||||
|
||||
export type ModerationAppeal = {
|
||||
ID: number;
|
||||
CaseID: number;
|
||||
AppellantUserID: number;
|
||||
Text: string;
|
||||
Status: string;
|
||||
PreviousCaseStatus: string;
|
||||
Reviewer: string;
|
||||
ReviewReason: string;
|
||||
CreatedAt: string;
|
||||
ReviewedAt: string;
|
||||
};
|
||||
|
||||
export type ModerationCaseDetail = {
|
||||
Case: ModerationCaseRow;
|
||||
ReportIDs: number[];
|
||||
Decisions: ModerationDecision[];
|
||||
Actions: ModerationAction[];
|
||||
Appeals: ModerationAppeal[];
|
||||
};
|
||||
|
||||
export type ModerationReport = {
|
||||
ID: number;
|
||||
ReporterUserID: number;
|
||||
Source: string;
|
||||
Target: ModerationPeer;
|
||||
Reason: string;
|
||||
Option: string;
|
||||
Comment: string;
|
||||
Items: Array<Record<string, unknown>>;
|
||||
MediaHolds: Array<Record<string, unknown>>;
|
||||
CreatedAt: string;
|
||||
};
|
||||
|
||||
export type StarGiftCollectibleAttributeRow = {
|
||||
id: string;
|
||||
kind: "model" | "pattern" | "backdrop";
|
||||
|
|
@ -294,6 +383,347 @@ export type StarGiftCollectiblePreview = {
|
|||
backdrops?: StarGiftCollectibleAttributeRow[];
|
||||
};
|
||||
|
||||
export type CollectibleUsernameStatus = "vault" | "owned" | "burned";
|
||||
|
||||
export type CollectiblePeerType = "" | "user" | "channel";
|
||||
|
||||
export type CollectibleCurrency = "XTR" | "TON" | "USD";
|
||||
|
||||
// int64 columns arrive as JSON strings to survive the 2^53 boundary.
|
||||
export type CollectibleUsernameRow = {
|
||||
ID: string;
|
||||
Username: string;
|
||||
Status: CollectibleUsernameStatus;
|
||||
OwnerPeerType: CollectiblePeerType;
|
||||
OwnerPeerID: string;
|
||||
OwnerUsername: string;
|
||||
OwnerName: string;
|
||||
PurchaseDate: string;
|
||||
Currency: CollectibleCurrency;
|
||||
Amount: string;
|
||||
CryptoCurrency: string;
|
||||
CryptoAmount: string;
|
||||
URL: string;
|
||||
OriginalOwnerPeerType: string;
|
||||
OriginalOwnerPeerID: string;
|
||||
OriginalOwnerUsername: string;
|
||||
TransferCount: number;
|
||||
Version: string;
|
||||
// Mirrors the holder's username-registry row: an owned asset can still be
|
||||
// hidden from the profile.
|
||||
RegistryActive: boolean;
|
||||
RegistrySortOrder: number;
|
||||
CreatedAt: string;
|
||||
UpdatedAt: string;
|
||||
};
|
||||
|
||||
export type CollectibleUsernameTransferKind = "mint" | "transfer" | "revoke" | "burn";
|
||||
|
||||
export type CollectibleUsernameTransferRow = {
|
||||
ID: string;
|
||||
CollectibleID: string;
|
||||
Kind: CollectibleUsernameTransferKind;
|
||||
FromPeerType: string;
|
||||
FromPeerID: string;
|
||||
FromUsername: string;
|
||||
ToPeerType: string;
|
||||
ToPeerID: string;
|
||||
ToUsername: string;
|
||||
Currency: string;
|
||||
Amount: string;
|
||||
Actor: string;
|
||||
Reason: string;
|
||||
CommandKey: string;
|
||||
CreatedAt: string;
|
||||
};
|
||||
|
||||
export type CollectibleUsernameListResponse = {
|
||||
rows: CollectibleUsernameRow[] | null;
|
||||
has_more: boolean;
|
||||
next_before_id: string;
|
||||
};
|
||||
|
||||
export type CollectibleUsernameDetail = {
|
||||
asset: CollectibleUsernameRow;
|
||||
transfers: CollectibleUsernameTransferRow[] | null;
|
||||
};
|
||||
|
||||
export type AccountRatingRow = {
|
||||
UserID: string;
|
||||
Username: string;
|
||||
FirstName: string;
|
||||
Level: number;
|
||||
Stars: string;
|
||||
CurrentLevelStars: string;
|
||||
NextLevelStars: string;
|
||||
HasNextLevel: boolean;
|
||||
StarsComponent: string;
|
||||
ActivityComponent: string;
|
||||
PenaltyComponent: string;
|
||||
ManualComponent: string;
|
||||
PendingStars: string;
|
||||
PendingDate: string;
|
||||
ComputedAt: string;
|
||||
UpdatedAt: string;
|
||||
Version: string;
|
||||
};
|
||||
|
||||
export type AccountRatingEventKind = "stars" | "activity" | "moderation" | "manual" | "recompute";
|
||||
|
||||
export type AccountRatingEventRow = {
|
||||
ID: string;
|
||||
UserID: string;
|
||||
Kind: AccountRatingEventKind;
|
||||
Amount: string;
|
||||
Reason: string;
|
||||
Actor: string;
|
||||
CommandKey: string;
|
||||
CreatedAt: string;
|
||||
};
|
||||
|
||||
export type AccountRatingListResponse = {
|
||||
rows: AccountRatingRow[] | null;
|
||||
has_more: boolean;
|
||||
next_before_id: string;
|
||||
};
|
||||
|
||||
export type AccountRatingDetail = {
|
||||
rating: AccountRatingRow;
|
||||
events: AccountRatingEventRow[] | null;
|
||||
};
|
||||
|
||||
// Official platform verification. Every int64 the backend tags `,string` stays a
|
||||
// decimal string here: application ids, peer ids and the optimistic-locking
|
||||
// version all outgrow the exact range of a JSON number, and a rounded version
|
||||
// would send a decision against the wrong revision of the row.
|
||||
export type VerificationTargetType = "bot" | "channel" | "supergroup" | "user";
|
||||
|
||||
export type VerificationStatus =
|
||||
| "draft"
|
||||
| "submitted"
|
||||
| "in_review"
|
||||
| "approved"
|
||||
| "rejected"
|
||||
| "cancelled";
|
||||
|
||||
export type VerificationEventKind =
|
||||
| "created"
|
||||
| "updated"
|
||||
| "submitted"
|
||||
| "claimed"
|
||||
| "approved"
|
||||
| "rejected"
|
||||
| "cancelled"
|
||||
| "revoked"
|
||||
| "notified";
|
||||
|
||||
export type VerificationApplicationRow = {
|
||||
ID: string;
|
||||
ApplicantUserID: string;
|
||||
ApplicantUsername: string;
|
||||
ApplicantName: string;
|
||||
TargetType: VerificationTargetType;
|
||||
TargetID: string;
|
||||
TargetTitle: string;
|
||||
TargetUsername: string;
|
||||
TargetVerified: boolean;
|
||||
Category: string;
|
||||
Description: string;
|
||||
OfficialWebsite: string;
|
||||
// Go marshals an empty slice as null, so both shapes have to be tolerated.
|
||||
SocialLinks: string[] | null;
|
||||
PressLinks: string[] | null;
|
||||
AdditionalNote: string;
|
||||
Status: VerificationStatus;
|
||||
ReviewerAdminID: string;
|
||||
DecisionReason: string;
|
||||
// InternalNote is the reviewer handover note: operator-only, never shown to the
|
||||
// applicant.
|
||||
InternalNote: string;
|
||||
CorrelationID: string;
|
||||
CreatedAt: string;
|
||||
UpdatedAt: string;
|
||||
SubmittedAt: string;
|
||||
ReviewedAt: string;
|
||||
Version: string;
|
||||
};
|
||||
|
||||
export type VerificationEventRow = {
|
||||
ID: string;
|
||||
Kind: VerificationEventKind;
|
||||
FromStatus: string;
|
||||
ToStatus: string;
|
||||
Actor: string;
|
||||
Reason: string;
|
||||
Note: string;
|
||||
CreatedAt: string;
|
||||
};
|
||||
|
||||
export type VerificationApplicationListResponse = {
|
||||
rows: VerificationApplicationRow[] | null;
|
||||
has_more: boolean;
|
||||
next_before_id: string;
|
||||
};
|
||||
|
||||
export type VerificationApplicationDetail = {
|
||||
application: VerificationApplicationRow;
|
||||
events: VerificationEventRow[] | null;
|
||||
// Both flags describe the target as it is now, not as it was at submission.
|
||||
applicant_controls_target: boolean;
|
||||
target_verified: boolean;
|
||||
};
|
||||
|
||||
// Counts are decimal strings for the same exactness reason as the ids; the
|
||||
// backend always sends all six statuses.
|
||||
export type VerificationCountsResponse = {
|
||||
counts: Record<string, string> | null;
|
||||
};
|
||||
|
||||
// Third-party bot verification (core.telegram.org/api/bots/verification): a
|
||||
// verifier bot marks a peer with its OWN icon and description, rendered before the
|
||||
// name. It is a different mechanism from the official checkmark above — the two
|
||||
// never read each other's state — so it gets its own row types rather than reusing
|
||||
// VerificationApplicationRow.
|
||||
//
|
||||
// Every int64 the backend tags `,string` stays a decimal string here: bot ids, peer
|
||||
// ids, custom emoji document ids and the optimistic-locking version all outgrow the
|
||||
// exact range of a JSON number.
|
||||
export type BotVerificationPeerType = "user" | "channel";
|
||||
|
||||
export type CustomVerificationRequestStatus = "pending" | "approved" | "rejected" | "revoked";
|
||||
|
||||
// MarkCount is tagged `,string` like the ids (it is the count that would cascade
|
||||
// away with a revocation, read as int64), while VerificationIconRow.UsedByVerifiers
|
||||
// is a plain number — it counts verifier rows and cannot approach the exactness
|
||||
// limit. Both are rendered through String(), so neither shape can surprise a cell.
|
||||
export type BotVerifierRow = {
|
||||
BotID: string;
|
||||
BotUsername: string;
|
||||
BotName: string;
|
||||
// IconDocumentID is the custom emoji document the verifier marks with. Clients
|
||||
// resolve it through messages.getCustomEmojiDocuments, so an id naming no
|
||||
// fetchable document renders as no badge at all.
|
||||
IconDocumentID: string;
|
||||
IconName: string;
|
||||
CompanyName: string;
|
||||
DefaultDescription: string;
|
||||
// CanModifyCustomDescription mirrors botVerifierSettings flags.1: when false the
|
||||
// verifier may only apply DefaultDescription.
|
||||
CanModifyCustomDescription: boolean;
|
||||
// Enabled is the operator kill switch: a disabled verifier keeps its granted
|
||||
// marks but can no longer mark anything new.
|
||||
Enabled: boolean;
|
||||
GrantedBy: string;
|
||||
GrantReason: string;
|
||||
MarkCount: string;
|
||||
CreatedAt: string;
|
||||
UpdatedAt: string;
|
||||
Version: string;
|
||||
};
|
||||
|
||||
export type VerificationIconRow = {
|
||||
ID: string;
|
||||
DocumentID: string;
|
||||
// OwnerBotID is "0" for a catalogue entry any verifier may use, and a bot id
|
||||
// when the operator reserved the icon for one verifier.
|
||||
OwnerBotID: string;
|
||||
OwnerBotUsername: string;
|
||||
Name: string;
|
||||
Active: boolean;
|
||||
UsedByVerifiers: number;
|
||||
CreatedAt: string;
|
||||
UpdatedAt: string;
|
||||
};
|
||||
|
||||
export type CustomVerificationRow = {
|
||||
ID: string;
|
||||
VerifierBotID: string;
|
||||
VerifierBotUsername: string;
|
||||
CompanyName: string;
|
||||
PeerType: BotVerificationPeerType;
|
||||
PeerID: string;
|
||||
PeerTitle: string;
|
||||
PeerUsername: string;
|
||||
// Denormalised at grant time, so a mark keeps the icon it was granted with even
|
||||
// after the verifier changes its own.
|
||||
IconDocumentID: string;
|
||||
Description: string;
|
||||
CreatedAt: string;
|
||||
UpdatedAt: string;
|
||||
Version: string;
|
||||
};
|
||||
|
||||
export type CustomVerificationRequestRow = {
|
||||
ID: string;
|
||||
VerifierBotID: string;
|
||||
VerifierBotUsername: string;
|
||||
ApplicantUserID: string;
|
||||
ApplicantUsername: string;
|
||||
PeerType: BotVerificationPeerType;
|
||||
PeerID: string;
|
||||
PeerTitle: string;
|
||||
PeerUsername: string;
|
||||
Reason: string;
|
||||
RequestedDescription: string;
|
||||
Status: CustomVerificationRequestStatus;
|
||||
DecidedBy: string;
|
||||
DecisionReason: string;
|
||||
// InternalNote is the operator handover note: never shown to the applicant.
|
||||
InternalNote: string;
|
||||
CorrelationID: string;
|
||||
CreatedAt: string;
|
||||
UpdatedAt: string;
|
||||
ApprovedAt: string;
|
||||
RejectedAt: string;
|
||||
Version: string;
|
||||
};
|
||||
|
||||
export type BotVerifierListResponse = {
|
||||
rows: BotVerifierRow[] | null;
|
||||
};
|
||||
|
||||
export type VerificationIconListResponse = {
|
||||
rows: VerificationIconRow[] | null;
|
||||
};
|
||||
|
||||
export type CustomVerificationListResponse = {
|
||||
rows: CustomVerificationRow[] | null;
|
||||
has_more: boolean;
|
||||
next_before_id: string;
|
||||
};
|
||||
|
||||
export type CustomVerificationRequestListResponse = {
|
||||
rows: CustomVerificationRequestRow[] | null;
|
||||
has_more: boolean;
|
||||
next_before_id: string;
|
||||
};
|
||||
|
||||
export type CustomVerificationRequestDetail = {
|
||||
request: CustomVerificationRequestRow;
|
||||
// The verifier row as it is now: it can be disabled, or revoked entirely, after
|
||||
// the application was filed.
|
||||
verifier: BotVerifierRow | null;
|
||||
// mark_active describes the peer right now, not the application status: an
|
||||
// approved application whose mark a verifier later withdrew reads false.
|
||||
mark_active: boolean;
|
||||
};
|
||||
|
||||
// Counts are decimal strings for the same exactness reason as the ids; the backend
|
||||
// always sends all four statuses.
|
||||
export type BotVerificationCountsResponse = {
|
||||
counts: Record<string, string> | null;
|
||||
};
|
||||
|
||||
export type AdminSession = {
|
||||
actor: string;
|
||||
// The right set the signed session was issued with; ["*"] means everything.
|
||||
permissions?: string[] | null;
|
||||
};
|
||||
|
||||
export type AdminLoginResult = AdminSession & {
|
||||
csrf_token: string;
|
||||
};
|
||||
|
||||
export type MessageDetail = {
|
||||
Message: MessageRow;
|
||||
MessageJSON: string;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue