added ability to copy bot token

This commit is contained in:
onysd 2026-08-05 22:33:33 +03:00
parent bb1f680d3b
commit 8aad71643f
16 changed files with 294 additions and 23 deletions

View file

@ -1,4 +1,4 @@
import { CheckCircle2, CircleAlert, FileJson, Loader2, Play, X } from "lucide-react";
import { Check, CheckCircle2, CircleAlert, Copy, FileJson, Loader2, Play, X } from "lucide-react";
import type { ReactNode } from "react";
import { useMemo, useState } from "react";
import { createPortal } from "react-dom";
@ -17,7 +17,8 @@ export function ActionButton({
tone = "danger",
disabled = false,
onDone,
onError
onError,
secretField
}: {
label: string;
path: string;
@ -34,17 +35,24 @@ export function ActionButton({
// form — an optimistic-locking 409, say — and replace the raw backend text with
// an explanation by returning it.
onError?: (error: unknown) => string | undefined;
// secretField names a key in result.details that holds a one-time secret
// (e.g. a bot token) -- when present, it's pulled out of the generic JSON
// dump and rendered instead as its own copy-to-clipboard callout, so it
// doesn't get lost among the other fields.
secretField?: string;
}) {
const [open, setOpen] = useState(false);
const [reason, setReason] = useState("");
const [result, setResult] = useState<CommandResult | null>(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [secretCopied, setSecretCopied] = useState(false);
function reset() {
setReason("");
setResult(null);
setError("");
setSecretCopied(false);
}
async function run(confirm: boolean) {
@ -78,6 +86,18 @@ export function ActionButton({
}
}, [open, payload]);
const secretValue = secretField && result?.details && typeof result.details[secretField] === "string"
? (result.details[secretField] as string)
: "";
const visibleDetails = secretValue && result?.details
? Object.fromEntries(Object.entries(result.details).filter(([key]) => key !== secretField))
: result?.details;
async function copySecret() {
await navigator.clipboard.writeText(secretValue);
setSecretCopied(true);
}
return (
<>
<button
@ -133,7 +153,19 @@ export function ActionButton({
<div className="result-line"><span>{"Status"}</span><strong>{result.status}</strong></div>
<div className="result-line"><span>{"Dry-run"}</span><strong>{result.dry_run ? "Yes" : "No"}</strong></div>
<div className="result-message">{result.message || result.error}</div>
{result.details && <JsonBlock value={JSON.stringify(result.details, null, 2)} />}
{secretValue && (
<div className="secret-reveal">
<div className="secret-reveal-label">{"One-time secret — copy it now, it won't be shown again"}</div>
<div className="secret-reveal-row">
<code className="secret-reveal-value">{"•".repeat(Math.min(secretValue.length, 40))}</code>
<button className="btn icon-text" type="button" onClick={() => void copySecret()}>
{secretCopied ? <Check size={15} /> : <Copy size={15} />}
{secretCopied ? "Copied" : "Copy"}
</button>
</div>
</div>
)}
{visibleDetails && Object.keys(visibleDetails).length > 0 && <JsonBlock value={JSON.stringify(visibleDetails, null, 2)} />}
</div>
)}
</div>

View file

@ -0,0 +1,79 @@
import { Check, Copy, X } from "lucide-react";
import { useState } from "react";
import { createPortal } from "react-dom";
import { api, errorMessage } from "../api";
import { Alert } from "./ui";
// CopyBotTokenModal writes a non-system bot's token straight to the
// clipboard without ever rendering it on screen -- the value only ever
// lives in the fetch response and the clipboard API call; React state only
// ever tracks whether the copy succeeded, never the token itself.
export function CopyBotTokenModal({ botID, onClose }: { botID: number; onClose: () => void }) {
const [reason, setReason] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [copied, setCopied] = useState(false);
async function copyToken() {
if (!reason.trim()) {
setError("Please enter an operation reason");
return;
}
setBusy(true);
setError("");
setCopied(false);
try {
const result = await api.action("/api/actions/export-bot-token", {
command_id: "",
reason: reason.trim(),
confirm: true,
bot_user_id: botID
});
const token = result.details?.token;
if (result.error || typeof token !== "string" || !token) {
setError(result.error || "No token returned.");
return;
}
await navigator.clipboard.writeText(token);
setCopied(true);
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
return createPortal(
<div className="modal-backdrop" role="presentation">
<section className="modal command-modal" role="dialog" aria-modal="true" aria-label={"Copy bot token"}>
<div className="modal-head">
<div>
<div className="eyebrow">{"Bot"}</div>
<h2>{"Copy bot token"}</h2>
</div>
<button className="icon-btn" type="button" onClick={onClose} disabled={busy} aria-label={"Close"}><X size={15} /></button>
</div>
<div className="command-body">
<p>{"The token is written straight to your clipboard and is never shown on screen. Paste it wherever it's needed right after copying."}</p>
<label className="form-field">
<span>{"Operation reason"}</span>
<textarea value={reason} onChange={(event) => setReason(event.target.value)} rows={3} placeholder={"Describe why this token is being retrieved"} />
</label>
{error && <Alert>{error}</Alert>}
{copied && (
<div className="secret-reveal">
<div className="secret-reveal-label"><Check size={14} /> {"Token copied to clipboard."}</div>
</div>
)}
</div>
<div className="modal-actions">
<button className="btn" type="button" onClick={onClose} disabled={busy}>{"Close"}</button>
<button className="btn primary icon-text" type="button" onClick={() => void copyToken()} disabled={busy}>
<Copy size={15} /> {copied ? "Copy again" : "Copy token"}
</button>
</div>
</section>
</div>,
document.body
);
}

View file

@ -14,11 +14,11 @@ export function CreateBotModal({ onClose, onCreated }: { onClose: () => void; on
return createPortal(
<div className="modal-backdrop" role="presentation">
<section className="modal command-modal" role="dialog" aria-modal="true" aria-label={"Create a system bot"}>
<section className="modal command-modal" role="dialog" aria-modal="true" aria-label={"Create bot"}>
<div className="modal-head">
<div>
<div className="eyebrow">{"Bots"}</div>
<h2>{"Create a system bot"}</h2>
<h2>{"Create bot"}</h2>
</div>
<button className="icon-btn" type="button" onClick={onClose} aria-label={"Close"}><X size={15} /></button>
</div>
@ -58,6 +58,7 @@ export function CreateBotModal({ onClose, onCreated }: { onClose: () => void; on
name: botName.trim(),
username: botUsername.trim().replace(/^@/, "")
})}
secretField="token"
onDone={onCreated}
/>
</div>

View file

@ -1,9 +1,10 @@
import { ArrowLeft, BadgeCheck, ImagePlus, ScrollText, Settings2, Trash2, UserRound } from "lucide-react";
import { ArrowLeft, BadgeCheck, Copy, ImagePlus, ScrollText, Settings2, Trash2, UserRound } from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { Avatar } from "../components/Avatar";
import { AvatarModal } from "../components/AvatarModal";
import { CopyBotTokenModal } from "../components/CopyBotTokenModal";
import { Alert, AuditTable, Badge, LoadingSurface, PageFrame, SectionHead, Summary } from "../components/ui";
import { ScamFakeActions, ScamFakeBadges } from "../components/flags";
import { ColorAction, EmojiStatusAction, UsernameAction } from "../components/attributes";
@ -20,6 +21,7 @@ export function BotDetailPage({ id, navigate }: { id: number; navigate: Navigate
const [tab, setTab] = useState<Tab>("profile");
const [avatarModalOpen, setAvatarModalOpen] = useState(false);
const [avatarVersion, setAvatarVersion] = useState(0);
const [copyTokenModalOpen, setCopyTokenModalOpen] = useState(false);
async function load() {
setBusy(true);
@ -145,6 +147,18 @@ export function BotDetailPage({ id, navigate }: { id: number; navigate: Navigate
<EmojiStatusAction idKey="user_id" id={bot.ID} path="/api/actions/set-account-emoji-status" onDone={load} />
</section>
{!bot.System && (
<section className="section-block">
<SectionHead title={"Credentials"} />
<div className="action-stack">
<button className="btn icon-text" type="button" onClick={() => setCopyTokenModalOpen(true)}>
<Copy size={15} /> {"Copy token"}
</button>
</div>
<p className="bot-create-note">{"Copies straight to the clipboard through a dedicated confirmation step -- the token itself is never shown on this page."}</p>
</section>
)}
<section className="section-block">
<SectionHead title={"Danger Zone"} />
{bot.System ? (
@ -183,6 +197,10 @@ export function BotDetailPage({ id, navigate }: { id: number; navigate: Navigate
}}
/>
)}
{copyTokenModalOpen && (
<CopyBotTokenModal botID={bot.ID} onClose={() => setCopyTokenModalOpen(false)} />
)}
</PageFrame>
);
}

View file

@ -172,10 +172,11 @@ export function BotsPage({ navigate }: { navigate: Navigate }) {
{createModalOpen && (
<CreateBotModal
onClose={() => setCreateModalOpen(false)}
onCreated={() => {
setCreateModalOpen(false);
void loadFresh();
}}
// Deliberately does not close the modal -- the token is only ever
// shown once, inside ActionButton's own result panel, and closing
// immediately would yank it away before it can be copied. The
// operator closes both modals manually once they're done with it.
onCreated={() => void loadFresh()}
/>
)}
</PageFrame>

View file

@ -168,6 +168,41 @@
color: var(--text-soft);
}
.secret-reveal {
display: grid;
gap: 6px;
padding: 10px;
background: var(--warn-tint);
border: 1px solid var(--warn-border);
border-radius: var(--radius);
}
.secret-reveal-label {
color: var(--warn);
font-size: 12px;
font-weight: 800;
}
.secret-reveal-row {
display: flex;
align-items: center;
gap: 10px;
}
.secret-reveal-value {
overflow: hidden;
flex: 1 1 auto;
padding: 6px 10px;
color: var(--text-soft);
letter-spacing: .12em;
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.modal-actions {
justify-content: flex-end;
padding: 12px 18px;