added auth strategies form + add ability to disable strategies

This commit is contained in:
dswbx
2025-02-26 14:53:32 +01:00
parent d4a6a9326f
commit 2a9c1be151
18 changed files with 391 additions and 114 deletions

View File

@@ -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)
}))
}; };
} }
} }

View File

@@ -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());
} }

View File

@@ -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"
} }

View File

@@ -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))
}; };
} }
} }

View File

@@ -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
};
} }
} }

View File

@@ -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
}
}; };
} }
} }

View File

@@ -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: {

View File

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

View File

@@ -1,3 +1,4 @@
import clsx from "clsx";
import { getBrowser } from "core/utils"; import { 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>
); );
}); });

View File

@@ -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 (

View File

@@ -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;

View File

@@ -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(

View File

@@ -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][];

View File

@@ -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

View File

@@ -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()}>

View File

@@ -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>
); );

View File

@@ -1,55 +1,247 @@
import { cloneDeep, omit } from "lodash-es"; import { isDebug } from "core";
import { autoFormatString } from "core/utils";
import { type ChangeEvent, useState } from "react";
import {
TbAt,
TbBrandAppleFilled,
TbBrandDiscordFilled,
TbBrandFacebookFilled,
TbBrandGithubFilled,
TbBrandGoogleFilled,
TbBrandInstagram,
TbBrandOauth,
TbBrandX,
TbSettings
} from "react-icons/tb";
import { twMerge } from "tailwind-merge";
import { useBknd } from "ui/client/bknd"; import { 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 />
</>
);
};

View File

@@ -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