add better 403 screens and start with bun dev mode. (going to rewrite to fit the bun standard in a bit).
This commit is contained in:
@@ -8,7 +8,7 @@
|
||||
"test:e2e": "bun run test:e2e:prepare && playwright test",
|
||||
"test:e2e:prepare": "bun run build:css && bun run vendor",
|
||||
"test:e2e:install": "playwright install chromium",
|
||||
"dev": "bun --watch src/index.ts",
|
||||
"dev": "bun run scripts/dev.ts",
|
||||
"start": "bun run build && bun dist/index.js",
|
||||
"build": "bun run build:css && bun run vendor && bun run build:bundle",
|
||||
"build:public:prod": "bun run build:css && bun run vendor && bun run minify",
|
||||
@@ -17,7 +17,7 @@
|
||||
"build:prod:win": "bun run build:public:prod && bun build --production src/index.ts --compile --target bun-windows-x64 --outfile dist/overlord-server.exe",
|
||||
"build:prod:linux": "bun run build:public:prod && bun build --production src/index.ts --compile --target bun-linux-x64 --outfile dist/overlord-server-linux-x64",
|
||||
"build:css": "tailwindcss -i ./src/styles.css -o ./public/assets/tailwind.css --minify",
|
||||
"watch:css": "tailwindcss -i ./src/styles.css -o ./public/assets/tailwind.css --watch",
|
||||
"watch:css": "tailwindcss -i ./src/styles.css -o ./public/assets/tailwind.css --watch=always",
|
||||
"minify": "bun run scripts/minify-public.ts",
|
||||
"bench:client-db": "bun run scripts/client-db-stress.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: "Inter", system-ui, sans-serif;
|
||||
background: #05070f;
|
||||
color: #eef2ff;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background:
|
||||
radial-gradient(circle at 18% 16%, rgba(37, 99, 235, 0.22), transparent 32rem),
|
||||
radial-gradient(circle at 82% 84%, rgba(124, 58, 237, 0.16), transparent 30rem),
|
||||
#05070f;
|
||||
}
|
||||
|
||||
body::before {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(148, 163, 184, 0.025) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(148, 163, 184, 0.025) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
mask-image: radial-gradient(circle at center, black, transparent 80%);
|
||||
}
|
||||
|
||||
.access-shell {
|
||||
position: relative;
|
||||
display: grid;
|
||||
min-height: 100vh;
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
padding: 32px 20px;
|
||||
}
|
||||
|
||||
.access-card {
|
||||
width: min(100%, 590px);
|
||||
padding: clamp(28px, 6vw, 48px);
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
background: linear-gradient(145deg, rgba(24, 32, 52, 0.96), rgba(11, 15, 27, 0.98));
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 28px 90px rgba(0, 0, 0, 0.48), 0 0 80px rgba(59, 130, 246, 0.08);
|
||||
}
|
||||
|
||||
.access-status {
|
||||
display: inline-flex;
|
||||
padding: 7px 11px;
|
||||
margin-bottom: 28px;
|
||||
color: #fbbf24;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
background: rgba(245, 158, 11, 0.09);
|
||||
border: 1px solid rgba(245, 158, 11, 0.2);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.access-icon {
|
||||
display: grid;
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
margin: 0 auto 22px;
|
||||
place-items: center;
|
||||
color: #a5b4fc;
|
||||
font-size: 1.8rem;
|
||||
background: linear-gradient(145deg, rgba(79, 70, 229, 0.25), rgba(37, 99, 235, 0.12));
|
||||
border: 1px solid rgba(129, 140, 248, 0.24);
|
||||
border-radius: 22px;
|
||||
box-shadow: inset 0 1px rgba(255, 255, 255, 0.08), 0 14px 35px rgba(37, 99, 235, 0.14);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(1.7rem, 5vw, 2.35rem);
|
||||
line-height: 1.12;
|
||||
letter-spacing: -0.035em;
|
||||
}
|
||||
|
||||
.access-message {
|
||||
max-width: 460px;
|
||||
margin: 14px auto 0;
|
||||
color: #aebbd0;
|
||||
font-size: 1rem;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.access-detail {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 26px;
|
||||
padding: 14px 16px;
|
||||
text-align: left;
|
||||
background: rgba(2, 6, 23, 0.58);
|
||||
border: 1px solid rgba(100, 116, 139, 0.2);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.access-detail span {
|
||||
color: #8593aa;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.access-detail code {
|
||||
padding: 4px 7px;
|
||||
color: #c7d2fe;
|
||||
font-family: ui-monospace, "Cascadia Code", monospace;
|
||||
font-size: 0.78rem;
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.access-help {
|
||||
margin: 17px auto 0;
|
||||
color: #708097;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.access-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.access-actions a {
|
||||
display: inline-flex;
|
||||
gap: 9px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
padding: 0 17px;
|
||||
color: #dbe7f7;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
border-radius: 10px;
|
||||
transition: transform 150ms ease, border-color 150ms ease, background 150ms ease;
|
||||
}
|
||||
|
||||
.access-actions a:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.access-primary {
|
||||
background: linear-gradient(135deg, #4f46e5, #2563eb);
|
||||
border: 1px solid rgba(165, 180, 252, 0.24);
|
||||
box-shadow: 0 10px 24px rgba(37, 99, 235, 0.2);
|
||||
}
|
||||
|
||||
.access-primary:hover {
|
||||
background: linear-gradient(135deg, #5b52ee, #3472ec);
|
||||
}
|
||||
|
||||
.access-secondary {
|
||||
background: rgba(30, 41, 59, 0.6);
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
}
|
||||
|
||||
.access-secondary:hover {
|
||||
background: rgba(51, 65, 85, 0.66);
|
||||
border-color: rgba(148, 163, 184, 0.3);
|
||||
}
|
||||
|
||||
.access-footer {
|
||||
margin: 22px 0 0;
|
||||
color: #536077;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.access-footer i {
|
||||
margin-right: 6px;
|
||||
color: #6776a5;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.access-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.access-detail {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Development supervisor.
|
||||
*
|
||||
* Keeps the backend and generated Tailwind stylesheet up to date while
|
||||
* preserving a single foreground process for the repository launchers.
|
||||
*/
|
||||
|
||||
const cwd = import.meta.dir.replace(/[\\/]scripts$/, "");
|
||||
const env = {
|
||||
...process.env,
|
||||
NODE_ENV: "development",
|
||||
};
|
||||
|
||||
function runPreparation(command: string): void {
|
||||
const result = Bun.spawnSync(["bun", "run", command], {
|
||||
cwd,
|
||||
env,
|
||||
stdin: "inherit",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
process.exit(result.exitCode || 1);
|
||||
}
|
||||
}
|
||||
|
||||
runPreparation("build:css");
|
||||
runPreparation("vendor");
|
||||
|
||||
const backend = Bun.spawn(["bun", "--watch", "src/index.ts"], {
|
||||
cwd,
|
||||
env,
|
||||
stdin: "inherit",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
});
|
||||
|
||||
const styles = Bun.spawn(["bun", "run", "watch:css"], {
|
||||
cwd,
|
||||
env,
|
||||
stdin: "inherit",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
});
|
||||
|
||||
let stopping = false;
|
||||
|
||||
function stop(exitCode = 0): void {
|
||||
if (stopping) return;
|
||||
stopping = true;
|
||||
backend.kill();
|
||||
styles.kill();
|
||||
process.exitCode = exitCode;
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => stop(130));
|
||||
process.on("SIGTERM", () => stop(143));
|
||||
|
||||
const result = await Promise.race([
|
||||
backend.exited.then((exitCode) => ({ process: "backend", exitCode })),
|
||||
styles.exited.then((exitCode) => ({ process: "Tailwind watcher", exitCode })),
|
||||
]);
|
||||
|
||||
if (!stopping) {
|
||||
console.error(`[dev] ${result.process} exited with code ${result.exitCode}`);
|
||||
stop(result.exitCode || 1);
|
||||
}
|
||||
|
||||
await Promise.allSettled([backend.exited, styles.exited]);
|
||||
@@ -278,6 +278,7 @@ function parseMaxHttpBodyBytes(): number {
|
||||
}
|
||||
|
||||
const MAX_HTTP_BODY_BYTES = parseMaxHttpBodyBytes();
|
||||
const IS_DEVELOPMENT = String(process.env.NODE_ENV || "").trim().toLowerCase() === "development";
|
||||
|
||||
const pluginLoadedByClient = new Map<string, Set<string>>();
|
||||
const pendingPluginEvents = new Map<string, Array<{ event: string; payload: any }>>();
|
||||
@@ -710,6 +711,13 @@ async function startServer() {
|
||||
port: PORT,
|
||||
hostname: HOST,
|
||||
...(tls ? { tls: tls.tlsOptions } : {}),
|
||||
development: IS_DEVELOPMENT
|
||||
? {
|
||||
hmr: true,
|
||||
console: true,
|
||||
chromeDevToolsAutomaticWorkspaceFolders: true,
|
||||
}
|
||||
: false,
|
||||
idleTimeout: 255,
|
||||
maxRequestBodySize: MAX_HTTP_BODY_BYTES,
|
||||
fetch: createHttpFetchHandler({
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { authenticateRequest } from "../../auth";
|
||||
import { getConfig } from "../../config";
|
||||
import { consumeLoginPageRateLimit } from "../../rateLimit";
|
||||
import { requirePermission, type Permission } from "../../rbac";
|
||||
import { getPermissionDescription, requirePermission, type Permission } from "../../rbac";
|
||||
import { canUserAccessClient, canUserAccessFeature, getUserById, type FeatureName, type UserRole } from "../../users";
|
||||
import { renderAccessDeniedPage, type AccessDeniedKind } from "../ui/access-denied-view";
|
||||
import { htmlResponse } from "../ui/html";
|
||||
|
||||
type PageRouteDeps = {
|
||||
PUBLIC_ROOT: string;
|
||||
@@ -120,23 +122,60 @@ const PATH_CLIENT_PAGES: ClientPageDef[] = [
|
||||
{ pattern: /^\/(.+)\/keylogger$/, file: "keylogger.html", clientIdGroup: 1, feature: "keylogger" },
|
||||
];
|
||||
|
||||
function checkAccess(role: UserRole, access: AccessLevel): Response | null {
|
||||
function accessDeniedResponse(
|
||||
deps: PageRouteDeps,
|
||||
options: {
|
||||
kind: AccessDeniedKind;
|
||||
title?: string;
|
||||
message: string;
|
||||
detail?: string;
|
||||
detailLabel?: string;
|
||||
},
|
||||
): Response {
|
||||
return htmlResponse(renderAccessDeniedPage(options), {
|
||||
status: 403,
|
||||
headers: deps.secureHeaders("text/html; charset=utf-8"),
|
||||
});
|
||||
}
|
||||
|
||||
function permissionDeniedResponse(deps: PageRouteDeps, permission: Permission): Response {
|
||||
return accessDeniedResponse(deps, {
|
||||
kind: "permission",
|
||||
message: `This page requires permission to ${getPermissionDescription(permission).toLowerCase()}.`,
|
||||
detail: permission,
|
||||
detailLabel: "Missing permission",
|
||||
});
|
||||
}
|
||||
|
||||
function checkAccess(deps: PageRouteDeps, role: UserRole, access: AccessLevel): Response | null {
|
||||
switch (access) {
|
||||
case "any":
|
||||
return null;
|
||||
case "no-viewer":
|
||||
if (role === "viewer") {
|
||||
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
|
||||
return accessDeniedResponse(deps, {
|
||||
kind: "role",
|
||||
message: "Viewer accounts can inspect data, but cannot open interactive control features.",
|
||||
detail: "Operator or administrator role",
|
||||
});
|
||||
}
|
||||
return null;
|
||||
case "admin":
|
||||
if (role !== "admin") {
|
||||
return new Response("Forbidden: Admin access required", { status: 403 });
|
||||
return accessDeniedResponse(deps, {
|
||||
kind: "role",
|
||||
message: "This area is reserved for administrators.",
|
||||
detail: "Administrator role",
|
||||
});
|
||||
}
|
||||
return null;
|
||||
case "admin-or-operator":
|
||||
if (role !== "admin" && role !== "operator") {
|
||||
return new Response("Forbidden: Admin or operator access required", { status: 403 });
|
||||
return accessDeniedResponse(deps, {
|
||||
kind: "role",
|
||||
message: "This interactive area is available to operators and administrators.",
|
||||
detail: "Operator or administrator role",
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -195,7 +234,12 @@ export async function handlePageRoutes(
|
||||
if (!user) return serveLoginOrUnauthorized(req, deps);
|
||||
|
||||
if (page.path === "/screenshots" && !getConfig().thumbnails.wallEnabled) {
|
||||
return new Response("Screenshot wall is disabled by the administrator", { status: 403 });
|
||||
return accessDeniedResponse(deps, {
|
||||
kind: "unavailable",
|
||||
message: "The screenshot wall has been disabled in server settings.",
|
||||
detail: "Screenshot wall disabled",
|
||||
detailLabel: "Current status",
|
||||
});
|
||||
}
|
||||
|
||||
if (page.checkPasswordChange) {
|
||||
@@ -203,14 +247,16 @@ export async function handlePageRoutes(
|
||||
if (maybeChange) return maybeChange;
|
||||
}
|
||||
|
||||
const accessDenied = checkAccess(user.role, page.access);
|
||||
const accessDenied = checkAccess(deps, user.role, page.access);
|
||||
if (accessDenied) return accessDenied;
|
||||
|
||||
if (page.permission) {
|
||||
try {
|
||||
requirePermission(user, page.permission);
|
||||
} catch (error) {
|
||||
if (error instanceof Response) return error;
|
||||
if (error instanceof Response && error.status === 403) {
|
||||
return permissionDeniedResponse(deps, page.permission);
|
||||
}
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
}
|
||||
@@ -232,18 +278,34 @@ export async function handlePageRoutes(
|
||||
try {
|
||||
requirePermission(user, page.permission);
|
||||
} catch (error) {
|
||||
if (error instanceof Response) return error;
|
||||
if (error instanceof Response && error.status === 403) {
|
||||
return permissionDeniedResponse(deps, page.permission);
|
||||
}
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
if (page.feature && !canUserAccessFeature(user.userId, user.role, page.feature)) {
|
||||
return new Response(`Forbidden: feature access denied for ${page.feature}`, { status: 403 });
|
||||
return accessDeniedResponse(deps, {
|
||||
kind: "feature",
|
||||
title: "This feature isn't enabled for you",
|
||||
message: "Your account's feature access does not include this client tool.",
|
||||
detail: page.feature,
|
||||
detailLabel: "Restricted feature",
|
||||
});
|
||||
}
|
||||
|
||||
const clientId = (url.searchParams.get("clientId") || "").trim();
|
||||
if (!canAccessClientPage(user.userId, user.role, clientId)) {
|
||||
return new Response("Forbidden: Client access denied", { status: 403 });
|
||||
return accessDeniedResponse(deps, {
|
||||
kind: "client",
|
||||
title: "Client access restricted",
|
||||
message: clientId
|
||||
? "This client is outside the scope assigned to your account."
|
||||
: "Choose a client from the dashboard before opening this tool.",
|
||||
detail: clientId || "No client selected",
|
||||
detailLabel: "Client",
|
||||
});
|
||||
}
|
||||
|
||||
return serveFile(deps, page.file);
|
||||
@@ -261,18 +323,32 @@ export async function handlePageRoutes(
|
||||
try {
|
||||
requirePermission(user, page.permission);
|
||||
} catch (error) {
|
||||
if (error instanceof Response) return error;
|
||||
if (error instanceof Response && error.status === 403) {
|
||||
return permissionDeniedResponse(deps, page.permission);
|
||||
}
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
if (page.feature && !canUserAccessFeature(user.userId, user.role, page.feature)) {
|
||||
return new Response(`Forbidden: feature access denied for ${page.feature}`, { status: 403 });
|
||||
return accessDeniedResponse(deps, {
|
||||
kind: "feature",
|
||||
title: "This feature isn't enabled for you",
|
||||
message: "Your account's feature access does not include this client tool.",
|
||||
detail: page.feature,
|
||||
detailLabel: "Restricted feature",
|
||||
});
|
||||
}
|
||||
|
||||
const clientId = match[page.clientIdGroup];
|
||||
if (!canAccessClientPage(user.userId, user.role, clientId)) {
|
||||
return new Response("Forbidden: Client access denied", { status: 403 });
|
||||
return accessDeniedResponse(deps, {
|
||||
kind: "client",
|
||||
title: "Client access restricted",
|
||||
message: "This client is outside the scope assigned to your account.",
|
||||
detail: clientId,
|
||||
detailLabel: "Client",
|
||||
});
|
||||
}
|
||||
|
||||
return serveFile(deps, page.file);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { renderAccessDeniedPage } from "./access-denied-view";
|
||||
|
||||
describe("renderAccessDeniedPage", () => {
|
||||
test("renders a complete permission screen with recovery actions", () => {
|
||||
const html = renderAccessDeniedPage({
|
||||
kind: "permission",
|
||||
message: "This page requires additional access.",
|
||||
detail: "users:manage",
|
||||
detailLabel: "Missing permission",
|
||||
});
|
||||
|
||||
expect(html).toContain("<!doctype html>");
|
||||
expect(html).toContain("You don't have access");
|
||||
expect(html).toContain("users:manage");
|
||||
expect(html).toContain('href="/"');
|
||||
expect(html).toContain('href="/settings"');
|
||||
});
|
||||
|
||||
test("escapes dynamic denial details", () => {
|
||||
const html = renderAccessDeniedPage({
|
||||
kind: "client",
|
||||
title: "<script>bad()</script>",
|
||||
message: "Client <not-allowed>",
|
||||
detail: '"><img src=x onerror=bad()>',
|
||||
});
|
||||
|
||||
expect(html).not.toContain("<script>bad()</script>");
|
||||
expect(html).not.toContain("<img src=x");
|
||||
expect(html).toContain("<script>bad()</script>");
|
||||
expect(html).toContain("<not-allowed>");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { escapeHtml } from "./html";
|
||||
|
||||
export type AccessDeniedKind =
|
||||
| "permission"
|
||||
| "role"
|
||||
| "feature"
|
||||
| "client"
|
||||
| "unavailable";
|
||||
|
||||
type AccessDeniedViewOptions = {
|
||||
kind: AccessDeniedKind;
|
||||
title?: string;
|
||||
message: string;
|
||||
detail?: string;
|
||||
detailLabel?: string;
|
||||
};
|
||||
|
||||
const ICONS: Record<AccessDeniedKind, string> = {
|
||||
permission: "fa-user-lock",
|
||||
role: "fa-shield-halved",
|
||||
feature: "fa-toggle-off",
|
||||
client: "fa-desktop",
|
||||
unavailable: "fa-circle-pause",
|
||||
};
|
||||
|
||||
export function renderAccessDeniedPage(options: AccessDeniedViewOptions): string {
|
||||
const title = options.title || (
|
||||
options.kind === "unavailable" ? "This feature is unavailable" : "You don't have access"
|
||||
);
|
||||
const detail = options.detail
|
||||
? `
|
||||
<div class="access-detail">
|
||||
<span>${escapeHtml(options.detailLabel || "Required access")}</span>
|
||||
<code>${escapeHtml(options.detail)}</code>
|
||||
</div>`
|
||||
: "";
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="turbo-visit-control" content="reload" />
|
||||
<title>${escapeHtml(title)} · Overlord</title>
|
||||
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico" />
|
||||
<link rel="stylesheet" href="/vendor/inter/400.css" />
|
||||
<link rel="stylesheet" href="/vendor/inter/600.css" />
|
||||
<link rel="stylesheet" href="/vendor/inter/700.css" />
|
||||
<link rel="stylesheet" href="/vendor/fontawesome/css/all.min.css" />
|
||||
<link rel="stylesheet" href="/assets/access-denied.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="access-shell">
|
||||
<section class="access-card" aria-labelledby="access-title">
|
||||
<div class="access-status">403 · Access restricted</div>
|
||||
<div class="access-icon" aria-hidden="true">
|
||||
<i class="fa-solid ${ICONS[options.kind]}"></i>
|
||||
</div>
|
||||
<h1 id="access-title">${escapeHtml(title)}</h1>
|
||||
<p class="access-message">${escapeHtml(options.message)}</p>
|
||||
${detail}
|
||||
<p class="access-help">If you believe you should have access, ask an administrator to update your account.</p>
|
||||
<div class="access-actions">
|
||||
<a class="access-primary" href="/">
|
||||
<i class="fa-solid fa-border-all" aria-hidden="true"></i>
|
||||
Return to dashboard
|
||||
</a>
|
||||
<a class="access-secondary" href="/settings">
|
||||
<i class="fa-solid fa-gear" aria-hidden="true"></i>
|
||||
Account settings
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
<p class="access-footer"><i class="fa-solid fa-crown" aria-hidden="true"></i> Overlord</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
@@ -56,7 +56,9 @@ test.describe("authentication and authorization", () => {
|
||||
|
||||
const denied = await page.goto("/users");
|
||||
expect(denied?.status()).toBe(403);
|
||||
await expect(page.locator("body")).toContainText("missing permission users:manage");
|
||||
await expect(page.getByRole("heading", { name: "You don't have access" })).toBeVisible();
|
||||
await expect(page.locator(".access-detail")).toContainText("users:manage");
|
||||
await expect(page.getByRole("link", { name: "Return to dashboard" })).toBeVisible();
|
||||
|
||||
const allowed = await page.goto("/metrics");
|
||||
expect(allowed?.status()).toBe(200);
|
||||
|
||||
Reference in New Issue
Block a user