added new settings UI for auth

This commit is contained in:
dswbx
2025-02-25 13:59:44 +01:00
parent 253174c14e
commit de854eec3a
14 changed files with 220 additions and 120 deletions

View File

@@ -63,12 +63,14 @@
"@aws-sdk/client-s3": "^3.613.0", "@aws-sdk/client-s3": "^3.613.0",
"@bluwy/giget-core": "^0.1.2", "@bluwy/giget-core": "^0.1.2",
"@dagrejs/dagre": "^1.1.4", "@dagrejs/dagre": "^1.1.4",
"@mantine/modals": "^7.13.4",
"@mantine/notifications": "^7.13.4",
"@hono/typebox-validator": "^0.2.6", "@hono/typebox-validator": "^0.2.6",
"@hono/vite-dev-server": "^0.17.0", "@hono/vite-dev-server": "^0.17.0",
"@hono/zod-validator": "^0.4.1", "@hono/zod-validator": "^0.4.1",
"@hookform/resolvers": "^3.9.1", "@hookform/resolvers": "^3.9.1",
"@libsql/kysely-libsql": "^0.4.1", "@libsql/kysely-libsql": "^0.4.1",
"@rjsf/core": "^5.22.2", "@rjsf/core": "5.22.2",
"@tabler/icons-react": "3.18.0", "@tabler/icons-react": "3.18.0",
"@types/node": "^22.10.0", "@types/node": "^22.10.0",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",

View File

@@ -1,9 +1,16 @@
import type { AppAuthSchema } from "auth/auth-schema";
import { useBknd } from "ui/client/bknd"; import { useBknd } from "ui/client/bknd";
export function useBkndAuth() { export function useBkndAuth() {
const { config, schema, actions: bkndActions } = useBknd(); const { config, schema, actions: bkndActions } = useBknd();
const actions = { const actions = {
config: {
set: async (data: Partial<AppAuthSchema>) => {
console.log("--set", data);
return await bkndActions.set("auth", data, true);
}
},
roles: { roles: {
add: async (name: string, data: any = {}) => { add: async (name: string, data: any = {}) => {
console.log("add role", name, data); console.log("add role", name, data);

View File

@@ -25,8 +25,10 @@ const Base: React.FC<AlertProps> = ({
className className
)} )}
> >
{title && <b className="mr-2">{title}:</b>} <p>
{message || children} {title && <b>{title}: </b>}
{message || children}
</p>
</div> </div>
) : null; ) : null;

View File

@@ -3,20 +3,16 @@ import type { ChangeEvent, ComponentPropsWithoutRef } from "react";
import * as Formy from "ui/components/form/Formy"; import * as Formy from "ui/components/form/Formy";
import { useEvent } from "ui/hooks/use-event"; import { useEvent } from "ui/hooks/use-event";
import { ArrayField } from "./ArrayField"; import { ArrayField } from "./ArrayField";
import { FieldWrapper } from "./FieldWrapper"; import { FieldWrapper, type FieldwrapperProps } from "./FieldWrapper";
import { useDerivedFieldContext, useFormValue } from "./Form"; import { useDerivedFieldContext, useFormValue } from "./Form";
import { ObjectField } from "./ObjectField"; import { ObjectField } from "./ObjectField";
import { coerce, isType, isTypeSchema } from "./utils"; import { coerce, isType, isTypeSchema } from "./utils";
export type FieldProps = { export type FieldProps = {
name: string;
schema?: JsonSchema;
onChange?: (e: ChangeEvent<any>) => void; onChange?: (e: ChangeEvent<any>) => void;
label?: string | false; } & Omit<FieldwrapperProps, "children">;
hidden?: boolean;
};
export const Field = ({ name, schema: _schema, onChange, label: _label, hidden }: FieldProps) => { export const Field = ({ name, schema: _schema, onChange, ...props }: FieldProps) => {
const { path, setValue, required, ...ctx } = useDerivedFieldContext(name, _schema); const { path, setValue, required, ...ctx } = useDerivedFieldContext(name, _schema);
const schema = _schema ?? ctx.schema; const schema = _schema ?? ctx.schema;
if (!isTypeSchema(schema)) if (!isTypeSchema(schema))
@@ -46,7 +42,7 @@ export const Field = ({ name, schema: _schema, onChange, label: _label, hidden }
}); });
return ( return (
<FieldWrapper name={name} label={_label} required={required} schema={schema} hidden={hidden}> <FieldWrapper name={name} required={required} schema={schema} {...props}>
<FieldComponent <FieldComponent
schema={schema} schema={schema}
name={name} name={name}

View File

@@ -22,6 +22,8 @@ export type FieldwrapperProps = {
hidden?: boolean; hidden?: boolean;
children: ReactElement | ReactNode; children: ReactElement | ReactNode;
errorPlacement?: "top" | "bottom"; errorPlacement?: "top" | "bottom";
description?: string;
descriptionPlacement?: "top" | "bottom";
}; };
export function FieldWrapper({ export function FieldWrapper({
@@ -32,18 +34,26 @@ export function FieldWrapper({
wrapper, wrapper,
hidden, hidden,
errorPlacement = "bottom", errorPlacement = "bottom",
children descriptionPlacement = "bottom",
children,
...props
}: FieldwrapperProps) { }: FieldwrapperProps) {
const errors = useFormError(name, { strict: true }); const errors = useFormError(name, { strict: true });
const examples = schema?.examples || []; const examples = schema?.examples || [];
const examplesId = `${name}-examples`; const examplesId = `${name}-examples`;
const description = schema?.description; const description = props?.description ?? schema?.description;
const label = typeof _label !== "undefined" ? _label : schema ? getLabel(name, schema) : name; const label = typeof _label !== "undefined" ? _label : schema ? getLabel(name, schema) : name;
const Errors = errors.length > 0 && ( const Errors = errors.length > 0 && (
<Formy.ErrorMessage>{errors.map((e) => e.message).join(", ")}</Formy.ErrorMessage> <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>
);
return ( return (
<Formy.Group <Formy.Group
error={errors.length > 0} error={errors.length > 0}
@@ -62,6 +72,7 @@ export function FieldWrapper({
{label} {required && <span className="font-medium opacity-30">*</span>} {label} {required && <span className="font-medium opacity-30">*</span>}
</Formy.Label> </Formy.Label>
)} )}
{descriptionPlacement === "top" && Description}
<div className="flex flex-row gap-2"> <div className="flex flex-row gap-2">
<div className="flex flex-1 flex-col gap-3"> <div className="flex flex-1 flex-col gap-3">
@@ -80,7 +91,7 @@ export function FieldWrapper({
)} )}
</div> </div>
</div> </div>
{description && <Formy.Help>{description}</Formy.Help>} {descriptionPlacement === "bottom" && Description}
{errorPlacement === "bottom" && Errors} {errorPlacement === "bottom" && Errors}
</Formy.Group> </Formy.Group>
); );

View File

@@ -324,9 +324,6 @@ export function useDerivedFieldContext<Data = any, Reduced = undefined>(
const prefixedName = prefixPath(path, root); const prefixedName = prefixPath(path, root);
const prefixedPointer = pathToPointer(prefixedName); const prefixedPointer = pathToPointer(prefixedName);
const value = getPath(state.data, prefixedName); const value = getPath(state.data, prefixedName);
/*const errors = state.errors.filter((error) =>
error.data.pointer.startsWith(prefixedPointer)
);*/
const fieldSchema = const fieldSchema =
pointer === "#/" pointer === "#/"
? (schema as LibJsonSchema) ? (schema as LibJsonSchema)

View File

@@ -53,7 +53,7 @@ export const Modal2 = forwardRef<Modal2Ref, Modal2Props>(
export const ModalTitle = ({ path, onClose }: { path: string[]; onClose: () => void }) => { export const ModalTitle = ({ path, onClose }: { path: string[]; onClose: () => void }) => {
return ( return (
<div className="py-3 px-5 font-bold bg-primary/5 flex flex-row justify-between items-center sticky top-0 left-0 right-0 z-10 border-b border-b-muted"> <div className="py-3 px-5 font-bold bg-lightest flex flex-row justify-between items-center sticky top-0 left-0 right-0 z-10 border-none">
<div className="flex flex-row gap-1"> <div className="flex flex-row gap-1">
{path.map((p, i) => { {path.map((p, i) => {
const last = i + 1 === path.length; const last = i + 1 === path.length;

View File

@@ -29,7 +29,7 @@ export function createMantineTheme(scheme: "light" | "dark"): {
}; };
const input = const input =
"bg-muted/40 border-transparent disabled:bg-muted/50 disabled:text-primary/50 focus:border-zinc-500"; "!bg-muted/40 border-transparent disabled:bg-muted/50 disabled:text-primary/50 focus:border-zinc-500";
return { return {
theme: createTheme({ theme: createTheme({
@@ -81,7 +81,6 @@ export function createMantineTheme(scheme: "light" | "dark"): {
TextInput: TextInput.extend({ TextInput: TextInput.extend({
classNames: (theme, props) => ({ classNames: (theme, props) => ({
wrapper: "leading-none", wrapper: "leading-none",
//input: "focus:border-primary/50 bg-transparent disabled:text-primary"
input input
}) })
}), }),
@@ -100,9 +99,9 @@ export function createMantineTheme(scheme: "light" | "dark"): {
Modal: Modal.extend({ Modal: Modal.extend({
classNames: (theme, props) => ({ classNames: (theme, props) => ({
...props.classNames, ...props.classNames,
root: `bknd-admin ${scheme} ${props.className ?? ""} `, root: `bknd-admin ${scheme} ${props.className ?? ""}`,
content: "bg-lightest border border-primary/10", content: "!bg-background !rounded-lg !select-none",
overlay: "backdrop-blur" overlay: "!backdrop-blur-sm"
}) })
}), }),
Menu: Menu.extend({ Menu: Menu.extend({

View File

@@ -1,5 +1,6 @@
import { type ModalProps, Tabs } from "@mantine/core"; import { type ModalProps, Tabs } from "@mantine/core";
import type { ContextModalProps } from "@mantine/modals"; import type { ContextModalProps } from "@mantine/modals";
import clsx from "clsx";
import { transformObject } from "core/utils"; import { transformObject } from "core/utils";
import type { ComponentProps } from "react"; import type { ComponentProps } from "react";
import { JsonViewer } from "../../components/code/JsonViewer"; import { JsonViewer } from "../../components/code/JsonViewer";
@@ -29,8 +30,8 @@ export function DebugModal({ innerProps }: ContextModalProps<DebugProps>) {
}); });
const count = Object.keys(tabs).length; const count = Object.keys(tabs).length;
function renderTab({ value, label, ...props }: (typeof tabs)[keyof typeof tabs]) { function renderTab({ value, label, className, ...props }: (typeof tabs)[keyof typeof tabs]) {
return <JsonViewer json={value as any} {...props} />; return <JsonViewer json={value as any} className={clsx("text-sm", className)} {...props} />;
} }
return ( return (

View File

@@ -61,7 +61,7 @@ export function SchemaFormModal({
return ( return (
<> <>
{error && <Alert.Exception message={error} />} {error && <Alert.Exception message={error} />}
<div className="pt-3 pb-3 px-3 gap-4 flex flex-col"> <div className="pt-3 pb-3 px-4 gap-4 flex flex-col">
<JsonSchemaForm <JsonSchemaForm
tagName="form" tagName="form"
ref={formRef} ref={formRef}
@@ -84,10 +84,10 @@ export function SchemaFormModal({
SchemaFormModal.defaultTitle = "JSON Schema Form Modal"; SchemaFormModal.defaultTitle = "JSON Schema Form Modal";
SchemaFormModal.modalProps = { SchemaFormModal.modalProps = {
size: "md",
classNames: { classNames: {
size: "md",
root: "bknd-admin", root: "bknd-admin",
header: "!bg-primary/5 border-b border-b-muted !py-3 px-5 !h-auto !min-h-px", header: "!bg-lightest !py-3 px-5 !h-auto !min-h-px",
content: "rounded-lg select-none", content: "rounded-lg select-none",
title: "!font-bold !text-md", title: "!font-bold !text-md",
body: "!p-0" body: "!p-0"

View File

@@ -47,8 +47,7 @@ export function AuthRoot({ children }) {
<AppShell.SidebarLink as={Link} href={routes.auth.strategies()} disabled> <AppShell.SidebarLink as={Link} href={routes.auth.strategies()} disabled>
Strategies Strategies
</AppShell.SidebarLink> </AppShell.SidebarLink>
{/*<AppShell.SidebarLink as={Link} href={routes.auth.settings()}>*/} <AppShell.SidebarLink as={Link} href={routes.auth.settings()}>
<AppShell.SidebarLink as={Link} href={app.getSettingsPath(["auth"])}>
Settings Settings
</AppShell.SidebarLink> </AppShell.SidebarLink>
</nav> </nav>

View File

@@ -1,111 +1,198 @@
import { cloneDeep, omit } from "lodash-es"; import clsx from "clsx";
import { useEffect, useRef } from "react"; import { TbChevronDown, TbChevronUp } from "react-icons/tb";
import { useBknd } from "ui/client/bknd"; import { useBknd } from "ui/client/BkndProvider";
import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth"; import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
import { Button } from "ui/components/buttons/Button"; import { Button } from "ui/components/buttons/Button";
import { Alert } from "ui/components/display/Alert"; import { Message } from "ui/components/display/Message";
import { JsonSchemaForm, type JsonSchemaFormRef } from "ui/components/form/json-schema"; import { Field, type FieldProps, Form, Subscribe } from "ui/components/form/json-schema-form";
import { useBrowserTitle } from "ui/hooks/use-browser-title";
import * as AppShell from "ui/layouts/AppShell/AppShell"; import * as AppShell from "ui/layouts/AppShell/AppShell";
import { useNavigate } from "ui/lib/routes"; import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2";
import { extractSchema } from "../settings/utils/schema"; import { create } from "zustand";
import { combine } from "zustand/middleware";
// @todo: improve the inline editing expierence, for now redirect to settings const useAuthSettingsStore = create(
export function AuthSettingsList() { combine(
const { app } = useBknd(); {
const [navigate] = useNavigate(); advanced: [] as string[]
useEffect(() => { },
navigate(app.getSettingsPath(["auth"])); (set) => ({
}, []); toggleAdvanced: (which: string) =>
set((state) => ({
advanced: state.advanced.includes(which)
? state.advanced.filter((w) => w !== which)
: [...state.advanced, which]
}))
})
)
);
return null; export function AuthSettings(props) {
useBrowserTitle(["Auth", "Settings"]);
/*useBknd({ withSecrets: true }); const { hasSecrets } = useBknd({ withSecrets: true });
return <AuthSettingsListInternal />;*/ if (!hasSecrets) {
return <Message.MissingPermission what="Auth Settings" />;
}
return <AuthSettingsInternal {...props} />;
} }
const uiSchema = { const formConfig = {
jwt: { ignoreKeys: ["roles", "strategies"],
fields: { options: { keepEmpty: true }
"ui:options": {
orderable: false
}
}
}
}; };
function AuthSettingsListInternal() { function AuthSettingsInternal() {
const $auth = useBkndAuth(); const { config, schema: _schema, actions } = useBkndAuth();
const { entities } = useBkndData(); const schema = JSON.parse(JSON.stringify(_schema));
const formRef = useRef<JsonSchemaFormRef>(null);
const config = $auth.config;
const schema = cloneDeep(omit($auth.schema, ["title"]));
const [generalSchema, generalConfig, extracted] = extractSchema(schema as any, config, [
"jwt",
"roles",
"guard",
"strategies"
]);
try {
const user_entity = config.entity_name ?? "users";
const user_fields = Object.entries(entities[user_entity]?.fields ?? {})
.map(([name, field]) => (!field.config?.virtual ? name : undefined))
.filter(Boolean);
if (user_fields) { schema.properties.jwt.required = ["alg"];
console.log("user_fields", user_fields);
extracted.jwt.schema.properties.fields.items.enum = user_fields;
extracted.jwt.schema.properties.fields.uniqueItems = true;
uiSchema.jwt.fields["ui:widget"] = "checkboxes";
} else {
uiSchema.jwt.fields["ui:widget"] = "hidden";
}
} catch (e) {
console.error(e);
}
async function handleSubmit() { async function onSubmit(data: any) {
console.log(formRef.current?.validateForm(), formRef.current?.formData()); await actions.config.set(data);
} }
return ( return (
<> <Form schema={schema} initialValues={config as any} onSubmit={onSubmit} {...formConfig}>
<AppShell.SectionHeader <Subscribe
right={ selector={(state) => ({
<Button variant="primary" onClick={handleSubmit}> dirty: state.dirty,
Update errors: state.errors.length > 0,
</Button> submitting: state.submitting
} })}
> >
Settings {({ dirty, errors, submitting }) => (
</AppShell.SectionHeader> <AppShell.SectionHeader
className="pl-3"
right={
<Button
variant="primary"
type="submit"
disabled={!dirty || errors || submitting}
>
Update
</Button>
}
>
<div className="flex flex-row gap-4 items-center">
<Breadcrumbs2
path={[{ label: "Auth", href: "/" }, { label: "Settings" }]}
backTo="/"
/>
</div>
</AppShell.SectionHeader>
)}
</Subscribe>
<AppShell.Scrollable> <AppShell.Scrollable>
<Alert.Warning <Section className="pt-4">
visible={!config.enabled} <AuthField
title="Auth not enabled" name="enabled"
message="Enable it by toggling the switch below. Please also make sure set a secure secret to sign JWT tokens." label="Authentication Enabled"
/> description="Only after enabling authentication, all settings below will take effect."
<div className="flex flex-col flex-grow px-5 py-4 gap-8"> descriptionPlacement="top"
<div> />
<JsonSchemaForm <div className="flex flex-col gap-6 relative">
schema={generalSchema} <Overlay />
className="legacy hide-required-mark fieldset-alternative mute-root" <AuthField
name="guard.enabled"
label="Guard Enabled"
description="When enabled, enforces permissions on all routes. Make sure to create roles first."
descriptionPlacement="top"
/>
<AuthField
name="allow_register"
label="Allow User Registration"
description="When enabled, allows users to register autonomously. New users use the default role."
descriptionPlacement="top"
/> />
</div> </div>
</Section>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3 relative mt-3 pb-4">
<h3 className="font-bold">JWT Settings</h3> <Overlay />
<JsonSchemaForm <AppShell.Separator />
ref={formRef} <Section title="JWT">
schema={extracted.jwt.schema} <AuthField name="jwt.issuer" />
uiSchema={uiSchema.jwt} <AuthField
className="legacy hide-required-mark fieldset-alternative mute-root" name="jwt.secret"
description="The secret used to sign the JWT token. If not set, a random key will be generated after enabling authentication."
advanced="jwt"
/> />
</div> <AuthField name="jwt.alg" advanced="jwt" />
<AuthField name="jwt.expires" advanced="jwt" />
<ToggleAdvanced which="jwt" />
</Section>
<AppShell.Separator />
<Section title="Cookie">
<AuthField name="cookie.path" advanced="cookie" />
<AuthField name="cookie.sameSite" advanced="cookie" />
<AuthField name="cookie.secure" advanced="cookie" />
<AuthField name="cookie.expires" advanced="cookie" />
<AuthField
name="cookie.renew"
label="Renew Cookie"
description="Automatically renew users cookie on every request."
descriptionPlacement="top"
/>
<AuthField name="cookie.pathSuccess" advanced="cookie" />
<AuthField name="cookie.pathLoggedOut" />
<ToggleAdvanced which="cookie" />
</Section>
</div> </div>
{/* <FormDebug /> */}
</AppShell.Scrollable> </AppShell.Scrollable>
</Form>
);
}
const ToggleAdvanced = ({ which }: { which: string }) => {
const { advanced, toggleAdvanced } = useAuthSettingsStore();
const show = advanced.includes(which);
return (
<Button
IconLeft={show ? TbChevronUp : TbChevronDown}
onClick={() => toggleAdvanced(which)}
variant={show ? "default" : "ghost"}
className="self-start"
size="small"
>
{show ? "Hide advanced settings" : "Show advanced settings"}
</Button>
);
};
//const Overlay = () => null;
const Overlay = () => (
<Subscribe selector={(state) => ({ enabled: state.data.enabled })}>
{({ enabled }) =>
!enabled && (
<div className="absolute w-full h-full z-50 inset-0 bg-background opacity-90" />
)
}
</Subscribe>
);
function Section(props: {
children: React.ReactNode;
className?: string;
title?: string;
first?: boolean;
}) {
const { children, title, className } = props;
return (
<>
<div className={clsx("flex flex-col gap-6 px-4", title && "pt-0", className)}>
{title && <h3 className="text-lg font-bold">{title}</h3>}
{children}
</div>
</> </>
); );
} }
function AuthJwtSettings() {} function AuthField(props: FieldProps & { advanced?: string }) {
const { advanced, ...rest } = props;
const showAdvanced = useAuthSettingsStore((state) => state.advanced);
if (advanced && !showAdvanced.includes(advanced)) return null;
return <Field {...rest} />;
}

View File

@@ -3,7 +3,7 @@ import { AuthRoot } from "./_auth.root";
import { AuthIndex } from "./auth.index"; import { AuthIndex } from "./auth.index";
import { AuthRolesList } from "./auth.roles"; import { AuthRolesList } from "./auth.roles";
import { AuthRolesEdit } from "./auth.roles.edit.$role"; import { AuthRolesEdit } from "./auth.roles.edit.$role";
import { AuthSettingsList } from "./auth.settings"; import { AuthSettings } from "./auth.settings";
import { AuthStrategiesList } from "./auth.strategies"; import { AuthStrategiesList } from "./auth.strategies";
import { AuthUsersList } from "./auth.users"; import { AuthUsersList } from "./auth.users";
@@ -15,7 +15,7 @@ export default function AuthRoutes() {
<Route path="/roles" component={AuthRolesList} /> <Route path="/roles" component={AuthRolesList} />
<Route path="/roles/edit/:role" component={AuthRolesEdit} /> <Route path="/roles/edit/:role" component={AuthRolesEdit} />
<Route path="/strategies" component={AuthStrategiesList} /> <Route path="/strategies" component={AuthStrategiesList} />
<Route path="/settings" component={AuthSettingsList} /> <Route path="/settings" component={AuthSettings} />
</AuthRoot> </AuthRoot>
); );
} }

View File

@@ -18,7 +18,6 @@ import {
Subscribe, Subscribe,
useFormError useFormError
} from "ui/components/form/json-schema-form"; } from "ui/components/form/json-schema-form";
import { Media } from "ui/elements";
import { useBrowserTitle } from "ui/hooks/use-browser-title"; import { useBrowserTitle } from "ui/hooks/use-browser-title";
import * as AppShell from "ui/layouts/AppShell/AppShell"; import * as AppShell from "ui/layouts/AppShell/AppShell";
@@ -87,7 +86,7 @@ function MediaSettingsInternal() {
</div> </div>
</div> </div>
<AppShell.Separator /> <AppShell.Separator />
<div className="flex flex-col gap-3 p-3"> <div className="flex flex-col gap-3 p-3 relative">
<Overlay /> <Overlay />
<AnyOf.Root path="adapter"> <AnyOf.Root path="adapter">
<Adapters /> <Adapters />
@@ -177,7 +176,7 @@ const Overlay = () => (
<Subscribe selector={(state) => ({ enabled: state.data.enabled })}> <Subscribe selector={(state) => ({ enabled: state.data.enabled })}>
{({ enabled }) => {({ enabled }) =>
!enabled && ( !enabled && (
<div className="absolute w-full h-full z-50 bg-background opacity-70 pointer-events-none" /> <div className="absolute w-full h-full z-50 inset-0 bg-background opacity-90" />
) )
} }
</Subscribe> </Subscribe>