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

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