added 403 screen when operator have no permission for section
This commit is contained in:
parent
f7b3af48de
commit
db40f100cd
10 changed files with 252 additions and 76 deletions
File diff suppressed because one or more lines are too long
9
cmd/telesrv-admin/web/dist/assets/index-Cs_euGRI.js
vendored
Normal file
9
cmd/telesrv-admin/web/dist/assets/index-Cs_euGRI.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
cmd/telesrv-admin/web/dist/assets/index-mpMvGEXi.css
vendored
Normal file
1
cmd/telesrv-admin/web/dist/assets/index-mpMvGEXi.css
vendored
Normal file
File diff suppressed because one or more lines are too long
4
cmd/telesrv-admin/web/dist/index.html
vendored
4
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -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>
|
||||
|
|
|
|||
49
cmd/telesrv-admin/web/src/components/StatusScreen.tsx
Normal file
49
cmd/telesrv-admin/web/src/components/StatusScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
// 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 {
|
||||
|
|
|
|||
|
|
@ -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} />);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue