Merge branch 'main' into cp/216-fix-users-link

This commit is contained in:
Cameron Pak
2026-02-28 11:03:54 -06:00
260 changed files with 8084 additions and 3850 deletions
+22 -20
View File
@@ -5,7 +5,7 @@ import { BkndProvider } from "ui/client/bknd";
import { useTheme, type AppTheme } from "ui/client/use-theme";
import { Logo } from "ui/components/display/Logo";
import * as AppShell from "ui/layouts/AppShell/AppShell";
import { ClientProvider, useBkndWindowContext, type ClientProviderProps } from "./client";
import { ClientProvider, useBkndWindowContext, type ClientProviderProps } from "bknd/client";
import { createMantineTheme } from "./lib/mantine/theme";
import { Routes } from "./routes";
import type { BkndAdminAppShellOptions, BkndAdminEntitiesOptions } from "./options";
@@ -52,26 +52,30 @@ export type BkndAdminProps = {
children?: ReactNode;
};
export default function Admin({
baseUrl: baseUrlOverride,
withProvider = false,
config: _config = {},
children,
}: BkndAdminProps) {
const { theme } = useTheme();
export default function Admin(props: BkndAdminProps) {
const Provider = ({ children }: any) =>
withProvider ? (
props.withProvider ? (
<ClientProvider
baseUrl={baseUrlOverride}
{...(typeof withProvider === "object" ? withProvider : {})}
baseUrl={props.baseUrl}
{...(typeof props.withProvider === "object" ? props.withProvider : {})}
>
{children}
</ClientProvider>
) : (
children
);
return (
<Provider>
<AdminInner {...props} />
</Provider>
);
}
function AdminInner(props: BkndAdminProps) {
const { theme } = useTheme();
const config = {
..._config,
...props.config,
...useBkndWindowContext(),
};
@@ -82,14 +86,12 @@ export default function Admin({
);
return (
<Provider>
<MantineProvider {...createMantineTheme(theme as any)}>
<Notifications position="top-right" />
<Routes BkndWrapper={BkndWrapper} basePath={config?.basepath}>
{children}
</Routes>
</MantineProvider>
</Provider>
<MantineProvider {...createMantineTheme(theme as any)}>
<Notifications position="top-right" />
<Routes BkndWrapper={BkndWrapper} basePath={config?.basepath}>
{props.children}
</Routes>
</MantineProvider>
);
}
+1 -1
View File
@@ -9,7 +9,7 @@ import {
useState,
type ReactNode,
} from "react";
import { useApi } from "ui/client";
import { useApi } from "bknd/client";
import { type TSchemaActions, getSchemaActions } from "./schema/actions";
import { AppReduced } from "./utils/AppReduced";
import { Message } from "ui/components/display/Message";
+10 -2
View File
@@ -14,18 +14,20 @@ const ClientContext = createContext<BkndClientContext>(undefined!);
export type ClientProviderProps = {
children?: ReactNode;
baseUrl?: string;
api?: Api;
} & ApiOptions;
export const ClientProvider = ({
children,
host,
baseUrl: _baseUrl = host,
api: _api,
...props
}: ClientProviderProps) => {
const winCtx = useBkndWindowContext();
const _ctx = useClientContext();
let actualBaseUrl = _baseUrl ?? _ctx?.baseUrl ?? "";
let user: any = undefined;
let user: any;
if (winCtx) {
user = winCtx.user;
@@ -40,6 +42,7 @@ export const ClientProvider = ({
const apiProps = { user, ...props, host: actualBaseUrl };
const api = useMemo(
() =>
_api ??
new Api({
...apiProps,
verbose: isDebug(),
@@ -50,7 +53,7 @@ export const ClientProvider = ({
}
},
}),
[JSON.stringify(apiProps)],
[_api, JSON.stringify(apiProps)],
);
const [authState, setAuthState] = useState<Partial<AuthState> | undefined>(api.getAuthState());
@@ -64,9 +67,14 @@ export const ClientProvider = ({
export const useApi = (host?: ApiOptions["host"]): Api => {
const context = useContext(ClientContext);
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;
};
+1 -1
View File
@@ -2,7 +2,7 @@ import type { Api } from "Api";
import { FetchPromise, type ModuleApi, type ResponseObject } from "modules/ModuleApi";
import useSWR, { type SWRConfiguration, useSWRConfig, type Middleware, type SWRHook } from "swr";
import useSWRInfinite from "swr/infinite";
import { useApi } from "ui/client";
import { useApi } from "../ClientProvider";
import { useState } from "react";
export const useApiQuery = <
+7 -4
View File
@@ -8,9 +8,9 @@ import type {
ModuleApi,
} from "bknd";
import { objectTransform, encodeSearch } from "bknd/utils";
import type { Insertable, Selectable, Updateable } from "kysely";
import type { Insertable, Selectable, Updateable, Generated } from "kysely";
import useSWR, { type SWRConfiguration, type SWRResponse, mutate } from "swr";
import { type Api, useApi } from "ui/client";
import { type Api, useApi } from "bknd/client";
export class UseEntityApiError<Payload = any> extends Error {
constructor(
@@ -33,6 +33,7 @@ interface UseEntityReturn<
Entity extends keyof DB | string,
Id extends PrimaryFieldType | undefined,
Data = Entity extends keyof DB ? DB[Entity] : EntityData,
ActualId = Data extends { id: infer I } ? (I extends Generated<infer T> ? T : I) : never,
Response = ResponseObject<RepositoryResult<Selectable<Data>>>,
> {
create: (input: Insertable<Data>) => Promise<Response>;
@@ -42,9 +43,11 @@ interface UseEntityReturn<
ResponseObject<RepositoryResult<Id extends undefined ? Selectable<Data>[] : Selectable<Data>>>
>;
update: Id extends undefined
? (input: Updateable<Data>, id: Id) => Promise<Response>
? (input: Updateable<Data>, id: ActualId) => Promise<Response>
: (input: Updateable<Data>) => Promise<Response>;
_delete: Id extends undefined ? (id: Id) => Promise<Response> : () => Promise<Response>;
_delete: Id extends undefined
? (id: PrimaryFieldType) => Promise<Response>
: () => Promise<Response>;
}
export const useEntity = <
+1
View File
@@ -4,6 +4,7 @@ export {
type ClientProviderProps,
useApi,
useBaseUrl,
useClientContext
} from "./ClientProvider";
export * from "./api/use-api";
+3 -2
View File
@@ -1,7 +1,6 @@
import type { AuthState } from "Api";
import type { AuthResponse } from "bknd";
import { useApi, useInvalidate } from "ui/client";
import { useClientContext } from "ui/client/ClientProvider";
import { useApi, useInvalidate, useClientContext } from "bknd/client";
type LoginData = {
email: string;
@@ -19,6 +18,7 @@ type UseAuth = {
logout: () => Promise<void>;
verify: () => Promise<void>;
setToken: (token: string) => void;
local: boolean;
};
export const useAuth = (options?: { baseUrl?: string }): UseAuth => {
@@ -61,5 +61,6 @@ export const useAuth = (options?: { baseUrl?: string }): UseAuth => {
logout,
setToken,
verify,
local: !!api.options.storage,
};
};
+1 -2
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";
@@ -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 {
@@ -16,15 +16,18 @@ 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;
onSubmit?: (
data: any,
ctx: { event: FormEvent<HTMLFormElement>; form: HTMLFormElement },
) => Promise<void> | void;
onSubmitInvalid?: (
errors: InputError[],
ctx: { event: FormEvent<HTMLFormElement> },
ctx: { event: FormEvent<HTMLFormElement>; form: HTMLFormElement },
) => Promise<void> | void;
onError?: (errors: InputError[]) => void;
disableSubmitOnError?: boolean;
@@ -33,7 +36,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;
@@ -188,12 +191,12 @@ export function NativeForm({
const errors = validate({ report: true });
if (errors.length > 0) {
onSubmitInvalid?.(errors, { event: e });
onSubmitInvalid?.(errors, { event: e, form });
return;
}
if (onSubmit) {
await onSubmit(getFormValues(), { event: e });
await onSubmit(getFormValues(), { event: e, form });
} else {
form.submit();
}
+1 -2
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";
/*
+43 -13
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,50 @@ 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>; form: 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.form.submit();
}
useEffect(() => {
if ($auth.user) {
navigate("/");
}
}, [$auth.user]);
return (
<div className="flex flex-col gap-4 w-full">
{has_oauth && (
@@ -63,17 +91,19 @@ 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 />
</Group>
<Group>
<Label htmlFor="password">Password</Label>
<Password name="password" required minLength={8} />
<Password name="password" required minLength={1} />
</Group>
<Button
+1 -1
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";
+4 -2
View File
@@ -1,9 +1,11 @@
import type { AppAuthSchema } from "auth/auth-schema";
import { useEffect, useState } from "react";
import { useApi } from "ui/client";
import { useApi } from "bknd/client";
type AuthStrategyData = Pick<AppAuthSchema, "strategies" | "basepath">;
export const useAuthStrategies = (options?: { baseUrl?: string }): Partial<AuthStrategyData> & {
export const useAuthStrategies = (options?: {
baseUrl?: string;
}): Partial<AuthStrategyData> & {
loading: boolean;
} => {
const [data, setData] = useState<AuthStrategyData>();
+1 -1
View File
@@ -14,7 +14,7 @@ import { isFileAccepted } from "bknd/utils";
import { type FileWithPath, useDropzone } from "./use-dropzone";
import { checkMaxReached } from "./helper";
import { DropzoneInner } from "./DropzoneInner";
import { createDropzoneStore } from "ui/elements/media/dropzone-state";
import { createDropzoneStore } from "./dropzone-state";
import { useStore } from "zustand";
export type FileState = {
+19 -22
View File
@@ -1,9 +1,8 @@
import type { Api } from "bknd/client";
import type { PrimaryFieldType, RepoQueryIn } from "bknd";
import type { MediaFieldSchema } from "media/AppMedia";
import type { TAppMediaConfig } from "media/media-schema";
import { useId, useEffect, useRef, useState } from "react";
import { useApi, useApiInfiniteQuery, useApiQuery, useInvalidate } from "bknd/client";
import { type Api, useApi, useApiInfiniteQuery, useApiQuery, useInvalidate } from "bknd/client";
import { useEvent } from "ui/hooks/use-event";
import { Dropzone, type DropzoneProps } from "./Dropzone";
import { mediaItemsToFileStates } from "./helper";
@@ -132,26 +131,24 @@ export function DropzoneContainer({
}
return (
<>
<Dropzone
key={key}
getUploadInfo={getUploadInfo}
handleDelete={handleDelete}
autoUpload
initialItems={_initialItems}
footer={
infinite &&
"setSize" in $q && (
<Footer
items={_initialItems.length}
length={placeholderLength}
onFirstVisible={() => $q.setSize($q.size + 1)}
/>
)
}
{...props}
/>
</>
<Dropzone
key={key}
getUploadInfo={getUploadInfo}
handleDelete={handleDelete}
autoUpload
initialItems={_initialItems}
footer={
infinite &&
"setSize" in $q && (
<Footer
items={_initialItems.length}
length={placeholderLength}
onFirstVisible={() => $q.setSize($q.size + 1)}
/>
)
}
{...props}
/>
);
}
+2 -2
View File
@@ -19,8 +19,8 @@ import {
} from "react-icons/tb";
import { Dropdown, type DropdownItem } from "ui/components/overlay/Dropdown";
import { IconButton } from "ui/components/buttons/IconButton";
import { formatNumber } from "core/utils";
import type { DropzoneRenderProps, FileState } from "ui/elements";
import { formatNumber } from "bknd/utils";
import type { DropzoneRenderProps, FileState } from "./Dropzone";
import { useDropzoneFileState, useDropzoneState } from "./Dropzone";
function handleUploadError(e: unknown) {
+20 -7
View File
@@ -474,6 +474,7 @@ type SectionHeaderAccordionItemProps = {
ActiveIcon?: any;
children?: React.ReactNode;
renderHeaderRight?: (props: { open: boolean }) => React.ReactNode;
scrollContainerRef?: React.RefObject<HTMLDivElement>;
};
export const SectionHeaderAccordionItem = ({
@@ -483,6 +484,7 @@ export const SectionHeaderAccordionItem = ({
ActiveIcon = IconChevronUp,
children,
renderHeaderRight,
scrollContainerRef,
}: SectionHeaderAccordionItemProps) => (
<div
style={{ minHeight: 49 }}
@@ -493,6 +495,8 @@ export const SectionHeaderAccordionItem = ({
: "flex-initial cursor-pointer hover:bg-primary/5",
)}
>
{/** biome-ignore lint/a11y/noStaticElementInteractions: . */}
{/** biome-ignore lint/a11y/useKeyWithClickEvents: . */}
<div
className={twMerge(
"flex flex-row bg-muted/10 border-muted border-b h-14 py-4 pr-4 pl-2 items-center gap-2",
@@ -501,14 +505,12 @@ export const SectionHeaderAccordionItem = ({
>
<IconButton Icon={open ? ActiveIcon : IconChevronDown} disabled={open} />
<h2 className="text-lg dark:font-bold font-semibold select-text">{title}</h2>
<div className="flex flex-grow" />
<div className="flex grow" />
{renderHeaderRight?.({ open })}
</div>
<div
className={twMerge(
"overflow-y-scroll transition-all",
open ? " flex-grow" : "h-0 opacity-0",
)}
ref={scrollContainerRef}
className={twMerge("overflow-y-scroll transition-all", open ? " grow" : "h-0 opacity-0")}
>
{children}
</div>
@@ -518,14 +520,25 @@ export const SectionHeaderAccordionItem = ({
export const RouteAwareSectionHeaderAccordionItem = ({
routePattern,
identifier,
renderHeaderRight,
...props
}: Omit<SectionHeaderAccordionItemProps, "open" | "toggle"> & {
}: Omit<SectionHeaderAccordionItemProps, "open" | "toggle" | "renderHeaderRight"> & {
renderHeaderRight?: (props: { open: boolean; active: boolean }) => React.ReactNode;
// it's optional because it could be provided using the context
routePattern?: string;
identifier: string;
}) => {
const { active, toggle } = useRoutePathState(routePattern, identifier);
return <SectionHeaderAccordionItem {...props} open={active} toggle={toggle} />;
return (
<SectionHeaderAccordionItem
{...props}
open={active}
toggle={toggle}
renderHeaderRight={
renderHeaderRight && ((props) => renderHeaderRight?.({ open: props.open, active }))
}
/>
);
};
export const Separator = ({ className, ...props }: ComponentPropsWithoutRef<"hr">) => (
+18 -15
View File
@@ -1,4 +1,4 @@
import { SegmentedControl, Tooltip } from "@mantine/core";
import { SegmentedControl } from "@mantine/core";
import { IconApi, IconBook, IconKeyOff, IconSettings, IconUser } from "@tabler/icons-react";
import {
TbDatabase,
@@ -10,7 +10,7 @@ import {
TbUser,
TbX,
} from "react-icons/tb";
import { useAuth, useBkndWindowContext } from "ui/client";
import { useAuth, useBkndWindowContext } from "bknd/client";
import { useBknd } from "ui/client/bknd";
import { useTheme } from "ui/client/use-theme";
import { Button } from "ui/components/buttons/Button";
@@ -24,34 +24,35 @@ import { useLocation } from "wouter";
import { NavLink } from "./AppShell";
import { autoFormatString } from "core/utils";
import { appShellStore } from "ui/store";
import { getVersion } from "core/env";
import { getVersion, isDebug } from "core/env";
import { McpIcon } from "ui/routes/tools/mcp/components/mcp-icon";
import { useAppShellAdminOptions } from "ui/options";
export function HeaderNavigation() {
const [location, navigate] = useLocation();
const { config } = useBknd();
const items: {
label: string;
href: string;
Icon: any;
Icon?: any;
exact?: boolean;
tooltip?: string;
disabled?: boolean;
}[] = [
/*{
label: "Base",
href: "#",
exact: true,
Icon: TbLayoutDashboard,
disabled: true,
tooltip: "Coming soon"
},*/
{ label: "Data", href: "/data", Icon: TbDatabase },
{ label: "Auth", href: "/auth", Icon: TbFingerprint },
{ label: "Media", href: "/media", Icon: TbPhoto },
{ label: "Flows", href: "/flows", Icon: TbHierarchy2 },
];
if (isDebug() || Object.keys(config.flows?.flows ?? {}).length > 0) {
items.push({ label: "Flows", href: "/flows", Icon: TbHierarchy2 });
}
if (config.server.mcp.enabled) {
items.push({ label: "MCP", href: "/tools/mcp", Icon: McpIcon });
}
const activeItem = items.find((item) =>
item.exact ? location === item.href : location.startsWith(item.href),
);
@@ -154,8 +155,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() {
+1 -1
View File
@@ -1,6 +1,6 @@
import type { ContextModalProps } from "@mantine/modals";
import { type ReactNode, useEffect, useMemo, useState } from "react";
import { useEntityQuery } from "ui/client";
import { useEntityQuery } from "bknd/client";
import { type FileState, Media } from "ui/elements";
import { autoFormatString, datetimeStringLocal, formatNumber } from "core/utils";
import { twMerge } from "tailwind-merge";
@@ -1,4 +1,4 @@
import { useApi, useInvalidate } from "ui/client";
import { useApi, useInvalidate } from "bknd/client";
import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
import { routes, useNavigate } from "ui/lib/routes";
import { bkndModals } from "ui/modals";
@@ -19,9 +19,10 @@ import type { RelationField } from "data/relations";
import { useEntityAdminOptions } from "ui/options";
// simplify react form types 🤦
export type FormApi = ReactFormExtendedApi<any, any, any, any, any, any, any, any, any, any>;
// biome-ignore format: ...
export type TFieldApi = FieldApi<any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any>;
export type FormApi = ReactFormExtendedApi<any, any, any, any, any, any, any, any, any, any, any, any>;
// biome-ignore format: ...
export type TFieldApi = FieldApi<any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any>;
type EntityFormProps = {
entity: Entity;
@@ -4,7 +4,7 @@ import type { EntityData } from "bknd";
import type { RelationField } from "data/relations";
import { useEffect, useRef, useState } from "react";
import { TbEye } from "react-icons/tb";
import { useEntityQuery } from "ui/client";
import { useEntityQuery } from "bknd/client";
import { useBknd } from "ui/client/bknd";
import { Button } from "ui/components/buttons/Button";
import * as Formy from "ui/components/form/Formy";
+1 -1
View File
@@ -1,6 +1,6 @@
import clsx from "clsx";
import { TbArrowRight, TbCircle, TbCircleCheckFilled, TbFingerprint } from "react-icons/tb";
import { useApiQuery } from "ui/client";
import { useApiQuery } from "bknd/client";
import { useBknd } from "ui/client/bknd";
import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
import { ButtonLink, type ButtonLinkProps } from "ui/components/buttons/Button";
@@ -35,7 +35,7 @@ import { SegmentedControl, Tooltip } from "@mantine/core";
import { Popover } from "ui/components/overlay/Popover";
import { cn } from "ui/lib/utils";
import { JsonViewer } from "ui/components/code/JsonViewer";
import { mountOnce, useApiQuery } from "ui/client";
import { mountOnce, useApiQuery } from "bknd/client";
import { CodePreview } from "ui/components/code/CodePreview";
import type { JsonError } from "json-schema-library";
import { Alert } from "ui/components/display/Alert";
@@ -378,11 +378,7 @@ function replaceEntitiesEnum(schema: Record<string, any>, entities: string[]) {
});
}
const Policy = ({
permission,
}: {
permission: TPermission;
}) => {
const Policy = ({ permission }: { permission: TPermission }) => {
const { value } = useDerivedFieldContext("", ({ value }) => ({
effect: (value?.effect ?? "allow") as "allow" | "deny" | "filter",
}));
@@ -503,22 +499,24 @@ const CustomFieldWrapper = ({
className: "max-w-none",
}}
position="bottom-end"
target={() =>
typeof schema.content === "object" ? (
<JsonViewer
className="w-auto max-w-120 bg-background pr-3 text-sm"
json={schema.content}
title={schema.name}
expand={5}
/>
) : (
<CodePreview
code={schema.content}
lang="typescript"
className="w-auto max-w-120 bg-background p-3 text-sm"
/>
)
}
target={() => (
<div className="w-auto max-w-[80vw] md:max-w-120 bg-background overflow-scroll">
{typeof schema.content === "object" ? (
<JsonViewer
className="pr-3 text-sm"
json={schema.content}
title={schema.name}
expand={5}
/>
) : (
<CodePreview
code={schema.content}
lang="typescript"
className="p-3 text-sm"
/>
)}
</div>
)}
>
<Button variant="ghost" size="smaller" IconLeft={TbCodeDots}>
{autoFormatString(schema.name)}
+2 -2
View File
@@ -3,7 +3,7 @@ import { ucFirst } from "bknd/utils";
import type { Entity, EntityData, EntityRelation } from "bknd";
import { Fragment, useState } from "react";
import { TbDots } from "react-icons/tb";
import { useApiQuery, useEntityQuery } from "ui/client";
import { useApiQuery, useEntityQuery } from "bknd/client";
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
import { Button } from "ui/components/buttons/Button";
import { IconButton } from "ui/components/buttons/IconButton";
@@ -207,7 +207,7 @@ function DataEntityUpdateImpl({ params }) {
handleSubmit={handleSubmit}
fieldsDisabled={fieldsDisabled}
data={data ?? undefined}
Form={Form}
Form={Form as any}
action="update"
className="flex flex-grow flex-col gap-3 p-3"
/>
@@ -1,6 +1,6 @@
import type { EntityData } from "bknd";
import { useState } from "react";
import { useEntityMutate } from "ui/client";
import { useEntityMutate } from "bknd/client";
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
import { Button } from "ui/components/buttons/Button";
import { Message } from "ui/components/display/Message";
@@ -121,7 +121,7 @@ export function DataEntityCreate({ params }) {
handleSubmit={handleSubmit}
fieldsDisabled={fieldsDisabled}
data={search.value}
Form={Form}
Form={Form as any}
action="create"
className="flex flex-grow flex-col gap-3 p-3"
/>
@@ -2,7 +2,7 @@ import type { Entity } from "bknd";
import { repoQuery } from "data/server/query";
import { Fragment } from "react";
import { TbDots } from "react-icons/tb";
import { useApiQuery } from "ui/client";
import { useApiQuery } from "bknd/client";
import { useBknd } from "ui/client/bknd";
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
import { Button } from "ui/components/buttons/Button";
@@ -5,12 +5,12 @@ import {
ucFirstAllSnakeToPascalWithSpaces,
s,
stringIdentifier,
pickKeys,
} from "bknd/utils";
import {
type TAppDataEntityFields,
fieldsSchemaObject as originalFieldsSchemaObject,
} from "data/data-schema";
import { omit } from "lodash-es";
import { forwardRef, memo, useEffect, useImperativeHandle } from "react";
import { type FieldArrayWithId, type UseFormReturn, useFieldArray, useForm } from "react-hook-form";
import { TbGripVertical, TbSettings, TbTrash } from "react-icons/tb";
@@ -317,7 +317,6 @@ function EntityField({
const name = watch(`fields.${index}.name`);
const { active, toggle } = useRoutePathState(routePattern ?? "", name);
const fieldSpec = fieldSpecs.find((s) => s.type === type)!;
const specificData = omit(field.field.config, commonProps);
const disabled = fieldSpec.disabled || [];
const hidden = fieldSpec.hidden || [];
const dragDisabled = index === 0;
@@ -476,7 +475,7 @@ function EntityField({
field={field}
onChange={(value) => {
setValue(`${prefix}.config`, {
...getValues([`fields.${index}.config`])[0],
...pickKeys(getValues([`${prefix}.config`])[0], commonProps),
...value,
});
}}
@@ -520,7 +519,7 @@ const SpecificForm = ({
readonly?: boolean;
}) => {
const type = field.field.type;
const specificData = omit(field.field.config, commonProps);
const specificData = omitKeys(field.field.config ?? {}, commonProps);
return (
<JsonSchemaForm
+14 -7
View File
@@ -11,11 +11,16 @@ import SettingsRoutes from "./settings";
import { FlashMessage } from "ui/modules/server/FlashMessage";
import { AuthRegister } from "ui/routes/auth/auth.register";
import { BkndModalsProvider } from "ui/modals";
import { useBkndWindowContext } from "ui/client";
import { useBkndWindowContext } from "bknd/client";
import ToolsRoutes from "./tools";
// @ts-ignore
const TestRoutes = lazy(() => import("./test"));
let TestRoutes: any;
try {
if (import.meta.env.DEV) {
TestRoutes = lazy(() => import("./test"));
}
} catch {}
export function Routes({
BkndWrapper,
@@ -43,11 +48,13 @@ export function Routes({
<Route path="/" nest>
<Root>
<Switch>
<Route path="/test*" nest>
<Suspense fallback={null}>
<TestRoutes />
</Suspense>
</Route>
{TestRoutes && (
<Route path="/test*" nest>
<Suspense fallback={null}>
<TestRoutes />
</Suspense>
</Route>
)}
{children}
+3 -2
View File
@@ -1,11 +1,12 @@
import { IconPhoto } from "@tabler/icons-react";
import { useBknd } from "ui/client/BkndProvider";
import { Empty } from "ui/components/display/Empty";
import { type FileState, Media } from "ui/elements";
import { useBrowserTitle } from "ui/hooks/use-browser-title";
import * as AppShell from "ui/layouts/AppShell/AppShell";
import { useLocation } from "wouter";
import { bkndModals } from "ui/modals";
import { DropzoneContainer } from "ui/elements/media/DropzoneContainer";
import type { FileState } from "ui/elements/media/Dropzone";
export function MediaIndex() {
const { config } = useBknd();
@@ -35,7 +36,7 @@ export function MediaIndex() {
return (
<AppShell.Scrollable>
<div className="flex flex-1 p-3">
<Media.Dropzone onClick={onClick} infinite query={{ sort: "-id" }} />
<DropzoneContainer onClick={onClick} infinite query={{ sort: "-id" }} />
</div>
</AppShell.Scrollable>
);
+1 -1
View File
@@ -1,6 +1,6 @@
import { IconHome } from "@tabler/icons-react";
import { useEffect } from "react";
import { useAuth } from "ui/client";
import { useAuth } from "bknd/client";
import { useEffectOnce } from "ui/hooks/use-effect";
import { Empty } from "../components/display/Empty";
import { useBrowserTitle } from "../hooks/use-browser-title";
@@ -49,19 +49,21 @@ export const AuthSettings = ({ schema: _unsafe_copy, config }) => {
try {
const user_entity = config.entity_name ?? "users";
const entities = _s.config.data.entities ?? {};
console.log("entities", entities, user_entity);
const user_fields = Object.entries(entities[user_entity]?.fields ?? {})
.map(([name, field]) => (!field.config?.virtual ? name : undefined))
.filter(Boolean);
if (user_fields.length > 0) {
console.log("user_fields", user_fields);
_schema.properties.jwt.properties.fields.items.enum = user_fields;
_schema.properties.jwt.properties.fields.uniqueItems = true;
uiSchema.jwt.fields["ui:widget"] = "checkboxes";
}
} catch (e) {}
console.log("_s", _s);
const roles = Object.keys(config.roles ?? {});
if (roles.length > 0) {
_schema.properties.default_role_register.enum = roles;
}
} catch (_e) {}
const roleSchema = _schema.properties.roles?.additionalProperties ?? { type: "object" };
/* if (_s.permissions) {
roleSchema.properties.permissions.items.enum = _s.permissions;
+2 -2
View File
@@ -1,4 +1,4 @@
import type { JSONSchema7 } from "json-schema";
import type { JSONSchema } from "json-schema-to-ts";
import { omitKeys, type s } from "bknd/utils";
export function extractSchema<
@@ -10,7 +10,7 @@ export function extractSchema<
config: Config,
keys: Keys[],
): [
JSONSchema7,
Exclude<JSONSchema, boolean | null | undefined>,
Partial<Config>,
{
[K in Keys]: {
+1 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { useApi, useApiQuery } from "ui/client";
import { useApi, useApiQuery } from "bknd/client";
import { Scrollable } from "ui/layouts/AppShell/AppShell";
function Bla() {
+1 -1
View File
@@ -6,7 +6,7 @@ export default function ToolsRoutes() {
return (
<>
<Route path="/" component={ToolsIndex} />
<Route path="/mcp" component={ToolsMcp} />
<Route path="/mcp*" component={ToolsMcp} nest />
</>
);
}
@@ -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,
});
}
+50 -45
View File
@@ -8,14 +8,13 @@ import { Empty } from "ui/components/display/Empty";
import { Button } from "ui/components/buttons/Button";
import { appShellStore } from "ui/store";
import { useBrowserTitle } from "ui/hooks/use-browser-title";
import { RoutePathStateProvider } from "ui/hooks/use-route-path-state";
import { Route, Switch } from "wouter";
export default function ToolsMcp() {
useBrowserTitle(["MCP UI"]);
const { config, options } = useBknd();
const feature = useMcpStore((state) => state.feature);
const setFeature = useMcpStore((state) => state.setFeature);
const content = useMcpStore((state) => state.content);
const { config } = useBknd();
const openSidebar = appShellStore((store) => store.toggleSidebar("default"));
const mcpPath = config.server.mcp.path;
@@ -29,51 +28,57 @@ export default function ToolsMcp() {
}
return (
<div className="flex flex-col flex-grow max-w-screen">
<AppShell.SectionHeader>
<div className="flex flex-row gap-4 items-center">
<McpIcon />
<AppShell.SectionHeaderTitle className="whitespace-nowrap truncate">
MCP UI
</AppShell.SectionHeaderTitle>
<div className="hidden md:flex flex-row gap-2 items-center bg-primary/5 rounded-full px-3 pr-3.5 py-2">
<TbWorld />
<div className="min-w-0 flex-1">
<span className="block truncate text-sm font-mono leading-none select-text">
{window.location.origin + mcpPath}
</span>
<RoutePathStateProvider path={"/:type?"} defaultIdentifier="tools">
<div className="flex flex-col flex-grow max-w-screen">
<AppShell.SectionHeader>
<div className="flex flex-row gap-4 items-center">
<McpIcon />
<AppShell.SectionHeaderTitle className="whitespace-nowrap truncate">
MCP UI
</AppShell.SectionHeaderTitle>
<div className="hidden md:flex flex-row gap-2 items-center bg-primary/5 rounded-full px-3 pr-3.5 py-2">
<TbWorld />
<div className="min-w-0 flex-1">
<span className="block truncate text-sm font-mono leading-none select-text">
{window.location.origin + mcpPath}
</span>
</div>
</div>
</div>
</div>
</AppShell.SectionHeader>
</AppShell.SectionHeader>
<div className="flex h-full">
<AppShell.Sidebar>
<Tools.Sidebar open={feature === "tools"} toggle={() => setFeature("tools")} />
<AppShell.SectionHeaderAccordionItem
title="Resources"
open={feature === "resources"}
toggle={() => setFeature("resources")}
>
<div className="flex flex-col flex-grow p-3 gap-3 justify-center items-center opacity-40">
<i>Resources</i>
</div>
</AppShell.SectionHeaderAccordionItem>
</AppShell.Sidebar>
{feature === "tools" && <Tools.Content />}
{!content && (
<Empty title="No tool selected" description="Please select a tool to continue.">
<Button
variant="primary"
onClick={() => openSidebar()}
className="block md:hidden"
<div className="flex grow h-full">
<AppShell.Sidebar>
<Tools.Sidebar />
<AppShell.RouteAwareSectionHeaderAccordionItem
title="Resources"
identifier="resources"
>
Open Tools
</Button>
</Empty>
)}
<div className="flex flex-col flex-grow p-3 gap-3 justify-center items-center opacity-40">
<i>Resources</i>
</div>
</AppShell.RouteAwareSectionHeaderAccordionItem>
</AppShell.Sidebar>
<Switch>
<Route path="/tools/:toolName?" component={Tools.Content} />
<Route path="*">
<Empty
title="No tool selected"
description="Please select a tool to continue."
>
<Button
variant="primary"
onClick={() => openSidebar()}
className="block md:hidden"
>
Open Tools
</Button>
</Empty>
</Route>
</Switch>
</div>
</div>
</div>
</RoutePathStateProvider>
);
}
-7
View File
@@ -3,23 +3,16 @@ import { combine } from "zustand/middleware";
import type { ToolJson } from "jsonv-ts/mcp";
const FEATURES = ["tools", "resources"] as const;
export type Feature = (typeof FEATURES)[number];
export const useMcpStore = create(
combine(
{
tools: [] as ToolJson[],
feature: "tools" as Feature | null,
content: null as ToolJson | null,
history: [] as { type: "request" | "response"; data: any }[],
historyLimit: 50,
historyVisible: false,
},
(set) => ({
setTools: (tools: ToolJson[]) => set({ tools }),
setFeature: (feature: Feature) => set({ feature }),
setContent: (content: ToolJson | null) => set({ content }),
addHistory: (type: "request" | "response", data: any) =>
set((state) => ({
history: [{ type, data }, ...state.history.slice(0, state.historyLimit - 1)],
+44 -36
View File
@@ -5,7 +5,6 @@ import { AppShell } from "ui/layouts/AppShell";
import { TbHistory, TbHistoryOff, TbRefresh } from "react-icons/tb";
import { IconButton } from "ui/components/buttons/IconButton";
import { JsonViewer, JsonViewerTabs, type JsonViewerTabsRef } from "ui/components/code/JsonViewer";
import { twMerge } from "ui/elements/mocks/tailwind-merge";
import { Field, Form } from "ui/components/form/json-schema-form";
import { Button } from "ui/components/buttons/Button";
import * as Formy from "ui/components/form/Formy";
@@ -13,17 +12,18 @@ import { appShellStore } from "ui/store";
import { Icon } from "ui/components/display/Icon";
import { useMcpClient } from "./hooks/use-mcp-client";
import { Tooltip } from "@mantine/core";
import { Link } from "ui/components/wouter/Link";
import { useParams } from "wouter";
export function Sidebar({ open, toggle }) {
export function Sidebar() {
const client = useMcpClient();
const closeSidebar = appShellStore((store) => store.closeSidebar("default"));
const tools = useMcpStore((state) => state.tools);
const setTools = useMcpStore((state) => state.setTools);
const setContent = useMcpStore((state) => state.setContent);
const content = useMcpStore((state) => state.content);
const [loading, setLoading] = useState(false);
const [query, setQuery] = useState<string>("");
const [error, setError] = useState<string | null>(null);
const scrollContainerRef = useRef<HTMLDivElement>(undefined!);
const handleRefresh = useCallback(async () => {
setLoading(true);
@@ -39,15 +39,22 @@ export function Sidebar({ open, toggle }) {
}, []);
useEffect(() => {
handleRefresh();
handleRefresh().then(() => {
if (scrollContainerRef.current) {
const selectedTool = scrollContainerRef.current.querySelector(".active");
if (selectedTool) {
selectedTool.scrollIntoView({ behavior: "smooth", block: "center" });
}
}
});
}, []);
return (
<AppShell.SectionHeaderAccordionItem
<AppShell.RouteAwareSectionHeaderAccordionItem
scrollContainerRef={scrollContainerRef}
title="Tools"
open={open}
toggle={toggle}
renderHeaderRight={() => (
identifier="tools"
renderHeaderRight={({ active }) => (
<div className="flex flex-row gap-2 items-center">
{error && (
<Tooltip label={error}>
@@ -57,7 +64,7 @@ export function Sidebar({ open, toggle }) {
<span className="flex-inline bg-primary/10 px-2 py-1.5 rounded-xl text-sm font-mono leading-none">
{tools.length}
</span>
<IconButton Icon={TbRefresh} disabled={!open || loading} onClick={handleRefresh} />
<IconButton Icon={TbRefresh} disabled={!active || loading} onClick={handleRefresh} />
</div>
)}
>
@@ -76,12 +83,11 @@ export function Sidebar({ open, toggle }) {
return (
<AppShell.SidebarLink
key={tool.name}
className={twMerge(
"flex flex-col items-start h-auto py-3 gap-px",
content?.name === tool.name ? "active" : "",
)}
className="flex flex-col items-start h-auto py-3 gap-px"
as={Link}
href={`/tools/${tool.name}`}
onClick={() => {
setContent(tool);
//setContent(tool);
closeSidebar();
}}
>
@@ -92,32 +98,34 @@ export function Sidebar({ open, toggle }) {
})}
</nav>
</div>
</AppShell.SectionHeaderAccordionItem>
</AppShell.RouteAwareSectionHeaderAccordionItem>
);
}
export function Content() {
const content = useMcpStore((state) => state.content);
const { toolName } = useParams();
const tools = useMcpStore((state) => state.tools);
const tool = tools.find((tool) => tool.name === toolName);
const addHistory = useMcpStore((state) => state.addHistory);
const [payload, setPayload] = useState<object>(getTemplate(content?.inputSchema));
const [payload, setPayload] = useState<object>(getTemplate(tool?.inputSchema));
const [result, setResult] = useState<object | null>(null);
const historyVisible = useMcpStore((state) => state.historyVisible);
const setHistoryVisible = useMcpStore((state) => state.setHistoryVisible);
const client = useMcpClient();
const jsonViewerTabsRef = useRef<JsonViewerTabsRef>(null);
const hasInputSchema =
content?.inputSchema && Object.keys(content.inputSchema.properties ?? {}).length > 0;
tool?.inputSchema && Object.keys(tool.inputSchema.properties ?? {}).length > 0;
const [isPending, startTransition] = useTransition();
useEffect(() => {
setPayload(getTemplate(content?.inputSchema));
setPayload(getTemplate(tool?.inputSchema));
setResult(null);
}, [content]);
}, [toolName]);
const handleSubmit = useCallback(async () => {
if (!content?.name) return;
if (!tool?.name) return;
const request = {
name: content.name,
name: tool.name,
arguments: payload,
};
startTransition(async () => {
@@ -131,7 +139,7 @@ export function Content() {
});
}, [payload]);
if (!content) return null;
if (!tool) return null;
let readableResult = result;
try {
@@ -144,11 +152,11 @@ export function Content() {
return (
<Form
className="flex flex-grow flex-col min-w-0 max-w-screen"
key={content.name}
className="flex grow flex-col min-w-0 max-w-screen"
key={tool.name}
schema={{
title: "InputSchema",
...content?.inputSchema,
...tool?.inputSchema,
}}
validateOn="submit"
initialValues={payload}
@@ -163,12 +171,12 @@ export function Content() {
right={
<div className="flex flex-row gap-2">
<IconButton
Icon={historyVisible ? TbHistory : TbHistoryOff}
Icon={historyVisible ? TbHistoryOff : TbHistory}
onClick={() => setHistoryVisible(!historyVisible)}
/>
<Button
type="submit"
disabled={!content?.name || isPending}
disabled={!tool?.name || isPending}
variant="primary"
className="whitespace-nowrap"
>
@@ -181,19 +189,19 @@ export function Content() {
<span className="opacity-50">
Tools <span className="opacity-70">/</span>
</span>{" "}
<span className="truncate">{content?.name}</span>
<span className="truncate">{tool?.name}</span>
</AppShell.SectionHeaderTitle>
</AppShell.SectionHeader>
<div className="flex flex-grow flex-row w-vw">
<div className="flex grow flex-row w-vw">
<div
className="flex flex-grow flex-col max-w-full"
className="flex grow flex-col max-w-full"
style={{
width: "calc(100% - var(--sidebar-width-right) - 1px)",
}}
>
<AppShell.Scrollable>
<div key={JSON.stringify(content)} className="flex flex-col py-4 px-5 gap-4">
<p className="text-primary/80">{content?.description}</p>
<div key={JSON.stringify(tool)} className="flex flex-col py-4 px-5 gap-4">
<p className="text-primary/80">{tool?.description}</p>
{hasInputSchema && <Field name="" />}
<JsonViewerTabs
@@ -209,7 +217,7 @@ export function Content() {
},
Result: { json: readableResult, title: "Result" },
Configuration: {
json: content ?? null,
json: tool ?? null,
title: "Configuration",
},
}}
@@ -234,7 +242,7 @@ const History = () => {
<>
<AppShell.SectionHeader>History</AppShell.SectionHeader>
<AppShell.Scrollable>
<div className="flex flex-col flex-grow p-3 gap-1">
<div className="flex grow flex-col p-3 gap-1">
{history.map((item, i) => (
<JsonViewer
key={`${item.type}-${i}`}