diff --git a/app/package.json b/app/package.json index fec3db6..087cb80 100644 --- a/app/package.json +++ b/app/package.json @@ -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", diff --git a/app/src/auth/AppAuth.ts b/app/src/auth/AppAuth.ts index ca5b919..ba13862 100644 --- a/app/src/auth/AppAuth.ts +++ b/app/src/auth/AppAuth.ts @@ -56,7 +56,6 @@ export class AppAuth extends Module { // 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 { 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 { identifier: string, profile: ProfileExchange ): Promise { - /*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 { } 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 { 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 { } 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 { 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) + })) }; } } diff --git a/app/src/auth/api/AuthController.ts b/app/src/auth/api/AuthController.ts index 3687395..a5531b6 100644 --- a/app/src/auth/api/AuthController.ts +++ b/app/src/auth/api/AuthController.ts @@ -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) { + 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()); } diff --git a/app/src/auth/auth-schema.ts b/app/src/auth/auth-schema.ts index 84882b5..ab16ccf 100644 --- a/app/src/auth/auth-schema.ts +++ b/app/src/auth/auth-schema.ts @@ -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" } diff --git a/app/src/auth/authenticate/Authenticator.ts b/app/src/auth/authenticate/Authenticator.ts index f869318..9167355 100644 --- a/app/src/auth/authenticate/Authenticator.ts +++ b/app/src/auth/authenticate/Authenticator.ts @@ -342,8 +342,7 @@ export class Authenticator = 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 }; } } diff --git a/app/src/auth/authenticate/strategies/PasswordStrategy.ts b/app/src/auth/authenticate/strategies/PasswordStrategy.ts index c6a9a37..726dc9f 100644 --- a/app/src/auth/authenticate/strategies/PasswordStrategy.ts +++ b/app/src/auth/authenticate/strategies/PasswordStrategy.ts @@ -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; } } diff --git a/app/src/auth/authenticate/strategies/oauth/OAuthStrategy.ts b/app/src/auth/authenticate/strategies/oauth/OAuthStrategy.ts index 6015ebd..5751556 100644 --- a/app/src/auth/authenticate/strategies/oauth/OAuthStrategy.ts +++ b/app/src/auth/authenticate/strategies/oauth/OAuthStrategy.ts @@ -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 }; } } diff --git a/app/src/ui/client/schema/auth/use-bknd-auth.ts b/app/src/ui/client/schema/auth/use-bknd-auth.ts index a3bb003..b735b61 100644 --- a/app/src/ui/client/schema/auth/use-bknd-auth.ts +++ b/app/src/ui/client/schema/auth/use-bknd-auth.ts @@ -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) => { + 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, diff --git a/app/src/ui/components/display/Alert.tsx b/app/src/ui/components/display/Alert.tsx index ee5aad1..8df3074 100644 --- a/app/src/ui/components/display/Alert.tsx +++ b/app/src/ui/components/display/Alert.tsx @@ -25,8 +25,10 @@ const Base: React.FC = ({ className )} > - {title && {title}:} - {message || children} +

+ {title && {title}: } + {message || children} +

) : null; diff --git a/app/src/ui/components/display/ErrorBoundary.tsx b/app/src/ui/components/display/ErrorBoundary.tsx new file mode 100644 index 0000000..1148d29 --- /dev/null +++ b/app/src/ui/components/display/ErrorBoundary.tsx @@ -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 { + 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 + ) + ) : ( +
+

Something went wrong.

+ +
+ ); + } + + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/app/src/ui/components/display/Icon.tsx b/app/src/ui/components/display/Icon.tsx new file mode 100644 index 0000000..c624f63 --- /dev/null +++ b/app/src/ui/components/display/Icon.tsx @@ -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) => ( + +); + +export const Icon = { + Warning +}; diff --git a/app/src/ui/components/form/Formy/components.tsx b/app/src/ui/components/form/Formy/components.tsx index 34ddcb9..1b15ff0 100644 --- a/app/src/ui/components/form/Formy/components.tsx +++ b/app/src/ui/components/form/Formy/components.tsx @@ -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 & { value?: SwitchValue; + size?: keyof typeof SwitchSizes; onChange?: (e: { target: { value: boolean } }) => void; onCheckedChange?: (checked: boolean) => void; } >(({ type, required, ...props }, ref) => { return ( { + console.log("setting", bool); props.onChange?.({ target: { value: bool } }); }} {...(props as any)} @@ -204,7 +226,12 @@ export const Switch = forwardRef< } ref={ref} > - + ); }); diff --git a/app/src/ui/components/form/json-schema-form/AnyOfField.tsx b/app/src/ui/components/form/json-schema-form/AnyOfField.tsx index a51d107..fef96a7 100644 --- a/app/src/ui/components/form/json-schema-form/AnyOfField.tsx +++ b/app/src/ui/components/form/json-schema-form/AnyOfField.tsx @@ -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(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) => { +const Field = ({ name, label, ...props }: Partial) => { const { selected, selectedSchema, path, errors } = useAnyOfContext(); if (selected === null) return null; return ( diff --git a/app/src/ui/components/form/json-schema-form/ArrayField.tsx b/app/src/ui/components/form/json-schema-form/ArrayField.tsx index 1c95b0e..addc651 100644 --- a/app/src/ui/components/form/json-schema-form/ArrayField.tsx +++ b/app/src/ui/components/form/json-schema-form/ArrayField.tsx @@ -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); diff --git a/app/src/ui/components/form/json-schema-form/Field.tsx b/app/src/ui/components/form/json-schema-form/Field.tsx index bd225ae..7d87366 100644 --- a/app/src/ui/components/form/json-schema-form/Field.tsx +++ b/app/src/ui/components/form/json-schema-form/Field.tsx @@ -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) => void; - label?: string | false; - hidden?: boolean; + placeholder?: string; + disabled?: boolean; +} & Omit; + +export const Field = (props: FieldProps) => { + return ( + + + + ); }; -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 }) => ( +
+         Field "{name}" error: {error.message}
+      
+ ); + +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 (
@@ -27,14 +43,14 @@ export const Field = ({ name, schema: _schema, onChange, label: _label, hidden }
       );
 
    if (isType(schema.type, "object")) {
-      return ;
+      return ;
    }
 
    if (isType(schema.type, "array")) {
-      return ;
+      return ;
    }
 
-   const disabled = schema.readOnly ?? "const" in schema ?? false;
+   const disabled = props.disabled ?? schema.readOnly ?? "const" in schema ?? false;
 
    const handleChange = useEvent((e: ChangeEvent) => {
       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 (
-