Compare commits

..

12 Commits

Author SHA1 Message Date
dswbx 4c11789ea8 Fix Release 0.11.1 (#150)
* fix strategy forms handling, add register route and hidden fields

Refactored strategy forms to include hidden fields for type and name. Added a registration route with necessary adjustments to the admin controller and routes. Corrected field handling within relevant forms and components.

* fix admin access permissions and refactor routing structure

display a fixed error for unmet permissions when retrieving the schema. moved auth routes outside of BkndProvider and reorganized remaining routes to include BkndWrapper.

* fix: properly type BkndWrapper

* bump fix release version

* ModuleManager: update diff checking and AppData validation

Revised diff handling includes validation of diffs, reverting changes on failure, and enforcing module constraints with onBeforeUpdate hooks. Introduced `validateDiffs` and backup of stable configs. Applied changes in related modules, tests, and UI layer to align with updated diff logic.

* fix: cli: running from config file were using invalid args

* fix: cli: improve sequence of onBuilt trigger to allow custom routes from cli

* fix e2e tests
2025-04-20 09:29:58 +02:00
dswbx 2988e4c3bd fix: force json-schema-library version 2025-04-18 16:06:32 +02:00
dswbx ad7926db4c Merge pull request #121 from bknd-io/release/0.11
Release 0.11
2025-04-08 12:51:34 +02:00
dswbx 53a3dcdee7 release: v0.11 2025-04-08 12:50:37 +02:00
dswbx 53467d6750 fix: create: cloudflare starter wasn't creating a r2 bucket 2025-04-08 12:43:50 +02:00
dswbx a80a731498 fix: cli: user command now uses the same app env setup as run 2025-04-08 11:22:10 +02:00
dswbx 7e1757b7f4 fix: updated cloudflare adapter to use runtime config, aligned vite 2025-04-05 18:03:58 +02:00
dswbx 2c29e06fb8 fix: media infinite should be disabled for dropzone in entities 2025-04-05 18:01:04 +02:00
dswbx ca86fa58ac cli: user: add token generation (#140)
* cli: user: add token generation

* cli: user: add token generation

* cli: user: check for value being cancel before continuing
2025-04-05 17:57:03 +02:00
dswbx de984fa101 fixes issues in firefox where view transitions are not available (#139) 2025-04-04 09:00:50 +02:00
dswbx a12d4e13d0 e2e: added script to auto test adapters 2025-04-03 16:40:51 +02:00
dswbx fa6c7acaf5 implement/init e2e tests (#135)
* init e2e

* updated/moved vitest, finished merge

* fix bun picking up e2e tests

* e2e: overwrite webserver config with env

* e2e: added adapter configs

* e2e: replaced image
2025-04-03 11:08:16 +02:00
45 changed files with 782 additions and 365 deletions
+2 -1
View File
@@ -1,3 +1,4 @@
playwright-report playwright-report
test-results test-results
bknd.config.* bknd.config.*
__test__/helper.d.ts
+41 -3
View File
@@ -1,13 +1,51 @@
import { describe, expect, test } from "bun:test"; import { beforeEach, describe, expect, test } from "bun:test";
import { parse } from "../../src/core/utils"; import { parse } from "../../src/core/utils";
import { fieldsSchema } from "../../src/data/data-schema"; import { fieldsSchema } from "../../src/data/data-schema";
import { AppData } from "../../src/modules"; import { AppData, type ModuleBuildContext } from "../../src/modules";
import { moduleTestSuite } from "./module-test-suite"; import { makeCtx, moduleTestSuite } from "./module-test-suite";
import * as proto from "data/prototype";
describe("AppData", () => { describe("AppData", () => {
moduleTestSuite(AppData); moduleTestSuite(AppData);
let ctx: ModuleBuildContext;
beforeEach(() => {
ctx = makeCtx();
});
test("field config construction", () => { test("field config construction", () => {
expect(parse(fieldsSchema, { type: "text" })).toBeDefined(); expect(parse(fieldsSchema, { type: "text" })).toBeDefined();
}); });
test("should prevent multi-deletion of entities in single request", async () => {
const schema = proto.em({
one: proto.entity("one", {
text: proto.text(),
}),
two: proto.entity("two", {
text: proto.text(),
}),
three: proto.entity("three", {
text: proto.text(),
}),
});
const check = () => {
const expected = ["one", "two", "three"];
const fromConfig = Object.keys(data.config.entities ?? {});
const fromEm = data.em.entities.map((e) => e.name);
expect(fromConfig).toEqual(expected);
expect(fromEm).toEqual(expected);
};
// auth must be enabled, otherwise default config is returned
const data = new AppData(schema.toJSON(), ctx);
await data.build();
check();
expect(data.schema().remove("entities")).rejects.toThrow(/more than one entity/);
check();
await data.setContext(makeCtx()).build();
check();
});
}); });
+129 -3
View File
@@ -1,10 +1,12 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { Type, disableConsoleLog, enableConsoleLog, stripMark } from "../../src/core/utils"; import { disableConsoleLog, enableConsoleLog, stripMark, Type } from "../../src/core/utils";
import { entity, text } from "../../src/data"; import { Connection, entity, text } from "../../src/data";
import { Module } from "../../src/modules/Module"; import { Module } from "../../src/modules/Module";
import { ModuleManager, getDefaultConfig } from "../../src/modules/ModuleManager"; import { type ConfigTable, getDefaultConfig, ModuleManager } from "../../src/modules/ModuleManager";
import { CURRENT_VERSION, TABLE_NAME } from "../../src/modules/migrations"; import { CURRENT_VERSION, TABLE_NAME } from "../../src/modules/migrations";
import { getDummyConnection } from "../helper"; import { getDummyConnection } from "../helper";
import { diff } from "core/object/diff";
import type { Static } from "@sinclair/typebox";
describe("ModuleManager", async () => { describe("ModuleManager", async () => {
test("s1: no config, no build", async () => { test("s1: no config, no build", async () => {
@@ -380,4 +382,128 @@ describe("ModuleManager", async () => {
expect(() => f.default()).toThrow(); expect(() => f.default()).toThrow();
}); });
}); });
async function getRawConfig(c: Connection) {
return (await c.kysely
.selectFrom(TABLE_NAME)
.selectAll()
.where("type", "=", "config")
.orderBy("version", "desc")
.executeTakeFirstOrThrow()) as unknown as ConfigTable;
}
async function getDiffs(c: Connection, opts?: { dir?: "asc" | "desc"; limit?: number }) {
return await c.kysely
.selectFrom(TABLE_NAME)
.selectAll()
.where("type", "=", "diff")
.orderBy("version", opts?.dir ?? "desc")
.$if(!!opts?.limit, (b) => b.limit(opts!.limit!))
.execute();
}
describe("diffs", () => {
test("never empty", async () => {
const { dummyConnection: c } = getDummyConnection();
const mm = new ModuleManager(c);
await mm.build();
await mm.save();
expect(await getDiffs(c)).toHaveLength(0);
});
test("has timestamps", async () => {
const { dummyConnection: c } = getDummyConnection();
const mm = new ModuleManager(c);
await mm.build();
await mm.get("data").schema().patch("basepath", "/api/data2");
await mm.save();
const config = await getRawConfig(c);
const diffs = await getDiffs(c);
expect(config.json.data.basepath).toBe("/api/data2");
expect(diffs).toHaveLength(1);
expect(diffs[0]!.created_at).toBeDefined();
expect(diffs[0]!.updated_at).toBeDefined();
});
});
describe("validate & revert", () => {
const schema = Type.Object({
value: Type.Array(Type.Number(), { default: [] }),
});
type SampleSchema = Static<typeof schema>;
class Sample extends Module<typeof schema> {
getSchema() {
return schema;
}
override async build() {
this.setBuilt();
}
override async onBeforeUpdate(from: SampleSchema, to: SampleSchema) {
if (to.value.length > 3) {
throw new Error("too many values");
}
if (to.value.includes(7)) {
throw new Error("contains 7");
}
return to;
}
}
class TestModuleManager extends ModuleManager {
constructor(...args: ConstructorParameters<typeof ModuleManager>) {
super(...args);
this.modules["module1"] = new Sample({}, this.ctx());
}
}
test("respects module onBeforeUpdate", async () => {
const { dummyConnection: c } = getDummyConnection();
const mm = new TestModuleManager(c);
await mm.build();
const m = mm.get("module1" as any) as Sample;
{
expect(async () => {
await m.schema().set({ value: [1, 2, 3, 4, 5] });
return mm.save();
}).toThrow(/too many values/);
expect(m.config.value).toHaveLength(0);
expect((mm.configs() as any).module1.value).toHaveLength(0);
}
{
expect(async () => {
await mm.mutateConfigSafe("module1" as any).set({ value: [1, 2, 3, 4, 5] });
return mm.save();
}).toThrow(/too many values/);
expect(m.config.value).toHaveLength(0);
expect((mm.configs() as any).module1.value).toHaveLength(0);
}
{
expect(async () => {
await m.schema().set({ value: [1, 7, 5] });
return mm.save();
}).toThrow(/contains 7/);
expect(m.config.value).toHaveLength(0);
expect((mm.configs() as any).module1.value).toHaveLength(0);
}
{
expect(async () => {
await mm.mutateConfigSafe("module1" as any).set({ value: [1, 7, 5] });
return mm.save();
}).toThrow(/contains 7/);
expect(m.config.value).toHaveLength(0);
expect((mm.configs() as any).module1.value).toHaveLength(0);
}
});
});
}); });
+10 -2
View File
@@ -186,7 +186,8 @@ const adapters = {
}, },
} as const; } as const;
for (const [name, config] of Object.entries(adapters)) { async function testAdapter(name: keyof typeof adapters) {
const config = adapters[name];
console.log("adapter", c.cyan(name)); console.log("adapter", c.cyan(name));
await config.clean(); await config.clean();
@@ -202,5 +203,12 @@ for (const [name, config] of Object.entries(adapters)) {
await Bun.sleep(250); await Bun.sleep(250);
console.log("Waiting for process to exit..."); console.log("Waiting for process to exit...");
} }
//process.exit(0); }
if (process.env.TEST_ADAPTER) {
await testAdapter(process.env.TEST_ADAPTER as any);
} else {
for (const [name] of Object.entries(adapters)) {
await testAdapter(name as any);
}
} }
+1 -1
View File
@@ -13,7 +13,7 @@ test("can enable media", async ({ page }) => {
await page.goto(`${config.base_path}/media/settings`); await page.goto(`${config.base_path}/media/settings`);
// enable // enable
const enableToggle = page.locator("css=button#enabled"); const enableToggle = page.getByTestId(testIds.media.switchEnabled);
if ((await enableToggle.getAttribute("aria-checked")) !== "true") { if ((await enableToggle.getAttribute("aria-checked")) !== "true") {
await expect(enableToggle).toBeVisible(); await expect(enableToggle).toBeVisible();
await enableToggle.click(); await enableToggle.click();
+4 -3
View File
@@ -3,7 +3,7 @@
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,
"bin": "./dist/cli/index.js", "bin": "./dist/cli/index.js",
"version": "0.11.0-rc.2", "version": "0.11.1",
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.", "description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
"homepage": "https://bknd.io", "homepage": "https://bknd.io",
"repository": { "repository": {
@@ -26,7 +26,7 @@
"build:types": "tsc -p tsconfig.build.json --emitDeclarationOnly && tsc-alias", "build:types": "tsc -p tsconfig.build.json --emitDeclarationOnly && tsc-alias",
"updater": "bun x npm-check-updates -ui", "updater": "bun x npm-check-updates -ui",
"cli": "LOCAL=1 bun src/cli/index.ts", "cli": "LOCAL=1 bun src/cli/index.ts",
"prepublishOnly": "bun run types && bun run test && bun run test:node && bun run build:all && cp ../README.md ./", "prepublishOnly": "bun run types && bun run test && bun run test:node && bun run test:e2e && bun run build:all && cp ../README.md ./",
"postpublish": "rm -f README.md", "postpublish": "rm -f README.md",
"test": "ALL_TESTS=1 bun test --bail", "test": "ALL_TESTS=1 bun test --bail",
"test:all": "bun run test && bun run test:node", "test:all": "bun run test && bun run test:node",
@@ -38,6 +38,7 @@
"test:vitest:watch": "vitest", "test:vitest:watch": "vitest",
"test:vitest:coverage": "vitest run --coverage", "test:vitest:coverage": "vitest run --coverage",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:e2e:adapters": "bun run e2e/adapters.ts",
"test:e2e:ui": "playwright test --ui", "test:e2e:ui": "playwright test --ui",
"test:e2e:debug": "playwright test --debug", "test:e2e:debug": "playwright test --debug",
"test:e2e:report": "playwright show-report" "test:e2e:report": "playwright show-report"
@@ -61,7 +62,7 @@
"fast-xml-parser": "^5.0.8", "fast-xml-parser": "^5.0.8",
"hono": "^4.7.4", "hono": "^4.7.4",
"json-schema-form-react": "^0.0.2", "json-schema-form-react": "^0.0.2",
"json-schema-library": "^10.0.0-rc7", "json-schema-library": "10.0.0-rc7",
"json-schema-to-ts": "^3.1.1", "json-schema-to-ts": "^3.1.1",
"kysely": "^0.27.6", "kysely": "^0.27.6",
"liquidjs": "^10.21.0", "liquidjs": "^10.21.0",
+1
View File
@@ -12,6 +12,7 @@ export default defineConfig({
retries: process.env.CI ? 2 : 0, retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined, workers: process.env.CI ? 1 : undefined,
reporter: "html", reporter: "html",
timeout: 20000,
use: { use: {
baseURL: baseUrl, baseURL: baseUrl,
trace: "on-first-retry", trace: "on-first-retry",
+4 -1
View File
@@ -180,7 +180,10 @@ export class App {
registerAdminController(config?: AdminControllerOptions) { registerAdminController(config?: AdminControllerOptions) {
// register admin // register admin
this.adminController = new AdminController(this, config); this.adminController = new AdminController(this, config);
this.modules.server.route(config?.basepath ?? "/", this.adminController.getController()); this.modules.server.route(
this.adminController.basepath,
this.adminController.getController(),
);
return this; return this;
} }
+1 -1
View File
@@ -43,7 +43,7 @@ export async function createApp<Env extends AwsLambdaEnv = AwsLambdaEnv>(
case "url": case "url":
additional.adminOptions = { additional.adminOptions = {
...(typeof adminOptions === "object" ? adminOptions : {}), ...(typeof adminOptions === "object" ? adminOptions : {}),
assets_path: assets.url, assetsPath: assets.url,
}; };
break; break;
default: default:
@@ -1,14 +1,15 @@
/// <reference types="@cloudflare/workers-types" /> /// <reference types="@cloudflare/workers-types" />
import type { FrameworkBkndConfig } from "bknd/adapter"; import type { RuntimeBkndConfig } from "bknd/adapter";
import { Hono } from "hono"; import { Hono } from "hono";
import { serveStatic } from "hono/cloudflare-workers"; import { serveStatic } from "hono/cloudflare-workers";
import { getFresh } from "./modes/fresh";
import { getCached } from "./modes/cached"; import { getCached } from "./modes/cached";
import { getDurable } from "./modes/durable"; import { getDurable } from "./modes/durable";
import { getFresh, getWarm } from "./modes/fresh"; import type { App } from "bknd";
export type CloudflareEnv = object; export type CloudflareEnv = object;
export type CloudflareBkndConfig<Env = CloudflareEnv> = FrameworkBkndConfig<Env> & { export type CloudflareBkndConfig<Env = CloudflareEnv> = RuntimeBkndConfig<Env> & {
mode?: "warm" | "fresh" | "cache" | "durable"; mode?: "warm" | "fresh" | "cache" | "durable";
bindings?: (args: Env) => { bindings?: (args: Env) => {
kv?: KVNamespace; kv?: KVNamespace;
@@ -20,8 +21,6 @@ export type CloudflareBkndConfig<Env = CloudflareEnv> = FrameworkBkndConfig<Env>
keepAliveSeconds?: number; keepAliveSeconds?: number;
forceHttps?: boolean; forceHttps?: boolean;
manifest?: string; manifest?: string;
setAdminHtml?: boolean;
html?: string;
}; };
export type Context<Env = CloudflareEnv> = { export type Context<Env = CloudflareEnv> = {
@@ -43,7 +42,7 @@ export function serve<Env extends CloudflareEnv = CloudflareEnv>(
throw new Error("manifest is required with static 'kv'"); throw new Error("manifest is required with static 'kv'");
} }
if (config.manifest && config.static !== "assets") { if (config.manifest && config.static === "kv") {
const pathname = url.pathname.slice(1); const pathname = url.pathname.slice(1);
const assetManifest = JSON.parse(config.manifest); const assetManifest = JSON.parse(config.manifest);
if (pathname && pathname in assetManifest) { if (pathname && pathname in assetManifest) {
@@ -70,18 +69,24 @@ export function serve<Env extends CloudflareEnv = CloudflareEnv>(
const context = { request, env, ctx } as Context<Env>; const context = { request, env, ctx } as Context<Env>;
const mode = config.mode ?? "warm"; const mode = config.mode ?? "warm";
let app: App;
switch (mode) { switch (mode) {
case "fresh": case "fresh":
return await getFresh(config, context); app = await getFresh(config, context, { force: true });
break;
case "warm": case "warm":
return await getWarm(config, context); app = await getFresh(config, context);
break;
case "cache": case "cache":
return await getCached(config, context); app = await getCached(config, context);
break;
case "durable": case "durable":
return await getDurable(config, context); return await getDurable(config, context);
default: default:
throw new Error(`Unknown mode ${mode}`); throw new Error(`Unknown mode ${mode}`);
} }
return app.fetch(request, env, ctx);
}, },
}; };
} }
+1 -1
View File
@@ -1,7 +1,7 @@
import { D1Connection, type D1ConnectionConfig } from "./D1Connection"; import { D1Connection, type D1ConnectionConfig } from "./D1Connection";
export * from "./cloudflare-workers.adapter"; export * from "./cloudflare-workers.adapter";
export { makeApp, getFresh, getWarm } from "./modes/fresh"; export { makeApp, getFresh } from "./modes/fresh";
export { getCached } from "./modes/cached"; export { getCached } from "./modes/cached";
export { DurableBkndApp, getDurable } from "./modes/durable"; export { DurableBkndApp, getDurable } from "./modes/durable";
export { D1Connection, type D1ConnectionConfig }; export { D1Connection, type D1ConnectionConfig };
@@ -40,7 +40,6 @@ export async function getCached<Env extends CloudflareEnv = CloudflareEnv>(
); );
await config.beforeBuild?.(app); await config.beforeBuild?.(app);
}, },
adminOptions: { html: config.html },
}, },
{ env, ctx, ...args }, { env, ctx, ...args },
); );
+1 -2
View File
@@ -25,9 +25,7 @@ export async function getDurable<Env extends CloudflareEnv = CloudflareEnv>(
const res = await stub.fire(ctx.request, { const res = await stub.fire(ctx.request, {
config: create_config, config: create_config,
html: config.html,
keepAliveSeconds: config.keepAliveSeconds, keepAliveSeconds: config.keepAliveSeconds,
setAdminHtml: config.setAdminHtml,
}); });
const headers = new Headers(res.headers); const headers = new Headers(res.headers);
@@ -110,6 +108,7 @@ export class DurableBkndApp extends DurableObject {
} }
async onBuilt(app: App) {} async onBuilt(app: App) {}
async beforeBuild(app: App) {} async beforeBuild(app: App) {}
protected keepAlive(seconds: number) { protected keepAlive(seconds: number) {
+3 -22
View File
@@ -7,22 +7,15 @@ export async function makeApp<Env extends CloudflareEnv = CloudflareEnv>(
args: Env = {} as Env, args: Env = {} as Env,
opts?: RuntimeOptions, opts?: RuntimeOptions,
) { ) {
return await createRuntimeApp<Env>( return await createRuntimeApp<Env>(makeConfig(config, args), args, opts);
{
...makeConfig(config, args),
adminOptions: config.html ? { html: config.html } : undefined,
},
args,
opts,
);
} }
export async function getWarm<Env extends CloudflareEnv = CloudflareEnv>( export async function getFresh<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env>, config: CloudflareBkndConfig<Env>,
ctx: Context<Env>, ctx: Context<Env>,
opts: RuntimeOptions = {}, opts: RuntimeOptions = {},
) { ) {
const app = await makeApp( return await makeApp(
{ {
...config, ...config,
onBuilt: async (app) => { onBuilt: async (app) => {
@@ -33,16 +26,4 @@ export async function getWarm<Env extends CloudflareEnv = CloudflareEnv>(
ctx.env, ctx.env,
opts, opts,
); );
return app.fetch(ctx.request);
}
export async function getFresh<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env>,
ctx: Context<Env>,
opts: RuntimeOptions = {},
) {
return await getWarm(config, ctx, {
...opts,
force: true,
});
} }
+32 -38
View File
@@ -1,18 +1,24 @@
import { serveStatic } from "@hono/node-server/serve-static"; import { serveStatic } from "@hono/node-server/serve-static";
import { type DevServerOptions, default as honoViteDevServer } from "@hono/vite-dev-server"; import {
type DevServerOptions,
default as honoViteDevServer,
} from "@hono/vite-dev-server";
import type { App } from "bknd"; import type { App } from "bknd";
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter"; import {
type RuntimeBkndConfig,
createRuntimeApp,
type FrameworkOptions,
} from "bknd/adapter";
import { registerLocalMediaAdapter } from "bknd/adapter/node"; import { registerLocalMediaAdapter } from "bknd/adapter/node";
import { devServerConfig } from "./dev-server-config"; import { devServerConfig } from "./dev-server-config";
export type ViteBkndConfig<Env = any> = RuntimeBkndConfig<Env> & { export type ViteEnv = NodeJS.ProcessEnv;
mode?: "cached" | "fresh"; export type ViteBkndConfig<Env = ViteEnv> = RuntimeBkndConfig<Env> & {};
setAdminHtml?: boolean;
forceDev?: boolean | { mainPath: string };
html?: string;
};
export function addViteScript(html: string, addBkndContext: boolean = true) { export function addViteScript(
html: string,
addBkndContext: boolean = true,
) {
return html.replace( return html.replace(
"</head>", "</head>",
`<script type="module"> `<script type="module">
@@ -28,52 +34,40 @@ ${addBkndContext ? "<!-- BKND_CONTEXT -->" : ""}
); );
} }
async function createApp(config: ViteBkndConfig = {}, env?: any) { async function createApp<ViteEnv>(
config: ViteBkndConfig<ViteEnv> = {},
env: ViteEnv = {} as ViteEnv,
opts: FrameworkOptions = {},
): Promise<App> {
registerLocalMediaAdapter(); registerLocalMediaAdapter();
return await createRuntimeApp( return await createRuntimeApp(
{ {
...config, ...config,
adminOptions: adminOptions: config.adminOptions ?? {
config.setAdminHtml === false forceDev: {
? undefined mainPath: "/src/main.tsx",
: { },
html: config.html, },
forceDev: config.forceDev ?? {
mainPath: "/src/main.tsx",
},
},
serveStatic: ["/assets/*", serveStatic({ root: config.distPath ?? "./" })], serveStatic: ["/assets/*", serveStatic({ root: config.distPath ?? "./" })],
}, },
env, env,
opts,
); );
} }
export function serveFresh(config: Omit<ViteBkndConfig, "mode"> = {}) { export function serve<ViteEnv>(
config: ViteBkndConfig<ViteEnv> = {},
args?: ViteEnv,
opts?: FrameworkOptions,
) {
return { return {
async fetch(request: Request, env: any, ctx: ExecutionContext) { async fetch(request: Request, env: any, ctx: ExecutionContext) {
const app = await createApp(config, env); const app = await createApp(config, env, opts);
return app.fetch(request, env, ctx); return app.fetch(request, env, ctx);
}, },
}; };
} }
let app: App;
export function serveCached(config: Omit<ViteBkndConfig, "mode"> = {}) {
return {
async fetch(request: Request, env: any, ctx: ExecutionContext) {
if (!app) {
app = await createApp(config, env);
}
return app.fetch(request, env, ctx);
},
};
}
export function serve({ mode, ...config }: ViteBkndConfig = {}) {
return mode === "fresh" ? serveFresh(config) : serveCached(config);
}
export function devServer(options: DevServerOptions) { export function devServer(options: DevServerOptions) {
return honoViteDevServer({ return honoViteDevServer({
...devServerConfig, ...devServerConfig,
+6 -11
View File
@@ -1,4 +1,4 @@
import { type DB, Exception } from "core"; import { type DB, Exception, type PrimaryFieldType } from "core";
import { addFlashMessage } from "core/server/flash"; import { addFlashMessage } from "core/server/flash";
import { import {
type Static, type Static,
@@ -14,6 +14,7 @@ import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie";
import { sign, verify } from "hono/jwt"; import { sign, verify } from "hono/jwt";
import type { CookieOptions } from "hono/utils/cookie"; import type { CookieOptions } from "hono/utils/cookie";
import type { ServerEnv } from "modules/Controller"; import type { ServerEnv } from "modules/Controller";
import { pick } from "lodash-es";
type Input = any; // workaround type Input = any; // workaround
export type JWTPayload = Parameters<typeof sign>[0]; export type JWTPayload = Parameters<typeof sign>[0];
@@ -37,11 +38,10 @@ export interface Strategy {
} }
export type User = { export type User = {
id: number; id: PrimaryFieldType;
email: string; email: string;
username: string;
password: string; password: string;
role: string; role?: string | null;
}; };
export type ProfileExchange = { export type ProfileExchange = {
@@ -158,13 +158,8 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
} }
// @todo: add jwt tests // @todo: add jwt tests
async jwt(user: Omit<User, "password">): Promise<string> { async jwt(_user: Omit<User, "password">): Promise<string> {
const prohibited = ["password"]; const user = pick(_user, this.config.jwt.fields);
for (const prop of prohibited) {
if (prop in user) {
throw new Error(`Property "${prop}" is prohibited`);
}
}
const payload: JWTPayload = { const payload: JWTPayload = {
...user, ...user,
@@ -29,13 +29,15 @@ export const cloudflare = {
{ dir: ctx.dir }, { dir: ctx.dir },
); );
const db = ctx.skip ? "d1" : await $p.select({ const db = ctx.skip
message: "What database do you want to use?", ? "d1"
options: [ : await $p.select({
{ label: "Cloudflare D1", value: "d1" }, message: "What database do you want to use?",
{ label: "LibSQL", value: "libsql" }, options: [
], { label: "Cloudflare D1", value: "d1" },
}); { label: "LibSQL", value: "libsql" },
],
});
if ($p.isCancel(db)) { if ($p.isCancel(db)) {
process.exit(1); process.exit(1);
} }
@@ -64,17 +66,19 @@ export const cloudflare = {
async function createD1(ctx: TemplateSetupCtx) { async function createD1(ctx: TemplateSetupCtx) {
const default_db = "data"; const default_db = "data";
const name = ctx.skip ? default_db : await $p.text({ const name = ctx.skip
message: "Enter database name", ? default_db
initialValue: default_db, : await $p.text({
placeholder: default_db, message: "Enter database name",
validate: (v) => { initialValue: default_db,
if (!v) { placeholder: default_db,
return "Invalid name"; validate: (v) => {
} if (!v) {
return; return "Invalid name";
}, }
}); return;
},
});
if ($p.isCancel(name)) { if ($p.isCancel(name)) {
process.exit(1); process.exit(1);
} }
@@ -153,13 +157,16 @@ async function createLibsql(ctx: TemplateSetupCtx) {
} }
async function createR2(ctx: TemplateSetupCtx) { async function createR2(ctx: TemplateSetupCtx) {
const create = ctx.skip ?? await $p.confirm({ const create = ctx.skip
message: "Do you want to use a R2 bucket?", ? false
initialValue: true, : await $p.confirm({
}); message: "Do you want to use a R2 bucket?",
initialValue: true,
});
if ($p.isCancel(create)) { if ($p.isCancel(create)) {
process.exit(1); process.exit(1);
} }
if (!create) { if (!create) {
await overrideJson( await overrideJson(
WRANGLER_FILE, WRANGLER_FILE,
@@ -173,17 +180,19 @@ async function createR2(ctx: TemplateSetupCtx) {
} }
const default_bucket = "bucket"; const default_bucket = "bucket";
const name = ctx.skip ? default_bucket : await $p.text({ const name = ctx.skip
message: "Enter bucket name", ? default_bucket
initialValue: default_bucket, : await $p.text({
placeholder: default_bucket, message: "Enter bucket name",
validate: (v) => { initialValue: default_bucket,
if (!v) { placeholder: default_bucket,
return "Invalid name"; validate: (v) => {
} if (!v) {
return; return "Invalid name";
}, }
}); return;
},
});
if ($p.isCancel(name)) { if ($p.isCancel(name)) {
process.exit(1); process.exit(1);
} }
+2 -1
View File
@@ -4,6 +4,7 @@ import { config } from "core";
import type { MiddlewareHandler } from "hono"; import type { MiddlewareHandler } from "hono";
import open from "open"; import open from "open";
import { fileExists, getRelativeDistPath } from "../../utils/sys"; import { fileExists, getRelativeDistPath } from "../../utils/sys";
import type { App } from "App";
export const PLATFORMS = ["node", "bun"] as const; export const PLATFORMS = ["node", "bun"] as const;
export type Platform = (typeof PLATFORMS)[number]; export type Platform = (typeof PLATFORMS)[number];
@@ -32,7 +33,7 @@ export async function attachServeStatic(app: any, platform: Platform) {
export async function startServer( export async function startServer(
server: Platform, server: Platform,
app: any, app: App,
options: { port: number; open?: boolean }, options: { port: number; open?: boolean },
) { ) {
const port = options.port; const port = options.port;
+18 -9
View File
@@ -17,12 +17,13 @@ import {
startServer, startServer,
} from "./platform"; } from "./platform";
import { makeConfig } from "adapter"; import { makeConfig } from "adapter";
import { isBun as $isBun } from "cli/utils/sys";
const env_files = [".env", ".dev.vars"]; const env_files = [".env", ".dev.vars"];
dotenv.config({ dotenv.config({
path: env_files.map((file) => path.resolve(process.cwd(), file)), path: env_files.map((file) => path.resolve(process.cwd(), file)),
}); });
const isBun = typeof Bun !== "undefined"; const isBun = $isBun();
export const run: CliCommand = (program) => { export const run: CliCommand = (program) => {
program program
@@ -76,12 +77,12 @@ async function makeApp(config: MakeAppConfig) {
app.emgr.onEvent( app.emgr.onEvent(
App.Events.AppBuiltEvent, App.Events.AppBuiltEvent,
async () => { async () => {
await attachServeStatic(app, config.server?.platform ?? "node");
app.registerAdminController();
if (config.onBuilt) { if (config.onBuilt) {
await config.onBuilt(app); await config.onBuilt(app);
} }
await attachServeStatic(app, config.server?.platform ?? "node");
app.registerAdminController();
}, },
"sync", "sync",
); );
@@ -91,14 +92,14 @@ async function makeApp(config: MakeAppConfig) {
} }
export async function makeConfigApp(_config: CliBkndConfig, platform?: Platform) { export async function makeConfigApp(_config: CliBkndConfig, platform?: Platform) {
const config = makeConfig(_config, { env: process.env }); const config = makeConfig(_config, process.env);
return makeApp({ return makeApp({
...config, ...config,
server: { platform }, server: { platform },
}); });
} }
async function action(options: { type RunOptions = {
port: number; port: number;
memory?: boolean; memory?: boolean;
config?: string; config?: string;
@@ -106,8 +107,9 @@ async function action(options: {
dbToken?: string; dbToken?: string;
server: Platform; server: Platform;
open?: boolean; open?: boolean;
}) { };
colorizeConsole(console);
export async function makeAppFromEnv(options: Partial<RunOptions> = {}) {
const configFilePath = await getConfigPath(options.config); const configFilePath = await getConfigPath(options.config);
let app: App | undefined = undefined; let app: App | undefined = undefined;
@@ -147,12 +149,19 @@ async function action(options: {
// if nothing helps, create a file based app // if nothing helps, create a file based app
if (!app) { if (!app) {
const connection = { url: "file:data.db" } as Config; const connection = { url: "file:data.db" } as Config;
console.info("Using connection", c.cyan(connection.url)); console.info("Using fallback connection", c.cyan(connection.url));
app = await makeApp({ app = await makeApp({
connection, connection,
server: { platform: options.server }, server: { platform: options.server },
}); });
} }
return app;
}
async function action(options: RunOptions) {
colorizeConsole(console);
const app = await makeAppFromEnv(options);
await startServer(options.server, app, { port: options.port, open: options.open }); await startServer(options.server, app, { port: options.port, open: options.open });
} }
+70 -36
View File
@@ -1,28 +1,32 @@
import { password as $password, text as $text } from "@clack/prompts"; import {
isCancel as $isCancel,
log as $log,
password as $password,
text as $text,
} from "@clack/prompts";
import type { App } from "App"; import type { App } from "App";
import type { PasswordStrategy } from "auth/authenticate/strategies"; import type { PasswordStrategy } from "auth/authenticate/strategies";
import { makeConfigApp } from "cli/commands/run"; import { makeAppFromEnv } from "cli/commands/run";
import { getConfigPath } from "cli/commands/run/platform"; import type { CliCommand } from "cli/types";
import type { CliBkndConfig, CliCommand } from "cli/types";
import { Argument } from "commander"; import { Argument } from "commander";
import { $console } from "core";
import c from "picocolors";
import { isBun } from "cli/utils/sys";
export const user: CliCommand = (program) => { export const user: CliCommand = (program) => {
program program
.command("user") .command("user")
.description("create and update user (auth)") .description("create/update users, or generate a token (auth)")
.addArgument(new Argument("<action>", "action to perform").choices(["create", "update"])) .addArgument(
new Argument("<action>", "action to perform").choices(["create", "update", "token"]),
)
.action(action); .action(action);
}; };
async function action(action: "create" | "update", options: any) { async function action(action: "create" | "update" | "token", options: any) {
const configFilePath = await getConfigPath(); const app = await makeAppFromEnv({
if (!configFilePath) { server: "node",
console.error("config file not found"); });
return;
}
const config = (await import(configFilePath).then((m) => m.default)) as CliBkndConfig;
const app = await makeConfigApp(config, options.server);
switch (action) { switch (action) {
case "create": case "create":
@@ -31,6 +35,9 @@ async function action(action: "create" | "update", options: any) {
case "update": case "update":
await update(app, options); await update(app, options);
break; break;
case "token":
await token(app, options);
break;
} }
} }
@@ -38,7 +45,8 @@ async function create(app: App, options: any) {
const strategy = app.module.auth.authenticator.strategy("password") as PasswordStrategy; const strategy = app.module.auth.authenticator.strategy("password") as PasswordStrategy;
if (!strategy) { if (!strategy) {
throw new Error("Password strategy not configured"); $log.error("Password strategy not configured");
process.exit(1);
} }
const email = await $text({ const email = await $text({
@@ -50,6 +58,7 @@ async function create(app: App, options: any) {
return; return;
}, },
}); });
if ($isCancel(email)) process.exit(1);
const password = await $password({ const password = await $password({
message: "Enter password", message: "Enter password",
@@ -60,20 +69,17 @@ async function create(app: App, options: any) {
return; return;
}, },
}); });
if ($isCancel(password)) process.exit(1);
if (typeof email !== "string" || typeof password !== "string") {
console.log("Cancelled");
process.exit(0);
}
try { try {
const created = await app.createUser({ const created = await app.createUser({
email, email,
password: await strategy.hash(password as string), password: await strategy.hash(password as string),
}); });
console.log("Created:", created); $log.success(`Created user: ${c.cyan(created.email)}`);
} catch (e) { } catch (e) {
console.error("Error", e); $log.error("Error creating user");
$console.error(e);
} }
} }
@@ -92,17 +98,14 @@ async function update(app: App, options: any) {
return; return;
}, },
})) as string; })) as string;
if (typeof email !== "string") { if ($isCancel(email)) process.exit(1);
console.log("Cancelled");
process.exit(0);
}
const { data: user } = await em.repository(users_entity).findOne({ email }); const { data: user } = await em.repository(users_entity).findOne({ email });
if (!user) { if (!user) {
console.log("User not found"); $log.error("User not found");
process.exit(0); process.exit(1);
} }
console.log("User found:", user); $log.info(`User found: ${c.cyan(user.email)}`);
const password = await $password({ const password = await $password({
message: "New Password?", message: "New Password?",
@@ -113,10 +116,7 @@ async function update(app: App, options: any) {
return; return;
}, },
}); });
if (typeof password !== "string") { if ($isCancel(password)) process.exit(1);
console.log("Cancelled");
process.exit(0);
}
try { try {
function togglePw(visible: boolean) { function togglePw(visible: boolean) {
@@ -134,8 +134,42 @@ async function update(app: App, options: any) {
}); });
togglePw(false); togglePw(false);
console.log("Updated:", user); $log.success(`Updated user: ${c.cyan(user.email)}`);
} catch (e) { } catch (e) {
console.error("Error", e); $log.error("Error updating user");
$console.error(e);
} }
} }
async function token(app: App, options: any) {
if (isBun()) {
$log.error("Please use node to generate tokens");
process.exit(1);
}
const config = app.module.auth.toJSON(true);
const users_entity = config.entity_name as "users";
const em = app.modules.ctx().em;
const email = (await $text({
message: "Which user? Enter email",
validate: (v) => {
if (!v.includes("@")) {
return "Invalid email";
}
return;
},
})) as string;
if ($isCancel(email)) process.exit(1);
const { data: user } = await em.repository(users_entity).findOne({ email });
if (!user) {
$log.error("User not found");
process.exit(1);
}
$log.info(`User found: ${c.cyan(user.email)}`);
console.log(
`\n${c.dim("Token:")}\n${c.yellow(await app.module.auth.authenticator.jwt(user))}\n`,
);
}
+8
View File
@@ -3,6 +3,14 @@ import { readFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import url from "node:url"; import url from "node:url";
export function isBun(): boolean {
try {
return typeof Bun !== "undefined";
} catch (e) {
return false;
}
}
export function getRootPath() { export function getRootPath() {
const _path = path.dirname(url.fileURLToPath(import.meta.url)); const _path = path.dirname(url.fileURLToPath(import.meta.url));
// because of "src", local needs one more level up // because of "src", local needs one more level up
+14 -13
View File
@@ -1,4 +1,5 @@
enum Change { // biome-ignore lint/suspicious/noConstEnum: <explanation>
export const enum DiffChange {
Add = "a", Add = "a",
Remove = "r", Remove = "r",
Edit = "e", Edit = "e",
@@ -7,8 +8,8 @@ enum Change {
type Object = object; type Object = object;
type Primitive = string | number | boolean | null | object | any[] | undefined; type Primitive = string | number | boolean | null | object | any[] | undefined;
interface DiffEntry { export interface DiffEntry {
t: Change | string; t: DiffChange | string;
p: (string | number)[]; p: (string | number)[];
o: Primitive; o: Primitive;
n: Primitive; n: Primitive;
@@ -47,7 +48,7 @@ function diff(oldObj: Object, newObj: Object): DiffEntry[] {
if (typeof oldValue !== typeof newValue) { if (typeof oldValue !== typeof newValue) {
diffs.push({ diffs.push({
t: Change.Edit, t: DiffChange.Edit,
p: path, p: path,
o: oldValue, o: oldValue,
n: newValue, n: newValue,
@@ -57,14 +58,14 @@ function diff(oldObj: Object, newObj: Object): DiffEntry[] {
for (let i = 0; i < maxLength; i++) { for (let i = 0; i < maxLength; i++) {
if (i >= oldValue.length) { if (i >= oldValue.length) {
diffs.push({ diffs.push({
t: Change.Add, t: DiffChange.Add,
p: [...path, i], p: [...path, i],
o: undefined, o: undefined,
n: newValue[i], n: newValue[i],
}); });
} else if (i >= newValue.length) { } else if (i >= newValue.length) {
diffs.push({ diffs.push({
t: Change.Remove, t: DiffChange.Remove,
p: [...path, i], p: [...path, i],
o: oldValue[i], o: oldValue[i],
n: undefined, n: undefined,
@@ -80,14 +81,14 @@ function diff(oldObj: Object, newObj: Object): DiffEntry[] {
for (const key of allKeys) { for (const key of allKeys) {
if (!(key in oldValue)) { if (!(key in oldValue)) {
diffs.push({ diffs.push({
t: Change.Add, t: DiffChange.Add,
p: [...path, key], p: [...path, key],
o: undefined, o: undefined,
n: newValue[key], n: newValue[key],
}); });
} else if (!(key in newValue)) { } else if (!(key in newValue)) {
diffs.push({ diffs.push({
t: Change.Remove, t: DiffChange.Remove,
p: [...path, key], p: [...path, key],
o: oldValue[key], o: oldValue[key],
n: undefined, n: undefined,
@@ -98,7 +99,7 @@ function diff(oldObj: Object, newObj: Object): DiffEntry[] {
} }
} else { } else {
diffs.push({ diffs.push({
t: Change.Edit, t: DiffChange.Edit,
p: path, p: path,
o: oldValue, o: oldValue,
n: newValue, n: newValue,
@@ -136,9 +137,9 @@ function applyChange(obj: Object, diff: DiffEntry) {
const parent = getParent(obj, path.slice(0, -1)); const parent = getParent(obj, path.slice(0, -1));
const key = path[path.length - 1]!; const key = path[path.length - 1]!;
if (type === Change.Add || type === Change.Edit) { if (type === DiffChange.Add || type === DiffChange.Edit) {
parent[key] = newValue; parent[key] = newValue;
} else if (type === Change.Remove) { } else if (type === DiffChange.Remove) {
if (Array.isArray(parent)) { if (Array.isArray(parent)) {
parent.splice(key as number, 1); parent.splice(key as number, 1);
} else { } else {
@@ -152,13 +153,13 @@ function revertChange(obj: Object, diff: DiffEntry) {
const parent = getParent(obj, path.slice(0, -1)); const parent = getParent(obj, path.slice(0, -1));
const key = path[path.length - 1]!; const key = path[path.length - 1]!;
if (type === Change.Add) { if (type === DiffChange.Add) {
if (Array.isArray(parent)) { if (Array.isArray(parent)) {
parent.splice(key as number, 1); parent.splice(key as number, 1);
} else { } else {
delete parent[key]; delete parent[key];
} }
} else if (type === Change.Remove || type === Change.Edit) { } else if (type === DiffChange.Remove || type === DiffChange.Edit) {
parent[key] = oldValue; parent[key] = oldValue;
} }
} }
+13
View File
@@ -61,6 +61,19 @@ export class AppData extends Module<typeof dataConfigSchema> {
this.setBuilt(); this.setBuilt();
} }
override async onBeforeUpdate(from: AppDataConfig, to: AppDataConfig): Promise<AppDataConfig> {
// this is not 100% yet, since it could be legit
const entities = {
from: Object.keys(from.entities ?? {}),
to: Object.keys(to.entities ?? {}),
};
if (entities.from.length - entities.to.length > 1) {
throw new Error("Cannot remove more than one entity at a time");
}
return to;
}
getSchema() { getSchema() {
return dataConfigSchema; return dataConfigSchema;
} }
+2 -1
View File
@@ -1,6 +1,7 @@
import type { App } from "App"; import type { App } from "App";
import { type Context, Hono } from "hono"; import { type Context, Hono } from "hono";
import * as middlewares from "modules/middlewares"; import * as middlewares from "modules/middlewares";
import type { SafeUser } from "auth";
export type ServerEnv = { export type ServerEnv = {
Variables: { Variables: {
@@ -10,7 +11,7 @@ export type ServerEnv = {
resolved: boolean; resolved: boolean;
registered: boolean; registered: boolean;
skip: boolean; skip: boolean;
user?: { id: any; role?: string; [key: string]: any }; user?: SafeUser;
}; };
html?: string; html?: string;
}; };
+72 -15
View File
@@ -1,7 +1,7 @@
import { Guard } from "auth"; import { Guard } from "auth";
import { $console, BkndError, DebugLogger, env } from "core"; import { $console, BkndError, DebugLogger, env } from "core";
import { EventManager } from "core/events"; import { EventManager } from "core/events";
import { clone, diff } from "core/object/diff"; import * as $diff from "core/object/diff";
import { import {
Default, Default,
type Static, type Static,
@@ -95,7 +95,7 @@ export type ModuleManagerOptions = {
verbosity?: Verbosity; verbosity?: Verbosity;
}; };
type ConfigTable<Json = ModuleConfigs> = { export type ConfigTable<Json = ModuleConfigs> = {
id?: number; id?: number;
version: number; version: number;
type: "config" | "diff" | "backup"; type: "config" | "diff" | "backup";
@@ -144,6 +144,7 @@ export class ModuleManager {
private _version: number = 0; private _version: number = 0;
private _built = false; private _built = false;
private readonly _booted_with?: "provided" | "partial"; private readonly _booted_with?: "provided" | "partial";
private _stable_configs: ModuleConfigs | undefined;
private logger: DebugLogger; private logger: DebugLogger;
@@ -310,7 +311,7 @@ export class ModuleManager {
try { try {
const state = await this.fetch(); const state = await this.fetch();
if (!state) throw new BkndError("save: no config found"); if (!state) throw new BkndError("no config found");
this.logger.log("fetched version", state.version); this.logger.log("fetched version", state.version);
if (state.version !== version) { if (state.version !== version) {
@@ -330,15 +331,21 @@ export class ModuleManager {
this.logger.log("version matches", state.version); this.logger.log("version matches", state.version);
// clean configs because of Diff() function // clean configs because of Diff() function
const diffs = diff(state.json, clone(configs)); const diffs = $diff.diff(state.json, $diff.clone(configs));
this.logger.log("checking diff", [diffs.length]); this.logger.log("checking diff", [diffs.length]);
if (diff.length > 0) { if (diffs.length > 0) {
// validate diffs, it'll throw on invalid
this.validateDiffs(diffs);
const date = new Date();
// store diff // store diff
await this.mutator().insertOne({ await this.mutator().insertOne({
version, version,
type: "diff", type: "diff",
json: clone(diffs), json: $diff.clone(diffs),
created_at: date,
updated_at: date,
}); });
// store new version // store new version
@@ -346,7 +353,7 @@ export class ModuleManager {
{ {
version, version,
json: configs, json: configs,
updated_at: new Date(), updated_at: date,
} as any, } as any,
{ {
type: "config", type: "config",
@@ -358,7 +365,7 @@ export class ModuleManager {
} }
} }
} catch (e) { } catch (e) {
if (e instanceof BkndError) { if (e instanceof BkndError && e.message === "no config found") {
this.logger.log("no config, just save fresh"); this.logger.log("no config, just save fresh");
// no config, just save // no config, just save
await this.mutator().insertOne({ await this.mutator().insertOne({
@@ -369,10 +376,12 @@ export class ModuleManager {
updated_at: new Date(), updated_at: new Date(),
}); });
} else if (e instanceof TransformPersistFailedException) { } else if (e instanceof TransformPersistFailedException) {
console.error("Cannot save invalid config"); $console.error("ModuleManager: Cannot save invalid config");
this.revertModules();
throw e; throw e;
} else { } else {
console.error("Aborting"); $console.error("ModuleManager: Aborting");
this.revertModules();
throw e; throw e;
} }
} }
@@ -386,6 +395,52 @@ export class ModuleManager {
return this; return this;
} }
private revertModules() {
if (this._stable_configs) {
$console.warn("ModuleManager: Reverting modules");
this.setConfigs(this._stable_configs as any);
} else {
$console.error("ModuleManager: No stable configs to revert to");
}
}
/**
* Validates received diffs for an additional security control.
* Checks:
* - check if module is registered
* - run modules onBeforeUpdate() for added protection
*
* **Important**: Throw `Error` so it won't get catched.
*
* @param diffs
* @private
*/
private validateDiffs(diffs: $diff.DiffEntry[]): void {
// check top level paths, and only allow a single module to be modified in a single transaction
const modules = [...new Set(diffs.map((d) => d.p[0]))];
if (modules.length === 0) {
return;
}
for (const moduleName of modules) {
const name = moduleName as ModuleKey;
const module = this.get(name) as Module;
if (!module) {
const msg = "validateDiffs: module not registered";
// biome-ignore format: ...
$console.error(msg, JSON.stringify({ module: name, diffs }, null, 2));
throw new Error(msg);
}
// pass diffs to the module to allow it to throw
if (this._stable_configs?.[name]) {
const current = $diff.clone(this._stable_configs?.[name]);
const modified = $diff.apply({ [name]: current }, diffs)[name];
module.onBeforeUpdate(current, modified);
}
}
}
private setConfigs(configs: ModuleConfigs): void { private setConfigs(configs: ModuleConfigs): void {
this.logger.log("setting configs"); this.logger.log("setting configs");
objectEach(configs, (config, key) => { objectEach(configs, (config, key) => {
@@ -519,6 +574,9 @@ export class ModuleManager {
this.logger.log("resetting flags"); this.logger.log("resetting flags");
ctx.flags = Module.ctx_flags; ctx.flags = Module.ctx_flags;
// storing last stable config version
this._stable_configs = $diff.clone(this.configs());
this.logger.clear(); this.logger.clear();
return state; return state;
} }
@@ -551,7 +609,6 @@ export class ModuleManager {
name: Module, name: Module,
): Pick<ReturnType<Modules[Module]["schema"]>, "set" | "patch" | "overwrite" | "remove"> { ): Pick<ReturnType<Modules[Module]["schema"]>, "set" | "patch" | "overwrite" | "remove"> {
const module = this.modules[name]; const module = this.modules[name];
const copy = structuredClone(this.configs());
return new Proxy(module.schema(), { return new Proxy(module.schema(), {
get: (target, prop: string) => { get: (target, prop: string) => {
@@ -560,7 +617,7 @@ export class ModuleManager {
} }
return async (...args) => { return async (...args) => {
console.log("[Safe Mutate]", name); $console.log("[Safe Mutate]", name);
try { try {
// overwrite listener to run build inside this try/catch // overwrite listener to run build inside this try/catch
module.setListener(async () => { module.setListener(async () => {
@@ -582,12 +639,12 @@ export class ModuleManager {
return result; return result;
} catch (e) { } catch (e) {
console.error("[Safe Mutate] failed", e); $console.error(`[Safe Mutate] failed "${name}":`, String(e));
// revert to previous config & rebuild using original listener // revert to previous config & rebuild using original listener
this.setConfigs(copy); this.revertModules();
await this.onModuleConfigUpdated(name, module.config as any); await this.onModuleConfigUpdated(name, module.config as any);
console.log("[Safe Mutate] reverted"); $console.log(`[Safe Mutate] reverted "${name}":`);
// make sure to throw the error // make sure to throw the error
throw e; throw e;
+26 -17
View File
@@ -14,10 +14,11 @@ const htmlBkndContextReplace = "<!-- BKND_CONTEXT -->";
// @todo: add migration to remove admin path from config // @todo: add migration to remove admin path from config
export type AdminControllerOptions = { export type AdminControllerOptions = {
basepath?: string; basepath?: string;
assets_path?: string; adminBasepath?: string;
assetsPath?: string;
html?: string; html?: string;
forceDev?: boolean | { mainPath: string }; forceDev?: boolean | { mainPath: string };
debug_rerenders?: boolean; debugRerenders?: boolean;
}; };
export class AdminController extends Controller { export class AdminController extends Controller {
@@ -36,7 +37,8 @@ export class AdminController extends Controller {
return { return {
...this._options, ...this._options,
basepath: this._options.basepath ?? "/", basepath: this._options.basepath ?? "/",
assets_path: this._options.assets_path ?? config.server.assets_path, adminBasepath: this._options.adminBasepath ?? "",
assetsPath: this._options.assetsPath ?? config.server.assets_path,
}; };
} }
@@ -48,6 +50,10 @@ export class AdminController extends Controller {
return (this.basepath + route).replace(/(?<!:)\/+/g, "/"); return (this.basepath + route).replace(/(?<!:)\/+/g, "/");
} }
private withAdminBasePath(route: string = "") {
return this.withBasePath(this.options.adminBasepath + route);
}
override getController() { override getController() {
const { auth: authMiddleware, permission } = this.middlewares; const { auth: authMiddleware, permission } = this.middlewares;
const hono = this.create().use( const hono = this.create().use(
@@ -63,16 +69,17 @@ export class AdminController extends Controller {
const authRoutes = { const authRoutes = {
root: "/", root: "/",
success: configs.auth.cookie.pathSuccess ?? "/", success: configs.auth.cookie.pathSuccess ?? this.withAdminBasePath("/"),
loggedOut: configs.auth.cookie.pathLoggedOut ?? "/", loggedOut: configs.auth.cookie.pathLoggedOut ?? this.withAdminBasePath("/"),
login: "/auth/login", login: this.withAdminBasePath("/auth/login"),
logout: "/auth/logout", register: this.withAdminBasePath("/auth/register"),
logout: this.withAdminBasePath("/auth/logout"),
}; };
hono.use("*", async (c, next) => { hono.use("*", async (c, next) => {
const obj = { const obj = {
user: c.get("auth")?.user, user: c.get("auth")?.user,
logout_route: this.withBasePath(authRoutes.logout), logout_route: this.withAdminBasePath(authRoutes.logout),
}; };
const html = await this.getHtml(obj); const html = await this.getHtml(obj);
if (!html) { if (!html) {
@@ -86,8 +93,7 @@ export class AdminController extends Controller {
}); });
if (auth_enabled) { if (auth_enabled) {
hono.get( const redirectRouteParams = [
authRoutes.login,
permission([SystemPermissions.accessAdmin, SystemPermissions.schemaRead], { permission([SystemPermissions.accessAdmin, SystemPermissions.schemaRead], {
// @ts-ignore // @ts-ignore
onGranted: async (c) => { onGranted: async (c) => {
@@ -101,7 +107,10 @@ export class AdminController extends Controller {
async (c) => { async (c) => {
return c.html(c.get("html")!); return c.html(c.get("html")!);
}, },
); ] as const;
hono.get(authRoutes.login, ...redirectRouteParams);
hono.get(authRoutes.register, ...redirectRouteParams);
hono.get(authRoutes.logout, async (c) => { hono.get(authRoutes.logout, async (c) => {
await auth.authenticator?.logout(c); await auth.authenticator?.logout(c);
@@ -164,8 +173,8 @@ export class AdminController extends Controller {
if (isProd) { if (isProd) {
let manifest: any; let manifest: any;
if (this.options.assets_path.startsWith("http")) { if (this.options.assetsPath.startsWith("http")) {
manifest = await fetch(this.options.assets_path + "manifest.json", { manifest = await fetch(this.options.assetsPath + "manifest.json", {
headers: { headers: {
Accept: "application/json", Accept: "application/json",
}, },
@@ -182,7 +191,7 @@ export class AdminController extends Controller {
assets.css = manifest["src/ui/main.tsx"].css[0] as any; assets.css = manifest["src/ui/main.tsx"].css[0] as any;
} }
const favicon = isProd ? this.options.assets_path + "favicon.ico" : "/favicon.ico"; const favicon = isProd ? this.options.assetsPath + "favicon.ico" : "/favicon.ico";
return ( return (
<Fragment> <Fragment>
@@ -197,7 +206,7 @@ export class AdminController extends Controller {
/> />
<link rel="icon" href={favicon} type="image/x-icon" /> <link rel="icon" href={favicon} type="image/x-icon" />
<title>BKND</title> <title>BKND</title>
{this.options.debug_rerenders && ( {this.options.debugRerenders && (
<script <script
crossOrigin="anonymous" crossOrigin="anonymous"
src="//unpkg.com/react-scan/dist/auto.global.js" src="//unpkg.com/react-scan/dist/auto.global.js"
@@ -205,8 +214,8 @@ export class AdminController extends Controller {
)} )}
{isProd ? ( {isProd ? (
<Fragment> <Fragment>
<script type="module" src={this.options.assets_path + assets?.js} /> <script type="module" src={this.options.assetsPath + assets?.js} />
<link rel="stylesheet" href={this.options.assets_path + assets?.css} /> <link rel="stylesheet" href={this.options.assetsPath + assets?.css} />
</Fragment> </Fragment>
) : ( ) : (
<Fragment> <Fragment>
@@ -160,7 +160,6 @@ export class SystemController extends Controller {
if (this.app.modules.get(module).schema().has(path)) { if (this.app.modules.get(module).schema().has(path)) {
return c.json({ success: false, path, error: "Path already exists" }, { status: 400 }); return c.json({ success: false, path, error: "Path already exists" }, { status: 400 });
} }
console.log("-- add", module, path, value);
return await handleConfigUpdateResponse(c, async () => { return await handleConfigUpdateResponse(c, async () => {
await this.app.mutateConfig(module).patch(path, value); await this.app.mutateConfig(module).patch(path, value);
+22 -23
View File
@@ -1,6 +1,6 @@
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { Notifications } from "@mantine/notifications"; import { Notifications } from "@mantine/notifications";
import React from "react"; import React, { type ReactNode } from "react";
import { BkndProvider, type BkndAdminOptions } from "ui/client/bknd"; import { BkndProvider, type BkndAdminOptions } from "ui/client/bknd";
import { useTheme } from "ui/client/use-theme"; import { useTheme } from "ui/client/use-theme";
import { Logo } from "ui/components/display/Logo"; import { Logo } from "ui/components/display/Logo";
@@ -21,33 +21,32 @@ export default function Admin({
withProvider = false, withProvider = false,
config, config,
}: BkndAdminProps) { }: BkndAdminProps) {
const Component = ( const { theme } = useTheme();
const Provider = ({ children }: any) =>
withProvider ? (
<ClientProvider
baseUrl={baseUrlOverride}
{...(typeof withProvider === "object" ? withProvider : {})}
>
{children}
</ClientProvider>
) : (
children
);
const BkndWrapper = ({ children }: { children: ReactNode }) => (
<BkndProvider options={config} fallback={<Skeleton theme={config?.theme} />}> <BkndProvider options={config} fallback={<Skeleton theme={config?.theme} />}>
<AdminInternal /> {children}
</BkndProvider> </BkndProvider>
); );
return withProvider ? (
<ClientProvider
baseUrl={baseUrlOverride}
{...(typeof withProvider === "object" ? withProvider : {})}
>
{Component}
</ClientProvider>
) : (
Component
);
}
function AdminInternal() {
const { theme } = useTheme();
return ( return (
<MantineProvider {...createMantineTheme(theme as any)}> <Provider>
<Notifications position="top-right" /> <MantineProvider {...createMantineTheme(theme as any)}>
<BkndModalsProvider> <Notifications position="top-right" />
<Routes /> <Routes BkndWrapper={BkndWrapper} basePath={config?.basepath} />
</BkndModalsProvider> </MantineProvider>
</MantineProvider> </Provider>
); );
} }
+24 -14
View File
@@ -5,6 +5,8 @@ import { useApi } from "ui/client";
import { type TSchemaActions, getSchemaActions } from "./schema/actions"; import { type TSchemaActions, getSchemaActions } from "./schema/actions";
import { AppReduced } from "./utils/AppReduced"; import { AppReduced } from "./utils/AppReduced";
import type { AppTheme } from "ui/client/use-theme"; import type { AppTheme } from "ui/client/use-theme";
import { Message } from "ui/components/display/Message";
import { useNavigate } from "ui/lib/routes";
export type BkndAdminOptions = { export type BkndAdminOptions = {
logo_return_path?: string; logo_return_path?: string;
@@ -102,13 +104,19 @@ export function BkndProvider({
} as any); } as any);
startTransition(() => { startTransition(() => {
document.startViewTransition(() => { const commit = () => {
setSchema(newSchema); setSchema(newSchema);
setWithSecrets(_includeSecrets); setWithSecrets(_includeSecrets);
setFetched(true); setFetched(true);
set_local_version((v) => v + 1); set_local_version((v) => v + 1);
fetching.current = Fetching.None; fetching.current = Fetching.None;
}); };
if ("startViewTransition" in document) {
document.startViewTransition(commit);
} else {
commit();
}
}); });
} }
@@ -132,22 +140,24 @@ export function BkndProvider({
value={{ ...schema, actions, requireSecrets, app, options: app.options, hasSecrets }} value={{ ...schema, actions, requireSecrets, app, options: app.options, hasSecrets }}
key={local_version} key={local_version}
> >
{/*{error && ( {error ? <AccessDenied /> : children}
<Alert.Exception className="gap-2">
<IconAlertHexagon />
You attempted to load system configuration with secrets without having proper
permission.
<a href={schema.config.server.admin.basepath || "/"}>
<Button variant="red">Reload</Button>
</a>
</Alert.Exception>
)}*/}
{children}
</BkndContext.Provider> </BkndContext.Provider>
); );
} }
function AccessDenied() {
const [navigate] = useNavigate();
return (
<Message.MissingPermission
what="the Admin UI"
primary={{
children: "Login",
onClick: () => navigate("/auth/login"),
}}
/>
);
}
export function useBknd({ withSecrets }: { withSecrets?: boolean } = {}): BkndContext { export function useBknd({ withSecrets }: { withSecrets?: boolean } = {}): BkndContext {
const ctx = useContext(BkndContext); const ctx = useContext(BkndContext);
if (withSecrets) ctx.requireSecrets(); if (withSecrets) ctx.requireSecrets();
@@ -16,9 +16,11 @@ const MissingPermission = ({
{...props} {...props}
/> />
); );
const NotEnabled = (props: Partial<EmptyProps>) => <Empty title="Not Enabled" {...props} />;
export const Message = { export const Message = {
NotFound, NotFound,
NotAllowed, NotAllowed,
NotEnabled,
MissingPermission, MissingPermission,
}; };
@@ -1,5 +1,12 @@
import type { JsonSchema } from "json-schema-library"; import type { JsonSchema } from "json-schema-library";
import type { ChangeEvent, ComponentPropsWithoutRef, ReactNode } from "react"; import {
type ChangeEvent,
type ComponentPropsWithoutRef,
type ElementType,
type ReactNode,
useEffect,
useId,
} from "react";
import ErrorBoundary from "ui/components/display/ErrorBoundary"; import ErrorBoundary from "ui/components/display/ErrorBoundary";
import * as Formy from "ui/components/form/Formy"; import * as Formy from "ui/components/form/Formy";
import { useEvent } from "ui/hooks/use-event"; import { useEvent } from "ui/hooks/use-event";
@@ -7,7 +14,7 @@ import { ArrayField } from "./ArrayField";
import { FieldWrapper, type FieldwrapperProps } from "./FieldWrapper"; import { FieldWrapper, type FieldwrapperProps } from "./FieldWrapper";
import { useDerivedFieldContext, useFormValue } from "./Form"; import { useDerivedFieldContext, useFormValue } from "./Form";
import { ObjectField } from "./ObjectField"; import { ObjectField } from "./ObjectField";
import { coerce, isType, isTypeSchema } from "./utils"; import { coerce, firstDefined, isType, isTypeSchema } from "./utils";
export type FieldProps = { export type FieldProps = {
onChange?: (e: ChangeEvent<any>) => void; onChange?: (e: ChangeEvent<any>) => void;
@@ -24,6 +31,19 @@ export const Field = (props: FieldProps) => {
); );
}; };
export const HiddenField = ({
as = "div",
className,
...props
}: FieldProps & { as?: ElementType; className?: string }) => {
const Component = as;
return (
<Component className={[className, "hidden"].filter(Boolean).join(" ")}>
<Field {...props} />
</Component>
);
};
const fieldErrorBoundary = const fieldErrorBoundary =
({ name }: FieldProps) => ({ name }: FieldProps) =>
({ error }: { error: Error }) => ( ({ error }: { error: Error }) => (
@@ -41,8 +61,9 @@ const FieldImpl = ({
...props ...props
}: FieldProps) => { }: FieldProps) => {
const { path, setValue, schema, ...ctx } = useDerivedFieldContext(name); const { path, setValue, schema, ...ctx } = useDerivedFieldContext(name);
const id = `${name}-${useId()}`;
const required = typeof _required === "boolean" ? _required : ctx.required; const required = typeof _required === "boolean" ? _required : ctx.required;
//console.log("Field", { name, path, schema });
if (!isTypeSchema(schema)) if (!isTypeSchema(schema))
return ( return (
<Pre> <Pre>
@@ -58,7 +79,21 @@ const FieldImpl = ({
return <ArrayField path={name} />; return <ArrayField path={name} />;
} }
const disabled = props.disabled ?? schema.readOnly ?? "const" in schema ?? false; // account for `defaultValue`
// like <Field name="name" inputProps={{ defaultValue: "oauth" }} />
useEffect(() => {
if (inputProps?.defaultValue) {
setValue(path, inputProps.defaultValue);
}
}, [inputProps?.defaultValue]);
const disabled = firstDefined(
inputProps?.disabled,
props.disabled,
schema.readOnly,
"const" in schema,
false,
);
const handleChange = useEvent((e: ChangeEvent<HTMLInputElement>) => { const handleChange = useEvent((e: ChangeEvent<HTMLInputElement>) => {
const value = coerce(e.target.value, schema as any, { required }); const value = coerce(e.target.value, schema as any, { required });
@@ -70,9 +105,10 @@ const FieldImpl = ({
}); });
return ( return (
<FieldWrapper name={name} required={required} schema={schema} {...props}> <FieldWrapper name={name} required={required} schema={schema} fieldId={id} {...props}>
<FieldComponent <FieldComponent
{...inputProps} {...inputProps}
id={id}
schema={schema} schema={schema}
name={name} name={name}
required={required} required={required}
@@ -93,6 +129,7 @@ export const Pre = ({ children }) => (
export type FieldComponentProps = { export type FieldComponentProps = {
schema: JsonSchema; schema: JsonSchema;
render?: (props: Omit<FieldComponentProps, "render">) => ReactNode; render?: (props: Omit<FieldComponentProps, "render">) => ReactNode;
"data-testId"?: string;
} & ComponentPropsWithoutRef<"input">; } & ComponentPropsWithoutRef<"input">;
export const FieldComponent = ({ schema, render, ..._props }: FieldComponentProps) => { export const FieldComponent = ({ schema, render, ..._props }: FieldComponentProps) => {
@@ -111,7 +148,7 @@ export const FieldComponent = ({ schema, render, ..._props }: FieldComponentProp
if (render) return render({ schema, ...props }); if (render) return render({ schema, ...props });
if (schema.enum) { if (schema.enum) {
return <Formy.Select id={props.name} options={schema.enum} {...(props as any)} />; return <Formy.Select options={schema.enum} {...(props as any)} />;
} }
if (isType(schema.type, ["number", "integer"])) { if (isType(schema.type, ["number", "integer"])) {
@@ -121,26 +158,17 @@ export const FieldComponent = ({ schema, render, ..._props }: FieldComponentProp
step: schema.multipleOf, step: schema.multipleOf,
}; };
return ( return <Formy.Input type="number" {...props} value={props.value ?? ""} {...additional} />;
<Formy.Input
type="number"
id={props.name}
{...props}
value={props.value ?? ""}
{...additional}
/>
);
} }
if (isType(schema.type, "boolean")) { if (isType(schema.type, "boolean")) {
return <Formy.Switch id={props.name} {...(props as any)} checked={value === true} />; return <Formy.Switch {...(props as any)} checked={value === true} />;
} }
if (isType(schema.type, "string") && schema.format === "date-time") { if (isType(schema.type, "string") && schema.format === "date-time") {
const value = props.value ? new Date(props.value as string).toISOString().slice(0, 16) : ""; const value = props.value ? new Date(props.value as string).toISOString().slice(0, 16) : "";
return ( return (
<Formy.DateInput <Formy.DateInput
id={props.name}
{...props} {...props}
value={value} value={value}
type="datetime-local" type="datetime-local"
@@ -156,7 +184,7 @@ export const FieldComponent = ({ schema, render, ..._props }: FieldComponentProp
} }
if (isType(schema.type, "string") && schema.format === "date") { if (isType(schema.type, "string") && schema.format === "date") {
return <Formy.DateInput id={props.name} {...props} value={props.value ?? ""} />; return <Formy.DateInput {...props} value={props.value ?? ""} />;
} }
const additional = { const additional = {
@@ -171,7 +199,5 @@ export const FieldComponent = ({ schema, render, ..._props }: FieldComponentProp
} }
} }
return ( return <Formy.TypeAwareInput {...props} value={props.value ?? ""} {...additional} />;
<Formy.TypeAwareInput id={props.name} {...props} value={props.value ?? ""} {...additional} />
);
}; };
@@ -24,6 +24,7 @@ export type FieldwrapperProps = {
errorPlacement?: "top" | "bottom"; errorPlacement?: "top" | "bottom";
description?: string; description?: string;
descriptionPlacement?: "top" | "bottom"; descriptionPlacement?: "top" | "bottom";
fieldId?: string;
}; };
export function FieldWrapper({ export function FieldWrapper({
@@ -36,6 +37,7 @@ export function FieldWrapper({
errorPlacement = "bottom", errorPlacement = "bottom",
descriptionPlacement = "bottom", descriptionPlacement = "bottom",
children, children,
fieldId,
...props ...props
}: FieldwrapperProps) { }: FieldwrapperProps) {
const errors = useFormError(name, { strict: true }); const errors = useFormError(name, { strict: true });
@@ -66,7 +68,7 @@ export function FieldWrapper({
{label && ( {label && (
<Formy.Label <Formy.Label
as={wrapper === "fieldset" ? "legend" : "label"} as={wrapper === "fieldset" ? "legend" : "label"}
htmlFor={name} htmlFor={fieldId}
className="self-start" className="self-start"
> >
{label} {required && <span className="font-medium opacity-30">*</span>} {label} {required && <span className="font-medium opacity-30">*</span>}
@@ -138,3 +138,10 @@ export function omitSchema<Given extends JSONSchema>(_schema: Given, keys: strin
export function isTypeSchema(schema?: JsonSchema): schema is JsonSchema { export function isTypeSchema(schema?: JsonSchema): schema is JsonSchema {
return typeof schema === "object" && "type" in schema && !isType(schema.type, "error"); return typeof schema === "object" && "type" in schema && !isType(schema.type, "error");
} }
export function firstDefined<T>(...args: T[]): T | undefined {
for (const arg of args) {
if (typeof arg !== "undefined") return arg;
}
return undefined;
}
+5 -3
View File
@@ -1,4 +1,4 @@
import type { ReactNode } from "react"; import { isValidElement, type ReactNode } from "react";
import { useAuthStrategies } from "../hooks/use-auth"; import { useAuthStrategies } from "../hooks/use-auth";
import { AuthForm } from "./AuthForm"; import { AuthForm } from "./AuthForm";
@@ -30,11 +30,13 @@ export function AuthScreen({
{!loading && ( {!loading && (
<div className="flex flex-col gap-4 items-center w-96 px-6 py-7"> <div className="flex flex-col gap-4 items-center w-96 px-6 py-7">
{logo ? logo : null} {logo ? logo : null}
{typeof intro !== "undefined" ? ( {isValidElement(intro) ? (
intro intro
) : ( ) : (
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<h1 className="text-xl font-bold">Sign in to your admin panel</h1> <h1 className="text-xl font-bold">
Sign {action === "login" ? "in" : "up"} to your admin panel
</h1>
<p className="text-primary/50">Enter your credentials below to get access.</p> <p className="text-primary/50">Enter your credentials below to get access.</p>
</div> </div>
)} )}
+29 -17
View File
@@ -11,7 +11,7 @@ import {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import { useApi, useApiInfiniteQuery, useInvalidate } from "ui/client"; import { useApi, useApiInfiniteQuery, useApiQuery, useInvalidate } from "ui/client";
import { useEvent } from "ui/hooks/use-event"; import { useEvent } from "ui/hooks/use-event";
import { Dropzone, type DropzoneProps, type DropzoneRenderProps, type FileState } from "./Dropzone"; import { Dropzone, type DropzoneProps, type DropzoneRenderProps, type FileState } from "./Dropzone";
import { mediaItemsToFileStates } from "./helper"; import { mediaItemsToFileStates } from "./helper";
@@ -20,6 +20,7 @@ import { useInViewport } from "@mantine/hooks";
export type DropzoneContainerProps = { export type DropzoneContainerProps = {
children?: ReactNode; children?: ReactNode;
initialItems?: MediaFieldSchema[] | false; initialItems?: MediaFieldSchema[] | false;
infinite?: boolean;
entity?: { entity?: {
name: string; name: string;
id: number; id: number;
@@ -39,6 +40,7 @@ export function DropzoneContainer({
query, query,
children, children,
randomFilename, randomFilename,
infinite = false,
...props ...props
}: DropzoneContainerProps) { }: DropzoneContainerProps) {
const id = useId(); const id = useId();
@@ -54,7 +56,7 @@ export function DropzoneContainer({
const entity_name = (media?.entity_name ?? "media") as "media"; const entity_name = (media?.entity_name ?? "media") as "media";
//console.log("dropzone:baseUrl", baseUrl); //console.log("dropzone:baseUrl", baseUrl);
const selectApi = (api: Api, page: number) => const selectApi = (api: Api, page: number = 0) =>
entity entity
? api.data.readManyByReference(entity.name, entity.id, entity.field, { ? api.data.readManyByReference(entity.name, entity.id, entity.field, {
...query, ...query,
@@ -70,7 +72,11 @@ export function DropzoneContainer({
...defaultQuery(page), ...defaultQuery(page),
}); });
const $q = useApiInfiniteQuery(selectApi, {}); const $q = infinite
? useApiInfiniteQuery(selectApi, {})
: useApiQuery(selectApi, {
enabled: initialItems !== false && !initialItems,
});
const getUploadInfo = useEvent((file) => { const getUploadInfo = useEvent((file) => {
const url = entity const url = entity
@@ -108,11 +114,17 @@ export function DropzoneContainer({
autoUpload autoUpload
initialItems={_initialItems} initialItems={_initialItems}
footer={ footer={
<Footer infinite &&
items={_initialItems.length} "setSize" in $q && (
length={$q._data?.[0]?.body.meta.count ?? 0} <Footer
onFirstVisible={() => $q.setSize($q.size + 1)} items={_initialItems.length}
/> length={Math.min(
$q._data?.[0]?.body.meta.count ?? 0,
_initialItems.length + pageSize,
)}
onFirstVisible={() => $q.setSize($q.size + 1)}
/>
)
} }
{...props} {...props}
> >
@@ -142,15 +154,15 @@ const Footer = ({ items = 0, length = 0, onFirstVisible }) => {
const _len = length - items; const _len = length - items;
if (_len <= 0) return null; if (_len <= 0) return null;
return new Array(Math.max(length - items, 0)).fill(0).map((_, i) => ( return new Array(Math.max(length - items, 0))
<div .fill(0)
key={i} .map((_, i) => (
ref={i === 0 ? ref : undefined} <div
className="w-[49%] md:w-60 bg-muted aspect-square" key={i}
> ref={i === 0 ? ref : undefined}
{i === 0 ? (inViewport ? `load ${visible}` : "first") : "other"} className="w-[49%] md:w-60 bg-muted aspect-square"
</div> />
)); ));
}; };
export function useDropzone() { export function useDropzone() {
+1 -1
View File
@@ -134,7 +134,7 @@ export function Header({ hasSidebar = true }) {
<SidebarToggler /> <SidebarToggler />
<UserMenu /> <UserMenu />
</div> </div>
<div className="hidden lg:flex flex-row items-center px-4 gap-2"> <div className="hidden md:flex flex-row items-center px-4 gap-2">
<UserMenu /> <UserMenu />
</div> </div>
</header> </header>
+3 -1
View File
@@ -4,5 +4,7 @@ export const testIds = {
data: { data: {
btnCreateEntity: "data-btns-create-entity", btnCreateEntity: "data-btns-create-entity",
}, },
media: {}, media: {
switchEnabled: "media-switch-enabled",
},
}; };
+18
View File
@@ -0,0 +1,18 @@
import { Logo } from "ui/components/display/Logo";
import { Link } from "ui/components/wouter/Link";
import { Auth } from "ui/elements";
import { useBrowserTitle } from "ui/hooks/use-browser-title";
export function AuthRegister() {
useBrowserTitle(["Register"]);
return (
<Auth.Screen
action="register"
logo={
<Link href={"/"} className="link">
<Logo scale={0.25} />
</Link>
}
/>
);
}
+24 -9
View File
@@ -24,6 +24,7 @@ import {
Form, Form,
FormContextOverride, FormContextOverride,
FormDebug, FormDebug,
HiddenField,
ObjectField, ObjectField,
Subscribe, Subscribe,
useDerivedFieldContext, useDerivedFieldContext,
@@ -36,9 +37,16 @@ import * as AppShell from "../../layouts/AppShell/AppShell";
export function AuthStrategiesList(props) { export function AuthStrategiesList(props) {
useBrowserTitle(["Auth", "Strategies"]); useBrowserTitle(["Auth", "Strategies"]);
const { hasSecrets } = useBknd({ withSecrets: true }); const {
hasSecrets,
config: {
auth: { enabled },
},
} = useBknd({ withSecrets: true });
if (!hasSecrets) { if (!hasSecrets) {
return <Message.MissingPermission what="Auth Strategies" />; return <Message.MissingPermission what="Auth Strategies" />;
} else if (!enabled) {
return <Message.NotEnabled description="Enable Auth first." />;
} }
return <AuthStrategiesListInternal {...props} />; return <AuthStrategiesListInternal {...props} />;
@@ -62,7 +70,6 @@ function AuthStrategiesListInternal() {
); );
async function handleSubmit(data: any) { async function handleSubmit(data: any) {
console.log("submit", { strategies: data });
await $auth.actions.config.set({ strategies: data }); await $auth.actions.config.set({ strategies: data });
} }
@@ -152,7 +159,7 @@ const Strategy = ({ type, name, unavailable }: StrategyProps) => {
<span className="leading-none">{autoFormatString(name)}</span> <span className="leading-none">{autoFormatString(name)}</span>
</div> </div>
<div className="flex flex-row gap-4 items-center"> <div className="flex flex-row gap-4 items-center">
<StrategyToggle /> <StrategyToggle type={type} />
<IconButton <IconButton
Icon={TbSettings} Icon={TbSettings}
size="lg" size="lg"
@@ -168,7 +175,7 @@ const Strategy = ({ type, name, unavailable }: StrategyProps) => {
"flex flex-col border-t border-t-muted px-4 pt-3 pb-4 bg-lightest/50 gap-4", "flex flex-col border-t border-t-muted px-4 pt-3 pb-4 bg-lightest/50 gap-4",
)} )}
> >
<StrategyForm type={type} /> <StrategyForm type={type} name={name} />
</div> </div>
)} )}
</div> </div>
@@ -176,7 +183,7 @@ const Strategy = ({ type, name, unavailable }: StrategyProps) => {
); );
}; };
const StrategyToggle = () => { const StrategyToggle = ({ type }: { type: StrategyProps["type"] }) => {
const ctx = useDerivedFieldContext(""); const ctx = useDerivedFieldContext("");
const { value } = useFormValue(""); const { value } = useFormValue("");
@@ -219,8 +226,10 @@ const OAUTH_BRANDS = {
discord: TbBrandDiscordFilled, discord: TbBrandDiscordFilled,
}; };
const StrategyForm = ({ type }: Pick<StrategyProps, "type">) => { const StrategyForm = ({ type, name }: Pick<StrategyProps, "type" | "name">) => {
let Component = () => <ObjectField path="" wrapperProps={{ wrapper: "group", label: false }} />; let Component = (p: any) => (
<ObjectField path="" wrapperProps={{ wrapper: "group", label: false }} />
);
switch (type) { switch (type) {
case "password": case "password":
Component = StrategyPasswordForm; Component = StrategyPasswordForm;
@@ -230,16 +239,22 @@ const StrategyForm = ({ type }: Pick<StrategyProps, "type">) => {
break; break;
} }
return <Component />; return (
<>
<HiddenField name="type" inputProps={{ disabled: true, defaultValue: type }} />
<Component type={type} name={name} />
</>
);
}; };
const StrategyPasswordForm = () => { const StrategyPasswordForm = () => {
return <ObjectField path="config" wrapperProps={{ wrapper: "group", label: false }} />; return <ObjectField path="config" wrapperProps={{ wrapper: "group", label: false }} />;
}; };
const StrategyOAuthForm = () => { const StrategyOAuthForm = ({ type, name }: Pick<StrategyProps, "type" | "name">) => {
return ( return (
<> <>
<HiddenField name="config.name" inputProps={{ disabled: true, defaultValue: name }} />
<Field name="config.client.client_id" required inputProps={{ type: "password" }} /> <Field name="config.client.client_id" required inputProps={{ type: "password" }} />
<Field name="config.client.client_secret" required inputProps={{ type: "password" }} /> <Field name="config.client.client_secret" required inputProps={{ type: "password" }} />
</> </>
+52 -43
View File
@@ -1,5 +1,4 @@
import React, { Suspense, lazy } from "react"; import { Suspense, lazy, type ComponentType, type ReactNode } from "react";
import { useBknd } from "ui/client/bknd";
import { useTheme } from "ui/client/use-theme"; import { useTheme } from "ui/client/use-theme";
import { Route, Router, Switch } from "wouter"; import { Route, Router, Switch } from "wouter";
import AuthRoutes from "./auth"; import AuthRoutes from "./auth";
@@ -10,60 +9,70 @@ import MediaRoutes from "./media";
import { Root, RootEmpty } from "./root"; import { Root, RootEmpty } from "./root";
import SettingsRoutes from "./settings"; import SettingsRoutes from "./settings";
import { FlashMessage } from "ui/modules/server/FlashMessage"; import { FlashMessage } from "ui/modules/server/FlashMessage";
import { AuthRegister } from "ui/routes/auth/auth.register";
import { BkndModalsProvider } from "ui/modals";
// @ts-ignore // @ts-ignore
const TestRoutes = lazy(() => import("./test")); const TestRoutes = lazy(() => import("./test"));
export function Routes() { export function Routes({
const { app } = useBknd(); BkndWrapper,
basePath = "",
}: { BkndWrapper: ComponentType<{ children: ReactNode }>; basePath?: string }) {
const { theme } = useTheme(); const { theme } = useTheme();
return ( return (
<div id="bknd-admin" className={theme + " antialiased"}> <div id="bknd-admin" className={theme + " antialiased"}>
<FlashMessage /> <FlashMessage />
<Router base={app.options.basepath}> <Router base={basePath}>
<Switch> <Switch>
<Route path="/auth/login" component={AuthLogin} /> <Route path="/auth/login" component={AuthLogin} />
<Route path="/" nest> <Route path="/auth/register" component={AuthRegister} />
<Root>
<Switch>
<Route path="/test*" nest>
<Suspense fallback={null}>
<TestRoutes />
</Suspense>
</Route>
<Route path="/" component={RootEmpty} /> <BkndWrapper>
<Route path="/data" nest> <BkndModalsProvider>
<Suspense fallback={null}> <Route path="/" nest>
<DataRoutes /> <Root>
</Suspense> <Switch>
</Route> <Route path="/test*" nest>
<Route path="/flows" nest> <Suspense fallback={null}>
<Suspense fallback={null}> <TestRoutes />
<FlowRoutes /> </Suspense>
</Suspense> </Route>
</Route>
<Route path="/auth" nest>
<Suspense fallback={null}>
<AuthRoutes />
</Suspense>
</Route>
<Route path="/media" nest>
<Suspense fallback={null}>
<MediaRoutes />
</Suspense>
</Route>
<Route path="/settings" nest>
<Suspense fallback={null}>
<SettingsRoutes />
</Suspense>
</Route>
<Route path="*" component={NotFound} /> <Route path="/" component={RootEmpty} />
</Switch> <Route path="/data" nest>
</Root> <Suspense fallback={null}>
</Route> <DataRoutes />
</Suspense>
</Route>
<Route path="/flows" nest>
<Suspense fallback={null}>
<FlowRoutes />
</Suspense>
</Route>
<Route path="/auth" nest>
<Suspense fallback={null}>
<AuthRoutes />
</Suspense>
</Route>
<Route path="/media" nest>
<Suspense fallback={null}>
<MediaRoutes />
</Suspense>
</Route>
<Route path="/settings" nest>
<Suspense fallback={null}>
<SettingsRoutes />
</Suspense>
</Route>
<Route path="*" component={NotFound} />
</Switch>
</Root>
</Route>
</BkndModalsProvider>
</BkndWrapper>
</Switch> </Switch>
</Router> </Router>
</div> </div>
+1 -1
View File
@@ -35,7 +35,7 @@ export function MediaIndex() {
return ( return (
<AppShell.Scrollable> <AppShell.Scrollable>
<div className="flex flex-1 p-3"> <div className="flex flex-1 p-3">
<Media.Dropzone onClick={onClick} /> <Media.Dropzone onClick={onClick} infinite />
</div> </div>
</AppShell.Scrollable> </AppShell.Scrollable>
); );
+5 -1
View File
@@ -20,6 +20,7 @@ import {
} from "ui/components/form/json-schema-form"; } from "ui/components/form/json-schema-form";
import { useBrowserTitle } from "ui/hooks/use-browser-title"; import { useBrowserTitle } from "ui/hooks/use-browser-title";
import * as AppShell from "ui/layouts/AppShell/AppShell"; import * as AppShell from "ui/layouts/AppShell/AppShell";
import { testIds } from "ui/lib/config";
export function MediaSettings(props) { export function MediaSettings(props) {
useBrowserTitle(["Media", "Settings"]); useBrowserTitle(["Media", "Settings"]);
@@ -79,7 +80,10 @@ function MediaSettingsInternal() {
<AppShell.Scrollable> <AppShell.Scrollable>
<RootFormError /> <RootFormError />
<div className="flex flex-col gap-3 p-3"> <div className="flex flex-col gap-3 p-3">
<Field name="enabled" /> <Field
name="enabled"
inputProps={{ "data-testId": testIds.media.switchEnabled }}
/>
<div className="flex flex-col gap-3 relative"> <div className="flex flex-col gap-3 relative">
<Overlay /> <Overlay />
<Field name="storage.body_max_size" label="Storage Body Max Size" /> <Field name="storage.body_max_size" label="Storage Body Max Size" />
+4 -2
View File
@@ -50,7 +50,7 @@ if (example) {
} }
let app: App; let app: App;
const recreate = import.meta.env.VITE_APP_DISABLE_FRESH !== "1"; const recreate = import.meta.env.VITE_APP_FRESH === "1";
let firstStart = true; let firstStart = true;
export default { export default {
async fetch(request: Request) { async fetch(request: Request) {
@@ -66,7 +66,9 @@ export default {
}, },
"sync", "sync",
); );
await app.build(); await app.build({
sync: !!(firstStart && example),
});
// log routes // log routes
if (firstStart) { if (firstStart) {
@@ -0,0 +1,15 @@
"use client";
import { type BkndAdminProps, Admin } from "bknd/ui";
import { useEffect, useState } from "react";
export function AdminComponent(props: BkndAdminProps) {
const [ready, setReady] = useState(false);
useEffect(() => {
if (typeof window !== "undefined") setReady(true);
}, []);
if (!ready) return null;
return <Admin {...props} />;
}
@@ -1,12 +1,12 @@
import { Admin } from "bknd/ui"; import { AdminComponent } from "./Admin";
import "bknd/dist/styles.css";
import { getApi } from "@/bknd"; import { getApi } from "@/bknd";
import "bknd/dist/styles.css";
export default async function AdminPage() { export default async function AdminPage() {
const api = await getApi({ verify: true }); const api = await getApi({ verify: true });
return ( return (
<Admin <AdminComponent
withProvider={{ user: api.getUser() }} withProvider={{ user: api.getUser() }}
config={{ config={{
basepath: "/admin", basepath: "/admin",