feat: add NFT usernames and bot verification (#22)

Implements collectible usernames, official verification workflows, and third-party bot verification after maintainer protocol and migration review.

The composite activity/moderation rating remains an admin-only read model; Telegram Stars Rating wire fields stay unset pending a dedicated official-semantics implementation.

Reviewed-Head: 2796345775ea0f908fb7734601e5e1dee4b653b9
Original-Head: fa082b892fd5180c9c9bc53c81c21cf5d250a75b

Co-authored-by: Egor Egorov <business.egor.sg@gmail.com>
This commit is contained in:
Egor Egorov 2026-07-27 20:18:00 +03:00 committed by GitHub
parent b0fd3976f1
commit fff8de783a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
169 changed files with 55769 additions and 282 deletions

View file

@ -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>
);
}

View file

@ -1,11 +1,23 @@
import type {
AccountDetail,
AccountListResponse,
AccountRatingDetail,
AccountRatingListResponse,
AdminLoginResult,
AdminSession,
BotDetail,
BotListResponse,
BotVerificationCountsResponse,
BotVerifierListResponse,
ChannelDetail,
CustomVerificationListResponse,
CustomVerificationRequestDetail,
CustomVerificationRequestListResponse,
VerificationIconListResponse,
EmojiListResponse,
ChannelListResponse,
CollectibleUsernameDetail,
CollectibleUsernameListResponse,
CommandResult,
GroupMessageDetail,
GroupMessageListResponse,
@ -16,7 +28,10 @@ import type {
ModerationReport,
OfficialStarGiftListResponse,
StarGiftCollectiblePreview,
StarGiftListResponse
StarGiftListResponse,
VerificationApplicationDetail,
VerificationApplicationListResponse,
VerificationCountsResponse
} from "./types";
export class APIError extends Error {
@ -28,12 +43,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 +136,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()}`),
account: (id: number) => request<AccountDetail>(`/api/accounts/${id}`),
@ -64,6 +153,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()}`),

View file

@ -16,7 +16,9 @@ export function ActionButton({
icon,
compact = false,
tone = "danger",
onDone
disabled = false,
onDone,
onError
}: {
label: string;
path: string;
@ -24,7 +26,15 @@ 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);
@ -54,7 +64,7 @@ export function ActionButton({
onDone?.();
}
} catch (err) {
setError(errorMessage(err));
setError(onError?.(err) || errorMessage(err));
} finally {
setBusy(false);
}
@ -75,6 +85,7 @@ export function ActionButton({
<button
className={triggerClass}
type="button"
disabled={disabled}
onClick={() => {
reset();
setOpen(true);

View file

@ -3,7 +3,7 @@ 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({
@ -100,6 +100,103 @@ export function UserPicker({
);
}
// 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 { t } = useI18n();
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} /> {t("common.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={t("picker.botPlaceholder")}
/>
<button className="btn compact-btn" type="button" onClick={search} disabled={busy}>
{busy ? <Loader2 size={14} className="spin" /> : t("common.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">{t("picker.system")}</Badge> : <Badge>{t("picker.regular")}</Badge>}
</button>
))}
{rows.length === 0 && !busy ? <div className="picker-empty">{t("common.noResults")}</div> : null}
</div>
</div>
);
}
export function ChannelPicker({
label,
value,

View file

@ -1,4 +1,6 @@
import {
AtSign,
BadgeCheck,
Bot,
ChevronDown,
Database,
@ -10,6 +12,8 @@ import {
ShieldAlert,
ShieldCheck,
Smile,
Stamp,
Trophy,
Users,
Gift,
Send
@ -17,6 +21,7 @@ import {
import { useEffect, useState, type ReactNode } from "react";
import { api } from "../api";
import { LanguageSwitch, 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";
@ -51,6 +56,12 @@ export function Shell({
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);
@ -82,6 +93,14 @@ export function Shell({
<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={<ShieldAlert size={16} />} href="/moderation" route={route} navigate={navigate}>{t("layout.moderation")}</NavLink>
{canReviewVerification && (
<NavLink icon={<BadgeCheck size={16} />} href="/verification" route={route} navigate={navigate}>{t("layout.verification")}</NavLink>
)}
{canReviewBotVerification && (
<NavLink icon={<Stamp size={16} />} href="/bot-verification" route={route} navigate={navigate}>{t("layout.botVerification")}</NavLink>
)}
<NavLink icon={<AtSign size={16} />} href="/collectible-usernames" route={route} navigate={navigate}>{t("layout.collectibleUsernames")}</NavLink>
<NavLink icon={<Trophy size={16} />} href="/account-ratings" route={route} navigate={navigate}>{t("layout.accountRatings")}</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={<Smile size={16} />} href="/emoji" route={route} navigate={navigate}>{t("layout.emoji")}</NavLink>

View file

@ -1,8 +1,8 @@
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";
@ -129,3 +129,33 @@ 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 { t } = useI18n();
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>{t("usernames.inactive")}</em>}
</li>
))}
</ul>
</>
);
}

File diff suppressed because it is too large Load diff

View file

@ -44,12 +44,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,]+/)

View file

@ -3,7 +3,7 @@ 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";
@ -65,6 +65,11 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
<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>
{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>}

View file

@ -0,0 +1,261 @@
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 { useI18n } from "../i18n";
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 { t } = useI18n();
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 ? t("rating.loadingDetail") : t("account.waitingData")} />;
}
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={t("rating.detailTitle", { user: displayUsername(rating.Username) || rating.FirstName || rating.UserID })}
eyebrow={t("rating.detailEyebrow")}
actions={
<>
<button className="btn icon-text" type="button" onClick={() => navigate("/account-ratings")}>
<ArrowLeft size={15} /> {t("common.backToList")}
</button>
<button className="btn icon-text" type="button" onClick={load} disabled={busy}>
<RefreshCw size={15} className={busy ? "spin" : ""} /> {t("common.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 || t("bots.unnamed")}</div>
<div className="entity-subtitle">{t("rating.userID")}: {rating.UserID}</div>
</div>
<div className="entity-badges">
<LevelBadge level={rating.Level} />
{pending !== 0 && <Badge tone="warn">{t("rating.pendingBadge", { amount: formatSigned(rating.PendingStars) })}</Badge>}
</div>
</section>
<div className="metric-row">
<Metric label={t("rating.stars")} value={formatQuantity(rating.Stars)} mono />
<Metric label={t("rating.level")} value={String(rating.Level)} tone="good" />
<Metric
label={t("rating.nextLevel")}
value={rating.HasNextLevel ? formatQuantity(rating.NextLevelStars) : t("rating.maxLevel")}
mono={rating.HasNextLevel}
/>
<Metric
label={t("rating.toNextLevel")}
value={rating.HasNextLevel ? formatQuantity(String(progress.remaining)) : "-"}
mono
tone={rating.HasNextLevel && progress.percent >= 80 ? "good" : "neutral"}
/>
</div>
<section className="section-block">
<SectionHead title={t("rating.breakdownTitle")} text={t("rating.breakdownHint")} />
<Breakdown rating={rating} />
<div className="summary-grid">
<Summary label={t("rating.currentLevelStars")} value={formatQuantity(rating.CurrentLevelStars)} mono />
<Summary
label={t("rating.nextLevelStars")}
value={rating.HasNextLevel ? formatQuantity(rating.NextLevelStars) : t("rating.maxLevel")}
mono={rating.HasNextLevel}
/>
<Summary label={t("rating.computedAt")} value={formatDate(rating.ComputedAt) || "-"} />
<Summary label={t("common.updatedAt")} value={formatDate(rating.UpdatedAt) || "-"} />
</div>
<div className="progress-wide">
<RatingProgress row={rating} />
</div>
</section>
{pending !== 0 && (
<section className="section-block">
<SectionHead title={t("rating.pendingTitle")} text={t("rating.pendingHint")} />
<div className="summary-grid">
<Summary label={t("rating.pending")} value={formatSigned(rating.PendingStars)} mono />
<Summary label={t("rating.pendingDate")} value={formatDate(rating.PendingDate) || "-"} />
</div>
</section>
)}
<section className="section-block">
<SectionHead title={t("rating.eventsTitle")} text={t("rating.eventsHint")} />
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{t("common.id")}</th>
<th>{t("rating.eventKind")}</th>
<th>{t("rating.amount")}</th>
<th>{t("audit.reason")}</th>
<th>{t("audit.actor")}</th>
<th>{t("common.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">{t("rating.actionDock")}</div>
<button className="btn icon-text" type="button" onClick={() => navigate(`/accounts/${rating.UserID}`)}>
<User size={15} /> {t("rating.openAccount")}
</button>
<div className="action-stack">
<ActionButton
label={t("rating.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">{t("rating.recomputeHint")}</p>
<div className="dock-title">{t("rating.adjustTitle")}</div>
<label className="duration-field">
<span>{t("rating.adjustAmount")}</span>
<input
value={adjustment}
onChange={(event) => setAdjustment(event.target.value)}
type="number"
step="1"
placeholder="-500"
/>
</label>
<div className="action-stack">
<ActionButton
label={t("rating.adjust")}
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">{t("rating.adjustHint")}</p>
</section>
}
/>
</PageFrame>
);
}
function Breakdown({ rating }: { rating: AccountRatingRow }) {
const { t } = useI18n();
// 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: t("rating.componentStars"), hint: t("rating.componentStarsHint"), value: toNumeric(rating.StarsComponent) },
{ key: "activity", label: t("rating.componentActivity"), hint: t("rating.componentActivityHint"), value: toNumeric(rating.ActivityComponent) },
{ key: "penalty", label: t("rating.componentPenalty"), hint: t("rating.componentPenaltyHint"), value: -toNumeric(rating.PenaltyComponent) },
{ key: "manual", label: t("rating.componentManual"), hint: t("rating.componentManualHint"), 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>{t("rating.componentTotal")}</strong></div>
<div className="breakdown-value mono">{formatQuantity(rating.Stars)}</div>
</div>
</div>
{pending === 0 && sum !== total && (
<Alert>{t("rating.breakdownMismatch", { sum: formatQuantity(String(sum)), total: formatQuantity(rating.Stars) })}</Alert>
)}
{pending !== 0 && <p className="bot-create-note">{t("rating.breakdownPending", { amount: formatSigned(rating.PendingStars) })}</p>}
</>
);
}
function EventKind({ kind }: { kind: AccountRatingEventKind }) {
const { t } = useI18n();
const tone = kind === "moderation" ? "danger" : kind === "manual" ? "warn" : kind === "recompute" ? "neutral" : "good";
return <Badge tone={tone}>{t(`rating.kind.${kind}`)}</Badge>;
}

View file

@ -0,0 +1,167 @@
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 { useI18n } from "../i18n";
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 { t } = useI18n();
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={t("rating.pageTitle")}
eyebrow={t("rating.eyebrow")}
actions={
<button className="btn icon-text" type="button" onClick={() => load(false)} disabled={busy}>
<RefreshCw size={15} className={busy ? "spin" : ""} /> {t("common.refresh")}
</button>
}
>
{error && <Alert>{error}</Alert>}
<div className="metric-row">
<Metric label={t("rating.metricLoaded")} value={String(rows.length)} />
<Metric label={t("rating.metricTopLevel")} value={String(topLevel)} tone="good" />
<Metric label={t("rating.metricAvgLevel")} value={avgLevel} />
<Metric label={t("rating.metricPending")} 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={t("rating.searchPlaceholder")} />
</label>
<label className="field-inline">
<span>{t("rating.minLevel")}</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>{t("common.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} />} {t("common.search")}
</button>
</form>
</QueryPanel>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{t("rating.userID")}</th>
<th>{t("common.username")}</th>
<th>{t("rating.level")}</th>
<th>{t("rating.stars")}</th>
<th>{t("rating.progress")}</th>
<th>{t("rating.pending")}</th>
<th>{t("rating.computedAt")}</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} /> {t("common.detail")} <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} />} {t("common.loadMore")}
</button>
</div>
)}
</PageFrame>
);
}
export function LevelBadge({ level }: { level: number }) {
const { t } = useI18n();
const tone = level >= 10 ? "good" : level >= 5 ? "warn" : "neutral";
return <Badge tone={tone}>{t("rating.levelValue", { 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 }) {
const { t } = useI18n();
if (!row.HasNextLevel) {
return <span className="progress-note">{t("rating.maxLevel")}</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>{t("rating.progressHint", { remaining: formatQuantity(String(remaining)), target: formatQuantity(String(target)) })}</small>
</div>
);
}

View file

@ -1,10 +1,10 @@
import { ChevronRight, Loader2, RefreshCw, Search } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
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 } from "../types";
@ -107,7 +107,7 @@ export function AccountsPage({ navigate }: { navigate: Navigate }) {
<tr key={row.ID}>
<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.DeviceCount}</td>
<td>{formatDate(row.LastActiveAt)}</td>

View file

@ -0,0 +1,965 @@
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 { useI18n } from "../i18n";
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"];
// 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 { t } = useI18n();
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: t("botverification.tabRequests"), icon: <Stamp size={15} /> },
{ key: "verifiers", label: t("botverification.tabVerifiers"), icon: <Building2 size={15} /> },
{ key: "icons", label: t("botverification.tabIcons"), icon: <Sticker size={15} /> },
{ key: "marks", label: t("botverification.tabMarks"), icon: <BadgeCheck size={15} /> }
];
return (
<PageFrame
title={t("botverification.pageTitle")}
eyebrow={t("botverification.eyebrow")}
actions={
canSeeOfficial ? (
<button className="btn icon-text" type="button" onClick={() => navigate("/verification")}>
<ExternalLink size={15} /> {t("botverification.openOfficial")}
</button>
) : undefined
}
>
{error && <Alert>{error}</Alert>}
{rosterDenied && <Alert>{t("botverification.rosterDenied")}</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={t("botverification.explainTitle")} text={t("botverification.explainText")} />
<p className="bot-create-note">{t("botverification.explainIcon")}</p>
<p className="bot-create-note">{t("botverification.explainOfficial")}</p>
{!canManage && <p className="bot-create-note">{t("botverification.manageMissing")}</p>}
</section>
<div className="toolbar" role="group" aria-label={t("botverification.pageTitle")}>
{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 { t } = useI18n();
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={t("botverification.queueTitle")}
text={t("botverification.queueHint")}
action={
<button className="btn icon-text" type="button" onClick={refresh} disabled={busy}>
<RefreshCw size={15} className={busy ? "spin" : ""} /> {t("common.refresh")}
</button>
}
/>
{error && <Alert>{error}</Alert>}
<div className="metric-row">
{statuses.map((item) => (
<Metric
key={item}
label={t(`botverification.status.${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={t("botverification.searchPlaceholder")} />
</label>
<label className="field-inline">
<span>{t("common.status")}</span>
<select value={status} onChange={(event) => setStatus(event.target.value as StatusFilter)}>
<option value="all">{t("botverification.statusAll")}</option>
{statuses.map((item) => (
<option key={item} value={item}>{t(`botverification.status.${item}`)}</option>
))}
</select>
</label>
<label className="field-inline">
<span>{t("botverification.verifier")}</span>
<VerifierOptions value={verifierBotID} verifiers={verifiers} onChange={setVerifierBotID} />
</label>
<label className="field-inline">
<span>{t("botverification.peerType")}</span>
<select value={peerType} onChange={(event) => setPeerType(event.target.value as PeerTypeFilter)}>
<option value="all">{t("botverification.peerTypeAll")}</option>
{peerTypes.map((item) => (
<option key={item} value={item}>{t(`botverification.peer.${item}`)}</option>
))}
</select>
</label>
<label className="field-inline">
<span>{t("common.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} />} {t("common.search")}
</button>
</form>
</QueryPanel>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{t("common.id")}</th>
<th>{t("botverification.verifier")}</th>
<th>{t("botverification.target")}</th>
<th>{t("botverification.applicant")}</th>
<th>{t("botverification.reason")}</th>
<th>{t("common.status")}</th>
<th>{t("botverification.createdAt")}</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">
{t(`botverification.peer.${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} /> {t("common.detail")} <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} />} {t("common.loadMore")}
</button>
</div>
)}
</>
);
}
// ---------------------------------------------------------------------------
// Verifiers
// ---------------------------------------------------------------------------
function VerifiersBlock({
verifiers,
icons,
canManage,
onChanged,
navigate
}: {
verifiers: BotVerifierRow[];
icons: VerificationIconRow[];
canManage: boolean;
onChanged: () => void;
navigate: Navigate;
}) {
const { t } = useI18n();
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} (${t("botverification.iconInactive")})`
});
}
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 ? t("botverification.updateTitle") : t("botverification.grantTitle")}
text={t("botverification.grantHint")}
action={
editing ? (
<button className="btn icon-text" type="button" onClick={resetForm}>
{t("botverification.cancelEdit")}
</button>
) : undefined
}
/>
{editing ? (
<p className="bot-create-note">
{t("botverification.editing", {
bot: displayUsername(editing.BotUsername) || editing.BotID,
version: editing.Version
})}
</p>
) : (
<BotPicker label={t("botverification.grantBot")} value={bot} onChange={setBot} />
)}
<div className="bot-create-fields">
<label className="duration-field">
<span>{t("botverification.grantIcon")}</span>
<select value={iconDocumentID} onChange={(event) => setIconDocumentID(event.target.value)}>
<option value="">{t("botverification.grantIconPick")}</option>
{iconOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</label>
<label className="duration-field">
<span>{t("botverification.company")}</span>
<input
value={company}
onChange={(event) => setCompany(event.target.value)}
placeholder={t("botverification.companyPlaceholder")}
/>
</label>
<label className="duration-field">
<span>{t("botverification.defaultDescription")}</span>
<input
value={defaultDescription}
onChange={(event) => setDefaultDescription(event.target.value)}
placeholder={t("botverification.defaultDescriptionPlaceholder")}
/>
</label>
</div>
<label className="checkline">
<input type="checkbox" checked={canModify} onChange={(event) => setCanModify(event.target.checked)} />
{t("botverification.canModify")}
</label>
<p className="bot-create-note">{t("botverification.canModifyHint")}</p>
{activeIcons.length === 0 && <Alert>{t("botverification.noActiveIcons")}</Alert>}
<div className="bot-create-actions">
<span className="bot-create-note">{t("botverification.grantNote")}</span>
<ActionButton
label={editing ? t("botverification.update") : t("botverification.grant")}
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={t("botverification.verifiersTitle")}
text={t("botverification.verifiersHint")}
action={
<button className="btn icon-text" type="button" onClick={onChanged}>
<RefreshCw size={15} /> {t("common.refresh")}
</button>
}
/>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{t("botverification.bot")}</th>
<th>{t("botverification.company")}</th>
<th>{t("botverification.icon")}</th>
<th>{t("botverification.canModifyShort")}</th>
<th>{t("common.status")}</th>
<th>{t("botverification.markCount")}</th>
<th>{t("botverification.grantedBy")}</th>
<th>{t("common.updatedAt")}</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 || t("botverification.notProvided")}</div>
</td>
<td>
{row.IconName || "-"}
<div className="entity-subtitle mono">{row.IconDocumentID}</div>
</td>
<td>{row.CanModifyCustomDescription ? t("common.yes") : t("common.no")}</td>
<td>
{row.Enabled
? <Badge tone="good">{t("botverification.enabled")}</Badge>
: <Badge tone="warn">{t("botverification.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)}>
{t("botverification.edit")}
</button>
<ActionButton
label={row.Enabled ? t("botverification.disable") : t("botverification.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={t("botverification.revokeVerifier")}
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">{t("botverification.disableHint")}</p>
<p className="bot-create-note">{t("botverification.revokeVerifierHint")}</p>
</section>
</>
);
}
// ---------------------------------------------------------------------------
// Icon catalogue
// ---------------------------------------------------------------------------
function IconsBlock({
icons,
verifiers,
canManage,
onChanged
}: {
icons: VerificationIconRow[];
verifiers: BotVerifierRow[];
canManage: boolean;
onChanged: () => void;
}) {
const { t } = useI18n();
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={t("botverification.addIconTitle")} text={t("botverification.addIconHint")} />
<div className="bot-create-fields">
<label className="duration-field">
<span>{t("botverification.iconDocument")}</span>
<input
value={documentID}
onChange={(event) => setDocumentID(event.target.value)}
inputMode="numeric"
placeholder="5361371319611781774"
/>
</label>
<label className="duration-field">
<span>{t("botverification.iconName")}</span>
<input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder={t("botverification.iconNamePlaceholder")}
/>
</label>
<label className="duration-field">
<span>{t("botverification.iconOwner")}</span>
<select value={ownerBotID} onChange={(event) => setOwnerBotID(event.target.value)}>
<option value="">{t("botverification.iconOwnerShared")}</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">{t("botverification.iconDocumentHint")}</p>
<p className="bot-create-note">{t("botverification.iconOwnerHint")}</p>
<div className="bot-create-actions">
<span className="bot-create-note">{t("botverification.addIconNote")}</span>
<ActionButton
label={t("botverification.addIcon")}
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={t("botverification.iconsTitle")}
text={t("botverification.iconsHint")}
action={
<button className="btn icon-text" type="button" onClick={onChanged}>
<RefreshCw size={15} /> {t("common.refresh")}
</button>
}
/>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{t("botverification.iconDocument")}</th>
<th>{t("botverification.iconName")}</th>
<th>{t("botverification.iconOwner")}</th>
<th>{t("common.status")}</th>
<th>{t("botverification.usedBy")}</th>
<th>{t("botverification.createdAt")}</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>{t("botverification.iconOwnerShared")}</Badge>}
</td>
<td>
{row.Active
? <Badge tone="good">{t("botverification.iconActive")}</Badge>
: <Badge tone="warn">{t("botverification.iconInactive")}</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 ? t("botverification.deactivateIcon") : t("botverification.activateIcon")}
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">{t("botverification.deactivateIconHint")}</p>
</section>
</>
);
}
// ---------------------------------------------------------------------------
// Granted marks
// ---------------------------------------------------------------------------
function MarksBlock({
verifiers,
canManage,
navigate
}: {
verifiers: BotVerifierRow[];
canManage: boolean;
navigate: Navigate;
}) {
const { t } = useI18n();
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={t("botverification.marksTitle")}
text={t("botverification.marksHint")}
action={
<button className="btn icon-text" type="button" onClick={() => load(false)} disabled={busy}>
<RefreshCw size={15} className={busy ? "spin" : ""} /> {t("common.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={t("botverification.markSearchPlaceholder")} />
</label>
<label className="field-inline">
<span>{t("botverification.verifier")}</span>
<VerifierOptions value={verifierBotID} verifiers={verifiers} onChange={setVerifierBotID} />
</label>
<label className="field-inline">
<span>{t("botverification.peerType")}</span>
<select value={peerType} onChange={(event) => setPeerType(event.target.value as PeerTypeFilter)}>
<option value="all">{t("botverification.peerTypeAll")}</option>
{peerTypes.map((item) => (
<option key={item} value={item}>{t(`botverification.peer.${item}`)}</option>
))}
</select>
</label>
<label className="field-inline">
<span>{t("common.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} />} {t("common.search")}
</button>
</form>
</QueryPanel>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{t("common.id")}</th>
<th>{t("botverification.verifier")}</th>
<th>{t("botverification.target")}</th>
<th>{t("botverification.description")}</th>
<th>{t("botverification.icon")}</th>
<th>{t("botverification.createdAt")}</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">
{t(`botverification.peer.${row.PeerType}`)} · {row.PeerID}
</div>
</td>
<td className="truncate">{row.Description || t("botverification.notProvided")}</td>
<td className="mono">{row.IconDocumentID}</td>
<td>{formatDate(row.CreatedAt) || "-"}</td>
{canManage && (
<td>
<div className="row-actions">
<ActionButton
label={t("botverification.revokeMark")}
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">{t("botverification.revokeMarkHint")}</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} />} {t("common.loadMore")}
</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;
}) {
const { t } = useI18n();
return (
<select value={value} onChange={(event) => onChange(event.target.value)}>
<option value="">{t("botverification.verifierAll")}</option>
{verifiers.map((row) => (
<option key={row.BotID} value={row.BotID}>
{`${row.CompanyName || row.BotID} · ${displayUsername(row.BotUsername) || row.BotID}`
+ (row.Enabled ? "" : ` (${t("botverification.disabled")})`)}
</option>
))}
</select>
);
}
export function RequestStatusBadge({ status }: { status: CustomVerificationRequestStatus }) {
const { t } = useI18n();
return <Badge tone={statusTone(status)}>{t(`botverification.status.${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}`;
}

View file

@ -0,0 +1,360 @@
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 { useI18n } from "../i18n";
import { displayUsername, formatDate } from "../lib/format";
import type { Navigate } from "../routing";
import type { BotVerifierRow, CustomVerificationRequestDetail } from "../types";
import { RequestStatusBadge, peerHref, peerLabel } from "./BotVerificationPage";
export function BotVerificationRequestPage({ id, navigate }: { id: string; navigate: Navigate }) {
const { t } = useI18n();
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 t("botverification.conflict");
}
return undefined;
}
if (error && !detail) {
return <Alert>{error}</Alert>;
}
if (!detail) {
return <LoadingSurface label={t("botverification.loadingDetail")} />;
}
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={t("botverification.detailTitle", { id: request.ID })}
eyebrow={t("botverification.detailEyebrow")}
actions={
<>
<button className="btn icon-text" type="button" onClick={() => navigate("/bot-verification")}>
<ArrowLeft size={15} /> {t("common.backToList")}
</button>
<button className="btn icon-text" type="button" onClick={refresh} disabled={busy}>
<RefreshCw size={15} className={busy ? "spin" : ""} /> {t("common.refresh")}
</button>
</>
}
>
{error && <Alert>{error}</Alert>}
{conflict && <Alert>{t("botverification.conflict")}</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} · {t(`botverification.peer.${request.PeerType}`)}:{request.PeerID} · v{request.Version}
</div>
</div>
<div className="entity-badges">
<RequestStatusBadge status={request.Status} />
{markActive
? <Badge tone="good"><BadgeCheck size={12} /> {t("botverification.markActive")}</Badge>
: <Badge tone="neutral">{t("botverification.markInactive")}</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={t("botverification.explainTitle")} text={t("botverification.explainText")} />
<p className="bot-create-note">{t("botverification.explainIcon")}</p>
</section>
<section className="section-block">
<SectionHead
title={t("botverification.verifierSection")}
text={t("botverification.verifierHint")}
action={
<button className="btn icon-text" type="button" onClick={() => navigate(`/bots/${request.VerifierBotID}`)}>
<Building2 size={15} /> {t("botverification.openVerifier")}
</button>
}
/>
<div className="summary-grid">
<Summary label={t("botverification.company")} value={verifier?.CompanyName || "-"} />
<Summary label={t("botverification.bot")} value={displayUsername(request.VerifierBotUsername) || "-"} />
<Summary label={t("botverification.verifierID")} value={request.VerifierBotID} mono />
<Summary label={t("botverification.iconDocument")} value={verifier?.IconDocumentID || "-"} mono />
<Summary label={t("botverification.iconName")} value={verifier?.IconName || "-"} />
<Summary
label={t("botverification.canModifyShort")}
value={verifier?.CanModifyCustomDescription ? t("common.yes") : t("common.no")}
/>
</div>
<FieldBlock label={t("botverification.defaultDescription")}>
{verifier?.DefaultDescription
? <p className="about-text">{verifier.DefaultDescription}</p>
: <p className="bot-create-note">{t("botverification.notProvided")}</p>}
</FieldBlock>
{!verifier && <Alert>{t("botverification.verifierMissing")}</Alert>}
{verifier && !verifier.Enabled && <Alert>{t("botverification.verifierDisabledHint")}</Alert>}
</section>
<section className="section-block">
<SectionHead
title={t("botverification.targetSection")}
text={t("botverification.targetHint")}
action={
<button
className="btn icon-text"
type="button"
onClick={() => navigate(peerHref(request.PeerType, request.PeerID))}
>
<ExternalLink size={15} /> {t("botverification.openTarget")}
</button>
}
/>
<div className="summary-grid">
<Summary label={t("common.type")} value={t(`botverification.peer.${request.PeerType}`)} />
<Summary label={t("common.username")} value={displayUsername(request.PeerUsername) || "-"} />
<Summary label={t("botverification.targetTitle")} value={request.PeerTitle || "-"} />
<Summary label={t("botverification.targetID")} value={request.PeerID} mono />
</div>
</section>
<section className="section-block">
<SectionHead
title={t("botverification.applicantSection")}
text={t("botverification.applicantHint")}
action={
<button
className="btn icon-text"
type="button"
onClick={() => navigate(`/accounts/${request.ApplicantUserID}`)}
>
<User size={15} /> {t("botverification.openApplicant")}
</button>
}
/>
<div className="summary-grid">
<Summary label={t("common.username")} value={displayUsername(request.ApplicantUsername) || "-"} />
<Summary label={t("botverification.applicantID")} value={request.ApplicantUserID} mono />
<Summary label={t("botverification.createdAt")} value={formatDate(request.CreatedAt) || "-"} />
<Summary label={t("common.updatedAt")} value={formatDate(request.UpdatedAt) || "-"} />
</div>
</section>
<section className="section-block">
<SectionHead title={t("botverification.requestSection")} text={t("botverification.requestHint")} />
<div className="stacked-sections">
<div className="summary-grid">
<Summary label={t("botverification.correlationID")} value={request.CorrelationID || "-"} mono />
<Summary label={t("common.status")} value={t(`botverification.status.${request.Status}`)} />
</div>
<FieldBlock label={t("botverification.reason")}>
{request.Reason
? <p className="about-text">{request.Reason}</p>
: <p className="bot-create-note">{t("botverification.notProvided")}</p>}
</FieldBlock>
<FieldBlock label={t("botverification.requestedDescription")}>
{requestedDescription
? <p className="about-text">{requestedDescription}</p>
: <p className="bot-create-note">{t("botverification.notProvided")}</p>}
</FieldBlock>
<FieldBlock label={t("botverification.markPreview")}>
{effectiveDescription
? <p className="about-text">{effectiveDescription}</p>
: <p className="bot-create-note">{t("botverification.notProvided")}</p>}
</FieldBlock>
<p className="bot-create-note">{t("botverification.markPreviewHint")}</p>
{requestedDescription !== "" && !descriptionAllowed && (
<p className="bot-create-note">{t("botverification.descriptionIgnoredHint")}</p>
)}
</div>
</section>
<section className="section-block">
<SectionHead title={t("botverification.decisionSection")} text={t("botverification.decisionHint")} />
<div className="stacked-sections">
<div className="summary-grid">
<Summary label={t("botverification.decidedBy")} value={request.DecidedBy || "-"} />
<Summary label={t("botverification.approvedAt")} value={formatDate(request.ApprovedAt) || "-"} />
<Summary label={t("botverification.rejectedAt")} value={formatDate(request.RejectedAt) || "-"} />
<Summary label={t("botverification.version")} value={request.Version} mono />
</div>
<FieldBlock label={t("botverification.decisionReason")}>
{request.DecisionReason
? <p className="about-text">{request.DecisionReason}</p>
: <p className="bot-create-note">{t("botverification.noDecision")}</p>}
</FieldBlock>
{/* The internal note is the operator handover text and is labelled as
admin-only wherever it appears. */}
<FieldBlock label={`${t("botverification.internalNote")} · ${t("botverification.adminOnly")}`}>
{request.InternalNote
? <p className="about-text">{request.InternalNote}</p>
: <p className="bot-create-note">{t("botverification.notProvided")}</p>}
</FieldBlock>
</div>
</section>
</div>
}
side={
<section className="action-dock">
<div className="dock-title"><Stamp size={14} /> {t("botverification.actionDock")}</div>
{!canDecide && !canRevoke && <p className="bot-create-note">{t("botverification.noActions")}</p>}
{(canDecide || canRevoke) && (
<>
<label className="duration-field">
<span>{t("botverification.internalNote")}</span>
<textarea
value={note}
onChange={(event) => setNote(event.target.value)}
rows={3}
placeholder={t("botverification.internalNotePlaceholder")}
/>
</label>
<p className="bot-create-note">{t("botverification.internalNoteHint")}</p>
</>
)}
{canDecide && (
<>
{!verifier && <Alert>{t("botverification.verifierMissing")}</Alert>}
{verifier && !verifier.Enabled && <Alert>{t("botverification.verifierDisabledHint")}</Alert>}
{markActive && <p className="bot-create-note">{t("botverification.markActiveHint")}</p>}
<div className="action-stack">
<ActionButton
label={t("botverification.approve")}
icon={<CheckCircle2 size={15} />}
tone="neutral"
path={`/api/botverification/requests/${request.ID}/approve`}
payload={decisionPayload}
onDone={afterDecision}
onError={handleActionError}
/>
<ActionButton
label={t("botverification.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">{t("botverification.approveHint")}</p>
<p className="bot-create-note">{t("botverification.rejectHint")}</p>
</>
)}
{canRevoke && (
<>
<div className="dock-title"><ShieldOff size={14} /> {t("botverification.dangerZone")}</div>
<div className="danger-zone">
<ActionButton
label={t("botverification.revokeRequest")}
icon={<Ban size={15} />}
tone="danger"
path={`/api/botverification/requests/${request.ID}/revoke`}
payload={decisionPayload}
onDone={afterDecision}
onError={handleActionError}
/>
<p className="bot-create-note">{t("botverification.revokeRequestHint")}</p>
{!markActive && <p className="bot-create-note">{t("botverification.revokeNoMark")}</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;
}

View file

@ -0,0 +1,253 @@
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 { useI18n } from "../i18n";
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 { t } = useI18n();
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 ? t("usernames.loadingDetail") : t("account.waitingData")} />;
}
const asset = detail.asset;
const transfers = detail.transfers ?? [];
const vaultLabel = t("usernames.statusVault");
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={t("usernames.detailTitle", { username: displayUsername(asset.Username) })}
eyebrow={t("usernames.detailEyebrow")}
actions={
<>
<button className="btn icon-text" type="button" onClick={() => navigate("/collectible-usernames")}>
<ArrowLeft size={15} /> {t("common.backToList")}
</button>
<button className="btn icon-text" type="button" onClick={load} disabled={busy}>
<RefreshCw size={15} className={busy ? "spin" : ""} /> {t("common.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">{t("usernames.assetID", { id: asset.ID })}</div>
</div>
<div className="entity-badges">
<UsernameStatus status={asset.Status} />
<Badge tone={asset.TransferCount > 0 ? "warn" : "neutral"}>
{t("usernames.transferCount", { count: asset.TransferCount })}
</Badge>
{asset.Status === "owned" && (
<Badge tone={asset.RegistryActive ? "good" : "warn"}>
{asset.RegistryActive ? t("usernames.registryActive") : t("usernames.registryHidden")}
</Badge>
)}
</div>
</section>
<div className="summary-grid">
<Summary label={t("common.owner")} value={ownerLabel(asset, vaultLabel)} />
<Summary label={t("usernames.price")} value={priceLabel(asset)} mono />
<Summary label={t("usernames.purchaseDate")} value={formatDate(asset.PurchaseDate) || "-"} />
<Summary
label={t("usernames.originalOwner")}
value={peerLabel(asset.OriginalOwnerPeerType, asset.OriginalOwnerPeerID, vaultLabel, asset.OriginalOwnerUsername)}
/>
<Summary label={t("usernames.transfers")} value={String(asset.TransferCount)} mono />
<Summary label={t("account.createdAt")} value={formatDate(asset.CreatedAt) || "-"} />
<Summary label={t("common.updatedAt")} value={formatDate(asset.UpdatedAt) || "-"} />
</div>
<div className="toolbar">
{hasOwner && (
<button className="row-link" type="button" onClick={openOwner}>
{asset.OwnerPeerType === "channel" ? t("usernames.openOwnerChannel") : t("usernames.openOwnerAccount")}
</button>
)}
{asset.URL && (
<a className="row-link" href={asset.URL} target="_blank" rel="noreferrer noopener">
<ExternalLink size={14} /> {t("usernames.openMarketplace")}
</a>
)}
</div>
{!burned && (
<section className="section-block">
<SectionHead title={t("usernames.transferTitle")} text={t("usernames.transferHint")} />
<div className="toolbar" role="group" aria-label={t("usernames.recipientKind")}>
<button type="button" className={`btn ${recipientKind === "user" ? "primary" : ""}`} onClick={() => setRecipientKind("user")}>
{t("usernames.recipientUser")}
</button>
<button type="button" className={`btn ${recipientKind === "channel" ? "primary" : ""}`} onClick={() => setRecipientKind("channel")}>
{t("usernames.recipientChannel")}
</button>
</div>
{recipientKind === "user"
? <UserPicker label={t("usernames.recipientUser")} value={recipientUser} onChange={setRecipientUser} />
: <ChannelPicker label={t("usernames.recipientChannel")} value={recipientChannel} onChange={setRecipientChannel} />}
<div className="bot-create-actions">
<span className="bot-create-note">{t("usernames.transferNote")}</span>
<ActionButton
label={t("usernames.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={t("usernames.historyTitle")} text={t("usernames.historyHint")} />
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{t("common.id")}</th>
<th>{t("usernames.eventKind")}</th>
<th>{t("usernames.fromPeer")}</th>
<th>{t("usernames.toPeer")}</th>
<th>{t("usernames.price")}</th>
<th>{t("audit.actor")}</th>
<th>{t("audit.reason")}</th>
<th>{t("common.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">{t("usernames.actionDock")}</div>
{burned ? (
<p className="bot-create-note">{t("usernames.burnedHint")}</p>
) : (
<>
<div className="action-stack">
<ActionButton
label={t("usernames.revoke")}
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">{t("usernames.revokeHint")}</p>
<div className="danger-zone">
<ActionButton
label={t("usernames.burn")}
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">{t("usernames.burnHint")}</p>
<ActionButton
label={t("usernames.delete")}
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">{t("usernames.deleteHint")}</p>
</div>
</>
)}
</section>
}
/>
</PageFrame>
);
}
function TransferKind({ kind }: { kind: CollectibleUsernameTransferKind }) {
const { t } = useI18n();
const tone = kind === "burn" ? "danger" : kind === "revoke" ? "warn" : kind === "mint" ? "good" : "neutral";
return <Badge tone={tone}>{t(`usernames.kind.${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}`;
}

View file

@ -0,0 +1,309 @@
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 { useI18n } from "../i18n";
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 { t } = useI18n();
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={t("usernames.pageTitle")}
eyebrow={t("usernames.eyebrow")}
actions={
<button className="btn icon-text" type="button" onClick={() => load(false)} disabled={busy}>
<RefreshCw size={15} className={busy ? "spin" : ""} /> {t("common.refresh")}
</button>
}
>
{error && <Alert>{error}</Alert>}
<div className="metric-row">
<Metric label={t("usernames.metricLoaded")} value={String(rows.length)} />
<Metric label={t("usernames.metricVault")} value={String(vaultCount)} />
<Metric label={t("usernames.metricOwned")} value={String(ownedCount)} tone="good" />
<Metric label={t("usernames.metricBurned")} value={String(burnedCount)} tone={burnedCount ? "danger" : "neutral"} />
</div>
<section className="section-block">
<SectionHead title={t("usernames.mintTitle")} text={t("usernames.mintHint")} />
<div className="toolbar" role="group" aria-label={t("usernames.ownerKind")}>
<button type="button" className={`btn ${ownerKind === "vault" ? "primary" : ""}`} onClick={() => setOwnerKind("vault")}>
<Vault size={15} /> {t("usernames.ownerVault")}
</button>
<button type="button" className={`btn ${ownerKind === "user" ? "primary" : ""}`} onClick={() => setOwnerKind("user")}>
{t("usernames.ownerUser")}
</button>
<button type="button" className={`btn ${ownerKind === "channel" ? "primary" : ""}`} onClick={() => setOwnerKind("channel")}>
{t("usernames.ownerChannel")}
</button>
</div>
{ownerKind === "user" && <UserPicker label={t("usernames.ownerUser")} value={owner} onChange={setOwner} />}
{ownerKind === "channel" && <ChannelPicker label={t("usernames.ownerChannel")} value={ownerChannel} onChange={setOwnerChannel} />}
<div className="bot-create-fields">
<label className="duration-field">
<span>{t("common.username")}</span>
<input value={mintUsername} onChange={(event) => setMintUsername(event.target.value)} placeholder="durov" />
</label>
<label className="duration-field">
<span>{t("usernames.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>{t("usernames.amount", { currency })}</span>
<input value={amount} onChange={(event) => setAmount(event.target.value)} inputMode="decimal" placeholder="1000" />
</label>
<label className="duration-field">
<span>{t("usernames.cryptoCurrency")}</span>
<select value={cryptoCurrency} onChange={(event) => setCryptoCurrency(event.target.value)}>
<option value="">{t("usernames.cryptoNone")}</option>
<option value="TON">TON</option>
</select>
</label>
{cryptoCurrency !== "" && (
<label className="duration-field">
<span>{t("usernames.cryptoAmount", { currency: cryptoCurrency })}</span>
<input value={cryptoAmount} onChange={(event) => setCryptoAmount(event.target.value)} inputMode="decimal" placeholder="12.5" />
</label>
)}
<label className="duration-field">
<span>{t("usernames.url")}</span>
<input value={url} onChange={(event) => setUrl(event.target.value)} placeholder="https://fragment.com/username/durov" />
</label>
<label className="duration-field">
<span>{t("usernames.purchaseDate")}</span>
<input value={purchaseDate} onChange={(event) => setPurchaseDate(event.target.value)} type="date" />
</label>
<label className="duration-field">
<span>{t("usernames.purchaseTime")}</span>
<input
value={purchaseTime}
onChange={(event) => setPurchaseTime(event.target.value)}
type="time"
step={60}
disabled={!purchaseDate}
/>
</label>
</div>
<p className="bot-create-note">
{t("usernames.amountHint", {
currency,
decimals: String(currencyExponent(currency)),
preview: formatCurrency(minorAmount ?? "0", currency)
})}
</p>
{amountInvalid && <Alert>{t("usernames.amountInvalid", { currency, decimals: String(currencyExponent(currency)) })}</Alert>}
{cryptoCurrency !== "" && cryptoAmountInvalid && (
<Alert>{t("usernames.amountInvalid", { currency: cryptoCurrency, decimals: String(currencyExponent(cryptoCurrency)) })}</Alert>
)}
<div className="bot-create-actions">
<span className="bot-create-note">{t("usernames.mintNote")}</span>
<ActionButton
disabled={amountInvalid || cryptoAmountInvalid}
label={t("usernames.mint")}
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={t("usernames.searchPlaceholder")} />
</label>
<label className="field-inline">
<span>{t("common.status")}</span>
<select value={status} onChange={(event) => setStatus(event.target.value as StatusFilter)}>
<option value="all">{t("usernames.statusAll")}</option>
<option value="vault">{t("usernames.statusVault")}</option>
<option value="owned">{t("usernames.statusOwned")}</option>
<option value="burned">{t("usernames.statusBurned")}</option>
</select>
</label>
<label className="field-inline">
<span>{t("common.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} />} {t("common.search")}
</button>
</form>
</QueryPanel>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{t("common.username")}</th>
<th>{t("common.status")}</th>
<th>{t("common.owner")}</th>
<th>{t("usernames.price")}</th>
<th>{t("usernames.purchaseDate")}</th>
<th>{t("usernames.transfers")}</th>
<th>{t("common.updatedAt")}</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, t("usernames.statusVault"))}</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} /> {t("common.detail")} <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} />} {t("common.loadMore")}
</button>
</div>
)}
</PageFrame>
);
}
export function UsernameStatus({ status }: { status: CollectibleUsernameStatus }) {
const { t } = useI18n();
if (status === "owned") return <Badge tone="good">{t("usernames.statusOwned")}</Badge>;
if (status === "burned") return <Badge tone="danger"><Flame size={12} /> {t("usernames.statusBurned")}</Badge>;
return <Badge><Vault size={12} /> {t("usernames.statusVault")}</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;
}

View file

@ -4,8 +4,9 @@ import { api, errorMessage } from "../api";
import { Alert } from "../components/ui";
import { LanguageSwitch, useI18n } from "../i18n";
import { ThemeSwitch } from "../theme";
import type { AdminSession } from "../types";
export function LoginPage({ onLogin }: { onLogin: (actor: string) => void }) {
export function LoginPage({ onLogin }: { onLogin: (session: AdminSession) => void }) {
const { t } = useI18n();
const [secret, setSecret] = useState("");
const [error, setError] = useState("");
@ -16,8 +17,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 {

View file

@ -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";
@ -15,12 +19,71 @@ import { GiftsPage } from "./GiftsPage";
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} />;
}

View file

@ -0,0 +1,412 @@
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 { useI18n } from "../i18n";
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 } from "./VerificationPage";
export function VerificationDetailPage({ id, navigate }: { id: string; navigate: Navigate }) {
const { t } = useI18n();
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 t("verification.conflict");
}
return undefined;
}
if (error && !detail) {
return <Alert>{error}</Alert>;
}
if (!detail) {
return <LoadingSurface label={t("verification.loadingDetail")} />;
}
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={t("verification.detailTitle", { id: app.ID })}
eyebrow={t("verification.detailEyebrow")}
actions={
<>
<button className="btn icon-text" type="button" onClick={() => navigate("/verification")}>
<ArrowLeft size={15} /> {t("common.backToList")}
</button>
<button className="btn icon-text" type="button" onClick={refresh} disabled={busy}>
<RefreshCw size={15} className={busy ? "spin" : ""} /> {t("common.refresh")}
</button>
</>
}
>
{error && <Alert>{error}</Alert>}
{conflict && <Alert>{t("verification.conflict")}</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} · {t(`verification.type.${app.TargetType}`)}:{app.TargetID} · v{app.Version}
</div>
</div>
<div className="entity-badges">
<VerificationStatusBadge status={app.Status} />
{verified && <Badge tone="good"><BadgeCheck size={12} /> {t("verification.alreadyVerified")}</Badge>}
<Badge tone={controls ? "good" : "danger"}>
{controls ? t("verification.controlsOk") : t("verification.controlsLost")}
</Badge>
</div>
</section>
<section className="section-block">
<SectionHead
title={t("verification.targetSection")}
text={t("verification.targetHint")}
action={
<button className="btn icon-text" type="button" onClick={() => navigate(targetHref(app))}>
<ExternalLink size={15} /> {t("verification.openTarget")}
</button>
}
/>
<div className="summary-grid">
<Summary label={t("common.type")} value={t(`verification.type.${app.TargetType}`)} />
<Summary label={t("common.username")} value={displayUsername(app.TargetUsername) || "-"} />
<Summary label={t("verification.targetTitle")} value={app.TargetTitle || "-"} />
<Summary label={t("verification.targetID")} value={app.TargetID} mono />
</div>
</section>
<section className="section-block">
<SectionHead
title={t("verification.applicantSection")}
text={t("verification.applicantHint")}
action={
<button className="btn icon-text" type="button" onClick={() => navigate(`/accounts/${app.ApplicantUserID}`)}>
<User size={15} /> {t("verification.openApplicant")}
</button>
}
/>
<div className="summary-grid">
<Summary label={t("common.username")} value={displayUsername(app.ApplicantUsername) || "-"} />
<Summary label={t("common.name")} value={app.ApplicantName || "-"} />
<Summary label={t("verification.applicantID")} value={app.ApplicantUserID} mono />
<Summary label={t("verification.submittedAt")} value={formatDate(app.SubmittedAt) || "-"} />
</div>
{controls
? <p className="bot-create-note">{t("verification.controlsOkHint")}</p>
: <Alert>{t("verification.controlsLostHint")}</Alert>}
</section>
<section className="section-block">
<SectionHead title={t("verification.applicationSection")} text={t("verification.applicationHint")} />
<div className="stacked-sections">
<div className="summary-grid">
<Summary label={t("verification.category")} value={app.Category || "-"} />
<Summary label={t("verification.correlationID")} value={app.CorrelationID || "-"} mono />
<Summary label={t("verification.createdAt")} value={formatDate(app.CreatedAt) || "-"} />
<Summary label={t("common.updatedAt")} value={formatDate(app.UpdatedAt) || "-"} />
</div>
<FieldBlock label={t("verification.description")}>
{app.Description
? <p className="about-text">{app.Description}</p>
: <p className="bot-create-note">{t("verification.notProvided")}</p>}
</FieldBlock>
<FieldBlock label={t("verification.officialWebsite")}>
{app.OfficialWebsite
? <div className="about-text"><SafeLink value={app.OfficialWebsite} /></div>
: <p className="bot-create-note">{t("verification.notProvided")}</p>}
</FieldBlock>
<FieldBlock label={t("verification.socialLinks")}>
<LinkList values={app.SocialLinks} />
</FieldBlock>
<FieldBlock label={t("verification.pressLinks")}>
<LinkList values={app.PressLinks} />
</FieldBlock>
<FieldBlock label={t("verification.additionalNote")}>
{app.AdditionalNote
? <p className="about-text">{app.AdditionalNote}</p>
: <p className="bot-create-note">{t("verification.notProvided")}</p>}
</FieldBlock>
<p className="bot-create-note">{t("verification.linkSafetyHint")}</p>
</div>
</section>
<section className="section-block">
<SectionHead title={t("verification.decisionSection")} text={t("verification.decisionHint")} />
<div className="stacked-sections">
<div className="summary-grid">
<Summary label={t("verification.reviewer")} value={app.ReviewerAdminID || "-"} />
<Summary label={t("verification.reviewedAt")} value={formatDate(app.ReviewedAt) || "-"} />
<Summary label={t("common.status")} value={t(`verification.status.${app.Status}`)} />
<Summary label={t("verification.version")} value={app.Version} mono />
</div>
<FieldBlock label={t("verification.decisionReason")}>
{app.DecisionReason
? <p className="about-text">{app.DecisionReason}</p>
: <p className="bot-create-note">{t("verification.noDecision")}</p>}
</FieldBlock>
{/* The internal note is the reviewer handover text and is labelled
as admin-only wherever it appears. */}
<FieldBlock label={`${t("verification.internalNote")} · ${t("verification.adminOnly")}`}>
{app.InternalNote
? <p className="about-text">{app.InternalNote}</p>
: <p className="bot-create-note">{t("verification.notProvided")}</p>}
</FieldBlock>
</div>
</section>
<section className="section-block">
<SectionHead title={t("verification.eventsSection")} text={t("verification.eventsHint")} />
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{t("verification.eventKind")}</th>
<th>{t("verification.transition")}</th>
<th>{t("audit.actor")}</th>
<th>{t("audit.reason")}</th>
<th>{t("verification.eventNote")}</th>
<th>{t("common.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">{t("verification.actionDock")}</div>
{!canClaim && !canDecide && !canRevoke && (
<p className="bot-create-note">{t("verification.noActions")}</p>
)}
{canClaim && (
<>
<div className="action-stack">
<ActionButton
label={t("verification.claim")}
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">{t("verification.claimHint")}</p>
</>
)}
{/* One optional note field feeds every decision on this page,
including a revoke. */}
{(canDecide || canRevoke) && (
<>
<label className="duration-field">
<span>{t("verification.internalNote")}</span>
<textarea
value={note}
onChange={(event) => setNote(event.target.value)}
rows={3}
placeholder={t("verification.internalNotePlaceholder")}
/>
</label>
<p className="bot-create-note">{t("verification.internalNoteHint")}</p>
</>
)}
{canDecide && (
<>
{!controls && <Alert>{t("verification.controlsLostHint")}</Alert>}
{verified && <p className="bot-create-note">{t("verification.alreadyVerifiedHint")}</p>}
<div className="action-stack">
<ActionButton
label={t("verification.approve")}
icon={<CheckCircle2 size={15} />}
tone="neutral"
path={`/api/verification/applications/${app.ID}/approve`}
payload={decisionPayload}
onDone={afterDecision}
onError={handleActionError}
/>
<ActionButton
label={t("verification.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">{t("verification.approveHint")}</p>
<p className="bot-create-note">{t("verification.rejectHint")}</p>
</>
)}
{canRevoke && (
<>
<div className="dock-title"><ShieldOff size={14} /> {t("verification.dangerZone")}</div>
<div className="danger-zone">
<ActionButton
label={t("verification.revoke")}
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">{t("verification.revokeHint")}</p>
{!verified && <p className="bot-create-note">{t("verification.revokeNotVerified")}</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 { t } = useI18n();
const links = (values ?? []).filter((item) => item.trim() !== "");
if (links.length === 0) {
return <p className="bot-create-note">{t("verification.notProvided")}</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 { t } = useI18n();
const tone = kind === "approved"
? "good"
: kind === "rejected" || kind === "revoked" || kind === "cancelled"
? "danger"
: kind === "submitted" || kind === "claimed"
? "warn"
: "neutral";
return <Badge tone={tone}>{t(`verification.kind.${kind}`)}</Badge>;
}

View file

@ -0,0 +1,232 @@
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 { useI18n } from "../i18n";
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 function VerificationPage({ navigate }: { navigate: Navigate }) {
const { t } = useI18n();
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={t("verification.pageTitle")}
eyebrow={t("verification.eyebrow")}
actions={
<button className="btn icon-text" type="button" onClick={refresh} disabled={busy}>
<RefreshCw size={15} className={busy ? "spin" : ""} /> {t("common.refresh")}
</button>
}
>
{error && <Alert>{error}</Alert>}
<div className="metric-row">
{statuses.map((item) => (
<Metric
key={item}
label={t(`verification.status.${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={t("verification.searchPlaceholder")} />
</label>
<label className="field-inline">
<span>{t("common.status")}</span>
<select value={status} onChange={(event) => setStatus(event.target.value as StatusFilter)}>
<option value="all">{t("verification.statusAll")}</option>
{statuses.map((item) => (
<option key={item} value={item}>{t(`verification.status.${item}`)}</option>
))}
</select>
</label>
<label className="field-inline">
<span>{t("verification.targetType")}</span>
<select value={targetType} onChange={(event) => setTargetType(event.target.value as TargetFilter)}>
<option value="all">{t("verification.targetTypeAll")}</option>
{targetTypes.map((item) => (
<option key={item} value={item}>{t(`verification.type.${item}`)}</option>
))}
</select>
</label>
<label className="field-inline">
<span>{t("verification.reviewer")}</span>
<input value={reviewer} onChange={(event) => setReviewer(event.target.value)} placeholder={t("verification.reviewerPlaceholder")} />
</label>
<label className="field-inline">
<span>{t("common.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} />} {t("common.search")}
</button>
</form>
</QueryPanel>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{t("common.id")}</th>
<th>{t("verification.target")}</th>
<th>{t("verification.applicant")}</th>
<th>{t("verification.category")}</th>
<th>{t("common.status")}</th>
<th>{t("verification.submittedAt")}</th>
<th>{t("verification.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">
{t(`verification.type.${row.TargetType}`)} · {row.TargetID}
</div>
{row.TargetVerified && (
<Badge tone="good"><BadgeCheck size={12} /> {t("verification.alreadyVerified")}</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} /> {t("common.detail")} <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} />} {t("common.loadMore")}
</button>
</div>
)}
</PageFrame>
);
}
export function VerificationStatusBadge({ status }: { status: VerificationStatus }) {
const { t } = useI18n();
return <Badge tone={statusTone(status)}>{t(`verification.status.${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}`;
}

View file

@ -0,0 +1,75 @@
import { ShieldOff } from "lucide-react";
import { createContext, useContext, useMemo, type ReactNode } from "react";
import { Alert, PageFrame } from "./components/ui";
import { useI18n } from "./i18n";
// 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 }) {
const { t } = useI18n();
return (
<PageFrame title={t("permission.deniedTitle")} eyebrow={t("permission.deniedEyebrow")}>
<Alert>{t("permission.deniedBody", { permission })}</Alert>
<section className="section-block">
<div className="entity-head">
<div>
<div className="entity-title"><ShieldOff size={16} /> {t("permission.deniedHeading")}</div>
<div className="entity-subtitle">{t("permission.deniedHint")}</div>
</div>
</div>
</section>
</PageFrame>
);
}

View file

@ -17,6 +17,12 @@ export function currentRoute(): RouteState {
}
export function routeTitle(pathname: string, t: TFunction): 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 t("route.botVerification");
if (pathname.startsWith("/verification")) return t("route.verification");
if (pathname.startsWith("/collectible-usernames")) return t("route.collectibleUsernames");
if (pathname.startsWith("/account-ratings")) return t("route.accountRatings");
if (pathname.startsWith("/accounts")) return t("route.accounts");
if (pathname.startsWith("/channels")) return t("route.channels");
if (pathname.startsWith("/bots")) return t("route.bots");
@ -29,6 +35,10 @@ export function routeTitle(pathname: string, t: TFunction): string {
}
export function routeSubtitle(pathname: string, t: TFunction): string {
if (pathname.startsWith("/bot-verification")) return t("route.botVerificationSubtitle");
if (pathname.startsWith("/verification")) return t("route.verificationSubtitle");
if (pathname.startsWith("/collectible-usernames")) return t("route.collectibleUsernamesSubtitle");
if (pathname.startsWith("/account-ratings")) return t("route.accountRatingsSubtitle");
if (pathname.startsWith("/accounts")) return t("route.accountsSubtitle");
if (pathname.startsWith("/channels")) return t("route.channelsSubtitle");
if (pathname.startsWith("/bots")) return t("route.botsSubtitle");

View file

@ -349,6 +349,22 @@ select {
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 {
@ -621,3 +637,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;
}

View file

@ -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;
@ -585,7 +596,8 @@
border-radius: var(--radius-sm);
}
.attr-block .duration-field input {
.attr-block .duration-field input,
.duration-field select {
width: 100%;
}
@ -685,3 +697,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;
}

View file

@ -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;
@ -349,6 +359,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;