Merge remote-tracking branch 'origin/main' into feat/data-ui-fillable-visible
This commit is contained in:
@@ -15,13 +15,14 @@ import { AppReduced } from "./utils/AppReduced";
|
||||
import { Message } from "ui/components/display/Message";
|
||||
import { useNavigate } from "ui/lib/routes";
|
||||
import type { BkndAdminProps } from "ui/Admin";
|
||||
import type { TPermission } from "auth/authorize/Permission";
|
||||
|
||||
export type BkndContext = {
|
||||
version: number;
|
||||
readonly: boolean;
|
||||
schema: ModuleSchemas;
|
||||
config: ModuleConfigs;
|
||||
permissions: string[];
|
||||
permissions: TPermission[];
|
||||
hasSecrets: boolean;
|
||||
requireSecrets: () => Promise<void>;
|
||||
actions: ReturnType<typeof getSchemaActions>;
|
||||
@@ -122,11 +123,14 @@ export function BkndProvider({
|
||||
fetching.current = Fetching.None;
|
||||
};
|
||||
|
||||
if ("startViewTransition" in document) {
|
||||
// disable view transitions for now
|
||||
// because it causes browser crash on heavy pages (e.g. schema)
|
||||
commit();
|
||||
/* if ("startViewTransition" in document) {
|
||||
document.startViewTransition(commit);
|
||||
} else {
|
||||
commit();
|
||||
}
|
||||
} */
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -53,9 +53,7 @@ export const ClientProvider = ({
|
||||
[JSON.stringify(apiProps)],
|
||||
);
|
||||
|
||||
const [authState, setAuthState] = useState<Partial<AuthState> | undefined>(
|
||||
apiProps.user ? api.getAuthState() : undefined,
|
||||
);
|
||||
const [authState, setAuthState] = useState<Partial<AuthState> | undefined>(api.getAuthState());
|
||||
|
||||
return (
|
||||
<ClientContext.Provider value={{ baseUrl: api.baseUrl, api, authState }}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Api } from "Api";
|
||||
import { FetchPromise, type ModuleApi, type ResponseObject } from "modules/ModuleApi";
|
||||
import useSWR, { type SWRConfiguration, useSWRConfig } from "swr";
|
||||
import useSWR, { type SWRConfiguration, useSWRConfig, type Middleware, type SWRHook } from "swr";
|
||||
import useSWRInfinite from "swr/infinite";
|
||||
import { useApi } from "ui/client";
|
||||
import { useState } from "react";
|
||||
@@ -89,3 +89,25 @@ export const useInvalidate = (options?: { exact?: boolean }) => {
|
||||
return mutate((k) => typeof k === "string" && k.startsWith(key));
|
||||
};
|
||||
};
|
||||
|
||||
const mountOnceCache = new Map<string, any>();
|
||||
|
||||
/**
|
||||
* Simple middleware to only load on first mount.
|
||||
*/
|
||||
export const mountOnce: Middleware = (useSWRNext: SWRHook) => (key, fetcher, config) => {
|
||||
if (typeof key === "string") {
|
||||
if (mountOnceCache.has(key)) {
|
||||
return useSWRNext(key, fetcher, {
|
||||
...config,
|
||||
revalidateOnMount: false,
|
||||
});
|
||||
}
|
||||
const swr = useSWRNext(key, fetcher, config);
|
||||
if (swr.data) {
|
||||
mountOnceCache.set(key, true);
|
||||
}
|
||||
return swr;
|
||||
}
|
||||
return useSWRNext(key, fetcher, config);
|
||||
};
|
||||
|
||||
@@ -16,8 +16,8 @@ type UseAuth = {
|
||||
verified: boolean;
|
||||
login: (data: LoginData) => Promise<AuthResponse>;
|
||||
register: (data: LoginData) => Promise<AuthResponse>;
|
||||
logout: () => void;
|
||||
verify: () => void;
|
||||
logout: () => Promise<void>;
|
||||
verify: () => Promise<void>;
|
||||
setToken: (token: string) => void;
|
||||
};
|
||||
|
||||
@@ -42,12 +42,13 @@ export const useAuth = (options?: { baseUrl?: string }): UseAuth => {
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
api.updateToken(undefined);
|
||||
invalidate();
|
||||
await api.auth.logout();
|
||||
await invalidate();
|
||||
}
|
||||
|
||||
async function verify() {
|
||||
await api.verifyAuth();
|
||||
await invalidate();
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -49,7 +49,7 @@ export function useBkndAuth() {
|
||||
has_admin: Object.entries(config.auth.roles ?? {}).some(
|
||||
([name, role]) =>
|
||||
role.implicit_allow ||
|
||||
minimum_permissions.every((p) => role.permissions?.includes(p)),
|
||||
minimum_permissions.every((p) => role.permissions?.some((p) => p.permission === p)),
|
||||
),
|
||||
},
|
||||
routes: {
|
||||
|
||||
@@ -5,13 +5,15 @@ import { twMerge } from "tailwind-merge";
|
||||
import { Link } from "ui/components/wouter/Link";
|
||||
|
||||
const sizes = {
|
||||
smaller: "px-1.5 py-1 rounded-md gap-1 !text-xs",
|
||||
small: "px-2 py-1.5 rounded-md gap-1 text-sm",
|
||||
default: "px-3 py-2.5 rounded-md gap-1.5",
|
||||
large: "px-4 py-3 rounded-md gap-2.5 text-lg",
|
||||
};
|
||||
|
||||
const iconSizes = {
|
||||
small: 12,
|
||||
smaller: 12,
|
||||
small: 14,
|
||||
default: 16,
|
||||
large: 20,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTheme } from "ui/client/use-theme";
|
||||
import { cn, importDynamicBrowserModule } from "ui/lib/utils";
|
||||
|
||||
export type CodePreviewProps = {
|
||||
code: string;
|
||||
className?: string;
|
||||
lang?: string;
|
||||
theme?: string;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export const CodePreview = ({
|
||||
code,
|
||||
className,
|
||||
lang = "typescript",
|
||||
theme: _theme,
|
||||
enabled = true,
|
||||
}: CodePreviewProps) => {
|
||||
const [highlightedHtml, setHighlightedHtml] = useState<string | null>(null);
|
||||
const $theme = useTheme();
|
||||
const theme = (_theme ?? $theme.theme === "dark") ? "github-dark" : "github-light";
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
let cancelled = false;
|
||||
setHighlightedHtml(null);
|
||||
|
||||
async function highlightCode() {
|
||||
try {
|
||||
// Dynamically import Shiki from CDN
|
||||
const { codeToHtml } = await importDynamicBrowserModule(
|
||||
"shiki",
|
||||
"https://esm.sh/shiki@3.13.0",
|
||||
);
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
const html = await codeToHtml(code, {
|
||||
lang,
|
||||
theme,
|
||||
structure: "inline",
|
||||
});
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
setHighlightedHtml(html);
|
||||
} catch (error) {
|
||||
console.error("Failed to load Shiki:", error);
|
||||
// Fallback to plain text if Shiki fails to load
|
||||
if (!cancelled) {
|
||||
setHighlightedHtml(code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
highlightCode();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [code, enabled]);
|
||||
|
||||
if (!highlightedHtml) {
|
||||
return <pre className={cn("select-text cursor-text", className)}>{code}</pre>;
|
||||
}
|
||||
|
||||
return (
|
||||
<pre
|
||||
className={cn("select-text cursor-text", className)}
|
||||
dangerouslySetInnerHTML={{ __html: highlightedHtml }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,19 +1,68 @@
|
||||
import { Suspense, lazy } from "react";
|
||||
import { Suspense, lazy, useEffect, useState } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import type { CodeEditorProps } from "./CodeEditor";
|
||||
import { useDebouncedCallback } from "@mantine/hooks";
|
||||
const CodeEditor = lazy(() => import("./CodeEditor"));
|
||||
|
||||
export function JsonEditor({ editable, className, ...props }: CodeEditorProps) {
|
||||
export type JsonEditorProps = Omit<CodeEditorProps, "value" | "onChange"> & {
|
||||
value?: object;
|
||||
onChange?: (value: object) => void;
|
||||
emptyAs?: any;
|
||||
onInvalid?: (error: Error) => void;
|
||||
};
|
||||
|
||||
export function JsonEditor({
|
||||
editable,
|
||||
className,
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
emptyAs = undefined,
|
||||
onInvalid,
|
||||
...props
|
||||
}: JsonEditorProps) {
|
||||
const [editorValue, setEditorValue] = useState<string | null | undefined>(
|
||||
value ? JSON.stringify(value, null, 2) : emptyAs,
|
||||
);
|
||||
const [error, setError] = useState<boolean>(false);
|
||||
const handleChange = useDebouncedCallback((given: string) => {
|
||||
try {
|
||||
setError(false);
|
||||
onChange?.(given ? JSON.parse(given) : emptyAs);
|
||||
} catch (e) {
|
||||
onInvalid?.(e as Error);
|
||||
setError(true);
|
||||
}
|
||||
}, 250);
|
||||
const handleBlur = (e) => {
|
||||
try {
|
||||
const formatted = JSON.stringify(value, null, 2);
|
||||
setEditorValue(formatted);
|
||||
} catch (e) {}
|
||||
|
||||
onBlur?.(e);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorValue) {
|
||||
setEditorValue(value ? JSON.stringify(value, null, 2) : emptyAs);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<CodeEditor
|
||||
className={twMerge(
|
||||
"flex w-full border border-muted",
|
||||
!editable && "opacity-70",
|
||||
error && "border-red-500",
|
||||
className,
|
||||
)}
|
||||
editable={editable}
|
||||
_extensions={{ json: true }}
|
||||
value={editorValue ?? undefined}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
{...props}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
@@ -28,8 +28,9 @@ export const Group = <E extends ElementType = "div">({
|
||||
return (
|
||||
<Tag
|
||||
{...props}
|
||||
data-role="group"
|
||||
className={twMerge(
|
||||
"flex flex-col gap-1.5 has-disabled:cursor-not-allowed",
|
||||
"flex flex-col gap-1.5 has-disabled:cursor-not-allowed w-full",
|
||||
as === "fieldset" && "border border-primary/10 p-3 rounded-md",
|
||||
as === "fieldset" && error && "border-red-500",
|
||||
error && "text-red-500",
|
||||
|
||||
@@ -5,19 +5,29 @@ import { Button } from "ui/components/buttons/Button";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
import { Dropdown } from "ui/components/overlay/Dropdown";
|
||||
import { useEvent } from "ui/hooks/use-event";
|
||||
import { FieldComponent } from "./Field";
|
||||
import { FieldWrapper } from "./FieldWrapper";
|
||||
import { useDerivedFieldContext, useFormValue } from "./Form";
|
||||
import { Field, FieldComponent, type FieldProps } from "./Field";
|
||||
import { FieldWrapper, type FieldwrapperProps } from "./FieldWrapper";
|
||||
import { FormContextOverride, useDerivedFieldContext, useFormValue } from "./Form";
|
||||
import { coerce, getMultiSchema, getMultiSchemaMatched, isEqual, suffixPath } from "./utils";
|
||||
|
||||
export const ArrayField = ({ path = "" }: { path?: string }) => {
|
||||
export type ArrayFieldProps = {
|
||||
path?: string;
|
||||
labelAdd?: string;
|
||||
wrapperProps?: Omit<FieldwrapperProps, "name" | "children">;
|
||||
};
|
||||
|
||||
export const ArrayField = ({
|
||||
path = "",
|
||||
labelAdd = "Add",
|
||||
wrapperProps = { wrapper: "fieldset" },
|
||||
}: ArrayFieldProps) => {
|
||||
const { setValue, pointer, required, schema, ...ctx } = useDerivedFieldContext(path);
|
||||
if (!schema || typeof schema === "undefined") return `ArrayField(${path}): no schema ${pointer}`;
|
||||
|
||||
// if unique items with enum
|
||||
if (schema.uniqueItems && typeof schema.items === "object" && "enum" in schema.items) {
|
||||
return (
|
||||
<FieldWrapper name={path} schema={schema} wrapper="fieldset">
|
||||
<FieldWrapper {...wrapperProps} name={path} schema={schema}>
|
||||
<FieldComponent
|
||||
required
|
||||
name={path}
|
||||
@@ -35,7 +45,7 @@ export const ArrayField = ({ path = "" }: { path?: string }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<FieldWrapper name={path} schema={schema} wrapper="fieldset">
|
||||
<FieldWrapper {...wrapperProps} name={path} schema={schema}>
|
||||
<ArrayIterator name={path}>
|
||||
{({ value }) =>
|
||||
value?.map((v, index: number) => (
|
||||
@@ -44,17 +54,21 @@ export const ArrayField = ({ path = "" }: { path?: string }) => {
|
||||
}
|
||||
</ArrayIterator>
|
||||
<div className="flex flex-row">
|
||||
<ArrayAdd path={path} schema={schema} />
|
||||
<ArrayAdd path={path} schema={schema} label={labelAdd} />
|
||||
</div>
|
||||
</FieldWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const ArrayItem = memo(({ path, index, schema }: any) => {
|
||||
const { value, ...ctx } = useDerivedFieldContext(path, (ctx) => {
|
||||
const {
|
||||
value,
|
||||
path: absolutePath,
|
||||
...ctx
|
||||
} = useDerivedFieldContext(path, (ctx) => {
|
||||
return ctx.value?.[index];
|
||||
});
|
||||
const itemPath = suffixPath(path, index);
|
||||
const itemPath = suffixPath(absolutePath, index);
|
||||
let subschema = schema.items;
|
||||
const itemsMultiSchema = getMultiSchema(schema.items);
|
||||
if (itemsMultiSchema) {
|
||||
@@ -62,10 +76,6 @@ const ArrayItem = memo(({ path, index, schema }: any) => {
|
||||
subschema = _subschema;
|
||||
}
|
||||
|
||||
const handleUpdate = useEvent((pointer: string, value: any) => {
|
||||
ctx.setValue(pointer, value);
|
||||
});
|
||||
|
||||
const handleDelete = useEvent((pointer: string) => {
|
||||
ctx.deleteValue(pointer);
|
||||
});
|
||||
@@ -76,21 +86,26 @@ const ArrayItem = memo(({ path, index, schema }: any) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={itemPath} className="flex flex-row gap-2">
|
||||
<FieldComponent
|
||||
name={itemPath}
|
||||
schema={subschema!}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
handleUpdate(itemPath, coerce(e.target.value, subschema!));
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
{DeleteButton}
|
||||
</div>
|
||||
<FormContextOverride prefix={itemPath} schema={subschema!}>
|
||||
<div className="flex flex-row gap-2 w-full">
|
||||
{/* another wrap is required for primitive schemas */}
|
||||
<AnotherField label={false} />
|
||||
{DeleteButton}
|
||||
</div>
|
||||
</FormContextOverride>
|
||||
);
|
||||
}, isEqual);
|
||||
|
||||
const AnotherField = (props: Partial<FieldProps>) => {
|
||||
const { value } = useFormValue("");
|
||||
|
||||
const inputProps = {
|
||||
// @todo: check, potentially just provide value
|
||||
value: ["string", "number", "boolean"].includes(typeof value) ? value : undefined,
|
||||
};
|
||||
return <Field name={""} label={false} {...props} inputProps={inputProps} />;
|
||||
};
|
||||
|
||||
const ArrayIterator = memo(
|
||||
({ name, children }: any) => {
|
||||
return children(useFormValue(name));
|
||||
@@ -98,19 +113,25 @@ const ArrayIterator = memo(
|
||||
(prev, next) => prev.value?.length === next.value?.length,
|
||||
);
|
||||
|
||||
const ArrayAdd = ({ schema, path }: { schema: JsonSchema; path: string }) => {
|
||||
const ArrayAdd = ({
|
||||
schema,
|
||||
path: _path,
|
||||
label = "Add",
|
||||
}: { schema: JsonSchema; path: string; label?: string }) => {
|
||||
const {
|
||||
setValue,
|
||||
value: { currentIndex },
|
||||
path,
|
||||
...ctx
|
||||
} = useDerivedFieldContext(path, (ctx) => {
|
||||
} = useDerivedFieldContext(_path, (ctx) => {
|
||||
return { currentIndex: ctx.value?.length ?? 0 };
|
||||
});
|
||||
const itemsMultiSchema = getMultiSchema(schema.items);
|
||||
const options = { addOptionalProps: true };
|
||||
|
||||
function handleAdd(template?: any) {
|
||||
const newPath = suffixPath(path, currentIndex);
|
||||
setValue(newPath, template ?? ctx.lib.getTemplate(undefined, schema!.items));
|
||||
setValue(newPath, template ?? ctx.lib.getTemplate(undefined, schema!.items, options));
|
||||
}
|
||||
|
||||
if (itemsMultiSchema) {
|
||||
@@ -121,14 +142,14 @@ const ArrayAdd = ({ schema, path }: { schema: JsonSchema; path: string }) => {
|
||||
}}
|
||||
items={itemsMultiSchema.map((s, i) => ({
|
||||
label: s!.title ?? `Option ${i + 1}`,
|
||||
onClick: () => handleAdd(ctx.lib.getTemplate(undefined, s!)),
|
||||
onClick: () => handleAdd(ctx.lib.getTemplate(undefined, s!, options)),
|
||||
}))}
|
||||
onClickItem={console.log}
|
||||
>
|
||||
<Button IconLeft={IconLibraryPlus}>Add</Button>
|
||||
<Button IconLeft={IconLibraryPlus}>{label}</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
return <Button onClick={() => handleAdd()}>Add</Button>;
|
||||
return <Button onClick={() => handleAdd()}>{label}</Button>;
|
||||
};
|
||||
|
||||
@@ -72,7 +72,7 @@ const FieldImpl = ({
|
||||
);
|
||||
|
||||
if (isType(schema.type, "object")) {
|
||||
return <ObjectField path={name} />;
|
||||
return <ObjectField path={name} wrapperProps={props} />;
|
||||
}
|
||||
|
||||
if (isType(schema.type, "array")) {
|
||||
@@ -217,14 +217,14 @@ export type CustomFieldProps<Data = any> = {
|
||||
) => React.ReactNode;
|
||||
};
|
||||
|
||||
export const CustomField = <Data = any>({
|
||||
export function CustomField<Data = any>({
|
||||
path: _path,
|
||||
valueStrict = true,
|
||||
deriveFn,
|
||||
children,
|
||||
}: CustomFieldProps<Data>) => {
|
||||
}: CustomFieldProps<Data>) {
|
||||
const ctx = useDerivedFieldContext(_path, deriveFn);
|
||||
const $value = useFormValue(ctx.path, { strict: valueStrict });
|
||||
const $value = useFormValue(_path, { strict: valueStrict });
|
||||
const setValue = (value: any) => ctx.setValue(ctx.path, value);
|
||||
return children({ ...ctx, ...$value, setValue, _setValue: ctx.setValue });
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IconBug } from "@tabler/icons-react";
|
||||
import { IconBug, IconInfoCircle } from "@tabler/icons-react";
|
||||
import type { JsonSchema } from "json-schema-library";
|
||||
import { Children, type ReactElement, type ReactNode, cloneElement, isValidElement } from "react";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
} from "ui/components/form/json-schema-form/Form";
|
||||
import { Popover } from "ui/components/overlay/Popover";
|
||||
import { getLabel } from "./utils";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { Tooltip } from "@mantine/core";
|
||||
|
||||
export type FieldwrapperProps = {
|
||||
name: string;
|
||||
@@ -23,8 +25,9 @@ export type FieldwrapperProps = {
|
||||
children: ReactElement | ReactNode;
|
||||
errorPlacement?: "top" | "bottom";
|
||||
description?: string;
|
||||
descriptionPlacement?: "top" | "bottom";
|
||||
descriptionPlacement?: "top" | "bottom" | "label";
|
||||
fieldId?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function FieldWrapper({
|
||||
@@ -38,6 +41,7 @@ export function FieldWrapper({
|
||||
descriptionPlacement = "bottom",
|
||||
children,
|
||||
fieldId,
|
||||
className,
|
||||
...props
|
||||
}: FieldwrapperProps) {
|
||||
const errors = useFormError(name, { strict: true });
|
||||
@@ -50,17 +54,23 @@ export function FieldWrapper({
|
||||
<Formy.ErrorMessage>{errors.map((e) => e.message).join(", ")}</Formy.ErrorMessage>
|
||||
);
|
||||
|
||||
const Description = description && (
|
||||
<Formy.Help className={descriptionPlacement === "top" ? "-mt-1 mb-1" : "mb-2"}>
|
||||
{description}
|
||||
</Formy.Help>
|
||||
);
|
||||
const Description = description ? (
|
||||
["top", "bottom"].includes(descriptionPlacement) ? (
|
||||
<Formy.Help className={descriptionPlacement === "top" ? "-mt-1 mb-1" : "mb-2"}>
|
||||
{description}
|
||||
</Formy.Help>
|
||||
) : (
|
||||
<Tooltip label={description}>
|
||||
<IconInfoCircle className="size-4 opacity-50" />
|
||||
</Tooltip>
|
||||
)
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Formy.Group
|
||||
error={errors.length > 0}
|
||||
as={wrapper === "fieldset" ? "fieldset" : "div"}
|
||||
className={hidden ? "hidden" : "relative"}
|
||||
className={twMerge(hidden ? "hidden" : "relative", className)}
|
||||
>
|
||||
{errorPlacement === "top" && Errors}
|
||||
<FieldDebug name={name} schema={schema} required={required} />
|
||||
@@ -69,14 +79,15 @@ export function FieldWrapper({
|
||||
<Formy.Label
|
||||
as={wrapper === "fieldset" ? "legend" : "label"}
|
||||
htmlFor={fieldId}
|
||||
className="self-start"
|
||||
className="self-start flex flex-row gap-1 items-center"
|
||||
>
|
||||
{label} {required && <span className="font-medium opacity-30">*</span>}
|
||||
{descriptionPlacement === "label" && Description}
|
||||
</Formy.Label>
|
||||
)}
|
||||
{descriptionPlacement === "top" && Description}
|
||||
|
||||
<div className="flex flex-row gap-2">
|
||||
<div className="flex flex-row flex-grow gap-2">
|
||||
<div className="flex flex-1 flex-col gap-3">
|
||||
{Children.count(children) === 1 && isValidElement(children)
|
||||
? cloneElement(children, {
|
||||
|
||||
@@ -80,6 +80,7 @@ export function Form<
|
||||
onInvalidSubmit,
|
||||
validateOn = "submit",
|
||||
hiddenSubmit = true,
|
||||
beforeSubmit,
|
||||
ignoreKeys = [],
|
||||
options = {},
|
||||
readOnly = false,
|
||||
@@ -90,6 +91,7 @@ export function Form<
|
||||
initialOpts?: LibTemplateOptions;
|
||||
ignoreKeys?: string[];
|
||||
onChange?: (data: Partial<Data>, name: string, value: any, context: FormContext<Data>) => void;
|
||||
beforeSubmit?: (data: Data) => Data;
|
||||
onSubmit?: (data: Data) => void | Promise<void>;
|
||||
onInvalidSubmit?: (errors: JsonError[], data: Partial<Data>) => void;
|
||||
hiddenSubmit?: boolean;
|
||||
@@ -128,7 +130,7 @@ export function Form<
|
||||
if (errors.length === 0) {
|
||||
await onSubmit(data as Data);
|
||||
} else {
|
||||
console.log("invalid", errors);
|
||||
console.error("form: invalid", { data, errors });
|
||||
onInvalidSubmit?.(errors, data);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -177,7 +179,8 @@ export function Form<
|
||||
});
|
||||
|
||||
const validate = useEvent((_data?: Partial<Data>) => {
|
||||
const actual = _data ?? getCurrentState()?.data;
|
||||
const before = beforeSubmit ?? ((a: any) => a);
|
||||
const actual = before((_data as any) ?? getCurrentState()?.data);
|
||||
const errors = lib.validate(actual, schema);
|
||||
setFormState((prev) => ({ ...prev, errors }));
|
||||
return { data: actual, errors };
|
||||
@@ -378,5 +381,5 @@ export function FormDebug({ force = false }: { force?: boolean }) {
|
||||
if (options?.debug !== true && force !== true) return null;
|
||||
const ctx = useFormStateSelector((s) => s);
|
||||
|
||||
return <JsonViewer json={ctx} expand={99} />;
|
||||
return <JsonViewer json={ctx} expand={99} showCopy />;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import { isTypeSchema } from "ui/components/form/json-schema-form/utils";
|
||||
import { AnyOfField } from "./AnyOfField";
|
||||
import { Field } from "./Field";
|
||||
import { FieldWrapper, type FieldwrapperProps } from "./FieldWrapper";
|
||||
import { type JSONSchema, useDerivedFieldContext } from "./Form";
|
||||
import { type JSONSchema, useDerivedFieldContext, useFormValue } from "./Form";
|
||||
import { JsonEditor } from "ui/components/code/JsonEditor";
|
||||
|
||||
export type ObjectFieldProps = {
|
||||
path?: string;
|
||||
@@ -11,7 +12,7 @@ export type ObjectFieldProps = {
|
||||
};
|
||||
|
||||
export const ObjectField = ({ path = "", label: _label, wrapperProps = {} }: ObjectFieldProps) => {
|
||||
const { schema, ...ctx } = useDerivedFieldContext(path);
|
||||
const { schema } = useDerivedFieldContext(path);
|
||||
if (!isTypeSchema(schema)) return `ObjectField "${path}": no schema`;
|
||||
const properties = Object.entries(schema.properties ?? {}) as [string, JSONSchema][];
|
||||
|
||||
@@ -24,7 +25,7 @@ export const ObjectField = ({ path = "", label: _label, wrapperProps = {} }: Obj
|
||||
{...wrapperProps}
|
||||
>
|
||||
{properties.length === 0 ? (
|
||||
<i className="opacity-50">No properties</i>
|
||||
<ObjectJsonField path={path} />
|
||||
) : (
|
||||
properties.map(([prop, schema]) => {
|
||||
const name = [path, prop].filter(Boolean).join(".");
|
||||
@@ -40,3 +41,9 @@ export const ObjectField = ({ path = "", label: _label, wrapperProps = {} }: Obj
|
||||
</FieldWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export const ObjectJsonField = ({ path }: { path: string }) => {
|
||||
const { value } = useFormValue(path);
|
||||
const { setValue, path: absolutePath } = useDerivedFieldContext(path);
|
||||
return <JsonEditor value={value} onChange={(value) => setValue(absolutePath, value)} />;
|
||||
};
|
||||
|
||||
@@ -67,18 +67,23 @@ export function isRequired(lib: Draft, pointer: string, schema: JsonSchema, data
|
||||
return false;
|
||||
}
|
||||
|
||||
const childSchema = lib.getSchema({ pointer, data, schema });
|
||||
if (typeof childSchema === "object" && "const" in childSchema) {
|
||||
return true;
|
||||
try {
|
||||
const childSchema = lib.getSchema({ pointer, data, schema });
|
||||
if (typeof childSchema === "object" && "const" in childSchema) {
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parentPointer = getParentPointer(pointer);
|
||||
const parentSchema = lib.getSchema({ pointer: parentPointer, data });
|
||||
const required = parentSchema?.required?.includes(pointer.split("/").pop()!);
|
||||
const l = pointer.split("/").pop();
|
||||
const required = parentSchema?.required?.includes(l);
|
||||
|
||||
return !!required;
|
||||
} catch (e) {
|
||||
console.error("isRequired", { pointer, schema, data, e });
|
||||
console.warn("isRequired", { pointer, schema, data, e });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,23 +10,13 @@ export default function JsonField({
|
||||
readonly,
|
||||
...props
|
||||
}: FieldProps) {
|
||||
const value = JSON.stringify(formData, null, 2);
|
||||
|
||||
function handleChange(data) {
|
||||
try {
|
||||
onChange(JSON.parse(data));
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
const isDisabled = disabled || readonly;
|
||||
const id = props.idSchema.$id;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label label={props.name} id={id} />
|
||||
<JsonEditor value={value} editable={!isDisabled} onChange={handleChange} />
|
||||
<JsonEditor value={formData} editable={!isDisabled} onChange={onChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { isFileAccepted } from "bknd/utils";
|
||||
import { type FileWithPath, useDropzone } from "./use-dropzone";
|
||||
import { checkMaxReached } from "./helper";
|
||||
import { DropzoneInner } from "./DropzoneInner";
|
||||
@@ -173,12 +173,14 @@ export function Dropzone({
|
||||
|
||||
return specs.every((spec) => {
|
||||
if (spec.kind !== "file") {
|
||||
console.log("not a file", spec.kind);
|
||||
console.warn("file not accepted: not a file", spec.kind);
|
||||
return false;
|
||||
}
|
||||
if (allowedMimeTypes && allowedMimeTypes.length > 0) {
|
||||
console.log("not allowed mimetype", spec.type);
|
||||
return allowedMimeTypes.includes(spec.type);
|
||||
if (!isFileAccepted(i, allowedMimeTypes)) {
|
||||
console.warn("file not accepted: not allowed mimetype", spec.type);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -95,7 +95,7 @@ export function useNavigate() {
|
||||
window.location.href = url;
|
||||
return;
|
||||
} else if ("target" in options) {
|
||||
const _url = window.location.origin + basepath + router.base + url;
|
||||
const _url = window.location.origin + router.base + url;
|
||||
window.open(_url, options.target);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3,3 +3,30 @@ import { type ClassNameValue, twMerge } from "tailwind-merge";
|
||||
export function cn(...inputs: ClassNameValue[]) {
|
||||
return twMerge(inputs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamically import a module from a URL in the browser in a way compatible with all react frameworks (nextjs doesn't support dynamic imports)
|
||||
*/
|
||||
export async function importDynamicBrowserModule<T = any>(name: string, url: string): Promise<T> {
|
||||
if (!(window as any)[name]) {
|
||||
const script = document.createElement("script");
|
||||
script.type = "module";
|
||||
script.async = true;
|
||||
script.textContent = `import * as ${name} from '${url}';window.${name} = ${name};`;
|
||||
document.head.appendChild(script);
|
||||
|
||||
// poll for the module to be available
|
||||
const maxAttempts = 50; // 5s
|
||||
let attempts = 0;
|
||||
while (!(window as any)[name] && attempts < maxAttempts) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
attempts++;
|
||||
}
|
||||
|
||||
if (!(window as any)[name]) {
|
||||
throw new Error(`Browser module "${name}" failed to load`);
|
||||
}
|
||||
}
|
||||
|
||||
return (window as any)[name] as T;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { FieldApi, ReactFormExtendedApi } from "@tanstack/react-form";
|
||||
import type { JSX } from "react";
|
||||
import { useStore } from "@tanstack/react-store";
|
||||
import { MediaField } from "media/MediaField";
|
||||
import { type ComponentProps, Suspense } from "react";
|
||||
import { type ComponentProps, Suspense, useState } from "react";
|
||||
import { JsonEditor } from "ui/components/code/JsonEditor";
|
||||
import * as Formy from "ui/components/form/Formy";
|
||||
import { FieldLabel } from "ui/components/form/Formy";
|
||||
@@ -283,12 +283,14 @@ function EntityJsonFormField({
|
||||
field,
|
||||
...props
|
||||
}: { fieldApi: TFieldApi; field: JsonField }) {
|
||||
const [error, setError] = useState<any>(null);
|
||||
const handleUpdate = useEvent((value: any) => {
|
||||
setError(null);
|
||||
fieldApi.handleChange(value);
|
||||
});
|
||||
|
||||
return (
|
||||
<Formy.Group>
|
||||
<Formy.Group error={!!error}>
|
||||
<Formy.FieldLabel htmlFor={fieldApi.name} field={field} />
|
||||
<Suspense>
|
||||
<JsonEditor
|
||||
@@ -296,6 +298,7 @@ function EntityJsonFormField({
|
||||
value={fieldApi.state.value}
|
||||
onChange={handleUpdate}
|
||||
onBlur={fieldApi.handleBlur}
|
||||
onInvalid={setError}
|
||||
minHeight="100"
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -1,17 +1,48 @@
|
||||
import { useRef } from "react";
|
||||
import { TbDots } from "react-icons/tb";
|
||||
import { useBknd } from "ui/client/bknd";
|
||||
import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
import { Message } from "ui/components/display/Message";
|
||||
import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
|
||||
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "ui/lib/routes";
|
||||
import { isDebug } from "core/env";
|
||||
import { Dropdown } from "ui/components/overlay/Dropdown";
|
||||
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
import { TbAdjustments, TbDots, TbFilter, TbTrash, TbInfoCircle, TbCodeDots } from "react-icons/tb";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2";
|
||||
import { routes, useNavigate } from "ui/lib/routes";
|
||||
import { AuthRoleForm, type AuthRoleFormRef } from "ui/routes/auth/forms/role.form";
|
||||
import { routes } from "ui/lib/routes";
|
||||
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||
import * as Formy from "ui/components/form/Formy";
|
||||
import { ucFirst, s, transformObject, isObject, autoFormatString } from "bknd/utils";
|
||||
import type { ModuleSchemas } from "bknd";
|
||||
import {
|
||||
CustomField,
|
||||
Field,
|
||||
FieldWrapper,
|
||||
Form,
|
||||
FormContextOverride,
|
||||
FormDebug,
|
||||
ObjectJsonField,
|
||||
Subscribe,
|
||||
useDerivedFieldContext,
|
||||
useFormContext,
|
||||
useFormError,
|
||||
useFormValue,
|
||||
} from "ui/components/form/json-schema-form";
|
||||
import type { TPermission } from "auth/authorize/Permission";
|
||||
import type { RoleSchema } from "auth/authorize/Role";
|
||||
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 { CodePreview } from "ui/components/code/CodePreview";
|
||||
import type { JsonError } from "json-schema-library";
|
||||
import { Alert } from "ui/components/display/Alert";
|
||||
|
||||
export function AuthRolesEdit(props) {
|
||||
useBrowserTitle(["Auth", "Roles", props.params.role]);
|
||||
|
||||
const { hasSecrets } = useBknd({ withSecrets: true });
|
||||
if (!hasSecrets) {
|
||||
return <Message.MissingPermission what="Roles & Permissions" />;
|
||||
@@ -20,32 +51,69 @@ export function AuthRolesEdit(props) {
|
||||
return <AuthRolesEditInternal {...props} />;
|
||||
}
|
||||
|
||||
function AuthRolesEditInternal({ params }) {
|
||||
// currently for backward compatibility
|
||||
function getSchema(authSchema: ModuleSchemas["auth"]) {
|
||||
const roles = authSchema.properties.roles.additionalProperties;
|
||||
return {
|
||||
...roles,
|
||||
properties: {
|
||||
...roles.properties,
|
||||
permissions: {
|
||||
...roles.properties.permissions.anyOf[1],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const formConfig = {
|
||||
options: {
|
||||
debug: isDebug(),
|
||||
},
|
||||
};
|
||||
|
||||
function AuthRolesEditInternal({ params }: { params: { role: string } }) {
|
||||
const [navigate] = useNavigate();
|
||||
const { config, actions } = useBkndAuth();
|
||||
const { config, schema: authSchema, actions } = useBkndAuth();
|
||||
const [error, setError] = useState<JsonError[]>();
|
||||
const roleName = params.role;
|
||||
const role = config.roles?.[roleName];
|
||||
const formRef = useRef<AuthRoleFormRef>(null);
|
||||
const { readonly } = useBknd();
|
||||
const { readonly, permissions } = useBknd();
|
||||
const schema = getSchema(authSchema);
|
||||
const data = {
|
||||
...role,
|
||||
// this is to maintain array structure
|
||||
permissions: permissions.map((p) => {
|
||||
return role?.permissions?.find((v: any) => v.permission === p.name);
|
||||
}),
|
||||
};
|
||||
|
||||
async function handleUpdate() {
|
||||
console.log("data", formRef.current?.isValid());
|
||||
if (!formRef.current?.isValid()) return;
|
||||
const data = formRef.current?.getData();
|
||||
const success = await actions.roles.patch(roleName, data);
|
||||
async function handleDelete() {
|
||||
const success = await actions.roles.delete(roleName);
|
||||
if (success) {
|
||||
navigate(routes.auth.roles.list());
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (await actions.roles.delete(roleName)) {
|
||||
navigate(routes.auth.roles.list());
|
||||
}
|
||||
async function handleUpdate(data: any) {
|
||||
setError(undefined);
|
||||
await actions.roles.patch(roleName, data);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form
|
||||
schema={schema as any}
|
||||
initialValues={data}
|
||||
{...formConfig}
|
||||
beforeSubmit={(data) => {
|
||||
return {
|
||||
...data,
|
||||
permissions: [...Object.values(data.permissions).filter(Boolean)],
|
||||
};
|
||||
}}
|
||||
onSubmit={handleUpdate}
|
||||
onInvalidSubmit={(errors) => {
|
||||
setError(errors);
|
||||
}}
|
||||
>
|
||||
<AppShell.SectionHeader
|
||||
right={
|
||||
<>
|
||||
@@ -69,9 +137,23 @@ function AuthRolesEditInternal({ params }) {
|
||||
<IconButton Icon={TbDots} />
|
||||
</Dropdown>
|
||||
{!readonly && (
|
||||
<Button variant="primary" onClick={handleUpdate}>
|
||||
Update
|
||||
</Button>
|
||||
<Subscribe
|
||||
selector={(state) => ({
|
||||
dirty: state.dirty,
|
||||
errors: state.errors.length > 0,
|
||||
submitting: state.submitting,
|
||||
})}
|
||||
>
|
||||
{({ dirty, errors, submitting }) => (
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
disabled={!dirty || errors || submitting}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
)}
|
||||
</Subscribe>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
@@ -85,8 +167,368 @@ function AuthRolesEditInternal({ params }) {
|
||||
/>
|
||||
</AppShell.SectionHeader>
|
||||
<AppShell.Scrollable>
|
||||
<AuthRoleForm ref={formRef} role={role} />
|
||||
{error && <Alert.Exception message={"Invalid form data"} />}
|
||||
<div className="flex flex-col flex-grow px-5 py-5 gap-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Permissions />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<Field
|
||||
label="Should this role be the default?"
|
||||
name="is_default"
|
||||
description="In case an user is not assigned any role, this role will be assigned by default."
|
||||
descriptionPlacement="top"
|
||||
/>
|
||||
<Field
|
||||
label="Implicit allow missing permissions?"
|
||||
name="implicit_allow"
|
||||
description="This should be only used for admins. If a permission is not explicitly denied, it will be allowed."
|
||||
descriptionPlacement="top"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<FormDebug />
|
||||
</AppShell.Scrollable>
|
||||
</>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
type PermissionsData = Exclude<RoleSchema["permissions"], string[] | undefined>;
|
||||
type PermissionData = PermissionsData[number];
|
||||
|
||||
const Permissions = () => {
|
||||
const { permissions } = useBknd();
|
||||
|
||||
const grouped = permissions.reduce(
|
||||
(acc, permission, index) => {
|
||||
const [group, name] = permission.name.split(".") as [string, string];
|
||||
if (!acc[group]) acc[group] = [];
|
||||
acc[group].push({ index, permission });
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, { index: number; permission: TPermission }[]>,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
{Object.entries(grouped).map(([group, rows]) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-2" key={group}>
|
||||
<h3 className="font-semibold">{ucFirst(group)} Permissions</h3>
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-3 items-start">
|
||||
{rows.map(({ index, permission }) => (
|
||||
<Permission key={permission.name} permission={permission} index={index} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Permission = ({ permission, index }: { permission: TPermission; index?: number }) => {
|
||||
const path = `permissions.${index}`;
|
||||
const { value } = useDerivedFieldContext("permissions", (ctx) => {
|
||||
const v = ctx.value;
|
||||
if (!Array.isArray(v)) return undefined;
|
||||
const v2 = v.find((v) => v && v.permission === permission.name);
|
||||
return {
|
||||
set: !!v2,
|
||||
policies: (v2?.policies?.length ?? 0) as number,
|
||||
};
|
||||
});
|
||||
const { setValue } = useFormContext();
|
||||
const [open, setOpen] = useState(false);
|
||||
const policiesCount = value?.policies ?? 0;
|
||||
const isSet = value?.set ?? false;
|
||||
|
||||
async function handleSwitch() {
|
||||
if (isSet) {
|
||||
setValue(path, undefined);
|
||||
setOpen(false);
|
||||
} else {
|
||||
setValue(path, {
|
||||
permission: permission.name,
|
||||
policies: [],
|
||||
effect: "allow",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function toggleOpen() {
|
||||
setOpen((o) => !o);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
key={permission.name}
|
||||
className={cn("flex flex-col border border-muted", open && "border-primary/20")}
|
||||
>
|
||||
<div className={cn("flex flex-row gap-2 justify-between", open && "bg-primary/5")}>
|
||||
<div className="py-4 px-4 font-mono leading-none flex flex-row gap-2 items-center">
|
||||
{permission.name}
|
||||
{permission.filterable && (
|
||||
<Tooltip label="Permission supports filtering">
|
||||
<TbFilter className="opacity-50" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-grow" />
|
||||
<div className="flex flex-row gap-1 items-center px-2">
|
||||
<div className="relative flex flex-row gap-1 items-center">
|
||||
{policiesCount > 0 && (
|
||||
<div className="bg-primary/80 text-background rounded-full size-5 flex items-center justify-center text-sm font-bold pointer-events-none">
|
||||
{policiesCount}
|
||||
</div>
|
||||
)}
|
||||
<IconButton
|
||||
size="md"
|
||||
variant="ghost"
|
||||
disabled={!isSet}
|
||||
Icon={TbAdjustments}
|
||||
className={cn("disabled:opacity-20")}
|
||||
onClick={toggleOpen}
|
||||
/>
|
||||
</div>
|
||||
<Formy.Switch size="sm" checked={isSet} onChange={handleSwitch} />
|
||||
</div>
|
||||
</div>
|
||||
{open && (
|
||||
<div className="px-3.5 py-3.5">
|
||||
<Policies path={`permissions.${index}.policies`} permission={permission} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Policies = ({ path, permission }: { path: string; permission: TPermission }) => {
|
||||
const {
|
||||
setValue,
|
||||
schema: policySchema,
|
||||
lib,
|
||||
deleteValue,
|
||||
value,
|
||||
} = useDerivedFieldContext(path, ({ value }) => {
|
||||
return {
|
||||
policies: (value?.length ?? 0) as number,
|
||||
};
|
||||
});
|
||||
const policiesCount = value?.policies ?? 0;
|
||||
|
||||
function handleAdd() {
|
||||
setValue(
|
||||
`${path}.${policiesCount}`,
|
||||
lib.getTemplate(undefined, policySchema!.items, {
|
||||
addOptionalProps: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function handleDelete(index: number) {
|
||||
deleteValue(`${path}.${index}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col", policiesCount > 0 && "gap-8")}>
|
||||
<div className="flex flex-col gap-5">
|
||||
{policiesCount > 0 &&
|
||||
Array.from({ length: policiesCount }).map((_, i) => (
|
||||
<FormContextOverride key={i} prefix={`${path}.${i}`} schema={policySchema.items!}>
|
||||
{i > 0 && <div className="h-px bg-muted" />}
|
||||
<div className="flex flex-row gap-2 items-start">
|
||||
<div className="flex flex-col flex-grow w-full">
|
||||
<Policy permission={permission} />
|
||||
</div>
|
||||
<IconButton Icon={TbTrash} onClick={() => handleDelete(i)} size="sm" />
|
||||
</div>
|
||||
</FormContextOverride>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-row justify-center">
|
||||
<Button onClick={handleAdd}>Add Policy</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const mergeSchemas = (...schemas: object[]) => {
|
||||
return s.allOf(schemas.filter(Boolean).map(s.fromSchema));
|
||||
};
|
||||
|
||||
function replaceEntitiesEnum(schema: Record<string, any>, entities: string[]) {
|
||||
if (!isObject(schema) || !Array.isArray(entities) || entities.length === 0) return schema;
|
||||
return transformObject(schema, (sub, name) => {
|
||||
if (name === "properties") {
|
||||
return transformObject(sub as Record<string, any>, (propConfig, propKey) => {
|
||||
if (propKey === "entity" && propConfig.type === "string") {
|
||||
return {
|
||||
...propConfig,
|
||||
enum: entities,
|
||||
};
|
||||
}
|
||||
return propConfig;
|
||||
});
|
||||
}
|
||||
return sub;
|
||||
});
|
||||
}
|
||||
|
||||
const Policy = ({
|
||||
permission,
|
||||
}: {
|
||||
permission: TPermission;
|
||||
}) => {
|
||||
const { value } = useDerivedFieldContext("", ({ value }) => ({
|
||||
effect: (value?.effect ?? "allow") as "allow" | "deny" | "filter",
|
||||
}));
|
||||
const $bknd = useBknd();
|
||||
const $permissions = useApiQuery((api) => api.system.permissions(), {
|
||||
use: [mountOnce],
|
||||
});
|
||||
const entities = Object.keys($bknd.config.data.entities ?? {});
|
||||
const ctx = $permissions.data
|
||||
? mergeSchemas(
|
||||
$permissions.data.context,
|
||||
replaceEntitiesEnum(permission.context ?? null, entities),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Field name="description" />
|
||||
|
||||
<CustomFieldWrapper
|
||||
name="condition"
|
||||
label="Condition"
|
||||
description="The condition that must be met for the policy to be applied."
|
||||
schema={
|
||||
ctx && {
|
||||
name: "Context",
|
||||
content: s.toTypes(ctx, "Context"),
|
||||
}
|
||||
}
|
||||
>
|
||||
<ObjectJsonField path="condition" />
|
||||
</CustomFieldWrapper>
|
||||
|
||||
<CustomField path="effect">
|
||||
{({ value, setValue }) => (
|
||||
<FieldWrapper
|
||||
name="effect"
|
||||
label="Effect"
|
||||
descriptionPlacement="label"
|
||||
description="The effect of the policy to take effect on met condition."
|
||||
>
|
||||
<SegmentedControl
|
||||
className="border border-muted"
|
||||
defaultValue={value}
|
||||
onChange={(value) => setValue(value)}
|
||||
data={
|
||||
["allow", "deny", permission.filterable ? "filter" : undefined]
|
||||
.filter(Boolean)
|
||||
.map((effect) => ({
|
||||
label: ucFirst(effect ?? ""),
|
||||
value: effect,
|
||||
})) as any
|
||||
}
|
||||
/>
|
||||
</FieldWrapper>
|
||||
)}
|
||||
</CustomField>
|
||||
|
||||
{value?.effect === "filter" && (
|
||||
<CustomFieldWrapper
|
||||
name="filter"
|
||||
label="Filter"
|
||||
description="Filter to apply to all queries on met condition."
|
||||
schema={
|
||||
ctx && {
|
||||
name: "Variables",
|
||||
content: s.toTypes(ctx, "Variables"),
|
||||
}
|
||||
}
|
||||
>
|
||||
<ObjectJsonField path="filter" />
|
||||
</CustomFieldWrapper>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CustomFieldWrapper = ({
|
||||
children,
|
||||
name,
|
||||
label,
|
||||
description,
|
||||
schema,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
name: string;
|
||||
label: string;
|
||||
description: string;
|
||||
schema?: {
|
||||
name: string;
|
||||
content: string | object;
|
||||
};
|
||||
}) => {
|
||||
const errors = useFormError(name, { strict: true });
|
||||
const Errors = errors.length > 0 && (
|
||||
<Formy.ErrorMessage>{errors.map((e) => e.message).join(", ")}</Formy.ErrorMessage>
|
||||
);
|
||||
|
||||
return (
|
||||
<Formy.Group as="div">
|
||||
<Formy.Label
|
||||
as="label"
|
||||
htmlFor={name}
|
||||
className="flex flex-row gap-1 justify-between items-center"
|
||||
>
|
||||
<div className="flex flex-row gap-1 items-center">
|
||||
{label}
|
||||
{description && (
|
||||
<Tooltip label={description}>
|
||||
<TbInfoCircle className="size-4 opacity-50" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{schema && (
|
||||
<div>
|
||||
<Popover
|
||||
overlayProps={{
|
||||
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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Button variant="ghost" size="smaller" IconLeft={TbCodeDots}>
|
||||
{autoFormatString(schema.name)}
|
||||
</Button>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
</Formy.Label>
|
||||
{children}
|
||||
{Errors}
|
||||
</Formy.Group>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,8 +12,21 @@ import { CellValue, DataTable } from "../../components/table/DataTable";
|
||||
import * as AppShell from "../../layouts/AppShell/AppShell";
|
||||
import { routes, useNavigate } from "../../lib/routes";
|
||||
import { useBknd } from "ui/client/bknd";
|
||||
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
||||
import { Message } from "ui/components/display/Message";
|
||||
|
||||
export function AuthRolesList() {
|
||||
export function AuthRolesList(props) {
|
||||
useBrowserTitle(["Auth", "Roles"]);
|
||||
|
||||
const { hasSecrets } = useBknd({ withSecrets: true });
|
||||
if (!hasSecrets) {
|
||||
return <Message.MissingPermission what="Auth Roles" />;
|
||||
}
|
||||
|
||||
return <AuthRolesListInternal {...props} />;
|
||||
}
|
||||
|
||||
function AuthRolesListInternal() {
|
||||
const [navigate] = useNavigate();
|
||||
const { config, actions } = useBkndAuth();
|
||||
const { readonly } = useBknd();
|
||||
@@ -21,7 +34,10 @@ export function AuthRolesList() {
|
||||
const data = Object.values(
|
||||
transformObject(config.roles ?? {}, (role, name) => ({
|
||||
role: name,
|
||||
permissions: role.permissions,
|
||||
permissions: role.permissions?.map((p) => p.permission) as string[],
|
||||
policies: role.permissions
|
||||
?.flatMap((p) => p.policies?.length ?? 0)
|
||||
.reduce((acc, curr) => acc + curr, 0),
|
||||
is_default: role.is_default ?? false,
|
||||
implicit_allow: role.implicit_allow ?? false,
|
||||
})),
|
||||
@@ -94,6 +110,9 @@ const renderValue = ({ value, property }) => {
|
||||
if (["is_default", "implicit_allow"].includes(property)) {
|
||||
return value ? <span>Yes</span> : <span className="opacity-50">No</span>;
|
||||
}
|
||||
if (property === "policies") {
|
||||
return value ? <span>{value}</span> : <span className="opacity-50">0</span>;
|
||||
}
|
||||
|
||||
if (property === "permissions") {
|
||||
const max = 3;
|
||||
|
||||
@@ -34,7 +34,11 @@ export const AuthRoleForm = forwardRef<
|
||||
getValues,
|
||||
} = useForm({
|
||||
resolver: standardSchemaResolver(schema),
|
||||
defaultValues: role,
|
||||
defaultValues: {
|
||||
...role,
|
||||
// compat
|
||||
permissions: role?.permissions?.map((p) => p.permission),
|
||||
},
|
||||
});
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
@@ -47,7 +51,7 @@ export const AuthRoleForm = forwardRef<
|
||||
<div className="flex flex-col flex-grow px-5 py-5 gap-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
{/*<h3 className="font-semibold">Role Permissions</h3>*/}
|
||||
<Permissions control={control} permissions={permissions} />
|
||||
<Permissions control={control} permissions={permissions.map((p) => p.name)} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Input.Wrapper
|
||||
@@ -111,8 +115,6 @@ const Permissions = ({
|
||||
{} as Record<string, string[]>,
|
||||
);
|
||||
|
||||
console.log("grouped", grouped);
|
||||
//console.log("fieldState", fieldState, value);
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
{Object.entries(grouped).map(([group, permissions]) => {
|
||||
@@ -121,7 +123,7 @@ const Permissions = ({
|
||||
<h3 className="font-semibold">{ucFirst(group)} Permissions</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-3 gap-3">
|
||||
{permissions.map((permission) => {
|
||||
const selected = data.includes(permission);
|
||||
const selected = data.includes(permission as any);
|
||||
return (
|
||||
<div key={permission} className="flex flex-col border border-muted">
|
||||
<div className="flex flex-row gap-2 justify-between">
|
||||
|
||||
@@ -215,7 +215,9 @@ const EntityContextMenu = ({
|
||||
href && {
|
||||
icon: IconExternalLink,
|
||||
label: "Open in tab",
|
||||
onClick: () => navigate(href, { target: "_blank" }),
|
||||
onClick: () => {
|
||||
navigate(href, { target: "_blank", absolute: true });
|
||||
},
|
||||
},
|
||||
separator,
|
||||
!$data.system(entity.name).any && {
|
||||
|
||||
@@ -301,7 +301,11 @@ function EntityDetailInner({
|
||||
|
||||
// @todo: add custom key for invalidation
|
||||
const $q = useApiQuery(
|
||||
(api) => api.data.readManyByReference(entity.name, id, other.reference, search),
|
||||
(api) =>
|
||||
api.data.readManyByReference(entity.name, id, other.reference, {
|
||||
...search,
|
||||
limit: search.limit + 1 /* overfetch for softscan=false */,
|
||||
}),
|
||||
{
|
||||
keepPreviousData: true,
|
||||
revalidateOnFocus: true,
|
||||
@@ -320,7 +324,6 @@ function EntityDetailInner({
|
||||
navigate(routes.data.entity.create(other.entity.name), {
|
||||
query: ref.where,
|
||||
});
|
||||
//navigate(routes.data.entity.create(other.entity.name) + `?${query}`);
|
||||
};
|
||||
}
|
||||
} catch (e) {}
|
||||
@@ -330,6 +333,7 @@ function EntityDetailInner({
|
||||
}
|
||||
|
||||
const isUpdating = $q.isValidating || $q.isLoading;
|
||||
const meta = $q.data?.body.meta;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -344,7 +348,7 @@ function EntityDetailInner({
|
||||
onClickRow={handleClickRow}
|
||||
onClickNew={handleClickNew}
|
||||
page={Math.floor(search.offset / search.limit) + 1}
|
||||
total={$q.data?.body?.meta?.count ?? 1}
|
||||
total={meta?.count}
|
||||
onClickPage={(page) => {
|
||||
setSearch((s) => ({
|
||||
...s,
|
||||
|
||||
@@ -63,10 +63,10 @@ export const AuthSettings = ({ schema: _unsafe_copy, config }) => {
|
||||
} catch (e) {}
|
||||
console.log("_s", _s);
|
||||
const roleSchema = _schema.properties.roles?.additionalProperties ?? { type: "object" };
|
||||
if (_s.permissions) {
|
||||
/* if (_s.permissions) {
|
||||
roleSchema.properties.permissions.items.enum = _s.permissions;
|
||||
roleSchema.properties.permissions.uniqueItems = true;
|
||||
}
|
||||
} */
|
||||
|
||||
return (
|
||||
<Route path="/auth" nest>
|
||||
|
||||
@@ -27,6 +27,7 @@ import SortableTest from "./tests/sortable-test";
|
||||
import { SqlAiTest } from "./tests/sql-ai-test";
|
||||
import Themes from "./tests/themes";
|
||||
import ErrorBoundary from "ui/components/display/ErrorBoundary";
|
||||
import CodeEditorTest from "./tests/code-editor-test";
|
||||
|
||||
const tests = {
|
||||
DropdownTest,
|
||||
@@ -52,6 +53,7 @@ const tests = {
|
||||
JsonSchemaForm3,
|
||||
FormyTest,
|
||||
HtmlFormTest,
|
||||
CodeEditorTest,
|
||||
} as const;
|
||||
|
||||
export default function TestRoutes() {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useState } from "react";
|
||||
import { JsonEditor } from "ui/components/code/JsonEditor";
|
||||
import { JsonViewer } from "ui/components/code/JsonViewer";
|
||||
|
||||
export default function CodeEditorTest() {
|
||||
const [value, setValue] = useState({});
|
||||
return (
|
||||
<div className="flex flex-col p-4">
|
||||
<JsonEditor value={value} onChange={setValue} />
|
||||
<JsonViewer json={value} expand={9} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -56,6 +56,14 @@ const authSchema = {
|
||||
},
|
||||
} as const satisfies JSONSchema;
|
||||
|
||||
const objectCodeSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
config: { type: "object", properties: {} },
|
||||
},
|
||||
};
|
||||
|
||||
const formOptions = {
|
||||
debug: true,
|
||||
};
|
||||
@@ -77,6 +85,45 @@ export default function JsonSchemaForm3() {
|
||||
{/* <Form schema={_schema.auth.toJSON()} options={formOptions} /> */}
|
||||
|
||||
<Form
|
||||
schema={objectCodeSchema as any}
|
||||
options={formOptions}
|
||||
initialValues={{
|
||||
name: "Peter",
|
||||
config: {
|
||||
foo: "bar",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Form
|
||||
schema={s
|
||||
.object({
|
||||
name: s.string(),
|
||||
props: s.array(
|
||||
s.object({
|
||||
age: s.number(),
|
||||
config: s.object({}),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.toJSON()}
|
||||
options={formOptions}
|
||||
initialValues={{
|
||||
name: "Peter",
|
||||
props: [{ age: 20, config: { foo: "bar" } }],
|
||||
}}
|
||||
/>
|
||||
|
||||
<Form
|
||||
schema={s
|
||||
.object({
|
||||
name: s.string(),
|
||||
props: s.array(s.anyOf([s.string(), s.number()])),
|
||||
})
|
||||
.toJSON()}
|
||||
options={formOptions}
|
||||
/>
|
||||
|
||||
{/* <Form
|
||||
options={{
|
||||
anyOfNoneSelectedMode: "first",
|
||||
debug: true,
|
||||
@@ -98,7 +145,7 @@ export default function JsonSchemaForm3() {
|
||||
.optional(),
|
||||
})
|
||||
.toJSON()}
|
||||
/>
|
||||
/> */}
|
||||
|
||||
{/*<Form
|
||||
onChange={(data) => console.log("change", data)}
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function ToolsMcp() {
|
||||
<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">
|
||||
<span className="block truncate text-sm font-mono leading-none select-text">
|
||||
{window.location.origin + mcpPath}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ import * as Formy from "ui/components/form/Formy";
|
||||
import { appShellStore } from "ui/store";
|
||||
import { Icon } from "ui/components/display/Icon";
|
||||
import { useMcpClient } from "./hooks/use-mcp-client";
|
||||
import { Tooltip } from "@mantine/core";
|
||||
|
||||
export function Sidebar({ open, toggle }) {
|
||||
const client = useMcpClient();
|
||||
@@ -48,7 +49,11 @@ export function Sidebar({ open, toggle }) {
|
||||
toggle={toggle}
|
||||
renderHeaderRight={() => (
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
{error && <Icon.Err title={error} className="size-5 pointer-events-auto" />}
|
||||
{error && (
|
||||
<Tooltip label={error}>
|
||||
<Icon.Err className="size-5 pointer-events-auto" />
|
||||
</Tooltip>
|
||||
)}
|
||||
<span className="flex-inline bg-primary/10 px-2 py-1.5 rounded-xl text-sm font-mono leading-none">
|
||||
{tools.length}
|
||||
</span>
|
||||
|
||||
Reference in New Issue
Block a user