added 403 screen when operator have no permission for section

This commit is contained in:
onysd 2026-09-08 02:19:09 +03:00
parent f7b3af48de
commit db40f100cd
10 changed files with 252 additions and 76 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-BdmuVdXP.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-dNagKGR1.css">
<script type="module" crossorigin src="/assets/index-Cs_euGRI.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-mpMvGEXi.css">
</head>
<body>
<div id="root"></div>

View file

@ -0,0 +1,49 @@
import { ArrowLeft, type LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import type { Navigate } from "../routing";
// A full-height "this page is not for you" screen, in place of an alert bar
// bolted to the top of an otherwise empty page frame.
//
// The status code is set large and ghosted behind the message rather than
// spelled out in the text: an operator recognises 403 at a glance, and the
// words are then free to say the useful part -- which right is missing and who
// can grant it.
export function StatusScreen({
code,
icon: Icon,
title,
children,
detail,
navigate
}: {
code: string;
icon: LucideIcon;
title: string;
children: ReactNode;
// The machine-readable thing behind the message: a permission name, a config
// key. Shown in mono, because it is what someone will have to copy.
detail?: string;
navigate?: Navigate;
}) {
return (
<section className="status-screen">
<span className="status-screen-code" aria-hidden="true">{code}</span>
<div className="status-screen-body">
<span className="status-screen-icon"><Icon size={26} /></span>
<h1>{title}</h1>
<p>{children}</p>
{detail && <code className="status-screen-detail">{detail}</code>}
{navigate && (
<button
className="btn primary icon-text"
type="button"
onClick={() => navigate("/")}
>
<ArrowLeft size={15} /> {"Back to overview"}
</button>
)}
</div>
</section>
);
}

View file

@ -32,10 +32,22 @@ export function LoginPage({ onLogin }: { onLogin: (session: AdminSession) => voi
setBusy(true);
setError("");
try {
// The login answer carries the permission set and the CSRF token; api.login
// remembers the token, the session state keeps the rights.
// api.login remembers the CSRF token and tells us the sign-in worked.
const result = await api.login(secret, username);
onLogin({ actor: result.actor, permissions: result.permissions ?? [] });
// The session itself is then read from /api/session rather than assembled
// out of the login answer. The login response carries only the actor and
// the permissions, so building a session from it silently dropped the
// build info, the API layers and the third-party-verification flag --
// which is why the sidebar footer was blank until the page was reloaded.
// One endpoint decides what a session is.
try {
onLogin(await api.session());
} catch {
// Signed in, but the follow-up read failed. Falling back to what the
// login answer does carry beats bouncing someone back to a login form
// they have already passed; a reload fills in the rest.
onLogin({ actor: result.actor, permissions: result.permissions ?? [] });
}
} catch (err) {
setError(errorMessage(err));
} finally {

View file

@ -1,3 +1,4 @@
import type { ReactNode } from "react";
import { type Navigate, type RouteState } from "../routing";
import { AccountDetailPage } from "./AccountDetailPage";
import { AccountsPage } from "./AccountsPage";
@ -27,12 +28,30 @@ import { VerificationPage } from "./VerificationPage";
import {
PermissionGate,
ThirdPartyVerificationHiddenGate,
permissionAccountsRead,
permissionAdminsManage,
permissionBotVerificationReview,
permissionServerManage, permissionAdminsManage,
permissionBotsRead,
permissionBroadcastsRead,
permissionChannelsRead,
permissionContentRead,
permissionDashboardRead,
permissionMessagesRead,
permissionModerationReview,
permissionServerManage,
permissionStorageRead,
permissionUsernamesRead,
permissionVerificationReview
} from "../permissions";
export function Routes({ route, navigate }: { route: RouteState; navigate: Navigate }) {
// Every section is wrapped in the right it needs. Without this the page
// rendered, fired its request, and showed the backend's "permission X is
// required" as a red bar over an empty table -- an error where a refusal
// belongs. Gating here means the request is never made either.
const gate = (permission: string, node: ReactNode) => (
<PermissionGate navigate={navigate} permission={permission}>{node}</PermissionGate>
);
const accountID = route.path.match(/^\/accounts\/(\d+)$/)?.[1];
const channelID = route.path.match(/^\/channels\/(\d+)$/)?.[1];
const botID = route.path.match(/^\/bots\/(\d+)$/)?.[1];
@ -45,8 +64,8 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
const botVerificationRequestID = route.path.match(/^\/bot-verification\/(\d+)$/)?.[1];
if (botVerificationRequestID) {
return (
<ThirdPartyVerificationHiddenGate>
<PermissionGate permission={permissionBotVerificationReview}>
<ThirdPartyVerificationHiddenGate navigate={navigate}>
<PermissionGate navigate={navigate} permission={permissionBotVerificationReview}>
<BotVerificationRequestPage id={botVerificationRequestID} navigate={navigate} />
</PermissionGate>
</ThirdPartyVerificationHiddenGate>
@ -54,8 +73,8 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
}
if (route.path === "/bot-verification") {
return (
<ThirdPartyVerificationHiddenGate>
<PermissionGate permission={permissionBotVerificationReview}>
<ThirdPartyVerificationHiddenGate navigate={navigate}>
<PermissionGate navigate={navigate} permission={permissionBotVerificationReview}>
<BotVerificationPage navigate={navigate} />
</PermissionGate>
</ThirdPartyVerificationHiddenGate>
@ -66,108 +85,108 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
// itself instead of rendering an empty queue.
if (verificationID) {
return (
<PermissionGate permission={permissionVerificationReview}>
<PermissionGate navigate={navigate} permission={permissionVerificationReview}>
<VerificationDetailPage id={verificationID} navigate={navigate} />
</PermissionGate>
);
}
if (route.path === "/verification") {
return (
<PermissionGate permission={permissionVerificationReview}>
<PermissionGate navigate={navigate} permission={permissionVerificationReview}>
<VerificationPage navigate={navigate} />
</PermissionGate>
);
}
if (collectibleUsernameID) {
return <CollectibleUsernameDetailPage id={collectibleUsernameID} navigate={navigate} />;
return gate(permissionUsernamesRead, <CollectibleUsernameDetailPage id={collectibleUsernameID} navigate={navigate} />);
}
if (route.path === "/collectible-usernames") {
return <CollectibleUsernamesPage navigate={navigate} />;
return gate(permissionUsernamesRead, <CollectibleUsernamesPage navigate={navigate} />);
}
if (route.path === "/storage") {
return <StoragePage navigate={navigate} />;
return gate(permissionStorageRead, <StoragePage navigate={navigate} />);
}
if (accountID) {
return <AccountDetailPage id={Number(accountID)} navigate={navigate} />;
return gate(permissionAccountsRead, <AccountDetailPage id={Number(accountID)} navigate={navigate} />);
}
if (channelID) {
return <ChannelDetailPage id={Number(channelID)} navigate={navigate} />;
return gate(permissionChannelsRead, <ChannelDetailPage id={Number(channelID)} navigate={navigate} />);
}
if (botID) {
return <BotDetailPage id={Number(botID)} navigate={navigate} />;
return gate(permissionBotsRead, <BotDetailPage id={Number(botID)} navigate={navigate} />);
}
if (moderationCaseID) {
return <ModerationCaseDetailPage id={Number(moderationCaseID)} navigate={navigate} />;
return gate(permissionModerationReview, <ModerationCaseDetailPage id={Number(moderationCaseID)} navigate={navigate} />);
}
if (route.path === "/accounts/shared-devices") {
return <SharedDevicesPage navigate={navigate} />;
return gate(permissionAccountsRead, <SharedDevicesPage navigate={navigate} />);
}
if (route.path === "/accounts") {
return <AccountsPage navigate={navigate} />;
return gate(permissionAccountsRead, <AccountsPage navigate={navigate} />);
}
if (route.path === "/channels") {
return <ChannelsPage navigate={navigate} />;
return gate(permissionChannelsRead, <ChannelsPage navigate={navigate} />);
}
if (route.path === "/bots") {
return <BotsPage navigate={navigate} />;
return gate(permissionBotsRead, <BotsPage navigate={navigate} />);
}
if (route.path === "/moderation") {
return <ModerationCasesPage navigate={navigate} />;
return gate(permissionModerationReview, <ModerationCasesPage navigate={navigate} />);
}
if (route.path === "/broadcasts") {
return <BroadcastsPage />;
return gate(permissionBroadcastsRead, <BroadcastsPage />);
}
if (route.path === "/emoji") {
return <StickerSetsPage kind="emoji" />;
return gate(permissionContentRead, <StickerSetsPage kind="emoji" />);
}
if (route.path === "/stickers") {
return <StickerSetsPage kind="stickers" />;
return gate(permissionContentRead, <StickerSetsPage kind="stickers" />);
}
if (route.path === "/gif-catalog") {
return <GifCatalogPage />;
return gate(permissionContentRead, <GifCatalogPage />);
}
if (route.path === "/admin-users") {
return (
<PermissionGate permission={permissionAdminsManage}>
<PermissionGate navigate={navigate} permission={permissionAdminsManage}>
<AdminUsersPage />
</PermissionGate>
);
}
if (route.path === "/server-settings") {
return (
<PermissionGate permission={permissionServerManage}>
<PermissionGate navigate={navigate} permission={permissionServerManage}>
<ServerSettingsPage />
</PermissionGate>
);
}
if (route.path === "/messages/detail" || route.path === "/messages/private/detail") {
return (
return gate(permissionMessagesRead, (
<MessageDetailPage
ownerUserID={Number(route.search.get("owner_user_id") || "0")}
msgID={Number(route.search.get("msg_id") || "0")}
navigate={navigate}
/>
);
));
}
if (route.path === "/messages/groups/detail") {
return (
return gate(permissionMessagesRead, (
<GroupMessageDetailPage
channelID={Number(route.search.get("channel_id") || "0")}
msgID={Number(route.search.get("msg_id") || "0")}
navigate={navigate}
/>
);
));
}
// Both tabs keep their own path so a link to one still opens on it -- the
// tab is a view of /messages, not a hidden bit of component state.
if (route.path === "/messages" || route.path === "/messages/private" || route.path === "/messages/groups") {
return (
return gate(permissionMessagesRead, (
<MessagesPage
navigate={navigate}
tab={route.path === "/messages/groups" ? "groups" : "private"}
onTab={(tab) => navigate(tab === "groups" ? "/messages/groups" : "/messages/private")}
/>
);
));
}
return <Dashboard navigate={navigate} />;
return gate(permissionDashboardRead, <Dashboard navigate={navigate} />);
}

View file

@ -1,6 +1,7 @@
import { ShieldOff } from "lucide-react";
import { EyeOff, ShieldOff } from "lucide-react";
import { createContext, useContext, useMemo, type ReactNode } from "react";
import { Alert, PageFrame } from "./components/ui";
import { StatusScreen } from "./components/StatusScreen";
import type { Navigate } from "./routing";
// Permission names exactly as the backend spells them
// (cmd/telesrv-admin/security.go). "*" is the wildcard an operator configures for
// a full-access session.
@ -85,52 +86,65 @@ export function useThirdPartyVerificationHidden(): boolean {
}
// PermissionGate is what a direct URL hits: without the right the operator gets
// an explanation naming the missing permission, not an empty table that looks
// a proper refusal naming the missing permission, not an empty table that looks
// like "no data".
export function PermissionGate({ permission, children }: { permission: string; children: ReactNode }) {
export function PermissionGate({
permission,
navigate,
children
}: {
permission: string;
navigate?: Navigate;
children: ReactNode;
}) {
const { can } = usePermissions();
if (can(permission)) {
return <>{children}</>;
}
return <PermissionDenied permission={permission} />;
return <PermissionDenied permission={permission} navigate={navigate} />;
}
export function PermissionDenied({ permission }: { permission: string }) {
export function PermissionDenied({ permission, navigate }: { permission: string; navigate?: Navigate }) {
return (
<PageFrame title={"Not enough rights"} eyebrow={"Console / Access"}>
<Alert>{`This session was not granted the ${permission} permission, so the section stays closed.`}</Alert>
<section className="section-block">
<div className="entity-head">
<div>
<div className="entity-title"><ShieldOff size={16} /> {"Section unavailable"}</div>
<div className="entity-subtitle">{"Ask an operator to add the permission to TELESRV_ADMIN_UI_PERMISSIONS and sign in again."}</div>
</div>
</div>
</section>
</PageFrame>
<StatusScreen
code="403"
icon={ShieldOff}
title={"You do not have access to this section"}
detail={permission}
navigate={navigate}
>
{/* Named in the same words the operator editor uses, so "Reveal bot
tokens" is what gets asked for rather than "bots.token.read". The raw
string is still shown below, because that is what has to be ticked. */}
{`It needs the "${permissionTitle(permission)}" permission. Ask an operator who can manage operators to add it, then sign in again.`}
</StatusScreen>
);
}
// ThirdPartyVerificationHiddenGate is what a direct URL to a third-party
// verification page hits while the feature is hidden -- distinct from
// PermissionGate because no permission grant (not even "*") changes this.
export function ThirdPartyVerificationHiddenGate({ children }: { children: ReactNode }) {
export function ThirdPartyVerificationHiddenGate({
navigate,
children
}: {
navigate?: Navigate;
children: ReactNode;
}) {
const hidden = useThirdPartyVerificationHidden();
if (!hidden) {
return <>{children}</>;
}
return (
<PageFrame title={"Feature hidden"} eyebrow={"Console / Third-party marks"}>
<Alert>{"Third-party bot verification is hidden on this server (TELESRV_HIDE_THIRD_PARTY_VERIFICATION=true)."}</Alert>
<section className="section-block">
<div className="entity-head">
<div>
<div className="entity-title"><ShieldOff size={16} /> {"Not fully finished"}</div>
<div className="entity-subtitle">{"This feature may cause unstable server behavior and is hidden by default. Set TELESRV_HIDE_THIRD_PARTY_VERIFICATION=false to re-enable it."}</div>
</div>
</div>
</section>
</PageFrame>
<StatusScreen
code="404"
icon={EyeOff}
title={"This section is switched off"}
detail="TELESRV_HIDE_THIRD_PARTY_VERIFICATION=false"
navigate={navigate}
>
{"Third-party bot verification is not finished and is hidden on this server. It is a server setting, not a permission -- no account can see it while it is off."}
</StatusScreen>
);
}

View file

@ -1102,3 +1102,85 @@ textarea:focus {
.modal.narrow {
width: min(460px, 100%);
}
/* Refusal screens (403 / 404). A full-height panel rather than an alert strip
above an empty page: hitting one is the end of that navigation, not a
warning about the page you are on. */
.status-screen {
position: relative;
display: grid;
min-height: min(560px, 70vh);
overflow: hidden;
place-items: center;
padding: 32px 24px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
text-align: center;
}
/* The status code as a watermark. Large enough to be read instantly, faint
enough that the sentence below it is what the eye lands on. */
.status-screen-code {
position: absolute;
top: 50%;
left: 50%;
color: var(--heading);
font-size: clamp(140px, 26vw, 280px);
font-weight: 800;
line-height: 1;
letter-spacing: -0.04em;
opacity: 0.05;
transform: translate(-50%, -50%);
user-select: none;
pointer-events: none;
}
.status-screen-body {
position: relative;
display: grid;
max-width: 460px;
gap: 12px;
justify-items: center;
}
.status-screen-icon {
display: grid;
width: 54px;
height: 54px;
place-items: center;
color: var(--brand);
background: var(--brand-tint);
border: 1px solid var(--brand-tint-border);
border-radius: 50%;
}
.status-screen-body h1 {
margin: 0;
color: var(--heading);
font-size: 20px;
line-height: 1.25;
}
.status-screen-body p {
margin: 0;
color: var(--text-soft);
font-size: 13px;
line-height: 1.55;
}
.status-screen-detail {
padding: 5px 10px;
color: var(--text-soft);
background: var(--panel-strong);
border: 1px solid var(--line);
border-radius: 999px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11.5px;
overflow-wrap: anywhere;
}
.status-screen-body .btn {
margin-top: 4px;
}