feat: add local auth support if api storage provided

This commit is contained in:
dswbx
2025-12-02 18:18:45 +01:00
parent 506c7d84cc
commit acc10377ca
20 changed files with 193 additions and 174 deletions

View File

@@ -188,6 +188,7 @@ async function buildUiElements() {
"ui/client",
"bknd",
/^bknd\/.*/,
"wouter",
"react",
"react-dom",
"react/jsx-runtime",

View File

@@ -65,7 +65,7 @@
"hono": "4.10.4",
"json-schema-library": "10.0.0-rc7",
"json-schema-to-ts": "^3.1.1",
"jsonv-ts": "0.9.3",
"jsonv-ts": "^0.10.1",
"kysely": "0.28.8",
"lodash-es": "^4.17.21",
"oauth4webapi": "^2.11.1",

View File

@@ -61,7 +61,7 @@ export class Api {
private token?: string;
private user?: TApiUser;
private verified = false;
private token_transport: "header" | "cookie" | "none" = "header";
public token_transport: "header" | "cookie" | "none" = "header";
public system!: SystemApi;
public data!: DataApi;

View File

@@ -10,7 +10,7 @@ import {
import { checksum } from "bknd/utils";
import { App, registries, sqlocal, type BkndConfig } from "bknd";
import { Route, Router, Switch } from "wouter";
import { type Api, ClientProvider } from "bknd/client";
import { ClientProvider } from "bknd/client";
import { SQLocalKysely } from "sqlocal/kysely";
import type { ClientConfig, DatabasePath } from "sqlocal";
import { OpfsStorageAdapter } from "bknd/adapter/browser";
@@ -44,6 +44,7 @@ export type BrowserBkndConfig<Args = ImportMetaEnv> = Omit<
export type BkndBrowserAppProps = {
children: ReactNode;
header?: ReactNode;
loading?: ReactNode;
notFound?: ReactNode;
} & BrowserBkndConfig;
@@ -56,19 +57,18 @@ const BkndBrowserAppContext = createContext<{
export function BkndBrowserApp({
children,
adminConfig,
header,
loading,
notFound,
...config
}: BkndBrowserAppProps) {
const [app, setApp] = useState<App | undefined>(undefined);
const [api, setApi] = useState<Api | undefined>(undefined);
const [hash, setHash] = useState<string>("");
const adminRoutePath = (adminConfig?.basepath ?? "") + "/*?";
async function onBuilt(app: App) {
safeViewTransition(async () => {
setApp(app);
setApi(app.getApi());
setHash(await checksum(app.toJSON()));
});
}
@@ -79,7 +79,7 @@ export function BkndBrowserApp({
.catch(console.error);
}, []);
if (!app || !api) {
if (!app) {
return (
loading ?? (
<Center>
@@ -91,7 +91,8 @@ export function BkndBrowserApp({
return (
<BkndBrowserAppContext.Provider value={{ app, hash }}>
<ClientProvider api={api}>
<ClientProvider storage={window.localStorage} fetcher={app.server.request}>
{header}
<Router key={hash}>
<Switch>
{children}

View File

@@ -32,6 +32,7 @@ export function getFlashMessage(
): { type: FlashMessageType; message: string } | undefined {
const flash = getCookieValue(flash_key);
if (flash && clear) {
// biome-ignore lint/suspicious/noDocumentCookie: .
document.cookie = `${flash_key}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
}
return flash ? JSON.parse(flash) : undefined;

View File

@@ -87,7 +87,7 @@ export type ModuleManagerOptions = {
verbosity?: Verbosity;
};
const debug_modules = env("modules_debug");
const debug_modules = env("modules_debug", false);
abstract class ModuleManagerEvent<A = {}> extends Event<{ ctx: ModuleBuildContext } & A> {}
export class ModuleManagerConfigUpdateEvent<

View File

@@ -67,13 +67,14 @@ export const ClientProvider = ({
export const useApi = (host?: ApiOptions["host"]): Api => {
const context = useContext(ClientContext);
if (!context) {
throw new Error("useApi must be used within a ClientProvider");
}
if (!context?.api || (host && host.length > 0 && host !== context.baseUrl)) {
console.info("creating new api", { host });
return new Api({ host: host ?? "" });
}
if (!context) {
throw new Error("useApi must be used within a ClientProvider");
}
return context.api;
};

View File

@@ -18,6 +18,7 @@ type UseAuth = {
logout: () => Promise<void>;
verify: () => Promise<void>;
setToken: (token: string) => void;
local: boolean;
};
export const useAuth = (options?: { baseUrl?: string }): UseAuth => {
@@ -60,5 +61,6 @@ export const useAuth = (options?: { baseUrl?: string }): UseAuth => {
logout,
setToken,
verify,
local: !!api.options.storage,
};
};

View File

@@ -1,6 +1,5 @@
import type React from "react";
import { Children } from "react";
import { forwardRef } from "react";
import { Children, forwardRef } from "react";
import { twMerge } from "tailwind-merge";
import { Link } from "ui/components/wouter/Link";

View File

@@ -1,6 +1,6 @@
import { Tooltip } from "@mantine/core";
import clsx from "clsx";
import { getBrowser } from "core/utils";
import { getBrowser } from "bknd/utils";
import type { Field } from "data/fields";
import { Switch as RadixSwitch } from "radix-ui";
import {

View File

@@ -16,10 +16,10 @@ import {
setPath,
} from "./utils";
export type NativeFormProps = {
export type NativeFormProps = Omit<ComponentPropsWithoutRef<"form">, "onChange" | "onSubmit"> & {
hiddenSubmit?: boolean;
validateOn?: "change" | "submit";
errorFieldSelector?: <K extends keyof HTMLElementTagNameMap>(name: string) => any | null;
errorFieldSelector?: (selector: string) => any | null;
reportValidity?: boolean;
onSubmit?: (data: any, ctx: { event: FormEvent<HTMLFormElement> }) => Promise<void> | void;
onSubmitInvalid?: (
@@ -33,7 +33,7 @@ export type NativeFormProps = {
ctx: { event: ChangeEvent<HTMLFormElement>; key: string; value: any; errors: InputError[] },
) => Promise<void> | void;
clean?: CleanOptions | true;
} & Omit<ComponentPropsWithoutRef<"form">, "onChange" | "onSubmit">;
};
export type InputError = {
name: string;

View File

@@ -1,5 +1,4 @@
import { useInsertionEffect, useRef } from "react";
import { type LinkProps, Link as WouterLink, useRoute, useRouter } from "wouter";
import { type LinkProps, Link as WouterLink, useRouter } from "wouter";
import { useEvent } from "../../hooks/use-event";
/*

View File

@@ -1,13 +1,16 @@
import type { AppAuthOAuthStrategy, AppAuthSchema } from "auth/auth-schema";
import clsx from "clsx";
import { NativeForm } from "ui/components/form/native-form/NativeForm";
import { transform } from "lodash-es";
import type { ComponentPropsWithoutRef } from "react";
import { transformObject } from "bknd/utils";
import { useEffect, useState, type ComponentPropsWithoutRef, type FormEvent } from "react";
import { Button } from "ui/components/buttons/Button";
import { Group, Input, Password, Label } from "ui/components/form/Formy/components";
import { SocialLink } from "./SocialLink";
import { useAuth } from "bknd/client";
import { Alert } from "ui/components/display/Alert";
import { useLocation } from "wouter";
export type LoginFormProps = Omit<ComponentPropsWithoutRef<"form">, "onSubmit" | "action"> & {
export type LoginFormProps = Omit<ComponentPropsWithoutRef<"form">, "action"> & {
className?: string;
formData?: any;
action: "login" | "register";
@@ -23,25 +26,47 @@ export function AuthForm({
action,
auth,
buttonLabel = action === "login" ? "Sign in" : "Sign up",
onSubmit: _onSubmit,
...props
}: LoginFormProps) {
const $auth = useAuth();
const basepath = auth?.basepath ?? "/api/auth";
const [error, setError] = useState<string>();
const [, navigate] = useLocation();
const password = {
action: `${basepath}/password/${action}`,
strategy: auth?.strategies?.password ?? ({ type: "password" } as const),
};
const oauth = transform(
auth?.strategies ?? {},
(result, value, key) => {
if (value.type !== "password") {
result[key] = value.config;
}
},
{},
) as Record<string, AppAuthOAuthStrategy>;
const oauth = transformObject(auth?.strategies ?? {}, (value) => {
return value.type !== "password" ? value.config : undefined;
}) as Record<string, AppAuthOAuthStrategy>;
const has_oauth = Object.keys(oauth).length > 0;
async function onSubmit(data: any, ctx: { event: FormEvent<HTMLFormElement> }) {
if ($auth?.local) {
ctx.event.preventDefault();
const res = await $auth.login(data);
if ("token" in res) {
navigate("/");
} else {
setError((res as any).error);
return;
}
}
await _onSubmit?.(ctx.event);
// submit form
ctx.event.currentTarget.submit();
}
useEffect(() => {
if ($auth.user) {
navigate("/");
}
}, [$auth.user]);
return (
<div className="flex flex-col gap-4 w-full">
{has_oauth && (
@@ -63,10 +88,12 @@ export function AuthForm({
<NativeForm
method={method}
action={password.action}
onSubmit={onSubmit}
{...(props as any)}
validateOn="change"
className={clsx("flex flex-col gap-3 w-full", className)}
>
{error && <Alert.Exception message={error} className="justify-center" />}
<Group>
<Label htmlFor="email">Email address</Label>
<Input type="email" name="email" required />

View File

@@ -1,4 +1,4 @@
import { ucFirstAllSnakeToPascalWithSpaces } from "core/utils";
import { ucFirstAllSnakeToPascalWithSpaces } from "bknd/utils";
import type { ReactNode } from "react";
import { Button } from "ui/components/buttons/Button";
import type { IconType } from "ui/components/buttons/IconButton";

View File

@@ -154,8 +154,10 @@ function UserMenu() {
async function handleLogout() {
await auth.logout();
// @todo: grab from somewhere constant
navigate(logout_route, { reload: true });
if (!auth.local) {
navigate(logout_route, { reload: true });
}
}
async function handleLogin() {

View File

@@ -1,4 +1,5 @@
import { McpClient, type McpClientConfig } from "jsonv-ts/mcp";
import { useApi } from "bknd/client";
import { useBknd } from "ui/client/bknd";
const clients = new Map<string, McpClient>();
@@ -12,5 +13,14 @@ export function getClient(opts: McpClientConfig) {
export function useMcpClient() {
const { config } = useBknd();
return getClient({ url: window.location.origin + config.server.mcp.path });
const api = useApi();
const token = api.getAuthState().token;
const headers =
api.token_transport === "header" && token ? { Authorization: `Bearer ${token}` } : undefined;
return getClient({
url: window.location.origin + config.server.mcp.path,
fetch: api.fetcher,
headers,
});
}

View File

@@ -12,7 +12,7 @@ import { useBrowserTitle } from "ui/hooks/use-browser-title";
export default function ToolsMcp() {
useBrowserTitle(["MCP UI"]);
const { config, options } = useBknd();
const { config } = useBknd();
const feature = useMcpStore((state) => state.feature);
const setFeature = useMcpStore((state) => state.setFeature);
const content = useMcpStore((state) => state.content);