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 2d1ee43..bd1f91a 100644 --- a/app/src/ui/client/schema/auth/use-bknd-auth.ts +++ b/app/src/ui/client/schema/auth/use-bknd-auth.ts @@ -8,7 +8,11 @@ export function useBkndAuth() { config: { set: async (data: Partial) => { console.log("--set", data); - return await bkndActions.set("auth", data, true); + if (await bkndActions.set("auth", data, true)) { + await bkndActions.reload(); + return true; + } + return false; } }, roles: { 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..b1cea07 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 +225,12 @@ export const Switch = forwardRef< } ref={ref} > - + ); }); 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 355ef25..b9a74e9 100644 --- a/app/src/ui/components/form/json-schema-form/Field.tsx +++ b/app/src/ui/components/form/json-schema-form/Field.tsx @@ -30,8 +30,9 @@ const fieldErrorBoundary = ); -const FieldImpl = ({ name, onChange, placeholder, ...props }: FieldProps) => { - const { path, setValue, required, schema, ...ctx } = useDerivedFieldContext(name); +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 ( diff --git a/app/src/ui/components/form/json-schema-form/FieldWrapper.tsx b/app/src/ui/components/form/json-schema-form/FieldWrapper.tsx index 67d1370..6bfe4d6 100644 --- a/app/src/ui/components/form/json-schema-form/FieldWrapper.tsx +++ b/app/src/ui/components/form/json-schema-form/FieldWrapper.tsx @@ -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; diff --git a/app/src/ui/components/form/json-schema-form/Form.tsx b/app/src/ui/components/form/json-schema-form/Form.tsx index 7e85917..de4a5e5 100644 --- a/app/src/ui/components/form/json-schema-form/Form.tsx +++ b/app/src/ui/components/form/json-schema-form/Form.tsx @@ -35,7 +35,7 @@ import { prefixPointer } from "./utils"; -type JSONSchema = Exclude<$JSONSchema, boolean>; +export type JSONSchema = Exclude<$JSONSchema, boolean>; type FormState = { dirty: boolean; submitting: boolean; @@ -238,6 +238,7 @@ export function FormContextOverride({ ...overrides, ...additional }; + console.log("context", context); return {children}; } @@ -287,10 +288,11 @@ export function useFormError(name: string, opt?: { strict?: boolean; debug?: boo } export function useFormStateSelector( - selector: (state: FormState) => Reduced + selector: (state: FormState) => 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]; } @@ -306,14 +308,16 @@ export function useDerivedFieldContext( path: string; }, Reduced - > + >, + _schema?: JSONSchema ): FormContext & { value: Reduced; pointer: string; required: boolean; path: string; } { - const { _formStateAtom, root, lib, schema, ...ctx } = useFormContext(); + const { _formStateAtom, root, lib, ...ctx } = useFormContext(); + const schema = _schema ?? ctx.schema; const selected = selectAtom( _formStateAtom, useCallback( diff --git a/app/src/ui/components/form/json-schema-form/ObjectField.tsx b/app/src/ui/components/form/json-schema-form/ObjectField.tsx index ff1b598..59deceb 100644 --- a/app/src/ui/components/form/json-schema-form/ObjectField.tsx +++ b/app/src/ui/components/form/json-schema-form/ObjectField.tsx @@ -1,9 +1,8 @@ -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; @@ -12,7 +11,7 @@ export type ObjectFieldProps = { }; export const ObjectField = ({ path = "", label: _label, wrapperProps = {} }: ObjectFieldProps) => { - const { schema } = useDerivedFieldContext(path); + const { schema, ...ctx } = useDerivedFieldContext(path); if (!isTypeSchema(schema)) return `ObjectField "${path}": no schema`; const properties = Object.entries(schema.properties ?? {}) as [string, JSONSchema][]; diff --git a/app/src/ui/lib/mantine/theme.ts b/app/src/ui/lib/mantine/theme.ts index bb9a6a5..09bc670 100644 --- a/app/src/ui/lib/mantine/theme.ts +++ b/app/src/ui/lib/mantine/theme.ts @@ -8,6 +8,7 @@ import { SegmentedControl, Select, Switch, + Tabs, TagsInput, TextInput, Textarea, @@ -104,6 +105,11 @@ export function createMantineTheme(scheme: "light" | "dark"): { overlay: "!backdrop-blur-sm" }) }), + Tabs: Tabs.extend({ + classNames: (theme, props) => ({ + tab: "data-[active=true]:border-primary" + }) + }), Menu: Menu.extend({ defaultProps: { offset: 2 diff --git a/app/src/ui/routes/auth/_auth.root.tsx b/app/src/ui/routes/auth/_auth.root.tsx index d630001..4fa7693 100644 --- a/app/src/ui/routes/auth/_auth.root.tsx +++ b/app/src/ui/routes/auth/_auth.root.tsx @@ -44,7 +44,7 @@ export function AuthRoot({ children }) { > Roles & Permissions - + Strategies diff --git a/app/src/ui/routes/auth/auth.settings.tsx b/app/src/ui/routes/auth/auth.settings.tsx index e677bd4..1c64ded 100644 --- a/app/src/ui/routes/auth/auth.settings.tsx +++ b/app/src/ui/routes/auth/auth.settings.tsx @@ -1,10 +1,18 @@ import clsx from "clsx"; -import { TbChevronDown, TbChevronUp } from "react-icons/tb"; +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 { Button } from "ui/components/buttons/Button"; +import { Icon } from "ui/components/display/Icon"; import { Message } from "ui/components/display/Message"; -import { Field, type FieldProps, Form, Subscribe } from "ui/components/form/json-schema-form"; +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 { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2"; @@ -40,12 +48,13 @@ export function AuthSettings(props) { const formConfig = { ignoreKeys: ["roles", "strategies"], - options: { keepEmpty: true } + options: { keepEmpty: true, debug: isDebug() } }; function AuthSettingsInternal() { const { config, schema: _schema, actions } = useBkndAuth(); const schema = JSON.parse(JSON.stringify(_schema)); + const hasRoles = Object.keys(config.roles ?? {}).length > 0; schema.properties.jwt.required = ["alg"]; @@ -64,7 +73,7 @@ function AuthSettingsInternal() { > {({ dirty, errors, submitting }) => ( } > -
- -
+ Settings
)} -
- -
+
+
+ +
+
+ Guard Enabled + {!hasRoles && ( + + )} +
+ } description="When enabled, enforces permissions on all routes. Make sure to create roles first." descriptionPlacement="top" /> @@ -139,7 +152,7 @@ function AuthSettingsInternal() {
- {/* */} + ); diff --git a/app/src/ui/routes/auth/auth.strategies.tsx b/app/src/ui/routes/auth/auth.strategies.tsx index 2792767..802f88d 100644 --- a/app/src/ui/routes/auth/auth.strategies.tsx +++ b/app/src/ui/routes/auth/auth.strategies.tsx @@ -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 ; +export function AuthStrategiesList(props) { + useBrowserTitle(["Auth", "Strategies"]); + + const { hasSecrets } = useBknd({ withSecrets: true }); + if (!hasSecrets) { + return ; + } + + return ; } -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 ( - <> - Update}> - Strategies - +
+ ({ + dirty: state.dirty, + errors: state.errors.length > 0, + submitting: state.submitting + })} + > + {({ dirty, errors, submitting }) => ( + + Update + + } + > + Strategies + + )} + - strat - {/*
-
- +
+

+ Allow users to sign in or sign up using different strategies. +

+
+ + + + + + + +
- -
-

JWT Settings

- -
-
*/} +
+ - + ); } + +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 ( + +
0 && "border-red-500" + )} + > +
+
+ +
+
+ {autoFormatString(name)} +
+
+ + setOpen((o) => !o)} + /> +
+
+ {open && ( +
+ +
+ )} +
+
+ ); +}; + +const StrategyToggle = () => { + const ctx = useDerivedFieldContext(""); + const { value } = useFormValue(""); + + function handleToggleChange(e: ChangeEvent) { + 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 ; +}; + +const StrategyIcon = ({ type, provider }: { type: StrategyProps["type"]; provider?: string }) => { + if (type === "password") { + return ; + } + + if (provider && provider in OAUTH_BRANDS) { + const BrandIcon = OAUTH_BRANDS[provider]; + return ; + } + + return ; +}; + +const OAUTH_BRANDS = { + google: TbBrandGoogleFilled, + github: TbBrandGithubFilled, + facebook: TbBrandFacebookFilled, + x: TbBrandX, + instagram: TbBrandInstagram, + apple: TbBrandAppleFilled, + discord: TbBrandDiscordFilled +}; + +const StrategyForm = ({ type }: Pick) => { + let Component = () => ; + switch (type) { + case "password": + Component = StrategyPasswordForm; + break; + case "oauth": + Component = StrategyOAuthForm; + break; + } + + return ; +}; + +const StrategyPasswordForm = () => { + return ; +}; + +const StrategyOAuthForm = () => { + return ( + <> + + + + ); +}; diff --git a/app/src/ui/routes/media/_media.root.tsx b/app/src/ui/routes/media/_media.root.tsx index a14e6e0..971df64 100644 --- a/app/src/ui/routes/media/_media.root.tsx +++ b/app/src/ui/routes/media/_media.root.tsx @@ -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 && } + Main Bucket{" "} + {mediaDisabled && ( + + )} Settings