mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 04:27:21 +00:00
added auth strategies form + add ability to disable strategies
This commit is contained in:
@@ -56,7 +56,6 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
|||||||
|
|
||||||
// register roles
|
// register roles
|
||||||
const roles = transformObject(this.config.roles ?? {}, (role, name) => {
|
const roles = transformObject(this.config.roles ?? {}, (role, name) => {
|
||||||
//console.log("role", role, name);
|
|
||||||
return Role.create({ name, ...role });
|
return Role.create({ name, ...role });
|
||||||
});
|
});
|
||||||
this.ctx.guard.setRoles(Object.values(roles));
|
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));
|
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 {
|
get controller(): AuthController {
|
||||||
if (!this.isBuilt()) {
|
if (!this.isBuilt()) {
|
||||||
throw new Error("Can't access controller, AppAuth not built yet");
|
throw new Error("Can't access controller, AppAuth not built yet");
|
||||||
@@ -115,12 +119,6 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
|||||||
identifier: string,
|
identifier: string,
|
||||||
profile: ProfileExchange
|
profile: ProfileExchange
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
/*console.log("***** AppAuth:resolveUser", {
|
|
||||||
action,
|
|
||||||
strategy: strategy.getName(),
|
|
||||||
identifier,
|
|
||||||
profile
|
|
||||||
});*/
|
|
||||||
if (!this.config.allow_register && action === "register") {
|
if (!this.config.allow_register && action === "register") {
|
||||||
throw new Exception("Registration is not allowed", 403);
|
throw new Exception("Registration is not allowed", 403);
|
||||||
}
|
}
|
||||||
@@ -141,21 +139,10 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private filterUserData(user: any) {
|
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);
|
return pick(user, this.config.jwt.fields);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async login(strategy: Strategy, identifier: string, profile: ProfileExchange) {
|
private async login(strategy: Strategy, identifier: string, profile: ProfileExchange) {
|
||||||
/*console.log("--- trying to login", {
|
|
||||||
strategy: strategy.getName(),
|
|
||||||
identifier,
|
|
||||||
profile
|
|
||||||
});*/
|
|
||||||
if (!("email" in profile)) {
|
if (!("email" in profile)) {
|
||||||
throw new Exception("Profile must have email");
|
throw new Exception("Profile must have email");
|
||||||
}
|
}
|
||||||
@@ -172,18 +159,14 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
|||||||
if (!result.data) {
|
if (!result.data) {
|
||||||
throw new Exception("User not found", 404);
|
throw new Exception("User not found", 404);
|
||||||
}
|
}
|
||||||
//console.log("---login data", result.data, result);
|
|
||||||
|
|
||||||
// compare strategy and identifier
|
// compare strategy and identifier
|
||||||
//console.log("strategy comparison", result.data.strategy, strategy.getName());
|
|
||||||
if (result.data.strategy !== strategy.getName()) {
|
if (result.data.strategy !== strategy.getName()) {
|
||||||
//console.log("!!! User registered with different strategy");
|
//console.log("!!! User registered with different strategy");
|
||||||
throw new Exception("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) {
|
if (result.data.strategy_value !== identifier) {
|
||||||
//console.log("!!! Invalid credentials");
|
|
||||||
throw new Exception("Invalid credentials");
|
throw new Exception("Invalid credentials");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,6 +268,7 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
|||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// also keep disabled strategies as a choice
|
||||||
const strategies = Object.keys(this.config.strategies ?? {});
|
const strategies = Object.keys(this.config.strategies ?? {});
|
||||||
this.replaceEntityField(users, "strategy", enumm({ enum: strategies }));
|
this.replaceEntityField(users, "strategy", enumm({ enum: strategies }));
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
@@ -315,9 +299,16 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
|||||||
return this.configDefault;
|
return this.configDefault;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const strategies = this.authenticator.getStrategies();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...this.config,
|
...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)
|
||||||
|
}))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { type AppAuth, AuthPermissions, type SafeUser, type Strategy } from "auth";
|
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 { DataPermissions } from "data";
|
||||||
import type { Hono } from "hono";
|
import type { Hono } from "hono";
|
||||||
import { Controller } from "modules/Controller";
|
import { Controller } from "modules/Controller";
|
||||||
@@ -12,6 +13,10 @@ export type AuthActionResponse = {
|
|||||||
errors?: any;
|
errors?: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const booleanLike = Type.Transform(Type.String())
|
||||||
|
.Decode((v) => v === "1")
|
||||||
|
.Encode((v) => (v ? "1" : "0"));
|
||||||
|
|
||||||
export class AuthController extends Controller {
|
export class AuthController extends Controller {
|
||||||
constructor(private auth: AppAuth) {
|
constructor(private auth: AppAuth) {
|
||||||
super();
|
super();
|
||||||
@@ -31,6 +36,9 @@ export class AuthController extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private registerStrategyActions(strategy: Strategy, mainHono: Hono<ServerEnv>) {
|
private registerStrategyActions(strategy: Strategy, mainHono: Hono<ServerEnv>) {
|
||||||
|
if (!this.auth.isStrategyEnabled(strategy)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const actions = strategy.getActions?.();
|
const actions = strategy.getActions?.();
|
||||||
if (!actions) {
|
if (!actions) {
|
||||||
return;
|
return;
|
||||||
@@ -98,7 +106,8 @@ export class AuthController extends Controller {
|
|||||||
const strategies = this.auth.authenticator.getStrategies();
|
const strategies = this.auth.authenticator.getStrategies();
|
||||||
|
|
||||||
for (const [name, strategy] of Object.entries(strategies)) {
|
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));
|
hono.route(`/${name}`, strategy.getController(this.auth.authenticator));
|
||||||
this.registerStrategyActions(strategy, hono);
|
this.registerStrategyActions(strategy, hono);
|
||||||
}
|
}
|
||||||
@@ -127,10 +136,25 @@ export class AuthController extends Controller {
|
|||||||
return c.redirect("/");
|
return c.redirect("/");
|
||||||
});
|
});
|
||||||
|
|
||||||
hono.get("/strategies", async (c) => {
|
hono.get(
|
||||||
const { strategies, basepath } = this.auth.toJSON(false);
|
"/strategies",
|
||||||
return c.json({ strategies, basepath });
|
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());
|
return hono.all("*", (c) => c.notFound());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export const STRATEGIES = Strategies;
|
|||||||
const strategiesSchemaObject = objectTransform(STRATEGIES, (strategy, name) => {
|
const strategiesSchemaObject = objectTransform(STRATEGIES, (strategy, name) => {
|
||||||
return Type.Object(
|
return Type.Object(
|
||||||
{
|
{
|
||||||
|
enabled: Type.Optional(Type.Boolean({ default: true })),
|
||||||
type: Type.Const(name, { default: name, readOnly: true }),
|
type: Type.Const(name, { default: name, readOnly: true }),
|
||||||
config: strategy.schema
|
config: strategy.schema
|
||||||
},
|
},
|
||||||
@@ -61,6 +62,7 @@ export const authConfigSchema = Type.Object(
|
|||||||
default: {
|
default: {
|
||||||
password: {
|
password: {
|
||||||
type: "password",
|
type: "password",
|
||||||
|
enabled: true,
|
||||||
config: {
|
config: {
|
||||||
hashing: "sha256"
|
hashing: "sha256"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -342,8 +342,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
|||||||
toJSON(secrets?: boolean) {
|
toJSON(secrets?: boolean) {
|
||||||
return {
|
return {
|
||||||
...this.config,
|
...this.config,
|
||||||
jwt: secrets ? this.config.jwt : undefined,
|
jwt: secrets ? this.config.jwt : undefined
|
||||||
strategies: transformObject(this.getStrategies(), (s) => s.toJSON(secrets))
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,9 +147,6 @@ export class PasswordStrategy implements Strategy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toJSON(secrets?: boolean) {
|
toJSON(secrets?: boolean) {
|
||||||
return {
|
return secrets ? this.options : undefined;
|
||||||
type: this.getType(),
|
|
||||||
config: secrets ? this.options : undefined
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -476,11 +476,8 @@ export class OAuthStrategy implements Strategy {
|
|||||||
const config = secrets ? this.config : filterKeys(this.config, ["secret", "client_id"]);
|
const config = secrets ? this.config : filterKeys(this.config, ["secret", "client_id"]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: this.getType(),
|
type: this.getIssuerConfig().type,
|
||||||
config: {
|
...config
|
||||||
type: this.getIssuerConfig().type,
|
|
||||||
...config
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ export function useBkndAuth() {
|
|||||||
config: {
|
config: {
|
||||||
set: async (data: Partial<AppAuthSchema>) => {
|
set: async (data: Partial<AppAuthSchema>) => {
|
||||||
console.log("--set", data);
|
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: {
|
roles: {
|
||||||
|
|||||||
18
app/src/ui/components/display/Icon.tsx
Normal file
18
app/src/ui/components/display/Icon.tsx
Normal 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
|
||||||
|
};
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import clsx from "clsx";
|
||||||
import { getBrowser } from "core/utils";
|
import { getBrowser } from "core/utils";
|
||||||
import type { Field } from "data";
|
import type { Field } from "data";
|
||||||
import { Switch as RadixSwitch } from "radix-ui";
|
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";
|
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<
|
export const Switch = forwardRef<
|
||||||
HTMLButtonElement,
|
HTMLButtonElement,
|
||||||
Pick<
|
Pick<
|
||||||
@@ -184,14 +200,19 @@ export const Switch = forwardRef<
|
|||||||
"name" | "required" | "disabled" | "checked" | "defaultChecked" | "id" | "type"
|
"name" | "required" | "disabled" | "checked" | "defaultChecked" | "id" | "type"
|
||||||
> & {
|
> & {
|
||||||
value?: SwitchValue;
|
value?: SwitchValue;
|
||||||
|
size?: keyof typeof SwitchSizes;
|
||||||
onChange?: (e: { target: { value: boolean } }) => void;
|
onChange?: (e: { target: { value: boolean } }) => void;
|
||||||
onCheckedChange?: (checked: boolean) => void;
|
onCheckedChange?: (checked: boolean) => void;
|
||||||
}
|
}
|
||||||
>(({ type, required, ...props }, ref) => {
|
>(({ type, required, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<RadixSwitch.Root
|
<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
|
||||||
|
)}
|
||||||
onCheckedChange={(bool) => {
|
onCheckedChange={(bool) => {
|
||||||
|
console.log("setting", bool);
|
||||||
props.onChange?.({ target: { value: bool } });
|
props.onChange?.({ target: { value: bool } });
|
||||||
}}
|
}}
|
||||||
{...(props as any)}
|
{...(props as any)}
|
||||||
@@ -204,7 +225,12 @@ export const Switch = forwardRef<
|
|||||||
}
|
}
|
||||||
ref={ref}
|
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>
|
</RadixSwitch.Root>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,8 +30,9 @@ const fieldErrorBoundary =
|
|||||||
</Pre>
|
</Pre>
|
||||||
);
|
);
|
||||||
|
|
||||||
const FieldImpl = ({ name, onChange, placeholder, ...props }: FieldProps) => {
|
const FieldImpl = ({ name, onChange, placeholder, required: _required, ...props }: FieldProps) => {
|
||||||
const { path, setValue, required, schema, ...ctx } = useDerivedFieldContext(name);
|
const { path, setValue, schema, ...ctx } = useDerivedFieldContext(name);
|
||||||
|
const required = typeof _required === "boolean" ? _required : ctx.required;
|
||||||
//console.log("Field", { name, path, schema });
|
//console.log("Field", { name, path, schema });
|
||||||
if (!isTypeSchema(schema))
|
if (!isTypeSchema(schema))
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { getLabel } from "./utils";
|
|||||||
|
|
||||||
export type FieldwrapperProps = {
|
export type FieldwrapperProps = {
|
||||||
name: string;
|
name: string;
|
||||||
label?: string | false;
|
label?: string | ReactNode | false;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
schema?: JsonSchema;
|
schema?: JsonSchema;
|
||||||
debug?: object | boolean;
|
debug?: object | boolean;
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import {
|
|||||||
prefixPointer
|
prefixPointer
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
|
|
||||||
type JSONSchema = Exclude<$JSONSchema, boolean>;
|
export type JSONSchema = Exclude<$JSONSchema, boolean>;
|
||||||
type FormState<Data = any> = {
|
type FormState<Data = any> = {
|
||||||
dirty: boolean;
|
dirty: boolean;
|
||||||
submitting: boolean;
|
submitting: boolean;
|
||||||
@@ -238,6 +238,7 @@ export function FormContextOverride({
|
|||||||
...overrides,
|
...overrides,
|
||||||
...additional
|
...additional
|
||||||
};
|
};
|
||||||
|
console.log("context", context);
|
||||||
|
|
||||||
return <FormContext.Provider value={context}>{children}</FormContext.Provider>;
|
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>(
|
export function useFormStateSelector<Data = any, Reduced = Data>(
|
||||||
selector: (state: FormState<Data>) => Reduced
|
selector: (state: FormState<Data>) => Reduced,
|
||||||
|
deps: any[] = []
|
||||||
): Reduced {
|
): Reduced {
|
||||||
const { _formStateAtom } = useFormContext();
|
const { _formStateAtom } = useFormContext();
|
||||||
const selected = selectAtom(_formStateAtom, useCallback(selector, []), isEqual);
|
const selected = selectAtom(_formStateAtom, useCallback(selector, deps), isEqual);
|
||||||
return useAtom(selected)[0];
|
return useAtom(selected)[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,14 +308,16 @@ export function useDerivedFieldContext<Data = any, Reduced = undefined>(
|
|||||||
path: string;
|
path: string;
|
||||||
},
|
},
|
||||||
Reduced
|
Reduced
|
||||||
>
|
>,
|
||||||
|
_schema?: JSONSchema
|
||||||
): FormContext<Data> & {
|
): FormContext<Data> & {
|
||||||
value: Reduced;
|
value: Reduced;
|
||||||
pointer: string;
|
pointer: string;
|
||||||
required: boolean;
|
required: boolean;
|
||||||
path: string;
|
path: string;
|
||||||
} {
|
} {
|
||||||
const { _formStateAtom, root, lib, schema, ...ctx } = useFormContext();
|
const { _formStateAtom, root, lib, ...ctx } = useFormContext();
|
||||||
|
const schema = _schema ?? ctx.schema;
|
||||||
const selected = selectAtom(
|
const selected = selectAtom(
|
||||||
_formStateAtom,
|
_formStateAtom,
|
||||||
useCallback(
|
useCallback(
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import type { JSONSchema } from "json-schema-to-ts";
|
|
||||||
import { isTypeSchema } from "ui/components/form/json-schema-form/utils";
|
import { isTypeSchema } from "ui/components/form/json-schema-form/utils";
|
||||||
import { AnyOfField } from "./AnyOfField";
|
import { AnyOfField } from "./AnyOfField";
|
||||||
import { Field } from "./Field";
|
import { Field } from "./Field";
|
||||||
import { FieldWrapper, type FieldwrapperProps } from "./FieldWrapper";
|
import { FieldWrapper, type FieldwrapperProps } from "./FieldWrapper";
|
||||||
import { useDerivedFieldContext } from "./Form";
|
import { type JSONSchema, useDerivedFieldContext } from "./Form";
|
||||||
|
|
||||||
export type ObjectFieldProps = {
|
export type ObjectFieldProps = {
|
||||||
path?: string;
|
path?: string;
|
||||||
@@ -12,7 +11,7 @@ export type ObjectFieldProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const ObjectField = ({ path = "", label: _label, wrapperProps = {} }: 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`;
|
if (!isTypeSchema(schema)) return `ObjectField "${path}": no schema`;
|
||||||
const properties = Object.entries(schema.properties ?? {}) as [string, JSONSchema][];
|
const properties = Object.entries(schema.properties ?? {}) as [string, JSONSchema][];
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
SegmentedControl,
|
SegmentedControl,
|
||||||
Select,
|
Select,
|
||||||
Switch,
|
Switch,
|
||||||
|
Tabs,
|
||||||
TagsInput,
|
TagsInput,
|
||||||
TextInput,
|
TextInput,
|
||||||
Textarea,
|
Textarea,
|
||||||
@@ -104,6 +105,11 @@ export function createMantineTheme(scheme: "light" | "dark"): {
|
|||||||
overlay: "!backdrop-blur-sm"
|
overlay: "!backdrop-blur-sm"
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
Tabs: Tabs.extend({
|
||||||
|
classNames: (theme, props) => ({
|
||||||
|
tab: "data-[active=true]:border-primary"
|
||||||
|
})
|
||||||
|
}),
|
||||||
Menu: Menu.extend({
|
Menu: Menu.extend({
|
||||||
defaultProps: {
|
defaultProps: {
|
||||||
offset: 2
|
offset: 2
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function AuthRoot({ children }) {
|
|||||||
>
|
>
|
||||||
Roles & Permissions
|
Roles & Permissions
|
||||||
</AppShell.SidebarLink>
|
</AppShell.SidebarLink>
|
||||||
<AppShell.SidebarLink as={Link} href={routes.auth.strategies()} disabled>
|
<AppShell.SidebarLink as={Link} href={routes.auth.strategies()}>
|
||||||
Strategies
|
Strategies
|
||||||
</AppShell.SidebarLink>
|
</AppShell.SidebarLink>
|
||||||
<AppShell.SidebarLink as={Link} href={routes.auth.settings()}>
|
<AppShell.SidebarLink as={Link} href={routes.auth.settings()}>
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
import clsx from "clsx";
|
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 { useBknd } from "ui/client/BkndProvider";
|
||||||
import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
|
import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
|
||||||
import { Button } from "ui/components/buttons/Button";
|
import { Button } from "ui/components/buttons/Button";
|
||||||
|
import { Icon } from "ui/components/display/Icon";
|
||||||
import { Message } from "ui/components/display/Message";
|
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 { useBrowserTitle } from "ui/hooks/use-browser-title";
|
||||||
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||||
import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2";
|
import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2";
|
||||||
@@ -40,12 +48,13 @@ export function AuthSettings(props) {
|
|||||||
|
|
||||||
const formConfig = {
|
const formConfig = {
|
||||||
ignoreKeys: ["roles", "strategies"],
|
ignoreKeys: ["roles", "strategies"],
|
||||||
options: { keepEmpty: true }
|
options: { keepEmpty: true, debug: isDebug() }
|
||||||
};
|
};
|
||||||
|
|
||||||
function AuthSettingsInternal() {
|
function AuthSettingsInternal() {
|
||||||
const { config, schema: _schema, actions } = useBkndAuth();
|
const { config, schema: _schema, actions } = useBkndAuth();
|
||||||
const schema = JSON.parse(JSON.stringify(_schema));
|
const schema = JSON.parse(JSON.stringify(_schema));
|
||||||
|
const hasRoles = Object.keys(config.roles ?? {}).length > 0;
|
||||||
|
|
||||||
schema.properties.jwt.required = ["alg"];
|
schema.properties.jwt.required = ["alg"];
|
||||||
|
|
||||||
@@ -64,7 +73,7 @@ function AuthSettingsInternal() {
|
|||||||
>
|
>
|
||||||
{({ dirty, errors, submitting }) => (
|
{({ dirty, errors, submitting }) => (
|
||||||
<AppShell.SectionHeader
|
<AppShell.SectionHeader
|
||||||
className="pl-3"
|
className="pl-4"
|
||||||
right={
|
right={
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
@@ -75,28 +84,32 @@ function AuthSettingsInternal() {
|
|||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex flex-row gap-4 items-center">
|
Settings
|
||||||
<Breadcrumbs2
|
|
||||||
path={[{ label: "Auth", href: "/" }, { label: "Settings" }]}
|
|
||||||
backTo="/"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</AppShell.SectionHeader>
|
</AppShell.SectionHeader>
|
||||||
)}
|
)}
|
||||||
</Subscribe>
|
</Subscribe>
|
||||||
<AppShell.Scrollable>
|
<AppShell.Scrollable>
|
||||||
<Section className="pt-4">
|
<Section className="pt-4 pl-0 pb-0">
|
||||||
<AuthField
|
<div className="pl-4">
|
||||||
name="enabled"
|
<AuthField
|
||||||
label="Authentication Enabled"
|
name="enabled"
|
||||||
description="Only after enabling authentication, all settings below will take effect."
|
label="Authentication Enabled"
|
||||||
descriptionPlacement="top"
|
description="Only after enabling authentication, all settings below will take effect."
|
||||||
/>
|
descriptionPlacement="top"
|
||||||
<div className="flex flex-col gap-6 relative">
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-6 relative pl-4 pb-2">
|
||||||
<Overlay />
|
<Overlay />
|
||||||
<AuthField
|
<AuthField
|
||||||
name="guard.enabled"
|
name="guard.enabled"
|
||||||
label="Guard Enabled"
|
label={
|
||||||
|
<div className="flex flex-row gap-2 items-center">
|
||||||
|
<span>Guard Enabled</span>
|
||||||
|
{!hasRoles && (
|
||||||
|
<Icon.Warning title="No roles defined. Enabling the guard will block all requests." />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
description="When enabled, enforces permissions on all routes. Make sure to create roles first."
|
description="When enabled, enforces permissions on all routes. Make sure to create roles first."
|
||||||
descriptionPlacement="top"
|
descriptionPlacement="top"
|
||||||
/>
|
/>
|
||||||
@@ -139,7 +152,7 @@ function AuthSettingsInternal() {
|
|||||||
<ToggleAdvanced which="cookie" />
|
<ToggleAdvanced which="cookie" />
|
||||||
</Section>
|
</Section>
|
||||||
</div>
|
</div>
|
||||||
{/* <FormDebug /> */}
|
<FormDebug />
|
||||||
</AppShell.Scrollable>
|
</AppShell.Scrollable>
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 { useBknd } from "ui/client/bknd";
|
||||||
|
import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
|
||||||
import { Button } from "ui/components/buttons/Button";
|
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";
|
import * as AppShell from "../../layouts/AppShell/AppShell";
|
||||||
|
|
||||||
export function AuthStrategiesList() {
|
export function AuthStrategiesList(props) {
|
||||||
useBknd({ withSecrets: true });
|
useBrowserTitle(["Auth", "Strategies"]);
|
||||||
return <AuthStrategiesListInternal />;
|
|
||||||
|
const { hasSecrets } = useBknd({ withSecrets: true });
|
||||||
|
if (!hasSecrets) {
|
||||||
|
return <Message.MissingPermission what="Auth Strategies" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AuthStrategiesListInternal {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const uiSchema = {
|
const formOptions = {
|
||||||
jwt: {
|
keepEmpty: true,
|
||||||
fields: {
|
debug: isDebug()
|
||||||
"ui:options": {
|
|
||||||
orderable: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function AuthStrategiesListInternal() {
|
function AuthStrategiesListInternal() {
|
||||||
const s = useBknd();
|
const $auth = useBkndAuth();
|
||||||
const config = s.config.auth.strategies;
|
const config = $auth.config.strategies;
|
||||||
const schema = cloneDeep(omit(s.schema.auth.properties.strategies, ["title"]));
|
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 (
|
return (
|
||||||
<>
|
<Form schema={schema} initialValues={config} onSubmit={handleSubmit} options={formOptions}>
|
||||||
<AppShell.SectionHeader right={<Button variant="primary">Update</Button>}>
|
<Subscribe
|
||||||
Strategies
|
selector={(state) => ({
|
||||||
</AppShell.SectionHeader>
|
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>
|
<AppShell.Scrollable>
|
||||||
strat
|
<div className="flex flex-col p-4 gap-4">
|
||||||
{/*<div className="flex flex-col flex-grow px-5 py-4 gap-8">
|
<p className="opacity-70">
|
||||||
<div>
|
Allow users to sign in or sign up using different strategies.
|
||||||
<JsonSchemaForm
|
</p>
|
||||||
schema={generalSchema}
|
<div className="flex flex-col gap-2 max-w-4xl">
|
||||||
className="legacy hide-required-mark fieldset-alternative mute-root"
|
<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>
|
||||||
|
</div>
|
||||||
<div className="flex flex-col gap-3">
|
<FormDebug />
|
||||||
<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>*/}
|
|
||||||
</AppShell.Scrollable>
|
</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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { IconAlertHexagon } from "@tabler/icons-react";
|
|||||||
import { TbSettings } from "react-icons/tb";
|
import { TbSettings } from "react-icons/tb";
|
||||||
import { useBknd } from "ui/client/BkndProvider";
|
import { useBknd } from "ui/client/BkndProvider";
|
||||||
import { IconButton } from "ui/components/buttons/IconButton";
|
import { IconButton } from "ui/components/buttons/IconButton";
|
||||||
|
import { Icon } from "ui/components/display/Icon";
|
||||||
import { Link } from "ui/components/wouter/Link";
|
import { Link } from "ui/components/wouter/Link";
|
||||||
import { Media } from "ui/elements";
|
import { Media } from "ui/elements";
|
||||||
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
||||||
@@ -32,7 +33,10 @@ export function MediaRoot({ children }) {
|
|||||||
href={"/"}
|
href={"/"}
|
||||||
className="flex flex-row justify-between"
|
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>
|
||||||
<AppShell.SidebarLink as={Link} href={"/settings"}>
|
<AppShell.SidebarLink as={Link} href={"/settings"}>
|
||||||
Settings
|
Settings
|
||||||
|
|||||||
Reference in New Issue
Block a user