removed admin config from server, theme is now client side, fixed module manager migrations

This commit is contained in:
dswbx
2025-03-11 17:41:33 +01:00
parent de89e26ffb
commit 96781f5d3d
33 changed files with 1572 additions and 319 deletions

View File

@@ -4,6 +4,7 @@ import { AuthController } from "../../src/auth/api/AuthController";
import { em, entity, make, text } from "../../src/data"; import { em, entity, make, text } from "../../src/data";
import { AppAuth, type ModuleBuildContext } from "../../src/modules"; import { AppAuth, type ModuleBuildContext } from "../../src/modules";
import { disableConsoleLog, enableConsoleLog } from "../helper"; import { disableConsoleLog, enableConsoleLog } from "../helper";
// @ts-ignore
import { makeCtx, moduleTestSuite } from "./module-test-suite"; import { makeCtx, moduleTestSuite } from "./module-test-suite";
describe("AppAuth", () => { describe("AppAuth", () => {
@@ -22,7 +23,7 @@ describe("AppAuth", () => {
const config = auth.toJSON(); const config = auth.toJSON();
expect(config.jwt).toBeUndefined(); expect(config.jwt).toBeUndefined();
expect(config.strategies.password.config).toBeUndefined(); expect(config.strategies?.password?.config).toBeUndefined();
}); });
test("enabling auth: generate secret", async () => { test("enabling auth: generate secret", async () => {
@@ -42,6 +43,7 @@ describe("AppAuth", () => {
const auth = new AppAuth( const auth = new AppAuth(
{ {
enabled: true, enabled: true,
// @ts-ignore
jwt: { jwt: {
secret: "123456", secret: "123456",
}, },
@@ -75,7 +77,7 @@ describe("AppAuth", () => {
const { data: users } = await ctx.em.repository("users").findMany(); const { data: users } = await ctx.em.repository("users").findMany();
expect(users.length).toBe(1); expect(users.length).toBe(1);
expect(users[0].email).toBe("some@body.com"); expect(users[0]?.email).toBe("some@body.com");
} }
}); });

View File

@@ -43,7 +43,7 @@ describe("ModuleManager", async () => {
}).toJSON(), }).toJSON(),
}, },
}, },
}); }) as any;
//const { version, ...json } = mm.toJSON() as any; //const { version, ...json } = mm.toJSON() as any;
const c2 = getDummyConnection(); const c2 = getDummyConnection();
@@ -73,7 +73,7 @@ describe("ModuleManager", async () => {
}).toJSON(), }).toJSON(),
}, },
}, },
}; } as any;
//const { version, ...json } = mm.toJSON() as any; //const { version, ...json } = mm.toJSON() as any;
const { dummyConnection } = getDummyConnection(); const { dummyConnection } = getDummyConnection();
@@ -90,23 +90,20 @@ describe("ModuleManager", async () => {
await mm2.build(); await mm2.build();
expect(stripMark(json)).toEqual(stripMark(mm2.configs())); expect(stripMark(json)).toEqual(stripMark(mm2.configs()));
expect(mm2.configs().data.entities.test).toBeDefined(); expect(mm2.configs().data.entities?.test).toBeDefined();
expect(mm2.configs().data.entities.test.fields.content).toBeDefined(); expect(mm2.configs().data.entities?.test?.fields?.content).toBeDefined();
expect(mm2.get("data").toJSON().entities.test.fields.content).toBeDefined(); expect(mm2.get("data").toJSON().entities?.test?.fields?.content).toBeDefined();
}); });
test("s4: config given, table exists, version outdated, migrate", async () => { test("s4: config given, table exists, version outdated, migrate", async () => {
const c = getDummyConnection(); const c = getDummyConnection();
const mm = new ModuleManager(c.dummyConnection); const mm = new ModuleManager(c.dummyConnection);
await mm.build(); await mm.build();
const version = mm.version();
const json = mm.configs(); const json = mm.configs();
const c2 = getDummyConnection(); const c2 = getDummyConnection();
const db = c2.dummyConnection.kysely; const db = c2.dummyConnection.kysely;
const mm2 = new ModuleManager(c2.dummyConnection, { const mm2 = new ModuleManager(c2.dummyConnection);
initial: { version: version - 1, ...json },
});
await mm2.syncConfigTable(); await mm2.syncConfigTable();
await db await db
@@ -171,7 +168,7 @@ describe("ModuleManager", async () => {
expect(mm2.configs().data.basepath).toBe("/api/data2"); expect(mm2.configs().data.basepath).toBe("/api/data2");
}); });
test("blank app, modify config", async () => { /*test("blank app, modify config", async () => {
const { dummyConnection } = getDummyConnection(); const { dummyConnection } = getDummyConnection();
const mm = new ModuleManager(dummyConnection); const mm = new ModuleManager(dummyConnection);
@@ -194,7 +191,7 @@ describe("ModuleManager", async () => {
}, },
}, },
}); });
}); });*/
test("partial config given", async () => { test("partial config given", async () => {
const { dummyConnection } = getDummyConnection(); const { dummyConnection } = getDummyConnection();
@@ -212,13 +209,15 @@ describe("ModuleManager", async () => {
expect(mm.version()).toBe(CURRENT_VERSION); expect(mm.version()).toBe(CURRENT_VERSION);
expect(mm.built()).toBe(true); expect(mm.built()).toBe(true);
expect(mm.configs().auth.enabled).toBe(true); expect(mm.configs().auth.enabled).toBe(true);
expect(mm.configs().data.entities.users).toBeDefined(); expect(mm.configs().data.entities?.users).toBeDefined();
}); });
test("partial config given, but db version exists", async () => { test("partial config given, but db version exists", async () => {
const c = getDummyConnection(); const c = getDummyConnection();
const mm = new ModuleManager(c.dummyConnection); const mm = new ModuleManager(c.dummyConnection);
await mm.build(); await mm.build();
console.log("==".repeat(30));
console.log("");
const json = mm.configs(); const json = mm.configs();
const c2 = getDummyConnection(); const c2 = getDummyConnection();
@@ -265,8 +264,8 @@ describe("ModuleManager", async () => {
override async build() { override async build() {
//console.log("building FailingModule", this.config); //console.log("building FailingModule", this.config);
if (this.config.value < 0) { if (this.config.value && this.config.value < 0) {
throw new Error("value must be positive"); throw new Error("value must be positive, given: " + this.config.value);
} }
this.setBuilt(); this.setBuilt();
} }
@@ -332,6 +331,7 @@ describe("ModuleManager", async () => {
// @ts-ignore // @ts-ignore
const f = mm.mutateConfigSafe("failing"); const f = mm.mutateConfigSafe("failing");
// @ts-ignore
expect(f.set({ value: 2 })).resolves.toBeDefined(); expect(f.set({ value: 2 })).resolves.toBeDefined();
expect(mockOnUpdated).toHaveBeenCalled(); expect(mockOnUpdated).toHaveBeenCalled();
}); });

View File

@@ -1,39 +1,44 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { type InitialModuleConfigs, createApp } from "../../../src"; import { type InitialModuleConfigs, createApp } from "../../../src";
import type { Kysely } from "kysely"; import { type Kysely, sql } from "kysely";
import { getDummyConnection } from "../../helper"; import { getDummyConnection } from "../../helper";
import v7 from "./samples/v7.json"; import v7 from "./samples/v7.json";
import v8 from "./samples/v8.json";
import v8_2 from "./samples/v8-2.json";
// app expects migratable config to be present in database // app expects migratable config to be present in database
async function createVersionedApp(config: InitialModuleConfigs) { async function createVersionedApp(config: InitialModuleConfigs | any) {
const { dummyConnection } = getDummyConnection(); const { dummyConnection } = getDummyConnection();
if (!("version" in config)) throw new Error("config must have a version"); if (!("version" in config)) throw new Error("config must have a version");
const { version, ...rest } = config; const { version, ...rest } = config;
const app = createApp({ connection: dummyConnection }); const db = dummyConnection.kysely as Kysely<any>;
await app.build(); await sql`CREATE TABLE "__bknd" (
"id" integer not null primary key autoincrement,
"version" integer,
"type" text,
"json" text,
"created_at" datetime,
"updated_at" datetime
)`.execute(db);
const qb = app.modules.ctx().connection.kysely as Kysely<any>; await db
const current = await qb .insertInto("__bknd")
.selectFrom("__bknd") .values({
.selectAll() version,
.where("type", "=", "config") type: "config",
.executeTakeFirst(); created_at: new Date().toISOString(),
json: JSON.stringify(rest),
await qb })
.updateTable("__bknd")
.set("json", JSON.stringify(rest))
.set("version", 7)
.where("id", "=", current!.id)
.execute(); .execute();
const app2 = createApp({ const app = createApp({
connection: dummyConnection, connection: dummyConnection,
}); });
await app2.build(); await app.build();
return app2; return app;
} }
describe("Migrations", () => { describe("Migrations", () => {
@@ -46,8 +51,8 @@ describe("Migrations", () => {
const app = await createVersionedApp(v7); const app = await createVersionedApp(v7);
expect(app.version()).toBe(8); expect(app.version()).toBeGreaterThan(7);
expect(app.toJSON(true).auth.strategies.password.enabled).toBe(true); expect(app.toJSON(true).auth.strategies?.password?.enabled).toBe(true);
const req = await app.server.request("/api/auth/password/register", { const req = await app.server.request("/api/auth/password/register", {
method: "POST", method: "POST",
@@ -60,7 +65,30 @@ describe("Migrations", () => {
}), }),
}); });
expect(req.ok).toBe(true); expect(req.ok).toBe(true);
const res = await req.json(); const res = (await req.json()) as any;
expect(res.user.email).toBe("test@test.com"); expect(res.user.email).toBe("test@test.com");
}); });
test("migration from 8 to 9", async () => {
expect(v8.version).toBe(8);
const app = await createVersionedApp(v8);
expect(app.version()).toBeGreaterThan(8);
// @ts-expect-error
expect(app.toJSON(true).server.admin).toBeUndefined();
});
test.only("migration from 8 to 9 (from initial)", async () => {
expect(v8_2.version).toBe(8);
const app = createApp({
connection: getDummyConnection().dummyConnection,
initialConfig: v8_2 as any,
});
expect(app.version()).toBeGreaterThan(8);
// @ts-expect-error
expect(app.toJSON(true).server.admin).toBeUndefined();
});
}); });

View File

@@ -0,0 +1,709 @@
{
"version": 8,
"server": {
"admin": {
"basepath": "",
"color_scheme": "light",
"logo_return_path": "/"
},
"cors": {
"origin": "*",
"allow_methods": ["GET", "POST", "PATCH", "PUT", "DELETE"],
"allow_headers": [
"Content-Type",
"Content-Length",
"Authorization",
"Accept"
]
}
},
"data": {
"basepath": "/api/data",
"entities": {
"products": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": {
"fillable": false,
"required": false,
"hidden": false
}
},
"title": {
"type": "text",
"config": {
"required": true,
"fillable": true,
"hidden": false
}
},
"brand": {
"type": "text",
"config": {
"required": true,
"fillable": true,
"hidden": false
}
},
"currency": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"price": {
"type": "number",
"config": {
"required": true,
"fillable": true,
"hidden": false
}
},
"price_compare": {
"type": "number",
"config": {
"required": false,
"fillable": true,
"hidden": ["table"]
}
},
"url": {
"type": "text",
"config": {
"html_config": {
"element": "input"
},
"required": true,
"fillable": true,
"hidden": ["table"]
}
},
"created_at": {
"type": "date",
"config": {
"type": "date",
"required": false,
"fillable": true,
"hidden": ["table"]
}
},
"description": {
"type": "text",
"config": {
"html_config": {
"element": "textarea",
"props": {
"rows": 4
}
},
"required": false,
"fillable": true,
"hidden": ["table"]
}
},
"images": {
"type": "media",
"config": {
"required": false,
"fillable": ["update"],
"hidden": false,
"mime_types": [],
"virtual": true,
"entity": "products"
}
},
"identifier": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"metadata": {
"type": "jsonschema",
"config": {
"schema": {
"type": "object",
"properties": {
"size": {
"type": "string"
},
"gender": {
"type": "string"
},
"ai_description": {
"type": "string"
}
},
"additionalProperties": {
"type": ["string", "number"]
}
},
"required": false,
"fillable": true,
"hidden": ["table"]
}
},
"_likes": {
"type": "number",
"config": {
"default_value": 0,
"required": false,
"fillable": true,
"hidden": false
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "desc"
}
},
"media": {
"type": "system",
"fields": {
"id": {
"type": "primary",
"config": {
"fillable": false,
"required": false,
"hidden": false
}
},
"path": {
"type": "text",
"config": {
"required": true,
"fillable": true,
"hidden": false
}
},
"folder": {
"type": "boolean",
"config": {
"default_value": false,
"hidden": true,
"fillable": ["create"],
"required": false
}
},
"mime_type": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"size": {
"type": "number",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"scope": {
"type": "text",
"config": {
"hidden": true,
"fillable": ["create"],
"required": false
}
},
"etag": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"modified_at": {
"type": "date",
"config": {
"type": "datetime",
"required": false,
"fillable": true,
"hidden": false
}
},
"reference": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"entity_id": {
"type": "number",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"metadata": {
"type": "json",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"temporary": {
"type": "boolean",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "desc"
}
},
"users": {
"type": "system",
"fields": {
"id": {
"type": "primary",
"config": {
"fillable": false,
"required": false,
"hidden": false
}
},
"email": {
"type": "text",
"config": {
"required": true,
"fillable": true,
"hidden": false
}
},
"strategy": {
"type": "enum",
"config": {
"options": {
"type": "strings",
"values": ["password"]
},
"required": true,
"fillable": ["create"],
"hidden": ["read", "table", "update", "form"]
}
},
"strategy_value": {
"type": "text",
"config": {
"fillable": ["create"],
"hidden": ["read", "table", "update", "form"],
"required": true
}
},
"role": {
"type": "enum",
"config": {
"options": {
"type": "strings",
"values": ["guest", "admin"]
},
"required": false,
"fillable": true,
"hidden": false
}
},
"username": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"name": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"_boards": {
"type": "number",
"config": {
"default_value": 0,
"required": false,
"fillable": true,
"hidden": false
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "desc"
}
},
"product_likes": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": {
"fillable": false,
"required": false,
"hidden": false
}
},
"created_at": {
"type": "date",
"config": {
"type": "datetime",
"required": false,
"fillable": true,
"hidden": false
}
},
"users_id": {
"type": "relation",
"config": {
"label": "User",
"required": true,
"reference": "users",
"target": "users",
"target_field": "id",
"fillable": true,
"hidden": false,
"on_delete": "set null"
}
},
"products_id": {
"type": "relation",
"config": {
"label": "Product",
"required": true,
"reference": "products",
"target": "products",
"target_field": "id",
"fillable": true,
"hidden": false,
"on_delete": "set null"
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "desc",
"name": "Product Likes"
}
},
"boards": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": {
"fillable": false,
"required": false,
"hidden": false
}
},
"private": {
"type": "boolean",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"title": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"description": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"users_id": {
"type": "relation",
"config": {
"label": "Users",
"required": true,
"reference": "users",
"target": "users",
"target_field": "id",
"fillable": true,
"hidden": false,
"on_delete": "set null"
}
},
"images": {
"type": "media",
"config": {
"required": false,
"fillable": ["update"],
"hidden": false,
"mime_types": [],
"virtual": true,
"entity": "boards",
"max_items": 5
}
},
"cover": {
"type": "number",
"config": {
"default_value": 0,
"required": false,
"fillable": true,
"hidden": false
}
},
"_products": {
"type": "number",
"config": {
"default_value": 0,
"required": false,
"fillable": true,
"hidden": false
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "desc"
}
},
"boards_products": {
"type": "generated",
"fields": {
"id": {
"type": "primary",
"config": {
"fillable": false,
"required": false,
"hidden": false
}
},
"boards_id": {
"type": "relation",
"config": {
"required": true,
"reference": "boards",
"target": "boards",
"target_field": "id",
"fillable": true,
"hidden": false,
"on_delete": "set null"
}
},
"products_id": {
"type": "relation",
"config": {
"required": true,
"reference": "products",
"target": "products",
"target_field": "id",
"fillable": true,
"hidden": false,
"on_delete": "set null"
}
},
"manual": {
"type": "boolean",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "desc"
}
}
},
"relations": {
"poly_products_media_images": {
"type": "poly",
"source": "products",
"target": "media",
"config": {
"mappedBy": "images"
}
},
"n1_product_likes_users": {
"type": "n:1",
"source": "product_likes",
"target": "users",
"config": {
"mappedBy": "",
"inversedBy": "",
"required": true,
"with_limit": 5
}
},
"n1_product_likes_products": {
"type": "n:1",
"source": "product_likes",
"target": "products",
"config": {
"mappedBy": "",
"inversedBy": "",
"required": true,
"with_limit": 5
}
},
"n1_boards_users": {
"type": "n:1",
"source": "boards",
"target": "users",
"config": {
"mappedBy": "",
"inversedBy": "",
"required": true,
"with_limit": 5
}
},
"poly_boards_media_images": {
"type": "poly",
"source": "boards",
"target": "media",
"config": {
"mappedBy": "images",
"targetCardinality": 5
}
},
"mn_boards_products_boards_products,boards_products": {
"type": "m:n",
"source": "boards",
"target": "products",
"config": {}
}
},
"indices": {
"idx_unique_media_path": {
"entity": "media",
"fields": ["path"],
"unique": true
},
"idx_media_reference": {
"entity": "media",
"fields": ["reference"],
"unique": false
},
"idx_unique_users_email": {
"entity": "users",
"fields": ["email"],
"unique": true
},
"idx_users_strategy": {
"entity": "users",
"fields": ["strategy"],
"unique": false
},
"idx_users_strategy_value": {
"entity": "users",
"fields": ["strategy_value"],
"unique": false
},
"idx_product_likes_unique_products_id_users_id": {
"entity": "product_likes",
"fields": ["products_id", "users_id"],
"unique": true
},
"idx_boards_products_unique_boards_id_products_id": {
"entity": "boards_products",
"fields": ["boards_id", "products_id"],
"unique": true
},
"idx_media_entity_id": {
"entity": "media",
"fields": ["entity_id"],
"unique": false
},
"idx_products_identifier": {
"entity": "products",
"fields": ["identifier"],
"unique": false
}
}
},
"auth": {
"enabled": true,
"basepath": "/api/auth",
"entity_name": "users",
"allow_register": true,
"jwt": {
"secret": "2wY76Z$JQg(3t?Wn8g,^ZqZhlmjNx<@uQjI!7i^XZBF11Xa1>zZK2??Y[D|]cc%k",
"alg": "HS256",
"fields": ["id", "email", "role"]
},
"cookie": {
"path": "/",
"sameSite": "lax",
"secure": true,
"httpOnly": true,
"expires": 604800,
"renew": true,
"pathSuccess": "/",
"pathLoggedOut": "/"
},
"strategies": {
"password": {
"enabled": true,
"type": "password",
"config": {
"hashing": "sha256"
}
}
},
"roles": {
"guest": {
"is_default": true,
"permissions": ["system.access.api", "data.entity.read"]
},
"admin": {
"implicit_allow": true
}
},
"guard": {
"enabled": true
}
},
"media": {
"enabled": true,
"basepath": "/api/media",
"entity_name": "media",
"storage": {},
"adapter": {
"type": "s3",
"config": {
"access_key": "123",
"secret_access_key": "123",
"url": "https://123.r2.cloudflarestorage.com/enly"
}
}
},
"flows": {
"basepath": "/api/flows",
"flows": {}
}
}

View File

@@ -0,0 +1,598 @@
{
"version": 8,
"server": {
"admin": {
"basepath": "",
"logo_return_path": "/",
"color_scheme": "dark"
},
"cors": {
"origin": "*",
"allow_methods": ["GET", "POST", "PATCH", "PUT", "DELETE"],
"allow_headers": [
"Content-Type",
"Content-Length",
"Authorization",
"Accept"
]
}
},
"data": {
"basepath": "/api/data",
"entities": {
"posts": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": {
"fillable": false,
"required": false,
"hidden": false
}
},
"title": {
"type": "text",
"config": {
"required": true,
"fillable": true,
"hidden": false
}
},
"slug": {
"type": "text",
"config": {
"html_config": {
"element": "input"
},
"pattern": "^[a-z\\-\\_0-9]+$",
"required": false,
"fillable": true,
"hidden": false,
"label": "Slug"
}
},
"content": {
"type": "text",
"config": {
"html_config": {
"element": "textarea",
"props": {
"rows": 5
}
},
"required": false,
"fillable": true,
"hidden": ["form"]
}
},
"active": {
"type": "boolean",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"images": {
"type": "media",
"config": {
"required": false,
"fillable": ["update"],
"hidden": false,
"mime_types": [],
"virtual": true,
"entity": "posts"
}
},
"tags": {
"type": "jsonschema",
"config": {
"schema": {
"type": "array",
"items": {
"type": "string"
}
},
"ui_schema": {
"ui:options": {
"orderable": false
}
},
"required": false,
"fillable": true,
"hidden": false
}
},
"users_id": {
"type": "relation",
"config": {
"label": "Users",
"required": false,
"reference": "users",
"target": "users",
"target_field": "id",
"fillable": true,
"hidden": false,
"on_delete": "set null"
}
}
},
"config": {
"sort_field": "title",
"sort_dir": "desc"
}
},
"comments": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": {
"fillable": false,
"required": false,
"hidden": false
}
},
"content": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"created_at": {
"type": "date",
"config": {
"type": "date",
"required": false,
"fillable": true,
"hidden": false
}
},
"posts_id": {
"type": "relation",
"config": {
"label": "Posts",
"required": true,
"reference": "posts",
"target": "posts",
"target_field": "id",
"fillable": true,
"hidden": false,
"on_delete": "set null"
}
},
"users_id": {
"type": "relation",
"config": {
"label": "Users",
"required": true,
"reference": "users",
"target": "users",
"target_field": "id",
"fillable": true,
"hidden": false,
"on_delete": "set null"
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "asc"
}
},
"media": {
"type": "system",
"fields": {
"id": {
"type": "primary",
"config": {
"fillable": false,
"required": false,
"hidden": false
}
},
"path": {
"type": "text",
"config": {
"required": true,
"fillable": true,
"hidden": false
}
},
"folder": {
"type": "boolean",
"config": {
"default_value": false,
"hidden": true,
"fillable": ["create"],
"required": false
}
},
"mime_type": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"size": {
"type": "number",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"scope": {
"type": "text",
"config": {
"hidden": true,
"fillable": ["create"],
"required": false
}
},
"etag": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"modified_at": {
"type": "date",
"config": {
"type": "datetime",
"required": false,
"fillable": true,
"hidden": false
}
},
"reference": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"entity_id": {
"type": "number",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"metadata": {
"type": "json",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "asc"
}
},
"users": {
"type": "system",
"fields": {
"id": {
"type": "primary",
"config": {
"fillable": false,
"required": false,
"hidden": false
}
},
"email": {
"type": "text",
"config": {
"required": true,
"fillable": true,
"hidden": false
}
},
"strategy": {
"type": "enum",
"config": {
"options": {
"type": "strings",
"values": ["password", "google"]
},
"required": true,
"fillable": ["create"],
"hidden": ["update", "form"]
}
},
"strategy_value": {
"type": "text",
"config": {
"fillable": ["create"],
"hidden": ["read", "table", "update", "form"],
"required": true
}
},
"role": {
"type": "enum",
"config": {
"options": {
"type": "strings",
"values": ["guest", "admin", "editor"]
},
"required": false,
"fillable": true,
"hidden": false
}
},
"avatar": {
"type": "media",
"config": {
"required": false,
"fillable": ["update"],
"hidden": false,
"mime_types": [],
"virtual": true,
"entity": "users",
"max_items": 1
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "asc"
}
},
"test": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": {
"fillable": false,
"required": false,
"hidden": false
}
},
"title": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"number": {
"type": "number",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "asc"
}
}
},
"relations": {
"n1_comments_posts": {
"type": "n:1",
"source": "comments",
"target": "posts",
"config": {
"required": true,
"with_limit": 5
}
},
"poly_posts_media_images": {
"type": "poly",
"source": "posts",
"target": "media",
"config": {
"mappedBy": "images"
}
},
"n1_posts_users": {
"type": "n:1",
"source": "posts",
"target": "users",
"config": {
"with_limit": 5
}
},
"n1_comments_users": {
"type": "n:1",
"source": "comments",
"target": "users",
"config": {
"required": true,
"with_limit": 5
}
},
"poly_users_media_avatar": {
"type": "poly",
"source": "users",
"target": "media",
"config": {
"mappedBy": "avatar",
"targetCardinality": 1
}
}
},
"indices": {
"idx_unique_media_path": {
"entity": "media",
"fields": ["path"],
"unique": true
},
"idx_unique_users_email": {
"entity": "users",
"fields": ["email"],
"unique": true
},
"idx_users_strategy": {
"entity": "users",
"fields": ["strategy"],
"unique": false
},
"idx_users_strategy_value": {
"entity": "users",
"fields": ["strategy_value"],
"unique": false
},
"idx_media_reference": {
"entity": "media",
"fields": ["reference"],
"unique": false
},
"idx_media_entity_id": {
"entity": "media",
"fields": ["entity_id"],
"unique": false
}
}
},
"auth": {
"enabled": true,
"basepath": "/api/auth",
"entity_name": "users",
"jwt": {
"secret": "A%3jk*wD!Zruj123123123j$Wm8qS8m8qS8",
"alg": "HS256",
"fields": ["id", "email", "role"],
"issuer": "showoff"
},
"guard": {
"enabled": true
},
"strategies": {
"password": {
"enabled": true,
"type": "password",
"config": {
"hashing": "sha256"
}
},
"google": {
"enabled": true,
"type": "oauth",
"config": {
"type": "oidc",
"client": {
"client_id": "545948917277-123ieuifrag.apps.googleusercontent.com",
"client_secret": "123-123hTTZfDDGPDPp"
},
"name": "google"
}
}
},
"roles": {
"guest": {
"permissions": [
"data.entity.read",
"system.access.api",
"system.config.read"
],
"is_default": true
},
"admin": {
"is_default": false,
"implicit_allow": true
},
"editor": {
"permissions": [
"system.access.admin",
"system.config.read",
"system.schema.read",
"system.config.read.secrets",
"system.access.api",
"data.entity.read",
"data.entity.update",
"data.entity.delete",
"data.entity.create"
]
}
},
"allow_register": true,
"cookie": {
"path": "/",
"sameSite": "lax",
"secure": true,
"httpOnly": true,
"expires": 604800,
"renew": true,
"pathSuccess": "/",
"pathLoggedOut": "/"
}
},
"media": {
"enabled": true,
"basepath": "/api/media",
"entity_name": "media",
"storage": {},
"adapter": {
"type": "s3",
"config": {
"access_key": "123",
"secret_access_key": "123",
"url": "https://123.r2.cloudflarestorage.com/bknd-123"
}
}
},
"flows": {
"basepath": "/api/flows",
"flows": {
"test": {
"trigger": {
"type": "http",
"config": {
"mode": "sync",
"method": "GET",
"response_type": "html",
"path": "/json-posts"
}
},
"tasks": {
"fetching": {
"type": "fetch",
"params": {
"method": "GET",
"headers": [],
"url": "https://jsonplaceholder.typicode.com/posts"
}
},
"render": {
"type": "render",
"params": {
"render": "<h1>Posts</h1>\n<ul>\n {% for post in fetching.output %}\n <li>{{ post.title }}</li>\n {% endfor %}\n</ul>"
}
}
},
"connections": {
"5cce66b5-57c6-4541-88ac-b298794c6c52": {
"source": "fetching",
"target": "render",
"config": {
"condition": {
"type": "success"
}
}
}
},
"start_task": "fetching",
"responding_task": "render"
}
}
}
}

View File

@@ -30,7 +30,7 @@ export class DebugLogger {
const now = performance.now(); const now = performance.now();
const time = this.last === 0 ? 0 : Number.parseInt(String(now - this.last)); const time = this.last === 0 ? 0 : Number.parseInt(String(now - this.last));
const indents = " ".repeat(this._context.length); const indents = " ".repeat(Math.max(this._context.length - 1, 0));
const context = const context =
this._context.length > 0 ? `[${this._context[this._context.length - 1]}]` : ""; this._context.length > 0 ? `[${this._context[this._context.length - 1]}]` : "";
console.log(indents, context, time, ...args); console.log(indents, context, time, ...args);

View File

@@ -19,8 +19,6 @@ export class AppData extends Module<typeof dataConfigSchema> {
indices: _indices = {}, indices: _indices = {},
} = this.config; } = this.config;
this.ctx.logger.context("AppData").log("building with entities", Object.keys(_entities));
const entities = transformObject(_entities, (entityConfig, name) => { const entities = transformObject(_entities, (entityConfig, name) => {
return constructEntity(name, entityConfig); return constructEntity(name, entityConfig);
}); });
@@ -60,7 +58,6 @@ export class AppData extends Module<typeof dataConfigSchema> {
); );
this.ctx.guard.registerPermissions(Object.values(DataPermissions)); this.ctx.guard.registerPermissions(Object.values(DataPermissions));
this.ctx.logger.clear();
this.setBuilt(); this.setBuilt();
} }

View File

@@ -115,7 +115,7 @@ const configJsonSchema = Type.Union([
}), }),
), ),
]); ]);
const __bknd = entity(TABLE_NAME, { export const __bknd = entity(TABLE_NAME, {
version: number().required(), version: number().required(),
type: enumm({ enum: ["config", "diff", "backup"] }).required(), type: enumm({ enum: ["config", "diff", "backup"] }).required(),
json: jsonSchema({ schema: configJsonSchema }).required(), json: jsonSchema({ schema: configJsonSchema }).required(),
@@ -170,6 +170,8 @@ export class ModuleManager {
} }
} }
this.logger.log("booted with", this._booted_with);
this.createModules(initial); this.createModules(initial);
} }
@@ -271,7 +273,7 @@ export class ModuleManager {
}; };
} }
private async fetch(): Promise<ConfigTable> { private async fetch(): Promise<ConfigTable | undefined> {
this.logger.context("fetch").log("fetching"); this.logger.context("fetch").log("fetching");
const startTime = performance.now(); const startTime = performance.now();
@@ -285,7 +287,7 @@ export class ModuleManager {
if (!result) { if (!result) {
this.logger.log("error fetching").clear(); this.logger.log("error fetching").clear();
throw BkndError.with("no config"); return undefined;
} }
this.logger this.logger
@@ -305,6 +307,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");
this.logger.log("fetched version", state.version); this.logger.log("fetched version", state.version);
if (state.version !== version) { if (state.version !== version) {
@@ -321,11 +324,11 @@ export class ModuleManager {
json: configs, json: configs,
}); });
} else { } else {
this.logger.log("version matches"); 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(state.json, clone(configs));
this.logger.log("checking diff", diffs); this.logger.log("checking diff", [diffs.length]);
if (diff.length > 0) { if (diff.length > 0) {
// store diff // store diff
@@ -380,78 +383,6 @@ export class ModuleManager {
return this; return this;
} }
private async migrate() {
const state = {
success: false,
migrated: false,
version: {
before: this.version(),
after: this.version(),
},
};
this.logger.context("migrate").log("migrating?", this.version(), CURRENT_VERSION);
if (this.version() < CURRENT_VERSION) {
state.version.before = this.version();
this.logger.log("there are migrations, verify version");
// sync __bknd table
await this.syncConfigTable();
// modules must be built before migration
this.logger.log("building modules");
await this.buildModules({ graceful: true });
this.logger.log("modules built");
try {
const state = await this.fetch();
if (state.version !== this.version()) {
// @todo: potentially drop provided config and use database version
throw new Error(
`Given version (${this.version()}) and fetched version (${state.version}) do not match.`,
);
}
} catch (e: any) {
throw new Error(`Version is ${this.version()}, fetch failed: ${e.message}`);
}
this.logger.log("now migrating");
let version = this.version();
let configs: any = this.configs();
//console.log("migrating with", version, configs);
if (Object.keys(configs).length === 0) {
throw new Error("No config to migrate");
}
const [_version, _configs] = await migrate(version, configs, {
db: this.db,
});
version = _version;
configs = _configs;
this._version = version;
state.version.after = version;
state.migrated = true;
this.ctx().flags.sync_required = true;
this.logger.log("setting configs");
this.createModules(configs);
await this.buildModules();
this.logger.log("migrated to", version);
$console.log("Migrated config from", state.version.before, "to", state.version.after);
await this.save();
} else {
this.logger.log("no migrations needed");
}
state.success = true;
this.logger.clear();
return state;
}
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) => {
@@ -469,66 +400,66 @@ export class ModuleManager {
async build(opts?: { fetch?: boolean }) { async build(opts?: { fetch?: boolean }) {
this.logger.context("build").log("version", this.version()); this.logger.context("build").log("version", this.version());
this.logger.log("booted with", this._booted_with);
// if no config provided, try fetch from db // if no config provided, try fetch from db
if (this.version() === 0 || opts?.fetch === true) { if (this.version() === 0 || opts?.fetch === true) {
if (this.version() === 0) { if (opts?.fetch) {
this.logger.context("no version").log("version is 0"); this.logger.log("force fetch");
} else {
this.logger.context("force fetch").log("force fetch");
} }
try { const result = await this.fetch();
const result = await this.fetch();
// if no version, and nothing found, go with initial
if (!result) {
this.logger.log("nothing in database, go initial");
await this.setupInitial();
} else {
this.logger.log("db has", result.version);
// set version and config from fetched // set version and config from fetched
this._version = result.version; this._version = result.version;
if (this.version() !== CURRENT_VERSION) {
await this.syncConfigTable();
}
if (this.options?.trustFetched === true) { if (this.options?.trustFetched === true) {
this.logger.log("trusting fetched config (mark)"); this.logger.log("trusting fetched config (mark)");
mark(result.json); mark(result.json);
} }
this.setConfigs(result.json); // if version doesn't match, migrate before building
} catch (e: any) { if (this.version() !== CURRENT_VERSION) {
this.logger.clear(); // fetch couldn't clear this.logger.log("now migrating");
this.logger.context("error handler").log("fetch failed", e.message); await this.syncConfigTable();
// we can safely build modules, since config version is up to date const version_before = this.version();
// it's up to date because we use default configs (no fetch result) const [_version, _configs] = await migrate(version_before, result.json, {
this._version = CURRENT_VERSION; db: this.db,
await this.syncConfigTable(); });
const state = await this.buildModules();
if (!state.saved) { this._version = _version;
await this.save(); this.ctx().flags.sync_required = true;
this.logger.log("migrated to", _version);
$console.log("Migrated config from", version_before, "to", this.version());
this.createModules(_configs);
await this.buildModules();
} else {
this.logger.log("version is current", this.version());
this.createModules(result.json);
await this.buildModules();
} }
// run initial setup
await this.setupInitial();
this.logger.clear();
return this;
} }
this.logger.clear();
}
// migrate to latest if needed
this.logger.log("check migrate");
const migration = await this.migrate();
if (migration.success && migration.migrated) {
this.logger.log("skipping build after migration");
} else { } else {
this.logger.log("trigger build modules"); if (this.version() !== CURRENT_VERSION) {
throw new Error(
`Given version (${this.version()}) and current version (${CURRENT_VERSION}) do not match.`,
);
}
this.logger.log("current version is up to date", this.version());
await this.buildModules(); await this.buildModules();
} }
this.logger.log("done"); this.logger.log("done");
this.logger.clear();
return this; return this;
} }
@@ -589,6 +520,14 @@ export class ModuleManager {
} }
protected async setupInitial() { protected async setupInitial() {
this.logger.context("initial").log("start");
this._version = CURRENT_VERSION;
await this.syncConfigTable();
const state = await this.buildModules();
if (!state.saved) {
await this.save();
}
const ctx = { const ctx = {
...this.ctx(), ...this.ctx(),
// disable events for initial setup // disable events for initial setup
@@ -601,6 +540,7 @@ export class ModuleManager {
// run first boot event // run first boot event
await this.options?.onFirstBoot?.(); await this.options?.onFirstBoot?.();
this.logger.clear();
} }
mutateConfigSafe<Module extends keyof Modules>( mutateConfigSafe<Module extends keyof Modules>(

View File

@@ -1,6 +1,7 @@
import { _jsonp, transformObject } from "core/utils"; import { _jsonp, transformObject } from "core/utils";
import { type Kysely, sql } from "kysely"; import { type Kysely, sql } from "kysely";
import { set } from "lodash-es"; import { set } from "lodash-es";
import type { InitialModuleConfigs } from "modules/ModuleManager";
export type MigrationContext = { export type MigrationContext = {
db: Kysely<any>; db: Kysely<any>;
@@ -91,6 +92,17 @@ export const migrations: Migration[] = [
}; };
}, },
}, },
{
// remove admin config
version: 9,
up: async (config) => {
const { admin, ...server } = config.server;
return {
...config,
server,
};
},
},
]; ];
export const CURRENT_VERSION = migrations[migrations.length - 1]?.version ?? 0; export const CURRENT_VERSION = migrations[migrations.length - 1]?.version ?? 0;

View File

@@ -8,7 +8,6 @@ import { Fragment } from "hono/jsx";
import { css, Style } from "hono/css"; import { css, Style } from "hono/css";
import { Controller } from "modules/Controller"; import { Controller } from "modules/Controller";
import * as SystemPermissions from "modules/permissions"; import * as SystemPermissions from "modules/permissions";
import type { AppTheme } from "modules/server/AppServer";
const htmlBkndContextReplace = "<!-- BKND_CONTEXT -->"; const htmlBkndContextReplace = "<!-- BKND_CONTEXT -->";
@@ -74,7 +73,6 @@ export class AdminController extends Controller {
const obj = { const obj = {
user: c.get("auth")?.user, user: c.get("auth")?.user,
logout_route: this.withBasePath(authRoutes.logout), logout_route: this.withBasePath(authRoutes.logout),
color_scheme: configs.server.admin.color_scheme,
}; };
const html = await this.getHtml(obj); const html = await this.getHtml(obj);
if (!html) { if (!html) {

View File

@@ -4,24 +4,9 @@ import { cors } from "hono/cors";
import { Module } from "modules/Module"; import { Module } from "modules/Module";
const serverMethods = ["GET", "POST", "PATCH", "PUT", "DELETE"]; const serverMethods = ["GET", "POST", "PATCH", "PUT", "DELETE"];
const appThemes = ["dark", "light", "system"] as const;
export type AppTheme = (typeof appThemes)[number];
export const serverConfigSchema = Type.Object( export const serverConfigSchema = Type.Object(
{ {
admin: Type.Object(
{
basepath: Type.Optional(Type.String({ default: "", pattern: "^(/.+)?$" })),
color_scheme: Type.Optional(StringEnum(["dark", "light", "system"])),
logo_return_path: Type.Optional(
Type.String({
default: "/",
description: "Path to return to after *clicking* the logo",
}),
),
},
{ default: {}, additionalProperties: false },
),
cors: Type.Object( cors: Type.Object(
{ {
origin: Type.String({ default: "*" }), origin: Type.String({ default: "*" }),
@@ -43,12 +28,6 @@ export const serverConfigSchema = Type.Object(
export type AppServerConfig = Static<typeof serverConfigSchema>; export type AppServerConfig = Static<typeof serverConfigSchema>;
/*declare global {
interface Request {
cf: IncomingRequestCfProperties;
}
}*/
export class AppServer extends Module<typeof serverConfigSchema> { export class AppServer extends Module<typeof serverConfigSchema> {
//private admin_html?: string; //private admin_html?: string;

View File

@@ -1,8 +1,7 @@
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { Notifications } from "@mantine/notifications"; import { Notifications } from "@mantine/notifications";
import type { ModuleConfigs } from "modules";
import React from "react"; import React from "react";
import { BkndProvider } 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";
import * as AppShell from "ui/layouts/AppShell/AppShell"; import * as AppShell from "ui/layouts/AppShell/AppShell";
@@ -14,7 +13,7 @@ import { Routes } from "./routes";
export type BkndAdminProps = { export type BkndAdminProps = {
baseUrl?: string; baseUrl?: string;
withProvider?: boolean | ClientProviderProps; withProvider?: boolean | ClientProviderProps;
config?: ModuleConfigs["server"]["admin"]; config?: BkndAdminOptions;
}; };
export default function Admin({ export default function Admin({
@@ -23,7 +22,7 @@ export default function Admin({
config, config,
}: BkndAdminProps) { }: BkndAdminProps) {
const Component = ( const Component = (
<BkndProvider adminOverride={config} fallback={<Skeleton theme={config?.color_scheme} />}> <BkndProvider options={config} fallback={<Skeleton />}>
<AdminInternal /> <AdminInternal />
</BkndProvider> </BkndProvider>
); );

View File

@@ -4,7 +4,13 @@ import { createContext, startTransition, useContext, useEffect, useRef, useState
import { useApi } from "ui/client"; 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";
export type BkndAdminOptions = {
logo_return_path?: string;
basepath?: string;
theme?: AppTheme;
};
type BkndContext = { type BkndContext = {
version: number; version: number;
schema: ModuleSchemas; schema: ModuleSchemas;
@@ -14,7 +20,7 @@ type BkndContext = {
requireSecrets: () => Promise<void>; requireSecrets: () => Promise<void>;
actions: ReturnType<typeof getSchemaActions>; actions: ReturnType<typeof getSchemaActions>;
app: AppReduced; app: AppReduced;
adminOverride?: ModuleConfigs["server"]["admin"]; options: BkndAdminOptions;
fallback: boolean; fallback: boolean;
}; };
@@ -29,13 +35,15 @@ enum Fetching {
export function BkndProvider({ export function BkndProvider({
includeSecrets = false, includeSecrets = false,
adminOverride, options,
children, children,
fallback = null, fallback = null,
}: { includeSecrets?: boolean; children: any; fallback?: React.ReactNode } & Pick< }: {
BkndContext, includeSecrets?: boolean;
"adminOverride" children: any;
>) { fallback?: React.ReactNode;
options?: BkndAdminOptions;
}) {
const [withSecrets, setWithSecrets] = useState<boolean>(includeSecrets); const [withSecrets, setWithSecrets] = useState<boolean>(includeSecrets);
const [schema, setSchema] = const [schema, setSchema] =
useState<Pick<BkndContext, "version" | "schema" | "config" | "permissions" | "fallback">>(); useState<Pick<BkndContext, "version" | "schema" | "config" | "permissions" | "fallback">>();
@@ -93,13 +101,6 @@ export function BkndProvider({
fallback: true, fallback: true,
} as any); } as any);
if (adminOverride) {
newSchema.config.server.admin = {
...newSchema.config.server.admin,
...adminOverride,
};
}
startTransition(() => { startTransition(() => {
document.startViewTransition(() => { document.startViewTransition(() => {
setSchema(newSchema); setSchema(newSchema);
@@ -122,13 +123,13 @@ export function BkndProvider({
}, []); }, []);
if (!fetched || !schema) return fallback; if (!fetched || !schema) return fallback;
const app = new AppReduced(schema?.config as any); const app = new AppReduced(schema?.config as any, options);
const actions = getSchemaActions({ api, setSchema, reloadSchema }); const actions = getSchemaActions({ api, setSchema, reloadSchema });
const hasSecrets = withSecrets && !error; const hasSecrets = withSecrets && !error;
return ( return (
<BkndContext.Provider <BkndContext.Provider
value={{ ...schema, actions, requireSecrets, app, adminOverride, hasSecrets }} value={{ ...schema, actions, requireSecrets, app, options: app.options, hasSecrets }}
key={local_version} key={local_version}
> >
{/*{error && ( {/*{error && (
@@ -153,3 +154,12 @@ export function useBknd({ withSecrets }: { withSecrets?: boolean } = {}): BkndCo
return ctx; return ctx;
} }
export function useBkndOptions(): BkndAdminOptions {
const ctx = useContext(BkndContext);
return (
ctx.options ?? {
basepath: "/",
}
);
}

View File

@@ -1,6 +1,5 @@
import { Api, type ApiOptions, type TApiUser } from "Api"; import { Api, type ApiOptions, type TApiUser } from "Api";
import { isDebug } from "core"; import { isDebug } from "core";
import type { AppTheme } from "modules/server/AppServer";
import { createContext, useContext } from "react"; import { createContext, useContext } from "react";
const ClientContext = createContext<{ baseUrl: string; api: Api }>({ const ClientContext = createContext<{ baseUrl: string; api: Api }>({
@@ -62,7 +61,6 @@ export const useBaseUrl = () => {
type BkndWindowContext = { type BkndWindowContext = {
user?: TApiUser; user?: TApiUser;
logout_route: string; logout_route: string;
color_scheme?: AppTheme;
}; };
export function useBkndWindowContext(): BkndWindowContext { export function useBkndWindowContext(): BkndWindowContext {
if (typeof window !== "undefined" && window.__BKND__) { if (typeof window !== "undefined" && window.__BKND__) {

View File

@@ -1 +1 @@
export { BkndProvider, useBknd } from "./BkndProvider"; export { BkndProvider, type BkndAdminOptions, useBknd } from "./BkndProvider";

View File

@@ -29,17 +29,3 @@ export function useBkndSystem() {
actions, actions,
}; };
} }
export function useBkndSystemTheme() {
const $sys = useBkndSystem();
return {
theme: $sys.theme,
set: $sys.actions.theme.set,
toggle: async () => {
document.startViewTransition(async () => {
await $sys.actions.theme.toggle();
});
},
};
}

View File

@@ -1,29 +1,49 @@
import type { AppTheme } from "modules/server/AppServer";
import { useBkndWindowContext } from "ui/client/ClientProvider";
import { useBknd } from "ui/client/bknd"; import { useBknd } from "ui/client/bknd";
import { create } from "zustand";
import { combine, persist } from "zustand/middleware";
const themes = ["dark", "light", "system"] as const;
export type AppTheme = (typeof themes)[number];
const themeStore = create(
persist(
combine({ theme: null as AppTheme | null }, (set) => ({
setTheme: (theme: AppTheme | any) => {
if (themes.includes(theme)) {
document.startViewTransition(() => {
set({ theme });
});
}
},
})),
{
name: "bknd-admin-theme",
},
),
);
export function useTheme(fallback: AppTheme = "system") { export function useTheme(fallback: AppTheme = "system") {
const b = useBknd(); const b = useBknd();
const winCtx = useBkndWindowContext(); const theme_state = themeStore((state) => state.theme);
const theme_set = themeStore((state) => state.setTheme);
// 1. override // 1. override
// 2. config // 2. local storage
// 3. winCtx // 3. fallback
// 4. fallback // 4. default
// 5. default const override = b?.options?.theme;
const override = b?.adminOverride?.color_scheme;
const config = b?.config.server.admin.color_scheme;
const win = winCtx.color_scheme;
const prefersDark = const prefersDark =
typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches; typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches;
const theme = override ?? config ?? win ?? fallback; const theme = override ?? theme_state ?? fallback;
return { return {
theme: (theme === "system" ? (prefersDark ? "dark" : "light") : theme) as AppTheme, theme: (theme === "system" ? (prefersDark ? "dark" : "light") : theme) as AppTheme,
value: theme,
themes,
setTheme: theme_set,
state: theme_state,
prefersDark, prefersDark,
override, override,
config,
win,
}; };
} }

View File

@@ -2,6 +2,7 @@ import type { App } from "App";
import { type Entity, type EntityRelation, constructEntity, constructRelation } from "data"; import { type Entity, type EntityRelation, constructEntity, constructRelation } from "data";
import { RelationAccessor } from "data/relations/RelationAccessor"; import { RelationAccessor } from "data/relations/RelationAccessor";
import { Flow, TaskMap } from "flows"; import { Flow, TaskMap } from "flows";
import type { BkndAdminOptions } from "ui/client/BkndProvider";
export type AppType = ReturnType<App["toJSON"]>; export type AppType = ReturnType<App["toJSON"]>;
@@ -15,7 +16,10 @@ export class AppReduced {
private _relations: EntityRelation[] = []; private _relations: EntityRelation[] = [];
private _flows: Flow[] = []; private _flows: Flow[] = [];
constructor(protected appJson: AppType) { constructor(
protected appJson: AppType,
protected _options: BkndAdminOptions = {},
) {
//console.log("received appjson", appJson); //console.log("received appjson", appJson);
this._entities = Object.entries(this.appJson.data.entities ?? {}).map(([name, entity]) => { this._entities = Object.entries(this.appJson.data.entities ?? {}).map(([name, entity]) => {
@@ -62,18 +66,21 @@ export class AppReduced {
return this.appJson; return this.appJson;
} }
getAdminConfig() { get options() {
return this.appJson.server.admin; return {
basepath: "",
logo_return_path: "/",
...this._options,
};
} }
getSettingsPath(path: string[] = []): string { getSettingsPath(path: string[] = []): string {
const { basepath } = this.getAdminConfig(); const base = `~/${this.options.basepath}/settings`.replace(/\/+/g, "/");
const base = `~/${basepath}/settings`.replace(/\/+/g, "/");
return [base, ...path].join("/"); return [base, ...path].join("/");
} }
getAbsolutePath(path?: string): string { getAbsolutePath(path?: string): string {
const { basepath } = this.getAdminConfig(); const { basepath } = this.options;
return (path ? `~/${basepath}/${path}` : `~/${basepath}`).replace(/\/+/g, "/"); return (path ? `~/${basepath}/${path}` : `~/${basepath}`).replace(/\/+/g, "/");
} }

View File

@@ -1,28 +0,0 @@
import { useState } from "react";
export type AppTheme = "light" | "dark" | string;
export function useSetTheme(initialTheme: AppTheme = "light") {
const [theme, _setTheme] = useState(initialTheme);
const $html = document.querySelector("#bknd-admin")!;
function setTheme(newTheme: AppTheme) {
$html?.classList.remove("dark", "light");
$html?.classList.add(newTheme);
_setTheme(newTheme);
// @todo: just a quick switcher config update test
fetch("/api/system/config/patch/server/admin", {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ color_scheme: newTheme }),
})
.then((res) => res.json())
.then((data) => {
console.log("theme updated", data);
});
}
return { theme, setTheme };
}

View File

@@ -1,19 +1,17 @@
import { import {
Background, Background,
BackgroundVariant, BackgroundVariant,
MarkerType,
MiniMap, MiniMap,
type MiniMapProps, type MiniMapProps,
ReactFlow, ReactFlow,
type ReactFlowProps, type ReactFlowProps,
ReactFlowProvider,
addEdge, addEdge,
useEdgesState, useEdgesState,
useNodesState, useNodesState,
useReactFlow, useReactFlow,
} from "@xyflow/react"; } from "@xyflow/react";
import { type ReactNode, useCallback, useEffect, useState } from "react"; import { type ReactNode, useCallback, useEffect, useState } from "react";
import { useBkndSystemTheme } from "ui/client/schema/system/use-bknd-system"; import { useTheme } from "ui/client/use-theme";
type CanvasProps = ReactFlowProps & { type CanvasProps = ReactFlowProps & {
externalProvider?: boolean; externalProvider?: boolean;
@@ -38,7 +36,7 @@ export function Canvas({
const [nodes, setNodes, onNodesChange] = useNodesState(_nodes ?? []); const [nodes, setNodes, onNodesChange] = useNodesState(_nodes ?? []);
const [edges, setEdges, onEdgesChange] = useEdgesState(_edges ?? []); const [edges, setEdges, onEdgesChange] = useEdgesState(_edges ?? []);
const { screenToFlowPosition } = useReactFlow(); const { screenToFlowPosition } = useReactFlow();
const { theme } = useBkndSystemTheme(); const { theme } = useTheme();
const [isCommandPressed, setIsCommandPressed] = useState(false); const [isCommandPressed, setIsCommandPressed] = useState(false);
const [isSpacePressed, setIsSpacePressed] = useState(false); const [isSpacePressed, setIsSpacePressed] = useState(false);

View File

@@ -7,10 +7,10 @@ import type { ComponentPropsWithoutRef } from "react";
import { Button } from "ui/components/buttons/Button"; import { Button } from "ui/components/buttons/Button";
import { Group, Input, Label } from "ui/components/form/Formy/components"; import { Group, Input, Label } from "ui/components/form/Formy/components";
import { SocialLink } from "./SocialLink"; import { SocialLink } from "./SocialLink";
import type { ValueError } from "@sinclair/typebox/value"; import type { ValueError } from "@sinclair/typebox/value";
import { type TSchema, Value } from "core/utils"; import { type TSchema, Value } from "core/utils";
import type { Validator } from "json-schema-form-react"; import type { Validator } from "json-schema-form-react";
import { useTheme } from "ui/client/use-theme";
class TypeboxValidator implements Validator<ValueError> { class TypeboxValidator implements Validator<ValueError> {
async validate(schema: TSchema, data: any) { async validate(schema: TSchema, data: any) {
@@ -46,6 +46,7 @@ export function AuthForm({
buttonLabel = action === "login" ? "Sign in" : "Sign up", buttonLabel = action === "login" ? "Sign in" : "Sign up",
...props ...props
}: LoginFormProps) { }: LoginFormProps) {
const { theme } = useTheme();
const basepath = auth?.basepath ?? "/api/auth"; const basepath = auth?.basepath ?? "/api/auth";
const password = { const password = {
action: `${basepath}/password/${action}`, action: `${basepath}/password/${action}`,

View File

@@ -12,7 +12,6 @@ import {
} from "react-icons/tb"; } from "react-icons/tb";
import { useAuth, useBkndWindowContext } from "ui/client"; import { useAuth, useBkndWindowContext } from "ui/client";
import { useBknd } from "ui/client/bknd"; import { useBknd } from "ui/client/bknd";
import { useBkndSystemTheme } from "ui/client/schema/system/use-bknd-system";
import { useTheme } from "ui/client/use-theme"; import { useTheme } from "ui/client/use-theme";
import { Button } from "ui/components/buttons/Button"; import { Button } from "ui/components/buttons/Button";
import { IconButton } from "ui/components/buttons/IconButton"; import { IconButton } from "ui/components/buttons/IconButton";
@@ -24,6 +23,7 @@ import { useAppShell } from "ui/layouts/AppShell/use-appshell";
import { useNavigate } from "ui/lib/routes"; import { useNavigate } from "ui/lib/routes";
import { useLocation } from "wouter"; import { useLocation } from "wouter";
import { NavLink } from "./AppShell"; import { NavLink } from "./AppShell";
import { autoFormatString } from "core/utils";
export function HeaderNavigation() { export function HeaderNavigation() {
const [location, navigate] = useLocation(); const [location, navigate] = useLocation();
@@ -114,7 +114,7 @@ function SidebarToggler() {
export function Header({ hasSidebar = true }) { export function Header({ hasSidebar = true }) {
const { app } = useBknd(); const { app } = useBknd();
const { theme } = useTheme(); const { theme } = useTheme();
const { logo_return_path = "/" } = app.getAdminConfig(); const { logo_return_path = "/" } = app.options;
return ( return (
<header <header
@@ -142,7 +142,7 @@ export function Header({ hasSidebar = true }) {
} }
function UserMenu() { function UserMenu() {
const { adminOverride, config } = useBknd(); const { config, options } = useBknd();
const auth = useAuth(); const auth = useAuth();
const [navigate] = useNavigate(); const [navigate] = useNavigate();
const { logout_route } = useBkndWindowContext(); const { logout_route } = useBkndWindowContext();
@@ -173,7 +173,7 @@ function UserMenu() {
} }
} }
if (!adminOverride) { if (!options.theme) {
items.push(() => <UserMenuThemeToggler />); items.push(() => <UserMenuThemeToggler />);
} }
@@ -193,17 +193,15 @@ function UserMenu() {
} }
function UserMenuThemeToggler() { function UserMenuThemeToggler() {
const { theme, toggle } = useBkndSystemTheme(); const { value, themes, setTheme } = useTheme();
return ( return (
<div className="flex flex-col items-center mt-1 pt-1 border-t border-primary/5"> <div className="flex flex-col items-center mt-1 pt-1 border-t border-primary/5">
<SegmentedControl <SegmentedControl
withItemsBorders={false}
className="w-full" className="w-full"
data={[ data={themes.map((t) => ({ value: t, label: autoFormatString(t) }))}
{ value: "light", label: "Light" }, value={value}
{ value: "dark", label: "Dark" }, onChange={setTheme}
]}
value={theme}
onChange={toggle}
size="xs" size="xs"
/> />
</div> </div>

View File

@@ -49,8 +49,7 @@ export function withQuery(url: string, query: object) {
export function withAbsolute(url: string) { export function withAbsolute(url: string) {
const { app } = useBknd(); const { app } = useBknd();
const basepath = app.getAdminConfig().basepath; return app.getAbsolutePath(url);
return `~/${basepath}/${url}`.replace(/\/+/g, "/");
} }
export function useRouteNavigate() { export function useRouteNavigate() {
@@ -65,7 +64,7 @@ export function useNavigate() {
const [location, navigate] = useLocation(); const [location, navigate] = useLocation();
const router = useRouter(); const router = useRouter();
const { app } = useBknd(); const { app } = useBknd();
const basepath = app.getAdminConfig().basepath; const basepath = app.options.basepath;
return [ return [
( (
url: string, url: string,
@@ -121,7 +120,6 @@ export function useGoBack(
}, },
) { ) {
const { app } = useBknd(); const { app } = useBknd();
const basepath = app.getAdminConfig().basepath;
const [navigate] = useNavigate(); const [navigate] = useNavigate();
const referrer = document.referrer; const referrer = document.referrer;
const history_length = window.history.length; const history_length = window.history.length;
@@ -142,9 +140,7 @@ export function useGoBack(
} else { } else {
//console.log("used fallback"); //console.log("used fallback");
if (typeof fallback === "string") { if (typeof fallback === "string") {
const _fallback = options?.absolute const _fallback = options?.absolute ? app.getAbsolutePath(fallback) : fallback;
? `~/${basepath}${fallback}`.replace(/\/+/g, "/")
: fallback;
//console.log("fallback", _fallback); //console.log("fallback", _fallback);
if (options?.native) { if (options?.native) {

View File

@@ -23,6 +23,7 @@
} }
.dark, .dark,
.dark .bknd-admin /* currently used for elements, drop after making headless */,
#bknd-admin.dark, #bknd-admin.dark,
.bknd-admin.dark { .bknd-admin.dark {
--color-primary: rgb(250 250 250); /* zinc-50 */ --color-primary: rgb(250 250 250); /* zinc-50 */

View File

@@ -1,11 +1,11 @@
import { MarkerType, type Node, Position, ReactFlowProvider } from "@xyflow/react"; import { MarkerType, type Node, Position, ReactFlowProvider } from "@xyflow/react";
import type { AppDataConfig, TAppDataEntity } from "data/data-schema"; import type { AppDataConfig, TAppDataEntity } from "data/data-schema";
import { useBknd } from "ui/client/BkndProvider"; import { useBknd } from "ui/client/BkndProvider";
import { useBkndSystemTheme } from "ui/client/schema/system/use-bknd-system";
import { Canvas } from "ui/components/canvas/Canvas"; import { Canvas } from "ui/components/canvas/Canvas";
import { layoutWithDagre } from "ui/components/canvas/layouts"; import { layoutWithDagre } from "ui/components/canvas/layouts";
import { Panels } from "ui/components/canvas/panels"; import { Panels } from "ui/components/canvas/panels";
import { EntityTableNode } from "./EntityTableNode"; import { EntityTableNode } from "./EntityTableNode";
import { useTheme } from "ui/client/use-theme";
function entitiesToNodes(entities: AppDataConfig["entities"]): Node<TAppDataEntity>[] { function entitiesToNodes(entities: AppDataConfig["entities"]): Node<TAppDataEntity>[] {
return Object.entries(entities ?? {}).map(([name, entity]) => { return Object.entries(entities ?? {}).map(([name, entity]) => {
@@ -69,7 +69,7 @@ export function DataSchemaCanvas() {
const { const {
config: { data }, config: { data },
} = useBknd(); } = useBknd();
const { theme } = useBkndSystemTheme(); const { theme } = useTheme();
const nodes = entitiesToNodes(data.entities); const nodes = entitiesToNodes(data.entities);
const edges = relationsToEdges(data.relations).map((e) => ({ const edges = relationsToEdges(data.relations).map((e) => ({
...e, ...e,

View File

@@ -20,12 +20,12 @@ import { Dropdown } from "../../components/overlay/Dropdown";
import { useFlow } from "../../container/use-flows"; import { useFlow } from "../../container/use-flows";
import * as AppShell from "../../layouts/AppShell/AppShell"; import * as AppShell from "../../layouts/AppShell/AppShell";
import { SectionHeader } from "../../layouts/AppShell/AppShell"; import { SectionHeader } from "../../layouts/AppShell/AppShell";
import { useTheme } from "ui/client/use-theme";
export function FlowEdit({ params }) { export function FlowEdit({ params }) {
const { app } = useBknd(); const { app } = useBknd();
const { color_scheme: theme } = app.getAdminConfig(); const { theme } = useTheme();
const { basepath } = app.getAdminConfig(); const prefix = app.getAbsolutePath("settings");
const prefix = `~/${basepath}/settings`.replace(/\/+/g, "/");
const [location, navigate] = useLocation(); const [location, navigate] = useLocation();
const [execution, setExecution] = useState<Execution>(); const [execution, setExecution] = useState<Execution>();
const [selectedNodes, setSelectedNodes] = useState<Node[]>([]); const [selectedNodes, setSelectedNodes] = useState<Node[]>([]);

View File

@@ -17,12 +17,11 @@ const TestRoutes = lazy(() => import("./test"));
export function Routes() { export function Routes() {
const { app } = useBknd(); const { app } = useBknd();
const { theme } = useTheme(); const { theme } = useTheme();
const { basepath } = app.getAdminConfig();
return ( return (
<div id="bknd-admin" className={theme + " antialiased"}> <div id="bknd-admin" className={theme + " antialiased"}>
<FlashMessage /> <FlashMessage />
<Router base={basepath}> <Router base={app.options.basepath}>
<Switch> <Switch>
<Route path="/auth/login" component={AuthLogin} /> <Route path="/auth/login" component={AuthLogin} />
<Route path="/" nest> <Route path="/" nest>

View File

@@ -15,7 +15,7 @@ import { ServerSettings } from "./routes/server.settings";
import { IconButton } from "ui/components/buttons/IconButton"; import { IconButton } from "ui/components/buttons/IconButton";
function SettingsSidebar() { function SettingsSidebar() {
const { version, schema, actions } = useBknd(); const { version, schema, actions, app } = useBknd();
useBrowserTitle(["Settings"]); useBrowserTitle(["Settings"]);
async function handleRefresh() { async function handleRefresh() {
@@ -151,11 +151,10 @@ const FallbackRoutes = ({
...settingProps ...settingProps
}: SettingProps<any> & { module: string }) => { }: SettingProps<any> & { module: string }) => {
const { app } = useBknd(); const { app } = useBknd();
const basepath = app.getAdminConfig(); const prefix = app.getAbsolutePath("settings");
const prefix = `~/${basepath}/settings`.replace(/\/+/g, "/");
return ( return (
<Route path={`/${module}`} nest> <Route path={module} nest>
<Switch> <Switch>
<Route <Route
path="/" path="/"

View File

@@ -44,8 +44,7 @@ const uiSchema = {
export const AuthSettings = ({ schema: _unsafe_copy, config }) => { export const AuthSettings = ({ schema: _unsafe_copy, config }) => {
const _s = useBknd(); const _s = useBknd();
const _schema = cloneDeep(_unsafe_copy); const _schema = cloneDeep(_unsafe_copy);
const { basepath } = _s.app.getAdminConfig(); const prefix = _s.app.getAbsolutePath("settings");
const prefix = `~/${basepath}/settings`.replace(/\/+/g, "/");
try { try {
const user_entity = config.entity_name ?? "users"; const user_entity = config.entity_name ?? "users";

View File

@@ -68,8 +68,7 @@ export const DataSettings = ({
config, config,
}: { schema: ModuleSchemas["data"]; config: ModuleConfigs["data"] }) => { }: { schema: ModuleSchemas["data"]; config: ModuleConfigs["data"] }) => {
const { app } = useBknd(); const { app } = useBknd();
const basepath = app.getAdminConfig().basepath; const prefix = app.getAbsolutePath("settings");
const prefix = `~/${basepath}/settings`.replace(/\/+/g, "/");
const entities = Object.keys(config.entities ?? {}); const entities = Object.keys(config.entities ?? {});
function fillEntities(schema: any, key: string = "entity") { function fillEntities(schema: any, key: string = "entity") {

View File

@@ -31,8 +31,7 @@ const uiSchema = {
export const FlowsSettings = ({ schema, config }) => { export const FlowsSettings = ({ schema, config }) => {
const { app } = useBknd(); const { app } = useBknd();
const { basepath } = app.getAdminConfig(); const prefix = app.getAbsolutePath("settings");
const prefix = `~/${basepath}/settings`.replace(/\/+/g, "/");
function fillTasks(schema: any, flow: any, key: string) { function fillTasks(schema: any, flow: any, key: string) {
const tasks = Object.keys(flow.tasks ?? {}); const tasks = Object.keys(flow.tasks ?? {});

View File

@@ -17,15 +17,11 @@ const uiSchema = {
}; };
export const ServerSettings = ({ schema: _unsafe_copy, config }) => { export const ServerSettings = ({ schema: _unsafe_copy, config }) => {
const { app, adminOverride } = useBknd(); const { app } = useBknd();
const { basepath } = app.getAdminConfig();
const _schema = cloneDeep(_unsafe_copy); const _schema = cloneDeep(_unsafe_copy);
const prefix = `~/${basepath}/settings`.replace(/\/+/g, "/"); const prefix = app.getAbsolutePath("settings");
const schema = _schema; const schema = _schema;
if (adminOverride) {
schema.properties.admin.readOnly = true;
}
return ( return (
<Route path="/server" nest> <Route path="/server" nest>
@@ -33,14 +29,6 @@ export const ServerSettings = ({ schema: _unsafe_copy, config }) => {
path="/" path="/"
component={() => ( component={() => (
<Setting <Setting
options={{
showAlert: () => {
if (adminOverride) {
return "The admin settings are read-only as they are overriden. Remaining server configuration can be edited.";
}
return;
},
}}
schema={schema} schema={schema}
uiSchema={uiSchema} uiSchema={uiSchema}
config={config} config={config}

View File

@@ -3,6 +3,8 @@ import { serveStatic } from "@hono/node-server/serve-static";
import { showRoutes } from "hono/dev"; import { showRoutes } from "hono/dev";
import { App, registries } from "./src"; import { App, registries } from "./src";
import { StorageLocalAdapter } from "./src/media/storage/adapters/StorageLocalAdapter"; import { StorageLocalAdapter } from "./src/media/storage/adapters/StorageLocalAdapter";
import { EntityManager, LibsqlConnection } from "data";
import { __bknd } from "modules/ModuleManager";
registries.media.register("local", StorageLocalAdapter); registries.media.register("local", StorageLocalAdapter);
@@ -21,10 +23,30 @@ const credentials = example
url: ":memory:", url: ":memory:",
}; };
let initialConfig: any = undefined;
if (example) { if (example) {
const { version, ...config } = JSON.parse(await readFile(`.configs/${example}.json`, "utf-8")); const { version, ...config } = JSON.parse(await readFile(`.configs/${example}.json`, "utf-8"));
initialConfig = config;
// create db with config
const conn = new LibsqlConnection(credentials);
const em = new EntityManager([__bknd], conn);
try {
await em.schema().sync({ force: true });
} catch (e) {}
const { data: existing } = await em.repo(__bknd).findOne({ type: "config" });
if (!existing || existing.version !== version) {
if (existing) await em.mutator(__bknd).deleteOne(existing.id);
await em.mutator(__bknd).insertOne({
version,
json: config,
created_at: new Date(),
type: "config",
});
} else {
await em.mutator(__bknd).updateOne(existing.id, {
json: config,
});
}
} }
let app: App; let app: App;
@@ -35,7 +57,6 @@ export default {
if (!app || recreate) { if (!app || recreate) {
app = App.create({ app = App.create({
connection: credentials, connection: credentials,
initialConfig,
}); });
app.emgr.onEvent( app.emgr.onEvent(
App.Events.AppBuiltEvent, App.Events.AppBuiltEvent,