mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 04:27:21 +00:00
Merge pull request #3 from bknd-io/feat/auth-improvements
Feat: Auth improvements
This commit is contained in:
@@ -1,8 +1,9 @@
|
|||||||
/*import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { decodeJwt, jwtVerify } from "jose";
|
import { Authenticator, type User, type UserPool } from "../../src/auth";
|
||||||
import { Authenticator, type User, type UserPool } from "../authenticate/Authenticator";
|
import { cookieConfig } from "../../src/auth/authenticate/Authenticator";
|
||||||
import { PasswordStrategy } from "../authenticate/strategies/PasswordStrategy";
|
import { PasswordStrategy } from "../../src/auth/authenticate/strategies/PasswordStrategy";
|
||||||
import * as hash from "../utils/hash";*/
|
import * as hash from "../../src/auth/utils/hash";
|
||||||
|
import { Default, parse } from "../../src/core/utils";
|
||||||
|
|
||||||
/*class MemoryUserPool implements UserPool {
|
/*class MemoryUserPool implements UserPool {
|
||||||
constructor(private users: User[] = []) {}
|
constructor(private users: User[] = []) {}
|
||||||
@@ -17,10 +18,14 @@ import * as hash from "../utils/hash";*/
|
|||||||
this.users.push(newUser);
|
this.users.push(newUser);
|
||||||
return newUser;
|
return newUser;
|
||||||
}
|
}
|
||||||
}
|
}*/
|
||||||
|
|
||||||
describe("Authenticator", async () => {
|
describe("Authenticator", async () => {
|
||||||
const userpool = new MemoryUserPool([
|
test("cookie options", async () => {
|
||||||
|
console.log("parsed", parse(cookieConfig, undefined));
|
||||||
|
console.log(Default(cookieConfig, {}));
|
||||||
|
});
|
||||||
|
/*const userpool = new MemoryUserPool([
|
||||||
{ id: 1, email: "d", username: "test", password: await hash.sha256("test") },
|
{ id: 1, email: "d", username: "test", password: await hash.sha256("test") },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -37,5 +42,5 @@ describe("Authenticator", async () => {
|
|||||||
const { iat, ...decoded } = decodeJwt<any>(token);
|
const { iat, ...decoded } = decodeJwt<any>(token);
|
||||||
expect(decoded).toEqual({ id: 1, email: "d", username: "test" });
|
expect(decoded).toEqual({ id: 1, email: "d", username: "test" });
|
||||||
expect(await auth.verify(token)).toBe(true);
|
expect(await auth.verify(token)).toBe(true);
|
||||||
});
|
|
||||||
});*/
|
});*/
|
||||||
|
});
|
||||||
|
|||||||
@@ -65,11 +65,11 @@ describe("SchemaObject", async () => {
|
|||||||
expect(m.get()).toEqual({ methods: ["GET", "PATCH"] });
|
expect(m.get()).toEqual({ methods: ["GET", "PATCH"] });
|
||||||
|
|
||||||
// array values are fully overwritten, whether accessed by index ...
|
// array values are fully overwritten, whether accessed by index ...
|
||||||
m.patch("methods[0]", "POST");
|
await m.patch("methods[0]", "POST");
|
||||||
expect(m.get()).toEqual({ methods: ["POST"] });
|
expect(m.get().methods[0]).toEqual("POST");
|
||||||
|
|
||||||
// or by path!
|
// or by path!
|
||||||
m.patch("methods", ["GET", "DELETE"]);
|
await m.patch("methods", ["GET", "DELETE"]);
|
||||||
expect(m.get()).toEqual({ methods: ["GET", "DELETE"] });
|
expect(m.get()).toEqual({ methods: ["GET", "DELETE"] });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -93,15 +93,15 @@ describe("SchemaObject", async () => {
|
|||||||
expect(m.get()).toEqual({ s: { a: "b", b: { c: "d" } } });
|
expect(m.get()).toEqual({ s: { a: "b", b: { c: "d" } } });
|
||||||
|
|
||||||
// expect no change, because the default then applies
|
// expect no change, because the default then applies
|
||||||
m.remove("s.a");
|
await m.remove("s.a");
|
||||||
expect(m.get()).toEqual({ s: { a: "b", b: { c: "d" } } });
|
expect(m.get()).toEqual({ s: { a: "b", b: { c: "d" } } });
|
||||||
|
|
||||||
// adding another path, and then deleting it
|
// adding another path, and then deleting it
|
||||||
m.patch("s.c", "d");
|
await m.patch("s.c", "d");
|
||||||
expect(m.get()).toEqual({ s: { a: "b", b: { c: "d" }, c: "d" } } as any);
|
expect(m.get()).toEqual({ s: { a: "b", b: { c: "d" }, c: "d" } } as any);
|
||||||
|
|
||||||
// now it should be removed without applying again
|
// now it should be removed without applying again
|
||||||
m.remove("s.c");
|
await m.remove("s.c");
|
||||||
expect(m.get()).toEqual({ s: { a: "b", b: { c: "d" } } });
|
expect(m.get()).toEqual({ s: { a: "b", b: { c: "d" } } });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -113,14 +113,14 @@ describe("SchemaObject", async () => {
|
|||||||
);
|
);
|
||||||
expect(m.get()).toEqual({ methods: ["GET", "PATCH"] });
|
expect(m.get()).toEqual({ methods: ["GET", "PATCH"] });
|
||||||
|
|
||||||
m.set({ methods: ["GET", "POST"] });
|
await m.set({ methods: ["GET", "POST"] });
|
||||||
expect(m.get()).toEqual({ methods: ["GET", "POST"] });
|
expect(m.get()).toEqual({ methods: ["GET", "POST"] });
|
||||||
|
|
||||||
// wrong type
|
// wrong type
|
||||||
expect(() => m.set({ methods: [1] as any })).toThrow();
|
expect(() => m.set({ methods: [1] as any })).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("listener", async () => {
|
test("listener: onUpdate", async () => {
|
||||||
let called = false;
|
let called = false;
|
||||||
let result: any;
|
let result: any;
|
||||||
const m = new SchemaObject(
|
const m = new SchemaObject(
|
||||||
@@ -142,6 +142,30 @@ describe("SchemaObject", async () => {
|
|||||||
expect(result).toEqual({ methods: ["GET", "POST"] });
|
expect(result).toEqual({ methods: ["GET", "POST"] });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("listener: onBeforeUpdate", async () => {
|
||||||
|
let called = false;
|
||||||
|
const m = new SchemaObject(
|
||||||
|
Type.Object({
|
||||||
|
methods: Type.Array(Type.String(), { default: ["GET", "PATCH"] })
|
||||||
|
}),
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
onBeforeUpdate: async (from, to) => {
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
called = true;
|
||||||
|
to.methods.push("OPTIONS");
|
||||||
|
return to;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await m.set({ methods: ["GET", "POST"] });
|
||||||
|
expect(called).toBe(true);
|
||||||
|
expect(result).toEqual({ methods: ["GET", "POST", "OPTIONS"] });
|
||||||
|
const [, result2] = await m.patch("methods", ["GET", "POST"]);
|
||||||
|
expect(result2).toEqual({ methods: ["GET", "POST", "OPTIONS"] });
|
||||||
|
});
|
||||||
|
|
||||||
test("throwIfRestricted", async () => {
|
test("throwIfRestricted", async () => {
|
||||||
const m = new SchemaObject(Type.Object({}), undefined, {
|
const m = new SchemaObject(Type.Object({}), undefined, {
|
||||||
restrictPaths: ["a.b"]
|
restrictPaths: ["a.b"]
|
||||||
@@ -175,9 +199,9 @@ describe("SchemaObject", async () => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(() => m.patch("s.b.c", "e")).toThrow();
|
expect(m.patch("s.b.c", "e")).rejects.toThrow();
|
||||||
expect(m.bypass().patch("s.b.c", "e")).toBeDefined();
|
expect(m.bypass().patch("s.b.c", "e")).resolves.toBeDefined();
|
||||||
expect(() => m.patch("s.b.c", "f")).toThrow();
|
expect(m.patch("s.b.c", "f")).rejects.toThrow();
|
||||||
expect(m.get()).toEqual({ s: { a: "b", b: { c: "e" } } });
|
expect(m.get()).toEqual({ s: { a: "b", b: { c: "e" } } });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -222,7 +246,7 @@ describe("SchemaObject", async () => {
|
|||||||
overwritePaths: [/^entities\..*\.fields\..*\.config/]
|
overwritePaths: [/^entities\..*\.fields\..*\.config/]
|
||||||
});
|
});
|
||||||
|
|
||||||
m.patch("entities.some.fields.a", { type: "string", config: { another: "one" } });
|
await m.patch("entities.some.fields.a", { type: "string", config: { another: "one" } });
|
||||||
|
|
||||||
expect(m.get()).toEqual({
|
expect(m.get()).toEqual({
|
||||||
entities: {
|
entities: {
|
||||||
@@ -251,7 +275,7 @@ describe("SchemaObject", async () => {
|
|||||||
overwritePaths: [/^entities\..*\.fields\..*\.config\.html_config$/]
|
overwritePaths: [/^entities\..*\.fields\..*\.config\.html_config$/]
|
||||||
});
|
});
|
||||||
|
|
||||||
m.patch("entities.test", {
|
await m.patch("entities.test", {
|
||||||
fields: {
|
fields: {
|
||||||
content: {
|
content: {
|
||||||
type: "text"
|
type: "text"
|
||||||
@@ -296,7 +320,7 @@ describe("SchemaObject", async () => {
|
|||||||
|
|
||||||
expect(m.patch("desc", "entities.users.config.sort_dir")).rejects.toThrow();
|
expect(m.patch("desc", "entities.users.config.sort_dir")).rejects.toThrow();
|
||||||
|
|
||||||
m.patch("entities.test", {
|
await m.patch("entities.test", {
|
||||||
fields: {
|
fields: {
|
||||||
content: {
|
content: {
|
||||||
type: "text"
|
type: "text"
|
||||||
@@ -304,7 +328,7 @@ describe("SchemaObject", async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
m.patch("entities.users.config", {
|
await m.patch("entities.users.config", {
|
||||||
sort_dir: "desc"
|
sort_dir: "desc"
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -19,10 +19,23 @@ describe("AppAuth", () => {
|
|||||||
await auth.build();
|
await auth.build();
|
||||||
|
|
||||||
const config = auth.toJSON();
|
const config = auth.toJSON();
|
||||||
expect(config.jwt.secret).toBeUndefined();
|
expect(config.jwt).toBeUndefined();
|
||||||
expect(config.strategies.password.config).toBeUndefined();
|
expect(config.strategies.password.config).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("enabling auth: generate secret", async () => {
|
||||||
|
const auth = new AppAuth(undefined, ctx);
|
||||||
|
await auth.build();
|
||||||
|
|
||||||
|
const oldConfig = auth.toJSON(true);
|
||||||
|
//console.log(oldConfig);
|
||||||
|
await auth.schema().patch("enabled", true);
|
||||||
|
await auth.build();
|
||||||
|
const newConfig = auth.toJSON(true);
|
||||||
|
//console.log(newConfig);
|
||||||
|
expect(newConfig.jwt.secret).not.toBe(oldConfig.jwt.secret);
|
||||||
|
});
|
||||||
|
|
||||||
test("creates user on register", async () => {
|
test("creates user on register", async () => {
|
||||||
const auth = new AppAuth(
|
const auth = new AppAuth(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ export default {
|
|||||||
connection: {
|
connection: {
|
||||||
type: "libsql",
|
type: "libsql",
|
||||||
config: {
|
config: {
|
||||||
url: "http://localhost:8080"
|
//url: "http://localhost:8080"
|
||||||
|
url: ":memory:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import process from "node:process";
|
|
||||||
import { $ } from "bun";
|
|
||||||
import * as esbuild from "esbuild";
|
|
||||||
import type { BuildOptions } from "esbuild";
|
|
||||||
|
|
||||||
const isDev = process.env.NODE_ENV !== "production";
|
|
||||||
|
|
||||||
const metafile = true;
|
|
||||||
const sourcemap = false;
|
|
||||||
|
|
||||||
const config: BuildOptions = {
|
|
||||||
entryPoints: ["worker.ts"],
|
|
||||||
bundle: true,
|
|
||||||
format: "esm",
|
|
||||||
external: ["__STATIC_CONTENT_MANIFEST", "@xyflow/react"],
|
|
||||||
platform: "browser",
|
|
||||||
conditions: ["worker", "browser"],
|
|
||||||
target: "es2022",
|
|
||||||
sourcemap,
|
|
||||||
metafile,
|
|
||||||
minify: !isDev,
|
|
||||||
loader: {
|
|
||||||
".html": "copy"
|
|
||||||
},
|
|
||||||
outfile: "dist/worker.js"
|
|
||||||
};
|
|
||||||
|
|
||||||
const dist = config.outfile!.split("/")[0];
|
|
||||||
if (!isDev) {
|
|
||||||
await $`rm -rf ${dist}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await esbuild.build(config);
|
|
||||||
|
|
||||||
if (result.metafile) {
|
|
||||||
console.log("writing metafile to", `${dist}/meta.json`);
|
|
||||||
await Bun.write(`${dist}/meta.json`, JSON.stringify(result.metafile!));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isDev) {
|
|
||||||
await $`gzip ${dist}/worker.js -c > ${dist}/worker.js.gz`;
|
|
||||||
}
|
|
||||||
257
app/build.esbuild.ts
Normal file
257
app/build.esbuild.ts
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
import { $, type Subprocess } from "bun";
|
||||||
|
import * as esbuild from "esbuild";
|
||||||
|
import postcss from "esbuild-postcss";
|
||||||
|
import { entryOutputMeta } from "./internal/esbuild.entry-output-meta.plugin";
|
||||||
|
import { guessMimeType } from "./src/media/storage/mime-types";
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const watch = args.includes("--watch");
|
||||||
|
const minify = args.includes("--minify");
|
||||||
|
const types = args.includes("--types");
|
||||||
|
const sourcemap = args.includes("--sourcemap");
|
||||||
|
|
||||||
|
type BuildOptions = esbuild.BuildOptions & { name: string };
|
||||||
|
|
||||||
|
const baseOptions: Partial<Omit<esbuild.BuildOptions, "plugins">> & { plugins?: any[] } = {
|
||||||
|
minify,
|
||||||
|
sourcemap,
|
||||||
|
metafile: true,
|
||||||
|
format: "esm",
|
||||||
|
drop: ["console", "debugger"],
|
||||||
|
loader: {
|
||||||
|
".svg": "dataurl"
|
||||||
|
},
|
||||||
|
define: {
|
||||||
|
__isDev: "0"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
type BuildFn = (format?: "esm" | "cjs") => BuildOptions;
|
||||||
|
|
||||||
|
// build BE
|
||||||
|
const builds: Record<string, BuildFn> = {
|
||||||
|
backend: (format = "esm") => ({
|
||||||
|
...baseOptions,
|
||||||
|
name: `backend ${format}`,
|
||||||
|
entryPoints: [
|
||||||
|
"src/index.ts",
|
||||||
|
"src/data/index.ts",
|
||||||
|
"src/core/index.ts",
|
||||||
|
"src/core/utils/index.ts",
|
||||||
|
"src/ui/index.ts",
|
||||||
|
"src/ui/main.css"
|
||||||
|
],
|
||||||
|
outdir: "dist",
|
||||||
|
outExtension: { ".js": format === "esm" ? ".js" : ".cjs" },
|
||||||
|
platform: "browser",
|
||||||
|
splitting: false,
|
||||||
|
bundle: true,
|
||||||
|
plugins: [postcss()],
|
||||||
|
//target: "es2022",
|
||||||
|
format
|
||||||
|
}),
|
||||||
|
/*components: (format = "esm") => ({
|
||||||
|
...baseOptions,
|
||||||
|
name: `components ${format}`,
|
||||||
|
entryPoints: ["src/ui/index.ts", "src/ui/main.css"],
|
||||||
|
outdir: "dist/ui",
|
||||||
|
outExtension: { ".js": format === "esm" ? ".js" : ".cjs" },
|
||||||
|
format,
|
||||||
|
platform: "browser",
|
||||||
|
splitting: false,
|
||||||
|
//target: "es2022",
|
||||||
|
bundle: true,
|
||||||
|
//external: ["react", "react-dom", "@tanstack/react-query-devtools"],
|
||||||
|
plugins: [postcss()],
|
||||||
|
loader: {
|
||||||
|
".svg": "dataurl",
|
||||||
|
".js": "jsx"
|
||||||
|
}
|
||||||
|
}),*/
|
||||||
|
static: (format = "esm") => ({
|
||||||
|
...baseOptions,
|
||||||
|
name: `static ${format}`,
|
||||||
|
entryPoints: ["src/ui/main.tsx", "src/ui/main.css"],
|
||||||
|
entryNames: "[dir]/[name]-[hash]",
|
||||||
|
outdir: "dist/static",
|
||||||
|
outExtension: { ".js": format === "esm" ? ".js" : ".cjs" },
|
||||||
|
platform: "browser",
|
||||||
|
bundle: true,
|
||||||
|
splitting: true,
|
||||||
|
inject: ["src/ui/inject.js"],
|
||||||
|
target: "es2022",
|
||||||
|
format,
|
||||||
|
loader: {
|
||||||
|
".svg": "dataurl",
|
||||||
|
".js": "jsx"
|
||||||
|
},
|
||||||
|
define: {
|
||||||
|
__isDev: "0",
|
||||||
|
"process.env.NODE_ENV": '"production"'
|
||||||
|
},
|
||||||
|
chunkNames: "chunks/[name]-[hash]",
|
||||||
|
plugins: [
|
||||||
|
postcss(),
|
||||||
|
entryOutputMeta(async (info) => {
|
||||||
|
const manifest: Record<string, object> = {};
|
||||||
|
const toAsset = (output: string) => {
|
||||||
|
const name = output.split("/").pop()!;
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
path: output,
|
||||||
|
mime: guessMimeType(name)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
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);
|
||||||
|
})
|
||||||
|
]
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
function adapter(adapter: string, overrides: Partial<esbuild.BuildOptions> = {}): BuildOptions {
|
||||||
|
return {
|
||||||
|
...baseOptions,
|
||||||
|
name: `adapter ${adapter} ${overrides?.format === "cjs" ? "cjs" : "esm"}`,
|
||||||
|
entryPoints: [`src/adapter/${adapter}`],
|
||||||
|
platform: "neutral",
|
||||||
|
outfile: `dist/adapter/${adapter}/index.${overrides?.format === "cjs" ? "cjs" : "js"}`,
|
||||||
|
external: [
|
||||||
|
"cloudflare:workers",
|
||||||
|
"@hono*",
|
||||||
|
"hono*",
|
||||||
|
"bknd*",
|
||||||
|
"*.html",
|
||||||
|
"node*",
|
||||||
|
"react*",
|
||||||
|
"next*",
|
||||||
|
"libsql",
|
||||||
|
"@libsql*"
|
||||||
|
],
|
||||||
|
splitting: false,
|
||||||
|
treeShaking: true,
|
||||||
|
bundle: true,
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const adapters = [
|
||||||
|
adapter("vite", { platform: "node" }),
|
||||||
|
adapter("cloudflare"),
|
||||||
|
adapter("nextjs", { platform: "node", format: "esm" }),
|
||||||
|
adapter("nextjs", { platform: "node", format: "cjs" }),
|
||||||
|
adapter("remix", { format: "esm" }),
|
||||||
|
adapter("remix", { format: "cjs" }),
|
||||||
|
adapter("bun"),
|
||||||
|
adapter("node", { platform: "node", format: "esm" }),
|
||||||
|
adapter("node", { platform: "node", format: "cjs" })
|
||||||
|
];
|
||||||
|
|
||||||
|
const collect = [
|
||||||
|
builds.static(),
|
||||||
|
builds.backend(),
|
||||||
|
//builds.components(),
|
||||||
|
builds.backend("cjs"),
|
||||||
|
//builds.components("cjs"),
|
||||||
|
...adapters
|
||||||
|
];
|
||||||
|
|
||||||
|
if (watch) {
|
||||||
|
const _state: {
|
||||||
|
timeout: Timer | undefined;
|
||||||
|
cleanup: Subprocess | undefined;
|
||||||
|
building: Subprocess | undefined;
|
||||||
|
} = {
|
||||||
|
timeout: undefined,
|
||||||
|
cleanup: undefined,
|
||||||
|
building: undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
async function rebuildTypes() {
|
||||||
|
if (!types) return;
|
||||||
|
if (_state.timeout) {
|
||||||
|
clearTimeout(_state.timeout);
|
||||||
|
if (_state.cleanup) _state.cleanup.kill();
|
||||||
|
if (_state.building) _state.building.kill();
|
||||||
|
}
|
||||||
|
_state.timeout = setTimeout(async () => {
|
||||||
|
_state.cleanup = Bun.spawn(["bun", "clean:types"], {
|
||||||
|
onExit: () => {
|
||||||
|
_state.cleanup = undefined;
|
||||||
|
_state.building = Bun.spawn(["bun", "build:types"], {
|
||||||
|
onExit: () => {
|
||||||
|
_state.building = undefined;
|
||||||
|
console.log("Types rebuilt");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { name, ...build } of collect) {
|
||||||
|
const ctx = await esbuild.context({
|
||||||
|
...build,
|
||||||
|
plugins: [
|
||||||
|
...(build.plugins ?? []),
|
||||||
|
{
|
||||||
|
name: "rebuild-notify",
|
||||||
|
setup(build) {
|
||||||
|
build.onEnd((result) => {
|
||||||
|
console.log(`rebuilt ${name} with ${result.errors.length} errors`);
|
||||||
|
rebuildTypes();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
ctx.watch();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await $`rm -rf dist`;
|
||||||
|
|
||||||
|
async function _build() {
|
||||||
|
let i = 0;
|
||||||
|
const count = collect.length;
|
||||||
|
for await (const { name, ...build } of collect) {
|
||||||
|
await esbuild.build({
|
||||||
|
...build,
|
||||||
|
plugins: [
|
||||||
|
...(build.plugins || []),
|
||||||
|
{
|
||||||
|
name: "progress",
|
||||||
|
setup(build) {
|
||||||
|
i++;
|
||||||
|
build.onEnd((result) => {
|
||||||
|
const errors = result.errors.length;
|
||||||
|
const from = String(i).padStart(String(count).length);
|
||||||
|
console.log(`[${from}/${count}] built ${name} with ${errors} errors`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("All builds complete");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _buildtypes() {
|
||||||
|
if (!types) return;
|
||||||
|
Bun.spawn(["bun", "build:types"], {
|
||||||
|
onExit: () => {
|
||||||
|
console.log("Types rebuilt");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all([_build(), _buildtypes()]);
|
||||||
|
}
|
||||||
181
app/build.ts
Normal file
181
app/build.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import { $ } from "bun";
|
||||||
|
import * as esbuild from "esbuild";
|
||||||
|
import postcss from "esbuild-postcss";
|
||||||
|
import * as tsup from "tsup";
|
||||||
|
import { guessMimeType } from "./src/media/storage/mime-types";
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const watch = args.includes("--watch");
|
||||||
|
const minify = args.includes("--minify");
|
||||||
|
const types = args.includes("--types");
|
||||||
|
const sourcemap = args.includes("--sourcemap");
|
||||||
|
|
||||||
|
await $`rm -rf dist`;
|
||||||
|
if (types) {
|
||||||
|
Bun.spawn(["bun", "build:types"], {
|
||||||
|
onExit: () => {
|
||||||
|
console.log("Types built");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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]"
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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
|
||||||
|
*/
|
||||||
|
await tsup.build({
|
||||||
|
minify,
|
||||||
|
sourcemap,
|
||||||
|
watch,
|
||||||
|
entry: ["src/index.ts", "src/data/index.ts", "src/core/index.ts", "src/core/utils/index.ts"],
|
||||||
|
outDir: "dist",
|
||||||
|
external: ["bun:test"],
|
||||||
|
metafile: true,
|
||||||
|
platform: "browser",
|
||||||
|
format: ["esm", "cjs"],
|
||||||
|
splitting: false,
|
||||||
|
loader: {
|
||||||
|
".svg": "dataurl"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Building UI for direct imports
|
||||||
|
*/
|
||||||
|
await tsup.build({
|
||||||
|
minify,
|
||||||
|
sourcemap,
|
||||||
|
watch,
|
||||||
|
entry: ["src/ui/index.ts", "src/ui/client/index.ts", "src/ui/main.css"],
|
||||||
|
outDir: "dist/ui",
|
||||||
|
external: ["bun:test"],
|
||||||
|
metafile: true,
|
||||||
|
platform: "browser",
|
||||||
|
format: ["esm", "cjs"],
|
||||||
|
splitting: true,
|
||||||
|
loader: {
|
||||||
|
".svg": "dataurl"
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
console.log("--- ui built");
|
||||||
|
},
|
||||||
|
esbuildOptions: (options) => {
|
||||||
|
options.chunkNames = "chunks/[name]-[hash]";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Building adapters
|
||||||
|
*/
|
||||||
|
function baseConfig(adapter: string): tsup.Options {
|
||||||
|
return {
|
||||||
|
minify,
|
||||||
|
sourcemap,
|
||||||
|
watch,
|
||||||
|
entry: [`src/adapter/${adapter}`],
|
||||||
|
format: ["esm"],
|
||||||
|
platform: "neutral",
|
||||||
|
outDir: `dist/adapter/${adapter}`,
|
||||||
|
define: {
|
||||||
|
__isDev: "0"
|
||||||
|
},
|
||||||
|
external: [
|
||||||
|
/^cloudflare*/,
|
||||||
|
/^@?(hono|libsql).*?/,
|
||||||
|
/^(bknd|react|next|node).*?/,
|
||||||
|
/.*\.(html)$/
|
||||||
|
],
|
||||||
|
metafile: true,
|
||||||
|
splitting: false,
|
||||||
|
treeshake: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await tsup.build({
|
||||||
|
...baseConfig("vite"),
|
||||||
|
platform: "node"
|
||||||
|
});
|
||||||
|
|
||||||
|
await tsup.build({
|
||||||
|
...baseConfig("cloudflare")
|
||||||
|
});
|
||||||
|
|
||||||
|
await tsup.build({
|
||||||
|
...baseConfig("nextjs"),
|
||||||
|
format: ["esm", "cjs"],
|
||||||
|
platform: "node"
|
||||||
|
});
|
||||||
|
|
||||||
|
await tsup.build({
|
||||||
|
...baseConfig("remix"),
|
||||||
|
format: ["esm", "cjs"]
|
||||||
|
});
|
||||||
|
|
||||||
|
await tsup.build({
|
||||||
|
...baseConfig("bun")
|
||||||
|
});
|
||||||
|
|
||||||
|
await tsup.build({
|
||||||
|
...baseConfig("node"),
|
||||||
|
platform: "node",
|
||||||
|
format: ["esm", "cjs"]
|
||||||
|
});
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en" class="light">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
|
|
||||||
<title>BKND</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<div id="app"></div>
|
|
||||||
<script type="module" src="/src/ui/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
33
app/internal/esbuild.entry-output-meta.plugin.ts
Normal file
33
app/internal/esbuild.entry-output-meta.plugin.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import type { Metafile, Plugin } from "esbuild";
|
||||||
|
|
||||||
|
export const entryOutputMeta = (
|
||||||
|
onComplete?: (
|
||||||
|
outputs: {
|
||||||
|
output: string;
|
||||||
|
meta: Metafile["outputs"][string];
|
||||||
|
}[]
|
||||||
|
) => void | Promise<void>
|
||||||
|
): Plugin => ({
|
||||||
|
name: "report-entry-output-plugin",
|
||||||
|
setup(build) {
|
||||||
|
build.initialOptions.metafile = true; // Ensure metafile is enabled
|
||||||
|
|
||||||
|
build.onEnd(async (result) => {
|
||||||
|
console.log("result", result);
|
||||||
|
if (result?.metafile?.outputs) {
|
||||||
|
const entries = build.initialOptions.entryPoints! as string[];
|
||||||
|
|
||||||
|
const outputs = Object.entries(result.metafile.outputs)
|
||||||
|
.filter(([, meta]) => {
|
||||||
|
return meta.entryPoint && entries.includes(meta.entryPoint);
|
||||||
|
})
|
||||||
|
.map(([output, meta]) => ({ output, meta }));
|
||||||
|
if (outputs.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await onComplete?.(outputs);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -5,18 +5,16 @@
|
|||||||
"bin": "./dist/cli/index.js",
|
"bin": "./dist/cli/index.js",
|
||||||
"version": "0.0.14",
|
"version": "0.0.14",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build:all": "rm -rf dist && bun build:css && bun run build && bun build:vite && bun build:adapters && bun build:cli",
|
"build:all": "bun run build && bun run build:cli",
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"test": "ALL_TESTS=1 bun test --bail",
|
"test": "ALL_TESTS=1 bun test --bail",
|
||||||
"build": "bun tsup && bun build:types",
|
"build": "bun run build.ts --minify --types",
|
||||||
"watch": "bun tsup --watch --onSuccess 'bun run build:types'",
|
"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",
|
||||||
"build:types": "tsc --emitDeclarationOnly",
|
"build:types": "tsc --emitDeclarationOnly",
|
||||||
"build:css": "bun tailwindcss -i ./src/ui/styles.css -o ./dist/styles.css",
|
"build:css": "bun tailwindcss -i src/ui/main.css -o ./dist/static/styles.css",
|
||||||
"watch:css": "bun tailwindcss --watch -i ./src/ui/styles.css -o ./dist/styles.css",
|
"watch:css": "bun tailwindcss --watch -i src/ui/main.css -o ./dist/styles.css",
|
||||||
"build:vite": "NODE_ENV=production vite build",
|
|
||||||
"build:adapters": "bun tsup.adapters.ts --minify",
|
|
||||||
"watch:adapters": "bun tsup.adapters.ts --watch",
|
|
||||||
"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",
|
"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"
|
||||||
@@ -29,8 +27,8 @@
|
|||||||
"@codemirror/lang-liquid": "^6.2.1",
|
"@codemirror/lang-liquid": "^6.2.1",
|
||||||
"@dagrejs/dagre": "^1.1.4",
|
"@dagrejs/dagre": "^1.1.4",
|
||||||
"@hello-pangea/dnd": "^17.0.0",
|
"@hello-pangea/dnd": "^17.0.0",
|
||||||
"@hono/typebox-validator": "^0.2.4",
|
"@hono/typebox-validator": "^0.2.6",
|
||||||
"@hono/zod-validator": "^0.2.2",
|
"@hono/zod-validator": "^0.4.1",
|
||||||
"@hookform/resolvers": "^3.9.1",
|
"@hookform/resolvers": "^3.9.1",
|
||||||
"@libsql/client": "^0.14.0",
|
"@libsql/client": "^0.14.0",
|
||||||
"@libsql/kysely-libsql": "^0.4.1",
|
"@libsql/kysely-libsql": "^0.4.1",
|
||||||
@@ -50,8 +48,7 @@
|
|||||||
"codemirror-lang-liquid": "^1.0.0",
|
"codemirror-lang-liquid": "^1.0.0",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"fast-xml-parser": "^4.4.0",
|
"fast-xml-parser": "^4.4.0",
|
||||||
"hono": "^4.4.12",
|
"hono": "^4.6.12",
|
||||||
"jose": "^5.6.3",
|
|
||||||
"jotai": "^2.10.1",
|
"jotai": "^2.10.1",
|
||||||
"kysely": "^0.27.4",
|
"kysely": "^0.27.4",
|
||||||
"liquidjs": "^10.15.0",
|
"liquidjs": "^10.15.0",
|
||||||
@@ -69,14 +66,16 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.613.0",
|
"@aws-sdk/client-s3": "^3.613.0",
|
||||||
"@hono/node-server": "^1.13.3",
|
"@hono/node-server": "^1.13.7",
|
||||||
"@hono/vite-dev-server": "^0.16.0",
|
"@hono/vite-dev-server": "^0.17.0",
|
||||||
"@tanstack/react-query-devtools": "^5.59.16",
|
"@tanstack/react-query-devtools": "^5.59.16",
|
||||||
"@types/diff": "^5.2.3",
|
"@types/diff": "^5.2.3",
|
||||||
|
"@types/node": "^22.10.0",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@vitejs/plugin-react": "^4.3.3",
|
"@vitejs/plugin-react": "^4.3.3",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
|
"esbuild-postcss": "^0.0.4",
|
||||||
"node-fetch": "^3.3.2",
|
"node-fetch": "^3.3.2",
|
||||||
"openapi-types": "^12.1.3",
|
"openapi-types": "^12.1.3",
|
||||||
"postcss": "^8.4.47",
|
"postcss": "^8.4.47",
|
||||||
@@ -88,27 +87,13 @@
|
|||||||
"vite-plugin-static-copy": "^2.0.0",
|
"vite-plugin-static-copy": "^2.0.0",
|
||||||
"vite-tsconfig-paths": "^5.0.1"
|
"vite-tsconfig-paths": "^5.0.1"
|
||||||
},
|
},
|
||||||
"tsup": {
|
|
||||||
"entry": ["src/index.ts", "src/ui/index.ts", "src/data/index.ts", "src/core/index.ts", "src/core/utils/index.ts"],
|
|
||||||
"minify": true,
|
|
||||||
"outDir": "dist",
|
|
||||||
"external": ["bun:test"],
|
|
||||||
"sourcemap": true,
|
|
||||||
"metafile": true,
|
|
||||||
"platform": "browser",
|
|
||||||
"format": ["esm", "cjs"],
|
|
||||||
"splitting": false,
|
|
||||||
"loader": {
|
|
||||||
".svg": "dataurl"
|
|
||||||
},
|
|
||||||
"esbuild": {
|
|
||||||
"drop": ["console", "debugger"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": ">=18",
|
"react": ">=18",
|
||||||
"react-dom": ">=18"
|
"react-dom": ">=18"
|
||||||
},
|
},
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"module": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
@@ -120,6 +105,11 @@
|
|||||||
"import": "./dist/ui/index.js",
|
"import": "./dist/ui/index.js",
|
||||||
"require": "./dist/ui/index.cjs"
|
"require": "./dist/ui/index.cjs"
|
||||||
},
|
},
|
||||||
|
"./client": {
|
||||||
|
"types": "./dist/ui/client/index.d.ts",
|
||||||
|
"import": "./dist/ui/client/index.js",
|
||||||
|
"require": "./dist/ui/client/index.cjs"
|
||||||
|
},
|
||||||
"./data": {
|
"./data": {
|
||||||
"types": "./dist/data/index.d.ts",
|
"types": "./dist/data/index.d.ts",
|
||||||
"import": "./dist/data/index.js",
|
"import": "./dist/data/index.js",
|
||||||
@@ -165,9 +155,13 @@
|
|||||||
"import": "./dist/adapter/bun/index.js",
|
"import": "./dist/adapter/bun/index.js",
|
||||||
"require": "./dist/adapter/bun/index.cjs"
|
"require": "./dist/adapter/bun/index.cjs"
|
||||||
},
|
},
|
||||||
"./dist/static/manifest.json": "./dist/static/.vite/manifest.json",
|
"./adapter/node": {
|
||||||
"./dist/styles.css": "./dist/styles.css",
|
"types": "./dist/adapter/node/index.d.ts",
|
||||||
"./dist/index.html": "./dist/static/index.html"
|
"import": "./dist/adapter/node/index.js",
|
||||||
|
"require": "./dist/adapter/node/index.cjs"
|
||||||
|
},
|
||||||
|
"./dist/styles.css": "./dist/ui/main.css",
|
||||||
|
"./dist/manifest.json": "./dist/static/manifest.json"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
|
|||||||
@@ -1,22 +1,34 @@
|
|||||||
import { AuthApi } from "auth/api/AuthApi";
|
import { AuthApi } from "auth/api/AuthApi";
|
||||||
import { DataApi } from "data/api/DataApi";
|
import { DataApi } from "data/api/DataApi";
|
||||||
import { decodeJwt } from "jose";
|
import { decode } from "hono/jwt";
|
||||||
|
import { omit } from "lodash-es";
|
||||||
import { MediaApi } from "media/api/MediaApi";
|
import { MediaApi } from "media/api/MediaApi";
|
||||||
import { SystemApi } from "modules/SystemApi";
|
import { SystemApi } from "modules/SystemApi";
|
||||||
|
|
||||||
|
export type TApiUser = object;
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__BKND__: {
|
||||||
|
user?: TApiUser;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type ApiOptions = {
|
export type ApiOptions = {
|
||||||
host: string;
|
host: string;
|
||||||
|
user?: TApiUser;
|
||||||
token?: string;
|
token?: string;
|
||||||
tokenStorage?: "localStorage";
|
headers?: Headers;
|
||||||
localStorage?: {
|
|
||||||
key?: string;
|
key?: string;
|
||||||
};
|
localStorage?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class Api {
|
export class Api {
|
||||||
private token?: string;
|
private token?: string;
|
||||||
private user?: object;
|
private user?: TApiUser;
|
||||||
private verified = false;
|
private verified = false;
|
||||||
|
private token_transport: "header" | "cookie" | "none" = "header";
|
||||||
|
|
||||||
public system!: SystemApi;
|
public system!: SystemApi;
|
||||||
public data!: DataApi;
|
public data!: DataApi;
|
||||||
@@ -24,7 +36,12 @@ export class Api {
|
|||||||
public media!: MediaApi;
|
public media!: MediaApi;
|
||||||
|
|
||||||
constructor(private readonly options: ApiOptions) {
|
constructor(private readonly options: ApiOptions) {
|
||||||
if (options.token) {
|
if (options.user) {
|
||||||
|
this.user = options.user;
|
||||||
|
this.token_transport = "none";
|
||||||
|
this.verified = true;
|
||||||
|
} else if (options.token) {
|
||||||
|
this.token_transport = "header";
|
||||||
this.updateToken(options.token);
|
this.updateToken(options.token);
|
||||||
} else {
|
} else {
|
||||||
this.extractToken();
|
this.extractToken();
|
||||||
@@ -33,28 +50,48 @@ export class Api {
|
|||||||
this.buildApis();
|
this.buildApis();
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractToken() {
|
get tokenKey() {
|
||||||
if (this.options.tokenStorage === "localStorage") {
|
return this.options.key ?? "auth";
|
||||||
const key = this.options.localStorage?.key ?? "auth";
|
}
|
||||||
const raw = localStorage.getItem(key);
|
|
||||||
|
|
||||||
if (raw) {
|
private extractToken() {
|
||||||
const { token } = JSON.parse(raw);
|
if (this.options.headers) {
|
||||||
this.token = token;
|
// try cookies
|
||||||
this.user = decodeJwt(token) as any;
|
const cookieToken = getCookieValue(this.options.headers.get("cookie"), "auth");
|
||||||
|
if (cookieToken) {
|
||||||
|
this.updateToken(cookieToken);
|
||||||
|
this.token_transport = "cookie";
|
||||||
|
this.verified = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// try authorization header
|
||||||
|
const headerToken = this.options.headers.get("authorization")?.replace("Bearer ", "");
|
||||||
|
if (headerToken) {
|
||||||
|
this.token_transport = "header";
|
||||||
|
this.updateToken(headerToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (this.options.localStorage) {
|
||||||
|
const token = localStorage.getItem(this.tokenKey);
|
||||||
|
if (token) {
|
||||||
|
this.token_transport = "header";
|
||||||
|
this.updateToken(token);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//console.warn("Couldn't extract token");
|
||||||
}
|
}
|
||||||
|
|
||||||
updateToken(token?: string, rebuild?: boolean) {
|
updateToken(token?: string, rebuild?: boolean) {
|
||||||
this.token = token;
|
this.token = token;
|
||||||
this.user = token ? (decodeJwt(token) as any) : undefined;
|
this.user = token ? omit(decode(token).payload as any, ["iat", "iss", "exp"]) : undefined;
|
||||||
|
|
||||||
if (this.options.tokenStorage === "localStorage") {
|
if (this.options.localStorage) {
|
||||||
const key = this.options.localStorage?.key ?? "auth";
|
const key = this.tokenKey;
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
localStorage.setItem(key, JSON.stringify({ token }));
|
localStorage.setItem(key, token);
|
||||||
} else {
|
} else {
|
||||||
localStorage.removeItem(key);
|
localStorage.removeItem(key);
|
||||||
}
|
}
|
||||||
@@ -69,8 +106,6 @@ export class Api {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getAuthState() {
|
getAuthState() {
|
||||||
if (!this.token) return;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
token: this.token,
|
token: this.token,
|
||||||
user: this.user,
|
user: this.user,
|
||||||
@@ -78,10 +113,16 @@ export class Api {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getUser(): TApiUser | null {
|
||||||
|
return this.user || null;
|
||||||
|
}
|
||||||
|
|
||||||
private buildApis() {
|
private buildApis() {
|
||||||
const baseParams = {
|
const baseParams = {
|
||||||
host: this.options.host,
|
host: this.options.host,
|
||||||
token: this.token
|
token: this.token,
|
||||||
|
headers: this.options.headers,
|
||||||
|
token_transport: this.token_transport
|
||||||
};
|
};
|
||||||
|
|
||||||
this.system = new SystemApi(baseParams);
|
this.system = new SystemApi(baseParams);
|
||||||
@@ -93,3 +134,15 @@ export class Api {
|
|||||||
this.media = new MediaApi(baseParams);
|
this.media = new MediaApi(baseParams);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCookieValue(cookies: string | null, name: string) {
|
||||||
|
if (!cookies) return null;
|
||||||
|
|
||||||
|
for (const cookie of cookies.split("; ")) {
|
||||||
|
const [key, value] = cookie.split("=");
|
||||||
|
if (key === name && value) {
|
||||||
|
return decodeURIComponent(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
type Modules
|
type Modules
|
||||||
} from "modules/ModuleManager";
|
} from "modules/ModuleManager";
|
||||||
import * as SystemPermissions from "modules/permissions";
|
import * as SystemPermissions from "modules/permissions";
|
||||||
|
import { AdminController, type AdminControllerOptions } from "modules/server/AdminController";
|
||||||
import { SystemController } from "modules/server/SystemController";
|
import { SystemController } from "modules/server/SystemController";
|
||||||
|
|
||||||
export type AppPlugin<DB> = (app: App<DB>) => void;
|
export type AppPlugin<DB> = (app: App<DB>) => void;
|
||||||
@@ -58,7 +59,7 @@ export class App<DB = any> {
|
|||||||
static create(config: CreateAppConfig) {
|
static create(config: CreateAppConfig) {
|
||||||
let connection: Connection | undefined = undefined;
|
let connection: Connection | undefined = undefined;
|
||||||
|
|
||||||
if (config.connection instanceof Connection) {
|
if (Connection.isConnection(config.connection)) {
|
||||||
connection = config.connection;
|
connection = config.connection;
|
||||||
} else if (typeof config.connection === "object") {
|
} else if (typeof config.connection === "object") {
|
||||||
switch (config.connection.type) {
|
switch (config.connection.type) {
|
||||||
@@ -66,6 +67,8 @@ export class App<DB = any> {
|
|||||||
connection = new LibsqlConnection(config.connection.config);
|
connection = new LibsqlConnection(config.connection.config);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unknown connection of type ${typeof config.connection} given.`);
|
||||||
}
|
}
|
||||||
if (!connection) {
|
if (!connection) {
|
||||||
throw new Error("Invalid connection");
|
throw new Error("Invalid connection");
|
||||||
@@ -79,7 +82,6 @@ export class App<DB = any> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async build(options?: { sync?: boolean; drop?: boolean; save?: boolean }) {
|
async build(options?: { sync?: boolean; drop?: boolean; save?: boolean }) {
|
||||||
//console.log("building");
|
|
||||||
await this.modules.build();
|
await this.modules.build();
|
||||||
|
|
||||||
if (options?.sync) {
|
if (options?.sync) {
|
||||||
@@ -136,6 +138,12 @@ export class App<DB = any> {
|
|||||||
return this.modules.version();
|
return this.modules.version();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
registerAdminController(config?: AdminControllerOptions) {
|
||||||
|
// register admin
|
||||||
|
this.modules.server.route("/", new AdminController(this, config).getController());
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
toJSON(secrets?: boolean) {
|
toJSON(secrets?: boolean) {
|
||||||
return this.modules.toJSON(secrets);
|
return this.modules.toJSON(secrets);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,48 @@
|
|||||||
import { readFile } from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { App, type CreateAppConfig } from "bknd";
|
import { App, type CreateAppConfig } from "bknd";
|
||||||
|
import { LibsqlConnection } from "bknd/data";
|
||||||
import { serveStatic } from "hono/bun";
|
import { serveStatic } from "hono/bun";
|
||||||
|
|
||||||
let app: App;
|
async function getConnection(conn?: CreateAppConfig["connection"]) {
|
||||||
export function serve(config: CreateAppConfig, distPath?: string) {
|
if (conn) {
|
||||||
|
if (LibsqlConnection.isConnection(conn)) {
|
||||||
|
return conn;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new LibsqlConnection(conn.config);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createClient = await import("@libsql/client/node").then((m) => m.createClient);
|
||||||
|
if (!createClient) {
|
||||||
|
throw new Error('libsql client not found, you need to install "@libsql/client/node"');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Using in-memory database");
|
||||||
|
return new LibsqlConnection(createClient({ url: ":memory:" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serve(_config: Partial<CreateAppConfig> = {}, distPath?: string) {
|
||||||
const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static");
|
const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static");
|
||||||
|
let app: App;
|
||||||
|
|
||||||
return async (req: Request) => {
|
return async (req: Request) => {
|
||||||
if (!app) {
|
if (!app) {
|
||||||
app = App.create(config);
|
const connection = await getConnection(_config.connection);
|
||||||
|
app = App.create({
|
||||||
|
..._config,
|
||||||
|
connection
|
||||||
|
});
|
||||||
|
|
||||||
app.emgr.on(
|
app.emgr.on(
|
||||||
"app-built",
|
"app-built",
|
||||||
async () => {
|
async () => {
|
||||||
app.modules.server.get(
|
app.modules.server.get(
|
||||||
"/assets/*",
|
"/*",
|
||||||
serveStatic({
|
serveStatic({
|
||||||
root
|
root
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
app.module?.server?.setAdminHtml(await readFile(root + "/index.html", "utf-8"));
|
app.registerAdminController();
|
||||||
},
|
},
|
||||||
"sync"
|
"sync"
|
||||||
);
|
);
|
||||||
@@ -28,6 +50,6 @@ export function serve(config: CreateAppConfig, distPath?: string) {
|
|||||||
await app.build();
|
await app.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
return app.modules.server.fetch(req);
|
return app.fetch(req);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,21 +4,15 @@ import { Hono } from "hono";
|
|||||||
import { serveStatic } from "hono/cloudflare-workers";
|
import { serveStatic } from "hono/cloudflare-workers";
|
||||||
import type { BkndConfig, CfBkndModeCache } from "../index";
|
import type { BkndConfig, CfBkndModeCache } from "../index";
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
//import manifest from "__STATIC_CONTENT_MANIFEST";
|
|
||||||
|
|
||||||
import _html from "../../static/index.html";
|
|
||||||
|
|
||||||
type Context = {
|
type Context = {
|
||||||
request: Request;
|
request: Request;
|
||||||
env: any;
|
env: any;
|
||||||
ctx: ExecutionContext;
|
ctx: ExecutionContext;
|
||||||
manifest: any;
|
manifest: any;
|
||||||
html: string;
|
html?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function serve(_config: BkndConfig, manifest?: string, overrideHtml?: string) {
|
export function serve(_config: BkndConfig, manifest?: string, html?: string) {
|
||||||
const html = overrideHtml ?? _html;
|
|
||||||
return {
|
return {
|
||||||
async fetch(request: Request, env: any, ctx: ExecutionContext) {
|
async fetch(request: Request, env: any, ctx: ExecutionContext) {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
@@ -113,11 +107,10 @@ async function getFresh(config: BkndConfig, { env, html }: Context) {
|
|||||||
"sync"
|
"sync"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await app.build();
|
await app.build();
|
||||||
|
|
||||||
if (config?.setAdminHtml !== false) {
|
if (config.setAdminHtml) {
|
||||||
app.module.server.setAdminHtml(html);
|
app.registerAdminController({ html });
|
||||||
}
|
}
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
@@ -147,6 +140,7 @@ async function getCached(
|
|||||||
await cache.delete(key);
|
await cache.delete(key);
|
||||||
return c.json({ message: "Cache cleared" });
|
return c.json({ message: "Cache cleared" });
|
||||||
});
|
});
|
||||||
|
app.registerAdminController({ html });
|
||||||
|
|
||||||
config.onBuilt!(app);
|
config.onBuilt!(app);
|
||||||
},
|
},
|
||||||
@@ -163,13 +157,13 @@ async function getCached(
|
|||||||
);
|
);
|
||||||
|
|
||||||
await app.build();
|
await app.build();
|
||||||
if (!cachedConfig) {
|
|
||||||
saveConfig(app.toJSON(true));
|
if (config.setAdminHtml) {
|
||||||
|
app.registerAdminController({ html });
|
||||||
}
|
}
|
||||||
|
|
||||||
//addAssetsRoute(app, manifest);
|
if (!cachedConfig) {
|
||||||
if (config?.setAdminHtml !== false) {
|
saveConfig(app.toJSON(true));
|
||||||
app.module.server.setAdminHtml(html);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
@@ -184,7 +178,7 @@ export class DurableBkndApp extends DurableObject {
|
|||||||
request: Request,
|
request: Request,
|
||||||
options: {
|
options: {
|
||||||
config: CreateAppConfig;
|
config: CreateAppConfig;
|
||||||
html: string;
|
html?: string;
|
||||||
keepAliveSeconds?: number;
|
keepAliveSeconds?: number;
|
||||||
setAdminHtml?: boolean;
|
setAdminHtml?: boolean;
|
||||||
}
|
}
|
||||||
@@ -212,10 +206,6 @@ export class DurableBkndApp extends DurableObject {
|
|||||||
colo: context.colo
|
colo: context.colo
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
if (options?.setAdminHtml !== false) {
|
|
||||||
app.module.server.setAdminHtml(options.html);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"sync"
|
"sync"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { IncomingMessage } from "node:http";
|
||||||
import type { App, CreateAppConfig } from "bknd";
|
import type { App, CreateAppConfig } from "bknd";
|
||||||
|
|
||||||
export type CfBkndModeCache<Env = any> = (env: Env) => {
|
export type CfBkndModeCache<Env = any> = (env: Env) => {
|
||||||
@@ -16,6 +17,7 @@ export type CloudflareBkndConfig<Env = any> = {
|
|||||||
forceHttps?: boolean;
|
forceHttps?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// @todo: move to App
|
||||||
export type BkndConfig<Env = any> = {
|
export type BkndConfig<Env = any> = {
|
||||||
app: CreateAppConfig | ((env: Env) => CreateAppConfig);
|
app: CreateAppConfig | ((env: Env) => CreateAppConfig);
|
||||||
setAdminHtml?: boolean;
|
setAdminHtml?: boolean;
|
||||||
@@ -34,3 +36,27 @@ export type BkndConfigJson = {
|
|||||||
port?: number;
|
port?: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function nodeRequestToRequest(req: IncomingMessage): Request {
|
||||||
|
let protocol = "http";
|
||||||
|
try {
|
||||||
|
protocol = req.headers["x-forwarded-proto"] as string;
|
||||||
|
} catch (e) {}
|
||||||
|
const host = req.headers.host;
|
||||||
|
const url = `${protocol}://${host}${req.url}`;
|
||||||
|
const headers = new Headers();
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(req.headers)) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
headers.append(key, value.join(", "));
|
||||||
|
} else if (value) {
|
||||||
|
headers.append(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const method = req.method || "GET";
|
||||||
|
return new Request(url, {
|
||||||
|
method,
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
24
app/src/adapter/nextjs/AdminPage.tsx
Normal file
24
app/src/adapter/nextjs/AdminPage.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { withApi } from "bknd/adapter/nextjs";
|
||||||
|
import type { InferGetServerSidePropsType } from "next";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
export const getServerSideProps = withApi(async (context) => {
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
user: context.api.getUser()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export function adminPage() {
|
||||||
|
const Admin = dynamic(() => import("bknd/ui").then((mod) => mod.Admin), { ssr: false });
|
||||||
|
const ClientProvider = dynamic(() => import("bknd/ui").then((mod) => mod.ClientProvider));
|
||||||
|
return (props: InferGetServerSidePropsType<typeof getServerSideProps>) => {
|
||||||
|
if (typeof document === "undefined") return null;
|
||||||
|
return (
|
||||||
|
<ClientProvider user={props.user}>
|
||||||
|
<Admin />
|
||||||
|
</ClientProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1 +1,2 @@
|
|||||||
export * from "./nextjs.adapter";
|
export * from "./nextjs.adapter";
|
||||||
|
export * from "./AdminPage";
|
||||||
|
|||||||
@@ -1,5 +1,35 @@
|
|||||||
import { App, type CreateAppConfig } from "bknd";
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
import { isDebug } from "bknd/core";
|
import { Api, App, type CreateAppConfig } from "bknd";
|
||||||
|
import { nodeRequestToRequest } from "../index";
|
||||||
|
|
||||||
|
type GetServerSidePropsContext = {
|
||||||
|
req: IncomingMessage;
|
||||||
|
res: ServerResponse;
|
||||||
|
params?: Params;
|
||||||
|
query: any;
|
||||||
|
preview?: boolean;
|
||||||
|
previewData?: any;
|
||||||
|
draftMode?: boolean;
|
||||||
|
resolvedUrl: string;
|
||||||
|
locale?: string;
|
||||||
|
locales?: string[];
|
||||||
|
defaultLocale?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createApi({ req }: GetServerSidePropsContext) {
|
||||||
|
const request = nodeRequestToRequest(req);
|
||||||
|
//console.log("createApi:request.headers", request.headers);
|
||||||
|
return new Api({
|
||||||
|
host: new URL(request.url).origin,
|
||||||
|
headers: request.headers
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withApi<T>(handler: (ctx: GetServerSidePropsContext & { api: Api }) => T) {
|
||||||
|
return (ctx: GetServerSidePropsContext & { api: Api }) => {
|
||||||
|
return handler({ ...ctx, api: createApi(ctx) });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function getCleanRequest(req: Request) {
|
function getCleanRequest(req: Request) {
|
||||||
// clean search params from "route" attribute
|
// clean search params from "route" attribute
|
||||||
@@ -15,7 +45,7 @@ function getCleanRequest(req: Request) {
|
|||||||
let app: App;
|
let app: App;
|
||||||
export function serve(config: CreateAppConfig) {
|
export function serve(config: CreateAppConfig) {
|
||||||
return async (req: Request) => {
|
return async (req: Request) => {
|
||||||
if (!app || isDebug()) {
|
if (!app) {
|
||||||
app = App.create(config);
|
app = App.create(config);
|
||||||
await app.build();
|
await app.build();
|
||||||
}
|
}
|
||||||
|
|||||||
73
app/src/adapter/node/index.ts
Normal file
73
app/src/adapter/node/index.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { serve as honoServe } from "@hono/node-server";
|
||||||
|
import { serveStatic } from "@hono/node-server/serve-static";
|
||||||
|
import { App, type CreateAppConfig } from "bknd";
|
||||||
|
import { LibsqlConnection } from "bknd/data";
|
||||||
|
|
||||||
|
async function getConnection(conn?: CreateAppConfig["connection"]) {
|
||||||
|
if (conn) {
|
||||||
|
if (LibsqlConnection.isConnection(conn)) {
|
||||||
|
return conn;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new LibsqlConnection(conn.config);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createClient = await import("@libsql/client/node").then((m) => m.createClient);
|
||||||
|
if (!createClient) {
|
||||||
|
throw new Error('libsql client not found, you need to install "@libsql/client/node"');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Using in-memory database");
|
||||||
|
return new LibsqlConnection(createClient({ url: ":memory:" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NodeAdapterOptions = {
|
||||||
|
relativeDistPath?: string;
|
||||||
|
port?: number;
|
||||||
|
hostname?: string;
|
||||||
|
listener?: Parameters<typeof honoServe>[1];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function serve(_config: Partial<CreateAppConfig> = {}, options: NodeAdapterOptions = {}) {
|
||||||
|
const root = path.relative(
|
||||||
|
process.cwd(),
|
||||||
|
path.resolve(options.relativeDistPath ?? "./node_modules/bknd/dist", "static")
|
||||||
|
);
|
||||||
|
let app: App;
|
||||||
|
|
||||||
|
honoServe(
|
||||||
|
{
|
||||||
|
port: options.port ?? 1337,
|
||||||
|
hostname: options.hostname,
|
||||||
|
fetch: async (req: Request) => {
|
||||||
|
if (!app) {
|
||||||
|
const connection = await getConnection(_config.connection);
|
||||||
|
app = App.create({
|
||||||
|
..._config,
|
||||||
|
connection
|
||||||
|
});
|
||||||
|
|
||||||
|
app.emgr.on(
|
||||||
|
"app-built",
|
||||||
|
async () => {
|
||||||
|
app.modules.server.get(
|
||||||
|
"/*",
|
||||||
|
serveStatic({
|
||||||
|
root
|
||||||
|
})
|
||||||
|
);
|
||||||
|
app.registerAdminController();
|
||||||
|
},
|
||||||
|
"sync"
|
||||||
|
);
|
||||||
|
|
||||||
|
await app.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
return app.fetch(req);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
options.listener
|
||||||
|
);
|
||||||
|
}
|
||||||
19
app/src/adapter/remix/AdminPage.tsx
Normal file
19
app/src/adapter/remix/AdminPage.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Suspense, lazy, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export function adminPage() {
|
||||||
|
const Admin = lazy(() => import("bknd/ui").then((mod) => ({ default: mod.Admin })));
|
||||||
|
return () => {
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
setLoaded(true);
|
||||||
|
}, []);
|
||||||
|
if (!loaded) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Suspense>
|
||||||
|
<Admin />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1 +1,2 @@
|
|||||||
export * from "./remix.adapter";
|
export * from "./remix.adapter";
|
||||||
|
export * from "./AdminPage";
|
||||||
|
|||||||
@@ -1,59 +1,34 @@
|
|||||||
import { readFile } from "node:fs/promises";
|
|
||||||
import { serveStatic } from "@hono/node-server/serve-static";
|
import { serveStatic } from "@hono/node-server/serve-static";
|
||||||
import type { BkndConfig } from "bknd";
|
import type { BkndConfig } from "bknd";
|
||||||
import { App } from "bknd";
|
import { App } from "bknd";
|
||||||
|
|
||||||
async function getHtml() {
|
|
||||||
return readFile("index.html", "utf8");
|
|
||||||
}
|
|
||||||
function addViteScripts(html: string) {
|
|
||||||
return html.replace(
|
|
||||||
"<head>",
|
|
||||||
`<script type="module">
|
|
||||||
import RefreshRuntime from "/@react-refresh"
|
|
||||||
RefreshRuntime.injectIntoGlobalHook(window)
|
|
||||||
window.$RefreshReg$ = () => {}
|
|
||||||
window.$RefreshSig$ = () => (type) => type
|
|
||||||
window.__vite_plugin_react_preamble_installed__ = true
|
|
||||||
</script>
|
|
||||||
<script type="module" src="/@vite/client"></script>
|
|
||||||
`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createApp(config: BkndConfig, env: any) {
|
function createApp(config: BkndConfig, env: any) {
|
||||||
const create_config = typeof config.app === "function" ? config.app(env) : config.app;
|
const create_config = typeof config.app === "function" ? config.app(env) : config.app;
|
||||||
return App.create(create_config);
|
return App.create(create_config);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setAppBuildListener(app: App, config: BkndConfig, html: string) {
|
function setAppBuildListener(app: App, config: BkndConfig, html?: string) {
|
||||||
app.emgr.on(
|
app.emgr.on(
|
||||||
"app-built",
|
"app-built",
|
||||||
async () => {
|
async () => {
|
||||||
await config.onBuilt?.(app);
|
await config.onBuilt?.(app);
|
||||||
app.module.server.setAdminHtml(html);
|
if (config.setAdminHtml) {
|
||||||
app.module.server.client.get("/assets/!*", serveStatic({ root: "./" }));
|
app.registerAdminController({ html, forceDev: true });
|
||||||
|
app.module.server.client.get("/assets/*", serveStatic({ root: "./" }));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"sync"
|
"sync"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function serveFresh(config: BkndConfig, _html?: string) {
|
export async function serveFresh(config: BkndConfig, _html?: string) {
|
||||||
let html = _html;
|
|
||||||
if (!html) {
|
|
||||||
html = await getHtml();
|
|
||||||
}
|
|
||||||
|
|
||||||
html = addViteScripts(html);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
async fetch(request: Request, env: any, ctx: ExecutionContext) {
|
async fetch(request: Request, env: any, ctx: ExecutionContext) {
|
||||||
const app = createApp(config, env);
|
const app = createApp(config, env);
|
||||||
|
|
||||||
setAppBuildListener(app, config, html);
|
setAppBuildListener(app, config, _html);
|
||||||
await app.build();
|
await app.build();
|
||||||
|
|
||||||
//console.log("routes", app.module.server.client.routes);
|
|
||||||
return app.fetch(request, env, ctx);
|
return app.fetch(request, env, ctx);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -61,18 +36,11 @@ export async function serveFresh(config: BkndConfig, _html?: string) {
|
|||||||
|
|
||||||
let app: App;
|
let app: App;
|
||||||
export async function serveCached(config: BkndConfig, _html?: string) {
|
export async function serveCached(config: BkndConfig, _html?: string) {
|
||||||
let html = _html;
|
|
||||||
if (!html) {
|
|
||||||
html = await getHtml();
|
|
||||||
}
|
|
||||||
|
|
||||||
html = addViteScripts(html);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
async fetch(request: Request, env: any, ctx: ExecutionContext) {
|
async fetch(request: Request, env: any, ctx: ExecutionContext) {
|
||||||
if (!app) {
|
if (!app) {
|
||||||
app = createApp(config, env);
|
app = createApp(config, env);
|
||||||
setAppBuildListener(app, config, html);
|
setAppBuildListener(app, config, _html);
|
||||||
await app.build();
|
await app.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,9 @@
|
|||||||
import { type AuthAction, Authenticator, type ProfileExchange, Role, type Strategy } from "auth";
|
import { type AuthAction, Authenticator, type ProfileExchange, Role, type Strategy } from "auth";
|
||||||
import { Exception } from "core";
|
import { Exception } from "core";
|
||||||
import { transformObject } from "core/utils";
|
import { type Static, secureRandomString, transformObject } from "core/utils";
|
||||||
import {
|
import { type Entity, EntityIndex, type EntityManager } from "data";
|
||||||
type Entity,
|
|
||||||
EntityIndex,
|
|
||||||
type EntityManager,
|
|
||||||
EnumField,
|
|
||||||
type Field,
|
|
||||||
type Mutator
|
|
||||||
} from "data";
|
|
||||||
import { type FieldSchema, entity, enumm, make, text } from "data/prototype";
|
import { type FieldSchema, entity, enumm, make, text } from "data/prototype";
|
||||||
import { cloneDeep, mergeWith, omit, 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";
|
||||||
import { type AppAuthSchema, STRATEGIES, authConfigSchema } from "./auth-schema";
|
import { type AppAuthSchema, STRATEGIES, authConfigSchema } from "./auth-schema";
|
||||||
@@ -22,9 +15,25 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AuthSchema = Static<typeof authConfigSchema>;
|
||||||
|
|
||||||
export class AppAuth extends Module<typeof authConfigSchema> {
|
export class AppAuth extends Module<typeof authConfigSchema> {
|
||||||
private _authenticator?: Authenticator;
|
private _authenticator?: Authenticator;
|
||||||
cache: Record<string, any> = {};
|
cache: Record<string, any> = {};
|
||||||
|
_controller!: AuthController;
|
||||||
|
|
||||||
|
override async onBeforeUpdate(from: AuthSchema, to: AuthSchema) {
|
||||||
|
const defaultSecret = authConfigSchema.properties.jwt.properties.secret.default;
|
||||||
|
|
||||||
|
if (!from.enabled && to.enabled) {
|
||||||
|
if (to.jwt.secret === defaultSecret) {
|
||||||
|
console.warn("No JWT secret provided, generating a random one");
|
||||||
|
to.jwt.secret = secureRandomString(64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return to;
|
||||||
|
}
|
||||||
|
|
||||||
override async build() {
|
override async build() {
|
||||||
if (!this.config.enabled) {
|
if (!this.config.enabled) {
|
||||||
@@ -46,22 +55,32 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
|||||||
return new STRATEGIES[strategy.type].cls(strategy.config as any);
|
return new STRATEGIES[strategy.type].cls(strategy.config as any);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Could not build strategy ${String(name)} with config ${JSON.stringify(strategy.config)}`
|
`Could not build strategy ${String(
|
||||||
|
name
|
||||||
|
)} with config ${JSON.stringify(strategy.config)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const { fields, ...jwt } = this.config.jwt;
|
|
||||||
this._authenticator = new Authenticator(strategies, this.resolveUser.bind(this), {
|
this._authenticator = new Authenticator(strategies, this.resolveUser.bind(this), {
|
||||||
jwt
|
jwt: this.config.jwt,
|
||||||
|
cookie: this.config.cookie
|
||||||
});
|
});
|
||||||
|
|
||||||
this.registerEntities();
|
this.registerEntities();
|
||||||
super.setBuilt();
|
super.setBuilt();
|
||||||
|
|
||||||
const controller = new AuthController(this);
|
this._controller = new AuthController(this);
|
||||||
//this.ctx.server.use(controller.getMiddleware);
|
//this.ctx.server.use(controller.getMiddleware);
|
||||||
this.ctx.server.route(this.config.basepath, controller.getController());
|
this.ctx.server.route(this.config.basepath, this._controller.getController());
|
||||||
|
}
|
||||||
|
|
||||||
|
get controller(): AuthController {
|
||||||
|
if (!this.isBuilt()) {
|
||||||
|
throw new Error("Can't access controller, AppAuth not built yet");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this._controller;
|
||||||
}
|
}
|
||||||
|
|
||||||
getMiddleware() {
|
getMiddleware() {
|
||||||
@@ -97,6 +116,9 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
|||||||
identifier,
|
identifier,
|
||||||
profile
|
profile
|
||||||
});
|
});
|
||||||
|
if (!this.config.allow_register && action === "register") {
|
||||||
|
throw new Exception("Registration is not allowed", 403);
|
||||||
|
}
|
||||||
|
|
||||||
const fields = this.getUsersEntity()
|
const fields = this.getUsersEntity()
|
||||||
.getFillableFields("create")
|
.getFillableFields("create")
|
||||||
@@ -124,7 +146,11 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async login(strategy: Strategy, identifier: string, profile: ProfileExchange) {
|
private async login(strategy: Strategy, identifier: string, profile: ProfileExchange) {
|
||||||
console.log("--- trying to login", { strategy: strategy.getName(), identifier, profile });
|
/*console.log("--- trying to login", {
|
||||||
|
strategy: strategy.getName(),
|
||||||
|
identifier,
|
||||||
|
profile
|
||||||
|
});*/
|
||||||
if (!("email" in profile)) {
|
if (!("email" in profile)) {
|
||||||
throw new Exception("Profile must have email");
|
throw new Exception("Profile must have email");
|
||||||
}
|
}
|
||||||
@@ -263,17 +289,9 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
|||||||
return this.configDefault;
|
return this.configDefault;
|
||||||
}
|
}
|
||||||
|
|
||||||
const obj = {
|
return {
|
||||||
...this.config,
|
...this.config,
|
||||||
...this.authenticator.toJSON(secrets)
|
...this.authenticator.toJSON(secrets)
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
|
||||||
...obj,
|
|
||||||
jwt: {
|
|
||||||
...obj.jwt,
|
|
||||||
fields: this.config.jwt.fields
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async strategies() {
|
async strategies() {
|
||||||
return this.get<{ strategies: AppAuthSchema["strategies"] }>(["strategies"]);
|
return this.get<Pick<AppAuthSchema, "strategies" | "basepath">>(["strategies"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async logout() {}
|
async logout() {}
|
||||||
|
|||||||
@@ -5,27 +5,13 @@ import { Hono, type MiddlewareHandler } from "hono";
|
|||||||
export class AuthController implements ClassController {
|
export class AuthController implements ClassController {
|
||||||
constructor(private auth: AppAuth) {}
|
constructor(private auth: AppAuth) {}
|
||||||
|
|
||||||
getMiddleware: MiddlewareHandler = async (c, next) => {
|
get guard() {
|
||||||
// @todo: consider adding app name to the payload, because user is not refetched
|
return this.auth.ctx.guard;
|
||||||
|
|
||||||
//try {
|
|
||||||
if (c.req.raw.headers.has("Authorization")) {
|
|
||||||
const bearerHeader = String(c.req.header("Authorization"));
|
|
||||||
const token = bearerHeader.replace("Bearer ", "");
|
|
||||||
const verified = await this.auth.authenticator.verify(token);
|
|
||||||
|
|
||||||
// @todo: don't extract user from token, but from the database or cache
|
|
||||||
this.auth.ctx.guard.setUserContext(this.auth.authenticator.getUser());
|
|
||||||
/*console.log("jwt verified?", {
|
|
||||||
verified,
|
|
||||||
auth: this.auth.authenticator.isUserLoggedIn()
|
|
||||||
});*/
|
|
||||||
} else {
|
|
||||||
this.auth.authenticator.__setUserNull();
|
|
||||||
}
|
}
|
||||||
/* } catch (e) {
|
|
||||||
this.auth.authenticator.__setUserNull();
|
getMiddleware: MiddlewareHandler = async (c, next) => {
|
||||||
}*/
|
const user = await this.auth.authenticator.resolveAuthFromRequest(c);
|
||||||
|
this.auth.ctx.guard.setUserContext(user);
|
||||||
|
|
||||||
await next();
|
await next();
|
||||||
};
|
};
|
||||||
@@ -33,7 +19,6 @@ export class AuthController implements ClassController {
|
|||||||
getController(): Hono<any> {
|
getController(): Hono<any> {
|
||||||
const hono = new Hono();
|
const hono = new Hono();
|
||||||
const strategies = this.auth.authenticator.getStrategies();
|
const strategies = this.auth.authenticator.getStrategies();
|
||||||
//console.log("strategies", strategies);
|
|
||||||
|
|
||||||
for (const [name, strategy] of Object.entries(strategies)) {
|
for (const [name, strategy] of Object.entries(strategies)) {
|
||||||
//console.log("registering", name, "at", `/${name}`);
|
//console.log("registering", name, "at", `/${name}`);
|
||||||
@@ -48,8 +33,23 @@ export class AuthController implements ClassController {
|
|||||||
return c.json({ user: null }, 403);
|
return c.json({ user: null }, 403);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
hono.get("/logout", async (c) => {
|
||||||
|
await this.auth.authenticator.logout(c);
|
||||||
|
if (this.auth.authenticator.isJsonRequest(c)) {
|
||||||
|
return c.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const referer = c.req.header("referer");
|
||||||
|
if (referer) {
|
||||||
|
return c.redirect(referer);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.redirect("/");
|
||||||
|
});
|
||||||
|
|
||||||
hono.get("/strategies", async (c) => {
|
hono.get("/strategies", async (c) => {
|
||||||
return c.json({ strategies: this.auth.toJSON(false).strategies });
|
const { strategies, basepath } = this.auth.toJSON(false);
|
||||||
|
return c.json({ strategies, basepath });
|
||||||
});
|
});
|
||||||
|
|
||||||
return hono;
|
return hono;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { jwtConfig } from "auth/authenticate/Authenticator";
|
import { cookieConfig, jwtConfig } from "auth/authenticate/Authenticator";
|
||||||
import { CustomOAuthStrategy, OAuthStrategy, PasswordStrategy } from "auth/authenticate/strategies";
|
import { CustomOAuthStrategy, OAuthStrategy, PasswordStrategy } from "auth/authenticate/strategies";
|
||||||
import { type Static, StringRecord, Type, objectTransform } from "core/utils";
|
import { type Static, StringRecord, Type, objectTransform } from "core/utils";
|
||||||
|
|
||||||
@@ -51,15 +51,9 @@ export const authConfigSchema = Type.Object(
|
|||||||
enabled: Type.Boolean({ default: false }),
|
enabled: Type.Boolean({ default: false }),
|
||||||
basepath: Type.String({ default: "/api/auth" }),
|
basepath: Type.String({ default: "/api/auth" }),
|
||||||
entity_name: Type.String({ default: "users" }),
|
entity_name: Type.String({ default: "users" }),
|
||||||
jwt: Type.Composite(
|
allow_register: Type.Optional(Type.Boolean({ default: true })),
|
||||||
[
|
jwt: jwtConfig,
|
||||||
jwtConfig,
|
cookie: cookieConfig,
|
||||||
Type.Object({
|
|
||||||
fields: Type.Array(Type.String(), { default: ["id", "email", "role"] })
|
|
||||||
})
|
|
||||||
],
|
|
||||||
{ default: {}, additionalProperties: false }
|
|
||||||
),
|
|
||||||
strategies: Type.Optional(
|
strategies: Type.Optional(
|
||||||
StringRecord(strategiesSchema, {
|
StringRecord(strategiesSchema, {
|
||||||
title: "Strategies",
|
title: "Strategies",
|
||||||
|
|||||||
@@ -1,8 +1,22 @@
|
|||||||
import { type Static, type TSchema, Type, parse, randomString, transformObject } from "core/utils";
|
import { Exception } from "core";
|
||||||
import type { Hono } from "hono";
|
import { addFlashMessage } from "core/server/flash";
|
||||||
import { type JWTVerifyOptions, SignJWT, jwtVerify } from "jose";
|
import {
|
||||||
|
type Static,
|
||||||
|
StringEnum,
|
||||||
|
type TSchema,
|
||||||
|
Type,
|
||||||
|
parse,
|
||||||
|
randomString,
|
||||||
|
transformObject
|
||||||
|
} from "core/utils";
|
||||||
|
import type { Context, Hono } from "hono";
|
||||||
|
import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie";
|
||||||
|
import { decode, sign, verify } from "hono/jwt";
|
||||||
|
import type { CookieOptions } from "hono/utils/cookie";
|
||||||
|
import { omit } from "lodash-es";
|
||||||
|
|
||||||
type Input = any; // workaround
|
type Input = any; // workaround
|
||||||
|
export type JWTPayload = Parameters<typeof sign>[0];
|
||||||
|
|
||||||
// @todo: add schema to interface to ensure proper inference
|
// @todo: add schema to interface to ensure proper inference
|
||||||
export interface Strategy {
|
export interface Strategy {
|
||||||
@@ -38,13 +52,29 @@ export interface UserPool<Fields = "id" | "email" | "username"> {
|
|||||||
create: (user: CreateUser) => Promise<User | undefined>;
|
create: (user: CreateUser) => Promise<User | undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defaultCookieExpires = 60 * 60 * 24 * 7; // 1 week in seconds
|
||||||
|
export const cookieConfig = Type.Partial(
|
||||||
|
Type.Object({
|
||||||
|
path: Type.String({ default: "/" }),
|
||||||
|
sameSite: StringEnum(["strict", "lax", "none"], { default: "lax" }),
|
||||||
|
secure: Type.Boolean({ default: true }),
|
||||||
|
httpOnly: Type.Boolean({ default: true }),
|
||||||
|
expires: Type.Number({ default: defaultCookieExpires }), // seconds
|
||||||
|
renew: Type.Boolean({ default: true }),
|
||||||
|
pathSuccess: Type.String({ default: "/" }),
|
||||||
|
pathLoggedOut: Type.String({ default: "/" })
|
||||||
|
}),
|
||||||
|
{ default: {}, additionalProperties: false }
|
||||||
|
);
|
||||||
|
|
||||||
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
|
||||||
secret: Type.String({ default: "secret" }),
|
secret: Type.String({ default: "" }),
|
||||||
alg: Type.Optional(Type.String({ enum: ["HS256"], default: "HS256" })),
|
alg: Type.Optional(StringEnum(["HS256", "HS384", "HS512"], { default: "HS256" })),
|
||||||
expiresIn: Type.Optional(Type.String()),
|
expires: Type.Optional(Type.Number()), // seconds
|
||||||
issuer: Type.Optional(Type.String())
|
issuer: Type.Optional(Type.String()),
|
||||||
|
fields: Type.Array(Type.String(), { default: ["id", "email", "role"] })
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
default: {},
|
default: {},
|
||||||
@@ -52,7 +82,8 @@ export const jwtConfig = Type.Object(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
export const authenticatorConfig = Type.Object({
|
export const authenticatorConfig = Type.Object({
|
||||||
jwt: jwtConfig
|
jwt: jwtConfig,
|
||||||
|
cookie: cookieConfig
|
||||||
});
|
});
|
||||||
|
|
||||||
type AuthConfig = Static<typeof authenticatorConfig>;
|
type AuthConfig = Static<typeof authenticatorConfig>;
|
||||||
@@ -74,11 +105,6 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
|||||||
this.userResolver = userResolver ?? (async (a, s, i, p) => p as any);
|
this.userResolver = userResolver ?? (async (a, s, i, p) => p as any);
|
||||||
this.strategies = strategies as Strategies;
|
this.strategies = strategies as Strategies;
|
||||||
this.config = parse(authenticatorConfig, config ?? {});
|
this.config = parse(authenticatorConfig, config ?? {});
|
||||||
|
|
||||||
/*const secret = String(this.config.jwt.secret);
|
|
||||||
if (secret === "secret" || secret.length === 0) {
|
|
||||||
this.config.jwt.secret = randomString(64, true);
|
|
||||||
}*/
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async resolve(
|
async resolve(
|
||||||
@@ -86,7 +112,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
|||||||
strategy: Strategy,
|
strategy: Strategy,
|
||||||
identifier: string,
|
identifier: string,
|
||||||
profile: ProfileExchange
|
profile: ProfileExchange
|
||||||
) {
|
): Promise<AuthResponse> {
|
||||||
//console.log("resolve", { action, strategy: strategy.getName(), profile });
|
//console.log("resolve", { action, strategy: strategy.getName(), profile });
|
||||||
const user = await this.userResolver(action, strategy, identifier, profile);
|
const user = await this.userResolver(action, strategy, identifier, profile);
|
||||||
|
|
||||||
@@ -136,50 +162,140 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const jwt = new SignJWT(user)
|
const payload: JWTPayload = {
|
||||||
.setProtectedHeader({ alg: this.config.jwt?.alg ?? "HS256" })
|
...user,
|
||||||
.setIssuedAt();
|
iat: Math.floor(Date.now() / 1000)
|
||||||
|
};
|
||||||
|
|
||||||
|
// issuer
|
||||||
if (this.config.jwt?.issuer) {
|
if (this.config.jwt?.issuer) {
|
||||||
jwt.setIssuer(this.config.jwt.issuer);
|
payload.iss = this.config.jwt.issuer;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.config.jwt?.expiresIn) {
|
// expires in seconds
|
||||||
jwt.setExpirationTime(this.config.jwt.expiresIn);
|
if (this.config.jwt?.expires) {
|
||||||
|
payload.exp = Math.floor(Date.now() / 1000) + this.config.jwt.expires;
|
||||||
}
|
}
|
||||||
|
|
||||||
return jwt.sign(new TextEncoder().encode(this.config.jwt?.secret ?? ""));
|
return sign(payload, this.config.jwt?.secret ?? "", this.config.jwt?.alg ?? "HS256");
|
||||||
}
|
}
|
||||||
|
|
||||||
async verify(jwt: string): Promise<boolean> {
|
async verify(jwt: string): Promise<boolean> {
|
||||||
const options: JWTVerifyOptions = {
|
|
||||||
algorithms: [this.config.jwt?.alg ?? "HS256"]
|
|
||||||
};
|
|
||||||
|
|
||||||
if (this.config.jwt?.issuer) {
|
|
||||||
options.issuer = this.config.jwt.issuer;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.config.jwt?.expiresIn) {
|
|
||||||
options.maxTokenAge = this.config.jwt.expiresIn;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { payload } = await jwtVerify<User>(
|
const payload = await verify(
|
||||||
jwt,
|
jwt,
|
||||||
new TextEncoder().encode(this.config.jwt?.secret ?? ""),
|
this.config.jwt?.secret ?? "",
|
||||||
options
|
this.config.jwt?.alg ?? "HS256"
|
||||||
);
|
);
|
||||||
this._user = payload;
|
|
||||||
|
// manually verify issuer (hono doesn't support it)
|
||||||
|
if (this.config.jwt?.issuer) {
|
||||||
|
if (payload.iss !== this.config.jwt.issuer) {
|
||||||
|
throw new Exception("Invalid issuer", 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._user = omit(payload, ["iat", "exp", "iss"]) as SafeUser;
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this._user = undefined;
|
this._user = undefined;
|
||||||
//console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private get cookieOptions(): CookieOptions {
|
||||||
|
const { expires = defaultCookieExpires, renew, ...cookieConfig } = this.config.cookie;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...cookieConfig,
|
||||||
|
expires: new Date(Date.now() + expires * 1000)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAuthCookie(c: Context): Promise<string | undefined> {
|
||||||
|
const secret = this.config.jwt.secret;
|
||||||
|
|
||||||
|
const token = await getSignedCookie(c, secret, "auth");
|
||||||
|
if (typeof token !== "string") {
|
||||||
|
await deleteCookie(c, "auth", this.cookieOptions);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async requestCookieRefresh(c: Context) {
|
||||||
|
if (this.config.cookie.renew) {
|
||||||
|
console.log("renewing cookie", c.req.url);
|
||||||
|
const token = await this.getAuthCookie(c);
|
||||||
|
if (token) {
|
||||||
|
await this.setAuthCookie(c, token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async setAuthCookie(c: Context, token: string) {
|
||||||
|
const secret = this.config.jwt.secret;
|
||||||
|
await setSignedCookie(c, "auth", token, secret, this.cookieOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
async logout(c: Context) {
|
||||||
|
const cookie = await this.getAuthCookie(c);
|
||||||
|
if (cookie) {
|
||||||
|
await deleteCookie(c, "auth", this.cookieOptions);
|
||||||
|
await addFlashMessage(c, "Signed out", "info");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isJsonRequest(c: Context): boolean {
|
||||||
|
//return c.req.header("Content-Type") === "application/x-www-form-urlencoded";
|
||||||
|
return c.req.header("Content-Type") === "application/json";
|
||||||
|
}
|
||||||
|
|
||||||
|
async respond(c: Context, data: AuthResponse | Error | any, redirect?: string) {
|
||||||
|
if (this.isJsonRequest(c)) {
|
||||||
|
return c.json(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
const successPath = this.config.cookie.pathSuccess ?? "/";
|
||||||
|
const successUrl = new URL(c.req.url).origin + successPath.replace(/\/+$/, "/");
|
||||||
|
const referer = new URL(redirect ?? c.req.header("Referer") ?? successUrl);
|
||||||
|
|
||||||
|
if ("token" in data) {
|
||||||
|
await this.setAuthCookie(c, data.token);
|
||||||
|
// can't navigate to "/" – doesn't work on nextjs
|
||||||
|
return c.redirect(successUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
let message = "An error occured";
|
||||||
|
if (data instanceof Exception) {
|
||||||
|
message = data.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
await addFlashMessage(c, message, "error");
|
||||||
|
return c.redirect(referer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// @todo: don't extract user from token, but from the database or cache
|
||||||
|
async resolveAuthFromRequest(c: Context): Promise<SafeUser | undefined> {
|
||||||
|
let token: string | undefined;
|
||||||
|
if (c.req.raw.headers.has("Authorization")) {
|
||||||
|
const bearerHeader = String(c.req.header("Authorization"));
|
||||||
|
token = bearerHeader.replace("Bearer ", "");
|
||||||
|
} else {
|
||||||
|
token = await this.getAuthCookie(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
await this.verify(token);
|
||||||
|
return this._user;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
toJSON(secrets?: boolean) {
|
toJSON(secrets?: boolean) {
|
||||||
return {
|
return {
|
||||||
...this.config,
|
...this.config,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Authenticator, Strategy } from "auth";
|
import type { Authenticator, Strategy } from "auth";
|
||||||
import { type Static, StringEnum, Type, parse } from "core/utils";
|
import { type Static, StringEnum, Type, parse } from "core/utils";
|
||||||
import { hash } from "core/utils";
|
import { hash } from "core/utils";
|
||||||
import { Hono } from "hono";
|
import { type Context, Hono } from "hono";
|
||||||
|
|
||||||
type LoginSchema = { username: string; password: string } | { email: string; password: string };
|
type LoginSchema = { username: string; password: string } | { email: string; password: string };
|
||||||
type RegisterSchema = { email: string; password: string; [key: string]: any };
|
type RegisterSchema = { email: string; password: string; [key: string]: any };
|
||||||
@@ -54,22 +54,34 @@ export class PasswordStrategy implements Strategy {
|
|||||||
getController(authenticator: Authenticator): Hono<any> {
|
getController(authenticator: Authenticator): Hono<any> {
|
||||||
const hono = new Hono();
|
const hono = new Hono();
|
||||||
|
|
||||||
|
async function getBody(c: Context) {
|
||||||
|
if (authenticator.isJsonRequest(c)) {
|
||||||
|
return await c.req.json();
|
||||||
|
} else {
|
||||||
|
return Object.fromEntries((await c.req.formData()).entries());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return hono
|
return hono
|
||||||
.post("/login", async (c) => {
|
.post("/login", async (c) => {
|
||||||
const body = (await c.req.json()) ?? {};
|
const body = await getBody(c);
|
||||||
|
|
||||||
|
try {
|
||||||
const payload = await this.login(body);
|
const payload = await this.login(body);
|
||||||
const data = await authenticator.resolve("login", this, payload.password, payload);
|
const data = await authenticator.resolve("login", this, payload.password, payload);
|
||||||
|
|
||||||
return c.json(data);
|
return await authenticator.respond(c, data);
|
||||||
|
} catch (e) {
|
||||||
|
return await authenticator.respond(c, e);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.post("/register", async (c) => {
|
.post("/register", async (c) => {
|
||||||
const body = (await c.req.json()) ?? {};
|
const body = await getBody(c);
|
||||||
|
|
||||||
const payload = await this.register(body);
|
const payload = await this.register(body);
|
||||||
const data = await authenticator.resolve("register", this, payload.password, payload);
|
const data = await authenticator.resolve("register", this, payload.password, payload);
|
||||||
|
|
||||||
return c.json(data);
|
return await authenticator.respond(c, data);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { AuthAction, Authenticator, Strategy } from "auth";
|
import type { AuthAction, Authenticator, Strategy } from "auth";
|
||||||
import { Exception } from "core";
|
import { Exception, isDebug } from "core";
|
||||||
import { type Static, StringEnum, type TSchema, Type, filterKeys, parse } from "core/utils";
|
import { type Static, StringEnum, type TSchema, Type, filterKeys, parse } from "core/utils";
|
||||||
import { type Context, Hono } from "hono";
|
import { type Context, Hono } from "hono";
|
||||||
import { getSignedCookie, setSignedCookie } from "hono/cookie";
|
import { getSignedCookie, setSignedCookie } from "hono/cookie";
|
||||||
@@ -173,7 +173,7 @@ export class OAuthStrategy implements Strategy {
|
|||||||
const config = await this.getConfig();
|
const config = await this.getConfig();
|
||||||
const { client, as, type } = config;
|
const { client, as, type } = config;
|
||||||
//console.log("config", config);
|
//console.log("config", config);
|
||||||
//console.log("callbackParams", callbackParams, options);
|
console.log("callbackParams", callbackParams, options);
|
||||||
const parameters = oauth.validateAuthResponse(
|
const parameters = oauth.validateAuthResponse(
|
||||||
as,
|
as,
|
||||||
client, // no client_secret required
|
client, // no client_secret required
|
||||||
@@ -216,7 +216,7 @@ export class OAuthStrategy implements Strategy {
|
|||||||
expectedNonce
|
expectedNonce
|
||||||
);
|
);
|
||||||
if (oauth.isOAuth2Error(result)) {
|
if (oauth.isOAuth2Error(result)) {
|
||||||
//console.log("callback.error", result);
|
console.log("callback.error", result);
|
||||||
// @todo: Handle OAuth 2.0 response body error
|
// @todo: Handle OAuth 2.0 response body error
|
||||||
throw new OAuthCallbackException(result, "processAuthorizationCodeOpenIDResponse");
|
throw new OAuthCallbackException(result, "processAuthorizationCodeOpenIDResponse");
|
||||||
}
|
}
|
||||||
@@ -317,10 +317,15 @@ export class OAuthStrategy implements Strategy {
|
|||||||
const secret = "secret";
|
const secret = "secret";
|
||||||
const cookie_name = "_challenge";
|
const cookie_name = "_challenge";
|
||||||
|
|
||||||
const setState = async (
|
type TState = {
|
||||||
c: Context,
|
state: string;
|
||||||
config: { state: string; action: AuthAction; redirect?: string }
|
action: AuthAction;
|
||||||
): Promise<void> => {
|
redirect?: string;
|
||||||
|
mode: "token" | "cookie";
|
||||||
|
};
|
||||||
|
|
||||||
|
const setState = async (c: Context, config: TState): Promise<void> => {
|
||||||
|
console.log("--- setting state", config);
|
||||||
await setSignedCookie(c, cookie_name, JSON.stringify(config), secret, {
|
await setSignedCookie(c, cookie_name, JSON.stringify(config), secret, {
|
||||||
secure: true,
|
secure: true,
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
@@ -329,12 +334,18 @@ export class OAuthStrategy implements Strategy {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const getState = async (
|
const getState = async (c: Context): Promise<TState> => {
|
||||||
c: Context
|
if (c.req.header("X-State-Challenge")) {
|
||||||
): Promise<{ state: string; action: AuthAction; redirect?: string }> => {
|
return {
|
||||||
const state = await getSignedCookie(c, secret, cookie_name);
|
state: c.req.header("X-State-Challenge"),
|
||||||
|
action: c.req.header("X-State-Action"),
|
||||||
|
mode: "token"
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = await getSignedCookie(c, secret, cookie_name);
|
||||||
try {
|
try {
|
||||||
return JSON.parse(state as string);
|
return JSON.parse(value as string);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error("Invalid state");
|
throw new Error("Invalid state");
|
||||||
}
|
}
|
||||||
@@ -345,22 +356,68 @@ export class OAuthStrategy implements Strategy {
|
|||||||
const params = new URLSearchParams(url.search);
|
const params = new URLSearchParams(url.search);
|
||||||
|
|
||||||
const state = await getState(c);
|
const state = await getState(c);
|
||||||
console.log("url", url);
|
console.log("state", state);
|
||||||
|
|
||||||
|
// @todo: add config option to determine if state.action is allowed
|
||||||
|
const redirect_uri =
|
||||||
|
state.mode === "cookie"
|
||||||
|
? url.origin + url.pathname
|
||||||
|
: url.origin + url.pathname.replace("/callback", "/token");
|
||||||
|
|
||||||
const profile = await this.callback(params, {
|
const profile = await this.callback(params, {
|
||||||
redirect_uri: url.origin + url.pathname,
|
redirect_uri,
|
||||||
state: state.state
|
state: state.state
|
||||||
});
|
});
|
||||||
|
|
||||||
const { user, token } = await auth.resolve(state.action, this, profile.sub, profile);
|
try {
|
||||||
console.log("******** RESOLVED ********", { user, token });
|
const data = await auth.resolve(state.action, this, profile.sub, profile);
|
||||||
|
console.log("******** RESOLVED ********", data);
|
||||||
|
|
||||||
if (state.redirect) {
|
if (state.mode === "cookie") {
|
||||||
console.log("redirect to", state.redirect + "?token=" + token);
|
return await auth.respond(c, data, state.redirect);
|
||||||
return c.redirect(state.redirect + "?token=" + token);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.json({ user, token });
|
return c.json(data);
|
||||||
|
} catch (e) {
|
||||||
|
if (state.mode === "cookie") {
|
||||||
|
return await auth.respond(c, e, state.redirect);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
hono.get("/token", async (c) => {
|
||||||
|
const url = new URL(c.req.url);
|
||||||
|
const params = new URLSearchParams(url.search);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
code: params.get("code") ?? null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
hono.post("/:action", async (c) => {
|
||||||
|
const action = c.req.param("action") as AuthAction;
|
||||||
|
if (!["login", "register"].includes(action)) {
|
||||||
|
return c.notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(c.req.url);
|
||||||
|
const path = url.pathname.replace(`/${action}`, "");
|
||||||
|
const redirect_uri = url.origin + path + "/callback";
|
||||||
|
const referer = new URL(c.req.header("Referer") ?? "/");
|
||||||
|
|
||||||
|
const state = oauth.generateRandomCodeVerifier();
|
||||||
|
const response = await this.request({
|
||||||
|
redirect_uri,
|
||||||
|
state
|
||||||
|
});
|
||||||
|
//console.log("_state", state);
|
||||||
|
|
||||||
|
await setState(c, { state, action, redirect: referer.toString(), mode: "cookie" });
|
||||||
|
console.log("--redirecting to", response.url);
|
||||||
|
|
||||||
|
return c.redirect(response.url);
|
||||||
});
|
});
|
||||||
|
|
||||||
hono.get("/:action", async (c) => {
|
hono.get("/:action", async (c) => {
|
||||||
@@ -371,31 +428,29 @@ export class OAuthStrategy implements Strategy {
|
|||||||
|
|
||||||
const url = new URL(c.req.url);
|
const url = new URL(c.req.url);
|
||||||
const path = url.pathname.replace(`/${action}`, "");
|
const path = url.pathname.replace(`/${action}`, "");
|
||||||
const redirect_uri = url.origin + path + "/callback";
|
const redirect_uri = url.origin + path + "/token";
|
||||||
const q_redirect = (c.req.query("redirect") as string) ?? undefined;
|
|
||||||
|
|
||||||
const state = await oauth.generateRandomCodeVerifier();
|
const state = oauth.generateRandomCodeVerifier();
|
||||||
const response = await this.request({
|
const response = await this.request({
|
||||||
redirect_uri,
|
redirect_uri,
|
||||||
state
|
state
|
||||||
});
|
});
|
||||||
//console.log("_state", state);
|
|
||||||
|
|
||||||
await setState(c, { state, action, redirect: q_redirect });
|
if (isDebug()) {
|
||||||
|
|
||||||
if (c.req.header("Accept") === "application/json") {
|
|
||||||
return c.json({
|
return c.json({
|
||||||
url: response.url,
|
url: response.url,
|
||||||
redirect_uri,
|
redirect_uri,
|
||||||
challenge: state,
|
challenge: state,
|
||||||
|
action,
|
||||||
params: response.params
|
params: response.params
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
//return c.text(response.url);
|
return c.json({
|
||||||
console.log("--redirecting to", response.url);
|
url: response.url,
|
||||||
|
challenge: state,
|
||||||
return c.redirect(response.url);
|
action
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return hono;
|
return hono;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Exception, Permission } from "core";
|
import { Exception, Permission } from "core";
|
||||||
import { type Static, Type, objectTransform } from "core/utils";
|
import { objectTransform } from "core/utils";
|
||||||
import { Role } from "./Role";
|
import { Role } from "./Role";
|
||||||
|
|
||||||
export type GuardUserContext = {
|
export type GuardUserContext = {
|
||||||
@@ -11,6 +11,8 @@ export type GuardConfig = {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const debug = false;
|
||||||
|
|
||||||
export class Guard {
|
export class Guard {
|
||||||
permissions: Permission[];
|
permissions: Permission[];
|
||||||
user?: GuardUserContext;
|
user?: GuardUserContext;
|
||||||
@@ -96,12 +98,12 @@ 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) {
|
||||||
console.log("guard: role found", this.user.role);
|
debug && console.log("guard: role found", this.user.role);
|
||||||
return role;
|
return role;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("guard: role not found", this.user, this.user?.role);
|
debug && console.log("guard: role not found", this.user, this.user?.role);
|
||||||
return this.getDefaultRole();
|
return this.getDefaultRole();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,10 +111,14 @@ export class Guard {
|
|||||||
return this.roles?.find((role) => role.is_default);
|
return this.roles?.find((role) => role.is_default);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isEnabled() {
|
||||||
|
return this.config?.enabled === true;
|
||||||
|
}
|
||||||
|
|
||||||
hasPermission(permission: Permission): boolean;
|
hasPermission(permission: Permission): boolean;
|
||||||
hasPermission(name: string): boolean;
|
hasPermission(name: string): boolean;
|
||||||
hasPermission(permissionOrName: Permission | string): boolean {
|
hasPermission(permissionOrName: Permission | string): boolean {
|
||||||
if (this.config?.enabled !== true) {
|
if (!this.isEnabled()) {
|
||||||
//console.log("guard not enabled, allowing");
|
//console.log("guard not enabled, allowing");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -126,10 +132,10 @@ export class Guard {
|
|||||||
const role = this.getUserRole();
|
const role = this.getUserRole();
|
||||||
|
|
||||||
if (!role) {
|
if (!role) {
|
||||||
console.log("guard: role not found, denying");
|
debug && console.log("guard: role not found, denying");
|
||||||
return false;
|
return false;
|
||||||
} else if (role.implicit_allow === true) {
|
} else if (role.implicit_allow === true) {
|
||||||
console.log("guard: role implicit allow, allowing");
|
debug && console.log("guard: role implicit allow, allowing");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,6 +143,7 @@ export class Guard {
|
|||||||
(rolePermission) => rolePermission.permission.name === name
|
(rolePermission) => rolePermission.permission.name === name
|
||||||
);
|
);
|
||||||
|
|
||||||
|
debug &&
|
||||||
console.log("guard: rolePermission, allowing?", {
|
console.log("guard: rolePermission, allowing?", {
|
||||||
permission: name,
|
permission: name,
|
||||||
role: role.name,
|
role: role.name,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const config: CliCommand = (program) => {
|
|||||||
.description("get default config")
|
.description("get default config")
|
||||||
.option("--pretty", "pretty print")
|
.option("--pretty", "pretty print")
|
||||||
.action((options) => {
|
.action((options) => {
|
||||||
console.log(getDefaultConfig(options.pretty));
|
const config = getDefaultConfig();
|
||||||
|
console.log(options.pretty ? JSON.stringify(config, null, 2) : JSON.stringify(config));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -28,7 +28,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("/assets/*", await serveStatic(platform));
|
app.module.server.client.get("/*", 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 }) {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { Config } from "@libsql/client/node";
|
import type { Config } from "@libsql/client/node";
|
||||||
import { App } from "App";
|
import { App } from "App";
|
||||||
import type { BkndConfig } from "adapter";
|
import type { BkndConfig } from "adapter";
|
||||||
|
import type { CliCommand } from "cli/types";
|
||||||
import { Option } from "commander";
|
import { Option } from "commander";
|
||||||
import type { Connection } from "data";
|
import type { Connection } from "data";
|
||||||
import type { CliCommand } from "../../types";
|
|
||||||
import {
|
import {
|
||||||
PLATFORMS,
|
PLATFORMS,
|
||||||
type Platform,
|
type Platform,
|
||||||
@@ -48,14 +48,13 @@ type MakeAppConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function makeApp(config: MakeAppConfig) {
|
async function makeApp(config: MakeAppConfig) {
|
||||||
const html = await getHtml();
|
|
||||||
const app = new App(config.connection);
|
const app = new App(config.connection);
|
||||||
|
|
||||||
app.emgr.on(
|
app.emgr.on(
|
||||||
"app-built",
|
"app-built",
|
||||||
async () => {
|
async () => {
|
||||||
await attachServeStatic(app, config.server?.platform ?? "node");
|
await attachServeStatic(app, config.server?.platform ?? "node");
|
||||||
app.module.server.setAdminHtml(html);
|
app.registerAdminController();
|
||||||
|
|
||||||
if (config.onBuilt) {
|
if (config.onBuilt) {
|
||||||
await config.onBuilt(app);
|
await config.onBuilt(app);
|
||||||
@@ -70,14 +69,13 @@ async function makeApp(config: MakeAppConfig) {
|
|||||||
|
|
||||||
export async function makeConfigApp(config: BkndConfig, platform?: Platform) {
|
export async function makeConfigApp(config: BkndConfig, platform?: Platform) {
|
||||||
const appConfig = typeof config.app === "function" ? config.app(process.env) : config.app;
|
const appConfig = typeof config.app === "function" ? config.app(process.env) : config.app;
|
||||||
const html = await getHtml();
|
|
||||||
const app = App.create(appConfig);
|
const app = App.create(appConfig);
|
||||||
|
|
||||||
app.emgr.on(
|
app.emgr.on(
|
||||||
"app-built",
|
"app-built",
|
||||||
async () => {
|
async () => {
|
||||||
await attachServeStatic(app, platform ?? "node");
|
await attachServeStatic(app, platform ?? "node");
|
||||||
app.module.server.setAdminHtml(html);
|
app.registerAdminController();
|
||||||
|
|
||||||
if (config.onBuilt) {
|
if (config.onBuilt) {
|
||||||
await config.onBuilt(app);
|
await config.onBuilt(app);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const schema: CliCommand = (program) => {
|
|||||||
.description("get schema")
|
.description("get schema")
|
||||||
.option("--pretty", "pretty print")
|
.option("--pretty", "pretty print")
|
||||||
.action((options) => {
|
.action((options) => {
|
||||||
console.log(getDefaultSchema(options.pretty));
|
const schema = getDefaultSchema();
|
||||||
|
console.log(options.pretty ? JSON.stringify(schema, null, 2) : JSON.stringify(schema));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import {
|
|||||||
|
|
||||||
export type SchemaObjectOptions<Schema extends TObject> = {
|
export type SchemaObjectOptions<Schema extends TObject> = {
|
||||||
onUpdate?: (config: Static<Schema>) => void | Promise<void>;
|
onUpdate?: (config: Static<Schema>) => void | Promise<void>;
|
||||||
|
onBeforeUpdate?: (
|
||||||
|
from: Static<Schema>,
|
||||||
|
to: Static<Schema>
|
||||||
|
) => Static<Schema> | Promise<Static<Schema>>;
|
||||||
restrictPaths?: string[];
|
restrictPaths?: string[];
|
||||||
overwritePaths?: (RegExp | string)[];
|
overwritePaths?: (RegExp | string)[];
|
||||||
forceParse?: boolean;
|
forceParse?: boolean;
|
||||||
@@ -45,6 +49,13 @@ export class SchemaObject<Schema extends TObject> {
|
|||||||
return this._default;
|
return this._default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async onBeforeUpdate(from: Static<Schema>, to: Static<Schema>): Promise<Static<Schema>> {
|
||||||
|
if (this.options?.onBeforeUpdate) {
|
||||||
|
return this.options.onBeforeUpdate(from, to);
|
||||||
|
}
|
||||||
|
return to;
|
||||||
|
}
|
||||||
|
|
||||||
get(options?: { stripMark?: boolean }): Static<Schema> {
|
get(options?: { stripMark?: boolean }): Static<Schema> {
|
||||||
if (options?.stripMark) {
|
if (options?.stripMark) {
|
||||||
return stripMark(this._config);
|
return stripMark(this._config);
|
||||||
@@ -58,8 +69,10 @@ export class SchemaObject<Schema extends TObject> {
|
|||||||
forceParse: true,
|
forceParse: true,
|
||||||
skipMark: this.isForceParse()
|
skipMark: this.isForceParse()
|
||||||
});
|
});
|
||||||
this._value = valid;
|
const updatedConfig = noEmit ? valid : await this.onBeforeUpdate(this._config, valid);
|
||||||
this._config = Object.freeze(valid);
|
|
||||||
|
this._value = updatedConfig;
|
||||||
|
this._config = Object.freeze(updatedConfig);
|
||||||
|
|
||||||
if (noEmit !== true) {
|
if (noEmit !== true) {
|
||||||
await this.options?.onUpdate?.(this._config);
|
await this.options?.onUpdate?.(this._config);
|
||||||
@@ -134,7 +147,7 @@ export class SchemaObject<Schema extends TObject> {
|
|||||||
overwritePaths.length > 1
|
overwritePaths.length > 1
|
||||||
? overwritePaths.filter((k) =>
|
? overwritePaths.filter((k) =>
|
||||||
overwritePaths.some((k2) => {
|
overwritePaths.some((k2) => {
|
||||||
console.log("keep?", { k, k2 }, k2 !== k && k2.startsWith(k));
|
//console.log("keep?", { k, k2 }, k2 !== k && k2.startsWith(k));
|
||||||
return k2 !== k && k2.startsWith(k);
|
return k2 !== k && k2.startsWith(k);
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|||||||
40
app/src/core/server/flash.ts
Normal file
40
app/src/core/server/flash.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type { Context } from "hono";
|
||||||
|
import { setCookie } from "hono/cookie";
|
||||||
|
|
||||||
|
const flash_key = "__bknd_flash";
|
||||||
|
export type FlashMessageType = "error" | "warning" | "success" | "info";
|
||||||
|
|
||||||
|
export async function addFlashMessage(
|
||||||
|
c: Context,
|
||||||
|
message: string,
|
||||||
|
type: FlashMessageType = "info"
|
||||||
|
) {
|
||||||
|
setCookie(c, flash_key, JSON.stringify({ type, message }), {
|
||||||
|
path: "/"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCookieValue(name) {
|
||||||
|
const cookies = document.cookie.split("; ");
|
||||||
|
for (const cookie of cookies) {
|
||||||
|
const [key, value] = cookie.split("=");
|
||||||
|
if (key === name) {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(value as any);
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null; // Return null if the cookie is not found
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFlashMessage(
|
||||||
|
clear = true
|
||||||
|
): { type: FlashMessageType; message: string } | undefined {
|
||||||
|
const flash = getCookieValue(flash_key);
|
||||||
|
if (flash && clear) {
|
||||||
|
document.cookie = `${flash_key}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
|
||||||
|
}
|
||||||
|
return flash ? JSON.parse(flash) : undefined;
|
||||||
|
}
|
||||||
@@ -27,3 +27,9 @@ export async function checksum(s: any) {
|
|||||||
const o = typeof s === "string" ? s : JSON.stringify(s);
|
const o = typeof s === "string" ? s : JSON.stringify(s);
|
||||||
return await digest("SHA-1", o);
|
return await digest("SHA-1", o);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function secureRandomString(length: number): string {
|
||||||
|
const array = new Uint8Array(length);
|
||||||
|
crypto.getRandomValues(array);
|
||||||
|
return Array.from(array, (byte) => String.fromCharCode(33 + (byte % 94))).join("");
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,10 +15,6 @@ export function ucFirstAll(str: string, split: string = " "): string {
|
|||||||
.join(split);
|
.join(split);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ucFirstAllSnakeToPascalWithSpaces(str: string, split: string = " "): string {
|
|
||||||
return ucFirstAll(snakeToPascalWithSpaces(str), split);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function randomString(length: number, includeSpecial = false): string {
|
export function randomString(length: number, includeSpecial = false): string {
|
||||||
const base = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
const base = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||||
const special = "!@#$%^&*()_+{}:\"<>?|[];',./`~";
|
const special = "!@#$%^&*()_+{}:\"<>?|[];',./`~";
|
||||||
@@ -49,6 +45,54 @@ export function pascalToKebab(pascalStr: string): string {
|
|||||||
return pascalStr.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
return pascalStr.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type StringCaseType =
|
||||||
|
| "snake_case"
|
||||||
|
| "PascalCase"
|
||||||
|
| "camelCase"
|
||||||
|
| "kebab-case"
|
||||||
|
| "SCREAMING_SNAKE_CASE"
|
||||||
|
| "unknown";
|
||||||
|
export function detectCase(input: string): StringCaseType {
|
||||||
|
if (/^[a-z]+(_[a-z]+)*$/.test(input)) {
|
||||||
|
return "snake_case";
|
||||||
|
} else if (/^[A-Z][a-zA-Z]*$/.test(input)) {
|
||||||
|
return "PascalCase";
|
||||||
|
} else if (/^[a-z][a-zA-Z]*$/.test(input)) {
|
||||||
|
return "camelCase";
|
||||||
|
} else if (/^[a-z]+(-[a-z]+)*$/.test(input)) {
|
||||||
|
return "kebab-case";
|
||||||
|
} else if (/^[A-Z]+(_[A-Z]+)*$/.test(input)) {
|
||||||
|
return "SCREAMING_SNAKE_CASE";
|
||||||
|
} else {
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function identifierToHumanReadable(str: string) {
|
||||||
|
const _case = detectCase(str);
|
||||||
|
switch (_case) {
|
||||||
|
case "snake_case":
|
||||||
|
return snakeToPascalWithSpaces(str);
|
||||||
|
case "PascalCase":
|
||||||
|
return kebabToPascalWithSpaces(pascalToKebab(str));
|
||||||
|
case "camelCase":
|
||||||
|
return ucFirst(kebabToPascalWithSpaces(pascalToKebab(str)));
|
||||||
|
case "kebab-case":
|
||||||
|
return kebabToPascalWithSpaces(str);
|
||||||
|
case "SCREAMING_SNAKE_CASE":
|
||||||
|
return snakeToPascalWithSpaces(str.toLowerCase());
|
||||||
|
case "unknown":
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function kebabToPascalWithSpaces(str: string): string {
|
||||||
|
return str.split("-").map(ucFirst).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ucFirstAllSnakeToPascalWithSpaces(str: string, split: string = " "): string {
|
||||||
|
return ucFirstAll(snakeToPascalWithSpaces(str), split);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replace simple mustache like {placeholders} in a string
|
* Replace simple mustache like {placeholders} in a string
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
import { Hono } from "hono";
|
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 { AppData } from "../AppData";
|
import * as SystemPermissions from "modules/permissions";
|
||||||
import { type AppDataConfig, FIELDS } from "../data-schema";
|
import { type AppDataConfig, FIELDS } from "../data-schema";
|
||||||
|
|
||||||
export class DataController implements ClassController {
|
export class DataController implements ClassController {
|
||||||
@@ -89,12 +89,10 @@ export class DataController implements ClassController {
|
|||||||
return func;
|
return func;
|
||||||
}
|
}
|
||||||
|
|
||||||
// add timing
|
hono.use("*", async (c, next) => {
|
||||||
/*hono.use("*", async (c, next) => {
|
this.ctx.guard.throwUnlessGranted(SystemPermissions.accessApi);
|
||||||
startTime(c, "data");
|
|
||||||
await next();
|
await next();
|
||||||
endTime(c, "data");
|
});
|
||||||
});*/
|
|
||||||
|
|
||||||
// info
|
// info
|
||||||
hono.get(
|
hono.get(
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export type DbFunctions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export abstract class Connection {
|
export abstract class Connection {
|
||||||
|
cls = "bknd:connection";
|
||||||
kysely: Kysely<any>;
|
kysely: Kysely<any>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -52,6 +53,15 @@ export abstract class Connection {
|
|||||||
this.kysely = kysely;
|
this.kysely = kysely;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is a helper function to manage Connection classes
|
||||||
|
* coming from different places
|
||||||
|
* @param conn
|
||||||
|
*/
|
||||||
|
static isConnection(conn: any): conn is Connection {
|
||||||
|
return conn?.cls === "bknd:connection";
|
||||||
|
}
|
||||||
|
|
||||||
getIntrospector(): ConnectionIntrospector {
|
getIntrospector(): ConnectionIntrospector {
|
||||||
return this.kysely.introspection as ConnectionIntrospector;
|
return this.kysely.introspection as ConnectionIntrospector;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export class EntityManager<DB> {
|
|||||||
relations.forEach((relation) => this.addRelation(relation));
|
relations.forEach((relation) => this.addRelation(relation));
|
||||||
indices.forEach((index) => this.addIndex(index));
|
indices.forEach((index) => this.addIndex(index));
|
||||||
|
|
||||||
if (!(connection instanceof Connection)) {
|
if (!Connection.isConnection(connection)) {
|
||||||
throw new UnableToConnectException("");
|
throw new UnableToConnectException("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export type ModuleBuildContext = {
|
|||||||
guard: Guard;
|
guard: Guard;
|
||||||
};
|
};
|
||||||
|
|
||||||
export abstract class Module<Schema extends TSchema = TSchema> {
|
export abstract class Module<Schema extends TSchema = TSchema, ConfigSchema = Static<Schema>> {
|
||||||
private _built = false;
|
private _built = false;
|
||||||
private _schema: SchemaObject<ReturnType<(typeof this)["getSchema"]>>;
|
private _schema: SchemaObject<ReturnType<(typeof this)["getSchema"]>>;
|
||||||
private _listener: any = () => null;
|
private _listener: any = () => null;
|
||||||
@@ -28,10 +28,15 @@ export abstract class Module<Schema extends TSchema = TSchema> {
|
|||||||
await this._listener(c);
|
await this._listener(c);
|
||||||
},
|
},
|
||||||
restrictPaths: this.getRestrictedPaths(),
|
restrictPaths: this.getRestrictedPaths(),
|
||||||
overwritePaths: this.getOverwritePaths()
|
overwritePaths: this.getOverwritePaths(),
|
||||||
|
onBeforeUpdate: this.onBeforeUpdate.bind(this)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onBeforeUpdate(from: ConfigSchema, to: ConfigSchema): ConfigSchema | Promise<ConfigSchema> {
|
||||||
|
return to;
|
||||||
|
}
|
||||||
|
|
||||||
setListener(listener: (c: ReturnType<(typeof this)["getSchema"]>) => void | Promise<void>) {
|
setListener(listener: (c: ReturnType<(typeof this)["getSchema"]>) => void | Promise<void>) {
|
||||||
this._listener = listener;
|
this._listener = listener;
|
||||||
return this;
|
return this;
|
||||||
@@ -92,7 +97,8 @@ export abstract class Module<Schema extends TSchema = TSchema> {
|
|||||||
},
|
},
|
||||||
forceParse: this.useForceParse(),
|
forceParse: this.useForceParse(),
|
||||||
restrictPaths: this.getRestrictedPaths(),
|
restrictPaths: this.getRestrictedPaths(),
|
||||||
overwritePaths: this.getOverwritePaths()
|
overwritePaths: this.getOverwritePaths(),
|
||||||
|
onBeforeUpdate: this.onBeforeUpdate.bind(this)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export type BaseModuleApiOptions = {
|
|||||||
host: string;
|
host: string;
|
||||||
basepath?: string;
|
basepath?: string;
|
||||||
token?: string;
|
token?: string;
|
||||||
|
headers?: Headers;
|
||||||
|
token_transport?: "header" | "cookie" | "none";
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ApiResponse<Data = any> = {
|
export type ApiResponse<Data = any> = {
|
||||||
@@ -53,18 +55,22 @@ export abstract class ModuleApi<Options extends BaseModuleApiOptions> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = new Headers(_init?.headers ?? {});
|
const headers = new Headers(this.options.headers ?? {});
|
||||||
|
// add init headers
|
||||||
|
for (const [key, value] of Object.entries(_init?.headers ?? {})) {
|
||||||
|
headers.set(key, value as string);
|
||||||
|
}
|
||||||
|
|
||||||
headers.set("Accept", "application/json");
|
headers.set("Accept", "application/json");
|
||||||
|
|
||||||
if (this.options.token) {
|
// only add token if initial headers not provided
|
||||||
|
if (this.options.token && this.options.token_transport === "header") {
|
||||||
//console.log("setting token", this.options.token);
|
//console.log("setting token", this.options.token);
|
||||||
headers.set("Authorization", `Bearer ${this.options.token}`);
|
headers.set("Authorization", `Bearer ${this.options.token}`);
|
||||||
} else {
|
|
||||||
//console.log("no token");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let body: any = _init?.body;
|
let body: any = _init?.body;
|
||||||
if (_init && "body" in _init && ["POST", "PATCH"].includes(method)) {
|
if (_init && "body" in _init && ["POST", "PATCH", "PUT"].includes(method)) {
|
||||||
const requestContentType = (headers.get("Content-Type") as string) ?? undefined;
|
const requestContentType = (headers.get("Content-Type") as string) ?? undefined;
|
||||||
if (!requestContentType || requestContentType.startsWith("application/json")) {
|
if (!requestContentType || requestContentType.startsWith("application/json")) {
|
||||||
body = JSON.stringify(_init.body);
|
body = JSON.stringify(_init.body);
|
||||||
@@ -137,6 +143,18 @@ export abstract class ModuleApi<Options extends BaseModuleApiOptions> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected async put<Data = any>(
|
||||||
|
_input: string | (string | number | PrimaryFieldType)[],
|
||||||
|
body?: any,
|
||||||
|
_init?: RequestInit
|
||||||
|
) {
|
||||||
|
return this.request<Data>(_input, undefined, {
|
||||||
|
..._init,
|
||||||
|
body,
|
||||||
|
method: "PUT"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
protected async delete<Data = any>(
|
protected async delete<Data = any>(
|
||||||
_input: string | (string | number | PrimaryFieldType)[],
|
_input: string | (string | number | PrimaryFieldType)[],
|
||||||
_init?: RequestInit
|
_init?: RequestInit
|
||||||
|
|||||||
@@ -425,19 +425,19 @@ export class ModuleManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDefaultSchema(pretty = false) {
|
export function getDefaultSchema() {
|
||||||
const schema = {
|
const schema = {
|
||||||
type: "object",
|
type: "object",
|
||||||
...transformObject(MODULES, (module) => module.prototype.getSchema())
|
...transformObject(MODULES, (module) => module.prototype.getSchema())
|
||||||
};
|
};
|
||||||
|
|
||||||
return JSON.stringify(schema, null, pretty ? 2 : undefined);
|
return schema as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDefaultConfig(pretty = false): ModuleConfigs {
|
export function getDefaultConfig(): ModuleConfigs {
|
||||||
const config = transformObject(MODULES, (module) => {
|
const config = transformObject(MODULES, (module) => {
|
||||||
return Default(module.prototype.getSchema(), {});
|
return Default(module.prototype.getSchema(), {});
|
||||||
});
|
});
|
||||||
|
|
||||||
return JSON.stringify(config, null, pretty ? 2 : undefined) as any;
|
return config as any;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ModuleApi } from "./ModuleApi";
|
import { ModuleApi } from "./ModuleApi";
|
||||||
import type { ModuleConfigs, ModuleSchemas } from "./ModuleManager";
|
import type { ModuleConfigs, ModuleKey, ModuleSchemas } from "./ModuleManager";
|
||||||
|
|
||||||
export type ApiSchemaResponse = {
|
export type ApiSchemaResponse = {
|
||||||
version: number;
|
version: number;
|
||||||
@@ -21,4 +21,31 @@ export class SystemApi extends ModuleApi<any> {
|
|||||||
secrets: options?.secrets ? 1 : 0
|
secrets: options?.secrets ? 1 : 0
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setConfig<Module extends ModuleKey>(
|
||||||
|
module: Module,
|
||||||
|
value: ModuleConfigs[Module],
|
||||||
|
force?: boolean
|
||||||
|
) {
|
||||||
|
return await this.post<any>(
|
||||||
|
["config", "set", module].join("/") + `?force=${force ? 1 : 0}`,
|
||||||
|
value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addConfig<Module extends ModuleKey>(module: Module, path: string, value: any) {
|
||||||
|
return await this.post<any>(["config", "add", module, path], value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async patchConfig<Module extends ModuleKey>(module: Module, path: string, value: any) {
|
||||||
|
return await this.patch<any>(["config", "patch", module, path], value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async overwriteConfig<Module extends ModuleKey>(module: Module, path: string, value: any) {
|
||||||
|
return await this.put<any>(["config", "overwrite", module, path], value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeConfig<Module extends ModuleKey>(module: Module, path: string) {
|
||||||
|
return await this.delete<any>(["config", "remove", module, path]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,21 @@ export const migrations: Migration[] = [
|
|||||||
up: async (config, { db }) => {
|
up: async (config, { db }) => {
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 7,
|
||||||
|
up: async (config, { db }) => {
|
||||||
|
// automatically adds auth.cookie options
|
||||||
|
// remove "expiresIn" (string), it's now "expires" (number)
|
||||||
|
const { expiresIn, ...jwt } = config.auth.jwt;
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
auth: {
|
||||||
|
...config.auth,
|
||||||
|
jwt
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Permission } from "core";
|
import { Permission } from "core";
|
||||||
|
|
||||||
|
export const accessAdmin = new Permission("system.access.admin");
|
||||||
|
export const accessApi = new Permission("system.access.api");
|
||||||
export const configRead = new Permission("system.config.read");
|
export const configRead = new Permission("system.config.read");
|
||||||
export const configReadSecrets = new Permission("system.config.read.secrets");
|
export const configReadSecrets = new Permission("system.config.read.secrets");
|
||||||
export const configWrite = new Permission("system.config.write");
|
export const configWrite = new Permission("system.config.write");
|
||||||
|
|||||||
181
app/src/modules/server/AdminController.tsx
Normal file
181
app/src/modules/server/AdminController.tsx
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
/** @jsxImportSource hono/jsx */
|
||||||
|
|
||||||
|
import type { App } from "App";
|
||||||
|
import { type ClassController, isDebug } from "core";
|
||||||
|
import { addFlashMessage } from "core/server/flash";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { html } from "hono/html";
|
||||||
|
import { Fragment } from "hono/jsx";
|
||||||
|
import * as SystemPermissions from "modules/permissions";
|
||||||
|
|
||||||
|
const htmlBkndContextReplace = "<!-- BKND_CONTEXT -->";
|
||||||
|
|
||||||
|
export type AdminControllerOptions = {
|
||||||
|
html?: string;
|
||||||
|
forceDev?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class AdminController implements ClassController {
|
||||||
|
constructor(
|
||||||
|
private readonly app: App,
|
||||||
|
private options: AdminControllerOptions = {}
|
||||||
|
) {}
|
||||||
|
|
||||||
|
get ctx() {
|
||||||
|
return this.app.modules.ctx();
|
||||||
|
}
|
||||||
|
|
||||||
|
private withBasePath(route: string = "") {
|
||||||
|
return (this.app.modules.configs().server.admin.basepath + route).replace(/\/+$/, "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
getController(): Hono<any> {
|
||||||
|
const auth = this.app.module.auth;
|
||||||
|
const configs = this.app.modules.configs();
|
||||||
|
// if auth is not enabled, authenticator is undefined
|
||||||
|
const auth_enabled = configs.auth.enabled;
|
||||||
|
const hono = new Hono<{
|
||||||
|
Variables: {
|
||||||
|
html: string;
|
||||||
|
};
|
||||||
|
}>().basePath(this.withBasePath());
|
||||||
|
const authRoutes = {
|
||||||
|
root: "/",
|
||||||
|
success: configs.auth.cookie.pathSuccess ?? "/",
|
||||||
|
loggedOut: configs.auth.cookie.pathLoggedOut ?? "/",
|
||||||
|
login: "/auth/login",
|
||||||
|
logout: "/auth/logout"
|
||||||
|
};
|
||||||
|
|
||||||
|
hono.use("*", async (c, next) => {
|
||||||
|
const obj = {
|
||||||
|
user: auth.authenticator?.getUser(),
|
||||||
|
logout_route: this.withBasePath(authRoutes.logout)
|
||||||
|
};
|
||||||
|
const html = await this.getHtml(obj);
|
||||||
|
if (!html) {
|
||||||
|
console.warn("Couldn't generate HTML for admin UI");
|
||||||
|
// re-casting to void as a return is not required
|
||||||
|
return c.notFound() as unknown as void;
|
||||||
|
}
|
||||||
|
c.set("html", html);
|
||||||
|
|
||||||
|
// refresh cookie if needed
|
||||||
|
await auth.authenticator?.requestCookieRefresh(c);
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (auth_enabled) {
|
||||||
|
hono.get(authRoutes.login, async (c) => {
|
||||||
|
if (
|
||||||
|
this.app.module.auth.authenticator?.isUserLoggedIn() &&
|
||||||
|
this.ctx.guard.granted(SystemPermissions.accessAdmin)
|
||||||
|
) {
|
||||||
|
return c.redirect(authRoutes.success);
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = c.get("html");
|
||||||
|
return c.html(html);
|
||||||
|
});
|
||||||
|
|
||||||
|
hono.get(authRoutes.logout, async (c) => {
|
||||||
|
await auth.authenticator?.logout(c);
|
||||||
|
return c.redirect(authRoutes.loggedOut);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
hono.get("*", async (c) => {
|
||||||
|
if (!this.ctx.guard.granted(SystemPermissions.accessAdmin)) {
|
||||||
|
await addFlashMessage(c, "You are not authorized to access the Admin UI", "error");
|
||||||
|
return c.redirect(authRoutes.login);
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = c.get("html");
|
||||||
|
return c.html(html);
|
||||||
|
});
|
||||||
|
|
||||||
|
return hono;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getHtml(obj: any = {}) {
|
||||||
|
const bknd_context = `window.__BKND__ = JSON.parse('${JSON.stringify(obj)}');`;
|
||||||
|
|
||||||
|
if (this.options.html) {
|
||||||
|
if (this.options.html.includes(htmlBkndContextReplace)) {
|
||||||
|
return this.options.html.replace(htmlBkndContextReplace, bknd_context);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn(
|
||||||
|
`Custom HTML needs to include '${htmlBkndContextReplace}' to inject BKND context`
|
||||||
|
);
|
||||||
|
return this.options.html as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const configs = this.app.modules.configs();
|
||||||
|
const isProd = !isDebug() && !this.options.forceDev;
|
||||||
|
|
||||||
|
const assets = {
|
||||||
|
js: "main.js",
|
||||||
|
css: "styles.css"
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isProd) {
|
||||||
|
try {
|
||||||
|
// @ts-ignore
|
||||||
|
const manifest = await import("bknd/dist/manifest.json", {
|
||||||
|
assert: { type: "json" }
|
||||||
|
}).then((m) => m.default);
|
||||||
|
assets.js = manifest["src/ui/main.tsx"].name;
|
||||||
|
assets.css = manifest["src/ui/main.css"].name;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error loading manifest", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
{/* dnd complains otherwise */}
|
||||||
|
{html`<!DOCTYPE html>`}
|
||||||
|
<html lang="en" class={configs.server.admin.color_scheme ?? "light"}>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1, maximum-scale=1"
|
||||||
|
/>
|
||||||
|
<title>BKND</title>
|
||||||
|
{isProd ? (
|
||||||
|
<Fragment>
|
||||||
|
<script type="module" CrossOrigin src={"/" + assets?.js} />
|
||||||
|
<link rel="stylesheet" crossOrigin href={"/" + assets?.css} />
|
||||||
|
</Fragment>
|
||||||
|
) : (
|
||||||
|
<Fragment>
|
||||||
|
<script
|
||||||
|
type="module"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `import RefreshRuntime from "/@react-refresh"
|
||||||
|
RefreshRuntime.injectIntoGlobalHook(window)
|
||||||
|
window.$RefreshReg$ = () => {}
|
||||||
|
window.$RefreshSig$ = () => (type) => type
|
||||||
|
window.__vite_plugin_react_preamble_installed__ = true`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<script type="module" src={"/@vite/client"} />
|
||||||
|
</Fragment>
|
||||||
|
)}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app" />
|
||||||
|
<script
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: bknd_context
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{!isProd && <script type="module" src="/src/ui/main.tsx" />}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
import type { ClassController } from "core";
|
|
||||||
import { SimpleRenderer } from "core";
|
|
||||||
import { FetchTask, Flow, LogTask } from "flows";
|
|
||||||
import { Hono } from "hono";
|
|
||||||
import { endTime, startTime } from "hono/timing";
|
|
||||||
import type { App } from "../../App";
|
|
||||||
|
|
||||||
export class AppController implements ClassController {
|
|
||||||
constructor(
|
|
||||||
private readonly app: App,
|
|
||||||
private config: any = {}
|
|
||||||
) {}
|
|
||||||
|
|
||||||
getController(): Hono {
|
|
||||||
const hono = new Hono();
|
|
||||||
|
|
||||||
// @todo: add test endpoints
|
|
||||||
|
|
||||||
hono
|
|
||||||
.get("/config", (c) => {
|
|
||||||
return c.json(this.app.toJSON());
|
|
||||||
})
|
|
||||||
.get("/ping", (c) => {
|
|
||||||
//console.log("c", c);
|
|
||||||
try {
|
|
||||||
// @ts-ignore @todo: fix with env
|
|
||||||
const context: any = c.req.raw.cf ? c.req.raw.cf : c.env.cf;
|
|
||||||
const cf = {
|
|
||||||
colo: context.colo,
|
|
||||||
city: context.city,
|
|
||||||
postal: context.postalCode,
|
|
||||||
region: context.region,
|
|
||||||
regionCode: context.regionCode,
|
|
||||||
continent: context.continent,
|
|
||||||
country: context.country,
|
|
||||||
eu: context.isEUCountry,
|
|
||||||
lat: context.latitude,
|
|
||||||
lng: context.longitude,
|
|
||||||
timezone: context.timezone
|
|
||||||
};
|
|
||||||
return c.json({ pong: true, cf, another: 6 });
|
|
||||||
} catch (e) {
|
|
||||||
return c.json({ pong: true, cf: null });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// test endpoints
|
|
||||||
if (this.config?.registerTest) {
|
|
||||||
hono.get("/test/kv", async (c) => {
|
|
||||||
// @ts-ignore
|
|
||||||
const cache = c.env!.CACHE as KVNamespace;
|
|
||||||
startTime(c, "kv-get");
|
|
||||||
const value: any = await cache.get("count");
|
|
||||||
endTime(c, "kv-get");
|
|
||||||
console.log("value", value);
|
|
||||||
startTime(c, "kv-put");
|
|
||||||
if (!value) {
|
|
||||||
await cache.put("count", "1");
|
|
||||||
} else {
|
|
||||||
await cache.put("count", (Number(value) + 1).toString());
|
|
||||||
}
|
|
||||||
endTime(c, "kv-put");
|
|
||||||
|
|
||||||
let cf: any = {};
|
|
||||||
// @ts-ignore
|
|
||||||
if ("cf" in c.req.raw) {
|
|
||||||
cf = {
|
|
||||||
// @ts-ignore
|
|
||||||
colo: c.req.raw.cf?.colo
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json({ pong: true, value, cf });
|
|
||||||
});
|
|
||||||
|
|
||||||
hono.get("/test/flow", async (c) => {
|
|
||||||
const first = new LogTask("Task 0");
|
|
||||||
const second = new LogTask("Task 1");
|
|
||||||
const third = new LogTask("Task 2", { delay: 250 });
|
|
||||||
const fourth = new FetchTask("Fetch Something", {
|
|
||||||
url: "https://jsonplaceholder.typicode.com/todos/1"
|
|
||||||
});
|
|
||||||
const fifth = new LogTask("Task 4"); // without connection
|
|
||||||
|
|
||||||
const flow = new Flow("flow", [first, second, third, fourth, fifth]);
|
|
||||||
flow.task(first).asInputFor(second);
|
|
||||||
flow.task(first).asInputFor(third);
|
|
||||||
flow.task(fourth).asOutputFor(third);
|
|
||||||
|
|
||||||
flow.setRespondingTask(fourth);
|
|
||||||
|
|
||||||
const execution = flow.createExecution();
|
|
||||||
await execution.start();
|
|
||||||
|
|
||||||
const results = flow.tasks.map((t) => t.toJSON());
|
|
||||||
|
|
||||||
return c.json({ results, response: execution.getResponse() });
|
|
||||||
});
|
|
||||||
|
|
||||||
hono.get("/test/template", async (c) => {
|
|
||||||
const renderer = new SimpleRenderer({ var: 123 });
|
|
||||||
const template = "Variable: {{ var }}";
|
|
||||||
|
|
||||||
return c.text(await renderer.render(template));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return hono;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,7 @@ import { Hono } from "hono";
|
|||||||
import { cors } from "hono/cors";
|
import { cors } from "hono/cors";
|
||||||
import { timing } from "hono/timing";
|
import { timing } from "hono/timing";
|
||||||
import { Module } from "modules/Module";
|
import { Module } from "modules/Module";
|
||||||
|
import * as SystemPermissions from "modules/permissions";
|
||||||
|
|
||||||
const serverMethods = ["GET", "POST", "PATCH", "PUT", "DELETE"];
|
const serverMethods = ["GET", "POST", "PATCH", "PUT", "DELETE"];
|
||||||
export const serverConfigSchema = Type.Object(
|
export const serverConfigSchema = Type.Object(
|
||||||
@@ -49,7 +50,7 @@ export type AppServerConfig = Static<typeof serverConfigSchema>;
|
|||||||
}*/
|
}*/
|
||||||
|
|
||||||
export class AppServer extends Module<typeof serverConfigSchema> {
|
export class AppServer extends Module<typeof serverConfigSchema> {
|
||||||
private admin_html?: string;
|
//private admin_html?: string;
|
||||||
|
|
||||||
override getRestrictedPaths() {
|
override getRestrictedPaths() {
|
||||||
return [];
|
return [];
|
||||||
@@ -64,12 +65,6 @@ export class AppServer extends Module<typeof serverConfigSchema> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override async build() {
|
override async build() {
|
||||||
//this.client.use(timing());
|
|
||||||
|
|
||||||
/*this.client.use("*", async (c, next) => {
|
|
||||||
console.log(`[${c.req.method}] ${c.req.url}`);
|
|
||||||
await next();
|
|
||||||
});*/
|
|
||||||
this.client.use(
|
this.client.use(
|
||||||
"*",
|
"*",
|
||||||
cors({
|
cors({
|
||||||
@@ -79,18 +74,6 @@ export class AppServer extends Module<typeof serverConfigSchema> {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
/*this.client.use(async (c, next) => {
|
|
||||||
c.res.headers.set("X-Powered-By", "BKND");
|
|
||||||
try {
|
|
||||||
c.res.headers.set("X-Colo", c.req.raw.cf.colo);
|
|
||||||
} catch (e) {}
|
|
||||||
await next();
|
|
||||||
});
|
|
||||||
this.client.use(async (c, next) => {
|
|
||||||
console.log(`[${c.req.method}] ${c.req.url}`);
|
|
||||||
await next();
|
|
||||||
});*/
|
|
||||||
|
|
||||||
this.client.onError((err, c) => {
|
this.client.onError((err, c) => {
|
||||||
//throw err;
|
//throw err;
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -124,18 +107,31 @@ export class AppServer extends Module<typeof serverConfigSchema> {
|
|||||||
this.setBuilt();
|
this.setBuilt();
|
||||||
}
|
}
|
||||||
|
|
||||||
setAdminHtml(html: string) {
|
/*setAdminHtml(html: string) {
|
||||||
this.admin_html = html;
|
this.admin_html = html;
|
||||||
const basepath = (String(this.config.admin.basepath) + "/").replace(/\/+$/, "/");
|
const basepath = (String(this.config.admin.basepath) + "/").replace(/\/+$/, "/");
|
||||||
|
|
||||||
|
const allowed_prefix = basepath + "auth";
|
||||||
|
const login_path = basepath + "auth/login";
|
||||||
|
|
||||||
this.client.get(basepath + "*", async (c, next) => {
|
this.client.get(basepath + "*", async (c, next) => {
|
||||||
|
const path = new URL(c.req.url).pathname;
|
||||||
|
if (!path.startsWith(allowed_prefix)) {
|
||||||
|
console.log("guard check permissions");
|
||||||
|
try {
|
||||||
|
this.ctx.guard.throwUnlessGranted(SystemPermissions.admin);
|
||||||
|
} catch (e) {
|
||||||
|
return c.redirect(login_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return c.html(this.admin_html!);
|
return c.html(this.admin_html!);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getAdminHtml() {
|
getAdminHtml() {
|
||||||
return this.admin_html;
|
return this.admin_html;
|
||||||
}
|
}*/
|
||||||
|
|
||||||
override toJSON(secrets?: boolean) {
|
override toJSON(secrets?: boolean) {
|
||||||
return this.config;
|
return this.config;
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ 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);
|
||||||
@@ -66,10 +67,16 @@ export class SystemController implements ClassController {
|
|||||||
console.error(e);
|
console.error(e);
|
||||||
|
|
||||||
if (e instanceof TypeInvalidError) {
|
if (e instanceof TypeInvalidError) {
|
||||||
return c.json({ success: false, errors: e.errors }, { status: 400 });
|
return c.json(
|
||||||
|
{ success: false, type: "type-invalid", errors: e.errors },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (e instanceof Error) {
|
||||||
|
return c.json({ success: false, type: "error", error: e.message }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.json({ success: false }, { status: 500 });
|
return c.json({ success: false, type: "unknown" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +99,12 @@ export class SystemController implements ClassController {
|
|||||||
// 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
|
||||||
if (force === true) {
|
if (force === true) {
|
||||||
await this.app.mutateConfig(module).set(value);
|
// force overwrite defined keys
|
||||||
|
const newConfig = {
|
||||||
|
...this.app.module[module].config,
|
||||||
|
...value
|
||||||
|
};
|
||||||
|
await this.app.mutateConfig(module).set(newConfig);
|
||||||
} else {
|
} else {
|
||||||
await this.app.mutateConfig(module).patch("", value);
|
await this.app.mutateConfig(module).patch("", value);
|
||||||
}
|
}
|
||||||
@@ -281,7 +293,7 @@ export class SystemController implements ClassController {
|
|||||||
|
|
||||||
hono.get("/openapi.json", async (c) => {
|
hono.get("/openapi.json", async (c) => {
|
||||||
//const config = this.app.toJSON();
|
//const config = this.app.toJSON();
|
||||||
const config = JSON.parse(getDefaultConfig() as any);
|
const config = getDefaultConfig();
|
||||||
return c.json(generateOpenAPI(config));
|
return c.json(generateOpenAPI(config));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import { Notifications } from "@mantine/notifications";
|
import { Notifications } from "@mantine/notifications";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { FlashMessage } from "ui/modules/server/FlashMessage";
|
||||||
import { BkndProvider, ClientProvider, useBknd } from "./client";
|
import { BkndProvider, ClientProvider, useBknd } from "./client";
|
||||||
import { createMantineTheme } from "./lib/mantine/theme";
|
import { createMantineTheme } from "./lib/mantine/theme";
|
||||||
import { BkndModalsProvider } from "./modals";
|
import { BkndModalsProvider } from "./modals";
|
||||||
import { Routes } from "./routes";
|
import { Routes } from "./routes";
|
||||||
|
|
||||||
export default function Admin({
|
export type BkndAdminProps = {
|
||||||
baseUrl: baseUrlOverride,
|
baseUrl?: string;
|
||||||
withProvider = false
|
withProvider?: boolean;
|
||||||
}: { baseUrl?: string; withProvider?: boolean }) {
|
// @todo: add admin config override
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Admin({ baseUrl: baseUrlOverride, withProvider = false }: BkndAdminProps) {
|
||||||
const Component = (
|
const Component = (
|
||||||
<BkndProvider>
|
<BkndProvider>
|
||||||
<AdminInternal />
|
<AdminInternal />
|
||||||
@@ -25,9 +29,11 @@ export default function Admin({
|
|||||||
function AdminInternal() {
|
function AdminInternal() {
|
||||||
const b = useBknd();
|
const b = useBknd();
|
||||||
const theme = b.app.getAdminConfig().color_scheme;
|
const theme = b.app.getAdminConfig().color_scheme;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MantineProvider {...createMantineTheme(theme ?? "light")}>
|
<MantineProvider {...createMantineTheme(theme ?? "light")}>
|
||||||
<Notifications />
|
<Notifications />
|
||||||
|
<FlashMessage />
|
||||||
<BkndModalsProvider>
|
<BkndModalsProvider>
|
||||||
<Routes />
|
<Routes />
|
||||||
</BkndModalsProvider>
|
</BkndModalsProvider>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { createContext, useContext, useEffect, useRef, useState } from "react";
|
//import { notifications } from "@mantine/notifications";
|
||||||
|
import { getDefaultConfig, getDefaultSchema } from "modules/ModuleManager";
|
||||||
|
import { createContext, startTransition, useContext, useEffect, useRef, useState } from "react";
|
||||||
import type { ModuleConfigs, ModuleSchemas } from "../../modules";
|
import type { ModuleConfigs, ModuleSchemas } from "../../modules";
|
||||||
import { useClient } from "./ClientProvider";
|
import { useClient } from "./ClientProvider";
|
||||||
import { type TSchemaActions, getSchemaActions } from "./schema/actions";
|
import { type TSchemaActions, getSchemaActions } from "./schema/actions";
|
||||||
@@ -22,18 +24,51 @@ export function BkndProvider({
|
|||||||
children
|
children
|
||||||
}: { includeSecrets?: boolean; children: any }) {
|
}: { includeSecrets?: boolean; children: any }) {
|
||||||
const [withSecrets, setWithSecrets] = useState<boolean>(includeSecrets);
|
const [withSecrets, setWithSecrets] = useState<boolean>(includeSecrets);
|
||||||
const [schema, setSchema] = useState<BkndContext>();
|
const [schema, setSchema] =
|
||||||
|
useState<Pick<BkndContext, "version" | "schema" | "config" | "permissions">>();
|
||||||
|
const [fetched, setFetched] = useState(false);
|
||||||
|
const errorShown = useRef<boolean>();
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
|
|
||||||
async function fetchSchema(_includeSecrets: boolean = false) {
|
async function reloadSchema() {
|
||||||
if (withSecrets) return;
|
await fetchSchema(includeSecrets, true);
|
||||||
const { body } = await client.api.system.readSchema({
|
}
|
||||||
|
|
||||||
|
async function fetchSchema(_includeSecrets: boolean = false, force?: boolean) {
|
||||||
|
if (withSecrets && !force) return;
|
||||||
|
const { body, res } = await client.api.system.readSchema({
|
||||||
config: true,
|
config: true,
|
||||||
secrets: _includeSecrets
|
secrets: _includeSecrets
|
||||||
});
|
});
|
||||||
console.log("--schema fetched", body);
|
|
||||||
setSchema(body as any);
|
if (!res.ok) {
|
||||||
|
if (errorShown.current) return;
|
||||||
|
errorShown.current = true;
|
||||||
|
/*notifications.show({
|
||||||
|
title: "Failed to fetch schema",
|
||||||
|
// @ts-ignore
|
||||||
|
message: body.error,
|
||||||
|
color: "red",
|
||||||
|
position: "top-right",
|
||||||
|
autoClose: false,
|
||||||
|
withCloseButton: true
|
||||||
|
});*/
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema = res.ok
|
||||||
|
? body
|
||||||
|
: ({
|
||||||
|
version: 0,
|
||||||
|
schema: getDefaultSchema(),
|
||||||
|
config: getDefaultConfig(),
|
||||||
|
permissions: []
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
startTransition(() => {
|
||||||
|
setSchema(schema);
|
||||||
setWithSecrets(_includeSecrets);
|
setWithSecrets(_includeSecrets);
|
||||||
|
setFetched(true);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requireSecrets() {
|
async function requireSecrets() {
|
||||||
@@ -46,10 +81,9 @@ export function BkndProvider({
|
|||||||
fetchSchema(includeSecrets);
|
fetchSchema(includeSecrets);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!schema?.schema) return null;
|
if (!fetched || !schema) return null;
|
||||||
const app = new AppReduced(schema.config as any);
|
const app = new AppReduced(schema?.config as any);
|
||||||
|
const actions = getSchemaActions({ client, setSchema, reloadSchema });
|
||||||
const actions = getSchemaActions({ client, setSchema });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BkndContext.Provider value={{ ...schema, actions, requireSecrets, app }}>
|
<BkndContext.Provider value={{ ...schema, actions, requireSecrets, app }}>
|
||||||
@@ -58,26 +92,23 @@ export function BkndProvider({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BkndWindowContext = {
|
||||||
|
user?: object;
|
||||||
|
logout_route: string;
|
||||||
|
};
|
||||||
|
export function useBkndWindowContext(): BkndWindowContext {
|
||||||
|
if (typeof window !== "undefined" && window.__BKND__) {
|
||||||
|
return window.__BKND__ as any;
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
logout_route: "/api/auth/logout"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function useBknd({ withSecrets }: { withSecrets?: boolean } = {}): BkndContext {
|
export function useBknd({ withSecrets }: { withSecrets?: boolean } = {}): BkndContext {
|
||||||
const ctx = useContext(BkndContext);
|
const ctx = useContext(BkndContext);
|
||||||
if (withSecrets) ctx.requireSecrets();
|
if (withSecrets) ctx.requireSecrets();
|
||||||
|
|
||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
type UseSchemaForType<Key extends keyof ModuleSchemas> = {
|
|
||||||
version: number;
|
|
||||||
schema: ModuleSchemas[Key];
|
|
||||||
config: ModuleConfigs[Key];
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useSchemaFor<Key extends keyof ModuleConfigs>(module: Key): UseSchemaForType<Key> {
|
|
||||||
//const app = useApp();
|
|
||||||
const { version, schema, config } = useSchema();
|
|
||||||
return {
|
|
||||||
version,
|
|
||||||
schema: schema[module],
|
|
||||||
config: config[module]
|
|
||||||
};
|
|
||||||
}*/
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import type { TApiUser } from "Api";
|
||||||
import { createContext, useContext, useEffect, useState } from "react";
|
import { createContext, useContext, useEffect, useState } from "react";
|
||||||
|
import { useBkndWindowContext } from "ui/client/BkndProvider";
|
||||||
import { AppQueryClient } from "./utils/AppQueryClient";
|
import { AppQueryClient } from "./utils/AppQueryClient";
|
||||||
|
|
||||||
const ClientContext = createContext<{ baseUrl: string; client: AppQueryClient }>({
|
const ClientContext = createContext<{ baseUrl: string; client: AppQueryClient }>({
|
||||||
@@ -15,8 +17,13 @@ export const queryClient = new QueryClient({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ClientProvider = ({ children, baseUrl }: { children?: any; baseUrl?: string }) => {
|
export const ClientProvider = ({
|
||||||
|
children,
|
||||||
|
baseUrl,
|
||||||
|
user
|
||||||
|
}: { children?: any; baseUrl?: string; user?: TApiUser | null }) => {
|
||||||
const [actualBaseUrl, setActualBaseUrl] = useState<string | null>(null);
|
const [actualBaseUrl, setActualBaseUrl] = useState<string | null>(null);
|
||||||
|
const winCtx = useBkndWindowContext();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const _ctx_baseUrl = useBaseUrl();
|
const _ctx_baseUrl = useBaseUrl();
|
||||||
@@ -40,8 +47,8 @@ export const ClientProvider = ({ children, baseUrl }: { children?: any; baseUrl?
|
|||||||
return null; // or a loader/spinner if desired
|
return null; // or a loader/spinner if desired
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("client provider11 with", { baseUrl, fallback: actualBaseUrl });
|
//console.log("client provider11 with", { baseUrl, fallback: actualBaseUrl, user });
|
||||||
const client = createClient(actualBaseUrl);
|
const client = createClient(actualBaseUrl, user ?? winCtx.user);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
@@ -52,11 +59,11 @@ export const ClientProvider = ({ children, baseUrl }: { children?: any; baseUrl?
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createClient(baseUrl: string = window.location.origin) {
|
export function createClient(baseUrl: string, user?: object) {
|
||||||
return new AppQueryClient(baseUrl);
|
return new AppQueryClient(baseUrl, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createOrUseClient(baseUrl: string = window.location.origin) {
|
export function createOrUseClient(baseUrl: string) {
|
||||||
const context = useContext(ClientContext);
|
const context = useContext(ClientContext);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
console.warn("createOrUseClient returned a new client");
|
console.warn("createOrUseClient returned a new client");
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ export { ClientProvider, useClient, useBaseUrl } from "./ClientProvider";
|
|||||||
export { BkndProvider, useBknd } from "./BkndProvider";
|
export { BkndProvider, useBknd } from "./BkndProvider";
|
||||||
|
|
||||||
export { useAuth } from "./schema/auth/use-auth";
|
export { useAuth } from "./schema/auth/use-auth";
|
||||||
|
export { Api } from "../../Api";
|
||||||
|
|||||||
@@ -1,190 +1,106 @@
|
|||||||
import { set } from "lodash-es";
|
import { type NotificationData, notifications } from "@mantine/notifications";
|
||||||
import type { ModuleConfigs } from "../../../modules";
|
import { ucFirst } from "core/utils";
|
||||||
|
import type { ApiResponse, ModuleConfigs } from "../../../modules";
|
||||||
import type { AppQueryClient } from "../utils/AppQueryClient";
|
import type { AppQueryClient } from "../utils/AppQueryClient";
|
||||||
|
|
||||||
export type SchemaActionsProps = {
|
export type SchemaActionsProps = {
|
||||||
client: AppQueryClient;
|
client: AppQueryClient;
|
||||||
setSchema: React.Dispatch<React.SetStateAction<any>>;
|
setSchema: React.Dispatch<React.SetStateAction<any>>;
|
||||||
|
reloadSchema: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TSchemaActions = ReturnType<typeof getSchemaActions>;
|
export type TSchemaActions = ReturnType<typeof getSchemaActions>;
|
||||||
|
|
||||||
export function getSchemaActions({ client, setSchema }: SchemaActionsProps) {
|
export function getSchemaActions({ client, setSchema, reloadSchema }: SchemaActionsProps) {
|
||||||
const baseUrl = client.baseUrl;
|
const api = client.api;
|
||||||
const token = client.auth().state()?.token;
|
|
||||||
return {
|
async function handleConfigUpdate(
|
||||||
set: async <Module extends keyof ModuleConfigs>(
|
action: string,
|
||||||
module: keyof ModuleConfigs,
|
module: string,
|
||||||
value: ModuleConfigs[Module],
|
res: ApiResponse,
|
||||||
force?: boolean
|
path?: string
|
||||||
) => {
|
): Promise<boolean> {
|
||||||
const res = await fetch(
|
const base: Partial<NotificationData> = {
|
||||||
`${baseUrl}/api/system/config/set/${module}?force=${force ? 1 : 0}`,
|
id: "schema-" + [action, module, path].join("-"),
|
||||||
{
|
position: "top-right",
|
||||||
method: "POST",
|
autoClose: 3000
|
||||||
headers: {
|
};
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${token}`
|
if (res.res.ok && res.body.success) {
|
||||||
},
|
console.log("update config", action, module, path, res.body);
|
||||||
body: JSON.stringify(value)
|
if (res.body.success) {
|
||||||
}
|
|
||||||
);
|
|
||||||
if (res.ok) {
|
|
||||||
const data = (await res.json()) as any;
|
|
||||||
console.log("update config set", module, data);
|
|
||||||
if (data.success) {
|
|
||||||
setSchema((prev) => {
|
setSchema((prev) => {
|
||||||
if (!prev) return prev;
|
if (!prev) return prev;
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
config: {
|
config: {
|
||||||
...prev.config,
|
...prev.config,
|
||||||
[module]: data.config
|
[module]: res.body.config
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return data.success;
|
notifications.show({
|
||||||
|
...base,
|
||||||
|
title: `Config updated: ${ucFirst(module)}`,
|
||||||
|
color: "blue",
|
||||||
|
message: `Operation ${action.toUpperCase()} at ${module}${path ? "." + path : ""}`
|
||||||
|
});
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
notifications.show({
|
||||||
|
...base,
|
||||||
|
title: `Config Update failed: ${ucFirst(module)}${path ? "." + path : ""}`,
|
||||||
|
color: "red",
|
||||||
|
withCloseButton: true,
|
||||||
|
autoClose: false,
|
||||||
|
message: res.body.error ?? "Failed to complete config update"
|
||||||
|
});
|
||||||
return false;
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
reload: reloadSchema,
|
||||||
|
set: async <Module extends keyof ModuleConfigs>(
|
||||||
|
module: keyof ModuleConfigs,
|
||||||
|
value: ModuleConfigs[Module],
|
||||||
|
force?: boolean
|
||||||
|
) => {
|
||||||
|
const res = await api.system.setConfig(module, value, force);
|
||||||
|
return await handleConfigUpdate("set", module, res);
|
||||||
},
|
},
|
||||||
patch: async <Module extends keyof ModuleConfigs>(
|
patch: async <Module extends keyof ModuleConfigs>(
|
||||||
module: keyof ModuleConfigs,
|
module: keyof ModuleConfigs,
|
||||||
path: string,
|
path: string,
|
||||||
value: any
|
value: any
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
const res = await fetch(`${baseUrl}/api/system/config/patch/${module}/${path}`, {
|
const res = await api.system.patchConfig(module, path, value);
|
||||||
method: "PATCH",
|
return await handleConfigUpdate("patch", module, res, path);
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${token}`
|
|
||||||
},
|
|
||||||
body: JSON.stringify(value)
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
const data = (await res.json()) as any;
|
|
||||||
console.log("update config patch", module, path, data);
|
|
||||||
if (data.success) {
|
|
||||||
setSchema((prev) => {
|
|
||||||
if (!prev) return prev;
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
config: {
|
|
||||||
...prev.config,
|
|
||||||
[module]: data.config
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return data.success;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
},
|
},
|
||||||
overwrite: async <Module extends keyof ModuleConfigs>(
|
overwrite: async <Module extends keyof ModuleConfigs>(
|
||||||
module: keyof ModuleConfigs,
|
module: keyof ModuleConfigs,
|
||||||
path: string,
|
path: string,
|
||||||
value: any
|
value: any
|
||||||
) => {
|
) => {
|
||||||
const res = await fetch(`${baseUrl}/api/system/config/overwrite/${module}/${path}`, {
|
const res = await api.system.overwriteConfig(module, path, value);
|
||||||
method: "PUT",
|
return await handleConfigUpdate("overwrite", module, res, path);
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${token}`
|
|
||||||
},
|
|
||||||
body: JSON.stringify(value)
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
const data = (await res.json()) as any;
|
|
||||||
console.log("update config overwrite", module, path, data);
|
|
||||||
if (data.success) {
|
|
||||||
setSchema((prev) => {
|
|
||||||
if (!prev) return prev;
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
config: {
|
|
||||||
...prev.config,
|
|
||||||
[module]: data.config
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return data.success;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
},
|
},
|
||||||
add: async <Module extends keyof ModuleConfigs>(
|
add: async <Module extends keyof ModuleConfigs>(
|
||||||
module: keyof ModuleConfigs,
|
module: keyof ModuleConfigs,
|
||||||
path: string,
|
path: string,
|
||||||
value: any
|
value: any
|
||||||
) => {
|
) => {
|
||||||
const res = await fetch(`${baseUrl}/api/system/config/add/${module}/${path}`, {
|
const res = await api.system.addConfig(module, path, value);
|
||||||
method: "POST",
|
return await handleConfigUpdate("add", module, res, path);
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${token}`
|
|
||||||
},
|
|
||||||
body: JSON.stringify(value)
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
const data = (await res.json()) as any;
|
|
||||||
console.log("update config add", module, data);
|
|
||||||
|
|
||||||
if (data.success) {
|
|
||||||
setSchema((prev) => {
|
|
||||||
if (!prev) return prev;
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
config: {
|
|
||||||
...prev.config,
|
|
||||||
[module]: data.config
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return data.success;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
},
|
},
|
||||||
remove: async <Module extends keyof ModuleConfigs>(
|
remove: async <Module extends keyof ModuleConfigs>(
|
||||||
module: keyof ModuleConfigs,
|
module: keyof ModuleConfigs,
|
||||||
path: string
|
path: string
|
||||||
) => {
|
) => {
|
||||||
const res = await fetch(`${baseUrl}/api/system/config/remove/${module}/${path}`, {
|
const res = await api.system.removeConfig(module, path);
|
||||||
method: "DELETE",
|
return await handleConfigUpdate("remove", module, res, path);
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${token}`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
const data = (await res.json()) as any;
|
|
||||||
console.log("update config remove", module, data);
|
|
||||||
|
|
||||||
if (data.success) {
|
|
||||||
setSchema((prev) => {
|
|
||||||
if (!prev) return prev;
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
config: {
|
|
||||||
...prev.config,
|
|
||||||
[module]: data.config
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return data.success;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,26 +82,25 @@ export const useAuth = (options?: { baseUrl?: string }): UseAuth => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useAuthStrategies = (options?: { baseUrl?: string }): {
|
type AuthStrategyData = Pick<AppAuthSchema, "strategies" | "basepath">;
|
||||||
strategies: AppAuthSchema["strategies"];
|
export const useAuthStrategies = (options?: { baseUrl?: string }): Partial<AuthStrategyData> & {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
} => {
|
} => {
|
||||||
const [strategies, setStrategies] = useState<AppAuthSchema["strategies"]>();
|
const [data, setData] = useState<AuthStrategyData>();
|
||||||
const ctxBaseUrl = useBaseUrl();
|
const ctxBaseUrl = useBaseUrl();
|
||||||
const api = new Api({
|
const api = new Api({
|
||||||
host: options?.baseUrl ? options?.baseUrl : ctxBaseUrl,
|
host: options?.baseUrl ? options?.baseUrl : ctxBaseUrl
|
||||||
tokenStorage: "localStorage"
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
const res = await api.auth.strategies();
|
const res = await api.auth.strategies();
|
||||||
console.log("res", res);
|
//console.log("res", res);
|
||||||
if (res.res.ok) {
|
if (res.res.ok) {
|
||||||
setStrategies(res.body.strategies);
|
setData(res.body);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [options?.baseUrl]);
|
}, [options?.baseUrl]);
|
||||||
|
|
||||||
return { strategies, loading: !strategies };
|
return { strategies: data?.strategies, basepath: data?.basepath, loading: !data };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export function useBkndAuth() {
|
|||||||
if (window.confirm(`Are you sure you want to delete the role "${name}"?`)) {
|
if (window.confirm(`Are you sure you want to delete the role "${name}"?`)) {
|
||||||
return await bkndActions.remove("auth", `roles.${name}`);
|
return await bkndActions.remove("auth", `roles.${name}`);
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,10 +13,13 @@ import { queryClient } from "../ClientProvider";
|
|||||||
|
|
||||||
export class AppQueryClient {
|
export class AppQueryClient {
|
||||||
api: Api;
|
api: Api;
|
||||||
constructor(public baseUrl: string) {
|
constructor(
|
||||||
|
public baseUrl: string,
|
||||||
|
user?: object
|
||||||
|
) {
|
||||||
this.api = new Api({
|
this.api = new Api({
|
||||||
host: baseUrl,
|
host: baseUrl,
|
||||||
tokenStorage: "localStorage"
|
user
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,14 +53,18 @@ export class AppQueryClient {
|
|||||||
return this.api.getAuthState();
|
return this.api.getAuthState();
|
||||||
},
|
},
|
||||||
verify: async () => {
|
verify: async () => {
|
||||||
console.log("verifiying");
|
try {
|
||||||
|
//console.log("verifiying");
|
||||||
const res = await this.api.auth.me();
|
const res = await this.api.auth.me();
|
||||||
console.log("verifying result", res);
|
//console.log("verifying result", res);
|
||||||
if (!res.res.ok) {
|
if (!res.res.ok || !res.body.user) {
|
||||||
|
throw new Error();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.api.markAuthVerified(true);
|
||||||
|
} catch (e) {
|
||||||
this.api.markAuthVerified(false);
|
this.api.markAuthVerified(false);
|
||||||
this.api.updateToken(undefined);
|
this.api.updateToken(undefined);
|
||||||
} else {
|
|
||||||
this.api.markAuthVerified(true);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type AppType = ReturnType<App["toJSON"]>;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Reduced version of the App class for frontend use
|
* Reduced version of the App class for frontend use
|
||||||
|
* @todo: remove this class
|
||||||
*/
|
*/
|
||||||
export class AppReduced {
|
export class AppReduced {
|
||||||
// @todo: change to record
|
// @todo: change to record
|
||||||
@@ -16,7 +17,7 @@ export class AppReduced {
|
|||||||
private _flows: Flow[] = [];
|
private _flows: Flow[] = [];
|
||||||
|
|
||||||
constructor(protected appJson: AppType) {
|
constructor(protected appJson: AppType) {
|
||||||
console.log("received appjson", appJson);
|
//console.log("received appjson", appJson);
|
||||||
|
|
||||||
this._entities = Object.entries(this.appJson.data.entities ?? {}).map(([name, entity]) => {
|
this._entities = Object.entries(this.appJson.data.entities ?? {}).map(([name, entity]) => {
|
||||||
return AppData.constructEntity(name, entity);
|
return AppData.constructEntity(name, entity);
|
||||||
|
|||||||
@@ -14,22 +14,30 @@ const Base: React.FC<AlertProps> = ({ visible = true, title, message, className,
|
|||||||
{...props}
|
{...props}
|
||||||
className={twMerge("flex flex-row dark:bg-amber-300/20 bg-amber-200 p-4", className)}
|
className={twMerge("flex flex-row dark:bg-amber-300/20 bg-amber-200 p-4", className)}
|
||||||
>
|
>
|
||||||
<div>
|
|
||||||
{title && <b className="mr-2">{title}:</b>}
|
{title && <b className="mr-2">{title}:</b>}
|
||||||
{message}
|
{message}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
const Warning: React.FC<AlertProps> = (props) => (
|
const Warning: React.FC<AlertProps> = ({ className, ...props }) => (
|
||||||
<Base {...props} className="dark:bg-amber-300/20 bg-amber-200" />
|
<Base {...props} className={twMerge("dark:bg-amber-300/20 bg-amber-200", className)} />
|
||||||
);
|
);
|
||||||
|
|
||||||
const Exception: React.FC<AlertProps> = (props) => (
|
const Exception: React.FC<AlertProps> = ({ className, ...props }) => (
|
||||||
<Base {...props} className="dark:bg-red-950 bg-red-100" />
|
<Base {...props} className={twMerge("dark:bg-red-950 bg-red-100", className)} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const Success: React.FC<AlertProps> = ({ className, ...props }) => (
|
||||||
|
<Base {...props} className={twMerge("dark:bg-green-950 bg-green-100", className)} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const Info: React.FC<AlertProps> = ({ className, ...props }) => (
|
||||||
|
<Base {...props} className={twMerge("dark:bg-blue-950 bg-blue-100", className)} />
|
||||||
);
|
);
|
||||||
|
|
||||||
export const Alert = {
|
export const Alert = {
|
||||||
Warning,
|
Warning,
|
||||||
Exception
|
Exception,
|
||||||
|
Success,
|
||||||
|
Info
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
getTemplate,
|
getTemplate,
|
||||||
getUiOptions
|
getUiOptions
|
||||||
} from "@rjsf/utils";
|
} from "@rjsf/utils";
|
||||||
import { ucFirstAll, ucFirstAllSnakeToPascalWithSpaces } from "core/utils";
|
import { identifierToHumanReadable } from "core/utils";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
const REQUIRED_FIELD_SYMBOL = "*";
|
const REQUIRED_FIELD_SYMBOL = "*";
|
||||||
@@ -31,7 +31,7 @@ export function Label(props: LabelProps) {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<label className="control-label" htmlFor={id}>
|
<label className="control-label" htmlFor={id}>
|
||||||
{ucFirstAllSnakeToPascalWithSpaces(label)}
|
{identifierToHumanReadable(label)}
|
||||||
{required && <span className="required">{REQUIRED_FIELD_SYMBOL}</span>}
|
{required && <span className="required">{REQUIRED_FIELD_SYMBOL}</span>}
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export function DataTable<Data extends Record<string, any> = Record<string, any>
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="border-muted border rounded-md shadow-sm w-full max-w-full overflow-x-scroll overflow-y-hidden">
|
<div className="border-muted border rounded-md shadow-sm w-full max-w-full overflow-x-scroll overflow-y-hidden">
|
||||||
<table className="w-full">
|
<table className="w-full text-md">
|
||||||
{select.length > 0 ? (
|
{select.length > 0 ? (
|
||||||
<thead className="sticky top-0 bg-muted/10">
|
<thead className="sticky top-0 bg-muted/10">
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
5
app/src/ui/inject.js
Normal file
5
app/src/ui/inject.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// react shim
|
||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
|
||||||
|
export { React, ReactDOM };
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from "react-icons/tb";
|
} from "react-icons/tb";
|
||||||
import { Button } from "ui";
|
import { Button } from "ui";
|
||||||
import { useAuth, useBknd } from "ui/client";
|
import { useAuth, useBknd } from "ui/client";
|
||||||
|
import { useBkndWindowContext } from "ui/client/BkndProvider";
|
||||||
import { useBkndSystemTheme } from "ui/client/schema/system/use-bknd-system";
|
import { useBkndSystemTheme } from "ui/client/schema/system/use-bknd-system";
|
||||||
import { IconButton } from "ui/components/buttons/IconButton";
|
import { IconButton } from "ui/components/buttons/IconButton";
|
||||||
import { Logo } from "ui/components/display/Logo";
|
import { Logo } from "ui/components/display/Logo";
|
||||||
@@ -147,10 +148,12 @@ export function Header({ hasSidebar = true }) {
|
|||||||
function UserMenu() {
|
function UserMenu() {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const [navigate] = useNavigate();
|
const [navigate] = useNavigate();
|
||||||
|
const { logout_route } = useBkndWindowContext();
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
await auth.logout();
|
await auth.logout();
|
||||||
navigate("/auth/login", { replace: true });
|
// @todo: grab from somewhere constant
|
||||||
|
navigate(logout_route, { reload: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleLogin() {
|
async function handleLogin() {
|
||||||
|
|||||||
@@ -63,8 +63,15 @@ export function useNavigate() {
|
|||||||
return [
|
return [
|
||||||
(
|
(
|
||||||
url: string,
|
url: string,
|
||||||
options?: { query?: object; absolute?: boolean; replace?: boolean; state?: any }
|
options?:
|
||||||
|
| { query?: object; absolute?: boolean; replace?: boolean; state?: any }
|
||||||
|
| { reload: true }
|
||||||
) => {
|
) => {
|
||||||
|
if (options && "reload" in options) {
|
||||||
|
window.location.href = url;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const _url = options?.absolute ? `~/${basepath}${url}`.replace(/\/+/g, "/") : url;
|
const _url = options?.absolute ? `~/${basepath}${url}`.replace(/\/+/g, "/") : url;
|
||||||
navigate(options?.query ? withQuery(_url, options?.query) : _url, {
|
navigate(options?.query ? withQuery(_url, options?.query) : _url, {
|
||||||
replace: options?.replace,
|
replace: options?.replace,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
@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;
|
||||||
@@ -47,6 +46,10 @@ html.fixed, html.fixed body {
|
|||||||
@mixin dark {
|
@mixin dark {
|
||||||
--mantine-color-body: rgb(9 9 11);
|
--mantine-color-body: rgb(9 9 11);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
html, body {
|
html, body {
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { StrictMode } from "react";
|
import * as React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import * as ReactDOM from "react-dom/client";
|
||||||
import "./styles.css";
|
import "./main.css";
|
||||||
|
|
||||||
import Admin from "./Admin";
|
import Admin from "./Admin";
|
||||||
|
|
||||||
@@ -13,9 +13,9 @@ const rootElement = document.getElementById("app")!;
|
|||||||
if (!rootElement.innerHTML) {
|
if (!rootElement.innerHTML) {
|
||||||
const root = ReactDOM.createRoot(rootElement);
|
const root = ReactDOM.createRoot(rootElement);
|
||||||
root.render(
|
root.render(
|
||||||
<StrictMode>
|
<React.StrictMode>
|
||||||
<ClientApp />
|
<ClientApp />
|
||||||
</StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,117 +1,55 @@
|
|||||||
import { type FieldApi, useForm } from "@tanstack/react-form";
|
import { typeboxResolver } from "@hookform/resolvers/typebox";
|
||||||
import { Type, type TypeInvalidError, parse } from "core/utils";
|
import { Type } from "core/utils";
|
||||||
|
import type { ComponentPropsWithoutRef } from "react";
|
||||||
import { Button } from "ui/components/buttons/Button";
|
import { useForm } from "react-hook-form";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import { Button } from "ui";
|
||||||
import * as Formy from "ui/components/form/Formy";
|
import * as Formy from "ui/components/form/Formy";
|
||||||
|
|
||||||
type LoginFormProps = {
|
export type LoginFormProps = Omit<ComponentPropsWithoutRef<"form">, "onSubmit"> & {
|
||||||
onSubmitted?: (value: { email: string; password: string }) => Promise<void>;
|
className?: string;
|
||||||
|
formData?: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function LoginForm({ onSubmitted }: LoginFormProps) {
|
const schema = Type.Object({
|
||||||
const form = useForm({
|
email: Type.String({
|
||||||
defaultValues: {
|
pattern: "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$"
|
||||||
email: "",
|
}),
|
||||||
password: ""
|
password: Type.String({
|
||||||
},
|
minLength: 8 // @todo: this should be configurable
|
||||||
onSubmit: async ({ value }) => {
|
})
|
||||||
onSubmitted?.(value);
|
|
||||||
},
|
|
||||||
defaultState: {
|
|
||||||
canSubmit: false,
|
|
||||||
isValid: false
|
|
||||||
},
|
|
||||||
validatorAdapter: () => {
|
|
||||||
function validate(
|
|
||||||
{ value, fieldApi }: { value: any; fieldApi: FieldApi<any, any> },
|
|
||||||
fn: any
|
|
||||||
): any {
|
|
||||||
if (fieldApi.form.state.submissionAttempts === 0) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
parse(fn, value);
|
|
||||||
} catch (e) {
|
|
||||||
return (e as TypeInvalidError).errors
|
|
||||||
.map((error) => error.schema.error ?? error.message)
|
|
||||||
.join(", ");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { validate, validateAsync: validate };
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
export function LoginForm({ formData, className, method = "POST", ...props }: LoginFormProps) {
|
||||||
e.preventDefault();
|
const {
|
||||||
e.stopPropagation();
|
register,
|
||||||
void form.handleSubmit();
|
formState: { isValid, errors }
|
||||||
}
|
} = useForm({
|
||||||
|
mode: "onChange",
|
||||||
|
defaultValues: formData,
|
||||||
|
resolver: typeboxResolver(schema)
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3 w-full" noValidate>
|
<form {...props} method={method} className={twMerge("flex flex-col gap-3 w-full", className)}>
|
||||||
<form.Field
|
<Formy.Group>
|
||||||
name="email"
|
<Formy.Label htmlFor="email">Email address</Formy.Label>
|
||||||
validators={{
|
<Formy.Input type="email" {...register("email")} />
|
||||||
onChange: Type.String({
|
|
||||||
pattern: "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$"
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
children={(field) => (
|
|
||||||
<Formy.Group error={field.state.meta.errors.length > 0}>
|
|
||||||
<Formy.Label htmlFor={field.name}>Email address</Formy.Label>
|
|
||||||
<Formy.Input
|
|
||||||
type="email"
|
|
||||||
id={field.name}
|
|
||||||
name={field.name}
|
|
||||||
value={field.state.value}
|
|
||||||
onChange={(e) => field.handleChange(e.target.value)}
|
|
||||||
onBlur={field.handleBlur}
|
|
||||||
/>
|
|
||||||
</Formy.Group>
|
</Formy.Group>
|
||||||
)}
|
<Formy.Group>
|
||||||
/>
|
<Formy.Label htmlFor="password">Password</Formy.Label>
|
||||||
<form.Field
|
<Formy.Input type="password" {...register("password")} />
|
||||||
name="password"
|
|
||||||
validators={{
|
|
||||||
onChange: Type.String({
|
|
||||||
minLength: 8
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
children={(field) => (
|
|
||||||
<Formy.Group error={field.state.meta.errors.length > 0}>
|
|
||||||
<Formy.Label htmlFor={field.name}>Password</Formy.Label>
|
|
||||||
<Formy.Input
|
|
||||||
type="password"
|
|
||||||
id={field.name}
|
|
||||||
name={field.name}
|
|
||||||
value={field.state.value}
|
|
||||||
onChange={(e) => field.handleChange(e.target.value)}
|
|
||||||
onBlur={field.handleBlur}
|
|
||||||
/>
|
|
||||||
</Formy.Group>
|
</Formy.Group>
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<form.Subscribe
|
|
||||||
selector={(state) => {
|
|
||||||
//console.log("state", state, Object.values(state.fieldMeta));
|
|
||||||
const fieldMeta = Object.values(state.fieldMeta).map((f) => f.isDirty);
|
|
||||||
const allDirty = fieldMeta.length > 0 ? fieldMeta.reduce((a, b) => a && b) : false;
|
|
||||||
return [allDirty, state.isSubmitting];
|
|
||||||
}}
|
|
||||||
children={([allDirty, isSubmitting]) => {
|
|
||||||
return (
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="large"
|
size="large"
|
||||||
className="w-full mt-2 justify-center"
|
className="w-full mt-2 justify-center"
|
||||||
disabled={!allDirty || isSubmitting}
|
disabled={!isValid}
|
||||||
>
|
>
|
||||||
Sign in
|
Sign in
|
||||||
</Button>
|
</Button>
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
39
app/src/ui/modules/server/FlashMessage.tsx
Normal file
39
app/src/ui/modules/server/FlashMessage.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { getFlashMessage } from "core/server/flash";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Alert } from "ui/components/display/Alert";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles flash message from server
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
export function FlashMessage() {
|
||||||
|
const [flash, setFlash] = useState<any>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!flash) {
|
||||||
|
const content = getFlashMessage();
|
||||||
|
if (content) {
|
||||||
|
setFlash(content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (flash) {
|
||||||
|
let Component = Alert.Info;
|
||||||
|
switch (flash.type) {
|
||||||
|
case "error":
|
||||||
|
Component = Alert.Exception;
|
||||||
|
break;
|
||||||
|
case "success":
|
||||||
|
Component = Alert.Success;
|
||||||
|
break;
|
||||||
|
case "warning":
|
||||||
|
Component = Alert.Warning;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Component message={flash.message} className="justify-center" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,58 +1,17 @@
|
|||||||
import type { AppAuthOAuthStrategy } from "auth/auth-schema";
|
import type { AppAuthOAuthStrategy } from "auth/auth-schema";
|
||||||
import { Type, ucFirst, ucFirstAllSnakeToPascalWithSpaces } from "core/utils";
|
import { ucFirstAllSnakeToPascalWithSpaces } from "core/utils";
|
||||||
import { transform } from "lodash-es";
|
import { transform } from "lodash-es";
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useAuth } from "ui/client";
|
|
||||||
import { useAuthStrategies } from "ui/client/schema/auth/use-auth";
|
import { useAuthStrategies } from "ui/client/schema/auth/use-auth";
|
||||||
import { Button } from "ui/components/buttons/Button";
|
import { Button } from "ui/components/buttons/Button";
|
||||||
import { Logo } from "ui/components/display/Logo";
|
import { Logo } from "ui/components/display/Logo";
|
||||||
import { Link } from "ui/components/wouter/Link";
|
import { Link } from "ui/components/wouter/Link";
|
||||||
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
||||||
import { useSearch } from "ui/hooks/use-search";
|
|
||||||
import { LoginForm } from "ui/modules/auth/LoginForm";
|
import { LoginForm } from "ui/modules/auth/LoginForm";
|
||||||
import { useLocation } from "wouter";
|
|
||||||
import * as AppShell from "../../layouts/AppShell/AppShell";
|
import * as AppShell from "../../layouts/AppShell/AppShell";
|
||||||
|
|
||||||
const schema = Type.Object({
|
|
||||||
token: Type.String()
|
|
||||||
});
|
|
||||||
|
|
||||||
export function AuthLogin() {
|
export function AuthLogin() {
|
||||||
useBrowserTitle(["Login"]);
|
useBrowserTitle(["Login"]);
|
||||||
const [, navigate] = useLocation();
|
const { strategies, basepath, loading } = useAuthStrategies();
|
||||||
const search = useSearch(schema);
|
|
||||||
const token = search.value.token;
|
|
||||||
//console.log("search", token, "/api/auth/google?redirect=" + window.location.href);
|
|
||||||
|
|
||||||
const auth = useAuth();
|
|
||||||
const { strategies, loading } = useAuthStrategies();
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (token) {
|
|
||||||
auth.setToken(token);
|
|
||||||
}
|
|
||||||
}, [token]);
|
|
||||||
|
|
||||||
async function handleSubmit(value: { email: string; password: string }) {
|
|
||||||
console.log("submit", value);
|
|
||||||
const { res, data } = await auth.login(value);
|
|
||||||
if (!res.ok) {
|
|
||||||
if (data && "error" in data) {
|
|
||||||
setError(data.error.message);
|
|
||||||
} else {
|
|
||||||
setError("An error occurred");
|
|
||||||
}
|
|
||||||
} else if (error) {
|
|
||||||
setError(null);
|
|
||||||
}
|
|
||||||
console.log("res:login", { res, data });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (auth.user) {
|
|
||||||
console.log("user set", auth.user);
|
|
||||||
navigate("/", { replace: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const oauth = transform(
|
const oauth = transform(
|
||||||
strategies ?? {},
|
strategies ?? {},
|
||||||
@@ -63,7 +22,7 @@ export function AuthLogin() {
|
|||||||
},
|
},
|
||||||
{}
|
{}
|
||||||
) as Record<string, AppAuthOAuthStrategy>;
|
) as Record<string, AppAuthOAuthStrategy>;
|
||||||
console.log("oauth", oauth, strategies);
|
//console.log("oauth", oauth, strategies);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell.Root>
|
<AppShell.Root>
|
||||||
@@ -77,26 +36,26 @@ export function AuthLogin() {
|
|||||||
<h1 className="text-xl font-bold">Sign in to your admin panel</h1>
|
<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>
|
<p className="text-primary/50">Enter your credentials below to get access.</p>
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
|
||||||
<div className="bg-red-500/40 p-3 w-full rounded font-bold mb-1">
|
|
||||||
<span>{error}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-col gap-4 w-full">
|
<div className="flex flex-col gap-4 w-full">
|
||||||
{Object.keys(oauth).length > 0 && (
|
{Object.keys(oauth).length > 0 && (
|
||||||
<>
|
<>
|
||||||
{Object.entries(oauth)?.map(([name, oauth], key) => (
|
{Object.entries(oauth)?.map(([name, oauth], key) => (
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action={`${basepath}/${name}/login`}
|
||||||
|
key={key}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
key={key}
|
key={key}
|
||||||
|
type="submit"
|
||||||
size="large"
|
size="large"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="justify-center"
|
className="justify-center w-full"
|
||||||
onClick={() => {
|
|
||||||
window.location.href = `/api/auth/${name}/login?redirect=${window.location.href}`;
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Continue with {ucFirstAllSnakeToPascalWithSpaces(oauth.name)}
|
Continue with {ucFirstAllSnakeToPascalWithSpaces(oauth.name)}
|
||||||
</Button>
|
</Button>
|
||||||
|
</form>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="w-full flex flex-row items-center">
|
<div className="w-full flex flex-row items-center">
|
||||||
@@ -111,7 +70,8 @@ export function AuthLogin() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<LoginForm onSubmitted={handleSubmit} />
|
<LoginForm action="/api/auth/password/login" />
|
||||||
|
{/*<a href="/auth/logout">Logout</a>*/}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -29,13 +29,13 @@ function AuthRolesEditInternal({ params }) {
|
|||||||
const data = formRef.current?.getData();
|
const data = formRef.current?.getData();
|
||||||
const success = await actions.roles.patch(roleName, data);
|
const success = await actions.roles.patch(roleName, data);
|
||||||
|
|
||||||
notifications.show({
|
/*notifications.show({
|
||||||
id: `role-${roleName}-update`,
|
id: `role-${roleName}-update`,
|
||||||
position: "top-right",
|
position: "top-right",
|
||||||
title: success ? "Update success" : "Update failed",
|
title: success ? "Update success" : "Update failed",
|
||||||
message: success ? "Role updated successfully" : "Failed to update role",
|
message: success ? "Role updated successfully" : "Failed to update role",
|
||||||
color: !success ? "red" : undefined
|
color: !success ? "red" : undefined
|
||||||
});
|
});*/
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
|
|||||||
@@ -90,14 +90,18 @@ const renderValue = ({ value, property }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (property === "permissions") {
|
if (property === "permissions") {
|
||||||
return [...(value || [])].map((p, i) => (
|
return (
|
||||||
|
<div className="flex flex-row gap-1">
|
||||||
|
{[...(value || [])].map((p, i) => (
|
||||||
<span
|
<span
|
||||||
key={i}
|
key={i}
|
||||||
className="inline-block px-2 py-1.5 text-sm bg-primary/5 rounded font-mono leading-none"
|
className="inline-block px-2 py-1.5 text-sm bg-primary/5 rounded font-mono leading-none"
|
||||||
>
|
>
|
||||||
{p}
|
{p}
|
||||||
</span>
|
</span>
|
||||||
));
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <CellValue value={value} property={property} />;
|
return <CellValue value={value} property={property} />;
|
||||||
|
|||||||
@@ -6,10 +6,9 @@ import {
|
|||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { isDebug } from "core";
|
import { isDebug } from "core";
|
||||||
import type { Entity } from "data";
|
import type { Entity } from "data";
|
||||||
import { cloneDeep, omit } from "lodash-es";
|
import { cloneDeep } from "lodash-es";
|
||||||
import { forwardRef, useImperativeHandle, useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { TbArrowLeft, TbDots } from "react-icons/tb";
|
import { TbDots } from "react-icons/tb";
|
||||||
import { useBknd } from "ui/client";
|
|
||||||
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
|
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
|
||||||
import { Button } from "ui/components/buttons/Button";
|
import { Button } from "ui/components/buttons/Button";
|
||||||
import { IconButton } from "ui/components/buttons/IconButton";
|
import { IconButton } from "ui/components/buttons/IconButton";
|
||||||
@@ -20,9 +19,8 @@ import {
|
|||||||
} from "ui/components/form/json-schema/JsonSchemaForm";
|
} from "ui/components/form/json-schema/JsonSchemaForm";
|
||||||
import { Dropdown } from "ui/components/overlay/Dropdown";
|
import { Dropdown } from "ui/components/overlay/Dropdown";
|
||||||
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||||
import { SectionHeaderAccordionItem } from "ui/layouts/AppShell/AppShell";
|
|
||||||
import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2";
|
import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2";
|
||||||
import { routes, useGoBack, useNavigate } from "ui/lib/routes";
|
import { routes, useNavigate } from "ui/lib/routes";
|
||||||
import { extractSchema } from "../settings/utils/schema";
|
import { extractSchema } from "../settings/utils/schema";
|
||||||
import { EntityFieldsForm, type EntityFieldsFormRef } from "./forms/entity.fields.form";
|
import { EntityFieldsForm, type EntityFieldsFormRef } from "./forms/entity.fields.form";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Suspense, lazy } from "react";
|
import { Suspense, lazy } from "react";
|
||||||
|
import { useBknd } from "ui/client";
|
||||||
import { Route, Router, Switch } from "wouter";
|
import { Route, Router, Switch } from "wouter";
|
||||||
import { useBknd } from "../client/BkndProvider";
|
|
||||||
import { AuthLogin } from "./auth/auth.login";
|
import { AuthLogin } from "./auth/auth.login";
|
||||||
import { Root, RootEmpty } from "./root";
|
import { Root, RootEmpty } from "./root";
|
||||||
|
|
||||||
|
|||||||
@@ -155,8 +155,8 @@ export function Setting<Schema extends TObject = any>({
|
|||||||
if (success) {
|
if (success) {
|
||||||
if (options?.reloadOnSave) {
|
if (options?.reloadOnSave) {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
|
//await actions.reload();
|
||||||
}
|
}
|
||||||
//window.location.reload();
|
|
||||||
} else {
|
} else {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
import { type Options, build } from "tsup";
|
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
|
|
||||||
const watch = args.includes("--watch");
|
|
||||||
const minify = args.includes("--minify");
|
|
||||||
|
|
||||||
function baseConfig(adapter: string): Options {
|
|
||||||
return {
|
|
||||||
entry: [`src/adapter/${adapter}`],
|
|
||||||
format: ["esm"],
|
|
||||||
platform: "neutral",
|
|
||||||
minify,
|
|
||||||
outDir: `dist/adapter/${adapter}`,
|
|
||||||
watch,
|
|
||||||
define: {
|
|
||||||
__isDev: "0"
|
|
||||||
},
|
|
||||||
external: [new RegExp(`^(?!\\.\\/src\\/adapter\\/${adapter}\\/).+$`)],
|
|
||||||
metafile: true,
|
|
||||||
splitting: false,
|
|
||||||
treeshake: true
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
await build({
|
|
||||||
...baseConfig("vite"),
|
|
||||||
platform: "node"
|
|
||||||
});
|
|
||||||
|
|
||||||
await build({
|
|
||||||
...baseConfig("cloudflare")
|
|
||||||
});
|
|
||||||
|
|
||||||
await build({
|
|
||||||
...baseConfig("nextjs"),
|
|
||||||
format: ["esm", "cjs"],
|
|
||||||
platform: "node"
|
|
||||||
});
|
|
||||||
|
|
||||||
await build({
|
|
||||||
...baseConfig("remix"),
|
|
||||||
format: ["esm", "cjs"]
|
|
||||||
});
|
|
||||||
|
|
||||||
await build({
|
|
||||||
...baseConfig("bun")
|
|
||||||
});
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
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 } from "vite";
|
import { defineConfig, loadEnv } from "vite";
|
||||||
import { viteStaticCopy } from "vite-plugin-static-copy";
|
|
||||||
import tsconfigPaths from "vite-tsconfig-paths";
|
import tsconfigPaths from "vite-tsconfig-paths";
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
@@ -45,18 +44,5 @@ export default defineConfig(async () => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
throw new Error("Don't use vite for building in production");
|
||||||
define: {
|
|
||||||
__isDev: "0"
|
|
||||||
},
|
|
||||||
publicDir: "./src/ui/assets",
|
|
||||||
build: {
|
|
||||||
manifest: true,
|
|
||||||
outDir: "dist/static"
|
|
||||||
/*rollupOptions: { // <-- use this to not require index.html
|
|
||||||
input: "./src/ui/main.tsx"
|
|
||||||
}*/
|
|
||||||
},
|
|
||||||
plugins: [react(), tsconfigPaths()]
|
|
||||||
} as any;
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,82 +1,39 @@
|
|||||||
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, type BkndConfig } from "./src";
|
import { App } from "./src";
|
||||||
import { LibsqlConnection } from "./src/data";
|
import { LibsqlConnection } from "./src/data";
|
||||||
import { StorageLocalAdapter } from "./src/media/storage/adapters/StorageLocalAdapter";
|
import { StorageLocalAdapter } from "./src/media/storage/adapters/StorageLocalAdapter";
|
||||||
import { registries } from "./src/modules/registries";
|
import { registries } from "./src/modules/registries";
|
||||||
|
|
||||||
async function getHtml() {
|
|
||||||
return readFile("index.html", "utf8");
|
|
||||||
}
|
|
||||||
function addViteScripts(html: string) {
|
|
||||||
return html.replace(
|
|
||||||
"<head>",
|
|
||||||
`<script type="module">
|
|
||||||
import RefreshRuntime from "/@react-refresh"
|
|
||||||
RefreshRuntime.injectIntoGlobalHook(window)
|
|
||||||
window.$RefreshReg$ = () => {}
|
|
||||||
window.$RefreshSig$ = () => (type) => type
|
|
||||||
window.__vite_plugin_react_preamble_installed__ = true
|
|
||||||
</script>
|
|
||||||
<script type="module" src="/@vite/client"></script>
|
|
||||||
`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createApp(config: BkndConfig, env: any) {
|
|
||||||
const create_config = typeof config.app === "function" ? config.app(env) : config.app;
|
|
||||||
return App.create(create_config);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setAppBuildListener(app: App, config: BkndConfig, html: string) {
|
|
||||||
app.emgr.on(
|
|
||||||
"app-built",
|
|
||||||
async () => {
|
|
||||||
await config.onBuilt?.(app);
|
|
||||||
app.module.server.setAdminHtml(html);
|
|
||||||
app.module.server.client.get("/assets/!*", serveStatic({ root: "./" }));
|
|
||||||
},
|
|
||||||
"sync"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function serveFresh(config: BkndConfig, _html?: string) {
|
|
||||||
let html = _html;
|
|
||||||
if (!html) {
|
|
||||||
html = await getHtml();
|
|
||||||
}
|
|
||||||
|
|
||||||
html = addViteScripts(html);
|
|
||||||
|
|
||||||
return {
|
|
||||||
async fetch(request: Request, env: any) {
|
|
||||||
const app = createApp(config, env);
|
|
||||||
|
|
||||||
setAppBuildListener(app, config, html);
|
|
||||||
await app.build();
|
|
||||||
|
|
||||||
return app.fetch(request, env);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
registries.media.add("local", {
|
registries.media.add("local", {
|
||||||
cls: StorageLocalAdapter,
|
cls: StorageLocalAdapter,
|
||||||
schema: StorageLocalAdapter.prototype.getSchema()
|
schema: StorageLocalAdapter.prototype.getSchema()
|
||||||
});
|
});
|
||||||
|
|
||||||
const connection = new LibsqlConnection(
|
const credentials = {
|
||||||
createClient({
|
url: import.meta.env.VITE_DB_URL!,
|
||||||
url: "file:.db/new.db"
|
authToken: import.meta.env.VITE_DB_TOKEN!
|
||||||
})
|
};
|
||||||
);
|
if (!credentials.url) {
|
||||||
|
throw new Error("Missing VITE_DB_URL env variable. Add it to .env file");
|
||||||
|
}
|
||||||
|
|
||||||
const app = await serveFresh({
|
const connection = new LibsqlConnection(createClient(credentials));
|
||||||
app: {
|
|
||||||
connection
|
export default {
|
||||||
|
async fetch(request: Request) {
|
||||||
|
const app = App.create({ connection });
|
||||||
|
|
||||||
|
app.emgr.on(
|
||||||
|
"app-built",
|
||||||
|
async () => {
|
||||||
|
app.registerAdminController({ forceDev: true });
|
||||||
|
app.module.server.client.get("/assets/*", serveStatic({ root: "./" }));
|
||||||
},
|
},
|
||||||
setAdminHtml: true
|
"sync"
|
||||||
});
|
);
|
||||||
|
await app.build();
|
||||||
|
|
||||||
export default app;
|
return app.fetch(request);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -59,6 +59,9 @@
|
|||||||
"noImplicitAnyLet": "warn",
|
"noImplicitAnyLet": "warn",
|
||||||
"noConfusingVoidType": "off"
|
"noConfusingVoidType": "off"
|
||||||
},
|
},
|
||||||
|
"security": {
|
||||||
|
"noDangerouslySetInnerHtml": "off"
|
||||||
|
},
|
||||||
"style": {
|
"style": {
|
||||||
"noNonNullAssertion": "off",
|
"noNonNullAssertion": "off",
|
||||||
"noInferrableTypes": "off",
|
"noInferrableTypes": "off",
|
||||||
|
|||||||
@@ -4,11 +4,6 @@ description: 'Run bknd inside Next.js'
|
|||||||
---
|
---
|
||||||
import InstallBknd from '/snippets/install-bknd.mdx';
|
import InstallBknd from '/snippets/install-bknd.mdx';
|
||||||
|
|
||||||
<Note>
|
|
||||||
Next.js support is currently experimental, this guide only covers adding bknd using `pages`
|
|
||||||
folder.
|
|
||||||
</Note>
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
Install bknd as a dependency:
|
Install bknd as a dependency:
|
||||||
<InstallBknd />
|
<InstallBknd />
|
||||||
@@ -17,10 +12,10 @@ Install bknd as a dependency:
|
|||||||
``` tsx
|
``` tsx
|
||||||
// pages/api/[...route].ts
|
// pages/api/[...route].ts
|
||||||
import { serve } from "bknd/adapter/nextjs";
|
import { serve } from "bknd/adapter/nextjs";
|
||||||
import type { PageConfig } from "next";
|
|
||||||
|
|
||||||
export const config: PageConfig = {
|
export const config = {
|
||||||
runtime: "edge"
|
runtime: "experimental-edge",
|
||||||
|
unstable_allowDynamic: ["**/*.js"]
|
||||||
};
|
};
|
||||||
|
|
||||||
export default serve({
|
export default serve({
|
||||||
@@ -39,20 +34,37 @@ For more information about the connection object, refer to the [Setup](/setup) g
|
|||||||
Create a file `[[...admin]].tsx` inside the `pages/admin` folder:
|
Create a file `[[...admin]].tsx` inside the `pages/admin` folder:
|
||||||
```tsx
|
```tsx
|
||||||
// pages/admin/[[...admin]].tsx
|
// pages/admin/[[...admin]].tsx
|
||||||
import type { PageConfig } from "next";
|
import { adminPage, getServerSideProps } from "bknd/adapter/nextjs";
|
||||||
import dynamic from "next/dynamic";
|
|
||||||
import "bknd/dist/styles.css";
|
import "bknd/dist/styles.css";
|
||||||
|
|
||||||
export const config: PageConfig = {
|
export { getServerSideProps };
|
||||||
runtime: "experimental-edge",
|
export default adminPage();
|
||||||
};
|
```
|
||||||
|
|
||||||
const Admin = dynamic(
|
## Example usage of the API in pages dir
|
||||||
() => import("bknd/ui").then((mod) => mod.Admin),
|
Using pages dir, you need to wrap the `getServerSideProps` function with `withApi` to get access
|
||||||
{ ssr: false },
|
to the API. With the API, you can query the database or retrieve the authentication status:
|
||||||
|
```tsx
|
||||||
|
import { withApi } from "bknd/adapter/nextjs";
|
||||||
|
import type { InferGetServerSidePropsType as InferProps } from "next";
|
||||||
|
|
||||||
|
export const getServerSideProps = withApi(async (context) => {
|
||||||
|
const { data = [] } = await context.api.data.readMany("todos");
|
||||||
|
const user = context.api.getUser();
|
||||||
|
|
||||||
|
return { props: { data, user } };
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function Home(props: InferProps<typeof getServerSideProps>) {
|
||||||
|
const { data, user } = props;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Data</h1>
|
||||||
|
<pre>{JSON.stringify(data, null, 2)}</pre>
|
||||||
|
|
||||||
|
<h1>User</h1>
|
||||||
|
<pre>{JSON.stringify(user, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default function AdminPage() {
|
|
||||||
return <Admin />;
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -4,10 +4,6 @@ description: 'Run bknd inside Remix'
|
|||||||
---
|
---
|
||||||
import InstallBknd from '/snippets/install-bknd.mdx';
|
import InstallBknd from '/snippets/install-bknd.mdx';
|
||||||
|
|
||||||
<Note>
|
|
||||||
Remix SSR support is currently limited.
|
|
||||||
</Note>
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
Install bknd as a dependency:
|
Install bknd as a dependency:
|
||||||
<InstallBknd />
|
<InstallBknd />
|
||||||
@@ -32,28 +28,90 @@ export const action = handler;
|
|||||||
```
|
```
|
||||||
For more information about the connection object, refer to the [Setup](/setup) guide.
|
For more information about the connection object, refer to the [Setup](/setup) guide.
|
||||||
|
|
||||||
|
Now make sure that you wrap your root layout with the `ClientProvider` so that all components
|
||||||
|
share the same context:
|
||||||
|
```tsx
|
||||||
|
// app/root.tsx
|
||||||
|
export function Layout(props) {
|
||||||
|
// nothing to change here, just for orientation
|
||||||
|
return (
|
||||||
|
<html>{/* ... */}</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// add the api to the `AppLoadContext`
|
||||||
|
// so you don't have to manually type it again
|
||||||
|
declare module "@remix-run/server-runtime" {
|
||||||
|
export interface AppLoadContext {
|
||||||
|
api: Api;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// export a loader that initiates the API
|
||||||
|
// and pass it through the context
|
||||||
|
export const loader = async (args: LoaderFunctionArgs) => {
|
||||||
|
const api = new Api({
|
||||||
|
host: new URL(args.request.url).origin,
|
||||||
|
headers: args.request.headers
|
||||||
|
});
|
||||||
|
|
||||||
|
// get the user from the API
|
||||||
|
const user = api.getUser();
|
||||||
|
|
||||||
|
// add api to the context
|
||||||
|
args.context.api = api;
|
||||||
|
|
||||||
|
return { user };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const { user } = useLoaderData<typeof loader>();
|
||||||
|
return (
|
||||||
|
<ClientProvider user={user}>
|
||||||
|
<Outlet />
|
||||||
|
</ClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Enabling the Admin UI
|
## Enabling the Admin UI
|
||||||
Create a new splat route file at `app/routes/admin.$.tsx`:
|
Create a new splat route file at `app/routes/admin.$.tsx`:
|
||||||
```tsx
|
```tsx
|
||||||
// app/routes/admin.$.tsx
|
// app/routes/admin.$.tsx
|
||||||
import { Suspense, lazy, useEffect, useState } from "react";
|
import { adminPage } from "bknd/adapter/remix";
|
||||||
import "bknd/dist/styles.css";
|
import "bknd/dist/styles.css";
|
||||||
|
|
||||||
const Admin = lazy(() => import("bknd/ui")
|
export default adminPage();
|
||||||
.then((mod) => ({ default: mod.Admin })));
|
```
|
||||||
|
|
||||||
export default function AdminPage() {
|
## Example usage of the API
|
||||||
const [loaded, setLoaded] = useState(false);
|
Since the API has already been constructed in the root layout, you can now use it in any page:
|
||||||
useEffect(() => {
|
```tsx
|
||||||
setLoaded(true);
|
// app/routes/_index.tsx
|
||||||
}, []);
|
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||||
if (!loaded) return null;
|
import { useLoaderData } from "@remix-run/react";
|
||||||
|
|
||||||
|
export const loader = async (args: LoaderFunctionArgs) => {
|
||||||
|
const { api } = args.context;
|
||||||
|
|
||||||
|
// get the authenticated user
|
||||||
|
const user = api.getAuthState().user;
|
||||||
|
|
||||||
|
// get the data from the API
|
||||||
|
const { data } = await api.data.readMany("todos");
|
||||||
|
return { data, user };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Index() {
|
||||||
|
const { data, user } = useLoaderData<typeof loader>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense>
|
<div>
|
||||||
<Admin withProvider />
|
<h1>Data</h1>
|
||||||
</Suspense>
|
<pre>{JSON.stringify(data, null, 2)}</pre>
|
||||||
|
<h1>User</h1>
|
||||||
|
<pre>{JSON.stringify(user, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
/*
|
// @ts-ignore somehow causes types:build issues on app
|
||||||
// somehow causes types:build issues on app
|
|
||||||
|
|
||||||
import type { CreateAppConfig } from "bknd";
|
import type { CreateAppConfig } from "bknd";
|
||||||
|
// @ts-ignore somehow causes types:build issues on app
|
||||||
import { serve } from "bknd/adapter/bun";
|
import { serve } from "bknd/adapter/bun";
|
||||||
|
|
||||||
const root = "../../node_modules/bknd/dist";
|
// this is optional, if omitted, it uses an in-memory database
|
||||||
const config = {
|
const config = {
|
||||||
connection: {
|
connection: {
|
||||||
type: "libsql",
|
type: "libsql",
|
||||||
@@ -16,8 +15,12 @@ const config = {
|
|||||||
|
|
||||||
Bun.serve({
|
Bun.serve({
|
||||||
port: 1337,
|
port: 1337,
|
||||||
fetch: serve(config, root)
|
fetch: serve(
|
||||||
|
config,
|
||||||
|
// this is only required to run inside the same workspace
|
||||||
|
// leave blank if you're running this from a different project
|
||||||
|
"../../app/dist"
|
||||||
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("Server running at http://localhost:1337");
|
console.log("Server running at http://localhost:1337");
|
||||||
s*/
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"lib": ["ESNext", "DOM"],
|
"lib": ["ESNext", "DOM"],
|
||||||
"target": "ESNext",
|
"target": "ES2022",
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
|
|||||||
@@ -8,17 +8,20 @@ const result = await esbuild.build({
|
|||||||
conditions: ["worker", "browser"],
|
conditions: ["worker", "browser"],
|
||||||
entryPoints: ["./src/index.ts"],
|
entryPoints: ["./src/index.ts"],
|
||||||
outdir: "dist",
|
outdir: "dist",
|
||||||
external: [],
|
external: ["__STATIC_CONTENT_MANIFEST", "cloudflare:workers"],
|
||||||
format: "esm",
|
format: "esm",
|
||||||
target: "es2022",
|
target: "es2022",
|
||||||
keepNames: true,
|
keepNames: true,
|
||||||
bundle: true,
|
bundle: true,
|
||||||
metafile: true,
|
metafile: true,
|
||||||
minify: true,
|
minify: true,
|
||||||
|
loader: {
|
||||||
|
".html": "copy"
|
||||||
|
},
|
||||||
define: {
|
define: {
|
||||||
IS_CLOUDFLARE_WORKER: "true"
|
IS_CLOUDFLARE_WORKER: "true"
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await Bun.write("dist/meta.json", JSON.stringify(result.metafile));
|
await Bun.write("dist/meta.json", JSON.stringify(result.metafile));
|
||||||
//console.log("result", result.metafile);
|
await $`gzip dist/index.js -c > dist/index.js.gz`;
|
||||||
|
|||||||
@@ -8,15 +8,13 @@ export default serve(
|
|||||||
connection: {
|
connection: {
|
||||||
type: "libsql",
|
type: "libsql",
|
||||||
config: {
|
config: {
|
||||||
url: env.DB_URL,
|
url: "http://localhost:8080"
|
||||||
authToken: env.DB_TOKEN
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
onBuilt: async (app) => {
|
onBuilt: async (app) => {
|
||||||
app.modules.server.get("/", (c) => c.json({ hello: "world" }));
|
app.modules.server.get("/hello", (c) => c.json({ hello: "world" }));
|
||||||
},
|
}
|
||||||
setAdminHtml: true
|
|
||||||
},
|
},
|
||||||
manifest
|
manifest
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
"db": "turso dev --db-file test.db",
|
||||||
|
"db:check": "sqlite3 test.db \"PRAGMA wal_checkpoint(FULL);\"",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
import dynamic from "next/dynamic";
|
|
||||||
|
|
||||||
const Admin = dynamic(() => import("bknd/ui").then((mod) => mod.Admin), { ssr: false });
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
// @ts-ignore
|
|
||||||
import("bknd/dist/styles.css");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BkndAdmin() {
|
|
||||||
return <Admin />;
|
|
||||||
}
|
|
||||||
@@ -1,14 +1,5 @@
|
|||||||
import type { PageConfig } from "next";
|
import { adminPage, getServerSideProps } from "bknd/adapter/nextjs";
|
||||||
import dynamic from "next/dynamic";
|
|
||||||
|
|
||||||
export const config: PageConfig = {
|
|
||||||
runtime: "experimental-edge"
|
|
||||||
};
|
|
||||||
|
|
||||||
const Admin = dynamic(() => import("bknd/ui").then((mod) => mod.Admin), { ssr: false });
|
|
||||||
import "bknd/dist/styles.css";
|
import "bknd/dist/styles.css";
|
||||||
|
|
||||||
export default function AdminPage() {
|
export { getServerSideProps };
|
||||||
if (typeof document === "undefined") return null;
|
export default adminPage();
|
||||||
return <Admin withProvider />;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,59 +1,19 @@
|
|||||||
import { serve } from "bknd/adapter/nextjs";
|
import { serve } from "bknd/adapter/nextjs";
|
||||||
import type { PageConfig } from "next";
|
|
||||||
|
|
||||||
export const config: PageConfig = {
|
export const config = {
|
||||||
runtime: "edge"
|
runtime: "experimental-edge",
|
||||||
|
// add a matcher for bknd dist to allow dynamic otherwise build may fail.
|
||||||
|
// inside this repo it's '../../app/dist/index.js', outside probably inside node_modules
|
||||||
|
// see https://github.com/vercel/next.js/issues/51401
|
||||||
|
// and https://github.com/vercel/next.js/pull/69402
|
||||||
|
unstable_allowDynamic: ["**/*.js"]
|
||||||
};
|
};
|
||||||
|
|
||||||
export default serve({
|
export default serve({
|
||||||
connection: {
|
connection: {
|
||||||
type: "libsql",
|
type: "libsql",
|
||||||
config: {
|
config: {
|
||||||
url: process.env.DB_URL!,
|
url: "http://localhost:8080"
|
||||||
authToken: process.env.DB_AUTH_TOKEN!
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}); /*
|
|
||||||
let app: App;
|
|
||||||
|
|
||||||
async function getApp() {
|
|
||||||
if (!app) {
|
|
||||||
app = App.create({
|
|
||||||
connection: {
|
|
||||||
type: "libsql",
|
|
||||||
config: {
|
|
||||||
url: process.env.DB_URL!,
|
|
||||||
authToken: process.env.DB_AUTH_TOKEN!
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
await app.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCleanRequest(req: Request) {
|
|
||||||
// clean search params from "route" attribute
|
|
||||||
const url = new URL(req.url);
|
|
||||||
url.searchParams.delete("route");
|
|
||||||
return new Request(url.toString(), {
|
|
||||||
method: req.method,
|
|
||||||
headers: req.headers,
|
|
||||||
body: req.body
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async (req: Request, requestContext: any) => {
|
|
||||||
//console.log("here");
|
|
||||||
if (!app) {
|
|
||||||
app = await getApp();
|
|
||||||
}
|
|
||||||
//const app = await getApp();
|
|
||||||
const request = getCleanRequest(req);
|
|
||||||
//console.log("url", req.url);
|
|
||||||
//console.log("req", req);
|
|
||||||
return app.fetch(request, process.env);
|
|
||||||
};
|
|
||||||
//export default handle(app.server.getInstance())
|
|
||||||
*/
|
|
||||||
|
|||||||
@@ -1,115 +1,24 @@
|
|||||||
import Image from "next/image";
|
import { withApi } from "bknd/adapter/nextjs";
|
||||||
import localFont from "next/font/local";
|
import type { InferGetServerSidePropsType } from "next";
|
||||||
|
|
||||||
const geistSans = localFont({
|
export const getServerSideProps = withApi(async (context) => {
|
||||||
src: "./fonts/GeistVF.woff",
|
const { data = [] } = await context.api.data.readMany("todos");
|
||||||
variable: "--font-geist-sans",
|
const user = context.api.getUser();
|
||||||
weight: "100 900",
|
|
||||||
});
|
return { props: { data, user } };
|
||||||
const geistMono = localFont({
|
|
||||||
src: "./fonts/GeistMonoVF.woff",
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
weight: "100 900",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home({
|
||||||
|
data,
|
||||||
|
user
|
||||||
|
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div>
|
||||||
className={`${geistSans.variable} ${geistMono.variable} grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]`}
|
<h1>Data</h1>
|
||||||
>
|
<pre>{JSON.stringify(data, null, 2)}</pre>
|
||||||
<main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/next.svg"
|
|
||||||
alt="Next.js logo"
|
|
||||||
width={180}
|
|
||||||
height={38}
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
<ol className="list-inside list-decimal text-sm text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
|
|
||||||
<li className="mb-2">
|
|
||||||
Get started by editing{" "}
|
|
||||||
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-semibold">
|
|
||||||
src/pages/index.tsx
|
|
||||||
</code>
|
|
||||||
.
|
|
||||||
</li>
|
|
||||||
<li>Save and see your changes instantly.</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
<h1>User</h1>
|
||||||
<a
|
<pre>{JSON.stringify(user, null, 2)}</pre>
|
||||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5"
|
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=default-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/vercel.svg"
|
|
||||||
alt="Vercel logomark"
|
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
/>
|
|
||||||
Deploy now
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:min-w-44"
|
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=default-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Read our docs
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
<footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center">
|
|
||||||
<a
|
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=default-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="/file.svg"
|
|
||||||
alt="File icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Learn
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=default-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="/window.svg"
|
|
||||||
alt="Window icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Examples
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
|
||||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=default-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="/globe.svg"
|
|
||||||
alt="Globe icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Go to nextjs.org →
|
|
||||||
</a>
|
|
||||||
</footer>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
examples/nextjs/test.db
Normal file
BIN
examples/nextjs/test.db
Normal file
Binary file not shown.
20
examples/node/index.js
Normal file
20
examples/node/index.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { serve } from "bknd/adapter/node";
|
||||||
|
|
||||||
|
// this is optional, if omitted, it uses an in-memory database
|
||||||
|
/** @type {import("bknd").CreateAppConfig} */
|
||||||
|
const config = {
|
||||||
|
connection: {
|
||||||
|
type: "libsql",
|
||||||
|
config: {
|
||||||
|
url: "http://localhost:8080"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
serve(config, {
|
||||||
|
relativeDistPath: "../../node_modules/bknd/dist",
|
||||||
|
port: 1337,
|
||||||
|
listener: ({ port }) => {
|
||||||
|
console.log(`Server is running on http://localhost:${port}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
20
examples/node/package.json
Normal file
20
examples/node/package.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "node",
|
||||||
|
"module": "index.js",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"start": "node index.js",
|
||||||
|
"dev": "tsx --watch index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"bknd": "workspace:*",
|
||||||
|
"@hono/node-server": "^1.13.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"tsx": "^4.19.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||||
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "@remix-run/react";
|
import { Links, Meta, Outlet, Scripts, ScrollRestoration, useLoaderData } from "@remix-run/react";
|
||||||
import { Api } from "bknd";
|
import { Api, ClientProvider } from "bknd/client";
|
||||||
import { ClientProvider } from "bknd/ui";
|
|
||||||
|
declare module "@remix-run/server-runtime" {
|
||||||
|
export interface AppLoadContext {
|
||||||
|
api: Api;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function Layout({ children }: { children: React.ReactNode }) {
|
export function Layout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
@@ -22,15 +27,26 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const loader = async (args: LoaderFunctionArgs) => {
|
export const loader = async (args: LoaderFunctionArgs) => {
|
||||||
args.context.api = new Api({
|
const api = new Api({
|
||||||
host: new URL(args.request.url).origin
|
host: new URL(args.request.url).origin,
|
||||||
|
headers: args.request.headers
|
||||||
});
|
});
|
||||||
return null;
|
|
||||||
|
// add api to the context
|
||||||
|
args.context.api = api;
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: api.getAuthState().user
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const data = useLoaderData<typeof loader>();
|
||||||
|
|
||||||
|
// add user to the client provider to indicate
|
||||||
|
// that you're authed using cookie
|
||||||
return (
|
return (
|
||||||
<ClientProvider>
|
<ClientProvider user={data.user}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</ClientProvider>
|
</ClientProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,30 +1,26 @@
|
|||||||
import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
|
import { type MetaFunction, useLoaderData } from "@remix-run/react";
|
||||||
import { useLoaderData } from "@remix-run/react";
|
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||||
import type { Api } from "bknd";
|
|
||||||
import { useClient } from "bknd/ui";
|
|
||||||
|
|
||||||
export const meta: MetaFunction = () => {
|
export const meta: MetaFunction = () => {
|
||||||
return [{ title: "Remix & bknd" }, { name: "description", content: "Welcome to Remix & bknd!" }];
|
return [{ title: "Remix & bknd" }, { name: "description", content: "Welcome to Remix & bknd!" }];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loader = async (args: LoaderFunctionArgs) => {
|
export const loader = async (args: LoaderFunctionArgs) => {
|
||||||
const api = args.context.api as Api;
|
const api = args.context.api;
|
||||||
|
const user = api.getAuthState().user;
|
||||||
const { data } = await api.data.readMany("todos");
|
const { data } = await api.data.readMany("todos");
|
||||||
return { data };
|
return { data, user };
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
const data = useLoaderData<typeof loader>();
|
const { data, user } = useLoaderData<typeof loader>();
|
||||||
const client = useClient();
|
|
||||||
|
|
||||||
const query = client.query().data.entity("todos").readMany();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
hello
|
<h1>Data</h1>
|
||||||
<pre>{client.baseUrl}</pre>
|
|
||||||
<pre>{JSON.stringify(data, null, 2)}</pre>
|
<pre>{JSON.stringify(data, null, 2)}</pre>
|
||||||
<pre>{JSON.stringify(query.data, null, 2)}</pre>
|
<h1>User</h1>
|
||||||
|
<pre>{JSON.stringify(user, null, 2)}</pre>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,4 @@
|
|||||||
import { Suspense, lazy, useEffect, useState } from "react";
|
import { adminPage } from "bknd/adapter/remix";
|
||||||
|
|
||||||
const Admin = lazy(() => import("bknd/ui").then((mod) => ({ default: mod.Admin })));
|
|
||||||
import "bknd/dist/styles.css";
|
import "bknd/dist/styles.css";
|
||||||
|
|
||||||
export default function AdminPage() {
|
export default adminPage();
|
||||||
const [loaded, setLoaded] = useState(false);
|
|
||||||
useEffect(() => {
|
|
||||||
setLoaded(true);
|
|
||||||
}, []);
|
|
||||||
if (!loaded) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Suspense>
|
|
||||||
<Admin withProvider />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "remix vite:build",
|
"build": "remix vite:build",
|
||||||
"dev": "remix vite:dev",
|
"dev": "remix vite:dev",
|
||||||
"lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .",
|
"db": "turso dev --db-file test.db",
|
||||||
|
"db:check": "sqlite3 test.db \"PRAGMA wal_checkpoint(FULL);\"",
|
||||||
"start": "remix-serve ./build/server/index.js",
|
"start": "remix-serve ./build/server/index.js",
|
||||||
"typecheck": "tsc"
|
"typecheck": "tsc"
|
||||||
},
|
},
|
||||||
@@ -17,7 +18,8 @@
|
|||||||
"bknd": "workspace:*",
|
"bknd": "workspace:*",
|
||||||
"isbot": "^4.1.0",
|
"isbot": "^4.1.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0"
|
"react-dom": "^18.2.0",
|
||||||
|
"remix-utils": "^7.7.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@remix-run/dev": "^2.14.0",
|
"@remix-run/dev": "^2.14.0",
|
||||||
|
|||||||
BIN
examples/remix/test.db
Normal file
BIN
examples/remix/test.db
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user