This commit is contained in:
onysd 2026-09-01 20:06:58 +03:00
parent d2f4e11390
commit 95e62c2d77
17 changed files with 288 additions and 16 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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-D9dyWksN.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DUxLVnzO.css">
<script type="module" crossorigin src="/assets/index-CP20QCwX.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Wtwg__bv.css">
</head>
<body>
<div id="root"></div>

View file

@ -237,6 +237,7 @@ export const api = {
gifCatalog: () => request<GifCatalogListResponse>("/api/gif-catalog"),
createGifCatalogEntry: (form: FormData) => request<CommandResult>("/api/actions/create-gif-catalog-entry", { method: "POST", body: form }),
serverIdentity: () => request<ServerIdentity>("/api/server/identity"),
addServerLink: () => request<{ link: string }>("/api/server/add-server-link"),
uploadServerIcon: (form: FormData) => request<CommandResult>("/api/actions/upload-server-icon", { method: "POST", body: form }),
serverIconURL: () => `/api/server/icon?t=${Date.now()}`,
serverEnv: () => request<EnvGroup[]>("/api/server/env"),

View file

@ -0,0 +1,28 @@
// navigator.clipboard only exists in a secure context (HTTPS, or localhost).
// This admin panel is frequently reached over a plain http:// LAN address
// (e.g. a self-hosted server's own IP), where navigator.clipboard is simply
// undefined -- calling .writeText on it throws "Cannot read properties of
// undefined". Fall back to the old execCommand('copy') path via a hidden,
// off-screen textarea, which still works in that case.
export async function copyToClipboard(text: string): Promise<void> {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
}
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.top = "-1000px";
textarea.style.left = "-1000px";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
try {
const ok = document.execCommand("copy");
if (!ok) {
throw new Error("Copy command was not successful");
}
} finally {
document.body.removeChild(textarea);
}
}

View file

@ -3,6 +3,7 @@ import type { ReactNode } from "react";
import { useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { api, errorMessage } from "../api";
import { copyToClipboard } from "../clipboard";
import type { CommandResult } from "../types";
import { Alert, JsonBlock } from "./ui";
@ -94,7 +95,7 @@ export function ActionButton({
: result?.details;
async function copySecret() {
await navigator.clipboard.writeText(secretValue);
await copyToClipboard(secretValue);
setSecretCopied(true);
}

View file

@ -0,0 +1,81 @@
import { Check, Copy, X } from "lucide-react";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { api, errorMessage } from "../api";
import { copyToClipboard } from "../clipboard";
import { Alert, LoadingSurface } from "./ui";
// AddServerLinkModal shows a ready-made owpg://addserver link for this
// exact server (host+port only -- see the Go handler's doc comment for why
// name/description/key/DC are deliberately never embedded in it) -- an
// operator hands this out (a website button, a QR code, a message
// elsewhere) and the desktop/Android client's "Add Server" form opens
// pre-filled from it, fetching the rest straight from this server itself.
export function AddServerLinkModal({ onClose }: { onClose: () => void }) {
const [link, setLink] = useState<string | null>(null);
const [error, setError] = useState("");
const [copied, setCopied] = useState(false);
useEffect(() => {
let cancelled = false;
api.addServerLink()
.then((result) => {
if (!cancelled) setLink(result.link);
})
.catch((err) => {
if (!cancelled) setError(errorMessage(err));
});
return () => {
cancelled = true;
};
}, []);
async function copy() {
if (!link) return;
try {
await copyToClipboard(link);
setCopied(true);
} catch (err) {
setError(errorMessage(err));
}
}
return createPortal(
<div className="modal-backdrop" role="presentation">
<section className="modal command-modal add-server-link-modal" role="dialog" aria-modal="true" aria-label={"Share server"}>
<div className="modal-head">
<div>
<div className="eyebrow">{"Server"}</div>
<h2>{"Share server"}</h2>
</div>
<button className="icon-btn" type="button" onClick={onClose} aria-label={"Close"}><X size={15} /></button>
</div>
<div className="command-body">
<p>{"Share this link (a button, a QR code, a message) so anyone with the OwpenGram client can add this server in one tap. It only carries the address and port -- the client fetches the name, description, and key directly from the server itself, so the link can never be tampered with to point someone at a fake identity for this address."}</p>
{error && <Alert>{error}</Alert>}
{!link && !error && <LoadingSurface label={"Building link..."} />}
{link && (
<div className="add-server-link-field">
<label className="form-field">
<span>{"owpg:// link"}</span>
<textarea value={link} readOnly rows={2} onFocus={(event) => event.currentTarget.select()} />
</label>
<button className="btn primary icon-text" type="button" onClick={() => void copy()}>
<Copy size={15} /> {copied ? "Copy again" : "Copy link"}
</button>
{copied && (
<div className="secret-reveal">
<div className="secret-reveal-label"><Check size={14} /> {"Copied to clipboard."}</div>
</div>
)}
</div>
)}
</div>
<div className="modal-actions">
<button className="btn" type="button" onClick={onClose}>{"Close"}</button>
</div>
</section>
</div>,
document.body
);
}

View file

@ -2,6 +2,7 @@ import { Check, Copy, X } from "lucide-react";
import { useState } from "react";
import { createPortal } from "react-dom";
import { api, errorMessage } from "../api";
import { copyToClipboard } from "../clipboard";
import { Alert } from "./ui";
// CopyBotTokenModal writes a non-system bot's token straight to the
@ -34,7 +35,7 @@ export function CopyBotTokenModal({ botID, onClose }: { botID: number; onClose:
setError(result.error || "No token returned.");
return;
}
await navigator.clipboard.writeText(token);
await copyToClipboard(token);
setCopied(true);
} catch (err) {
setError(errorMessage(err));

View file

@ -10,18 +10,21 @@ import {
Megaphone,
MessageSquareText,
Settings,
Share2,
ShieldAlert,
ShieldCheck,
Smile,
Stamp,
Users,
Zap,
Sticker
} from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { api } from "../api";
import { api, errorMessage } from "../api";
import { permissionBotVerificationReview, permissionServerManage, permissionVerificationReview, useCan, useThirdPartyVerificationHidden } from "../permissions";
import { type Navigate, type RouteState, routeTitle } from "../routing";
import { ThemeSwitch } from "../theme";
import { AddServerLinkModal } from "./AddServerLinkModal";
import { AppLink } from "./AppLink";
// Compresses a sorted (or unsorted) list of layer numbers into run-length
@ -85,6 +88,29 @@ export function Shell({
// sections are granted independently, so one entry can be visible without the other.
const canReviewBotVerification = useCan(permissionBotVerificationReview);
const canManageServer = useCan(permissionServerManage);
const [addServerLinkOpen, setAddServerLinkOpen] = useState(false);
const [connecting, setConnecting] = useState(false);
const [connectError, setConnectError] = useState("");
// "Connect" is the short-cut version of Share: instead of showing a link to
// copy elsewhere, it opens the owpg://addserver link (host+port only --
// see the Go handler's doc comment for why nothing else ever goes in it)
// right here in this browser, so if an OwpenGram client is registered for
// that scheme on this machine, it launches straight into "Add Server"
// pre-filled for the server this very admin panel manages, fetching the
// rest (name/description/key) straight from it.
async function connectThisServer() {
setConnecting(true);
setConnectError("");
try {
const result = await api.addServerLink();
window.location.href = result.link;
} catch (err) {
setConnectError(errorMessage(err));
} finally {
setConnecting(false);
}
}
// Server identity (name/icon) is admin-editable per Server Settings ->
// Server identity, and takes over the sidebar branding when set -- the
// operator's own server should look like their server, not like the
@ -256,6 +282,29 @@ export function Shell({
</span>
)}
</div>
{canManageServer && (
<div className="sidebar-server-actions">
<button
className="btn ghost sidebar-server-action"
type="button"
title={"Connect this browser's client to this server"}
disabled={connecting}
onClick={() => void connectThisServer()}
>
<Zap size={15} /> {"Connect"}
</button>
<button
className="btn ghost sidebar-server-action"
type="button"
title={"Share server (get an add-server link)"}
onClick={() => setAddServerLinkOpen(true)}
>
<Share2 size={15} /> {"Share"}
</button>
</div>
)}
{connectError && <div className="sidebar-server-action-error">{connectError}</div>}
{addServerLinkOpen && <AddServerLinkModal onClose={() => setAddServerLinkOpen(false)} />}
</aside>
<div className="workspace">
<header className="topbar">

View file

@ -1,6 +1,7 @@
import { Check, ChevronRight, Copy, Loader2, RefreshCw, Search } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { copyToClipboard } from "../clipboard";
import { StaticLottie } from "../components/StaticLottie";
import { Alert, Metric, PageFrame, QueryPanel } from "../components/ui";
import type { EmojiListResponse, EmojiRow } from "../types";
@ -43,7 +44,7 @@ function EmojiCard({ row }: { row: EmojiRow }) {
async function copy() {
try {
await navigator.clipboard.writeText(row.DocumentID);
await copyToClipboard(row.DocumentID);
setCopied(true);
setTimeout(() => setCopied(false), 1200);
} catch {

View file

@ -256,6 +256,35 @@ a {
font-size: 11px;
}
.sidebar-server-actions {
display: flex;
gap: 8px;
}
.sidebar-server-action {
flex: 1 1 0;
min-width: 0;
font-size: 12.5px;
color: var(--sidebar-text);
background: transparent;
border-color: var(--sidebar-line);
}
.sidebar-server-action:hover:not(:disabled) {
background: var(--sidebar-line);
}
.sidebar-server-action:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.sidebar-server-action-error {
padding: 0 4px;
color: var(--danger, #e5484d);
font-size: 11.5px;
}
.sidebar-label {
padding: 0 8px;
color: var(--sidebar-faint);

View file

@ -63,6 +63,33 @@
gap: 8px;
}
.add-server-link-modal {
width: min(920px, 100%);
max-height: min(880px, calc(100vh - 48px));
}
.add-server-link-field {
display: grid;
gap: 8px;
padding: 14px;
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: var(--radius-md);
}
.add-server-link-field textarea {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 12px;
word-break: break-all;
resize: vertical;
}
.add-server-link-hint {
margin: 0;
color: var(--muted-2);
font-size: 12.5px;
}
.command-body {
display: grid;
min-height: 0;