added ability to copy bot token
This commit is contained in:
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
10
cmd/telesrv-admin/web/dist/assets/index-DL3Lv8wS.js
vendored
Normal file
10
cmd/telesrv-admin/web/dist/assets/index-DL3Lv8wS.js
vendored
Normal file
File diff suppressed because one or more lines are too long
4
cmd/telesrv-admin/web/dist/index.html
vendored
4
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -23,8 +23,8 @@
|
|||
})();
|
||||
</script>
|
||||
|
||||
<script type="module" crossorigin src="/assets/index-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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
79
cmd/telesrv-admin/web/src/components/CopyBotTokenModal.tsx
Normal file
79
cmd/telesrv-admin/web/src/components/CopyBotTokenModal.tsx
Normal 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
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue