Merge pull request #98 from bknd-io/feat/auth-ui-improvements

feat/auth-ui-improvements
This commit is contained in:
dswbx
2025-02-26 19:52:39 +01:00
committed by GitHub
31 changed files with 824 additions and 269 deletions

View File

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

View File

@@ -56,7 +56,6 @@ export class AppAuth extends Module<typeof authConfigSchema> {
// register roles
const roles = transformObject(this.config.roles ?? {}, (role, name) => {
//console.log("role", role, name);
return Role.create({ name, ...role });
});
this.ctx.guard.setRoles(Object.values(roles));
@@ -88,6 +87,11 @@ export class AppAuth extends Module<typeof authConfigSchema> {
this.ctx.guard.registerPermissions(Object.values(AuthPermissions));
}
isStrategyEnabled(strategy: Strategy | string) {
const name = typeof strategy === "string" ? strategy : strategy.getName();
return this.config.strategies?.[name]?.enabled ?? false;
}
get controller(): AuthController {
if (!this.isBuilt()) {
throw new Error("Can't access controller, AppAuth not built yet");
@@ -115,12 +119,6 @@ export class AppAuth extends Module<typeof authConfigSchema> {
identifier: string,
profile: ProfileExchange
): Promise<any> {
/*console.log("***** AppAuth:resolveUser", {
action,
strategy: strategy.getName(),
identifier,
profile
});*/
if (!this.config.allow_register && action === "register") {
throw new Exception("Registration is not allowed", 403);
}
@@ -141,21 +139,10 @@ export class AppAuth extends Module<typeof authConfigSchema> {
}
private filterUserData(user: any) {
/*console.log(
"--filterUserData",
user,
this.config.jwt.fields,
pick(user, this.config.jwt.fields)
);*/
return pick(user, this.config.jwt.fields);
}
private async login(strategy: Strategy, identifier: string, profile: ProfileExchange) {
/*console.log("--- trying to login", {
strategy: strategy.getName(),
identifier,
profile
});*/
if (!("email" in profile)) {
throw new Exception("Profile must have email");
}
@@ -172,18 +159,14 @@ export class AppAuth extends Module<typeof authConfigSchema> {
if (!result.data) {
throw new Exception("User not found", 404);
}
//console.log("---login data", result.data, result);
// compare strategy and identifier
//console.log("strategy comparison", result.data.strategy, strategy.getName());
if (result.data.strategy !== strategy.getName()) {
//console.log("!!! User registered with different strategy");
throw new Exception("User registered with different strategy");
}
//console.log("identifier comparison", result.data.strategy_value, identifier);
if (result.data.strategy_value !== identifier) {
//console.log("!!! Invalid credentials");
throw new Exception("Invalid credentials");
}
@@ -285,6 +268,7 @@ export class AppAuth extends Module<typeof authConfigSchema> {
} catch (e) {}
try {
// also keep disabled strategies as a choice
const strategies = Object.keys(this.config.strategies ?? {});
this.replaceEntityField(users, "strategy", enumm({ enum: strategies }));
} catch (e) {}
@@ -315,9 +299,16 @@ export class AppAuth extends Module<typeof authConfigSchema> {
return this.configDefault;
}
const strategies = this.authenticator.getStrategies();
return {
...this.config,
...this.authenticator.toJSON(secrets)
...this.authenticator.toJSON(secrets),
strategies: transformObject(strategies, (strategy) => ({
enabled: this.isStrategyEnabled(strategy),
type: strategy.getType(),
config: strategy.toJSON(secrets)
}))
};
}
}

View File

@@ -1,5 +1,6 @@
import { type AppAuth, AuthPermissions, type SafeUser, type Strategy } from "auth";
import { TypeInvalidError, parse } from "core/utils";
import { tbValidator as tb } from "core";
import { Type, TypeInvalidError, parse, transformObject } from "core/utils";
import { DataPermissions } from "data";
import type { Hono } from "hono";
import { Controller } from "modules/Controller";
@@ -12,6 +13,10 @@ export type AuthActionResponse = {
errors?: any;
};
const booleanLike = Type.Transform(Type.String())
.Decode((v) => v === "1")
.Encode((v) => (v ? "1" : "0"));
export class AuthController extends Controller {
constructor(private auth: AppAuth) {
super();
@@ -31,6 +36,9 @@ export class AuthController extends Controller {
}
private registerStrategyActions(strategy: Strategy, mainHono: Hono<ServerEnv>) {
if (!this.auth.isStrategyEnabled(strategy)) {
return;
}
const actions = strategy.getActions?.();
if (!actions) {
return;
@@ -98,7 +106,8 @@ export class AuthController extends Controller {
const strategies = this.auth.authenticator.getStrategies();
for (const [name, strategy] of Object.entries(strategies)) {
//console.log("registering", name, "at", `/${name}`);
if (!this.auth.isStrategyEnabled(strategy)) continue;
hono.route(`/${name}`, strategy.getController(this.auth.authenticator));
this.registerStrategyActions(strategy, hono);
}
@@ -127,10 +136,25 @@ export class AuthController extends Controller {
return c.redirect("/");
});
hono.get("/strategies", async (c) => {
const { strategies, basepath } = this.auth.toJSON(false);
return c.json({ strategies, basepath });
});
hono.get(
"/strategies",
tb("query", Type.Object({ include_disabled: Type.Optional(booleanLike) })),
async (c) => {
const { include_disabled } = c.req.valid("query");
const { strategies, basepath } = this.auth.toJSON(false);
if (!include_disabled) {
return c.json({
strategies: transformObject(strategies ?? {}, (strategy, name) => {
return this.auth.isStrategyEnabled(name) ? strategy : undefined;
}),
basepath
});
}
return c.json({ strategies, basepath });
}
);
return hono.all("*", (c) => c.notFound());
}

View File

@@ -21,6 +21,7 @@ export const STRATEGIES = Strategies;
const strategiesSchemaObject = objectTransform(STRATEGIES, (strategy, name) => {
return Type.Object(
{
enabled: Type.Optional(Type.Boolean({ default: true })),
type: Type.Const(name, { default: name, readOnly: true }),
config: strategy.schema
},
@@ -61,6 +62,7 @@ export const authConfigSchema = Type.Object(
default: {
password: {
type: "password",
enabled: true,
config: {
hashing: "sha256"
}

View File

@@ -342,8 +342,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
toJSON(secrets?: boolean) {
return {
...this.config,
jwt: secrets ? this.config.jwt : undefined,
strategies: transformObject(this.getStrategies(), (s) => s.toJSON(secrets))
jwt: secrets ? this.config.jwt : undefined
};
}
}

View File

@@ -147,9 +147,6 @@ export class PasswordStrategy implements Strategy {
}
toJSON(secrets?: boolean) {
return {
type: this.getType(),
config: secrets ? this.options : undefined
};
return secrets ? this.options : undefined;
}
}

View File

@@ -476,11 +476,8 @@ export class OAuthStrategy implements Strategy {
const config = secrets ? this.config : filterKeys(this.config, ["secret", "client_id"]);
return {
type: this.getType(),
config: {
type: this.getIssuerConfig().type,
...config
}
type: this.getIssuerConfig().type,
...config
};
}
}

View File

@@ -1,9 +1,21 @@
import type { AppAuthSchema } from "auth/auth-schema";
import { useBknd } from "ui/client/bknd";
import { routes } from "ui/lib/routes";
export function useBkndAuth() {
const { config, schema, actions: bkndActions } = useBknd();
const { config, schema, actions: bkndActions, app } = useBknd();
const actions = {
config: {
set: async (data: Partial<AppAuthSchema>) => {
console.log("--set", data);
if (await bkndActions.set("auth", data, true)) {
await bkndActions.reload();
return true;
}
return false;
}
},
roles: {
add: async (name: string, data: any = {}) => {
console.log("add role", name, data);
@@ -22,7 +34,29 @@ export function useBkndAuth() {
}
}
};
const $auth = {};
const minimum_permissions = [
"system.access.admin",
"system.access.api",
"system.config.read",
"system.config.read.secrets",
"system.build"
];
const $auth = {
roles: {
none: Object.keys(config.auth.roles ?? {}).length === 0,
minimum_permissions,
has_admin: Object.entries(config.auth.roles ?? {}).some(
([name, role]) =>
role.implicit_allow ||
minimum_permissions.every((p) => role.permissions?.includes(p))
)
},
routes: {
settings: app.getSettingsPath(["auth"]),
listUsers: app.getAbsolutePath("/data/" + routes.data.entity.list(config.auth.entity_name))
}
};
return {
$auth,

View File

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

View File

@@ -0,0 +1,53 @@
import React, { Component, type ErrorInfo, type ReactNode } from "react";
interface ErrorBoundaryProps {
children: ReactNode;
fallback?:
| (({ error, resetError }: { error: Error; resetError: () => void }) => ReactNode)
| ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
error?: Error | undefined;
}
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: undefined };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
override componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("ErrorBoundary caught an error:", error, errorInfo);
}
resetError = () => {
this.setState({ hasError: false, error: undefined });
};
override render() {
if (this.state.hasError) {
return this.props.fallback ? (
typeof this.props.fallback === "function" ? (
this.props.fallback({ error: this.state.error!, resetError: this.resetError })
) : (
this.props.fallback
)
) : (
<div>
<h2>Something went wrong.</h2>
<button onClick={this.resetError}>Try Again</button>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;

View File

@@ -0,0 +1,18 @@
import { TbAlertCircle } from "react-icons/tb";
import { twMerge } from "tailwind-merge";
export type IconProps = {
className?: string;
title?: string;
};
const Warning = ({ className, ...props }: IconProps) => (
<TbAlertCircle
{...props}
className={twMerge("dark:text-amber-300 text-amber-700 cursor-help", className)}
/>
);
export const Icon = {
Warning
};

View File

@@ -1,3 +1,4 @@
import clsx from "clsx";
import { getBrowser } from "core/utils";
import type { Field } from "data";
import { Switch as RadixSwitch } from "radix-ui";
@@ -177,6 +178,21 @@ export const BooleanInput = forwardRef<HTMLInputElement, React.ComponentProps<"i
);
export type SwitchValue = boolean | 1 | 0 | "true" | "false" | "on" | "off";
const SwitchSizes = {
xs: {
root: "h-5 w-8",
thumb: "data-[state=checked]:left-[calc(100%-1rem)]"
},
sm: {
root: "h-6 w-10",
thumb: "data-[state=checked]:left-[calc(100%-1.25rem)]"
},
md: {
root: "h-7 w-12",
thumb: "data-[state=checked]:left-[calc(100%-1.5rem)]"
}
};
export const Switch = forwardRef<
HTMLButtonElement,
Pick<
@@ -184,14 +200,20 @@ export const Switch = forwardRef<
"name" | "required" | "disabled" | "checked" | "defaultChecked" | "id" | "type"
> & {
value?: SwitchValue;
size?: keyof typeof SwitchSizes;
onChange?: (e: { target: { value: boolean } }) => void;
onCheckedChange?: (checked: boolean) => void;
}
>(({ type, required, ...props }, ref) => {
return (
<RadixSwitch.Root
className="relative h-7 w-12 cursor-pointer rounded-full bg-muted border-2 border-transparent outline-none data-[state=checked]:bg-primary/75 appearance-none transition-colors hover:bg-muted/80"
className={clsx(
"relative cursor-pointer rounded-full bg-muted border-2 border-transparent outline-none ring-1 dark:ring-primary/10 ring-primary/20 data-[state=checked]:ring-primary/60 data-[state=checked]:bg-primary/60 appearance-none transition-colors hover:bg-muted/80",
SwitchSizes[props.size ?? "md"].root,
props.disabled && "opacity-50 !cursor-not-allowed"
)}
onCheckedChange={(bool) => {
console.log("setting", bool);
props.onChange?.({ target: { value: bool } });
}}
{...(props as any)}
@@ -204,7 +226,12 @@ export const Switch = forwardRef<
}
ref={ref}
>
<RadixSwitch.Thumb className="absolute top-0 left-0 h-full aspect-square rounded-full bg-background transition-[left,right] duration-100 border border-muted data-[state=checked]:left-[calc(100%-1.5rem)]" />
<RadixSwitch.Thumb
className={clsx(
"absolute top-0 left-0 h-full aspect-square rounded-full bg-primary/30 data-[state=checked]:bg-background transition-[left,right] duration-100 border border-muted",
SwitchSizes[props.size ?? "md"].thumb
)}
/>
</RadixSwitch.Root>
);
});

View File

@@ -10,7 +10,6 @@ import { getLabel, getMultiSchemaMatched } from "./utils";
export type AnyOfFieldRootProps = {
path?: string;
schema?: JsonSchema;
children: ReactNode;
};
@@ -34,14 +33,14 @@ export const useAnyOfContext = () => {
const selectedAtom = atom<number | null>(null);
const Root = ({ path = "", schema: _schema, children }: AnyOfFieldRootProps) => {
const Root = ({ path = "", children }: AnyOfFieldRootProps) => {
const {
setValue,
lib,
pointer,
value: { matchedIndex, schemas },
schema
} = useDerivedFieldContext(path, _schema, (ctx) => {
} = useDerivedFieldContext(path, (ctx) => {
const [matchedIndex, schemas = []] = getMultiSchemaMatched(ctx.schema, ctx.value);
return { matchedIndex, schemas };
});
@@ -115,7 +114,7 @@ const Select = () => {
};
// @todo: add local validation for AnyOf fields
const Field = ({ name, label, schema, ...props }: Partial<FormFieldProps>) => {
const Field = ({ name, label, ...props }: Partial<FormFieldProps>) => {
const { selected, selectedSchema, path, errors } = useAnyOfContext();
if (selected === null) return null;
return (

View File

@@ -10,12 +10,8 @@ import { FieldWrapper } from "./FieldWrapper";
import { useDerivedFieldContext, useFormValue } from "./Form";
import { coerce, getMultiSchema, getMultiSchemaMatched, isEqual, suffixPath } from "./utils";
export const ArrayField = ({
path = "",
schema: _schema
}: { path?: string; schema?: JsonSchema }) => {
const { setValue, pointer, required, ...ctx } = useDerivedFieldContext(path, _schema);
const schema = _schema ?? ctx.schema;
export const ArrayField = ({ path = "" }: { path?: string }) => {
const { setValue, pointer, required, schema, ...ctx } = useDerivedFieldContext(path);
if (!schema || typeof schema === "undefined") return `ArrayField(${path}): no schema ${pointer}`;
// if unique items with enum
@@ -55,7 +51,7 @@ export const ArrayField = ({
};
const ArrayItem = memo(({ path, index, schema }: any) => {
const { value, ...ctx } = useDerivedFieldContext(path, schema, (ctx) => {
const { value, ...ctx } = useDerivedFieldContext(path, (ctx) => {
return ctx.value?.[index];
});
const itemPath = suffixPath(path, index);
@@ -107,7 +103,7 @@ const ArrayAdd = ({ schema, path }: { schema: JsonSchema; path: string }) => {
setValue,
value: { currentIndex },
...ctx
} = useDerivedFieldContext(path, schema, (ctx) => {
} = useDerivedFieldContext(path, (ctx) => {
return { currentIndex: ctx.value?.length ?? 0 };
});
const itemsMultiSchema = getMultiSchema(schema.items);

View File

@@ -1,24 +1,40 @@
import type { JsonSchema } from "json-schema-library";
import type { ChangeEvent, ComponentPropsWithoutRef } from "react";
import ErrorBoundary from "ui/components/display/ErrorBoundary";
import * as Formy from "ui/components/form/Formy";
import { useEvent } from "ui/hooks/use-event";
import { ArrayField } from "./ArrayField";
import { FieldWrapper } from "./FieldWrapper";
import { FieldWrapper, type FieldwrapperProps } from "./FieldWrapper";
import { useDerivedFieldContext, useFormValue } from "./Form";
import { ObjectField } from "./ObjectField";
import { coerce, isType, isTypeSchema } from "./utils";
export type FieldProps = {
name: string;
schema?: JsonSchema;
onChange?: (e: ChangeEvent<any>) => void;
label?: string | false;
hidden?: boolean;
placeholder?: string;
disabled?: boolean;
} & Omit<FieldwrapperProps, "children" | "schema">;
export const Field = (props: FieldProps) => {
return (
<ErrorBoundary fallback={fieldErrorBoundary(props)}>
<FieldImpl {...props} />
</ErrorBoundary>
);
};
export const Field = ({ name, schema: _schema, onChange, label: _label, hidden }: FieldProps) => {
const { path, setValue, required, ...ctx } = useDerivedFieldContext(name, _schema);
const schema = _schema ?? ctx.schema;
const fieldErrorBoundary =
({ name }: FieldProps) =>
({ error }: { error: Error }) => (
<Pre>
Field "{name}" error: {error.message}
</Pre>
);
const FieldImpl = ({ name, onChange, placeholder, required: _required, ...props }: FieldProps) => {
const { path, setValue, schema, ...ctx } = useDerivedFieldContext(name);
const required = typeof _required === "boolean" ? _required : ctx.required;
//console.log("Field", { name, path, schema });
if (!isTypeSchema(schema))
return (
<Pre>
@@ -27,14 +43,14 @@ export const Field = ({ name, schema: _schema, onChange, label: _label, hidden }
);
if (isType(schema.type, "object")) {
return <ObjectField path={name} schema={schema} />;
return <ObjectField path={name} />;
}
if (isType(schema.type, "array")) {
return <ArrayField path={name} schema={schema} />;
return <ArrayField path={name} />;
}
const disabled = schema.readOnly ?? "const" in schema ?? false;
const disabled = props.disabled ?? schema.readOnly ?? "const" in schema ?? false;
const handleChange = useEvent((e: ChangeEvent<HTMLInputElement>) => {
const value = coerce(e.target.value, schema as any, { required });
@@ -46,12 +62,13 @@ export const Field = ({ name, schema: _schema, onChange, label: _label, hidden }
});
return (
<FieldWrapper name={name} label={_label} required={required} schema={schema} hidden={hidden}>
<FieldWrapper name={name} required={required} schema={schema} {...props}>
<FieldComponent
schema={schema}
name={name}
required={required}
disabled={disabled}
placeholder={placeholder}
onChange={onChange ?? handleChange}
/>
</FieldWrapper>
@@ -73,7 +90,9 @@ export const FieldComponent = ({
const props = {
..._props,
// allow override
value: typeof _props.value !== "undefined" ? _props.value : value
value: typeof _props.value !== "undefined" ? _props.value : value,
placeholder:
(_props.placeholder ?? typeof schema.default !== "undefined") ? String(schema.default) : ""
};
if (schema.enum) {

View File

@@ -14,7 +14,7 @@ import { getLabel } from "./utils";
export type FieldwrapperProps = {
name: string;
label?: string | false;
label?: string | ReactNode | false;
required?: boolean;
schema?: JsonSchema;
debug?: object | boolean;
@@ -22,6 +22,8 @@ export type FieldwrapperProps = {
hidden?: boolean;
children: ReactElement | ReactNode;
errorPlacement?: "top" | "bottom";
description?: string;
descriptionPlacement?: "top" | "bottom";
};
export function FieldWrapper({
@@ -32,18 +34,26 @@ export function FieldWrapper({
wrapper,
hidden,
errorPlacement = "bottom",
children
descriptionPlacement = "bottom",
children,
...props
}: FieldwrapperProps) {
const errors = useFormError(name, { strict: true });
const examples = schema?.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 Errors = errors.length > 0 && (
<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 (
<Formy.Group
error={errors.length > 0}
@@ -62,6 +72,7 @@ export function FieldWrapper({
{label} {required && <span className="font-medium opacity-30">*</span>}
</Formy.Label>
)}
{descriptionPlacement === "top" && Description}
<div className="flex flex-row gap-2">
<div className="flex flex-1 flex-col gap-3">
@@ -80,7 +91,7 @@ export function FieldWrapper({
)}
</div>
</div>
{description && <Formy.Help>{description}</Formy.Help>}
{descriptionPlacement === "bottom" && Description}
{errorPlacement === "bottom" && Errors}
</Formy.Group>
);

View File

@@ -35,7 +35,7 @@ import {
prefixPointer
} from "./utils";
type JSONSchema = Exclude<$JSONSchema, boolean>;
export type JSONSchema = Exclude<$JSONSchema, boolean>;
type FormState<Data = any> = {
dirty: boolean;
submitting: boolean;
@@ -238,6 +238,7 @@ export function FormContextOverride({
...overrides,
...additional
};
console.log("context", context);
return <FormContext.Provider value={context}>{children}</FormContext.Provider>;
}
@@ -287,10 +288,11 @@ export function useFormError(name: string, opt?: { strict?: boolean; debug?: boo
}
export function useFormStateSelector<Data = any, Reduced = Data>(
selector: (state: FormState<Data>) => Reduced
selector: (state: FormState<Data>) => Reduced,
deps: any[] = []
): Reduced {
const { _formStateAtom } = useFormContext();
const selected = selectAtom(_formStateAtom, useCallback(selector, []), isEqual);
const selected = selectAtom(_formStateAtom, useCallback(selector, deps), isEqual);
return useAtom(selected)[0];
}
@@ -298,7 +300,6 @@ type SelectorFn<Ctx = any, Refined = any> = (state: Ctx) => Refined;
export function useDerivedFieldContext<Data = any, Reduced = undefined>(
path,
_schema?: LibJsonSchema,
deriveFn?: SelectorFn<
FormContext<Data> & {
pointer: string;
@@ -307,7 +308,8 @@ export function useDerivedFieldContext<Data = any, Reduced = undefined>(
path: string;
},
Reduced
>
>,
_schema?: JSONSchema
): FormContext<Data> & {
value: Reduced;
pointer: string;
@@ -324,9 +326,6 @@ export function useDerivedFieldContext<Data = any, Reduced = undefined>(
const prefixedName = prefixPath(path, root);
const prefixedPointer = pathToPointer(prefixedName);
const value = getPath(state.data, prefixedName);
/*const errors = state.errors.filter((error) =>
error.data.pointer.startsWith(prefixedPointer)
);*/
const fieldSchema =
pointer === "#/"
? (schema as LibJsonSchema)

View File

@@ -1,27 +1,19 @@
import type { JSONSchema } from "json-schema-to-ts";
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 { useDerivedFieldContext } from "./Form";
import { type JSONSchema, useDerivedFieldContext } from "./Form";
export type ObjectFieldProps = {
path?: string;
schema?: Exclude<JSONSchema, boolean>;
label?: string | false;
wrapperProps?: Partial<FieldwrapperProps>;
};
export const ObjectField = ({
path = "",
schema: _schema,
label: _label,
wrapperProps = {}
}: ObjectFieldProps) => {
const ctx = useDerivedFieldContext(path, _schema);
const schema = _schema ?? ctx.schema;
export const ObjectField = ({ path = "", label: _label, wrapperProps = {} }: ObjectFieldProps) => {
const { schema, ...ctx } = useDerivedFieldContext(path);
if (!isTypeSchema(schema)) return `ObjectField "${path}": no schema`;
const properties = schema.properties ?? {};
const properties = Object.entries(schema.properties ?? {}) as [string, JSONSchema][];
return (
<FieldWrapper
@@ -31,17 +23,20 @@ export const ObjectField = ({
errorPlacement="top"
{...wrapperProps}
>
{Object.keys(properties).map((prop) => {
const schema = properties[prop];
const name = [path, prop].filter(Boolean).join(".");
if (typeof schema === "undefined" || typeof schema === "boolean") return;
{properties.length === 0 ? (
<i className="opacity-50">No properties</i>
) : (
properties.map(([prop, schema]) => {
const name = [path, prop].filter(Boolean).join(".");
if (typeof schema === "undefined" || typeof schema === "boolean") return;
if (schema.anyOf || schema.oneOf) {
return <AnyOfField key={name} path={name} />;
}
if (schema.anyOf || schema.oneOf) {
return <AnyOfField key={name} path={name} />;
}
return <Field key={name} name={name} />;
})}
return <Field key={name} name={name} />;
})
)}
</FieldWrapper>
);
};

View File

@@ -53,7 +53,7 @@ export const Modal2 = forwardRef<Modal2Ref, Modal2Props>(
export const ModalTitle = ({ path, onClose }: { path: string[]; onClose: () => void }) => {
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">
{path.map((p, i) => {
const last = i + 1 === path.length;

View File

@@ -8,6 +8,7 @@ import {
SegmentedControl,
Select,
Switch,
Tabs,
TagsInput,
TextInput,
Textarea,
@@ -29,7 +30,7 @@ export function createMantineTheme(scheme: "light" | "dark"): {
};
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 {
theme: createTheme({
@@ -81,7 +82,6 @@ export function createMantineTheme(scheme: "light" | "dark"): {
TextInput: TextInput.extend({
classNames: (theme, props) => ({
wrapper: "leading-none",
//input: "focus:border-primary/50 bg-transparent disabled:text-primary"
input
})
}),
@@ -100,9 +100,14 @@ export function createMantineTheme(scheme: "light" | "dark"): {
Modal: Modal.extend({
classNames: (theme, props) => ({
...props.classNames,
root: `bknd-admin ${scheme} ${props.className ?? ""} `,
content: "bg-lightest border border-primary/10",
overlay: "backdrop-blur"
root: `bknd-admin ${scheme} ${props.className ?? ""}`,
content: "!bg-background !rounded-lg !select-none",
overlay: "!backdrop-blur-sm"
})
}),
Tabs: Tabs.extend({
classNames: (theme, props) => ({
tab: "data-[active=true]:border-primary"
})
}),
Menu: Menu.extend({

View File

@@ -11,7 +11,8 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
);
// REGISTER ERROR OVERLAY
if (process.env.NODE_ENV !== "production") {
const showOverlay = true;
if (process.env.NODE_ENV !== "production" && showOverlay) {
const showErrorOverlay = (err) => {
// must be within function call because that's when the element is defined for sure.
const ErrorOverlay = customElements.get("vite-error-overlay");

View File

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

View File

@@ -61,7 +61,7 @@ export function SchemaFormModal({
return (
<>
{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
tagName="form"
ref={formRef}
@@ -84,10 +84,10 @@ export function SchemaFormModal({
SchemaFormModal.defaultTitle = "JSON Schema Form Modal";
SchemaFormModal.modalProps = {
size: "md",
classNames: {
size: "md",
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",
title: "!font-bold !text-md",
body: "!p-0"

View File

@@ -1,23 +1,23 @@
import { IconFingerprint } from "@tabler/icons-react";
import { TbSettings } from "react-icons/tb";
import { useBknd } from "ui/client/bknd";
import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
import { IconButton } from "ui/components/buttons/IconButton";
import { Empty } from "ui/components/display/Empty";
import { Icon } from "ui/components/display/Icon";
import { Link } from "ui/components/wouter/Link";
import { useBrowserTitle } from "ui/hooks/use-browser-title";
import * as AppShell from "ui/layouts/AppShell/AppShell";
import { routes, useNavigate } from "ui/lib/routes";
import { routes } from "ui/lib/routes";
export function AuthRoot({ children }) {
const { app, config } = useBknd();
const users_entity = config.auth.entity_name;
const { config, $auth } = useBkndAuth();
return (
<>
<AppShell.Sidebar>
<AppShell.SectionHeader
right={
<Link href={app.getSettingsPath(["auth"])}>
<Link href={$auth.routes.settings}>
<IconButton Icon={TbSettings} />
</Link>
}
@@ -32,23 +32,42 @@ export function AuthRoot({ children }) {
</AppShell.SidebarLink>
<AppShell.SidebarLink
as={Link}
href={app.getAbsolutePath("/data/" + routes.data.entity.list(users_entity))}
disabled={!config.auth.enabled}
href={$auth.routes.listUsers}
disabled={!config.enabled}
className="justify-between"
>
Users
{!config.enabled && <AuthWarning title="Auth is not enabled." />}
</AppShell.SidebarLink>
<AppShell.SidebarLink
as={Link}
href={routes.auth.roles.list()}
disabled={!config.auth.enabled}
disabled={!config.enabled}
className="justify-between"
>
Roles & Permissions
{!config.enabled ? (
<AuthWarning title="Auth is not enabled." />
) : $auth.roles.none ? (
<AuthWarning title="No roles defined." />
) : !$auth.roles.has_admin ? (
<AuthWarning title="No admin role defined." />
) : null}
</AppShell.SidebarLink>
<AppShell.SidebarLink as={Link} href={routes.auth.strategies()} disabled>
<AppShell.SidebarLink
as={Link}
href={routes.auth.strategies()}
disabled={!config.enabled}
className="justify-between"
>
Strategies
{!config.enabled && <AuthWarning title="Auth is not enabled." />}
</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()}
className="justify-between"
>
Settings
</AppShell.SidebarLink>
</nav>
@@ -60,6 +79,10 @@ export function AuthRoot({ children }) {
);
}
const AuthWarning = ({ title }) => (
<Icon.Warning title={title} className="size-5 pointer-events-auto" />
);
export function AuthEmpty() {
useBrowserTitle(["Auth"]);
return <Empty Icon={IconFingerprint} title="Not implemented yet" />;

View File

@@ -1,10 +1,13 @@
import clsx from "clsx";
import { TbArrowRight, TbCircle, TbCircleCheckFilled, TbFingerprint } from "react-icons/tb";
import { useApiQuery } from "ui/client";
import { useBknd } from "ui/client/bknd";
import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
import { ButtonLink, type ButtonLinkProps } from "ui/components/buttons/Button";
import { IconButton } from "ui/components/buttons/IconButton";
import { Alert } from "ui/components/display/Alert";
import * as AppShell from "ui/layouts/AppShell/AppShell";
import { routes } from "ui/lib/routes";
import { routes, useNavigate } from "ui/lib/routes";
export function AuthIndex() {
const { app } = useBknd();
@@ -21,7 +24,7 @@ export function AuthIndex() {
const usersLink = app.getAbsolutePath("/data/" + routes.data.entity.list(users_entity));
const rolesLink = routes.auth.roles.list();
const strategiesLink = app.getSettingsPath(["auth", "strategies"]);
const strategiesLink = routes.auth.strategies();
return (
<>
@@ -32,8 +35,22 @@ export function AuthIndex() {
title="Auth not enabled"
message="To use authentication features, please enable it in the settings."
/>
<div className="flex flex-col flex-grow p-3 gap-3">
<div className="grid xs:grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-5">
<div className="flex flex-col md:flex-row flex-1 p-3 gap-3">
<div className="flex flex-col border border-primary/20 self-stretch md:self-start">
<div className="flex flex-row gap-3 py-3 px-4 border-b border-b-muted font-medium bg-muted items-center justify-between">
Getting started
<TbFingerprint className="size-5" />
</div>
<Item title="Enable authentication" done={enabled} to={routes.auth.settings()} />
<Item title="Create Roles" done={rolesTotal > 0} to={rolesLink} />
<Item title="Create an user" done={usersTotal > 0} to={usersLink} />
<Item
title="Enable a second strategy"
done={strategiesTotal > 1}
to={strategiesLink}
/>
</div>
<div className="grid xs:grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-5 flex-grow">
<KpiCard
title="Users registered"
value={!enabled ? 0 : usersTotal}
@@ -50,7 +67,7 @@ export function AuthIndex() {
value={!enabled ? 0 : rolesTotal}
actions={[
{ label: "View all", href: rolesLink },
{ label: "Add new", variant: "default", href: rolesLink }
{ label: "Manage", variant: "default", href: rolesLink }
]}
/>
<KpiCard
@@ -58,7 +75,7 @@ export function AuthIndex() {
value={!enabled ? 0 : strategiesTotal}
actions={[
{ label: "View all", href: strategiesLink },
{ label: "Add new", variant: "default", href: strategiesLink }
{ label: "Manage", variant: "default", href: strategiesLink }
]}
/>
</div>
@@ -75,7 +92,7 @@ type KpiCardProps = {
};
const KpiCard: React.FC<KpiCardProps> = ({ title, value, actions }) => (
<div className="flex flex-col border border-muted">
<div className="flex flex-col border border-muted h-auto self-start">
<div className="flex flex-col gap-2 px-5 pt-3.5 pb-4 border-b border-b-muted">
<div>
<span className="opacity-50">{title}</span>
@@ -92,3 +109,25 @@ const KpiCard: React.FC<KpiCardProps> = ({ title, value, actions }) => (
</div>
</div>
);
const Item = ({ title, done = false, to }: { title: string; done?: boolean; to?: string }) => {
const [navigate] = useNavigate();
return (
<div className="flex border-b border-b-muted">
<div className={clsx("flex flex-1 flex-row gap-3 py-3 px-4", done && "opacity-50")}>
<div className="flex flex-row flex-1 gap-3 items-center">
{done ? <TbCircleCheckFilled className="size-5" /> : <TbCircle className="size-5" />}
<p
className={clsx(
"font-medium text-primary/80 leading-none",
done ? "line-through" : ""
)}
>
{title}
</p>
</div>
{to && <IconButton Icon={TbArrowRight} onClick={() => navigate(to)} />}
</div>
</div>
);
};

View File

@@ -1,111 +1,210 @@
import { cloneDeep, omit } from "lodash-es";
import { useEffect, useRef } from "react";
import { useBknd } from "ui/client/bknd";
import clsx from "clsx";
import { isDebug } from "core";
import { TbAlertCircle, 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 { Icon } from "ui/components/display/Icon";
import { Message } from "ui/components/display/Message";
import {
Field,
type FieldProps,
Form,
FormDebug,
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 { 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, debug: isDebug() }
};
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, $auth } = 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-4"
right={
<Button
variant="primary"
type="submit"
disabled={!dirty || errors || submitting}
>
Update
</Button>
}
>
Settings
</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 pl-0 pb-0">
<div className="pl-4">
<AuthField
name="enabled"
label="Authentication Enabled"
description="Only after enabling authentication, all settings below will take effect."
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"
<div className="flex flex-col gap-6 relative pl-4 pb-2">
<Overlay />
<AuthField
name="guard.enabled"
label={
<div className="flex flex-row gap-2 items-center">
<span>Guard Enabled</span>
{!$auth.roles.has_admin && (
<Icon.Warning title="No admin roles defined. Enabling the guard will likely block all requests." />
)}
</div>
}
disabled={$auth.roles.none}
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>
</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"
/>
<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

@@ -1,55 +1,247 @@
import { cloneDeep, omit } from "lodash-es";
import { isDebug } from "core";
import { autoFormatString } from "core/utils";
import { type ChangeEvent, useState } from "react";
import {
TbAt,
TbBrandAppleFilled,
TbBrandDiscordFilled,
TbBrandFacebookFilled,
TbBrandGithubFilled,
TbBrandGoogleFilled,
TbBrandInstagram,
TbBrandOauth,
TbBrandX,
TbSettings
} from "react-icons/tb";
import { twMerge } from "tailwind-merge";
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 {
Field,
Form,
FormContextOverride,
FormDebug,
ObjectField,
Subscribe,
useDerivedFieldContext,
useFormError,
useFormValue
} from "ui/components/form/json-schema-form";
import { useBrowserTitle } from "ui/hooks/use-browser-title";
import * as AppShell from "../../layouts/AppShell/AppShell";
export function AuthStrategiesList() {
useBknd({ withSecrets: true });
return <AuthStrategiesListInternal />;
export function AuthStrategiesList(props) {
useBrowserTitle(["Auth", "Strategies"]);
const { hasSecrets } = useBknd({ withSecrets: true });
if (!hasSecrets) {
return <Message.MissingPermission what="Auth Strategies" />;
}
return <AuthStrategiesListInternal {...props} />;
}
const uiSchema = {
jwt: {
fields: {
"ui:options": {
orderable: false
}
}
}
const formOptions = {
keepEmpty: true,
debug: isDebug()
};
function AuthStrategiesListInternal() {
const s = useBknd();
const config = s.config.auth.strategies;
const schema = cloneDeep(omit(s.schema.auth.properties.strategies, ["title"]));
const $auth = useBkndAuth();
const config = $auth.config.strategies;
const schema = $auth.schema.properties.strategies;
const schemas = Object.fromEntries(
// @ts-ignore
$auth.schema.properties.strategies.additionalProperties.anyOf.map((s) => [
s.properties.type.const,
s
])
);
console.log("strategies", { config, schema });
async function handleSubmit(data: any) {
console.log("submit", { strategies: data });
await $auth.actions.config.set({ strategies: data });
}
return (
<>
<AppShell.SectionHeader right={<Button variant="primary">Update</Button>}>
Strategies
</AppShell.SectionHeader>
<Form schema={schema} initialValues={config} onSubmit={handleSubmit} options={formOptions}>
<Subscribe
selector={(state) => ({
dirty: state.dirty,
errors: state.errors.length > 0,
submitting: state.submitting
})}
>
{({ dirty, errors, submitting }) => (
<AppShell.SectionHeader
className="pl-4"
right={
<Button
variant="primary"
type="submit"
disabled={!dirty || errors || submitting}
>
Update
</Button>
}
>
Strategies
</AppShell.SectionHeader>
)}
</Subscribe>
<AppShell.Scrollable>
strat
{/*<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"
/>
<div className="flex flex-col p-4 gap-4">
<p className="opacity-70">
Allow users to sign in or sign up using different strategies.
</p>
<div className="flex flex-col gap-2 max-w-4xl">
<Strategy type="password" name="password" />
<Strategy type="oauth" name="google" />
<Strategy type="oauth" name="github" />
<Strategy type="oauth" name="facebook" unavailable />
<Strategy type="oauth" name="x" unavailable />
<Strategy type="oauth" name="instagram" unavailable />
<Strategy type="oauth" name="apple" unavailable />
<Strategy type="oauth" name="discord" unavailable />
</div>
<div className="flex flex-col gap-3">
<h3 className="font-bold">JWT Settings</h3>
<JsonSchemaForm
schema={extracted.jwt.schema}
uiSchema={uiSchema.jwt}
className="legacy hide-required-mark fieldset-alternative mute-root"
/>
</div>
</div>*/}
</div>
<FormDebug />
</AppShell.Scrollable>
</>
</Form>
);
}
type StrategyProps = {
type: "password" | "oauth" | "custom_oauth";
name: string;
unavailable?: boolean;
};
const Strategy = ({ type, name, unavailable }: StrategyProps) => {
const errors = useFormError(name, { strict: true });
const $auth = useBkndAuth();
const schemas = Object.fromEntries(
// @ts-ignore
$auth.schema.properties.strategies.additionalProperties.anyOf.map((s) => [
s.properties.type.const,
s
])
);
const schema = schemas[type];
const [open, setOpen] = useState(false);
if (!schema) return null;
return (
<FormContextOverride schema={schema} prefix={name}>
<div
className={twMerge(
"flex flex-col border border-muted rounded bg-background",
unavailable && "opacity-20 pointer-events-none cursor-not-allowed",
errors.length > 0 && "border-red-500"
)}
>
<div className="flex flex-row justify-between p-3 gap-3 items-center">
<div className="flex flex-row items-center p-2 bg-primary/5 rounded">
<StrategyIcon type={type} provider={name} />
</div>
<div className="font-mono flex-grow flex flex-row gap-3">
<span className="leading-none">{autoFormatString(name)}</span>
</div>
<div className="flex flex-row gap-4 items-center">
<StrategyToggle />
<IconButton
Icon={TbSettings}
size="lg"
iconProps={{ strokeWidth: 1.5 }}
variant={open ? "primary" : "ghost"}
onClick={() => setOpen((o) => !o)}
/>
</div>
</div>
{open && (
<div
className={twMerge(
"flex flex-col border-t border-t-muted px-4 pt-3 pb-4 bg-lightest/50 gap-4"
)}
>
<StrategyForm type={type} />
</div>
)}
</div>
</FormContextOverride>
);
};
const StrategyToggle = () => {
const ctx = useDerivedFieldContext("");
const { value } = useFormValue("");
function handleToggleChange(e: ChangeEvent<HTMLInputElement>) {
const checked = e.target.value;
const value_keys = Object.keys(value ?? {});
const can_remove =
value_keys.length === 0 || (value_keys.length === 1 && value_keys[0] === "enabled");
if (!checked && can_remove) {
ctx.deleteValue(ctx.path);
} else {
ctx.setValue([ctx.path, "enabled"].join("."), checked);
}
}
return <Field name="enabled" label={false} required onChange={handleToggleChange} />;
};
const StrategyIcon = ({ type, provider }: { type: StrategyProps["type"]; provider?: string }) => {
if (type === "password") {
return <TbAt className="size-5" />;
}
if (provider && provider in OAUTH_BRANDS) {
const BrandIcon = OAUTH_BRANDS[provider];
return <BrandIcon className="size-5" />;
}
return <TbBrandOauth className="size-5" />;
};
const OAUTH_BRANDS = {
google: TbBrandGoogleFilled,
github: TbBrandGithubFilled,
facebook: TbBrandFacebookFilled,
x: TbBrandX,
instagram: TbBrandInstagram,
apple: TbBrandAppleFilled,
discord: TbBrandDiscordFilled
};
const StrategyForm = ({ type }: Pick<StrategyProps, "type">) => {
let Component = () => <ObjectField path="" wrapperProps={{ wrapper: "group", label: false }} />;
switch (type) {
case "password":
Component = StrategyPasswordForm;
break;
case "oauth":
Component = StrategyOAuthForm;
break;
}
return <Component />;
};
const StrategyPasswordForm = () => {
return <ObjectField path="config" wrapperProps={{ wrapper: "group", label: false }} />;
};
const StrategyOAuthForm = () => {
return (
<>
<Field name="config.client.client_id" required />
<Field name="config.client.client_secret" required />
</>
);
};

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

@@ -2,6 +2,7 @@ import { IconAlertHexagon } from "@tabler/icons-react";
import { TbSettings } from "react-icons/tb";
import { useBknd } from "ui/client/BkndProvider";
import { IconButton } from "ui/components/buttons/IconButton";
import { Icon } from "ui/components/display/Icon";
import { Link } from "ui/components/wouter/Link";
import { Media } from "ui/elements";
import { useBrowserTitle } from "ui/hooks/use-browser-title";
@@ -32,7 +33,10 @@ export function MediaRoot({ children }) {
href={"/"}
className="flex flex-row justify-between"
>
Main Bucket {mediaDisabled && <IconAlertHexagon className="size-5" />}
Main Bucket{" "}
{mediaDisabled && (
<Icon.Warning title="Media not enabled." className="size-5" />
)}
</AppShell.SidebarLink>
<AppShell.SidebarLink as={Link} href={"/settings"}>
Settings

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>

View File

@@ -32,6 +32,33 @@ const schema2 = {
required: ["age"]
} as const satisfies JSONSchema;
const authSchema = {
type: "object",
properties: {
what: {
type: "array",
items: {
type: "string"
}
},
jwt: {
type: "object",
properties: {
fields: {
type: "array",
items: {
type: "string"
}
}
}
}
}
} as const satisfies JSONSchema;
const formOptions = {
debug: true
};
export default function JsonSchemaForm3() {
const { schema: _schema, config } = useBknd();
const schema = JSON.parse(JSON.stringify(_schema));
@@ -46,6 +73,8 @@ export default function JsonSchemaForm3() {
return (
<Scrollable>
<div className="flex flex-col p-3">
<Form schema={_schema.auth} options={formOptions} />
{/*<Form
onChange={(data) => console.log("change", data)}
onSubmit={(data) => console.log("submit", data)}
@@ -249,13 +278,13 @@ export default function JsonSchemaForm3() {
</Form>*/}
{/*<CustomMediaForm />*/}
<Form
{/*<Form
schema={schema.media}
initialValues={config.media as any}
onSubmit={console.log}
options={{ debug: true }}
/*validateOn="change"*/
/>
/>*/}
{/*<Form
schema={removeKeyRecursively(schema.media, "pattern") as any}
@@ -333,7 +362,7 @@ function CustomMediaForm() {
<AnyOf.Root path="adapter">
<CustomMediaFormAdapter />
</AnyOf.Root>
{/*<FormDebug force />*/}
<FormDebug force />
</Form>
);
}