mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-15 20:17:22 +00:00
535 lines
18 KiB
TypeScript
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 "bknd/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>
|
|
);
|
|
};
|