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>
|
||||||
|
|
||||||
<script type="module" crossorigin src="/assets/index-BdmuVdXP.js"></script>
|
<script type="module" crossorigin src="/assets/index-Cs_euGRI.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-dNagKGR1.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-mpMvGEXi.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<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);
|
setBusy(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
// The login answer carries the permission set and the CSRF token; api.login
|
// api.login remembers the CSRF token and tells us the sign-in worked.
|
||||||
// remembers the token, the session state keeps the rights.
|
|
||||||
const result = await api.login(secret, username);
|
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) {
|
} catch (err) {
|
||||||
setError(errorMessage(err));
|
setError(errorMessage(err));
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
import { type Navigate, type RouteState } from "../routing";
|
import { type Navigate, type RouteState } from "../routing";
|
||||||
import { AccountDetailPage } from "./AccountDetailPage";
|
import { AccountDetailPage } from "./AccountDetailPage";
|
||||||
import { AccountsPage } from "./AccountsPage";
|
import { AccountsPage } from "./AccountsPage";
|
||||||
|
|
@ -27,12 +28,30 @@ import { VerificationPage } from "./VerificationPage";
|
||||||
import {
|
import {
|
||||||
PermissionGate,
|
PermissionGate,
|
||||||
ThirdPartyVerificationHiddenGate,
|
ThirdPartyVerificationHiddenGate,
|
||||||
|
permissionAccountsRead,
|
||||||
|
permissionAdminsManage,
|
||||||
permissionBotVerificationReview,
|
permissionBotVerificationReview,
|
||||||
permissionServerManage, permissionAdminsManage,
|
permissionBotsRead,
|
||||||
|
permissionBroadcastsRead,
|
||||||
|
permissionChannelsRead,
|
||||||
|
permissionContentRead,
|
||||||
|
permissionDashboardRead,
|
||||||
|
permissionMessagesRead,
|
||||||
|
permissionModerationReview,
|
||||||
|
permissionServerManage,
|
||||||
|
permissionStorageRead,
|
||||||
|
permissionUsernamesRead,
|
||||||
permissionVerificationReview
|
permissionVerificationReview
|
||||||
} from "../permissions";
|
} from "../permissions";
|
||||||
|
|
||||||
export function Routes({ route, navigate }: { route: RouteState; navigate: Navigate }) {
|
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 accountID = route.path.match(/^\/accounts\/(\d+)$/)?.[1];
|
||||||
const channelID = route.path.match(/^\/channels\/(\d+)$/)?.[1];
|
const channelID = route.path.match(/^\/channels\/(\d+)$/)?.[1];
|
||||||
const botID = route.path.match(/^\/bots\/(\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];
|
const botVerificationRequestID = route.path.match(/^\/bot-verification\/(\d+)$/)?.[1];
|
||||||
if (botVerificationRequestID) {
|
if (botVerificationRequestID) {
|
||||||
return (
|
return (
|
||||||
<ThirdPartyVerificationHiddenGate>
|
<ThirdPartyVerificationHiddenGate navigate={navigate}>
|
||||||
<PermissionGate permission={permissionBotVerificationReview}>
|
<PermissionGate navigate={navigate} permission={permissionBotVerificationReview}>
|
||||||
<BotVerificationRequestPage id={botVerificationRequestID} navigate={navigate} />
|
<BotVerificationRequestPage id={botVerificationRequestID} navigate={navigate} />
|
||||||
</PermissionGate>
|
</PermissionGate>
|
||||||
</ThirdPartyVerificationHiddenGate>
|
</ThirdPartyVerificationHiddenGate>
|
||||||
|
|
@ -54,8 +73,8 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
|
||||||
}
|
}
|
||||||
if (route.path === "/bot-verification") {
|
if (route.path === "/bot-verification") {
|
||||||
return (
|
return (
|
||||||
<ThirdPartyVerificationHiddenGate>
|
<ThirdPartyVerificationHiddenGate navigate={navigate}>
|
||||||
<PermissionGate permission={permissionBotVerificationReview}>
|
<PermissionGate navigate={navigate} permission={permissionBotVerificationReview}>
|
||||||
<BotVerificationPage navigate={navigate} />
|
<BotVerificationPage navigate={navigate} />
|
||||||
</PermissionGate>
|
</PermissionGate>
|
||||||
</ThirdPartyVerificationHiddenGate>
|
</ThirdPartyVerificationHiddenGate>
|
||||||
|
|
@ -66,108 +85,108 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
|
||||||
// itself instead of rendering an empty queue.
|
// itself instead of rendering an empty queue.
|
||||||
if (verificationID) {
|
if (verificationID) {
|
||||||
return (
|
return (
|
||||||
<PermissionGate permission={permissionVerificationReview}>
|
<PermissionGate navigate={navigate} permission={permissionVerificationReview}>
|
||||||
<VerificationDetailPage id={verificationID} navigate={navigate} />
|
<VerificationDetailPage id={verificationID} navigate={navigate} />
|
||||||
</PermissionGate>
|
</PermissionGate>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (route.path === "/verification") {
|
if (route.path === "/verification") {
|
||||||
return (
|
return (
|
||||||
<PermissionGate permission={permissionVerificationReview}>
|
<PermissionGate navigate={navigate} permission={permissionVerificationReview}>
|
||||||
<VerificationPage navigate={navigate} />
|
<VerificationPage navigate={navigate} />
|
||||||
</PermissionGate>
|
</PermissionGate>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (collectibleUsernameID) {
|
if (collectibleUsernameID) {
|
||||||
return <CollectibleUsernameDetailPage id={collectibleUsernameID} navigate={navigate} />;
|
return gate(permissionUsernamesRead, <CollectibleUsernameDetailPage id={collectibleUsernameID} navigate={navigate} />);
|
||||||
}
|
}
|
||||||
if (route.path === "/collectible-usernames") {
|
if (route.path === "/collectible-usernames") {
|
||||||
return <CollectibleUsernamesPage navigate={navigate} />;
|
return gate(permissionUsernamesRead, <CollectibleUsernamesPage navigate={navigate} />);
|
||||||
}
|
}
|
||||||
if (route.path === "/storage") {
|
if (route.path === "/storage") {
|
||||||
return <StoragePage navigate={navigate} />;
|
return gate(permissionStorageRead, <StoragePage navigate={navigate} />);
|
||||||
}
|
}
|
||||||
if (accountID) {
|
if (accountID) {
|
||||||
return <AccountDetailPage id={Number(accountID)} navigate={navigate} />;
|
return gate(permissionAccountsRead, <AccountDetailPage id={Number(accountID)} navigate={navigate} />);
|
||||||
}
|
}
|
||||||
if (channelID) {
|
if (channelID) {
|
||||||
return <ChannelDetailPage id={Number(channelID)} navigate={navigate} />;
|
return gate(permissionChannelsRead, <ChannelDetailPage id={Number(channelID)} navigate={navigate} />);
|
||||||
}
|
}
|
||||||
if (botID) {
|
if (botID) {
|
||||||
return <BotDetailPage id={Number(botID)} navigate={navigate} />;
|
return gate(permissionBotsRead, <BotDetailPage id={Number(botID)} navigate={navigate} />);
|
||||||
}
|
}
|
||||||
if (moderationCaseID) {
|
if (moderationCaseID) {
|
||||||
return <ModerationCaseDetailPage id={Number(moderationCaseID)} navigate={navigate} />;
|
return gate(permissionModerationReview, <ModerationCaseDetailPage id={Number(moderationCaseID)} navigate={navigate} />);
|
||||||
}
|
}
|
||||||
if (route.path === "/accounts/shared-devices") {
|
if (route.path === "/accounts/shared-devices") {
|
||||||
return <SharedDevicesPage navigate={navigate} />;
|
return gate(permissionAccountsRead, <SharedDevicesPage navigate={navigate} />);
|
||||||
}
|
}
|
||||||
if (route.path === "/accounts") {
|
if (route.path === "/accounts") {
|
||||||
return <AccountsPage navigate={navigate} />;
|
return gate(permissionAccountsRead, <AccountsPage navigate={navigate} />);
|
||||||
}
|
}
|
||||||
if (route.path === "/channels") {
|
if (route.path === "/channels") {
|
||||||
return <ChannelsPage navigate={navigate} />;
|
return gate(permissionChannelsRead, <ChannelsPage navigate={navigate} />);
|
||||||
}
|
}
|
||||||
if (route.path === "/bots") {
|
if (route.path === "/bots") {
|
||||||
return <BotsPage navigate={navigate} />;
|
return gate(permissionBotsRead, <BotsPage navigate={navigate} />);
|
||||||
}
|
}
|
||||||
if (route.path === "/moderation") {
|
if (route.path === "/moderation") {
|
||||||
return <ModerationCasesPage navigate={navigate} />;
|
return gate(permissionModerationReview, <ModerationCasesPage navigate={navigate} />);
|
||||||
}
|
}
|
||||||
if (route.path === "/broadcasts") {
|
if (route.path === "/broadcasts") {
|
||||||
return <BroadcastsPage />;
|
return gate(permissionBroadcastsRead, <BroadcastsPage />);
|
||||||
}
|
}
|
||||||
if (route.path === "/emoji") {
|
if (route.path === "/emoji") {
|
||||||
return <StickerSetsPage kind="emoji" />;
|
return gate(permissionContentRead, <StickerSetsPage kind="emoji" />);
|
||||||
}
|
}
|
||||||
if (route.path === "/stickers") {
|
if (route.path === "/stickers") {
|
||||||
return <StickerSetsPage kind="stickers" />;
|
return gate(permissionContentRead, <StickerSetsPage kind="stickers" />);
|
||||||
}
|
}
|
||||||
if (route.path === "/gif-catalog") {
|
if (route.path === "/gif-catalog") {
|
||||||
return <GifCatalogPage />;
|
return gate(permissionContentRead, <GifCatalogPage />);
|
||||||
}
|
}
|
||||||
if (route.path === "/admin-users") {
|
if (route.path === "/admin-users") {
|
||||||
return (
|
return (
|
||||||
<PermissionGate permission={permissionAdminsManage}>
|
<PermissionGate navigate={navigate} permission={permissionAdminsManage}>
|
||||||
<AdminUsersPage />
|
<AdminUsersPage />
|
||||||
</PermissionGate>
|
</PermissionGate>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (route.path === "/server-settings") {
|
if (route.path === "/server-settings") {
|
||||||
return (
|
return (
|
||||||
<PermissionGate permission={permissionServerManage}>
|
<PermissionGate navigate={navigate} permission={permissionServerManage}>
|
||||||
<ServerSettingsPage />
|
<ServerSettingsPage />
|
||||||
</PermissionGate>
|
</PermissionGate>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (route.path === "/messages/detail" || route.path === "/messages/private/detail") {
|
if (route.path === "/messages/detail" || route.path === "/messages/private/detail") {
|
||||||
return (
|
return gate(permissionMessagesRead, (
|
||||||
<MessageDetailPage
|
<MessageDetailPage
|
||||||
ownerUserID={Number(route.search.get("owner_user_id") || "0")}
|
ownerUserID={Number(route.search.get("owner_user_id") || "0")}
|
||||||
msgID={Number(route.search.get("msg_id") || "0")}
|
msgID={Number(route.search.get("msg_id") || "0")}
|
||||||
navigate={navigate}
|
navigate={navigate}
|
||||||
/>
|
/>
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
if (route.path === "/messages/groups/detail") {
|
if (route.path === "/messages/groups/detail") {
|
||||||
return (
|
return gate(permissionMessagesRead, (
|
||||||
<GroupMessageDetailPage
|
<GroupMessageDetailPage
|
||||||
channelID={Number(route.search.get("channel_id") || "0")}
|
channelID={Number(route.search.get("channel_id") || "0")}
|
||||||
msgID={Number(route.search.get("msg_id") || "0")}
|
msgID={Number(route.search.get("msg_id") || "0")}
|
||||||
navigate={navigate}
|
navigate={navigate}
|
||||||
/>
|
/>
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
// Both tabs keep their own path so a link to one still opens on it -- the
|
// 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.
|
// 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") {
|
if (route.path === "/messages" || route.path === "/messages/private" || route.path === "/messages/groups") {
|
||||||
return (
|
return gate(permissionMessagesRead, (
|
||||||
<MessagesPage
|
<MessagesPage
|
||||||
navigate={navigate}
|
navigate={navigate}
|
||||||
tab={route.path === "/messages/groups" ? "groups" : "private"}
|
tab={route.path === "/messages/groups" ? "groups" : "private"}
|
||||||
onTab={(tab) => navigate(tab === "groups" ? "/messages/groups" : "/messages/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 { 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
|
// Permission names exactly as the backend spells them
|
||||||
// (cmd/telesrv-admin/security.go). "*" is the wildcard an operator configures for
|
// (cmd/telesrv-admin/security.go). "*" is the wildcard an operator configures for
|
||||||
// a full-access session.
|
// 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
|
// 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".
|
// 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();
|
const { can } = usePermissions();
|
||||||
if (can(permission)) {
|
if (can(permission)) {
|
||||||
return <>{children}</>;
|
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 (
|
return (
|
||||||
<PageFrame title={"Not enough rights"} eyebrow={"Console / Access"}>
|
<StatusScreen
|
||||||
<Alert>{`This session was not granted the ${permission} permission, so the section stays closed.`}</Alert>
|
code="403"
|
||||||
<section className="section-block">
|
icon={ShieldOff}
|
||||||
<div className="entity-head">
|
title={"You do not have access to this section"}
|
||||||
<div>
|
detail={permission}
|
||||||
<div className="entity-title"><ShieldOff size={16} /> {"Section unavailable"}</div>
|
navigate={navigate}
|
||||||
<div className="entity-subtitle">{"Ask an operator to add the permission to TELESRV_ADMIN_UI_PERMISSIONS and sign in again."}</div>
|
>
|
||||||
</div>
|
{/* Named in the same words the operator editor uses, so "Reveal bot
|
||||||
</div>
|
tokens" is what gets asked for rather than "bots.token.read". The raw
|
||||||
</section>
|
string is still shown below, because that is what has to be ticked. */}
|
||||||
</PageFrame>
|
{`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
|
// ThirdPartyVerificationHiddenGate is what a direct URL to a third-party
|
||||||
// verification page hits while the feature is hidden -- distinct from
|
// verification page hits while the feature is hidden -- distinct from
|
||||||
// PermissionGate because no permission grant (not even "*") changes this.
|
// 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();
|
const hidden = useThirdPartyVerificationHidden();
|
||||||
if (!hidden) {
|
if (!hidden) {
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<PageFrame title={"Feature hidden"} eyebrow={"Console / Third-party marks"}>
|
<StatusScreen
|
||||||
<Alert>{"Third-party bot verification is hidden on this server (TELESRV_HIDE_THIRD_PARTY_VERIFICATION=true)."}</Alert>
|
code="404"
|
||||||
<section className="section-block">
|
icon={EyeOff}
|
||||||
<div className="entity-head">
|
title={"This section is switched off"}
|
||||||
<div>
|
detail="TELESRV_HIDE_THIRD_PARTY_VERIFICATION=false"
|
||||||
<div className="entity-title"><ShieldOff size={16} /> {"Not fully finished"}</div>
|
navigate={navigate}
|
||||||
<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>
|
{"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."}
|
||||||
</div>
|
</StatusScreen>
|
||||||
</section>
|
|
||||||
</PageFrame>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1102,3 +1102,85 @@ textarea:focus {
|
||||||
.modal.narrow {
|
.modal.narrow {
|
||||||
width: min(460px, 100%);
|
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