mirror of
https://github.com/bknd-io/bknd/
synced 2026-08-02 16:16:02 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c11789ea8 | |||
| 2988e4c3bd | |||
| ad7926db4c | |||
| 53a3dcdee7 | |||
| 53467d6750 | |||
| a80a731498 | |||
| 7e1757b7f4 | |||
| 2c29e06fb8 | |||
| ca86fa58ac | |||
| de984fa101 | |||
| a12d4e13d0 | |||
| fa6c7acaf5 |
+2
-1
@@ -1,3 +1,4 @@
|
|||||||
playwright-report
|
playwright-report
|
||||||
test-results
|
test-results
|
||||||
bknd.config.*
|
bknd.config.*
|
||||||
|
__test__/helper.d.ts
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,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 },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user