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": "bun run test:e2e:prepare && playwright test",
|
||||||
"test:e2e:prepare": "bun run build:css && bun run vendor",
|
"test:e2e:prepare": "bun run build:css && bun run vendor",
|
||||||
"test:e2e:install": "playwright install chromium",
|
"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",
|
"start": "bun run build && bun dist/index.js",
|
||||||
"build": "bun run build:css && bun run vendor && bun run build:bundle",
|
"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",
|
"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: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: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",
|
"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",
|
"minify": "bun run scripts/minify-public.ts",
|
||||||
"bench:client-db": "bun run scripts/client-db-stress.ts",
|
"bench:client-db": "bun run scripts/client-db-stress.ts",
|
||||||
"typecheck": "tsc --noEmit"
|
"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 MAX_HTTP_BODY_BYTES = parseMaxHttpBodyBytes();
|
||||||
|
const IS_DEVELOPMENT = String(process.env.NODE_ENV || "").trim().toLowerCase() === "development";
|
||||||
|
|
||||||
const pluginLoadedByClient = new Map<string, Set<string>>();
|
const pluginLoadedByClient = new Map<string, Set<string>>();
|
||||||
const pendingPluginEvents = new Map<string, Array<{ event: string; payload: any }>>();
|
const pendingPluginEvents = new Map<string, Array<{ event: string; payload: any }>>();
|
||||||
@@ -710,6 +711,13 @@ async function startServer() {
|
|||||||
port: PORT,
|
port: PORT,
|
||||||
hostname: HOST,
|
hostname: HOST,
|
||||||
...(tls ? { tls: tls.tlsOptions } : {}),
|
...(tls ? { tls: tls.tlsOptions } : {}),
|
||||||
|
development: IS_DEVELOPMENT
|
||||||
|
? {
|
||||||
|
hmr: true,
|
||||||
|
console: true,
|
||||||
|
chromeDevToolsAutomaticWorkspaceFolders: true,
|
||||||
|
}
|
||||||
|
: false,
|
||||||
idleTimeout: 255,
|
idleTimeout: 255,
|
||||||
maxRequestBodySize: MAX_HTTP_BODY_BYTES,
|
maxRequestBodySize: MAX_HTTP_BODY_BYTES,
|
||||||
fetch: createHttpFetchHandler({
|
fetch: createHttpFetchHandler({
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { authenticateRequest } from "../../auth";
|
import { authenticateRequest } from "../../auth";
|
||||||
import { getConfig } from "../../config";
|
import { getConfig } from "../../config";
|
||||||
import { consumeLoginPageRateLimit } from "../../rateLimit";
|
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 { 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 = {
|
type PageRouteDeps = {
|
||||||
PUBLIC_ROOT: string;
|
PUBLIC_ROOT: string;
|
||||||
@@ -120,23 +122,60 @@ const PATH_CLIENT_PAGES: ClientPageDef[] = [
|
|||||||
{ pattern: /^\/(.+)\/keylogger$/, file: "keylogger.html", clientIdGroup: 1, feature: "keylogger" },
|
{ 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) {
|
switch (access) {
|
||||||
case "any":
|
case "any":
|
||||||
return null;
|
return null;
|
||||||
case "no-viewer":
|
case "no-viewer":
|
||||||
if (role === "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;
|
return null;
|
||||||
case "admin":
|
case "admin":
|
||||||
if (role !== "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;
|
return null;
|
||||||
case "admin-or-operator":
|
case "admin-or-operator":
|
||||||
if (role !== "admin" && role !== "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;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -195,7 +234,12 @@ export async function handlePageRoutes(
|
|||||||
if (!user) return serveLoginOrUnauthorized(req, deps);
|
if (!user) return serveLoginOrUnauthorized(req, deps);
|
||||||
|
|
||||||
if (page.path === "/screenshots" && !getConfig().thumbnails.wallEnabled) {
|
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) {
|
if (page.checkPasswordChange) {
|
||||||
@@ -203,14 +247,16 @@ export async function handlePageRoutes(
|
|||||||
if (maybeChange) return maybeChange;
|
if (maybeChange) return maybeChange;
|
||||||
}
|
}
|
||||||
|
|
||||||
const accessDenied = checkAccess(user.role, page.access);
|
const accessDenied = checkAccess(deps, user.role, page.access);
|
||||||
if (accessDenied) return accessDenied;
|
if (accessDenied) return accessDenied;
|
||||||
|
|
||||||
if (page.permission) {
|
if (page.permission) {
|
||||||
try {
|
try {
|
||||||
requirePermission(user, page.permission);
|
requirePermission(user, page.permission);
|
||||||
} catch (error) {
|
} 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 });
|
return new Response("Forbidden", { status: 403 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -232,18 +278,34 @@ export async function handlePageRoutes(
|
|||||||
try {
|
try {
|
||||||
requirePermission(user, page.permission);
|
requirePermission(user, page.permission);
|
||||||
} catch (error) {
|
} 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 });
|
return new Response("Forbidden", { status: 403 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (page.feature && !canUserAccessFeature(user.userId, user.role, page.feature)) {
|
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();
|
const clientId = (url.searchParams.get("clientId") || "").trim();
|
||||||
if (!canAccessClientPage(user.userId, user.role, clientId)) {
|
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);
|
return serveFile(deps, page.file);
|
||||||
@@ -261,18 +323,32 @@ export async function handlePageRoutes(
|
|||||||
try {
|
try {
|
||||||
requirePermission(user, page.permission);
|
requirePermission(user, page.permission);
|
||||||
} catch (error) {
|
} 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 });
|
return new Response("Forbidden", { status: 403 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (page.feature && !canUserAccessFeature(user.userId, user.role, page.feature)) {
|
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];
|
const clientId = match[page.clientIdGroup];
|
||||||
if (!canAccessClientPage(user.userId, user.role, clientId)) {
|
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);
|
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");
|
const denied = await page.goto("/users");
|
||||||
expect(denied?.status()).toBe(403);
|
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");
|
const allowed = await page.goto("/metrics");
|
||||||
expect(allowed?.status()).toBe(200);
|
expect(allowed?.status()).toBe(200);
|
||||||
|
|||||||
@@ -23,12 +23,9 @@ fi
|
|||||||
echo "[server] using bun at: $(command -v $BUN_BIN)"
|
echo "[server] using bun at: $(command -v $BUN_BIN)"
|
||||||
echo "[server] bun install..."
|
echo "[server] bun install..."
|
||||||
"$BUN_BIN" install
|
"$BUN_BIN" install
|
||||||
echo "[server] building Tailwind CSS..."
|
|
||||||
"$BUN_BIN" run build:css
|
|
||||||
echo "[server] building vendor assets..."
|
|
||||||
"$BUN_BIN" run vendor
|
|
||||||
echo "[server] starting bun dev (foreground)"
|
echo "[server] starting bun dev (foreground)"
|
||||||
# Use exec so the script process is replaced by the bun process. This keeps the
|
# Use exec so the script process is replaced by the bun process. This keeps the
|
||||||
# bun process as a child of the dispatcher so the dispatcher can kill it.
|
# bun process as a child of the dispatcher so the dispatcher can kill it.
|
||||||
export OVERLORD_AGENT_TOKEN="dev-token-insecure-local-only"
|
export OVERLORD_AGENT_TOKEN="dev-token-insecure-local-only"
|
||||||
|
export NODE_ENV="development"
|
||||||
exec "$BUN_BIN" run dev
|
exec "$BUN_BIN" run dev
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ set OVERLORD_DISABLE_AGENT_AUTH=true
|
|||||||
set LOG_LEVEL=debug
|
set LOG_LEVEL=debug
|
||||||
set CGO_ENABLED=1
|
set CGO_ENABLED=1
|
||||||
|
|
||||||
start "Overlord-Server" cmd /k "cd /d ""%ROOT%Overlord-Server"" && set OVERLORD_DISABLE_AGENT_AUTH=true && set OVERLORD_AGENT_TOKEN=dev-token-insecure-local-only && set LOG_LEVEL=debug && set NODE_ENV=development && bun install && bun run start"
|
start "Overlord-Server" cmd /k "cd /d ""%ROOT%Overlord-Server"" && set OVERLORD_DISABLE_AGENT_AUTH=true && set OVERLORD_AGENT_TOKEN=dev-token-insecure-local-only && set LOG_LEVEL=debug && set NODE_ENV=development && bun run dev"
|
||||||
timeout /t 3 /nobreak >nul
|
timeout /t 3 /nobreak >nul
|
||||||
start "Overlord-Client" cmd /k "cd /d ""%ROOT%Overlord-Client"" && set CGO_ENABLED=1 && set OVERLORD_SERVER=wss://localhost:5173 && set OVERLORD_AGENT_TOKEN=dev-token-insecure-local-only && set OVERLORD_TLS_INSECURE_SKIP_VERIFY=true && set OVERLORD_MODE=dev && set OVERLORD_CAPTURE_METRICS=true && set GOINSECURE=* && set GOSUMDB=off && set GOPROXY=https://proxy.golang.org,direct && go mod tidy && go run ./cmd/agent"
|
start "Overlord-Client" cmd /k "cd /d ""%ROOT%Overlord-Client"" && set CGO_ENABLED=1 && set OVERLORD_SERVER=wss://localhost:5173 && set OVERLORD_AGENT_TOKEN=dev-token-insecure-local-only && set OVERLORD_TLS_INSECURE_SKIP_VERIFY=true && set OVERLORD_MODE=dev && set OVERLORD_CAPTURE_METRICS=true && set GOINSECURE=* && set GOSUMDB=off && set GOPROXY=https://proxy.golang.org,direct && go mod tidy && go run ./cmd/agent"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user