Files
bknd/app/src/ui/routes/auth/auth.roles.edit.$role.tsx
dswbx 869031bbfa Refactor CustomFieldWrapper and enhance schema handling in Policy component
- Updated `CustomFieldWrapper` to accept a more structured schema object, improving clarity and type safety.
- Modified schema handling in the `Policy` component to ensure proper context and variable naming, enhancing the overall user experience.
- Introduced `autoFormatString` utility for dynamic button labeling based on schema name.
2025-10-24 12:43:32 +02:00

535 lines
18 KiB
TypeScript

import { useBknd } from "ui/client/bknd";
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 { 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 } 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" />;
}
return <AuthRolesEditInternal {...props} />;
}
// 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, schema: authSchema, actions } = useBkndAuth();
const [error, setError] = useState<JsonError[]>();
const roleName = params.role;
const role = config.roles?.[roleName];
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 handleDelete() {
const success = await actions.roles.delete(roleName);
if (success) {
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={
<>
<Dropdown
items={[
{
label: "Advanced Settings",
onClick: () =>
navigate(routes.settings.path(["auth", "roles", roleName]), {
absolute: true,
}),
},
!readonly && {
label: "Delete",
onClick: handleDelete,
destructive: true,
},
]}
position="bottom-end"
>
<IconButton Icon={TbDots} />
</Dropdown>
{!readonly && (
<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>
)}
</>
}
className="pl-3"
>
<Breadcrumbs2
path={[
{ label: "Roles & Permissions", href: routes.auth.roles.list() },
{ label: roleName },
]}
/>
</AppShell.SectionHeader>
<AppShell.Scrollable>
{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>
);
};