Merge pull request #3 from bknd-io/feat/auth-improvements

Feat: Auth improvements
This commit is contained in:
dswbx
2024-11-28 10:02:31 +01:00
committed by GitHub
103 changed files with 2276 additions and 1341 deletions

View File

@@ -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);
}); });*/
});*/ });

View File

@@ -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"
}); });

View File

@@ -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(
{ {

View File

@@ -5,7 +5,8 @@ export default {
connection: { connection: {
type: "libsql", type: "libsql",
config: { config: {
url: "http://localhost:8080" //url: "http://localhost:8080"
url: ":memory:"
} }
} }
} }

View File

@@ -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
View 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
View 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"]
});

View File

@@ -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>

View 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);
}
});
}
});

View File

@@ -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",

View File

@@ -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;
}

View File

@@ -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);
} }

View File

@@ -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);
}; };
} }

View File

@@ -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"
); );

View File

@@ -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
});
}

View 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>
);
};
}

View File

@@ -1 +1,2 @@
export * from "./nextjs.adapter"; export * from "./nextjs.adapter";
export * from "./AdminPage";

View File

@@ -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();
} }

View 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
);
}

View 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>
);
};
}

View File

@@ -1 +1,2 @@
export * from "./remix.adapter"; export * from "./remix.adapter";
export * from "./AdminPage";

View File

@@ -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();
} }

View File

@@ -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
}
};
} }
} }

View File

@@ -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() {}

View File

@@ -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) {}
get guard() {
return this.auth.ctx.guard;
}
getMiddleware: MiddlewareHandler = async (c, next) => { getMiddleware: MiddlewareHandler = async (c, next) => {
// @todo: consider adding app name to the payload, because user is not refetched const user = await this.auth.authenticator.resolveAuthFromRequest(c);
this.auth.ctx.guard.setUserContext(user);
//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();
}*/
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;

View File

@@ -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",

View File

@@ -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,

View File

@@ -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);
const payload = await this.login(body); try {
const data = await authenticator.resolve("login", this, payload.password, payload); const payload = await this.login(body);
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);
}); });
} }

View File

@@ -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(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();
} }
return c.json({ user, token }); 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;

View File

@@ -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,11 +143,12 @@ export class Guard {
(rolePermission) => rolePermission.permission.name === name (rolePermission) => rolePermission.permission.name === name
); );
console.log("guard: rolePermission, allowing?", { debug &&
permission: name, console.log("guard: rolePermission, allowing?", {
role: role.name, permission: name,
allowing: !!rolePermission role: role.name,
}); allowing: !!rolePermission
});
return !!rolePermission; return !!rolePermission;
} }

View File

@@ -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));
}); });
}; };

View File

@@ -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 }) {

View File

@@ -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);

View File

@@ -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));
}); });
}; };

View File

@@ -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);
}) })
) )

View 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;
}

View File

@@ -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("");
}

View File

@@ -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
* *

View File

@@ -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(

View File

@@ -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;
} }

View File

@@ -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("");
} }

View File

@@ -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)
}); });
} }

View File

@@ -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

View File

@@ -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;
} }

View File

@@ -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]);
}
} }

View File

@@ -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
}
};
}
} }
]; ];

View File

@@ -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");

View 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>
);
}
}

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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));
}); });

View File

@@ -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>

View File

@@ -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) {
setWithSecrets(_includeSecrets); 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);
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]
};
}*/

View File

@@ -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");

View File

@@ -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";

View File

@@ -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;
async function handleConfigUpdate(
action: string,
module: string,
res: ApiResponse,
path?: string
): Promise<boolean> {
const base: Partial<NotificationData> = {
id: "schema-" + [action, module, path].join("-"),
position: "top-right",
autoClose: 3000
};
if (res.res.ok && res.body.success) {
console.log("update config", action, module, path, res.body);
if (res.body.success) {
setSchema((prev) => {
if (!prev) return prev;
return {
...prev,
config: {
...prev.config,
[module]: res.body.config
}
};
});
}
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 { return {
reload: reloadSchema,
set: async <Module extends keyof ModuleConfigs>( set: async <Module extends keyof ModuleConfigs>(
module: keyof ModuleConfigs, module: keyof ModuleConfigs,
value: ModuleConfigs[Module], value: ModuleConfigs[Module],
force?: boolean force?: boolean
) => { ) => {
const res = await fetch( const res = await api.system.setConfig(module, value, force);
`${baseUrl}/api/system/config/set/${module}?force=${force ? 1 : 0}`, return await handleConfigUpdate("set", module, res);
{
method: "POST",
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 set", module, data);
if (data.success) {
setSchema((prev) => {
if (!prev) return prev;
return {
...prev,
config: {
...prev.config,
[module]: data.config
}
};
});
}
return data.success;
}
return false;
}, },
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;
} }
}; };
} }

View File

@@ -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 };
}; };

View File

@@ -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;
} }
} }
}; };

View File

@@ -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 {
const res = await this.api.auth.me(); //console.log("verifiying");
console.log("verifying result", res); const res = await this.api.auth.me();
if (!res.res.ok) { //console.log("verifying result", res);
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);
} }
} }
}; };

View File

@@ -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);

View File

@@ -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
}; };

View File

@@ -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>
); );

View File

@@ -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
View File

@@ -0,0 +1,5 @@
// react shim
import React from "react";
import ReactDOM from "react-dom/client";
export { React, ReactDOM };

View File

@@ -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() {

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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>
); );
} }

View File

@@ -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 { export function LoginForm({ formData, className, method = "POST", ...props }: LoginFormProps) {
parse(fn, value); const {
} catch (e) { register,
return (e as TypeInvalidError).errors formState: { isValid, errors }
.map((error) => error.schema.error ?? error.message) } = useForm({
.join(", "); mode: "onChange",
} defaultValues: formData,
} resolver: typeboxResolver(schema)
return { validate, validateAsync: validate };
}
}); });
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
e.stopPropagation();
void form.handleSubmit();
}
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({ </Formy.Group>
pattern: "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$" <Formy.Group>
}) <Formy.Label htmlFor="password">Password</Formy.Label>
}} <Formy.Input type="password" {...register("password")} />
children={(field) => ( </Formy.Group>
<Formy.Group error={field.state.meta.errors.length > 0}>
<Formy.Label htmlFor={field.name}>Email address</Formy.Label> <Button
<Formy.Input type="submit"
type="email" variant="primary"
id={field.name} size="large"
name={field.name} className="w-full mt-2 justify-center"
value={field.state.value} disabled={!isValid}
onChange={(e) => field.handleChange(e.target.value)} >
onBlur={field.handleBlur} Sign in
/> </Button>
</Formy.Group>
)}
/>
<form.Field
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>
)}
/>
<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
type="submit"
variant="primary"
size="large"
className="w-full mt-2 justify-center"
disabled={!allDirty || isSubmitting}
>
Sign in
</Button>
);
}}
/>
</form> </form>
); );
} }

View 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;
}

View File

@@ -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) => (
<Button <form
method="POST"
action={`${basepath}/${name}/login`}
key={key} key={key}
size="large" className="w-full"
variant="outline"
className="justify-center"
onClick={() => {
window.location.href = `/api/auth/${name}/login?redirect=${window.location.href}`;
}}
> >
Continue with {ucFirstAllSnakeToPascalWithSpaces(oauth.name)} <Button
</Button> key={key}
type="submit"
size="large"
variant="outline"
className="justify-center w-full"
>
Continue with {ucFirstAllSnakeToPascalWithSpaces(oauth.name)}
</Button>
</form>
))} ))}
<div className="w-full flex flex-row items-center"> <div className="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>
)} )}

View File

@@ -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() {

View File

@@ -90,14 +90,18 @@ const renderValue = ({ value, property }) => {
} }
if (property === "permissions") { if (property === "permissions") {
return [...(value || [])].map((p, i) => ( return (
<span <div className="flex flex-row gap-1">
key={i} {[...(value || [])].map((p, i) => (
className="inline-block px-2 py-1.5 text-sm bg-primary/5 rounded font-mono leading-none" <span
> key={i}
{p} className="inline-block px-2 py-1.5 text-sm bg-primary/5 rounded font-mono leading-none"
</span> >
)); {p}
</span>
))}
</div>
);
} }
return <CellValue value={value} property={property} />; return <CellValue value={value} property={property} />;

View File

@@ -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";

View File

@@ -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";

View File

@@ -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);
} }

View File

@@ -36,4 +36,4 @@
}, },
"include": ["./src/**/*.ts", "./src/**/*.tsx", "./env.d.ts"], "include": ["./src/**/*.ts", "./src/**/*.tsx", "./env.d.ts"],
"exclude": ["node_modules", "dist/**/*", "../examples/bun"] "exclude": ["node_modules", "dist/**/*", "../examples/bun"]
} }

View File

@@ -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")
});

View File

@@ -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;
}); });

View File

@@ -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
},
setAdminHtml: true
});
export default app; 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: "./" }));
},
"sync"
);
await app.build();
return app.fetch(request);
}
};

View File

@@ -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",

BIN
bun.lockb

Binary file not shown.

View File

@@ -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 default function AdminPage() { export const getServerSideProps = withApi(async (context) => {
return <Admin />; 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>
);
} }
``` ```

View File

@@ -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>
); );
} }
``` ```

View File

@@ -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*/

View File

@@ -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,

View File

@@ -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`;

View File

@@ -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
); );

View File

@@ -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"

View File

@@ -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 />;
}

View File

@@ -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 />;
}

View File

@@ -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())
*/

View File

@@ -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({
return ( data,
<div user
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)]`} }: InferGetServerSidePropsType<typeof getServerSideProps>) {
> return (
<main className="flex flex-col gap-8 row-start-2 items-center sm:items-start"> <div>
<Image <h1>Data</h1>
className="dark:invert" <pre>{JSON.stringify(data, null, 2)}</pre>
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" </div>
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>
);
} }

BIN
examples/nextjs/test.db Normal file

Binary file not shown.

20
examples/node/index.js Normal file
View 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}`);
}
});

View 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"
}
}

View File

@@ -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>
); );

View File

@@ -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>
); );
} }

View File

@@ -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>
);
}

View File

@@ -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",

Some files were not shown because too many files have changed in this diff Show More