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

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;