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
{"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. */}