mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 04:27:21 +00:00
1
.gitignore
vendored
1
.gitignore
vendored
@@ -18,6 +18,7 @@ packages/media/.env
|
|||||||
**/*/vite.config.ts.timestamp*
|
**/*/vite.config.ts.timestamp*
|
||||||
.history
|
.history
|
||||||
**/*/.db/*
|
**/*/.db/*
|
||||||
|
**/*/.configs/*
|
||||||
**/*/*.db
|
**/*/*.db
|
||||||
**/*/*.db-shm
|
**/*/*.db-shm
|
||||||
**/*/*.db-wal
|
**/*/*.db-wal
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||

|

|
||||||
|
|
||||||
bknd simplifies app development by providing fully functional backend for data management,
|
bknd simplifies app development by providing fully functional backend for data management,
|
||||||
authentication, workflows and media. Since it's lightweight and built on Web Standards, it can
|
authentication, workflows and media. Since it's lightweight and built on Web Standards, it can
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
|
||||||
import { type TSchema, Type, stripMark } from "../src/core/utils";
|
|
||||||
import { Module } from "../src/modules/Module";
|
|
||||||
|
|
||||||
function createModule<Schema extends TSchema>(schema: Schema) {
|
|
||||||
class TestModule extends Module<typeof schema> {
|
|
||||||
getSchema() {
|
|
||||||
return schema;
|
|
||||||
}
|
|
||||||
toJSON() {
|
|
||||||
return this.config;
|
|
||||||
}
|
|
||||||
useForceParse() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return TestModule;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Module", async () => {
|
|
||||||
test("basic", async () => {});
|
|
||||||
|
|
||||||
test("listener", async () => {
|
|
||||||
let result: any;
|
|
||||||
|
|
||||||
const module = createModule(Type.Object({ a: Type.String() }));
|
|
||||||
const m = new module({ a: "test" });
|
|
||||||
|
|
||||||
await m.schema().set({ a: "test2" });
|
|
||||||
m.setListener(async (c) => {
|
|
||||||
await new Promise((r) => setTimeout(r, 10));
|
|
||||||
result = stripMark(c);
|
|
||||||
});
|
|
||||||
await m.schema().set({ a: "test3" });
|
|
||||||
expect(result).toEqual({ a: "test3" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
1
app/__test__/auth/middleware.spec.ts
Normal file
1
app/__test__/auth/middleware.spec.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
import { describe, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { checksum, hash } from "../../src/core/utils";
|
import { checksum, hash } from "../../src/core/utils";
|
||||||
|
|
||||||
describe("crypto", async () => {
|
describe("crypto", async () => {
|
||||||
test("sha256", async () => {
|
test("sha256", async () => {
|
||||||
console.log(await hash.sha256("test"));
|
expect(await hash.sha256("test")).toBe(
|
||||||
|
"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
test("sha1", async () => {
|
test("sha1", async () => {
|
||||||
console.log(await hash.sha1("test"));
|
expect(await hash.sha1("test")).toBe("a94a8fe5ccb19ba61c4c0873d391e987982fbbd3");
|
||||||
});
|
});
|
||||||
test("checksum", async () => {
|
test("checksum", async () => {
|
||||||
console.log(checksum("hello world"));
|
expect(await checksum("hello world")).toBe("2aae6c35c94fcfb415dbe95f408b9ce91ee846ed");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import type { QueryObject } from "ufo";
|
import { Value } from "../../src/core/utils";
|
||||||
import { WhereBuilder, type WhereQuery } from "../../src/data/entities/query/WhereBuilder";
|
import { WhereBuilder, type WhereQuery, querySchema } from "../../src/data";
|
||||||
import { getDummyConnection } from "./helper";
|
import { getDummyConnection } from "./helper";
|
||||||
|
|
||||||
const t = "t";
|
|
||||||
describe("data-query-impl", () => {
|
describe("data-query-impl", () => {
|
||||||
function qb() {
|
function qb() {
|
||||||
const c = getDummyConnection();
|
const c = getDummyConnection();
|
||||||
const kysely = c.dummyConnection.kysely;
|
const kysely = c.dummyConnection.kysely;
|
||||||
return kysely.selectFrom(t).selectAll();
|
return kysely.selectFrom("t").selectAll();
|
||||||
}
|
}
|
||||||
function compile(q: QueryObject) {
|
function compile(q: WhereQuery) {
|
||||||
const { sql, parameters } = WhereBuilder.addClause(qb(), q).compile();
|
const { sql, parameters } = WhereBuilder.addClause(qb(), q).compile();
|
||||||
return { sql, parameters };
|
return { sql, parameters };
|
||||||
}
|
}
|
||||||
@@ -90,3 +89,20 @@ describe("data-query-impl", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("data-query-impl: Typebox", () => {
|
||||||
|
test("sort", async () => {
|
||||||
|
const decode = (input: any, expected: any) => {
|
||||||
|
const result = Value.Decode(querySchema, input);
|
||||||
|
expect(result.sort).toEqual(expected);
|
||||||
|
};
|
||||||
|
const _dflt = { by: "id", dir: "asc" };
|
||||||
|
|
||||||
|
decode({ sort: "" }, _dflt);
|
||||||
|
decode({ sort: "name" }, { by: "name", dir: "asc" });
|
||||||
|
decode({ sort: "-name" }, { by: "name", dir: "desc" });
|
||||||
|
decode({ sort: "-posts.name" }, { by: "posts.name", dir: "desc" });
|
||||||
|
decode({ sort: "-1name" }, _dflt);
|
||||||
|
decode({ sort: { by: "name", dir: "desc" } }, { by: "name", dir: "desc" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ describe("some tests", async () => {
|
|||||||
|
|
||||||
const users = new Entity("users", [
|
const users = new Entity("users", [
|
||||||
new TextField("username", { required: true, default_value: "nobody" }),
|
new TextField("username", { required: true, default_value: "nobody" }),
|
||||||
new TextField("email", { max_length: 3 })
|
new TextField("email", { maxLength: 3 })
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const posts = new Entity("posts", [
|
const posts = new Entity("posts", [
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// eslint-disable-next-line import/no-unresolved
|
// eslint-disable-next-line import/no-unresolved
|
||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { isEqual } from "lodash-es";
|
import { isEqual } from "lodash-es";
|
||||||
import { type Static, Type, _jsonp } from "../../src/core/utils";
|
import { type Static, Type, _jsonp, withDisabledConsole } from "../../src/core/utils";
|
||||||
import { Condition, ExecutionEvent, FetchTask, Flow, LogTask, Task } from "../../src/flows";
|
import { Condition, ExecutionEvent, FetchTask, Flow, LogTask, Task } from "../../src/flows";
|
||||||
|
|
||||||
/*beforeAll(disableConsoleLog);
|
/*beforeAll(disableConsoleLog);
|
||||||
@@ -232,8 +232,10 @@ describe("Flow tests", async () => {
|
|||||||
).toEqual(["second", "fourth"]);
|
).toEqual(["second", "fourth"]);
|
||||||
|
|
||||||
const execution = back.createExecution();
|
const execution = back.createExecution();
|
||||||
|
withDisabledConsole(async () => {
|
||||||
expect(execution.start()).rejects.toThrow();
|
expect(execution.start()).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("Flow with back step: enough retries", async () => {
|
test("Flow with back step: enough retries", async () => {
|
||||||
const first = getNamedTask("first");
|
const first = getNamedTask("first");
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ const _oldConsoles = {
|
|||||||
error: console.error
|
error: console.error
|
||||||
};
|
};
|
||||||
|
|
||||||
export function disableConsoleLog(severities: ConsoleSeverity[] = ["log"]) {
|
export function disableConsoleLog(severities: ConsoleSeverity[] = ["log", "warn"]) {
|
||||||
severities.forEach((severity) => {
|
severities.forEach((severity) => {
|
||||||
console[severity] = () => null;
|
console[severity] = () => null;
|
||||||
});
|
});
|
||||||
|
|||||||
213
app/__test__/integration/auth.integration.test.ts
Normal file
213
app/__test__/integration/auth.integration.test.ts
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||||
|
import { App, createApp } from "../../src";
|
||||||
|
import type { AuthResponse } from "../../src/auth";
|
||||||
|
import { randomString, secureRandomString, withDisabledConsole } from "../../src/core/utils";
|
||||||
|
import { disableConsoleLog, enableConsoleLog } from "../helper";
|
||||||
|
|
||||||
|
beforeAll(disableConsoleLog);
|
||||||
|
afterAll(enableConsoleLog);
|
||||||
|
|
||||||
|
const roles = {
|
||||||
|
sloppy: {
|
||||||
|
guest: {
|
||||||
|
permissions: [
|
||||||
|
"system.access.admin",
|
||||||
|
"system.schema.read",
|
||||||
|
"system.access.api",
|
||||||
|
"system.config.read",
|
||||||
|
"data.entity.read"
|
||||||
|
],
|
||||||
|
is_default: true
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
is_default: true,
|
||||||
|
implicit_allow: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
strict: {
|
||||||
|
guest: {
|
||||||
|
permissions: ["system.access.api", "system.config.read", "data.entity.read"],
|
||||||
|
is_default: true
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
is_default: true,
|
||||||
|
implicit_allow: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const configs = {
|
||||||
|
auth: {
|
||||||
|
enabled: true,
|
||||||
|
entity_name: "users",
|
||||||
|
jwt: {
|
||||||
|
secret: secureRandomString(20),
|
||||||
|
issuer: randomString(10)
|
||||||
|
},
|
||||||
|
roles: roles.strict,
|
||||||
|
guard: {
|
||||||
|
enabled: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
users: {
|
||||||
|
normal: {
|
||||||
|
email: "normal@bknd.io",
|
||||||
|
password: "12345678"
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
email: "admin@bknd.io",
|
||||||
|
password: "12345678",
|
||||||
|
role: "admin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function createAuthApp() {
|
||||||
|
const app = createApp({
|
||||||
|
initialConfig: {
|
||||||
|
auth: configs.auth
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.emgr.onEvent(
|
||||||
|
App.Events.AppFirstBoot,
|
||||||
|
async () => {
|
||||||
|
await app.createUser(configs.users.normal);
|
||||||
|
await app.createUser(configs.users.admin);
|
||||||
|
},
|
||||||
|
"sync"
|
||||||
|
);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCookie(r: Response, name: string) {
|
||||||
|
const cookies = r.headers.get("cookie") ?? r.headers.get("set-cookie");
|
||||||
|
if (!cookies) return;
|
||||||
|
const cookie = cookies.split(";").find((c) => c.trim().startsWith(name));
|
||||||
|
if (!cookie) return;
|
||||||
|
return cookie.split("=")[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
const fns = <Mode extends "cookie" | "token" = "token">(app: App, mode?: Mode) => {
|
||||||
|
function headers(token?: string, additional?: Record<string, string>) {
|
||||||
|
if (mode === "cookie") {
|
||||||
|
return {
|
||||||
|
cookie: `auth=${token};`,
|
||||||
|
...additional
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...additional
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function body(obj?: Record<string, any>) {
|
||||||
|
if (mode === "cookie") {
|
||||||
|
const formData = new FormData();
|
||||||
|
for (const key in obj) {
|
||||||
|
formData.append(key, obj[key]);
|
||||||
|
}
|
||||||
|
return formData;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
login: async (
|
||||||
|
user: any
|
||||||
|
): Promise<{ res: Response; data: Mode extends "token" ? AuthResponse : string }> => {
|
||||||
|
const res = (await app.server.request("/api/auth/password/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: headers(),
|
||||||
|
body: body(user)
|
||||||
|
})) as Response;
|
||||||
|
|
||||||
|
const data = mode === "cookie" ? getCookie(res, "auth") : await res.json();
|
||||||
|
|
||||||
|
return { res, data };
|
||||||
|
},
|
||||||
|
me: async (token?: string): Promise<Pick<AuthResponse, "user">> => {
|
||||||
|
const res = (await app.server.request("/api/auth/me", {
|
||||||
|
method: "GET",
|
||||||
|
headers: headers(token)
|
||||||
|
})) as Response;
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("integration auth", () => {
|
||||||
|
it("should create users on boot", async () => {
|
||||||
|
const app = createAuthApp();
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
const { data: users } = await app.em.repository("users").findMany();
|
||||||
|
expect(users.length).toBe(2);
|
||||||
|
expect(users[0].email).toBe(configs.users.normal.email);
|
||||||
|
expect(users[1].email).toBe(configs.users.admin.email);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should log you in with API", async () => {
|
||||||
|
const app = createAuthApp();
|
||||||
|
await app.build();
|
||||||
|
const $fns = fns(app);
|
||||||
|
|
||||||
|
// login api
|
||||||
|
const { data } = await $fns.login(configs.users.normal);
|
||||||
|
const me = await $fns.me(data.token);
|
||||||
|
|
||||||
|
expect(data.user.email).toBe(me.user.email);
|
||||||
|
expect(me.user.email).toBe(configs.users.normal.email);
|
||||||
|
|
||||||
|
// expect no user with no token
|
||||||
|
expect(await $fns.me()).toEqual({ user: null as any });
|
||||||
|
|
||||||
|
// expect no user with invalid token
|
||||||
|
expect(await $fns.me("invalid")).toEqual({ user: null as any });
|
||||||
|
expect(await $fns.me()).toEqual({ user: null as any });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should log you in with form and cookie", async () => {
|
||||||
|
const app = createAuthApp();
|
||||||
|
await app.build();
|
||||||
|
const $fns = fns(app, "cookie");
|
||||||
|
|
||||||
|
const { res, data: token } = await $fns.login(configs.users.normal);
|
||||||
|
expect(token).toBeDefined();
|
||||||
|
expect(res.status).toBe(302); // because it redirects
|
||||||
|
|
||||||
|
// test cookie jwt interchangability
|
||||||
|
{
|
||||||
|
// expect token to not work as-is for api endpoints
|
||||||
|
expect(await fns(app).me(token)).toEqual({ user: null as any });
|
||||||
|
// hono adds an additional segment to cookies
|
||||||
|
const apified_token = token.split(".").slice(0, -1).join(".");
|
||||||
|
// now it should work
|
||||||
|
// @todo: maybe add a config to don't allow re-use?
|
||||||
|
expect((await fns(app).me(apified_token)).user.email).toBe(configs.users.normal.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
// test cookie with me endpoint
|
||||||
|
{
|
||||||
|
const me = await $fns.me(token);
|
||||||
|
expect(me.user.email).toBe(configs.users.normal.email);
|
||||||
|
|
||||||
|
// check with invalid & empty
|
||||||
|
expect(await $fns.me("invalid")).toEqual({ user: null as any });
|
||||||
|
expect(await $fns.me()).toEqual({ user: null as any });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should check for permissions", async () => {
|
||||||
|
const app = createAuthApp();
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
await withDisabledConsole(async () => {
|
||||||
|
const res = await app.server.request("/api/system/schema");
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
import { afterAll, beforeAll, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
||||||
|
import { createApp } from "../../src";
|
||||||
import { AuthController } from "../../src/auth/api/AuthController";
|
import { AuthController } from "../../src/auth/api/AuthController";
|
||||||
|
import { em, entity, 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";
|
||||||
import { makeCtx, moduleTestSuite } from "./module-test-suite";
|
import { makeCtx, moduleTestSuite } from "./module-test-suite";
|
||||||
@@ -76,4 +78,53 @@ describe("AppAuth", () => {
|
|||||||
expect(users[0].email).toBe("some@body.com");
|
expect(users[0].email).toBe("some@body.com");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("registers auth middleware for bknd routes only", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
initialConfig: {
|
||||||
|
auth: {
|
||||||
|
enabled: true,
|
||||||
|
jwt: {
|
||||||
|
secret: "123456"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.build();
|
||||||
|
const spy = spyOn(app.module.auth.authenticator, "requestCookieRefresh");
|
||||||
|
|
||||||
|
// register custom route
|
||||||
|
app.server.get("/test", async (c) => c.text("test"));
|
||||||
|
|
||||||
|
// call a system api and then the custom route
|
||||||
|
await app.server.request("/api/system/ping");
|
||||||
|
await app.server.request("/test");
|
||||||
|
|
||||||
|
expect(spy.mock.calls.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should allow additional user fields", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
initialConfig: {
|
||||||
|
auth: {
|
||||||
|
entity_name: "users",
|
||||||
|
enabled: true
|
||||||
|
},
|
||||||
|
data: em({
|
||||||
|
users: entity("users", {
|
||||||
|
additional: text()
|
||||||
|
})
|
||||||
|
}).toJSON()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
const e = app.modules.em.entity("users");
|
||||||
|
const fields = e.fields.map((f) => f.name);
|
||||||
|
expect(e.type).toBe("system");
|
||||||
|
expect(fields).toContain("additional");
|
||||||
|
expect(fields).toEqual(["id", "email", "strategy", "strategy_value", "role", "additional"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,55 @@
|
|||||||
import { describe } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { createApp, registries } from "../../src";
|
||||||
|
import { em, entity, text } from "../../src/data";
|
||||||
|
import { StorageLocalAdapter } from "../../src/media/storage/adapters/StorageLocalAdapter";
|
||||||
import { AppMedia } from "../../src/modules";
|
import { AppMedia } from "../../src/modules";
|
||||||
import { moduleTestSuite } from "./module-test-suite";
|
import { moduleTestSuite } from "./module-test-suite";
|
||||||
|
|
||||||
describe("AppMedia", () => {
|
describe("AppMedia", () => {
|
||||||
moduleTestSuite(AppMedia);
|
moduleTestSuite(AppMedia);
|
||||||
|
|
||||||
|
test("should allow additional fields", async () => {
|
||||||
|
registries.media.register("local", StorageLocalAdapter);
|
||||||
|
|
||||||
|
const app = createApp({
|
||||||
|
initialConfig: {
|
||||||
|
media: {
|
||||||
|
entity_name: "media",
|
||||||
|
enabled: true,
|
||||||
|
adapter: {
|
||||||
|
type: "local",
|
||||||
|
config: {
|
||||||
|
path: "./"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data: em({
|
||||||
|
media: entity("media", {
|
||||||
|
additional: text()
|
||||||
|
})
|
||||||
|
}).toJSON()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
const e = app.modules.em.entity("media");
|
||||||
|
const fields = e.fields.map((f) => f.name);
|
||||||
|
expect(e.type).toBe("system");
|
||||||
|
expect(fields).toContain("additional");
|
||||||
|
expect(fields).toEqual([
|
||||||
|
"id",
|
||||||
|
"path",
|
||||||
|
"folder",
|
||||||
|
"mime_type",
|
||||||
|
"size",
|
||||||
|
"scope",
|
||||||
|
"etag",
|
||||||
|
"modified_at",
|
||||||
|
"reference",
|
||||||
|
"entity_id",
|
||||||
|
"metadata",
|
||||||
|
"additional"
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
213
app/__test__/modules/Module.spec.ts
Normal file
213
app/__test__/modules/Module.spec.ts
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { type TSchema, Type, stripMark } from "../../src/core/utils";
|
||||||
|
import { EntityManager, em, entity, index, text } from "../../src/data";
|
||||||
|
import { DummyConnection } from "../../src/data/connection/DummyConnection";
|
||||||
|
import { Module } from "../../src/modules/Module";
|
||||||
|
|
||||||
|
function createModule<Schema extends TSchema>(schema: Schema) {
|
||||||
|
class TestModule extends Module<typeof schema> {
|
||||||
|
getSchema() {
|
||||||
|
return schema;
|
||||||
|
}
|
||||||
|
toJSON() {
|
||||||
|
return this.config;
|
||||||
|
}
|
||||||
|
useForceParse() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return TestModule;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Module", async () => {
|
||||||
|
describe("basic", () => {
|
||||||
|
test("listener", async () => {
|
||||||
|
let result: any;
|
||||||
|
|
||||||
|
const module = createModule(Type.Object({ a: Type.String() }));
|
||||||
|
const m = new module({ a: "test" });
|
||||||
|
|
||||||
|
await m.schema().set({ a: "test2" });
|
||||||
|
m.setListener(async (c) => {
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
result = stripMark(c);
|
||||||
|
});
|
||||||
|
await m.schema().set({ a: "test3" });
|
||||||
|
expect(result).toEqual({ a: "test3" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("db schema", () => {
|
||||||
|
class M extends Module {
|
||||||
|
override getSchema() {
|
||||||
|
return Type.Object({});
|
||||||
|
}
|
||||||
|
|
||||||
|
prt = {
|
||||||
|
ensureEntity: this.ensureEntity.bind(this),
|
||||||
|
ensureIndex: this.ensureIndex.bind(this),
|
||||||
|
ensureSchema: this.ensureSchema.bind(this)
|
||||||
|
};
|
||||||
|
|
||||||
|
get em() {
|
||||||
|
return this.ctx.em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function make(_em: ReturnType<typeof em>) {
|
||||||
|
const em = new EntityManager(
|
||||||
|
Object.values(_em.entities),
|
||||||
|
new DummyConnection(),
|
||||||
|
_em.relations,
|
||||||
|
_em.indices
|
||||||
|
);
|
||||||
|
return new M({} as any, { em, flags: Module.ctx_flags } as any);
|
||||||
|
}
|
||||||
|
function flat(_em: EntityManager) {
|
||||||
|
return {
|
||||||
|
entities: _em.entities.map((e) => ({
|
||||||
|
name: e.name,
|
||||||
|
fields: e.fields.map((f) => f.name),
|
||||||
|
type: e.type
|
||||||
|
})),
|
||||||
|
indices: _em.indices.map((i) => ({
|
||||||
|
name: i.name,
|
||||||
|
entity: i.entity.name,
|
||||||
|
fields: i.fields.map((f) => f.name),
|
||||||
|
unique: i.unique
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("no change", () => {
|
||||||
|
const initial = em({});
|
||||||
|
|
||||||
|
const m = make(initial);
|
||||||
|
expect(m.ctx.flags.sync_required).toBe(false);
|
||||||
|
|
||||||
|
expect(flat(make(initial).em)).toEqual({
|
||||||
|
entities: [],
|
||||||
|
indices: []
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("init", () => {
|
||||||
|
const initial = em({
|
||||||
|
users: entity("u", {
|
||||||
|
name: text()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const m = make(initial);
|
||||||
|
expect(m.ctx.flags.sync_required).toBe(false);
|
||||||
|
|
||||||
|
expect(flat(m.em)).toEqual({
|
||||||
|
entities: [
|
||||||
|
{
|
||||||
|
name: "u",
|
||||||
|
fields: ["id", "name"],
|
||||||
|
type: "regular"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
indices: []
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ensure entity", () => {
|
||||||
|
const initial = em({
|
||||||
|
users: entity("u", {
|
||||||
|
name: text()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const m = make(initial);
|
||||||
|
expect(flat(m.em)).toEqual({
|
||||||
|
entities: [
|
||||||
|
{
|
||||||
|
name: "u",
|
||||||
|
fields: ["id", "name"],
|
||||||
|
type: "regular"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
indices: []
|
||||||
|
});
|
||||||
|
|
||||||
|
// this should add a new entity
|
||||||
|
m.prt.ensureEntity(
|
||||||
|
entity("p", {
|
||||||
|
title: text()
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// this should only add the field "important"
|
||||||
|
m.prt.ensureEntity(
|
||||||
|
entity(
|
||||||
|
"u",
|
||||||
|
{
|
||||||
|
important: text()
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
"system"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(m.ctx.flags.sync_required).toBe(true);
|
||||||
|
expect(flat(m.em)).toEqual({
|
||||||
|
entities: [
|
||||||
|
{
|
||||||
|
name: "u",
|
||||||
|
// ensured properties must come first
|
||||||
|
fields: ["id", "important", "name"],
|
||||||
|
// ensured type must be present
|
||||||
|
type: "system"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "p",
|
||||||
|
fields: ["id", "title"],
|
||||||
|
type: "regular"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
indices: []
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ensure index", () => {
|
||||||
|
const users = entity("u", {
|
||||||
|
name: text(),
|
||||||
|
title: text()
|
||||||
|
});
|
||||||
|
const initial = em({ users }, ({ index }, { users }) => {
|
||||||
|
index(users).on(["title"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
const m = make(initial);
|
||||||
|
m.prt.ensureIndex(index(users).on(["name"]));
|
||||||
|
|
||||||
|
expect(m.ctx.flags.sync_required).toBe(true);
|
||||||
|
expect(flat(m.em)).toEqual({
|
||||||
|
entities: [
|
||||||
|
{
|
||||||
|
name: "u",
|
||||||
|
fields: ["id", "name", "title"],
|
||||||
|
type: "regular"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
indices: [
|
||||||
|
{
|
||||||
|
name: "idx_u_title",
|
||||||
|
entity: "u",
|
||||||
|
fields: ["title"],
|
||||||
|
unique: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "idx_u_name",
|
||||||
|
entity: "u",
|
||||||
|
fields: ["name"],
|
||||||
|
unique: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { mark, stripMark } from "../src/core/utils";
|
import { stripMark } from "../../src/core/utils";
|
||||||
import { entity, text } from "../src/data";
|
import { entity, text } from "../../src/data";
|
||||||
import { ModuleManager, getDefaultConfig } from "../src/modules/ModuleManager";
|
import { ModuleManager, getDefaultConfig } from "../../src/modules/ModuleManager";
|
||||||
import { CURRENT_VERSION, TABLE_NAME } from "../src/modules/migrations";
|
import { CURRENT_VERSION, TABLE_NAME } from "../../src/modules/migrations";
|
||||||
import { getDummyConnection } from "./helper";
|
import { getDummyConnection } from "../helper";
|
||||||
|
|
||||||
describe("ModuleManager", async () => {
|
describe("ModuleManager", async () => {
|
||||||
test("s1: no config, no build", async () => {
|
test("s1: no config, no build", async () => {
|
||||||
@@ -5,7 +5,7 @@ import { Guard } from "../../src/auth";
|
|||||||
import { EventManager } from "../../src/core/events";
|
import { EventManager } from "../../src/core/events";
|
||||||
import { Default, stripMark } from "../../src/core/utils";
|
import { Default, stripMark } from "../../src/core/utils";
|
||||||
import { EntityManager } from "../../src/data";
|
import { EntityManager } from "../../src/data";
|
||||||
import type { Module, ModuleBuildContext } from "../../src/modules/Module";
|
import { Module, type ModuleBuildContext } from "../../src/modules/Module";
|
||||||
import { getDummyConnection } from "../helper";
|
import { getDummyConnection } from "../helper";
|
||||||
|
|
||||||
export function makeCtx(overrides?: Partial<ModuleBuildContext>): ModuleBuildContext {
|
export function makeCtx(overrides?: Partial<ModuleBuildContext>): ModuleBuildContext {
|
||||||
@@ -16,6 +16,7 @@ export function makeCtx(overrides?: Partial<ModuleBuildContext>): ModuleBuildCon
|
|||||||
em: new EntityManager([], dummyConnection),
|
em: new EntityManager([], dummyConnection),
|
||||||
emgr: new EventManager(),
|
emgr: new EventManager(),
|
||||||
guard: new Guard(),
|
guard: new Guard(),
|
||||||
|
flags: Module.ctx_flags,
|
||||||
...overrides
|
...overrides
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
121
app/build.ts
121
app/build.ts
@@ -1,8 +1,5 @@
|
|||||||
import { $ } from "bun";
|
import { $ } from "bun";
|
||||||
import * as esbuild from "esbuild";
|
|
||||||
import postcss from "esbuild-postcss";
|
|
||||||
import * as tsup from "tsup";
|
import * as tsup from "tsup";
|
||||||
import { guessMimeType } from "./src/media/storage/mime-types";
|
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const watch = args.includes("--watch");
|
const watch = args.includes("--watch");
|
||||||
@@ -12,8 +9,8 @@ const sourcemap = args.includes("--sourcemap");
|
|||||||
const clean = args.includes("--clean");
|
const clean = args.includes("--clean");
|
||||||
|
|
||||||
if (clean) {
|
if (clean) {
|
||||||
console.log("Cleaning dist");
|
console.log("Cleaning dist (w/o static)");
|
||||||
await $`rm -rf dist`;
|
await $`find dist -mindepth 1 ! -path "dist/static/*" ! -path "dist/static" -exec rm -rf {} +`;
|
||||||
}
|
}
|
||||||
|
|
||||||
let types_running = false;
|
let types_running = false;
|
||||||
@@ -22,9 +19,11 @@ function buildTypes() {
|
|||||||
types_running = true;
|
types_running = true;
|
||||||
|
|
||||||
Bun.spawn(["bun", "build:types"], {
|
Bun.spawn(["bun", "build:types"], {
|
||||||
|
stdout: "inherit",
|
||||||
onExit: () => {
|
onExit: () => {
|
||||||
console.log("Types built");
|
console.log("Types built");
|
||||||
Bun.spawn(["bun", "tsc-alias"], {
|
Bun.spawn(["bun", "tsc-alias"], {
|
||||||
|
stdout: "inherit",
|
||||||
onExit: () => {
|
onExit: () => {
|
||||||
console.log("Types aliased");
|
console.log("Types aliased");
|
||||||
types_running = false;
|
types_running = false;
|
||||||
@@ -36,7 +35,7 @@ function buildTypes() {
|
|||||||
|
|
||||||
let watcher_timeout: any;
|
let watcher_timeout: any;
|
||||||
function delayTypes() {
|
function delayTypes() {
|
||||||
if (!watch) return;
|
if (!watch || !types) return;
|
||||||
if (watcher_timeout) {
|
if (watcher_timeout) {
|
||||||
clearTimeout(watcher_timeout);
|
clearTimeout(watcher_timeout);
|
||||||
}
|
}
|
||||||
@@ -47,67 +46,6 @@ if (types && !watch) {
|
|||||||
buildTypes();
|
buildTypes();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Build static assets
|
|
||||||
* Using esbuild because tsup doesn't include "react"
|
|
||||||
*/
|
|
||||||
const result = await esbuild.build({
|
|
||||||
minify,
|
|
||||||
sourcemap,
|
|
||||||
entryPoints: ["src/ui/main.tsx"],
|
|
||||||
entryNames: "[dir]/[name]-[hash]",
|
|
||||||
outdir: "dist/static",
|
|
||||||
platform: "browser",
|
|
||||||
bundle: true,
|
|
||||||
splitting: true,
|
|
||||||
metafile: true,
|
|
||||||
drop: ["console", "debugger"],
|
|
||||||
inject: ["src/ui/inject.js"],
|
|
||||||
target: "es2022",
|
|
||||||
format: "esm",
|
|
||||||
plugins: [postcss()],
|
|
||||||
loader: {
|
|
||||||
".svg": "dataurl",
|
|
||||||
".js": "jsx"
|
|
||||||
},
|
|
||||||
define: {
|
|
||||||
__isDev: "0",
|
|
||||||
"process.env.NODE_ENV": '"production"'
|
|
||||||
},
|
|
||||||
chunkNames: "chunks/[name]-[hash]",
|
|
||||||
logLevel: "error"
|
|
||||||
});
|
|
||||||
|
|
||||||
// Write manifest
|
|
||||||
{
|
|
||||||
const manifest: Record<string, object> = {};
|
|
||||||
const toAsset = (output: string) => {
|
|
||||||
const name = output.split("/").pop()!;
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
path: output,
|
|
||||||
mime: guessMimeType(name)
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const info = Object.entries(result.metafile.outputs)
|
|
||||||
.filter(([, meta]) => {
|
|
||||||
return meta.entryPoint && meta.entryPoint === "src/ui/main.tsx";
|
|
||||||
})
|
|
||||||
.map(([output, meta]) => ({ output, meta }));
|
|
||||||
|
|
||||||
for (const { output, meta } of info) {
|
|
||||||
manifest[meta.entryPoint as string] = toAsset(output);
|
|
||||||
if (meta.cssBundle) {
|
|
||||||
manifest["src/ui/main.css"] = toAsset(meta.cssBundle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const manifest_file = "dist/static/manifest.json";
|
|
||||||
await Bun.write(manifest_file, JSON.stringify(manifest, null, 2));
|
|
||||||
console.log(`Manifest written to ${manifest_file}`, manifest);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Building backend and general API
|
* Building backend and general API
|
||||||
*/
|
*/
|
||||||
@@ -120,7 +58,7 @@ await tsup.build({
|
|||||||
external: ["bun:test", "@libsql/client"],
|
external: ["bun:test", "@libsql/client"],
|
||||||
metafile: true,
|
metafile: true,
|
||||||
platform: "browser",
|
platform: "browser",
|
||||||
format: ["esm", "cjs"],
|
format: ["esm"],
|
||||||
splitting: false,
|
splitting: false,
|
||||||
treeshake: true,
|
treeshake: true,
|
||||||
loader: {
|
loader: {
|
||||||
@@ -138,12 +76,24 @@ await tsup.build({
|
|||||||
minify,
|
minify,
|
||||||
sourcemap,
|
sourcemap,
|
||||||
watch,
|
watch,
|
||||||
entry: ["src/ui/index.ts", "src/ui/client/index.ts", "src/ui/main.css"],
|
entry: [
|
||||||
|
"src/ui/index.ts",
|
||||||
|
"src/ui/client/index.ts",
|
||||||
|
"src/ui/elements/index.ts",
|
||||||
|
"src/ui/main.css"
|
||||||
|
],
|
||||||
outDir: "dist/ui",
|
outDir: "dist/ui",
|
||||||
external: ["bun:test", "react", "react-dom", "use-sync-external-store"],
|
external: [
|
||||||
|
"bun:test",
|
||||||
|
"react",
|
||||||
|
"react-dom",
|
||||||
|
"react/jsx-runtime",
|
||||||
|
"react/jsx-dev-runtime",
|
||||||
|
"use-sync-external-store"
|
||||||
|
],
|
||||||
metafile: true,
|
metafile: true,
|
||||||
platform: "browser",
|
platform: "browser",
|
||||||
format: ["esm", "cjs"],
|
format: ["esm"],
|
||||||
splitting: true,
|
splitting: true,
|
||||||
treeshake: true,
|
treeshake: true,
|
||||||
loader: {
|
loader: {
|
||||||
@@ -166,7 +116,7 @@ function baseConfig(adapter: string): tsup.Options {
|
|||||||
minify,
|
minify,
|
||||||
sourcemap,
|
sourcemap,
|
||||||
watch,
|
watch,
|
||||||
entry: [`src/adapter/${adapter}`],
|
entry: [`src/adapter/${adapter}/index.ts`],
|
||||||
format: ["esm"],
|
format: ["esm"],
|
||||||
platform: "neutral",
|
platform: "neutral",
|
||||||
outDir: `dist/adapter/${adapter}`,
|
outDir: `dist/adapter/${adapter}`,
|
||||||
@@ -188,37 +138,22 @@ function baseConfig(adapter: string): tsup.Options {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await tsup.build(baseConfig("remix"));
|
||||||
|
await tsup.build(baseConfig("bun"));
|
||||||
|
await tsup.build(baseConfig("astro"));
|
||||||
|
await tsup.build(baseConfig("cloudflare"));
|
||||||
|
|
||||||
await tsup.build({
|
await tsup.build({
|
||||||
...baseConfig("vite"),
|
...baseConfig("vite"),
|
||||||
platform: "node"
|
platform: "node"
|
||||||
});
|
});
|
||||||
|
|
||||||
await tsup.build({
|
|
||||||
...baseConfig("cloudflare")
|
|
||||||
});
|
|
||||||
|
|
||||||
await tsup.build({
|
await tsup.build({
|
||||||
...baseConfig("nextjs"),
|
...baseConfig("nextjs"),
|
||||||
format: ["esm", "cjs"],
|
|
||||||
platform: "node"
|
platform: "node"
|
||||||
});
|
});
|
||||||
|
|
||||||
await tsup.build({
|
|
||||||
...baseConfig("remix"),
|
|
||||||
format: ["esm", "cjs"]
|
|
||||||
});
|
|
||||||
|
|
||||||
await tsup.build({
|
|
||||||
...baseConfig("bun")
|
|
||||||
});
|
|
||||||
|
|
||||||
await tsup.build({
|
await tsup.build({
|
||||||
...baseConfig("node"),
|
...baseConfig("node"),
|
||||||
platform: "node",
|
platform: "node"
|
||||||
format: ["esm", "cjs"]
|
|
||||||
});
|
|
||||||
|
|
||||||
await tsup.build({
|
|
||||||
...baseConfig("astro"),
|
|
||||||
format: ["esm", "cjs"]
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,22 +3,21 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"bin": "./dist/cli/index.js",
|
"bin": "./dist/cli/index.js",
|
||||||
"version": "0.4.0",
|
"version": "0.5.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build:all": "NODE_ENV=production bun run build.ts --minify --types --clean && bun run build:cli",
|
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"test": "ALL_TESTS=1 bun test --bail",
|
"test": "ALL_TESTS=1 bun test --bail",
|
||||||
"build": "NODE_ENV=production bun run build.ts --minify --types",
|
"build": "NODE_ENV=production bun run build.ts --minify --types",
|
||||||
|
"build:all": "rm -rf dist && bun run build:static && NODE_ENV=production bun run build.ts --minify --types --clean && bun run build:cli",
|
||||||
|
"build:cli": "bun build src/cli/index.ts --target node --outdir dist/cli --minify",
|
||||||
|
"build:static": "vite build",
|
||||||
"watch": "bun run build.ts --types --watch",
|
"watch": "bun run build.ts --types --watch",
|
||||||
"types": "bun tsc --noEmit",
|
"types": "bun tsc --noEmit",
|
||||||
"clean:types": "find ./dist -name '*.d.ts' -delete && rm -f ./dist/tsconfig.tsbuildinfo",
|
"clean:types": "find ./dist -name '*.d.ts' -delete && rm -f ./dist/tsconfig.tsbuildinfo",
|
||||||
"build:types": "tsc --emitDeclarationOnly && tsc-alias",
|
"build:types": "tsc --emitDeclarationOnly && tsc-alias",
|
||||||
"build:css": "bun tailwindcss -i src/ui/main.css -o ./dist/static/styles.css",
|
|
||||||
"watch:css": "bun tailwindcss --watch -i src/ui/main.css -o ./dist/styles.css",
|
|
||||||
"updater": "bun x npm-check-updates -ui",
|
"updater": "bun x npm-check-updates -ui",
|
||||||
"build:cli": "bun build src/cli/index.ts --target node --outdir dist/cli --minify",
|
|
||||||
"cli": "LOCAL=1 bun src/cli/index.ts",
|
"cli": "LOCAL=1 bun src/cli/index.ts",
|
||||||
"prepublishOnly": "bun run build:all"
|
"prepublishOnly": "bun run test && bun run build:all"
|
||||||
},
|
},
|
||||||
"license": "FSL-1.1-MIT",
|
"license": "FSL-1.1-MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -34,7 +33,8 @@
|
|||||||
"liquidjs": "^10.15.0",
|
"liquidjs": "^10.15.0",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"oauth4webapi": "^2.11.1",
|
"oauth4webapi": "^2.11.1",
|
||||||
"swr": "^2.2.5"
|
"swr": "^2.2.5",
|
||||||
|
"json-schema-form-react": "^0.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.613.0",
|
"@aws-sdk/client-s3": "^3.613.0",
|
||||||
@@ -103,6 +103,11 @@
|
|||||||
"import": "./dist/ui/index.js",
|
"import": "./dist/ui/index.js",
|
||||||
"require": "./dist/ui/index.cjs"
|
"require": "./dist/ui/index.cjs"
|
||||||
},
|
},
|
||||||
|
"./elements": {
|
||||||
|
"types": "./dist/types/ui/elements/index.d.ts",
|
||||||
|
"import": "./dist/ui/elements/index.js",
|
||||||
|
"require": "./dist/ui/elements/index.cjs"
|
||||||
|
},
|
||||||
"./client": {
|
"./client": {
|
||||||
"types": "./dist/types/ui/client/index.d.ts",
|
"types": "./dist/types/ui/client/index.d.ts",
|
||||||
"import": "./dist/ui/client/index.js",
|
"import": "./dist/ui/client/index.js",
|
||||||
@@ -164,7 +169,7 @@
|
|||||||
"require": "./dist/adapter/astro/index.cjs"
|
"require": "./dist/adapter/astro/index.cjs"
|
||||||
},
|
},
|
||||||
"./dist/styles.css": "./dist/ui/main.css",
|
"./dist/styles.css": "./dist/ui/main.css",
|
||||||
"./dist/manifest.json": "./dist/static/manifest.json"
|
"./dist/manifest.json": "./dist/static/.vite/manifest.json"
|
||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
|
import type { CreateUserPayload } from "auth/AppAuth";
|
||||||
|
import { auth } from "auth/middlewares";
|
||||||
|
import { config } from "core";
|
||||||
import { Event } from "core/events";
|
import { Event } from "core/events";
|
||||||
|
import { patternMatch } from "core/utils";
|
||||||
import { Connection, type LibSqlCredentials, LibsqlConnection } from "data";
|
import { Connection, type LibSqlCredentials, LibsqlConnection } from "data";
|
||||||
import {
|
import {
|
||||||
type InitialModuleConfigs,
|
type InitialModuleConfigs,
|
||||||
@@ -68,6 +72,12 @@ export class App {
|
|||||||
onFirstBoot: async () => {
|
onFirstBoot: async () => {
|
||||||
console.log("[APP] first boot");
|
console.log("[APP] first boot");
|
||||||
this.trigger_first_boot = true;
|
this.trigger_first_boot = true;
|
||||||
|
},
|
||||||
|
onServerInit: async (server) => {
|
||||||
|
server.use(async (c, next) => {
|
||||||
|
c.set("app", this);
|
||||||
|
await next();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.modules.ctx().emgr.registerEvents(AppEvents);
|
this.modules.ctx().emgr.registerEvents(AppEvents);
|
||||||
@@ -87,20 +97,20 @@ export class App {
|
|||||||
//console.log("syncing", syncResult);
|
//console.log("syncing", syncResult);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { guard, server } = this.modules.ctx();
|
||||||
|
|
||||||
// load system controller
|
// load system controller
|
||||||
this.modules.ctx().guard.registerPermissions(Object.values(SystemPermissions));
|
guard.registerPermissions(Object.values(SystemPermissions));
|
||||||
this.modules.server.route("/api/system", new SystemController(this).getController());
|
server.route("/api/system", new SystemController(this).getController());
|
||||||
|
|
||||||
// load plugins
|
// load plugins
|
||||||
if (this.plugins.length > 0) {
|
if (this.plugins.length > 0) {
|
||||||
await Promise.all(this.plugins.map((plugin) => plugin(this)));
|
await Promise.all(this.plugins.map((plugin) => plugin(this)));
|
||||||
}
|
}
|
||||||
|
|
||||||
//console.log("emitting built", options);
|
|
||||||
await this.emgr.emit(new AppBuiltEvent({ app: this }));
|
await this.emgr.emit(new AppBuiltEvent({ app: this }));
|
||||||
|
|
||||||
// not found on any not registered api route
|
server.all("/api/*", async (c) => c.notFound());
|
||||||
this.modules.server.all("/api/*", async (c) => c.notFound());
|
|
||||||
|
|
||||||
if (options?.save) {
|
if (options?.save) {
|
||||||
await this.modules.save();
|
await this.modules.save();
|
||||||
@@ -121,6 +131,10 @@ export class App {
|
|||||||
return this.modules.server;
|
return this.modules.server;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get em() {
|
||||||
|
return this.modules.ctx().em;
|
||||||
|
}
|
||||||
|
|
||||||
get fetch(): any {
|
get fetch(): any {
|
||||||
return this.server.fetch;
|
return this.server.fetch;
|
||||||
}
|
}
|
||||||
@@ -147,7 +161,7 @@ export class App {
|
|||||||
registerAdminController(config?: AdminControllerOptions) {
|
registerAdminController(config?: AdminControllerOptions) {
|
||||||
// register admin
|
// register admin
|
||||||
this.adminController = new AdminController(this, config);
|
this.adminController = new AdminController(this, config);
|
||||||
this.modules.server.route("/", this.adminController.getController());
|
this.modules.server.route(config?.basepath ?? "/", this.adminController.getController());
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,6 +172,10 @@ export class App {
|
|||||||
static create(config: CreateAppConfig) {
|
static create(config: CreateAppConfig) {
|
||||||
return createApp(config);
|
return createApp(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createUser(p: CreateUserPayload) {
|
||||||
|
return this.module.auth.createUser(p);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createApp(config: CreateAppConfig = {}) {
|
export function createApp(config: CreateAppConfig = {}) {
|
||||||
|
|||||||
@@ -11,13 +11,7 @@ let app: App;
|
|||||||
|
|
||||||
export type BunBkndConfig = RuntimeBkndConfig & Omit<ServeOptions, "fetch">;
|
export type BunBkndConfig = RuntimeBkndConfig & Omit<ServeOptions, "fetch">;
|
||||||
|
|
||||||
export async function createApp({
|
export async function createApp({ distPath, ...config }: RuntimeBkndConfig = {}) {
|
||||||
distPath,
|
|
||||||
onBuilt,
|
|
||||||
buildConfig,
|
|
||||||
beforeBuild,
|
|
||||||
...config
|
|
||||||
}: RuntimeBkndConfig = {}) {
|
|
||||||
const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static");
|
const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static");
|
||||||
|
|
||||||
if (!app) {
|
if (!app) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { IncomingMessage } from "node:http";
|
import type { IncomingMessage } from "node:http";
|
||||||
import { App, type CreateAppConfig, registries } from "bknd";
|
import { App, type CreateAppConfig, registries } from "bknd";
|
||||||
|
import { config as $config } from "core";
|
||||||
import type { MiddlewareHandler } from "hono";
|
import type { MiddlewareHandler } from "hono";
|
||||||
import { StorageLocalAdapter } from "media/storage/adapters/StorageLocalAdapter";
|
import { StorageLocalAdapter } from "media/storage/adapters/StorageLocalAdapter";
|
||||||
import type { AdminControllerOptions } from "modules/server/AdminController";
|
import type { AdminControllerOptions } from "modules/server/AdminController";
|
||||||
@@ -106,12 +107,10 @@ export async function createRuntimeApp<Env = any>(
|
|||||||
App.Events.AppBuiltEvent,
|
App.Events.AppBuiltEvent,
|
||||||
async () => {
|
async () => {
|
||||||
if (serveStatic) {
|
if (serveStatic) {
|
||||||
if (Array.isArray(serveStatic)) {
|
const [path, handler] = Array.isArray(serveStatic)
|
||||||
const [path, handler] = serveStatic;
|
? serveStatic
|
||||||
|
: [$config.server.assets_path + "*", serveStatic];
|
||||||
app.modules.server.get(path, handler);
|
app.modules.server.get(path, handler);
|
||||||
} else {
|
|
||||||
app.modules.server.get("/*", serveStatic);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await config.onBuilt?.(app);
|
await config.onBuilt?.(app);
|
||||||
|
|||||||
@@ -19,9 +19,6 @@ export function serve({
|
|||||||
port = $config.server.default_port,
|
port = $config.server.default_port,
|
||||||
hostname,
|
hostname,
|
||||||
listener,
|
listener,
|
||||||
onBuilt,
|
|
||||||
buildConfig = {},
|
|
||||||
beforeBuild,
|
|
||||||
...config
|
...config
|
||||||
}: NodeBkndConfig = {}) {
|
}: NodeBkndConfig = {}) {
|
||||||
const root = path.relative(
|
const root = path.relative(
|
||||||
|
|||||||
14
app/src/adapter/vite/dev-server-config.ts
Normal file
14
app/src/adapter/vite/dev-server-config.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export const devServerConfig = {
|
||||||
|
entry: "./server.ts",
|
||||||
|
exclude: [
|
||||||
|
/.*\.tsx?($|\?)/,
|
||||||
|
/^(?!.*\/__admin).*\.(s?css|less)($|\?)/,
|
||||||
|
// exclude except /api
|
||||||
|
/^(?!.*\/api).*\.(ico|mp4|jpg|jpeg|svg|png|vtt|mp3|js)($|\?)/,
|
||||||
|
/^\/@.+$/,
|
||||||
|
/\/components.*?\.json.*/, // @todo: improve
|
||||||
|
/^\/(public|assets|static)\/.+/,
|
||||||
|
/^\/node_modules\/.*/
|
||||||
|
] as any,
|
||||||
|
injectClientScript: false
|
||||||
|
} as const;
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
import { serveStatic } from "@hono/node-server/serve-static";
|
import { serveStatic } from "@hono/node-server/serve-static";
|
||||||
|
import { type DevServerOptions, default as honoViteDevServer } from "@hono/vite-dev-server";
|
||||||
import { type RuntimeBkndConfig, createRuntimeApp } from "adapter";
|
import { type RuntimeBkndConfig, createRuntimeApp } from "adapter";
|
||||||
import type { App } from "bknd";
|
import type { App } from "bknd";
|
||||||
|
import { devServerConfig } from "./dev-server-config";
|
||||||
|
|
||||||
export type ViteBkndConfig<Env = any> = RuntimeBkndConfig<Env> & {
|
export type ViteBkndConfig<Env = any> = RuntimeBkndConfig<Env> & {
|
||||||
|
mode?: "cached" | "fresh";
|
||||||
setAdminHtml?: boolean;
|
setAdminHtml?: boolean;
|
||||||
forceDev?: boolean;
|
forceDev?: boolean | { mainPath: string };
|
||||||
html?: string;
|
html?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -24,20 +27,27 @@ ${addBkndContext ? "<!-- BKND_CONTEXT -->" : ""}
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createApp(config: ViteBkndConfig, env?: any) {
|
async function createApp(config: ViteBkndConfig = {}, env?: any) {
|
||||||
return await createRuntimeApp(
|
return await createRuntimeApp(
|
||||||
{
|
{
|
||||||
...config,
|
...config,
|
||||||
adminOptions: config.setAdminHtml
|
registerLocalMedia: true,
|
||||||
? { html: config.html, forceDev: config.forceDev }
|
adminOptions:
|
||||||
: undefined,
|
config.setAdminHtml === false
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
html: config.html,
|
||||||
|
forceDev: config.forceDev ?? {
|
||||||
|
mainPath: "/src/main.tsx"
|
||||||
|
}
|
||||||
|
},
|
||||||
serveStatic: ["/assets/*", serveStatic({ root: config.distPath ?? "./" })]
|
serveStatic: ["/assets/*", serveStatic({ root: config.distPath ?? "./" })]
|
||||||
},
|
},
|
||||||
env
|
env
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function serveFresh(config: ViteBkndConfig) {
|
export function serveFresh(config: Omit<ViteBkndConfig, "mode"> = {}) {
|
||||||
return {
|
return {
|
||||||
async fetch(request: Request, env: any, ctx: ExecutionContext) {
|
async fetch(request: Request, env: any, ctx: ExecutionContext) {
|
||||||
const app = await createApp(config, env);
|
const app = await createApp(config, env);
|
||||||
@@ -47,7 +57,7 @@ export async function serveFresh(config: ViteBkndConfig) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let app: App;
|
let app: App;
|
||||||
export async function serveCached(config: ViteBkndConfig) {
|
export function serveCached(config: Omit<ViteBkndConfig, "mode"> = {}) {
|
||||||
return {
|
return {
|
||||||
async fetch(request: Request, env: any, ctx: ExecutionContext) {
|
async fetch(request: Request, env: any, ctx: ExecutionContext) {
|
||||||
if (!app) {
|
if (!app) {
|
||||||
@@ -58,3 +68,14 @@ export async function serveCached(config: ViteBkndConfig) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function serve({ mode, ...config }: ViteBkndConfig = {}) {
|
||||||
|
return mode === "fresh" ? serveFresh(config) : serveCached(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function devServer(options: DevServerOptions) {
|
||||||
|
return honoViteDevServer({
|
||||||
|
...devServerConfig,
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { type AuthAction, Authenticator, type ProfileExchange, Role, type Strategy } from "auth";
|
import { type AuthAction, Authenticator, type ProfileExchange, Role, type Strategy } from "auth";
|
||||||
import type { PasswordStrategy } from "auth/authenticate/strategies";
|
import type { PasswordStrategy } from "auth/authenticate/strategies";
|
||||||
import { Exception, type PrimaryFieldType } from "core";
|
import { auth } from "auth/middlewares";
|
||||||
|
import { type DB, Exception, type PrimaryFieldType } from "core";
|
||||||
import { type Static, secureRandomString, transformObject } from "core/utils";
|
import { type Static, secureRandomString, transformObject } from "core/utils";
|
||||||
import { type Entity, EntityIndex, type EntityManager } from "data";
|
import { type Entity, EntityIndex, type EntityManager } from "data";
|
||||||
import { type FieldSchema, entity, enumm, make, text } from "data/prototype";
|
import { type FieldSchema, em, entity, enumm, make, text } from "data/prototype";
|
||||||
|
import type { Hono } from "hono";
|
||||||
import { pick } from "lodash-es";
|
import { pick } from "lodash-es";
|
||||||
import { Module } from "modules/Module";
|
import { Module } from "modules/Module";
|
||||||
import { AuthController } from "./api/AuthController";
|
import { AuthController } from "./api/AuthController";
|
||||||
@@ -17,6 +19,7 @@ declare module "core" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AuthSchema = Static<typeof authConfigSchema>;
|
type AuthSchema = Static<typeof authConfigSchema>;
|
||||||
|
export type CreateUserPayload = { email: string; password: string; [key: string]: any };
|
||||||
|
|
||||||
export class AppAuth extends Module<typeof authConfigSchema> {
|
export class AppAuth extends Module<typeof authConfigSchema> {
|
||||||
private _authenticator?: Authenticator;
|
private _authenticator?: Authenticator;
|
||||||
@@ -36,8 +39,12 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
|||||||
return to;
|
return to;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get enabled() {
|
||||||
|
return this.config.enabled;
|
||||||
|
}
|
||||||
|
|
||||||
override async build() {
|
override async build() {
|
||||||
if (!this.config.enabled) {
|
if (!this.enabled) {
|
||||||
this.setBuilt();
|
this.setBuilt();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -84,14 +91,6 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
|||||||
return this._controller;
|
return this._controller;
|
||||||
}
|
}
|
||||||
|
|
||||||
getMiddleware() {
|
|
||||||
if (!this.config.enabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new AuthController(this).getMiddleware;
|
|
||||||
}
|
|
||||||
|
|
||||||
getSchema() {
|
getSchema() {
|
||||||
return authConfigSchema;
|
return authConfigSchema;
|
||||||
}
|
}
|
||||||
@@ -111,12 +110,12 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
|||||||
identifier: string,
|
identifier: string,
|
||||||
profile: ProfileExchange
|
profile: ProfileExchange
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
console.log("***** AppAuth:resolveUser", {
|
/*console.log("***** AppAuth:resolveUser", {
|
||||||
action,
|
action,
|
||||||
strategy: strategy.getName(),
|
strategy: strategy.getName(),
|
||||||
identifier,
|
identifier,
|
||||||
profile
|
profile
|
||||||
});
|
});*/
|
||||||
if (!this.config.allow_register && action === "register") {
|
if (!this.config.allow_register && action === "register") {
|
||||||
throw new Exception("Registration is not allowed", 403);
|
throw new Exception("Registration is not allowed", 403);
|
||||||
}
|
}
|
||||||
@@ -137,12 +136,12 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private filterUserData(user: any) {
|
private filterUserData(user: any) {
|
||||||
console.log(
|
/*console.log(
|
||||||
"--filterUserData",
|
"--filterUserData",
|
||||||
user,
|
user,
|
||||||
this.config.jwt.fields,
|
this.config.jwt.fields,
|
||||||
pick(user, this.config.jwt.fields)
|
pick(user, this.config.jwt.fields)
|
||||||
);
|
);*/
|
||||||
return pick(user, this.config.jwt.fields);
|
return pick(user, this.config.jwt.fields);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,18 +167,18 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
|||||||
if (!result.data) {
|
if (!result.data) {
|
||||||
throw new Exception("User not found", 404);
|
throw new Exception("User not found", 404);
|
||||||
}
|
}
|
||||||
console.log("---login data", result.data, result);
|
//console.log("---login data", result.data, result);
|
||||||
|
|
||||||
// compare strategy and identifier
|
// compare strategy and identifier
|
||||||
console.log("strategy comparison", result.data.strategy, strategy.getName());
|
//console.log("strategy comparison", result.data.strategy, strategy.getName());
|
||||||
if (result.data.strategy !== strategy.getName()) {
|
if (result.data.strategy !== strategy.getName()) {
|
||||||
console.log("!!! User registered with different strategy");
|
//console.log("!!! User registered with different strategy");
|
||||||
throw new Exception("User registered with different strategy");
|
throw new Exception("User registered with different strategy");
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("identifier comparison", result.data.strategy_value, identifier);
|
//console.log("identifier comparison", result.data.strategy_value, identifier);
|
||||||
if (result.data.strategy_value !== identifier) {
|
if (result.data.strategy_value !== identifier) {
|
||||||
console.log("!!! Invalid credentials");
|
//console.log("!!! Invalid credentials");
|
||||||
throw new Exception("Invalid credentials");
|
throw new Exception("Invalid credentials");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,51 +246,36 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
registerEntities() {
|
registerEntities() {
|
||||||
const users = this.getUsersEntity();
|
const users = this.getUsersEntity(true);
|
||||||
|
this.ensureSchema(
|
||||||
if (!this.em.hasEntity(users.name)) {
|
em(
|
||||||
this.em.addEntity(users);
|
{
|
||||||
} else {
|
[users.name as "users"]: users
|
||||||
// if exists, check all fields required are there
|
},
|
||||||
// @todo: add to context: "needs sync" flag
|
({ index }, { users }) => {
|
||||||
const _entity = this.getUsersEntity(true);
|
index(users).on(["email"], true).on(["strategy"]).on(["strategy_value"]);
|
||||||
for (const field of _entity.fields) {
|
|
||||||
const _field = users.field(field.name);
|
|
||||||
if (!_field) {
|
|
||||||
users.addField(field);
|
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
}
|
);
|
||||||
|
|
||||||
const indices = [
|
|
||||||
new EntityIndex(users, [users.field("email")!], true),
|
|
||||||
new EntityIndex(users, [users.field("strategy")!]),
|
|
||||||
new EntityIndex(users, [users.field("strategy_value")!])
|
|
||||||
];
|
|
||||||
indices.forEach((index) => {
|
|
||||||
if (!this.em.hasIndex(index)) {
|
|
||||||
this.em.addIndex(index);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const roles = Object.keys(this.config.roles ?? {});
|
const roles = Object.keys(this.config.roles ?? {});
|
||||||
const field = make("role", enumm({ enum: roles }));
|
const field = make("role", enumm({ enum: roles }));
|
||||||
this.em.entity(users.name).__experimental_replaceField("role", field);
|
users.__replaceField("role", field);
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const strategies = Object.keys(this.config.strategies ?? {});
|
const strategies = Object.keys(this.config.strategies ?? {});
|
||||||
const field = make("strategy", enumm({ enum: strategies }));
|
const field = make("strategy", enumm({ enum: strategies }));
|
||||||
this.em.entity(users.name).__experimental_replaceField("strategy", field);
|
users.__replaceField("strategy", field);
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
async createUser({
|
async createUser({ email, password, ...additional }: CreateUserPayload): Promise<DB["users"]> {
|
||||||
email,
|
if (!this.enabled) {
|
||||||
password,
|
throw new Error("Cannot create user, auth not enabled");
|
||||||
...additional
|
}
|
||||||
}: { email: string; password: string; [key: string]: any }) {
|
|
||||||
const strategy = "password";
|
const strategy = "password";
|
||||||
const pw = this.authenticator.strategy(strategy) as PasswordStrategy;
|
const pw = this.authenticator.strategy(strategy) as PasswordStrategy;
|
||||||
const strategy_value = await pw.hash(password);
|
const strategy_value = await pw.hash(password);
|
||||||
|
|||||||
@@ -1,42 +1,18 @@
|
|||||||
import type { AppAuth } from "auth";
|
import type { AppAuth } from "auth";
|
||||||
import { type ClassController, isDebug } from "core";
|
import { Controller } from "modules/Controller";
|
||||||
import { Hono, type MiddlewareHandler } from "hono";
|
|
||||||
|
|
||||||
export class AuthController implements ClassController {
|
export class AuthController extends Controller {
|
||||||
constructor(private auth: AppAuth) {}
|
constructor(private auth: AppAuth) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
get guard() {
|
get guard() {
|
||||||
return this.auth.ctx.guard;
|
return this.auth.ctx.guard;
|
||||||
}
|
}
|
||||||
|
|
||||||
getMiddleware: MiddlewareHandler = async (c, next) => {
|
override getController() {
|
||||||
// @todo: ONLY HOTFIX
|
const { auth } = this.middlewares;
|
||||||
// middlewares are added for all routes are registered. But we need to make sure that
|
const hono = this.create();
|
||||||
// only HTML/JSON routes are adding a cookie to the response. Config updates might
|
|
||||||
// also use an extension "syntax", e.g. /api/system/patch/data/entities.posts
|
|
||||||
// This middleware should be extracted and added by each Controller individually,
|
|
||||||
// but it requires access to the auth secret.
|
|
||||||
// Note: This doesn't mean endpoints aren't protected, just the cookie is not set.
|
|
||||||
const url = new URL(c.req.url);
|
|
||||||
const last = url.pathname.split("/")?.pop();
|
|
||||||
const ext = last?.includes(".") ? last.split(".")?.pop() : undefined;
|
|
||||||
if (
|
|
||||||
!this.auth.authenticator.isJsonRequest(c) &&
|
|
||||||
["GET", "HEAD", "OPTIONS"].includes(c.req.method) &&
|
|
||||||
ext &&
|
|
||||||
["js", "css", "png", "jpg", "jpeg", "svg", "ico"].includes(ext)
|
|
||||||
) {
|
|
||||||
isDebug() && console.log("Skipping auth", { ext }, url.pathname);
|
|
||||||
} else {
|
|
||||||
const user = await this.auth.authenticator.resolveAuthFromRequest(c);
|
|
||||||
this.auth.ctx.guard.setUserContext(user);
|
|
||||||
}
|
|
||||||
|
|
||||||
await next();
|
|
||||||
};
|
|
||||||
|
|
||||||
getController(): Hono<any> {
|
|
||||||
const hono = new Hono();
|
|
||||||
const strategies = this.auth.authenticator.getStrategies();
|
const strategies = this.auth.authenticator.getStrategies();
|
||||||
|
|
||||||
for (const [name, strategy] of Object.entries(strategies)) {
|
for (const [name, strategy] of Object.entries(strategies)) {
|
||||||
@@ -44,7 +20,7 @@ export class AuthController implements ClassController {
|
|||||||
hono.route(`/${name}`, strategy.getController(this.auth.authenticator));
|
hono.route(`/${name}`, strategy.getController(this.auth.authenticator));
|
||||||
}
|
}
|
||||||
|
|
||||||
hono.get("/me", async (c) => {
|
hono.get("/me", auth(), async (c) => {
|
||||||
if (this.auth.authenticator.isUserLoggedIn()) {
|
if (this.auth.authenticator.isUserLoggedIn()) {
|
||||||
return c.json({ user: await this.auth.authenticator.getUser() });
|
return c.json({ user: await this.auth.authenticator.getUser() });
|
||||||
}
|
}
|
||||||
@@ -52,7 +28,7 @@ export class AuthController implements ClassController {
|
|||||||
return c.json({ user: null }, 403);
|
return c.json({ user: null }, 403);
|
||||||
});
|
});
|
||||||
|
|
||||||
hono.get("/logout", async (c) => {
|
hono.get("/logout", auth(), async (c) => {
|
||||||
await this.auth.authenticator.logout(c);
|
await this.auth.authenticator.logout(c);
|
||||||
if (this.auth.authenticator.isJsonRequest(c)) {
|
if (this.auth.authenticator.isJsonRequest(c)) {
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const strategiesSchemaObject = objectTransform(STRATEGIES, (strategy, name) => {
|
|||||||
const strategiesSchema = Type.Union(Object.values(strategiesSchemaObject));
|
const strategiesSchema = Type.Union(Object.values(strategiesSchemaObject));
|
||||||
export type AppAuthStrategies = Static<typeof strategiesSchema>;
|
export type AppAuthStrategies = Static<typeof strategiesSchema>;
|
||||||
export type AppAuthOAuthStrategy = Static<typeof STRATEGIES.oauth.schema>;
|
export type AppAuthOAuthStrategy = Static<typeof STRATEGIES.oauth.schema>;
|
||||||
|
export type AppAuthCustomOAuthStrategy = Static<typeof STRATEGIES.custom_oauth.schema>;
|
||||||
|
|
||||||
const guardConfigSchema = Type.Object({
|
const guardConfigSchema = Type.Object({
|
||||||
enabled: Type.Optional(Type.Boolean({ default: false }))
|
enabled: Type.Optional(Type.Boolean({ default: false }))
|
||||||
|
|||||||
@@ -1,19 +1,11 @@
|
|||||||
import { Exception } from "core";
|
import { Exception } from "core";
|
||||||
import { addFlashMessage } from "core/server/flash";
|
import { addFlashMessage } from "core/server/flash";
|
||||||
import {
|
import { type Static, StringEnum, Type, parse, runtimeSupports, transformObject } from "core/utils";
|
||||||
type Static,
|
|
||||||
StringEnum,
|
|
||||||
type TSchema,
|
|
||||||
Type,
|
|
||||||
parse,
|
|
||||||
randomString,
|
|
||||||
transformObject
|
|
||||||
} from "core/utils";
|
|
||||||
import type { Context, Hono } from "hono";
|
import type { Context, Hono } from "hono";
|
||||||
import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie";
|
import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie";
|
||||||
import { sign, verify } from "hono/jwt";
|
import { sign, verify } from "hono/jwt";
|
||||||
import type { CookieOptions } from "hono/utils/cookie";
|
import type { CookieOptions } from "hono/utils/cookie";
|
||||||
import { omit } from "lodash-es";
|
import type { ServerEnv } from "modules/Module";
|
||||||
|
|
||||||
type Input = any; // workaround
|
type Input = any; // workaround
|
||||||
export type JWTPayload = Parameters<typeof sign>[0];
|
export type JWTPayload = Parameters<typeof sign>[0];
|
||||||
@@ -67,6 +59,9 @@ export const cookieConfig = Type.Partial(
|
|||||||
{ default: {}, additionalProperties: false }
|
{ default: {}, additionalProperties: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// @todo: maybe add a config to not allow cookie/api tokens to be used interchangably?
|
||||||
|
// see auth.integration test for further details
|
||||||
|
|
||||||
export const jwtConfig = Type.Object(
|
export const jwtConfig = Type.Object(
|
||||||
{
|
{
|
||||||
// @todo: autogenerate a secret if not present. But it must be persisted from AppAuth
|
// @todo: autogenerate a secret if not present. But it must be persisted from AppAuth
|
||||||
@@ -98,7 +93,13 @@ export type AuthUserResolver = (
|
|||||||
export class Authenticator<Strategies extends Record<string, Strategy> = Record<string, Strategy>> {
|
export class Authenticator<Strategies extends Record<string, Strategy> = Record<string, Strategy>> {
|
||||||
private readonly strategies: Strategies;
|
private readonly strategies: Strategies;
|
||||||
private readonly config: AuthConfig;
|
private readonly config: AuthConfig;
|
||||||
private _user: SafeUser | undefined;
|
private _claims:
|
||||||
|
| undefined
|
||||||
|
| (SafeUser & {
|
||||||
|
iat: number;
|
||||||
|
iss?: string;
|
||||||
|
exp?: number;
|
||||||
|
});
|
||||||
private readonly userResolver: AuthUserResolver;
|
private readonly userResolver: AuthUserResolver;
|
||||||
|
|
||||||
constructor(strategies: Strategies, userResolver?: AuthUserResolver, config?: AuthConfig) {
|
constructor(strategies: Strategies, userResolver?: AuthUserResolver, config?: AuthConfig) {
|
||||||
@@ -131,16 +132,18 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
|||||||
}
|
}
|
||||||
|
|
||||||
isUserLoggedIn(): boolean {
|
isUserLoggedIn(): boolean {
|
||||||
return this._user !== undefined;
|
return this._claims !== undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
getUser() {
|
getUser(): SafeUser | undefined {
|
||||||
return this._user;
|
if (!this._claims) return;
|
||||||
|
|
||||||
|
const { iat, exp, iss, ...user } = this._claims;
|
||||||
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
// @todo: determine what to do exactly
|
resetUser() {
|
||||||
__setUserNull() {
|
this._claims = undefined;
|
||||||
this._user = undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
strategy<
|
strategy<
|
||||||
@@ -154,6 +157,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// @todo: add jwt tests
|
||||||
async jwt(user: Omit<User, "password">): Promise<string> {
|
async jwt(user: Omit<User, "password">): Promise<string> {
|
||||||
const prohibited = ["password"];
|
const prohibited = ["password"];
|
||||||
for (const prop of prohibited) {
|
for (const prop of prohibited) {
|
||||||
@@ -200,11 +204,11 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this._user = omit(payload, ["iat", "exp", "iss"]) as SafeUser;
|
this._claims = payload as any;
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this._user = undefined;
|
this.resetUser();
|
||||||
console.error(e);
|
//console.error(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -222,10 +226,8 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
|||||||
private async getAuthCookie(c: Context): Promise<string | undefined> {
|
private async getAuthCookie(c: Context): Promise<string | undefined> {
|
||||||
try {
|
try {
|
||||||
const secret = this.config.jwt.secret;
|
const secret = this.config.jwt.secret;
|
||||||
|
|
||||||
const token = await getSignedCookie(c, secret, "auth");
|
const token = await getSignedCookie(c, secret, "auth");
|
||||||
if (typeof token !== "string") {
|
if (typeof token !== "string") {
|
||||||
await deleteCookie(c, "auth", this.cookieOptions);
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,23 +245,27 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
|||||||
if (this.config.cookie.renew) {
|
if (this.config.cookie.renew) {
|
||||||
const token = await this.getAuthCookie(c);
|
const token = await this.getAuthCookie(c);
|
||||||
if (token) {
|
if (token) {
|
||||||
console.log("renewing cookie", c.req.url);
|
|
||||||
await this.setAuthCookie(c, token);
|
await this.setAuthCookie(c, token);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async setAuthCookie(c: Context, token: string) {
|
private async setAuthCookie(c: Context<ServerEnv>, token: string) {
|
||||||
const secret = this.config.jwt.secret;
|
const secret = this.config.jwt.secret;
|
||||||
await setSignedCookie(c, "auth", token, secret, this.cookieOptions);
|
await setSignedCookie(c, "auth", token, secret, this.cookieOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async deleteAuthCookie(c: Context) {
|
||||||
|
await deleteCookie(c, "auth", this.cookieOptions);
|
||||||
|
}
|
||||||
|
|
||||||
async logout(c: Context) {
|
async logout(c: Context) {
|
||||||
const cookie = await this.getAuthCookie(c);
|
const cookie = await this.getAuthCookie(c);
|
||||||
if (cookie) {
|
if (cookie) {
|
||||||
await deleteCookie(c, "auth", this.cookieOptions);
|
await this.deleteAuthCookie(c);
|
||||||
await addFlashMessage(c, "Signed out", "info");
|
await addFlashMessage(c, "Signed out", "info");
|
||||||
}
|
}
|
||||||
|
this.resetUser();
|
||||||
}
|
}
|
||||||
|
|
||||||
// @todo: move this to a server helper
|
// @todo: move this to a server helper
|
||||||
@@ -268,18 +274,31 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
|||||||
return c.req.header("Content-Type") === "application/json";
|
return c.req.header("Content-Type") === "application/json";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getSuccessPath(c: Context) {
|
||||||
|
const p = (this.config.cookie.pathSuccess ?? "/").replace(/\/+$/, "/");
|
||||||
|
|
||||||
|
// nextjs doesn't support non-fq urls
|
||||||
|
// but env could be proxied (stackblitz), so we shouldn't fq every url
|
||||||
|
if (!runtimeSupports("redirects_non_fq")) {
|
||||||
|
return new URL(c.req.url).origin + p;
|
||||||
|
}
|
||||||
|
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
async respond(c: Context, data: AuthResponse | Error | any, redirect?: string) {
|
async respond(c: Context, data: AuthResponse | Error | any, redirect?: string) {
|
||||||
if (this.isJsonRequest(c)) {
|
if (this.isJsonRequest(c)) {
|
||||||
return c.json(data);
|
return c.json(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
const successPath = this.config.cookie.pathSuccess ?? "/";
|
const successUrl = this.getSuccessPath(c);
|
||||||
const successUrl = new URL(c.req.url).origin + successPath.replace(/\/+$/, "/");
|
const referer = redirect ?? c.req.header("Referer") ?? successUrl;
|
||||||
const referer = new URL(redirect ?? c.req.header("Referer") ?? successUrl);
|
//console.log("auth respond", { redirect, successUrl, successPath });
|
||||||
|
|
||||||
if ("token" in data) {
|
if ("token" in data) {
|
||||||
await this.setAuthCookie(c, data.token);
|
await this.setAuthCookie(c, data.token);
|
||||||
// can't navigate to "/" – doesn't work on nextjs
|
// can't navigate to "/" – doesn't work on nextjs
|
||||||
|
//console.log("auth success, redirecting to", successUrl);
|
||||||
return c.redirect(successUrl);
|
return c.redirect(successUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,6 +308,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
|||||||
}
|
}
|
||||||
|
|
||||||
await addFlashMessage(c, message, "error");
|
await addFlashMessage(c, message, "error");
|
||||||
|
//console.log("auth failed, redirecting to", referer);
|
||||||
return c.redirect(referer);
|
return c.redirect(referer);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,7 +324,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
|||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
await this.verify(token);
|
await this.verify(token);
|
||||||
return this._user;
|
return this.getUser();
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
@@ -98,12 +98,16 @@ export class Guard {
|
|||||||
if (this.user && typeof this.user.role === "string") {
|
if (this.user && typeof this.user.role === "string") {
|
||||||
const role = this.roles?.find((role) => role.name === this.user?.role);
|
const role = this.roles?.find((role) => role.name === this.user?.role);
|
||||||
if (role) {
|
if (role) {
|
||||||
debug && console.log("guard: role found", this.user.role);
|
debug && console.log("guard: role found", [this.user.role]);
|
||||||
return role;
|
return role;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
debug && console.log("guard: role not found", this.user, this.user?.role);
|
debug &&
|
||||||
|
console.log("guard: role not found", {
|
||||||
|
user: this.user,
|
||||||
|
role: this.user?.role
|
||||||
|
});
|
||||||
return this.getDefaultRole();
|
return this.getDefaultRole();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
105
app/src/auth/middlewares.ts
Normal file
105
app/src/auth/middlewares.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import type { Permission } from "core";
|
||||||
|
import { patternMatch } from "core/utils";
|
||||||
|
import type { Context } from "hono";
|
||||||
|
import { createMiddleware } from "hono/factory";
|
||||||
|
import type { ServerEnv } from "modules/Module";
|
||||||
|
|
||||||
|
function getPath(reqOrCtx: Request | Context) {
|
||||||
|
const req = reqOrCtx instanceof Request ? reqOrCtx : reqOrCtx.req.raw;
|
||||||
|
return new URL(req.url).pathname;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldSkip(c: Context<ServerEnv>, skip?: (string | RegExp)[]) {
|
||||||
|
if (c.get("auth_skip")) return true;
|
||||||
|
|
||||||
|
const req = c.req.raw;
|
||||||
|
if (!skip) return false;
|
||||||
|
|
||||||
|
const path = getPath(req);
|
||||||
|
const result = skip.some((s) => patternMatch(path, s));
|
||||||
|
|
||||||
|
c.set("auth_skip", result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const auth = (options?: {
|
||||||
|
skip?: (string | RegExp)[];
|
||||||
|
}) =>
|
||||||
|
createMiddleware<ServerEnv>(async (c, next) => {
|
||||||
|
// make sure to only register once
|
||||||
|
if (c.get("auth_registered")) {
|
||||||
|
throw new Error(`auth middleware already registered for ${getPath(c)}`);
|
||||||
|
}
|
||||||
|
c.set("auth_registered", true);
|
||||||
|
|
||||||
|
const app = c.get("app");
|
||||||
|
const skipped = shouldSkip(c, options?.skip) || !app?.module.auth.enabled;
|
||||||
|
const guard = app?.modules.ctx().guard;
|
||||||
|
const authenticator = app?.module.auth.authenticator;
|
||||||
|
|
||||||
|
if (!skipped) {
|
||||||
|
const resolved = c.get("auth_resolved");
|
||||||
|
if (!resolved) {
|
||||||
|
if (!app.module.auth.enabled) {
|
||||||
|
guard?.setUserContext(undefined);
|
||||||
|
} else {
|
||||||
|
guard?.setUserContext(await authenticator?.resolveAuthFromRequest(c));
|
||||||
|
c.set("auth_resolved", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await next();
|
||||||
|
|
||||||
|
if (!skipped) {
|
||||||
|
// renew cookie if applicable
|
||||||
|
authenticator?.requestCookieRefresh(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// release
|
||||||
|
guard?.setUserContext(undefined);
|
||||||
|
authenticator?.resetUser();
|
||||||
|
c.set("auth_resolved", false);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const permission = (
|
||||||
|
permission: Permission | Permission[],
|
||||||
|
options?: {
|
||||||
|
onGranted?: (c: Context<ServerEnv>) => Promise<Response | void | undefined>;
|
||||||
|
onDenied?: (c: Context<ServerEnv>) => Promise<Response | void | undefined>;
|
||||||
|
}
|
||||||
|
) =>
|
||||||
|
// @ts-ignore
|
||||||
|
createMiddleware<ServerEnv>(async (c, next) => {
|
||||||
|
const app = c.get("app");
|
||||||
|
//console.log("skip?", c.get("auth_skip"));
|
||||||
|
|
||||||
|
// in tests, app is not defined
|
||||||
|
if (!c.get("auth_registered") || !app) {
|
||||||
|
const msg = `auth middleware not registered, cannot check permissions for ${getPath(c)}`;
|
||||||
|
if (app?.module.auth.enabled) {
|
||||||
|
throw new Error(msg);
|
||||||
|
} else {
|
||||||
|
console.warn(msg);
|
||||||
|
}
|
||||||
|
} else if (!c.get("auth_skip")) {
|
||||||
|
const guard = app.modules.ctx().guard;
|
||||||
|
const permissions = Array.isArray(permission) ? permission : [permission];
|
||||||
|
|
||||||
|
if (options?.onGranted || options?.onDenied) {
|
||||||
|
let returned: undefined | void | Response;
|
||||||
|
if (permissions.every((p) => guard.granted(p))) {
|
||||||
|
returned = await options?.onGranted?.(c);
|
||||||
|
} else {
|
||||||
|
returned = await options?.onDenied?.(c);
|
||||||
|
}
|
||||||
|
if (returned instanceof Response) {
|
||||||
|
return returned;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
permissions.some((p) => guard.throwUnlessGranted(p));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await next();
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { Config } from "@libsql/client/node";
|
import type { Config } from "@libsql/client/node";
|
||||||
|
import { config } from "core";
|
||||||
import type { MiddlewareHandler } from "hono";
|
import type { MiddlewareHandler } from "hono";
|
||||||
import open from "open";
|
import open from "open";
|
||||||
import { fileExists, getRelativeDistPath } from "../../utils/sys";
|
import { fileExists, getRelativeDistPath } from "../../utils/sys";
|
||||||
@@ -26,7 +27,7 @@ export async function serveStatic(server: Platform): Promise<MiddlewareHandler>
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function attachServeStatic(app: any, platform: Platform) {
|
export async function attachServeStatic(app: any, platform: Platform) {
|
||||||
app.module.server.client.get("/*", await serveStatic(platform));
|
app.module.server.client.get(config.server.assets_path + "*", await serveStatic(platform));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startServer(server: Platform, app: any, options: { port: number }) {
|
export async function startServer(server: Platform, app: any, options: { port: number }) {
|
||||||
|
|||||||
@@ -35,9 +35,11 @@ async function action(action: "create" | "update", options: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function create(app: App, options: any) {
|
async function create(app: App, options: any) {
|
||||||
const config = app.module.auth.toJSON(true);
|
|
||||||
const strategy = app.module.auth.authenticator.strategy("password") as PasswordStrategy;
|
const strategy = app.module.auth.authenticator.strategy("password") as PasswordStrategy;
|
||||||
const users_entity = config.entity_name as "users";
|
|
||||||
|
if (!strategy) {
|
||||||
|
throw new Error("Password strategy not configured");
|
||||||
|
}
|
||||||
|
|
||||||
const email = await $text({
|
const email = await $text({
|
||||||
message: "Enter email",
|
message: "Enter email",
|
||||||
@@ -65,16 +67,11 @@ async function create(app: App, options: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const mutator = app.modules.ctx().em.mutator(users_entity);
|
const created = await app.createUser({
|
||||||
mutator.__unstable_toggleSystemEntityCreation(false);
|
|
||||||
const res = await mutator.insertOne({
|
|
||||||
email,
|
email,
|
||||||
strategy: "password",
|
password: await strategy.hash(password as string)
|
||||||
strategy_value: await strategy.hash(password as string)
|
})
|
||||||
});
|
console.log("Created:", created);
|
||||||
mutator.__unstable_toggleSystemEntityCreation(true);
|
|
||||||
|
|
||||||
console.log("Created:", res.data);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Error", e);
|
console.error("Error", e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ export interface DB {}
|
|||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
server: {
|
server: {
|
||||||
default_port: 1337
|
default_port: 1337,
|
||||||
|
// resetted to root for now, bc bundling with vite
|
||||||
|
assets_path: "/"
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
default_primary_field: "id"
|
default_primary_field: "id"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export class Exception extends Error {
|
export class Exception extends Error {
|
||||||
code = 400;
|
code = 400;
|
||||||
override name = "Exception";
|
override name = "Exception";
|
||||||
|
protected _context = undefined;
|
||||||
|
|
||||||
constructor(message: string, code?: number) {
|
constructor(message: string, code?: number) {
|
||||||
super(message);
|
super(message);
|
||||||
@@ -9,11 +10,16 @@ export class Exception extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
context(context: any) {
|
||||||
|
this._context = context;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
toJSON() {
|
toJSON() {
|
||||||
return {
|
return {
|
||||||
error: this.message,
|
error: this.message,
|
||||||
type: this.name
|
type: this.name,
|
||||||
//message: this.message
|
context: this._context
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,15 +4,13 @@ import { setCookie } from "hono/cookie";
|
|||||||
const flash_key = "__bknd_flash";
|
const flash_key = "__bknd_flash";
|
||||||
export type FlashMessageType = "error" | "warning" | "success" | "info";
|
export type FlashMessageType = "error" | "warning" | "success" | "info";
|
||||||
|
|
||||||
export async function addFlashMessage(
|
export function addFlashMessage(c: Context, message: string, type: FlashMessageType = "info") {
|
||||||
c: Context,
|
if (c.req.header("Accept")?.includes("text/html")) {
|
||||||
message: string,
|
|
||||||
type: FlashMessageType = "info"
|
|
||||||
) {
|
|
||||||
setCookie(c, flash_key, JSON.stringify({ type, message }), {
|
setCookie(c, flash_key, JSON.stringify({ type, message }), {
|
||||||
path: "/"
|
path: "/"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getCookieValue(name) {
|
function getCookieValue(name) {
|
||||||
const cookies = document.cookie.split("; ");
|
const cookies = document.cookie.split("; ");
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ export * from "./crypto";
|
|||||||
export * from "./uuid";
|
export * from "./uuid";
|
||||||
export { FromSchema } from "./typebox/from-schema";
|
export { FromSchema } from "./typebox/from-schema";
|
||||||
export * from "./test";
|
export * from "./test";
|
||||||
|
export * from "./runtime";
|
||||||
|
|||||||
41
app/src/core/utils/runtime.ts
Normal file
41
app/src/core/utils/runtime.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { getRuntimeKey as honoGetRuntimeKey } from "hono/adapter";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds additional checks for nextjs
|
||||||
|
*/
|
||||||
|
export function getRuntimeKey(): string {
|
||||||
|
const global = globalThis as any;
|
||||||
|
|
||||||
|
// Detect Next.js server-side runtime
|
||||||
|
if (global?.process?.env?.NEXT_RUNTIME === "nodejs") {
|
||||||
|
return "nextjs";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect Next.js edge runtime
|
||||||
|
if (global?.process?.env?.NEXT_RUNTIME === "edge") {
|
||||||
|
return "nextjs-edge";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect Next.js client-side runtime
|
||||||
|
// @ts-ignore
|
||||||
|
if (typeof window !== "undefined" && window.__NEXT_DATA__) {
|
||||||
|
return "nextjs-client";
|
||||||
|
}
|
||||||
|
|
||||||
|
return honoGetRuntimeKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
const features = {
|
||||||
|
// supports the redirect of not full qualified addresses
|
||||||
|
// not supported in nextjs
|
||||||
|
redirects_non_fq: true
|
||||||
|
};
|
||||||
|
|
||||||
|
export function runtimeSupports(feature: keyof typeof features) {
|
||||||
|
const runtime = getRuntimeKey();
|
||||||
|
if (runtime.startsWith("nextjs")) {
|
||||||
|
features.redirects_non_fq = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return features[feature];
|
||||||
|
}
|
||||||
@@ -104,3 +104,14 @@ export function replaceSimplePlaceholders(str: string, vars: Record<string, any>
|
|||||||
return key in vars ? vars[key] : match;
|
return key in vars ? vars[key] : match;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function patternMatch(target: string, pattern: RegExp | string): boolean {
|
||||||
|
if (pattern instanceof RegExp) {
|
||||||
|
return pattern.test(target);
|
||||||
|
} else if (typeof pattern === "string" && pattern.startsWith("/")) {
|
||||||
|
return new RegExp(pattern).test(target);
|
||||||
|
} else if (typeof pattern === "string") {
|
||||||
|
return target.startsWith(pattern);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const _oldConsoles = {
|
|||||||
|
|
||||||
export async function withDisabledConsole<R>(
|
export async function withDisabledConsole<R>(
|
||||||
fn: () => Promise<R>,
|
fn: () => Promise<R>,
|
||||||
severities: ConsoleSeverity[] = ["log"]
|
severities: ConsoleSeverity[] = ["log", "warn", "error"]
|
||||||
): Promise<R> {
|
): Promise<R> {
|
||||||
const _oldConsoles = {
|
const _oldConsoles = {
|
||||||
log: console.log,
|
log: console.log,
|
||||||
@@ -30,7 +30,7 @@ export async function withDisabledConsole<R>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function disableConsoleLog(severities: ConsoleSeverity[] = ["log"]) {
|
export function disableConsoleLog(severities: ConsoleSeverity[] = ["log", "warn"]) {
|
||||||
severities.forEach((severity) => {
|
severities.forEach((severity) => {
|
||||||
console[severity] = () => null;
|
console[severity] = () => null;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,32 +1,26 @@
|
|||||||
import { type ClassController, isDebug, tbValidator as tb } from "core";
|
import { isDebug, tbValidator as tb } from "core";
|
||||||
import { StringEnum, Type, objectCleanEmpty, objectTransform } from "core/utils";
|
import { StringEnum, Type } from "core/utils";
|
||||||
import {
|
import {
|
||||||
DataPermissions,
|
DataPermissions,
|
||||||
type EntityData,
|
type EntityData,
|
||||||
type EntityManager,
|
type EntityManager,
|
||||||
FieldClassMap,
|
|
||||||
type MutatorResponse,
|
type MutatorResponse,
|
||||||
PrimaryField,
|
|
||||||
type RepoQuery,
|
type RepoQuery,
|
||||||
type RepositoryResponse,
|
type RepositoryResponse,
|
||||||
TextField,
|
|
||||||
querySchema
|
querySchema
|
||||||
} from "data";
|
} from "data";
|
||||||
import { Hono } from "hono";
|
|
||||||
import type { Handler } from "hono/types";
|
import type { Handler } from "hono/types";
|
||||||
import type { ModuleBuildContext } from "modules";
|
import type { ModuleBuildContext } from "modules";
|
||||||
|
import { Controller } from "modules/Controller";
|
||||||
import * as SystemPermissions from "modules/permissions";
|
import * as SystemPermissions from "modules/permissions";
|
||||||
import { type AppDataConfig, FIELDS } from "../data-schema";
|
import type { AppDataConfig } from "../data-schema";
|
||||||
|
|
||||||
export class DataController implements ClassController {
|
export class DataController extends Controller {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly ctx: ModuleBuildContext,
|
private readonly ctx: ModuleBuildContext,
|
||||||
private readonly config: AppDataConfig
|
private readonly config: AppDataConfig
|
||||||
) {
|
) {
|
||||||
/*console.log(
|
super();
|
||||||
"data controller",
|
|
||||||
this.em.entities.map((e) => e.name)
|
|
||||||
);*/
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get em(): EntityManager<any> {
|
get em(): EntityManager<any> {
|
||||||
@@ -74,8 +68,10 @@ export class DataController implements ClassController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getController(): Hono<any> {
|
override getController() {
|
||||||
const hono = new Hono();
|
const { permission, auth } = this.middlewares;
|
||||||
|
const hono = this.create().use(auth());
|
||||||
|
|
||||||
const definedEntities = this.em.entities.map((e) => e.name);
|
const definedEntities = this.em.entities.map((e) => e.name);
|
||||||
const tbNumber = Type.Transform(Type.String({ pattern: "^[1-9][0-9]{0,}$" }))
|
const tbNumber = Type.Transform(Type.String({ pattern: "^[1-9][0-9]{0,}$" }))
|
||||||
.Decode(Number.parseInt)
|
.Decode(Number.parseInt)
|
||||||
@@ -89,10 +85,7 @@ export class DataController implements ClassController {
|
|||||||
return func;
|
return func;
|
||||||
}
|
}
|
||||||
|
|
||||||
hono.use("*", async (c, next) => {
|
hono.use("*", permission(SystemPermissions.accessApi));
|
||||||
this.ctx.guard.throwUnlessGranted(SystemPermissions.accessApi);
|
|
||||||
await next();
|
|
||||||
});
|
|
||||||
|
|
||||||
// info
|
// info
|
||||||
hono.get(
|
hono.get(
|
||||||
@@ -104,9 +97,7 @@ export class DataController implements ClassController {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// sync endpoint
|
// sync endpoint
|
||||||
hono.get("/sync", async (c) => {
|
hono.get("/sync", permission(DataPermissions.databaseSync), async (c) => {
|
||||||
this.guard.throwUnlessGranted(DataPermissions.databaseSync);
|
|
||||||
|
|
||||||
const force = c.req.query("force") === "1";
|
const force = c.req.query("force") === "1";
|
||||||
const drop = c.req.query("drop") === "1";
|
const drop = c.req.query("drop") === "1";
|
||||||
//console.log("force", force);
|
//console.log("force", force);
|
||||||
@@ -126,10 +117,9 @@ export class DataController implements ClassController {
|
|||||||
// fn: count
|
// fn: count
|
||||||
.post(
|
.post(
|
||||||
"/:entity/fn/count",
|
"/:entity/fn/count",
|
||||||
|
permission(DataPermissions.entityRead),
|
||||||
tb("param", Type.Object({ entity: Type.String() })),
|
tb("param", Type.Object({ entity: Type.String() })),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
this.guard.throwUnlessGranted(DataPermissions.entityRead);
|
|
||||||
|
|
||||||
const { entity } = c.req.valid("param");
|
const { entity } = c.req.valid("param");
|
||||||
if (!this.entityExists(entity)) {
|
if (!this.entityExists(entity)) {
|
||||||
return c.notFound();
|
return c.notFound();
|
||||||
@@ -143,10 +133,9 @@ export class DataController implements ClassController {
|
|||||||
// fn: exists
|
// fn: exists
|
||||||
.post(
|
.post(
|
||||||
"/:entity/fn/exists",
|
"/:entity/fn/exists",
|
||||||
|
permission(DataPermissions.entityRead),
|
||||||
tb("param", Type.Object({ entity: Type.String() })),
|
tb("param", Type.Object({ entity: Type.String() })),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
this.guard.throwUnlessGranted(DataPermissions.entityRead);
|
|
||||||
|
|
||||||
const { entity } = c.req.valid("param");
|
const { entity } = c.req.valid("param");
|
||||||
if (!this.entityExists(entity)) {
|
if (!this.entityExists(entity)) {
|
||||||
return c.notFound();
|
return c.notFound();
|
||||||
@@ -163,8 +152,7 @@ export class DataController implements ClassController {
|
|||||||
*/
|
*/
|
||||||
hono
|
hono
|
||||||
// read entity schema
|
// read entity schema
|
||||||
.get("/schema.json", async (c) => {
|
.get("/schema.json", permission(DataPermissions.entityRead), async (c) => {
|
||||||
this.guard.throwUnlessGranted(DataPermissions.entityRead);
|
|
||||||
const $id = `${this.config.basepath}/schema.json`;
|
const $id = `${this.config.basepath}/schema.json`;
|
||||||
const schemas = Object.fromEntries(
|
const schemas = Object.fromEntries(
|
||||||
this.em.entities.map((e) => [
|
this.em.entities.map((e) => [
|
||||||
@@ -183,6 +171,7 @@ export class DataController implements ClassController {
|
|||||||
// read schema
|
// read schema
|
||||||
.get(
|
.get(
|
||||||
"/schemas/:entity/:context?",
|
"/schemas/:entity/:context?",
|
||||||
|
permission(DataPermissions.entityRead),
|
||||||
tb(
|
tb(
|
||||||
"param",
|
"param",
|
||||||
Type.Object({
|
Type.Object({
|
||||||
@@ -191,8 +180,6 @@ export class DataController implements ClassController {
|
|||||||
})
|
})
|
||||||
),
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
this.guard.throwUnlessGranted(DataPermissions.entityRead);
|
|
||||||
|
|
||||||
//console.log("request", c.req.raw);
|
//console.log("request", c.req.raw);
|
||||||
const { entity, context } = c.req.param();
|
const { entity, context } = c.req.param();
|
||||||
if (!this.entityExists(entity)) {
|
if (!this.entityExists(entity)) {
|
||||||
@@ -216,11 +203,10 @@ export class DataController implements ClassController {
|
|||||||
// read many
|
// read many
|
||||||
.get(
|
.get(
|
||||||
"/:entity",
|
"/:entity",
|
||||||
|
permission(DataPermissions.entityRead),
|
||||||
tb("param", Type.Object({ entity: Type.String() })),
|
tb("param", Type.Object({ entity: Type.String() })),
|
||||||
tb("query", querySchema),
|
tb("query", querySchema),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
this.guard.throwUnlessGranted(DataPermissions.entityRead);
|
|
||||||
|
|
||||||
//console.log("request", c.req.raw);
|
//console.log("request", c.req.raw);
|
||||||
const { entity } = c.req.param();
|
const { entity } = c.req.param();
|
||||||
if (!this.entityExists(entity)) {
|
if (!this.entityExists(entity)) {
|
||||||
@@ -238,6 +224,7 @@ export class DataController implements ClassController {
|
|||||||
// read one
|
// read one
|
||||||
.get(
|
.get(
|
||||||
"/:entity/:id",
|
"/:entity/:id",
|
||||||
|
permission(DataPermissions.entityRead),
|
||||||
tb(
|
tb(
|
||||||
"param",
|
"param",
|
||||||
Type.Object({
|
Type.Object({
|
||||||
@@ -246,11 +233,7 @@ export class DataController implements ClassController {
|
|||||||
})
|
})
|
||||||
),
|
),
|
||||||
tb("query", querySchema),
|
tb("query", querySchema),
|
||||||
/*zValidator("param", z.object({ entity: z.string(), id: z.coerce.number() })),
|
|
||||||
zValidator("query", repoQuerySchema),*/
|
|
||||||
async (c) => {
|
async (c) => {
|
||||||
this.guard.throwUnlessGranted(DataPermissions.entityRead);
|
|
||||||
|
|
||||||
const { entity, id } = c.req.param();
|
const { entity, id } = c.req.param();
|
||||||
if (!this.entityExists(entity)) {
|
if (!this.entityExists(entity)) {
|
||||||
return c.notFound();
|
return c.notFound();
|
||||||
@@ -264,6 +247,7 @@ export class DataController implements ClassController {
|
|||||||
// read many by reference
|
// read many by reference
|
||||||
.get(
|
.get(
|
||||||
"/:entity/:id/:reference",
|
"/:entity/:id/:reference",
|
||||||
|
permission(DataPermissions.entityRead),
|
||||||
tb(
|
tb(
|
||||||
"param",
|
"param",
|
||||||
Type.Object({
|
Type.Object({
|
||||||
@@ -274,8 +258,6 @@ export class DataController implements ClassController {
|
|||||||
),
|
),
|
||||||
tb("query", querySchema),
|
tb("query", querySchema),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
this.guard.throwUnlessGranted(DataPermissions.entityRead);
|
|
||||||
|
|
||||||
const { entity, id, reference } = c.req.param();
|
const { entity, id, reference } = c.req.param();
|
||||||
if (!this.entityExists(entity)) {
|
if (!this.entityExists(entity)) {
|
||||||
return c.notFound();
|
return c.notFound();
|
||||||
@@ -292,11 +274,10 @@ export class DataController implements ClassController {
|
|||||||
// func query
|
// func query
|
||||||
.post(
|
.post(
|
||||||
"/:entity/query",
|
"/:entity/query",
|
||||||
|
permission(DataPermissions.entityRead),
|
||||||
tb("param", Type.Object({ entity: Type.String() })),
|
tb("param", Type.Object({ entity: Type.String() })),
|
||||||
tb("json", querySchema),
|
tb("json", querySchema),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
this.guard.throwUnlessGranted(DataPermissions.entityRead);
|
|
||||||
|
|
||||||
const { entity } = c.req.param();
|
const { entity } = c.req.param();
|
||||||
if (!this.entityExists(entity)) {
|
if (!this.entityExists(entity)) {
|
||||||
return c.notFound();
|
return c.notFound();
|
||||||
@@ -314,9 +295,11 @@ export class DataController implements ClassController {
|
|||||||
*/
|
*/
|
||||||
// insert one
|
// insert one
|
||||||
hono
|
hono
|
||||||
.post("/:entity", tb("param", Type.Object({ entity: Type.String() })), async (c) => {
|
.post(
|
||||||
this.guard.throwUnlessGranted(DataPermissions.entityCreate);
|
"/:entity",
|
||||||
|
permission(DataPermissions.entityCreate),
|
||||||
|
tb("param", Type.Object({ entity: Type.String() })),
|
||||||
|
async (c) => {
|
||||||
const { entity } = c.req.param();
|
const { entity } = c.req.param();
|
||||||
if (!this.entityExists(entity)) {
|
if (!this.entityExists(entity)) {
|
||||||
return c.notFound();
|
return c.notFound();
|
||||||
@@ -325,14 +308,14 @@ export class DataController implements ClassController {
|
|||||||
const result = await this.em.mutator(entity).insertOne(body);
|
const result = await this.em.mutator(entity).insertOne(body);
|
||||||
|
|
||||||
return c.json(this.mutatorResult(result), 201);
|
return c.json(this.mutatorResult(result), 201);
|
||||||
})
|
}
|
||||||
|
)
|
||||||
// update one
|
// update one
|
||||||
.patch(
|
.patch(
|
||||||
"/:entity/:id",
|
"/:entity/:id",
|
||||||
|
permission(DataPermissions.entityUpdate),
|
||||||
tb("param", Type.Object({ entity: Type.String(), id: tbNumber })),
|
tb("param", Type.Object({ entity: Type.String(), id: tbNumber })),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
this.guard.throwUnlessGranted(DataPermissions.entityUpdate);
|
|
||||||
|
|
||||||
const { entity, id } = c.req.param();
|
const { entity, id } = c.req.param();
|
||||||
if (!this.entityExists(entity)) {
|
if (!this.entityExists(entity)) {
|
||||||
return c.notFound();
|
return c.notFound();
|
||||||
@@ -346,6 +329,8 @@ export class DataController implements ClassController {
|
|||||||
// delete one
|
// delete one
|
||||||
.delete(
|
.delete(
|
||||||
"/:entity/:id",
|
"/:entity/:id",
|
||||||
|
|
||||||
|
permission(DataPermissions.entityDelete),
|
||||||
tb("param", Type.Object({ entity: Type.String(), id: tbNumber })),
|
tb("param", Type.Object({ entity: Type.String(), id: tbNumber })),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
this.guard.throwUnlessGranted(DataPermissions.entityDelete);
|
this.guard.throwUnlessGranted(DataPermissions.entityDelete);
|
||||||
@@ -363,11 +348,10 @@ export class DataController implements ClassController {
|
|||||||
// delete many
|
// delete many
|
||||||
.delete(
|
.delete(
|
||||||
"/:entity",
|
"/:entity",
|
||||||
|
permission(DataPermissions.entityDelete),
|
||||||
tb("param", Type.Object({ entity: Type.String() })),
|
tb("param", Type.Object({ entity: Type.String() })),
|
||||||
tb("json", querySchema.properties.where),
|
tb("json", querySchema.properties.where),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
this.guard.throwUnlessGranted(DataPermissions.entityDelete);
|
|
||||||
|
|
||||||
//console.log("request", c.req.raw);
|
//console.log("request", c.req.raw);
|
||||||
const { entity } = c.req.param();
|
const { entity } = c.req.param();
|
||||||
if (!this.entityExists(entity)) {
|
if (!this.entityExists(entity)) {
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ export class Entity<
|
|||||||
return this.fields.find((field) => field.name === name);
|
return this.fields.find((field) => field.name === name);
|
||||||
}
|
}
|
||||||
|
|
||||||
__experimental_replaceField(name: string, field: Field) {
|
__replaceField(name: string, field: Field) {
|
||||||
const index = this.fields.findIndex((f) => f.name === name);
|
const index = this.fields.findIndex((f) => f.name === name);
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
throw new Error(`Field "${name}" not found on entity "${this.name}"`);
|
throw new Error(`Field "${name}" not found on entity "${this.name}"`);
|
||||||
|
|||||||
@@ -99,14 +99,24 @@ export class EntityManager<TBD extends object = DefaultDB> {
|
|||||||
this.entities.push(entity);
|
this.entities.push(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
entity(e: Entity | keyof TBD | string): Entity {
|
__replaceEntity(entity: Entity, name: string | undefined = entity.name) {
|
||||||
let entity: Entity | undefined;
|
const entityIndex = this._entities.findIndex((e) => e.name === name);
|
||||||
if (typeof e === "string") {
|
|
||||||
entity = this.entities.find((entity) => entity.name === e);
|
if (entityIndex === -1) {
|
||||||
} else if (e instanceof Entity) {
|
throw new Error(`Entity "${name}" not found and cannot be replaced`);
|
||||||
entity = e;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this._entities[entityIndex] = entity;
|
||||||
|
|
||||||
|
// caused issues because this.entity() was using a reference (for when initial config was given)
|
||||||
|
}
|
||||||
|
|
||||||
|
entity(e: Entity | keyof TBD | string): Entity {
|
||||||
|
// make sure to always retrieve by name
|
||||||
|
const entity = this.entities.find((entity) =>
|
||||||
|
e instanceof Entity ? entity.name === e.name : entity.name === e
|
||||||
|
);
|
||||||
|
|
||||||
if (!entity) {
|
if (!entity) {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
throw new EntityNotDefinedException(e instanceof Entity ? e.name : e);
|
throw new EntityNotDefinedException(e instanceof Entity ? e.name : e);
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
|||||||
}
|
}
|
||||||
|
|
||||||
private cloneFor(entity: Entity) {
|
private cloneFor(entity: Entity) {
|
||||||
return new Repository(this.em, entity, this.emgr);
|
return new Repository(this.em, this.em.entity(entity), this.emgr);
|
||||||
}
|
}
|
||||||
|
|
||||||
private get conn() {
|
private get conn() {
|
||||||
@@ -94,7 +94,10 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
|||||||
if (invalid.length > 0) {
|
if (invalid.length > 0) {
|
||||||
throw new InvalidSearchParamsException(
|
throw new InvalidSearchParamsException(
|
||||||
`Invalid select field(s): ${invalid.join(", ")}`
|
`Invalid select field(s): ${invalid.join(", ")}`
|
||||||
);
|
).context({
|
||||||
|
entity: entity.name,
|
||||||
|
valid: validated.select
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
validated.select = options.select;
|
validated.select = options.select;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { EntityData, Field } from "data";
|
import type { EntityData, EntityManager, Field } from "data";
|
||||||
import { transform } from "lodash-es";
|
import { transform } from "lodash-es";
|
||||||
|
|
||||||
export function getDefaultValues(fields: Field[], data: EntityData): EntityData {
|
export function getDefaultValues(fields: Field[], data: EntityData): EntityData {
|
||||||
@@ -48,3 +48,23 @@ export function getChangeSet(
|
|||||||
{} as typeof formData
|
{} as typeof formData
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function readableEmJson(_em: EntityManager) {
|
||||||
|
return {
|
||||||
|
entities: _em.entities.map((e) => ({
|
||||||
|
name: e.name,
|
||||||
|
fields: e.fields.map((f) => f.name),
|
||||||
|
type: e.type
|
||||||
|
})),
|
||||||
|
indices: _em.indices.map((i) => ({
|
||||||
|
name: i.name,
|
||||||
|
entity: i.entity.name,
|
||||||
|
fields: i.fields.map((f) => f.name),
|
||||||
|
unique: i.unique
|
||||||
|
})),
|
||||||
|
relations: _em.relations.all.map((r) => ({
|
||||||
|
name: r.getName(),
|
||||||
|
...r.toJSON()
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -272,18 +272,22 @@ class EntityManagerPrototype<Entities extends Record<string, Entity>> extends En
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type Chained<Fn extends (...args: any[]) => any, Rt = ReturnType<Fn>> = <E extends Entity>(
|
type Chained<R extends Record<string, (...args: any[]) => any>> = {
|
||||||
e: E
|
[K in keyof R]: R[K] extends (...args: any[]) => any
|
||||||
) => {
|
? (...args: Parameters<R[K]>) => Chained<R>
|
||||||
[K in keyof Rt]: Rt[K] extends (...args: any[]) => any
|
|
||||||
? (...args: Parameters<Rt[K]>) => Rt
|
|
||||||
: never;
|
: never;
|
||||||
};
|
};
|
||||||
|
type ChainedFn<
|
||||||
|
Fn extends (...args: any[]) => Record<string, (...args: any[]) => any>,
|
||||||
|
Return extends ReturnType<Fn> = ReturnType<Fn>
|
||||||
|
> = (e: Entity) => {
|
||||||
|
[K in keyof Return]: (...args: Parameters<Return[K]>) => Chained<Return>;
|
||||||
|
};
|
||||||
|
|
||||||
export function em<Entities extends Record<string, Entity>>(
|
export function em<Entities extends Record<string, Entity>>(
|
||||||
entities: Entities,
|
entities: Entities,
|
||||||
schema?: (
|
schema?: (
|
||||||
fns: { relation: Chained<typeof relation>; index: Chained<typeof index> },
|
fns: { relation: ChainedFn<typeof relation>; index: ChainedFn<typeof index> },
|
||||||
entities: Entities
|
entities: Entities
|
||||||
) => void
|
) => void
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
Type,
|
Type,
|
||||||
Value
|
Value
|
||||||
} from "core/utils";
|
} from "core/utils";
|
||||||
import type { Simplify } from "type-fest";
|
|
||||||
import { WhereBuilder } from "../entities";
|
import { WhereBuilder } from "../entities";
|
||||||
|
|
||||||
const NumberOrString = (options: SchemaOptions = {}) =>
|
const NumberOrString = (options: SchemaOptions = {}) =>
|
||||||
@@ -19,18 +18,26 @@ const limit = NumberOrString({ default: 10 });
|
|||||||
const offset = NumberOrString({ default: 0 });
|
const offset = NumberOrString({ default: 0 });
|
||||||
|
|
||||||
// @todo: allow "id" and "-id"
|
// @todo: allow "id" and "-id"
|
||||||
|
const sort_default = { by: "id", dir: "asc" };
|
||||||
const sort = Type.Transform(
|
const sort = Type.Transform(
|
||||||
Type.Union(
|
Type.Union(
|
||||||
[Type.String(), Type.Object({ by: Type.String(), dir: StringEnum(["asc", "desc"]) })],
|
[Type.String(), Type.Object({ by: Type.String(), dir: StringEnum(["asc", "desc"]) })],
|
||||||
{
|
{
|
||||||
default: { by: "id", dir: "asc" }
|
default: sort_default
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.Decode((value) => {
|
.Decode((value) => {
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
|
if (/^-?[a-zA-Z_][a-zA-Z0-9_.]*$/.test(value)) {
|
||||||
|
const dir = value[0] === "-" ? "desc" : "asc";
|
||||||
|
return { by: dir === "desc" ? value.slice(1) : value, dir };
|
||||||
|
} else if (/^{.*}$/.test(value)) {
|
||||||
return JSON.parse(value);
|
return JSON.parse(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return sort_default;
|
||||||
|
}
|
||||||
return value;
|
return value;
|
||||||
})
|
})
|
||||||
.Encode(JSON.stringify);
|
.Encode(JSON.stringify);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export {
|
|||||||
type ModuleBuildContext
|
type ModuleBuildContext
|
||||||
} from "./modules/ModuleManager";
|
} from "./modules/ModuleManager";
|
||||||
|
|
||||||
|
export * as middlewares from "modules/middlewares";
|
||||||
export { registries } from "modules/registries";
|
export { registries } from "modules/registries";
|
||||||
|
|
||||||
export type * from "./adapter";
|
export type * from "./adapter";
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
import type { PrimaryFieldType } from "core";
|
import type { PrimaryFieldType } from "core";
|
||||||
import { EntityIndex, type EntityManager } from "data";
|
import { type Entity, EntityIndex, type EntityManager } from "data";
|
||||||
import { type FileUploadedEventData, Storage, type StorageAdapter } from "media";
|
import { type FileUploadedEventData, Storage, type StorageAdapter } from "media";
|
||||||
import { Module } from "modules/Module";
|
import { Module } from "modules/Module";
|
||||||
import { type FieldSchema, boolean, datetime, entity, json, number, text } from "../data/prototype";
|
import {
|
||||||
|
type FieldSchema,
|
||||||
|
boolean,
|
||||||
|
datetime,
|
||||||
|
em,
|
||||||
|
entity,
|
||||||
|
json,
|
||||||
|
number,
|
||||||
|
text
|
||||||
|
} from "../data/prototype";
|
||||||
import { MediaController } from "./api/MediaController";
|
import { MediaController } from "./api/MediaController";
|
||||||
import { ADAPTERS, buildMediaSchema, type mediaConfigSchema, registry } from "./media-schema";
|
import { ADAPTERS, buildMediaSchema, type mediaConfigSchema, registry } from "./media-schema";
|
||||||
|
|
||||||
@@ -38,18 +47,12 @@ export class AppMedia extends Module<typeof mediaConfigSchema> {
|
|||||||
this.setupListeners();
|
this.setupListeners();
|
||||||
this.ctx.server.route(this.basepath, new MediaController(this).getController());
|
this.ctx.server.route(this.basepath, new MediaController(this).getController());
|
||||||
|
|
||||||
// @todo: add check for media entity
|
const media = this.getMediaEntity(true);
|
||||||
const mediaEntity = this.getMediaEntity();
|
this.ensureSchema(
|
||||||
if (!this.ctx.em.hasEntity(mediaEntity)) {
|
em({ [media.name as "media"]: media }, ({ index }, { media }) => {
|
||||||
this.ctx.em.addEntity(mediaEntity);
|
index(media).on(["path"], true).on(["reference"]);
|
||||||
}
|
})
|
||||||
|
);
|
||||||
const pathIndex = new EntityIndex(mediaEntity, [mediaEntity.field("path")!], true);
|
|
||||||
if (!this.ctx.em.hasIndex(pathIndex)) {
|
|
||||||
this.ctx.em.addIndex(pathIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
// @todo: check indices
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -94,13 +97,13 @@ export class AppMedia extends Module<typeof mediaConfigSchema> {
|
|||||||
metadata: json()
|
metadata: json()
|
||||||
};
|
};
|
||||||
|
|
||||||
getMediaEntity() {
|
getMediaEntity(forceCreate?: boolean): Entity<"media", typeof AppMedia.mediaFields> {
|
||||||
const entity_name = this.config.entity_name;
|
const entity_name = this.config.entity_name;
|
||||||
if (!this.em.hasEntity(entity_name)) {
|
if (forceCreate || !this.em.hasEntity(entity_name)) {
|
||||||
return entity(entity_name, AppMedia.mediaFields, undefined, "system");
|
return entity(entity_name as "media", AppMedia.mediaFields, undefined, "system");
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.em.entity(entity_name);
|
return this.em.entity(entity_name) as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
get em(): EntityManager {
|
get em(): EntityManager {
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { type ClassController, tbValidator as tb } from "core";
|
import { tbValidator as tb } from "core";
|
||||||
import { Type } from "core/utils";
|
import { Type } from "core/utils";
|
||||||
import { Hono } from "hono";
|
|
||||||
import { bodyLimit } from "hono/body-limit";
|
import { bodyLimit } from "hono/body-limit";
|
||||||
import type { StorageAdapter } from "media";
|
import type { StorageAdapter } from "media";
|
||||||
import { StorageEvents } from "media";
|
import { StorageEvents, getRandomizedFilename } from "media";
|
||||||
import { getRandomizedFilename } from "media";
|
import { Controller } from "modules/Controller";
|
||||||
import type { AppMedia } from "../AppMedia";
|
import type { AppMedia } from "../AppMedia";
|
||||||
import { MediaField } from "../MediaField";
|
import { MediaField } from "../MediaField";
|
||||||
|
|
||||||
@@ -12,8 +11,10 @@ const booleanLike = Type.Transform(Type.String())
|
|||||||
.Decode((v) => v === "1")
|
.Decode((v) => v === "1")
|
||||||
.Encode((v) => (v ? "1" : "0"));
|
.Encode((v) => (v ? "1" : "0"));
|
||||||
|
|
||||||
export class MediaController implements ClassController {
|
export class MediaController extends Controller {
|
||||||
constructor(private readonly media: AppMedia) {}
|
constructor(private readonly media: AppMedia) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
private getStorageAdapter(): StorageAdapter {
|
private getStorageAdapter(): StorageAdapter {
|
||||||
return this.getStorage().getAdapter();
|
return this.getStorage().getAdapter();
|
||||||
@@ -23,11 +24,11 @@ export class MediaController implements ClassController {
|
|||||||
return this.media.storage;
|
return this.media.storage;
|
||||||
}
|
}
|
||||||
|
|
||||||
getController(): Hono<any> {
|
override getController() {
|
||||||
// @todo: multiple providers?
|
// @todo: multiple providers?
|
||||||
// @todo: implement range requests
|
// @todo: implement range requests
|
||||||
|
const { auth } = this.middlewares;
|
||||||
const hono = new Hono();
|
const hono = this.create().use(auth());
|
||||||
|
|
||||||
// get files list (temporary)
|
// get files list (temporary)
|
||||||
hono.get("/files", async (c) => {
|
hono.get("/files", async (c) => {
|
||||||
@@ -107,7 +108,7 @@ export class MediaController implements ClassController {
|
|||||||
return c.json({ error: `Invalid field "${field_name}"` }, 400);
|
return c.json({ error: `Invalid field "${field_name}"` }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const mediaEntity = this.media.getMediaEntity();
|
const media_entity = this.media.getMediaEntity().name as "media";
|
||||||
const reference = `${entity_name}.${field_name}`;
|
const reference = `${entity_name}.${field_name}`;
|
||||||
const mediaRef = {
|
const mediaRef = {
|
||||||
scope: field_name,
|
scope: field_name,
|
||||||
@@ -117,11 +118,10 @@ export class MediaController implements ClassController {
|
|||||||
|
|
||||||
// check max items
|
// check max items
|
||||||
const max_items = field.getMaxItems();
|
const max_items = field.getMaxItems();
|
||||||
const ids_to_delete: number[] = [];
|
const paths_to_delete: string[] = [];
|
||||||
const id_field = mediaEntity.getPrimaryField().name;
|
|
||||||
if (max_items) {
|
if (max_items) {
|
||||||
const { overwrite } = c.req.valid("query");
|
const { overwrite } = c.req.valid("query");
|
||||||
const { count } = await this.media.em.repository(mediaEntity).count(mediaRef);
|
const { count } = await this.media.em.repository(media_entity).count(mediaRef);
|
||||||
|
|
||||||
// if there are more than or equal to max items
|
// if there are more than or equal to max items
|
||||||
if (count >= max_items) {
|
if (count >= max_items) {
|
||||||
@@ -140,18 +140,18 @@ export class MediaController implements ClassController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// collect items to delete
|
// collect items to delete
|
||||||
const deleteRes = await this.media.em.repo(mediaEntity).findMany({
|
const deleteRes = await this.media.em.repo(media_entity).findMany({
|
||||||
select: [id_field],
|
select: ["path"],
|
||||||
where: mediaRef,
|
where: mediaRef,
|
||||||
sort: {
|
sort: {
|
||||||
by: id_field,
|
by: "id",
|
||||||
dir: "asc"
|
dir: "asc"
|
||||||
},
|
},
|
||||||
limit: count - max_items + 1
|
limit: count - max_items + 1
|
||||||
});
|
});
|
||||||
|
|
||||||
if (deleteRes.data && deleteRes.data.length > 0) {
|
if (deleteRes.data && deleteRes.data.length > 0) {
|
||||||
deleteRes.data.map((item) => ids_to_delete.push(item[id_field]));
|
deleteRes.data.map((item) => paths_to_delete.push(item.path));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,7 +169,7 @@ export class MediaController implements ClassController {
|
|||||||
const file_name = getRandomizedFilename(file as File);
|
const file_name = getRandomizedFilename(file as File);
|
||||||
const info = await this.getStorage().uploadFile(file, file_name, true);
|
const info = await this.getStorage().uploadFile(file, file_name, true);
|
||||||
|
|
||||||
const mutator = this.media.em.mutator(mediaEntity);
|
const mutator = this.media.em.mutator(media_entity);
|
||||||
mutator.__unstable_toggleSystemEntityCreation(false);
|
mutator.__unstable_toggleSystemEntityCreation(false);
|
||||||
const result = await mutator.insertOne({
|
const result = await mutator.insertOne({
|
||||||
...this.media.uploadedEventDataToMediaPayload(info),
|
...this.media.uploadedEventDataToMediaPayload(info),
|
||||||
@@ -178,10 +178,11 @@ export class MediaController implements ClassController {
|
|||||||
mutator.__unstable_toggleSystemEntityCreation(true);
|
mutator.__unstable_toggleSystemEntityCreation(true);
|
||||||
|
|
||||||
// delete items if needed
|
// delete items if needed
|
||||||
if (ids_to_delete.length > 0) {
|
if (paths_to_delete.length > 0) {
|
||||||
await this.media.em
|
// delete files from db & adapter
|
||||||
.mutator(mediaEntity)
|
for (const path of paths_to_delete) {
|
||||||
.deleteWhere({ [id_field]: { $in: ids_to_delete } });
|
await this.getStorage().deleteFile(path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.json({ ok: true, result: result.data, ...info });
|
return c.json({ ok: true, result: result.data, ...info });
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Const, Type, objectTransform } from "core/utils";
|
import { Const, type Static, Type, objectTransform } from "core/utils";
|
||||||
import { Adapters } from "media";
|
import { Adapters } from "media";
|
||||||
import { registries } from "modules/registries";
|
import { registries } from "modules/registries";
|
||||||
|
|
||||||
@@ -47,3 +47,4 @@ export function buildMediaSchema() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const mediaConfigSchema = buildMediaSchema();
|
export const mediaConfigSchema = buildMediaSchema();
|
||||||
|
export type TAppMediaConfig = Static<typeof mediaConfigSchema>;
|
||||||
|
|||||||
19
app/src/modules/Controller.ts
Normal file
19
app/src/modules/Controller.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import type { ServerEnv } from "modules/Module";
|
||||||
|
import * as middlewares from "modules/middlewares";
|
||||||
|
|
||||||
|
export class Controller {
|
||||||
|
protected middlewares = middlewares;
|
||||||
|
|
||||||
|
protected create(): Hono<ServerEnv> {
|
||||||
|
return Controller.createServer();
|
||||||
|
}
|
||||||
|
|
||||||
|
static createServer(): Hono<ServerEnv> {
|
||||||
|
return new Hono<ServerEnv>();
|
||||||
|
}
|
||||||
|
|
||||||
|
getController(): Hono<ServerEnv> {
|
||||||
|
return this.create();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,32 @@
|
|||||||
|
import type { App } from "App";
|
||||||
import type { Guard } from "auth";
|
import type { Guard } from "auth";
|
||||||
import { SchemaObject } from "core";
|
import { SchemaObject } from "core";
|
||||||
import type { EventManager } from "core/events";
|
import type { EventManager } from "core/events";
|
||||||
import type { Static, TSchema } from "core/utils";
|
import type { Static, TSchema } from "core/utils";
|
||||||
import type { Connection, EntityManager } from "data";
|
import type { Connection, EntityIndex, EntityManager, em as prototypeEm } from "data";
|
||||||
|
import { Entity } from "data";
|
||||||
import type { Hono } from "hono";
|
import type { Hono } from "hono";
|
||||||
|
|
||||||
|
export type ServerEnv = {
|
||||||
|
Variables: {
|
||||||
|
app?: App;
|
||||||
|
// to prevent resolving auth multiple times
|
||||||
|
auth_resolved?: boolean;
|
||||||
|
// to only register once
|
||||||
|
auth_registered?: boolean;
|
||||||
|
// whether or not to bypass auth
|
||||||
|
auth_skip?: boolean;
|
||||||
|
html?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export type ModuleBuildContext = {
|
export type ModuleBuildContext = {
|
||||||
connection: Connection;
|
connection: Connection;
|
||||||
server: Hono<any>;
|
server: Hono<ServerEnv>;
|
||||||
em: EntityManager;
|
em: EntityManager;
|
||||||
emgr: EventManager<any>;
|
emgr: EventManager<any>;
|
||||||
guard: Guard;
|
guard: Guard;
|
||||||
|
flags: (typeof Module)["ctx_flags"];
|
||||||
};
|
};
|
||||||
|
|
||||||
export abstract class Module<Schema extends TSchema = TSchema, ConfigSchema = Static<Schema>> {
|
export abstract class Module<Schema extends TSchema = TSchema, ConfigSchema = Static<Schema>> {
|
||||||
@@ -33,6 +49,15 @@ export abstract class Module<Schema extends TSchema = TSchema, ConfigSchema = St
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static ctx_flags = {
|
||||||
|
sync_required: false,
|
||||||
|
ctx_reload_required: false
|
||||||
|
} as {
|
||||||
|
// signal that a sync is required at the end of build
|
||||||
|
sync_required: boolean;
|
||||||
|
ctx_reload_required: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
onBeforeUpdate(from: ConfigSchema, to: ConfigSchema): ConfigSchema | Promise<ConfigSchema> {
|
onBeforeUpdate(from: ConfigSchema, to: ConfigSchema): ConfigSchema | Promise<ConfigSchema> {
|
||||||
return to;
|
return to;
|
||||||
}
|
}
|
||||||
@@ -78,6 +103,10 @@ export abstract class Module<Schema extends TSchema = TSchema, ConfigSchema = St
|
|||||||
return this._schema;
|
return this._schema;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// action performed when server has been initialized
|
||||||
|
// can be used to assign global middlewares
|
||||||
|
onServerInit(hono: Hono<ServerEnv>) {}
|
||||||
|
|
||||||
get ctx() {
|
get ctx() {
|
||||||
if (!this._ctx) {
|
if (!this._ctx) {
|
||||||
throw new Error("Context not set");
|
throw new Error("Context not set");
|
||||||
@@ -115,4 +144,44 @@ export abstract class Module<Schema extends TSchema = TSchema, ConfigSchema = St
|
|||||||
toJSON(secrets?: boolean): Static<ReturnType<(typeof this)["getSchema"]>> {
|
toJSON(secrets?: boolean): Static<ReturnType<(typeof this)["getSchema"]>> {
|
||||||
return this.config;
|
return this.config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected ensureEntity(entity: Entity) {
|
||||||
|
// check fields
|
||||||
|
if (!this.ctx.em.hasEntity(entity.name)) {
|
||||||
|
this.ctx.em.addEntity(entity);
|
||||||
|
this.ctx.flags.sync_required = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const instance = this.ctx.em.entity(entity.name);
|
||||||
|
|
||||||
|
// if exists, check all fields required are there
|
||||||
|
// @todo: check if the field also equal
|
||||||
|
for (const field of instance.fields) {
|
||||||
|
const _field = entity.field(field.name);
|
||||||
|
if (!_field) {
|
||||||
|
entity.addField(field);
|
||||||
|
this.ctx.flags.sync_required = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// replace entity (mainly to keep the ensured type)
|
||||||
|
this.ctx.em.__replaceEntity(
|
||||||
|
new Entity(entity.name, entity.fields, instance.config, entity.type)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected ensureIndex(index: EntityIndex) {
|
||||||
|
if (!this.ctx.em.hasIndex(index)) {
|
||||||
|
this.ctx.em.addIndex(index);
|
||||||
|
this.ctx.flags.sync_required = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected ensureSchema<Schema extends ReturnType<typeof prototypeEm>>(schema: Schema): Schema {
|
||||||
|
Object.values(schema.entities ?? {}).forEach(this.ensureEntity.bind(this));
|
||||||
|
schema.indices?.forEach(this.ensureIndex.bind(this));
|
||||||
|
|
||||||
|
return schema;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import { AppAuth } from "../auth/AppAuth";
|
|||||||
import { AppData } from "../data/AppData";
|
import { AppData } from "../data/AppData";
|
||||||
import { AppFlows } from "../flows/AppFlows";
|
import { AppFlows } from "../flows/AppFlows";
|
||||||
import { AppMedia } from "../media/AppMedia";
|
import { AppMedia } from "../media/AppMedia";
|
||||||
import type { Module, ModuleBuildContext } from "./Module";
|
import { Module, type ModuleBuildContext, type ServerEnv } from "./Module";
|
||||||
|
|
||||||
export type { ModuleBuildContext };
|
export type { ModuleBuildContext };
|
||||||
|
|
||||||
@@ -79,6 +79,8 @@ export type ModuleManagerOptions = {
|
|||||||
onFirstBoot?: () => Promise<void>;
|
onFirstBoot?: () => Promise<void>;
|
||||||
// base path for the hono instance
|
// base path for the hono instance
|
||||||
basePath?: string;
|
basePath?: string;
|
||||||
|
// callback after server was created
|
||||||
|
onServerInit?: (server: Hono<ServerEnv>) => void;
|
||||||
// doesn't perform validity checks for given/fetched config
|
// doesn't perform validity checks for given/fetched config
|
||||||
trustFetched?: boolean;
|
trustFetched?: boolean;
|
||||||
// runs when initial config provided on a fresh database
|
// runs when initial config provided on a fresh database
|
||||||
@@ -124,15 +126,12 @@ export class ModuleManager {
|
|||||||
__em!: EntityManager<T_INTERNAL_EM>;
|
__em!: EntityManager<T_INTERNAL_EM>;
|
||||||
// ctx for modules
|
// ctx for modules
|
||||||
em!: EntityManager;
|
em!: EntityManager;
|
||||||
server!: Hono;
|
server!: Hono<ServerEnv>;
|
||||||
emgr!: EventManager;
|
emgr!: EventManager;
|
||||||
guard!: Guard;
|
guard!: Guard;
|
||||||
|
|
||||||
private _version: number = 0;
|
private _version: number = 0;
|
||||||
private _built = false;
|
private _built = false;
|
||||||
private _fetched = false;
|
|
||||||
|
|
||||||
// @todo: keep? not doing anything with it
|
|
||||||
private readonly _booted_with?: "provided" | "partial";
|
private readonly _booted_with?: "provided" | "partial";
|
||||||
|
|
||||||
private logger = new DebugLogger(false);
|
private logger = new DebugLogger(false);
|
||||||
@@ -204,19 +203,17 @@ export class ModuleManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private rebuildServer() {
|
private rebuildServer() {
|
||||||
this.server = new Hono();
|
this.server = new Hono<ServerEnv>();
|
||||||
if (this.options?.basePath) {
|
if (this.options?.basePath) {
|
||||||
this.server = this.server.basePath(this.options.basePath);
|
this.server = this.server.basePath(this.options.basePath);
|
||||||
}
|
}
|
||||||
|
if (this.options?.onServerInit) {
|
||||||
|
this.options.onServerInit(this.server);
|
||||||
|
}
|
||||||
|
|
||||||
// @todo: this is a current workaround, controllers must be reworked
|
// optional method for each module to register global middlewares, etc.
|
||||||
objectEach(this.modules, (module) => {
|
objectEach(this.modules, (module) => {
|
||||||
if ("getMiddleware" in module) {
|
module.onServerInit(this.server);
|
||||||
const middleware = module.getMiddleware();
|
|
||||||
if (middleware) {
|
|
||||||
this.server.use(middleware);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,7 +229,8 @@ export class ModuleManager {
|
|||||||
server: this.server,
|
server: this.server,
|
||||||
em: this.em,
|
em: this.em,
|
||||||
emgr: this.emgr,
|
emgr: this.emgr,
|
||||||
guard: this.guard
|
guard: this.guard,
|
||||||
|
flags: Module.ctx_flags
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,8 +400,8 @@ export class ModuleManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async buildModules(options?: { graceful?: boolean }) {
|
private async buildModules(options?: { graceful?: boolean; ignoreFlags?: boolean }) {
|
||||||
this.logger.log("buildModules() triggered", options?.graceful, this._built);
|
this.logger.log("buildModules() triggered", options, this._built);
|
||||||
if (options?.graceful && this._built) {
|
if (options?.graceful && this._built) {
|
||||||
this.logger.log("skipping build (graceful)");
|
this.logger.log("skipping build (graceful)");
|
||||||
return;
|
return;
|
||||||
@@ -417,7 +415,27 @@ export class ModuleManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this._built = true;
|
this._built = true;
|
||||||
this.logger.log("modules built");
|
this.logger.log("modules built", ctx.flags);
|
||||||
|
|
||||||
|
if (options?.ignoreFlags !== true) {
|
||||||
|
if (ctx.flags.sync_required) {
|
||||||
|
ctx.flags.sync_required = false;
|
||||||
|
this.logger.log("db sync requested");
|
||||||
|
|
||||||
|
// sync db
|
||||||
|
await ctx.em.schema().sync({ force: true });
|
||||||
|
await this.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.flags.ctx_reload_required) {
|
||||||
|
ctx.flags.ctx_reload_required = false;
|
||||||
|
this.logger.log("ctx reload requested");
|
||||||
|
this.ctx(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reset all falgs
|
||||||
|
ctx.flags = Module.ctx_flags;
|
||||||
}
|
}
|
||||||
|
|
||||||
async build() {
|
async build() {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export {
|
|||||||
MODULE_NAMES,
|
MODULE_NAMES,
|
||||||
type ModuleKey
|
type ModuleKey
|
||||||
} from "./ModuleManager";
|
} from "./ModuleManager";
|
||||||
export { /*Module,*/ type ModuleBuildContext } from "./Module";
|
export type { ModuleBuildContext } from "./Module";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
type PrimaryFieldType,
|
type PrimaryFieldType,
|
||||||
|
|||||||
1
app/src/modules/middlewares.ts
Normal file
1
app/src/modules/middlewares.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { auth, permission } from "auth/middlewares";
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
/** @jsxImportSource hono/jsx */
|
/** @jsxImportSource hono/jsx */
|
||||||
|
|
||||||
import type { App } from "App";
|
import type { App } from "App";
|
||||||
import { type ClassController, isDebug } from "core";
|
import { config, isDebug } from "core";
|
||||||
import { addFlashMessage } from "core/server/flash";
|
import { addFlashMessage } from "core/server/flash";
|
||||||
import { Hono } from "hono";
|
|
||||||
import { html } from "hono/html";
|
import { html } from "hono/html";
|
||||||
import { Fragment } from "hono/jsx";
|
import { Fragment } from "hono/jsx";
|
||||||
|
import { Controller } from "modules/Controller";
|
||||||
import * as SystemPermissions from "modules/permissions";
|
import * as SystemPermissions from "modules/permissions";
|
||||||
|
|
||||||
const htmlBkndContextReplace = "<!-- BKND_CONTEXT -->";
|
const htmlBkndContextReplace = "<!-- BKND_CONTEXT -->";
|
||||||
@@ -13,38 +13,52 @@ const htmlBkndContextReplace = "<!-- BKND_CONTEXT -->";
|
|||||||
// @todo: add migration to remove admin path from config
|
// @todo: add migration to remove admin path from config
|
||||||
export type AdminControllerOptions = {
|
export type AdminControllerOptions = {
|
||||||
basepath?: string;
|
basepath?: string;
|
||||||
|
assets_path?: string;
|
||||||
html?: string;
|
html?: string;
|
||||||
forceDev?: boolean | { mainPath: string };
|
forceDev?: boolean | { mainPath: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
export class AdminController implements ClassController {
|
export class AdminController extends Controller {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly app: App,
|
private readonly app: App,
|
||||||
private options: AdminControllerOptions = {}
|
private _options: AdminControllerOptions = {}
|
||||||
) {}
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
get ctx() {
|
get ctx() {
|
||||||
return this.app.modules.ctx();
|
return this.app.modules.ctx();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get options() {
|
||||||
|
return {
|
||||||
|
...this._options,
|
||||||
|
basepath: this._options.basepath ?? "/",
|
||||||
|
assets_path: this._options.assets_path ?? config.server.assets_path
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
get basepath() {
|
get basepath() {
|
||||||
return this.options.basepath ?? "/";
|
return this.options.basepath ?? "/";
|
||||||
}
|
}
|
||||||
|
|
||||||
private withBasePath(route: string = "") {
|
private withBasePath(route: string = "") {
|
||||||
return (this.basepath + route).replace(/\/+$/, "/");
|
return (this.basepath + route).replace(/(?<!:)\/+/g, "/");
|
||||||
}
|
}
|
||||||
|
|
||||||
getController(): Hono<any> {
|
override getController() {
|
||||||
|
const { auth: authMiddleware, permission } = this.middlewares;
|
||||||
|
const hono = this.create().use(
|
||||||
|
authMiddleware({
|
||||||
|
//skip: [/favicon\.ico$/]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
const auth = this.app.module.auth;
|
const auth = this.app.module.auth;
|
||||||
const configs = this.app.modules.configs();
|
const configs = this.app.modules.configs();
|
||||||
// if auth is not enabled, authenticator is undefined
|
// if auth is not enabled, authenticator is undefined
|
||||||
const auth_enabled = configs.auth.enabled;
|
const auth_enabled = configs.auth.enabled;
|
||||||
const hono = new Hono<{
|
|
||||||
Variables: {
|
|
||||||
html: string;
|
|
||||||
};
|
|
||||||
}>().basePath(this.withBasePath());
|
|
||||||
const authRoutes = {
|
const authRoutes = {
|
||||||
root: "/",
|
root: "/",
|
||||||
success: configs.auth.cookie.pathSuccess ?? "/",
|
success: configs.auth.cookie.pathSuccess ?? "/",
|
||||||
@@ -66,23 +80,26 @@ export class AdminController implements ClassController {
|
|||||||
}
|
}
|
||||||
c.set("html", html);
|
c.set("html", html);
|
||||||
|
|
||||||
// refresh cookie if needed
|
|
||||||
await auth.authenticator?.requestCookieRefresh(c);
|
|
||||||
await next();
|
await next();
|
||||||
});
|
});
|
||||||
|
|
||||||
if (auth_enabled) {
|
if (auth_enabled) {
|
||||||
hono.get(authRoutes.login, async (c) => {
|
hono.get(
|
||||||
if (
|
authRoutes.login,
|
||||||
this.app.module.auth.authenticator?.isUserLoggedIn() &&
|
permission([SystemPermissions.accessAdmin, SystemPermissions.schemaRead], {
|
||||||
this.ctx.guard.granted(SystemPermissions.accessAdmin)
|
// @ts-ignore
|
||||||
) {
|
onGranted: async (c) => {
|
||||||
|
// @todo: add strict test to permissions middleware?
|
||||||
|
if (auth.authenticator.isUserLoggedIn()) {
|
||||||
|
console.log("redirecting to success");
|
||||||
return c.redirect(authRoutes.success);
|
return c.redirect(authRoutes.success);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
const html = c.get("html");
|
}),
|
||||||
return c.html(html);
|
async (c) => {
|
||||||
});
|
return c.html(c.get("html")!);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
hono.get(authRoutes.logout, async (c) => {
|
hono.get(authRoutes.logout, async (c) => {
|
||||||
await auth.authenticator?.logout(c);
|
await auth.authenticator?.logout(c);
|
||||||
@@ -90,15 +107,26 @@ export class AdminController implements ClassController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
hono.get("*", async (c) => {
|
// @todo: only load known paths
|
||||||
if (!this.ctx.guard.granted(SystemPermissions.accessAdmin)) {
|
hono.get(
|
||||||
await addFlashMessage(c, "You are not authorized to access the Admin UI", "error");
|
"/*",
|
||||||
|
permission(SystemPermissions.accessAdmin, {
|
||||||
|
onDenied: async (c) => {
|
||||||
|
addFlashMessage(c, "You are not authorized to access the Admin UI", "error");
|
||||||
|
|
||||||
|
console.log("redirecting");
|
||||||
return c.redirect(authRoutes.login);
|
return c.redirect(authRoutes.login);
|
||||||
}
|
}
|
||||||
|
}),
|
||||||
const html = c.get("html");
|
permission(SystemPermissions.schemaRead, {
|
||||||
return c.html(html);
|
onDenied: async (c) => {
|
||||||
});
|
addFlashMessage(c, "You not allowed to read the schema", "warning");
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
return c.html(c.get("html")!);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return hono;
|
return hono;
|
||||||
}
|
}
|
||||||
@@ -138,29 +166,42 @@ export class AdminController implements ClassController {
|
|||||||
const manifest = await import("bknd/dist/manifest.json", {
|
const manifest = await import("bknd/dist/manifest.json", {
|
||||||
assert: { type: "json" }
|
assert: { type: "json" }
|
||||||
}).then((m) => m.default);
|
}).then((m) => m.default);
|
||||||
assets.js = manifest["src/ui/main.tsx"].name;
|
// @todo: load all marked as entry (incl. css)
|
||||||
assets.css = manifest["src/ui/main.css"].name;
|
assets.js = manifest["src/ui/main.tsx"].file;
|
||||||
|
assets.css = manifest["src/ui/main.tsx"].css[0] as any;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Error loading manifest", e);
|
console.error("Error loading manifest", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const theme = configs.server.admin.color_scheme ?? "light";
|
||||||
|
const favicon = isProd ? this.options.assets_path + "favicon.ico" : "/favicon.ico";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
{/* dnd complains otherwise */}
|
{/* dnd complains otherwise */}
|
||||||
{html`<!DOCTYPE html>`}
|
{html`<!DOCTYPE html>`}
|
||||||
<html lang="en" class={configs.server.admin.color_scheme ?? "light"}>
|
<html lang="en" class={theme}>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta
|
<meta
|
||||||
name="viewport"
|
name="viewport"
|
||||||
content="width=device-width, initial-scale=1, maximum-scale=1"
|
content="width=device-width, initial-scale=1, maximum-scale=1"
|
||||||
/>
|
/>
|
||||||
|
<link rel="icon" href={favicon} type="image/x-icon" />
|
||||||
<title>BKND</title>
|
<title>BKND</title>
|
||||||
{isProd ? (
|
{isProd ? (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<script type="module" CrossOrigin src={"/" + assets?.js} />
|
<script
|
||||||
<link rel="stylesheet" crossOrigin href={"/" + assets?.css} />
|
type="module"
|
||||||
|
CrossOrigin
|
||||||
|
src={this.options.assets_path + assets?.js}
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
crossOrigin
|
||||||
|
href={this.options.assets_path + assets?.css}
|
||||||
|
/>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
) : (
|
) : (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
@@ -177,10 +218,16 @@ export class AdminController implements ClassController {
|
|||||||
<script type="module" src={"/@vite/client"} />
|
<script type="module" src={"/@vite/client"} />
|
||||||
</Fragment>
|
</Fragment>
|
||||||
)}
|
)}
|
||||||
|
<style dangerouslySetInnerHTML={{ __html: "body { margin: 0; padding: 0; }" }} />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root" />
|
<div id="root">
|
||||||
<div id="app" />
|
<div id="loading" style={style(theme)}>
|
||||||
|
<span style={{ opacity: 0.3, fontSize: 14, fontFamily: "monospace" }}>
|
||||||
|
Initializing...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<script
|
<script
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: bknd_context
|
__html: bknd_context
|
||||||
@@ -193,3 +240,32 @@ export class AdminController implements ClassController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const style = (theme: "light" | "dark" = "light") => {
|
||||||
|
const base = {
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
height: "100vh",
|
||||||
|
width: "100vw",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
"-webkit-font-smoothing": "antialiased",
|
||||||
|
"-moz-osx-font-smoothing": "grayscale"
|
||||||
|
};
|
||||||
|
const styles = {
|
||||||
|
light: {
|
||||||
|
color: "rgb(9,9,11)",
|
||||||
|
backgroundColor: "rgb(250,250,250)"
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
color: "rgb(250,250,250)",
|
||||||
|
backgroundColor: "rgb(30,31,34)"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
...styles[theme]
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
/// <reference types="@cloudflare/workers-types" />
|
/// <reference types="@cloudflare/workers-types" />
|
||||||
|
|
||||||
import type { App } from "App";
|
import type { App } from "App";
|
||||||
import type { ClassController } from "core";
|
|
||||||
import { tbValidator as tb } from "core";
|
import { tbValidator as tb } from "core";
|
||||||
import { StringEnum, Type, TypeInvalidError } from "core/utils";
|
import { StringEnum, Type, TypeInvalidError } from "core/utils";
|
||||||
import { type Context, Hono } from "hono";
|
import { getRuntimeKey } from "core/utils";
|
||||||
|
import type { Context, Hono } from "hono";
|
||||||
|
import { Controller } from "modules/Controller";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
MODULE_NAMES,
|
MODULE_NAMES,
|
||||||
type ModuleConfigs,
|
type ModuleConfigs,
|
||||||
@@ -27,21 +29,20 @@ export type ConfigUpdateResponse<Key extends ModuleKey = ModuleKey> =
|
|||||||
| ConfigUpdate<Key>
|
| ConfigUpdate<Key>
|
||||||
| { success: false; type: "type-invalid" | "error" | "unknown"; error?: any; errors?: any };
|
| { success: false; type: "type-invalid" | "error" | "unknown"; error?: any; errors?: any };
|
||||||
|
|
||||||
export class SystemController implements ClassController {
|
export class SystemController extends Controller {
|
||||||
constructor(private readonly app: App) {}
|
constructor(private readonly app: App) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
get ctx() {
|
get ctx() {
|
||||||
return this.app.modules.ctx();
|
return this.app.modules.ctx();
|
||||||
}
|
}
|
||||||
|
|
||||||
private registerConfigController(client: Hono<any>): void {
|
private registerConfigController(client: Hono<any>): void {
|
||||||
const hono = new Hono();
|
const { permission } = this.middlewares;
|
||||||
|
const hono = this.create();
|
||||||
|
|
||||||
/*hono.use("*", async (c, next) => {
|
hono.use(permission(SystemPermissions.configRead));
|
||||||
//this.ctx.guard.throwUnlessGranted(SystemPermissions.configRead);
|
|
||||||
console.log("perm?", this.ctx.guard.hasPermission(SystemPermissions.configRead));
|
|
||||||
return next();
|
|
||||||
});*/
|
|
||||||
|
|
||||||
hono.get(
|
hono.get(
|
||||||
"/:module?",
|
"/:module?",
|
||||||
@@ -57,7 +58,6 @@ export class SystemController implements ClassController {
|
|||||||
const { secrets } = c.req.valid("query");
|
const { secrets } = c.req.valid("query");
|
||||||
const { module } = c.req.valid("param");
|
const { module } = c.req.valid("param");
|
||||||
|
|
||||||
this.ctx.guard.throwUnlessGranted(SystemPermissions.configRead);
|
|
||||||
secrets && this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets);
|
secrets && this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets);
|
||||||
|
|
||||||
const config = this.app.toJSON(secrets);
|
const config = this.app.toJSON(secrets);
|
||||||
@@ -96,6 +96,7 @@ export class SystemController implements ClassController {
|
|||||||
|
|
||||||
hono.post(
|
hono.post(
|
||||||
"/set/:module",
|
"/set/:module",
|
||||||
|
permission(SystemPermissions.configWrite),
|
||||||
tb(
|
tb(
|
||||||
"query",
|
"query",
|
||||||
Type.Object({
|
Type.Object({
|
||||||
@@ -107,8 +108,6 @@ export class SystemController implements ClassController {
|
|||||||
const { force } = c.req.valid("query");
|
const { force } = c.req.valid("query");
|
||||||
const value = await c.req.json();
|
const value = await c.req.json();
|
||||||
|
|
||||||
this.ctx.guard.throwUnlessGranted(SystemPermissions.configWrite);
|
|
||||||
|
|
||||||
return await handleConfigUpdateResponse(c, async () => {
|
return await handleConfigUpdateResponse(c, async () => {
|
||||||
// you must explicitly set force to override existing values
|
// you must explicitly set force to override existing values
|
||||||
// because omitted values gets removed
|
// because omitted values gets removed
|
||||||
@@ -131,14 +130,12 @@ export class SystemController implements ClassController {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
hono.post("/add/:module/:path", async (c) => {
|
hono.post("/add/:module/:path", permission(SystemPermissions.configWrite), async (c) => {
|
||||||
// @todo: require auth (admin)
|
// @todo: require auth (admin)
|
||||||
const module = c.req.param("module") as any;
|
const module = c.req.param("module") as any;
|
||||||
const value = await c.req.json();
|
const value = await c.req.json();
|
||||||
const path = c.req.param("path") as string;
|
const path = c.req.param("path") as string;
|
||||||
|
|
||||||
this.ctx.guard.throwUnlessGranted(SystemPermissions.configWrite);
|
|
||||||
|
|
||||||
const moduleConfig = this.app.mutateConfig(module);
|
const moduleConfig = this.app.mutateConfig(module);
|
||||||
if (moduleConfig.has(path)) {
|
if (moduleConfig.has(path)) {
|
||||||
return c.json({ success: false, path, error: "Path already exists" }, { status: 400 });
|
return c.json({ success: false, path, error: "Path already exists" }, { status: 400 });
|
||||||
@@ -155,14 +152,12 @@ export class SystemController implements ClassController {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
hono.patch("/patch/:module/:path", async (c) => {
|
hono.patch("/patch/:module/:path", permission(SystemPermissions.configWrite), async (c) => {
|
||||||
// @todo: require auth (admin)
|
// @todo: require auth (admin)
|
||||||
const module = c.req.param("module") as any;
|
const module = c.req.param("module") as any;
|
||||||
const value = await c.req.json();
|
const value = await c.req.json();
|
||||||
const path = c.req.param("path");
|
const path = c.req.param("path");
|
||||||
|
|
||||||
this.ctx.guard.throwUnlessGranted(SystemPermissions.configWrite);
|
|
||||||
|
|
||||||
return await handleConfigUpdateResponse(c, async () => {
|
return await handleConfigUpdateResponse(c, async () => {
|
||||||
await this.app.mutateConfig(module).patch(path, value);
|
await this.app.mutateConfig(module).patch(path, value);
|
||||||
return {
|
return {
|
||||||
@@ -173,14 +168,12 @@ export class SystemController implements ClassController {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
hono.put("/overwrite/:module/:path", async (c) => {
|
hono.put("/overwrite/:module/:path", permission(SystemPermissions.configWrite), async (c) => {
|
||||||
// @todo: require auth (admin)
|
// @todo: require auth (admin)
|
||||||
const module = c.req.param("module") as any;
|
const module = c.req.param("module") as any;
|
||||||
const value = await c.req.json();
|
const value = await c.req.json();
|
||||||
const path = c.req.param("path");
|
const path = c.req.param("path");
|
||||||
|
|
||||||
this.ctx.guard.throwUnlessGranted(SystemPermissions.configWrite);
|
|
||||||
|
|
||||||
return await handleConfigUpdateResponse(c, async () => {
|
return await handleConfigUpdateResponse(c, async () => {
|
||||||
await this.app.mutateConfig(module).overwrite(path, value);
|
await this.app.mutateConfig(module).overwrite(path, value);
|
||||||
return {
|
return {
|
||||||
@@ -191,13 +184,11 @@ export class SystemController implements ClassController {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
hono.delete("/remove/:module/:path", async (c) => {
|
hono.delete("/remove/:module/:path", permission(SystemPermissions.configWrite), async (c) => {
|
||||||
// @todo: require auth (admin)
|
// @todo: require auth (admin)
|
||||||
const module = c.req.param("module") as any;
|
const module = c.req.param("module") as any;
|
||||||
const path = c.req.param("path")!;
|
const path = c.req.param("path")!;
|
||||||
|
|
||||||
this.ctx.guard.throwUnlessGranted(SystemPermissions.configWrite);
|
|
||||||
|
|
||||||
return await handleConfigUpdateResponse(c, async () => {
|
return await handleConfigUpdateResponse(c, async () => {
|
||||||
await this.app.mutateConfig(module).remove(path);
|
await this.app.mutateConfig(module).remove(path);
|
||||||
return {
|
return {
|
||||||
@@ -211,13 +202,15 @@ export class SystemController implements ClassController {
|
|||||||
client.route("/config", hono);
|
client.route("/config", hono);
|
||||||
}
|
}
|
||||||
|
|
||||||
getController(): Hono {
|
override getController() {
|
||||||
const hono = new Hono();
|
const { permission, auth } = this.middlewares;
|
||||||
|
const hono = this.create().use(auth());
|
||||||
|
|
||||||
this.registerConfigController(hono);
|
this.registerConfigController(hono);
|
||||||
|
|
||||||
hono.get(
|
hono.get(
|
||||||
"/schema/:module?",
|
"/schema/:module?",
|
||||||
|
permission(SystemPermissions.schemaRead),
|
||||||
tb(
|
tb(
|
||||||
"query",
|
"query",
|
||||||
Type.Object({
|
Type.Object({
|
||||||
@@ -228,7 +221,7 @@ export class SystemController implements ClassController {
|
|||||||
async (c) => {
|
async (c) => {
|
||||||
const module = c.req.param("module") as ModuleKey | undefined;
|
const module = c.req.param("module") as ModuleKey | undefined;
|
||||||
const { config, secrets } = c.req.valid("query");
|
const { config, secrets } = c.req.valid("query");
|
||||||
this.ctx.guard.throwUnlessGranted(SystemPermissions.schemaRead);
|
|
||||||
config && this.ctx.guard.throwUnlessGranted(SystemPermissions.configRead);
|
config && this.ctx.guard.throwUnlessGranted(SystemPermissions.configRead);
|
||||||
secrets && this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets);
|
secrets && this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets);
|
||||||
|
|
||||||
@@ -300,8 +293,8 @@ export class SystemController implements ClassController {
|
|||||||
return c.json({
|
return c.json({
|
||||||
version: this.app.version(),
|
version: this.app.version(),
|
||||||
test: 2,
|
test: 2,
|
||||||
// @ts-ignore
|
app: c.get("app")?.version(),
|
||||||
app: !!c.var.app
|
runtime: getRuntimeKey()
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -54,16 +54,19 @@ function AdminInternal() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const Skeleton = ({ theme = "light" }: { theme?: string }) => {
|
const Skeleton = ({ theme }: { theme?: string }) => {
|
||||||
|
const actualTheme =
|
||||||
|
(theme ?? document.querySelector("html")?.classList.contains("light")) ? "light" : "dark";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id="bknd-admin" className={(theme ?? "light") + " antialiased"}>
|
<div id="bknd-admin" className={actualTheme + " antialiased"}>
|
||||||
<AppShell.Root>
|
<AppShell.Root>
|
||||||
<header
|
<header
|
||||||
data-shell="header"
|
data-shell="header"
|
||||||
className="flex flex-row w-full h-16 gap-2.5 border-muted border-b justify-start bg-muted/10"
|
className="flex flex-row w-full h-16 gap-2.5 border-muted border-b justify-start bg-muted/10"
|
||||||
>
|
>
|
||||||
<div className="max-h-full flex hover:bg-primary/5 link p-2.5 w-[134px] outline-none">
|
<div className="max-h-full flex hover:bg-primary/5 link p-2.5 w-[134px] outline-none">
|
||||||
<Logo theme={theme} />
|
<Logo theme={actualTheme} />
|
||||||
</div>
|
</div>
|
||||||
<nav className="hidden md:flex flex-row gap-2.5 pl-0 p-2.5 items-center">
|
<nav className="hidden md:flex flex-row gap-2.5 pl-0 p-2.5 items-center">
|
||||||
{[...new Array(5)].map((item, key) => (
|
{[...new Array(5)].map((item, key) => (
|
||||||
@@ -84,7 +87,7 @@ const Skeleton = ({ theme = "light" }: { theme?: string }) => {
|
|||||||
</header>
|
</header>
|
||||||
<AppShell.Content>
|
<AppShell.Content>
|
||||||
<div className="flex flex-col w-full h-full justify-center items-center">
|
<div className="flex flex-col w-full h-full justify-center items-center">
|
||||||
<span className="font-mono opacity-30">Loading</span>
|
{/*<span className="font-mono opacity-30">Loading</span>*/}
|
||||||
</div>
|
</div>
|
||||||
</AppShell.Content>
|
</AppShell.Content>
|
||||||
</AppShell.Root>
|
</AppShell.Root>
|
||||||
|
|||||||
@@ -143,6 +143,8 @@ export const useEntityQuery = <
|
|||||||
return {
|
return {
|
||||||
...swr,
|
...swr,
|
||||||
...mapped,
|
...mapped,
|
||||||
|
mutate: mutateAll,
|
||||||
|
mutateRaw: swr.mutate,
|
||||||
api,
|
api,
|
||||||
key
|
key
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -125,12 +125,18 @@ export function DataTable<Data extends Record<string, any> = Record<string, any>
|
|||||||
</thead>
|
</thead>
|
||||||
) : null}
|
) : null}
|
||||||
<tbody>
|
<tbody>
|
||||||
{!data || data.length === 0 ? (
|
{!data || !Array.isArray(data) || data.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={select.length + (checkable ? 1 : 0)}>
|
<td colSpan={select.length + (checkable ? 1 : 0)}>
|
||||||
<div className="flex flex-col gap-2 p-8 justify-center items-center border-t border-muted">
|
<div className="flex flex-col gap-2 p-8 justify-center items-center border-t border-muted">
|
||||||
<i className="opacity-50">
|
<i className="opacity-50">
|
||||||
{Array.isArray(data) ? "No data to show" : "Loading..."}
|
{Array.isArray(data) ? (
|
||||||
|
"No data to show"
|
||||||
|
) : !data ? (
|
||||||
|
"Loading..."
|
||||||
|
) : (
|
||||||
|
<pre>{JSON.stringify(data, null, 2)}</pre>
|
||||||
|
)}
|
||||||
</i>
|
</i>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -45,8 +45,9 @@ const useLocationFromRouter = (router) => {
|
|||||||
export function Link({
|
export function Link({
|
||||||
className,
|
className,
|
||||||
native,
|
native,
|
||||||
|
onClick,
|
||||||
...props
|
...props
|
||||||
}: { className?: string; native?: boolean } & LinkProps) {
|
}: { className?: string; native?: boolean; transition?: boolean } & LinkProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [path, navigate] = useLocationFromRouter(router);
|
const [path, navigate] = useLocationFromRouter(router);
|
||||||
|
|
||||||
@@ -69,17 +70,28 @@ export function Link({
|
|||||||
const absPath = absolutePath(path, router.base).replace("//", "/");
|
const absPath = absolutePath(path, router.base).replace("//", "/");
|
||||||
const active =
|
const active =
|
||||||
href.replace(router.base, "").length <= 1 ? href === absPath : isActive(absPath, href);
|
href.replace(router.base, "").length <= 1 ? href === absPath : isActive(absPath, href);
|
||||||
const a = useRoute(_href);
|
|
||||||
|
|
||||||
/*if (active) {
|
|
||||||
console.log("link", { a, path, absPath, href, to, active, router });
|
|
||||||
}*/
|
|
||||||
if (native) {
|
if (native) {
|
||||||
return <a className={`${active ? "active " : ""}${className}`} {...props} />;
|
return <a className={`${active ? "active " : ""}${className}`} {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const wouterOnClick = (e: any) => {
|
||||||
|
// prepared for view transition
|
||||||
|
/*if (props.transition !== false) {
|
||||||
|
e.preventDefault();
|
||||||
|
onClick?.(e);
|
||||||
|
document.startViewTransition(() => {
|
||||||
|
navigate(props.href ?? props.to, props);
|
||||||
|
});
|
||||||
|
}*/
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<WouterLink
|
||||||
// @ts-expect-error className is not typed on WouterLink
|
// @ts-expect-error className is not typed on WouterLink
|
||||||
<WouterLink className={`${active ? "active " : ""}${className}`} {...props} />
|
className={`${active ? "active " : ""}${className}`}
|
||||||
|
{...props}
|
||||||
|
onClick={wouterOnClick}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
2
app/src/ui/elements/index.ts
Normal file
2
app/src/ui/elements/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { Auth } from "ui/modules/auth/index";
|
||||||
|
export * from "./media";
|
||||||
15
app/src/ui/elements/media.ts
Normal file
15
app/src/ui/elements/media.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { PreviewWrapperMemoized } from "ui/modules/media/components/dropzone/Dropzone";
|
||||||
|
import { DropzoneContainer } from "ui/modules/media/components/dropzone/DropzoneContainer";
|
||||||
|
|
||||||
|
export const Media = {
|
||||||
|
Dropzone: DropzoneContainer,
|
||||||
|
Preview: PreviewWrapperMemoized
|
||||||
|
};
|
||||||
|
|
||||||
|
export type {
|
||||||
|
PreviewComponentProps,
|
||||||
|
FileState,
|
||||||
|
DropzoneProps,
|
||||||
|
DropzoneRenderProps
|
||||||
|
} from "ui/modules/media/components/dropzone/Dropzone";
|
||||||
|
export type { DropzoneContainerProps } from "ui/modules/media/components/dropzone/DropzoneContainer";
|
||||||
@@ -144,7 +144,7 @@ export function Header({ hasSidebar = true }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function UserMenu() {
|
function UserMenu() {
|
||||||
const { adminOverride } = useBknd();
|
const { adminOverride, config } = useBknd();
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const [navigate] = useNavigate();
|
const [navigate] = useNavigate();
|
||||||
const { logout_route } = useBkndWindowContext();
|
const { logout_route } = useBkndWindowContext();
|
||||||
@@ -163,10 +163,16 @@ function UserMenu() {
|
|||||||
{ label: "Settings", onClick: () => navigate("/settings"), icon: IconSettings }
|
{ label: "Settings", onClick: () => navigate("/settings"), icon: IconSettings }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (config.auth.enabled) {
|
||||||
if (!auth.user) {
|
if (!auth.user) {
|
||||||
items.push({ label: "Login", onClick: handleLogin, icon: IconUser });
|
items.push({ label: "Login", onClick: handleLogin, icon: IconUser });
|
||||||
} else {
|
} else {
|
||||||
items.push({ label: `Logout ${auth.user.email}`, onClick: handleLogout, icon: IconKeyOff });
|
items.push({
|
||||||
|
label: `Logout ${auth.user.email}`,
|
||||||
|
onClick: handleLogout,
|
||||||
|
icon: IconKeyOff
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!adminOverride) {
|
if (!adminOverride) {
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import type { PrimaryFieldType } from "core";
|
import type { PrimaryFieldType } from "core";
|
||||||
import { encodeSearch } from "core/utils";
|
import { encodeSearch } from "core/utils";
|
||||||
import { atom, useSetAtom } from "jotai";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
import { useBaseUrl } from "../client";
|
|
||||||
import { useBknd } from "../client/BkndProvider";
|
import { useBknd } from "../client/BkndProvider";
|
||||||
|
|
||||||
export const routes = {
|
export const routes = {
|
||||||
@@ -64,9 +61,26 @@ export function useNavigate() {
|
|||||||
(
|
(
|
||||||
url: string,
|
url: string,
|
||||||
options?:
|
options?:
|
||||||
| { query?: object; absolute?: boolean; replace?: boolean; state?: any }
|
| {
|
||||||
|
query?: object;
|
||||||
|
absolute?: boolean;
|
||||||
|
replace?: boolean;
|
||||||
|
state?: any;
|
||||||
|
transition?: boolean;
|
||||||
|
}
|
||||||
| { reload: true }
|
| { reload: true }
|
||||||
) => {
|
) => {
|
||||||
|
const wrap = (fn: () => void) => {
|
||||||
|
fn();
|
||||||
|
// prepared for view transition
|
||||||
|
/*if (options && "transition" in options && options.transition === false) {
|
||||||
|
fn();
|
||||||
|
} else {
|
||||||
|
document.startViewTransition(fn);
|
||||||
|
}*/
|
||||||
|
};
|
||||||
|
|
||||||
|
wrap(() => {
|
||||||
if (options && "reload" in options) {
|
if (options && "reload" in options) {
|
||||||
window.location.href = url;
|
window.location.href = url;
|
||||||
return;
|
return;
|
||||||
@@ -77,6 +91,7 @@ export function useNavigate() {
|
|||||||
replace: options?.replace,
|
replace: options?.replace,
|
||||||
state: options?.state
|
state: options?.state
|
||||||
});
|
});
|
||||||
|
});
|
||||||
},
|
},
|
||||||
location
|
location
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
@import "./components/form/json-schema/styles.css";
|
@import "./components/form/json-schema/styles.css";
|
||||||
@import '@xyflow/react/dist/style.css';
|
@import "@xyflow/react/dist/style.css";
|
||||||
@import "@mantine/core/styles.css";
|
@import "@mantine/core/styles.css";
|
||||||
@import '@mantine/notifications/styles.css';
|
@import "@mantine/notifications/styles.css";
|
||||||
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
html.fixed, html.fixed body {
|
html.fixed,
|
||||||
|
html.fixed body {
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -18,20 +19,14 @@ html.fixed, html.fixed body {
|
|||||||
touch-action: none;
|
touch-action: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#bknd-admin, .bknd-admin {
|
#bknd-admin,
|
||||||
|
.bknd-admin {
|
||||||
--color-primary: 9 9 11; /* zinc-950 */
|
--color-primary: 9 9 11; /* zinc-950 */
|
||||||
--color-background: 250 250 250; /* zinc-50 */
|
--color-background: 250 250 250; /* zinc-50 */
|
||||||
--color-muted: 228 228 231; /* ? */
|
--color-muted: 228 228 231; /* ? */
|
||||||
--color-darkest: 0 0 0; /* black */
|
--color-darkest: 0 0 0; /* black */
|
||||||
--color-lightest: 255 255 255; /* white */
|
--color-lightest: 255 255 255; /* white */
|
||||||
|
|
||||||
&.dark {
|
|
||||||
--color-primary: 250 250 250; /* zinc-50 */
|
|
||||||
--color-background: 9 9 11; /* zinc-950 */
|
|
||||||
--color-muted: 39 39 42; /* zinc-800 */
|
|
||||||
--color-darkest: 255 255 255; /* white */
|
|
||||||
--color-lightest: 0 0 0; /* black */
|
|
||||||
}
|
|
||||||
&.dark {
|
&.dark {
|
||||||
--color-primary: 250 250 250; /* zinc-50 */
|
--color-primary: 250 250 250; /* zinc-50 */
|
||||||
--color-background: 30 31 34;
|
--color-background: 30 31 34;
|
||||||
@@ -52,7 +47,8 @@ html.fixed, html.fixed body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
html, body {
|
html,
|
||||||
|
body {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
@@ -111,7 +107,8 @@ body,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {}
|
@layer utilities {
|
||||||
|
}
|
||||||
|
|
||||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||||
.app-scrollbar::-webkit-scrollbar {
|
.app-scrollbar::-webkit-scrollbar {
|
||||||
@@ -153,11 +150,15 @@ input[type="date"]::-webkit-calendar-picker-indicator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
input[readonly]::placeholder, input[disabled]::placeholder {
|
input[readonly]::placeholder,
|
||||||
|
input[disabled]::placeholder {
|
||||||
opacity: 0.1;
|
opacity: 0.1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-flow__pane, .react-flow__renderer, .react-flow__node, .react-flow__edge {
|
.react-flow__pane,
|
||||||
|
.react-flow__renderer,
|
||||||
|
.react-flow__node,
|
||||||
|
.react-flow__edge {
|
||||||
cursor: inherit !important;
|
cursor: inherit !important;
|
||||||
.drag-handle {
|
.drag-handle {
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
@@ -168,7 +169,6 @@ input[readonly]::placeholder, input[disabled]::placeholder {
|
|||||||
stroke-width: 2;
|
stroke-width: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.mantine-TextInput-wrapper input {
|
.mantine-TextInput-wrapper input {
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
@@ -191,7 +191,8 @@ input[readonly]::placeholder, input[disabled]::placeholder {
|
|||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#bknd-admin, .bknd-admin {
|
#bknd-admin,
|
||||||
|
.bknd-admin {
|
||||||
/* Chrome, Edge, and Safari */
|
/* Chrome, Edge, and Safari */
|
||||||
& *::-webkit-scrollbar {
|
& *::-webkit-scrollbar {
|
||||||
@apply w-1;
|
@apply w-1;
|
||||||
|
|||||||
@@ -1,23 +1,13 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import * as ReactDOM from "react-dom/client";
|
import * as ReactDOM from "react-dom/client";
|
||||||
|
import Admin from "./Admin";
|
||||||
import "./main.css";
|
import "./main.css";
|
||||||
|
|
||||||
import Admin from "./Admin";
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
|
||||||
function ClientApp() {
|
|
||||||
return <Admin withProvider />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render the app
|
|
||||||
const rootElement = document.getElementById("app")!;
|
|
||||||
if (!rootElement.innerHTML) {
|
|
||||||
const root = ReactDOM.createRoot(rootElement);
|
|
||||||
root.render(
|
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<ClientApp />
|
<Admin withProvider />
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
// REGISTER ERROR OVERLAY
|
// REGISTER ERROR OVERLAY
|
||||||
if (process.env.NODE_ENV !== "production") {
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
|||||||
128
app/src/ui/modules/auth/AuthForm.tsx
Normal file
128
app/src/ui/modules/auth/AuthForm.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import type { ValueError } from "@sinclair/typebox/value";
|
||||||
|
import type { AppAuthOAuthStrategy, AppAuthSchema } from "auth/auth-schema";
|
||||||
|
import { type TSchema, Type, Value } from "core/utils";
|
||||||
|
import { Form, type Validator } from "json-schema-form-react";
|
||||||
|
import { transform } from "lodash-es";
|
||||||
|
import type { ComponentPropsWithoutRef } from "react";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import { Button } from "ui/components/buttons/Button";
|
||||||
|
import { Group, Input, Label } from "ui/components/form/Formy";
|
||||||
|
import { SocialLink } from "ui/modules/auth/SocialLink";
|
||||||
|
|
||||||
|
export type LoginFormProps = Omit<ComponentPropsWithoutRef<"form">, "onSubmit" | "action"> & {
|
||||||
|
className?: string;
|
||||||
|
formData?: any;
|
||||||
|
action: "login" | "register";
|
||||||
|
method?: "POST" | "GET";
|
||||||
|
auth?: Partial<Pick<AppAuthSchema, "basepath" | "strategies">>;
|
||||||
|
buttonLabel?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
class TypeboxValidator implements Validator<ValueError> {
|
||||||
|
async validate(schema: TSchema, data: any) {
|
||||||
|
return Value.Check(schema, data) ? [] : [...Value.Errors(schema, data)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validator = new TypeboxValidator();
|
||||||
|
|
||||||
|
const schema = Type.Object({
|
||||||
|
email: Type.String({
|
||||||
|
pattern: "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$"
|
||||||
|
}),
|
||||||
|
password: Type.String({
|
||||||
|
minLength: 8 // @todo: this should be configurable
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
export function AuthForm({
|
||||||
|
formData,
|
||||||
|
className,
|
||||||
|
method = "POST",
|
||||||
|
action,
|
||||||
|
auth,
|
||||||
|
buttonLabel = action === "login" ? "Sign in" : "Sign up",
|
||||||
|
...props
|
||||||
|
}: LoginFormProps) {
|
||||||
|
const basepath = auth?.basepath ?? "/api/auth";
|
||||||
|
const password = {
|
||||||
|
action: `${basepath}/password/${action}`,
|
||||||
|
strategy: auth?.strategies?.password ?? ({ type: "password" } as const)
|
||||||
|
};
|
||||||
|
|
||||||
|
const oauth = transform(
|
||||||
|
auth?.strategies ?? {},
|
||||||
|
(result, value, key) => {
|
||||||
|
if (value.type !== "password") {
|
||||||
|
result[key] = value.config;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
) as Record<string, AppAuthOAuthStrategy>;
|
||||||
|
const has_oauth = Object.keys(oauth).length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 w-full">
|
||||||
|
{has_oauth && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
{Object.entries(oauth)?.map(([name, oauth], key) => (
|
||||||
|
<SocialLink
|
||||||
|
provider={name}
|
||||||
|
method={method}
|
||||||
|
basepath={basepath}
|
||||||
|
key={key}
|
||||||
|
action={action}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Or />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Form
|
||||||
|
method={method}
|
||||||
|
action={password.action}
|
||||||
|
{...props}
|
||||||
|
schema={schema}
|
||||||
|
validator={validator}
|
||||||
|
validationMode="change"
|
||||||
|
className={twMerge("flex flex-col gap-3 w-full", className)}
|
||||||
|
>
|
||||||
|
{({ errors, submitting }) => (
|
||||||
|
<>
|
||||||
|
<Group>
|
||||||
|
<Label htmlFor="email">Email address</Label>
|
||||||
|
<Input type="email" name="email" />
|
||||||
|
</Group>
|
||||||
|
<Group>
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<Input type="password" name="password" />
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
size="large"
|
||||||
|
className="w-full mt-2 justify-center"
|
||||||
|
disabled={errors.length > 0 || submitting}
|
||||||
|
>
|
||||||
|
{buttonLabel}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Or = () => (
|
||||||
|
<div className="w-full flex flex-row items-center">
|
||||||
|
<div className="relative flex grow">
|
||||||
|
<div className="h-px bg-primary/10 w-full absolute top-[50%] z-0" />
|
||||||
|
</div>
|
||||||
|
<div className="mx-5">or</div>
|
||||||
|
<div className="relative flex grow">
|
||||||
|
<div className="h-px bg-primary/10 w-full absolute top-[50%] z-0" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
41
app/src/ui/modules/auth/AuthScreen.tsx
Normal file
41
app/src/ui/modules/auth/AuthScreen.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useAuthStrategies } from "ui/client/schema/auth/use-auth";
|
||||||
|
import { Logo } from "ui/components/display/Logo";
|
||||||
|
import { Link } from "ui/components/wouter/Link";
|
||||||
|
import { AuthForm } from "ui/modules/auth/AuthForm";
|
||||||
|
|
||||||
|
export type AuthScreenProps = {
|
||||||
|
method?: "POST" | "GET";
|
||||||
|
action?: "login" | "register";
|
||||||
|
logo?: ReactNode;
|
||||||
|
intro?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AuthScreen({ method = "POST", action = "login", logo, intro }: AuthScreenProps) {
|
||||||
|
const { strategies, basepath, loading } = useAuthStrategies();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col select-none h-dvh w-dvw justify-center items-center bknd-admin">
|
||||||
|
{!loading && (
|
||||||
|
<div className="flex flex-col gap-4 items-center w-96 px-6 py-7">
|
||||||
|
{typeof logo !== "undefined" ? (
|
||||||
|
logo
|
||||||
|
) : (
|
||||||
|
<Link href={"/"} className="link">
|
||||||
|
<Logo scale={0.25} />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{typeof intro !== "undefined" ? (
|
||||||
|
intro
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<h1 className="text-xl font-bold">Sign in to your admin panel</h1>
|
||||||
|
<p className="text-primary/50">Enter your credentials below to get access.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<AuthForm auth={{ basepath, strategies }} method={method} action={action} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import { typeboxResolver } from "@hookform/resolvers/typebox";
|
|
||||||
import { Type } from "core/utils";
|
|
||||||
import type { ComponentPropsWithoutRef } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { twMerge } from "tailwind-merge";
|
|
||||||
import { Button } from "ui/components/buttons/Button";
|
|
||||||
import * as Formy from "ui/components/form/Formy";
|
|
||||||
|
|
||||||
export type LoginFormProps = Omit<ComponentPropsWithoutRef<"form">, "onSubmit"> & {
|
|
||||||
className?: string;
|
|
||||||
formData?: any;
|
|
||||||
};
|
|
||||||
|
|
||||||
const schema = Type.Object({
|
|
||||||
email: Type.String({
|
|
||||||
pattern: "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$"
|
|
||||||
}),
|
|
||||||
password: Type.String({
|
|
||||||
minLength: 8 // @todo: this should be configurable
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
export function LoginForm({ formData, className, method = "POST", ...props }: LoginFormProps) {
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
formState: { isValid, errors }
|
|
||||||
} = useForm({
|
|
||||||
mode: "onChange",
|
|
||||||
defaultValues: formData,
|
|
||||||
resolver: typeboxResolver(schema)
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form {...props} method={method} className={twMerge("flex flex-col gap-3 w-full", className)}>
|
|
||||||
<Formy.Group>
|
|
||||||
<Formy.Label htmlFor="email">Email address</Formy.Label>
|
|
||||||
<Formy.Input type="email" {...register("email")} />
|
|
||||||
</Formy.Group>
|
|
||||||
<Formy.Group>
|
|
||||||
<Formy.Label htmlFor="password">Password</Formy.Label>
|
|
||||||
<Formy.Input type="password" {...register("password")} />
|
|
||||||
</Formy.Group>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="primary"
|
|
||||||
size="large"
|
|
||||||
className="w-full mt-2 justify-center"
|
|
||||||
disabled={!isValid}
|
|
||||||
>
|
|
||||||
Sign in
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
33
app/src/ui/modules/auth/SocialLink.tsx
Normal file
33
app/src/ui/modules/auth/SocialLink.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { ucFirstAllSnakeToPascalWithSpaces } from "core/utils";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Button } from "ui/components/buttons/Button";
|
||||||
|
import type { IconType } from "ui/components/buttons/IconButton";
|
||||||
|
|
||||||
|
export type SocialLinkProps = {
|
||||||
|
label?: string;
|
||||||
|
provider: string;
|
||||||
|
icon?: IconType;
|
||||||
|
action: "login" | "register";
|
||||||
|
method?: "GET" | "POST";
|
||||||
|
basepath?: string;
|
||||||
|
children?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SocialLink({
|
||||||
|
label,
|
||||||
|
provider,
|
||||||
|
icon,
|
||||||
|
action,
|
||||||
|
method = "POST",
|
||||||
|
basepath = "/api/auth",
|
||||||
|
children
|
||||||
|
}: SocialLinkProps) {
|
||||||
|
return (
|
||||||
|
<form method={method} action={[basepath, name, action].join("/")} className="w-full">
|
||||||
|
<Button type="submit" size="large" variant="outline" className="justify-center w-full">
|
||||||
|
Continue with {label ?? ucFirstAllSnakeToPascalWithSpaces(provider)}
|
||||||
|
</Button>
|
||||||
|
{children}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
app/src/ui/modules/auth/index.ts
Normal file
9
app/src/ui/modules/auth/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { AuthForm } from "ui/modules/auth/AuthForm";
|
||||||
|
import { AuthScreen } from "ui/modules/auth/AuthScreen";
|
||||||
|
import { SocialLink } from "ui/modules/auth/SocialLink";
|
||||||
|
|
||||||
|
export const Auth = {
|
||||||
|
Screen: AuthScreen,
|
||||||
|
Form: AuthForm,
|
||||||
|
SocialLink: SocialLink
|
||||||
|
};
|
||||||
@@ -10,13 +10,11 @@ import {
|
|||||||
} from "data";
|
} from "data";
|
||||||
import { MediaField } from "media/MediaField";
|
import { MediaField } from "media/MediaField";
|
||||||
import { type ComponentProps, Suspense } from "react";
|
import { type ComponentProps, Suspense } from "react";
|
||||||
import { useApi, useBaseUrl, useInvalidate } from "ui/client";
|
|
||||||
import { JsonEditor } from "ui/components/code/JsonEditor";
|
import { JsonEditor } from "ui/components/code/JsonEditor";
|
||||||
import * as Formy from "ui/components/form/Formy";
|
import * as Formy from "ui/components/form/Formy";
|
||||||
import { FieldLabel } from "ui/components/form/Formy";
|
import { FieldLabel } from "ui/components/form/Formy";
|
||||||
|
import { Media } from "ui/elements";
|
||||||
import { useEvent } from "ui/hooks/use-event";
|
import { useEvent } from "ui/hooks/use-event";
|
||||||
import { Dropzone, type FileState } from "../../media/components/dropzone/Dropzone";
|
|
||||||
import { mediaItemsToFileStates } from "../../media/helper";
|
|
||||||
import { EntityJsonSchemaFormField } from "./fields/EntityJsonSchemaFormField";
|
import { EntityJsonSchemaFormField } from "./fields/EntityJsonSchemaFormField";
|
||||||
import { EntityRelationalFormField } from "./fields/EntityRelationalFormField";
|
import { EntityRelationalFormField } from "./fields/EntityRelationalFormField";
|
||||||
|
|
||||||
@@ -215,9 +213,6 @@ function EntityMediaFormField({
|
|||||||
}) {
|
}) {
|
||||||
if (!entityId) return;
|
if (!entityId) return;
|
||||||
|
|
||||||
const api = useApi();
|
|
||||||
const baseUrl = useBaseUrl();
|
|
||||||
const invalidate = useInvalidate();
|
|
||||||
const value = formApi.useStore((state) => {
|
const value = formApi.useStore((state) => {
|
||||||
const val = state.values[field.name];
|
const val = state.values[field.name];
|
||||||
if (!val || typeof val === "undefined") return [];
|
if (!val || typeof val === "undefined") return [];
|
||||||
@@ -225,37 +220,20 @@ function EntityMediaFormField({
|
|||||||
return [val];
|
return [val];
|
||||||
});
|
});
|
||||||
|
|
||||||
const initialItems: FileState[] =
|
const key = JSON.stringify([entity, entityId, field.name, value.length]);
|
||||||
value.length === 0
|
|
||||||
? []
|
|
||||||
: mediaItemsToFileStates(value, {
|
|
||||||
baseUrl: api.baseUrl,
|
|
||||||
overrides: { state: "uploaded" }
|
|
||||||
});
|
|
||||||
|
|
||||||
const getUploadInfo = useEvent(() => {
|
|
||||||
return {
|
|
||||||
url: api.media.getEntityUploadUrl(entity.name, entityId, field.name),
|
|
||||||
headers: api.media.getUploadHeaders(),
|
|
||||||
method: "POST"
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleDelete = useEvent(async (file: FileState) => {
|
|
||||||
invalidate((api) => api.data.readOne(entity.name, entityId));
|
|
||||||
return api.media.deleteFile(file.path);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Formy.Group>
|
<Formy.Group>
|
||||||
<FieldLabel field={field} />
|
<FieldLabel field={field} />
|
||||||
<Dropzone
|
<Media.Dropzone
|
||||||
key={`${entity.name}-${entityId}-${field.name}-${value.length === 0 ? "initial" : "loaded"}`}
|
key={key}
|
||||||
getUploadInfo={getUploadInfo}
|
|
||||||
handleDelete={handleDelete}
|
|
||||||
initialItems={initialItems}
|
|
||||||
maxItems={field.getMaxItems()}
|
maxItems={field.getMaxItems()}
|
||||||
autoUpload
|
initialItems={value} /* @todo: test if better be omitted, so it fetches */
|
||||||
|
entity={{
|
||||||
|
name: entity.name,
|
||||||
|
id: entityId,
|
||||||
|
field: field.name
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Formy.Group>
|
</Formy.Group>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
type ComponentPropsWithRef,
|
type ComponentPropsWithRef,
|
||||||
|
type ComponentPropsWithoutRef,
|
||||||
type RefObject,
|
type RefObject,
|
||||||
memo,
|
memo,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -28,10 +29,11 @@ export type DropzoneRenderProps = {
|
|||||||
state: {
|
state: {
|
||||||
files: FileState[];
|
files: FileState[];
|
||||||
isOver: boolean;
|
isOver: boolean;
|
||||||
|
isOverAccepted: boolean;
|
||||||
showPlaceholder: boolean;
|
showPlaceholder: boolean;
|
||||||
};
|
};
|
||||||
actions: {
|
actions: {
|
||||||
uploadFileProgress: (file: FileState) => Promise<void>;
|
uploadFile: (file: FileState) => Promise<void>;
|
||||||
deleteFile: (file: FileState) => Promise<void>;
|
deleteFile: (file: FileState) => Promise<void>;
|
||||||
openFileInput: () => void;
|
openFileInput: () => void;
|
||||||
};
|
};
|
||||||
@@ -43,11 +45,16 @@ export type DropzoneProps = {
|
|||||||
handleDelete: (file: FileState) => Promise<boolean>;
|
handleDelete: (file: FileState) => Promise<boolean>;
|
||||||
initialItems?: FileState[];
|
initialItems?: FileState[];
|
||||||
maxItems?: number;
|
maxItems?: number;
|
||||||
|
overwrite?: boolean;
|
||||||
autoUpload?: boolean;
|
autoUpload?: boolean;
|
||||||
|
onRejected?: (files: FileWithPath[]) => void;
|
||||||
|
onDeleted?: (file: FileState) => void;
|
||||||
|
onUploaded?: (file: FileState) => void;
|
||||||
placeholder?: {
|
placeholder?: {
|
||||||
show?: boolean;
|
show?: boolean;
|
||||||
text?: string;
|
text?: string;
|
||||||
};
|
};
|
||||||
|
children?: (props: DropzoneRenderProps) => JSX.Element;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Dropzone({
|
export function Dropzone({
|
||||||
@@ -55,23 +62,65 @@ export function Dropzone({
|
|||||||
handleDelete,
|
handleDelete,
|
||||||
initialItems = [],
|
initialItems = [],
|
||||||
maxItems,
|
maxItems,
|
||||||
|
overwrite,
|
||||||
autoUpload,
|
autoUpload,
|
||||||
placeholder
|
placeholder,
|
||||||
|
onRejected,
|
||||||
|
onDeleted,
|
||||||
|
onUploaded,
|
||||||
|
children
|
||||||
}: DropzoneProps) {
|
}: DropzoneProps) {
|
||||||
const [files, setFiles] = useState<FileState[]>(initialItems);
|
const [files, setFiles] = useState<FileState[]>(initialItems);
|
||||||
const [uploading, setUploading] = useState<boolean>(false);
|
const [uploading, setUploading] = useState<boolean>(false);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [isOverAccepted, setIsOverAccepted] = useState(false);
|
||||||
|
|
||||||
|
function isMaxReached(added: number): boolean {
|
||||||
|
if (!maxItems) {
|
||||||
|
console.log("maxItems is undefined, never reached");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = files.length;
|
||||||
|
const remaining = maxItems - current;
|
||||||
|
console.log("isMaxReached", { added, current, remaining, maxItems, overwrite });
|
||||||
|
|
||||||
|
// if overwrite is set, but added is bigger than max items
|
||||||
|
if (overwrite) {
|
||||||
|
console.log("added > maxItems, stop?", added > maxItems);
|
||||||
|
return added > maxItems;
|
||||||
|
}
|
||||||
|
console.log("remaining > added, stop?", remaining > added);
|
||||||
|
// or remaining doesn't suffice, stop
|
||||||
|
return added > remaining;
|
||||||
|
}
|
||||||
|
|
||||||
const { isOver, handleFileInputChange, ref } = useDropzone({
|
const { isOver, handleFileInputChange, ref } = useDropzone({
|
||||||
onDropped: (newFiles: FileWithPath[]) => {
|
onDropped: (newFiles: FileWithPath[]) => {
|
||||||
if (maxItems && files.length + newFiles.length > maxItems) {
|
let to_drop = 0;
|
||||||
alert("Max items reached");
|
const added = newFiles.length;
|
||||||
|
|
||||||
|
if (maxItems) {
|
||||||
|
if (isMaxReached(added)) {
|
||||||
|
if (onRejected) {
|
||||||
|
onRejected(newFiles);
|
||||||
|
} else {
|
||||||
|
console.warn("maxItems reached");
|
||||||
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("files", newFiles);
|
to_drop = added;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("files", newFiles, { to_drop });
|
||||||
setFiles((prev) => {
|
setFiles((prev) => {
|
||||||
const currentPaths = prev.map((f) => f.path);
|
// drop amount calculated
|
||||||
|
const _prev = prev.slice(to_drop);
|
||||||
|
|
||||||
|
// prep new files
|
||||||
|
const currentPaths = _prev.map((f) => f.path);
|
||||||
const filteredFiles: FileState[] = newFiles
|
const filteredFiles: FileState[] = newFiles
|
||||||
.filter((f) => f.path && !currentPaths.includes(f.path))
|
.filter((f) => f.path && !currentPaths.includes(f.path))
|
||||||
.map((f) => ({
|
.map((f) => ({
|
||||||
@@ -84,7 +133,7 @@ export function Dropzone({
|
|||||||
progress: 0
|
progress: 0
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return [...prev, ...filteredFiles];
|
return [..._prev, ...filteredFiles];
|
||||||
});
|
});
|
||||||
|
|
||||||
if (autoUpload) {
|
if (autoUpload) {
|
||||||
@@ -92,17 +141,12 @@ export function Dropzone({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onOver: (items) => {
|
onOver: (items) => {
|
||||||
if (maxItems && files.length + items.length >= maxItems) {
|
const max_reached = isMaxReached(items.length);
|
||||||
// indicate that the drop is not allowed
|
setIsOverAccepted(!max_reached);
|
||||||
return;
|
},
|
||||||
|
onLeave: () => {
|
||||||
|
setIsOverAccepted(false);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
/*onOver: (items) =>
|
|
||||||
console.log(
|
|
||||||
"onOver",
|
|
||||||
items,
|
|
||||||
items.map((i) => [i.kind, i.type].join(":"))
|
|
||||||
)*/
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -180,7 +224,14 @@ export function Dropzone({
|
|||||||
formData.append("file", file.body);
|
formData.append("file", file.body);
|
||||||
|
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
xhr.open(method, url, true);
|
const urlWithParams = new URL(url);
|
||||||
|
if (overwrite) {
|
||||||
|
urlWithParams.searchParams.append("overwrite", "1");
|
||||||
|
}
|
||||||
|
console.log("url", urlWithParams.toString());
|
||||||
|
//return;
|
||||||
|
|
||||||
|
xhr.open(method, urlWithParams.toString(), true);
|
||||||
|
|
||||||
if (headers) {
|
if (headers) {
|
||||||
headers.forEach((value, key) => {
|
headers.forEach((value, key) => {
|
||||||
@@ -207,6 +258,8 @@ export function Dropzone({
|
|||||||
if (xhr.status === 200) {
|
if (xhr.status === 200) {
|
||||||
//setFileState(file.path, "uploaded", 1);
|
//setFileState(file.path, "uploaded", 1);
|
||||||
console.log("Upload complete");
|
console.log("Upload complete");
|
||||||
|
onUploaded?.(file);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = JSON.parse(xhr.responseText);
|
const response = JSON.parse(xhr.responseText);
|
||||||
|
|
||||||
@@ -252,6 +305,7 @@ export function Dropzone({
|
|||||||
setFileState(file.path, "deleting");
|
setFileState(file.path, "deleting");
|
||||||
await handleDelete(file);
|
await handleDelete(file);
|
||||||
removeFileFromState(file.path);
|
removeFileFromState(file.path);
|
||||||
|
onDeleted?.(file);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -262,54 +316,61 @@ export function Dropzone({
|
|||||||
placeholder?.show === true || !maxItems || (maxItems && files.length < maxItems)
|
placeholder?.show === true || !maxItems || (maxItems && files.length < maxItems)
|
||||||
);
|
);
|
||||||
|
|
||||||
const Component = DropzoneInner;
|
const renderProps: DropzoneRenderProps = {
|
||||||
|
wrapperRef: ref,
|
||||||
return (
|
inputProps: {
|
||||||
<Component
|
|
||||||
wrapperRef={ref}
|
|
||||||
inputProps={{
|
|
||||||
ref: inputRef,
|
ref: inputRef,
|
||||||
type: "file",
|
type: "file",
|
||||||
multiple: !maxItems || maxItems > 1,
|
multiple: !maxItems || maxItems > 1,
|
||||||
onChange: handleFileInputChange
|
onChange: handleFileInputChange
|
||||||
}}
|
},
|
||||||
state={{ files, isOver, showPlaceholder }}
|
state: {
|
||||||
actions={{ uploadFileProgress, deleteFile, openFileInput }}
|
files,
|
||||||
dropzoneProps={{ maxItems, placeholder, autoUpload }}
|
isOver,
|
||||||
/>
|
isOverAccepted,
|
||||||
);
|
showPlaceholder
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
uploadFile: uploadFileProgress,
|
||||||
|
deleteFile,
|
||||||
|
openFileInput
|
||||||
|
},
|
||||||
|
dropzoneProps: {
|
||||||
|
maxItems,
|
||||||
|
placeholder,
|
||||||
|
autoUpload
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return children ? children(renderProps) : <DropzoneInner {...renderProps} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DropzoneInner = ({
|
const DropzoneInner = ({
|
||||||
wrapperRef,
|
wrapperRef,
|
||||||
inputProps,
|
inputProps,
|
||||||
state: { files, isOver, showPlaceholder },
|
state: { files, isOver, isOverAccepted, showPlaceholder },
|
||||||
actions: { uploadFileProgress, deleteFile, openFileInput },
|
actions: { uploadFile, deleteFile, openFileInput },
|
||||||
dropzoneProps: { placeholder }
|
dropzoneProps: { placeholder }
|
||||||
}: DropzoneRenderProps) => {
|
}: DropzoneRenderProps) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={wrapperRef}
|
ref={wrapperRef}
|
||||||
/*data-drag-over={"1"}*/
|
className={twMerge(
|
||||||
data-drag-over={isOver ? "1" : undefined}
|
"dropzone w-full h-full align-start flex flex-col select-none",
|
||||||
className="dropzone data-[drag-over]:bg-green-200/10 w-full h-full align-start flex flex-col select-none"
|
isOver && isOverAccepted && "bg-green-200/10",
|
||||||
|
isOver && !isOverAccepted && "bg-red-200/40 cursor-not-allowed"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className="hidden">
|
<div className="hidden">
|
||||||
<input
|
<input {...inputProps} />
|
||||||
{...inputProps}
|
|
||||||
/*ref={inputRef}
|
|
||||||
type="file"
|
|
||||||
multiple={!maxItems || maxItems > 1}
|
|
||||||
onChange={handleFileInputChange}*/
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-1 flex-col">
|
<div className="flex flex-1 flex-col">
|
||||||
<div className="flex flex-row flex-wrap gap-2 md:gap-3">
|
<div className="flex flex-row flex-wrap gap-2 md:gap-3">
|
||||||
{files.map((file, i) => (
|
{files.map((file) => (
|
||||||
<Preview
|
<Preview
|
||||||
key={file.path}
|
key={file.path}
|
||||||
file={file}
|
file={file}
|
||||||
handleUpload={uploadFileProgress}
|
handleUpload={uploadFile}
|
||||||
handleDelete={deleteFile}
|
handleDelete={deleteFile}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -333,18 +394,29 @@ const UploadPlaceholder = ({ onClick, text = "Upload files" }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Wrapper = ({ file }: { file: FileState }) => {
|
export type PreviewComponentProps = {
|
||||||
|
file: FileState;
|
||||||
|
fallback?: (props: { file: FileState }) => JSX.Element;
|
||||||
|
className?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
onTouchStart?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Wrapper = ({ file, fallback, ...props }: PreviewComponentProps) => {
|
||||||
if (file.type.startsWith("image/")) {
|
if (file.type.startsWith("image/")) {
|
||||||
return <ImagePreview file={file} />;
|
return <ImagePreview {...props} file={file} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file.type.startsWith("video/")) {
|
if (file.type.startsWith("video/")) {
|
||||||
return <VideoPreview file={file} />;
|
return <VideoPreview {...props} file={file} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <FallbackPreview file={file} />;
|
return fallback ? fallback({ file }) : null;
|
||||||
};
|
};
|
||||||
const WrapperMemoized = memo(Wrapper, (prev, next) => prev.file.path === next.file.path);
|
export const PreviewWrapperMemoized = memo(
|
||||||
|
Wrapper,
|
||||||
|
(prev, next) => prev.file.path === next.file.path
|
||||||
|
);
|
||||||
|
|
||||||
type PreviewProps = {
|
type PreviewProps = {
|
||||||
file: FileState;
|
file: FileState;
|
||||||
@@ -370,7 +442,6 @@ const Preview: React.FC<PreviewProps> = ({ file, handleUpload, handleDelete }) =
|
|||||||
file.state === "deleting" && "opacity-70"
|
file.state === "deleting" && "opacity-70"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/*{file.state}*/}
|
|
||||||
<div className="absolute top-2 right-2">
|
<div className="absolute top-2 right-2">
|
||||||
<Dropdown items={dropdownItems} position="bottom-end">
|
<Dropdown items={dropdownItems} position="bottom-end">
|
||||||
<IconButton Icon={TbDots} />
|
<IconButton Icon={TbDots} />
|
||||||
@@ -385,7 +456,11 @@ const Preview: React.FC<PreviewProps> = ({ file, handleUpload, handleDelete }) =
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex bg-primary/5 aspect-[1/0.8] overflow-hidden items-center justify-center">
|
<div className="flex bg-primary/5 aspect-[1/0.8] overflow-hidden items-center justify-center">
|
||||||
<WrapperMemoized file={file} />
|
<PreviewWrapperMemoized
|
||||||
|
file={file}
|
||||||
|
fallback={FallbackPreview}
|
||||||
|
className="max-w-full max-h-full"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col px-1.5 py-1">
|
<div className="flex flex-col px-1.5 py-1">
|
||||||
<p className="truncate">{file.name}</p>
|
<p className="truncate">{file.name}</p>
|
||||||
@@ -398,14 +473,20 @@ const Preview: React.FC<PreviewProps> = ({ file, handleUpload, handleDelete }) =
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ImagePreview = ({ file }: { file: FileState }) => {
|
const ImagePreview = ({
|
||||||
|
file,
|
||||||
|
...props
|
||||||
|
}: { file: FileState } & ComponentPropsWithoutRef<"img">) => {
|
||||||
const objectUrl = typeof file.body === "string" ? file.body : URL.createObjectURL(file.body);
|
const objectUrl = typeof file.body === "string" ? file.body : URL.createObjectURL(file.body);
|
||||||
return <img className="max-w-full max-h-full" src={objectUrl} />;
|
return <img {...props} src={objectUrl} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const VideoPreview = ({ file }: { file: FileState }) => {
|
const VideoPreview = ({
|
||||||
|
file,
|
||||||
|
...props
|
||||||
|
}: { file: FileState } & ComponentPropsWithoutRef<"video">) => {
|
||||||
const objectUrl = typeof file.body === "string" ? file.body : URL.createObjectURL(file.body);
|
const objectUrl = typeof file.body === "string" ? file.body : URL.createObjectURL(file.body);
|
||||||
return <video src={objectUrl} />;
|
return <video {...props} src={objectUrl} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const FallbackPreview = ({ file }: { file: FileState }) => {
|
const FallbackPreview = ({ file }: { file: FileState }) => {
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import type { RepoQuery } from "data";
|
||||||
|
import type { MediaFieldSchema } from "media/AppMedia";
|
||||||
|
import type { TAppMediaConfig } from "media/media-schema";
|
||||||
|
import { useId } from "react";
|
||||||
|
import { useApi, useBaseUrl, useEntityQuery, useInvalidate } from "ui/client";
|
||||||
|
import { useEvent } from "ui/hooks/use-event";
|
||||||
|
import {
|
||||||
|
Dropzone,
|
||||||
|
type DropzoneProps,
|
||||||
|
type DropzoneRenderProps,
|
||||||
|
type FileState
|
||||||
|
} from "ui/modules/media/components/dropzone/Dropzone";
|
||||||
|
import { mediaItemsToFileStates } from "ui/modules/media/helper";
|
||||||
|
|
||||||
|
export type DropzoneContainerProps = {
|
||||||
|
children?: (props: DropzoneRenderProps) => JSX.Element;
|
||||||
|
initialItems?: MediaFieldSchema[];
|
||||||
|
entity?: {
|
||||||
|
name: string;
|
||||||
|
id: number;
|
||||||
|
field: string;
|
||||||
|
};
|
||||||
|
query?: Partial<RepoQuery>;
|
||||||
|
} & Partial<Pick<TAppMediaConfig, "basepath" | "entity_name" | "storage">> &
|
||||||
|
Partial<DropzoneProps>;
|
||||||
|
|
||||||
|
export function DropzoneContainer({
|
||||||
|
initialItems,
|
||||||
|
basepath = "/api/media",
|
||||||
|
storage = {},
|
||||||
|
entity_name = "media",
|
||||||
|
entity,
|
||||||
|
query,
|
||||||
|
...props
|
||||||
|
}: DropzoneContainerProps) {
|
||||||
|
const id = useId();
|
||||||
|
const baseUrl = useBaseUrl();
|
||||||
|
const api = useApi();
|
||||||
|
const invalidate = useInvalidate();
|
||||||
|
const limit = query?.limit ? query?.limit : props.maxItems ? props.maxItems : 50;
|
||||||
|
|
||||||
|
const $q = useEntityQuery(
|
||||||
|
entity_name as "media",
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
...query,
|
||||||
|
limit,
|
||||||
|
where: entity
|
||||||
|
? {
|
||||||
|
reference: `${entity.name}.${entity.field}`,
|
||||||
|
entity_id: entity.id,
|
||||||
|
...query?.where
|
||||||
|
}
|
||||||
|
: query?.where
|
||||||
|
},
|
||||||
|
{ enabled: !initialItems }
|
||||||
|
);
|
||||||
|
|
||||||
|
const getUploadInfo = useEvent((file) => {
|
||||||
|
const url = entity
|
||||||
|
? api.media.getEntityUploadUrl(entity.name, entity.id, entity.field)
|
||||||
|
: api.media.getFileUploadUrl(file);
|
||||||
|
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
headers: api.media.getUploadHeaders(),
|
||||||
|
method: "POST"
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const refresh = useEvent(async () => {
|
||||||
|
if (entity) {
|
||||||
|
invalidate((api) => api.data.readOne(entity.name, entity.id));
|
||||||
|
}
|
||||||
|
await $q.mutate();
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDelete = useEvent(async (file: FileState) => {
|
||||||
|
return api.media.deleteFile(file.path);
|
||||||
|
});
|
||||||
|
|
||||||
|
const actualItems = initialItems ?? (($q.data || []) as MediaFieldSchema[]);
|
||||||
|
const _initialItems = mediaItemsToFileStates(actualItems, { baseUrl });
|
||||||
|
|
||||||
|
const key = id + JSON.stringify(_initialItems);
|
||||||
|
return (
|
||||||
|
<Dropzone
|
||||||
|
key={id + key}
|
||||||
|
getUploadInfo={getUploadInfo}
|
||||||
|
handleDelete={handleDelete}
|
||||||
|
onUploaded={refresh}
|
||||||
|
onDeleted={refresh}
|
||||||
|
autoUpload
|
||||||
|
initialItems={_initialItems}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,15 +4,16 @@ import { type FileWithPath, fromEvent } from "./file-selector";
|
|||||||
type DropzoneProps = {
|
type DropzoneProps = {
|
||||||
onDropped: (files: FileWithPath[]) => void;
|
onDropped: (files: FileWithPath[]) => void;
|
||||||
onOver?: (items: DataTransferItem[]) => void;
|
onOver?: (items: DataTransferItem[]) => void;
|
||||||
|
onLeave?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const events = {
|
const events = {
|
||||||
enter: ["dragenter", "dragover", "dragstart"],
|
enter: ["dragenter", "dragover", "dragstart"],
|
||||||
leave: ["dragleave", "drop"],
|
leave: ["dragleave", "drop"]
|
||||||
};
|
};
|
||||||
const allEvents = [...events.enter, ...events.leave];
|
const allEvents = [...events.enter, ...events.leave];
|
||||||
|
|
||||||
export function useDropzone({ onDropped, onOver }: DropzoneProps) {
|
export function useDropzone({ onDropped, onOver, onLeave }: DropzoneProps) {
|
||||||
const [isOver, setIsOver] = useState(false);
|
const [isOver, setIsOver] = useState(false);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const onOverCalled = useRef(false);
|
const onOverCalled = useRef(false);
|
||||||
@@ -31,8 +32,10 @@ export function useDropzone({ onDropped, onOver }: DropzoneProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setIsOver(_isOver);
|
setIsOver(_isOver);
|
||||||
|
|
||||||
if (_isOver === false && onOverCalled.current) {
|
if (_isOver === false && onOverCalled.current) {
|
||||||
onOverCalled.current = false;
|
onOverCalled.current = false;
|
||||||
|
onLeave?.();
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -42,7 +45,7 @@ export function useDropzone({ onDropped, onOver }: DropzoneProps) {
|
|||||||
onDropped?.(files as any);
|
onDropped?.(files as any);
|
||||||
onOverCalled.current = false;
|
onOverCalled.current = false;
|
||||||
},
|
},
|
||||||
[onDropped],
|
[onDropped]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleFileInputChange = useCallback(
|
const handleFileInputChange = useCallback(
|
||||||
@@ -50,7 +53,7 @@ export function useDropzone({ onDropped, onOver }: DropzoneProps) {
|
|||||||
const files = await fromEvent(e);
|
const files = await fromEvent(e);
|
||||||
onDropped?.(files as any);
|
onDropped?.(files as any);
|
||||||
},
|
},
|
||||||
[onDropped],
|
[onDropped]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ export function AuthIndex() {
|
|||||||
config: { roles, strategies, entity_name, enabled }
|
config: { roles, strategies, entity_name, enabled }
|
||||||
} = useBkndAuth();
|
} = useBkndAuth();
|
||||||
const users_entity = entity_name;
|
const users_entity = entity_name;
|
||||||
const $q = useApiQuery((api) => api.data.count(users_entity));
|
const $q = useApiQuery((api) => api.data.count(users_entity), {
|
||||||
|
enabled
|
||||||
|
});
|
||||||
const usersTotal = $q.data?.count ?? 0;
|
const usersTotal = $q.data?.count ?? 0;
|
||||||
const rolesTotal = Object.keys(roles ?? {}).length ?? 0;
|
const rolesTotal = Object.keys(roles ?? {}).length ?? 0;
|
||||||
const strategiesTotal = Object.keys(strategies ?? {}).length ?? 0;
|
const strategiesTotal = Object.keys(strategies ?? {}).length ?? 0;
|
||||||
|
|||||||
@@ -1,81 +1,7 @@
|
|||||||
import type { AppAuthOAuthStrategy } from "auth/auth-schema";
|
|
||||||
import { ucFirstAllSnakeToPascalWithSpaces } from "core/utils";
|
|
||||||
import { transform } from "lodash-es";
|
|
||||||
import { useAuthStrategies } from "ui/client/schema/auth/use-auth";
|
|
||||||
import { Button } from "ui/components/buttons/Button";
|
|
||||||
import { Logo } from "ui/components/display/Logo";
|
|
||||||
import { Link } from "ui/components/wouter/Link";
|
|
||||||
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
||||||
import { LoginForm } from "ui/modules/auth/LoginForm";
|
import { AuthScreen } from "ui/modules/auth/AuthScreen";
|
||||||
import * as AppShell from "../../layouts/AppShell/AppShell";
|
|
||||||
|
|
||||||
export function AuthLogin() {
|
export function AuthLogin() {
|
||||||
useBrowserTitle(["Login"]);
|
useBrowserTitle(["Login"]);
|
||||||
const { strategies, basepath, loading } = useAuthStrategies();
|
return <AuthScreen action="login" />;
|
||||||
|
|
||||||
const oauth = transform(
|
|
||||||
strategies ?? {},
|
|
||||||
(result, value, key) => {
|
|
||||||
if (value.type !== "password") {
|
|
||||||
result[key] = value.config;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{}
|
|
||||||
) as Record<string, AppAuthOAuthStrategy>;
|
|
||||||
//console.log("oauth", oauth, strategies);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AppShell.Root>
|
|
||||||
<AppShell.Content center>
|
|
||||||
{!loading && (
|
|
||||||
<div className="flex flex-col gap-4 items-center w-96 px-6 py-7">
|
|
||||||
<Link href={"/"} className="link">
|
|
||||||
<Logo scale={0.25} />
|
|
||||||
</Link>
|
|
||||||
<div className="flex flex-col items-center">
|
|
||||||
<h1 className="text-xl font-bold">Sign in to your admin panel</h1>
|
|
||||||
<p className="text-primary/50">Enter your credentials below to get access.</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-4 w-full">
|
|
||||||
{Object.keys(oauth).length > 0 && (
|
|
||||||
<>
|
|
||||||
{Object.entries(oauth)?.map(([name, oauth], key) => (
|
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
action={`${basepath}/${name}/login`}
|
|
||||||
key={key}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
key={key}
|
|
||||||
type="submit"
|
|
||||||
size="large"
|
|
||||||
variant="outline"
|
|
||||||
className="justify-center w-full"
|
|
||||||
>
|
|
||||||
Continue with {ucFirstAllSnakeToPascalWithSpaces(oauth.name)}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div className="w-full flex flex-row items-center">
|
|
||||||
<div className="relative flex grow">
|
|
||||||
<div className="h-px bg-primary/10 w-full absolute top-[50%] z-0" />
|
|
||||||
</div>
|
|
||||||
<div className="mx-5">or</div>
|
|
||||||
<div className="relative flex grow">
|
|
||||||
<div className="h-px bg-primary/10 w-full absolute top-[50%] z-0" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<LoginForm action="/api/auth/password/login" />
|
|
||||||
{/*<a href="/auth/logout">Logout</a>*/}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</AppShell.Content>
|
|
||||||
</AppShell.Root>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
import { IconPhoto } from "@tabler/icons-react";
|
import { IconPhoto } from "@tabler/icons-react";
|
||||||
import type { MediaFieldSchema } from "modules";
|
|
||||||
import { TbSettings } from "react-icons/tb";
|
import { TbSettings } from "react-icons/tb";
|
||||||
import { useApi, useBaseUrl, useEntityQuery } from "ui/client";
|
|
||||||
import { useBknd } from "ui/client/BkndProvider";
|
import { useBknd } from "ui/client/BkndProvider";
|
||||||
import { IconButton } from "ui/components/buttons/IconButton";
|
import { IconButton } from "ui/components/buttons/IconButton";
|
||||||
import { Empty } from "ui/components/display/Empty";
|
import { Empty } from "ui/components/display/Empty";
|
||||||
import { Link } from "ui/components/wouter/Link";
|
import { Link } from "ui/components/wouter/Link";
|
||||||
|
import { Media } from "ui/elements";
|
||||||
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
||||||
import { useEvent } from "ui/hooks/use-event";
|
|
||||||
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||||
import { Dropzone, type FileState } from "ui/modules/media/components/dropzone/Dropzone";
|
|
||||||
import { mediaItemsToFileStates } from "ui/modules/media/helper";
|
|
||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
|
|
||||||
export function MediaRoot({ children }) {
|
export function MediaRoot({ children }) {
|
||||||
@@ -63,35 +59,11 @@ export function MediaRoot({ children }) {
|
|||||||
// @todo: add infinite load
|
// @todo: add infinite load
|
||||||
export function MediaEmpty() {
|
export function MediaEmpty() {
|
||||||
useBrowserTitle(["Media"]);
|
useBrowserTitle(["Media"]);
|
||||||
const baseUrl = useBaseUrl();
|
|
||||||
const api = useApi();
|
|
||||||
const $q = useEntityQuery("media", undefined, { limit: 50 });
|
|
||||||
|
|
||||||
const getUploadInfo = useEvent((file) => {
|
|
||||||
return {
|
|
||||||
url: api.media.getFileUploadUrl(file),
|
|
||||||
headers: api.media.getUploadHeaders(),
|
|
||||||
method: "POST"
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleDelete = useEvent(async (file: FileState) => {
|
|
||||||
return api.media.deleteFile(file.path);
|
|
||||||
});
|
|
||||||
|
|
||||||
const media = ($q.data || []) as MediaFieldSchema[];
|
|
||||||
const initialItems = mediaItemsToFileStates(media, { baseUrl });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell.Scrollable>
|
<AppShell.Scrollable>
|
||||||
<div className="flex flex-1 p-3">
|
<div className="flex flex-1 p-3">
|
||||||
<Dropzone
|
<Media.Dropzone />
|
||||||
key={$q.isLoading ? "loaded" : "initial"}
|
|
||||||
getUploadInfo={getUploadInfo}
|
|
||||||
handleDelete={handleDelete}
|
|
||||||
autoUpload
|
|
||||||
initialItems={initialItems}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</AppShell.Scrollable>
|
</AppShell.Scrollable>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import AppShellAccordionsTest from "ui/routes/test/tests/appshell-accordions-test";
|
import AppShellAccordionsTest from "ui/routes/test/tests/appshell-accordions-test";
|
||||||
|
import JsonSchemaFormReactTest from "ui/routes/test/tests/json-schema-form-react-test";
|
||||||
import SwaggerTest from "ui/routes/test/tests/swagger-test";
|
import SwaggerTest from "ui/routes/test/tests/swagger-test";
|
||||||
import SWRAndAPI from "ui/routes/test/tests/swr-and-api";
|
import SWRAndAPI from "ui/routes/test/tests/swr-and-api";
|
||||||
import SwrAndDataApi from "ui/routes/test/tests/swr-and-data-api";
|
import SwrAndDataApi from "ui/routes/test/tests/swr-and-data-api";
|
||||||
@@ -11,6 +12,7 @@ import FlowFormTest from "../../routes/test/tests/flow-form-test";
|
|||||||
import ModalTest from "../../routes/test/tests/modal-test";
|
import ModalTest from "../../routes/test/tests/modal-test";
|
||||||
import QueryJsonFormTest from "../../routes/test/tests/query-jsonform";
|
import QueryJsonFormTest from "../../routes/test/tests/query-jsonform";
|
||||||
import DropdownTest from "./tests/dropdown-test";
|
import DropdownTest from "./tests/dropdown-test";
|
||||||
|
import DropzoneElementTest from "./tests/dropzone-element-test";
|
||||||
import EntityFieldsForm from "./tests/entity-fields-form";
|
import EntityFieldsForm from "./tests/entity-fields-form";
|
||||||
import FlowsTest from "./tests/flows-test";
|
import FlowsTest from "./tests/flows-test";
|
||||||
import JsonFormTest from "./tests/jsonform-test";
|
import JsonFormTest from "./tests/jsonform-test";
|
||||||
@@ -41,7 +43,9 @@ const tests = {
|
|||||||
AppShellAccordionsTest,
|
AppShellAccordionsTest,
|
||||||
SwaggerTest,
|
SwaggerTest,
|
||||||
SWRAndAPI,
|
SWRAndAPI,
|
||||||
SwrAndDataApi
|
SwrAndDataApi,
|
||||||
|
DropzoneElementTest,
|
||||||
|
JsonSchemaFormReactTest
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export default function TestRoutes() {
|
export default function TestRoutes() {
|
||||||
|
|||||||
78
app/src/ui/routes/test/tests/dropzone-element-test.tsx
Normal file
78
app/src/ui/routes/test/tests/dropzone-element-test.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { type DropzoneRenderProps, Media } from "ui/elements";
|
||||||
|
import { Scrollable } from "ui/layouts/AppShell/AppShell";
|
||||||
|
|
||||||
|
export default function DropzoneElementTest() {
|
||||||
|
return (
|
||||||
|
<Scrollable>
|
||||||
|
<div className="flex flex-col w-full h-full p-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<b>Dropzone User Avatar 1 (fully customized)</b>
|
||||||
|
<Media.Dropzone
|
||||||
|
entity={{ name: "users", id: 1, field: "avatar" }}
|
||||||
|
maxItems={1}
|
||||||
|
overwrite
|
||||||
|
>
|
||||||
|
{(props) => <CustomUserAvatarDropzone {...props} />}
|
||||||
|
</Media.Dropzone>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<b>Dropzone User Avatar 1 (overwrite)</b>
|
||||||
|
<Media.Dropzone
|
||||||
|
entity={{ name: "users", id: 1, field: "avatar" }}
|
||||||
|
maxItems={1}
|
||||||
|
overwrite
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<b>Dropzone User Avatar 1</b>
|
||||||
|
<Media.Dropzone entity={{ name: "users", id: 1, field: "avatar" }} maxItems={1} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<b>Dropzone Container blank w/ query</b>
|
||||||
|
<Media.Dropzone query={{ limit: 2 }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<b>Dropzone Container blank</b>
|
||||||
|
<Media.Dropzone />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<b>Dropzone Post 12</b>
|
||||||
|
<Media.Dropzone entity={{ name: "posts", id: 12, field: "images" }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Scrollable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CustomUserAvatarDropzone({
|
||||||
|
wrapperRef,
|
||||||
|
inputProps,
|
||||||
|
state: { files, isOver, isOverAccepted, showPlaceholder },
|
||||||
|
actions: { openFileInput }
|
||||||
|
}: DropzoneRenderProps) {
|
||||||
|
const file = files[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={wrapperRef}
|
||||||
|
className="size-32 rounded-full border border-gray-200 flex justify-center items-center leading-none overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="hidden">
|
||||||
|
<input {...inputProps} />
|
||||||
|
</div>
|
||||||
|
{showPlaceholder && <>{isOver && isOverAccepted ? "let it drop" : "drop here"}</>}
|
||||||
|
{file && (
|
||||||
|
<Media.Preview
|
||||||
|
file={file}
|
||||||
|
className="object-cover w-full h-full"
|
||||||
|
onClick={openFileInput}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
app/src/ui/routes/test/tests/json-schema-form-react-test.tsx
Normal file
54
app/src/ui/routes/test/tests/json-schema-form-react-test.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { Form, type Validator } from "json-schema-form-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { type TSchema, Type } from "@sinclair/typebox";
|
||||||
|
import { Value, type ValueError } from "@sinclair/typebox/value";
|
||||||
|
|
||||||
|
class TypeboxValidator implements Validator<ValueError> {
|
||||||
|
async validate(schema: TSchema, data: any) {
|
||||||
|
return Value.Check(schema, data) ? [] : [...Value.Errors(schema, data)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const validator = new TypeboxValidator();
|
||||||
|
|
||||||
|
const schema = Type.Object({
|
||||||
|
name: Type.String(),
|
||||||
|
age: Type.Optional(Type.Number())
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function JsonSchemaFormReactTest() {
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Form
|
||||||
|
schema={schema}
|
||||||
|
onChange={setData}
|
||||||
|
onSubmit={setData}
|
||||||
|
validator={validator}
|
||||||
|
validationMode="change"
|
||||||
|
>
|
||||||
|
{({ errors, dirty, reset }) => (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<b>
|
||||||
|
Form {dirty ? "*" : ""} (valid: {errors.length === 0 ? "valid" : "invalid"})
|
||||||
|
</b>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input type="text" name="name" />
|
||||||
|
<input type="number" name="age" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button type="submit">submit</button>
|
||||||
|
<button type="button" onClick={reset}>
|
||||||
|
reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
|
<pre>{JSON.stringify(data, null, 2)}</pre>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,20 +1,16 @@
|
|||||||
import devServer from "@hono/vite-dev-server";
|
import devServer from "@hono/vite-dev-server";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import { defineConfig, loadEnv } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import tsconfigPaths from "vite-tsconfig-paths";
|
import tsconfigPaths from "vite-tsconfig-paths";
|
||||||
|
import { devServerConfig } from "./src/adapter/vite/dev-server-config";
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig(async () => {
|
export default defineConfig({
|
||||||
/**
|
|
||||||
* DEVELOPMENT MODE
|
|
||||||
*/
|
|
||||||
if (process.env.NODE_ENV === "development") {
|
|
||||||
return {
|
|
||||||
define: {
|
define: {
|
||||||
__isDev: "1"
|
__isDev: "1"
|
||||||
},
|
},
|
||||||
clearScreen: false,
|
clearScreen: false,
|
||||||
publicDir: "./src/admin/assets",
|
publicDir: "./src/ui/assets",
|
||||||
server: {
|
server: {
|
||||||
host: true,
|
host: true,
|
||||||
port: 28623,
|
port: 28623,
|
||||||
@@ -26,23 +22,15 @@ export default defineConfig(async () => {
|
|||||||
react(),
|
react(),
|
||||||
tsconfigPaths(),
|
tsconfigPaths(),
|
||||||
devServer({
|
devServer({
|
||||||
entry: "./vite.dev.ts",
|
...devServerConfig,
|
||||||
exclude: [
|
entry: "./vite.dev.ts"
|
||||||
// We need to override this option since the default setting doesn't fit
|
|
||||||
/.*\.tsx?($|\?)/,
|
|
||||||
/^(?!.*\/__admin).*\.(s?css|less)($|\?)/,
|
|
||||||
/^(?!.*\/api).*\.(svg|png)($|\?)/, // exclude except /api
|
|
||||||
/^\/@.+$/,
|
|
||||||
/^\/favicon\.ico$/,
|
|
||||||
/^\/(public|assets|static)\/.+/,
|
|
||||||
/^\/node_modules\/.*/
|
|
||||||
],
|
|
||||||
//injectClientScript: true
|
|
||||||
injectClientScript: false // This option is buggy, disable it and inject the code manually
|
|
||||||
})
|
})
|
||||||
]
|
],
|
||||||
};
|
build: {
|
||||||
|
manifest: true,
|
||||||
|
outDir: "./dist/static",
|
||||||
|
rollupOptions: {
|
||||||
|
input: "./src/ui/main.tsx"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error("Don't use vite for building in production");
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
import { serveStatic } from "@hono/node-server/serve-static";
|
import { serveStatic } from "@hono/node-server/serve-static";
|
||||||
import { createClient } from "@libsql/client/node";
|
import { createClient } from "@libsql/client/node";
|
||||||
import { App, registries } from "./src";
|
import { App, registries } from "./src";
|
||||||
@@ -6,7 +7,15 @@ import { StorageLocalAdapter } from "./src/media/storage/adapters/StorageLocalAd
|
|||||||
|
|
||||||
registries.media.register("local", StorageLocalAdapter);
|
registries.media.register("local", StorageLocalAdapter);
|
||||||
|
|
||||||
const credentials = {
|
const run_example: string | boolean = false;
|
||||||
|
//run_example = "ex-admin-rich";
|
||||||
|
|
||||||
|
const credentials = run_example
|
||||||
|
? {
|
||||||
|
url: `file:.configs/${run_example}.db`
|
||||||
|
//url: ":memory:"
|
||||||
|
}
|
||||||
|
: {
|
||||||
url: import.meta.env.VITE_DB_URL!,
|
url: import.meta.env.VITE_DB_URL!,
|
||||||
authToken: import.meta.env.VITE_DB_TOKEN!
|
authToken: import.meta.env.VITE_DB_TOKEN!
|
||||||
};
|
};
|
||||||
@@ -16,10 +25,20 @@ if (!credentials.url) {
|
|||||||
|
|
||||||
const connection = new LibsqlConnection(createClient(credentials));
|
const connection = new LibsqlConnection(createClient(credentials));
|
||||||
|
|
||||||
|
let initialConfig: any = undefined;
|
||||||
|
if (run_example) {
|
||||||
|
const { version, ...config } = JSON.parse(
|
||||||
|
await readFile(`.configs/${run_example}.json`, "utf-8")
|
||||||
|
);
|
||||||
|
initialConfig = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
let app: App;
|
||||||
|
const recreate = true;
|
||||||
export default {
|
export default {
|
||||||
async fetch(request: Request) {
|
async fetch(request: Request) {
|
||||||
const app = App.create({ connection });
|
if (!app || recreate) {
|
||||||
|
app = App.create({ connection, initialConfig });
|
||||||
app.emgr.onEvent(
|
app.emgr.onEvent(
|
||||||
App.Events.AppBuiltEvent,
|
App.Events.AppBuiltEvent,
|
||||||
async () => {
|
async () => {
|
||||||
@@ -29,6 +48,7 @@ export default {
|
|||||||
"sync"
|
"sync"
|
||||||
);
|
);
|
||||||
await app.build();
|
await app.build();
|
||||||
|
}
|
||||||
|
|
||||||
return app.fetch(request);
|
return app.fetch(request);
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 163 KiB |
116
docs/integration/vite.mdx
Normal file
116
docs/integration/vite.mdx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
---
|
||||||
|
title: 'Vite'
|
||||||
|
description: 'Run bknd inside Vite'
|
||||||
|
---
|
||||||
|
import InstallBknd from '/snippets/install-bknd.mdx';
|
||||||
|
|
||||||
|
Vite is a powerful toolkit to accelerate your local development.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
Create a new vite project by following the [official guide](https://vite.dev/guide/#scaffolding-your-first-vite-project)
|
||||||
|
and then install bknd as a dependency:
|
||||||
|
<InstallBknd />
|
||||||
|
|
||||||
|
Additionally, install required dependencies:
|
||||||
|
```bash
|
||||||
|
npm install @hono/vite-dev-server
|
||||||
|
```
|
||||||
|
|
||||||
|
## Serve the API
|
||||||
|
To serve the **bknd** API, you first have to create a local server file for you vite environment.
|
||||||
|
Create a `server.ts` file:
|
||||||
|
```ts
|
||||||
|
import { serve } from "bknd/adapter/vite";
|
||||||
|
|
||||||
|
// the configuration given is optional
|
||||||
|
export default serve({
|
||||||
|
mode: "cached", // that's the default
|
||||||
|
connection: {
|
||||||
|
type: "libsql",
|
||||||
|
config: {
|
||||||
|
url: ":memory:"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
For more information about the connection object, refer to the [Setup](/setup/introduction) guide.
|
||||||
|
|
||||||
|
You can also run your vite server in `mode: "fresh"`, this will re-create the app on every fetch.
|
||||||
|
This is only useful for when working on the `bknd` repository directly.
|
||||||
|
|
||||||
|
Next, adjust your `vite.config.ts` to look like the following:
|
||||||
|
```ts
|
||||||
|
import { devServer } from "bknd/adapter/vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
import tsconfigPaths from "vite-tsconfig-paths";
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
tsconfigPaths(),
|
||||||
|
devServer({
|
||||||
|
// point to your previously created server file
|
||||||
|
entry: "./server.ts"
|
||||||
|
})
|
||||||
|
]
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Now you can start your application using `npm run dev`. Now opening http://localhost:5174/
|
||||||
|
looks like an empty project. That's because we only registered the API, head over to
|
||||||
|
http://localhost:5174/api/system/config to see **bknd** respond.
|
||||||
|
|
||||||
|
## Serve the Admin UI
|
||||||
|
After adding the API, you can easily add the Admin UI by simply returning it in your `App.tsx`.
|
||||||
|
Replace all of its content with the following:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Admin } from "bknd/ui";
|
||||||
|
import "bknd/dist/styles.css";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return <Admin withProvider />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Now http://localhost:5174/ should give you the Admin UI.
|
||||||
|
|
||||||
|
## Customizations
|
||||||
|
This is just the bare minimum and may not always fulfill your requirements. There are a few
|
||||||
|
options you can make use of to adjust it according to your setup.
|
||||||
|
|
||||||
|
### Use custom HTML to serve the Admin UI
|
||||||
|
There might be cases you want to be sure to be in control over the HTML that is being used.
|
||||||
|
`bknd` generates it automatically, but you use your own one as follows:
|
||||||
|
|
||||||
|
```ts server.ts
|
||||||
|
import { serve, addViteScript } from "bknd/adapter/vite";
|
||||||
|
import { readFile } from "node:fs/promises"
|
||||||
|
|
||||||
|
let html = await readFile("./index.html", "utf-8");
|
||||||
|
|
||||||
|
// add vite scripts
|
||||||
|
html = addViteScript(html);
|
||||||
|
|
||||||
|
// then add it as an option
|
||||||
|
export default serve({ html })
|
||||||
|
```
|
||||||
|
|
||||||
|
The vite scripts has to be added manually currently, as adding them automatically with
|
||||||
|
`@hono/vite-dev-server` is buggy. This may change in the future.
|
||||||
|
|
||||||
|
### Use a custom entry point
|
||||||
|
By default, the entry point `/src/main.tsx` is used and should fit most cases. If that's not you,
|
||||||
|
you can supply a different one like so:
|
||||||
|
```ts server.ts
|
||||||
|
import { serve } from "bknd/adapter/vite";
|
||||||
|
|
||||||
|
// the configuration given is optional
|
||||||
|
export default serve({
|
||||||
|
forceDev: {
|
||||||
|
mainPath: "/src/special.tsx"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
@@ -82,6 +82,15 @@ in the future, so stay tuned!
|
|||||||
</div>}
|
</div>}
|
||||||
href="/integration/node"
|
href="/integration/node"
|
||||||
/>
|
/>
|
||||||
|
<Card
|
||||||
|
title="Vite"
|
||||||
|
icon={<div className="text-primary-light">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24">
|
||||||
|
<rect width="24" height="24" fill="none"/><path fill="currentColor" d="m8.525 4.63l-5.132-.915a1.17 1.17 0 0 0-1.164.468a1.16 1.16 0 0 0-.07 1.28l8.901 15.58a1.182 1.182 0 0 0 2.057-.008l8.729-15.578c.49-.875-.262-1.917-1.242-1.739l-4.574.813l-.206.754l4.906-.871a.474.474 0 0 1 .498.697L12.5 20.689a.47.47 0 0 1-.5.234a.47.47 0 0 1-.326-.231L2.772 5.112a.474.474 0 0 1 .496-.7l5.133.916l.074.013z"/><path fill="currentColor" d="m15.097 5.26l.162-.593l-.6.107zm-5.88-.506l.513.09l-.542.427z"/><path fill="currentColor" d="m15.549 2.367l-6.1 1.26a.22.22 0 0 0-.126.077a.25.25 0 0 0-.055.142l-.375 6.685a.24.24 0 0 0 .079.194a.21.21 0 0 0 .195.05l1.698-.414c.16-.038.302.11.27.278l-.505 2.606c-.034.176.122.326.285.274l1.049-.336c.162-.052.319.098.284.274l-.801 4.093c-.05.257.272.396.407.177l.09-.147l4.97-10.464c.084-.175-.06-.375-.242-.338l-1.748.356c-.165.034-.304-.128-.258-.297l1.14-4.173c.047-.17-.093-.331-.257-.297"/>
|
||||||
|
</svg>
|
||||||
|
</div>}
|
||||||
|
href="/integration/vite"
|
||||||
|
/>
|
||||||
<Card
|
<Card
|
||||||
title="Docker"
|
title="Docker"
|
||||||
icon={<div className="text-primary-light">
|
icon={<div className="text-primary-light">
|
||||||
|
|||||||
@@ -89,6 +89,7 @@
|
|||||||
"integration/astro",
|
"integration/astro",
|
||||||
"integration/node",
|
"integration/node",
|
||||||
"integration/deno",
|
"integration/deno",
|
||||||
|
"integration/vite",
|
||||||
"integration/docker"
|
"integration/docker"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"deploy": "wrangler deploy",
|
"deploy": "wrangler deploy",
|
||||||
|
"db": "turso dev --db-file test.db",
|
||||||
"dev": "wrangler dev",
|
"dev": "wrangler dev",
|
||||||
"start": "wrangler dev",
|
"start": "wrangler dev",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
import { App } from "bknd";
|
||||||
import { serve } from "bknd/adapter/nextjs";
|
import { serve } from "bknd/adapter/nextjs";
|
||||||
|
import { boolean, em, entity, text } from "bknd/data";
|
||||||
|
import { secureRandomString } from "bknd/utils";
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
runtime: "edge",
|
runtime: "edge",
|
||||||
@@ -9,11 +12,60 @@ export const config = {
|
|||||||
unstable_allowDynamic: ["**/*.js"]
|
unstable_allowDynamic: ["**/*.js"]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// the em() function makes it easy to create an initial schema
|
||||||
|
const schema = em({
|
||||||
|
todos: entity("todos", {
|
||||||
|
title: text(),
|
||||||
|
done: boolean()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// register your schema to get automatic type completion
|
||||||
|
type Database = (typeof schema)["DB"];
|
||||||
|
declare module "bknd/core" {
|
||||||
|
interface DB extends Database {}
|
||||||
|
}
|
||||||
|
|
||||||
export default serve({
|
export default serve({
|
||||||
|
// we can use any libsql config, and if omitted, uses in-memory
|
||||||
connection: {
|
connection: {
|
||||||
type: "libsql",
|
type: "libsql",
|
||||||
config: {
|
config: {
|
||||||
url: "http://localhost:8080"
|
url: "http://localhost:8080"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
// an initial config is only applied if the database is empty
|
||||||
|
initialConfig: {
|
||||||
|
data: schema.toJSON(),
|
||||||
|
// we're enabling auth ...
|
||||||
|
auth: {
|
||||||
|
enabled: true,
|
||||||
|
jwt: {
|
||||||
|
secret: secureRandomString(64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
// the seed option is only executed if the database was empty
|
||||||
|
seed: async (ctx) => {
|
||||||
|
await ctx.em.mutator("todos").insertMany([
|
||||||
|
{ title: "Learn bknd", done: true },
|
||||||
|
{ title: "Build something cool", done: false }
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// here we can hook into the app lifecycle events ...
|
||||||
|
beforeBuild: async (app) => {
|
||||||
|
app.emgr.onEvent(
|
||||||
|
App.Events.AppFirstBoot,
|
||||||
|
async () => {
|
||||||
|
// ... to create an initial user
|
||||||
|
await app.module.auth.createUser({
|
||||||
|
email: "ds@bknd.io",
|
||||||
|
password: "12345678"
|
||||||
|
});
|
||||||
|
},
|
||||||
|
"sync"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export const meta: MetaFunction = () => {
|
|||||||
|
|
||||||
export const loader = async (args: LoaderFunctionArgs) => {
|
export const loader = async (args: LoaderFunctionArgs) => {
|
||||||
const api = args.context.api;
|
const api = args.context.api;
|
||||||
const user = (await api.getVerifiedAuthState()).user;
|
const user = (await api.getVerifiedAuthState(true)).user;
|
||||||
const { data } = await api.data.readMany("todos");
|
const { data } = await api.data.readMany("todos");
|
||||||
return { data, user };
|
return { data, user };
|
||||||
};
|
};
|
||||||
|
|||||||
125
tmp/lazy_codemirror.patch
Normal file
125
tmp/lazy_codemirror.patch
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
Subject: [PATCH] lazy codemirror
|
||||||
|
---
|
||||||
|
Index: app/src/ui/components/code/LiquidJsEditor.tsx
|
||||||
|
IDEA additional info:
|
||||||
|
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
|
||||||
|
<+>UTF-8
|
||||||
|
===================================================================
|
||||||
|
diff --git a/app/src/ui/components/code/LiquidJsEditor.tsx b/app/src/ui/components/code/LiquidJsEditor.tsx
|
||||||
|
--- a/app/src/ui/components/code/LiquidJsEditor.tsx (revision b1a32f370565aded3a34b79ffd254c3c45d1085c)
|
||||||
|
+++ b/app/src/ui/components/code/LiquidJsEditor.tsx (date 1736687726081)
|
||||||
|
@@ -1,7 +1,7 @@
|
||||||
|
-import { liquid } from "@codemirror/lang-liquid";
|
||||||
|
-import type { ReactCodeMirrorProps } from "@uiw/react-codemirror";
|
||||||
|
import { Suspense, lazy } from "react";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
+
|
||||||
|
+import type { CodeEditorProps } from "./CodeEditor";
|
||||||
|
const CodeEditor = lazy(() => import("./CodeEditor"));
|
||||||
|
|
||||||
|
const filters = [
|
||||||
|
@@ -106,7 +106,7 @@
|
||||||
|
{ label: "when" }
|
||||||
|
];
|
||||||
|
|
||||||
|
-export function LiquidJsEditor({ editable, ...props }: ReactCodeMirrorProps) {
|
||||||
|
+export function LiquidJsEditor({ editable, ...props }: CodeEditorProps) {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<CodeEditor
|
||||||
|
@@ -115,7 +115,9 @@
|
||||||
|
!editable && "opacity-70"
|
||||||
|
)}
|
||||||
|
editable={editable}
|
||||||
|
- extensions={[liquid({ filters, tags })]}
|
||||||
|
+ _extensions={{
|
||||||
|
+ liquid: { filters, tags }
|
||||||
|
+ }}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
Index: app/src/ui/components/code/CodeEditor.tsx
|
||||||
|
IDEA additional info:
|
||||||
|
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
|
||||||
|
<+>UTF-8
|
||||||
|
===================================================================
|
||||||
|
diff --git a/app/src/ui/components/code/CodeEditor.tsx b/app/src/ui/components/code/CodeEditor.tsx
|
||||||
|
--- a/app/src/ui/components/code/CodeEditor.tsx (revision b1a32f370565aded3a34b79ffd254c3c45d1085c)
|
||||||
|
+++ b/app/src/ui/components/code/CodeEditor.tsx (date 1736687634668)
|
||||||
|
@@ -1,8 +1,22 @@
|
||||||
|
import { default as CodeMirror, type ReactCodeMirrorProps } from "@uiw/react-codemirror";
|
||||||
|
-
|
||||||
|
import { useBknd } from "ui/client/bknd";
|
||||||
|
|
||||||
|
-export default function CodeEditor({ editable, basicSetup, ...props }: ReactCodeMirrorProps) {
|
||||||
|
+import { json } from "@codemirror/lang-json";
|
||||||
|
+import { type LiquidCompletionConfig, liquid } from "@codemirror/lang-liquid";
|
||||||
|
+
|
||||||
|
+export type CodeEditorProps = ReactCodeMirrorProps & {
|
||||||
|
+ _extensions?: Partial<{
|
||||||
|
+ json: boolean;
|
||||||
|
+ liquid: LiquidCompletionConfig;
|
||||||
|
+ }>;
|
||||||
|
+};
|
||||||
|
+
|
||||||
|
+export default function CodeEditor({
|
||||||
|
+ editable,
|
||||||
|
+ basicSetup,
|
||||||
|
+ _extensions = {},
|
||||||
|
+ ...props
|
||||||
|
+}: CodeEditorProps) {
|
||||||
|
const b = useBknd();
|
||||||
|
const theme = b.app.getAdminConfig().color_scheme;
|
||||||
|
const _basicSetup: Partial<ReactCodeMirrorProps["basicSetup"]> = !editable
|
||||||
|
@@ -13,11 +27,21 @@
|
||||||
|
}
|
||||||
|
: basicSetup;
|
||||||
|
|
||||||
|
+ const extensions = Object.entries(_extensions ?? {}).map(([ext, config]: any) => {
|
||||||
|
+ switch (ext) {
|
||||||
|
+ case "json":
|
||||||
|
+ return json();
|
||||||
|
+ case "liquid":
|
||||||
|
+ return liquid(config);
|
||||||
|
+ }
|
||||||
|
+ });
|
||||||
|
+
|
||||||
|
return (
|
||||||
|
<CodeMirror
|
||||||
|
theme={theme === "dark" ? "dark" : "light"}
|
||||||
|
editable={editable}
|
||||||
|
basicSetup={_basicSetup}
|
||||||
|
+ extensions={extensions}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
Index: app/src/ui/components/code/JsonEditor.tsx
|
||||||
|
IDEA additional info:
|
||||||
|
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
|
||||||
|
<+>UTF-8
|
||||||
|
===================================================================
|
||||||
|
diff --git a/app/src/ui/components/code/JsonEditor.tsx b/app/src/ui/components/code/JsonEditor.tsx
|
||||||
|
--- a/app/src/ui/components/code/JsonEditor.tsx (revision b1a32f370565aded3a34b79ffd254c3c45d1085c)
|
||||||
|
+++ b/app/src/ui/components/code/JsonEditor.tsx (date 1736687681965)
|
||||||
|
@@ -1,10 +1,9 @@
|
||||||
|
-import { json } from "@codemirror/lang-json";
|
||||||
|
-import type { ReactCodeMirrorProps } from "@uiw/react-codemirror";
|
||||||
|
import { Suspense, lazy } from "react";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
+import type { CodeEditorProps } from "./CodeEditor";
|
||||||
|
const CodeEditor = lazy(() => import("./CodeEditor"));
|
||||||
|
|
||||||
|
-export function JsonEditor({ editable, className, ...props }: ReactCodeMirrorProps) {
|
||||||
|
+export function JsonEditor({ editable, className, ...props }: CodeEditorProps) {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<CodeEditor
|
||||||
|
@@ -14,7 +13,7 @@
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
editable={editable}
|
||||||
|
- extensions={[json()]}
|
||||||
|
+ _extensions={{ json: true }}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
Reference in New Issue
Block a user