Fix Release 0.11.1 (#150)

* fix strategy forms handling, add register route and hidden fields

Refactored strategy forms to include hidden fields for type and name. Added a registration route with necessary adjustments to the admin controller and routes. Corrected field handling within relevant forms and components.

* fix admin access permissions and refactor routing structure

display a fixed error for unmet permissions when retrieving the schema. moved auth routes outside of BkndProvider and reorganized remaining routes to include BkndWrapper.

* fix: properly type BkndWrapper

* bump fix release version

* ModuleManager: update diff checking and AppData validation

Revised diff handling includes validation of diffs, reverting changes on failure, and enforcing module constraints with onBeforeUpdate hooks. Introduced `validateDiffs` and backup of stable configs. Applied changes in related modules, tests, and UI layer to align with updated diff logic.

* fix: cli: running from config file were using invalid args

* fix: cli: improve sequence of onBuilt trigger to allow custom routes from cli

* fix e2e tests
This commit is contained in:
dswbx
2025-04-20 09:29:58 +02:00
committed by GitHub
parent 2988e4c3bd
commit 4c11789ea8
29 changed files with 520 additions and 169 deletions

View File

@@ -4,6 +4,7 @@ import { config } from "core";
import type { MiddlewareHandler } from "hono";
import open from "open";
import { fileExists, getRelativeDistPath } from "../../utils/sys";
import type { App } from "App";
export const PLATFORMS = ["node", "bun"] as const;
export type Platform = (typeof PLATFORMS)[number];
@@ -32,7 +33,7 @@ export async function attachServeStatic(app: any, platform: Platform) {
export async function startServer(
server: Platform,
app: any,
app: App,
options: { port: number; open?: boolean },
) {
const port = options.port;

View File

@@ -77,12 +77,12 @@ async function makeApp(config: MakeAppConfig) {
app.emgr.onEvent(
App.Events.AppBuiltEvent,
async () => {
await attachServeStatic(app, config.server?.platform ?? "node");
app.registerAdminController();
if (config.onBuilt) {
await config.onBuilt(app);
}
await attachServeStatic(app, config.server?.platform ?? "node");
app.registerAdminController();
},
"sync",
);
@@ -92,7 +92,7 @@ async function makeApp(config: MakeAppConfig) {
}
export async function makeConfigApp(_config: CliBkndConfig, platform?: Platform) {
const config = makeConfig(_config, { env: process.env });
const config = makeConfig(_config, process.env);
return makeApp({
...config,
server: { platform },

View File

@@ -1,4 +1,5 @@
enum Change {
// biome-ignore lint/suspicious/noConstEnum: <explanation>
export const enum DiffChange {
Add = "a",
Remove = "r",
Edit = "e",
@@ -7,8 +8,8 @@ enum Change {
type Object = object;
type Primitive = string | number | boolean | null | object | any[] | undefined;
interface DiffEntry {
t: Change | string;
export interface DiffEntry {
t: DiffChange | string;
p: (string | number)[];
o: Primitive;
n: Primitive;
@@ -47,7 +48,7 @@ function diff(oldObj: Object, newObj: Object): DiffEntry[] {
if (typeof oldValue !== typeof newValue) {
diffs.push({
t: Change.Edit,
t: DiffChange.Edit,
p: path,
o: oldValue,
n: newValue,
@@ -57,14 +58,14 @@ function diff(oldObj: Object, newObj: Object): DiffEntry[] {
for (let i = 0; i < maxLength; i++) {
if (i >= oldValue.length) {
diffs.push({
t: Change.Add,
t: DiffChange.Add,
p: [...path, i],
o: undefined,
n: newValue[i],
});
} else if (i >= newValue.length) {
diffs.push({
t: Change.Remove,
t: DiffChange.Remove,
p: [...path, i],
o: oldValue[i],
n: undefined,
@@ -80,14 +81,14 @@ function diff(oldObj: Object, newObj: Object): DiffEntry[] {
for (const key of allKeys) {
if (!(key in oldValue)) {
diffs.push({
t: Change.Add,
t: DiffChange.Add,
p: [...path, key],
o: undefined,
n: newValue[key],
});
} else if (!(key in newValue)) {
diffs.push({
t: Change.Remove,
t: DiffChange.Remove,
p: [...path, key],
o: oldValue[key],
n: undefined,
@@ -98,7 +99,7 @@ function diff(oldObj: Object, newObj: Object): DiffEntry[] {
}
} else {
diffs.push({
t: Change.Edit,
t: DiffChange.Edit,
p: path,
o: oldValue,
n: newValue,
@@ -136,9 +137,9 @@ function applyChange(obj: Object, diff: DiffEntry) {
const parent = getParent(obj, path.slice(0, -1));
const key = path[path.length - 1]!;
if (type === Change.Add || type === Change.Edit) {
if (type === DiffChange.Add || type === DiffChange.Edit) {
parent[key] = newValue;
} else if (type === Change.Remove) {
} else if (type === DiffChange.Remove) {
if (Array.isArray(parent)) {
parent.splice(key as number, 1);
} else {
@@ -152,13 +153,13 @@ function revertChange(obj: Object, diff: DiffEntry) {
const parent = getParent(obj, path.slice(0, -1));
const key = path[path.length - 1]!;
if (type === Change.Add) {
if (type === DiffChange.Add) {
if (Array.isArray(parent)) {
parent.splice(key as number, 1);
} else {
delete parent[key];
}
} else if (type === Change.Remove || type === Change.Edit) {
} else if (type === DiffChange.Remove || type === DiffChange.Edit) {
parent[key] = oldValue;
}
}

View File

@@ -61,6 +61,19 @@ export class AppData extends Module<typeof dataConfigSchema> {
this.setBuilt();
}
override async onBeforeUpdate(from: AppDataConfig, to: AppDataConfig): Promise<AppDataConfig> {
// this is not 100% yet, since it could be legit
const entities = {
from: Object.keys(from.entities ?? {}),
to: Object.keys(to.entities ?? {}),
};
if (entities.from.length - entities.to.length > 1) {
throw new Error("Cannot remove more than one entity at a time");
}
return to;
}
getSchema() {
return dataConfigSchema;
}

View File

@@ -1,7 +1,7 @@
import { Guard } from "auth";
import { $console, BkndError, DebugLogger, env } from "core";
import { EventManager } from "core/events";
import { clone, diff } from "core/object/diff";
import * as $diff from "core/object/diff";
import {
Default,
type Static,
@@ -95,7 +95,7 @@ export type ModuleManagerOptions = {
verbosity?: Verbosity;
};
type ConfigTable<Json = ModuleConfigs> = {
export type ConfigTable<Json = ModuleConfigs> = {
id?: number;
version: number;
type: "config" | "diff" | "backup";
@@ -144,6 +144,7 @@ export class ModuleManager {
private _version: number = 0;
private _built = false;
private readonly _booted_with?: "provided" | "partial";
private _stable_configs: ModuleConfigs | undefined;
private logger: DebugLogger;
@@ -310,7 +311,7 @@ export class ModuleManager {
try {
const state = await this.fetch();
if (!state) throw new BkndError("save: no config found");
if (!state) throw new BkndError("no config found");
this.logger.log("fetched version", state.version);
if (state.version !== version) {
@@ -330,15 +331,21 @@ export class ModuleManager {
this.logger.log("version matches", state.version);
// clean configs because of Diff() function
const diffs = diff(state.json, clone(configs));
const diffs = $diff.diff(state.json, $diff.clone(configs));
this.logger.log("checking diff", [diffs.length]);
if (diff.length > 0) {
if (diffs.length > 0) {
// validate diffs, it'll throw on invalid
this.validateDiffs(diffs);
const date = new Date();
// store diff
await this.mutator().insertOne({
version,
type: "diff",
json: clone(diffs),
json: $diff.clone(diffs),
created_at: date,
updated_at: date,
});
// store new version
@@ -346,7 +353,7 @@ export class ModuleManager {
{
version,
json: configs,
updated_at: new Date(),
updated_at: date,
} as any,
{
type: "config",
@@ -358,7 +365,7 @@ export class ModuleManager {
}
}
} catch (e) {
if (e instanceof BkndError) {
if (e instanceof BkndError && e.message === "no config found") {
this.logger.log("no config, just save fresh");
// no config, just save
await this.mutator().insertOne({
@@ -369,10 +376,12 @@ export class ModuleManager {
updated_at: new Date(),
});
} else if (e instanceof TransformPersistFailedException) {
console.error("Cannot save invalid config");
$console.error("ModuleManager: Cannot save invalid config");
this.revertModules();
throw e;
} else {
console.error("Aborting");
$console.error("ModuleManager: Aborting");
this.revertModules();
throw e;
}
}
@@ -386,6 +395,52 @@ export class ModuleManager {
return this;
}
private revertModules() {
if (this._stable_configs) {
$console.warn("ModuleManager: Reverting modules");
this.setConfigs(this._stable_configs as any);
} else {
$console.error("ModuleManager: No stable configs to revert to");
}
}
/**
* Validates received diffs for an additional security control.
* Checks:
* - check if module is registered
* - run modules onBeforeUpdate() for added protection
*
* **Important**: Throw `Error` so it won't get catched.
*
* @param diffs
* @private
*/
private validateDiffs(diffs: $diff.DiffEntry[]): void {
// check top level paths, and only allow a single module to be modified in a single transaction
const modules = [...new Set(diffs.map((d) => d.p[0]))];
if (modules.length === 0) {
return;
}
for (const moduleName of modules) {
const name = moduleName as ModuleKey;
const module = this.get(name) as Module;
if (!module) {
const msg = "validateDiffs: module not registered";
// biome-ignore format: ...
$console.error(msg, JSON.stringify({ module: name, diffs }, null, 2));
throw new Error(msg);
}
// pass diffs to the module to allow it to throw
if (this._stable_configs?.[name]) {
const current = $diff.clone(this._stable_configs?.[name]);
const modified = $diff.apply({ [name]: current }, diffs)[name];
module.onBeforeUpdate(current, modified);
}
}
}
private setConfigs(configs: ModuleConfigs): void {
this.logger.log("setting configs");
objectEach(configs, (config, key) => {
@@ -519,6 +574,9 @@ export class ModuleManager {
this.logger.log("resetting flags");
ctx.flags = Module.ctx_flags;
// storing last stable config version
this._stable_configs = $diff.clone(this.configs());
this.logger.clear();
return state;
}
@@ -551,7 +609,6 @@ export class ModuleManager {
name: Module,
): Pick<ReturnType<Modules[Module]["schema"]>, "set" | "patch" | "overwrite" | "remove"> {
const module = this.modules[name];
const copy = structuredClone(this.configs());
return new Proxy(module.schema(), {
get: (target, prop: string) => {
@@ -560,7 +617,7 @@ export class ModuleManager {
}
return async (...args) => {
console.log("[Safe Mutate]", name);
$console.log("[Safe Mutate]", name);
try {
// overwrite listener to run build inside this try/catch
module.setListener(async () => {
@@ -582,12 +639,12 @@ export class ModuleManager {
return result;
} catch (e) {
console.error("[Safe Mutate] failed", e);
$console.error(`[Safe Mutate] failed "${name}":`, String(e));
// revert to previous config & rebuild using original listener
this.setConfigs(copy);
this.revertModules();
await this.onModuleConfigUpdated(name, module.config as any);
console.log("[Safe Mutate] reverted");
$console.log(`[Safe Mutate] reverted "${name}":`);
// make sure to throw the error
throw e;

View File

@@ -72,6 +72,7 @@ export class AdminController extends Controller {
success: configs.auth.cookie.pathSuccess ?? this.withAdminBasePath("/"),
loggedOut: configs.auth.cookie.pathLoggedOut ?? this.withAdminBasePath("/"),
login: this.withAdminBasePath("/auth/login"),
register: this.withAdminBasePath("/auth/register"),
logout: this.withAdminBasePath("/auth/logout"),
};
@@ -92,8 +93,7 @@ export class AdminController extends Controller {
});
if (auth_enabled) {
hono.get(
authRoutes.login,
const redirectRouteParams = [
permission([SystemPermissions.accessAdmin, SystemPermissions.schemaRead], {
// @ts-ignore
onGranted: async (c) => {
@@ -107,7 +107,10 @@ export class AdminController extends Controller {
async (c) => {
return c.html(c.get("html")!);
},
);
] as const;
hono.get(authRoutes.login, ...redirectRouteParams);
hono.get(authRoutes.register, ...redirectRouteParams);
hono.get(authRoutes.logout, async (c) => {
await auth.authenticator?.logout(c);

View File

@@ -160,7 +160,6 @@ export class SystemController extends Controller {
if (this.app.modules.get(module).schema().has(path)) {
return c.json({ success: false, path, error: "Path already exists" }, { status: 400 });
}
console.log("-- add", module, path, value);
return await handleConfigUpdateResponse(c, async () => {
await this.app.mutateConfig(module).patch(path, value);

View File

@@ -1,6 +1,6 @@
import { MantineProvider } from "@mantine/core";
import { Notifications } from "@mantine/notifications";
import React from "react";
import React, { type ReactNode } from "react";
import { BkndProvider, type BkndAdminOptions } from "ui/client/bknd";
import { useTheme } from "ui/client/use-theme";
import { Logo } from "ui/components/display/Logo";
@@ -21,33 +21,32 @@ export default function Admin({
withProvider = false,
config,
}: BkndAdminProps) {
const Component = (
const { theme } = useTheme();
const Provider = ({ children }: any) =>
withProvider ? (
<ClientProvider
baseUrl={baseUrlOverride}
{...(typeof withProvider === "object" ? withProvider : {})}
>
{children}
</ClientProvider>
) : (
children
);
const BkndWrapper = ({ children }: { children: ReactNode }) => (
<BkndProvider options={config} fallback={<Skeleton theme={config?.theme} />}>
<AdminInternal />
{children}
</BkndProvider>
);
return withProvider ? (
<ClientProvider
baseUrl={baseUrlOverride}
{...(typeof withProvider === "object" ? withProvider : {})}
>
{Component}
</ClientProvider>
) : (
Component
);
}
function AdminInternal() {
const { theme } = useTheme();
return (
<MantineProvider {...createMantineTheme(theme as any)}>
<Notifications position="top-right" />
<BkndModalsProvider>
<Routes />
</BkndModalsProvider>
</MantineProvider>
<Provider>
<MantineProvider {...createMantineTheme(theme as any)}>
<Notifications position="top-right" />
<Routes BkndWrapper={BkndWrapper} basePath={config?.basepath} />
</MantineProvider>
</Provider>
);
}

View File

@@ -5,6 +5,8 @@ import { useApi } from "ui/client";
import { type TSchemaActions, getSchemaActions } from "./schema/actions";
import { AppReduced } from "./utils/AppReduced";
import type { AppTheme } from "ui/client/use-theme";
import { Message } from "ui/components/display/Message";
import { useNavigate } from "ui/lib/routes";
export type BkndAdminOptions = {
logo_return_path?: string;
@@ -101,7 +103,6 @@ export function BkndProvider({
fallback: true,
} as any);
startTransition(() => {
const commit = () => {
setSchema(newSchema);
@@ -109,7 +110,7 @@ export function BkndProvider({
setFetched(true);
set_local_version((v) => v + 1);
fetching.current = Fetching.None;
}
};
if ("startViewTransition" in document) {
document.startViewTransition(commit);
@@ -139,22 +140,24 @@ export function BkndProvider({
value={{ ...schema, actions, requireSecrets, app, options: app.options, hasSecrets }}
key={local_version}
>
{/*{error && (
<Alert.Exception className="gap-2">
<IconAlertHexagon />
You attempted to load system configuration with secrets without having proper
permission.
<a href={schema.config.server.admin.basepath || "/"}>
<Button variant="red">Reload</Button>
</a>
</Alert.Exception>
)}*/}
{children}
{error ? <AccessDenied /> : children}
</BkndContext.Provider>
);
}
function AccessDenied() {
const [navigate] = useNavigate();
return (
<Message.MissingPermission
what="the Admin UI"
primary={{
children: "Login",
onClick: () => navigate("/auth/login"),
}}
/>
);
}
export function useBknd({ withSecrets }: { withSecrets?: boolean } = {}): BkndContext {
const ctx = useContext(BkndContext);
if (withSecrets) ctx.requireSecrets();

View File

@@ -16,9 +16,11 @@ const MissingPermission = ({
{...props}
/>
);
const NotEnabled = (props: Partial<EmptyProps>) => <Empty title="Not Enabled" {...props} />;
export const Message = {
NotFound,
NotAllowed,
NotEnabled,
MissingPermission,
};

View File

@@ -1,5 +1,12 @@
import type { JsonSchema } from "json-schema-library";
import type { ChangeEvent, ComponentPropsWithoutRef, ReactNode } from "react";
import {
type ChangeEvent,
type ComponentPropsWithoutRef,
type ElementType,
type ReactNode,
useEffect,
useId,
} from "react";
import ErrorBoundary from "ui/components/display/ErrorBoundary";
import * as Formy from "ui/components/form/Formy";
import { useEvent } from "ui/hooks/use-event";
@@ -7,7 +14,7 @@ import { ArrayField } from "./ArrayField";
import { FieldWrapper, type FieldwrapperProps } from "./FieldWrapper";
import { useDerivedFieldContext, useFormValue } from "./Form";
import { ObjectField } from "./ObjectField";
import { coerce, isType, isTypeSchema } from "./utils";
import { coerce, firstDefined, isType, isTypeSchema } from "./utils";
export type FieldProps = {
onChange?: (e: ChangeEvent<any>) => void;
@@ -24,6 +31,19 @@ export const Field = (props: FieldProps) => {
);
};
export const HiddenField = ({
as = "div",
className,
...props
}: FieldProps & { as?: ElementType; className?: string }) => {
const Component = as;
return (
<Component className={[className, "hidden"].filter(Boolean).join(" ")}>
<Field {...props} />
</Component>
);
};
const fieldErrorBoundary =
({ name }: FieldProps) =>
({ error }: { error: Error }) => (
@@ -41,8 +61,9 @@ const FieldImpl = ({
...props
}: FieldProps) => {
const { path, setValue, schema, ...ctx } = useDerivedFieldContext(name);
const id = `${name}-${useId()}`;
const required = typeof _required === "boolean" ? _required : ctx.required;
//console.log("Field", { name, path, schema });
if (!isTypeSchema(schema))
return (
<Pre>
@@ -58,7 +79,21 @@ const FieldImpl = ({
return <ArrayField path={name} />;
}
const disabled = props.disabled ?? schema.readOnly ?? "const" in schema ?? false;
// account for `defaultValue`
// like <Field name="name" inputProps={{ defaultValue: "oauth" }} />
useEffect(() => {
if (inputProps?.defaultValue) {
setValue(path, inputProps.defaultValue);
}
}, [inputProps?.defaultValue]);
const disabled = firstDefined(
inputProps?.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 });
@@ -70,9 +105,10 @@ const FieldImpl = ({
});
return (
<FieldWrapper name={name} required={required} schema={schema} {...props}>
<FieldWrapper name={name} required={required} schema={schema} fieldId={id} {...props}>
<FieldComponent
{...inputProps}
id={id}
schema={schema}
name={name}
required={required}
@@ -93,6 +129,7 @@ export const Pre = ({ children }) => (
export type FieldComponentProps = {
schema: JsonSchema;
render?: (props: Omit<FieldComponentProps, "render">) => ReactNode;
"data-testId"?: string;
} & ComponentPropsWithoutRef<"input">;
export const FieldComponent = ({ schema, render, ..._props }: FieldComponentProps) => {
@@ -111,7 +148,7 @@ export const FieldComponent = ({ schema, render, ..._props }: FieldComponentProp
if (render) return render({ schema, ...props });
if (schema.enum) {
return <Formy.Select id={props.name} options={schema.enum} {...(props as any)} />;
return <Formy.Select options={schema.enum} {...(props as any)} />;
}
if (isType(schema.type, ["number", "integer"])) {
@@ -121,26 +158,17 @@ export const FieldComponent = ({ schema, render, ..._props }: FieldComponentProp
step: schema.multipleOf,
};
return (
<Formy.Input
type="number"
id={props.name}
{...props}
value={props.value ?? ""}
{...additional}
/>
);
return <Formy.Input type="number" {...props} value={props.value ?? ""} {...additional} />;
}
if (isType(schema.type, "boolean")) {
return <Formy.Switch id={props.name} {...(props as any)} checked={value === true} />;
return <Formy.Switch {...(props as any)} checked={value === true} />;
}
if (isType(schema.type, "string") && schema.format === "date-time") {
const value = props.value ? new Date(props.value as string).toISOString().slice(0, 16) : "";
return (
<Formy.DateInput
id={props.name}
{...props}
value={value}
type="datetime-local"
@@ -156,7 +184,7 @@ export const FieldComponent = ({ schema, render, ..._props }: FieldComponentProp
}
if (isType(schema.type, "string") && schema.format === "date") {
return <Formy.DateInput id={props.name} {...props} value={props.value ?? ""} />;
return <Formy.DateInput {...props} value={props.value ?? ""} />;
}
const additional = {
@@ -171,7 +199,5 @@ export const FieldComponent = ({ schema, render, ..._props }: FieldComponentProp
}
}
return (
<Formy.TypeAwareInput id={props.name} {...props} value={props.value ?? ""} {...additional} />
);
return <Formy.TypeAwareInput {...props} value={props.value ?? ""} {...additional} />;
};

View File

@@ -24,6 +24,7 @@ export type FieldwrapperProps = {
errorPlacement?: "top" | "bottom";
description?: string;
descriptionPlacement?: "top" | "bottom";
fieldId?: string;
};
export function FieldWrapper({
@@ -36,6 +37,7 @@ export function FieldWrapper({
errorPlacement = "bottom",
descriptionPlacement = "bottom",
children,
fieldId,
...props
}: FieldwrapperProps) {
const errors = useFormError(name, { strict: true });
@@ -66,7 +68,7 @@ export function FieldWrapper({
{label && (
<Formy.Label
as={wrapper === "fieldset" ? "legend" : "label"}
htmlFor={name}
htmlFor={fieldId}
className="self-start"
>
{label} {required && <span className="font-medium opacity-30">*</span>}

View File

@@ -138,3 +138,10 @@ export function omitSchema<Given extends JSONSchema>(_schema: Given, keys: strin
export function isTypeSchema(schema?: JsonSchema): schema is JsonSchema {
return typeof schema === "object" && "type" in schema && !isType(schema.type, "error");
}
export function firstDefined<T>(...args: T[]): T | undefined {
for (const arg of args) {
if (typeof arg !== "undefined") return arg;
}
return undefined;
}

View File

@@ -1,4 +1,4 @@
import type { ReactNode } from "react";
import { isValidElement, type ReactNode } from "react";
import { useAuthStrategies } from "../hooks/use-auth";
import { AuthForm } from "./AuthForm";
@@ -30,11 +30,13 @@ export function AuthScreen({
{!loading && (
<div className="flex flex-col gap-4 items-center w-96 px-6 py-7">
{logo ? logo : null}
{typeof intro !== "undefined" ? (
{isValidElement(intro) ? (
intro
) : (
<div className="flex flex-col items-center">
<h1 className="text-xl font-bold">Sign in to your admin panel</h1>
<h1 className="text-xl font-bold">
Sign {action === "login" ? "in" : "up"} to your admin panel
</h1>
<p className="text-primary/50">Enter your credentials below to get access.</p>
</div>
)}

View File

@@ -134,7 +134,7 @@ export function Header({ hasSidebar = true }) {
<SidebarToggler />
<UserMenu />
</div>
<div className="hidden lg:flex flex-row items-center px-4 gap-2">
<div className="hidden md:flex flex-row items-center px-4 gap-2">
<UserMenu />
</div>
</header>

View File

@@ -4,5 +4,7 @@ export const testIds = {
data: {
btnCreateEntity: "data-btns-create-entity",
},
media: {},
media: {
switchEnabled: "media-switch-enabled",
},
};

View File

@@ -0,0 +1,18 @@
import { Logo } from "ui/components/display/Logo";
import { Link } from "ui/components/wouter/Link";
import { Auth } from "ui/elements";
import { useBrowserTitle } from "ui/hooks/use-browser-title";
export function AuthRegister() {
useBrowserTitle(["Register"]);
return (
<Auth.Screen
action="register"
logo={
<Link href={"/"} className="link">
<Logo scale={0.25} />
</Link>
}
/>
);
}

View File

@@ -24,6 +24,7 @@ import {
Form,
FormContextOverride,
FormDebug,
HiddenField,
ObjectField,
Subscribe,
useDerivedFieldContext,
@@ -36,9 +37,16 @@ import * as AppShell from "../../layouts/AppShell/AppShell";
export function AuthStrategiesList(props) {
useBrowserTitle(["Auth", "Strategies"]);
const { hasSecrets } = useBknd({ withSecrets: true });
const {
hasSecrets,
config: {
auth: { enabled },
},
} = useBknd({ withSecrets: true });
if (!hasSecrets) {
return <Message.MissingPermission what="Auth Strategies" />;
} else if (!enabled) {
return <Message.NotEnabled description="Enable Auth first." />;
}
return <AuthStrategiesListInternal {...props} />;
@@ -62,7 +70,6 @@ function AuthStrategiesListInternal() {
);
async function handleSubmit(data: any) {
console.log("submit", { strategies: data });
await $auth.actions.config.set({ strategies: data });
}
@@ -152,7 +159,7 @@ const Strategy = ({ type, name, unavailable }: StrategyProps) => {
<span className="leading-none">{autoFormatString(name)}</span>
</div>
<div className="flex flex-row gap-4 items-center">
<StrategyToggle />
<StrategyToggle type={type} />
<IconButton
Icon={TbSettings}
size="lg"
@@ -168,7 +175,7 @@ const Strategy = ({ type, name, unavailable }: StrategyProps) => {
"flex flex-col border-t border-t-muted px-4 pt-3 pb-4 bg-lightest/50 gap-4",
)}
>
<StrategyForm type={type} />
<StrategyForm type={type} name={name} />
</div>
)}
</div>
@@ -176,7 +183,7 @@ const Strategy = ({ type, name, unavailable }: StrategyProps) => {
);
};
const StrategyToggle = () => {
const StrategyToggle = ({ type }: { type: StrategyProps["type"] }) => {
const ctx = useDerivedFieldContext("");
const { value } = useFormValue("");
@@ -219,8 +226,10 @@ const OAUTH_BRANDS = {
discord: TbBrandDiscordFilled,
};
const StrategyForm = ({ type }: Pick<StrategyProps, "type">) => {
let Component = () => <ObjectField path="" wrapperProps={{ wrapper: "group", label: false }} />;
const StrategyForm = ({ type, name }: Pick<StrategyProps, "type" | "name">) => {
let Component = (p: any) => (
<ObjectField path="" wrapperProps={{ wrapper: "group", label: false }} />
);
switch (type) {
case "password":
Component = StrategyPasswordForm;
@@ -230,16 +239,22 @@ const StrategyForm = ({ type }: Pick<StrategyProps, "type">) => {
break;
}
return <Component />;
return (
<>
<HiddenField name="type" inputProps={{ disabled: true, defaultValue: type }} />
<Component type={type} name={name} />
</>
);
};
const StrategyPasswordForm = () => {
return <ObjectField path="config" wrapperProps={{ wrapper: "group", label: false }} />;
};
const StrategyOAuthForm = () => {
const StrategyOAuthForm = ({ type, name }: Pick<StrategyProps, "type" | "name">) => {
return (
<>
<HiddenField name="config.name" inputProps={{ disabled: true, defaultValue: name }} />
<Field name="config.client.client_id" required inputProps={{ type: "password" }} />
<Field name="config.client.client_secret" required inputProps={{ type: "password" }} />
</>

View File

@@ -1,5 +1,4 @@
import React, { Suspense, lazy } from "react";
import { useBknd } from "ui/client/bknd";
import { Suspense, lazy, type ComponentType, type ReactNode } from "react";
import { useTheme } from "ui/client/use-theme";
import { Route, Router, Switch } from "wouter";
import AuthRoutes from "./auth";
@@ -10,60 +9,70 @@ import MediaRoutes from "./media";
import { Root, RootEmpty } from "./root";
import SettingsRoutes from "./settings";
import { FlashMessage } from "ui/modules/server/FlashMessage";
import { AuthRegister } from "ui/routes/auth/auth.register";
import { BkndModalsProvider } from "ui/modals";
// @ts-ignore
const TestRoutes = lazy(() => import("./test"));
export function Routes() {
const { app } = useBknd();
export function Routes({
BkndWrapper,
basePath = "",
}: { BkndWrapper: ComponentType<{ children: ReactNode }>; basePath?: string }) {
const { theme } = useTheme();
return (
<div id="bknd-admin" className={theme + " antialiased"}>
<FlashMessage />
<Router base={app.options.basepath}>
<Router base={basePath}>
<Switch>
<Route path="/auth/login" component={AuthLogin} />
<Route path="/" nest>
<Root>
<Switch>
<Route path="/test*" nest>
<Suspense fallback={null}>
<TestRoutes />
</Suspense>
</Route>
<Route path="/auth/register" component={AuthRegister} />
<Route path="/" component={RootEmpty} />
<Route path="/data" nest>
<Suspense fallback={null}>
<DataRoutes />
</Suspense>
</Route>
<Route path="/flows" nest>
<Suspense fallback={null}>
<FlowRoutes />
</Suspense>
</Route>
<Route path="/auth" nest>
<Suspense fallback={null}>
<AuthRoutes />
</Suspense>
</Route>
<Route path="/media" nest>
<Suspense fallback={null}>
<MediaRoutes />
</Suspense>
</Route>
<Route path="/settings" nest>
<Suspense fallback={null}>
<SettingsRoutes />
</Suspense>
</Route>
<BkndWrapper>
<BkndModalsProvider>
<Route path="/" nest>
<Root>
<Switch>
<Route path="/test*" nest>
<Suspense fallback={null}>
<TestRoutes />
</Suspense>
</Route>
<Route path="*" component={NotFound} />
</Switch>
</Root>
</Route>
<Route path="/" component={RootEmpty} />
<Route path="/data" nest>
<Suspense fallback={null}>
<DataRoutes />
</Suspense>
</Route>
<Route path="/flows" nest>
<Suspense fallback={null}>
<FlowRoutes />
</Suspense>
</Route>
<Route path="/auth" nest>
<Suspense fallback={null}>
<AuthRoutes />
</Suspense>
</Route>
<Route path="/media" nest>
<Suspense fallback={null}>
<MediaRoutes />
</Suspense>
</Route>
<Route path="/settings" nest>
<Suspense fallback={null}>
<SettingsRoutes />
</Suspense>
</Route>
<Route path="*" component={NotFound} />
</Switch>
</Root>
</Route>
</BkndModalsProvider>
</BkndWrapper>
</Switch>
</Router>
</div>

View File

@@ -20,6 +20,7 @@ import {
} from "ui/components/form/json-schema-form";
import { useBrowserTitle } from "ui/hooks/use-browser-title";
import * as AppShell from "ui/layouts/AppShell/AppShell";
import { testIds } from "ui/lib/config";
export function MediaSettings(props) {
useBrowserTitle(["Media", "Settings"]);
@@ -79,7 +80,10 @@ function MediaSettingsInternal() {
<AppShell.Scrollable>
<RootFormError />
<div className="flex flex-col gap-3 p-3">
<Field name="enabled" />
<Field
name="enabled"
inputProps={{ "data-testId": testIds.media.switchEnabled }}
/>
<div className="flex flex-col gap-3 relative">
<Overlay />
<Field name="storage.body_max_size" label="Storage Body Max Size" />