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

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

View File

@@ -1,111 +1,198 @@
import { cloneDeep, omit } from "lodash-es";
import { useEffect, useRef } from "react";
import { useBknd } from "ui/client/bknd";
import clsx from "clsx";
import { TbChevronDown, TbChevronUp } from "react-icons/tb";
import { useBknd } from "ui/client/BkndProvider";
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 { Alert } from "ui/components/display/Alert";
import { JsonSchemaForm, type JsonSchemaFormRef } from "ui/components/form/json-schema";
import { Message } from "ui/components/display/Message";
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 { useNavigate } from "ui/lib/routes";
import { extractSchema } from "../settings/utils/schema";
import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2";
import { create } from "zustand";
import { combine } from "zustand/middleware";
// @todo: improve the inline editing expierence, for now redirect to settings
export function AuthSettingsList() {
const { app } = useBknd();
const [navigate] = useNavigate();
useEffect(() => {
navigate(app.getSettingsPath(["auth"]));
}, []);
const useAuthSettingsStore = create(
combine(
{
advanced: [] as string[]
},
(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 });
return <AuthSettingsListInternal />;*/
const { hasSecrets } = useBknd({ withSecrets: true });
if (!hasSecrets) {
return <Message.MissingPermission what="Auth Settings" />;
}
return <AuthSettingsInternal {...props} />;
}
const uiSchema = {
jwt: {
fields: {
"ui:options": {
orderable: false
}
}
}
const formConfig = {
ignoreKeys: ["roles", "strategies"],
options: { keepEmpty: true }
};
function AuthSettingsListInternal() {
const $auth = useBkndAuth();
const { entities } = useBkndData();
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);
function AuthSettingsInternal() {
const { config, schema: _schema, actions } = useBkndAuth();
const schema = JSON.parse(JSON.stringify(_schema));
if (user_fields) {
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);
}
schema.properties.jwt.required = ["alg"];
async function handleSubmit() {
console.log(formRef.current?.validateForm(), formRef.current?.formData());
async function onSubmit(data: any) {
await actions.config.set(data);
}
return (
<>
<AppShell.SectionHeader
right={
<Button variant="primary" onClick={handleSubmit}>
Update
</Button>
}
<Form schema={schema} initialValues={config as any} onSubmit={onSubmit} {...formConfig}>
<Subscribe
selector={(state) => ({
dirty: state.dirty,
errors: state.errors.length > 0,
submitting: state.submitting
})}
>
Settings
</AppShell.SectionHeader>
{({ dirty, errors, submitting }) => (
<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>
<Alert.Warning
visible={!config.enabled}
title="Auth not enabled"
message="Enable it by toggling the switch below. Please also make sure set a secure secret to sign JWT tokens."
/>
<div className="flex flex-col flex-grow px-5 py-4 gap-8">
<div>
<JsonSchemaForm
schema={generalSchema}
className="legacy hide-required-mark fieldset-alternative mute-root"
<Section className="pt-4">
<AuthField
name="enabled"
label="Authentication Enabled"
description="Only after enabling authentication, all settings below will take effect."
descriptionPlacement="top"
/>
<div className="flex flex-col gap-6 relative">
<Overlay />
<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 className="flex flex-col gap-3">
<h3 className="font-bold">JWT Settings</h3>
<JsonSchemaForm
ref={formRef}
schema={extracted.jwt.schema}
uiSchema={uiSchema.jwt}
className="legacy hide-required-mark fieldset-alternative mute-root"
</Section>
<div className="flex flex-col gap-3 relative mt-3 pb-4">
<Overlay />
<AppShell.Separator />
<Section title="JWT">
<AuthField name="jwt.issuer" />
<AuthField
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>
{/* <FormDebug /> */}
</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 { AuthRolesList } from "./auth.roles";
import { AuthRolesEdit } from "./auth.roles.edit.$role";
import { AuthSettingsList } from "./auth.settings";
import { AuthSettings } from "./auth.settings";
import { AuthStrategiesList } from "./auth.strategies";
import { AuthUsersList } from "./auth.users";
@@ -15,7 +15,7 @@ export default function AuthRoutes() {
<Route path="/roles" component={AuthRolesList} />
<Route path="/roles/edit/:role" component={AuthRolesEdit} />
<Route path="/strategies" component={AuthStrategiesList} />
<Route path="/settings" component={AuthSettingsList} />
<Route path="/settings" component={AuthSettings} />
</AuthRoot>
);
}

View File

@@ -18,7 +18,6 @@ import {
Subscribe,
useFormError
} from "ui/components/form/json-schema-form";
import { Media } from "ui/elements";
import { useBrowserTitle } from "ui/hooks/use-browser-title";
import * as AppShell from "ui/layouts/AppShell/AppShell";
@@ -87,7 +86,7 @@ function MediaSettingsInternal() {
</div>
</div>
<AppShell.Separator />
<div className="flex flex-col gap-3 p-3">
<div className="flex flex-col gap-3 p-3 relative">
<Overlay />
<AnyOf.Root path="adapter">
<Adapters />
@@ -177,7 +176,7 @@ const Overlay = () => (
<Subscribe selector={(state) => ({ enabled: state.data.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>