Files
bknd/app/src/modules/server/AdminController.tsx
dswbx a298b65abf Release 0.16 (#196)
* initial refactor

* fixes

* test secrets extraction

* updated lock

* fix secret schema

* updated schemas, fixed tests, skipping flow tests for now

* added validator for rjsf, hook form via standard schema

* removed @sinclair/typebox

* remove unneeded vite dep

* fix jsonv literal on Field.tsx

* fix schema import path

* fix schema modals

* fix schema modals

* fix json field form, replaced auth form

* initial waku

* finalize waku example

* fix jsonv-ts version

* fix schema updates with falsy values

* fix media api to respect options' init, improve types

* checking media controller test

* checking media controller test

* checking media controller test

* clean up mediacontroller test

* added cookie option `partitioned`, as well as cors `origin` to be array, option to enable `credentials` (#214)

* added cookie option `partitioned`, as well as cors `origin` to be array, option to enable `credentials`

* fix server test

* fix data api (updated jsonv-ts)

* enhance cloudflare image optimization plugin with new options and explain endpoint (#215)

* feat: add ability to serve static by using dynamic imports (#197)

* feat: add ability to serve static by using dynamic imports

* serveStaticViaImport: make manifest optional

* serveStaticViaImport: add error log

* refactor/imports (#217)

* refactored core and core/utils imports

* refactored core and core/utils imports

* refactored media imports

* refactored auth imports

* refactored data imports

* updated package json exports, fixed mm config

* fix tests

* feat/deno (#219)

* update bun version

* fix module manager's em reference

* add basic deno example

* finalize

* docs: fumadocs migration (#185)

* feat(docs): initialize documentation structure with Fumadocs

* feat(docs): remove home route and move /docs route to /route

* feat(docs): add redirect to /start page

* feat(docs): migrate Getting Started chapters

* feat(docs): migrate Usage and Extending chapters

* feat(callout): add CalloutCaution, CalloutDanger, CalloutInfo, and CalloutPositive

* feat(layout): add Discord and GitHub links to documentation layout

* feat(docs): add integration chapters draft

* feat(docs): add modules chapters draft

* refactor(mdx-components): remove unused Icon import

* refactor(StackBlitz): enhance type safety by using unknown instead of any

* refactor(layout): update navigation mode to 'top' in layout configuration

* feat(docs): add @iconify/react package

* docs(mdx-components): add Icon component to MDX components list

* feat(docs): update Next.js integration guide

* feat(docs): update React Router integration guide

* feat(docs): update Astro integration guide

* feat(docs): update Vite integration guide

* fix(docs): update package manager initialization commands

* feat(docs): migrate Modules chapters

* chore(docs): update package.json with new devDependencies

* feat(docs): migrate Integration Runtimes chapters

* feat(docs): update Database usage chapter

* feat(docs): restructure documentation paths

* chore(docs): clean up unused imports and files in documentation

* style(layout): revert navigation mode to previous state

* fix(docs): routing for documentation structure

* feat(openapi): add API documentation generation from OpenAPI schema

* feat(docs): add icons to documentation pages

* chore(dependencies): remove unused content-collections packages

* fix(types): fix type error for attachFile in source.ts

* feat(redirects): update root redirect destination to '/start'

* feat(search): add static search functionality

* chore(dependencies): update fumadocs-core and fumadocs-ui to latest versions

* feat(search): add Powered by Orama link

* feat(generate-openapi): add error handling for missing OpenAPI schema

* feat(scripts): add OpenAPI generation to build process

* feat(config): enable dynamic redirects and rewrites in development mode

* feat(layout): add GitHub token support for improved API rate limits

* feat(redirects): add 301 redirects for cloudflare pages

* feat(docs): add Vercel redirects configuration

* feat(config): enable standalone output for development environment

* chore(layout): adjust layout settings

* refactor(package): clean up ajv dependency versions

* feat(docs): add twoslash support

* refactor(layout): update DocsLayout import and navigation configuration

* chore(layout): clean up layout.tsx by commenting out GithubInfo

* fix(Search): add locale to search initialization

* chore(package): update fumadocs and orama to latest versions

* docs: add menu items descriptions

* feat(layout): add GitHub URL to the layout component

* feat(docs): add AutoTypeTable component to MDX components

* feat(app): implement AutoTypeTable rendering for AppEvents type

* docs(layout): switch callouts back to default components

* fix(config): use __filename and __dirname for module paths

* docs: add note about node.js 22 requirement

* feat(styles): add custom color variables for light and dark themes

* docs: add S3 setup instructions for media module

* docs: fix typos and indentation in media module docs

* docs: add local media adapter example for Node.js

* docs(media): add S3/R2 URL format examples and fix typo

* docs: add cross-links to initial config and seeding sections

* indent numbered lists content, clarified media serve locations

* fix mediacontroller tests

* feat(layout): add AnimatedGridPattern component for dynamic background

* style(layout): configure fancy ToC style ('clerk')

* fix(AnimatedGridPattern): correct strokeDasharray type

* docs: actualize docs

* feat: add favicon

* style(cloudflare): format code examples

* feat(layout): add Github and Discord footer icons

* feat(footer): add SVG social media icons for GitHub and Discord

* docs: adjusted auto type table, added llm functions

* added static deployment to cloudflare workers

* docs: change cf redirects to proxy *.mdx instead of redirecting

---------

Co-authored-by: dswbx <dennis.senn@gmx.ch>
Co-authored-by: cameronapak <cameronandrewpak@gmail.com>

* build: improve build script

* add missing exports, fix EntityTypescript imports

* media: Dropzone: add programmatic upload, additional events, loading state

* schema object: disable extended defaults to allow empty config values

* Feat/new docs deploy (#224)

* test

* try fixing pm

* try fixing pm

* fix docs on imports, export events correctly

---------

Co-authored-by: Tim Seriakov <59409712+timseriakov@users.noreply.github.com>
Co-authored-by: cameronapak <cameronandrewpak@gmail.com>
2025-08-01 15:55:59 +02:00

307 lines
10 KiB
TypeScript

/** @jsxImportSource hono/jsx */
import type { App } from "App";
import { isDebug } from "core/env";
import { config } from "core/config";
import { $console } from "bknd/utils";
import { addFlashMessage } from "core/server/flash";
import { html } from "hono/html";
import { Fragment } from "hono/jsx";
import { css, Style } from "hono/css";
import { Controller } from "modules/Controller";
import * as SystemPermissions from "modules/permissions";
import type { TApiUser } from "Api";
const htmlBkndContextReplace = "<!-- BKND_CONTEXT -->";
export type AdminBkndWindowContext = {
user?: TApiUser;
logout_route: string;
admin_basepath: string;
logo_return_path?: string;
theme?: "dark" | "light" | "system";
};
// @todo: add migration to remove admin path from config
export type AdminControllerOptions = {
basepath?: string;
adminBasepath?: string;
assetsPath?: string;
html?: string;
forceDev?: boolean | { mainPath: string };
debugRerenders?: boolean;
theme?: "dark" | "light" | "system";
logoReturnPath?: string;
};
export class AdminController extends Controller {
constructor(
private readonly app: App,
private _options: AdminControllerOptions = {},
) {
super();
}
get ctx() {
return this.app.modules.ctx();
}
get options() {
return {
...this._options,
basepath: this._options.basepath ?? "/",
adminBasepath: this._options.adminBasepath ?? "",
assetsPath: this._options.assetsPath ?? config.server.assets_path,
//theme: this._options.theme ?? "system",
logo_return_path: this._options.logoReturnPath ?? "/",
};
}
get basepath() {
return this.withAdminBasePath();
}
private withBasePath(route: string = "") {
return (this.options.basepath + route).replace(/(?<!:)\/+/g, "/");
}
private withAdminBasePath(route: string = "") {
return this.withBasePath(this.options.adminBasepath + route);
}
override getController() {
const { auth: authMiddleware, permission } = this.middlewares;
const hono = this.create().use(
authMiddleware({
//skip: [/favicon\.ico$/]
}),
);
const auth = this.app.module.auth;
const configs = this.app.modules.configs();
// if auth is not enabled, authenticator is undefined
const auth_enabled = configs.auth.enabled;
const authRoutes = {
root: "/",
success: configs.auth.cookie.pathSuccess ?? this.withAdminBasePath("/"),
loggedOut: configs.auth.cookie.pathLoggedOut ?? this.withAdminBasePath("/"),
login: this.withAdminBasePath("/auth/login"),
register: this.withAdminBasePath("/auth/register"),
logout: "/api/auth/logout",
};
const paths = ["/", "/data/*", "/auth/*", "/media/*", "/flows/*", "/settings/*"];
if (isDebug()) {
paths.push("/test/*");
}
for (const path of paths) {
hono.get(
path,
permission(SystemPermissions.accessAdmin, {
onDenied: async (c) => {
if (!path.startsWith("/auth")) {
addFlashMessage(c, "You are not authorized to access the Admin UI", "error");
$console.log("redirecting", authRoutes.login);
return c.redirect(authRoutes.login);
}
return;
},
}),
permission(SystemPermissions.schemaRead, {
onDenied: async (c) => {
addFlashMessage(c, "You not allowed to read the schema", "warning");
},
}),
async (c) => {
const obj: AdminBkndWindowContext = {
user: c.get("auth")?.user,
logout_route: authRoutes.logout,
admin_basepath: this.options.adminBasepath,
theme: this.options.theme,
logo_return_path: this.options.logoReturnPath,
};
const html = await this.getHtml(obj);
if (!html) {
console.warn("Couldn't generate HTML for admin UI");
// re-casting to void as a return is not required
return c.notFound() as unknown as void;
}
await auth.authenticator?.requestCookieRefresh(c);
return c.html(html);
},
);
}
if (auth_enabled) {
const redirectRouteParams = [
permission([SystemPermissions.accessAdmin, SystemPermissions.schemaRead], {
// @ts-ignore
onGranted: async (c) => {
// @todo: add strict test to permissions middleware?
if (c.get("auth")?.user) {
$console.log("redirecting to success");
return c.redirect(authRoutes.success);
}
},
}),
async (c) => {
return c.html(c.get("html")!);
},
] as const;
hono.get(authRoutes.login, ...redirectRouteParams);
hono.get(authRoutes.register, ...redirectRouteParams);
hono.get(authRoutes.logout, async (c) => {
await auth.authenticator?.logout(c);
return c.redirect(authRoutes.loggedOut);
});
}
return hono;
}
private async getHtml(obj: AdminBkndWindowContext) {
const bknd_context = `window.__BKND__ = JSON.parse('${JSON.stringify(obj)}');`;
if (this.options.html) {
if (this.options.html.includes(htmlBkndContextReplace)) {
return this.options.html.replace(
htmlBkndContextReplace,
"<script>" + bknd_context + "</script>",
);
}
$console.warn(
`Custom HTML needs to include '${htmlBkndContextReplace}' to inject BKND context`,
);
return this.options.html as string;
}
const isProd = !isDebug() && !this.options.forceDev;
const mainPath =
typeof this.options.forceDev === "object" && "mainPath" in this.options.forceDev
? this.options.forceDev.mainPath
: "/src/ui/main.tsx";
const assets = {
js: "main.js",
css: "styles.css",
};
if (isProd) {
let manifest: any;
if (this.options.assetsPath.startsWith("http")) {
manifest = await fetch(this.options.assetsPath + ".vite/manifest.json", {
headers: {
Accept: "application/json",
},
}).then((res) => res.json());
} else {
// @ts-ignore
manifest = await import("bknd/dist/manifest.json", {
assert: { type: "json" },
}).then((res) => res.default);
}
try {
// @todo: load all marked as entry (incl. css)
assets.js = manifest["src/ui/main.tsx"].file;
assets.css = manifest["src/ui/main.tsx"].css[0] as any;
} catch (e) {
$console.warn("Couldn't find assets in manifest", e);
}
}
const favicon = isProd ? this.options.assetsPath + "favicon.ico" : "/favicon.ico";
return (
<Fragment>
{/* dnd complains otherwise */}
{html`<!DOCTYPE html>`}
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1"
/>
<link rel="icon" href={favicon} type="image/x-icon" />
<title>BKND</title>
{this.options.debugRerenders && (
<script
crossOrigin="anonymous"
src="//unpkg.com/react-scan/dist/auto.global.js"
/>
)}
{isProd ? (
<Fragment>
<script type="module" src={this.options.assetsPath + assets?.js} />
<link rel="stylesheet" href={this.options.assetsPath + assets?.css} />
</Fragment>
) : (
<Fragment>
<script
type="module"
dangerouslySetInnerHTML={{
__html: `import RefreshRuntime from "/@react-refresh"
RefreshRuntime.injectIntoGlobalHook(window)
window.$RefreshReg$ = () => {}
window.$RefreshSig$ = () => (type) => type
window.__vite_plugin_react_preamble_installed__ = true`,
}}
/>
<script type="module" src={"/@vite/client"} />
</Fragment>
)}
<style dangerouslySetInnerHTML={{ __html: "body { margin: 0; padding: 0; }" }} />
</head>
<body>
<div id="root">
<Style />
<div id="loading" className={wrapperStyle}>
<span className={loaderStyle}>Initializing...</span>
</div>
</div>
<script
dangerouslySetInnerHTML={{
__html: bknd_context,
}}
/>
{!isProd && <script type="module" src={mainPath} />}
</body>
</html>
</Fragment>
);
}
}
const wrapperStyle = css`
margin: 0;
padding: 0;
height: 100vh;
width: 100vw;
display: flex;
justify-content: center;
align-items: center;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: rgb(9,9,11);
background-color: rgb(250,250,250);
@media (prefers-color-scheme: dark) {
color: rgb(250,250,250);
background-color: rgb(30,31,34);
}
`;
const loaderStyle = css`
opacity: 0.3;
font-size: 14px;
font-family: monospace;
`;