added ability to copy bot token
This commit is contained in:
parent
bb1f680d3b
commit
8aad71643f
16 changed files with 294 additions and 23 deletions
|
|
@ -108,6 +108,7 @@ func (s *server) routes() http.Handler {
|
|||
mux.Handle("POST /api/actions/set-channel-emoji-status", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelEmojiStatusAPI)))
|
||||
mux.Handle("POST /api/actions/create-bot", s.requireAuthAPI(http.HandlerFunc(s.handleCreateBotAPI)))
|
||||
mux.Handle("POST /api/actions/delete-bot", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteBotAPI)))
|
||||
mux.Handle("POST /api/actions/export-bot-token", s.requireAuthAPI(http.HandlerFunc(s.handleExportBotTokenAPI)))
|
||||
mux.Handle("POST /api/actions/set-channel-verified", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelVerifiedAPI)))
|
||||
mux.Handle("POST /api/actions/revoke-sessions", s.requireAuthAPI(http.HandlerFunc(s.handleRevokeSessionsAPI)))
|
||||
mux.Handle("POST /api/actions/delete-messages", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteMessagesAPI)))
|
||||
|
|
@ -897,6 +898,26 @@ func (s *server) handleDeleteBotAPI(w http.ResponseWriter, r *http.Request) {
|
|||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type exportBotTokenAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
BotUserID int64 `json:"bot_user_id"`
|
||||
}
|
||||
|
||||
func (s *server) handleExportBotTokenAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body exportBotTokenAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.ExportBotTokenRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "export-bot-token"),
|
||||
BotUserID: body.BotUserID,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/bots/export-token", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
func (s *server) handleChannelsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if s.read == nil {
|
||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||
|
|
|
|||
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