From 4c11789ea8a1c511c807d07a147569769f3d2fca Mon Sep 17 00:00:00 2001 From: dswbx Date: Sun, 20 Apr 2025 09:29:58 +0200 Subject: [PATCH] Fix Release 0.11.1 (#150) * fix strategy forms handling, add register route and hidden fields Refactored strategy forms to include hidden fields for type and name. Added a registration route with necessary adjustments to the admin controller and routes. Corrected field handling within relevant forms and components. * fix admin access permissions and refactor routing structure display a fixed error for unmet permissions when retrieving the schema. moved auth routes outside of BkndProvider and reorganized remaining routes to include BkndWrapper. * fix: properly type BkndWrapper * bump fix release version * ModuleManager: update diff checking and AppData validation Revised diff handling includes validation of diffs, reverting changes on failure, and enforcing module constraints with onBeforeUpdate hooks. Introduced `validateDiffs` and backup of stable configs. Applied changes in related modules, tests, and UI layer to align with updated diff logic. * fix: cli: running from config file were using invalid args * fix: cli: improve sequence of onBuilt trigger to allow custom routes from cli * fix e2e tests --- app/__test__/modules/AppData.spec.ts | 44 +++++- app/__test__/modules/ModuleManager.spec.ts | 132 +++++++++++++++++- app/e2e/adapters.ts | 12 +- app/e2e/media.e2e-spec.ts | 2 +- app/package.json | 4 +- app/playwright.config.ts | 1 + app/src/cli/commands/run/platform.ts | 3 +- app/src/cli/commands/run/run.ts | 8 +- app/src/core/object/diff.ts | 27 ++-- app/src/data/AppData.ts | 13 ++ app/src/modules/ModuleManager.ts | 87 ++++++++++-- app/src/modules/server/AdminController.tsx | 9 +- app/src/modules/server/SystemController.ts | 1 - app/src/ui/Admin.tsx | 45 +++--- app/src/ui/client/BkndProvider.tsx | 31 ++-- app/src/ui/components/display/Message.tsx | 2 + .../form/json-schema-form/Field.tsx | 68 ++++++--- .../form/json-schema-form/FieldWrapper.tsx | 4 +- .../components/form/json-schema-form/utils.ts | 7 + app/src/ui/elements/auth/AuthScreen.tsx | 8 +- app/src/ui/layouts/AppShell/Header.tsx | 2 +- app/src/ui/lib/config.ts | 4 +- app/src/ui/routes/auth/auth.register.tsx | 18 +++ app/src/ui/routes/auth/auth.strategies.tsx | 33 +++-- app/src/ui/routes/index.tsx | 95 +++++++------ app/src/ui/routes/media/media.settings.tsx | 6 +- app/vite.dev.ts | 2 +- .../src/app/admin/[[...admin]]/Admin.tsx | 15 ++ .../src/app/admin/[[...admin]]/page.tsx | 6 +- 29 files changed, 520 insertions(+), 169 deletions(-) create mode 100644 app/src/ui/routes/auth/auth.register.tsx create mode 100644 examples/nextjs/src/app/admin/[[...admin]]/Admin.tsx diff --git a/app/__test__/modules/AppData.spec.ts b/app/__test__/modules/AppData.spec.ts index 19461b3..1d3e971 100644 --- a/app/__test__/modules/AppData.spec.ts +++ b/app/__test__/modules/AppData.spec.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 { fieldsSchema } from "../../src/data/data-schema"; -import { AppData } from "../../src/modules"; -import { moduleTestSuite } from "./module-test-suite"; +import { AppData, type ModuleBuildContext } from "../../src/modules"; +import { makeCtx, moduleTestSuite } from "./module-test-suite"; +import * as proto from "data/prototype"; describe("AppData", () => { moduleTestSuite(AppData); + let ctx: ModuleBuildContext; + beforeEach(() => { + ctx = makeCtx(); + }); + test("field config construction", () => { 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(); + }); }); diff --git a/app/__test__/modules/ModuleManager.spec.ts b/app/__test__/modules/ModuleManager.spec.ts index 31b2027..461d11e 100644 --- a/app/__test__/modules/ModuleManager.spec.ts +++ b/app/__test__/modules/ModuleManager.spec.ts @@ -1,10 +1,12 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; -import { Type, disableConsoleLog, enableConsoleLog, stripMark } from "../../src/core/utils"; -import { entity, text } from "../../src/data"; +import { disableConsoleLog, enableConsoleLog, stripMark, Type } from "../../src/core/utils"; +import { Connection, entity, text } from "../../src/data"; 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 { getDummyConnection } from "../helper"; +import { diff } from "core/object/diff"; +import type { Static } from "@sinclair/typebox"; describe("ModuleManager", async () => { test("s1: no config, no build", async () => { @@ -380,4 +382,128 @@ describe("ModuleManager", async () => { 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; + class Sample extends Module { + 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) { + 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); + } + }); + }); }); diff --git a/app/e2e/adapters.ts b/app/e2e/adapters.ts index cac87e1..fd64442 100644 --- a/app/e2e/adapters.ts +++ b/app/e2e/adapters.ts @@ -186,7 +186,8 @@ const adapters = { }, } 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)); await config.clean(); @@ -202,5 +203,12 @@ for (const [name, config] of Object.entries(adapters)) { await Bun.sleep(250); 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); + } } diff --git a/app/e2e/media.e2e-spec.ts b/app/e2e/media.e2e-spec.ts index 307ba39..1c2b6fc 100644 --- a/app/e2e/media.e2e-spec.ts +++ b/app/e2e/media.e2e-spec.ts @@ -13,7 +13,7 @@ test("can enable media", async ({ page }) => { await page.goto(`${config.base_path}/media/settings`); // enable - const enableToggle = page.locator("css=button#enabled"); + const enableToggle = page.getByTestId(testIds.media.switchEnabled); if ((await enableToggle.getAttribute("aria-checked")) !== "true") { await expect(enableToggle).toBeVisible(); await enableToggle.click(); diff --git a/app/package.json b/app/package.json index ec3b963..dae26c7 100644 --- a/app/package.json +++ b/app/package.json @@ -3,7 +3,7 @@ "type": "module", "sideEffects": false, "bin": "./dist/cli/index.js", - "version": "0.11.0", + "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.", "homepage": "https://bknd.io", "repository": { @@ -26,7 +26,7 @@ "build:types": "tsc -p tsconfig.build.json --emitDeclarationOnly && tsc-alias", "updater": "bun x npm-check-updates -ui", "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", "test": "ALL_TESTS=1 bun test --bail", "test:all": "bun run test && bun run test:node", diff --git a/app/playwright.config.ts b/app/playwright.config.ts index 72096dc..7f4f9d1 100644 --- a/app/playwright.config.ts +++ b/app/playwright.config.ts @@ -12,6 +12,7 @@ export default defineConfig({ retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, reporter: "html", + timeout: 20000, use: { baseURL: baseUrl, trace: "on-first-retry", diff --git a/app/src/cli/commands/run/platform.ts b/app/src/cli/commands/run/platform.ts index 480a979..808458e 100644 --- a/app/src/cli/commands/run/platform.ts +++ b/app/src/cli/commands/run/platform.ts @@ -4,6 +4,7 @@ import { config } from "core"; import type { MiddlewareHandler } from "hono"; import open from "open"; import { fileExists, getRelativeDistPath } from "../../utils/sys"; +import type { App } from "App"; export const PLATFORMS = ["node", "bun"] as const; export type Platform = (typeof PLATFORMS)[number]; @@ -32,7 +33,7 @@ export async function attachServeStatic(app: any, platform: Platform) { export async function startServer( server: Platform, - app: any, + app: App, options: { port: number; open?: boolean }, ) { const port = options.port; diff --git a/app/src/cli/commands/run/run.ts b/app/src/cli/commands/run/run.ts index d5efe36..c8a14d0 100644 --- a/app/src/cli/commands/run/run.ts +++ b/app/src/cli/commands/run/run.ts @@ -77,12 +77,12 @@ async function makeApp(config: MakeAppConfig) { app.emgr.onEvent( App.Events.AppBuiltEvent, async () => { - await attachServeStatic(app, config.server?.platform ?? "node"); - app.registerAdminController(); - if (config.onBuilt) { await config.onBuilt(app); } + + await attachServeStatic(app, config.server?.platform ?? "node"); + app.registerAdminController(); }, "sync", ); @@ -92,7 +92,7 @@ async function makeApp(config: MakeAppConfig) { } export async function makeConfigApp(_config: CliBkndConfig, platform?: Platform) { - const config = makeConfig(_config, { env: process.env }); + const config = makeConfig(_config, process.env); return makeApp({ ...config, server: { platform }, diff --git a/app/src/core/object/diff.ts b/app/src/core/object/diff.ts index 10fa427..437e47b 100644 --- a/app/src/core/object/diff.ts +++ b/app/src/core/object/diff.ts @@ -1,4 +1,5 @@ -enum Change { +// biome-ignore lint/suspicious/noConstEnum: +export const enum DiffChange { Add = "a", Remove = "r", Edit = "e", @@ -7,8 +8,8 @@ enum Change { type Object = object; type Primitive = string | number | boolean | null | object | any[] | undefined; -interface DiffEntry { - t: Change | string; +export interface DiffEntry { + t: DiffChange | string; p: (string | number)[]; o: Primitive; n: Primitive; @@ -47,7 +48,7 @@ function diff(oldObj: Object, newObj: Object): DiffEntry[] { if (typeof oldValue !== typeof newValue) { diffs.push({ - t: Change.Edit, + t: DiffChange.Edit, p: path, o: oldValue, n: newValue, @@ -57,14 +58,14 @@ function diff(oldObj: Object, newObj: Object): DiffEntry[] { for (let i = 0; i < maxLength; i++) { if (i >= oldValue.length) { diffs.push({ - t: Change.Add, + t: DiffChange.Add, p: [...path, i], o: undefined, n: newValue[i], }); } else if (i >= newValue.length) { diffs.push({ - t: Change.Remove, + t: DiffChange.Remove, p: [...path, i], o: oldValue[i], n: undefined, @@ -80,14 +81,14 @@ function diff(oldObj: Object, newObj: Object): DiffEntry[] { for (const key of allKeys) { if (!(key in oldValue)) { diffs.push({ - t: Change.Add, + t: DiffChange.Add, p: [...path, key], o: undefined, n: newValue[key], }); } else if (!(key in newValue)) { diffs.push({ - t: Change.Remove, + t: DiffChange.Remove, p: [...path, key], o: oldValue[key], n: undefined, @@ -98,7 +99,7 @@ function diff(oldObj: Object, newObj: Object): DiffEntry[] { } } else { diffs.push({ - t: Change.Edit, + t: DiffChange.Edit, p: path, o: oldValue, n: newValue, @@ -136,9 +137,9 @@ function applyChange(obj: Object, diff: DiffEntry) { const parent = getParent(obj, path.slice(0, -1)); const key = path[path.length - 1]!; - if (type === Change.Add || type === Change.Edit) { + if (type === DiffChange.Add || type === DiffChange.Edit) { parent[key] = newValue; - } else if (type === Change.Remove) { + } else if (type === DiffChange.Remove) { if (Array.isArray(parent)) { parent.splice(key as number, 1); } else { @@ -152,13 +153,13 @@ function revertChange(obj: Object, diff: DiffEntry) { const parent = getParent(obj, path.slice(0, -1)); const key = path[path.length - 1]!; - if (type === Change.Add) { + if (type === DiffChange.Add) { if (Array.isArray(parent)) { parent.splice(key as number, 1); } else { delete parent[key]; } - } else if (type === Change.Remove || type === Change.Edit) { + } else if (type === DiffChange.Remove || type === DiffChange.Edit) { parent[key] = oldValue; } } diff --git a/app/src/data/AppData.ts b/app/src/data/AppData.ts index 22fad1b..10f491b 100644 --- a/app/src/data/AppData.ts +++ b/app/src/data/AppData.ts @@ -61,6 +61,19 @@ export class AppData extends Module { this.setBuilt(); } + override async onBeforeUpdate(from: AppDataConfig, to: AppDataConfig): Promise { + // 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() { return dataConfigSchema; } diff --git a/app/src/modules/ModuleManager.ts b/app/src/modules/ModuleManager.ts index cfd9e57..fafc54f 100644 --- a/app/src/modules/ModuleManager.ts +++ b/app/src/modules/ModuleManager.ts @@ -1,7 +1,7 @@ import { Guard } from "auth"; import { $console, BkndError, DebugLogger, env } from "core"; import { EventManager } from "core/events"; -import { clone, diff } from "core/object/diff"; +import * as $diff from "core/object/diff"; import { Default, type Static, @@ -95,7 +95,7 @@ export type ModuleManagerOptions = { verbosity?: Verbosity; }; -type ConfigTable = { +export type ConfigTable = { id?: number; version: number; type: "config" | "diff" | "backup"; @@ -144,6 +144,7 @@ export class ModuleManager { private _version: number = 0; private _built = false; private readonly _booted_with?: "provided" | "partial"; + private _stable_configs: ModuleConfigs | undefined; private logger: DebugLogger; @@ -310,7 +311,7 @@ export class ModuleManager { try { 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); if (state.version !== version) { @@ -330,15 +331,21 @@ export class ModuleManager { this.logger.log("version matches", state.version); // 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]); - if (diff.length > 0) { + if (diffs.length > 0) { + // validate diffs, it'll throw on invalid + this.validateDiffs(diffs); + + const date = new Date(); // store diff await this.mutator().insertOne({ version, type: "diff", - json: clone(diffs), + json: $diff.clone(diffs), + created_at: date, + updated_at: date, }); // store new version @@ -346,7 +353,7 @@ export class ModuleManager { { version, json: configs, - updated_at: new Date(), + updated_at: date, } as any, { type: "config", @@ -358,7 +365,7 @@ export class ModuleManager { } } } catch (e) { - if (e instanceof BkndError) { + if (e instanceof BkndError && e.message === "no config found") { this.logger.log("no config, just save fresh"); // no config, just save await this.mutator().insertOne({ @@ -369,10 +376,12 @@ export class ModuleManager { updated_at: new Date(), }); } else if (e instanceof TransformPersistFailedException) { - console.error("Cannot save invalid config"); + $console.error("ModuleManager: Cannot save invalid config"); + this.revertModules(); throw e; } else { - console.error("Aborting"); + $console.error("ModuleManager: Aborting"); + this.revertModules(); throw e; } } @@ -386,6 +395,52 @@ export class ModuleManager { 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 { this.logger.log("setting configs"); objectEach(configs, (config, key) => { @@ -519,6 +574,9 @@ export class ModuleManager { this.logger.log("resetting flags"); ctx.flags = Module.ctx_flags; + // storing last stable config version + this._stable_configs = $diff.clone(this.configs()); + this.logger.clear(); return state; } @@ -551,7 +609,6 @@ export class ModuleManager { name: Module, ): Pick, "set" | "patch" | "overwrite" | "remove"> { const module = this.modules[name]; - const copy = structuredClone(this.configs()); return new Proxy(module.schema(), { get: (target, prop: string) => { @@ -560,7 +617,7 @@ export class ModuleManager { } return async (...args) => { - console.log("[Safe Mutate]", name); + $console.log("[Safe Mutate]", name); try { // overwrite listener to run build inside this try/catch module.setListener(async () => { @@ -582,12 +639,12 @@ export class ModuleManager { return result; } catch (e) { - console.error("[Safe Mutate] failed", e); + $console.error(`[Safe Mutate] failed "${name}":`, String(e)); // revert to previous config & rebuild using original listener - this.setConfigs(copy); + this.revertModules(); 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 throw e; diff --git a/app/src/modules/server/AdminController.tsx b/app/src/modules/server/AdminController.tsx index d1e5bcb..6f2752f 100644 --- a/app/src/modules/server/AdminController.tsx +++ b/app/src/modules/server/AdminController.tsx @@ -72,6 +72,7 @@ export class AdminController extends Controller { success: configs.auth.cookie.pathSuccess ?? this.withAdminBasePath("/"), loggedOut: configs.auth.cookie.pathLoggedOut ?? this.withAdminBasePath("/"), login: this.withAdminBasePath("/auth/login"), + register: this.withAdminBasePath("/auth/register"), logout: this.withAdminBasePath("/auth/logout"), }; @@ -92,8 +93,7 @@ export class AdminController extends Controller { }); if (auth_enabled) { - hono.get( - authRoutes.login, + const redirectRouteParams = [ permission([SystemPermissions.accessAdmin, SystemPermissions.schemaRead], { // @ts-ignore onGranted: async (c) => { @@ -107,7 +107,10 @@ export class AdminController extends Controller { async (c) => { return c.html(c.get("html")!); }, - ); + ] as const; + + hono.get(authRoutes.login, ...redirectRouteParams); + hono.get(authRoutes.register, ...redirectRouteParams); hono.get(authRoutes.logout, async (c) => { await auth.authenticator?.logout(c); diff --git a/app/src/modules/server/SystemController.ts b/app/src/modules/server/SystemController.ts index 88782f5..d02b55d 100644 --- a/app/src/modules/server/SystemController.ts +++ b/app/src/modules/server/SystemController.ts @@ -160,7 +160,6 @@ export class SystemController extends Controller { if (this.app.modules.get(module).schema().has(path)) { return c.json({ success: false, path, error: "Path already exists" }, { status: 400 }); } - console.log("-- add", module, path, value); return await handleConfigUpdateResponse(c, async () => { await this.app.mutateConfig(module).patch(path, value); diff --git a/app/src/ui/Admin.tsx b/app/src/ui/Admin.tsx index 59bf859..d69738f 100644 --- a/app/src/ui/Admin.tsx +++ b/app/src/ui/Admin.tsx @@ -1,6 +1,6 @@ import { MantineProvider } from "@mantine/core"; import { Notifications } from "@mantine/notifications"; -import React from "react"; +import React, { type ReactNode } from "react"; import { BkndProvider, type BkndAdminOptions } from "ui/client/bknd"; import { useTheme } from "ui/client/use-theme"; import { Logo } from "ui/components/display/Logo"; @@ -21,33 +21,32 @@ export default function Admin({ withProvider = false, config, }: BkndAdminProps) { - const Component = ( + const { theme } = useTheme(); + const Provider = ({ children }: any) => + withProvider ? ( + + {children} + + ) : ( + children + ); + + const BkndWrapper = ({ children }: { children: ReactNode }) => ( }> - + {children} ); - return withProvider ? ( - - {Component} - - ) : ( - Component - ); -} - -function AdminInternal() { - const { theme } = useTheme(); return ( - - - - - - + + + + + + ); } diff --git a/app/src/ui/client/BkndProvider.tsx b/app/src/ui/client/BkndProvider.tsx index 7591883..15672c8 100644 --- a/app/src/ui/client/BkndProvider.tsx +++ b/app/src/ui/client/BkndProvider.tsx @@ -5,6 +5,8 @@ import { useApi } from "ui/client"; import { type TSchemaActions, getSchemaActions } from "./schema/actions"; import { AppReduced } from "./utils/AppReduced"; import type { AppTheme } from "ui/client/use-theme"; +import { Message } from "ui/components/display/Message"; +import { useNavigate } from "ui/lib/routes"; export type BkndAdminOptions = { logo_return_path?: string; @@ -101,7 +103,6 @@ export function BkndProvider({ fallback: true, } as any); - startTransition(() => { const commit = () => { setSchema(newSchema); @@ -109,7 +110,7 @@ export function BkndProvider({ setFetched(true); set_local_version((v) => v + 1); fetching.current = Fetching.None; - } + }; if ("startViewTransition" in document) { document.startViewTransition(commit); @@ -139,22 +140,24 @@ export function BkndProvider({ value={{ ...schema, actions, requireSecrets, app, options: app.options, hasSecrets }} key={local_version} > - {/*{error && ( - - - You attempted to load system configuration with secrets without having proper - permission. - - - - - )}*/} - - {children} + {error ? : children} ); } +function AccessDenied() { + const [navigate] = useNavigate(); + return ( + navigate("/auth/login"), + }} + /> + ); +} + export function useBknd({ withSecrets }: { withSecrets?: boolean } = {}): BkndContext { const ctx = useContext(BkndContext); if (withSecrets) ctx.requireSecrets(); diff --git a/app/src/ui/components/display/Message.tsx b/app/src/ui/components/display/Message.tsx index 8679086..ea16f09 100644 --- a/app/src/ui/components/display/Message.tsx +++ b/app/src/ui/components/display/Message.tsx @@ -16,9 +16,11 @@ const MissingPermission = ({ {...props} /> ); +const NotEnabled = (props: Partial) => ; export const Message = { NotFound, NotAllowed, + NotEnabled, MissingPermission, }; diff --git a/app/src/ui/components/form/json-schema-form/Field.tsx b/app/src/ui/components/form/json-schema-form/Field.tsx index e511977..e516b8f 100644 --- a/app/src/ui/components/form/json-schema-form/Field.tsx +++ b/app/src/ui/components/form/json-schema-form/Field.tsx @@ -1,5 +1,12 @@ 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 * as Formy from "ui/components/form/Formy"; import { useEvent } from "ui/hooks/use-event"; @@ -7,7 +14,7 @@ import { ArrayField } from "./ArrayField"; import { FieldWrapper, type FieldwrapperProps } from "./FieldWrapper"; import { useDerivedFieldContext, useFormValue } from "./Form"; import { ObjectField } from "./ObjectField"; -import { coerce, isType, isTypeSchema } from "./utils"; +import { coerce, firstDefined, isType, isTypeSchema } from "./utils"; export type FieldProps = { onChange?: (e: ChangeEvent) => 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 ( + + + + ); +}; + const fieldErrorBoundary = ({ name }: FieldProps) => ({ error }: { error: Error }) => ( @@ -41,8 +61,9 @@ const FieldImpl = ({ ...props }: FieldProps) => { const { path, setValue, schema, ...ctx } = useDerivedFieldContext(name); + const id = `${name}-${useId()}`; const required = typeof _required === "boolean" ? _required : ctx.required; - //console.log("Field", { name, path, schema }); + if (!isTypeSchema(schema)) return (
@@ -58,7 +79,21 @@ const FieldImpl = ({
       return ;
    }
 
-   const disabled = props.disabled ?? schema.readOnly ?? "const" in schema ?? false;
+   // account for `defaultValue`
+   // like 
+   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) => {
       const value = coerce(e.target.value, schema as any, { required });
@@ -70,9 +105,10 @@ const FieldImpl = ({
    });
 
    return (
-      
+      
           (
 export type FieldComponentProps = {
    schema: JsonSchema;
    render?: (props: Omit) => ReactNode;
+   "data-testId"?: string;
 } & ComponentPropsWithoutRef<"input">;
 
 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 (schema.enum) {
-      return ;
+      return ;
    }
 
    if (isType(schema.type, ["number", "integer"])) {
@@ -121,26 +158,17 @@ export const FieldComponent = ({ schema, render, ..._props }: FieldComponentProp
          step: schema.multipleOf,
       };
 
-      return (
-         
-      );
+      return ;
    }
 
    if (isType(schema.type, "boolean")) {
-      return ;
+      return ;
    }
 
    if (isType(schema.type, "string") && schema.format === "date-time") {
       const value = props.value ? new Date(props.value as string).toISOString().slice(0, 16) : "";
       return (
          ;
+      return ;
    }
 
    const additional = {
@@ -171,7 +199,5 @@ export const FieldComponent = ({ schema, render, ..._props }: FieldComponentProp
       }
    }
 
-   return (
-      
-   );
+   return ;
 };
diff --git a/app/src/ui/components/form/json-schema-form/FieldWrapper.tsx b/app/src/ui/components/form/json-schema-form/FieldWrapper.tsx
index 2af4e53..aec5b88 100644
--- a/app/src/ui/components/form/json-schema-form/FieldWrapper.tsx
+++ b/app/src/ui/components/form/json-schema-form/FieldWrapper.tsx
@@ -24,6 +24,7 @@ export type FieldwrapperProps = {
    errorPlacement?: "top" | "bottom";
    description?: string;
    descriptionPlacement?: "top" | "bottom";
+   fieldId?: string;
 };
 
 export function FieldWrapper({
@@ -36,6 +37,7 @@ export function FieldWrapper({
    errorPlacement = "bottom",
    descriptionPlacement = "bottom",
    children,
+   fieldId,
    ...props
 }: FieldwrapperProps) {
    const errors = useFormError(name, { strict: true });
@@ -66,7 +68,7 @@ export function FieldWrapper({
          {label && (
             
                {label} {required && *}
diff --git a/app/src/ui/components/form/json-schema-form/utils.ts b/app/src/ui/components/form/json-schema-form/utils.ts
index db609b5..00f8caf 100644
--- a/app/src/ui/components/form/json-schema-form/utils.ts
+++ b/app/src/ui/components/form/json-schema-form/utils.ts
@@ -138,3 +138,10 @@ export function omitSchema(_schema: Given, keys: strin
 export function isTypeSchema(schema?: JsonSchema): schema is JsonSchema {
    return typeof schema === "object" && "type" in schema && !isType(schema.type, "error");
 }
+
+export function firstDefined(...args: T[]): T | undefined {
+   for (const arg of args) {
+      if (typeof arg !== "undefined") return arg;
+   }
+   return undefined;
+}
diff --git a/app/src/ui/elements/auth/AuthScreen.tsx b/app/src/ui/elements/auth/AuthScreen.tsx
index d31f03d..b0d7ae0 100644
--- a/app/src/ui/elements/auth/AuthScreen.tsx
+++ b/app/src/ui/elements/auth/AuthScreen.tsx
@@ -1,4 +1,4 @@
-import type { ReactNode } from "react";
+import { isValidElement, type ReactNode } from "react";
 import { useAuthStrategies } from "../hooks/use-auth";
 import { AuthForm } from "./AuthForm";
 
@@ -30,11 +30,13 @@ export function AuthScreen({
          {!loading && (
             
{logo ? logo : null} - {typeof intro !== "undefined" ? ( + {isValidElement(intro) ? ( intro ) : (
-

Sign in to your admin panel

+

+ Sign {action === "login" ? "in" : "up"} to your admin panel +

Enter your credentials below to get access.

)} diff --git a/app/src/ui/layouts/AppShell/Header.tsx b/app/src/ui/layouts/AppShell/Header.tsx index 4093094..866535e 100644 --- a/app/src/ui/layouts/AppShell/Header.tsx +++ b/app/src/ui/layouts/AppShell/Header.tsx @@ -134,7 +134,7 @@ export function Header({ hasSidebar = true }) {
-
+
diff --git a/app/src/ui/lib/config.ts b/app/src/ui/lib/config.ts index eb4bbcc..bbb42ed 100644 --- a/app/src/ui/lib/config.ts +++ b/app/src/ui/lib/config.ts @@ -4,5 +4,7 @@ export const testIds = { data: { btnCreateEntity: "data-btns-create-entity", }, - media: {}, + media: { + switchEnabled: "media-switch-enabled", + }, }; diff --git a/app/src/ui/routes/auth/auth.register.tsx b/app/src/ui/routes/auth/auth.register.tsx new file mode 100644 index 0000000..9b1e7e7 --- /dev/null +++ b/app/src/ui/routes/auth/auth.register.tsx @@ -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 ( + + + + } + /> + ); +} diff --git a/app/src/ui/routes/auth/auth.strategies.tsx b/app/src/ui/routes/auth/auth.strategies.tsx index 24a23b2..7e68a96 100644 --- a/app/src/ui/routes/auth/auth.strategies.tsx +++ b/app/src/ui/routes/auth/auth.strategies.tsx @@ -24,6 +24,7 @@ import { Form, FormContextOverride, FormDebug, + HiddenField, ObjectField, Subscribe, useDerivedFieldContext, @@ -36,9 +37,16 @@ import * as AppShell from "../../layouts/AppShell/AppShell"; export function AuthStrategiesList(props) { useBrowserTitle(["Auth", "Strategies"]); - const { hasSecrets } = useBknd({ withSecrets: true }); + const { + hasSecrets, + config: { + auth: { enabled }, + }, + } = useBknd({ withSecrets: true }); if (!hasSecrets) { return ; + } else if (!enabled) { + return ; } return ; @@ -62,7 +70,6 @@ function AuthStrategiesListInternal() { ); async function handleSubmit(data: any) { - console.log("submit", { strategies: data }); await $auth.actions.config.set({ strategies: data }); } @@ -152,7 +159,7 @@ const Strategy = ({ type, name, unavailable }: StrategyProps) => { {autoFormatString(name)}
- + { "flex flex-col border-t border-t-muted px-4 pt-3 pb-4 bg-lightest/50 gap-4", )} > - +
)} @@ -176,7 +183,7 @@ const Strategy = ({ type, name, unavailable }: StrategyProps) => { ); }; -const StrategyToggle = () => { +const StrategyToggle = ({ type }: { type: StrategyProps["type"] }) => { const ctx = useDerivedFieldContext(""); const { value } = useFormValue(""); @@ -219,8 +226,10 @@ const OAUTH_BRANDS = { discord: TbBrandDiscordFilled, }; -const StrategyForm = ({ type }: Pick) => { - let Component = () => ; +const StrategyForm = ({ type, name }: Pick) => { + let Component = (p: any) => ( + + ); switch (type) { case "password": Component = StrategyPasswordForm; @@ -230,16 +239,22 @@ const StrategyForm = ({ type }: Pick) => { break; } - return ; + return ( + <> + + + + ); }; const StrategyPasswordForm = () => { return ; }; -const StrategyOAuthForm = () => { +const StrategyOAuthForm = ({ type, name }: Pick) => { return ( <> + diff --git a/app/src/ui/routes/index.tsx b/app/src/ui/routes/index.tsx index 27d40a0..cedf051 100644 --- a/app/src/ui/routes/index.tsx +++ b/app/src/ui/routes/index.tsx @@ -1,5 +1,4 @@ -import React, { Suspense, lazy } from "react"; -import { useBknd } from "ui/client/bknd"; +import { Suspense, lazy, type ComponentType, type ReactNode } from "react"; import { useTheme } from "ui/client/use-theme"; import { Route, Router, Switch } from "wouter"; import AuthRoutes from "./auth"; @@ -10,60 +9,70 @@ import MediaRoutes from "./media"; import { Root, RootEmpty } from "./root"; import SettingsRoutes from "./settings"; import { FlashMessage } from "ui/modules/server/FlashMessage"; +import { AuthRegister } from "ui/routes/auth/auth.register"; +import { BkndModalsProvider } from "ui/modals"; // @ts-ignore const TestRoutes = lazy(() => import("./test")); -export function Routes() { - const { app } = useBknd(); +export function Routes({ + BkndWrapper, + basePath = "", +}: { BkndWrapper: ComponentType<{ children: ReactNode }>; basePath?: string }) { const { theme } = useTheme(); return (
- + - - - - - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/app/src/ui/routes/media/media.settings.tsx b/app/src/ui/routes/media/media.settings.tsx index 810a6da..1635539 100644 --- a/app/src/ui/routes/media/media.settings.tsx +++ b/app/src/ui/routes/media/media.settings.tsx @@ -20,6 +20,7 @@ import { } from "ui/components/form/json-schema-form"; import { useBrowserTitle } from "ui/hooks/use-browser-title"; import * as AppShell from "ui/layouts/AppShell/AppShell"; +import { testIds } from "ui/lib/config"; export function MediaSettings(props) { useBrowserTitle(["Media", "Settings"]); @@ -79,7 +80,10 @@ function MediaSettingsInternal() {
- +
diff --git a/app/vite.dev.ts b/app/vite.dev.ts index 0049995..7552e8f 100644 --- a/app/vite.dev.ts +++ b/app/vite.dev.ts @@ -50,7 +50,7 @@ if (example) { } 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; export default { async fetch(request: Request) { diff --git a/examples/nextjs/src/app/admin/[[...admin]]/Admin.tsx b/examples/nextjs/src/app/admin/[[...admin]]/Admin.tsx new file mode 100644 index 0000000..988282c --- /dev/null +++ b/examples/nextjs/src/app/admin/[[...admin]]/Admin.tsx @@ -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 ; +} diff --git a/examples/nextjs/src/app/admin/[[...admin]]/page.tsx b/examples/nextjs/src/app/admin/[[...admin]]/page.tsx index 43e25f3..ad0e3d4 100644 --- a/examples/nextjs/src/app/admin/[[...admin]]/page.tsx +++ b/examples/nextjs/src/app/admin/[[...admin]]/page.tsx @@ -1,12 +1,12 @@ -import { Admin } from "bknd/ui"; -import "bknd/dist/styles.css"; +import { AdminComponent } from "./Admin"; import { getApi } from "@/bknd"; +import "bknd/dist/styles.css"; export default async function AdminPage() { const api = await getApi({ verify: true }); return ( -