import { AtSign, BadgeCheck, Bot, ChevronDown, Database, Film, LayoutDashboard, LogOut, Megaphone, MessageSquareText, PanelLeftClose, PanelLeftOpen, Settings, UserCog, UserRound, Share2, ShieldAlert, ShieldCheck, Smile, Stamp, Users, Zap, Sticker } from "lucide-react"; import { useEffect, useState, type ReactNode } from "react"; import { api, errorMessage } from "../api"; import { permissionBotVerificationReview, permissionServerManage, permissionAdminsManage, permissionAccountsRead, permissionChannelsRead, permissionBotsRead, permissionMessagesRead, permissionModerationReview, permissionBroadcastsRead, permissionStorageRead, permissionContentRead, permissionUsernamesRead, permissionVerificationReview, useCan, useThirdPartyVerificationHidden } from "../permissions"; import { type Navigate, type RouteState, routeTitle } from "../routing"; import { ThemeSwitch } from "../theme"; import { AddServerLinkModal } from "./AddServerLinkModal"; import { AppBackground } from "./AppBackground"; import { AppLink } from "./AppLink"; // Compresses a sorted (or unsorted) list of layer numbers into run-length // ranges for the compact sidebar label, e.g. [225,226,227,228,229] -> "225-229", // or [225,226,228] -> "225-226, 228" if the server ever supports a // non-contiguous set. The full list is still always shown in the tooltip. function formatLayerRanges(layers: number[]): string { const sorted = [...layers].sort((a, b) => a - b); const parts: string[] = []; let start = sorted[0]; let prev = sorted[0]; for (let i = 1; i <= sorted.length; i++) { const current = sorted[i]; if (current === prev + 1) { prev = current; continue; } parts.push(start === prev ? `${start}` : `${start}-${prev}`); start = current; prev = current; } return parts.join(", "); } export function BootScreen() { return (
OwpenGram OwpenGram {"Admin Console"}
); } export function Shell({ actor, apiLayers, build, route, navigate, onLogout, children }: { actor: string; apiLayers?: number[]; build?: { commit: string; short_commit: string; dirty: boolean; build_time: string }; route: RouteState; navigate: Navigate; onLogout: () => void; children: ReactNode; }) { // The verification queue is hidden for a session without verification.review: // the entry would only lead to a 403 (and the route itself is gated as well). const canReviewVerification = useCan(permissionVerificationReview); const canManageAdmins = useCan(permissionAdminsManage); // Each section entry is hidden without the right to open it: the route is // gated server-side either way, so showing it would only lead to a 403. const canReadAccounts = useCan(permissionAccountsRead); const canReadChannels = useCan(permissionChannelsRead); const canReadBots = useCan(permissionBotsRead); const canReadMessages = useCan(permissionMessagesRead); const canReviewModeration = useCan(permissionModerationReview); const canReadBroadcasts = useCan(permissionBroadcastsRead); const canReadStorage = useCan(permissionStorageRead); const canReadContent = useCan(permissionContentRead); const canReadUsernames = useCan(permissionUsernamesRead); // Same reasoning for the third-party queue, which has its own right: the two // sections are granted independently, so one entry can be visible without the other. const canReviewBotVerification = useCan(permissionBotVerificationReview); const canManageServer = useCan(permissionServerManage); // Remembered per browser: an operator who works in a narrow window should not // have to re-collapse the navigation on every visit. A failed read (private // mode, blocked storage) just means the default. const [navCollapsed, setNavCollapsed] = useState(() => { try { return localStorage.getItem("owpengram.nav.collapsed") === "1"; } catch { return false; } }); function toggleNav() { setNavCollapsed((current) => { const next = !current; try { localStorage.setItem("owpengram.nav.collapsed", next ? "1" : "0"); } catch { // Not being able to remember the choice is not a reason to refuse it. } return next; }); } 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 // "OwpenGram" reference build, once they've bothered to configure it. // Only fetched for sessions that can even see Server Settings; a session // without that permission just gets the default branding. const [identity, setIdentity] = useState<{ name: string; iconExt?: string } | null>(null); useEffect(() => { if (!canManageServer) return; api.serverIdentity() .then((info) => setIdentity({ name: info.name, iconExt: info.icon_ext })) .catch(() => undefined); }, [canManageServer]); const [brandIconFailed, setBrandIconFailed] = useState(false); const brandName = identity?.name?.trim() || "OwpenGram"; const brandIconSrc = identity?.iconExt && !brandIconFailed ? api.serverIconURL() : "/logo.png"; // The browser tab (title + favicon) follows the same custom-identity // override as the sidebar brand above, so a re-labeled server actually // looks like itself in the tab strip too, not just inside the app. useEffect(() => { document.title = `${brandName} Admin`; }, [brandName]); useEffect(() => { let link = document.querySelector("link[rel='icon']"); if (!link) { link = document.createElement("link"); link.rel = "icon"; document.head.appendChild(link); } const linkEl = link; const src = identity?.iconExt && !brandIconFailed ? api.serverIconURL() : "/logo.png"; // Browsers render the favicon file as-is -- they don't apply the // sidebar's CSS border-radius to it, so a square-cornered source image // (the default logo, or whatever shape an operator's uploaded icon // happens to be) shows up square in the tab strip. Bake the circular // mask into the actual pixels instead, the same way an app icon export // would, so the tab matches the round mark everywhere else in the UI. let cancelled = false; const img = new Image(); img.crossOrigin = "anonymous"; img.onload = () => { if (cancelled) { return; } const size = 64; const canvas = document.createElement("canvas"); canvas.width = size; canvas.height = size; const ctx = canvas.getContext("2d"); if (!ctx) { linkEl.href = src; return; } ctx.save(); ctx.beginPath(); ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2); ctx.closePath(); ctx.clip(); ctx.drawImage(img, 0, 0, size, size); ctx.restore(); linkEl.href = canvas.toDataURL("image/png"); }; img.onerror = () => { // Cross-origin or load failure: fall back to the raw image rather // than leaving the tab with no favicon at all. if (!cancelled) { linkEl.href = src; } }; img.src = src; return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [identity?.iconExt, brandIconFailed]); // Third-party verification is additionally hidden by default (not fully // finished) regardless of what the session was granted -- see permissions.tsx. const thirdPartyVerificationHidden = useThirdPartyVerificationHidden(); async function logout() { await api.logout().catch(() => undefined); onLogout(); } return (

{routeTitle(route.path)}

{actor}
{children}
{/* Behind everything, fixed to the viewport. The sidebar and topbar paint over it, so it shows through the working area only. */}
); } function NavLink({ href, route, navigate, icon, children, activeWhen }: { href: string; route: RouteState; navigate: Navigate; icon?: ReactNode; children: ReactNode; activeWhen?: (path: string) => boolean; }) { const active = activeWhen ? activeWhen(route.path) : href === "/" ? route.path === "/" : route.path.startsWith(href); return ( ); }