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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -23,8 +23,8 @@
})();
</script>
<script type="module" crossorigin src="/assets/index-CUbSxjdM.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BpSP1ojC.css">
<script type="module" crossorigin src="/assets/index-DL3Lv8wS.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D8Q_54bE.css">
</head>
<body>
<div id="root"></div>

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;